diff --git a/app/app/clusters/[clusterId]/runs/layout.tsx b/app/app/clusters/[clusterId]/runs/layout.tsx index 58be7e90..4f9a4d4b 100644 --- a/app/app/clusters/[clusterId]/runs/layout.tsx +++ b/app/app/clusters/[clusterId]/runs/layout.tsx @@ -1,7 +1,11 @@ import { client } from "@/client/client"; -import { ClusterDetails } from "@/components/cluster-details"; +import { MachineList } from "@/components/MachineList"; +import { ServiceList } from "@/components/ServiceList"; import { RunList } from "@/components/WorkflowList"; import { auth } from "@clerk/nextjs"; +import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +import { Blocks, Cpu } from "lucide-react"; export async function generateMetadata({ params: { clusterId }, @@ -23,7 +27,7 @@ export async function generateMetadata({ return { title: `${cluster.body?.name}` }; } -function Home({ +async function Home({ params: { clusterId }, children, }: { @@ -32,15 +36,53 @@ function Home({ }; children: React.ReactNode; }) { + const { getToken } = auth(); + const token = await getToken(); + const cluster = await client.getCluster({ + headers: { authorization: `Bearer ${token}` }, + params: { clusterId }, + }); + return ( -
-
- -
- {children} +
+
+
+ +
+ + {cluster.status === 200 ? cluster.body.name : clusterId} + +
+ + + + + + + + + + + + + + + +
-
- +
+
+
+ +
+
+ {children}
diff --git a/app/app/clusters/[clusterId]/runs/page.tsx b/app/app/clusters/[clusterId]/runs/page.tsx index 854034a5..3486184b 100644 --- a/app/app/clusters/[clusterId]/runs/page.tsx +++ b/app/app/clusters/[clusterId]/runs/page.tsx @@ -40,7 +40,7 @@ export default async function Page({ return (
- + diff --git a/app/components/MachineList.tsx b/app/components/MachineList.tsx new file mode 100644 index 00000000..0f6a215d --- /dev/null +++ b/app/components/MachineList.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { client } from "@/client/client"; +import { contract } from "@/client/contract"; +import { useAuth } from "@clerk/nextjs"; +import { ClientInferResponseBody } from "@ts-rest/core"; +import { formatRelative } from "date-fns"; +import { useCallback, useEffect, useState } from "react"; +import { DeadGrayCircle, DeadRedCircle, LiveGreenCircle } from "./circles"; +import ErrorDisplay from "./error-display"; +import { EventsOverlayButton } from "./events-overlay"; +import { cn } from "@/lib/utils"; + +function MachineCard({ + machine, + clusterId, +}: { + machine: ClientInferResponseBody[number]; + clusterId: string; +}) { + const isLive = + Date.now() - new Date(machine.lastPingAt!).getTime() < 1000 * 60; + + return ( +
+
+
+
{isLive ? : }
+
+
{machine.id}
+
{machine.ip}
+
+
+ +
+
+
+ {isLive ? "Active" : "Inactive"} +
+
+ Last heartbeat: {formatRelative(machine.lastPingAt!, new Date())} +
+
+
+ ); +} + +export function MachineList({ clusterId }: { clusterId: string }) { + const [machines, setMachines] = useState< + ClientInferResponseBody + >([]); + const [liveMachineCount, setLiveMachineCount] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const { getToken } = useAuth(); + const [error, setError] = useState(null); + + const getClusterMachines = useCallback(async () => { + setIsLoading(true); + try { + const machinesResponse = await client.listMachines({ + headers: { + authorization: `Bearer ${await getToken()}`, + }, + params: { + clusterId, + }, + }); + + if (machinesResponse.status === 200) { + setMachines(machinesResponse.body); + setLiveMachineCount( + machinesResponse.body.filter( + (m) => Date.now() - new Date(m.lastPingAt!).getTime() < 1000 * 60 + ).length + ); + } else { + setError(machinesResponse); + } + } finally { + setIsLoading(false); + } + }, [clusterId, getToken]); + + useEffect(() => { + getClusterMachines(); + const interval = setInterval(getClusterMachines, 1000 * 10); + return () => clearInterval(interval); + }, [getClusterMachines]); + + if (error) { + return ; + } + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

Machines

+
+

+ You have {liveMachineCount} machine + {liveMachineCount === 1 ? "" : "s"} connected +

+ {liveMachineCount > 0 && ( +
+ {liveMachineCount} Active +
+ )} +
+
+ +
+ {machines && machines.length > 0 ? ( + machines + .sort( + (a, b) => + new Date(b.lastPingAt!).getTime() - + new Date(a.lastPingAt!).getTime() + ) + .map((m) => ( + + )) + ) : ( +
+
+ + + Your machines are offline + +

+ No active machines found in this cluster. Make sure your + machines are running and properly configured. +

+
+
+ )} +
+
+ ); +} diff --git a/app/components/ServiceList.tsx b/app/components/ServiceList.tsx new file mode 100644 index 00000000..0844a81c --- /dev/null +++ b/app/components/ServiceList.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { client } from "@/client/client"; +import { contract } from "@/client/contract"; +import { useAuth } from "@clerk/nextjs"; +import { ClientInferResponseBody } from "@ts-rest/core"; +import { AppWindowIcon, Blocks, Network } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { SmallLiveGreenCircle } from "./circles"; +import ToolContextButton from "./chat/ToolContextButton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "./ui/table"; +import { cn, createErrorToast } from "@/lib/utils"; +import { formatDistance } from "date-fns"; + +function toServiceName(name: string) { + return {name}; +} + +function toFunctionName(name: string) { + return {name}; +} + +function ControlPlaneBox() { + return ( +
+
+
+
+ +
+
+
+ Control Plane + +
+
+ api.inferable.ai + + Connected + +
+
+
+
+ ); +} + +function ServiceCard({ + service, + clusterId, + index, + total, +}: { + service: ClientInferResponseBody[number]; + clusterId: string; + index: number; + total: number; +}) { + const isActive = + new Date(service.timestamp) > new Date() || + Date.now() - new Date(service.timestamp).getTime() < 1000 * 60; + + return ( +
+
+ +
+
+
+
+ {service.name === "InferableApplications" ? ( + + ) : ( + + )} +
+
+
+ {toServiceName(service.name)} +
+
+ + {service.functions?.length || 0} Function + {service.functions?.length !== 1 ? "s" : ""} + + + {isActive ? "Active" : "Inactive"} + +
+
+
+
+ +
+ + + + Function + Last Update + + + + {service.functions + ?.sort((a, b) => a.name.localeCompare(b.name)) + .map((func) => ( + + +
+
+ + {toFunctionName(func.name)} + + +
+
+ {func.description || "No description"} +
+
+
+ + {new Date(service.timestamp) > new Date() ? ( +
+ + Permanent Sync +
+ ) : ( + + {formatDistance( + new Date(service.timestamp), + new Date(), + { addSuffix: true } + )} + + )} +
+
+ ))} +
+
+
+
+
+ ); +} + +export function ServiceList({ clusterId }: { clusterId: string }) { + const [services, setServices] = useState< + ClientInferResponseBody + >([]); + const { getToken } = useAuth(); + + const getClusterServices = useCallback(async () => { + const servicesResponse = await client.listServices({ + headers: { + authorization: `Bearer ${await getToken()}`, + }, + params: { + clusterId, + }, + }); + + if (servicesResponse.status === 200) { + setServices(servicesResponse.body); + } else { + createErrorToast(servicesResponse, "Failed to get cluster services"); + } + }, [clusterId, getToken]); + + useEffect(() => { + getClusterServices(); + }, [getClusterServices]); + + const sortedServices = services.sort((a, b) => a.name.localeCompare(b.name)); + + return ( +
+ +
+ {sortedServices.length > 0 && ( +
+ )} + + {sortedServices.map((service, index) => ( + + ))} +
+
+ ); +} diff --git a/app/components/WorkflowList.tsx b/app/components/WorkflowList.tsx index 4ebffaf0..454205c7 100644 --- a/app/components/WorkflowList.tsx +++ b/app/components/WorkflowList.tsx @@ -4,6 +4,7 @@ import { client } from "@/client/client"; import { contract } from "@/client/contract"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { createErrorToast } from "@/lib/utils"; import { useAuth, useUser } from "@clerk/nextjs"; @@ -26,6 +27,134 @@ const runFiltersSchema = z.object({ test: z.boolean().optional(), }); +function RunListContent({ + workflows, + runToggle, + setRunToggle, + runFilters, + setRunFilters, + path, + router, + clusterId, + goToWorkflow, + goToCluster, + fetchWorkflows, + hasMore, + loadMore, + limit, +}: { + workflows: ClientInferResponseBody; + runToggle: string; + setRunToggle: (value: string) => void; + runFilters: z.infer; + setRunFilters: (filters: z.infer) => void; + path: string | null; + router: any; + clusterId: string; + goToWorkflow: (c: string, w: string) => void; + goToCluster: (c: string) => void; + fetchWorkflows: () => Promise; + hasMore: boolean; + loadMore: () => void; + limit: number; +}) { + return ( + + {(!!runFilters.configId || !!runFilters.test) && ( +
+ {runFilters.configId && ( + { + setRunFilters({}); + if (path) { + router.push(path); + } + }} + > + Filtering by Prompt + + + )} + {runFilters.test && ( + { + setRunFilters({}); + if (path) { + router.push(path); + } + }} + > + Filtering By Test Runs + + + )} +
+ )} +
+ { + if (value) setRunToggle(value); + }} + variant="outline" + size="sm" + className="flex-1" + > + + + All Runs + + + + My Runs + + + +
+
+ + {hasMore && ( + + )} + {!hasMore && limit >= 50 && ( +

+ Maximum number of runs loaded. Delete some runs to load older + ones. +

+ )} +
+
+ ); +} + export function RunList({ clusterId }: WorkflowListProps) { const router = useRouter(); const { getToken, userId } = useAuth(); @@ -46,6 +175,7 @@ export function RunList({ clusterId }: WorkflowListProps) { const goToWorkflow = useCallback( (c: string, w: string) => { router.push(`/clusters/${c}/runs/${w}`); + setIsOpen(false); }, [router] ); @@ -132,101 +262,61 @@ export function RunList({ clusterId }: WorkflowListProps) { } }; + const [isOpen, setIsOpen] = useState(false); + return ( -
- - {(!!runFilters.configId || !!runFilters.test) && ( -
- {runFilters.configId && ( - { - setRunFilters({}); - if (path) { - router.push(path); - } - }} - > - Filtering by Prompt - - - )} - {runFilters.test && ( - { - setRunFilters({}); - if (path) { - router.push(path); - } - }} - > - Filtering By Test Runs - - - )} -
- )} -
- { - if (value) setRunToggle(value); - }} - variant="outline" - size="sm" - className="flex-1" - > - - - All Runs - - - - My Runs - - - -
-
- + {/* Mobile view */} +
+ + + + + + + + +
+ + {/* Desktop view */} +
+
+ - {hasMore && ( - - )} - {!hasMore && limit >= 50 && ( -

- Maximum number of runs loaded. Delete some runs to load older - ones. -

- )}
- -
+
+ ); } diff --git a/app/components/breadcrumbs.tsx b/app/components/breadcrumbs.tsx index 323029be..1d2440c9 100644 --- a/app/components/breadcrumbs.tsx +++ b/app/components/breadcrumbs.tsx @@ -41,11 +41,6 @@ export async function ClusterBreadcrumbs({ return (
-
-

- {clusterDetails.body.name} -

-
Runs diff --git a/app/components/cluster-details.tsx b/app/components/cluster-details.tsx deleted file mode 100644 index 7fa2362a..00000000 --- a/app/components/cluster-details.tsx +++ /dev/null @@ -1,658 +0,0 @@ -"use client"; - -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { Blocks, Cpu, Network } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import { SmallLiveGreenCircle } from "./circles"; -import { Button } from "./ui/button"; - -import { client } from "@/client/client"; -import { contract } from "@/client/contract"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { cn, createErrorToast } from "@/lib/utils"; -import { useAuth } from "@clerk/nextjs"; -import { ClientInferResponseBody, ClientInferResponses } from "@ts-rest/core"; -import { formatDistance, formatRelative } from "date-fns"; -import { AppWindowIcon } from "lucide-react"; -import ToolContextButton from "./chat/ToolContextButton"; -import { DeadGrayCircle, DeadRedCircle, LiveGreenCircle } from "./circles"; -import ErrorDisplay from "./error-display"; -import { EventsOverlayButton } from "./events-overlay"; -import { ServerConnectionStatus } from "./server-connection-pane"; - -function toServiceName(name: string) { - return {name}; -} - -function toFunctionName(name: string, serviceName: string) { - if (serviceName === "InferableApplications") { - return Inferable App; - } - - return {name}; -} - -function ControlPlaneBox() { - return ( -
-
-
-
- -
-
-
- Control Plane - -
-
- api.inferable.ai - - Connected - -
-
-
-
- ); -} - -function ServiceCard({ - service, - clusterId, - index, - total, -}: { - service: ClientInferResponseBody[number]; - clusterId: string; - index: number; - total: number; -}) { - const isActive = - new Date(service.timestamp) > new Date() || - Date.now() - new Date(service.timestamp).getTime() < 1000 * 60; - - return ( -
-
- -
-
-
-
- {service.name === "InferableApplications" ? ( - - ) : ( - - )} -
-
-
- {toServiceName(service.name)} -
-
- - {service.functions?.length || 0} Function - {service.functions?.length !== 1 ? "s" : ""} - - - {isActive ? "Active" : "Inactive"} - -
-
-
-
- -
- - - - Function - Last Update - - - - {service.functions - ?.sort((a, b) => a.name.localeCompare(b.name)) - .map((func) => ( - - -
-
- - {toFunctionName(func.name, service.name)} - - -
-
- {func.description || "No description"} -
-
-
- - {new Date(service.timestamp) > new Date() ? ( -
- - Permanent Sync -
- ) : ( - - {formatDistance( - new Date(service.timestamp), - new Date(), - { addSuffix: true } - )} - - )} -
-
- ))} -
-
-
-
-
- ); -} - -export default function ServicesOverview({ clusterId }: { clusterId: string }) { - const [services, setServices] = useState< - ClientInferResponseBody - >([]); - const { getToken } = useAuth(); - - const getClusterServices = useCallback(async () => { - const servicesResponse = await client.listServices({ - headers: { - authorization: `Bearer ${await getToken()}`, - }, - params: { - clusterId, - }, - }); - - if (servicesResponse.status === 200) { - setServices(servicesResponse.body); - } else { - createErrorToast(servicesResponse, "Failed to get cluster services"); - } - }, [clusterId, getToken]); - - useEffect(() => { - getClusterServices(); - }, [getClusterServices]); - - const sortedServices = services.sort((a, b) => a.name.localeCompare(b.name)); - - return ( -
- -
- {sortedServices.length > 0 && ( -
- )} - - {sortedServices.map((service, index) => ( - - ))} -
-
- ); -} - -export function ClusterDetails({ - clusterId, -}: { - clusterId: string; -}): JSX.Element { - const { getToken } = useAuth(); - const [clusterDetails, setClusterDetails] = useState< - ClientInferResponses["body"] | null - >(null); - const [machines, setMachines] = useState< - ClientInferResponseBody - >([]); - const [services, setServices] = useState< - ClientInferResponseBody - >([]); - const [isInitialLoading, setIsInitialLoading] = useState(true); - const [hasInitialDataLoaded, setHasInitialDataLoaded] = useState(false); - - const fetchData = useCallback( - async (isInitialFetch: boolean = false) => { - if (!clusterId) return; - if (isInitialFetch) { - setIsInitialLoading(true); - } - - try { - const token = await getToken(); - const headers = { authorization: `Bearer ${token}` }; - const params = { clusterId }; - - const [clusterResult, machinesResponse, servicesResponse] = - await Promise.all([ - client.getCluster({ headers, params }), - client.listMachines({ headers, params }), - client.listServices({ headers, params }), - ]); - - if (clusterResult.status === 200) { - setClusterDetails(clusterResult.body); - } else { - ServerConnectionStatus.addEvent({ - type: "getCluster", - success: false, - }); - } - - if (machinesResponse.status === 200) { - setMachines(machinesResponse.body); - } - - if (servicesResponse.status === 200) { - setServices(servicesResponse.body); - } - } catch (error) { - console.error("Failed to fetch cluster data:", error); - } finally { - if (isInitialFetch) { - setIsInitialLoading(false); - setHasInitialDataLoaded(true); - } - } - }, - [clusterId, getToken] - ); - - const pollWithDelay = useCallback(async () => { - // Initial fetch - await fetchData(true); - - // Start polling loop - while (true) { - await new Promise((resolve) => setTimeout(resolve, 10000)); // Wait 10 seconds - await fetchData(false); - } - }, [fetchData]); - - useEffect(() => { - const abortController = new AbortController(); - - if (!hasInitialDataLoaded) { - pollWithDelay().catch((error) => { - if (!abortController.signal.aborted) { - console.error("Polling error:", error); - } - }); - } - - return () => { - abortController.abort(); - }; - }, [pollWithDelay, hasInitialDataLoaded]); - - const liveMachineCount = machines.filter( - (m) => Date.now() - new Date(m.lastPingAt!).getTime() < 1000 * 60 - ).length; - - return ( -
- - - - - - - -
-
- -
-
-
Cluster Health
-
- Monitor and manage your cluster's machines -
-
-
-
-
-
- {isInitialLoading ? ( -
-
-
- ) : ( - - )} -
-
-
- - - - - - - - -
-
- -
-
-
Service Details
-
- Manage and monitor your cluster services -
-
-
-
-
-
- {isInitialLoading ? ( -
-
-
- ) : ( - - )} -
-
-
-
- ); -} - -function MachineCard({ - machine, - clusterId, -}: { - machine: ClientInferResponseBody[number]; - clusterId: string; -}) { - const isLive = - Date.now() - new Date(machine.lastPingAt!).getTime() < 1000 * 60; - - return ( -
-
-
-
{isLive ? : }
-
-
{machine.id}
-
{machine.ip}
-
-
- -
-
-
- {isLive ? "Active" : "Inactive"} -
-
- Last heartbeat: {formatRelative(machine.lastPingAt!, new Date())} -
-
-
- ); -} - -function MachinesOverview({ clusterId }: { clusterId: string }) { - const [machines, setMachines] = useState< - ClientInferResponseBody - >([]); - const [liveMachineCount, setLiveMachineCount] = useState(0); - const [isLoading, setIsLoading] = useState(true); - const { getToken } = useAuth(); - const [error, setError] = useState(null); - - const getClusterMachines = useCallback(async () => { - setIsLoading(true); - try { - const machinesResponse = await client.listMachines({ - headers: { - authorization: `Bearer ${await getToken()}`, - }, - params: { - clusterId, - }, - }); - - if (machinesResponse.status === 200) { - setMachines(machinesResponse.body); - setLiveMachineCount( - machinesResponse.body.filter( - (m) => Date.now() - new Date(m.lastPingAt!).getTime() < 1000 * 60 - ).length - ); - } else { - setError(machinesResponse); - } - } finally { - setIsLoading(false); - } - }, [clusterId, getToken]); - - useEffect(() => { - getClusterMachines(); - const interval = setInterval(getClusterMachines, 1000 * 10); - return () => clearInterval(interval); - }, [getClusterMachines]); - - if (error) { - return ; - } - - if (isLoading) { - return ( -
-
-
- ); - } - - return ( -
-
-

Machines

-
-

- You have {liveMachineCount} machine - {liveMachineCount === 1 ? "" : "s"} connected -

- {liveMachineCount > 0 && ( -
- {liveMachineCount} Active -
- )} -
-
- -
- {machines && machines.length > 0 ? ( - machines - .sort( - (a, b) => - new Date(b.lastPingAt!).getTime() - - new Date(a.lastPingAt!).getTime() - ) - .map((m) => ( - - )) - ) : ( -
-
- - - Your machines are offline - -

- No active machines found in this cluster. Make sure your - machines are running and properly configured. -

-
-
- )} -
-
- ); -} - -export function ClusterHealthPane({ - clusterDetails, -}: { - clusterDetails: - | ClientInferResponses["body"] - | null; -}): JSX.Element { - return ( -
- {clusterDetails?.id && } -
- ); -} diff --git a/app/components/cluster-list.tsx b/app/components/cluster-list.tsx deleted file mode 100644 index c8de89ad..00000000 --- a/app/components/cluster-list.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { useLocalStorage } from "@uidotdev/usehooks"; -import { useMemo } from "react"; -import { ClusterCard } from "./cluster-card"; - -interface Cluster { - id: string; - name: string; - description: string | null; -} - -interface ClusterListProps { - clusters: Cluster[]; -} - -export function ClusterList({ clusters }: ClusterListProps) { - const [recentClusters] = useLocalStorage< - Array<{ id: string; name: string; orgId: string }> - >("recentClusters", []); - - const sortedClusters = useMemo(() => { - const recentClusterIds = recentClusters.map((rc) => rc.id); - return clusters.sort((a, b) => { - const aIsRecent = recentClusterIds.includes(a.id); - const bIsRecent = recentClusterIds.includes(b.id); - if (aIsRecent && !bIsRecent) return -1; - if (!aIsRecent && bIsRecent) return 1; - return 0; - }); - }, [clusters, recentClusters]); - - return ( -
- {sortedClusters.map((cluster) => ( - - ))} -
- ); -} diff --git a/app/components/workflow-tab.tsx b/app/components/workflow-tab.tsx index fea1aeac..fd04f94d 100644 --- a/app/components/workflow-tab.tsx +++ b/app/components/workflow-tab.tsx @@ -106,7 +106,7 @@ export function RunTab({ }, [clusterId, getToken]); return ( -
+
{workflows .sort((a, b) => (a.id > b.id ? -1 : 1)) .map((workflow) => ( diff --git a/app/components/workflow.tsx b/app/components/workflow.tsx index 206a38b3..65cf75ca 100644 --- a/app/components/workflow.tsx +++ b/app/components/workflow.tsx @@ -24,7 +24,6 @@ import { DebugEvent } from "./debug-event"; import { Skeleton } from "@/components/ui/skeleton"; import { toast } from "react-hot-toast"; import { Blob } from "./chat/blob"; -import { Strait } from "next/font/google"; const messageSkeleton = (
@@ -580,10 +579,10 @@ export function Run({ }, [elements.length]); return ( -
+
{elements.length > 0 ? (
{elements}
@@ -592,7 +591,7 @@ export function Run({ )}
-
+
{!!runTimeline && isEditable ? (