Canvas: Render logs using XTerm

Use Server Sent Events to stream logs.

Change-Id: I3790a22a39b71409636a81dbe2a2cc8bf4977cb4
diff --git a/apps/canvas/back/src/project_monitor.ts b/apps/canvas/back/src/project_monitor.ts
index b494854..0f4fffe 100644
--- a/apps/canvas/back/src/project_monitor.ts
+++ b/apps/canvas/back/src/project_monitor.ts
@@ -1,5 +1,21 @@
 import { z } from "zod";
 
+const LogItemSchema = z.object({
+	runId: z.string(),
+	timestampMilli: z.number(),
+	commit: z.string().optional(),
+	contents: z.preprocess((val) => {
+		if (typeof val === "string") {
+			return Buffer.from(val, "base64").toString("utf-8");
+		}
+		throw new Error("Log item contents is not a string");
+	}, z.string()),
+});
+
+export type LogItem = z.infer<typeof LogItemSchema>;
+
+const LogItemsSchema = z.array(LogItemSchema);
+
 export const WorkerSchema = z.object({
 	id: z.string(),
 	service: z.string(),
@@ -22,22 +38,32 @@
 			),
 		}),
 	),
-	logs: z.optional(z.string()),
+	logs: LogItemsSchema.optional(),
 });
 
 export type Worker = z.infer<typeof WorkerSchema>;
 
 class ServiceMonitor {
 	private workers: Map<string, string> = new Map();
-	private logs: Map<string, string> = new Map();
+	private logs: Map<string, LogItem[]> = 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 {
+	registerWorker(
+		workerId: string,
+		workerAddress: string,
+		workerLog?: LogItem[],
+		workerStatus?: Worker["status"],
+	): void {
 		this.workers.set(workerId, workerAddress);
 		if (workerLog) {
-			this.logs.set(workerId, workerLog);
+			const existingLogs = this.logs.get(workerId);
+			if (existingLogs) {
+				existingLogs.push(...workerLog);
+			} else {
+				this.logs.set(workerId, workerLog);
+			}
 		}
 		if (workerStatus) {
 			this.statuses.set(workerId, workerStatus);
@@ -48,7 +74,7 @@
 		return this.workers.get(workerId);
 	}
 
-	getWorkerLog(workerId: string): string | undefined {
+	getWorkerLog(workerId: string): LogItem[] | undefined {
 		return this.logs.get(workerId);
 	}
 
@@ -56,7 +82,7 @@
 		return this.statuses.get(workerId);
 	}
 
-	getAllLogs(): Map<string, string> {
+	getAllLogs(): Map<string, LogItem[]> {
 		return new Map(this.logs);
 	}
 
@@ -118,7 +144,7 @@
 		return Array.from(new Set(allAddresses));
 	}
 
-	getWorkerLog(serviceName: string, workerId: string): string | undefined {
+	getWorkerLog(serviceName: string, workerId: string): LogItem[] | undefined {
 		const serviceMonitor = this.serviceMonitors.get(serviceName);
 		if (serviceMonitor) {
 			return serviceMonitor.getWorkerLog(workerId);