From 9dc882a82f45d662d8ec0dbd93c10ac8f7dc620c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 8 Sep 2026 01:26:33 +0200 Subject: [PATCH] feat(ui): frontend log file, written by the page and served by the server The browser console was the only place Atlas wrote to, and it dies with the tab: a reader who saw "get_code_snippet returned no source" had nothing to hand over, and an agent looking into it had nothing to read. The page now keeps a second copy of everything it logs, on the server, next to the daemon's own records. Server (src/ui/http_server.c): - POST /api/ui-log takes {page, session, entries[]} and appends one JSON line per entry to /logs/ui.log (received, page, session, seq, ts, level, source, message, detail, stack, url, line, col). Bounds: 64 KiB per post, 100 entries per post, 4096 characters per text field cut on a UTF-8 boundary and marked, the level normalised to a known word, every string escaped again on the way out. The file rotates once to ui.log.1 at CBM_UI_LOG_ROTATE_BYTES (default 5 MiB). Warnings and errors are mirrored into the ring GET /api/logs serves, so both sides of one failure sit in one list; the chatter stays in the file. The reply names the file and says whether it could be written. - GET /api/ui-log?lines=N tails the file (default 200, at most 1000, from the last 256 KiB) and names the file and its rotated predecessor; `partial` says when the lines do not start at the top. - cbm_ui_log_file_path() is the one place the path is built. Frontend (graph-ui): - app/ui-log.ts: a bounded buffer (200 entries, oldest dropped first and the loss named at the head of the next batch), batched posts (25 entries or 1.5 s), backoff on a refused post, never writes to the console itself. app/ui-log-transport.ts posts with fetch, and with a beacon when the page is going away. - app/ui-log-install.ts wraps the five console methods (they still print; the copy is taken after), listens to window error and unhandledrejection, and flushes on pagehide. Installed first in main.tsx, so nothing can fail before the copy exists. - provider/error-observer.ts is the seam for failures the panels catch and show as a sentence: the /rpc transport and the /api client announce every failed call there before throwing, with a level that tells a tool the server does not offer (info) from a tool that said no (warn) from a broken or unreachable server (error). The reader announces a file the index does not know. - The projects panel shows the tail under "This server" as sentences, names the file to attach to a report, and reloads it with the rest. - README (both) and INVENTAR.md name the file and the routes. Tests: three httpd cases (JSON lines and tail, refused bodies and dropped entries, rotation); vitest for the buffer, the wiring in jsdom, the transport, the seam through both clients, the API method, the model and the panel. Verified live against a real project: the reader's failure appears in the file and in the panel within two seconds. Signed-off-by: Martin Vogel --- README.md | 2 + graph-ui/INVENTAR.md | 9 +- graph-ui/README.md | 9 + graph-ui/src/App.tsx | 8 + graph-ui/src/app/atlas-api.test.ts | 15 + graph-ui/src/app/atlas-api.ts | 43 +- graph-ui/src/app/ui-log-install.test.ts | 198 ++++++++++ graph-ui/src/app/ui-log-install.ts | 224 +++++++++++ graph-ui/src/app/ui-log-transport.ts | 63 +++ graph-ui/src/app/ui-log.test.ts | 163 ++++++++ graph-ui/src/app/ui-log.ts | 269 +++++++++++++ graph-ui/src/i18n/messages.ts | 9 + graph-ui/src/main.tsx | 6 + graph-ui/src/projects/ProjectsPanel.test.tsx | 33 ++ graph-ui/src/projects/ProjectsPanel.tsx | 40 +- graph-ui/src/projects/projects-model.test.ts | 20 + graph-ui/src/projects/projects-model.ts | 53 +++ graph-ui/src/provider/error-observer.test.ts | 116 ++++++ graph-ui/src/provider/error-observer.ts | 63 +++ graph-ui/src/provider/rpc-transport.ts | 49 ++- src/ui/http_server.c | 396 +++++++++++++++++++ src/ui/http_server.h | 5 + tests/test_httpd.c | 212 ++++++++++ 23 files changed, 1996 insertions(+), 9 deletions(-) create mode 100644 graph-ui/src/app/ui-log-install.test.ts create mode 100644 graph-ui/src/app/ui-log-install.ts create mode 100644 graph-ui/src/app/ui-log-transport.ts create mode 100644 graph-ui/src/app/ui-log.test.ts create mode 100644 graph-ui/src/app/ui-log.ts create mode 100644 graph-ui/src/provider/error-observer.test.ts create mode 100644 graph-ui/src/provider/error-observer.ts diff --git a/README.md b/README.md index 2c5c2f6dd..923a7db47 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,8 @@ Open `http://localhost:9749` in your browser. The UI is owned by the shared coor Atlas is **CodeAtlasWeb**, a reading IDE built by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz) on the CBM read surface (design and tracking in #1964). It runs air-gapped on loopback, reaches no cloud, and every panel names where its facts come from; what the index did not record is shown as a gap, never filled in with a guess. +When something in Atlas does not load, the page has already written it down: its console output, uncaught errors and failed requests go to `${CBM_CACHE_DIR}/logs/ui.log` (default `~/.cache/codebase-memory-mcp/logs/ui.log`, one JSON line per entry, rotated once at 5 MiB). The projects panel (alt+p) shows the tail under "This server", `GET http://localhost:9749/api/ui-log?lines=200` returns it, and a bug report can attach the file. + - **Explorer and reader** — the indexed file tree and a read-only Monaco reader whose source comes from the index. - **Semantic twin** — follows the caret: what the symbol under it holds, calls, raises and touches, as facts or as pseudocode, pitched at who is reading. - **Reading modes** — `[w]hy am I here` picks a way in (hunt a bug, scope a change, understand the project, pick an entry point); **flow** walks the calls in the order they run; **bug hunt** compares the expected path into a symbol with what `ingest_traces` observed; **change scope** shows what a change reaches and what covers it. diff --git a/graph-ui/INVENTAR.md b/graph-ui/INVENTAR.md index 3abd1e295..9e2aa2d40 100644 --- a/graph-ui/INVENTAR.md +++ b/graph-ui/INVENTAR.md @@ -73,15 +73,18 @@ fuer W1; die daraus folgenden Luecken stehen in UPSTREAM-ASKS.md. (src/mcp/mcp.c:376-711) plus Alias trace_call_path (mcp.c:11564); drei Tool-Profile all/analysis/scout; tools/list paginiert (Seitengroesse 8). -## 3. /api-Routen (28 benannte Routen plus / und /assets/*) +## 3. /api-Routen (30 benannte Routen plus / und /assets/*) GET-Routen (src/ui/http_server.c:2545-2714): /__cbm/ui-readiness (HMAC-Challenge), /api/layout, /api/tree, /api/symbol-history, /api/who, /api/symbol, /api/metrics, /api/why, /api/handout, /api/blast, /api/bridges, /api/scent, /api/trace, /api/impact, /api/flows, /api/flow, /api/repo-info, /api/index-status, /api/ui-config, /api/browse, /api/adr, -/api/project-health, /api/processes, /api/logs. POST: /rpc, /api/index, -/api/adr. DELETE: /api/project. Dazu / und /assets/* aus dem Embedding. +/api/project-health, /api/processes, /api/logs, /api/ui-log (Schwanz der +Frontend-Logdatei). POST: /rpc, /api/index, /api/adr, /api/ui-log (das +Frontend schreibt Konsole, Fehler und gescheiterte Anfragen in +`/logs/ui.log`). DELETE: /api/project. Dazu / und /assets/* aus +dem Embedding. - /api/tree liefert den indexierten Datei-Baum aus dem Graphen (empirisch bestaetigt), NICHT das Dateisystem. - /api/browse ist ein Verzeichnis-Picker fuer die Index-Anlage (empirisch: diff --git a/graph-ui/README.md b/graph-ui/README.md index acf074a17..c5cd67d4e 100644 --- a/graph-ui/README.md +++ b/graph-ui/README.md @@ -20,6 +20,15 @@ What the maintainers added on top: check or remove an index, edit the decision record, read the server's processes and log. It is the one surface that asks the server to write, and it names every route it uses. +- **The frontend log** (`src/app/ui-log.ts`, installed in `src/main.tsx`): + the console keeps printing, and a copy of every console line, uncaught + error, unhandled rejection and failed `/rpc` or `/api` call is batched + to `POST /api/ui-log`, which the server appends as JSON lines to + `/logs/ui.log` (rotating once at 5 MiB). `GET /api/ui-log` + tails it, the projects panel shows it under "This server", and a bug + report attaches the file. The caught request failures reach the log + through `src/provider/error-observer.ts`, the seam the two clients + announce on before they throw. - **Gates in CI** (`.github/workflows/_test.yml`, job `test-ui`, which runs `scripts/ci/test-ui.sh`; run that script locally for the identical leg): `npm run test:unit`, `npm run check:style`, diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 33a2ce9e5..c297a65de 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -156,6 +156,7 @@ import { CbmRpcProvider, symbolKindOf } from './provider/cbm-rpc-provider'; import MonacoReader from './reader/MonacoReader'; import type { ReaderStatus } from './reader/MonacoReader'; import { FileNotReadableError, loadFileDocument, READER_RPC_TOOL } from './reader/file-source'; +import { reportError } from './provider/error-observer'; import type { ReaderDocument } from './reader/file-source'; import { badgesForLines } from './core/step-badge-decorator'; import type { SymbolRef } from './core/focus-protocol'; @@ -756,6 +757,7 @@ export default function App(): JSX.Element { saveAdr: (name, content) => api.saveAdr(name, content), logs: (lines) => api.logs(lines), processes: () => api.processes(), + uiLogTail: (lines) => api.uiLogTail(lines), }), [client, api], ); @@ -1758,6 +1760,12 @@ export default function App(): JSX.Element { setDocument(undefined); setReaderStatus(error instanceof FileNotReadableError ? 'unavailable' : 'failed'); setReaderMessage(error instanceof Error ? error.message : String(error)); + // The reader shows the sentence; the frontend log keeps it + // (the /rpc failures underneath announce themselves, a file + // the index does not know does not). + if (error instanceof FileNotReadableError) { + reportError({ source: 'reader', level: 'warn', message: error.message }); + } }); }, [client, project], diff --git a/graph-ui/src/app/atlas-api.test.ts b/graph-ui/src/app/atlas-api.test.ts index cd22c5af0..ce37ed864 100644 --- a/graph-ui/src/app/atlas-api.test.ts +++ b/graph-ui/src/app/atlas-api.test.ts @@ -94,6 +94,21 @@ describe('AtlasApi, the projects routes', () => { expect(processes.calls[0]?.url).toBe('http://127.0.0.1:9749/api/processes'); }); + it('reads the frontend log tail with its file', async () => { + const { api: client, calls } = api({ + body: '{"path":"/c/logs/ui.log","previous_path":"/c/logs/ui.log.1","size_bytes":77,"partial":true,"lines":["{}"],"total":3}', + }); + expect(await client.uiLogTail(50)).toEqual({ + path: '/c/logs/ui.log', + previousPath: '/c/logs/ui.log.1', + sizeBytes: 77, + partial: true, + lines: ['{}'], + total: 3, + }); + expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/ui-log?lines=50'); + }); + it('carries the status of a refusal, so busy can be told from forbidden', async () => { const { api: client } = api({ ok: false, status: 423, body: '{"error":"project is busy; retry after indexing"}' }); await expect(client.saveAdr('p', 'x')).rejects.toSatisfy((error: unknown) => diff --git a/graph-ui/src/app/atlas-api.ts b/graph-ui/src/app/atlas-api.ts index 8bd39986e..c7d9d9118 100644 --- a/graph-ui/src/app/atlas-api.ts +++ b/graph-ui/src/app/atlas-api.ts @@ -13,6 +13,7 @@ * `base` ist darum nur fuer Node-Aufrufer da, die keinen Origin senden. */ +import { hasErrorObservers, reportError } from '../provider/error-observer'; import { readTreeLevel } from './tree-model'; import type { TreeLevel } from './tree-model'; import { @@ -29,6 +30,7 @@ import { readIndexStarted, readLogs, readProcesses, + readUiLogTail, } from '../projects/projects-model'; import type { AdrRecord, @@ -38,6 +40,7 @@ import type { LogTail, ProcessReport, ProjectHealth, + UiLogTail, } from '../projects/projects-model'; export interface AtlasApiOptions { @@ -70,6 +73,25 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * Announce a failed /api call before it is thrown (provider/error-observer.ts), + * so the frontend log holds it even though the panel catches it and shows a + * sentence instead. A 4xx is the server saying no to this request (warn); + * no answer, a 5xx or an unreadable body is a failure (error). + */ +function announce(error: AtlasApiError, detail?: string): AtlasApiError { + if (hasErrorObservers()) { + const refused = error.status >= 400 && error.status < 500; + reportError({ + source: 'api', + level: refused ? 'warn' : 'error', + message: error.message, + detail, + }); + } + return error; +} + const text = (value: unknown): string => (typeof value === 'string' ? value : ''); export class AtlasApi { @@ -99,16 +121,22 @@ export class AtlasApi { response = await doFetch(url, init); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new AtlasApiError(route, 0, `${route} war nicht erreichbar: ${message}`); + throw announce(new AtlasApiError(route, 0, `${route} war nicht erreichbar: ${message}`)); } const body = await response.text(); if (!response.ok) { - throw new AtlasApiError(route, response.status, `${route} antwortete mit HTTP ${response.status}: ${body.slice(0, 200)}`); + throw announce( + new AtlasApiError(route, response.status, `${route} antwortete mit HTTP ${response.status}: ${body.slice(0, 200)}`), + body.slice(0, 400), + ); } try { return JSON.parse(body) as unknown; } catch { - throw new AtlasApiError(route, response.status, `${route} lieferte kein JSON: ${body.slice(0, 200)}`); + throw announce( + new AtlasApiError(route, response.status, `${route} lieferte kein JSON: ${body.slice(0, 200)}`), + body.slice(0, 400), + ); } } @@ -206,6 +234,15 @@ export class AtlasApi { return readProcesses(await this.getJson('/api/processes')); } + /** + * The tail of the frontend's own log file on the server, the one this + * page writes through POST /api/ui-log (app/ui-log.ts). The answer names + * the file, so the panel can tell a reader what to attach to a report. + */ + async uiLogTail(lines: number): Promise { + return readUiLogTail(await this.getJson(`/api/ui-log?${new URLSearchParams({ lines: String(lines) }).toString()}`)); + } + /** Wurzelpfad, Branch und Remote eines Projekts. Leere Felder bleiben leer. */ async repoInfo(project: string): Promise { const raw = await this.getJson(`/api/repo-info?${new URLSearchParams({ project }).toString()}`); diff --git a/graph-ui/src/app/ui-log-install.test.ts b/graph-ui/src/app/ui-log-install.test.ts new file mode 100644 index 000000000..908b7a8f1 --- /dev/null +++ b/graph-ui/src/app/ui-log-install.test.ts @@ -0,0 +1,198 @@ +// @vitest-environment jsdom +/* + * The frontend log wired into a page: the console still prints, and what it + * printed is recorded; uncaught errors, rejected promises and the failures + * the clients announce all reach the buffer; uninstall puts the console + * back. The transport is a recorder, so nothing here touches a network. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { reportError } from '../provider/error-observer'; +import { UiLogBuffer } from './ui-log'; +import type { UiLogPayload } from './ui-log'; +import { describeArgs, installUiLog, safeStringify } from './ui-log-install'; +import type { UiLogHandle } from './ui-log-install'; +import { httpUiLogTransport } from './ui-log-transport'; + +let handle: UiLogHandle | undefined; + +afterEach(() => { + handle?.uninstall(); + handle = undefined; +}); + +function install() { + const posts: { payload: UiLogPayload; final: boolean }[] = []; + const buffer = new UiLogBuffer({ + page: '/?project=demo', + session: 'test', + transport: { + send: async (payload, final) => { + posts.push({ payload, final }); + return true; + }, + }, + schedule: () => 0, + cancel: () => undefined, + }); + const printed: unknown[][] = []; + const fakeConsole = { + debug: (...args: unknown[]) => printed.push(['debug', ...args]), + log: (...args: unknown[]) => printed.push(['log', ...args]), + info: (...args: unknown[]) => printed.push(['info', ...args]), + warn: (...args: unknown[]) => printed.push(['warn', ...args]), + error: (...args: unknown[]) => printed.push(['error', ...args]), + } as unknown as Console; + handle = installUiLog({ buffer, console: fakeConsole, session: 'test' }); + return { buffer, posts, printed, fakeConsole }; +} + +async function entries(buffer: UiLogBuffer, posts: { payload: UiLogPayload }[]) { + await buffer.flush(); + return posts.flatMap((post) => post.payload.entries); +} + +describe('installUiLog', () => { + it('starts the session with a line that names page, build and browser', async () => { + const { buffer, posts } = install(); + const all = await entries(buffer, posts); + expect(all[0]?.source).toBe('ui-log'); + expect(all[0]?.message).toBe('session test started on /'); + expect(all[0]?.detail).toContain('build v'); + }); + + it('lets the console print as before and records what it printed', async () => { + const { buffer, posts, printed, fakeConsole } = install(); + const failure = new Error('twin failed'); + fakeConsole.error('reader', failure); + fakeConsole.warn('slow frame', { ms: 41 }); + fakeConsole.log('plain'); + expect(printed).toEqual([['error', 'reader', failure], ['warn', 'slow frame', { ms: 41 }], ['log', 'plain']]); + + const all = await entries(buffer, posts); + const fromConsole = all.filter((entry) => entry.source === 'console'); + expect(fromConsole.map((entry) => [entry.level, entry.message])).toEqual([ + ['error', 'reader Error: twin failed'], + ['warn', 'slow frame {"ms":41}'], + ['log', 'plain'], + ]); + expect(fromConsole[0]?.stack).toContain('twin failed'); + }); + + it('records an uncaught error with its file, line and column', async () => { + const { buffer, posts } = install(); + window.dispatchEvent(new ErrorEvent('error', { + message: 'Uncaught TypeError: x is not a function', + filename: 'http://127.0.0.1:9749/assets/index.js', + lineno: 12, + colno: 7, + error: new TypeError('x is not a function'), + })); + const all = await entries(buffer, posts); + const uncaught = all.find((entry) => entry.source === 'window'); + expect(uncaught?.level).toBe('error'); + expect(uncaught?.message).toBe('Uncaught TypeError: x is not a function'); + expect(uncaught?.url).toBe('http://127.0.0.1:9749/assets/index.js'); + expect(uncaught?.line).toBe(12); + expect(uncaught?.col).toBe(7); + expect(uncaught?.stack).toContain('x is not a function'); + }); + + it('records an unhandled rejection', async () => { + const { buffer, posts } = install(); + const event = new Event('unhandledrejection') as Event & { reason: unknown }; + event.reason = new Error('nobody caught this'); + window.dispatchEvent(event); + const all = await entries(buffer, posts); + const rejected = all.find((entry) => entry.source === 'promise'); + expect(rejected?.level).toBe('error'); + expect(rejected?.message).toBe('Error: nobody caught this'); + }); + + it('records what the clients announce through the observer seam', async () => { + const { buffer, posts } = install(); + reportError({ source: 'rpc', level: 'error', message: '/rpc get_code_snippet: HTTP 500', detail: 'oom' }); + const all = await entries(buffer, posts); + const announced = all.find((entry) => entry.source === 'rpc'); + expect(announced?.message).toBe('/rpc get_code_snippet: HTTP 500'); + expect(announced?.detail).toBe('oom'); + }); + + it('flushes with final when the page hides', () => { + const { buffer } = install(); + const flush = vi.spyOn(buffer, 'flush'); + window.dispatchEvent(new Event('pagehide')); + expect(flush).toHaveBeenCalledWith(true); + }); + + it('puts the console back on uninstall and stops listening', async () => { + const { buffer, posts, printed, fakeConsole } = install(); + const wrapped = fakeConsole.log; + handle?.uninstall(); + handle = undefined; + expect(fakeConsole.log).not.toBe(wrapped); + fakeConsole.log('after'); + reportError({ source: 'api', level: 'warn', message: 'late' }); + expect(printed[printed.length - 1]).toEqual(['log', 'after']); + const all = await entries(buffer, posts); + expect(all.some((entry) => entry.message === 'after')).toBe(false); + expect(all.some((entry) => entry.message === 'late')).toBe(false); + }); +}); + +describe('describeArgs and safeStringify', () => { + it('renders strings as they are, errors with their name, objects as JSON with cycles held', () => { + const loop: Record = { name: 'loop' }; + loop['self'] = loop; + expect(describeArgs(['a', 1, true, null, undefined, loop]).message) + .toBe('a 1 true null undefined {"name":"loop","self":"[circular]"}'); + expect(safeStringify(10n)).toBe('10n'); + expect(safeStringify(() => 1)).toBe('[function anonymous]'); + // A string is passed through as it is; the field cap applies later in + // the buffer. An object is cut here, so one huge argument cannot + // crowd out the rest of the line. + expect(safeStringify('x'.repeat(2500)).length).toBe(2500); + expect(safeStringify({ s: 'x'.repeat(2500) }).length).toBeLessThan(2100); + }); +}); + +describe('httpUiLogTransport', () => { + it('posts JSON to /api/ui-log and reports the server verdict', async () => { + const calls: { url: string; init: RequestInit | undefined }[] = []; + const transport = httpUiLogTransport({ + base: 'http://127.0.0.1:9749', + fetch: ((url: string, init?: RequestInit) => { + calls.push({ url, init }); + return Promise.resolve({ ok: true } as Response); + }) as unknown as typeof globalThis.fetch, + }); + const payload: UiLogPayload = { page: '/', session: 's', entries: [] }; + expect(await transport.send(payload, false)).toBe(true); + expect(calls[0]?.url).toBe('http://127.0.0.1:9749/api/ui-log'); + expect(calls[0]?.init?.method).toBe('POST'); + expect((calls[0]?.init?.headers as Record)['Content-Type']).toBe('application/json'); + expect(calls[0]?.init?.body).toBe(JSON.stringify(payload)); + expect(calls[0]?.init?.keepalive).toBe(false); + }); + + it('uses the beacon for a final batch and never throws', async () => { + const beacons: { url: string; type: string }[] = []; + const transport = httpUiLogTransport({ + beacon: (url, body) => { + beacons.push({ url, type: body.type }); + return true; + }, + fetch: (() => { + throw new Error('must not be used for a final batch'); + }) as unknown as typeof globalThis.fetch, + }); + expect(await transport.send({ page: '/', session: 's', entries: [] }, true)).toBe(true); + expect(beacons).toEqual([{ url: '/api/ui-log', type: 'application/json' }]); + + const failing = httpUiLogTransport({ + fetch: (() => Promise.reject(new Error('down'))) as unknown as typeof globalThis.fetch, + }); + expect(await failing.send({ page: '/', session: 's', entries: [] }, false)).toBe(false); + }); +}); diff --git a/graph-ui/src/app/ui-log-install.ts b/graph-ui/src/app/ui-log-install.ts new file mode 100644 index 000000000..2377df058 --- /dev/null +++ b/graph-ui/src/app/ui-log-install.ts @@ -0,0 +1,224 @@ +/** + * Wiring the frontend log into a page: console, window, promises, clients. + * + * Four feeds, and each one is a place where an error would otherwise stop: + * + * 1. **The console.** Every console method still prints, exactly as before; + * the wrapper calls the original first and records second. Arguments + * are rendered the way a reader would want them in a file: strings as + * they are, an Error as name, message and stack, anything else as JSON + * with cycles and length held in check. + * 2. **Uncaught errors** (`window.onerror` as the `error` event), with file, + * line and column when the browser has them. + * 3. **Unhandled promise rejections.** + * 4. **Caught request failures**, announced by the /rpc and /api clients + * through the observer seam (provider/error-observer.ts). These are the + * ones the panels show as a sentence and the console never sees. + * + * The buffer is flushed with `final` on pagehide and when the tab is hidden, + * so the last lines of a session reach the file (ui-log-transport.ts uses a + * beacon for that). A re-entrancy guard makes sure nothing recorded from + * inside a record call comes back around. + * + * The first entry of a session says which page, which build and which + * browser, because a log file that holds several sessions needs a line + * where each one starts. + */ + +import { observeErrors } from '../provider/error-observer'; +import { UiLogBuffer } from './ui-log'; +import type { UiLogLevel, UiLogTransport } from './ui-log'; +import { httpUiLogTransport } from './ui-log-transport'; +import { ATLAS_BUILD_SUFFIX, ATLAS_VERSION } from './build-info'; + +export interface InstallUiLogOptions { + target?: Window; + console?: Console; + transport?: UiLogTransport; + buffer?: UiLogBuffer; + session?: string; +} + +export interface UiLogHandle { + buffer: UiLogBuffer; + session: string; + uninstall(): void; +} + +const LEVELS: readonly UiLogLevel[] = ['debug', 'log', 'info', 'warn', 'error']; + +/** The cap on a rendered console argument; the field cap applies after. */ +const ARG_MAX = 2000; + +/** JSON for a console argument that is not a string or an Error. */ +export function safeStringify(value: unknown, max = ARG_MAX): string { + if (typeof value === 'string') { + return value; + } + if (typeof value === 'bigint') { + return `${value.toString()}n`; + } + if (typeof value === 'function') { + return `[function ${value.name || 'anonymous'}]`; + } + if (typeof value === 'symbol') { + return value.toString(); + } + if (value === undefined) { + return 'undefined'; + } + const seen = new WeakSet(); + let text: string; + try { + text = JSON.stringify(value, (_key, entry: unknown) => { + if (typeof entry === 'bigint') { + return `${entry.toString()}n`; + } + if (typeof entry === 'object' && entry !== null) { + if (seen.has(entry)) { + return '[circular]'; + } + seen.add(entry); + } + return entry; + }) ?? String(value); + } catch { + text = String(value); + } + return text.length > max ? `${text.slice(0, max)} [cut at ${max}]` : text; +} + +/** One line for a console call, plus the first stack among its arguments. */ +export function describeArgs(args: readonly unknown[]): { message: string; stack?: string } { + const parts: string[] = []; + let stack: string | undefined; + for (const arg of args) { + if (arg instanceof Error) { + parts.push(`${arg.name}: ${arg.message}`); + if (stack === undefined && typeof arg.stack === 'string' && arg.stack.length > 0) { + stack = arg.stack; + } + } else { + parts.push(safeStringify(arg)); + } + } + const message = parts.join(' '); + return stack === undefined ? { message } : { message, stack }; +} + +/** What a rejected promise rejected with, as message and stack. */ +export function describeReason(reason: unknown): { message: string; stack?: string } { + if (reason instanceof Error) { + const stack = typeof reason.stack === 'string' && reason.stack.length > 0 ? reason.stack : undefined; + const message = `${reason.name}: ${reason.message}`; + return stack === undefined ? { message } : { message, stack }; + } + return { message: safeStringify(reason) }; +} + +function newSessionId(): string { + const cryptoApi = (globalThis as { crypto?: Crypto }).crypto; + if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') { + return cryptoApi.randomUUID().slice(0, 8); + } + return Math.random().toString(16).slice(2, 10); +} + +export function installUiLog(options: InstallUiLogOptions = {}): UiLogHandle { + const target = options.target ?? window; + const con = options.console ?? console; + const session = options.session ?? newSessionId(); + const page = `${target.location.pathname}${target.location.search}`; + const buffer = options.buffer + ?? new UiLogBuffer({ page, session, transport: options.transport ?? httpUiLogTransport() }); + + let recording = false; + const guarded = (fn: () => void): void => { + if (recording) { + return; + } + recording = true; + try { + fn(); + } catch { + /* Recording must never break the call that was being recorded. */ + } finally { + recording = false; + } + }; + + const originals = new Map void>(); + for (const level of LEVELS) { + const original = con[level] as (...args: unknown[]) => void; + originals.set(level, original); + con[level] = (...args: unknown[]): void => { + original.apply(con, args); + guarded(() => { + const described = describeArgs(args); + buffer.record(level, 'console', described.message, + described.stack === undefined ? {} : { stack: described.stack }); + }); + }; + } + + const onError = (event: ErrorEvent): void => { + guarded(() => { + const described = describeReason(event.error); + buffer.record('error', 'window', event.message.length > 0 ? event.message : described.message, { + stack: described.stack, + url: event.filename, + line: event.lineno, + col: event.colno, + }); + }); + }; + const onRejection = (event: PromiseRejectionEvent): void => { + guarded(() => { + const described = describeReason(event.reason); + buffer.record('error', 'promise', described.message, + described.stack === undefined ? {} : { stack: described.stack }); + }); + }; + const onHide = (): void => { + void buffer.flush(true); + }; + const onVisibility = (): void => { + if (target.document.visibilityState === 'hidden') { + void buffer.flush(true); + } + }; + const stopObserving = observeErrors((report) => { + guarded(() => { + buffer.record(report.level, report.source, report.message, { + detail: report.detail, + stack: report.stack, + }); + }); + }); + + target.addEventListener('error', onError); + target.addEventListener('unhandledrejection', onRejection); + target.addEventListener('pagehide', onHide); + target.document.addEventListener('visibilitychange', onVisibility); + + const userAgent = (target.navigator as Navigator | undefined)?.userAgent ?? ''; + buffer.record('info', 'ui-log', `session ${session} started on ${page}`, { + detail: `build ${ATLAS_VERSION}${ATLAS_BUILD_SUFFIX.length > 0 ? `-${ATLAS_BUILD_SUFFIX}` : ''}; ${userAgent}`, + url: target.location.href, + }); + + return { + buffer, + session, + uninstall(): void { + for (const [level, original] of originals) { + con[level] = original; + } + target.removeEventListener('error', onError); + target.removeEventListener('unhandledrejection', onRejection); + target.removeEventListener('pagehide', onHide); + target.document.removeEventListener('visibilitychange', onVisibility); + stopObserving(); + }, + }; +} diff --git a/graph-ui/src/app/ui-log-transport.ts b/graph-ui/src/app/ui-log-transport.ts new file mode 100644 index 000000000..fcd25bed9 --- /dev/null +++ b/graph-ui/src/app/ui-log-transport.ts @@ -0,0 +1,63 @@ +/** + * The HTTP leg of the frontend log: POST /api/ui-log, same origin. + * + * Two ways to send, chosen by the buffer: an ordinary fetch while the page + * lives, and `navigator.sendBeacon` when it is being closed, because a fetch + * started from pagehide is cancelled with the page while a beacon is handed + * to the browser and survives it. Both carry `application/json`, which the + * server requires on every POST under /api (the same header the projects + * panel sends); the beacon sets it through the Blob's type. + * + * No error is thrown from here and nothing is written to the console: a + * transport that logged its own failures would feed the buffer it serves. + */ + +import { UI_LOG_ROUTE } from './ui-log'; +import type { UiLogPayload, UiLogTransport } from './ui-log'; + +export interface HttpUiLogTransportOptions { + /** Origin of the server, no trailing slash. Empty means same origin. */ + base?: string; + fetch?: typeof globalThis.fetch; + /** Replaceable beacon so a test sees what the page sends on its way out. */ + beacon?: (url: string, body: Blob) => boolean; +} + +function defaultBeacon(): ((url: string, body: Blob) => boolean) | undefined { + const nav = (globalThis as { navigator?: Navigator }).navigator; + if (nav === undefined || typeof nav.sendBeacon !== 'function') { + return undefined; + } + return (url, body) => nav.sendBeacon(url, body); +} + +export function httpUiLogTransport(options: HttpUiLogTransportOptions = {}): UiLogTransport { + const url = `${options.base ?? ''}${UI_LOG_ROUTE}`; + return { + async send(payload: UiLogPayload, final: boolean): Promise { + const body = JSON.stringify(payload); + if (final) { + const beacon = options.beacon ?? defaultBeacon(); + if (beacon !== undefined) { + try { + return beacon(url, new Blob([body], { type: 'application/json' })); + } catch { + return false; + } + } + } + const doFetch = options.fetch ?? globalThis.fetch; + try { + const response = await doFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: final, + }); + return response.ok; + } catch { + return false; + } + }, + }; +} diff --git a/graph-ui/src/app/ui-log.test.ts b/graph-ui/src/app/ui-log.test.ts new file mode 100644 index 000000000..31484a14d --- /dev/null +++ b/graph-ui/src/app/ui-log.test.ts @@ -0,0 +1,163 @@ +/* + * The frontend log buffer: what it batches, what it caps, what it drops and + * says it dropped, and how it behaves while the server does not answer. + * Timers and the clock are handed in, so nothing here waits. + */ + +import { describe, expect, it } from 'vitest'; + +import { capField, UI_LOG_FIELD_MAX, UiLogBuffer } from './ui-log'; +import type { UiLogPayload, UiLogTransport } from './ui-log'; + +interface Timer { + fn: () => void; + ms: number; + cancelled: boolean; +} + +function harness(answers: () => boolean = () => true) { + const posts: { payload: UiLogPayload; final: boolean }[] = []; + const timers: Timer[] = []; + const transport: UiLogTransport = { + send: async (payload, final) => { + posts.push({ payload, final }); + return answers(); + }, + }; + let tick = 0; + const buffer = new UiLogBuffer({ + page: '/?project=demo', + session: 'abc', + transport, + now: () => new Date(Date.UTC(2026, 8, 8, 10, 0, tick++)), + schedule: (fn, ms) => { + const timer: Timer = { fn, ms, cancelled: false }; + timers.push(timer); + return timer; + }, + cancel: (handle) => { + (handle as Timer).cancelled = true; + }, + bufferMax: 5, + batchMax: 3, + }); + /** Fire the newest live timer, as the browser would when its time comes. */ + const fire = async (): Promise => { + const live = timers.filter((timer) => !timer.cancelled); + const timer = live[live.length - 1]; + if (timer === undefined) { + throw new Error('no timer armed'); + } + timer.cancelled = true; + timer.fn(); + await Promise.resolve(); + await Promise.resolve(); + }; + return { buffer, posts, timers, fire }; +} + +describe('UiLogBuffer', () => { + it('queues entries with a sequence and a timestamp, and posts them as one batch on the timer', async () => { + const { buffer, posts, timers, fire } = harness(); + buffer.record('error', 'rpc', 'get_code_snippet returned no source', { detail: 'HTTP 200' }); + buffer.record('log', 'console', 'galaxy ready'); + expect(timers.length).toBe(1); + expect(timers[0]?.ms).toBe(1500); + expect(posts.length).toBe(0); + + await fire(); + expect(posts.length).toBe(1); + expect(posts[0]?.final).toBe(false); + expect(posts[0]?.payload.page).toBe('/?project=demo'); + expect(posts[0]?.payload.session).toBe('abc'); + expect(posts[0]?.payload.entries).toEqual([ + { + ts: '2026-09-08T10:00:00.000Z', + seq: 1, + level: 'error', + source: 'rpc', + message: 'get_code_snippet returned no source', + detail: 'HTTP 200', + }, + { ts: '2026-09-08T10:00:01.000Z', seq: 2, level: 'log', source: 'console', message: 'galaxy ready' }, + ]); + expect(buffer.stats()).toEqual({ queued: 0, sent: 2, dropped: 0, failedPosts: 0 }); + }); + + it('sends more than one batch when the queue is longer than a batch', async () => { + const { buffer, posts, fire } = harness(); + for (let i = 0; i < 5; i++) { + buffer.record('info', 'console', `line ${i}`); + } + await fire(); + expect(posts.length).toBe(1); + expect(posts[0]?.payload.entries.map((entry) => entry.message)).toEqual(['line 0', 'line 1', 'line 2']); + await fire(); + expect(posts.length).toBe(2); + expect(posts[1]?.payload.entries.map((entry) => entry.message)).toEqual(['line 3', 'line 4']); + }); + + it('caps every text field at the shared limit and marks the cut', () => { + const long = 'x'.repeat(UI_LOG_FIELD_MAX + 10); + expect(capField(long)).toBe(`${'x'.repeat(UI_LOG_FIELD_MAX)} [cut at ${UI_LOG_FIELD_MAX}]`); + expect(capField('short')).toBe('short'); + }); + + it('drops the oldest beyond the cap and says so at the head of the next batch', async () => { + const { buffer, posts, fire } = harness(); + for (let i = 0; i < 7; i++) { + buffer.record('info', 'console', `line ${i}`); + } + expect(buffer.stats().dropped).toBe(2); + await fire(); + const first = posts[0]?.payload.entries[0]; + expect(first?.level).toBe('warn'); + expect(first?.source).toBe('ui-log'); + expect(first?.message).toContain('2 entries were dropped'); + expect(posts[0]?.payload.entries.slice(1).map((entry) => entry.message)).toEqual(['line 2', 'line 3', 'line 4']); + }); + + it('keeps a refused batch, backs off, and resends it in order when the server answers', async () => { + let ok = false; + const { buffer, posts, timers, fire } = harness(() => ok); + buffer.record('error', 'window', 'boom'); + await fire(); + expect(posts.length).toBe(1); + expect(buffer.stats()).toEqual({ queued: 1, sent: 0, dropped: 0, failedPosts: 1 }); + const retry = timers[timers.length - 1]; + expect(retry?.ms).toBe(2000); + + buffer.record('log', 'console', 'after'); + await fire(); + expect(posts.length).toBe(2); + expect(timers[timers.length - 1]?.ms).toBe(5000); + + ok = true; + await fire(); + expect(posts.length).toBe(3); + expect(posts[2]?.payload.entries.map((entry) => entry.message)).toEqual(['boom', 'after']); + expect(buffer.stats()).toEqual({ queued: 0, sent: 2, dropped: 0, failedPosts: 2 }); + }); + + it('sends the rest with final on flush(true) and arms nothing after it', async () => { + const { buffer, posts, timers } = harness(); + buffer.record('warn', 'api', '/api/tree answered with HTTP 500'); + await buffer.flush(true); + expect(posts.length).toBe(1); + expect(posts[0]?.final).toBe(true); + expect(timers.every((timer) => timer.cancelled)).toBe(true); + }); + + it('does not throw when the transport does', async () => { + const throwing: UiLogTransport = { + send: async () => { + throw new Error('no network'); + }, + }; + const buffer = new UiLogBuffer({ page: '/', session: 's', transport: throwing, schedule: () => 0, cancel: () => undefined }); + buffer.record('info', 'console', 'x'); + await expect(buffer.flush()).resolves.toBeUndefined(); + expect(buffer.stats().failedPosts).toBe(1); + expect(buffer.stats().queued).toBe(1); + }); +}); diff --git a/graph-ui/src/app/ui-log.ts b/graph-ui/src/app/ui-log.ts new file mode 100644 index 000000000..047cc0e7b --- /dev/null +++ b/graph-ui/src/app/ui-log.ts @@ -0,0 +1,269 @@ +/** + * The frontend's log, on its way to the server. + * + * The browser console is where this window has always written, and it stays + * that way: the console is the reader's view. But the console lives and dies + * with the tab. Nothing there reaches the process that serves the page, so a + * reader who saw a panel fail had nothing to hand over, and an agent looking + * into the failure had nothing to read. This buffer is the second copy: every + * console call, every uncaught error, every rejected promise and every failed + * /rpc or /api request is queued here and posted to POST /api/ui-log, which + * appends it to /logs/ui.log on the server (src/ui/http_server.c). + * The file is what a bug report attaches and what `tail -f` reads. + * + * The rules of the buffer, and why each is there: + * + * - **Batched, not chatty.** Entries wait up to UI_LOG_FLUSH_MS and go out + * in groups of at most UI_LOG_BATCH_MAX. A page that logs once per frame + * must not turn into a request per frame. + * - **Bounded.** UI_LOG_BUFFER_MAX entries wait while the server does not + * answer; the oldest are dropped first, and the next batch that gets + * through starts with a line that says how many were lost. A log that + * silently forgets is worse than one that says it forgot. + * - **Backs off.** A failed post is retried on a growing delay + * (UI_LOG_BACKOFF_MS). An unreachable server is a fact the reader sees in + * the header chip; hammering it from here would add nothing. + * - **Never loud.** Nothing in this file writes to the console. The console + * wrapper (ui-log-install.ts) feeds this buffer, so a line written from + * here would come straight back. + * - **Capped fields.** Every text field is cut at UI_LOG_FIELD_MAX + * characters, the same cap the server applies; a cut is marked so nobody + * reads a truncated stack as a complete one. + * + * Time and timers are injectable so a test runs in one tick. + */ + +export type UiLogLevel = 'debug' | 'log' | 'info' | 'warn' | 'error'; + +export interface UiLogEntry { + /** When the entry was recorded on the page, ISO 8601. */ + ts: string; + /** Position in this page's sequence, from 1. Gaps mean dropped entries. */ + seq: number; + level: UiLogLevel; + /** Who recorded it: console, window, promise, rpc, api, reader, ui-log. */ + source: string; + message: string; + detail?: string; + stack?: string; + url?: string; + line?: number; + col?: number; +} + +export interface UiLogPayload { + page: string; + session: string; + entries: UiLogEntry[]; +} + +export interface UiLogTransport { + /** + * Deliver one batch. `final` means the page is going away and the answer + * will not be waited for. Resolves true when the server accepted it. + */ + send(payload: UiLogPayload, final: boolean): Promise; +} + +export interface UiLogExtra { + detail?: string; + stack?: string; + url?: string; + line?: number; + col?: number; +} + +export interface UiLogStats { + queued: number; + sent: number; + dropped: number; + failedPosts: number; +} + +/** The route the transport posts to; the server tails the same path on GET. */ +export const UI_LOG_ROUTE = '/api/ui-log'; +/** The cap per text field, the server's as well. */ +export const UI_LOG_FIELD_MAX = 4096; +export const UI_LOG_BATCH_MAX = 25; +export const UI_LOG_FLUSH_MS = 1500; +export const UI_LOG_BUFFER_MAX = 200; +export const UI_LOG_BACKOFF_MS: readonly number[] = [2000, 5000, 15000, 60000]; + +export interface UiLogOptions { + page: string; + session: string; + transport: UiLogTransport; + now?: () => Date; + schedule?: (fn: () => void, ms: number) => unknown; + cancel?: (handle: unknown) => void; + flushMs?: number; + batchMax?: number; + bufferMax?: number; + backoffMs?: readonly number[]; +} + +/** Cut a text field at the shared cap and say so. */ +export function capField(text: string, max = UI_LOG_FIELD_MAX): string { + return text.length > max ? `${text.slice(0, max)} [cut at ${max}]` : text; +} + +export class UiLogBuffer { + private readonly queue: UiLogEntry[] = []; + private seq = 0; + private sent = 0; + private dropped = 0; + private droppedSinceFlush = 0; + private failedPosts = 0; + private timer: unknown = undefined; + private inFlight = false; + private backoffStep = 0; + + private readonly now: () => Date; + private readonly schedule: (fn: () => void, ms: number) => unknown; + private readonly cancel: (handle: unknown) => void; + private readonly flushMs: number; + private readonly batchMax: number; + private readonly bufferMax: number; + private readonly backoffMs: readonly number[]; + + constructor(private readonly options: UiLogOptions) { + this.now = options.now ?? (() => new Date()); + this.schedule = options.schedule ?? ((fn, ms) => setTimeout(fn, ms)); + this.cancel = options.cancel ?? ((handle) => clearTimeout(handle as ReturnType)); + this.flushMs = options.flushMs ?? UI_LOG_FLUSH_MS; + this.batchMax = options.batchMax ?? UI_LOG_BATCH_MAX; + this.bufferMax = options.bufferMax ?? UI_LOG_BUFFER_MAX; + this.backoffMs = options.backoffMs ?? UI_LOG_BACKOFF_MS; + } + + get session(): string { + return this.options.session; + } + + get page(): string { + return this.options.page; + } + + /** Queue one entry. Never throws and never writes to the console. */ + record(level: UiLogLevel, source: string, message: string, extra: UiLogExtra = {}): void { + const entry: UiLogEntry = { + ts: this.now().toISOString(), + seq: ++this.seq, + level, + source: source.slice(0, 64), + message: capField(message), + }; + if (extra.detail !== undefined && extra.detail.length > 0) { + entry.detail = capField(extra.detail); + } + if (extra.stack !== undefined && extra.stack.length > 0) { + entry.stack = capField(extra.stack); + } + if (extra.url !== undefined && extra.url.length > 0) { + entry.url = capField(extra.url, 1024); + } + if (typeof extra.line === 'number' && Number.isFinite(extra.line)) { + entry.line = extra.line; + } + if (typeof extra.col === 'number' && Number.isFinite(extra.col)) { + entry.col = extra.col; + } + this.push(entry); + this.arm(this.flushMs); + } + + stats(): UiLogStats { + return { + queued: this.queue.length, + sent: this.sent, + dropped: this.dropped, + failedPosts: this.failedPosts, + }; + } + + /** + * Send what is queued, one batch now and the rest on the timer. With + * `final` the page is going away: the transport is told so, and nothing + * is rescheduled. + */ + async flush(final = false): Promise { + if (this.timer !== undefined) { + this.cancel(this.timer); + this.timer = undefined; + } + if (this.inFlight && !final) { + return; + } + if (this.queue.length === 0) { + return; + } + const batch = this.queue.splice(0, this.batchMax); + if (this.droppedSinceFlush > 0) { + const lost = this.droppedSinceFlush; + this.droppedSinceFlush = 0; + batch.unshift({ + ts: this.now().toISOString(), + seq: ++this.seq, + level: 'warn', + source: 'ui-log', + message: `${lost} entries were dropped before this batch: the page keeps ${this.bufferMax} while the server does not answer`, + }); + } + const payload: UiLogPayload = { + page: this.options.page, + session: this.options.session, + entries: batch, + }; + this.inFlight = true; + let accepted = false; + try { + accepted = await this.options.transport.send(payload, final); + } catch { + accepted = false; + } finally { + this.inFlight = false; + } + if (accepted) { + this.sent += batch.length; + this.backoffStep = 0; + if (this.queue.length > 0 && !final) { + this.arm(0); + } + return; + } + this.failedPosts += 1; + // Put the batch back in front so order is kept, then trim to the cap + // from the oldest end, the same way record() does. + this.queue.unshift(...batch); + while (this.queue.length > this.bufferMax) { + this.queue.shift(); + this.dropped += 1; + this.droppedSinceFlush += 1; + } + if (!final) { + const step = Math.min(this.backoffStep, this.backoffMs.length - 1); + const wait = this.backoffMs[step] ?? this.flushMs; + this.backoffStep += 1; + this.arm(wait); + } + } + + private push(entry: UiLogEntry): void { + if (this.queue.length >= this.bufferMax) { + this.queue.shift(); + this.dropped += 1; + this.droppedSinceFlush += 1; + } + this.queue.push(entry); + } + + private arm(ms: number): void { + if (this.timer !== undefined || this.inFlight) { + return; + } + this.timer = this.schedule(() => { + this.timer = undefined; + void this.flush(); + }, ms); + } +} diff --git a/graph-ui/src/i18n/messages.ts b/graph-ui/src/i18n/messages.ts index 8eb624aad..6c7e1c587 100644 --- a/graph-ui/src/i18n/messages.ts +++ b/graph-ui/src/i18n/messages.ts @@ -1081,6 +1081,15 @@ export const messages = { logsSource: (shown: number, total: number): string => `read from GET /api/logs: ${shown} of ${total} lines`, logsEmpty: 'the log ring is empty', logsError: (detail: string): string => `the log did not arrive: ${detail}`, + uiLogTitle: 'Frontend log', + uiLogSource: (shown: number, total: number, path: string): string => + `read from GET /api/ui-log: ${shown} of ${total} lines of ${path}`, + uiLogPrevious: (path: string): string => `rotated once; the lines before these are in ${path}`, + uiLogAbout: + 'This page sends its console output, uncaught errors and failed requests to that file as ' + + 'they happen. Attach the file to a bug report; an agent can read it with the same route.', + uiLogEmpty: 'nothing recorded yet', + uiLogError: (detail: string): string => `the frontend log did not arrive: ${detail}`, reload: 'reload', }, diff --git a/graph-ui/src/main.tsx b/graph-ui/src/main.tsx index 732908901..98df07e10 100644 --- a/graph-ui/src/main.tsx +++ b/graph-ui/src/main.tsx @@ -1,12 +1,18 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; +import { installUiLog } from './app/ui-log-install'; // Erst die Tokens, dann das Chrome, das sie benutzt. Die Reihenfolge ist keine // Kosmetik: eine Regel, die eine noch nicht definierte Variable liest, faellt // still auf ihren Initialwert zurueck. import './styles/tokens.css'; import './styles/terminal.css'; +// Before anything else can fail: the console keeps printing, and from here +// on a copy of every line, every uncaught error and every failed request +// reaches the server's file (app/ui-log.ts, GET /api/ui-log). +installUiLog(); + const container = document.getElementById('root'); if (container === null) { throw new Error('#root fehlt in index.html'); diff --git a/graph-ui/src/projects/ProjectsPanel.test.tsx b/graph-ui/src/projects/ProjectsPanel.test.tsx index 889c42fd5..6550966b3 100644 --- a/graph-ui/src/projects/ProjectsPanel.test.tsx +++ b/graph-ui/src/projects/ProjectsPanel.test.tsx @@ -62,6 +62,17 @@ function fakeSource(jobs: () => IndexJob[] = () => []): ProjectsSource & { calls adr: record('adr', async () => ({ hasAdr: true, content: '# old record', updatedAt: '2026-09-05 10:00' })), saveAdr: record('saveAdr', async () => undefined), logs: record('logs', async () => ({ lines: ['line one', 'line two'], total: 40 })), + uiLogTail: record('uiLogTail', async () => ({ + path: '/cache/logs/ui.log', + previousPath: '', + sizeBytes: 512, + partial: false, + lines: [ + '{"received":"2026-09-08T10:00:00Z","ts":"2026-09-08T10:00:00.000Z","level":"error","source":"rpc","message":"get_code_snippet: HTTP 500","detail":"oom"}', + 'not json at all', + ], + total: 2, + })), processes: record('processes', async () => ({ selfPid: 42, selfRssMb: 120.5, @@ -308,6 +319,27 @@ describe('the server block', () => { expect(container.textContent).toContain(messages.projects.logsSource(2, 40)); }); + it('shows the frontend log as sentences and names the file to attach', async () => { + await render(); + expect(byTestId('atlas-projects-ui-log-source')?.textContent) + .toBe(messages.projects.uiLogSource(2, 2, '/cache/logs/ui.log')); + expect(byTestId('atlas-projects-ui-log')?.textContent).toBe( + '2026-09-08T10:00:00.000Z error rpc: get_code_snippet: HTTP 500 (oom)\nnot json at all', + ); + expect(container.textContent).toContain(messages.projects.uiLogAbout); + expect(container.textContent).not.toContain(messages.projects.uiLogPrevious('')); + }); + + it('says when the frontend log did not arrive, and still shows the rest', async () => { + const source = fakeSource(); + source.uiLogTail = async () => { + throw new AtlasApiError('/api/ui-log', 500, '/api/ui-log antwortete mit HTTP 500: no'); + }; + await render({ source }); + expect(container.textContent).toContain(messages.projects.uiLogError('/api/ui-log antwortete mit HTTP 500: no')); + expect(byTestId('atlas-projects-logs')?.textContent).toBe('line one\nline two'); + }); + it('asks again on reload', async () => { const props = await render(); const source = props.source as ReturnType; @@ -315,6 +347,7 @@ describe('the server block', () => { await click(byTestId('atlas-projects-server-reload')); expect(source.calls['logs']?.length).toBe(before + 1); expect(source.calls['processes']?.length).toBe(before + 1); + expect(source.calls['uiLogTail']?.length).toBe(before + 1); }); }); diff --git a/graph-ui/src/projects/ProjectsPanel.tsx b/graph-ui/src/projects/ProjectsPanel.tsx index e45d95d8b..98d4d8cad 100644 --- a/graph-ui/src/projects/ProjectsPanel.tsx +++ b/graph-ui/src/projects/ProjectsPanel.tsx @@ -12,7 +12,8 @@ * Three rules shape it, and they are the rules of the rest of this surface: * * 1. **Every block names its source.** The list says it comes from - * list_projects, the jobs say they come from /api/index-status, and a + * list_projects, the jobs say they come from /api/index-status, the + * frontend log names the file it is read from (GET /api/ui-log), and a * number the server did not send is shown as not sent, never as zero. * 2. **A control that cannot act is not on the screen.** The index button * appears once a path and a name are there; a project whose root the @@ -34,7 +35,7 @@ import type { JSX, ReactNode } from 'react'; import { AtlasApiError } from '../app/atlas-api'; import { messages } from '../i18n/messages'; import type { ProjectEntry } from '../provider/rpc-schemas'; -import { anyIndexing, childPath, finishedSince, megabytes, projectNameFor } from './projects-model'; +import { anyIndexing, childPath, finishedSince, megabytes, projectNameFor, uiLogLineText } from './projects-model'; import type { AdrRecord, BrowseLevel, @@ -43,6 +44,7 @@ import type { LogTail, ProcessReport, ProjectHealth, + UiLogTail, } from './projects-model'; const text = messages.projects; @@ -69,6 +71,7 @@ export interface ProjectsSource { saveAdr(project: string, content: string): Promise; logs(lines: number): Promise; processes(): Promise; + uiLogTail(lines: number): Promise; } export interface ProjectsPanelProps { @@ -170,6 +173,8 @@ export default function ProjectsPanel(props: ProjectsPanelProps): JSX.Element { const [processesError, setProcessesError] = useState(''); const [logs, setLogs] = useState(undefined); const [logsError, setLogsError] = useState(''); + const [uiLog, setUiLog] = useState(undefined); + const [uiLogError, setUiLogError] = useState(''); useEffect(() => { mounted.current = true; @@ -256,6 +261,17 @@ export default function ProjectsPanel(props: ProjectsPanelProps): JSX.Element { setLogsError(text.logsError(describeError(error))); } } + try { + const tail = await source.uiLogTail(LOG_LINES); + if (mounted.current) { + setUiLog(tail); + setUiLogError(''); + } + } catch (error) { + if (mounted.current) { + setUiLogError(text.uiLogError(describeError(error))); + } + } }, [source]); useEffect(() => { @@ -799,6 +815,26 @@ export default function ProjectsPanel(props: ProjectsPanelProps): JSX.Element { )} )} +

{text.uiLogTitle}

+

{text.uiLogAbout}

+ {uiLogError.length > 0 &&

{uiLogError}

} + {uiLog !== undefined && ( + <> +

+ {text.uiLogSource(uiLog.lines.length, uiLog.total, uiLog.path)} +

+ {uiLog.previousPath.length > 0 && ( +

{text.uiLogPrevious(uiLog.previousPath)}

+ )} + {uiLog.lines.length === 0 ? ( +

{text.uiLogEmpty}

+ ) : ( +
+                                {uiLog.lines.map(uiLogLineText).join('\n')}
+                            
+ )} + + )}