Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions ui/src/lib/api/http-checks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import type { components } from '$control/schema.d.ts';
import { shortRunId } from '$lib/build-area/labels';
import { cellText } from '../../routes/checks/utils';
import { checkDetailOf, thresholdsOf, toCheck, toCheckEvidence } from './http-checks';

type W = components['schemas'];

function result(eventId: string, at: string, outcome: string, extra: Partial<W['CheckResult']> = {}): W['CheckResult'] {
return {
event_id: eventId,
binding_key: 'b-orders-row-count',
audit_name: 'orders_row_count',
audit_definition_name: 'row_count',
attachment_kind: 'model',
attached_target_name: 'orders',
severity: 'error',
evaluation_mode: 'measurement',
run_scope_phase: 'final',
occurred_at: at,
run_id: `run-${eventId}`,
invocation_id: `inv-${eventId}`,
outcome,
reused: 'false',
...extra
};
}

describe('check mapping', () => {
it('reads warn and error bounds, keeping both ends of an outside range', () => {
expect(thresholdsOf('{"error":{"limit":10,"operator":"below"},"warn":{"lower":5,"operator":"outside","upper":500}}')).toEqual([
{ level: 'warn', operator: 'outside', value: [5, 500] },
{ level: 'error', operator: 'below', value: 10 }
]);
expect(thresholdsOf(null)).toEqual([]);
});

it('keeps the sqb run apart from a scheduler run and reads text booleans and numbers', () => {
const warn = toCheck(result('e1', '2026-11-07T06:00:00Z', 'warn', { measured_value: '12.5', evidence_truncated: 'false' }));
expect([warn.latest?.runId, warn.latest?.fingerprintRunId, warn.latest?.measuredValue, warn.latest?.evidenceTruncated]).toEqual([null, 'run-e1', 12.5, false]);
const truncated = toCheck(result('e1', '2026-11-07T06:00:00Z', 'pass', { evidence_truncated: 'true' }));
expect([truncated.latest?.evidenceTruncated, truncated.kind, truncated.recentOutcomes]).toEqual([true, null, null]);
});

it('takes the detail from the newest result and lists recent outcomes oldest first', () => {
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')]);
expect(detail?.latest?.id).toBe('e3');
expect(detail?.history.map((e) => e.id)).toEqual(['e3', 'e2', 'e1']);
expect(detail?.recentOutcomes).toEqual(['pass', 'warn', 'error']);
expect(checkDetailOf([])).toBeNull();
});

it('keeps evidence cells aligned to their columns', () => {
const evidence = toCheckEvidence({ binding_key: 'b', event_id: 'e1', columns: ['id', 'note'], rows: [[7, null]] as never, truncated: 'true' });
expect([evidence.rows, evidence.truncated]).toEqual([[[7, null]], true]);
});

it('shortens an sqb run id to its random suffix, not its date', () => {
expect(shortRunId('20261107T061043Z_ea9122fb7f42')).toBe('ea9122fb7f42');
expect(shortRunId('0f9a3c27-5d61-4e0b-9a88-2c4d7e1b6f30')).toBe('0f9a3c27');
});
});

describe('evidence cells', () => {
it('renders structured values as JSON and missing keys as NULL', () => {
expect([cellText({ sku: 7 }), cellText([1, 'a']), cellText(null), cellText(false), cellText(3.5)]).toEqual(['{"sku":7}', '[1,"a"]', 'NULL', 'false', '3.5']);
});
});
83 changes: 83 additions & 0 deletions ui/src/lib/api/http-checks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { components } from '$control/schema.d.ts';
import type { Check, CheckAttachment, CheckDetail, CheckEvaluation, CheckEvidence, CheckOutcome, CheckSeverity, CheckSql, CheckThreshold, EvaluationMode, RunScopePhase } from './types/build';

type Wire = components['schemas'];

type Bound = { operator: CheckThreshold['operator']; limit?: number; lower?: number; upper?: number };

