From 3cb3a7183d268475fbf30a30c48eaf1f61e9d501 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:13:21 +0000 Subject: [PATCH 01/13] feat: read a trigger's on/off history as a person A new person read lists a trigger's operator changes, newest first, from the security audit: what changed, who changed it (by name for a person), when, and the reason with credential-shaped text redacted. It needs view on every current node of the target, like the trigger listing, and an unknown trigger is refused the same way. The trigger page shows it in its On/off history section. --- clients/typescript/src/schema.d.ts | 87 ++++++++++++++ .../authentication/password_sessions.rs | 4 + .../api/_helpers/resources/person_reads.rs | 36 +++++- .../sqlbuild-orchestration/src/api/models.rs | 1 + .../src/declarations/_helpers/reads.rs | 49 +++++++- .../main/list_person_trigger_changes.rs | 41 +++++++ .../src/declarations/main/mod.rs | 1 + .../src/declarations/models.rs | 17 +++ .../tests/credentials/roles/helpers.rs | 47 +++++++- .../tests/credentials/test_project_roles.rs | 113 +++++++++++++++++- ui/src/lib/api/http-client.ts | 16 ++- ui/src/lib/api/http-jobs.test.ts | 35 +++++- ui/src/lib/api/http-jobs.ts | 27 ++++- .../routes/triggers/[id]/trigger-view.svelte | 2 +- 14 files changed, 458 insertions(+), 18 deletions(-) create mode 100644 crates/sqlbuild-orchestration/src/declarations/main/list_person_trigger_changes.rs diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index f985d67ffd..91a87c7d96 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -706,6 +706,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/changes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listPersonTriggerChanges"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/ticks": { parameters: { query?: never; @@ -1920,6 +1936,28 @@ export interface components { /** Format: uuid */ principal_id: string; }; + /** @description One operator change of a trigger, as a person reads it: what changed, who changed it, when and why. */ + PersonTriggerChange: { + /** @description The audited actor, such as `principal:` for a person. */ + actor: string; + /** @description The person's name when the actor is a person who still exists. */ + actor_name?: string | null; + change: components["schemas"]["TriggerChange"]; + /** + * Format: uuid + * @description The change's own ID, as recorded in the security audit. + */ + event_id: string; + /** Format: date-time */ + occurred_at: string; + /** @description The operator's reason, with credential-shaped text redacted. */ + reason: string; + /** + * Format: uuid + * @description The publication that was current when the change was made. + */ + snapshot_id: string; + }; /** @description A trigger as people list it, with its newest tick so a list needs no read per trigger. */ PersonTriggerRecord: components["schemas"]["TriggerRecord"] & { latest_tick?: null | components["schemas"]["TickRecord"]; @@ -4509,6 +4547,55 @@ export interface operations { }; }; }; + listPersonTriggerChanges: { + parameters: { + query?: { + /** @description Maximum recent changes; defaults to 100, capped at 1000 */ + limit?: number; + }; + header?: never; + path: { + location_id: string; + target: string; + trigger_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Operator changes, newest first */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonTriggerChange"][]; + }; + }; + /** @description Session rejected */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorBody"]; + }; + }; + /** @description Trigger not found for a person who may view the whole target */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listPersonTicks: { 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 aa338502b5..929078663a 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs @@ -138,6 +138,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}/triggers/{trigger_id}/changes", + get(crate::api::_helpers::resources::person_reads::trigger_changes), + ) .route( "/api/control/locations/{location_id}/targets/{target}/snapshots", get(crate::api::_helpers::resources::person_reads::snapshots), 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 aa9846a7a2..708bbf96b7 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs @@ -9,9 +9,12 @@ use crate::api::errors::ApiError; use crate::api::models::{ReadQuery, RunEventsQuery, RunNodePath, StatementSqlQuery}; use crate::credentials::models::{AuditContext, AuthenticatedPerson, DefaultRole}; use crate::declarations::main::list_person_jobs::list_person_jobs; +use crate::declarations::main::list_person_trigger_changes::list_person_trigger_changes; use crate::declarations::main::list_person_triggers::list_person_triggers; use crate::declarations::main::read_person_job::read_person_job; -use crate::declarations::models::{JobDetailRecord, JobRecord, PersonTriggerRecord}; +use crate::declarations::models::{ + JobDetailRecord, JobRecord, PersonTriggerChange, PersonTriggerRecord, +}; use crate::platform::main::read_person_location_health::read_person_location_health; use crate::platform::main::read_person_problems::read_person_problems; use crate::platform::models::{LocationHealthObservation, PlatformProblems}; @@ -333,6 +336,37 @@ pub(crate) async fn ticks( .ok_or_else(|| not_found("trigger not found")) } +#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/changes", operation_id = "listPersonTriggerChanges", + params(("location_id" = Uuid, Path), ("target" = String, Path), ("trigger_id" = Uuid, Path), + ("limit" = Option, Query, description = "Maximum recent changes; defaults to 100, capped at 1000")), + responses((status = 200, body = Vec, description = "Operator changes, newest first"), + (status = 401, description = "Session rejected"), + (status = 403, body = crate::api::models::ApiErrorBody), + (status = 404, description = "Trigger not found for a person who may view the whole target")), + security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn trigger_changes( + (State(store), Extension(context)): (State, Extension), + Extension(default_role): Extension, + (Path((location_id, target, trigger_id)), Query(options)): ( + Path<(Uuid, String, Uuid)>, + Query, + ), + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result>, ApiError> { + list_person_trigger_changes( + &store, + (&context, principal), + ( + (location_id, &target), + (trigger_id, options.limit.unwrap_or(100)), + ), + default_role, + ) + .await? + .map(Json) + .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")), diff --git a/crates/sqlbuild-orchestration/src/api/models.rs b/crates/sqlbuild-orchestration/src/api/models.rs index c57fbc085b..cb27eb28ea 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -37,6 +37,7 @@ use crate::api::_helpers::resources::{ person_reads::job, person_reads::triggers, person_reads::ticks, + person_reads::trigger_changes, person_reads::snapshots, person_reads::health, person_reads::problems, diff --git a/crates/sqlbuild-orchestration/src/declarations/_helpers/reads.rs b/crates/sqlbuild-orchestration/src/declarations/_helpers/reads.rs index ac716e189b..c527a4066d 100644 --- a/crates/sqlbuild-orchestration/src/declarations/_helpers/reads.rs +++ b/crates/sqlbuild-orchestration/src/declarations/_helpers/reads.rs @@ -2,9 +2,14 @@ use uuid::Uuid; -use crate::declarations::models::{JobRecord, StoredJob, TriggerRecord}; +use sqlbuild_core::redaction::main::redact_text::redact_text; + +use crate::declarations::models::{ + JobRecord, PersonTriggerChange, StoredJob, TriggerChangeRequest, TriggerRecord, +}; use crate::scheduler::models::TickRecord; use crate::store::errors::ControlError; +use crate::store::main::query::query; use crate::store::main::query_records::query_records; use crate::store::models::{ControlTransaction, Parameter}; @@ -19,6 +24,48 @@ pub(crate) async fn triggers_in( vec![Parameter::Id(location_id), Parameter::Text(target.into())]).await } +/// A trigger's operator changes from the security audit, newest first, with a person actor's name. +pub(crate) async fn trigger_changes_in( + transaction: ControlTransaction, + trigger_id: Uuid, + limit: u32, +) -> Result<(ControlTransaction, Vec), ControlError> { + let (transaction, rows) = query( + transaction, + "SELECT a.scope_json, a.occurred_at, p.name AS actor_name FROM sqb_security_audit a \ + LEFT JOIN sqb_principals p ON 'principal:' || CAST(p.principal_id AS TEXT)=a.actor \ + WHERE a.action='trigger.changed' AND a.scope_json ->> 'trigger_id'=$1 \ + ORDER BY a.occurred_at DESC, a.event_id DESC LIMIT $2", + vec![ + Parameter::Text(trigger_id.to_string()), + Parameter::Integer(i64::from(limit.min(1000))), + ], + ) + .await?; + let changes = rows + .into_iter() + .map(|row| { + let request: TriggerChangeRequest = + serde_json::from_value(row["scope_json"]["request"].clone()) + .map_err(|error| ControlError::Schema(format!("trigger change: {error}")))?; + let snapshot_id = serde_json::from_value(row["scope_json"]["snapshot_id"].clone()) + .map_err(|error| ControlError::Schema(format!("trigger change: {error}")))?; + let occurred_at = serde_json::from_value(row["occurred_at"].clone()) + .map_err(|error| ControlError::Schema(format!("trigger change: {error}")))?; + Ok(PersonTriggerChange { + event_id: request.event_id, + change: request.change, + actor: request.actor, + actor_name: row["actor_name"].as_str().map(str::to_owned), + reason: redact_text(&request.reason), + occurred_at, + snapshot_id, + }) + }) + .collect::>()?; + Ok((transaction, changes)) +} + pub(crate) async fn jobs_in( transaction: ControlTransaction, (location_id, target): (Uuid, &str), diff --git a/crates/sqlbuild-orchestration/src/declarations/main/list_person_trigger_changes.rs b/crates/sqlbuild-orchestration/src/declarations/main/list_person_trigger_changes.rs new file mode 100644 index 0000000000..08030cc462 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/declarations/main/list_person_trigger_changes.rs @@ -0,0 +1,41 @@ +//! A person sees a trigger's on/off history only with view on every current node; an unknown trigger is refused alike. + +use uuid::Uuid; + +use crate::authorization::main::authorize_listing::authorize_listing; +use crate::authorization::main::row_in_target::row_in_target; +use crate::credentials::models::{AuditContext, DefaultRole}; +use crate::declarations::_helpers::reads::trigger_changes_in; +use crate::declarations::models::PersonTriggerChange; +use crate::store::errors::ControlError; +use crate::store::main::{begin::begin, commit::commit}; +use crate::store::models::{ControlStore, TargetRow, TransactionMode}; + +/// `None` when the trigger is not in this location and target; authorization runs first either way. +pub async fn list_person_trigger_changes( + store: &ControlStore, + (context, credentials): (&AuditContext, Uuid), + ((location_id, target), (trigger_id, limit)): ((Uuid, &str), (Uuid, u32)), + default_role: DefaultRole, +) -> Result>, ControlError> { + let (transaction, _) = authorize_listing( + begin(store, TransactionMode::Write).await?, + (context, credentials), + (location_id, target), + default_role, + ) + .await?; + let (transaction, known) = row_in_target( + transaction, + (location_id, target), + TargetRow::Trigger(trigger_id), + ) + .await?; + if !known { + commit(transaction).await?; + return Ok(None); + } + let (transaction, changes) = trigger_changes_in(transaction, trigger_id, limit).await?; + commit(transaction).await?; + Ok(Some(changes)) +} diff --git a/crates/sqlbuild-orchestration/src/declarations/main/mod.rs b/crates/sqlbuild-orchestration/src/declarations/main/mod.rs index ec6d61efd4..e9ec240fc8 100644 --- a/crates/sqlbuild-orchestration/src/declarations/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/declarations/main/mod.rs @@ -3,6 +3,7 @@ pub mod change_trigger; pub(crate) mod frozen_definitions; pub mod group_dependencies; pub mod list_person_jobs; +pub mod list_person_trigger_changes; pub mod list_person_triggers; pub mod list_trigger_events; pub mod list_triggers; diff --git a/crates/sqlbuild-orchestration/src/declarations/models.rs b/crates/sqlbuild-orchestration/src/declarations/models.rs index a3113d39cc..66e289e3cd 100644 --- a/crates/sqlbuild-orchestration/src/declarations/models.rs +++ b/crates/sqlbuild-orchestration/src/declarations/models.rs @@ -153,6 +153,23 @@ pub struct PersonTriggerRecord { pub latest_tick: Option, } +/// One operator change of a trigger, as a person reads it: what changed, who changed it, when and why. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PersonTriggerChange { + /// The change's own ID, as recorded in the security audit. + pub event_id: uuid::Uuid, + pub change: TriggerChange, + /// The audited actor, such as `principal:` for a person. + pub actor: String, + /// The person's name when the actor is a person who still exists. + pub actor_name: Option, + /// The operator's reason, with credential-shaped text redacted. + pub reason: String, + pub occurred_at: DateTime, + /// The publication that was current when the change was made. + pub snapshot_id: uuid::Uuid, +} + /// Operator changes are immutable facts; clearing an override restores the published code default. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] diff --git a/crates/sqlbuild-orchestration/tests/credentials/roles/helpers.rs b/crates/sqlbuild-orchestration/tests/credentials/roles/helpers.rs index 31c25cb2c3..e05086e73d 100644 --- a/crates/sqlbuild-orchestration/tests/credentials/roles/helpers.rs +++ b/crates/sqlbuild-orchestration/tests/credentials/roles/helpers.rs @@ -18,8 +18,9 @@ use crate::credentials::models::{ use crate::credentials::tests::location_helpers::create_location; use crate::credentials::tests::test_types::RoleFixture; use crate::declarations::main::change_person_trigger::change_person_trigger; +use crate::declarations::main::list_person_trigger_changes::list_person_trigger_changes; use crate::declarations::main::list_triggers::list_triggers; -use crate::declarations::models::{TriggerChange, TriggerChangeRequest}; +use crate::declarations::models::{PersonTriggerChange, TriggerChange, TriggerChangeRequest}; use crate::runs::main::list_person_runs::list_person_runs; use crate::runs::tests::helpers::snapshot; use crate::scheduler::tests::contracts::ticks::helpers::calendar_text; @@ -236,6 +237,50 @@ pub(crate) async fn member_trigger_change( .map_err(denial) } +/// The administrator's override of the location's only trigger, with its reason. +pub(crate) async fn administrator_trigger_change( + store: &ControlStore, + fixture: &RoleFixture, + (change, reason): (TriggerChange, &str), +) -> Uuid { + let trigger_id = list_triggers(store, fixture.location_id, "development") + .await + .expect("triggers")[0] + .trigger_id; + assert!( + change_person_trigger( + store, + (&fixture.context, credentials(&fixture.administrator)), + &TriggerChangeRequest { + event_id: Uuid::now_v7(), + trigger_id, + actor: String::new(), + reason: reason.into(), + change, + }, + ) + .await + .expect("administrator change") + ); + trigger_id +} + +/// The member's read of a trigger's on/off history: the changes, `None` for an unknown trigger, or the denial. +pub(crate) async fn member_trigger_changes( + store: &ControlStore, + fixture: &RoleFixture, + trigger_id: Uuid, +) -> Result>, String> { + list_person_trigger_changes( + store, + (&fixture.context, credentials(&fixture.member)), + ((fixture.location_id, "development"), (trigger_id, 100)), + DefaultRole::None, + ) + .await + .map_err(denial) +} + /// The member's catalog, statistics and run listings of the fixture location: allowed or the denial. pub(crate) async fn member_listings( store: &ControlStore, diff --git a/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs b/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs index 543013707a..3179c22693 100644 --- a/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs +++ b/crates/sqlbuild-orchestration/tests/credentials/test_project_roles.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::json; +use uuid::Uuid; use crate::api::models::GroupRole; use crate::authorization::main::compile_roles::compile_roles; @@ -11,9 +12,9 @@ use crate::authorization::main::publish_group_roles::publish_group_roles; use crate::credentials::main::identities::create_principal::create_principal; use crate::credentials::models::{Action, RoleAssignmentRequest, RoleKey}; use crate::credentials::tests::role_helpers::{ - SUPPORT_ROLE, assign, authorize_member, developer_grants, member_listings, - member_trigger_change, orders_grant, publish_customers_trigger, publish_support_role, - role_changes, role_fixture, + SUPPORT_ROLE, administrator_trigger_change, assign, authorize_member, developer_grants, + member_listings, member_trigger_change, member_trigger_changes, orders_grant, + publish_customers_trigger, publish_support_role, role_changes, role_fixture, }; use crate::credentials::tests::test_types::{ProjectRoleTestCase, RolesFileTestCase}; use crate::declarations::models::TriggerChange; @@ -318,6 +319,112 @@ fn given_grant_outside_a_triggers_job_when_the_member_overrides_it_then_both_tri } } +#[test] +fn given_trigger_overrides_when_a_member_reads_their_history_then_only_full_view_sees_them_newest_first() + { + let test_cases = [ + ProjectRoleTestCase { + description: "SQLite trigger history needs view on every node", + backend: Backend::Sqlite, + expected_before: Err("missing view permission for this target"), + expected_after: Ok("orders-admin"), + }, + ProjectRoleTestCase { + description: "PostgreSQL trigger history needs view on every node", + backend: Backend::Postgres, + expected_before: Err("missing view permission for this target"), + expected_after: Ok("orders-admin"), + }, + ]; + for test_case in test_cases { + run(async { + let Some(fixture) = open_test_store(test_case.backend).await else { + return; + }; + let store = &fixture.store; + migrate_store(store).await.expect("migrate"); + let roles = role_fixture(store).await; + let view = |nodes: &[&str]| json!([orders_grant(json!(["view"]), nodes)]); + publish_customers_trigger(store, roles.location_id, (2, view(&["orders"]))).await; + let trigger_id = administrator_trigger_change( + store, + &roles, + ( + TriggerChange::Pause, + "Hold while postgres://orders:orders-db-pass@warehouse.example.test/orders rotates", + ), + ) + .await; + administrator_trigger_change(store, &roles, (TriggerChange::Enable, "Rotation done")) + .await; + assert_eq!( + member_trigger_changes(store, &roles, trigger_id) + .await + .map(|_| ()), + test_case.expected_before.map(|_| ()).map_err(str::to_owned), + "{}: part coverage is denied, not filtered", + test_case.description + ); + assert_eq!( + member_trigger_changes(store, &roles, Uuid::now_v7()) + .await + .map(|_| ()), + test_case.expected_before.map(|_| ()).map_err(str::to_owned), + "{}: an unknown trigger is refused alike", + test_case.description + ); + publish_customers_trigger( + store, + roles.location_id, + (3, view(&["customers", "inventory", "orders", "products"])), + ) + .await; + let changes = member_trigger_changes(store, &roles, trigger_id) + .await + .expect("full coverage reads the history") + .expect("published trigger"); + assert_eq!( + changes + .iter() + .map(|change| ( + change.change.clone(), + change.actor_name.as_deref(), + change.reason.as_str() + )) + .collect::>(), + [ + ( + TriggerChange::Enable, + test_case.expected_after.ok(), + "Rotation done" + ), + ( + TriggerChange::Pause, + test_case.expected_after.ok(), + "Hold while postgres://orders:[REDACTED]@warehouse.example.test/orders rotates" + ), + ], + "{}: newest first, with the person's name and a redacted reason", + test_case.description + ); + assert!( + changes + .iter() + .all(|change| change.actor.starts_with("principal:")), + "{}: the audited actor is kept", + test_case.description + ); + assert_eq!( + member_trigger_changes(store, &roles, Uuid::now_v7()).await, + Ok(None), + "{}: an unknown trigger is not found once the listing is allowed", + test_case.description + ); + close_test_store(fixture).await; + }); + } +} + #[test] fn given_view_grant_on_part_of_the_location_when_listing_then_each_listing_is_denied_until_full_coverage() { diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index 89f9f03210..35e0d1a326 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -11,7 +11,7 @@ import { toMaintenanceHistory } from './http-maintenance'; import { overviewOf } from './http-overview'; import { toPlatformEvents, toPlatformHealth } from './http-problems'; import { roleDetailOf, toRoleDefinition } from './http-roles'; -import { jobDetailOf, runWindowOf, toJob, toTick, toTrigger, triggerDetailOf } from './http-jobs'; +import { jobDetailOf, runWindowOf, toJob, toTick, toTrigger, toTriggerStateEvent, triggerDetailOf } from './http-jobs'; import { RUN_WINDOW, groupRunNodes, toAdhocCheck, runDetailOf, runRowPage, runsMatching, runTabCountsOf, toRunNode, toRuns, toRunEvent, toRunOutput, toRunStatements } from './http-runs'; import type { ChangeSubscription, ChangedChannel, ChangedSignal, ConnectionState } from './signals'; import type { AuthMode, Bootstrap, Capability, Location, PlatformEvent, Scope, WarehouseSync } from './types'; @@ -569,8 +569,8 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt const [wires, runs] = await Promise.all([listedTriggers(scope), recentRuns(scope)]); const wire = wires.find((t) => t.trigger_id === id); if (!wire) return null; - const ticks = (await ticksOf(scope, id, 100)).map(toTick); - return triggerDetailOf(toTrigger(wire, runs, ticks[0] ?? null), wire, ticks); + const [ticks, changes] = await Promise.all([ticksOf(scope, id, 100).then((t) => t.map(toTick)), triggerChangesOf(scope, id)]); + return triggerDetailOf(toTrigger(wire, runs, ticks[0] ?? null), wire, ticks, changes.map(toTriggerStateEvent)); }, async setTriggerStates(scope: Scope, ids: string[], state: TriggerState, reason?: string | null): Promise { for (const [done, id] of ids.entries()) { @@ -644,6 +644,16 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt return data; } + async function triggerChangesOf(scope: Scope, triggerId: string) { + const { locationId, target } = parseLocationKey(scope.locationId); + const { data, error, response } = await http.GET('/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/changes', { + params: { path: { location_id: locationId, target, trigger_id: triggerId }, query: { limit: 100 } } + }); + if (response.status === 401) onUnauthorized(); + if (!data) throw new ApiError(errorText(error, response), response.status); + return data; + } + async function roleOverviews(locationKey: string) { const { locationId, target } = parseLocationKey(locationKey); const { data, error, response } = await http.GET('/api/admin/locations/{location_id}/targets/{target}/roles', { params: { path: { location_id: locationId, target } } }); diff --git a/ui/src/lib/api/http-jobs.test.ts b/ui/src/lib/api/http-jobs.test.ts index 31b1881d0e..e158f11fbb 100644 --- a/ui/src/lib/api/http-jobs.test.ts +++ b/ui/src/lib/api/http-jobs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { components } from '$control/schema.d.ts'; -import { cadenceOf, jobDetailOf, runWindowOf, toJob, toTick, toTrigger, triggerDetailOf, triggerKindOf } from './http-jobs'; +import { cadenceOf, jobDetailOf, runWindowOf, toJob, toTick, toTrigger, toTriggerStateEvent, triggerDetailOf, triggerKindOf } from './http-jobs'; import type { Run } from './types/operate'; type W = components['schemas']; @@ -49,10 +49,10 @@ describe('trigger mapping', () => { it('reports lateness from evaluation time and hides sensor parts the server lacks', () => { const tick = toTick({ tick_id: 'k1', trigger_id: 't-daily', epoch: 1, scheduled_for: '2026-11-07T06:00:00Z', evaluated_at: '2026-11-07T06:02:30Z', outcome: 'requested', run_id: 'r-new' }); expect(tick.lateBySeconds).toBe(150); - const detail = triggerDetailOf(toTrigger(onInventory, [], tick), onInventory, [tick]); + const detail = triggerDetailOf(toTrigger(onInventory, [], tick), onInventory, [tick], []); expect(detail.sensor).toMatchObject({ minimumIntervalSeconds: 60, cursorKnown: false }); - expect([detail.declaration, detail.upcoming, detail.evaluations, detail.stateHistory]).toEqual([null, null, null, null]); - expect(triggerDetailOf(toTrigger(daily, [], null), daily, []).sensor).toBeNull(); + expect([detail.declaration, detail.upcoming, detail.evaluations, detail.stateHistory]).toEqual([null, null, null, []]); + expect(triggerDetailOf(toTrigger(daily, [], null), daily, [], []).sensor).toBeNull(); }); }); @@ -125,7 +125,7 @@ describe('schedule due times', () => { ...trigger('t-cal', 'business_days', 'orders', { description: 'Business days', job_key: 'orders', schedule: { kind: 'calendar', times, refresh_at: '2026-11-08T00:00:00Z' } }), next_due_at: nextDueAt }); - const detailOf = (wire: W['TriggerRecord']) => triggerDetailOf(toTrigger(wire, [], null), wire, []); + const detailOf = (wire: W['TriggerRecord']) => triggerDetailOf(toTrigger(wire, [], null), wire, [], []); it('lists a calendar from the next due time on, in time order, with the last stored time and next refresh', () => { const detail = detailOf(calendar('2026-11-07T09:00:00Z')); @@ -146,3 +146,28 @@ describe('schedule due times', () => { expect(detailOf(onInventory).upcoming).toBeNull(); }); }); + +describe('trigger on/off history', () => { + it('names a person, keeps a system actor, carries a set cursor and drops a blank reason', () => { + const change = (extra: Partial): W['PersonTriggerChange'] => ({ + event_id: 'e1', + change: { kind: 'pause' }, + actor: 'principal:0193', + actor_name: 'ada-admin', + reason: 'Hold for the warehouse upgrade', + occurred_at: '2026-11-09T08:00:00Z', + snapshot_id: 's1', + ...extra + }); + expect(toTriggerStateEvent(change({}))).toEqual({ + id: 'e1', + at: '2026-11-09T08:00:00Z', + kind: 'paused', + actor: { kind: 'user', name: 'ada-admin', displayName: 'ada-admin' }, + reason: 'Hold for the warehouse upgrade', + value: null + }); + const cursor = toTriggerStateEvent(change({ change: { kind: 'set_cursor', time: '2026-11-01T00:00:00Z' }, actor: 'local-operator', actor_name: null, reason: ' ' })); + expect([cursor.kind, cursor.value, cursor.actor.kind, cursor.actor.name, cursor.reason]).toEqual(['cursor_set', '2026-11-01T00:00:00Z', 'system', 'local-operator', null]); + }); +}); diff --git a/ui/src/lib/api/http-jobs.ts b/ui/src/lib/api/http-jobs.ts index 63e332e7d3..02f7ccaf27 100644 --- a/ui/src/lib/api/http-jobs.ts +++ b/ui/src/lib/api/http-jobs.ts @@ -1,6 +1,6 @@ import type { components } from '$control/schema.d.ts'; import { ApiError } from './client'; -import type { CursorWindow, Job, JobDetail, JobGroupMember, Run, RunRef, Tick, Trigger, TriggerDetail, TriggerKind } from './types/operate'; +import type { CursorWindow, Job, JobDetail, JobGroupMember, Run, RunRef, Tick, Trigger, TriggerDetail, TriggerKind, TriggerStateEvent, TriggerStateEventKind } from './types/operate'; type Wire = components['schemas']; @@ -95,7 +95,28 @@ function upcomingOf(d: Wire['TriggerDeclaration'], nextDueAt: string | null): st } /** A trigger's detail; parts with no server source are null and their widgets stay hidden. */ -export function triggerDetailOf(trigger: Trigger, wire: Wire['TriggerRecord'], ticks: Tick[]): TriggerDetail { +const CHANGE_KINDS: Record = { + enable: 'enabled', + pause: 'paused', + reset_default: 'reset_to_default', + set_cursor: 'cursor_set', + reset_cursor: 'cursor_reset' +}; + +/** One operator change, newest first as the server lists them; a person shows by name. */ +export function toTriggerStateEvent(wire: Wire['PersonTriggerChange']): TriggerStateEvent { + const name = wire.actor_name ?? wire.actor; + return { + id: wire.event_id, + at: wire.occurred_at, + kind: CHANGE_KINDS[wire.change.kind], + actor: { kind: wire.actor_name ? 'user' : 'system', name, displayName: name }, + reason: wire.reason.trim() || null, + value: wire.change.kind === 'set_cursor' ? wire.change.time : null + }; +} + +export function triggerDetailOf(trigger: Trigger, wire: Wire['TriggerRecord'], ticks: Tick[], history: TriggerStateEvent[]): TriggerDetail { const d = wire.definition_json; const calendar = isSchedule(d) && d.schedule.kind === 'calendar' ? d.schedule : null; const stored = calendar ? calendar.times.map(Date.parse) : []; @@ -129,7 +150,7 @@ export function triggerDetailOf(trigger: Trigger, wire: Wire['TriggerRecord'], t ticks, evaluations: null, runRequests: null, - stateHistory: null + stateHistory: history }; } diff --git a/ui/src/routes/triggers/[id]/trigger-view.svelte b/ui/src/routes/triggers/[id]/trigger-view.svelte index 26597418ab..7cfd6e7a01 100644 --- a/ui/src/routes/triggers/[id]/trigger-view.svelte +++ b/ui/src/routes/triggers/[id]/trigger-view.svelte @@ -144,7 +144,7 @@ {#each t.stateHistory as e (e.id)}
{stateEventLabel[e.kind]}{#if e.value} {e.value}{/if}
-
{e.actor.displayName} ({e.actor.name}) · {formatAgo(e.at, app.now)}
+
{e.actor.displayName}{#if e.actor.name !== e.actor.displayName} ({e.actor.name}){/if} · {formatAgo(e.at, app.now)}
{#if e.reason}
“{e.reason}”
{/if}
{/each} From 3b95bd3d9dcbe2376dea30a18f2ef0c6a3dcf58a Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:16:56 +0000 Subject: [PATCH 02/13] fix(ui): show the trigger change reason box only to people who can change it --- ui/src/routes/triggers/[id]/trigger-view.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/routes/triggers/[id]/trigger-view.svelte b/ui/src/routes/triggers/[id]/trigger-view.svelte index 7cfd6e7a01..fd5189498c 100644 --- a/ui/src/routes/triggers/[id]/trigger-view.svelte +++ b/ui/src/routes/triggers/[id]/trigger-view.svelte @@ -46,7 +46,7 @@ {#if t} - + {#if app.can('trigger.toggle')}{/if} {#if t.state !== t.defaultState} Set back to the code ({t.defaultState === 'running' ? 'on' : 'paused'}) {/if} From fd39d4eaf08f7d9ebc80b73bf2e7912e5e94aac1 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:39:08 +0000 Subject: [PATCH 03/13] fix(ui): show remembered data when returning to a page, and loading text only for slow first loads Each read's last answer is kept in memory for the page load, keyed by person, location, page and read, and shown at once while the page reloads. A first load faster than 400 ms shows no loading text. --- ui/src/lib/components/list/load-row.svelte | 2 +- .../components/platform/problem-list.svelte | 2 +- ui/src/lib/state/resource.svelte.ts | 38 +++++++++++++++++++ ui/src/routes/+page.svelte | 4 +- ui/src/routes/changes/+page.svelte | 2 +- ui/src/routes/jobs/[name]/job-view.svelte | 11 +++++- ui/src/routes/runs/[id]/run-view.svelte | 2 +- ui/src/routes/runs/run-list.svelte | 2 +- ui/src/routes/runs/state.svelte.ts | 22 ++++++++++- 9 files changed, 74 insertions(+), 11 deletions(-) diff --git a/ui/src/lib/components/list/load-row.svelte b/ui/src/lib/components/list/load-row.svelte index 1853e0f560..ce13cfad42 100644 --- a/ui/src/lib/components/list/load-row.svelte +++ b/ui/src/lib/components/list/load-row.svelte @@ -11,7 +11,7 @@ {:else if resource.loading && resource.data === undefined} - Loading… + {resource.slow ? 'Loading…' : ''} {:else} {empty} {/if} diff --git a/ui/src/lib/components/platform/problem-list.svelte b/ui/src/lib/components/platform/problem-list.svelte index 579534d41c..ea96a81596 100644 --- a/ui/src/lib/components/platform/problem-list.svelte +++ b/ui/src/lib/components/platform/problem-list.svelte @@ -95,7 +95,7 @@ {:else} - {#if resource.error}Couldn't load problems: {resource.error}{:else if resource.loading && !events.length}Loading…{:else}{empty}{/if} + {#if resource.error}Couldn't load problems: {resource.error}{:else if resource.loading && !events.length}{resource.slow ? 'Loading…' : ''}{:else}{empty}{/if} {/each} diff --git a/ui/src/lib/state/resource.svelte.ts b/ui/src/lib/state/resource.svelte.ts index db4da3e202..a6ccaa36b1 100644 --- a/ui/src/lib/state/resource.svelte.ts +++ b/ui/src/lib/state/resource.svelte.ts @@ -1,14 +1,33 @@ +import { browser } from '$app/environment'; import type { ApiClient, Scope } from '$lib/api'; import { channelMatches } from '$lib/api'; import { app } from './app.svelte'; /** How long a burst of change signals may last before one reload starts. */ const SIGNAL_PAUSE_MS = 300; +/** A first load faster than this shows no loading text, so it doesn't flash. */ +const SLOW_MS = 400; + +/** + * The last answer of each read, kept in memory for this page load only, so returning to a page shows + * it at once while it reloads. Keyed by person, location, page and read; signing out reloads the page, + * which empties it. + */ +const remembered = new Map(); +const REMEMBER_MAX = 200; + +function remember(key: string, value: unknown) { + remembered.delete(key); + remembered.set(key, value); + if (remembered.size > REMEMBER_MAX) remembered.delete(remembered.keys().next().value!); +} export type Resource = { readonly data: T | undefined; readonly error: string | null; readonly loading: boolean; + /** Loading with nothing to show for longer than a moment; pages show loading text only then. */ + readonly slow: boolean; refresh(): void; }; @@ -22,6 +41,8 @@ export type Resource = { * * Must be called during component initialisation. Reactive values read * synchronously inside `load` (such as route params) are also dependencies. + * + * A page that has loaded before shows its remembered data straight away and reloads it behind. */ export function createResource( load: (client: ApiClient, scope: Scope) => Promise, @@ -30,6 +51,9 @@ export function createResource( let data = $state(undefined); let error = $state(null); let loading = $state(true); + let slow = $state(false); + let slowTimer: ReturnType | null = null; + let shownKey = ''; let latest = 0; let settled = 0; let reloadAgain = false; @@ -37,8 +61,17 @@ export function createResource( function run(scope: Scope) { const request = ++latest; + // The page's path and query tell apart reads of different jobs, runs and filters made by the same code. + const key = `${app.user?.name ?? ''}|${scope.locationId}|${browser ? window.location.pathname + window.location.search : ''}|${load}`; + if (key !== shownKey && remembered.has(key)) data = remembered.get(key) as T; + shownKey = key; loading = true; + if (slowTimer) clearTimeout(slowTimer); + slowTimer = setTimeout(() => (slow = data === undefined), SLOW_MS); const done = () => { + if (slowTimer) clearTimeout(slowTimer); + slowTimer = null; + slow = false; settled = request; if (reloadAgain && request === latest) { reloadAgain = false; @@ -48,6 +81,7 @@ export function createResource( load(app.client, scope).then( (value) => { if (request !== latest) return; + remember(key, value); data = value; error = null; loading = false; @@ -87,6 +121,7 @@ export function createResource( }); return () => { unsubscribe(); + if (slowTimer) clearTimeout(slowTimer); if (pause) clearTimeout(pause); pause = null; }; @@ -102,6 +137,9 @@ export function createResource( get loading() { return loading; }, + get slow() { + return slow; + }, refresh }; } diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte index c747d7bcd1..778033d98b 100644 --- a/ui/src/routes/+page.svelte +++ b/ui/src/routes/+page.svelte @@ -180,7 +180,7 @@ {g.failed || '—'} {:else} -
{overview.loading ? 'Loading…' : 'Nothing deployed yet. Deploy the project to see its models here.'}
+
{overview.loading ? (overview.slow ? 'Loading…' : '') : 'Nothing deployed yet. Deploy the project to see its models here.'}
{/each} {/if} @@ -200,7 +200,7 @@ {c.gitAuthor ? `${c.gitAuthor} · ` : ''}{formatAgo(c.publishedAt, app.now)} {:else} -
{overview.loading ? 'Loading…' : 'Nothing deployed yet.'}
+
{overview.loading ? (overview.slow ? 'Loading…' : '') : 'Nothing deployed yet.'}
{/each} diff --git a/ui/src/routes/changes/+page.svelte b/ui/src/routes/changes/+page.svelte index 0be4a2aa76..e47722027c 100644 --- a/ui/src/routes/changes/+page.svelte +++ b/ui/src/routes/changes/+page.svelte @@ -168,7 +168,7 @@ {:else} -
{changes.loading ? 'Loading…' : all.length ? 'No deploys match the filter.' : 'Nothing has been deployed to this location yet.'}
+
{changes.loading ? (changes.slow ? 'Loading…' : '') : all.length ? 'No deploys match the filter.' : 'Nothing has been deployed to this location yet.'}
{/each} diff --git a/ui/src/routes/jobs/[name]/job-view.svelte b/ui/src/routes/jobs/[name]/job-view.svelte index f3cd427d4f..4c7570fa91 100644 --- a/ui/src/routes/jobs/[name]/job-view.svelte +++ b/ui/src/routes/jobs/[name]/job-view.svelte @@ -92,7 +92,8 @@ {#if catalog.data && (def.kind !== 'group' || members.data)} {:else} -

{catalog.error ?? members.error ?? 'Loading…'}

+ +

{catalog.error ?? members.error ?? (catalog.slow || members.slow ? 'Loading…' : '')}

{/if} {#if def.criticalNodes.length}

@@ -122,7 +123,7 @@ {:else} -

{jobRuns.loading ? 'Loading…' : 'No runs yet. Use Run now, or turn on a schedule.'}

+

{jobRuns.loading ? (jobRuns.slow ? 'Loading…' : '') : 'No runs yet. Use Run now, or turn on a schedule.'}

{/if} @@ -278,6 +279,12 @@ .num { text-align: right; } + .graph-wait { + border: 1px solid var(--border); + border-radius: 2px; + background: var(--sb-inset); + padding: 14px; + } .critical { margin-top: 8px; color: var(--muted-foreground); diff --git a/ui/src/routes/runs/[id]/run-view.svelte b/ui/src/routes/runs/[id]/run-view.svelte index ed6f28a25b..c9f444390c 100644 --- a/ui/src/routes/runs/[id]/run-view.svelte +++ b/ui/src/routes/runs/[id]/run-view.svelte @@ -123,7 +123,7 @@ {/if} {:else if view.detailRes.loading} -
Loading…
+
{view.detailRes.slow ? 'Loading…' : ''}
{:else if view.detailRes.error}
Couldn't load this run: {view.detailRes.error}
{:else} diff --git a/ui/src/routes/runs/run-list.svelte b/ui/src/routes/runs/run-list.svelte index 65eb4640a9..9cd1839bd9 100644 --- a/ui/src/routes/runs/run-list.svelte +++ b/ui/src/routes/runs/run-list.svelte @@ -162,7 +162,7 @@ {#if list.error} Couldn't load runs: {list.error} {:else if list.loading} - Loading… + {list.slow ? 'Loading…' : ''} {:else} {emptyText} {/if} diff --git a/ui/src/routes/runs/state.svelte.ts b/ui/src/routes/runs/state.svelte.ts index 619e47abd5..a093073962 100644 --- a/ui/src/routes/runs/state.svelte.ts +++ b/ui/src/routes/runs/state.svelte.ts @@ -12,6 +12,11 @@ const SIGNAL_GAP_MS = 300; /** Safety refresh in case a signal was missed. */ const SAFETY_MS = 5 * 60_000; +/** A first load faster than this shows no loading text, so it doesn't flash. */ +const SLOW_MS = 400; +/** The first page of each filter, for this page load only, so coming back to the list shows it at once while it reloads. */ +const remembered = new Map(); + function message(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -25,6 +30,7 @@ export function createRunsState() { let rows = $state([]); let nextCursor = $state(null); let loading = $state(true); + let slow = $state(false); let loadingMore = $state(false); let error = $state(null); let updatedAt = $state(null); @@ -34,6 +40,8 @@ export function createRunsState() { let children = $state>({}); let request = 0; + const memoryKey = (locationId: string) => `${app.user?.name ?? ''}|${locationId}|${key}`; + function update(patch: Partial) { void goto(writeParams(page.url, { ...params, ...patch }), { replaceState: true, noScroll: true, keepFocus: true }); } @@ -48,6 +56,7 @@ export function createRunsState() { loadingMore = true; } else { loading = true; + if (!rows.length) setTimeout(() => (slow = id === request && loading && !rows.length), SLOW_MS); } try { const result = await app.client.listRunRows(scope, { @@ -60,11 +69,13 @@ export function createRunsState() { nextCursor = result.nextCursor; error = null; updatedAt = Date.now(); + if (mode === 'reset') remembered.set(memoryKey(scope.locationId), { rows: result.rows, nextCursor, counts }); } catch (e) { if (id === request) error = message(e); } finally { if (id === request) { loading = false; + slow = false; loadingMore = false; } } @@ -76,6 +87,8 @@ export function createRunsState() { try { counts = await app.client.runTabCounts(scope, toQuery(params, Date.now(), 'all')); countsError = false; + const kept = remembered.get(memoryKey(scope.locationId)); + if (kept) kept.counts = counts; } catch { countsError = true; } @@ -103,8 +116,10 @@ export function createRunsState() { void key; if (!app.scope) return; untrack(() => { - rows = []; - nextCursor = null; + const kept = remembered.get(memoryKey(app.scope!.locationId)); + rows = kept?.rows ?? []; + nextCursor = kept?.nextCursor ?? null; + if (kept?.counts) counts = kept.counts; selected = new Set(); armed = null; void loadRows('reset'); @@ -273,6 +288,9 @@ export function createRunsState() { get loading() { return loading; }, + get slow() { + return slow; + }, get loadingMore() { return loadingMore; }, From d0238f5a3db77f27368bf1e49d7d6b624740eac0 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:45:48 +0000 Subject: [PATCH 04/13] feat(ui): tell a run's story in the log, one row per node A node's finished queries fold under its outcome row and open on demand. A failed query keeps its own row. Run-level bookkeeping queries and query progress stay in the Statements tab. Printed output is hidden behind a switch, except a failing run's last printed block, which holds its failures. --- ui/src/routes/runs/[id]/log-tab.svelte | 82 ++++++++++++++++++-- ui/src/routes/runs/[id]/run-tabs.svelte | 7 +- ui/src/routes/runs/[id]/utils/events.test.ts | 46 ++++++++++- ui/src/routes/runs/[id]/utils/events.ts | 75 +++++++++++++----- 4 files changed, 182 insertions(+), 28 deletions(-) diff --git a/ui/src/routes/runs/[id]/log-tab.svelte b/ui/src/routes/runs/[id]/log-tab.svelte index 12120210ba..2c73d55ec6 100644 --- a/ui/src/routes/runs/[id]/log-tab.svelte +++ b/ui/src/routes/runs/[id]/log-tab.svelte @@ -1,4 +1,5 @@ @@ -45,7 +48,7 @@ {#if view.tab === 'statements'} {:else} - view.focusNode(key, 'statements') : undefined} /> + view.focusNode(key, 'statements') : undefined} /> {/if} diff --git a/ui/src/routes/runs/[id]/utils/events.test.ts b/ui/src/routes/runs/[id]/utils/events.test.ts index 2e6b0539d3..9b299f849f 100644 --- a/ui/src/routes/runs/[id]/utils/events.test.ts +++ b/ui/src/routes/runs/[id]/utils/events.test.ts @@ -21,7 +21,7 @@ const event = (id: string, at: string, extra: Partial = {}): RunEvent describe('run log', () => { it("interleaves a group's events and printed lines by time and keeps each line's run", () => { const entries = logEntries( - [event('e2', '2026-11-09T08:17:38Z', { type: 'resource_attempt_failed', level: 'error', message: 'ConversionError: no', runId: 'staging' }), event('e1', '2026-11-09T08:17:35Z')], + [event('e2', '2026-11-09T08:17:38Z', { type: 'resource_attempt_failed', level: 'error', message: 'ConversionError: no', runId: 'staging' }), event('e1', '2026-11-09T08:17:35Z', { nodeKey: 'model:orders' })], [{ runId: 'orders', invocationId: 'i', stream: 'stderr', at: '2026-11-09T08:17:36Z', text: 'Compiling project...' }] ); expect(entries.map((e) => [e.id, e.runId, e.category, e.level])).toEqual([ @@ -31,4 +31,48 @@ describe('run log', () => { ]); expect(entries[2].text).toBe('Failed: no'); }); + + it("folds a node's finished queries under its outcome, drops its start row, and leaves run-level queries out", () => { + const entries = logEntries( + [ + event('start', '2026-11-09T08:00:01Z'), + event('sent', '2026-11-09T08:00:02Z', { type: 'statement_submitted', statementId: 's1' }), + event('q1', '2026-11-09T08:00:03Z', { type: 'statement_completed', statementId: 's1', message: '3 rows' }), + event('q2', '2026-11-09T08:00:04Z', { type: 'statement_completed', statementId: 's2', message: '0 rows' }), + event('done', '2026-11-09T08:00:05Z', { type: 'resource_attempt_completed', message: '1.2s · 3 rows' }), + event('other', '2026-11-09T08:00:06Z', { nodeKey: 'model:orders' }), + event('book', '2026-11-09T08:00:07Z', { type: 'statement_completed', statementId: 's3', nodeKey: null }) + ], + [] + ); + expect(entries.map((e) => e.id)).toEqual(['done', 'other']); + expect(entries[0].text).toBe('Finished · 1.2s · 3 rows'); + expect(entries[0].queries.map((q) => q.id)).toEqual(['q1', 'q2']); + // A node still running keeps its start row and has nothing folded. + expect(entries[1].queries).toEqual([]); + }); + + it('keeps a failed query on its own row as well as under its node', () => { + const entries = logEntries( + [ + event('q', '2026-11-09T08:00:03Z', { type: 'statement_failed', level: 'error', statementId: 's1', message: 'bad cast' }), + event('fail', '2026-11-09T08:00:04Z', { type: 'resource_attempt_failed', level: 'error', message: 'ConversionError: bad cast' }) + ], + [] + ); + expect(entries.map((e) => [e.id, e.category])).toEqual([ + ['q', 'queries'], + ['fail', 'nodes'] + ]); + expect(entries[1].queries.map((q) => q.id)).toEqual(['q']); + }); + + it("pins only a failing run's last stderr block", () => { + const out = (runId: string, stream: 'stdout' | 'stderr', at: string) => ({ runId, invocationId: 'i', stream, at, text: `${runId} ${stream}` }); + const entries = logEntries( + [event('fail', '2026-11-09T08:00:04Z', { type: 'resource_attempt_failed', level: 'error', message: 'X: no' }), event('ok', '2026-11-09T08:00:04Z', { runId: 'orders', type: 'resource_attempt_completed' })], + [out('staging', 'stderr', '2026-11-09T08:00:01Z'), out('staging', 'stderr', '2026-11-09T08:00:05Z'), out('staging', 'stdout', '2026-11-09T08:00:06Z'), out('orders', 'stderr', '2026-11-09T08:00:07Z')] + ); + expect(entries.filter((e) => e.pinned).map((e) => e.id)).toEqual(['out-staging-1']); + }); }); diff --git a/ui/src/routes/runs/[id]/utils/events.ts b/ui/src/routes/runs/[id]/utils/events.ts index 3836b6b3a4..25576d00bc 100644 --- a/ui/src/routes/runs/[id]/utils/events.ts +++ b/ui/src/routes/runs/[id]/utils/events.ts @@ -62,7 +62,7 @@ export function eventSummary(e: RunEvent): string { } } -/** One row of the run log: an event, or a line the run printed. */ +/** One row of the run log: an event, or a block the run printed. */ export type LogEntry = { id: string; runId: string; @@ -75,31 +75,64 @@ export type LogEntry = { title: string; tone: string; stream: 'stdout' | 'stderr' | null; - /** Query events link to their statement's SQL. */ + /** Query rows link to their statement's SQL. */ statementId: string | null; + /** A node's finished queries, folded under its outcome row. */ + queries: LogEntry[]; + /** Shown even with printed output hidden: a failing run's last printed block, which ends with its failures. */ + pinned: boolean; }; +const QUERY_DONE = new Set(['statement_completed', 'statement_failed']); +const NODE_ENDS = new Set(['resource_attempt_completed', 'resource_attempt_failed', 'resource_attempt_skipped']); + +function entryOf(e: RunEvent): LogEntry { + const text = eventSummary(e); + return { + id: e.id, + runId: e.runId, + at: e.occurredAt, + nodeKey: e.nodeKey, + level: e.level, + category: eventCategory(e), + text, + title: `${e.producer === 'scheduler' ? 'Scheduler' : 'sqb'} event ${e.type}${e.message !== text ? `\n${e.message}` : ''}`, + tone: eventTone(e), + stream: null, + statementId: e.statementId, + queries: [], + pinned: false + }; +} + /** - * Events and printed lines in one time-ordered log. Printed lines count as info, so the level - * filter applies to events; stderr is told apart by its stream, since sqb prints progress there. + * The run's story in time order: run and node milestones, checks, failures, and printed output. + * A node's finished queries fold under its outcome row, and its start row goes once it has one; + * a failed query keeps its own row. Run-level bookkeeping queries and query progress are left to + * the Statements tab. */ export function logEntries(events: readonly RunEvent[], output: readonly (RunOutputLine & { runId: string })[]): LogEntry[] { - const fromEvents = events.map((e): LogEntry => { - const text = eventSummary(e); - return { - id: e.id, - runId: e.runId, - at: e.occurredAt, - nodeKey: e.nodeKey, - level: e.level, - category: eventCategory(e), - text, - title: `${e.producer === 'scheduler' ? 'Scheduler' : 'sqb'} event ${e.type}${e.message !== text ? `\n${e.message}` : ''}`, - tone: eventTone(e), - stream: null, - statementId: e.statementId - }; + const nodeOf = (e: RunEvent) => `${e.runId}|${e.nodeKey}`; + const queries = new Map(); + const ended = new Set(); + for (const e of events) { + if (!e.nodeKey) continue; + if (QUERY_DONE.has(e.type)) queries.set(nodeOf(e), [...(queries.get(nodeOf(e)) ?? []), entryOf(e)]); + if (NODE_ENDS.has(e.type)) ended.add(nodeOf(e)); + } + + const fromEvents = events.flatMap((e): LogEntry[] => { + if (e.type.startsWith('statement_')) return e.type === 'statement_failed' ? [entryOf(e)] : []; + if (e.type === 'resource_attempt_started' && ended.has(nodeOf(e))) return []; + const entry = entryOf(e); + if (!NODE_ENDS.has(e.type)) return [entry]; + const text = e.type === 'resource_attempt_completed' ? ['Finished', entry.text].filter(Boolean).join(' · ') : entry.text; + return [{ ...entry, text, queries: queries.get(nodeOf(e)) ?? [] }]; }); + + const failing = new Set(fromEvents.filter((e) => e.level === 'error').map((e) => e.runId)); + const lastStderr = new Map(); + output.forEach((l, i) => l.stream === 'stderr' && failing.has(l.runId) && lastStderr.set(l.runId, i)); const fromOutput = output.map( (l, i): LogEntry => ({ id: `out-${l.runId}-${i}`, @@ -112,7 +145,9 @@ export function logEntries(events: readonly RunEvent[], output: readonly (RunOut title: l.stream, tone: l.stream === 'stderr' ? 'var(--sb-warning)' : 'var(--sb-text-faint)', stream: l.stream, - statementId: null + statementId: null, + queries: [], + pinned: lastStderr.get(l.runId) === i }) ); // A stable sort keeps each source's own order for equal times. From 857e2a0a165385d01628d215ec036f752dc73040 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:52:19 +0000 Subject: [PATCH 05/13] fix(ui): drop remembered data when a reload is refused A 403 or 404 on reload now clears the read's remembered answer and the data on screen, so someone whose access was removed sees the refusal instead of the earlier answer. Network errors keep the data. --- ui/src/lib/api/client.ts | 5 +++++ ui/src/lib/api/index.ts | 2 +- ui/src/lib/state/resource.svelte.ts | 7 ++++++- ui/src/routes/runs/state.svelte.ts | 12 ++++++++++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 87ebd3ac67..57dcdfb544 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -145,3 +145,8 @@ export class ApiError extends Error { this.name = 'ApiError'; } } + +/** The server refused the read or no longer has what it names, so an earlier answer must not stay on screen. */ +export function isRefusal(error: unknown): boolean { + return error instanceof ApiError && (error.status === 403 || error.status === 404); +} diff --git a/ui/src/lib/api/index.ts b/ui/src/lib/api/index.ts index da46c5c9f9..bc585191b3 100644 --- a/ui/src/lib/api/index.ts +++ b/ui/src/lib/api/index.ts @@ -4,7 +4,7 @@ import { MockApiClient, type MockOptions } from './mock/mock-client'; import type { BuiltInRole } from './types'; export type { ApiClient } from './client'; -export { ApiError } from './client'; +export { ApiError, isRefusal } from './client'; export * from './types'; export * from './signals'; export { hasCapability, hasAnyCapability, ADMIN_CAPABILITIES } from './capabilities'; diff --git a/ui/src/lib/state/resource.svelte.ts b/ui/src/lib/state/resource.svelte.ts index a6ccaa36b1..d687c89941 100644 --- a/ui/src/lib/state/resource.svelte.ts +++ b/ui/src/lib/state/resource.svelte.ts @@ -1,6 +1,6 @@ import { browser } from '$app/environment'; import type { ApiClient, Scope } from '$lib/api'; -import { channelMatches } from '$lib/api'; +import { channelMatches, isRefusal } from '$lib/api'; import { app } from './app.svelte'; /** How long a burst of change signals may last before one reload starts. */ @@ -89,6 +89,11 @@ export function createResource( }, (reason: unknown) => { if (request !== latest) return; + // A refusal also drops the earlier answer, so a person whose access went sees the refusal, not old data. + if (isRefusal(reason)) { + remembered.delete(key); + data = undefined; + } error = reason instanceof Error ? reason.message : String(reason); loading = false; done(); diff --git a/ui/src/routes/runs/state.svelte.ts b/ui/src/routes/runs/state.svelte.ts index a093073962..ab6435ab41 100644 --- a/ui/src/routes/runs/state.svelte.ts +++ b/ui/src/routes/runs/state.svelte.ts @@ -1,7 +1,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; import { untrack } from 'svelte'; -import { channelMatches, type RunRow, type RunTabCounts } from '$lib/api'; +import { channelMatches, isRefusal, type RunRow, type RunTabCounts } from '$lib/api'; import { app } from '$lib/state/app.svelte'; import type { BulkAction, DisplayRow, Freshness, ListParams } from './types'; import { eligibleIds, readParams, toQuery, writeParams } from './utils'; @@ -71,7 +71,15 @@ export function createRunsState() { updatedAt = Date.now(); if (mode === 'reset') remembered.set(memoryKey(scope.locationId), { rows: result.rows, nextCursor, counts }); } catch (e) { - if (id === request) error = message(e); + if (id !== request) return; + error = message(e); + // A refusal also drops the earlier rows, so a person whose access went sees the refusal, not old data. + if (isRefusal(e)) { + remembered.delete(memoryKey(scope.locationId)); + rows = []; + nextCursor = null; + counts = null; + } } finally { if (id === request) { loading = false; From 591aba558ed4dc60051bad3e9ad8c8add561d34d Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:56:43 +0000 Subject: [PATCH 06/13] fix(ui): style the run log's printed output switch like the other checkboxes --- ui/src/routes/runs/[id]/log-tab.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/routes/runs/[id]/log-tab.svelte b/ui/src/routes/runs/[id]/log-tab.svelte index 2c73d55ec6..f0c633e0ce 100644 --- a/ui/src/routes/runs/[id]/log-tab.svelte +++ b/ui/src/routes/runs/[id]/log-tab.svelte @@ -86,7 +86,7 @@ {#if hasOutput} {/if}