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