/** `thresholds_json` holds `{warn?, error?}` bounds, each with a `limit` or, for `outside`, a `lower` and `upper`. */
export function thresholdsOf(json: string | null | undefined): CheckThreshold[] {
if (!json) return [];
const levels = JSON.parse(json) as Partial<Record<CheckThreshold['level'], Bound>>;
return (['warn', 'error'] as const).flatMap((level) => {
const bound = levels[level];
if (!bound) return [];
const value: CheckThreshold['value'] = bound.operator === 'outside' ? [bound.lower ?? 0, bound.upper ?? 0] : (bound.limit ?? 0);
return [{ level, operator: bound.operator, value }];
});
}

/**
* One stored result. Its `run_id` is the `sqb` run that evaluated it; the server does not
* report which scheduler run, if any, that was, so `runId` stays null.
*/
export function toEvaluation(wire: Wire['CheckResult']): CheckEvaluation {
return {
id: wire.event_id,
runId: null,
fingerprintRunId: wire.run_id,
evaluatedAt: wire.occurred_at,
outcome: wire.outcome as CheckOutcome,
violationCount: wire.violation_count ?? null,
measuredValue: wire.measured_value == null ? null : Number(wire.measured_value),
evidenceCount: wire.evidence_count ?? null,
evidenceTruncated: wire.evidence_truncated === 'true'
};
}

/** A check from its newest result. The server stores no check kind or recent outcomes with the listing. */
export function toCheck(wire: Wire['CheckResult']): Check {
return {
id: wire.binding_key,
name: wire.audit_name,
definition: wire.audit_definition_name,
kind: null,
attachment: wire.attachment_kind as CheckAttachment,
node: wire.attached_target_name ?? null,
column: wire.attached_column_name ?? null,
severity: wire.severity as CheckSeverity,
runScopePhase: wire.run_scope_phase as RunScopePhase,
evaluationMode: wire.evaluation_mode as EvaluationMode,
thresholds: thresholdsOf(wire.thresholds_json),
description: wire.audit_description ?? null,
latest: toEvaluation(wire),
recentOutcomes: null
};
}

/** A check's detail from its history, newest first; no history means no such check. */
export function checkDetailOf(history: Wire['CheckResult'][]): CheckDetail | null {
if (!history.length) return null;
const evaluations = history.map(toEvaluation);
return { ...toCheck(history[0]), recentOutcomes: [...evaluations].reverse().map((e) => e.outcome), history: evaluations };
}

export function toCheckSql(wire: Wire['CheckSql']): CheckSql {
return {
evaluationId: wire.event_id,
executedSql: wire.executed_sql ?? null,
measurementSql: wire.measurement_sql ?? null,
evidenceSql: wire.evidence_sql ?? null,
executionError: wire.execution_error ?? null,
evidenceError: wire.evidence_error ?? null
};
}

export function toCheckEvidence(wire: Wire['CheckEvidence']): CheckEvidence {
return {
evaluationId: wire.event_id,
columns: wire.columns,
rows: wire.rows as unknown as CheckEvidence['rows'],
truncated: wire.truncated === 'true'
};
}
43 changes: 42 additions & 1 deletion ui/src/lib/api/http-client.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import createFetch from 'openapi-fetch';
import type { components, paths } from '$control/schema.d.ts';
import { ApiError, type ApiClient } from './client';
import { checkDetailOf, toCheck, toCheckEvidence, toCheckSql } from './http-checks';
import { jobDetailOf, toJob, toTick, toTrigger, triggerDetailOf } from './http-jobs';
import { RUN_WINDOW, runDetailOf, runRowPage, runsMatching, runTabCountsOf, toRunNode, toRuns } from './http-runs';
import type { ChangeSubscription, ChangedChannel, ChangedSignal, ConnectionState } from './signals';
import type { AuthMode, Bootstrap, Capability, Location, Scope } from './types';
import type { Check, CheckDetail, CheckEvidence, CheckSql } from './types/build';
import type { Job, JobDetail, RerunMode, Run, RunDetail, RunListQuery, RunRowPage, RunRowQuery, RunTabCounts, Trigger, TriggerDetail, TriggerState } from './types/operate';

