Canvas: Logs tab

Change-Id: Iddf52dbce6fb2090f095cecb04bafcb50c47e4a7
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 0a35dfe..c934a9b 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -9,6 +9,7 @@
 
 // Map to store worker addresses by project ID
 const workers = new Map<number, string[]>();
+const logs = new Map<number, Map<string, string>>();
 
 const handleProjectCreate: express.Handler = async (req, resp) => {
 	try {
@@ -347,6 +348,9 @@
 			return;
 		}
 
+		const projectLogs = logs.get(projectId) || new Map();
+		const services = Array.from(projectLogs.keys());
+
 		resp.status(200);
 		resp.write(
 			JSON.stringify({
@@ -366,6 +370,7 @@
 						domain: "p.v1.dodo.cloud",
 					},
 				],
+				services,
 			}),
 		);
 	} catch (error) {
@@ -377,8 +382,40 @@
 	}
 };
 
+const handleServiceLogs: express.Handler = async (req, resp) => {
+	try {
+		const projectId = Number(req.params["projectId"]);
+		const service = req.params["service"];
+
+		const projectLogs = logs.get(projectId);
+		if (!projectLogs) {
+			resp.status(404);
+			resp.write(JSON.stringify({ error: "No logs found for this project" }));
+			return;
+		}
+
+		const serviceLog = projectLogs.get(service);
+		if (!serviceLog) {
+			resp.status(404);
+			resp.write(JSON.stringify({ error: "No logs found for this service" }));
+			return;
+		}
+
+		resp.status(200);
+		resp.write(JSON.stringify({ logs: serviceLog }));
+	} catch (e) {
+		console.log(e);
+		resp.status(500);
+		resp.write(JSON.stringify({ error: "Failed to get service logs" }));
+	} finally {
+		resp.end();
+	}
+};
+
 const WorkerSchema = z.object({
+	service: z.string(),
 	address: z.string().url(),
+	logs: z.optional(z.string()),
 });
 
 const handleRegisterWorker: express.Handler = async (req, resp) => {
@@ -386,7 +423,6 @@
 		const projectId = Number(req.params["projectId"]);
 
 		const result = WorkerSchema.safeParse(req.body);
-		console.log(result);
 		if (!result.success) {
 			resp.status(400);
 			resp.write(
@@ -398,7 +434,8 @@
 			return;
 		}
 
-		const { address } = result.data;
+		console.log(result);
+		const { service, address, logs: log } = result.data;
 
 		// Get existing workers or initialize empty array
 		const projectWorkers = workers.get(projectId) || [];
@@ -409,12 +446,15 @@
 		}
 
 		workers.set(projectId, projectWorkers);
-
+		if (log) {
+			const svcLogs: Map<string, string> = logs.get(projectId) || new Map();
+			svcLogs.set(service, log);
+			logs.set(projectId, svcLogs);
+		}
 		resp.status(200);
 		resp.write(
 			JSON.stringify({
 				success: true,
-				workers: projectWorkers,
 			}),
 		);
 	} catch (e) {
@@ -475,6 +515,7 @@
 	app.get("/api/project/:projectId/env", handleEnv);
 	app.post("/api/project/:projectId/workers", handleRegisterWorker);
 	app.post("/api/project/:projectId/reload", handleReload);
+	app.get("/api/project/:projectId/logs/:service", handleServiceLogs);
 	app.use("/", express.static("../front/dist"));
 	app.listen(env.DODO_PORT_WEB, () => {
 		console.log("started");
diff --git a/apps/canvas/front/index.html b/apps/canvas/front/index.html
index 59e9967..9b35d46 100644
--- a/apps/canvas/front/index.html
+++ b/apps/canvas/front/index.html
@@ -4,6 +4,9 @@
 		<meta charset="UTF-8" />
 		<link rel="icon" type="image/svg+xml" href="/vite.svg" />
 		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+		<link rel="preconnect" href="https://fonts.googleapis.com">
+		<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+		<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
 		<title>dodo: Canvas</title>
 	</head>
 	<body>
diff --git a/apps/canvas/front/src/App.tsx b/apps/canvas/front/src/App.tsx
index ffdb119..afacdb1 100644
--- a/apps/canvas/front/src/App.tsx
+++ b/apps/canvas/front/src/App.tsx
@@ -6,6 +6,7 @@
 import { Integrations } from "./Integrations";
 import { Toaster } from "./components/ui/toaster";
 import { Header } from "./Header";
+import { Logs } from "./components/logs";
 
 export default function App() {
 	return (
@@ -24,6 +25,7 @@
 				<TabsTrigger value="canvas">Canvas</TabsTrigger>
 				<TabsTrigger value="config">Config</TabsTrigger>
 				<TabsTrigger value="integrations">Integrations</TabsTrigger>
+				<TabsTrigger value="logs">Logs</TabsTrigger>
 			</TabsList>
 			<TabsContent value="canvas">
 				<CanvasBuilder />
@@ -34,6 +36,9 @@
 			<TabsContent value="integrations">
 				<Integrations />
 			</TabsContent>
+			<TabsContent value="logs">
+				<Logs />
+			</TabsContent>
 		</Tabs>
 	);
 }
diff --git a/apps/canvas/front/src/Config.tsx b/apps/canvas/front/src/Config.tsx
index 7306456..df03746 100644
--- a/apps/canvas/front/src/Config.tsx
+++ b/apps/canvas/front/src/Config.tsx
@@ -14,7 +14,7 @@
 			setNodes(n);
 		}
 	}, [n, setNodes]);
