diff --git a/.gitignore b/.gitignore index d7f950f9..a71262e0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ coverage/ *.tgz .worktrees/ + +.env.local diff --git a/src/observability/dashboard-server.ts b/src/observability/dashboard-server.ts index bf53c9fd..c4246a76 100644 --- a/src/observability/dashboard-server.ts +++ b/src/observability/dashboard-server.ts @@ -318,3 +318,258 @@ function writeMethodNotAllowed( allow, }); } + +function writeHtml( + response: ServerResponse, + statusCode: number, + html: string, +): void { + response.statusCode = statusCode; + response.setHeader("content-type", "text/html; charset=utf-8"); + response.setHeader("content-length", Buffer.byteLength(html)); + response.end(html); +} + +function writeNotFound(response: ServerResponse, path: string): void { + response.statusCode = 404; + response.setHeader("content-type", "text/plain; charset=utf-8"); + response.end(`Not found: ${path}`); +} + +async function readRequestBody(request: IncomingMessage): Promise { + await new Promise((resolve, reject) => { + request.on("error", reject); + request.on("end", resolve); + request.resume(); + }); +} + +async function withTimeout( + promise: Promise | T, + timeoutMs: number, + createError: () => Error, +): Promise { + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(createError()); + }, timeoutMs); + + Promise.resolve(promise).then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + +function isSnapshotTimeoutError(error: unknown): boolean { + return ( + error instanceof Error && + error.message.startsWith("Runtime snapshot timed out after ") + ); +} + +function renderDashboardHtml(snapshot: RuntimeSnapshot): string { + const runningRows = + snapshot.running.length === 0 + ? 'No active sessions.' + : snapshot.running + .map( + (row) => ` + + ${escapeHtml(row.issue_identifier)} + ${escapeHtml(row.state)} + ${escapeHtml(row.session_id ?? "-")} + ${row.turn_count} + ${escapeHtml(row.last_event ?? "-")} + ${escapeHtml(row.last_message ?? "-")} + ${escapeHtml(row.last_event_at ?? "-")} + `, + ) + .join(""); + + const retryRows = + snapshot.retrying.length === 0 + ? 'No queued retries.' + : snapshot.retrying + .map( + (row) => ` + + ${escapeHtml(row.issue_identifier ?? row.issue_id)} + ${row.attempt} + ${escapeHtml(row.due_at)} + ${escapeHtml(row.error ?? "-")} + `, + ) + .join(""); + + return ` + + + + + + Symphony Dashboard + + + +
+

Symphony Dashboard

+

Generated at ${escapeHtml(snapshot.generated_at)}

+ +
+
+
Running
+
${snapshot.counts.running}
+
+
+
Retrying
+
${snapshot.counts.retrying}
+
+
+
Input Tokens
+
${snapshot.codex_totals.input_tokens}
+
+
+
Output Tokens
+
${snapshot.codex_totals.output_tokens}
+
+
+
Total Tokens
+
${snapshot.codex_totals.total_tokens}
+
+
+
Seconds Running
+
${snapshot.codex_totals.seconds_running.toFixed(1)}
+
+
+ +
+

Running Sessions

+ + + + + + + + + + + + + ${runningRows} +
IssueStateSessionTurnsLast EventLast MessageLast Event At
+
+ +
+

Retry Queue

+ + + + + + + + + + ${retryRows} +
IssueAttemptDue AtError
+
+ +
+

Rate Limits

+
${escapeHtml(JSON.stringify(snapshot.rate_limits, null, 2) ?? "null")}
+
+
+ +`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} diff --git a/tests/observability/dashboard-server.test.ts b/tests/observability/dashboard-server.test.ts index 16d6965b..ec0009f7 100644 --- a/tests/observability/dashboard-server.test.ts +++ b/tests/observability/dashboard-server.test.ts @@ -36,6 +36,9 @@ describe("dashboard server", () => { expect(dashboard.headers["content-type"]).toContain("text/html"); expect(dashboard.body).toContain("Operations Dashboard"); expect(dashboard.body).toContain("ABC-123"); + expect(dashboard.body).toContain( + '', + ); expect(dashboard.body).toContain("Running sessions"); expect(dashboard.body).toContain("Runtime / turns"); expect(dashboard.body).toContain("Codex update");