From 1e4aa20ade249d29e443e0f6fb8da13d27ef4e96 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:46:22 +0000 Subject: [PATCH 1/3] feat(ui): wire the lineage and changes pages to the live control API Add a person read of a target's published snapshots with the subjects each one changed, behind the same listing authorisation as jobs. The Changes page reads it, and the Lineage page builds its graph and inspector from the catalog listing, so neither page needs mock data. --- clients/typescript/src/schema.d.ts | 79 +++++++++++++++++ .../authentication/password_sessions.rs | 4 + .../api/_helpers/resources/person_reads.rs | 27 +++++- .../sqlbuild-orchestration/src/api/models.rs | 1 + .../snapshots/main/list_person_snapshots.rs | 84 +++++++++++++++++++ .../src/snapshots/main/mod.rs | 1 + .../src/snapshots/models.rs | 23 +++++ .../tests/api/test_person_sessions.rs | 1 + .../tests/api/test_reads.rs | 51 ++++++++++- fensu.toml | 1 - ui/src/lib/api/http-changes.test.ts | 61 ++++++++++++++ ui/src/lib/api/http-changes.ts | 65 ++++++++++++++ ui/src/lib/api/http-client.ts | 16 +++- ui/src/lib/api/types/build.ts | 8 +- ui/src/lib/nav.test.ts | 5 +- ui/src/lib/nav.ts | 2 +- ui/src/routes/changes/+page.svelte | 13 +-- ui/src/routes/changes/utils.ts | 10 ++- ui/src/routes/lineage/+page.svelte | 21 ++++- 19 files changed, 451 insertions(+), 22 deletions(-) create mode 100644 crates/sqlbuild-orchestration/src/snapshots/main/list_person_snapshots.rs create mode 100644 ui/src/lib/api/http-changes.test.ts create mode 100644 ui/src/lib/api/http-changes.ts diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 117fb8c855..35c7f687f4 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -482,6 +482,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/control/locations/{location_id}/targets/{target}/snapshots": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPersonSnapshots"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/control/locations/{location_id}/targets/{target}/statistics": { parameters: { query?: never; @@ -1488,6 +1504,28 @@ export interface components { }; /** @enum {string} */ PlatformEventKind: "heartbeat" | "scheduler_no_leader" | "tick_overdue" | "calendar_horizon_low" | "location_broken" | "launch_failed"; + /** @description A changed subject, such as `model:orders` or `role:analyst`, without its content hashes. */ + PublishedChange: { + change_kind: string; + subject_key: string; + }; + /** @description One publication as people browse it: its commit, environment and changed definitions. */ + PublishedSnapshot: { + /** @description Added, changed and removed nodes and roles, in publication order. */ + changes: components["schemas"]["PublishedChange"][]; + /** @description Digest of the lock file, Python and `sqb` versions; a change means dependencies changed. */ + environment_hash: string; + /** Format: int64 */ + generation: number; + git_author?: string | null; + git_dirty: boolean; + git_revision?: string | null; + /** Format: date-time */ + published_at: string; + /** Format: uuid */ + snapshot_id: string; + sqb_version: string; + }; /** @description One invocation's records; the server owns its claim namespace, start time and version. */ RecallBatch: { invocation_id: string; @@ -3286,6 +3324,47 @@ export interface operations { }; }; }; + listPersonSnapshots: { + parameters: { + query?: { + /** @description Maximum recent publications; defaults to 20, capped at 100 */ + limit?: number; + }; + header?: never; + path: { + location_id: string; + target: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Publications newest first, each with its changed nodes and roles */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublishedSnapshot"][]; + }; + }; + /** @description Session rejected */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorBody"]; + }; + }; + }; + }; readPersonStatistics: { parameters: { query: { diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs index f40bb3d87d..5bce2d5b43 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs @@ -117,6 +117,10 @@ pub(crate) fn person_routes() -> Router { "/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/ticks", get(crate::api::_helpers::resources::person_reads::ticks), ) + .route( + "/api/control/locations/{location_id}/targets/{target}/snapshots", + get(crate::api::_helpers::resources::person_reads::snapshots), + ) .route( "/api/control/locations/{location_id}/targets/{target}/catalog", get(crate::api::_helpers::resources::person_operations::catalog), diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs index 79fbb670ad..aa10e0c867 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs @@ -1,4 +1,4 @@ -//! Person reads of run nodes, catalog nodes, jobs, triggers and ticks; refusal precedes every lookup, so unknown objects look forbidden. +//! Person reads of run nodes, catalog nodes, jobs, triggers, ticks and publications; refusal precedes every lookup, so unknown objects look forbidden. use axum::extract::{Path, Query, State}; use axum::http::StatusCode; @@ -15,6 +15,8 @@ use crate::runs::main::list_person_run_nodes::list_person_run_nodes; use crate::runs::models::RunNodeRecord; use crate::scheduler::main::list_person_ticks::list_person_ticks; use crate::scheduler::models::TickRecord; +use crate::snapshots::main::list_person_snapshots::list_person_snapshots; +use crate::snapshots::models::PublishedSnapshot; use crate::store::models::ControlStore; use crate::warehouse_metadata::main::list_person_catalog_nodes::list_person_catalog_nodes; use crate::warehouse_metadata::models::CatalogNodeRecord; @@ -136,6 +138,29 @@ pub(crate) async fn ticks( .ok_or_else(|| not_found("trigger not found")) } +#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/snapshots", operation_id = "listPersonSnapshots", + params(("location_id" = Uuid, Path), ("target" = String, Path), + ("limit" = Option, Query, description = "Maximum recent publications; defaults to 20, capped at 100")), + responses((status = 200, body = Vec, description = "Publications newest first, each with its changed nodes and roles"), + (status = 401, description = "Session rejected"), + (status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn snapshots( + (State(store), Extension(context)): (State, Extension), + Extension(default_role): Extension, + (Path((location_id, target)), Query(options)): (Path<(Uuid, String)>, Query), + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result>, ApiError> { + Ok(Json( + list_person_snapshots( + &store, + (&context, principal), + ((location_id, &target), options.limit.unwrap_or(20)), + default_role, + ) + .await?, + )) +} + fn not_found(message: &str) -> ApiError { ApiError { status: StatusCode::NOT_FOUND, diff --git a/crates/sqlbuild-orchestration/src/api/models.rs b/crates/sqlbuild-orchestration/src/api/models.rs index 530893c084..9571345a46 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -25,6 +25,7 @@ use crate::api::_helpers::resources::{ person_reads::jobs, person_reads::triggers, person_reads::ticks, + person_reads::snapshots, person_operations::catalog, person_operations::statistics, person_operations::fingerprint, diff --git a/crates/sqlbuild-orchestration/src/snapshots/main/list_person_snapshots.rs b/crates/sqlbuild-orchestration/src/snapshots/main/list_person_snapshots.rs new file mode 100644 index 0000000000..eaf79c0e86 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/snapshots/main/list_person_snapshots.rs @@ -0,0 +1,84 @@ +//! A person browses a target's publications only with view on every current node, like its jobs. + +use std::collections::BTreeMap; + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::authorization::main::authorize_listing::authorize_listing; +use crate::credentials::models::{AuditContext, DefaultRole}; +use crate::snapshots::models::{PublishedChange, PublishedSnapshot}; +use crate::store::errors::ControlError; +use crate::store::main::{begin::begin, commit::commit, decode_row::decode_row, query::query}; +use crate::store::models::{ControlStore, Parameter, TransactionMode}; +use crate::store::types::Row; + +/// Newest first, at most `limit` (capped at 100) publications with their changes. +pub async fn list_person_snapshots( + store: &ControlStore, + (context, credentials): (&AuditContext, Uuid), + ((location_id, target), limit): ((Uuid, &str), u32), + default_role: DefaultRole, +) -> Result, ControlError> { + let (transaction, _) = authorize_listing( + begin(store, TransactionMode::Write).await?, + (context, credentials), + (location_id, target), + default_role, + ) + .await?; + let scope = vec![ + Parameter::Id(location_id), + Parameter::Text(target.into()), + Parameter::Integer(i64::from(limit.min(100))), + ]; + let (transaction, published) = query(transaction, + "SELECT snapshot_id, generation, git_revision, CASE WHEN git_dirty THEN 1 ELSE 0 END AS git_dirty, git_author, environment_hash, sqb_version, published_at \ + FROM sqb_snapshots WHERE location_id=$1 AND target=$2 ORDER BY generation DESC LIMIT $3", + scope.clone()).await?; + let (transaction, changed) = query(transaction, + "SELECT c.snapshot_id, c.change_kind, c.subject_key FROM sqb_snapshot_changes c \ + WHERE c.snapshot_id IN (SELECT s.snapshot_id FROM sqb_snapshots s WHERE s.location_id=$1 AND s.target=$2 \ + ORDER BY s.generation DESC LIMIT $3) ORDER BY c.snapshot_id, c.change_seq", + scope).await?; + commit(transaction).await?; + let mut changes: BTreeMap> = BTreeMap::new(); + for row in changed { + let snapshot_id = id_of(&row)?; + changes + .entry(snapshot_id) + .or_default() + .push(decode_row(row)?); + } + published + .into_iter() + .map(|row| { + let mut snapshot = snapshot_of(row)?; + snapshot.changes = changes.remove(&snapshot.snapshot_id).unwrap_or_default(); + Ok(snapshot) + }) + .collect() +} + +fn snapshot_of(row: Row) -> Result { + let snapshot_id = id_of(&row)?; + let text = |key: &str| row[key].as_str().map(String::from); + let published_at: DateTime = serde_json::from_value(row["published_at"].clone()) + .map_err(|error| ControlError::Invalid(format!("stored publication time: {error}")))?; + Ok(PublishedSnapshot { + snapshot_id, + generation: row["generation"].as_i64().unwrap_or_default(), + git_revision: text("git_revision"), + git_dirty: row["git_dirty"].as_i64() == Some(1), + git_author: text("git_author"), + environment_hash: text("environment_hash").unwrap_or_default(), + sqb_version: text("sqb_version").unwrap_or_default(), + published_at, + changes: Vec::new(), + }) +} + +fn id_of(row: &Row) -> Result { + serde_json::from_value(row["snapshot_id"].clone()) + .map_err(|error| ControlError::Invalid(format!("stored snapshot id: {error}"))) +} diff --git a/crates/sqlbuild-orchestration/src/snapshots/main/mod.rs b/crates/sqlbuild-orchestration/src/snapshots/main/mod.rs index 342c47fd5a..8c05cf753a 100644 --- a/crates/sqlbuild-orchestration/src/snapshots/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/snapshots/main/mod.rs @@ -2,6 +2,7 @@ pub(super) mod _publish_snapshot; pub mod current_snapshot; pub mod current_snapshot_record; pub(crate) mod job_write_set; +pub mod list_person_snapshots; pub mod next_remote_generation; pub(crate) mod prune_snapshots; pub(crate) mod publish_development_snapshot; diff --git a/crates/sqlbuild-orchestration/src/snapshots/models.rs b/crates/sqlbuild-orchestration/src/snapshots/models.rs index 4fef7aee74..0428d221c6 100644 --- a/crates/sqlbuild-orchestration/src/snapshots/models.rs +++ b/crates/sqlbuild-orchestration/src/snapshots/models.rs @@ -122,3 +122,26 @@ impl Default for SnapshotRetention { } } } + +/// One publication as people browse it: its commit, environment and changed definitions. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, utoipa::ToSchema)] +pub struct PublishedSnapshot { + pub snapshot_id: Uuid, + pub generation: i64, + pub git_revision: Option, + pub git_dirty: bool, + pub git_author: Option, + /// Digest of the lock file, Python and `sqb` versions; a change means dependencies changed. + pub environment_hash: String, + pub sqb_version: String, + pub published_at: DateTime, + /// Added, changed and removed nodes and roles, in publication order. + pub changes: Vec, +} + +/// A changed subject, such as `model:orders` or `role:analyst`, without its content hashes. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PublishedChange { + pub change_kind: String, + pub subject_key: String, +} diff --git a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs index f2d7d6a691..cc253368c7 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs @@ -288,6 +288,7 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_ format!("/api/control/locations/{location}/targets/development/nodes"), format!("/api/control/locations/{location}/targets/development/jobs"), format!("/api/control/locations/{location}/targets/development/triggers"), + format!("/api/control/locations/{location}/targets/development/snapshots"), format!( "/api/control/locations/{location}/targets/development/triggers/{}/ticks", trigger.trigger_id diff --git a/crates/sqlbuild-orchestration/tests/api/test_reads.rs b/crates/sqlbuild-orchestration/tests/api/test_reads.rs index e3adc23031..2f3db1edf0 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_reads.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_reads.rs @@ -1,12 +1,14 @@ //! Real HTTP reads keep both location and target scope, including guessed nested resource IDs. +use crate::authorization::main::bootstrap_user::bootstrap_user; use crate::scheduler::tests::contracts::ticks::helpers::calendar_text; +use crate::snapshots::main::list_person_snapshots::list_person_snapshots; use std::collections::BTreeMap; use std::time::Duration; use crate::credentials::main::tokens::issue_token::issue_token; use crate::credentials::main::tokens::revoke_token::revoke_token; -use crate::credentials::models::{AuditContext, CredentialScope}; +use crate::credentials::models::{AuditContext, CredentialScope, DefaultRole}; use crate::declarations::main::change_trigger::change_trigger; use crate::declarations::main::list_triggers::list_triggers; use crate::declarations::models::{TriggerChange, TriggerChangeRequest}; @@ -323,6 +325,53 @@ fn given_published_locations_and_scheduled_work_when_read_through_http_then_reso ); assert_eq!(ticks[0]["outcome"], "requested"); assert_eq!(ticks[0]["scheduled_for"], calendar_text(2)); + let administrator = bootstrap_user(store, &audit, "orders-admin") + .await + .expect("administrator"); + let mut extra = input.clone(); + extra.generation += 1; + extra.nodes.remove("products"); + extra.git_dirty = !input.git_dirty; + publish_snapshot(store, &extra) + .await + .expect("second publication"); + input.generation = extra.generation; + let history = list_person_snapshots( + store, + (&audit, administrator), + ((location_id, "development"), 20), + DefaultRole::None, + ) + .await + .expect("person snapshots"); + assert_eq!( + history.iter().map(|s| s.generation).collect::>(), + [extra.generation, published.generation], + "newest first, this target only" + ); + assert_eq!( + json!(history[0].changes), + json!([{"change_kind": "removed", "subject_key": "products"}]), + "only the subjects that changed" + ); + assert!(!history[1].changes.is_empty()); + assert_eq!( + (history[0].git_dirty, history[1].git_dirty), + (extra.git_dirty, input.git_dirty), + "the working-tree flag survives each store's boolean type" + ); + assert_eq!( + list_person_snapshots( + store, + (&audit, administrator), + ((location_id, "development"), 1), + DefaultRole::None, + ) + .await + .expect("bounded snapshots") + .len(), + 1 + ); for path in [&run_path, &event_path, &tick_path, &invocation_path] { assert_eq!( read_json(&client, (&format!("{path}?limit=1"), &token.bearer)) diff --git a/fensu.toml b/fensu.toml index bee7f18a71..7c1f8e7b53 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1637,7 +1637,6 @@ allowed = [ "sqb_platform_sink_deliveries.delivered_at=Audit history read through audit exports: when an event reached the platform sink.", "sqb_security_audit_deliveries.delivered_at=Audit history read through audit exports: when an event reached a security audit sink.", "sqb_run_requests.evaluation_id=Reader comes with the operations UI: a sensor's run requests show the evaluation that made them.", - "sqb_snapshots.git_author=Reader comes with the operations UI: the change feed shows who published each snapshot.", "sqb_write_claims=Read through its primary-key conflict: an insert that conflicts means another run already holds the node.", "sqb_warehouse_microbatches.execution_run_id=Reader comes with the UI epic (D132), which filters on it: the run that executed the batch.", "sqb_warehouse_microbatches.record_type=Reader comes with the UI epic (D132), which filters on it: the batch state.", diff --git a/ui/src/lib/api/http-changes.test.ts b/ui/src/lib/api/http-changes.test.ts new file mode 100644 index 0000000000..164b1a45ff --- /dev/null +++ b/ui/src/lib/api/http-changes.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import type { components } from '$control/schema.d.ts'; +import { toSnapshotChanges } from './http-changes'; +import type { Run } from './types/operate'; + +type W = components['schemas']; + +function snapshot(id: string, generation: number, environment: string, changes: [string, string][]): W['PublishedSnapshot'] { + return { + snapshot_id: id, + generation, + git_revision: generation === 2 ? 'a1b2c3d' : null, + git_dirty: generation === 2, + git_author: generation === 2 ? 'Avery Example' : null, + environment_hash: environment, + sqb_version: '0.200.0', + published_at: `2026-11-0${generation}T09:00:00Z`, + changes: changes.map(([change_kind, subject_key]) => ({ change_kind, subject_key })) + }; +} + +function run(id: string, snapshotId: string, queuedAt: string, parentRunId: string | null = null): Run { + return { id, snapshotId, parentRunId, jobKey: 'orders', kind: 'job', status: 'success', startedAt: queuedAt, queuedAt } as Run; +} + +describe('change feed mapping', () => { + const newest = snapshot('s-2', 2, 'env-bbbbbbbbbbbbbbbb', [ + ['removed', 'model:stock'], + ['changed', 'seed:regions'], + ['added', 'role:analyst'] + ]); + const oldest = snapshot('s-1', 1, 'env-aaaaaaaaaaaaaaaa', [['added', 'model:orders']]); + // Runs are listed newest first, as the server returns them. + const runs = [run('r-late', 's-2', '2026-11-02T11:00:00Z'), run('r-child', 's-2', '2026-11-02T10:30:00Z', 'r-early'), run('r-early', 's-2', '2026-11-02T10:00:00Z'), run('r-old', 's-1', '2026-11-01T10:00:00Z')]; + const [second, first] = toSnapshotChanges([newest, oldest], runs); + + it('splits roles from nodes and keeps each change kind', () => { + expect(second.nodes.map((n) => [n.op, n.key, n.kind])).toEqual([ + ['removed', 'model:stock', null], + ['changed', 'seed:regions', 'seed'] + ]); + expect(second.roles.map((r) => [r.op, r.role])).toEqual([['added', 'analyst']]); + }); + + it('reports an environment change only against the previous publication', () => { + expect(second.environment).toMatchObject({ before: 'env-aaaaaaaa', after: 'env-bbbbbbbb' }); + expect(second.previousGeneration).toBe(1); + expect(first.environment).toBeNull(); + expect(first.previousGeneration).toBeNull(); + }); + + it("lists each deploy's own top-level runs, oldest first", () => { + expect(second.followingRuns.map((r) => r.id)).toEqual(['r-early', 'r-late']); + expect(first.followingRuns.map((r) => r.id)).toEqual(['r-old']); + }); + + it('keeps missing git facts empty instead of inventing them', () => { + expect([second.gitRevision, second.gitAuthor, second.gitDirty]).toEqual(['a1b2c3d', 'Avery Example', true]); + expect([first.gitRevision, first.gitAuthor, first.publishedBy]).toEqual([null, null, null]); + }); +}); diff --git a/ui/src/lib/api/http-changes.ts b/ui/src/lib/api/http-changes.ts new file mode 100644 index 0000000000..518a43ed28 --- /dev/null +++ b/ui/src/lib/api/http-changes.ts @@ -0,0 +1,65 @@ +import type { components } from '$control/schema.d.ts'; +import type { ChangeOp, NodeChange, RoleChange, SnapshotChange } from './types/build'; +import type { NodeKind, Run } from './types/operate'; + +type Wire = components['schemas']; + +/** How many runs of each deploy the feed lists. */ +const FOLLOWING_RUNS = 8; + +const NODE_KINDS: Record = { seed: 'seed', source: 'source' }; + +function opOf(kind: string): ChangeOp { + return kind === 'added' || kind === 'removed' ? kind : 'changed'; +} + +function nodeChange(change: Wire['PublishedChange']): NodeChange { + const prefix = change.subject_key.split(':', 1)[0]; + return { + key: change.subject_key, + kind: NODE_KINDS[prefix] ?? null, + op: opOf(change.change_kind), + formerKey: null, + compiledOnly: false, + detail: '', + columns: [] + }; +} + +function roleChange(change: Wire['PublishedChange']): RoleChange { + return { role: change.subject_key.slice('role:'.length), op: opOf(change.change_kind), detail: '', coverage: null }; +} + +/** + * Publications newest first, as the change feed shows them. The server records which nodes and + * roles changed, not job, trigger, column or cost changes, so those stay empty. Each deploy's runs + * are the listed runs pinned to its snapshot, oldest first. + */ +export function toSnapshotChanges(wire: Wire['PublishedSnapshot'][], runs: Run[]): SnapshotChange[] { + return wire.map((snapshot, index): SnapshotChange => { + const previous = wire[index + 1] ?? null; + const environmentChanged = previous !== null && previous.environment_hash !== snapshot.environment_hash; + return { + snapshotId: snapshot.snapshot_id, + generation: snapshot.generation, + previousGeneration: previous?.generation ?? null, + gitRevision: snapshot.git_revision ?? null, + gitDirty: snapshot.git_dirty, + gitAuthor: snapshot.git_author ?? null, + publishedAt: snapshot.published_at, + publishedBy: null, + nodes: snapshot.changes.filter((c) => !c.subject_key.startsWith('role:')).map(nodeChange), + orchestration: [], + roles: snapshot.changes.filter((c) => c.subject_key.startsWith('role:')).map(roleChange), + environment: environmentChanged + ? { before: previous.environment_hash.slice(0, 12), after: snapshot.environment_hash.slice(0, 12), detail: 'Dependencies or the sqb version changed' } + : null, + followingRuns: runs + .filter((r) => r.snapshotId === snapshot.snapshot_id && !r.parentRunId) + .reverse() + .slice(0, FOLLOWING_RUNS) + .map(({ id, jobKey, kind, status, startedAt, queuedAt }) => ({ id, jobKey, kind, status, startedAt, queuedAt })), + costChanges: null + }; + }); +} diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index 70a1df67b9..03baee8ca5 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -3,11 +3,12 @@ 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 { 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 } from './types/build'; +import type { CatalogNode, 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']; @@ -36,7 +37,8 @@ const SUPPORTED: ReadonlySet = new Set([ 'getCheck', 'getCheckSql', 'getCheckEvidence', - 'listCatalogNodes' + 'listCatalogNodes', + 'listSnapshotChanges' ]); /** A UI location is one server location and target; its id carries both. */ @@ -244,6 +246,16 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt if (!data) throw new ApiError(errorText(error, response), response.status); return toCatalogNodes(data); }, + async listSnapshotChanges(scope: Scope, query: { limit?: number } = {}): Promise { + const { locationId, target } = parseLocationKey(scope.locationId); + const [snapshots, runs] = await Promise.all([ + http.GET('/api/control/locations/{location_id}/targets/{target}/snapshots', { params: { path: { location_id: locationId, target }, query: { limit: query.limit } } }), + recentRuns(scope) + ]); + if (snapshots.response.status === 401) onUnauthorized(); + if (!snapshots.data) throw new ApiError(errorText(snapshots.error, snapshots.response), snapshots.response.status); + return toSnapshotChanges(snapshots.data, runs); + }, 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 6bc9353ffb..2eb5eb15b8 100644 --- a/ui/src/lib/api/types/build.ts +++ b/ui/src/lib/api/types/build.ts @@ -315,11 +315,13 @@ export type SnapshotChange = { snapshotId: string; generation: number; previousGeneration: number | null; - gitRevision: string; + /** Null when the project was published from outside a git checkout. */ + gitRevision: string | null; gitDirty: boolean; - gitAuthor: string; + gitAuthor: string | null; publishedAt: Iso; - publishedBy: Principal; + /** Null when the server does not record who published. */ + publishedBy: Principal | null; nodes: NodeChange[]; orchestration: OrchestrationChange[]; roles: RoleChange[]; diff --git a/ui/src/lib/nav.test.ts b/ui/src/lib/nav.test.ts index 8cdc115678..f0eaa47e62 100644 --- a/ui/src/lib/nav.test.ts +++ b/ui/src/lib/nav.test.ts @@ -16,8 +16,9 @@ describe('visibleNav', () => { ]); }); - it('keeps lineage hidden until node detail is served, even when the catalog list is', () => { - expect(labels(serving('listCatalogNodes'), ['view'])).toEqual([['Build', ['Catalog']]]); + it('shows lineage with the catalog list, since the graph needs no node detail', () => { + expect(labels(serving('listCatalogNodes'), ['view'])).toEqual([['Build', ['Lineage', 'Catalog']]]); + expect(labels(serving('getCatalogNode'), ['view'])).toEqual([]); }); it('hides admin pages from people without an admin capability, even when the server serves them', () => { diff --git a/ui/src/lib/nav.ts b/ui/src/lib/nav.ts index 269b0c967e..789191fae9 100644 --- a/ui/src/lib/nav.ts +++ b/ui/src/lib/nav.ts @@ -26,7 +26,7 @@ export const NAV: readonly NavGroup[] = [ { section: 'Build', items: [ - { label: 'Lineage', href: '/lineage', icon: GitBranchIcon, loads: 'getCatalogNode' }, + { label: 'Lineage', href: '/lineage', icon: GitBranchIcon, loads: 'listCatalogNodes' }, { label: 'Catalog', href: '/catalog', icon: DatabaseIcon, loads: 'listCatalogNodes', badge: { value: (c) => c.catalogNodes, tone: 'muted' } }, { label: 'Checks', href: '/checks', icon: CheckCircleIcon, loads: 'listChecks', badge: { value: (c) => c.failingChecks, tone: 'warning' } }, { label: 'Changes', href: '/changes', icon: GitCompareIcon, loads: 'listSnapshotChanges' } diff --git a/ui/src/routes/changes/+page.svelte b/ui/src/routes/changes/+page.svelte index dbb27390dd..ba98226c03 100644 --- a/ui/src/routes/changes/+page.svelte +++ b/ui/src/routes/changes/+page.svelte @@ -12,7 +12,7 @@ import { runStatusLabel, runStatusTone } from '$lib/status'; import { changeOpLabel, changeOpTone, formatCredits } from '$lib/build-area/labels'; import { summarizeChange } from '$lib/build-area/changes'; - import { orchestrationHref, percentChange } from './utils'; + import { nodeHref, orchestrationHref, percentChange } from './utils'; type Area = 'all' | 'nodes' | 'orchestration' | 'roles' | 'environment'; @@ -32,7 +32,7 @@ if (!hasArea(c, area)) return false; const q = query.trim().toLowerCase(); if (!q) return true; - return [c.gitRevision, c.gitAuthor, String(c.generation), ...c.nodes.map((n) => n.key), ...c.nodes.map((n) => n.formerKey ?? ''), ...c.orchestration.map((o) => o.key), ...c.roles.map((r) => r.role)].some((s) => s.toLowerCase().includes(q)); + return [c.gitRevision ?? '', c.gitAuthor ?? '', String(c.generation), ...c.nodes.map((n) => n.key), ...c.nodes.map((n) => n.formerKey ?? ''), ...c.orchestration.map((o) => o.key), ...c.roles.map((r) => r.role)].some((s) => s.toLowerCase().includes(q)); }) ); const areaOptions: { value: Area; label: string }[] = [ @@ -62,12 +62,12 @@
Version {c.generation} - {c.gitRevision}{c.gitDirty ? ' · with uncommitted changes' : ''} + {#if c.gitRevision}{c.gitRevision}{c.gitDirty ? ' · with uncommitted changes' : ''}{/if} {summarizeChange(c)}
- {c.gitAuthor} - deployed by {c.publishedBy.displayName} · {formatAgo(c.publishedAt, app.now)} + {#if c.gitAuthor}{c.gitAuthor}{/if} + {c.publishedBy ? `deployed by ${c.publishedBy.displayName} · ` : 'published '}{formatAgo(c.publishedAt, app.now)}
@@ -76,9 +76,10 @@

Nodes {c.nodes.length}

{#each c.nodes as n (n.key + n.op)} + {@const href = nodeHref(n, app.client.supports('getCatalogNode'))}
{changeOpLabel[n.op]} - {#if n.op === 'removed'}{n.key}{:else}{n.key}{/if} + {#if href}{n.key}{:else}{n.key}{/if} {n.detail}{#if n.compiledOnly}compiled only{/if}
{#each n.columns as col (col.name)} diff --git a/ui/src/routes/changes/utils.ts b/ui/src/routes/changes/utils.ts index 145a2de178..ae85baf1c3 100644 --- a/ui/src/routes/changes/utils.ts +++ b/ui/src/routes/changes/utils.ts @@ -1,6 +1,6 @@ // Pure helpers for the Changes page. -import type { OrchestrationChange } from '$lib/api'; +import type { NodeChange, OrchestrationChange } from '$lib/api'; export function orchestrationHref(change: OrchestrationChange): string | null { if (change.op === 'removed') return null; @@ -11,3 +11,11 @@ export function orchestrationHref(change: OrchestrationChange): string | null { export function percentChange(before: number, after: number): number { return before ? Math.round(((after - before) / before) * 100) : 0; } + +/** A changed node's page: its catalog detail, or the lineage graph when the server has no detail read. */ +export function nodeHref(change: NodeChange, catalogDetail: boolean): string | null { + if (change.op === 'removed') return null; + if (catalogDetail) return `/catalog/${change.key}${change.op === 'changed' ? '?tab=drift' : ''}`; + const graphNode = change.kind !== null || change.key.startsWith('model:') || !change.key.includes(':'); + return graphNode ? `/lineage?select=${encodeURIComponent(change.key)}` : null; +} diff --git a/ui/src/routes/lineage/+page.svelte b/ui/src/routes/lineage/+page.svelte index 83f904fb3f..3bb38719e1 100644 --- a/ui/src/routes/lineage/+page.svelte +++ b/ui/src/routes/lineage/+page.svelte @@ -243,11 +243,14 @@ const primary = $derived(selectedNodes[0] ?? nodes.find((n) => n.id === scaleStartId)); const primarySlug = $derived(primary?.data.name ?? ''); // Inspector detail for the primary selection; the scale-test graph is synthetic, so it has none. + // Without a detail read the inspector shows what the catalog listing reports. + const detailSupported = app.client.supports('getCatalogNode'); const detail = createResource( - (client, scope) => (scaleMode || !primarySlug ? Promise.resolve(null) : client.getCatalogNode(scope, String(primarySlug))), + (client, scope) => (scaleMode || !primarySlug || !detailSupported ? Promise.resolve(null) : client.getCatalogNode(scope, String(primarySlug))), ['runs', 'checks', 'snapshot', 'node_status'] ); const d = $derived(detail.data && detail.data.key === primarySlug ? detail.data : null); + const listed = $derived(detailSupported ? null : (catalogByKey.get(String(primarySlug)) ?? null)); const isSource = $derived(d?.kind === 'source'); const hasSql = $derived(!!d?.sql); @@ -455,7 +458,7 @@ - (runOpen = true)}>▷ Execute{runSelection.length ? ` ${runSelection.length}` : ''} + {#if app.client.supports('launchAdhocRun')} (runOpen = true)}>▷ Execute{runSelection.length ? ` ${runSelection.length}` : ''}{/if} @@ -632,6 +635,16 @@

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

+ {:else if listed} + +
+
Kind
{kindLine(listed)}
+ {#if listed.relation}
Relation
{relationName(listed.relation)}
{/if} + {#if listed.kind !== 'source'}
Last built
{#if listed.lastBuild}{:else}No build recorded{/if}
{/if} + {#if listed.tags.length}
Tags
{listed.tags.join(', ')}
{/if} + {#if listed.path}
File
{listed.path}
{/if} +
+
{:else if !d}

{detail.error ?? 'Loading…'}

{:else if isSource} @@ -675,9 +688,9 @@ {/if} - {#if d?.description} + {#if (d ?? listed)?.description} -

{d.description}

+

{(d ?? listed)?.description}

{/if} From d26ee957b96bd83e7428b4f05cb3f093ec1f593d Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:15:51 +0000 Subject: [PATCH 2/3] feat(server): name changed roles only for people who assign roles The publication history shows a changed role as a bare `role:` to people without the role.assign action server-wide, and the Changes page lists it as "a role". --- .../src/authorization/main/mod.rs | 1 + .../main/person_assigns_roles.rs | 26 ++++++++++ .../snapshots/main/list_person_snapshots.rs | 13 +++-- .../src/snapshots/models.rs | 2 +- .../tests/api/test_reads.rs | 48 +++++++++++++++++-- ui/src/routes/changes/+page.svelte | 6 ++- 6 files changed, 85 insertions(+), 11 deletions(-) create mode 100644 crates/sqlbuild-orchestration/src/authorization/main/person_assigns_roles.rs diff --git a/crates/sqlbuild-orchestration/src/authorization/main/mod.rs b/crates/sqlbuild-orchestration/src/authorization/main/mod.rs index 66851f5c97..03b7ff29e1 100644 --- a/crates/sqlbuild-orchestration/src/authorization/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/authorization/main/mod.rs @@ -7,6 +7,7 @@ pub mod change_administrator; pub mod compile_roles; pub(crate) mod deny_authorization; pub mod grant_admin; +pub(crate) mod person_assigns_roles; pub(crate) mod person_lists_target; pub(crate) mod publish_custom_roles; pub(crate) mod publish_group_roles; diff --git a/crates/sqlbuild-orchestration/src/authorization/main/person_assigns_roles.rs b/crates/sqlbuild-orchestration/src/authorization/main/person_assigns_roles.rs new file mode 100644 index 0000000000..1cd4e1c145 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/authorization/main/person_assigns_roles.rs @@ -0,0 +1,26 @@ +//! Whether a person may assign roles server-wide; unaudited, for deciding what a read may name. + +use std::collections::BTreeSet; + +use uuid::Uuid; + +use crate::authorization::_helpers::person_grants::{allowed, person_role_rows}; +use crate::credentials::models::{Action, DefaultRole}; +use crate::store::errors::ControlError; +use crate::store::models::ControlTransaction; + +/// Inside the caller's transaction, so the answer matches the rest of its read. +pub(crate) async fn person_assigns_roles( + transaction: ControlTransaction, + principal_id: Uuid, +) -> Result<(ControlTransaction, bool), ControlError> { + let (transaction, rows) = person_role_rows(transaction, principal_id, None).await?; + let assigns = allowed( + &rows.unwrap_or_default(), + (None, None), + &BTreeSet::new(), + DefaultRole::None, + ) + .contains(&Action::RoleAssign); + Ok((transaction, assigns)) +} diff --git a/crates/sqlbuild-orchestration/src/snapshots/main/list_person_snapshots.rs b/crates/sqlbuild-orchestration/src/snapshots/main/list_person_snapshots.rs index eaf79c0e86..33605212c7 100644 --- a/crates/sqlbuild-orchestration/src/snapshots/main/list_person_snapshots.rs +++ b/crates/sqlbuild-orchestration/src/snapshots/main/list_person_snapshots.rs @@ -6,6 +6,7 @@ use chrono::{DateTime, Utc}; use uuid::Uuid; use crate::authorization::main::authorize_listing::authorize_listing; +use crate::authorization::main::person_assigns_roles::person_assigns_roles; use crate::credentials::models::{AuditContext, DefaultRole}; use crate::snapshots::models::{PublishedChange, PublishedSnapshot}; use crate::store::errors::ControlError; @@ -13,7 +14,7 @@ use crate::store::main::{begin::begin, commit::commit, decode_row::decode_row, q use crate::store::models::{ControlStore, Parameter, TransactionMode}; use crate::store::types::Row; -/// Newest first, at most `limit` (capped at 100) publications with their changes. +/// Newest first, at most 100 publications; changed roles are named only for people who assign roles. pub async fn list_person_snapshots( store: &ControlStore, (context, credentials): (&AuditContext, Uuid), @@ -41,14 +42,16 @@ pub async fn list_person_snapshots( WHERE c.snapshot_id IN (SELECT s.snapshot_id FROM sqb_snapshots s WHERE s.location_id=$1 AND s.target=$2 \ ORDER BY s.generation DESC LIMIT $3) ORDER BY c.snapshot_id, c.change_seq", scope).await?; + let (transaction, names_roles) = person_assigns_roles(transaction, credentials).await?; commit(transaction).await?; let mut changes: BTreeMap> = BTreeMap::new(); for row in changed { let snapshot_id = id_of(&row)?; - changes - .entry(snapshot_id) - .or_default() - .push(decode_row(row)?); + let mut change: PublishedChange = decode_row(row)?; + if !names_roles && change.subject_key.starts_with("role:") { + change.subject_key = "role:".into(); + } + changes.entry(snapshot_id).or_default().push(change); } published .into_iter() diff --git a/crates/sqlbuild-orchestration/src/snapshots/models.rs b/crates/sqlbuild-orchestration/src/snapshots/models.rs index 0428d221c6..82c6f9d5a3 100644 --- a/crates/sqlbuild-orchestration/src/snapshots/models.rs +++ b/crates/sqlbuild-orchestration/src/snapshots/models.rs @@ -139,7 +139,7 @@ pub struct PublishedSnapshot { pub changes: Vec, } -/// A changed subject, such as `model:orders` or `role:analyst`, without its content hashes. +/// A changed subject, such as `model:orders`, or `role:analyst` (`role:` for those who can't assign roles). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct PublishedChange { pub change_kind: String, diff --git a/crates/sqlbuild-orchestration/tests/api/test_reads.rs b/crates/sqlbuild-orchestration/tests/api/test_reads.rs index 2f3db1edf0..52ed1644f1 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_reads.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_reads.rs @@ -1,6 +1,8 @@ //! Real HTTP reads keep both location and target scope, including guessed nested resource IDs. +use crate::authorization::main::assign_builtin_role::assign_builtin_role; use crate::authorization::main::bootstrap_user::bootstrap_user; +use crate::credentials::main::identities::create_principal::create_principal; use crate::scheduler::tests::contracts::ticks::helpers::calendar_text; use crate::snapshots::main::list_person_snapshots::list_person_snapshots; use std::collections::BTreeMap; @@ -8,7 +10,9 @@ use std::time::Duration; use crate::credentials::main::tokens::issue_token::issue_token; use crate::credentials::main::tokens::revoke_token::revoke_token; -use crate::credentials::models::{AuditContext, CredentialScope, DefaultRole}; +use crate::credentials::models::{ + AuditContext, BuiltinRole, CredentialScope, DefaultRole, RoleAssignmentRequest, +}; use crate::declarations::main::change_trigger::change_trigger; use crate::declarations::main::list_triggers::list_triggers; use crate::declarations::models::{TriggerChange, TriggerChangeRequest}; @@ -332,6 +336,11 @@ fn given_published_locations_and_scheduled_work_when_read_through_http_then_reso extra.generation += 1; extra.nodes.remove("products"); extra.git_dirty = !input.git_dirty; + extra.documents.insert( + "roles".into(), + json!({"order_support": {"description": "Answer order questions", "grants": [{"nodes": ["tag:orders"], + "permissions": ["view"], "resolved_nodes": {"tag:orders": ["orders"]}}]}}), + ); publish_snapshot(store, &extra) .await .expect("second publication"); @@ -351,8 +360,41 @@ fn given_published_locations_and_scheduled_work_when_read_through_http_then_reso ); assert_eq!( json!(history[0].changes), - json!([{"change_kind": "removed", "subject_key": "products"}]), - "only the subjects that changed" + json!([{"change_kind": "removed", "subject_key": "products"}, + {"change_kind": "added", "subject_key": "role:order_support"}]), + "only the subjects that changed; a role assigner sees the role's name" + ); + let viewer = create_principal(store, &audit, "orders-viewer") + .await + .expect("viewer"); + assign_builtin_role( + store, + (&audit, administrator), + &RoleAssignmentRequest { + principal_id: viewer, + role: BuiltinRole::Viewer.into(), + location_id: Some(location_id), + target: Some("development".into()), + revoke: false, + }, + ) + .await + .expect("viewer role"); + assert_eq!( + json!( + list_person_snapshots( + store, + (&audit, viewer), + ((location_id, "development"), 1), + DefaultRole::None, + ) + .await + .expect("viewer snapshots")[0] + .changes + ), + json!([{"change_kind": "removed", "subject_key": "products"}, + {"change_kind": "added", "subject_key": "role:"}]), + "a viewer sees that a role changed, not which" ); assert!(!history[1].changes.is_empty()); assert_eq!( diff --git a/ui/src/routes/changes/+page.svelte b/ui/src/routes/changes/+page.svelte index ba98226c03..0be4a2aa76 100644 --- a/ui/src/routes/changes/+page.svelte +++ b/ui/src/routes/changes/+page.svelte @@ -111,10 +111,12 @@ {#if c.roles.length}

Roles {c.roles.length}

- {#each c.roles as r (r.role)} + {#each c.roles as r, i (`${i}:${r.role}`)}
{changeOpLabel[r.op]} - {r.role} + {#if !r.role}a role + {:else if app.client.supports('getRole')}{r.role} + {:else}{r.role}{/if} {#if r.coverage}now covers {r.coverage.after} {r.coverage.after === 1 ? 'node' : 'nodes'}{`${r.coverage.before !== r.coverage.after ? ` (was ${r.coverage.before})` : ''} · `}{/if}{r.detail} From 0a8d751dd4528c0fc2318af77ba2d96205cb29a4 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:54:09 +0000 Subject: [PATCH 3/3] chore(client): regenerate the schema for the role change subject doc --- clients/typescript/src/schema.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 35c7f687f4..2aebecfe52 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -1504,7 +1504,7 @@ export interface components { }; /** @enum {string} */ PlatformEventKind: "heartbeat" | "scheduler_no_leader" | "tick_overdue" | "calendar_horizon_low" | "location_broken" | "launch_failed"; - /** @description A changed subject, such as `model:orders` or `role:analyst`, without its content hashes. */ + /** @description A changed subject, such as `model:orders`, or `role:analyst` (`role:` for those who can't assign roles). */ PublishedChange: { change_kind: string; subject_key: string;