Skip to content

Commit d7ebacb

Browse files
authored
feat(ui): wire the checks pages to the live control API (#1338)
* feat(ui): wire the checks pages to the live control API * fix(ui): show structured evidence cells as JSON
1 parent ca9a17a commit d7ebacb

8 files changed

Lines changed: 235 additions & 26 deletions

File tree

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { components } from '$control/schema.d.ts';
3+
import { shortRunId } from '$lib/build-area/labels';
4+
import { cellText } from '../../routes/checks/utils';
5+
import { checkDetailOf, thresholdsOf, toCheck, toCheckEvidence } from './http-checks';
6+
7+
type W = components['schemas'];
8+
9+
function result(eventId: string, at: string, outcome: string, extra: Partial<W['CheckResult']> = {}): W['CheckResult'] {
10+
return {
11+
event_id: eventId,
12+
binding_key: 'b-orders-row-count',
13+
audit_name: 'orders_row_count',
14+
audit_definition_name: 'row_count',
15+
attachment_kind: 'model',
16+
attached_target_name: 'orders',
17+
severity: 'error',
18+
evaluation_mode: 'measurement',
19+
run_scope_phase: 'final',
20+
occurred_at: at,
21+
run_id: `run-${eventId}`,
22+
invocation_id: `inv-${eventId}`,
23+
outcome,
24+
reused: 'false',
25+
...extra
26+
};
27+
}
28+
29+
describe('check mapping', () => {
30+
it('reads warn and error bounds, keeping both ends of an outside range', () => {
31+
expect(thresholdsOf('{"error":{"limit":10,"operator":"below"},"warn":{"lower":5,"operator":"outside","upper":500}}')).toEqual([
32+
{ level: 'warn', operator: 'outside', value: [5, 500] },
33+
{ level: 'error', operator: 'below', value: 10 }
34+
]);
35+
expect(thresholdsOf(null)).toEqual([]);
36+
});
37+
38+
it('keeps the sqb run apart from a scheduler run and reads text booleans and numbers', () => {
39+
const warn = toCheck(result('e1', '2026-11-07T06:00:00Z', 'warn', { measured_value: '12.5', evidence_truncated: 'false' }));
40+
expect([warn.latest?.runId, warn.latest?.fingerprintRunId, warn.latest?.measuredValue, warn.latest?.evidenceTruncated]).toEqual([null, 'run-e1', 12.5, false]);
41+
const truncated = toCheck(result('e1', '2026-11-07T06:00:00Z', 'pass', { evidence_truncated: 'true' }));
42+
expect([truncated.latest?.evidenceTruncated, truncated.kind, truncated.recentOutcomes]).toEqual([true, null, null]);
43+
});
44+
45+
it('takes the detail from the newest result and lists recent outcomes oldest first', () => {
46+
const detail = checkDetailOf([result('e3', '2026-11-07T08:00:00Z', 'error'), result('e2', '2026-11-07T07:00:00Z', 'warn'), result('e1', '2026-11-07T06:00:00Z', 'pass')]);
47+
expect(detail?.latest?.id).toBe('e3');
48+
expect(detail?.history.map((e) => e.id)).toEqual(['e3', 'e2', 'e1']);
49+
expect(detail?.recentOutcomes).toEqual(['pass', 'warn', 'error']);
50+
expect(checkDetailOf([])).toBeNull();
51+
});
52+
53+
it('keeps evidence cells aligned to their columns', () => {
54+
const evidence = toCheckEvidence({ binding_key: 'b', event_id: 'e1', columns: ['id', 'note'], rows: [[7, null]] as never, truncated: 'true' });
55+
expect([evidence.rows, evidence.truncated]).toEqual([[[7, null]], true]);
56+
});
57+
58+
it('shortens an sqb run id to its random suffix, not its date', () => {
59+
expect(shortRunId('20261107T061043Z_ea9122fb7f42')).toBe('ea9122fb7f42');
60+
expect(shortRunId('0f9a3c27-5d61-4e0b-9a88-2c4d7e1b6f30')).toBe('0f9a3c27');
61+
});
62+
});
63+
64+
describe('evidence cells', () => {
65+
it('renders structured values as JSON and missing keys as NULL', () => {
66+
expect([cellText({ sku: 7 }), cellText([1, 'a']), cellText(null), cellText(false), cellText(3.5)]).toEqual(['{"sku":7}', '[1,"a"]', 'NULL', 'false', '3.5']);
67+
});
68+
});

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { components } from '$control/schema.d.ts';
2+
import type { Check, CheckAttachment, CheckDetail, CheckEvaluation, CheckEvidence, CheckOutcome, CheckSeverity, CheckSql, CheckThreshold, EvaluationMode, RunScopePhase } from './types/build';
3+
4+
type Wire = components['schemas'];
5+
6+
type Bound = { operator: CheckThreshold['operator']; limit?: number; lower?: number; upper?: number };
7+
8+
/** `thresholds_json` holds `{warn?, error?}` bounds, each with a `limit` or, for `outside`, a `lower` and `upper`. */
9+
export function thresholdsOf(json: string | null | undefined): CheckThreshold[] {
10+
if (!json) return [];
11+
const levels = JSON.parse(json) as Partial<Record<CheckThreshold['level'], Bound>>;
12+
return (['warn', 'error'] as const).flatMap((level) => {
13+
const bound = levels[level];
14+
if (!bound) return [];
15+
const value: CheckThreshold['value'] = bound.operator === 'outside' ? [bound.lower ?? 0, bound.upper ?? 0] : (bound.limit ?? 0);
16+
return [{ level, operator: bound.operator, value }];
17+
});
18+
}
19+
20+
/**
21+
* One stored result. Its `run_id` is the `sqb` run that evaluated it; the server does not
22+
* report which scheduler run, if any, that was, so `runId` stays null.
23+
*/
24+
export function toEvaluation(wire: Wire['CheckResult']): CheckEvaluation {
25+
return {
26+
id: wire.event_id,
27+
runId: null,
28+
fingerprintRunId: wire.run_id,
29+
evaluatedAt: wire.occurred_at,
30+
outcome: wire.outcome as CheckOutcome,
31+
violationCount: wire.violation_count ?? null,
32+
measuredValue: wire.measured_value == null ? null : Number(wire.measured_value),
33+
evidenceCount: wire.evidence_count ?? null,
34+
evidenceTruncated: wire.evidence_truncated === 'true'
35+
};
36+
}
37+
38+
/** A check from its newest result. The server stores no check kind or recent outcomes with the listing. */
39+
export function toCheck(wire: Wire['CheckResult']): Check {
40+
return {
41+
id: wire.binding_key,
42+
name: wire.audit_name,
43+
definition: wire.audit_definition_name,
44+
kind: null,
45+
attachment: wire.attachment_kind as CheckAttachment,
46+
node: wire.attached_target_name ?? null,
47+
column: wire.attached_column_name ?? null,
48+
severity: wire.severity as CheckSeverity,
49+
runScopePhase: wire.run_scope_phase as RunScopePhase,
50+
evaluationMode: wire.evaluation_mode as EvaluationMode,
51+
thresholds: thresholdsOf(wire.thresholds_json),
52+
description: wire.audit_description ?? null,
53+
latest: toEvaluation(wire),
54+
recentOutcomes: null
55+
};
56+
}
57+
58+
/** A check's detail from its history, newest first; no history means no such check. */
59+
export function checkDetailOf(history: Wire['CheckResult'][]): CheckDetail | null {
60+
if (!history.length) return null;
61+
const evaluations = history.map(toEvaluation);
62+
return { ...toCheck(history[0]), recentOutcomes: [...evaluations].reverse().map((e) => e.outcome), history: evaluations };
63+
}
64+
65+
export function toCheckSql(wire: Wire['CheckSql']): CheckSql {
66+
return {
67+
evaluationId: wire.event_id,
68+
executedSql: wire.executed_sql ?? null,
69+
measurementSql: wire.measurement_sql ?? null,
70+
evidenceSql: wire.evidence_sql ?? null,
71+
executionError: wire.execution_error ?? null,
72+
evidenceError: wire.evidence_error ?? null
73+
};
74+
}
75+
76+
export function toCheckEvidence(wire: Wire['CheckEvidence']): CheckEvidence {
77+
return {
78+
evaluationId: wire.event_id,
79+
columns: wire.columns,
80+
rows: wire.rows as unknown as CheckEvidence['rows'],
81+
truncated: wire.truncated === 'true'
82+
};
83+
}

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import createFetch from 'openapi-fetch';
22
import type { components, paths } from '$control/schema.d.ts';
33
import { ApiError, type ApiClient } from './client';
4+
import { checkDetailOf, toCheck, toCheckEvidence, toCheckSql } from './http-checks';
45
import { jobDetailOf, toJob, toTick, toTrigger, triggerDetailOf } from './http-jobs';
56
import { RUN_WINDOW, runDetailOf, runRowPage, runsMatching, runTabCountsOf, toRunNode, toRuns } from './http-runs';
67
import type { ChangeSubscription, ChangedChannel, ChangedSignal, ConnectionState } from './signals';
78
import type { AuthMode, Bootstrap, Capability, Location, Scope } from './types';
9+
import type { Check, CheckDetail, CheckEvidence, CheckSql } from './types/build';
810
import type { Job, JobDetail, RerunMode, Run, RunDetail, RunListQuery, RunRowPage, RunRowQuery, RunTabCounts, Trigger, TriggerDetail, TriggerState } from './types/operate';
911

1012
type Wire = components['schemas'];
@@ -28,7 +30,11 @@ const SUPPORTED: ReadonlySet<string> = new Set<keyof ApiClient>([
2830
'getTrigger',
2931
'setTriggerStates',
3032
'resetTriggerToDefault',
31-
'resetSensorCursor'
33+
'resetSensorCursor',
34+
'listChecks',
35+
'getCheck',
36+
'getCheckSql',
37+
'getCheckEvidence'
3238
]);
3339

3440
/** A UI location is one server location and target; its id carries both. */
@@ -229,6 +235,28 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt
229235
if (!response.ok) throw new ApiError(errorText(error, response), response.status);
230236
return requireRun(scope, body.run_id);
231237
},
238+
listChecks: async (scope: Scope): Promise<Check[]> => (await checkResults(scope)).map(toCheck),
239+
getCheck: async (scope: Scope, id: string): Promise<CheckDetail | null> => checkDetailOf(await checkResults(scope, id)),
240+
async getCheckSql(scope: Scope, checkId: string): Promise<CheckSql> {
241+
const { locationId, target } = parseLocationKey(scope.locationId);
242+
const { data, error, response } = await http.GET('/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/sql', {
243+
params: { path: { location_id: locationId, target, binding_key: checkId } }
244+
});
245+
if (response.status === 401) onUnauthorized();
246+
if (!response.ok) throw new ApiError(errorText(error, response), response.status);
247+
if (!data) throw new ApiError('The check has no stored result yet', 404);
248+
return toCheckSql(data);
249+
},
250+
async getCheckEvidence(scope: Scope, checkId: string, evaluationId: string): Promise<CheckEvidence> {
251+
const { locationId, target } = parseLocationKey(scope.locationId);
252+
const { data, error, response } = await http.GET('/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/results/{event_id}/evidence', {
253+
params: { path: { location_id: locationId, target, binding_key: checkId, event_id: evaluationId } }
254+
});
255+
if (response.status === 401) onUnauthorized();
256+
if (!response.ok) throw new ApiError(errorText(error, response), response.status);
257+
if (!data) throw new ApiError('No evidence was stored with this result', 404);
258+
return toCheckEvidence(data);
259+
},
232260
listTriggers: (scope: Scope): Promise<Trigger[]> => triggersOf(scope),
233261
async getTrigger(scope: Scope, id: string): Promise<TriggerDetail | null> {
234262
const [wires, runs] = await Promise.all([listedTriggers(scope), recentRuns(scope)]);
@@ -286,6 +314,19 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt
286314
return data;
287315
}
288316

