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();
+	}
+}