type Wire = components['schemas'];
Expand All @@ -28,7 +30,11 @@ const SUPPORTED: ReadonlySet<string> = new Set<keyof ApiClient>([
'getTrigger',
'setTriggerStates',
'resetTriggerToDefault',
'resetSensorCursor'
'resetSensorCursor',
'listChecks',
'getCheck',
'getCheckSql',
'getCheckEvidence'
]);

/** A UI location is one server location and target; its id carries both. */
Expand Down Expand Up @@ -229,6 +235,28 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt
if (!response.ok) throw new ApiError(errorText(error, response), response.status);
return requireRun(scope, body.run_id);
},
listChecks: async (scope: Scope): Promise<Check[]> => (await checkResults(scope)).map(toCheck),
getCheck: async (scope: Scope, id: string): Promise<CheckDetail | null> => checkDetailOf(await checkResults(scope, id)),
async getCheckSql(scope: Scope, checkId: string): Promise<CheckSql> {
const { locationId, target } = parseLocationKey(scope.locationId);
const { data, error, response } = await http.GET('/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/sql', {
params: { path: { location_id: locationId, target, binding_key: checkId } }
});
if (response.status === 401) onUnauthorized();
if (!response.ok) throw new ApiError(errorText(error, response), response.status);
if (!data) throw new ApiError('The check has no stored result yet', 404);
return toCheckSql(data);
},
async getCheckEvidence(scope: Scope, checkId: string, evaluationId: string): Promise<CheckEvidence> {
const { locationId, target } = parseLocationKey(scope.locationId);
const { data, error, response } = await http.GET('/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}/results/{event_id}/evidence', {
params: { path: { location_id: locationId, target, binding_key: checkId, event_id: evaluationId } }
});
if (response.status === 401) onUnauthorized();
if (!response.ok) throw new ApiError(errorText(error, response), response.status);
if (!data) throw new ApiError('No evidence was stored with this result', 404);
return toCheckEvidence(data);
},
listTriggers: (scope: Scope): Promise<Trigger[]> => triggersOf(scope),
async getTrigger(scope: Scope, id: string): Promise<TriggerDetail | null> {
const [wires, runs] = await Promise.all([listedTriggers(scope), recentRuns(scope)]);
Expand Down Expand Up @@ -286,6 +314,19 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt
return data;
}

/** The newest result of every check, or one check's results newest first. */
async function checkResults(scope: Scope, bindingKey?: string) {
const { locationId, target } = parseLocationKey(scope.locationId);
const { data, error, response } = bindingKey
? await http.GET('/api/control/locations/{location_id}/targets/{target}/checks/{binding_key}', {
params: { path: { location_id: locationId, target, binding_key: bindingKey } }
})
: await http.GET('/api/control/locations/{location_id}/targets/{target}/checks', { params: { path: { location_id: locationId, target } } });
if (response.status === 401) onUnauthorized();
if (!data) throw new ApiError(errorText(error, response), response.status);
return data;
}

const recentRuns = async (scope: Scope) => toRuns(await listedRuns(scope), scope.locationId);