317+
/** The newest result of every check, or one check's results newest first. */
318+
async function checkResults(scope: Scope, bindingKey?: string) {
319+
const { locationId, target } = parseLocationKey(scope.locationId);
320+
const { data, error, response } = bindingKey
321+
? await http.GET('/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}', {
322+
params: { path: { location_id: locationId, target, binding_key: bindingKey } }
323+
})
324+
: await http.GET('/api/control/locations/{location_id}/targets/{target}/checks', { params: { path: { location_id: locationId, target } } });
325+
if (response.status === 401) onUnauthorized();
326+
if (!data) throw new ApiError(errorText(error, response), response.status);
327+
return data;
328+
}
329+
289330
const recentRuns = async (scope: Scope) => toRuns(await listedRuns(scope), scope.locationId);
290331

291332
/** Triggers with their latest tick and the newest listed run each started. */

‎ui/src/lib/api/types/build.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,8 @@ export type Check = {
224224
name: string;
225225
/** The audit definition, e.g. `not_null`; null for custom checks. */
226226
definition: string | null;
227-
kind: CheckKind;
227+
/** Null where the server does not report it. */
228+
kind: CheckKind | null;
228229
attachment: CheckAttachment;
229230
node: string | null;
230231
column: string | null;
@@ -234,8 +235,8 @@ export type Check = {
234235
thresholds: CheckThreshold[];
235236
description: string | null;
236237
latest: CheckEvaluation | null;
237-
/** Outcomes of recent evaluations, oldest first. */
238-
recentOutcomes: CheckOutcome[];
238+
/** Outcomes of recent evaluations, oldest first; null where the listing does not report them. */
239+
recentOutcomes: CheckOutcome[] | null;
239240
};
240241

241242
export type CheckDetail = Check & {
@@ -257,7 +258,8 @@ export type CheckEvidence = {
257258
evaluationId: string;
258259
/** Every row key, in sorted order; a row without a key holds null there. */
259260
columns: string[];
260-
rows: (string | number | boolean | null)[][];
261+
/** JSON cells: scalars, or objects and arrays from structured warehouse columns. */
262+
rows: unknown[][];
261263
truncated: boolean;
262264
};
263265

‎ui/src/lib/build-area/labels.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,10 @@ export const warehouseColumnTone: Record<WarehouseColumnState, string> = {
120120
undeclared: 'var(--sb-text-faint)'
121121
};
122122

123-
/** Short ID for a fingerprint run, which is a UUID or an invocation ID. */
123+
/** A UUID's first block, or the random suffix of an `sqb` run id such as `20261107T061043Z_ea9122fb7f42`. */
124124
export function shortRunId(id: string): string {
125-
return id.length > 12 ? id.slice(0, 8) : id;
125+
const suffix = id.slice(id.lastIndexOf('_') + 1);
126+
return suffix.length > 12 ? suffix.slice(0, 8) : suffix;
126127
}
127128

128129
/** "run-4f2a" for scheduler builds, "from the command line · 0f9a3c27" otherwise. */

‎ui/src/routes/checks/+page.svelte‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@
1818
import { formatAgo, formatStamp } from '$lib/format/time';
1919
import { checkOutcomeLabel, checkOutcomeTone } from '$lib/status';
2020
import { attachmentLabel, evaluationModeLabel, runScopePhaseLabel } from '$lib/build-area/labels';
21-
import { checkValue, outcomeOf } from './utils';
21+
import { checkValue, kindLabel, outcomeOf } from './utils';
2222
2323
type OutcomeFilter = 'all' | 'failing' | 'warn' | 'pass' | 'other';
2424
25-
const checks = createResource((client, scope) => client.listChecks(scope), ['checks', 'runs']);
25+
const checks = createResource((client, scope) => client.listChecks(scope), ['checks', 'runs', 'node_status']);
2626
const all = $derived(checks.data ?? []);
2727
2828
let query = $state(page.url.searchParams.get('q') ?? '');
@@ -41,7 +41,7 @@
4141
if (attachment && c.attachment !== attachment) return false;
4242
if (nodeFilter && c.node !== nodeFilter) return false;
4343
const q = query.trim().toLowerCase();
44-
return !q || [c.name, c.node ?? '', c.column ?? '', c.definition ?? c.kind].some((s) => s.toLowerCase().includes(q));
44+
return !q || [c.name, c.node ?? '', c.column ?? '', kindLabel(c)].some((s) => s.toLowerCase().includes(q));
4545
})
4646
);
4747
const counts = $derived({
@@ -63,14 +63,16 @@
6363
// A check-only run (`sqb audit`) over the nodes the visible checks attach to.
6464
const runSelection = $derived([...new Set(filtered.map((c) => c.node).filter((n): n is string => !!n))]);
6565
let runOpen = $state(false);
66+
const linksNodes = $derived(app.client.supports('getCatalogNode'));
67+
const hasRecent = $derived(all.some((c) => c.recentOutcomes !== null));
6668
</script>
6769

6870
<AppTopbar title="Checks">
6971
<Button variant="outline" size="sm" onclick={() => checks.refresh()}>↻ Refresh</Button>
70-
<GatedButton requires="run.adhoc" variant="default" onclick={() => (runOpen = true)}>▷ Run checks</GatedButton>
72+
{#if app.client.supports('launchAdhocRun')}<GatedButton requires="run.adhoc" variant="default" onclick={() => (runOpen = true)}>▷ Run checks</GatedButton>{/if}
7173
</AppTopbar>
7274

73-
<RunPanel bind:open={runOpen} verb="audit" selection={filtered.length === all.length ? [] : runSelection} />
75+
{#if app.client.supports('launchAdhocRun')}<RunPanel bind:open={runOpen} verb="audit" selection={filtered.length === all.length ? [] : runSelection} />{/if}
7476

7577
<ListToolbar
7678
bind:query
@@ -99,7 +101,7 @@
99101
<th>Checks</th>
100102
<th>Result type</th>
101103
<th class="num">Result</th>
102-
<th>Recent</th>
104+
{#if hasRecent}<th>Recent</th>{/if}
103105
<th>Last checked</th>
104106
</tr>
105107
</thead>
@@ -112,24 +114,24 @@
112114
<ShieldCheckIcon size={15} strokeWidth={1.75} />
113115
<div class="min-w-0">
114116
<a href="/checks/{c.id}" class="check-name" onclick={(e) => e.stopPropagation()}>{c.name}</a>
115-
<div class="cell-desc">{c.kind === 'audit' ? `audit · ${c.definition ?? 'custom'}` : c.kind.replace('_', ' ')}</div>
117+
<div class="cell-desc">{kindLabel(c)}</div>
116118
</div>
117119
</div>
118120
</td>
119121
<td><StatusLabel tone={checkOutcomeTone[o]} label={checkOutcomeLabel[o]} /></td>
120122
<td>
121-
{#if c.node}<a href="/catalog/{c.node}" class="link" onclick={(e) => e.stopPropagation()}>{c.node}</a>{#if c.column}<span class="muted">.{c.column}</span>{/if}
123+
{#if c.node}{#if linksNodes}<a href="/catalog/{c.node}" class="link" onclick={(e) => e.stopPropagation()}>{c.node}</a>{:else}<span>{c.node}</span>{/if}{#if c.column}<span class="muted">.{c.column}</span>{/if}
122124
{:else}<span class="muted">{attachmentLabel[c.attachment]}</span>{/if}
123125
</td>
124126
<td class="muted capitalize">{c.severity}</td>
125127
<td class="muted">{runScopePhaseLabel[c.runScopePhase]}</td>
126128
<td class="muted">{evaluationModeLabel[c.evaluationMode]}</td>
127129
<td class="num" style:color={o === 'pass' ? 'var(--muted-foreground)' : checkOutcomeTone[o]}>{checkValue(c.latest, c.evaluationMode)}</td>
128-
<td><span class="mini-strip">{#each c.recentOutcomes as r, i (i)}<i style:background={checkOutcomeTone[r as CheckOutcome]}></i>{/each}</span></td>
130+
{#if hasRecent}<td><span class="mini-strip">{#each c.recentOutcomes ?? [] as r, i (i)}<i style:background={checkOutcomeTone[r as CheckOutcome]}></i>{/each}</span></td>{/if}
129131
<td><RelativeTime value={formatAgo(c.latest?.evaluatedAt, app.now)} exact={formatStamp(c.latest?.evaluatedAt)} /></td>
130132
</tr>
131133
{:else}
132-
<LoadRow colspan={9} resource={checks} empty={all.length ? 'No checks match the filter.' : 'No checks yet. Add audits or tests to a model and they show up here after the next deploy.'} />
134+
<LoadRow colspan={hasRecent ? 9 : 8} resource={checks} empty={all.length ? 'No checks match the filter.' : 'No checks yet. Add audits or tests to a model and they show up here after the next deploy.'} />
133135
{/each}
134136
</tbody>
135137
</table>

0 commit comments

Comments
 (0)