Skip to content

Commit fe5e7d9

Browse files
committed
feat(ui): rework the run page around what happened
The first failed node's error shows without a click. Timeline rows run in start order and the timeline is only as tall as its rows. Events and printed output are one filterable log, with failures tinted and query rows linking to their SQL. The header shows the start time, the project version and the command. A group run gathers its job runs' log and statements, and lists each node once.
1 parent f13f6b1 commit fe5e7d9

20 files changed

Lines changed: 492 additions & 357 deletions

‎ui/src/lib/api/http-client.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { overviewOf } from './http-overview';
1212
import { toPlatformEvents, toPlatformHealth } from './http-problems';
1313
import { roleDetailOf, toRoleDefinition } from './http-roles';
1414
import { jobDetailOf, runWindowOf, toJob, toTick, toTrigger, triggerDetailOf } from './http-jobs';
15-
import { RUN_WINDOW, toAdhocCheck, runDetailOf, runRowPage, runsMatching, runTabCountsOf, toRunNode, toRuns, toRunEvent, toRunOutput, toRunStatements } from './http-runs';
15+
import { RUN_WINDOW, groupRunNodes, toAdhocCheck, runDetailOf, runRowPage, runsMatching, runTabCountsOf, toRunNode, toRuns, toRunEvent, toRunOutput, toRunStatements } from './http-runs';
1616
import type { ChangeSubscription, ChangedChannel, ChangedSignal, ConnectionState } from './signals';
1717
import type { AuthMode, Bootstrap, Capability, Location, PlatformEvent, Scope, WarehouseSync } from './types';
1818
import type { Account, AuditEntry, AuditQuery, ConnectionTest, LocationDetail, LocationSummary, PlatformHealth, MaintenanceHistory, RoleAssignment, RoleAssignmentRequest, RoleDefinition, RoleDetail } from './types/platform';
@@ -259,8 +259,8 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt
259259
if (!found) return null;
260260
const { run, wire, listed } = found;
261261
const owners = [run, ...listed.filter((r) => r.parentRunId === run.id)];
262-
const nodes = (await Promise.all(owners.map(async (owner) => (await runNodes(scope, owner.id)).map((n) => toRunNode(n, owner))))).flat();
263-
return runDetailOf(run, wire, listed, nodes);
262+
const [own, ...children] = await Promise.all(owners.map(async (owner) => (await runNodes(scope, owner.id)).map((n) => toRunNode(n, owner))));
263+
return runDetailOf(run, wire, listed, groupRunNodes(own, children.flat()));
264264
},
265265
async listRunEvents(scope: Scope, runId: string, query: { after?: number; limit?: number } = {}): Promise<RunEventPage> {
266266
const limit = query.limit ?? 1000;

‎ui/src/lib/api/http-runs.test.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22
import type { components } from '$control/schema.d.ts';
3-
import { runDetailOf, runRowPage, runTabCountsOf, toRunNode, toRuns, toRunEvent, toRunStatements, toRunOutput } from './http-runs';
3+
import { groupRunNodes, runDetailOf, runRowPage, runTabCountsOf, toRunNode, toRuns, toRunEvent, toRunStatements, toRunOutput } from './http-runs';
44

55
type RunRecord = components['schemas']['RunRecord'];
66
type W = components['schemas'];
@@ -176,3 +176,16 @@ describe('run Recall reads', () => {
176176
});
177177
});
178178

179+
180+
describe('group run nodes', () => {
181+
it("keeps each job run's node and the group's own row only where no job run has the node", () => {
182+
const group = toRuns([wire('g', { kind: 'group', job_key: 'nightly' })], LOCATION)[0];
183+
const staging = toRuns([wire('s', { parent_run_id: 'g', job_key: 'staging' })], LOCATION)[0];
184+
const node = (key: string, status: string, owner = group) => toRunNode({ node_key: key, status } as W['RunNodeRecord'], owner);
185+
const nodes = groupRunNodes([node('model:raw', 'success'), node('model:report', 'pending')], [node('model:raw', 'failed', staging)]);
186+
expect(nodes.map((n) => [n.nodeKey, n.runId, n.status])).toEqual([
187+
['model:report', 'g', 'pending'],
188+
['model:raw', 's', 'failed']
189+
]);
190+
});
191+
});

‎ui/src/lib/api/http-runs.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,15 @@ export function runDetailOf(run: Run, wire: Wire['RunRecord'], listed: Run[], no
226226
};
227227
}
228228

