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/authorization/main/row_in_target.rs b/crates/sqlbuild-orchestration/src/authorization/main/row_in_target.rs index 7b72aab83e..0ac63bfd3d 100644 --- a/crates/sqlbuild-orchestration/src/authorization/main/row_in_target.rs +++ b/crates/sqlbuild-orchestration/src/authorization/main/row_in_target.rs @@ -1,16 +1,29 @@ -//! Whether a run or trigger, including a removed trigger, belongs to one location and target. +//! A person reads one run or trigger, including a removed trigger, only with view on the whole target. use uuid::Uuid; +use crate::authorization::main::authorize_listing::authorize_listing; +use crate::credentials::models::{AuditContext, DefaultRole}; use crate::store::errors::ControlError; -use crate::store::main::query::query; -use crate::store::models::{ControlTransaction, Parameter, TargetRow}; +use crate::store::main::{begin::begin, commit::commit, query::query}; +use crate::store::models::{ + ControlStore, ControlTransaction, Parameter, TargetRow, TransactionMode, +}; +/// Authorizes first, then `None` (after committing) when the row is not in this location and target. pub(crate) async fn row_in_target( - transaction: ControlTransaction, - (location_id, target): (Uuid, &str), - row: TargetRow, -) -> Result<(ControlTransaction, bool), ControlError> { + store: &ControlStore, + credentials: (&AuditContext, Uuid), + ((location_id, target), row): ((Uuid, &str), TargetRow), + default_role: DefaultRole, +) -> Result, ControlError> { + let (transaction, _) = authorize_listing( + begin(store, TransactionMode::Write).await?, + credentials, + (location_id, target), + default_role, + ) + .await?; let (sql, id) = match row { TargetRow::Run(run_id) => ( "SELECT run_id FROM sqb_runs WHERE run_id=$1 AND location_id=$2 AND target=$3", @@ -31,5 +44,9 @@ pub(crate) async fn row_in_target( ], ) .await?; - Ok((transaction, !rows.is_empty())) + if rows.is_empty() { + commit(transaction).await?; + return Ok(None); + } + Ok(Some(transaction)) } 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..a890145e2b --- /dev/null +++ b/crates/sqlbuild-orchestration/src/declarations/main/list_person_trigger_changes.rs @@ -0,0 +1,33 @@ +//! 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::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::commit::commit; +use crate::store::models::{ControlStore, TargetRow}; + +/// `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 Some(transaction) = row_in_target( + store, + (context, credentials), + ((location_id, target), TargetRow::Trigger(trigger_id)), + default_role, + ) + .await? + else { + 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/src/runs/main/list_person_run_nodes.rs b/crates/sqlbuild-orchestration/src/runs/main/list_person_run_nodes.rs index 4634d81121..91e8cbbf0c 100644 --- a/crates/sqlbuild-orchestration/src/runs/main/list_person_run_nodes.rs +++ b/crates/sqlbuild-orchestration/src/runs/main/list_person_run_nodes.rs @@ -2,14 +2,13 @@ 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::runs::_helpers::lifecycle::reads::run_nodes_in; use crate::runs::models::RunNodeRecord; use crate::store::errors::ControlError; -use crate::store::main::{begin::begin, commit::commit}; -use crate::store::models::{ControlStore, TargetRow, TransactionMode}; +use crate::store::main::commit::commit; +use crate::store::models::{ControlStore, TargetRow}; /// `None` when the run is not in this location and target; authorization runs first either way. pub async fn list_person_run_nodes( @@ -18,19 +17,16 @@ pub async fn list_person_run_nodes( (location_id, target, run_id): (Uuid, &str, Uuid), default_role: DefaultRole, ) -> Result>, ControlError> { - let (transaction, _) = authorize_listing( - begin(store, TransactionMode::Write).await?, + let Some(transaction) = row_in_target( + store, (context, credentials), - (location_id, target), + ((location_id, target), TargetRow::Run(run_id)), default_role, ) - .await?; - let (transaction, known) = - row_in_target(transaction, (location_id, target), TargetRow::Run(run_id)).await?; - if !known { - commit(transaction).await?; + .await? + else { return Ok(None); - } + }; let (transaction, nodes) = run_nodes_in(transaction, run_id).await?; commit(transaction).await?; Ok(Some(nodes)) diff --git a/crates/sqlbuild-orchestration/src/scheduler/main/list_person_ticks.rs b/crates/sqlbuild-orchestration/src/scheduler/main/list_person_ticks.rs index 7062347159..ed5c77655f 100644 --- a/crates/sqlbuild-orchestration/src/scheduler/main/list_person_ticks.rs +++ b/crates/sqlbuild-orchestration/src/scheduler/main/list_person_ticks.rs @@ -2,14 +2,13 @@ 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::scheduler::_helpers::reads::ticks_in; use crate::scheduler::models::TickRecord; use crate::store::errors::ControlError; -use crate::store::main::{begin::begin, commit::commit}; -use crate::store::models::{ControlStore, TargetRow, TransactionMode}; +use crate::store::main::commit::commit; +use crate::store::models::{ControlStore, TargetRow}; /// `None` when the trigger is not in this location and target; authorization runs first either way. pub async fn list_person_ticks( @@ -18,23 +17,16 @@ pub async fn list_person_ticks( ((location_id, target), (trigger_id, limit)): ((Uuid, &str), (Uuid, u32)), default_role: DefaultRole, ) -> Result>, ControlError> { - let (transaction, _) = authorize_listing( - begin(store, TransactionMode::Write).await?, + let Some(transaction) = row_in_target( + store, (context, credentials), - (location_id, target), + ((location_id, target), TargetRow::Trigger(trigger_id)), default_role, ) - .await?; - let (transaction, known) = row_in_target( - transaction, - (location_id, target), - TargetRow::Trigger(trigger_id), - ) - .await?; - if !known { - commit(transaction).await?; + .await? + else { return Ok(None); - } + }; let (transaction, ticks) = ticks_in(transaction, trigger_id, limit).await?; commit(transaction).await?; Ok(Some(ticks)) 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/fensu.toml b/fensu.toml index 55500200d9..720ca75071 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1381,8 +1381,8 @@ paths = ["crates/sqlbuild-orchestration/src/api/_helpers/resources/administrator reason = "Each function is one utoipa-annotated axum route: OpenAPI needs one annotated handler per operation, and each body only checks the caller's scope and calls one store operation. Scope checks and store work live in their shared owners." [[dupes.allowlist]] -paths = ["crates/sqlbuild-orchestration/src/authorization/main/list_role_definitions.rs", "crates/sqlbuild-orchestration/src/authorization/main/read_security_audit.rs", "crates/sqlbuild-orchestration/src/credentials/_helpers/active_session.rs", "crates/sqlbuild-orchestration/src/declarations/_helpers/reads.rs", "crates/sqlbuild-orchestration/src/declarations/main/list_person_jobs.rs", "crates/sqlbuild-orchestration/src/declarations/main/list_person_triggers.rs", "crates/sqlbuild-orchestration/src/declarations/main/list_triggers.rs", "crates/sqlbuild-orchestration/src/platform/main/read_location_work.rs", "crates/sqlbuild-orchestration/src/platform/main/read_person_location_health.rs", "crates/sqlbuild-orchestration/src/platform/main/location_health.rs", "crates/sqlbuild-orchestration/src/snapshots/main/current_snapshot_record.rs", "crates/sqlbuild-orchestration/src/runs/main/list_person_runs.rs", "crates/sqlbuild-orchestration/src/runs/main/list_person_run_nodes.rs", "crates/sqlbuild-orchestration/src/runs/main/list_run_nodes.rs", "crates/sqlbuild-orchestration/src/runs/main/list_runs.rs", "crates/sqlbuild-orchestration/src/runs/_helpers/lifecycle/reads.rs", "crates/sqlbuild-orchestration/src/warehouse_history/_helpers/checks.rs", "crates/sqlbuild-orchestration/src/scheduler/main/list_person_ticks.rs", "crates/sqlbuild-orchestration/src/store/main/read_records.rs", "crates/sqlbuild-orchestration/src/platform/main/location_work.rs", "crates/sqlbuild-orchestration/src/scheduler/main/list_ticks.rs", "crates/sqlbuild-orchestration/src/sensors/_helpers/attempts.rs", "crates/sqlbuild-orchestration/src/sensors/_helpers/facts.rs", "crates/sqlbuild-orchestration/src/snapshots/main/read_current_record.rs", "crates/sqlbuild-orchestration/src/snapshots/main/read_snapshot_documents.rs", "crates/sqlbuild-orchestration/src/snapshots/main/read_snapshot_nodes.rs", "crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_catalog.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_check.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_checks.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_maintenance.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_microbatch_coverage.rs", "crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_statistics.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_node_runs.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_run_events.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_run_statements.rs", "crates/sqlbuild-orchestration/src/warehouse_history/_helpers/node_runs.rs"] -reason = "Each is one control-store read with its own SQL and record type. Transactions, person authorization and decoding go through the shared begin_authorized, query_records, read_records and query_bodies helpers; what remains alike is the call shape." +paths = ["crates/sqlbuild-orchestration/src/authorization/main/list_role_definitions.rs", "crates/sqlbuild-orchestration/src/authorization/main/read_security_audit.rs", "crates/sqlbuild-orchestration/src/credentials/_helpers/active_session.rs", "crates/sqlbuild-orchestration/src/declarations/_helpers/reads.rs", "crates/sqlbuild-orchestration/src/declarations/main/list_person_jobs.rs", "crates/sqlbuild-orchestration/src/declarations/main/list_person_trigger_changes.rs", "crates/sqlbuild-orchestration/src/declarations/main/list_person_triggers.rs", "crates/sqlbuild-orchestration/src/declarations/main/list_triggers.rs", "crates/sqlbuild-orchestration/src/platform/main/read_location_work.rs", "crates/sqlbuild-orchestration/src/platform/main/read_person_location_health.rs", "crates/sqlbuild-orchestration/src/platform/main/location_health.rs", "crates/sqlbuild-orchestration/src/snapshots/main/current_snapshot_record.rs", "crates/sqlbuild-orchestration/src/runs/main/list_person_runs.rs", "crates/sqlbuild-orchestration/src/runs/main/list_person_run_nodes.rs", "crates/sqlbuild-orchestration/src/runs/main/list_run_nodes.rs", "crates/sqlbuild-orchestration/src/runs/main/list_runs.rs", "crates/sqlbuild-orchestration/src/runs/_helpers/lifecycle/reads.rs", "crates/sqlbuild-orchestration/src/warehouse_history/_helpers/checks.rs", "crates/sqlbuild-orchestration/src/scheduler/main/list_person_ticks.rs", "crates/sqlbuild-orchestration/src/store/main/read_records.rs", "crates/sqlbuild-orchestration/src/platform/main/location_work.rs", "crates/sqlbuild-orchestration/src/scheduler/main/list_ticks.rs", "crates/sqlbuild-orchestration/src/sensors/_helpers/attempts.rs", "crates/sqlbuild-orchestration/src/sensors/_helpers/facts.rs", "crates/sqlbuild-orchestration/src/snapshots/main/read_current_record.rs", "crates/sqlbuild-orchestration/src/snapshots/main/read_snapshot_documents.rs", "crates/sqlbuild-orchestration/src/snapshots/main/read_snapshot_nodes.rs", "crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_catalog.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_check.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_checks.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_maintenance.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_microbatch_coverage.rs", "crates/sqlbuild-orchestration/src/warehouse_metadata/main/read_person_statistics.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_node_runs.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_run_events.rs", "crates/sqlbuild-orchestration/src/warehouse_history/main/read_person_run_statements.rs", "crates/sqlbuild-orchestration/src/warehouse_history/_helpers/node_runs.rs"] +reason = "Each is one control-store read with its own SQL and record type. Transactions, person authorization and decoding go through the shared begin_authorized, row_in_target, query_records, read_records and query_bodies helpers; what remains alike is the call shape." [[dupes.allowlist]] paths = ["crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/catalog.rs", "crates/sqlbuild-orchestration/src/warehouse_metadata/_helpers/statistics.rs"] diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/server.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/server.md index ce21db6214..db2d7e2b93 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/server.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/server.md @@ -157,7 +157,7 @@ next request, including sessions created by [`sqb login`](login.md). ## Run ad-hoc commands People with `run.adhoc` can run a `sqb` command on the current publication without declaring a -job. Use the web UI's Run panel, or send the command as argument tokens after `sqb`: +job. Use the web UI's plan page, or send the command as argument tokens after `sqb`: ```json { diff --git a/ui/scripts/extract-command-table.mjs b/ui/scripts/extract-command-table.mjs index 07ff797501..153bfba975 100644 --- a/ui/scripts/extract-command-table.mjs +++ b/ui/scripts/extract-command-table.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Extracts the run panel's verbs and flags from the CLI's command table, so the +// Extracts the plan page's verbs and flags from the CLI's command table, so the // panel offers exactly the flags an ad-hoc run accepts. // // node scripts/extract-command-table.mjs # rewrite the generated file diff --git a/ui/src/app.html b/ui/src/app.html index 710c90734e..ed5c328f3d 100644 --- a/ui/src/app.html +++ b/ui/src/app.html @@ -7,7 +7,7 @@ %sveltekit.head% - +
%sveltekit.body%
diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 87ebd3ac67..ad23a18dbc 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -79,7 +79,7 @@ export interface ApiClient extends PlatformApi { listSnapshotChanges(scope: Scope, query?: { limit?: number }): Promise; // ---- end BUILD ---- - // ---- OPERATE: runs, queue, run panel, jobs and triggers (types/operate.ts) ---- + // ---- OPERATE: runs, queue, plan page, jobs and triggers (types/operate.ts) ---- listRuns(scope: Scope, query?: RunListQuery): Promise; /** One page of the runs list, newest first, with the facts each row's status line needs. */ listRunRows(scope: Scope, query?: RunRowQuery): Promise; @@ -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/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/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/api/types/operate.ts b/ui/src/lib/api/types/operate.ts index 8c80ffa930..c81c6679dd 100644 --- a/ui/src/lib/api/types/operate.ts +++ b/ui/src/lib/api/types/operate.ts @@ -456,7 +456,7 @@ export type SnapshotNode = { upstream: string[]; }; -// ---- ad-hoc runs (the run panel) ---- +// ---- ad-hoc runs (the plan page) ---- export type LaunchVerb = 'build' | 'test' | 'seed' | 'audit' | 'freshness'; diff --git a/ui/src/lib/components/admin/assignment-list.svelte b/ui/src/lib/components/admin/assignment-list.svelte index 3d81e09145..f93d3c9944 100644 --- a/ui/src/lib/components/admin/assignment-list.svelte +++ b/ui/src/lib/components/admin/assignment-list.svelte @@ -131,7 +131,6 @@ {#each choices as r (r.name)}{/each} Give role - Admin is given for every location; custom roles for the location that defines them. {/if} {#if error}

{error}

{/if} @@ -173,10 +172,6 @@ color: var(--foreground); font-size: 12.5px; } - .faint { - color: var(--sb-text-faint); - font-size: 12px; - } .err { margin-top: 8px; color: var(--sb-error); 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/config/config-form.svelte b/ui/src/lib/config/config-form.svelte index 7477af14c9..68b532ebac 100644 --- a/ui/src/lib/config/config-form.svelte +++ b/ui/src/lib/config/config-form.svelte @@ -3,7 +3,7 @@ // enum / secret) from a provider's field schema (the Pydantic -> JSON-schema // truth), with inline defaults, descriptions, required markers and live // validation. Opt-in raw YAML escape hatch, two-way bound. Reused by the - // Providers page (durable config) and the Run panel (per-run overrides). + // Providers page (durable config) and the plan page (per-run overrides). import ChevronRight from '@lucide/svelte/icons/chevron-right'; import LockIcon from '@lucide/svelte/icons/lock'; @@ -23,7 +23,7 @@ let { schema, values = $bindable({}), - /** baseline to diff against → per-run override highlighting (Run panel) */ + /** baseline to diff against → per-run override highlighting (plan page) */ baseline = undefined, readOnly = false, /** bindable: how many fields are invalid (parent gates Save/Run on this) */ diff --git a/ui/src/routes/jobs/[name]/job-box-node.svelte b/ui/src/lib/lineage/job-box-node.svelte similarity index 100% rename from ui/src/routes/jobs/[name]/job-box-node.svelte rename to ui/src/lib/lineage/job-box-node.svelte diff --git a/ui/src/routes/jobs/[name]/job-graph.svelte b/ui/src/lib/lineage/scope-graph.svelte similarity index 67% rename from ui/src/routes/jobs/[name]/job-graph.svelte rename to ui/src/lib/lineage/scope-graph.svelte index e7e70f04bb..e8f2170b27 100644 --- a/ui/src/routes/jobs/[name]/job-graph.svelte +++ b/ui/src/lib/lineage/scope-graph.svelte @@ -1,16 +1,22 @@ -
- node.type === 'asset' && goto(`/catalog/${node.id}`)}> +
+ onselect(picked.filter((n) => n.type === 'asset').map((n) => n.id))} + > @@ -56,6 +76,7 @@ diff --git a/ui/src/lib/state/app.svelte.ts b/ui/src/lib/state/app.svelte.ts index 48bf1990d1..588ba34151 100644 --- a/ui/src/lib/state/app.svelte.ts +++ b/ui/src/lib/state/app.svelte.ts @@ -2,6 +2,7 @@ import { browser } from '$app/environment'; import { logout } from '$lib/api/http-client'; import { MockApiClient } from '$lib/api/mock/mock-client'; import { bootFailure, type BootFailure } from '$lib/boot-failure'; +import { withPrefetch } from './prefetch'; import { ApiError, channelMatches, @@ -49,7 +50,7 @@ class AppState { #syncRefused = new Set(); constructor() { - this.client = createClient(new URLSearchParams(browser ? window.location.search : ''), () => this.#signIn()); + this.client = withPrefetch(createClient(new URLSearchParams(browser ? window.location.search : ''), () => this.#signIn())); this.signOut = this.client instanceof MockApiClient ? null : async () => { await logout(); this.#signIn(); diff --git a/ui/src/lib/state/prefetch.ts b/ui/src/lib/state/prefetch.ts new file mode 100644 index 0000000000..543592a8e4 --- /dev/null +++ b/ui/src/lib/state/prefetch.ts @@ -0,0 +1,63 @@ +import { browser } from '$app/environment'; +import type { ApiClient, Scope } from '$lib/api'; + +/** How long a click waits on the current page for the next page's data before switching anyway. */ +const HOLD_MS = 1500; +/** A prefetched read older than this, such as from a hover with no click, is read again instead. */ +const KEEP_MS = 5000; + +type Read = (client: ApiClient, scope: Scope, match: RegExpMatchArray, search: URLSearchParams) => Promise[]; + +const started = new Map }>(); +let base: ApiClient | null = null; + +const keyOf = (method: PropertyKey, args: unknown[]) => `${String(method)}|${JSON.stringify(args)}`; + +/** + * Wraps the client so a page's first reads, already started by `prefetch` before the switch, are + * taken over by the page instead of being sent again. + */ +export function withPrefetch(client: ApiClient): ApiClient { + base = client; + return new Proxy(client, { + get(target, method, receiver) { + const value = Reflect.get(target, method, receiver); + if (typeof value !== 'function') return value; + return (...args: unknown[]) => { + const key = keyOf(method, args); + const kept = started.get(key); + if (kept) { + started.delete(key); + if (Date.now() - kept.at < KEEP_MS) return kept.promise; + } + return value.apply(target, args); + }; + } + }); +} + +/** + * Starts the reads the page at `pathname` makes first and waits for them, up to a limit, so a click + * stays on the current page until the next one has its data. Errors are left for the page to show. + */ +export async function prefetch(scope: Scope | null, pathname: string, search: URLSearchParams, reads: [RegExp, Read][]): Promise { + if (!browser || !base || !scope) return; + const found = reads.map(([pattern, read]) => [pathname.match(pattern), read] as const).find(([match]) => match); + if (!found) return; + const client = base; + const recorder = new Proxy(client, { + get(target, method, receiver) { + const value = Reflect.get(target, method, receiver); + if (typeof value !== 'function' || method === 'supports') return value; + return (...args: unknown[]) => { + if (!client.supports(method as keyof ApiClient)) return Promise.resolve(); + const promise = Promise.resolve(value.apply(target, args)); + promise.catch(() => {}); + started.set(keyOf(method, args), { at: Date.now(), promise }); + return promise; + }; + } + }); + const pending = Promise.allSettled(found[1](recorder, scope, found[0]!, search)); + await Promise.race([pending, new Promise((resolve) => setTimeout(resolve, HOLD_MS))]); +} diff --git a/ui/src/lib/state/resource.svelte.ts b/ui/src/lib/state/resource.svelte.ts index db4da3e202..d687c89941 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 { channelMatches, isRefusal } 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; @@ -55,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(); @@ -87,6 +126,7 @@ export function createResource( }); return () => { unsubscribe(); + if (slowTimer) clearTimeout(slowTimer); if (pause) clearTimeout(pause); pause = null; }; @@ -102,6 +142,9 @@ export function createResource( get loading() { return loading; }, + get slow() { + return slow; + }, refresh }; } diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index 5eb9983ea2..6fa65c2e53 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -1,7 +1,7 @@ -
{#if overview.error}
{overview.error}
{/if} @@ -104,7 +103,7 @@ {view.since} {#if item.kind === 'stale_models'} - (rebuildOpen = true)}>Rebuild {staleSelection.length} + goto(planHref(staleSelection))}>Rebuild {staleSelection.length} {/if} {view.action} → @@ -180,7 +179,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 +199,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/admin/roles/[name]/+page.svelte b/ui/src/routes/admin/roles/[name]/+page.svelte index cb34c4354a..abb1eeb6d2 100644 --- a/ui/src/routes/admin/roles/[name]/+page.svelte +++ b/ui/src/routes/admin/roles/[name]/+page.svelte @@ -77,7 +77,7 @@ inherit further than one built-in role.

{:else} -

Starts from nothing; only the additions below apply, on top of any built-in role a person also holds.

+

No built-in base.

{/if} {#if compare} diff --git a/ui/src/routes/catalog/+page.svelte b/ui/src/routes/catalog/+page.svelte index aa9010bdad..edde6cbae0 100644 --- a/ui/src/routes/catalog/+page.svelte +++ b/ui/src/routes/catalog/+page.svelte @@ -12,7 +12,7 @@ import Button from '$lib/components/ui/button/button.svelte'; import GatedButton from '$lib/components/operate/gated-button.svelte'; import StaleBadges from '$lib/components/build-area/stale-badges.svelte'; - import RunPanel from '$lib/run/run-panel.svelte'; + import { planHref } from '$lib/run/command'; import { app } from '$lib/state/app.svelte'; import { createResource } from '$lib/state/resource.svelte'; import { formatAgo, formatStamp } from '$lib/format/time'; @@ -77,15 +77,13 @@ const filteredRunnable = $derived(filtered.filter((n) => n.kind !== 'source').map((n) => n.key)); const narrowed = $derived(filtered.length !== all.length); - let runOpen = $state(false); - {#if canRun} (runOpen = true)}>▷ Execute{narrowed ? ` ${filteredRunnable.length}` : ''}{/if} + {#if canRun} goto(planHref(narrowed ? filteredRunnable : []))}>▷ Execute{narrowed ? ` ${filteredRunnable.length}` : ''}{/if} -{#if canRun}{/if} ([]); function execute(selection: string[]) { - runSelection = selection; - runOpen = true; + void goto(planHref(selection)); } const Glyph = $derived(n ? (nodeIcon[nodeIconKey(n)] ?? nodeIcon.table) : nodeIcon.table); @@ -90,8 +87,6 @@ {/if} -{#if canRun}{/if} -
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/checks/+page.svelte b/ui/src/routes/checks/+page.svelte index a36db38abc..105802461a 100644 --- a/ui/src/routes/checks/+page.svelte +++ b/ui/src/routes/checks/+page.svelte @@ -12,7 +12,7 @@ import StatusLabel from '$lib/components/list/status-label.svelte'; import RelativeTime from '$lib/components/list/relative-time.svelte'; import LoadRow from '$lib/components/list/load-row.svelte'; - import RunPanel from '$lib/run/run-panel.svelte'; + import { planHref } from '$lib/run/command'; import { app } from '$lib/state/app.svelte'; import { createResource } from '$lib/state/resource.svelte'; import { formatAgo, formatStamp } from '$lib/format/time'; @@ -62,17 +62,15 @@ // A check-only run (`sqb audit`) over the nodes the visible checks attach to. const runSelection = $derived([...new Set(filtered.map((c) => c.node).filter((n): n is string => !!n))]); - let runOpen = $state(false); const linksNodes = $derived(app.client.supports('getCatalogNode')); const hasRecent = $derived(all.some((c) => c.recentOutcomes !== null)); - {#if app.client.supports('launchAdhocRun')} (runOpen = true)}>▷ Run checks{/if} + {#if app.client.supports('launchAdhocRun')} goto(planHref(filtered.length === all.length ? [] : runSelection, 'audit'))}>▷ Run checks{/if} -{#if app.client.supports('launchAdhocRun')}{/if} + import { goto } from '$app/navigation'; import LockIcon from '@lucide/svelte/icons/lock'; import type { CheckEvidence, CheckSql } from '$lib/api'; import AppTopbar from '$lib/components/app-topbar.svelte'; @@ -8,7 +9,7 @@ import Panel from '$lib/components/platform/panel.svelte'; import StatusLabel from '$lib/components/list/status-label.svelte'; import OutcomeStrip from '$lib/components/build-area/outcome-strip.svelte'; - import RunPanel from '$lib/run/run-panel.svelte'; + import { planHref } from '$lib/run/command'; import { app } from '$lib/state/app.svelte'; import { createResource } from '$lib/state/resource.svelte'; import { formatAgo, formatStamp } from '$lib/format/time'; @@ -61,16 +62,14 @@ } } - let runOpen = $state(false); {#if c && app.client.supports('launchAdhocRun')} - (runOpen = true)}>▷ Run check + goto(planHref(c?.node ? [c.node] : [], 'audit'))}>▷ Run check {/if} -{#if app.client.supports('launchAdhocRun')}{/if}
diff --git a/ui/src/routes/jobs/[name]/job-view.svelte b/ui/src/routes/jobs/[name]/job-view.svelte index f3cd427d4f..396189522a 100644 --- a/ui/src/routes/jobs/[name]/job-view.svelte +++ b/ui/src/routes/jobs/[name]/job-view.svelte @@ -7,15 +7,17 @@ import { app } from '$lib/state/app.svelte'; import { createResource } from '$lib/state/resource.svelte'; import { formatAgo, formatDuration, formatStamp } from '$lib/format/time'; - import { formatNodeCounts, runOriginLabel, runStatusLabel, runStatusTone } from '$lib/status'; - import { formatCost, onFailureHelp, onFailurePlain, overlapHelp, overlapPlain, shortRunId, slotLabel } from '$lib/operate/labels'; - import JobGraph from './job-graph.svelte'; + import { formatNodeCounts, nodeOutcomeLabel, nodeOutcomeTone, runOriginLabel, runStatusLabel, runStatusTone } from '$lib/status'; + import { kindLine } from '$lib/build-area/labels'; + import { planHref } from '$lib/run/command'; + import { formatCost, onFailurePlain, overlapPlain, shortRunId, slotLabel } from '$lib/operate/labels'; + import ScopeGraph from '$lib/lineage/scope-graph.svelte'; import LaunchForm from './launch-form.svelte'; let { name }: { name: string } = $props(); const job = createResource((client, scope) => client.getJob(scope, name), ['jobs', 'runs', 'snapshot']); - const jobRuns = createResource((client, scope) => client.listRuns(scope, { jobKey: name, limit: 12 }), ['runs']); + const jobRuns = createResource((client, scope) => client.listRuns(scope, { jobKey: name, limit: 50 }), ['runs']); const triggers = createResource((client, scope) => client.listTriggers(scope), ['triggers', 'ticks']); const catalog = createResource((client, scope) => client.listCatalogNodes(scope), ['snapshot', 'runs', 'warehouse']); // Each member's nodes and last run, so a group's graph can box every member job around what it builds. @@ -34,11 +36,25 @@ }, ['jobs', 'runs', 'snapshot']); const def = $derived(job.data ?? null); - const jobTriggers = $derived((triggers.data ?? []).filter((t) => def?.triggerIds.includes(t.id))); const runs = $derived(jobRuns.data ?? []); - const showCost = $derived(runs.some((r) => r.cost !== null)); + const jobTriggers = $derived((triggers.data ?? []).filter((t) => def?.triggerIds.includes(t.id))); + const showCost = $derived(runs.some((r) => r.cost !== null)); const canToggle = $derived(app.can('trigger.toggle')); let launchOpen = $state(false); + let tab = $state<'overview' | 'runs' | 'code'>('overview'); + // Nodes picked in the graph, by catalog key; running them starts an ad-hoc run of just those. + let picked = $state([]); + let graph = $state<{ clear: () => void }>(); + const byKey = $derived(new Map((catalog.data ?? []).map((n) => [n.key, n]))); + const pickedNodes = $derived(picked.map((k) => byKey.get(k)).filter((n) => !!n)); + const canRunPicked = $derived(app.client.supports('launchAdhocRun')); + const latest = $derived(runs[0] ?? null); + // A group draws each member job, in run order, as a box around the nodes it builds. + const boxes = $derived( + def?.kind === 'group' + ? [...(def.groupMembers ?? [])].sort((a, b) => a.stage - b.stage).map((m) => ({ jobKey: m.jobKey, nodes: members.data?.nodes.get(m.jobKey) ?? [], lastRun: members.data?.lastRuns.get(m.jobKey) })) + : [] + ); let actionError = $state(null); async function toggleTrigger(t: Trigger) { @@ -56,16 +72,21 @@ -
- (launchOpen = !launchOpen)} - >▷ Run now - {#if launchOpen && def} (launchOpen = false)} />{/if} -
+ {#if picked.length && canRunPicked} + + goto(planHref(pickedNodes.map((n) => n.key)))}>▷ Run {picked.length} selected + {:else} +
+ (launchOpen = !launchOpen)} + >▷ Run now + {#if launchOpen && def} (launchOpen = false)} />{/if} +
+ {/if}
@@ -73,8 +94,25 @@ ← Jobs {name} {#if def} - {def.kind === 'group' ? (def.members ? `Group of ${def.members.length} jobs` : 'Group') : 'Job'} · {def.nodeCount} nodes - {def.description} + {def.kind === 'group' ? `Group${def.members ? ` · ${def.members.length} jobs` : ''}` : 'Job'} · {def.nodeCount} nodes + {#each jobTriggers as t (t.id)} + + {t.cadence} + + + + + {/each} + {#if latest} + + Latest run: {runStatusLabel[latest.status]} · {formatAgo(latest.startedAt ?? latest.queuedAt, app.now)} + + {/if} + {#if def.criticalNodes.length} + + Critical: {#each def.criticalNodes as n, i (n)}{i ? ', ' : ''}{n}{/each} + + {/if} {/if}
{#if job.error} @@ -85,135 +123,179 @@ {#if actionError}
{actionError}
{/if} {#if def} -
-
-
-

What it builds · {def.resolvedNodes.length} nodes {def.select ? `--select ${def.select}` : def.kind === 'group' ? 'each box is one of its jobs; arrows are the data each node reads' : ''}{def.exclude.length ? ` --exclude ${def.exclude.join(' ')}` : ''}

+ + + {#if tab === 'overview'} +
+
{#if catalog.data && (def.kind !== 'group' || members.data)} - + (picked = keys)} /> {:else} -

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

+

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

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

- Critical (if one fails, the whole group stops): - {#each def.criticalNodes as n, i (n)}{i ? ', ' : ''}{n}{/each} -

- {/if} -
+
-
-

Recent runs

- {#if runs.length} -
- - {#each runs as r (r.id)} - goto(`/runs/${r.id}`)}> - - - - - - - - {#if showCost}{/if} - - {/each} - -
{runStatusLabel[r.status]}{shortRunId(r.id)}{runOriginLabel[r.origin]}{r.parentRunId ? ` in group run ${shortRunId(r.parentRunId)}` : r.triggerName ? ` · ${r.triggerName}` : ''}{formatNodeCounts(r.nodeCounts)}{r.startedAt ? formatDuration(r.startedAt, r.endedAt, app.now) : '—'}{formatAgo(r.startedAt ?? r.queuedAt, app.now)}{r.requestedBy.displayName}{formatCost(r.cost)}
+
- - - + + {:else if def.declaration} +
+ +
+ {/if} {/if} diff --git a/ui/src/routes/providers/[name]/+page.svelte b/ui/src/routes/providers/[name]/+page.svelte index 29db33bfa4..ab793b0ef4 100644 --- a/ui/src/routes/providers/[name]/+page.svelte +++ b/ui/src/routes/providers/[name]/+page.svelte @@ -99,7 +99,6 @@ ]} />

{provider.description}

-

Change settings in the project code and deploy; the new values apply to runs that start after the deploy.

{:else} diff --git a/ui/src/routes/runs/+page.svelte b/ui/src/routes/runs/+page.svelte index 16060abb69..f9a1a8847f 100644 --- a/ui/src/routes/runs/+page.svelte +++ b/ui/src/routes/runs/+page.svelte @@ -1,9 +1,10 @@ @@ -36,8 +39,6 @@ Filtered: {chipText} - {:else} - Click a node above to filter {/if} @@ -45,7 +46,7 @@ {#if view.tab === 'statements'} {:else} - view.focusNode(key, 'statements') : undefined} /> + view.focusNode(key, 'statements') : undefined} /> {/if} @@ -101,11 +102,6 @@ background: var(--sb-hover); color: var(--foreground); } - .hint { - margin-left: 12px; - color: var(--sb-text-faint); - font-size: 12px; - } .body { min-height: 0; flex: 1; 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/[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. 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..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'; @@ -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,21 @@ 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); + 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; + slow = false; loadingMore = false; } } @@ -76,6 +95,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 +124,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 +296,9 @@ export function createRunsState() { get loading() { return loading; }, + get slow() { + return slow; + }, get loadingMore() { return loadingMore; }, diff --git a/ui/src/routes/triggers/[id]/sensor-panel.svelte b/ui/src/routes/triggers/[id]/sensor-panel.svelte index 8064bca98e..f331dc24d4 100644 --- a/ui/src/routes/triggers/[id]/sensor-panel.svelte +++ b/ui/src/routes/triggers/[id]/sensor-panel.svelte @@ -44,7 +44,7 @@ {sensor.minimumIntervalSeconds ? `Checks at most every ${sensor.minimumIntervalSeconds}s` : 'Checks each time something relevant happens'}{sensor.timeoutSeconds ? ` · a check is stopped if it takes over ${sensor.timeoutSeconds}s` : ''}

{#if trigger.kind === 'platform_sensor'} -

Reacts to problems such as failed deploys or late schedules. See the problems and how it handled them on the location page.

+

Problems it handled ↗

{/if} {#if changed}

Its code changed since it last checked. The next check uses the new code.

@@ -57,8 +57,6 @@ {#if sensor.cursorKnown} {sensor.cursor ?? '(the start: nothing handled yet)'} {#if sensor.cursorUpdatedAt}updated {formatAgo(sensor.cursorUpdatedAt, app.now)}{/if} - {:else} - Starting over makes the next check look at everything again. {/if} (confirmReset = !confirmReset)}>Start over @@ -119,7 +117,6 @@ {/each} -

One run per run key: the same key never starts a second run.

{:else}

{trigger.jobKey ? 'It hasn’t started any runs yet.' : 'This sensor only runs its own code; it doesn’t start runs.'}

{/if} @@ -148,10 +145,6 @@ .faint { color: var(--muted-foreground); } - .small { - margin-top: 6px; - font-size: 11.5px; - } .warn { padding: 6px 10px; border: 1px solid color-mix(in srgb, var(--sb-warning) 40%, transparent); diff --git a/ui/src/routes/triggers/[id]/trigger-view.svelte b/ui/src/routes/triggers/[id]/trigger-view.svelte index 26597418ab..a6ab294819 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} @@ -87,7 +87,7 @@ {/if} {#if t.declaration}
-

Code read-only; change it in the project and deploy

+

Code

{/if} @@ -109,7 +109,7 @@ {#if isSchedule && t.upcoming}

Next runs

{#if t.state === 'paused'} -

Paused, so it isn't starting runs. When you turn it back on, it makes up missed times as set under “After downtime”.

+

Paused.

{:else if t.upcoming.length} {#each t.upcoming as due, i (due)}

0}>{formatStamp(due)} {formatIn(due, app.now)}

@@ -128,7 +128,6 @@ {#if t.calendar.nextRefreshAt}
Next update
{Date.parse(t.calendar.nextRefreshAt) > app.now ? formatIn(t.calendar.nextRefreshAt, app.now) : `due ${formatAgo(t.calendar.nextRefreshAt, app.now)}`}
{/if} {#if t.calendar.lowHorizonWarningBelow}
Warns when under
{t.calendar.lowHorizonWarningBelow}
{/if} -

The calendar's code runs in the location after each deploy and on a regular refresh, and the dates it returns are saved. If they run out, the schedule stops starting runs.

{/if}

Last run

@@ -144,12 +143,12 @@ {#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} {:else} -

Never switched here: it's on or paused as the code says.

+

None.

{/if} {/if} @@ -218,9 +217,6 @@ font-size: 12px; font-weight: 500; } - h3 span { - font-weight: 400; - } section h3, .rail h3:first-child { margin-top: 0; @@ -256,12 +252,6 @@ p { padding: 2px 0; } - .help { - margin-top: 6px; - color: var(--sb-text-faint); - font-size: 11.5px; - line-height: 1.5; - } .dot { display: inline-block; width: 7px; diff --git a/website/src/content/docs/docs/cli/server.mdx b/website/src/content/docs/docs/cli/server.mdx index eb9077cd87..77ad3e0c06 100644 --- a/website/src/content/docs/docs/cli/server.mdx +++ b/website/src/content/docs/docs/cli/server.mdx @@ -147,7 +147,7 @@ next request, including sessions created by [`sqb login`](/docs/cli/login/). ## Run ad-hoc commands People with `run.adhoc` can run a `sqb` command on the current publication without declaring a -job. Use the web UI's Run panel, or send the command as argument tokens after `sqb`: +job. Use the web UI's plan page, or send the command as argument tokens after `sqb`: ```json {