diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 117fb8c855..2aebecfe52 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` (`role:` for those who can't assign roles). */ + 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/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 new file mode 100644 index 0000000000..33605212c7 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/snapshots/main/list_person_snapshots.rs @@ -0,0 +1,87 @@ +//! 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::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; +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 100 publications; changed roles are named only for people who assign roles. +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?; + 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)?; + 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() + .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..82c6f9d5a3 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` (`role:` for those who can't assign roles). +#[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..52ed1644f1 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_reads.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_reads.rs @@ -1,12 +1,18 @@ //! 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; 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, 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}; @@ -323,6 +329,91 @@ 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; + 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"); + 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"}, + {"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!( + (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..0be4a2aa76 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)} @@ -110,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} 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}