Skip to content

Commit 8b6a91c

Browse files
authored
feat(ui): wire the jobs and triggers pages to the live control API (#1336)
* feat(orchestration): let people list a target's published jobs and groups GET /api/control/locations/{location_id}/targets/{target}/jobs returns each current job and group with its kind, description, selection, concurrency groups and snapshot, under the same authorization as the trigger listing. Settings stay out of person reads. Validated: api and declarations tests on SQLite and PostgreSQL, including the session role matrix and the denial matrix. * feat(ui): wire the jobs and triggers pages to the control API * fix(ui): link a group trigger to its group run and report partial bulk toggles
1 parent 4d9e49c commit 8b6a91c

27 files changed

Lines changed: 696 additions & 94 deletions

File tree

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,22 @@ export interface paths {
354354
patch?: never;
355355
trace?: never;
356356
};
357+
"/api/control/locations/{location_id}/targets/{target}/jobs": {
358+
parameters: {
359+
query?: never;
360+
header?: never;
361+
path?: never;
362+
cookie?: never;
363+
};
364+
get: operations["listPersonJobs"];
365+
put?: never;
366+
post?: never;
367+
delete?: never;
368+
options?: never;
369+
head?: never;
370+
patch?: never;
371+
trace?: never;
372+
};
357373
"/api/control/locations/{location_id}/targets/{target}/maintenance": {
358374
parameters: {
359375
query?: never;
@@ -1134,6 +1150,17 @@ export interface components {
11341150
failed: components["schemas"]["Table"][];
11351151
failure?: string | null;
11361152
};
1153+
/** @description A job or group of the current published snapshot. Settings are left out of person reads. */
1154+
JobRecord: {
1155+
concurrency_groups: string[];
1156+
description: string;
1157+
job_key: string;
1158+
/** @description `job` or `group`; a group's selection is the union of its member jobs' selections. */
1159+
kind: components["schemas"]["RunKind"];
1160+
selection: string[];
1161+
/** Format: uuid */
1162+
snapshot_id: string;
1163+
};
11371164
/** @description A trusted operator names a published job; execution inputs are never supplied by the caller. */
11381165
JobRunRequest: {
11391166
job_key: string;
@@ -2885,6 +2912,43 @@ export interface operations {
28852912
};
28862913
};
28872914
};
2915+
listPersonJobs: {
2916+
parameters: {
2917+
query?: never;
2918+
header?: never;
2919+
path: {
2920+
location_id: string;
2921+
target: string;
2922+
};
2923+
cookie?: never;
2924+
};
2925+
requestBody?: never;
2926+
responses: {
2927+
200: {
2928+
headers: {
2929+
[name: string]: unknown;
2930+
};
2931+
content: {
2932+
"application/json": components["schemas"]["JobRecord"][];
2933+
};
2934+
};
2935+
/** @description Session rejected */
2936+
401: {
2937+
headers: {
2938+
[name: string]: unknown;
2939+
};
2940+
content?: never;
2941+
};
2942+
403: {
2943+
headers: {
2944+
[name: string]: unknown;
2945+
};
2946+
content: {
2947+
"application/json": components["schemas"]["ApiErrorBody"];
2948+
};
2949+
};
2950+
};
2951+
};
28882952
readPersonMaintenance: {
28892953
parameters: {
28902954
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
@@ -101,6 +101,10 @@ pub(crate) fn person_routes() -> Router<ControlStore> {
101101
"/api/control/locations/{location_id}/targets/{target}/runs/{run_id}/nodes",
102102
get(crate::api::_helpers::resources::person_reads::run_nodes),
103103
)
104+
.route(
105+
"/api/control/locations/{location_id}/targets/{target}/jobs",
106+
get(crate::api::_helpers::resources::person_reads::jobs),
107+
)
104108
.route(
105109
"/api/control/locations/{location_id}/targets/{target}/triggers",
106110
get(crate::api::_helpers::resources::person_reads::triggers),

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Person reads of run nodes, triggers and ticks; refusal precedes every lookup, so unknown objects look forbidden.
1+
//! Person reads of run nodes, jobs, triggers and ticks; refusal precedes every lookup, so unknown objects look forbidden.
22
33
use axum::extract::{Path, Query, State};
44
use axum::http::StatusCode;
@@ -8,8 +8,9 @@ use uuid::Uuid;
88
use crate::api::errors::ApiError;
99
use crate::api::models::ReadQuery;
1010
use crate::credentials::models::{AuditContext, AuthenticatedPerson, DefaultRole};
11+
use crate::declarations::main::list_person_jobs::list_person_jobs;
1112
use crate::declarations::main::list_person_triggers::list_person_triggers;
12-
use crate::declarations::models::TriggerRecord;
13+
use crate::declarations::models::{JobRecord, TriggerRecord};
1314
use crate::runs::main::list_person_run_nodes::list_person_run_nodes;
1415
use crate::runs::models::RunNodeRecord;
1516
use crate::scheduler::main::list_person_ticks::list_person_ticks;
@@ -39,6 +40,27 @@ pub(crate) async fn run_nodes(
3940
.ok_or_else(|| not_found("run not found"))
4041
}
4142

43+
#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/jobs", operation_id = "listPersonJobs",
44+
params(("location_id" = Uuid, Path), ("target" = String, Path)),
45+
responses((status = 200, body = Vec<JobRecord>), (status = 401, description = "Session rejected"),
46+
(status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))]
47+
pub(crate) async fn jobs(
48+
(State(store), Extension(context)): (State<ControlStore>, Extension<AuditContext>),
49+
Extension(default_role): Extension<DefaultRole>,
50+
Path((location_id, target)): Path<(Uuid, String)>,
51+
Extension(AuthenticatedPerson(principal, _)): Extension<AuthenticatedPerson>,
52+
) -> Result<Json<Vec<JobRecord>>, ApiError> {
53+
Ok(Json(
54+
list_person_jobs(
55+
&store,
56+
(&context, principal),
57+
(location_id, &target),
58+
default_role,
59+
)
60+
.await?,
61+
))
62+
}
63+
4264
#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/triggers", operation_id = "listPersonTriggers",
4365
params(("location_id" = Uuid, Path), ("target" = String, Path)),
4466
responses((status = 200, body = Vec<TriggerRecord>), (status = 401, description = "Session rejected"),

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use crate::api::_helpers::resources::{
2121
person_operations::change_trigger,
2222
person_operations::recent_runs,
2323
person_reads::run_nodes,
24+
person_reads::jobs,
2425
person_reads::triggers,
2526
person_reads::ticks,
2627
person_operations::catalog,

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
//! Trigger reads inside a caller's transaction, so authorization and results share one view.
1+
//! Job and trigger reads inside a caller's transaction, so authorization and results share one view.
22
33
use uuid::Uuid;
44

5-
use crate::declarations::models::TriggerRecord;
5+
use crate::declarations::models::{JobRecord, StoredJob, TriggerRecord};
66
use crate::store::errors::ControlError;
77
use crate::store::main::query_records::query_records;
88
use crate::store::models::{ControlTransaction, Parameter};
@@ -17,3 +17,28 @@ pub(crate) async fn triggers_in(
1717
WHERE t.location_id=$1 AND t.target=$2 AND t.removed_in_snapshot_id IS NULL ORDER BY t.name",
1818
vec![Parameter::Id(location_id), Parameter::Text(target.into())]).await
1919
}
20+
21+
pub(crate) async fn jobs_in(
22+
transaction: ControlTransaction,
23+
(location_id, target): (Uuid, &str),
24+
) -> Result<(ControlTransaction, Vec<JobRecord>), ControlError> {
25+
let (transaction, stored): (_, Vec<StoredJob>) = query_records(
26+
transaction,
27+
"SELECT job_key, kind, snapshot_id, definition_json FROM sqb_jobs \
28+
WHERE location_id=$1 AND target=$2 ORDER BY job_key",
29+
vec![Parameter::Id(location_id), Parameter::Text(target.into())],
30+
)
31+
.await?;
32+
let jobs: Vec<JobRecord> = stored
33+
.into_iter()
34+
.map(|job| JobRecord {
35+
job_key: job.job_key,
36+
kind: job.kind,
37+
description: job.definition_json.description,
38+
selection: job.definition_json.selection,
39+
concurrency_groups: job.definition_json.concurrency_groups,
40+
snapshot_id: job.snapshot_id,
41+
})
42+
.collect();
43+
Ok((transaction, jobs))
44+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//! A person lists jobs only with view on every current node of the target, like triggers.
2+
3+
use uuid::Uuid;
4+
5+
use crate::authorization::main::authorize_listing::authorize_listing;
6+
use crate::credentials::models::{AuditContext, DefaultRole};
7+
use crate::declarations::_helpers::reads::jobs_in;
8+
use crate::declarations::models::JobRecord;
9+
use crate::store::errors::ControlError;
10+
use crate::store::main::{begin::begin, commit::commit};
11+
use crate::store::models::{ControlStore, TransactionMode};
12+
13+
pub async fn list_person_jobs(
14+
store: &ControlStore,
15+
(context, credentials): (&AuditContext, Uuid),
16+
(location_id, target): (Uuid, &str),
17+
default_role: DefaultRole,
18+
) -> Result<Vec<JobRecord>, ControlError> {
19+
let (transaction, _) = authorize_listing(
20+
begin(store, TransactionMode::Write).await?,
21+
(context, credentials),
22+
(location_id, target),
23+
default_role,
24+
)
25+
.await?;
26+
let (transaction, jobs) = jobs_in(transaction, (location_id, target)).await?;
27+
commit(transaction).await?;
28+
Ok(jobs)
29+
}

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

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

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,27 @@ pub enum TriggerDeclaration {
8787
Sensor(SensorDefinition),
8888
}
8989

90+
/// A job or group of the current published snapshot. Settings are left out of person reads.
91+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
92+
pub struct JobRecord {
93+
pub job_key: String,
94+
/// `job` or `group`; a group's selection is the union of its member jobs' selections.
95+
pub kind: crate::runs::models::RunKind,
96+
pub description: String,
97+
pub selection: BTreeSet<String>,
98+
pub concurrency_groups: BTreeSet<String>,
99+
pub snapshot_id: uuid::Uuid,
100+
}
101+
102+
/// A `sqb_jobs` row as stored.
103+
#[derive(Clone, Debug, Deserialize)]
104+
pub(crate) struct StoredJob {
105+
pub job_key: String,
106+
pub kind: crate::runs::models::RunKind,
107+
pub snapshot_id: uuid::Uuid,
108+
pub definition_json: JobDefinition,
109+
}
110+
90111
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
91112
pub struct TriggerRecord {
92113
pub trigger_id: uuid::Uuid,

‎crates/sqlbuild-orchestration/tests/api/test_denials.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ use crate::store::tests::store_helpers::{close_test_store, open_test_store, run}
2020
use crate::store::tests::store_test_types::Backend;
2121

2222
/// Location-scoped reads and the action each needs.
23-
const READS: [(&str, &str); 10] = [
23+
const READS: [(&str, &str); 11] = [
2424
("runs", "view"),
25+
("jobs", "view"),
2526
("triggers", "view"),
2627
("changes", "view"),
2728
("catalog?schema=orders", "view"),

‎crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_
9393
let mut input = snapshot(location);
9494
input.documents.get_mut("triggers").expect("triggers")["health_orders"]["when"] =
9595
json!({"kind":"custom", "function":"orders_ready", "code_hash":"orders_v1"});
96+
input.documents.get_mut("jobs").expect("jobs")["orders"]["concurrency_groups"] =
97+
json!(["warehouse_heavy"]);
98+
input.documents.insert(
99+
"groups".into(),
100+
json!({"nightly": {"description": "Nightly orders", "selection": ["orders"], "jobs": {"orders": []}}}),
101+
);
96102
let sql_input: Value = serde_json::from_str(
97103
&std::fs::read_to_string(
98104
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -279,6 +285,7 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_
279285
assert!(!page.to_string().contains("select 1 as id"));
280286
let known_reads = [
281287
format!("{metadata}/{}/nodes", seed_run.run_id),
288+
format!("/api/control/locations/{location}/targets/development/jobs"),
282289
format!("/api/control/locations/{location}/targets/development/triggers"),
283290
format!(
284291
"/api/control/locations/{location}/targets/development/triggers/{}/ticks",
@@ -312,6 +319,32 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_
312319
"{read_path}"
313320
);
314321
}
322+
let jobs: Value = request(
323+
&provider.client,
324+
session,
325+
(
326+
Method::GET,
327+
&format!("/api/control/locations/{location}/targets/development/jobs"),
328+
),
329+
(&Value::Null, &session.csrf),
330+
)
331+
.await
332+
.json()
333+
.await
334+
.expect("jobs response");
335+
let listed_jobs: Vec<Value> =
336+
jobs.as_array().into_iter().flatten().cloned().collect();
337+
let expected_jobs = [
338+
json!({"job_key": "nightly", "kind": "group", "description": "Nightly orders",
339+
"selection": ["orders"], "concurrency_groups": [], "snapshot_id": published.snapshot_id}),
340+
json!({"job_key": "orders", "kind": "job", "description": "Refresh orders",
341+
"selection": ["orders"], "concurrency_groups": ["warehouse_heavy"], "snapshot_id": published.snapshot_id}),
342+
];
343+
assert_eq!(
344+
listed_jobs,
345+
expected_jobs[..expected_jobs.len() * usize::from(jobs.is_array())],
346+
"jobs list declared metadata and never settings"
347+
);
315348
for read_path in &unknown_reads {
316349
let missing = request(
317350
&provider.client,

0 commit comments

Comments
 (0)