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) => {