/** Triggers with their latest tick and the newest listed run each started. */
Expand Down
10 changes: 6 additions & 4 deletions ui/src/lib/api/types/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@ export type Check = {
name: string;
/** The audit definition, e.g. `not_null`; null for custom checks. */
definition: string | null;
kind: CheckKind;
/** Null where the server does not report it. */
kind: CheckKind | null;
attachment: CheckAttachment;
node: string | null;
column: string | null;
Expand All @@ -234,8 +235,8 @@ export type Check = {
thresholds: CheckThreshold[];
description: string | null;
latest: CheckEvaluation | null;
/** Outcomes of recent evaluations, oldest first. */
recentOutcomes: CheckOutcome[];
/** Outcomes of recent evaluations, oldest first; null where the listing does not report them. */
recentOutcomes: CheckOutcome[] | null;
};

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

Expand Down
5 changes: 3 additions & 2 deletions ui/src/lib/build-area/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,10 @@ export const warehouseColumnTone: Record<WarehouseColumnState, string> = {
undeclared: 'var(--sb-text-faint)'
};

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

/** "run-4f2a" for scheduler builds, "from the command line · 0f9a3c27" otherwise. */
Expand Down
22 changes: 12 additions & 10 deletions ui/src/routes/checks/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@
import { formatAgo, formatStamp } from '$lib/format/time';
import { checkOutcomeLabel, checkOutcomeTone } from '$lib/status';
import { attachmentLabel, evaluationModeLabel, runScopePhaseLabel } from '$lib/build-area/labels';
import { checkValue, outcomeOf } from './utils';
import { checkValue, kindLabel, outcomeOf } from './utils';

type OutcomeFilter = 'all' | 'failing' | 'warn' | 'pass' | 'other';

const checks = createResource((client, scope) => client.listChecks(scope), ['checks', 'runs']);
const checks = createResource((client, scope) => client.listChecks(scope), ['checks', 'runs', 'node_status']);
const all = $derived(checks.data ?? []);

let query = $state(page.url.searchParams.get('q') ?? '');
Expand All @@ -41,7 +41,7 @@
if (attachment && c.attachment !== attachment) return false;
if (nodeFilter && c.node !== nodeFilter) return false;
const q = query.trim().toLowerCase();
return !q || [c.name, c.node ?? '', c.column ?? '', c.definition ?? c.kind].some((s) => s.toLowerCase().includes(q));
return !q || [c.name, c.node ?? '', c.column ?? '', kindLabel(c)].some((s) => s.toLowerCase().includes(q));
})
);
const counts = $derived({
Expand All @@ -63,14 +63,16 @@
// A check-only run (`sqb audit`) over the nodes the visible checks attach to.
const runSelection = $derived([...new Set(filtered.map((c) => c.node).filter((n): n is string => !!n))]);
let runOpen = $state(false);
const linksNodes = $derived(app.client.supports('getCatalogNode'));
const hasRecent = $derived(all.some((c) => c.recentOutcomes !== null));
</script>

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

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

<ListToolbar
bind:query
Expand Down Expand Up @@ -99,7 +101,7 @@
<th>Checks</th>
<th>Result type</th>
<th class="num">Result</th>
<th>Recent</th>
{#if hasRecent}<th>Recent</th>{/if}
<th>Last checked</th>
</tr>
</thead>
Expand All @@ -112,24 +114,24 @@
<ShieldCheckIcon size={15} strokeWidth={1.75} />
<div class="min-w-0">
<a href="/checks/{c.id}" class="check-name" onclick={(e) => e.stopPropagation()}>{c.name}</a>
<div class="cell-desc">{c.kind === 'audit' ? `audit · ${c.definition ?? 'custom'}` : c.kind.replace('_', ' ')}</div>
<div class="cell-desc">{kindLabel(c)}</div>
</div>
</div>
</td>
<td><StatusLabel tone={checkOutcomeTone[o]} label={checkOutcomeLabel[o]} /></td>
<td>
{#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}
{#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}
{:else}<span class="muted">{attachmentLabel[c.attachment]}</span>{/if}
</td>
<td class="muted capitalize">{c.severity}</td>
<td class="muted">{runScopePhaseLabel[c.runScopePhase]}</td>
<td class="muted">{evaluationModeLabel[c.evaluationMode]}</td>
<td class="num" style:color={o === 'pass' ? 'var(--muted-foreground)' : checkOutcomeTone[o]}>{checkValue(c.latest, c.evaluationMode)}</td>
<td><span class="mini-strip">{#each c.recentOutcomes as r, i (i)}<i style:background={checkOutcomeTone[r as CheckOutcome]}></i>{/each}</span></td>
{#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}
<td><RelativeTime value={formatAgo(c.latest?.evaluatedAt, app.now)} exact={formatStamp(c.latest?.evaluatedAt)} /></td>
</tr>
{:else}
<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.'} />
<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.'} />
{/each}
</tbody>
</table>
Expand Down
Loading
Loading