Canvas: Rework monitoring page
Display worker statuses with list of commands
Commit hash
Change-Id: I7054ecc5ce81f35cad3fe26fc20677b6f50d3147
diff --git a/apps/canvas/back/.env b/apps/canvas/back/.env
index 9420359..4c9a4a9 100644
--- a/apps/canvas/back/.env
+++ b/apps/canvas/back/.env
@@ -1,3 +1,3 @@
-DATABASE_URL=file:${DODO_VOLUME_DATA}/dodo.db
-PUBLIC_ADDR=https://canvas.v1.dodo.cloud
-INTERNAL_API_ADDR=http://canvas-app.hgrz-dodo-app-gry.svc.cluster.local:8081
\ No newline at end of file
+DATABASE_URL=file:/home/gio/dodo.db
+# PUBLIC_ADDR=https://canvas.v1.dodo.cloud
+INTERNAL_API_ADDR=http://canvas.hgrz-dodo-app-jjy.svc.cluster.local:8081
\ No newline at end of file
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index aa25383..de18582 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -5,12 +5,12 @@
import { GithubClient } from "./github";
import { AppManager } from "./app_manager";
import { z } from "zod";
+import { ProjectMonitor, WorkerSchema } from "./project_monitor";
const db = new PrismaClient();
const appManager = new AppManager();
-const workers = new Map<number, string[]>();
-const logs = new Map<number, Map<string, string>>();
+const projectMonitors = new Map<number, ProjectMonitor>();
const handleProjectCreate: express.Handler = async (req, resp) => {
try {
@@ -486,8 +486,18 @@
resp.write(JSON.stringify({ error: "Project not found" }));
return;
}
- const projectLogs = logs.get(projectId) || new Map();
- const services = Array.from(projectLogs.keys());
+ const monitor = projectMonitors.get(projectId);
+ const serviceNames = monitor ? monitor.getAllServiceNames() : [];
+ const services = serviceNames.map((name) => ({
+ name,
+ workers: [...(monitor ? monitor.getWorkerStatusesForService(name) : new Map()).entries()].map(
+ ([id, status]) => ({
+ ...status,
+ id,
+ }),
+ ),
+ }));
+
resp.status(200);
resp.write(
JSON.stringify({
@@ -540,6 +550,7 @@
try {
const projectId = Number(req.params["projectId"]);
const service = req.params["service"];
+ const workerId = req.params["workerId"];
const project = await db.project.findUnique({
where: {
id: projectId,
@@ -551,16 +562,16 @@
resp.write(JSON.stringify({ error: "Project not found" }));
return;
}
- const projectLogs = logs.get(projectId);
- if (!projectLogs) {
+ const monitor = projectMonitors.get(projectId);
+ if (!monitor || !monitor.hasLogs()) {
resp.status(404);
resp.write(JSON.stringify({ error: "No logs found for this project" }));
return;
}
- const serviceLog = projectLogs.get(service);
+ const serviceLog = monitor.getWorkerLog(service, workerId);
if (!serviceLog) {
resp.status(404);
- resp.write(JSON.stringify({ error: "No logs found for this service" }));
+ resp.write(JSON.stringify({ error: "No logs found for this service/worker" }));
return;
}
resp.status(200);
@@ -574,12 +585,6 @@
}
};
-const WorkerSchema = z.object({
- service: z.string(),
- address: z.string().url(),
- logs: z.optional(z.string()),
-});
-
const handleRegisterWorker: express.Handler = async (req, resp) => {
try {
const projectId = Number(req.params["projectId"]);
@@ -594,17 +599,12 @@
);
return;
}
- const { service, address, logs: log } = result.data;
- const projectWorkers = workers.get(projectId) || [];
- if (!projectWorkers.includes(address)) {
- projectWorkers.push(address);
+ let monitor = projectMonitors.get(projectId);
+ if (!monitor) {
+ monitor = new ProjectMonitor();
+ projectMonitors.set(projectId, monitor);
}
- workers.set(projectId, projectWorkers);
- if (log) {
- const svcLogs: Map<string, string> = logs.get(projectId) || new Map();
- svcLogs.set(service, log);
- logs.set(projectId, svcLogs);
- }
+ monitor.registerWorker(result.data);
resp.status(200);
resp.write(
JSON.stringify({
@@ -621,7 +621,8 @@
};
async function reloadProject(projectId: number): Promise<boolean> {
- const projectWorkers = workers.get(projectId) || [];
+ const monitor = projectMonitors.get(projectId);
+ const projectWorkers = monitor ? monitor.getWorkerAddresses() : [];
const workerCount = projectWorkers.length;
if (workerCount === 0) {
return true;
@@ -750,7 +751,7 @@
projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
projectRouter.get("/:projectId/env", handleEnv);
projectRouter.post("/:projectId/reload", handleReload);
- projectRouter.get("/:projectId/logs/:service", handleServiceLogs);
+ projectRouter.get("/:projectId/logs/:service/:workerId", handleServiceLogs);
projectRouter.post("/:projectId/remove-deployment", handleRemoveDeployment);
projectRouter.get("/", handleProjectAll);
projectRouter.post("/", handleProjectCreate);
diff --git a/apps/canvas/back/src/project_monitor.ts b/apps/canvas/back/src/project_monitor.ts
new file mode 100644
index 0000000..6c178f0
--- /dev/null
+++ b/apps/canvas/back/src/project_monitor.ts
@@ -0,0 +1,130 @@
+import { z } from "zod";
+
+export const WorkerSchema = z.object({
+ id: z.string(),
+ service: z.string(),
+ address: z.string().url(),
+ status: z.optional(
+ z.object({
+ repoOK: z.boolean(),
+ commit: z.string(),
+ commands: z.optional(
+ z.array(
+ z.object({
+ command: z.string(),
+ state: z.string(),
+ }),
+ ),
+ ),
+ }),
+ ),
+ logs: z.optional(z.string()),
+});
+
+export type Worker = z.infer<typeof WorkerSchema>;
+
+class ServiceMonitor {
+ private workers: Map<string, string> = new Map();
+ private logs: Map<string, string> = new Map();
+ private statuses: Map<string, Worker["status"]> = new Map();
+
+ constructor(public readonly serviceName: string) {}
+
+ registerWorker(workerId: string, workerAddress: string, workerLog?: string, workerStatus?: Worker["status"]): void {
+ this.workers.set(workerId, workerAddress);
+ if (workerLog) {
+ this.logs.set(workerId, workerLog);
+ }
+ if (workerStatus) {
+ this.statuses.set(workerId, workerStatus);
+ }
+ }
+
+ getWorkerAddress(workerId: string): string | undefined {
+ return this.workers.get(workerId);
+ }
+
+ getWorkerLog(workerId: string): string | undefined {
+ return this.logs.get(workerId);
+ }
+
+ getWorkerStatus(workerId: string): Worker["status"] | undefined {
+ return this.statuses.get(workerId);
+ }
+
+ getAllLogs(): Map<string, string> {
+ return new Map(this.logs);
+ }
+
+ getAllStatuses(): Map<string, Worker["status"]> {
+ return new Map(this.statuses);
+ }
+
+ getWorkerAddresses(): string[] {
+ return Array.from(this.workers.values());
+ }
+
+ getWorkerIds(): string[] {
+ return Array.from(this.workers.keys());
+ }
+
+ hasLogs(): boolean {
+ return this.logs.size > 0;
+ }
+}
+
+export class ProjectMonitor {
+ private serviceMonitors: Map<string, ServiceMonitor> = new Map();
+
+ constructor() {}
+
+ registerWorker(workerData: Worker): void {
+ let serviceMonitor = this.serviceMonitors.get(workerData.service);
+ if (!serviceMonitor) {
+ serviceMonitor = new ServiceMonitor(workerData.service);
+ this.serviceMonitors.set(workerData.service, serviceMonitor);
+ }
+ serviceMonitor.registerWorker(workerData.id, workerData.address, workerData.logs, workerData.status);
+ }
+
+ getWorkerAddresses(): string[] {
+ let allAddresses: string[] = [];
+ for (const serviceMonitor of this.serviceMonitors.values()) {
+ allAddresses = allAddresses.concat(serviceMonitor.getWorkerAddresses());
+ }
+ return Array.from(new Set(allAddresses));
+ }
+
+ getWorkerLog(serviceName: string, workerId: string): string | undefined {
+ const serviceMonitor = this.serviceMonitors.get(serviceName);
+ if (serviceMonitor) {
+ return serviceMonitor.getWorkerLog(workerId);
+ }
+ return undefined;
+ }
+
+ getAllServiceNames(): string[] {
+ return Array.from(this.serviceMonitors.keys());
+ }
+
+ hasLogs(): boolean {
+ for (const serviceMonitor of this.serviceMonitors.values()) {
+ if (serviceMonitor.hasLogs()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ getServiceMonitor(serviceName: string): ServiceMonitor | undefined {
+ return this.serviceMonitors.get(serviceName);
+ }
+
+ getWorkerStatusesForService(serviceName: string): Map<string, Worker["status"]> {
+ const serviceMonitor = this.serviceMonitors.get(serviceName);
+ if (serviceMonitor) {
+ return serviceMonitor.getAllStatuses();
+ }
+ return new Map();
+ }
+}
diff --git a/apps/canvas/front/src/App.tsx b/apps/canvas/front/src/App.tsx
index 106e6f0..9b6d69a 100644
--- a/apps/canvas/front/src/App.tsx
+++ b/apps/canvas/front/src/App.tsx
@@ -6,7 +6,7 @@
import { Integrations } from "./Integrations";
import { Toaster } from "./components/ui/toaster";
import { ProjectSelect } from "./ProjectSelect";
-import { Logs } from "./Logs";
+import { Logs } from "./Monitoring";
export default function App() {
return (
@@ -25,7 +25,7 @@
<div className="flex justify-between border-b">
<TabsList className="!rounded-none">
<TabsTrigger value="canvas">Canvas</TabsTrigger>
- <TabsTrigger value="logs">Logs</TabsTrigger>
+ <TabsTrigger value="monitoring">Monitoring</TabsTrigger>
<TabsTrigger value="config">Config</TabsTrigger>
<TabsTrigger value="integrations">Integrations</TabsTrigger>
</TabsList>
@@ -40,7 +40,7 @@
<TabsContent value="integrations" className="!mt-0 flex-1 min-h-0">
<Integrations />
</TabsContent>
- <TabsContent value="logs" className="!mt-0 flex-1 min-h-0">
+ <TabsContent value="monitoring" className="!mt-0 flex-1 min-h-0">
<Logs />
</TabsContent>
</Tabs>
diff --git a/apps/canvas/front/src/Logs.tsx b/apps/canvas/front/src/Logs.tsx
deleted file mode 100644
index c27d420..0000000
--- a/apps/canvas/front/src/Logs.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import { useCallback, useEffect, useState, useRef, useMemo } from "react";
-import { useProjectId, useEnv } from "@/lib/state";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { useToast } from "@/hooks/use-toast";
-
-// ANSI escape sequence regex
-// eslint-disable-next-line no-control-regex
-const ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
-
-function cleanAnsiEscapeSequences(text: string): string {
- return text.replace(ANSI_ESCAPE_REGEX, "");
-}
-
-export function Logs() {
- const { toast } = useToast();
- const projectId = useProjectId();
- const env = useEnv();
- const [selectedService, setSelectedService] = useState<string>("");
- const [logs, setLogs] = useState<string>("");
- const preRef = useRef<HTMLPreElement>(null);
- const wasAtBottom = useRef(true);
-
- const checkIfAtBottom = useCallback(() => {
- if (!preRef.current) return;
- const { scrollTop, scrollHeight, clientHeight } = preRef.current;
- wasAtBottom.current = Math.abs(scrollHeight - clientHeight - scrollTop) < 10;
- }, []);
-
- const scrollToBottom = useCallback(() => {
- if (!preRef.current) return;
- preRef.current.scrollTop = preRef.current.scrollHeight;
- }, []);
-
- const fetchLogs = useCallback(
- async (service: string) => {
- if (!projectId || !service) return;
-
- try {
- const resp = await fetch(`/api/project/${projectId}/logs/${service}`);
- if (!resp.ok) {
- throw new Error("Failed to fetch logs");
- }
- const data = await resp.json();
- setLogs(data.logs || "");
- } catch (e) {
- console.error(e);
- toast({
- variant: "destructive",
- title: "Failed to fetch logs",
- });
- }
- },
- [projectId, toast],
- );
-
- useEffect(() => {
- if (selectedService) {
- // Initial fetch
- fetchLogs(selectedService);
-
- // Set up interval for periodic updates
- const interval = setInterval(() => {
- fetchLogs(selectedService);
- }, 5000);
-
- // Cleanup interval on unmount or when service changes
- return () => clearInterval(interval);
- }
- }, [selectedService, fetchLogs]);
-
- // Handle scroll events
- useEffect(() => {
- const pre = preRef.current;
- if (!pre) return;
-
- const handleScroll = () => {
- checkIfAtBottom();
- };
-
- pre.addEventListener("scroll", handleScroll);
- return () => pre.removeEventListener("scroll", handleScroll);
- }, [checkIfAtBottom]);
-
- // Auto-scroll when new logs arrive
- useEffect(() => {
- if (wasAtBottom.current) {
- scrollToBottom();
- }
- }, [logs, scrollToBottom]);
-
- const sortedServices = useMemo(() => (env?.services ? [...env.services].sort() : []), [env]);
-
- // Auto-select first service when services are available
- useEffect(() => {
- if (sortedServices.length && !selectedService) {
- setSelectedService(sortedServices[0]);
- }
- }, [sortedServices, selectedService]);
-
- return (
- <div className="flex flex-col h-full">
- <div className="flex-none w-full flex flex-row justify-start items-center gap-2 px-4 py-1">
- <div>Service</div>
- <Select value={selectedService} onValueChange={setSelectedService}>
- <SelectTrigger className="w-1/4">
- <SelectValue placeholder="Select a service" />
- </SelectTrigger>
- <SelectContent>
- {sortedServices.map((service) => (
- <SelectItem key={service} value={service}>
- {service}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- {selectedService && (
- <pre
- ref={preRef}
- className="flex-1 h-full p-4 bg-muted overflow-auto font-['JetBrains_Mono'] text-xs whitespace-pre-wrap break-all"
- >
- {cleanAnsiEscapeSequences(logs) || "No logs available"}
- </pre>
- )}
- </div>
- );
-}
diff --git a/apps/canvas/front/src/Monitoring.tsx b/apps/canvas/front/src/Monitoring.tsx
new file mode 100644
index 0000000..b49a8d8
--- /dev/null
+++ b/apps/canvas/front/src/Monitoring.tsx
@@ -0,0 +1,305 @@
+import { useCallback, useEffect, useState, useRef, useMemo } from "react";
+import { useProjectId, useEnv } from "@/lib/state";
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { useToast } from "@/hooks/use-toast";
+import { LogsIcon } from "lucide-react";
+
+// ANSI escape sequence regex
+// eslint-disable-next-line no-control-regex
+const ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
+
+function cleanAnsiEscapeSequences(text: string): string {
+ return text.replace(ANSI_ESCAPE_REGEX, "");
+}
+
+const WaitingIcon = () => (
+ <svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-gray-500">
+ <circle cx="6" cy="12" r="2" />
+ <circle cx="12" cy="12" r="2" />
+ <circle cx="18" cy="12" r="2" />
+ </svg>
+);
+const RunningIcon = () => (
+ <svg
+ className="animate-spin w-4 h-4 mr-2 inline-block align-middle text-blue-500"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ >
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+ ></path>
+ </svg>
+);
+const SuccessIcon = () => (
+ <svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-green-500">
+ <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
+ </svg>
+);
+const FailureIcon = () => (
+ <svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 mr-2 inline-block align-middle text-red-500">
+ <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
+ </svg>
+);
+
+export function Logs() {
+ const { toast } = useToast();
+ const projectId = useProjectId();
+ const env = useEnv();
+
+ const [selectedServiceForLogs, setSelectedServiceForLogs] = useState<string | null>(null);
+ const [selectedWorkerIdForLogs, setSelectedWorkerIdForLogs] = useState<string | null>(null);
+
+ const [logs, setLogs] = useState<string>("");
+ const preRef = useRef<HTMLPreElement>(null);
+ const wasAtBottom = useRef(true);
+
+ const checkIfAtBottom = useCallback(() => {
+ if (!preRef.current) return;
+ const { scrollTop, scrollHeight, clientHeight } = preRef.current;
+ wasAtBottom.current = Math.abs(scrollHeight - clientHeight - scrollTop) < 10;
+ }, []);
+
+ const scrollToBottom = useCallback(() => {
+ if (!preRef.current) return;
+ preRef.current.scrollTop = preRef.current.scrollHeight;
+ }, []);
+
+ const fetchLogs = useCallback(
+ async (serviceName: string, workerId: string) => {
+ if (!projectId || !serviceName || !workerId) return;
+ try {
+ const resp = await fetch(`/api/project/${projectId}/logs/${serviceName}/${workerId}`);
+ if (!resp.ok) {
+ throw new Error(`Failed to fetch logs: ${resp.statusText}`);
+ }
+ const data = await resp.json();
+ setLogs(data.logs || "");
+ } catch (e) {
+ console.error(e);
+ toast({
+ variant: "destructive",
+ title: "Failed to fetch logs",
+ });
+ }
+ },
+ [projectId, toast],
+ );
+
+ useEffect(() => {
+ if (selectedServiceForLogs && selectedWorkerIdForLogs) {
+ fetchLogs(selectedServiceForLogs, selectedWorkerIdForLogs);
+ const interval = setInterval(() => {
+ fetchLogs(selectedServiceForLogs, selectedWorkerIdForLogs);
+ }, 5000);
+ return () => clearInterval(interval);
+ } else {
+ setLogs("");
+ }
+ }, [selectedServiceForLogs, selectedWorkerIdForLogs, fetchLogs]);
+
+ useEffect(() => {
+ const pre = preRef.current;
+ if (!pre) return;
+ const handleScroll = () => checkIfAtBottom();
+ pre.addEventListener("scroll", handleScroll);
+ return () => pre.removeEventListener("scroll", handleScroll);
+ }, [checkIfAtBottom]);
+
+ useEffect(() => {
+ if (wasAtBottom.current) {
+ scrollToBottom();
+ }
+ }, [logs, scrollToBottom]);
+
+ const sortedServices = useMemo(
+ () => (env?.services ? [...env.services].sort((a, b) => a.name.localeCompare(b.name)) : []),
+ [env?.services],
+ );
+
+ useEffect(() => {
+ if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) {
+ // Only set if no logs are currently selected, to avoid overriding user interaction
+ if (!selectedServiceForLogs && !selectedWorkerIdForLogs) {
+ handleViewLogsClick(sortedServices[0].name, sortedServices[0].workers[0].id);
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sortedServices]);
+
+ const handleViewLogsClick = (serviceName: string, workerId: string) => {
+ setSelectedServiceForLogs(serviceName);
+ setSelectedWorkerIdForLogs(workerId);
+ };
+
+ const defaultExpandedServiceNames = useMemo(() => sortedServices.map((s) => s.name), [sortedServices]);
+ const defaultExpandedFirstWorkerId = useMemo(() => {
+ if (sortedServices.length > 0 && sortedServices[0].workers && sortedServices[0].workers.length > 0) {
+ return sortedServices[0].workers[0].id;
+ }
+ return undefined;
+ }, [sortedServices]);
+
+ return (
+ <ResizablePanelGroup direction="horizontal" className="h-full w-full">
+ <ResizablePanel defaultSize={15}>
+ <div className="flex flex-col h-full p-2 gap-2 overflow-y-auto">
+ {sortedServices.length > 0 ? (
+ <Accordion type="multiple" className="w-full" defaultValue={defaultExpandedServiceNames}>
+ {sortedServices.map((service, serviceIndex) => (
+ <AccordionItem value={service.name} key={service.name}>
+ <AccordionTrigger className="py-1">{service.name}</AccordionTrigger>
+ <AccordionContent className="pl-2">
+ {service.workers && service.workers.length > 0 ? (
+ <Accordion
+ type="single"
+ collapsible
+ className="w-full"
+ defaultValue={
+ serviceIndex === 0 ? defaultExpandedFirstWorkerId : undefined
+ }
+ >
+ {service.workers.map((worker) => (
+ <AccordionItem value={worker.id} key={worker.id}>
+ <AccordionTrigger className="py-1">
+ {worker.id}
+ </AccordionTrigger>
+ <AccordionContent className="pl-2">
+ <TooltipProvider>
+ <div className="text-sm">
+ <Button
+ onClick={() =>
+ handleViewLogsClick(service.name, worker.id)
+ }
+ size="sm"
+ variant="link"
+ className="!px-0"
+ >
+ <LogsIcon className="w-4 h-4" />
+ View Logs
+ </Button>
+ {!worker.repoOK && (
+ <p className="flex items-center">
+ <FailureIcon />
+ Clone Repository
+ </p>
+ )}
+ <p>
+ Commit:
+ {worker.repoOK && worker.commit && (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="inline-block">
+ <Badge
+ variant="outline"
+ className="ml-1 font-mono"
+ >
+ {worker.commit.substring(
+ 0,
+ 8,
+ )}
+ </Badge>
+ </span>
+ </TooltipTrigger>
+ <TooltipContent dir="right">
+ <p>{worker.commit}</p>
+ </TooltipContent>
+ </Tooltip>
+ )}
+ </p>
+ {worker.commands && worker.commands.length > 0 && (
+ <div>
+ Commands:
+ <ul className="list-none pl-0 font-['JetBrains_Mono']">
+ {worker.commands.map((cmd, index) => (
+ <li
+ key={index}
+ className="rounded flex items-start"
+ >
+ <span className="inline-block w-6 flex-shrink-0">
+ <CommandStateIcon
+ state={cmd.state}
+ />
+ </span>
+ <span className="font-mono break-all">
+ {cmd.command}
+ </span>
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+ {(!worker.commands ||
+ worker.commands.length === 0) && (
+ <p className="text-xs text-gray-500">
+ No commands for this worker.
+ </p>
+ )}
+ </div>
+ </TooltipProvider>
+ </AccordionContent>
+ </AccordionItem>
+ ))}
+ </Accordion>
+ ) : (
+ <p className="text-sm text-gray-500 p-2">
+ No workers found for this service.
+ </p>
+ )}
+ </AccordionContent>
+ </AccordionItem>
+ ))}
+ </Accordion>
+ ) : (
+ <div className="text-center text-gray-500 mt-4">No services available.</div>
+ )}
+ </div>
+ </ResizablePanel>
+ <ResizableHandle withHandle />
+ <ResizablePanel defaultSize={85}>
+ <div className="flex flex-col h-full">
+ {selectedServiceForLogs && selectedWorkerIdForLogs ? (
+ <>
+ <div className="p-2 border-b text-sm text-muted-foreground">
+ Logs for: {selectedServiceForLogs} / {selectedWorkerIdForLogs}
+ </div>
+ <pre
+ ref={preRef}
+ className="flex-1 h-full p-4 bg-muted overflow-auto font-['JetBrains_Mono'] text-xs whitespace-pre-wrap break-all"
+ >
+ {cleanAnsiEscapeSequences(logs) ||
+ `No logs available for ${selectedServiceForLogs} / ${selectedWorkerIdForLogs}.`}
+ </pre>
+ </>
+ ) : (
+ <div className="flex-1 flex items-center justify-center h-full p-4 bg-muted text-sm text-gray-500">
+ Click 'View Logs' on a worker to display logs here.
+ </div>
+ )}
+ </div>
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ );
+}
+
+function CommandStateIcon({ state }: { state: string | undefined }): JSX.Element | null {
+ switch (state?.toLowerCase()) {
+ case "running":
+ return <RunningIcon />;
+ case "success":
+ return <SuccessIcon />;
+ case "failure":
+ return <FailureIcon />;
+ case "waiting":
+ return <WaitingIcon />;
+ default:
+ return null;
+ }
+}
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index b0c2560..9f0aeb3 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -223,6 +223,7 @@
} finally {
console.log("done");
}
+ return "Unknown Node";
}
export function nodeIsConnectable(n: AppNode, handle: string): boolean {
@@ -391,6 +392,25 @@
}),
]);
+export const serviceInfoSchema = z.object({
+ name: z.string(),
+ workers: z.array(
+ z.object({
+ id: z.string(),
+ repoOK: z.boolean(),
+ commit: z.string(),
+ commands: z.optional(
+ z.array(
+ z.object({
+ command: z.string(),
+ state: z.string(),
+ }),
+ ),
+ ),
+ }),
+ ),
+});
+
export const envSchema = z.object({
managerAddr: z.optional(z.string().min(1)),
deployKey: z.optional(z.nullable(z.string().min(1))),
@@ -406,7 +426,7 @@
integrations: z.object({
github: z.boolean(),
}),
- services: z.array(z.string()),
+ services: z.array(serviceInfoSchema),
user: z.object({
id: z.string(),
username: z.string(),
@@ -414,6 +434,7 @@
access: z.array(accessSchema),
});
+export type ServiceInfo = z.infer<typeof serviceInfoSchema>;
export type Env = z.infer<typeof envSchema>;
const defaultEnv: Env = {
@@ -831,7 +852,7 @@
nodes.concat({
...node,
position: getRandomPosition(viewport),
- }),
+ } as AppNode),
);
},
setNodes: (nodes) => {