Skip to content

Commit 8d64fb7

Browse files
authored
feat: trigger on/off history, run plan page, job page layout and run log rework (#1390)
* 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. * fix(ui): show the trigger change reason box only to people who can change it * 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. * 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. * 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. * fix(ui): style the run log's printed output switch like the other checkboxes * feat(ui): stay on the current page until the next one has its data The root loader starts each page's first reads before the switch and waits for them, up to 1.5 s, and the page takes those reads over instead of sending them again. Links already preload on hover, so most switches land with the data in place. A slow switch shows a thin bar along the top. * feat(ui): lay the job page out around its graph, with selectable nodes Schedules, the latest run and critical nodes move to chips in the header, and runs and code to their own tabs, so the graph fills the page. Clicking a node selects it and shows it in the side panel; Shift, Ctrl or Cmd adds to the selection, and the selected nodes can be run on their own. * fix(ui): drop explanatory captions that repeat the docs * fix(ui): preload a page's data on tap, not hover Hover still preloads code. Reading data on hover recorded SQL and denial audit rows for pages the person never opened. * feat(ui): open every run on a plan page first Every Run entry point (lineage, catalog, node, job selection, runs, checks and the overview) now opens /plan with the command. The page shows the dry-run check: the selected nodes as a graph, grouped by state, with permissions and options. Nothing starts until Run is pressed there. * refactor(orchestration): authorize single-row person reads in one helper The trigger history, tick and run-node reads each began a writer transaction, authorized the whole-target listing, checked that the row belongs to the target and committed early when it did not. row_in_target now owns that sequence and returns the open transaction only for a known row, so each read keeps just its own query. * docs: sync the docs skill with the server page
1 parent c650fcd commit 8d64fb7

61 files changed

Lines changed: 1784 additions & 898 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎clients/typescript/src/schema.d.ts‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,22 @@ export interface paths {
706706
patch?: never;
707707
trace?: never;
708708
};
709+
"/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/changes": {
710+
parameters: {
711+
query?: never;
712+
header?: never;
713+
path?: never;
714+
cookie?: never;
715+
};
716+
get: operations["listPersonTriggerChanges"];
717+
put?: never;
718+
post?: never;
719+
delete?: never;
720+
options?: never;
721+
head?: never;
722+
patch?: never;
723+
trace?: never;
724+
};
709725
"/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/ticks": {
710726
parameters: {
711727
query?: never;
@@ -1920,6 +1936,28 @@ export interface components {
19201936
/** Format: uuid */
19211937
principal_id: string;
19221938
};
1939+
/** @description One operator change of a trigger, as a person reads it: what changed, who changed it, when and why. */
1940+
PersonTriggerChange: {
1941+
/** @description The audited actor, such as `principal:<id>` for a person. */
1942+
actor: string;
1943+
/** @description The person's name when the actor is a person who still exists. */
1944+
actor_name?: string | null;
1945+
change: components["schemas"]["TriggerChange"];
1946+
/**
1947+
* Format: uuid
1948+
* @description The change's own ID, as recorded in the security audit.
1949+
*/
1950+
event_id: string;
1951+
/** Format: date-time */
1952+
occurred_at: string;
1953+
/** @description The operator's reason, with credential-shaped text redacted. */
1954+
reason: string;
1955+
/**
1956+
* Format: uuid
1957+
* @description The publication that was current when the change was made.
1958+
*/
1959+
snapshot_id: string;
1960+
};
19231961
/** @description A trigger as people list it, with its newest tick so a list needs no read per trigger. */
19241962
PersonTriggerRecord: components["schemas"]["TriggerRecord"] & {
19251963
latest_tick?: null | components["schemas"]["TickRecord"];
@@ -4509,6 +4547,55 @@ export interface operations {
45094547
};
45104548
};
45114549
};
4550+
listPersonTriggerChanges: {
4551+
parameters: {
4552+
query?: {
4553+
/** @description Maximum recent changes; defaults to 100, capped at 1000 */
4554+
limit?: number;
4555+
};
4556+
header?: never;
4557+
path: {
4558+
location_id: string;
4559+
target: string;
4560+
trigger_id: string;
4561+
};
4562+
cookie?: never;
4563+
};
4564+
requestBody?: never;
4565+
responses: {
4566+
/** @description Operator changes, newest first */
4567+
200: {
4568+
headers: {
4569+
[name: string]: unknown;
4570+
};
4571+
content: {
4572+
"application/json": components["schemas"]["PersonTriggerChange"][];
4573+
};
4574+
};
4575+
/** @description Session rejected */
4576+
401: {
4577+
headers: {
4578+
[name: string]: unknown;
4579+
};
4580+
content?: never;
4581+
};
4582+
403: {
4583+
headers: {
4584+
[name: string]: unknown;
4585+
};
4586+
content: {
4587+
"application/json": components["schemas"]["ApiErrorBody"];
4588+
};
4589+
};
4590+
/** @description Trigger not found for a person who may view the whole target */
4591+
404: {
4592+
headers: {
4593+
[name: string]: unknown;
4594+
};
4595+
content?: never;
4596+
};
4597+
};
4598+
};
45124599
listPersonTicks: {
45134600
parameters: {
45144601
query?: {

‎crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ pub(crate) fn person_routes() -> Router<ControlStore> {
138138
"/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/ticks",
139139
get(crate::api::_helpers::resources::person_reads::ticks),
140140
)
141+
.route(
142+
"/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/changes",
143+
get(crate::api::_helpers::resources::person_reads::trigger_changes),
144+
)
141145
.route(
142146
"/api/control/locations/{location_id}/targets/{target}/snapshots",
143147
get(crate::api::_helpers::resources::person_reads::snapshots),

‎crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ use crate::api::errors::ApiError;
99
use crate::api::models::{ReadQuery, RunEventsQuery, RunNodePath, StatementSqlQuery};
1010
use crate::credentials::models::{AuditContext, AuthenticatedPerson, DefaultRole};
1111
use crate::declarations::main::list_person_jobs::list_person_jobs;
12+
use crate::declarations::main::list_person_trigger_changes::list_person_trigger_changes;
1213
use crate::declarations::main::list_person_triggers::list_person_triggers;
1314
use crate::declarations::main::read_person_job::read_person_job;
14-
use crate::declarations::models::{JobDetailRecord, JobRecord, PersonTriggerRecord};
15+
use crate::declarations::models::{
16+
JobDetailRecord, JobRecord, PersonTriggerChange, PersonTriggerRecord,
17+
};
1518
use crate::platform::main::read_person_location_health::read_person_location_health;
1619
use crate::platform::main::read_person_problems::read_person_problems;
1720
use crate::platform::models::{LocationHealthObservation, PlatformProblems};
@@ -333,6 +336,37 @@ pub(crate) async fn ticks(
333336
.ok_or_else(|| not_found("trigger not found"))
334337
}
335338

339+
#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/changes", operation_id = "listPersonTriggerChanges",
340+
params(("location_id" = Uuid, Path), ("target" = String, Path), ("trigger_id" = Uuid, Path),
341+
("limit" = Option<u32>, Query, description = "Maximum recent changes; defaults to 100, capped at 1000")),
342+
responses((status = 200, body = Vec<PersonTriggerChange>, description = "Operator changes, newest first"),
343+
(status = 401, description = "Session rejected"),
344+
(status = 403, body = crate::api::models::ApiErrorBody),
345+
(status = 404, description = "Trigger not found for a person who may view the whole target")),
346+
security(("session" = []), ("cliSession" = [])))]
347+
pub(crate) async fn trigger_changes(
348+
(State(store), Extension(context)): (State<ControlStore>, Extension<AuditContext>),
349+
Extension(default_role): Extension<DefaultRole>,
350+
(Path((location_id, target, trigger_id)), Query(options)): (
351+
Path<(Uuid, String, Uuid)>,
352+
Query<ReadQuery>,
353+
),
354+
Extension(AuthenticatedPerson(principal, _)): Extension<AuthenticatedPerson>,
355+
) -> Result<Json<Vec<PersonTriggerChange>>, ApiError> {
356+
list_person_trigger_changes(
357+
&store,
358+
(&context, principal),
359+
(
360+
(location_id, &target),
361+
(trigger_id, options.limit.unwrap_or(100)),
362+
),
363+
default_role,
364+
)
365+
.await?
366+
.map(Json)
367+
.ok_or_else(|| not_found("trigger not found"))
368+
}
369+
336370
#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/snapshots", operation_id = "listPersonSnapshots",
337371
params(("location_id" = Uuid, Path), ("target" = String, Path),
338372
("limit" = Option<u32>, Query, description = "Maximum recent publications; defaults to 20, capped at 100")),

‎crates/sqlbuild-orchestration/src/api/models.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ use crate::api::_helpers::resources::{
3737
person_reads::job,
3838
person_reads::triggers,
3939
person_reads::ticks,
40+
person_reads::trigger_changes,
4041
person_reads::snapshots,
4142
person_reads::health,
4243
person_reads::problems,

‎crates/sqlbuild-orchestration/src/authorization/main/row_in_target.rs‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,29 @@
1-
//! Whether a run or trigger, including a removed trigger, belongs to one location and target.
1+
//! A person reads one run or trigger, including a removed trigger, only with view on the whole target.
22
33
use uuid::Uuid;
44

5+
use crate::authorization::main::authorize_listing::authorize_listing;
6+
use crate::credentials::models::{AuditContext, DefaultRole};
57
use crate::store::errors::ControlError;
6-
use crate::store::main::query::query;
7-
use crate::store::models::{ControlTransaction, Parameter, TargetRow};
8+
use crate::store::main::{begin::begin, commit::commit, query::query};
9+
use crate::store::models::{
10+
ControlStore, ControlTransaction, Parameter, TargetRow, TransactionMode,
11+
};
812

13+
/// Authorizes first, then `None` (after committing) when the row is not in this location and target.
914
pub(crate) async fn row_in_target(
10-
transaction: ControlTransaction,
11-
(location_id, target): (Uuid, &str),
12-
row: TargetRow,
13-
) -> Result<(ControlTransaction, bool), ControlError> {
15+
store: &ControlStore,
16+
credentials: (&AuditContext, Uuid),
17+
((location_id, target), row): ((Uuid, &str), TargetRow),
18+
default_role: DefaultRole,
19+
) -> Result<Option<ControlTransaction>, ControlError> {
20+
let (transaction, _) = authorize_listing(
21+
begin(store, TransactionMode::Write).await?,
22+
credentials,
23+
(location_id, target),
24+
default_role,
25+
)
26+
.await?;
1427
let (sql, id) = match row {
1528
TargetRow::Run(run_id) => (
1629
"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(
3144
],
3245
)
3346
.await?;
34-
Ok((transaction, !rows.is_empty()))
47+
if rows.is_empty() {
48+
commit(transaction).await?;
49+
return Ok(None);
50+
}
51+
Ok(Some(transaction))
3552
}

‎crates/sqlbuild-orchestration/src/declarations/_helpers/reads.rs‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@
22
33
use uuid::Uuid;
44

5-
use crate::declarations::models::{JobRecord, StoredJob, TriggerRecord};
5+
use sqlbuild_core::redaction::main::redact_text::redact_text;
6+
7+
use crate::declarations::models::{
8+
JobRecord, PersonTriggerChange, StoredJob, TriggerChangeRequest, TriggerRecord,
9+
};
610
use crate::scheduler::models::TickRecord;
711
use crate::store::errors::ControlError;
12+
use crate::store::main::query::query;
813
use crate::store::main::query_records::query_records;
914
use crate::store::models::{ControlTransaction, Parameter};
1015

@@ -19,6 +24,48 @@ pub(crate) async fn triggers_in(
1924
vec![Parameter::Id(location_id), Parameter::Text(target.into())]).await
2025
}
2126

27+
/// A trigger's operator changes from the security audit, newest first, with a person actor's name.
28+
pub(crate) async fn trigger_changes_in(
29+
transaction: ControlTransaction,
30+
trigger_id: Uuid,
31+
limit: u32,
32+
) -> Result<(ControlTransaction, Vec<PersonTriggerChange>), ControlError> {
33+
let (transaction, rows) = query(
34+
transaction,
35+
"SELECT a.scope_json, a.occurred_at, p.name AS actor_name FROM sqb_security_audit a \
36+
LEFT JOIN sqb_principals p ON 'principal:' || CAST(p.principal_id AS TEXT)=a.actor \
37+
WHERE a.action='trigger.changed' AND a.scope_json ->> 'trigger_id'=$1 \
38+
ORDER BY a.occurred_at DESC, a.event_id DESC LIMIT $2",
39+
vec![
40+
Parameter::Text(trigger_id.to_string()),
41+
Parameter::Integer(i64::from(limit.min(1000))),
42+
],
43+
)
44+
.await?;
45+
let changes = rows
46+
.into_iter()
47+
.map(|row| {
48+
let request: TriggerChangeRequest =
49+
serde_json::from_value(row["scope_json"]["request"].clone())
50+
.map_err(|error| ControlError::Schema(format!("trigger change: {error}")))?;
51+
let snapshot_id = serde_json::from_value(row["scope_json"]["snapshot_id"].clone())
52+
.map_err(|error| ControlError::Schema(format!("trigger change: {error}")))?;
53+
let occurred_at = serde_json::from_value(row["occurred_at"].clone())
54+
.map_err(|error| ControlError::Schema(format!("trigger change: {error}")))?;
55+
Ok(PersonTriggerChange {
56+
event_id: request.event_id,
57+
change: request.change,
58+
actor: request.actor,
59+
actor_name: row["actor_name"].as_str().map(str::to_owned),
60+
reason: redact_text(&request.reason),
61+
occurred_at,
62+
snapshot_id,
63+
})
64+
})
65+
.collect::<Result<_, ControlError>>()?;
66+
Ok((transaction, changes))
67+
}
68+
2269
pub(crate) async fn jobs_in(
2370
transaction: ControlTransaction,
2471
(location_id, target): (Uuid, &str),
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//! A person sees a trigger's on/off history only with view on every current node; an unknown trigger is refused alike.
2+
3+
use uuid::Uuid;
4+
5+
use crate::authorization::main::row_in_target::row_in_target;
6+
use crate::credentials::models::{AuditContext, DefaultRole};
7+
use crate::declarations::_helpers::reads::trigger_changes_in;
8+
use crate::declarations::models::PersonTriggerChange;
9+
use crate::store::errors::ControlError;
10+
use crate::store::main::commit::commit;
11+
use crate::store::models::{ControlStore, TargetRow};
12+
13+
/// `None` when the trigger is not in this location and target; authorization runs first either way.
14+
pub async fn list_person_trigger_changes(
15+
store: &ControlStore,
16+
(context, credentials): (&AuditContext, Uuid),
17+
((location_id, target), (trigger_id, limit)): ((Uuid, &str), (Uuid, u32)),
18+
default_role: DefaultRole,
19+
) -> Result<Option<Vec<PersonTriggerChange>>, ControlError> {
20+
let Some(transaction) = row_in_target(
21+
store,
22+
(context, credentials),
23+
((location_id, target), TargetRow::Trigger(trigger_id)),
24+
default_role,
25+
)
26+
.await?
27+
else {
28+
return Ok(None);
29+
};
30+
let (transaction, changes) = trigger_changes_in(transaction, trigger_id, limit).await?;
31+
commit(transaction).await?;
32+
Ok(Some(changes))
33+
}

‎crates/sqlbuild-orchestration/src/declarations/main/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod change_trigger;
33
pub(crate) mod frozen_definitions;
44
pub mod group_dependencies;
55
pub mod list_person_jobs;
6+
pub mod list_person_trigger_changes;
67
pub mod list_person_triggers;
78
pub mod list_trigger_events;
89
pub mod list_triggers;

‎crates/sqlbuild-orchestration/src/declarations/models.rs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,23 @@ pub struct PersonTriggerRecord {
153153
pub latest_tick: Option<crate::scheduler::models::TickRecord>,
154154
}
155155

156+
/// One operator change of a trigger, as a person reads it: what changed, who changed it, when and why.
157+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
158+
pub struct PersonTriggerChange {
159+
/// The change's own ID, as recorded in the security audit.
160+
pub event_id: uuid::Uuid,
161+
pub change: TriggerChange,
162+
/// The audited actor, such as `principal:<id>` for a person.
163+
pub actor: String,
164+
/// The person's name when the actor is a person who still exists.
165+
pub actor_name: Option<String>,
166+
/// The operator's reason, with credential-shaped text redacted.
167+
pub reason: String,
168+
pub occurred_at: DateTime<Utc>,
169+
/// The publication that was current when the change was made.
170+
pub snapshot_id: uuid::Uuid,
171+
}
172+
156173
/// Operator changes are immutable facts; clearing an override restores the published code default.
157174
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
158175
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]

0 commit comments

Comments
 (0)