-	const config = useMemo(() => generateDodoConfig(projectId, nodes, env), [nodes, env]);
+	const config = useMemo(() => generateDodoConfig(projectId, nodes, env), [projectId, nodes, env]);
 	const configS = useMemo(() => JSON.stringify(config, undefined, 4), [config]);
 	return (
 		<div className="px-5">
diff --git a/apps/canvas/front/src/Integrations.tsx b/apps/canvas/front/src/Integrations.tsx
index 931e7b5..eb68a2a 100644
--- a/apps/canvas/front/src/Integrations.tsx
+++ b/apps/canvas/front/src/Integrations.tsx
@@ -72,60 +72,52 @@
 	return (
 		<div className="px-5 space-y-6">
 			<div>
-				<h3 className="text-md font-medium mb-2">GitHub</h3>
-				<div className="space-y-4">
-					<div className="flex items-center space-x-2">
-						<Checkbox checked={!!githubService} disabled />
-						<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
-							GitHub Access Token
-						</label>
-					</div>
-
-					{!!githubService && !isEditing && (
-						<Button variant="outline" onClick={() => setIsEditing(true)}>
-							Update Access Token
-						</Button>
-					)}
-
-					{(!githubService || isEditing) && (
-						<Form {...form}>
-							<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
-								<FormField
-									control={form.control}
-									name="githubToken"
-									render={({ field }) => (
-										<FormItem>
-											<FormControl>
-												<Input
-													type="password"
-													placeholder="GitHub Personal Access Token"
-													className="border border-black"
-													{...field}
-												/>
-											</FormControl>
-											<FormMessage />
-										</FormItem>
-									)}
-								/>
-								<div className="flex space-x-2">
-									<Button type="submit" disabled={isSaving}>
-										{isSaving ? "Saving..." : "Save"}
-									</Button>
-									{!!githubService && (
-										<Button
-											type="button"
-											variant="outline"
-											onClick={handleCancel}
-											disabled={isSaving}
-										>
-											Cancel
-										</Button>
-									)}
-								</div>
-							</form>
-						</Form>
-					)}
+				<div className="flex items-center space-x-2">
+					<Checkbox checked={!!githubService} disabled />
+					<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
+						<h3 className="text-md font-medium mb-2">GitHub</h3>
+					</label>
 				</div>
+
+				{!!githubService && !isEditing && (
+					<Button variant="outline" onClick={() => setIsEditing(true)}>
+						Update Access Token
+					</Button>
+				)}
+
+				{(!githubService || isEditing) && (
+					<Form {...form}>
+						<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
+							<FormField
+								control={form.control}
+								name="githubToken"
+								render={({ field }) => (
+									<FormItem>
+										<FormControl>
+											<Input
+												type="password"
+												placeholder="GitHub Personal Access Token"
+												className="border border-black"
+												{...field}
+											/>
+										</FormControl>
+										<FormMessage />
+									</FormItem>
+								)}
+							/>
+							<div className="flex space-x-2">
+								<Button type="submit" disabled={isSaving}>
+									{isSaving ? "Saving..." : "Save"}
+								</Button>
+								{!!githubService && (
+									<Button type="button" variant="outline" onClick={handleCancel} disabled={isSaving}>
+										Cancel
+									</Button>
+								)}
+							</div>
+						</form>
+					</Form>
+				)}
 			</div>
 		</div>
 	);
diff --git a/apps/canvas/front/src/components/logs.tsx b/apps/canvas/front/src/components/logs.tsx
new file mode 100644
index 0000000..5918cf7
--- /dev/null
+++ b/apps/canvas/front/src/components/logs.tsx
@@ -0,0 +1,128 @@
+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 { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { useToast } from "@/hooks/use-toast";
+
+// ANSI escape sequence 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 (
+		<Card>
+			<CardHeader>
+				<Select value={selectedService} onValueChange={setSelectedService}>
+					<SelectTrigger>
+						<SelectValue placeholder="Select a service" />
+					</SelectTrigger>
+					<SelectContent>
+						{sortedServices.map((service) => (
+							<SelectItem key={service} value={service}>
+								{service}
+							</SelectItem>
+						))}
+					</SelectContent>
+				</Select>
+			</CardHeader>
+			<CardContent className="h-full">
+				{selectedService && (
+					<pre
+						ref={preRef}
+						className="p-4 bg-muted rounded-lg overflow-auto max-h-[500px] font-['JetBrains_Mono'] whitespace-pre-wrap break-all"
+					>
+						{cleanAnsiEscapeSequences(logs) || "No logs available"}
+					</pre>
+				)}
+			</CardContent>
+		</Card>
+	);
+}
diff --git a/apps/canvas/front/src/components/ui/card.tsx b/apps/canvas/front/src/components/ui/card.tsx
new file mode 100644
index 0000000..63ba966
--- /dev/null
+++ b/apps/canvas/front/src/components/ui/card.tsx
@@ -0,0 +1,43 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
+	<div ref={ref} className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} {...props} />
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
+	({ className, ...props }, ref) => (
+		<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
+	),
+);
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
+	({ className, ...props }, ref) => (
+		<div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
+	),
+);
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
+	({ className, ...props }, ref) => (
+		<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
+	),
+);
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
+	({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
+);
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
+	({ className, ...props }, ref) => (
+		<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
+	),
+);
+CardFooter.displayName = "CardFooter";
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 0b3507d..fb461c2 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -327,6 +327,7 @@
 	integrations: z.object({
 		github: z.boolean(),
 	}),
+	services: z.array(z.string()),
 });
 
 export type Env = z.infer<typeof envSchema>;
@@ -338,6 +339,7 @@
 	integrations: {
 		github: false,
 	},
+	services: [],
 };
 
 export type Project = {