diff --git a/ui/src/lib/api/http-catalog.test.ts b/ui/src/lib/api/http-catalog.test.ts index 6c5452fd76..12323431a7 100644 --- a/ui/src/lib/api/http-catalog.test.ts +++ b/ui/src/lib/api/http-catalog.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { components } from '$control/schema.d.ts'; -import { toCatalogNodes } from './http-catalog'; +import { findNode, nodeDetailOf, toCatalogNodes } from './http-catalog'; +import { toCheck } from './http-checks'; type W = components['schemas']; @@ -53,4 +54,46 @@ describe('catalog node mapping', () => { expect([orders.description, orders.tags, orders.columnCount, orders.relation]).toEqual(['One row per order', ['marts'], 4, { database: null, schema: 'main', name: 'orders' }]); expect([orders.stale, orders.checks, orders.lastOutcome, orders.group, orders.owner]).toEqual([null, null, null, null, []]); }); + + it('finds a node by key, or by bare name preferring the model', () => { + const nodes = toCatalogNodes([node('source:orders'), node('source:customers'), node('model:orders', { materialization: 'view' })]); + expect([findNode(nodes, 'source:orders')?.key, findNode(nodes, 'orders')?.key, findNode(nodes, 'customers')?.key, findNode(nodes, 'order')]).toEqual(['source:orders', 'model:orders', 'source:customers', null]); + }); +}); + +function check(id: string, target: string, outcome: string): W['CheckResult'] { + return { event_id: `e-${id}`, binding_key: id, audit_name: id, audit_definition_name: 'not_null', attachment_kind: 'model', attached_target_name: target, severity: 'error', evaluation_mode: 'violations', run_scope_phase: 'final', occurred_at: '2026-11-07T06:00:00Z', run_id: 'r', invocation_id: 'i', outcome, reused: 'false' }; +} + +function status(definition: W['NodeStatus']['definition'], physical: W['NodeStatus']['physical']): W['NodeStatus'] { + const build = { definition_hash: 'd', event_id: 'e', observed_at: '2026-11-07T06:00:00Z', run_id: 'sqb-run-1', schema_fingerprint: 's', source_event_id: 'x', version_hash: 'v1' }; + return { node_key: 'model:orders', snapshot_id: 'snap', build, definition, physical, source_data: { status: 'unknown', reason: 'not tracked' } }; +} + +describe('catalog node detail', () => { + const nodes = toCatalogNodes([node('model:orders', { materialization: 'table' }), node('model:order_totals', { materialization: 'view', dependencies: ['model:orders'] }), node('model:orders_by_day', { materialization: 'view' })]); + const checks = [check('a', 'orders', 'pass'), check('b', 'model:orders', 'fail'), check('c', 'orders', 'warn'), check('d', 'orders_by_day', 'fail'), { ...check('e', 'orders', 'fail'), attachment_kind: 'source' }].map(toCheck); + const observed: W['NodeStatus']['physical'] = { status: 'observed', observation: { event_id: 'o', observed_at: '2026-11-07T06:00:00Z', record: { name: 'orders', relation_type: 'table', state_schema: 'main', columns: [{ name: 'order_id', data_type: 'INTEGER' }] } } }; + + it('keeps only its own checks, matched by key or by bare name of the same kind, and counts them by newest outcome', () => { + const d = nodeDetailOf(nodes, nodes[0], null, null, checks); + expect(d.checkResults.map((c) => c.id)).toEqual(['a', 'b', 'c']); + expect(d.checks).toEqual({ total: 3, passing: 1, warning: 1, failing: 1 }); + expect(d.downstream).toEqual(['model:order_totals']); + }); + + it('reports a changed definition as stale and maps the warehouse columns', () => { + const changed = nodeDetailOf(nodes, nodes[0], status({ status: 'changed', current_local_hash: 'b', recorded_local_hash: 'a' }, observed), null, []); + expect(changed.stale.map((r) => [r.reason, r.since])).toEqual([['changed', '2026-11-07T06:00:00Z']]); + expect(changed.columns).toEqual([{ name: 'order_id', type: 'INTEGER', description: '', audits: [] }]); + const matched = nodeDetailOf(nodes, nodes[0], status({ status: 'matched', local_hash: 'a' }, { status: 'unavailable', reason: 'no catalog' }), null, []); + expect([matched.stale, matched.columns]).toEqual([[], []]); + }); + + it('leaves refused SQL out and unreported history unknown rather than empty', () => { + const refused = nodeDetailOf(nodes, nodes[0], null, null, []); + expect([refused.sql, refused.runs, refused.jobs, refused.history]).toEqual([null, null, null, null]); + const withSql = nodeDetailOf(nodes, nodes[0], null, { node_key: 'model:orders', snapshot_id: 'snap', raw_sql: 'select {{ x }}', compiled_sql: null }, []); + expect(withSql.sql).toEqual({ raw: 'select {{ x }}', compiled: 'select {{ x }}' }); + }); }); diff --git a/ui/src/lib/api/http-catalog.ts b/ui/src/lib/api/http-catalog.ts index bba6472397..5c9c492fbd 100644 --- a/ui/src/lib/api/http-catalog.ts +++ b/ui/src/lib/api/http-catalog.ts @@ -1,5 +1,6 @@ import type { components } from '$control/schema.d.ts'; -import type { CatalogNode } from './types/build'; +import { shortName } from '$lib/build-area/labels'; +import type { CatalogNode, CatalogNodeDetail, Check, CheckCounts, StaleReason } from './types/build'; import type { NodeKind } from './types/operate'; type Wire = components['schemas']; @@ -48,3 +49,58 @@ export function toCatalogNodes(wire: Wire['CatalogNodeRecord'][]): CatalogNode[] const listed = new Set(nodes.map((node) => node.key)); return nodes.map((node) => ({ ...node, upstream: node.upstream.filter((key) => listed.has(key)) })); } + +/** Passing, warning and failing by each check's newest result; checks without one count only towards the total. */ +export function checkCountsOf(checks: Check[]): CheckCounts { + const outcome = (c: Check) => c.latest?.outcome; + return { + total: checks.length, + passing: checks.filter((c) => outcome(c) === 'pass').length, + warning: checks.filter((c) => outcome(c) === 'warn').length, + failing: checks.filter((c) => ['fail', 'error'].includes(outcome(c) ?? '')).length + }; +} + +/** A definition that changed since its last recorded build here; other staleness is not reported by the server. */ +function staleOf(status: Wire['NodeStatus'] | null): StaleReason[] { + if (status?.definition.status !== 'changed' || !status.build) return []; + return [{ reason: 'changed', change: 'definition', detail: 'The definition changed since it was last built here.', since: status.build.observed_at }]; +} + + +/** The listed node a key or bare name refers to, preferring a model; migrations name models without their kind. */ +export function findNode(nodes: CatalogNode[], ref: string): CatalogNode | null { + return nodes.find((n) => n.key === ref) ?? nodes.find((n) => n.key === `model:${ref}`) ?? nodes.find((n) => shortName(n.key) === ref) ?? null; +} + +/** + * A listed node with its status and SQL reads and its checks. The server keeps no + * per-node run history, row counts, cost, jobs or migrations, so those stay empty or null and the + * pages hide them. `sql` is null when the person may not see SQL. + */ +export function nodeDetailOf(nodes: CatalogNode[], node: CatalogNode, status: Wire['NodeStatus'] | null, sql: Wire['NodeSql'] | null, checks: Check[]): CatalogNodeDetail { + // Checks name their node without its kind, so the attachment tells a source from a model of the same name. + const own = checks.filter((c) => c.node === node.key || `${c.attachment}:${c.node}` === node.key); + const physical = status?.physical.status === 'observed' ? status.physical.observation.record : null; + return { + ...node, + stale: staleOf(status), + checks: checkCountsOf(own), + downstream: nodes.filter((n) => n.upstream.includes(node.key)).map((n) => n.key), + recentOutcomes: [], + lastRows: null, + rowsDelta: null, + lastDurationMs: null, + cost: null, + columns: (physical?.columns ?? []).map((c) => ({ name: c.name, type: c.data_type, description: '', audits: [] })), + sql: sql?.raw_sql ? { raw: sql.raw_sql, compiled: sql.compiled_sql ?? sql.raw_sql } : null, + code: null, + builds: [], + jobs: null, + runs: null, + checkResults: own, + warehouse: null, + microbatch: null, + history: null + }; +} diff --git a/ui/src/lib/api/http-client.test.ts b/ui/src/lib/api/http-client.test.ts index 8d05b8dc33..2105944003 100644 --- a/ui/src/lib/api/http-client.test.ts +++ b/ui/src/lib/api/http-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { HttpChangeFeed, feedAllowed } from './http-client'; +import { HttpChangeFeed, createHttpClient, feedAllowed } from './http-client'; import type { ChangedSignal, ConnectionState } from './signals'; /** A stand-in `EventSource` the test opens, fails and feeds by hand. */ @@ -237,3 +237,48 @@ describe('feedAllowed', () => { await expect(feedAllowed('', ORDERS, () => {})).rejects.toThrow('network down'); }); }); + +describe('getCatalogNode', () => { + /** Answers the reads a node page makes; anything else, such as node status, is refused. */ + function serve(actions: string[], sqlStatus = 200) { + const paths: string[] = []; + const answers: [string, unknown][] = [ + ['/bootstrap', { login_mode: 'password', access: { name: 'vic', principal_id: 'p', actions: [], locations: [{ location_id: '0191f0aa-orders', name: 'Orders', target: 'development', actions }] } }], + ['/nodes', [{ node_key: 'model:orders', kind: 'model', name: 'orders', materialization: 'table', tags: [], dependencies: [], snapshot_id: 's' }]], + ['/checks', []], + ['/sql', { node_key: 'model:orders', snapshot_id: 's', raw_sql: 'select 1', compiled_sql: 'select 1' }] + ]; + vi.stubGlobal( + 'fetch', + vi.fn(async (request: Request) => { + const path = new URL(request.url).pathname; + paths.push(path); + const answer = answers.find(([end]) => path.endsWith(end)); + const refused = !answer || (answer[0] === '/sql' && sqlStatus === 403); + return refused ? Response.json({ error: 'refused' }, { status: 403 }) : Response.json(answer[1]); + }) + ); + return paths; + } + + it.each([ + [['view', 'view.sql'], 'select 1'], + [['view'], null] + ])('with %j reads SQL only when the location allows it, so a viewer causes no refused read', async (actions, raw) => { + const paths = serve(actions); + const client = createHttpClient({ baseUrl: 'http://ui.test', onUnauthorized: () => {} }); + await client.bootstrap(); + const detail = await client.getCatalogNode(ORDERS, 'orders'); + expect([detail?.key, detail?.sql?.raw ?? null]).toEqual(['model:orders', raw]); + expect(paths.filter((p) => p.endsWith('/sql'))).toHaveLength(raw ? 1 : 0); + }); + + it('asks for a refused node SQL once, when view.sql holds on only some nodes', async () => { + const paths = serve(['view', 'view.sql'], 403); + const client = createHttpClient({ baseUrl: 'http://ui.test', onUnauthorized: () => {} }); + await client.bootstrap(); + const loads = [await client.getCatalogNode(ORDERS, 'orders'), await client.getCatalogNode(ORDERS, 'orders')]; + expect(loads.map((d) => [d?.key, d?.sql])).toEqual([['model:orders', null], ['model:orders', null]]); + expect(paths.filter((p) => p.endsWith('/sql'))).toHaveLength(1); + }); +}); diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index 03baee8ca5..952caa2133 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -2,13 +2,13 @@ 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 { toCatalogNodes } from './http-catalog'; +import { findNode, nodeDetailOf, toCatalogNodes } from './http-catalog'; import { toSnapshotChanges } from './http-changes'; 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 { CatalogNode, Check, CheckDetail, CheckEvidence, CheckSql, SnapshotChange } from './types/build'; +import type { CatalogNode, CatalogNodeDetail, Check, CheckDetail, CheckEvidence, CheckSql, SnapshotChange } from './types/build'; import type { Job, JobDetail, RerunMode, Run, RunDetail, RunListQuery, RunRowPage, RunRowQuery, RunTabCounts, Trigger, TriggerDetail, TriggerState } from './types/operate'; type Wire = components['schemas']; @@ -38,6 +38,7 @@ const SUPPORTED: ReadonlySet = new Set([ 'getCheckSql', 'getCheckEvidence', 'listCatalogNodes', + 'getCatalogNode', 'listSnapshotChanges' ]); @@ -186,6 +187,10 @@ export type HttpClientOptions = { */ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOptions): ApiClient { const http = createFetch({ baseUrl, credentials: 'same-origin' }); + // Locations whose bootstrap actions include view.sql, which may hold on only some nodes, and the + // nodes there whose SQL was refused; each refused read is audited, so neither is asked again until reload. + let sqlLocations = new Set(); + const sqlRefused = new Set(); const implemented = { supports: (method: keyof ApiClient) => SUPPORTED.has(method), changes: new HttpChangeFeed(baseUrl, (scope) => feedAllowed(baseUrl, scope, onUnauthorized)), @@ -193,7 +198,9 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt const { data, error, response } = await http.GET('/api/control/bootstrap'); if (response.status === 401) onUnauthorized(); if (!data) throw new ApiError(errorText(error, response), response.status); - return toBootstrap(data); + const bootstrap = toBootstrap(data); + sqlLocations = new Set(bootstrap.locations.filter((l) => l.capabilities?.includes('view.sql')).map((l) => l.id)); + return bootstrap; }, listRunRows: async (scope: Scope, query: RunRowQuery = {}): Promise => runRowPage(await listedRuns(scope), scope.locationId, query), runTabCounts: async (scope: Scope, query: RunRowQuery = {}): Promise => runTabCountsOf(await listedRuns(scope), scope.locationId, query), @@ -256,6 +263,24 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt if (!snapshots.data) throw new ApiError(errorText(snapshots.error, snapshots.response), snapshots.response.status); return toSnapshotChanges(snapshots.data, runs); }, + async getCatalogNode(scope: Scope, ref: string): Promise { + const nodes = await implemented.listCatalogNodes(scope); + const node = findNode(nodes, ref); + if (!node) return null; + const { locationId, target } = parseLocationKey(scope.locationId); + const path = { location_id: locationId, target }; + const [status, sql, checks] = await Promise.all([ + http.GET('/api/control/locations/{location_id}/targets/{target}/node-status', { params: { path, query: { node_key: node.key } } }), + sqlLocations.has(scope.locationId) && !sqlRefused.has(`${scope.locationId}|${node.key}`) + ? http.GET('/api/control/locations/{location_id}/targets/{target}/nodes/{node_key}/sql', { params: { path: { ...path, node_key: node.key } } }) + : null, + checkResults(scope).catch(() => []) + ]); + if (status.response.status === 401 || sql?.response.status === 401) onUnauthorized(); + if (sql?.response.status === 403) sqlRefused.add(`${scope.locationId}|${node.key}`); + // Status and SQL have their own permissions; a refusal leaves that part out rather than failing the page. + return nodeDetailOf(nodes, node, status.data ?? null, sql?.data ?? null, checks.map(toCheck)); + }, 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 { diff --git a/ui/src/lib/api/types/build.ts b/ui/src/lib/api/types/build.ts index 2eb5eb15b8..906f9e77df 100644 --- a/ui/src/lib/api/types/build.ts +++ b/ui/src/lib/api/types/build.ts @@ -177,12 +177,15 @@ export type CatalogNodeDetail = Omit & { code: { file: string; source: string } | null; /** As built in this target, newest first. */ builds: BuiltDefinition[]; - jobs: NodeJob[]; - runs: NodeRunEntry[]; + /** Null when the server does not report which jobs select the node. */ + jobs: NodeJob[] | null; + /** Null when the server does not report the node's run history. */ + runs: NodeRunEntry[] | null; checkResults: Check[]; warehouse: WarehouseObject | null; microbatch: MicrobatchCoverage | null; - history: NodeHistory; + /** Null when the server does not report renames and moves. */ + history: NodeHistory | null; }; // ---- column lineage ---- diff --git a/ui/src/lib/build-area/labels.ts b/ui/src/lib/build-area/labels.ts index b0bbdb1b2b..c6e5747701 100644 --- a/ui/src/lib/build-area/labels.ts +++ b/ui/src/lib/build-area/labels.ts @@ -174,3 +174,6 @@ export function relationName(relation: { database: string | null; schema: string if (!relation) return '—'; return [relation.database, relation.schema, relation.name].filter(Boolean).join('.'); } + +/** A node key without its kind, e.g. `customer_summary` for `model:customer_summary`. */ +export const shortName = (key: string) => key.slice(key.indexOf(':') + 1); diff --git a/ui/src/lib/components/code/code-preview.svelte b/ui/src/lib/components/code/code-preview.svelte index 326d87c0cd..8cd063d2fa 100644 --- a/ui/src/lib/components/code/code-preview.svelte +++ b/ui/src/lib/components/code/code-preview.svelte @@ -114,15 +114,14 @@ .ml-auto { margin-left: auto; } + /* A preview is read at a glance, so long lines wrap; the full viewer scrolls. */ .body { padding: 8px 0; - overflow-x: auto; color: var(--sb-mono); font: 12px/1.7 var(--font-mono); } .line { display: flex; - min-width: max-content; padding-right: 12px; } .ln { @@ -134,8 +133,10 @@ user-select: none; } .line code { + min-width: 0; font: inherit; - white-space: pre; + white-space: pre-wrap; + overflow-wrap: anywhere; } .more { display: block; diff --git a/ui/src/lib/components/lineage/asset-node.svelte b/ui/src/lib/components/lineage/asset-node.svelte index 182c52609f..4954794647 100644 --- a/ui/src/lib/components/lineage/asset-node.svelte +++ b/ui/src/lib/components/lineage/asset-node.svelte @@ -22,6 +22,8 @@ type AssetData = { name: string; + /** Shown instead of the key when set; the key stays in the tooltip. */ + label?: string; kind: string; /** The last build outcome; staleness is shown separately. */ status: 'ok' | 'fail' | 'warn' | 'run' | 'src' | 'none'; @@ -98,7 +100,7 @@
- {d.name} + {d.label ?? d.name} {#if f.kind}{kindShort}{/if}
diff --git a/ui/src/lib/lineage/catalog-graph.ts b/ui/src/lib/lineage/catalog-graph.ts index aa96ee033f..04ff84ddda 100644 --- a/ui/src/lib/lineage/catalog-graph.ts +++ b/ui/src/lib/lineage/catalog-graph.ts @@ -2,7 +2,7 @@ import type { Edge, Node } from '@xyflow/svelte'; import type { CatalogNode, StaleReason } from '$lib/api'; -import { kindLine, nodeIconKey } from '$lib/build-area/labels'; +import { kindLine, nodeIconKey, shortName } from '$lib/build-area/labels'; export type CardStatus = 'ok' | 'fail' | 'warn' | 'src' | 'none'; export type CardStale = 'changed' | 'compiled' | 'upstream' | 'behind'; @@ -35,6 +35,7 @@ export function checksBadge(node: CatalogNode): { checks?: string; checksColor?: export function cardData(node: CatalogNode): Record { return { name: node.key, + label: shortName(node.key), kind: kindLine(node), group: node.group, nodeKind: node.kind, diff --git a/ui/src/lib/lineage/scoped-lineage.svelte b/ui/src/lib/lineage/scoped-lineage.svelte index 9db90a9060..127c5ed0d4 100644 --- a/ui/src/lib/lineage/scoped-lineage.svelte +++ b/ui/src/lib/lineage/scoped-lineage.svelte @@ -181,12 +181,12 @@
--select {selectExpr} - + onclick={() => onExecute(effectiveSelection.map((id) => graphNodes.find((n) => n.id === id)?.name ?? id))}>▷ Execute {effectiveSelection.length > 1 ? `(${effectiveSelection.length})` : ''}{/if}
diff --git a/ui/src/routes/catalog/[name]/history-tab.svelte b/ui/src/routes/catalog/[name]/history-tab.svelte index 8cc25043b3..26a1301a63 100644 --- a/ui/src/routes/catalog/[name]/history-tab.svelte +++ b/ui/src/routes/catalog/[name]/history-tab.svelte @@ -1,5 +1,5 @@
diff --git a/ui/src/routes/catalog/[name]/node-view.svelte b/ui/src/routes/catalog/[name]/node-view.svelte index a2c0cec463..2af4eca3c3 100644 --- a/ui/src/routes/catalog/[name]/node-view.svelte +++ b/ui/src/routes/catalog/[name]/node-view.svelte @@ -15,7 +15,7 @@ import { createResource } from '$lib/state/resource.svelte'; import { formatAgo } from '$lib/format/time'; import { nodeOutcomeLabel, nodeOutcomeTone } from '$lib/status'; - import { kindLine, nodeIcon, nodeIconKey, nodeKindPlural, sqlVersions } from '$lib/build-area/labels'; + import { kindLine, nodeIcon, nodeIconKey, nodeKindPlural, shortName, sqlVersions } from '$lib/build-area/labels'; import { cardData } from '$lib/lineage/catalog-graph'; import OverviewTab from './overview-tab.svelte'; import DriftTab from './drift-tab.svelte'; @@ -29,6 +29,10 @@ const res = createResource((client, scope) => client.getCatalogNode(scope, name), ['runs', 'checks', 'snapshot', 'node_status', 'warehouse_sync']); const n = $derived(res.data ?? null); + // Checks and migrations link nodes by bare name; settle on the node key so the graph and links agree. + $effect(() => { + if (n && n.key !== name) goto(`/catalog/${n.key}${page.url.search}`, { replaceState: true }); + }); const catalog = createResource((client, scope) => client.listCatalogNodes(scope), ['snapshot', 'runs']); const colLineage = createResource((client, scope) => client.getColumnLineage(scope, name), ['snapshot']); @@ -38,13 +42,13 @@ if (n.columns.length) t.push(['columns', 'Columns']); if (n.sql) t.push(['sql', 'SQL']); else if (n.code) t.push(['sql', 'Code']); - if (n.sql) t.push(['drift', 'Drift']); + if (n.sql && n.builds.length) t.push(['drift', 'Drift']); t.push(['lineage', 'Lineage']); - if (n.columns.length) t.push(['collineage', 'Column lineage']); - if (n.kind !== 'source') t.push(['runs', 'Runs']); + if (n.columns.length && app.client.supports('getColumnLineage')) t.push(['collineage', 'Column lineage']); + if (n.kind !== 'source' && n.runs) t.push(['runs', 'Runs']); t.push(['checks', `Checks${n.checkResults.length ? ` ${n.checkResults.length}` : ''}`]); if (n.warehouse) t.push(['warehouse', 'Warehouse']); - t.push(['history', 'History']); + if (n.history) t.push(['history', 'History']); return t; }); // The URL is the single source of truth for the tab, so back/forward and reloads restore it. @@ -69,6 +73,7 @@ }; }); + const canRun = app.client.supports('launchAdhocRun'); let runOpen = $state(false); let runSelection = $state([]); function execute(selection: string[]) { @@ -80,25 +85,25 @@ - {#if n && n.kind !== 'source'} + {#if n && n.kind !== 'source' && canRun} execute([name])}>▷ Build {/if} - +{#if canRun}{/if}
-

{name}

+

{shortName(n?.key ?? name)}

{#if n} {#if n.kind === 'source'} {:else if n.lastOutcome} - {:else} + {:else if !n.lastBuild} {/if} @@ -109,7 +114,7 @@
{#if n}
- {kindLine(n)}{n.group}{#if n.owner.length}{n.owner.join(', ')}{/if} + {kindLine(n)}{#if n.group}{n.group}{/if}{#if n.owner.length}{n.owner.join(', ')}{/if} {#if n.kind === 'source'}{n.sourceFreshness?.loadedAt ? `Loaded ${formatAgo(n.sourceFreshness.loadedAt, app.now)}` : 'No freshness data'} {:else}{n.lastBuild ? `Last built ${formatAgo(n.lastBuild.builtAt, app.now)}` : 'Never built'}{/if}
@@ -143,17 +148,17 @@ {:else if tab === 'sql'} {#if n.sql} - goto(`/catalog/${m}?tab=sql`)} /> + goto(`/catalog/${m}?tab=sql`)} /> {:else if n.code}
{/if} {:else if tab === 'drift' && n.sql} - goto(`/catalog/${m}?tab=drift`)} /> + goto(`/catalog/${m}?tab=drift`)} /> {:else if tab === 'lineage'} {#if graph.nodes.length} - + {/if} {:else if tab === 'collineage'} {#if colLineage.data} @@ -165,8 +170,8 @@ {:else if tab === 'warehouse' && n.warehouse} - {:else if tab === 'history'} - + {:else if tab === 'history' && n.history} + {/if}
{/if} diff --git a/ui/src/routes/catalog/[name]/overview-tab.svelte b/ui/src/routes/catalog/[name]/overview-tab.svelte index 270ad9db38..63e874ca49 100644 --- a/ui/src/routes/catalog/[name]/overview-tab.svelte +++ b/ui/src/routes/catalog/[name]/overview-tab.svelte @@ -8,7 +8,7 @@ import { app } from '$lib/state/app.svelte'; import { formatAgo, formatStamp } from '$lib/format/time'; import { checkOutcomeLabel, checkOutcomeTone, nodeOutcomeTone, staleReasonDetail, staleReasonLabel } from '$lib/status'; - import { formatCredits, formatMs, formatNumber, kindLine, relationName, runScopePhaseLabel } from '$lib/build-area/labels'; + import { formatCredits, formatMs, formatNumber, kindLine, relationName, runScopePhaseLabel, shortName } from '$lib/build-area/labels'; import { driftLabel, driftOf, type Tab } from './utils'; let { node, onTab, onSql }: { node: CatalogNodeDetail; onTab: (tab: Tab) => void; onSql: (mode: 'raw' | 'compiled' | 'drift') => void } = $props(); @@ -18,6 +18,8 @@ const source = $derived(node.kind === 'source'); const unique = $derived(node.sql?.raw.match(/unique_key\s+([^,\n]+)/)?.[1] ?? null); const freshnessTone = $derived(!node.sourceFreshness ? 'var(--sb-text-faint)' : node.sourceFreshness.status === 'pass' ? 'var(--sb-success)' : node.sourceFreshness.status === 'warn' ? 'var(--sb-warning)' : 'var(--sb-error)'); + // Drift compares the current SQL with the SQL of the last build, so it needs both. + const hasDrift = $derived(!!node.sql && node.builds.length > 0); const minutes = (s: number | null) => (s === null ? '—' : s >= 3600 && s % 3600 === 0 ? `${s / 3600}h` : `${Math.round(s / 60)}m`); @@ -41,24 +43,24 @@ {:else}
- {#if node.lastOutcome}{:else}{/if} + {#if node.lastOutcome}{:else if !node.lastBuild}{/if} Last built:
Rows{formatNumber(node.lastRows)}{#if node.rowsDelta} 0}>{node.rowsDelta > 0 ? '+' : ''}{formatNumber(node.rowsDelta)}{/if}
Duration{formatMs(node.lastDurationMs)}
Checks{node.checks.total ? `${node.checks.passing}/${node.checks.total}` : '—'}
-
Builds · 40{node.recentOutcomes.filter((o) => o === 'failed').length} failed
+ {#if node.recentOutcomes.length}
Builds · {node.recentOutcomes.length}{node.recentOutcomes.filter((o) => o === 'failed').length} failed
{/if} {#if node.cost}
Cost per build{formatCredits(node.cost.perBuild)}{formatCredits(node.cost.last24h)} / 24h
{/if}
{#if node.recentOutcomes.length}
{/if} {#each node.stale as r, i (i)} - {/each}
@@ -73,7 +75,7 @@ {#if node.sql}
- onSql(sqlMode)} /> + onSql(sqlMode)} />
{:else if node.code}
@@ -122,7 +124,7 @@
Kind
{kindLine(node)}
{#if unique}
Unique key
{unique}
{/if} -
Group
{node.group}
+ {#if node.group}
Group
{node.group}
{/if}
Owner
{node.owner.join(', ') || '—'}
Path
{node.path}
Relation
{relationName(node.relation)}
@@ -136,14 +138,16 @@
{#each node.tags as t (t)}{t}{/each}
{/if} -
-

Jobs

- {#each node.jobs as j (j.key)} - {j.key}{j.kind === 'group' ? 'group' : 'job'}{j.triggerIds.length ? ` · ${j.triggerIds.length} ${j.triggerIds.length === 1 ? 'trigger' : 'triggers'}` : ' · manual'} - {:else} -

No job selects this node.

- {/each} -
+ {#if node.jobs} +
+

Jobs

+ {#each node.jobs as j (j.key)} + {j.key}{j.kind === 'group' ? 'group' : 'job'}{j.triggerIds.length ? ` · ${j.triggerIds.length} ${j.triggerIds.length === 1 ? 'trigger' : 'triggers'}` : ' · manual'} + {:else} +

No job selects this node.

+ {/each} +
+ {/if}
@@ -188,7 +192,7 @@ .job-row span { color: var(--primary); } .job-row small { margin-left: auto; color: var(--muted-foreground); font-size: 12px; } .drift-hint { display: flex; width: 100%; align-items: center; gap: 9px; margin-top: 12px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--sb-stale) 35%, var(--border)); border-radius: 2px; background: transparent; color: var(--foreground); font-size: 12.5px; text-align: left; } - .drift-hint:hover { background: var(--sb-hover); } + .drift-hint:not(:disabled):hover { background: var(--sb-hover); } .drift-hint :global(svg) { flex: 0 0 auto; color: var(--sb-stale); } .drift-hint.changed_upstream { border-style: dashed; } .drift-hint.behind { border-color: color-mix(in srgb, var(--sb-warning) 35%, var(--border)); } diff --git a/ui/src/routes/catalog/[name]/runs-tab.svelte b/ui/src/routes/catalog/[name]/runs-tab.svelte index c736199e6b..23005d37ab 100644 --- a/ui/src/routes/catalog/[name]/runs-tab.svelte +++ b/ui/src/routes/catalog/[name]/runs-tab.svelte @@ -15,7 +15,7 @@ - {#each node.runs as r, i (`${r.runId ?? r.fingerprintRunId}-${i}`)} + {#each node.runs ?? [] as r, i (`${r.runId ?? r.fingerprintRunId}-${i}`)}
OutcomeRunJobRowsDurationStarted
diff --git a/ui/src/routes/lineage/+page.svelte b/ui/src/routes/lineage/+page.svelte index 3bb38719e1..51ace19edc 100644 --- a/ui/src/routes/lineage/+page.svelte +++ b/ui/src/routes/lineage/+page.svelte @@ -38,7 +38,7 @@ import { createResource } from '$lib/state/resource.svelte'; import { formatAgo, formatStamp } from '$lib/format/time'; import { checkOutcomeTone, nodeOutcomeLabel, nodeOutcomeTone, staleReasonDetail, staleReasonLabel } from '$lib/status'; - import { NODE_KINDS, formatCredits, formatMs, formatNumber, kindLine, nodeKindPlural, relationName, runScopePhaseLabel, shortRunId, sqlVersions } from '$lib/build-area/labels'; + import { NODE_KINDS, formatCredits, formatMs, formatNumber, kindLine, nodeKindPlural, relationName, runScopePhaseLabel, shortName, shortRunId, sqlVersions } from '$lib/build-area/labels'; import { catalogGraph, cardData } from '$lib/lineage/catalog-graph'; let runOpen = $state(false); @@ -63,7 +63,7 @@ untrack(() => { if (!nodes.length) { const graph = catalogGraph(data); - const focus = data.some((n) => n.key === initialFocus) ? initialFocus : 'fact_orders'; + const focus = data.some((n) => n.key === initialFocus) ? initialFocus : null; nodes = graph.nodes.map((n) => (n.id === focus ? { ...n, selected: true } : n)); edges = graph.edges; return; @@ -83,7 +83,7 @@ const scaleModels = Number(page.url.searchParams.get('models') ?? 0); const scaleMode = scaleModels > 0; const FAR_ZOOM = 0.5; - let scaleStartId = 'fact_orders'; + let scaleStartId = ''; const scaleLayout = scaleMode ? (() => { const synthetic = scaleGraph(scaleModels); const t = performance.now(); @@ -253,6 +253,8 @@ const listed = $derived(detailSupported ? null : (catalogByKey.get(String(primarySlug)) ?? null)); const isSource = $derived(d?.kind === 'source'); const hasSql = $derived(!!d?.sql); + // Drift compares the current SQL with the SQL of the last build, so it needs both. + const hasDrift = $derived(hasSql && !!d?.builds.length); // ---- toolbar filters: non-matching nodes are dimmed ---- let kindFilter = $state(''); @@ -484,7 +486,7 @@ {#each browserNodes.slice(0, 8) as node (node.id)} {:else} @@ -496,8 +498,8 @@ {#if !scaleMode}
- - + {#if groupOptions.length}{/if} + {#if ownerOptions.length}{/if} ({ ...o, count: catalogNodes.filter((c) => matchesFilterStatus(c, o.value)).length }))} />
{/if} @@ -550,7 +552,7 @@ title={String(node.data.name)} > - {node.data.name} + {node.data.label ?? node.data.name} {/each} {/if} @@ -592,7 +594,7 @@ {#if sqlOpen && d?.sql}
- (sqlOpen = false)} /> + (sqlOpen = false)} />
{/if} @@ -621,16 +623,19 @@ {:else}
- {primary?.data.name} + {primary?.data.label ?? primary?.data.name} {#if d}Catalog ↗{/if}
{d ? kindLine(d) : primary?.data.kind} - {#if d}{d.group}{#if d.owner.length}{d.owner.join(', ')}{/if}{/if} + {#if d}{#if d.group}{d.group}{/if}{#if d.owner.length}{d.owner.join(', ')}{/if}{/if}
{/if}
+ {#if !scaleMode && !primary} +

Select a node to see its details.

+ {:else} {#if scaleMode}

A synthetic graph for layout and rendering at scale; nodes have no catalog detail.

@@ -660,7 +665,7 @@ {#snippet meta()}{#if d.lastBuild?.runId}View run →{/if}{/snippet}
- {#if d.lastOutcome}{:else}{/if} + {#if d.lastOutcome}{:else if !d.lastBuild}{/if}
@@ -673,10 +678,10 @@
{/if} {#each d.stale as r, i (i)} - {/each}
@@ -697,7 +702,7 @@ {#if d?.sql} {#snippet meta()}{sqlRaw.split('\n').length} lines{/snippet} - openSql()} /> + openSql()} /> {:else if d?.code} @@ -728,7 +733,7 @@ {/if} - {#if d && !isSource} + {#if d && !isSource && d.runs} {#snippet meta()}All →{/snippet} {#each d.runs.slice(0, 3) as run, i (i)} @@ -767,13 +772,14 @@
Kind
{kindLine(d)}
Unique key
{sqlOutline.config.find((c) => c.key === 'unique_key')?.value ?? '—'}
-
Group
{d.group}
+
Group
{d.group ?? '—'}
Owner
{d.owner.join(', ') || '—'}
Tags
{d.tags.join(', ') || '—'}
Build version
{d.lastBuild?.fingerprint.slice(0, 12) ?? '—'}
{/if} + {/if}