229+
/**
230+
* A run's nodes with its job runs' nodes. A group run's own rows repeat its job runs' nodes, so they
231+
* stay only for nodes no job run has, such as those of members that never started.
232+
*/
233+
export function groupRunNodes(own: RunNode[], children: RunNode[]): RunNode[] {
234+
const covered = new Set(children.map((n) => n.nodeKey));
235+
return [...own.filter((n) => !covered.has(n.nodeKey)), ...children];
236+
}
237+
229238
const KNOWN_EVENTS: ReadonlySet<string> = new Set(RECALL_EVENT_TYPES);
230239
/** Scheduler facts are stored as `run_<fact>` events in the scheduler invocation. */
231240
const SCHEDULER_EVENTS: ReadonlySet<string> = new Set(SCHEDULER_FACTS.map((f) => `run_${f}`));

‎ui/src/lib/api/mock/operate-store.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,8 @@ export class MockOperate {
296296
listRunEvents(scope: Scope, runId: string, query: { after?: number; limit?: number } = {}): Promise<RunEventPage> {
297297
const rec = this.#require(runId);
298298
const now = Date.now();
299-
const all = [rec, ...this.#children(rec)]
300-
.flatMap((r) => execution(r, this.#nodesOf(r, now)).events)
299+
// Each run's own, as the server returns; the run page gathers a group's job runs itself.
300+
const all = execution(rec, this.#nodesOf(rec, now)).events
301301
.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt) || a.storageId - b.storageId)
302302
.map((e, i) => ({ ...e, storageId: i + 1 }));
303303
const after = query.after ?? 0;
@@ -310,24 +310,22 @@ export class MockOperate {
310310
listRunStatements(scope: Scope, runId: string): Promise<RunStatement[]> {
311311
const rec = this.#require(runId);
312312
const now = Date.now();
313-
return this.#ctx.reply([rec, ...this.#children(rec)].flatMap((r) => execution(r, this.#nodesOf(r, now), now).statements));
313+
return this.#ctx.reply(execution(rec, this.#nodesOf(rec, now), now).statements);
314314
}
315315

316316
getStatementSql(scope: Scope, runId: string, statementId: string): Promise<StatementSql> {
317317
this.#need('view.sql');
318318
const rec = this.#require(runId);
319319
const now = Date.now();
320-
const statement = [rec, ...this.#children(rec)]
321-
.flatMap((r) => execution(r, this.#nodesOf(r, now), now).statements)
322-
.find((st) => st.id === statementId);
320+
const statement = execution(rec, this.#nodesOf(rec, now), now).statements.find((st) => st.id === statementId);
323321
if (!statement) throw new ApiError(`Unknown statement ${statementId}`, 404);
324322
return this.#ctx.reply({ statementId, sql: statement.sqlRecorded ? sqlFor(statement.nodeKey, statement.kind, this.#ctx.target(scope)) : null });
325323
}
326324

327325
listRunOutput(scope: Scope, runId: string): Promise<RunOutputLine[]> {
328326
const rec = this.#require(runId);
329327
const now = Date.now();
330-
return this.#ctx.reply([rec, ...this.#children(rec)].flatMap((r) => execution(r, this.#nodesOf(r, now)).output));
328+
return this.#ctx.reply(execution(rec, this.#nodesOf(rec, now)).output);
331329
}
332330

333331
listQueue(scope: Scope): Promise<QueueEntry[]> {

‎ui/src/routes/runs/[id]/events-tab.svelte‎

Lines changed: 0 additions & 196 deletions
This file was deleted.

‎ui/src/routes/runs/[id]/failures-panel.svelte‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script lang="ts">
2+
import { untrack } from 'svelte';
23
import { app } from '$lib/state/app.svelte';
34
import CopyButton from '$lib/components/copy-button.svelte';
45
import type { RunNode, RunNodeError } from '$lib/api';
@@ -30,6 +31,15 @@
3031
}
3132
}
3233
34+
// The first failure opens on its own, so the error is on screen without a click: one audited read per visit.
35+
let opened = false;
36+
$effect(() => {
37+
const first = failed[0];
38+
if (opened || !first || !(first.errorDetail || readable)) return;
39+
opened = true;
40+
untrack(() => void toggle(first));
41+
});
42+
3343
function read(key: string): RunNodeError | null {
3444
const found = errors[key];
3545
return found && 'nodeKey' in found ? found : null;

0 commit comments

Comments
 (0)