diff --git a/ui/src/lib/api/http-checks.test.ts b/ui/src/lib/api/http-checks.test.ts new file mode 100644 index 0000000000..050ffba003 --- /dev/null +++ b/ui/src/lib/api/http-checks.test.ts @@ -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'] { + 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']); + }); +}); diff --git a/ui/src/lib/api/http-checks.ts b/ui/src/lib/api/http-checks.ts new file mode 100644 index 0000000000..af34c2e6df --- /dev/null +++ b/ui/src/lib/api/http-checks.ts @@ -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>; + 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' + }; +} diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index b72b3b57d3..c1b9455b47 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -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']; @@ -28,7 +30,11 @@ const SUPPORTED: ReadonlySet = new Set([ 'getTrigger', 'setTriggerStates', 'resetTriggerToDefault', - 'resetSensorCursor' + 'resetSensorCursor', + 'listChecks', + 'getCheck', + 'getCheckSql', + 'getCheckEvidence' ]); /** A UI location is one server location and target; its id carries both. */ @@ -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 => (await checkResults(scope)).map(toCheck), + getCheck: async (scope: Scope, id: string): Promise => checkDetailOf(await checkResults(scope, id)), + async getCheckSql(scope: Scope, checkId: string): Promise { + 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 { + 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 => triggersOf(scope), async getTrigger(scope: Scope, id: string): Promise { const [wires, runs] = await Promise.all([listedTriggers(scope), recentRuns(scope)]); @@ -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. */ diff --git a/ui/src/lib/api/types/build.ts b/ui/src/lib/api/types/build.ts index ffd1e1d0fa..839f9b4aff 100644 --- a/ui/src/lib/api/types/build.ts +++ b/ui/src/lib/api/types/build.ts @@ -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; @@ -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 & { @@ -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; }; diff --git a/ui/src/lib/build-area/labels.ts b/ui/src/lib/build-area/labels.ts index f0d09e08aa..b0bbdb1b2b 100644 --- a/ui/src/lib/build-area/labels.ts +++ b/ui/src/lib/build-area/labels.ts @@ -120,9 +120,10 @@ export const warehouseColumnTone: Record = { 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. */ diff --git a/ui/src/routes/checks/+page.svelte b/ui/src/routes/checks/+page.svelte index 932dfe2251..a36db38abc 100644 --- a/ui/src/routes/checks/+page.svelte +++ b/ui/src/routes/checks/+page.svelte @@ -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') ?? ''); @@ -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({ @@ -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)); - (runOpen = true)}>▷ Run checks + {#if app.client.supports('launchAdhocRun')} (runOpen = true)}>▷ Run checks{/if} - +{#if app.client.supports('launchAdhocRun')}{/if} Checks Result type Result - Recent + {#if hasRecent}Recent{/if} Last checked @@ -112,24 +114,24 @@
e.stopPropagation()}>{c.name} -
{c.kind === 'audit' ? `audit · ${c.definition ?? 'custom'}` : c.kind.replace('_', ' ')}
+
{kindLabel(c)}
- {#if c.node} e.stopPropagation()}>{c.node}{#if c.column}.{c.column}{/if} + {#if c.node}{#if linksNodes} e.stopPropagation()}>{c.node}{:else}{c.node}{/if}{#if c.column}.{c.column}{/if} {:else}{attachmentLabel[c.attachment]}{/if} {c.severity} {runScopePhaseLabel[c.runScopePhase]} {evaluationModeLabel[c.evaluationMode]} {checkValue(c.latest, c.evaluationMode)} - {#each c.recentOutcomes as r, i (i)}{/each} + {#if hasRecent}{#each c.recentOutcomes ?? [] as r, i (i)}{/each}{/if} {:else} - + {/each} diff --git a/ui/src/routes/checks/[id]/check-view.svelte b/ui/src/routes/checks/[id]/check-view.svelte index 5ab4d60dbc..03a7f9e588 100644 --- a/ui/src/routes/checks/[id]/check-view.svelte +++ b/ui/src/routes/checks/[id]/check-view.svelte @@ -15,11 +15,11 @@ import { checkOutcomeLabel, checkOutcomeTone } from '$lib/status'; import { attachmentLabel, evaluationModeLabel, runScopePhaseLabel, shortRunId, thresholdLabel } from '$lib/build-area/labels'; import { errorMessage } from '$lib/platform/labels'; - import { checkValue, outcomeOf } from '../utils'; + import { cellText, checkValue, kindLabel, outcomeOf } from '../utils'; let { id }: { id: string } = $props(); - const res = createResource((client, scope) => client.getCheck(scope, id), ['checks', 'runs']); + const res = createResource((client, scope) => client.getCheck(scope, id), ['checks', 'runs', 'node_status']); const c = $derived(res.data ?? null); const outcome = $derived(c ? outcomeOf(c) : 'skipped'); @@ -65,12 +65,12 @@ - {#if c} + {#if c && app.client.supports('launchAdhocRun')} (runOpen = true)}>▷ Run check {/if} - +{#if app.client.supports('launchAdhocRun')}{/if}
@@ -106,7 +106,7 @@ {checkValue(e, c.evaluationMode)} {#if e.runId}{e.runId} - {:else}from the command line · {shortRunId(e.fingerprintRunId)}{/if} + {:else}sqb run {shortRunId(e.fingerprintRunId)}{/if} {#if e.evidenceCount} @@ -127,7 +127,7 @@ {#each evidence.columns as col (col)}{col}{/each} {#each evidence.rows as row, i (i)} - {#each row as v, j (j)}{v === null ? 'NULL' : String(v)}{/each} + {#each row as v, j (j)}{cellText(v)}{/each} {:else} No rows were stored. {/each} @@ -158,7 +158,7 @@

Definition

{#if c.node}

Node

-

{c.node}

+

{#if app.client.supports('getCatalogNode')}{c.node}{:else}{c.node}{/if}

{/if} {#if c.latest}

Latest

@@ -178,7 +178,7 @@ { label: 'Outcome', value: checkOutcomeLabel[c.latest.outcome], tone: checkOutcomeTone[c.latest.outcome] }, { label: 'Result', value: checkValue(c.latest, c.evaluationMode) }, { label: 'Last checked', value: `${formatAgo(c.latest.evaluatedAt, app.now)} · ${formatStamp(c.latest.evaluatedAt)}` }, - { label: 'Run', value: c.latest.runId ?? `from the command line · ${shortRunId(c.latest.fingerprintRunId)}`, mono: true } + { label: 'Run', value: c.latest.runId ?? `sqb run ${shortRunId(c.latest.fingerprintRunId)}`, title: c.latest.fingerprintRunId, mono: true } ]} /> {/if} diff --git a/ui/src/routes/checks/utils.ts b/ui/src/routes/checks/utils.ts index aa9b109742..b268b54d4d 100644 --- a/ui/src/routes/checks/utils.ts +++ b/ui/src/routes/checks/utils.ts @@ -14,3 +14,15 @@ export function checkValue(evaluation: CheckEvaluation | null, mode: EvaluationM if (evaluation.violationCount === null) return '—'; return evaluation.violationCount === 0 ? '0 rows' : `${evaluation.violationCount.toLocaleString('en-US')} ${evaluation.violationCount === 1 ? 'row' : 'rows'}`; } + +/** What kind of check it is, e.g. `audit · not_null`; just the definition where the kind is unknown. */ +export function kindLabel(check: Pick): string { + if (check.kind === null) return check.definition ?? 'check'; + return check.kind === 'audit' ? `audit · ${check.definition ?? 'custom'}` : check.kind.replace('_', ' '); +} + +/** An evidence cell as text: NULL, JSON for structured values, the value itself otherwise. */ +export function cellText(value: unknown): string { + if (value === null || value === undefined) return 'NULL'; + return typeof value === 'object' ? JSON.stringify(value) : String(value); +}