diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 6c3213c4c3..9fd5d4e42c 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -29,6 +29,7 @@ jobs: rules_performance: ${{ steps.scope.outputs.rules_performance }} wheels: ${{ steps.scope.outputs.wheels }} recovery: ${{ steps.scope.outputs.recovery }} + ui: ${{ steps.scope.outputs.ui }} steps: - uses: actions/checkout@v4 with: @@ -54,11 +55,12 @@ jobs: echo "compiler_differential=$8" echo "clickhouse=$9" echo "recovery=${10}" + echo "ui=${11}" } >> "${GITHUB_OUTPUT}" } if [[ "${EVENT_NAME}" != "pull_request" && "${EVENT_NAME}" != "merge_group" ]]; then - write_scope true true true true true true true true true true + write_scope true true true true true true true true true true true exit 0 fi @@ -70,7 +72,7 @@ jobs: if [[ -z "${BASE_SHA}" || -z "${HEAD_SHA}" ]] || \ ! git diff --name-only -z "${BASE_SHA}...${HEAD_SHA}" > "${changed_paths}"; then echo "Could not diff ${BASE_SHA:-}...${HEAD_SHA:-}; running every suite" >&2 - write_scope true true true true true true true true true true + write_scope true true true true true true true true true true true exit 0 fi @@ -116,7 +118,7 @@ jobs: && "${HEAD_REF}" == release-please--branches--* ]] && release_metadata_only; then echo "Release Please pull request changes only release metadata; skipping the suites" \ | tee -a "${GITHUB_STEP_SUMMARY}" - write_scope false false false false false false false false false false + write_scope false false false false false false false false false false false exit 0 fi @@ -129,6 +131,7 @@ jobs: wheels=false compiler_differential=false recovery=false + ui=false broad=false changed=false # A change that only touches the website, root-level Markdown, the README demo GIFs or the @@ -149,6 +152,13 @@ jobs: wheels=true ;; esac + case "${path}" in + ui/*|clients/typescript/*|.github/workflows/verify.yml) + # The web UI is not packaged; its own checks and unit tests cover it. It compiles + # against the generated control client, so client changes run them too. + ui=true + ;; + esac case "${path}" in crates/sqlbuild-orchestration/*|crates/sqlbuild-process-ownership/*|crates/sqlbuild-cli/*|Cargo.toml|Cargo.lock|.github/workflows/verify.yml) recovery=true @@ -201,6 +211,8 @@ jobs: ;; tests/unit/*|tests/integration/*) ;; + ui/*) + ;; .github/workflows/verify.yml|Makefile) performance=true format_performance=true @@ -215,7 +227,7 @@ jobs: done < "${changed_paths}" if [[ "${changed}" != "true" ]]; then - write_scope true true true true true true true true true true + write_scope true true true true true true true true true true true exit 0 fi @@ -241,7 +253,8 @@ jobs: "${wheels}" \ "${compiler_differential}" \ "${clickhouse}" \ - "${recovery}" + "${recovery}" \ + "${ui}" lockfile: name: Lockfile consistency @@ -1110,6 +1123,28 @@ jobs: - name: Verify packaged Compose deployment and operational recovery run: make test-compose-packaged + ui: + name: UI checks and unit tests + needs: changes + if: needs.changes.outputs.ui == 'true' + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: ui + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: ui/package-lock.json + - name: Install dependencies + run: npm ci + - name: Type-check + run: npm run check + - name: Unit tests + run: npm test + verify: # The required Verify of a Release Please pull request is the commit status that the trusted # release checks in release-please.yml post on the branch's current head, after the lockfile @@ -1145,6 +1180,7 @@ jobs: clickhouse, wheels, compose, + ui, ] runs-on: ubuntu-24.04 steps: @@ -1171,6 +1207,8 @@ jobs: CLICKHOUSE_RESULT: ${{ needs.clickhouse.result }} WHEELS_RESULT: ${{ needs.wheels.result }} COMPOSE_RESULT: ${{ needs.compose.result }} + UI_SELECTED: ${{ needs.changes.outputs.ui }} + UI_RESULT: ${{ needs.ui.result }} run: | if [[ "${CHANGES_RESULT}" != "success" || "${LOCKFILE_RESULT}" != "success" ]]; then echo "Required classification or lockfile checks did not pass" >&2 @@ -1209,4 +1247,9 @@ jobs: exit 1 fi done + if [[ "${UI_RESULT}" != "success" ]] && \ + ! [[ "${UI_SELECTED}" != "true" && "${UI_RESULT}" == "skipped" ]]; then + echo "Required UI checks did not pass: ${UI_RESULT}" >&2 + exit 1 + fi echo "All path-relevant verification jobs passed" diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index de824cbd63..8c9d52f41d 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -194,6 +194,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/control/bootstrap": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["readBootstrap"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/control/locations/{location_id}/targets/{target}/catalog": { parameters: { query?: never; @@ -210,6 +226,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/control/locations/{location_id}/targets/{target}/changes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["subscribeChanges"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/control/locations/{location_id}/targets/{target}/checks": { parameters: { query?: never; @@ -822,6 +854,11 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** + * @description Closed permission vocabulary; unknown actions fail deserialization. + * @enum {string} + */ + Action: "view" | "view.sql" | "view.evidence" | "job.run" | "run.cancel" | "run.rerun" | "run.rewrite_history" | "trigger.toggle" | "sensor.reset_cursor" | "run.adhoc" | "connection.test" | "user.manage" | "role.assign" | "audit.view"; /** * @description Administrator membership and person eligibility share the final-admin invariant. * @enum {string} @@ -955,6 +992,11 @@ export interface components { /** Format: uuid */ request_id: string; }; + /** @description What the UI needs first: how people sign in, and what this person may see and do. */ + ControlBootstrap: { + access: components["schemas"]["PersonAccess"]; + login_mode: components["schemas"]["LoginMode"]; + }; /** @description One stretch of the cursor, as raw bound texts. */ CoverageBatch: { end: string; @@ -1057,6 +1099,14 @@ export interface components { target: string; window?: null | components["schemas"]["RunWindow"]; }; + /** @description One location target the person can view, with the actions allowed on at least one of its nodes. */ + LocationAccess: { + actions: components["schemas"]["Action"][]; + /** Format: uuid */ + location_id: string; + name: string; + target: string; + }; /** @description Locations report bounded facts, never connection strings or user exception text. */ LocationHealth: { /** @enum {string} */ @@ -1166,6 +1216,11 @@ export interface components { /** Format: uuid */ session_id: string; }; + /** + * @description How people sign in to this server. + * @enum {string} + */ + LoginMode: "password" | "trusted_proxy" | "oidc"; /** @description Recorded moves, their old-name view steps and janitor cleanups, newest moves first. */ MaintenanceHistory: { /** @description Empty when the history is for one model; cleanups are not tied to models. */ @@ -1292,6 +1347,14 @@ export interface components { csrf_token: string; identity: components["schemas"]["SessionIdentity"]; }; + /** @description The person's name, the actions allowed server-wide, and the location targets they can view. */ + PersonAccess: { + actions: components["schemas"]["Action"][]; + locations: components["schemas"]["LocationAccess"][]; + name: string; + /** Format: uuid */ + principal_id: string; + }; /** @description Observed physical evidence is not a live warehouse-existence guarantee. */ PhysicalEvidence: { observation: components["schemas"]["CatalogObservation"]; @@ -2336,6 +2399,32 @@ export interface operations { }; }; }; + readBootstrap: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ControlBootstrap"]; + }; + }; + /** @description Session rejected or person disabled */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; readPersonCatalog: { parameters: { query: { @@ -2389,6 +2478,43 @@ export interface operations { }; }; }; + subscribeChanges: { + parameters: { + query?: never; + header?: never; + path: { + location_id: string; + target: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description changed events: all, runs, ticks, snapshot:, node_status:. Refetch REST on all/reconnect; no durable cursor. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/event-stream": components["schemas"]["ChangeNotice"]; + }; + }; + /** @description Session rejected or person disabled */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description View does not cover every node of this target */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listPersonChecks: { parameters: { query?: never; diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/configure.rs b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/configure.rs index 4f9210d6bd..81403e1762 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/configure.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/configure.rs @@ -5,7 +5,7 @@ use std::net::SocketAddr; use axum::{Extension, Router}; use crate::api::_helpers::authentication::{oidc, password_sessions, trusted_proxy}; -use crate::api::models::{ApiAuthentication, DevelopmentScope, GroupRole, HumanLogin}; +use crate::api::models::{ApiAuthentication, DevelopmentScope, GroupRole, HumanLogin, LoginMode}; use crate::store::errors::ControlError; use crate::store::models::ControlStore; @@ -28,6 +28,11 @@ pub(crate) async fn configure( HumanLogin::Oidc(settings) => settings.group_roles.clone(), _ => Vec::new(), }; + let login_mode = match &login { + HumanLogin::Oidc(_) => LoginMode::Oidc, + HumanLogin::TrustedProxy(_) => LoginMode::TrustedProxy, + _ => LoginMode::Password, + }; let (development, routes) = match login { HumanLogin::None => (None, Router::new()), HumanLogin::Development(scope) => { @@ -44,7 +49,9 @@ pub(crate) async fn configure( }; Ok(( development, - routes.layer(Extension(default_role)), + routes + .layer(Extension(default_role)) + .layer(Extension(login_mode)), group_roles, )) } 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 0e14f0579e..8839bd24df 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs @@ -49,6 +49,14 @@ pub(crate) fn session_routes(store: &ControlStore) -> Router { pub(crate) fn person_routes() -> Router { Router::new() .route("/api/auth/cli/approve", post(super::cli_login::approve)) + .route( + "/api/control/bootstrap", + get(crate::api::_helpers::resources::person_bootstrap::read), + ) + .route( + "/api/control/locations/{location_id}/targets/{target}/changes", + get(crate::api::_helpers::resources::changes::subscribe_person), + ) .route( "/api/control/audit", get(crate::api::_helpers::resources::audit::read), diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/changes.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/changes.rs index 6771cd3ca5..9502df1756 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/changes.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/changes.rs @@ -13,13 +13,20 @@ use tokio::sync::broadcast; use uuid::Uuid; use crate::api::_helpers::authentication::bearer::{bearer, require_location}; +use crate::api::_helpers::authentication::password_transport::session_credentials; use crate::api::errors::ApiError; -use crate::api::models::{ChangeFeed, ChangeNotice, ChangeStream}; +use crate::api::models::{ChangeFeed, ChangeNotice, ChangeStream, ChangeSubscriber}; +use crate::authorization::main::authorize_listing::authorize_listing; +use crate::authorization::main::person_lists_target::person_lists_target; +use crate::credentials::main::sessions::authenticate_session::authenticate_session; use crate::credentials::main::tokens::authenticate_token::authenticate_token; -use crate::credentials::models::{CredentialScope, TokenIdentity}; +use crate::credentials::models::{ + AuditContext, AuthenticatedPerson, CredentialScope, DefaultRole, SessionAccess, TokenIdentity, +}; use crate::store::errors::ControlError; use crate::store::main::listen_changes::listen_changes; -use crate::store::models::{ChangeMessage, ChangeTopic, ControlStore}; +use crate::store::main::{begin::begin, commit::commit}; +use crate::store::models::{ChangeMessage, ChangeTopic, ControlStore, TransactionMode}; #[utoipa::path(get, path = "/api/locations/{location_id}/targets/{target}/changes", params(("location_id" = Uuid, Path), ("target" = String, Path)), @@ -34,17 +41,78 @@ pub(crate) async fn subscribe( headers: HeaderMap, ) -> Result>>, ApiError> { require_location(&identity, location_id)?; - let authorization = if identity.token_id.is_nil() { - None + let subscriber = if identity.token_id.is_nil() { + ChangeSubscriber::Development } else { - let mut authorization = HeaderValue::from_str(bearer(&headers)?) - .map_err(|error| ControlError::Invalid(error.to_string()))?; - authorization.set_sensitive(true); - Some(authorization) + ChangeSubscriber::Location(sensitive(bearer(&headers)?)?) }; + Ok(stream_changes( + store, + feed, + subscriber, + (location_id, target), + )) +} + +#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/changes", + operation_id = "subscribeChanges", + params(("location_id" = Uuid, Path), ("target" = String, Path)), + responses((status = 200, body = ChangeNotice, content_type = "text/event-stream", + description = "changed events: all, runs, ticks, snapshot:, node_status:. Refetch REST on all/reconnect; no durable cursor."), + (status = 401, description = "Session rejected or person disabled"), + (status = 403, description = "View does not cover every node of this target")), + security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn subscribe_person( + State(store): State, + (Extension(default_role), Extension(feed)): (Extension, Extension), + (Extension(AuthenticatedPerson(principal_id, session_id)), Extension(context)): ( + Extension, + Extension, + ), + (Path((location_id, target)), headers): (Path<(Uuid, String)>, HeaderMap), +) -> Result>>, ApiError> { + let transaction = begin(&store, TransactionMode::Write).await?; + let (transaction, _) = authorize_listing( + transaction, + (&context, principal_id), + (location_id, &target), + default_role, + ) + .await?; + commit(transaction).await?; + let session = match session_id { + Some(_) => Some(sensitive(session_credentials(&headers).0)?), + None => None, + }; + let subscriber = ChangeSubscriber::Person { + principal_id, + session, + default_role, + }; + Ok(stream_changes( + store, + feed, + subscriber, + (location_id, target), + )) +} + +fn sensitive(value: &str) -> Result { + let mut value = + HeaderValue::from_str(value).map_err(|error| ControlError::Invalid(error.to_string()))?; + value.set_sensitive(true); + Ok(value) +} + +fn stream_changes( + store: ControlStore, + feed: ChangeFeed, + subscriber: ChangeSubscriber, + (location_id, target): (Uuid, String), +) -> Sse>> { let state = ChangeStream { store, - authorization, + subscriber, location_id, target_hash: sha256_hex(target.as_bytes()), target, @@ -53,7 +121,50 @@ pub(crate) async fn subscribe( keepalive: tokio::time::interval(Duration::from_secs(15)), initial: true, }; - Ok(Sse::new(stream::try_unfold(state, next_event))) + Sse::new(stream::try_unfold(state, next_event)) +} + +/// Whether the subscriber may still receive this stream, checked before every emission. +async fn still_authorized(state: &ChangeStream) -> Result { + let header = |value: &HeaderValue| { + value + .to_str() + .map(str::to_owned) + .map_err(|error| ControlError::Invalid(error.to_string())) + }; + match &state.subscriber { + ChangeSubscriber::Development => Ok(true), + ChangeSubscriber::Location(token) => { + let identity = authenticate_token(&state.store, &header(token)?).await?; + Ok(identity.is_some_and(|identity| { + identity.scope + == CredentialScope::Location { + location_id: state.location_id, + } + })) + } + ChangeSubscriber::Person { + principal_id, + session, + default_role, + } => { + if let Some(session) = session { + let identity = + authenticate_session(&state.store, &header(session)?, SessionAccess::Read) + .await?; + if identity.is_none_or(|identity| identity.principal_id != *principal_id) { + return Ok(false); + } + } + person_lists_target( + &state.store, + *principal_id, + (state.location_id, &state.target), + *default_role, + ) + .await + } + } } /// Revalidate every emission, keeping unexpected storage failures as stream errors. @@ -87,6 +198,10 @@ async fn next_event( if location_id != state.location_id || target_hash != state.target_hash { continue; } + let person = matches!(state.subscriber, ChangeSubscriber::Person { .. }); + if person && matches!(topic, ChangeTopic::Run(_)) { + continue; + } Some(match topic { ChangeTopic::Runs => "runs".into(), ChangeTopic::Run(id) => format!("run:{id}"), @@ -98,20 +213,8 @@ async fn next_event( Some(ChangeMessage::Resync) => Some("all".into()), Some(ChangeMessage::CredentialsChanged) | None => None, }; - if let Some(authorization) = &state.authorization { - let token = authorization - .to_str() - .map_err(|error| ControlError::Invalid(error.to_string()))?; - let Some(identity) = authenticate_token(&state.store, token).await? else { - return Ok(None); - }; - if identity.scope - != (CredentialScope::Location { - location_id: state.location_id, - }) - { - return Ok(None); - } + if !still_authorized(&state).await? { + return Ok(None); } if let Some(topic) = topic { let event = Event::default() diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs index be262332c1..66d3fd6e36 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod client; pub(crate) mod docker_image; pub(crate) mod location_health; pub(crate) mod location_work; +pub(crate) mod person_bootstrap; pub(crate) mod person_checks; pub(crate) mod person_location_work; pub(crate) mod person_maintenance; diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_bootstrap.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_bootstrap.rs new file mode 100644 index 0000000000..1b99368046 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/person_bootstrap.rs @@ -0,0 +1,31 @@ +//! The UI's first read; it reports access for display and never stands in for a request's authorization. + +use axum::http::StatusCode; +use axum::{Extension, Json, extract::State}; + +use crate::api::errors::ApiError; +use crate::api::models::{ControlBootstrap, LoginMode}; +use crate::authorization::main::read_person_access::read_person_access; +use crate::credentials::models::{AuthenticatedPerson, DefaultRole}; +use crate::store::models::ControlStore; + +#[utoipa::path(get, path = "/api/control/bootstrap", operation_id = "readBootstrap", + responses((status = 200, body = ControlBootstrap), (status = 401, description = "Session rejected or person disabled")), + security(("session" = []), ("cliSession" = [])))] +pub(crate) async fn read( + State(store): State, + (Extension(default_role), Extension(login_mode)): ( + Extension, + Extension, + ), + Extension(AuthenticatedPerson(principal, _)): Extension, +) -> Result, ApiError> { + let Some(access) = read_person_access(&store, principal, default_role).await? else { + return Err(ApiError { + status: StatusCode::UNAUTHORIZED, + message: "authenticated active person required".into(), + decision_id: None, + }); + }; + Ok(Json(ControlBootstrap { login_mode, access })) +} diff --git a/crates/sqlbuild-orchestration/src/api/main/serve_api.rs b/crates/sqlbuild-orchestration/src/api/main/serve_api.rs index b635c8d419..9186a7c139 100644 --- a/crates/sqlbuild-orchestration/src/api/main/serve_api.rs +++ b/crates/sqlbuild-orchestration/src/api/main/serve_api.rs @@ -39,8 +39,8 @@ pub async fn serve_api( sender: sender.clone(), stopped, }; - let application = router(&store, feed) - .merge(authentication_routes) + let application = router(&store, feed.clone()) + .merge(authentication_routes.layer(Extension(feed))) .layer(Extension(std::sync::Arc::new(images))) .layer(Extension(retention)) .route( diff --git a/crates/sqlbuild-orchestration/src/api/models.rs b/crates/sqlbuild-orchestration/src/api/models.rs index 0b13d5a793..74500a964c 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -5,9 +5,9 @@ use serde::{Deserialize, Serialize}; use crate::api::_helpers::authentication::password_sessions; use crate::api::_helpers::authentication::{cli_login, oidc}; use crate::api::_helpers::resources::{ - administrators, audit, changes, location_health, location_work, person_checks, - person_location_work, person_maintenance, person_node_sql, person_operations, run_reads, runs, - snapshots, trigger_reads, warehouse_metadata, + administrators, audit, changes, location_health, location_work, person_bootstrap, + person_checks, person_location_work, person_maintenance, person_node_sql, person_operations, + run_reads, runs, snapshots, trigger_reads, warehouse_metadata, }; #[derive(Debug, utoipa::OpenApi)] @@ -29,6 +29,7 @@ use crate::api::_helpers::resources::{ person_checks::sql, person_checks::evidence, person_maintenance::read, + person_bootstrap::read, person_maintenance::coverage, person_location_work::request, person_location_work::result, @@ -43,6 +44,7 @@ use crate::api::_helpers::resources::{ oidc::login, oidc::callback, changes::subscribe, + changes::subscribe_person, location_health::report, location_health::recent, location_work::poll, @@ -129,6 +131,22 @@ pub struct ApiErrorBody { pub decision_id: Option, } +/// How people sign in to this server. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum LoginMode { + Password, + TrustedProxy, + Oidc, +} + +/// What the UI needs first: how people sign in, and what this person may see and do. +#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +pub struct ControlBootstrap { + pub login_mode: LoginMode, + pub access: crate::authorization::models::PersonAccess, +} + /// SSE invalidation topics; `all` means refetch the authorized REST resources. #[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct ChangeNotice { @@ -365,11 +383,26 @@ pub(crate) struct ChangeFeed { pub(crate) stopped: tokio::sync::watch::Receiver, } +/// Who reads a change stream, rechecked before every event; credentials are sensitive header values. +#[derive(Debug)] +pub(crate) enum ChangeSubscriber { + /// The loopback development scope, which has no credential to recheck. + Development, + /// A location token. + Location(axum::http::HeaderValue), + /// A person, by browser or CLI session or by trusted proxy; sees no run IDs. + Person { + principal_id: uuid::Uuid, + session: Option, + default_role: crate::credentials::models::DefaultRole, + }, +} + /// Sensitive headers redact the bearer from Debug output and all wire notifications. #[derive(Debug)] pub(crate) struct ChangeStream { pub(crate) store: crate::store::models::ControlStore, - pub(crate) authorization: Option, + pub(crate) subscriber: ChangeSubscriber, pub(crate) location_id: uuid::Uuid, pub(crate) target: String, pub(crate) target_hash: String, diff --git a/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs b/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs index fe8542b42c..831d2d7b1e 100644 --- a/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs +++ b/crates/sqlbuild-orchestration/src/authorization/_helpers/mod.rs @@ -2,3 +2,4 @@ pub(crate) mod administrator_changes; pub(crate) mod administrator_roles; pub(crate) mod administrators; pub(crate) mod custom_roles; +pub(crate) mod person_grants; diff --git a/crates/sqlbuild-orchestration/src/authorization/_helpers/person_grants.rs b/crates/sqlbuild-orchestration/src/authorization/_helpers/person_grants.rs new file mode 100644 index 0000000000..a6d05a8f58 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/authorization/_helpers/person_grants.rs @@ -0,0 +1,163 @@ +//! A person's live role rows and the side-effect-free decision every person check shares. + +use std::collections::BTreeSet; + +use serde_json::json; +use uuid::Uuid; + +use crate::authorization::_helpers::custom_roles::custom_coverage; +use crate::credentials::models::{Action, BuiltinRole, DefaultRole, PermissionScope}; +use crate::store::errors::ControlError; +use crate::store::main::query::query; +use crate::store::models::{ControlTransaction, Parameter}; +use crate::store::types::Row; + +const ACTIONS: [Action; 14] = [ + Action::View, + Action::ViewSql, + Action::ViewEvidence, + Action::JobRun, + Action::RunCancel, + Action::RunRerun, + Action::RunRewriteHistory, + Action::TriggerToggle, + Action::SensorResetCursor, + Action::RunAdhoc, + Action::ConnectionTest, + Action::UserManage, + Action::RoleAssign, + Action::AuditView, +]; + +/// How a scope is decided: the granting role label, or the nodes no role covers. +pub(crate) enum GrantDecision { + Granted(String), + Denied(Vec), +} + +fn role_allows(role: BuiltinRole, action: Action) -> bool { + use Action::{ + ConnectionTest, JobRun, RunAdhoc, RunCancel, RunRerun, TriggerToggle, View, ViewSql, + }; + match role { + BuiltinRole::Admin => true, + BuiltinRole::Viewer => action == View, + BuiltinRole::Operator => { + matches!(action, View | JobRun | RunCancel | RunRerun | TriggerToggle) + } + BuiltinRole::Developer => matches!( + action, + View | JobRun + | RunCancel + | RunRerun + | TriggerToggle + | ViewSql + | RunAdhoc + | ConnectionTest + ), + } +} + +/// The assigned and group roles of an active person for `target`; `None` when the person is disabled or unknown. +pub(crate) async fn person_role_rows( + transaction: ControlTransaction, + principal_id: Uuid, + target: Option, +) -> Result<(ControlTransaction, Option>), ControlError> { + let (transaction, people) = query( + transaction, + "SELECT principal_id FROM sqb_principals WHERE principal_id=$1 AND disabled_at IS NULL", + vec![Parameter::Id(principal_id)], + ) + .await?; + if people.is_empty() { + return Ok((transaction, None)); + } + let (transaction, rows) = query(transaction, + "SELECT r.role_key,r.location_id,r.target,r.group_name,e.resolved_grants_json FROM \ + (SELECT role_key,location_id,target,NULL AS group_name FROM sqb_role_assignments WHERE principal_id=$1 \ + UNION ALL SELECT g.role_key,g.location_id,g.target,g.group_name FROM sqb_person_groups p \ + JOIN sqb_group_roles g ON g.group_name=p.group_name WHERE p.principal_id=$1) r \ + LEFT JOIN sqb_effective_roles e ON e.location_id=r.location_id AND e.role_key=r.role_key AND e.target=$2 \ + ORDER BY r.role_key,r.group_name", + vec![Parameter::Id(principal_id), Parameter::OptionalText(target)]).await?; + Ok((transaction, Some(rows))) +} + +/// Decide `scope` from role rows loaded for its target: a built-in role, then custom coverage, then the default role. +pub(crate) fn decide_grant( + rows: &[Row], + scope: &PermissionScope, + default_role: DefaultRole, +) -> GrantDecision { + let rows: Vec<&Row> = rows + .iter() + .filter(|row| { + (row["location_id"].is_null() || row["location_id"] == json!(scope.location_id)) + && (row["target"].is_null() || row["target"] == json!(scope.target)) + }) + .collect(); + let builtin = rows.iter().find(|row| { + serde_json::from_value::(row["role_key"].clone()) + .is_ok_and(|role| role_allows(role, scope.action)) + }); + if let Some(row) = builtin { + let role = row["role_key"].as_str().unwrap_or_default(); + return GrantDecision::Granted(match row["group_name"].as_str() { + Some(group) => format!("group:{group}:{role}"), + None => role.into(), + }); + } + let (granting, missing) = custom_coverage(&rows, scope); + if !granting.is_empty() && missing.is_empty() { + return GrantDecision::Granted(granting.join("+")); + } + if default_role == DefaultRole::Viewer && role_allows(BuiltinRole::Viewer, scope.action) { + return GrantDecision::Granted("default:viewer".into()); + } + GrantDecision::Denied(missing) +} + +/// The node keys of one snapshot. +pub(crate) async fn snapshot_nodes( + transaction: ControlTransaction, + snapshot_id: Uuid, +) -> Result<(ControlTransaction, BTreeSet), ControlError> { + let (transaction, nodes) = query( + transaction, + "SELECT node_key FROM sqb_snapshot_nodes WHERE snapshot_id=$1", + vec![Parameter::Id(snapshot_id)], + ) + .await?; + Ok(( + transaction, + nodes + .iter() + .map(|row| row["node_key"].as_str().unwrap_or_default().to_owned()) + .collect(), + )) +} + +/// Actions granted on every node, or on at least one node of a non-empty set. +pub(crate) fn allowed( + rows: &[Row], + (location_id, target): (Option, Option), + nodes: &BTreeSet, + default_role: DefaultRole, +) -> Vec { + ACTIONS + .into_iter() + .filter(|action| { + let scope = PermissionScope { + action: *action, + location_id, + target: target.clone(), + nodes: nodes.clone(), + }; + match decide_grant(rows, &scope, default_role) { + GrantDecision::Granted(_) => true, + GrantDecision::Denied(missing) => missing.len() < nodes.len(), + } + }) + .collect() +} diff --git a/crates/sqlbuild-orchestration/src/authorization/main/authorize_person.rs b/crates/sqlbuild-orchestration/src/authorization/main/authorize_person.rs index a3b0d3cb82..b8ca2c0170 100644 --- a/crates/sqlbuild-orchestration/src/authorization/main/authorize_person.rs +++ b/crates/sqlbuild-orchestration/src/authorization/main/authorize_person.rs @@ -3,36 +3,14 @@ use serde_json::json; use uuid::Uuid; -use crate::authorization::_helpers::custom_roles::{custom_coverage, denial_reason}; +use crate::authorization::_helpers::custom_roles::denial_reason; +use crate::authorization::_helpers::person_grants::{ + GrantDecision, decide_grant, person_role_rows, +}; use crate::authorization::main::deny_authorization::deny; -use crate::credentials::models::{Action, AuditContext, BuiltinRole, DefaultRole, PermissionScope}; +use crate::credentials::models::{AuditContext, DefaultRole, PermissionScope}; use crate::store::errors::ControlError; -use crate::store::main::query::query; -use crate::store::models::{ControlTransaction, Parameter}; -use crate::store::types::Row; - -fn role_allows(role: BuiltinRole, action: Action) -> bool { - use Action::{ - ConnectionTest, JobRun, RunAdhoc, RunCancel, RunRerun, TriggerToggle, View, ViewSql, - }; - match role { - BuiltinRole::Admin => true, - BuiltinRole::Viewer => action == View, - BuiltinRole::Operator => { - matches!(action, View | JobRun | RunCancel | RunRerun | TriggerToggle) - } - BuiltinRole::Developer => matches!( - action, - View | JobRun - | RunCancel - | RunRerun - | TriggerToggle - | ViewSql - | RunAdhoc - | ConnectionTest - ), - } -} +use crate::store::models::ControlTransaction; pub(crate) async fn authorize_person( transaction: ControlTransaction, @@ -45,13 +23,9 @@ pub(crate) async fn authorize_person( granting_role: "none".into(), ..context.clone() }; - let (transaction, people) = query( - transaction, - "SELECT principal_id FROM sqb_principals WHERE principal_id=$1 AND disabled_at IS NULL", - vec![Parameter::Id(principal_id)], - ) - .await?; - if people.is_empty() { + let (transaction, rows) = + person_role_rows(transaction, principal_id, scope.target.clone()).await?; + let Some(rows) = rows else { return deny( transaction, &context, @@ -59,51 +33,23 @@ pub(crate) async fn authorize_person( "authenticated active person required", ) .await; + }; + match decide_grant(&rows, scope, default_role) { + GrantDecision::Granted(role) => { + context.granting_role = role; + Ok((transaction, context)) + } + GrantDecision::Denied(missing) => { + let (audited, shown) = denial_reason(scope.action, &missing)?; + deny(transaction, &context, json!(scope), &audited) + .await + .map_err(|error| match error { + ControlError::Denied { decision_id, .. } => ControlError::Denied { + reason: shown, + decision_id, + }, + error => error, + }) + } } - let (transaction, rows) = query(transaction, - "SELECT r.role_key,r.location_id,r.target,r.group_name,e.resolved_grants_json FROM \ - (SELECT role_key,location_id,target,NULL AS group_name FROM sqb_role_assignments WHERE principal_id=$1 \ - UNION ALL SELECT g.role_key,g.location_id,g.target,g.group_name FROM sqb_person_groups p \ - JOIN sqb_group_roles g ON g.group_name=p.group_name WHERE p.principal_id=$1) r \ - LEFT JOIN sqb_effective_roles e ON e.location_id=r.location_id AND e.role_key=r.role_key AND e.target=$2 \ - ORDER BY r.role_key,r.group_name", - vec![Parameter::Id(principal_id), Parameter::OptionalText(scope.target.clone())]).await?; - let rows: Vec<&Row> = rows - .iter() - .filter(|row| { - (row["location_id"].is_null() || row["location_id"] == json!(scope.location_id)) - && (row["target"].is_null() || row["target"] == json!(scope.target)) - }) - .collect(); - let builtin = rows.iter().find(|row| { - serde_json::from_value::(row["role_key"].clone()) - .is_ok_and(|role| role_allows(role, scope.action)) - }); - if let Some(row) = builtin { - let role = row["role_key"].as_str().unwrap_or_default(); - context.granting_role = match row["group_name"].as_str() { - Some(group) => format!("group:{group}:{role}"), - None => role.into(), - }; - return Ok((transaction, context)); - } - let (granting, missing) = custom_coverage(&rows, scope); - if !granting.is_empty() && missing.is_empty() { - context.granting_role = granting.join("+"); - return Ok((transaction, context)); - } - if default_role == DefaultRole::Viewer && role_allows(BuiltinRole::Viewer, scope.action) { - context.granting_role = "default:viewer".into(); - return Ok((transaction, context)); - } - let (audited, shown) = denial_reason(scope.action, &missing)?; - deny(transaction, &context, json!(scope), &audited) - .await - .map_err(|error| match error { - ControlError::Denied { decision_id, .. } => ControlError::Denied { - reason: shown, - decision_id, - }, - error => error, - }) } diff --git a/crates/sqlbuild-orchestration/src/authorization/main/mod.rs b/crates/sqlbuild-orchestration/src/authorization/main/mod.rs index 6c4ddfef68..54eefbd50e 100644 --- a/crates/sqlbuild-orchestration/src/authorization/main/mod.rs +++ b/crates/sqlbuild-orchestration/src/authorization/main/mod.rs @@ -7,8 +7,10 @@ pub mod change_administrator; pub mod compile_roles; pub(crate) mod deny_authorization; pub mod grant_admin; +pub(crate) mod person_lists_target; pub(crate) mod publish_custom_roles; pub(crate) mod publish_group_roles; pub(crate) mod read_audit_events; +pub(crate) mod read_person_access; pub mod read_security_audit; pub(crate) mod refuse_unknown; diff --git a/crates/sqlbuild-orchestration/src/authorization/main/person_lists_target.rs b/crates/sqlbuild-orchestration/src/authorization/main/person_lists_target.rs new file mode 100644 index 0000000000..638066aea2 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/authorization/main/person_lists_target.rs @@ -0,0 +1,54 @@ +//! Whether a person may still receive a target's change notices; unaudited, rechecked before every notice. + +use uuid::Uuid; + +use crate::authorization::_helpers::person_grants::{ + GrantDecision, decide_grant, person_role_rows, snapshot_nodes, +}; +use crate::credentials::models::{Action, DefaultRole, PermissionScope}; +use crate::store::errors::ControlError; +use crate::store::main::{begin::begin, commit::commit, query::query}; +use crate::store::models::{ControlStore, Parameter, TransactionMode}; + +/// Whether an active person still views every node of the target, as its listings require; unaudited, for change notices. +pub(crate) async fn person_lists_target( + store: &ControlStore, + principal_id: Uuid, + (location_id, target): (Uuid, &str), + default_role: DefaultRole, +) -> Result { + let transaction = begin(store, TransactionMode::Read).await?; + let (transaction, current) = query( + transaction, + "SELECT c.snapshot_id FROM sqb_current_snapshots c JOIN sqb_locations l \ + ON l.location_id=c.location_id WHERE l.archived_at IS NULL AND c.location_id=$1 AND c.target=$2", + vec![Parameter::Id(location_id), Parameter::Text(target.into())], + ) + .await?; + let Some(snapshot_id) = current.first() else { + commit(transaction).await?; + return Ok(false); + }; + let (transaction, nodes) = snapshot_nodes( + transaction, + serde_json::from_value(snapshot_id["snapshot_id"].clone()) + .map_err(|error| ControlError::Invalid(error.to_string()))?, + ) + .await?; + let (transaction, rows) = + person_role_rows(transaction, principal_id, Some(target.into())).await?; + commit(transaction).await?; + let Some(rows) = rows else { + return Ok(false); + }; + let scope = PermissionScope { + action: Action::View, + location_id: Some(location_id), + target: Some(target.into()), + nodes, + }; + Ok(matches!( + decide_grant(&rows, &scope, default_role), + GrantDecision::Granted(_) + )) +} diff --git a/crates/sqlbuild-orchestration/src/authorization/main/read_person_access.rs b/crates/sqlbuild-orchestration/src/authorization/main/read_person_access.rs new file mode 100644 index 0000000000..7e6139fe30 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/authorization/main/read_person_access.rs @@ -0,0 +1,84 @@ +//! Report what a person may do without auditing; only display uses this, never a request's authorization. + +use std::collections::BTreeSet; + +use uuid::Uuid; + +use crate::authorization::_helpers::person_grants::{allowed, person_role_rows, snapshot_nodes}; +use crate::authorization::models::{LocationAccess, PersonAccess}; +use crate::credentials::models::{Action, DefaultRole}; +use crate::store::errors::ControlError; +use crate::store::main::{begin::begin, commit::commit, query::query}; +use crate::store::models::{ControlStore, Parameter, TransactionMode}; + +/// `None` for a disabled or unknown person; locations without view on any node are left out. +pub(crate) async fn read_person_access( + store: &ControlStore, + principal_id: Uuid, + default_role: DefaultRole, +) -> Result, ControlError> { + let transaction = begin(store, TransactionMode::Read).await?; + let (transaction, people) = query( + transaction, + "SELECT name FROM sqb_principals WHERE principal_id=$1 AND disabled_at IS NULL", + vec![Parameter::Id(principal_id)], + ) + .await?; + let Some(name) = people + .first() + .map(|row| row["name"].as_str().unwrap_or_default().to_owned()) + else { + commit(transaction).await?; + return Ok(None); + }; + let (transaction, rows) = person_role_rows(transaction, principal_id, None).await?; + let actions = allowed( + &rows.unwrap_or_default(), + (None, None), + &BTreeSet::new(), + DefaultRole::None, + ); + let (mut transaction, targets) = query( + transaction, + "SELECT l.location_id,l.name,c.target,c.snapshot_id FROM sqb_current_snapshots c \ + JOIN sqb_locations l ON l.location_id=c.location_id WHERE l.archived_at IS NULL \ + ORDER BY l.name,c.target", + Vec::new(), + ) + .await?; + let mut locations: Vec = Vec::new(); + for current in &targets { + let location_id: Uuid = serde_json::from_value(current["location_id"].clone()) + .map_err(|error| ControlError::Invalid(error.to_string()))?; + let target = current["target"].as_str().unwrap_or_default().to_owned(); + let (next, nodes) = snapshot_nodes( + transaction, + serde_json::from_value(current["snapshot_id"].clone()) + .map_err(|error| ControlError::Invalid(error.to_string()))?, + ) + .await?; + let (next, rows) = person_role_rows(next, principal_id, Some(target.clone())).await?; + transaction = next; + let actions = allowed( + &rows.unwrap_or_default(), + (Some(location_id), Some(target.clone())), + &nodes, + default_role, + ); + if actions.contains(&Action::View) { + locations.push(LocationAccess { + location_id, + name: current["name"].as_str().unwrap_or_default().to_owned(), + target, + actions, + }); + } + } + commit(transaction).await?; + Ok(Some(PersonAccess { + principal_id, + name, + actions, + locations, + })) +} diff --git a/crates/sqlbuild-orchestration/src/authorization/mod.rs b/crates/sqlbuild-orchestration/src/authorization/mod.rs index d1ddfc554a..ca921aa020 100644 --- a/crates/sqlbuild-orchestration/src/authorization/mod.rs +++ b/crates/sqlbuild-orchestration/src/authorization/mod.rs @@ -2,3 +2,4 @@ pub(crate) mod _helpers; pub mod main; +pub mod models; diff --git a/crates/sqlbuild-orchestration/src/authorization/models.rs b/crates/sqlbuild-orchestration/src/authorization/models.rs new file mode 100644 index 0000000000..2b2aeb7017 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/authorization/models.rs @@ -0,0 +1,24 @@ +//! What a person may do, reported for the UI to show or hide actions; every request is still authorized. + +use serde::Serialize; +use uuid::Uuid; + +use crate::credentials::models::Action; + +/// One location target the person can view, with the actions allowed on at least one of its nodes. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, utoipa::ToSchema)] +pub struct LocationAccess { + pub location_id: Uuid, + pub name: String, + pub target: String, + pub actions: Vec, +} + +/// The person's name, the actions allowed server-wide, and the location targets they can view. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, utoipa::ToSchema)] +pub struct PersonAccess { + pub principal_id: Uuid, + pub name: String, + pub actions: Vec, + pub locations: Vec, +} diff --git a/crates/sqlbuild-orchestration/tests/api/changes/helpers.rs b/crates/sqlbuild-orchestration/tests/api/changes/helpers.rs index 9a7ff3e5d9..7744395608 100644 --- a/crates/sqlbuild-orchestration/tests/api/changes/helpers.rs +++ b/crates/sqlbuild-orchestration/tests/api/changes/helpers.rs @@ -6,7 +6,8 @@ use std::time::Duration; use uuid::Uuid; use crate::api::tests::test_types::{ResetProxy, SseConnection, TestApi}; -use crate::store::models::ControlStore; +use crate::store::main::{begin::begin, commit::commit, query::query}; +use crate::store::models::{ControlStore, TransactionMode}; pub(crate) async fn connect( client: &reqwest::Client, @@ -22,6 +23,32 @@ pub(crate) async fn connect( .send() .await .expect("open scoped SSE connection"); + opened(response).await +} + +/// A person's feed over a browser session cookie; the caller checks the status. +pub(crate) async fn request_person_feed( + client: &reqwest::Client, + api: &TestApi, + (location_id, target, session): (Uuid, &str, &str), +) -> reqwest::Response { + client + .get(format!( + "{}/api/control/locations/{location_id}/targets/{target}/changes", + api.url + )) + .header(reqwest::header::HOST, "control.example.com") + .header( + reqwest::header::COOKIE, + format!("__Host-sqb-session={session}"), + ) + .send() + .await + .expect("open person SSE connection") +} + +/// An accepted SSE response, after its initial refetch notice. +pub(crate) async fn opened(response: reqwest::Response) -> SseConnection { assert_eq!(response.status(), reqwest::StatusCode::OK); assert_eq!(response.headers()["content-type"], "text/event-stream"); assert_eq!(response.headers()["cache-control"], "no-store"); @@ -97,6 +124,21 @@ pub(crate) async fn restore_snapshot_commits(store: &ControlStore) { )).await; } +pub(crate) async fn audit_events(store: &ControlStore) -> i64 { + let transaction = begin(store, TransactionMode::Read) + .await + .expect("inspect audit"); + let (transaction, rows) = query( + transaction, + "SELECT count(*) AS events FROM sqb_security_audit", + vec![], + ) + .await + .expect("audit count"); + commit(transaction).await.expect("close inspection"); + rows[0]["events"].as_i64().expect("count") +} + async fn execute_fault_sql(store: &ControlStore, (sqlite, postgres): (&str, &str)) { let ControlStore::Postgres(pool) = store else { let ControlStore::Sqlite(path) = store else { diff --git a/crates/sqlbuild-orchestration/tests/api/test_contracts.rs b/crates/sqlbuild-orchestration/tests/api/test_contracts.rs index 73b9d6681c..61356c9075 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_contracts.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_contracts.rs @@ -62,6 +62,8 @@ mod test_oidc; mod test_oidc_groups; #[path = "test_passwords.rs"] mod test_passwords; +#[path = "test_person_live.rs"] +mod test_person_live; #[path = "test_person_location_work.rs"] mod test_person_location_work; #[path = "test_person_sessions.rs"] diff --git a/crates/sqlbuild-orchestration/tests/api/test_denials.rs b/crates/sqlbuild-orchestration/tests/api/test_denials.rs index 838b41ebb7..f1342a76cf 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_denials.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_denials.rs @@ -20,8 +20,9 @@ use crate::store::tests::store_helpers::{close_test_store, open_test_store, run} use crate::store::tests::store_test_types::Backend; /// Location-scoped reads and the action each needs. -const READS: [(&str, &str); 8] = [ +const READS: [(&str, &str); 9] = [ ("runs", "view"), + ("changes", "view"), ("catalog?schema=orders", "view"), ("statistics?schema=orders&name=orders", "view"), ("checks", "view"), diff --git a/crates/sqlbuild-orchestration/tests/api/test_person_live.rs b/crates/sqlbuild-orchestration/tests/api/test_person_live.rs new file mode 100644 index 0000000000..b8abf89554 --- /dev/null +++ b/crates/sqlbuild-orchestration/tests/api/test_person_live.rs @@ -0,0 +1,251 @@ +//! A person's bootstrap and change feed expose only locations and topics their roles allow. + +use reqwest::{StatusCode, header}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::api::models::{ApiAuthentication, HumanLogin}; +use crate::api::tests::changes_helpers::{ + audit_events, expect_topics, next_topic, opened, request_person_feed, +}; +use crate::api::tests::helpers::{start_api_with_authentication, stop_api}; +use crate::api::tests::test_types::PersonLiveTestCase; +use crate::credentials::tests::location_helpers::create_location; +use crate::credentials::tests::role_helpers::{SUPPORT_ROLE, orders_grant, role_fixture}; +use crate::runs::main::request_job_run::request_job_run; +use crate::runs::models::JobRunRequest; +use crate::runs::tests::helpers::snapshot; +use crate::snapshots::main::publish_snapshot::publish_snapshot; +use crate::store::main::migrate_store::migrate_store; +use crate::store::tests::store_helpers::{close_test_store, open_test_store, run}; +use crate::store::tests::store_test_types::Backend; + +#[test] +fn given_people_with_different_roles_when_they_bootstrap_and_subscribe_then_only_permitted_locations_and_topics_are_exposed() + { + let test_cases = [ + PersonLiveTestCase { + description: "SQLite person bootstrap and change feed", + backend: Backend::Sqlite, + expected_denied_feed: StatusCode::FORBIDDEN, + expected_disabled_bootstrap: StatusCode::UNAUTHORIZED, + }, + PersonLiveTestCase { + description: "PostgreSQL person bootstrap and change feed", + backend: Backend::Postgres, + expected_denied_feed: StatusCode::FORBIDDEN, + expected_disabled_bootstrap: StatusCode::UNAUTHORIZED, + }, + ]; + 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 mut orders = snapshot(roles.location_id); + orders.generation = 2; + orders.documents.insert( + "roles".into(), + json!({SUPPORT_ROLE: {"description": "Answer order questions", + "grants": [orders_grant(json!(["view"]), &["orders"])]}}), + ); + orders.documents.insert( + "jobs".into(), + json!({"orders": {"description": "Build orders", "selection": ["orders"]}}), + ); + publish_snapshot(store, &orders).await.expect("orders jobs"); + let inventory = create_location(store, "inventory", "orders-admin") + .await + .expect("inventory location"); + publish_snapshot(store, &snapshot(inventory)) + .await + .expect("inventory snapshot"); + let api = start_api_with_authentication( + store, + ApiAuthentication { + login: HumanLogin::Password("https://control.example.com".into()), + ..ApiAuthentication::default() + }, + ) + .await; + let client = reqwest::Client::new(); + let bootstrap = |bearer: String| { + client + .get(format!("{}/api/control/bootstrap", api.url)) + .header(header::HOST, "control.example.com") + .header(header::COOKIE, format!("__Host-sqb-session={bearer}")) + .send() + }; + let visible = |body: &Value| { + body["access"]["locations"] + .as_array() + .expect("locations") + .iter() + .map(|location| { + json!([location["name"], location["target"], location["actions"]]) + }) + .collect::>() + }; + + let audited = audit_events(store).await; + let member: Value = bootstrap(roles.member.bearer.clone()) + .await + .expect("member bootstrap") + .json() + .await + .expect("bootstrap body"); + let administrator: Value = bootstrap(roles.administrator.bearer.clone()) + .await + .expect("administrator bootstrap") + .json() + .await + .expect("bootstrap body"); + assert_eq!( + (visible(&member), member["access"]["actions"].clone()), + (vec![json!(["orders", "development", ["view"]])], json!([])), + "{}: the member sees only the location its role can view", + test_case.description + ); + assert_eq!( + visible(&administrator) + .iter() + .map(|location| location[0].clone()) + .collect::>(), + vec![json!("inventory"), json!("orders")] + ); + assert_eq!( + audit_events(store).await, + audited, + "{}: capability reporting writes no audit events", + test_case.description + ); + + let partial = request_person_feed( + &client, + &api, + (roles.location_id, "development", &roles.member.bearer), + ) + .await + .status(); + let unassigned = request_person_feed( + &client, + &api, + (inventory, "development", &roles.member.bearer), + ) + .await + .status(); + assert_eq!( + (partial, unassigned, audit_events(store).await - audited), + ( + test_case.expected_denied_feed, + test_case.expected_denied_feed, + 2 + ), + "{}: feeds need view of every node, and each denial is audited", + test_case.description + ); + + let mut feed = opened( + request_person_feed( + &client, + &api, + ( + roles.location_id, + "development", + &roles.administrator.bearer, + ), + ) + .await, + ) + .await; + let request = JobRunRequest { + run_id: Uuid::now_v7(), + location_id: roles.location_id, + target: "development".into(), + job_key: "orders".into(), + reason: "manual verification".into(), + priority: 0, + window: None, + }; + assert!( + request_job_run(store, &roles.context, &request) + .await + .expect("queue job") + ); + expect_topics( + &mut feed, + &["runs".into(), "node_status:development".into()], + ) + .await; + let mut other = snapshot(inventory); + other.generation = 2; + publish_snapshot(store, &other) + .await + .expect("other location change"); + orders.generation = 3; + publish_snapshot(store, &orders) + .await + .expect("orders change"); + expect_topics( + &mut feed, + &[ + format!("snapshot:{}", roles.location_id), + "node_status:development".into(), + ], + ) + .await; + + let disabled = client + .post(format!("{}/api/admin/people/{}", api.url, roles.member_id)) + .header(header::HOST, "control.example.com") + .header(header::ORIGIN, "https://control.example.com") + .header( + header::COOKIE, + format!("__Host-sqb-session={}", roles.administrator.bearer), + ) + .header("x-sqb-csrf", &roles.administrator.csrf) + .json(&json!("disable")) + .send() + .await + .expect("disable member") + .status(); + let after_disable = bootstrap(roles.member.bearer.clone()) + .await + .expect("disabled bootstrap") + .status(); + let logout = client + .post(format!("{}/api/auth/logout", api.url)) + .header(header::HOST, "control.example.com") + .header(header::ORIGIN, "https://control.example.com") + .header( + header::COOKIE, + format!("__Host-sqb-session={}", roles.administrator.bearer), + ) + .header("x-sqb-csrf", &roles.administrator.csrf) + .send() + .await + .expect("logout") + .status(); + orders.generation = 4; + publish_snapshot(store, &orders) + .await + .expect("change after logout"); + assert_eq!( + (disabled, after_disable, logout, next_topic(&mut feed).await), + ( + StatusCode::OK, + test_case.expected_disabled_bootstrap, + StatusCode::NO_CONTENT, + None + ), + "{}: a disabled person loses bootstrap and a logged-out feed ends", + test_case.description + ); + stop_api(api).await; + close_test_store(fixture).await; + }); + } +} diff --git a/crates/sqlbuild-orchestration/tests/api/test_types.rs b/crates/sqlbuild-orchestration/tests/api/test_types.rs index b304469843..2f913bacfc 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_types.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_types.rs @@ -51,6 +51,13 @@ pub(crate) struct ResetProxy { pub(crate) task: tokio::task::JoinHandle<()>, } +pub(crate) struct PersonLiveTestCase { + pub(crate) description: &'static str, + pub(crate) backend: Backend, + pub(crate) expected_denied_feed: reqwest::StatusCode, + pub(crate) expected_disabled_bootstrap: reqwest::StatusCode, +} + pub(crate) struct ChangesTestCase { pub(crate) description: &'static str, pub(crate) backend: Backend, diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000000..dccd4e2705 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.svelte-kit/ +build/ +.vite/ +vite.config.ts.timestamp-* diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 0000000000..b6f27f1359 --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000000..5bee1080fa --- /dev/null +++ b/ui/README.md @@ -0,0 +1,33 @@ +# SQLBuild UI + +The SQLBuild orchestration web UI: a SvelteKit single-page app built to static +assets (`adapter-static`, SPA fallback, no SSR). + +This is an early, preliminary version. Pages read from a typed data layer in +`src/lib/api/`; until the server API exists, that layer is backed by an +in-memory mock client with neutral example data. + +## Develop + +```sh +npm ci +npm run dev +``` + +## Check and build + +```sh +npm run check +npm run build # writes static assets to build/ +``` + +## Generated files + +The run panel's verbs and flags come from the CLI's command table +(`crates/sqlbuild-cli/src/argv/command_table.json`). After changing CLI flags, +regenerate the extract; `check:command-table` fails when it is stale. + +```sh +npm run gen:command-table +npm run check:command-table +``` diff --git a/ui/components.json b/ui/components.json new file mode 100644 index 0000000000..5e6e1e67aa --- /dev/null +++ b/ui/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/routes/layout.css", + "baseColor": "neutral" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry", + "style": "vega", + "iconLibrary": "lucide", + "menuColor": "default", + "menuAccent": "subtle" +} diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000000..15b95eb3c5 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2664 @@ +{ + "name": "sqlbuild-ui", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sqlbuild-ui", + "version": "0.0.1", + "dependencies": { + "@dagrejs/dagre": "^3.0.0", + "@fontsource-variable/space-grotesk": "^5.2.10", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@xyflow/svelte": "^1.6.0", + "openapi-fetch": "0.17.0", + "prismjs": "^1.30.0" + }, + "devDependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@internationalized/date": "^3.12.2", + "@lucide/svelte": "^1.17.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^20.19.43", + "@types/prismjs": "^1.26.6", + "bits-ui": "^2.18.1", + "clsx": "^2.1.1", + "shadcn-svelte": "^1.3.0", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwind-merge": "^3.6.0", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0", + "typescript": "^6.0.2", + "vite": "^8.0.7", + "vitest": "^4.1.12" + } + }, + "node_modules/@dagrejs/dagre": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", + "integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "4.0.1" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz", + "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/space-grotesk": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.2.10.tgz", + "integrity": "sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz", + "integrity": "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@internationalized/date": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", + "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lucide/svelte": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.17.0.tgz", + "integrity": "sha512-q06YCFBN5CO8cd1ADmLCxWRVMVb7xxvHzqC0lvNoxGa+FLW6Cd1Y1AOxgbQk4Iwe68vkAMCRveNHint4WoaVKg==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@svelte-put/shortcut": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@svelte-put/shortcut/-/shortcut-4.1.0.tgz", + "integrity": "sha512-wImNEIkbxAIWFqlfuhcbC+jRPDeRa/uJGIXHMEVVD+jqL9xCwWNnkGQJ6Qb2XVszuRLHlb8SGZDL3Io/h3vs8w==", + "license": "MIT", + "peerDependencies": { + "svelte": "^5.1.0" + } + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.61.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.61.1.tgz", + "integrity": "sha512-Ny8s1SR1TyQS2hD2Rvw0XKzU2Nw1eUF52dTb6T2bdcgz7wSC+Nyb5IwjWYlR4b2dvbbR5NJDiQwHg3rnNseghg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.12", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.12", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.12", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/svelte": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-1.6.0.tgz", + "integrity": "sha512-msCT95OqTThh7gWhdFJ03Qzhc0Q8QYfA8K03M4I+KTtHdvatoTuRo9ofkFHJDbMfS243y+fOs5TCvU9T1kEUUA==", + "license": "MIT", + "dependencies": { + "@svelte-put/shortcut": "^4.1.0", + "@xyflow/system": "0.0.77" + }, + "peerDependencies": { + "svelte": "^5.25.0" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.77", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz", + "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bits-ui": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz", + "integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", + "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/shadcn-svelte": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/shadcn-svelte/-/shadcn-svelte-1.3.0.tgz", + "integrity": "sha512-Pd4ICWTkTks/b2YU4c9vF2XsX1x5HFPRl5bKszS1LcnWS83x+7T4WiIvbWz8Qh9knkcGZ+SCz1+Dmhdq+AYooA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.0", + "node-fetch-native": "^1.6.4", + "postcss": "^8.5.5", + "tailwind-merge": "^3.0.0" + }, + "bin": { + "shadcn-svelte": "dist/index.mjs" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/svelte": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.0.tgz", + "integrity": "sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.9", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.5.0.tgz", + "integrity": "sha512-9lNwPxCLWniFvQIcEv1LFqjIxcFtO3smb5+5BKbRJ3ttL4o2lXCej5rLF4DAnfLPI66oaA81vAxw6ILdIWI7kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-variants": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", + "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000000..685127b409 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,50 @@ +{ + "name": "sqlbuild-ui", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "vitest run", + "gen:command-table": "node scripts/extract-command-table.mjs", + "check:command-table": "node scripts/extract-command-table.mjs --check", + "check:checkboxes": "node scripts/check-checkboxes.mjs" + }, + "devDependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@internationalized/date": "^3.12.2", + "@lucide/svelte": "^1.17.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^20.19.43", + "@types/prismjs": "^1.26.6", + "bits-ui": "^2.18.1", + "clsx": "^2.1.1", + "shadcn-svelte": "^1.3.0", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwind-merge": "^3.6.0", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0", + "typescript": "^6.0.2", + "vite": "^8.0.7", + "vitest": "^4.1.12" + }, + "dependencies": { + "@dagrejs/dagre": "^3.0.0", + "@fontsource-variable/space-grotesk": "^5.2.10", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@xyflow/svelte": "^1.6.0", + "openapi-fetch": "0.17.0", + "prismjs": "^1.30.0" + } +} diff --git a/ui/scripts/check-checkboxes.mjs b/ui/scripts/check-checkboxes.mjs new file mode 100644 index 0000000000..ac4ba1a555 --- /dev/null +++ b/ui/scripts/check-checkboxes.mjs @@ -0,0 +1,31 @@ +// Fails when a native checkbox is missing the shared `sb-check` style. +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const root = new URL('../src', import.meta.url).pathname; +const failures = []; + +function walk(dir) { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path); + else if (path.endsWith('.svelte')) check(path); + } +} + +function check(path) { + const lines = readFileSync(path, 'utf8').split('\n'); + lines.forEach((line, index) => { + if (line.includes('type="checkbox"') && !/class="[^"]*\bsb-check\b/.test(line)) { + failures.push(`${relative(process.cwd(), path)}:${index + 1}`); + } + }); +} + +walk(root); +if (failures.length > 0) { + console.error('Checkboxes without the shared sb-check style:'); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); +} +console.log('All checkboxes use sb-check.'); diff --git a/ui/scripts/extract-command-table.mjs b/ui/scripts/extract-command-table.mjs new file mode 100644 index 0000000000..7164ee94ab --- /dev/null +++ b/ui/scripts/extract-command-table.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +// Extracts the run panel's verbs and flags from the CLI's command table, so the +// panel offers exactly the flags `sqb` accepts. +// +// node scripts/extract-command-table.mjs # rewrite the generated file +// node scripts/extract-command-table.mjs --check # fail if it is out of date +// +// Flags the server controls or that only make sense on a local shell (target, +// output formats, project paths, dbt interop paths) are left out. Every name in +// the lists below must exist in the table, so a renamed flag fails the script +// instead of silently drifting. + +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const SOURCE = resolve(here, '../../crates/sqlbuild-cli/src/argv/command_table.json'); +const OUTPUT = resolve(here, '../src/lib/run/command-table.generated.json'); + +/** Verbs a server run can launch, with the panel's one-line descriptions. */ +const VERBS = { + build: 'Build the selected nodes, running their tests and audits', + test: 'Run unit and scenario tests for the selection', + seed: 'Load seed files', + load: 'Run loaders into their source tables', + audit: 'Evaluate audits against what is already built', + freshness: 'Check source freshness' +}; + +/** Set by the server from the location's target, the run token and the event stream. */ +const SERVER_CONTROLLED = [ + '--help', + '--target', + '--json', + '--json-output', + '--event-output', + '--project-dir', + '--no-color', + '--debug', + '--manifest', + '--select-file', + '--selection-diagnostics', + '--dbt-project-dir', + '--dbt-profiles-dir', + '--dbt-target', + '--dbt-target-path', + '--since', + '--explain', + '--inspect' +]; + +/** Flags that rewrite history and need `run.rewrite_history`. */ +const REWRITES_HISTORY = [ + '--full-refresh', + '--allow-snapshot-full-refresh', + '--reload', + '--start-cursor', + '--end-cursor', + '--start-cursor-ts', + '--end-cursor-ts', + '--start-cursor-int', + '--end-cursor-int' +]; + +/** Descriptions for flags the table leaves undocumented. */ +const DESCRIPTIONS = { + '--select': 'Selector expression, e.g. +fact_orders, tag:finance, group:marts', + '--exclude': 'Selector expression to leave out', + '--defer-to': 'Read unselected upstream nodes from another target', + '--defer-clone-from': 'Clone unselected upstream nodes from another target', + '--defer-sources-to': 'Read sources from another target', + '--no-python': 'Skip Python nodes', + '--no-tests': 'Skip unit tests during the build', + '--no-audits': 'Skip audits during the build', + '--max-microbatches': 'Process at most this many microbatches per incremental model', + '--load': 'Run loaders as part of the build', + '--no-load': 'Skip loaders', + '--reload': 'Reload loader sources from the beginning', + '--fail-fast': 'Stop at the first failed node', + '--full-refresh': 'Rebuild incremental models and snapshots from scratch', + '--allow-snapshot-full-refresh': 'Allow a full refresh of snapshot models', + '--allow-table-type-downgrade': 'Allow replacing a table with a less durable table type', + '--allow-retention-decrease': 'Allow lowering a table retention setting', + '--allow-missing-migration-origin': 'Allow a migration whose origin relation no longer exists', + '--allow-snapshot-schema-change': 'Allow a schema change on snapshot models', + '--concurrency': 'Nodes to run in parallel within this run', + '--verbose': 'More detailed output', + '--vars': 'Project variables as a JSON or key=value mapping', + '--warehouse': 'Warehouse for this run, overriding the target default', + '--no-sql-analysis': 'Disable SQL syntax, binding and type validation', + '--no-cache': 'Bypass the compile analysis cache', + '--case': 'Run one named parameter case', + '--state': 'Record freshness results in the state tables', + '--fail-on-error': 'Fail when a freshness check errors', + '--fail-on-stale': 'Fail when a source is stale' +}; + +function valueType(action) { + const flag = action.options[0]; + if (action.kind === 'store_true' || action.kind === 'store_false') return 'bool'; + if (flag.endsWith('-ts')) return 'timestamp'; + if (flag.endsWith('-int') || flag === '--max-microbatches' || flag === '--concurrency') return 'int'; + if (action.kind === 'extend') return 'selector'; + return 'string'; +} + +function extract(table) { + const parsers = table.actions.find((a) => a.kind === 'parsers')?.parsers ?? []; + const known = new Set(); + const verbs = Object.entries(VERBS).map(([name, description]) => { + const parser = parsers.find((p) => p.name === name); + if (!parser) throw new Error(`Verb ${name} is not in the command table`); + const flags = []; + for (const action of parser.parser.actions) { + if (!action.options) continue; + for (const option of action.options) known.add(option); + const flag = action.options.find((o) => o.startsWith('--')); + if (!flag || action.options.some((o) => SERVER_CONTROLLED.includes(o))) continue; + const help = typeof action.help === 'string' ? action.help : null; + if (action.help && typeof action.help === 'object') continue; + flags.push({ + flag, + aliases: action.options.filter((o) => o !== flag), + type: valueType(action), + choices: action.choices ?? null, + repeatable: action.kind === 'extend' || action.kind === 'append', + rewritesHistory: REWRITES_HISTORY.includes(flag), + description: help ?? DESCRIPTIONS[flag] ?? '' + }); + } + return { name, description, flags }; + }); + for (const flag of [...SERVER_CONTROLLED, ...REWRITES_HISTORY, ...Object.keys(DESCRIPTIONS)]) { + if (!known.has(flag)) throw new Error(`${flag} is listed here but not in the command table for any launchable verb`); + } + return { source: 'crates/sqlbuild-cli/src/argv/command_table.json', verbs }; +} + +const generated = JSON.stringify(extract(JSON.parse(readFileSync(SOURCE, 'utf8'))), null, '\t') + '\n'; +if (process.argv.includes('--check')) { + const current = readFileSync(OUTPUT, 'utf8'); + if (current !== generated) { + console.error('src/lib/run/command-table.generated.json is out of date; run npm run gen:command-table'); + process.exit(1); + } +} else { + writeFileSync(OUTPUT, generated); +} diff --git a/ui/src/app.d.ts b/ui/src/app.d.ts new file mode 100644 index 0000000000..da08e6da59 --- /dev/null +++ b/ui/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/ui/src/app.html b/ui/src/app.html new file mode 100644 index 0000000000..b9a7abfef4 --- /dev/null +++ b/ui/src/app.html @@ -0,0 +1,23 @@ + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/ui/src/lib/api/capabilities.ts b/ui/src/lib/api/capabilities.ts new file mode 100644 index 0000000000..5db316c1ee --- /dev/null +++ b/ui/src/lib/api/capabilities.ts @@ -0,0 +1,25 @@ +import type { Capability } from './types'; + +/** + * Whether the current principal holds `capability`. + * + * Only used to hide or disable UI; the server remains the real check. + */ +export function hasCapability(capabilities: readonly Capability[] | undefined, capability: Capability): boolean { + return capabilities?.includes(capability) ?? false; +} + +/** Whether the current principal holds any of `required`. */ +export function hasAnyCapability( + capabilities: readonly Capability[] | undefined, + required: readonly Capability[] +): boolean { + return required.some((capability) => hasCapability(capabilities, capability)); +} + +export const ADMIN_CAPABILITIES: readonly Capability[] = [ + 'location.manage', + 'user.manage', + 'role.assign', + 'audit.view' +]; diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts new file mode 100644 index 0000000000..0944f8eb5f --- /dev/null +++ b/ui/src/lib/api/client.ts @@ -0,0 +1,135 @@ +import type { + AdhocRunCheck, + AdhocRunRequest, + Bootstrap, + BulkRunResult, + CatalogNode, + CatalogNodeDetail, + Check, + CheckDetail, + CheckEvidence, + CheckSql, + ColumnLineage, + Job, + JobDetail, + JobLaunchOptions, + NavCounts, + Overview, + PlatformEvent, + QueueEntry, + RerunMode, + Run, + RunDetail, + RunEventPage, + RunListQuery, + RunOutputLine, + RunRowPage, + RunRowQuery, + RunStatement, + RunTabCounts, + Scope, + SnapshotChange, + SnapshotNode, + StatementSql, + Timeline, + Trigger, + TriggerDetail, + TriggerState, + WarehouseSync +} from './types'; +import type { ChangeSubscription } from './signals'; +import type { PlatformApi } from './platform-client'; + +/** + * Everything the UI reads from or asks of the server. + * + * Pages depend on this interface only. The mock implementation stands in until + * the server's OpenAPI client exists; that client is wrapped to implement the + * same interface. + */ +export interface ApiClient extends PlatformApi { + /** Whether the server has an endpoint for `method`; the shell hides what it cannot load. */ + supports(method: keyof ApiClient): boolean; + + bootstrap(): Promise; + + navCounts(scope: Scope): Promise; + + // ---- BUILD: overview, catalog and lineage, checks, changes (types/build.ts) ---- + /** Health, attention items, metrics, build status by group and recent changes. */ + overview(scope: Scope): Promise; + /** Every node in the current snapshot with its build state; also the lineage graph. */ + listCatalogNodes(scope: Scope): Promise; + getCatalogNode(scope: Scope, key: string): Promise; + /** The snapshot's column lineage around one node. */ + getColumnLineage(scope: Scope, key: string): Promise; + /** Every audit and check with its latest result. */ + listChecks(scope: Scope): Promise; + getCheck(scope: Scope, id: string): Promise; + /** The latest evaluation's SQL and error texts. `view.sql`; every read is audited. */ + getCheckSql(scope: Scope, checkId: string): Promise; + /** Sample rows stored with a result. `view.evidence`; every read is audited. */ + getCheckEvidence(scope: Scope, checkId: string, evaluationId: string): Promise; + /** The change feed: one entry per published snapshot, newest first. */ + listSnapshotChanges(scope: Scope, query?: { limit?: number }): Promise; + // ---- end BUILD ---- + + // ---- OPERATE: runs, queue, run panel, 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; + /** Active and queued counts for the status tabs; fetched apart from the rows. */ + runTabCounts(scope: Scope, query?: RunRowQuery): Promise; + /** Requests a cancel of every active run matching the query ("Cancel all"). `run.cancel` */ + cancelMatchingRuns(scope: Scope, query: RunRowQuery): Promise; + getRun(scope: Scope, runId: string): Promise; + /** Nodes, children, liveness, cancel progress and provenance for one run. */ + getRunDetail(scope: Scope, runId: string): Promise; + /** Recall events and scheduler facts in storage order, paged by `after`. */ + listRunEvents(scope: Scope, runId: string, query?: { after?: number; limit?: number }): Promise; + listRunStatements(scope: Scope, runId: string): Promise; + /** One statement's SQL, fetched when its row is expanded. `view.sql` */ + getStatementSql(scope: Scope, runId: string, statementId: string): Promise; + listRunOutput(scope: Scope, runId: string): Promise; + /** Queued runs in claim order, with why each one waits. */ + listQueue(scope: Scope): Promise; + timeline(scope: Scope, window: { since: string; until: string }): Promise; + /** Writes a `cancel_requested` fact; the scheduler then signals the worker. */ + cancelRun(scope: Scope, runId: string): Promise; + /** `nodes` is required for `selected` and ignored otherwise. */ + rerunRun(scope: Scope, runId: string, mode: RerunMode, nodes?: string[]): Promise; + + listSnapshotNodes(scope: Scope): Promise; + /** The server's pre-launch check: resolution, write set and authorization. */ + checkAdhocRun(scope: Scope, request: AdhocRunRequest): Promise; + launchAdhocRun(scope: Scope, request: AdhocRunRequest): Promise; + + listJobs(scope: Scope): Promise; + getJob(scope: Scope, key: string): Promise; + launchJob(scope: Scope, key: string, options?: JobLaunchOptions): Promise; + + listTriggers(scope: Scope): Promise; + getTrigger(scope: Scope, id: string): Promise; + /** One audited state fact per trigger. */ + setTriggerStates(scope: Scope, ids: string[], state: TriggerState, reason?: string | null): Promise; + resetTriggerToDefault(scope: Scope, id: string): Promise; + resetSensorCursor(scope: Scope, id: string, reason?: string | null): Promise; + // ---- end OPERATE ---- + + listPlatformEvents(query: { activeOnly: boolean }): Promise; + + getWarehouseSync(scope: Scope): Promise; + + /** Live "changed" signals (SSE in the server client); consumers refetch. */ + changes: ChangeSubscription; +} + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + this.name = 'ApiError'; + } +} diff --git a/ui/src/lib/api/http-client.test.ts b/ui/src/lib/api/http-client.test.ts new file mode 100644 index 0000000000..8d05b8dc33 --- /dev/null +++ b/ui/src/lib/api/http-client.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { HttpChangeFeed, feedAllowed } from './http-client'; +import type { ChangedSignal, ConnectionState } from './signals'; + +/** A stand-in `EventSource` the test opens, fails and feeds by hand. */ +class FakeSource { + static readonly CLOSED = 2; + static opened: FakeSource[] = []; + readyState = 0; + closed = false; + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + listeners = new Map) => void>(); + + constructor(readonly url: string) { + FakeSource.opened.push(this); + } + + addEventListener(name: string, listener: (event: MessageEvent) => void) { + this.listeners.set(name, listener); + } + + close() { + this.closed = true; + } + + open() { + this.readyState = 1; + this.onopen?.(); + } + + /** The server refused or the connection failed for good, so the browser stopped retrying. */ + fail() { + this.readyState = FakeSource.CLOSED; + this.onerror?.(); + } +} + +const ORDERS = { locationId: '0191f0aa-orders:development' }; +const INVENTORY = { locationId: '0191f0bb-inventory:development' }; + +function feed(stillAllowed: (scope: { locationId: string }) => Promise) { + const changes = new HttpChangeFeed('', stillAllowed); + const states: ConnectionState[] = []; + changes.onConnectionState((state) => states.push(state)); + return { changes, states }; +} + +const latest = () => FakeSource.opened.at(-1)!; + +beforeEach(() => { + FakeSource.opened = []; + vi.useFakeTimers(); + vi.stubGlobal('EventSource', FakeSource); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('HttpChangeFeed', () => { + it('opens the location feed and is live once the stream opens', () => { + const { changes, states } = feed(async () => true); + changes.follow(ORDERS); + expect(latest().url).toBe('/api/control/locations/0191f0aa-orders/targets/development/changes'); + latest().open(); + expect(states).toEqual(['connecting', 'connecting', 'live']); + }); + + it('leaves transient drops to the browser without its own retry', () => { + const stillAllowed = vi.fn(async () => true); + const { changes, states } = feed(stillAllowed); + changes.follow(ORDERS); + latest().open(); + latest().onerror?.(); + vi.advanceTimersByTime(60_000); + expect(states.at(-1)).toBe('reconnecting'); + expect(stillAllowed).not.toHaveBeenCalled(); + expect(FakeSource.opened).toHaveLength(1); + }); + + it('checks access before each retry and backs off 1, 2, 4 then 8 seconds', async () => { + const stillAllowed = vi.fn(async () => true); + const { changes, states } = feed(stillAllowed); + changes.follow(ORDERS); + for (const delay of [1000, 2000, 4000, 8000]) { + const failed = latest(); + failed.fail(); + await vi.advanceTimersByTimeAsync(delay - 1); + expect(stillAllowed).toHaveBeenCalledTimes(FakeSource.opened.length - 1); + expect(latest()).toBe(failed); + await vi.advanceTimersByTimeAsync(1); + expect(stillAllowed).toHaveBeenLastCalledWith(ORDERS); + expect(latest()).not.toBe(failed); + expect(failed.closed).toBe(true); + } + expect(states.filter((state) => state !== 'connecting')).toEqual([ + 'reconnecting', + 'reconnecting', + 'reconnecting', + 'offline' + ]); + }); + + it('caps the backoff at 30 seconds', async () => { + const stillAllowed = vi.fn(async () => true); + const { changes } = feed(stillAllowed); + changes.follow(ORDERS); + for (let attempt = 0; attempt < 6; attempt += 1) { + latest().fail(); + await vi.advanceTimersByTimeAsync(30_000); + } + const calls = stillAllowed.mock.calls.length; + latest().fail(); + await vi.advanceTimersByTimeAsync(29_999); + expect(stillAllowed).toHaveBeenCalledTimes(calls); + await vi.advanceTimersByTimeAsync(1); + expect(stillAllowed).toHaveBeenCalledTimes(calls + 1); + }); + + it('resets the backoff once a retry opens', async () => { + const stillAllowed = vi.fn(async () => true); + const { changes } = feed(stillAllowed); + changes.follow(ORDERS); + latest().fail(); + await vi.advanceTimersByTimeAsync(1000); + latest().fail(); + await vi.advanceTimersByTimeAsync(2000); + latest().open(); + const reopened = latest(); + reopened.fail(); + await vi.advanceTimersByTimeAsync(1000); + expect(latest()).not.toBe(reopened); + }); + + it('goes offline and stops when access is refused', async () => { + const { changes, states } = feed(async () => false); + changes.follow(ORDERS); + latest().fail(); + await vi.advanceTimersByTimeAsync(60_000); + expect(FakeSource.opened).toHaveLength(1); + expect(states.at(-1)).toBe('offline'); + }); + + it('keeps retrying when the access check itself fails', async () => { + const { changes } = feed(() => Promise.reject(new Error('network down'))); + changes.follow(ORDERS); + latest().fail(); + await vi.advanceTimersByTimeAsync(1000); + expect(FakeSource.opened).toHaveLength(2); + }); + + it('drops a pending retry when the person follows another location', async () => { + const stillAllowed = vi.fn(async () => true); + const { changes } = feed(stillAllowed); + changes.follow(ORDERS); + const orders = latest(); + orders.fail(); + changes.follow(INVENTORY); + await vi.advanceTimersByTimeAsync(60_000); + expect(stillAllowed).not.toHaveBeenCalled(); + expect(FakeSource.opened.map((source) => source.url)).toEqual([ + '/api/control/locations/0191f0aa-orders/targets/development/changes', + '/api/control/locations/0191f0bb-inventory/targets/development/changes' + ]); + }); + + it('closes the stream and is offline when following nothing', () => { + const { changes, states } = feed(async () => true); + changes.follow(ORDERS); + const orders = latest(); + changes.follow(null); + expect(orders.closed).toBe(true); + expect(states.at(-1)).toBe('offline'); + }); + + it('maps server topics onto the followed location', () => { + const { changes } = feed(async () => true); + const signals: ChangedSignal['channel'][] = []; + changes.subscribe((signal) => signals.push(signal.channel)); + changes.follow(ORDERS); + for (const topic of ['snapshot:0191f0aa-orders', 'node_status:0191f0aa-orders', 'runs', 'audit']) { + latest().listeners.get('changed')!(new MessageEvent('changed', { data: JSON.stringify({ topic }) })); + } + expect(signals).toEqual([ + `snapshot:${ORDERS.locationId}`, + `node_status:${ORDERS.locationId}`, + 'runs', + 'all' + ]); + }); +}); + +describe('feedAllowed', () => { + const probe = (status: number) => { + const signals: AbortSignal[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init: RequestInit) => { + signals.push(init.signal!); + return new Response(null, { status }); + }) + ); + return signals; + }; + + it.each([ + [200, true, false], + [503, true, false], + [403, false, false], + [401, false, true] + ])('a %s answer allows a retry: %s, and signs out: %s', async (status, allowed, signedOut) => { + const signals = probe(status); + const onUnauthorized = vi.fn(); + expect(await feedAllowed('', ORDERS, onUnauthorized)).toBe(allowed); + expect(onUnauthorized).toHaveBeenCalledTimes(signedOut ? 1 : 0); + expect(vi.mocked(fetch)).toHaveBeenCalledWith('/api/control/locations/0191f0aa-orders/targets/development/changes', expect.anything()); + expect(signals[0].aborted).toBe(true); + }); + + it('stops a partial viewer, whom the bootstrap lists but the feed refuses, after one refusal', async () => { + probe(403); + const changes = new HttpChangeFeed('', (scope) => feedAllowed('', scope, () => {})); + const states: ConnectionState[] = []; + changes.onConnectionState((state) => states.push(state)); + changes.follow(ORDERS); + latest().fail(); + await vi.advanceTimersByTimeAsync(10 * 60_000); + expect(FakeSource.opened).toHaveLength(1); + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); + expect(states.at(-1)).toBe('offline'); + }); + + it('rejects when the network fails, so the feed keeps retrying', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('network down')))); + await expect(feedAllowed('', ORDERS, () => {})).rejects.toThrow('network down'); + }); +}); diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts new file mode 100644 index 0000000000..7c908c7c95 --- /dev/null +++ b/ui/src/lib/api/http-client.ts @@ -0,0 +1,201 @@ +import createFetch from 'openapi-fetch'; +import type { components, paths } from '$control/schema.d.ts'; +import { ApiError, type ApiClient } from './client'; +import type { ChangeSubscription, ChangedChannel, ChangedSignal, ConnectionState } from './signals'; +import type { AuthMode, Bootstrap, Capability, Location, Scope } from './types'; + +type Wire = components['schemas']; + +/** The client methods the server has endpoints for; the shell hides pages that need others. */ +const SUPPORTED: ReadonlySet = new Set(['supports', 'bootstrap', 'changes']); + +/** A UI location is one server location and target; its id carries both. */ +export function locationKey(locationId: string, target: string): string { + return `${locationId}:${target}`; +} + +export function parseLocationKey(key: string): { locationId: string; target: string } { + const split = key.indexOf(':'); + return { locationId: key.slice(0, split), target: key.slice(split + 1) }; +} + +export function toBootstrap(wire: Wire['ControlBootstrap']): Bootstrap { + const authMode: AuthMode = wire.login_mode; + return { + authMode, + user: { kind: 'user', name: wire.access.name, displayName: wire.access.name }, + capabilities: wire.access.actions as Capability[], + locations: wire.access.locations.map( + (location): Location => ({ + id: locationKey(location.location_id, location.target), + name: location.name, + health: null, + lastSeenAt: null, + target: { name: location.target, adapter: null, connectionSummary: null }, + warehouseSync: { lastSyncedAt: null, lastError: null }, + capabilities: location.actions as Capability[] + }) + ) + }; +} + +/** The person change feed URL of a UI location. */ +export function feedUrl(baseUrl: string, scope: Scope): string { + const { locationId, target } = parseLocationKey(scope.locationId); + return `${baseUrl}/api/control/locations/${encodeURIComponent(locationId)}/targets/${encodeURIComponent(target)}/changes`; +} + +/** + * Whether the feed would open now: a refusal (401 or 403) stops retrying, anything else retries. + * It asks the feed itself, because the bootstrap lists a location on partial view while the feed + * needs view on every node, so a bootstrap check would retry a refused feed forever. + */ +export async function feedAllowed(baseUrl: string, scope: Scope, onUnauthorized: () => void): Promise { + const probe = new AbortController(); + const response = await fetch(feedUrl(baseUrl, scope), { + credentials: 'same-origin', + headers: { accept: 'text/event-stream' }, + signal: probe.signal + }); + probe.abort(); + if (response.status === 401) onUnauthorized(); + return response.status !== 401 && response.status !== 403; +} + +/** The change feed of the followed location: one `EventSource`, recreated when the scope changes. */ +export class HttpChangeFeed implements ChangeSubscription { + #handlers = new Set<(signal: ChangedSignal) => void>(); + #stateHandlers = new Set<(state: ConnectionState) => void>(); + #state: ConnectionState = 'connecting'; + #source: EventSource | null = null; + #retry: ReturnType | null = null; + #failures = 0; + + /** `stillAllowed` checks access before a retry, so a lost session or refused feed stops retrying. */ + constructor( + private readonly baseUrl: string, + private readonly stillAllowed: (scope: Scope) => Promise + ) {} + + subscribe(handler: (signal: ChangedSignal) => void): () => void { + this.#handlers.add(handler); + return () => this.#handlers.delete(handler); + } + + onConnectionState(handler: (state: ConnectionState) => void): () => void { + this.#stateHandlers.add(handler); + handler(this.#state); + return () => this.#stateHandlers.delete(handler); + } + + follow(scope: Scope | null): void { + this.#failures = 0; + this.#connect(scope); + } + + #connect(scope: Scope | null) { + this.#source?.close(); + this.#source = null; + if (this.#retry) clearTimeout(this.#retry); + this.#retry = null; + if (!scope) return this.#setState('offline'); + const source = new EventSource(feedUrl(this.baseUrl, scope)); + this.#source = source; + this.#setState('connecting'); + source.onopen = () => { + this.#failures = 0; + this.#setState('live'); + }; + // The browser retries a dropped stream by itself, but gives up on any failed response. + source.onerror = () => { + if (source.readyState !== EventSource.CLOSED) return this.#setState('reconnecting'); + this.#failures += 1; + this.#setState(this.#failures > 3 ? 'offline' : 'reconnecting'); + const delay = Math.min(30_000, 1000 * 2 ** (this.#failures - 1)); + this.#retry = setTimeout(() => { + void this.stillAllowed(scope) + .catch(() => true) + .then((listed) => (listed && this.#source === source ? this.#connect(scope) : this.#setState('offline'))); + }, delay); + }; + source.addEventListener('changed', (event) => { + const notice = JSON.parse((event as MessageEvent).data) as Wire['ChangeNotice']; + this.#emit(channelOf(notice.topic, scope)); + }); + } + + #emit(channel: ChangedChannel) { + const signal = { channel, at: new Date().toISOString() }; + for (const handler of this.#handlers) handler(signal); + } + + #setState(state: ConnectionState) { + this.#state = state; + for (const handler of this.#stateHandlers) handler(state); + } +} + +/** Server topics name the server location; the UI's channels name the UI location. */ +export function channelOf(topic: string, scope: Scope): ChangedChannel { + if (topic.startsWith('snapshot:')) return `snapshot:${scope.locationId}`; + if (topic.startsWith('node_status:')) return `node_status:${scope.locationId}`; + return topic === 'runs' || topic === 'ticks' ? topic : 'all'; +} + +export type HttpClientOptions = { + baseUrl?: string; + /** Called when the session is missing or rejected, before the request fails. */ + onUnauthorized: () => void; +}; + +/** + * The server-backed client. Methods without an endpoint throw, and `supports` + * reports them so the shell never shows a page it cannot load. + */ +export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOptions): ApiClient { + const http = createFetch({ baseUrl, credentials: 'same-origin' }); + const implemented = { + supports: (method: keyof ApiClient) => SUPPORTED.has(method), + changes: new HttpChangeFeed(baseUrl, (scope) => feedAllowed(baseUrl, scope, onUnauthorized)), + async bootstrap(): Promise { + const { data, error, response } = await http.GET('/api/control/bootstrap'); + if (response.status === 401) onUnauthorized(); + if (!data) throw new ApiError(errorText(error, response), response.status); + return toBootstrap(data); + } + }; + return new Proxy(implemented, { + get(target, property, receiver) { + if (property in target) return Reflect.get(target, property, receiver); + return () => Promise.reject(new ApiError(`${String(property)} is not available on this server yet`, 501)); + } + }) as unknown as ApiClient; +} + +function errorText(error: unknown, response: Response): string { + const message = (error as { message?: unknown; error?: unknown } | undefined)?.message ?? (error as { error?: unknown } | undefined)?.error; + return typeof message === 'string' ? message : `${response.status} ${response.statusText}`; +} + +/** The CSRF token the server sets in a readable cookie at sign-in; writes send it back. */ +export function csrfToken(cookies: string): string { + for (const cookie of cookies.split(';')) { + const [name, ...value] = cookie.trim().split('='); + if (name === '__Host-sqb-csrf') return value.join('='); + } + return ''; +} + +/** Signs in with a password; resolves to whether the server accepted it. */ +export async function loginWithPassword(username: string, password: string, baseUrl = ''): Promise<'ok' | 'rejected' | 'throttled' | 'unavailable'> { + const http = createFetch({ baseUrl, credentials: 'same-origin' }); + const { response } = await http.POST('/api/auth/password/login', { body: { username, password } }); + if (response.ok) return 'ok'; + if (response.status === 429) return 'throttled'; + return response.status === 401 ? 'rejected' : 'unavailable'; +} + +export async function logout(baseUrl = ''): Promise { + const http = createFetch({ baseUrl, credentials: 'same-origin' }); + await http.POST('/api/auth/logout', { params: { header: { 'x-sqb-csrf': csrfToken(document.cookie) } } }); +} diff --git a/ui/src/lib/api/index.ts b/ui/src/lib/api/index.ts new file mode 100644 index 0000000000..da46c5c9f9 --- /dev/null +++ b/ui/src/lib/api/index.ts @@ -0,0 +1,31 @@ +import type { ApiClient } from './client'; +import { createHttpClient } from './http-client'; +import { MockApiClient, type MockOptions } from './mock/mock-client'; +import type { BuiltInRole } from './types'; + +export type { ApiClient } from './client'; +export { ApiError } from './client'; +export * from './types'; +export * from './signals'; +export { hasCapability, hasAnyCapability, ADMIN_CAPABILITIES } from './capabilities'; + +const ROLES: readonly BuiltInRole[] = ['viewer', 'operator', 'developer', 'admin']; +const EVENTS: readonly NonNullable[] = ['default', 'none', 'all']; +const LIVE: readonly NonNullable[] = ['live', 'reconnecting', 'offline']; + +/** + * The client the app uses: the server, or the mock with `?mock` or `VITE_SQB_MOCK=1`. + * With the mock, `?as=`, `?events=none|all` and `?live=reconnecting|offline` + * adjust it for demos. + */ +export function createClient(search: URLSearchParams, onUnauthorized: () => void = () => {}): ApiClient { + if (!search.has('mock') && import.meta.env.VITE_SQB_MOCK !== '1') return createHttpClient({ onUnauthorized }); + const role = search.get('as'); + const events = search.get('events'); + const live = search.get('live'); + return new MockApiClient({ + role: ROLES.find((r) => r === role), + events: EVENTS.find((e) => e === events), + connection: LIVE.find((l) => l === live) + }); +} diff --git a/ui/src/lib/api/mock/access.ts b/ui/src/lib/api/mock/access.ts new file mode 100644 index 0000000000..bfd059226a --- /dev/null +++ b/ui/src/lib/api/mock/access.ts @@ -0,0 +1,295 @@ +// Mock authorizer: resolves assignments → roles → grants → scopes the way the +// server does, so the admin pages can explain answers. Pure functions over the +// mock platform data. + +import type { + AccessExplanation, + AccessGrantPath, + AccessNearMiss, + AccessQuery, + Account, + BuiltInRole, + Capability, + EffectiveAccess, + EffectiveAccessCell, + Location, + RoleAssignment, + RoleDefinition, + RoleGrant, + SelectorMatch +} from '../types'; +import { CAPABILITIES } from '../types'; +import { ROLE_CAPABILITIES, SHOP_LOCATIONS } from './fixtures'; +import { jobDef, jobNodes } from './operate'; +import { resolveSelection } from './operate-graph'; + +export const ADMIN_ONLY: readonly Capability[] = ['location.manage', 'user.manage', 'role.assign', 'audit.view']; + +/** Nodes that started matching a selector in the last 7 days (mock history). */ +const RECENTLY_MATCHED: Record = { + 'tag:finance': ['revenue_report'] +}; + +export type AccessData = { + roles: RoleDefinition[]; + assignments: RoleAssignment[]; + accounts: Account[]; + locations: Location[]; +}; + +/** A grant as the authorizer sees it: a selector (null = every node) and its actions. */ +type ResolvedGrant = { selector: string | null; permissions: Capability[]; via: 'base' | 'addition'; baseRole: BuiltInRole | null }; + +/** Nodes a selector matches; commas intersect. */ +export function selectorNodes(selector: string): string[] { + const parts = selector.split(',').map((p) => p.trim()).filter(Boolean); + let nodes: string[] | null = null; + for (const part of parts) { + const hit = resolveSelection([part]).nodes; + nodes = nodes === null ? hit : nodes.filter((n) => hit.includes(n)); + } + return nodes ?? []; +} + +/** Nodes a request's subject stands for: a job's write set, a selector's matches, or one node. */ +export function subjectNodes(subject: string): string[] { + if (jobDef(subject)) return jobNodes(subject); + if (subject.includes(',')) return selectorNodes(subject); + const resolved = resolveSelection([subject]); + return resolved.unknown.length ? [] : resolved.nodes; +} + +export function roleAvailableAt(role: RoleDefinition, locationId: string): boolean { + return role.source !== 'project' || SHOP_LOCATIONS.includes(locationId); +} + +export function baseGrantsOf(role: RoleDefinition): RoleGrant[] { + return role.extends ? [{ nodes: ['*'], permissions: [...ROLE_CAPABILITIES[role.extends]] }] : []; +} + +function resolvedGrants(role: RoleDefinition, extra: RoleGrant[] = []): ResolvedGrant[] { + const out: ResolvedGrant[] = []; + const builtIn = role.source === 'built_in'; + for (const g of baseGrantsOf(role)) out.push({ selector: null, permissions: g.permissions, via: 'base', baseRole: role.extends }); + for (const g of [...role.grants, ...extra]) { + const permissions = builtIn ? g.permissions : g.permissions.filter((p) => !ADMIN_ONLY.includes(p)); + for (const selector of g.nodes) { + out.push({ selector: selector === '*' ? null : selector, permissions, via: builtIn ? 'base' : 'addition', baseRole: builtIn ? (role.name as BuiltInRole) : role.extends }); + } + } + return out; +} + +/** Whether a grant's selector covers the subject (null subject = the whole location). */ +function covers(selector: string | null, subject: string | null): boolean { + if (selector === null) return true; + if (subject === null) return false; + if (selector === subject) return true; + const wanted = subjectNodes(subject); + if (!wanted.length) return false; + const reach = selectorNodes(selector); + return wanted.every((n) => reach.includes(n)); +} + +function appliesAt(a: RoleAssignment, locationId: string): boolean { + return a.scope.locationId === null || a.scope.locationId === locationId; +} + +function locationName(data: AccessData, locationId: string | null): string { + if (locationId === null) return 'every location'; + return data.locations.find((l) => l.id === locationId)?.name ?? locationId; +} + +function label(action: Capability): string { + return PLAIN[action]; +} + +/** Server-side wording for summaries; the UI uses its own permission catalog. */ +const PLAIN: Record = { + view: 'see jobs, runs and results', + 'view.sql': 'see SQL', + 'view.evidence': 'see sample rows', + 'job.run': 'run jobs', + 'run.cancel': 'cancel runs', + 'run.rerun': 'rerun runs', + 'run.rewrite_history': 'full refresh or reprocess past data', + 'trigger.toggle': 'pause and resume schedules and sensors', + 'sensor.reset_cursor': 'reset where a sensor is up to', + 'run.adhoc': 'build any selection of nodes', + 'connection.test': 'test warehouse connections', + 'plan.preview': 'preview build plans', + 'location.manage': 'manage locations', + 'user.manage': 'manage people', + 'role.assign': 'give roles', + 'audit.view': 'see the audit log' +}; + +function roleOf(data: AccessData, name: string): RoleDefinition | undefined { + return data.roles.find((r) => r.name === name); +} + +/** Chains that allow `query` for one account, ignoring the account's status. */ +function chains(data: AccessData, query: AccessQuery, extra?: { role: string; grants: RoleGrant[] }): AccessGrantPath[] { + const out: AccessGrantPath[] = []; + for (const a of data.assignments) { + if (a.principal.name !== query.principal || a.stale || !appliesAt(a, query.locationId)) continue; + const role = roleOf(data, a.role); + if (!role || !roleAvailableAt(role, query.locationId)) continue; + const grants = resolvedGrants(role, extra?.role === role.name ? extra.grants : []); + const hit = grants.find((g) => g.permissions.includes(query.action) && covers(g.selector, query.subject)); + if (hit) { + out.push({ + assignmentId: a.id, + assignmentSource: a.source, + ssoGroup: a.ssoGroup, + definedIn: a.definedIn, + scope: a.scope, + role: role.name, + via: hit.via, + baseRole: hit.baseRole, + selector: hit.selector + }); + } + } + return out; +} + +export function allows(data: AccessData, query: AccessQuery): boolean { + const account = data.accounts.find((a) => a.name === query.principal); + return !!account && !account.disabled && chains(data, query).length > 0; +} + +export function explain(data: AccessData, query: AccessQuery): AccessExplanation { + const account = data.accounts.find((a) => a.name === query.principal)!; + const grantedBy = chains(data, query); + const nearMisses: AccessNearMiss[] = []; + const where = query.subject ? `${query.subject} in ${locationName(data, query.locationId)}` : locationName(data, query.locationId); + + if (account.disabled) nearMisses.push({ reason: 'disabled', role: null, assignmentId: null, detail: `${account.name} is disabled, so nothing they hold applies until they are enabled again.` }); + + if (!grantedBy.length) { + for (const a of data.assignments) { + if (a.principal.name !== query.principal) continue; + const role = roleOf(data, a.role); + if (a.stale || !role) { + if (appliesAt(a, query.locationId)) nearMisses.push({ reason: 'stale', role: a.role, assignmentId: a.id, detail: `Has ${a.role}, but that role no longer exists in the code, so it gives nothing.` }); + continue; + } + const grants = resolvedGrants(role).filter((g) => g.permissions.includes(query.action)); + if (role.source !== 'built_in' && ADMIN_ONLY.includes(query.action) && role.grants.some((g) => g.permissions.includes(query.action))) { + nearMisses.push({ reason: 'admin_only', role: role.name, assignmentId: a.id, detail: `${role.name} lists ${query.action}, but admin actions can only come from built-in roles, so it's ignored.` }); + continue; + } + if (!grants.length) continue; + if (appliesAt(a, query.locationId) && roleAvailableAt(role, query.locationId)) { + const selectors = [...new Set(grants.map((g) => g.selector).filter((s): s is string => s !== null))]; + nearMisses.push({ + reason: 'other_nodes', + role: role.name, + assignmentId: a.id, + detail: `Has ${label(query.action)} on ${selectors.join(', ')} through ${role.name}, ${query.subject ? `not on ${query.subject}` : 'not on every node: name a job, group or node to check'}.` + }); + } else { + nearMisses.push({ + reason: 'other_location', + role: role.name, + assignmentId: a.id, + detail: `Has ${label(query.action)} in ${locationName(data, a.scope.locationId)} through ${role.name}, not in ${locationName(data, query.locationId)}.` + }); + } + } + } + + const assigned = new Set(grantedBy.map((g) => g.role)); + const wouldBeGrantedBy = data.roles + .filter((r) => !assigned.has(r.name) && roleAvailableAt(r, query.locationId) && resolvedGrants(r).some((g) => g.permissions.includes(query.action) && covers(g.selector, query.subject))) + .map((r) => r.name); + + const allowed = grantedBy.length > 0 && !account.disabled; + const who = account.displayName; + const summary = allowed + ? `Yes: ${who} can ${label(query.action)} on ${where}.` + : account.disabled + ? `No: ${who} is disabled.` + : `No: ${who} can't ${label(query.action)} on ${where}.`; + return { query, allowed, grantedBy, nearMisses, wouldBeGrantedBy, summary }; +} + +export function effective(data: AccessData, principal: string): EffectiveAccess { + const locationIds = data.locations.map((l) => l.id); + const disabled = data.accounts.find((a) => a.name === principal)?.disabled ?? false; + const cells: EffectiveAccessCell[] = []; + for (const locationId of locationIds) { + for (const action of CAPABILITIES) { + let all = false; + const selectors = new Set(); + const roles = new Set(); + for (const a of data.assignments) { + if (disabled || a.principal.name !== principal || a.stale || !appliesAt(a, locationId)) continue; + const role = roleOf(data, a.role); + if (!role || !roleAvailableAt(role, locationId)) continue; + for (const g of resolvedGrants(role)) { + if (!g.permissions.includes(action)) continue; + roles.add(role.name); + if (g.selector === null) all = true; + else selectors.add(g.selector); + } + } + cells.push({ action, locationId, coverage: all ? 'all' : selectors.size ? 'some' : 'none', selectors: all ? [] : [...selectors], roles: [...roles] }); + } + } + return { principal, locationIds, cells }; +} + +export function selectorMatches(role: RoleDefinition): SelectorMatch[] { + const selectors = [...new Set(role.grants.flatMap((g) => g.nodes).filter((n) => n !== '*'))]; + return selectors.map((selector) => { + const nodes = selectorNodes(selector); + const recent = selector.split(',').flatMap((part) => RECENTLY_MATCHED[part.trim()] ?? []); + return { selector, nodes, newSinceLastWeek: nodes.filter((n) => recent.includes(n)) }; + }); +} + +export function recentlyMatchedCount(role: RoleDefinition): number { + return new Set(selectorMatches(role).flatMap((m) => m.newSinceLastWeek)).size; +} + +/** The role as written in its policy file or the server config. */ +export function definitionText(role: RoleDefinition, grants: RoleGrant[] = role.grants): string { + if (role.source === 'built_in') { + return [`# Built in; not defined in a file.`, `# ${role.name}: ${role.description}`, `permissions = [${grants.flatMap((g) => g.permissions).map((p) => `"${p}"`).join(', ')}]`].join('\n'); + } + const lines = [`[roles.${role.name}]`, `description = "${role.description}"`]; + if (role.extends) lines.push(`extends = "${role.extends}"`); + lines.push('grants = ['); + for (const g of grants) lines.push(` { nodes = [${g.nodes.map((n) => `"${n}"`).join(', ')}], permissions = [${g.permissions.map((p) => `"${p}"`).join(', ')}] },`); + lines.push(']'); + return lines.join('\n'); +} + +const RANK: Record = { viewer: 0, operator: 1, developer: 2, admin: 3 }; + +/** For "highest role": built-in rank, with custom roles just above their base. */ +export function roleRank(role: RoleDefinition | undefined): number { + if (!role) return -1; + if (role.source === 'built_in') return RANK[role.name as BuiltInRole] ?? 0; + return (role.extends ? RANK[role.extends] : 0) + 0.5; +} + +/** Which of the role's actions (with selectors) the account doesn't hold in the scope. */ +export function missingForGrant(data: AccessData, principal: string, role: RoleDefinition, locationIds: string[]): { action: Capability; selector: string | null; locationId: string }[] { + const missing: { action: Capability; selector: string | null; locationId: string }[] = []; + for (const locationId of locationIds) { + if (!roleAvailableAt(role, locationId)) continue; + for (const g of resolvedGrants(role)) { + for (const action of g.permissions) { + if (!allows(data, { principal, action, locationId, subject: g.selector })) missing.push({ action, selector: g.selector, locationId }); + } + } + } + return missing; +} + +export function plainAction(action: Capability): string { + return label(action); +} diff --git a/ui/src/lib/api/mock/build-fixtures.ts b/ui/src/lib/api/mock/build-fixtures.ts new file mode 100644 index 0000000000..05af694663 --- /dev/null +++ b/ui/src/lib/api/mock/build-fixtures.ts @@ -0,0 +1,452 @@ +// Neutral example data for the BUILD area: the shop project's node metadata, +// columns and column lineage, its audits, and the snapshot change feed. Build +// state itself (last builds, outcomes, staleness) is derived in mock/build.ts +// from the OPERATE run history plus a few builds made outside the scheduler. + +import type { + CheckAttachment, + CheckKind, + CheckOutcome, + CheckSeverity, + CheckThreshold, + ColumnLens, + EnvironmentChange, + EvaluationMode, + NodeChange, + OrchestrationChange, + Principal, + RoleChange, + RunScopePhase, + SourceFreshness +} from '../types'; +import { ALEX, CI_ACCOUNT, DEMO_USER, JORDAN, RILEY, SAM } from './principals'; + +const MIN = 60_000; +const DAY = 1440; + +// ---- node metadata (graph and kinds come from mock/operate-graph.ts) ---- + +export type ColumnSpec = { name: string; type: string; description: string; audits?: string[]; expr?: string }; + +export type NodeSpec = { + materialization: string | null; + owner: string[]; + description: string; + schema: string | null; + columns: ColumnSpec[]; + /** FROM clause for nodes that read more than one parent. */ + from?: string; + where?: string; + groupBy?: boolean; + /** Microbatch models: cursor column and batch size. */ + microbatch?: { cursorColumn: string; batchSize: string }; +}; + +const c = (name: string, type: string, description: string, audits: string[] = [], expr?: string): ColumnSpec => ({ name, type, description, audits, expr }); + +export const NODE_SPECS: Record = { + raw__customers: { + materialization: null, + owner: ['platform'], + description: 'Customer records replicated from the shop database.', + schema: 'RAW', + columns: [c('id', 'bigint', 'Customer ID'), c('first_name', 'varchar', 'Given name'), c('last_name', 'varchar', 'Family name'), c('email', 'varchar', 'Contact email'), c('created_at', 'timestamp', 'Sign-up time (UTC)')] + }, + raw__payments: { + materialization: null, + owner: ['platform'], + description: 'Payment events replicated from the payments service.', + schema: 'RAW', + columns: [c('id', 'bigint', 'Payment ID'), c('order_id', 'bigint', 'Order paid for'), c('payment_method', 'varchar', 'card | cash | voucher', ['accepted_values']), c('amount_cents', 'integer', 'Amount in cents'), c('status', 'varchar', 'success | failed'), c('created_at', 'timestamp', 'Payment time (UTC)')] + }, + raw__orders: { + materialization: 'loader · delete_insert', + owner: ['ingest'], + description: 'Partner order files, loaded incrementally by the order loader.', + schema: 'RAW', + columns: [c('id', 'bigint', 'Order ID'), c('user_id', 'bigint', 'Customer ID'), c('order_date', 'timestamp', 'Order time (UTC)'), c('status', 'varchar', 'Order status'), c('total_cents', 'integer', 'Order total in cents'), c('_loaded_at', 'timestamp', 'Load time')] + }, + country_codes: { + materialization: 'seed', + owner: ['analytics'], + description: 'ISO country codes and sales regions.', + schema: 'STAGING', + columns: [c('code', 'varchar', 'ISO 3166 alpha-2 code', ['unique']), c('name', 'varchar', 'Country name'), c('region', 'varchar', 'Sales region')] + }, + stg_customers: { + materialization: 'view', + owner: ['analytics'], + description: 'Cleaned customers from the raw replica.', + schema: 'STAGING', + columns: [c('customer_id', 'bigint', 'Customer ID', ['not_null'], 'id AS customer_id'), c('first_name', 'varchar', 'Given name'), c('last_name', 'varchar', 'Family name'), c('email_domain', 'varchar', 'Domain of the contact email', [], "split_part(email, '@', 2) AS email_domain"), c('country_code', 'varchar', 'ISO country code', [], "'GB' AS country_code"), c('first_order_at', 'timestamp', 'Sign-up time (UTC)', [], 'created_at AS first_order_at')] + }, + stg_orders: { + materialization: 'view', + owner: ['analytics'], + description: 'Cleaned orders from the order loader.', + schema: 'STAGING', + columns: [c('order_id', 'bigint', 'Order ID', ['not_null'], 'id AS order_id'), c('customer_id', 'bigint', 'Customer ID', [], 'user_id AS customer_id'), c('order_status', 'varchar', 'completed | pending | cancelled', ['accepted_values'], 'status AS order_status'), c('total_cents', 'integer', 'Order total in cents'), c('ordered_at', 'timestamp', 'Order time (UTC)', [], 'order_date AS ordered_at')] + }, + stg_payments: { + materialization: 'view', + owner: ['analytics'], + description: 'Cleaned payments from the payments replica.', + schema: 'STAGING', + columns: [c('payment_id', 'bigint', 'Payment ID', ['not_null'], 'id AS payment_id'), c('order_id', 'bigint', 'Order paid for'), c('payment_method', 'varchar', 'card | cash | voucher'), c('amount_cents', 'integer', 'Amount in cents'), c('status', 'varchar', 'success | failed')] + }, + fact_orders: { + materialization: 'table', + owner: ['analytics', 'finance-data'], + description: 'One row per order with payment and customer attributes.', + schema: 'MARTS', + columns: [ + c('order_id', 'bigint', 'Primary key, one row per order', ['not_null', 'unique'], 'o.order_id'), + c('customer_id', 'bigint', 'Customer who placed the order', ['relationships'], 'o.customer_id'), + c('order_status', 'varchar', 'completed | pending | cancelled', [], 'o.order_status'), + c('total_cents', 'integer', 'Order total in cents', [], 'o.total_cents'), + c('payment_method', 'varchar', 'card | cash | voucher', [], 'p.payment_method'), + c('ordered_at', 'timestamp', 'Order time (UTC)', [], 'o.ordered_at'), + c('is_first_order', 'boolean', 'First order for this customer', [], 'o.ordered_at = c.first_order_at AS is_first_order') + ] + }, + dim_customers: { + materialization: 'table', + owner: ['analytics'], + description: 'Customer dimension with first and last order and lifetime value.', + schema: 'MARTS', + from: '__ref("stg_customers")\nLEFT JOIN __ref("fact_orders") USING (customer_id)\nLEFT JOIN __ref("country_codes") ON country_code = code', + groupBy: true, + columns: [ + c('customer_id', 'bigint', 'Primary key', ['unique']), + c('full_name', 'varchar', 'Given and family name', [], "first_name || ' ' || last_name AS full_name"), + c('country', 'varchar', 'Country name', [], 'name AS country'), + c('first_order_at', 'timestamp', 'First order (UTC)'), + c('last_order_at', 'timestamp', 'Latest order (UTC)', [], 'max(ordered_at) AS last_order_at'), + c('order_count', 'bigint', 'Orders placed', [], 'count(order_id) AS order_count'), + c('lifetime_value', 'decimal(18,2)', 'Total spend in dollars', [], 'cents_to_dollars(sum(total_cents)) AS lifetime_value') + ] + }, + daily_revenue: { + materialization: 'table', + owner: ['finance-data'], + description: 'Daily revenue from completed orders.', + schema: 'MARTS', + where: "order_status = 'completed'", + groupBy: true, + columns: [c('revenue_date', 'date', 'Order date', ['unique'], "date_trunc('day', ordered_at) AS revenue_date"), c('total_revenue_cents', 'bigint', 'Revenue in cents', ['expression_is_true'], 'sum(total_cents) AS total_revenue_cents'), c('order_count', 'bigint', 'Completed orders', [], 'count(order_id) AS order_count')] + }, + customer_status: { + materialization: 'incremental · merge', + owner: ['growth'], + description: 'Active or lapsed status per customer, merged on change.', + schema: 'MARTS', + columns: [c('customer_id', 'bigint', 'Customer ID'), c('status', 'varchar', 'active | lapsed', ['not_null'], "CASE WHEN last_order_at > now() - INTERVAL 90 DAY THEN 'active' ELSE 'lapsed' END AS status"), c('status_changed_at', 'timestamp', 'When the status last changed', [], 'last_order_at AS status_changed_at')] + }, + hourly_order_activity: { + materialization: 'incremental · microbatch', + owner: ['analytics'], + description: 'Orders and revenue per hour, built in hourly microbatches.', + schema: 'ANALYTICS', + groupBy: true, + microbatch: { cursorColumn: 'activity_hour', batchSize: '1h' }, + columns: [c('activity_hour', 'timestamp', 'Hour (UTC)', [], "date_trunc('hour', ordered_at) AS activity_hour"), c('order_count', 'bigint', 'Orders in the hour', [], 'count(order_id) AS order_count'), c('revenue_cents', 'bigint', 'Revenue in the hour', [], 'sum(total_cents) AS revenue_cents')] + }, + hourly_activity_ctx: { + materialization: 'incremental · microbatch', + owner: ['analytics'], + description: 'Hourly activity with the day’s revenue for context.', + schema: 'ANALYTICS', + from: `__ref("hourly_order_activity")\nJOIN __ref("daily_revenue") ON date_trunc('day', activity_hour) = revenue_date`, + microbatch: { cursorColumn: 'activity_hour', batchSize: '1h' }, + columns: [c('activity_hour', 'timestamp', 'Hour (UTC)', ['expression_is_true']), c('order_count', 'bigint', 'Orders in the hour'), c('revenue_cents', 'bigint', 'Revenue in the hour'), c('day_revenue_cents', 'bigint', 'Revenue for the whole day', [], 'total_revenue_cents AS day_revenue_cents'), c('share_of_day', 'double', 'Share of the day’s revenue', [], 'revenue_cents / nullif(total_revenue_cents, 0) AS share_of_day')] + }, + daily_activity_rollup: { + materialization: 'incremental · microbatch', + owner: ['analytics'], + description: 'Daily rollup of hourly activity with the peak hour.', + schema: 'ANALYTICS', + groupBy: true, + microbatch: { cursorColumn: 'activity_date', batchSize: '1d' }, + columns: [c('activity_date', 'date', 'Day (UTC)', [], "date_trunc('day', activity_hour) AS activity_date"), c('order_count', 'bigint', 'Orders in the day', [], 'sum(order_count) AS order_count'), c('peak_hour', 'timestamp', 'Busiest hour', [], 'arg_max(activity_hour, order_count) AS peak_hour')] + }, + revenue_report: { + materialization: 'table', + owner: ['finance-data'], + description: 'Revenue report for the finance dashboard.', + schema: 'REPORTING', + columns: [c('revenue_date', 'date', 'Order date'), c('total_revenue', 'decimal(18,2)', 'Revenue in dollars', [], 'cents_to_dollars(total_revenue_cents) AS total_revenue'), c('order_count', 'bigint', 'Completed orders'), c('avg_order_value', 'decimal(18,2)', 'Average order value in dollars', [], 'cents_to_dollars(total_revenue_cents / nullif(order_count, 0)) AS avg_order_value')] + }, + activity_report: { + materialization: 'view', + owner: ['analytics'], + description: 'Hourly activity for the operations dashboard.', + schema: 'REPORTING', + columns: [c('activity_hour', 'timestamp', 'Hour (UTC)'), c('order_count', 'bigint', 'Orders in the hour'), c('share_of_day', 'double', 'Share of the day’s revenue, in percent', [], 'pct(share_of_day) AS share_of_day')] + }, + export_revenue_csv: { + materialization: null, + owner: ['finance-data'], + description: 'Exports the revenue report as CSV to the finance file drop.', + schema: null, + columns: [] + } +}; + +export const PYTHON_SOURCE: Record = { + raw__orders: { + file: 'tasks/raw__orders.py', + source: `from sqlbuild.loaders import loader +from sqlbuild.executor.load.models import LoaderContext + +from shop.partner_files import fetch_since + + +@loader(cursor="order_date", strategy="delete_insert") +def raw__orders(ctx: LoaderContext): + """Partner order files, loaded incrementally by order date.""" + return fetch_since(ctx.current_cursor_value)` + }, + export_revenue_csv: { + file: 'tasks/export_revenue_csv.py', + source: `from sqlbuild.tasks import task + +from shop.file_drop import upload_csv + + +@task(upstream=["revenue_report"]) +def export_revenue_csv(ctx): + """Exports the revenue report as CSV to the finance file drop.""" + rows = ctx.query('SELECT * FROM __ref("revenue_report") ORDER BY revenue_date') + upload_csv("finance/revenue.csv", rows)` + } +}; + +/** `model.column` → `model.column` edges from the snapshot's column lineage. */ +export const COLUMN_EDGES: [string, string, ColumnLens][] = [ + ['raw__customers.id', 'stg_customers.customer_id', 'rename'], + ['raw__customers.first_name', 'stg_customers.first_name', 'passthrough'], + ['raw__customers.last_name', 'stg_customers.last_name', 'passthrough'], + ['raw__customers.email', 'stg_customers.email_domain', 'transform'], + ['raw__customers.created_at', 'stg_customers.first_order_at', 'rename'], + ['raw__orders.id', 'stg_orders.order_id', 'rename'], + ['raw__orders.user_id', 'stg_orders.customer_id', 'rename'], + ['raw__orders.status', 'stg_orders.order_status', 'rename'], + ['raw__orders.total_cents', 'stg_orders.total_cents', 'passthrough'], + ['raw__orders.order_date', 'stg_orders.ordered_at', 'rename'], + ['raw__payments.id', 'stg_payments.payment_id', 'rename'], + ['raw__payments.order_id', 'stg_payments.order_id', 'passthrough'], + ['raw__payments.payment_method', 'stg_payments.payment_method', 'passthrough'], + ['raw__payments.amount_cents', 'stg_payments.amount_cents', 'passthrough'], + ['raw__payments.status', 'stg_payments.status', 'passthrough'], + ['stg_orders.order_id', 'fact_orders.order_id', 'passthrough'], + ['stg_orders.customer_id', 'fact_orders.customer_id', 'passthrough'], + ['stg_orders.order_status', 'fact_orders.order_status', 'passthrough'], + ['stg_orders.total_cents', 'fact_orders.total_cents', 'passthrough'], + ['stg_payments.payment_method', 'fact_orders.payment_method', 'passthrough'], + ['stg_orders.ordered_at', 'fact_orders.ordered_at', 'passthrough'], + ['stg_orders.ordered_at', 'fact_orders.is_first_order', 'transform'], + ['stg_customers.first_order_at', 'fact_orders.is_first_order', 'transform'], + ['stg_customers.customer_id', 'dim_customers.customer_id', 'passthrough'], + ['stg_customers.first_name', 'dim_customers.full_name', 'transform'], + ['stg_customers.last_name', 'dim_customers.full_name', 'transform'], + ['country_codes.name', 'dim_customers.country', 'rename'], + ['stg_customers.first_order_at', 'dim_customers.first_order_at', 'passthrough'], + ['fact_orders.ordered_at', 'dim_customers.last_order_at', 'transform'], + ['fact_orders.order_id', 'dim_customers.order_count', 'transform'], + ['fact_orders.total_cents', 'dim_customers.lifetime_value', 'transform'], + ['fact_orders.ordered_at', 'daily_revenue.revenue_date', 'transform'], + ['fact_orders.total_cents', 'daily_revenue.total_revenue_cents', 'transform'], + ['fact_orders.order_id', 'daily_revenue.order_count', 'transform'], + ['dim_customers.customer_id', 'customer_status.customer_id', 'passthrough'], + ['dim_customers.last_order_at', 'customer_status.status', 'transform'], + ['dim_customers.last_order_at', 'customer_status.status_changed_at', 'rename'], + ['fact_orders.ordered_at', 'hourly_order_activity.activity_hour', 'transform'], + ['fact_orders.order_id', 'hourly_order_activity.order_count', 'transform'], + ['fact_orders.total_cents', 'hourly_order_activity.revenue_cents', 'transform'], + ['hourly_order_activity.activity_hour', 'hourly_activity_ctx.activity_hour', 'passthrough'], + ['hourly_order_activity.order_count', 'hourly_activity_ctx.order_count', 'passthrough'], + ['hourly_order_activity.revenue_cents', 'hourly_activity_ctx.revenue_cents', 'passthrough'], + ['daily_revenue.total_revenue_cents', 'hourly_activity_ctx.day_revenue_cents', 'rename'], + ['hourly_order_activity.revenue_cents', 'hourly_activity_ctx.share_of_day', 'transform'], + ['daily_revenue.total_revenue_cents', 'hourly_activity_ctx.share_of_day', 'transform'], + ['hourly_activity_ctx.activity_hour', 'daily_activity_rollup.activity_date', 'transform'], + ['hourly_activity_ctx.order_count', 'daily_activity_rollup.order_count', 'transform'], + ['hourly_activity_ctx.activity_hour', 'daily_activity_rollup.peak_hour', 'transform'], + ['daily_revenue.revenue_date', 'revenue_report.revenue_date', 'passthrough'], + ['daily_revenue.total_revenue_cents', 'revenue_report.total_revenue', 'transform'], + ['daily_revenue.order_count', 'revenue_report.order_count', 'passthrough'], + ['daily_revenue.total_revenue_cents', 'revenue_report.avg_order_value', 'transform'], + ['daily_revenue.order_count', 'revenue_report.avg_order_value', 'transform'], + ['hourly_activity_ctx.activity_hour', 'activity_report.activity_hour', 'passthrough'], + ['hourly_activity_ctx.order_count', 'activity_report.order_count', 'passthrough'], + ['hourly_activity_ctx.share_of_day', 'activity_report.share_of_day', 'transform'] +]; + +// ---- builds outside the scheduler (CLI, CI), from synced fingerprints ---- + +export type ExternalBuild = { node: string; fingerprintRunId: string; minutesAgo: number; outcome: 'success' | 'warn'; rows: number | null; durationMs: number }; + +export const EXTERNAL_BUILDS: ExternalBuild[] = [ + { node: 'raw__orders', fingerprintRunId: '0f9a3c27-5d61-4e0b-9a88-2c4d7e1b6f30', minutesAgo: 95, outcome: 'success', rows: 1_312, durationMs: 41_000 }, + { node: 'country_codes', fingerprintRunId: '6c2e8b14-07fa-4d39-b5c1-9e3a2f7d4b85', minutesAgo: 9 * DAY, outcome: 'success', rows: 249, durationMs: 1_800 }, + { node: 'revenue_report', fingerprintRunId: '3b7d1e90-c4a2-4f68-8e15-a0d9c6b2f741', minutesAgo: 2 * DAY + 50, outcome: 'success', rows: 365, durationMs: 2_900 }, + { node: 'dim_customers', fingerprintRunId: 'a41f6d08-2b93-4c7e-b0d5-7f1e9c3a6b22', minutesAgo: 3 * DAY, outcome: 'success', rows: 8_410, durationMs: 6_100 }, + { node: 'activity_report', fingerprintRunId: 'e8c05b3a-91d7-4a26-8f4e-b6a2d0c7e913', minutesAgo: 4 * DAY, outcome: 'success', rows: null, durationMs: 700 } +]; + +// ---- source freshness (`_sqlbuild_source_freshness`) ---- + +export function sourceFreshness(now: number): Record { + const ago = (minutes: number) => new Date(now - minutes * MIN).toISOString(); + return { + raw__customers: { status: 'pass', loadedAt: ago(140), checkedAt: ago(2), warnAfterSeconds: 6 * 3600, errorAfterSeconds: 24 * 3600 }, + raw__payments: { status: 'warn', loadedAt: ago(34), checkedAt: ago(2), warnAfterSeconds: 1800, errorAfterSeconds: 2 * 3600 } + }; +} + +// ---- audits and checks (`_sqlbuild_audit_results`) ---- + +export type CheckSpec = { + id: string; + name: string; + definition: string | null; + kind: CheckKind; + attachment: CheckAttachment; + node: string | null; + column: string | null; + severity: CheckSeverity; + runScopePhase: RunScopePhase; + evaluationMode: EvaluationMode; + thresholds: CheckThreshold[]; + description: string | null; + /** The latest outcome, then the shape of the older history. */ + latest: { outcome: CheckOutcome; violations?: number; measured?: number }; + history: { failEvery?: number; warnEvery?: number; measuredBase?: number }; + /** A failed build of the node failed in this audit's query (an execution error), not before audits ran. */ + errorOnFailure?: boolean; + /** Compiled audit query, with `{relation}` for the attached object. */ + sql: string; + evidenceColumns?: string[]; +}; + +export const CHECK_SPECS: CheckSpec[] = [ + { id: 'chk-fo-not-null', name: 'not_null(order_id)', definition: 'not_null', kind: 'audit', attachment: 'model', node: 'fact_orders', column: 'order_id', severity: 'error', runScopePhase: 'delta_and_final', evaluationMode: 'violations', thresholds: [], description: null, latest: { outcome: 'pass', violations: 0 }, history: {}, sql: 'SELECT order_id FROM {relation} WHERE order_id IS NULL' }, + { id: 'chk-fo-unique', name: 'unique(order_id)', definition: 'unique', kind: 'audit', attachment: 'model', node: 'fact_orders', column: 'order_id', severity: 'error', runScopePhase: 'delta_and_final', evaluationMode: 'violations', thresholds: [], description: null, latest: { outcome: 'pass', violations: 0 }, history: { failEvery: 17 }, sql: 'SELECT order_id, count(*) AS n FROM {relation} GROUP BY order_id HAVING count(*) > 1', evidenceColumns: ['order_id', 'n'] }, + { id: 'chk-fo-rel', name: 'relationships(customer_id → dim_customers)', definition: 'relationships', kind: 'audit', attachment: 'model', node: 'fact_orders', column: 'customer_id', severity: 'warn', runScopePhase: 'final', evaluationMode: 'violations', thresholds: [], description: 'Every order belongs to a known customer.', latest: { outcome: 'pass', violations: 0 }, history: { warnEvery: 9 }, sql: 'SELECT f.customer_id FROM {relation} f LEFT JOIN "WAFFLE_SHOP"."MARTS"."DIM_CUSTOMERS" d USING (customer_id) WHERE d.customer_id IS NULL', evidenceColumns: ['customer_id'] }, + { id: 'chk-sc-not-null', name: 'not_null(customer_id)', definition: 'not_null', kind: 'audit', attachment: 'model', node: 'stg_customers', column: 'customer_id', severity: 'error', runScopePhase: 'final', evaluationMode: 'violations', thresholds: [], description: null, latest: { outcome: 'pass', violations: 0 }, history: {}, sql: 'SELECT customer_id FROM {relation} WHERE customer_id IS NULL' }, + { id: 'chk-sp-not-null', name: 'not_null(payment_id)', definition: 'not_null', kind: 'audit', attachment: 'model', node: 'stg_payments', column: 'payment_id', severity: 'warn', runScopePhase: 'final', evaluationMode: 'violations', thresholds: [], description: null, latest: { outcome: 'warn', violations: 2 }, history: { warnEvery: 3 }, sql: 'SELECT order_id, payment_method, amount_cents FROM {relation} WHERE payment_id IS NULL', evidenceColumns: ['order_id', 'payment_method', 'amount_cents'] }, + { id: 'chk-so-values', name: 'accepted_values(order_status)', definition: 'accepted_values', kind: 'audit', attachment: 'model', node: 'stg_orders', column: 'order_status', severity: 'error', runScopePhase: 'final', evaluationMode: 'violations', thresholds: [], description: null, latest: { outcome: 'pass', violations: 0 }, history: {}, sql: "SELECT order_status, count(*) AS n FROM {relation} WHERE order_status NOT IN ('completed', 'pending', 'cancelled') GROUP BY 1" }, + { id: 'chk-dc-unique', name: 'unique(customer_id)', definition: 'unique', kind: 'audit', attachment: 'model', node: 'dim_customers', column: 'customer_id', severity: 'error', runScopePhase: 'delta_and_final', evaluationMode: 'violations', thresholds: [], description: null, latest: { outcome: 'pass', violations: 0 }, history: {}, sql: 'SELECT customer_id, count(*) AS n FROM {relation} GROUP BY customer_id HAVING count(*) > 1' }, + { id: 'chk-dr-positive', name: 'expression_is_true(total_revenue_cents >= 0)', definition: 'expression_is_true', kind: 'audit', attachment: 'model', node: 'daily_revenue', column: 'total_revenue_cents', severity: 'error', runScopePhase: 'delta_and_final', evaluationMode: 'violations', thresholds: [], description: 'Revenue is never negative.', latest: { outcome: 'pass', violations: 0 }, history: {}, errorOnFailure: true, sql: 'SELECT revenue_date, total_revenue_cents FROM {relation} WHERE NOT (total_revenue_cents >= 0)', evidenceColumns: ['revenue_date', 'total_revenue_cents'] }, + { id: 'chk-dr-unique', name: 'unique(revenue_date)', definition: 'unique', kind: 'audit', attachment: 'model', node: 'daily_revenue', column: 'revenue_date', severity: 'error', runScopePhase: 'final', evaluationMode: 'violations', thresholds: [], description: null, latest: { outcome: 'pass', violations: 0 }, history: {}, sql: 'SELECT revenue_date, count(*) AS n FROM {relation} GROUP BY 1 HAVING count(*) > 1' }, + { id: 'chk-hoa-rows', name: 'row_count(activity_hour)', definition: 'row_count', kind: 'audit', attachment: 'model', node: 'hourly_order_activity', column: null, severity: 'warn', runScopePhase: 'delta_and_final', evaluationMode: 'measurement', thresholds: [{ level: 'warn', operator: 'below', value: 500 }, { level: 'error', operator: 'below', value: 100 }], description: 'Each batch should hold a normal hour of orders.', latest: { outcome: 'warn', measured: 412 }, history: { measuredBase: 640, warnEvery: 6 }, sql: 'SELECT count(*) AS measured_value FROM {relation} WHERE activity_hour >= :batch_start AND activity_hour < :batch_end' }, + { id: 'chk-hac-hour', name: 'expression_is_true(activity_hour IS NOT NULL)', definition: 'expression_is_true', kind: 'audit', attachment: 'model', node: 'hourly_activity_ctx', column: 'activity_hour', severity: 'error', runScopePhase: 'delta_and_final', evaluationMode: 'violations', thresholds: [], description: null, latest: { outcome: 'pass', violations: 0 }, history: { failEvery: 23 }, sql: 'SELECT * FROM {relation} WHERE NOT (activity_hour IS NOT NULL)' }, + { id: 'chk-cs-not-null', name: 'not_null(status)', definition: 'not_null', kind: 'audit', attachment: 'model', node: 'customer_status', column: 'status', severity: 'error', runScopePhase: 'final', evaluationMode: 'violations', thresholds: [], description: null, latest: { outcome: 'pass', violations: 0 }, history: {}, sql: 'SELECT customer_id FROM {relation} WHERE status IS NULL' }, + { id: 'chk-rp-values', name: 'accepted_values(payment_method)', definition: 'accepted_values', kind: 'audit', attachment: 'source', node: 'raw__payments', column: 'payment_method', severity: 'warn', runScopePhase: 'final', evaluationMode: 'violations', thresholds: [], description: null, latest: { outcome: 'pass', violations: 0 }, history: { warnEvery: 11 }, sql: "SELECT payment_method, count(*) AS n FROM {relation} WHERE payment_method NOT IN ('card', 'cash', 'voucher') GROUP BY 1", evidenceColumns: ['payment_method', 'n'] }, + { id: 'chk-cc-unique', name: 'unique(code)', definition: 'unique', kind: 'audit', attachment: 'seed', node: 'country_codes', column: 'code', severity: 'error', runScopePhase: 'final', evaluationMode: 'violations', thresholds: [], description: null, latest: { outcome: 'pass', violations: 0 }, history: {}, sql: 'SELECT code, count(*) AS n FROM {relation} GROUP BY 1 HAVING count(*) > 1' }, + { id: 'chk-end-reconcile', name: 'revenue_reconciles', definition: null, kind: 'sql_test', attachment: 'end', node: null, column: null, severity: 'error', runScopePhase: 'final', evaluationMode: 'measurement', thresholds: [{ level: 'warn', operator: 'above', value: 0.05 }, { level: 'error', operator: 'above', value: 0.1 }], description: 'Mart revenue matches successful payments within 0.1%.', latest: { outcome: 'pass', measured: 0.02 }, history: { measuredBase: 0.03, warnEvery: 8 }, sql: `SELECT 100 * abs(m.total - p.total) / nullif(p.total, 0) AS measured_value +FROM (SELECT sum(total_revenue_cents) AS total FROM "WAFFLE_SHOP"."MARTS"."DAILY_REVENUE") m, + (SELECT sum(amount_cents) AS total FROM "WAFFLE_SHOP"."STAGING"."STG_PAYMENTS" WHERE status = 'success') p` } +]; + +// ---- snapshot change feed (one entry per deploy) ---- + +export type DeploySpec = { + generation: number; + gitRevision: string; + gitDirty: boolean; + gitAuthor: string; + minutesAgo: number; + publishedBy: Principal; + nodes: NodeChange[]; + orchestration: Omit[]; + roles: RoleChange[]; + environment: EnvironmentChange | null; +}; + +const node = (key: string, op: NodeChange['op'], detail: string, extra: Partial = {}): NodeChange => ({ + key, + kind: null, + op, + formerKey: null, + compiledOnly: false, + detail, + columns: [], + ...extra +}); + +export const DEPLOYS: DeploySpec[] = [ + { + generation: 42, + gitRevision: '7d2e41a', + gitDirty: false, + gitAuthor: ALEX.displayName, + minutesAgo: 190, + publishedBy: CI_ACCOUNT, + nodes: [ + node('dim_customers', 'changed', 'Column added', { columns: [{ name: 'lifetime_value', op: 'added', detail: 'decimal(18,2)' }] }), + node('revenue_report', 'changed', 'SQL changed', { columns: [{ name: 'avg_order_value', op: 'added', detail: 'decimal(18,2)' }] }), + node('activity_report', 'changed', 'Compiled SQL changed: macro pct now rounds to one decimal place', { compiledOnly: true }) + ], + orchestration: [{ kind: 'trigger', key: 'revenue_refresh', op: 'changed', detail: 'Cron moved from 06:00 to 05:30' }], + roles: [ + { role: 'finance_operator', op: 'changed', detail: 'Adds run.rewrite_history and widens view.evidence to tag:finance; drops run.adhoc', coverage: { before: 3, after: 3 } }, + { role: 'reporting_operator', op: 'changed', detail: 'Adds trigger.toggle on group:reporting', coverage: { before: 2, after: 2 } } + ], + environment: null + }, + { + generation: 41, + gitRevision: '3a9c0f1', + gitDirty: false, + gitAuthor: SAM.displayName, + minutesAgo: 2 * DAY + 20, + publishedBy: CI_ACCOUNT, + nodes: [ + node('hourly_activity_ctx', 'renamed', 'Renamed from activity_hourly', { formerKey: 'activity_hourly' }), + node('fact_orders', 'changed', 'SQL changed: is_first_order now uses the customer’s first order', { columns: [{ name: 'is_first_order', op: 'changed', detail: 'expression changed' }] }) + ], + orchestration: [{ kind: 'sensor', key: 'on_fact_orders_rebuilt', op: 'added', detail: 'Node sensor: requests activity_microbatch when fact_orders is rebuilt' }], + roles: [], + environment: { before: 'env-3f1c9a', after: 'env-8b2d47', detail: 'Dependencies changed: uv.lock updated 2 packages' } + }, + { + generation: 40, + gitRevision: '5b1e7d3', + gitDirty: false, + gitAuthor: JORDAN.displayName, + minutesAgo: 5 * DAY + 130, + publishedBy: DEMO_USER, + nodes: [ + node('daily_activity_rollup', 'added', 'New incremental microbatch model'), + node('customer_status', 'changed', 'Config changed: materialization table → incremental · merge') + ], + orchestration: [ + { kind: 'job', key: 'activity_microbatch', op: 'changed', detail: 'Settings: --max-microbatches 4 → 8' }, + { kind: 'group', key: 'nightly_all', op: 'changed', detail: 'Member added: reporting_exports' } + ], + roles: [{ role: 'reporting_operator', op: 'added', detail: 'Operates the reporting group', coverage: { before: 0, after: 2 } }], + environment: null + }, + { + generation: 39, + gitRevision: 'e04f9b2', + gitDirty: false, + gitAuthor: RILEY.displayName, + minutesAgo: 9 * DAY + 15, + publishedBy: CI_ACCOUNT, + nodes: [ + node('daily_revenue', 'renamed', 'Renamed from revenue_by_day', { + formerKey: 'revenue_by_day', + columns: [ + { name: 'revenue_date', op: 'renamed', detail: 'from day' }, + { name: 'total_revenue_cents', op: 'renamed', detail: 'from amount' } + ] + }), + node('weekly_orders', 'removed', 'Model removed') + ], + orchestration: [], + roles: [], + environment: { before: 'env-1a7e02', after: 'env-3f1c9a', detail: 'sqb 0.184.2 → 0.185.0' } + } +]; diff --git a/ui/src/lib/api/mock/build-sql.ts b/ui/src/lib/api/mock/build-sql.ts new file mode 100644 index 0000000000..299a3c01e7 --- /dev/null +++ b/ui/src/lib/api/mock/build-sql.ts @@ -0,0 +1,105 @@ +// Model SQL for the mock project, standing in for the snapshot's raw and +// compiled SQL and for the definitions stored with each fingerprint. + +import type { SnapshotNode } from '../types'; +import { NODE_SPECS, type NodeSpec } from './build-fixtures'; + +const FACT_ORDERS_SQL = `MODEL ( + materialized table, + unique_key order_id, + owner [analytics, finance-data], + tags [finance, core], + description "One row per order with payment and customer attributes." +); + +WITH orders AS ( + SELECT order_id, customer_id, order_status, total_cents, ordered_at + FROM __ref("stg_orders") +), +payments AS ( + SELECT order_id, payment_method, amount_cents + FROM __ref("stg_payments") + WHERE status = 'success' +), +customers AS ( + SELECT customer_id, first_order_at + FROM __ref("stg_customers") +) + +SELECT + o.order_id, + o.customer_id, + o.order_status, + o.total_cents, + p.payment_method, + o.ordered_at, + o.ordered_at = c.first_order_at AS is_first_order +FROM orders o +LEFT JOIN payments p USING (order_id) +LEFT JOIN customers c USING (customer_id)`; + +/** Before generation 41, `is_first_order` was derived with a window function. */ +const FACT_ORDERS_BEFORE_41 = FACT_ORDERS_SQL.replace(',\ncustomers AS (\n SELECT customer_id, first_order_at\n FROM __ref("stg_customers")\n)', '') + .replace(' o.ordered_at = c.first_order_at AS is_first_order', ' row_number() OVER (PARTITION BY o.customer_id ORDER BY o.ordered_at) = 1 AS is_first_order') + .replace('\nLEFT JOIN customers c USING (customer_id)', ''); + +function header(spec: NodeSpec, sn: SnapshotNode): string { + const [materialized, strategy] = (spec.materialization ?? 'table').split(' · '); + const lines = [`materialized ${materialized}`]; + if (strategy) lines.push(`strategy ${strategy}`); + if (spec.microbatch) lines.push(`cursor ${spec.microbatch.cursorColumn}`, `batch_size ${spec.microbatch.batchSize}`); + if (spec.owner.length) lines.push(`owner [${spec.owner.join(', ')}]`); + if (sn.tags.length) lines.push(`tags [${sn.tags.join(', ')}]`); + lines.push(`description "${spec.description}"`); + return `MODEL (\n ${lines.join(',\n ')}\n);`; +} + +function generated(spec: NodeSpec, sn: SnapshotNode, columns = spec.columns): string { + const refOf = (key: string) => (NODE_SPECS[key] && key.startsWith('raw__') && key !== 'raw__orders' ? `__source("${key}")` : `__ref("${key}")`); + const from = spec.from ?? sn.upstream.map(refOf).join('\nCROSS JOIN '); + const select = columns.map((c) => ` ${c.expr ?? c.name}`).join(',\n'); + const parts = [header(spec, sn), '', `SELECT\n${select}`, `FROM ${from}`]; + if (spec.where) parts.push(`WHERE ${spec.where}`); + if (spec.groupBy) parts.push('GROUP BY ALL'); + return parts.join('\n'); +} + +/** Current raw SQL in the snapshot; null for sources, seeds and Python nodes. */ +export function rawSql(sn: SnapshotNode): string | null { + if (sn.kind === 'source' || sn.kind === 'seed' || sn.kind === 'loader' || sn.kind === 'task') return null; + if (sn.key === 'fact_orders') return FACT_ORDERS_SQL; + const spec = NODE_SPECS[sn.key]; + return spec ? generated(spec, sn) : null; +} + +/** Raw SQL as it was before `generation` took effect. */ +export function rawSqlBefore(sn: SnapshotNode, generation: number): string | null { + const current = rawSql(sn); + const spec = NODE_SPECS[sn.key]; + if (!current || !spec) return current; + if (sn.key === 'fact_orders' && generation <= 41) return FACT_ORDERS_BEFORE_41; + if (generation <= 42 && sn.key === 'dim_customers') return generated(spec, sn, spec.columns.filter((c) => c.name !== 'lifetime_value')); + if (generation <= 42 && sn.key === 'revenue_report') return generated(spec, sn, spec.columns.filter((c) => c.name !== 'avg_order_value')); + return current; +} + +const MACROS = (pctDecimals: number): [RegExp, string][] => [ + [/cents_to_dollars\(([^()]*(?:\([^()]*\))?[^()]*)\)/g, '($1) / 100.0'], + [/pct\(([^()]*)\)/g, `round(100 * $1, ${pctDecimals})`] +]; + +/** Compiles refs to this target's relations and expands the project's macros. */ +export function compileSql(raw: string, options: { pctDecimals?: number } = {}): string { + let sql = raw.replace(/^MODEL\s*\([\s\S]*?\n\);\s*\n+/, ''); + sql = sql.replace(/__(?:ref|source)\("(\w+)"\)/g, (_m, key: string) => { + const schema = NODE_SPECS[key]?.schema ?? 'ANALYTICS'; + return `"WAFFLE_SHOP"."${schema}"."${key.toUpperCase()}"`; + }); + for (const [re, replacement] of MACROS(options.pctDecimals ?? 1)) sql = sql.replace(re, replacement); + return sql; +} + +/** Compiled SQL as built before `generation` (the `pct` macro changed in 42). */ +export function compileSqlBefore(raw: string, generation: number): string { + return compileSql(raw, { pctDecimals: generation <= 42 ? 2 : 1 }); +} diff --git a/ui/src/lib/api/mock/build.ts b/ui/src/lib/api/mock/build.ts new file mode 100644 index 0000000000..5745def597 --- /dev/null +++ b/ui/src/lib/api/mock/build.ts @@ -0,0 +1,676 @@ +// In-memory stand-in for the server's BUILD endpoints: catalog and lineage, +// checks, the change feed and the overview. Build state is derived the way the +// server derives it: scheduler run nodes plus builds outside the scheduler +// (synced fingerprints), compared against the snapshot change feed. + +import { ApiError } from '../client'; +import type { + AttentionItem, + BuiltDefinition, + CatalogNode, + CatalogNodeDetail, + Capability, + Check, + CheckCounts, + CheckDetail, + CheckEvaluation, + CheckEvidence, + CheckSql, + CheckOutcome, + ColumnLineage, + CostChange, + GroupStatus, + MicrobatchCoverage, + NodeBuild, + NodeOutcome, + NodeRunEntry, + Overview, + PlatformEvent, + Run, + Scope, + SnapshotChange, + SnapshotNode, + StaleReason, + WarehouseObject +} from '../types'; +import { CHECK_SPECS, COLUMN_EDGES, DEPLOYS, EXTERNAL_BUILDS, NODE_SPECS, PYTHON_SOURCE, sourceFreshness, type CheckSpec } from './build-fixtures'; +import { compileSql, compileSqlBefore, rawSql, rawSqlBefore } from './build-sql'; +import { ancestors, descendants, SNAPSHOT_NODES, snapshotNode } from './operate-graph'; +import { TRIGGER_DEFS, triggerId } from './operate'; +import type { MockOperate } from './operate-store'; +import type { MockPlatformApi } from './platform'; +import { invocationId } from './run-detail'; + +const MIN = 60_000; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; +const CREDITS_PER_SECOND = 0.0009; +const MODEL_KINDS = new Set(['view', 'table', 'incremental']); + +export type BuildContext = { + now: number; + can: (capability: Capability) => boolean; + adapter: (scope: Scope) => string; + reply: (value: T) => Promise; + operate: MockOperate; + platform: MockPlatformApi; + platformEvents: () => PlatformEvent[]; +}; + +type NodeState = { + sn: SnapshotNode; + entries: NodeRunEntry[]; + lastBuild: NodeBuild | null; + lastOutcome: NodeOutcome | null; + stale: StaleReason[]; +}; + +type Deploy = (typeof DEPLOYS)[number] & { publishedAt: number }; + +function hash(text: string): number { + let h = 2166136261; + for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619); + return h >>> 0; +} + +const iso = (ms: number) => new Date(ms).toISOString(); +const fingerprintOf = (key: string, runId: string) => hash(`${key}/${runId}`).toString(16).padStart(8, '0') + hash(`${runId}/${key}`).toString(16).padStart(8, '0'); +const relationOf = (key: string) => { + const schema = NODE_SPECS[key]?.schema; + return schema ? { database: 'WAFFLE_SHOP', schema, name: key.toUpperCase() } : null; +}; + +/** Mock implementation of the BUILD endpoints, delegated to by `MockApiClient`. */ +export class MockBuild { + readonly #ctx: BuildContext; + readonly #freshness: ReturnType; + readonly #deploys: Deploy[]; + + constructor(ctx: BuildContext) { + this.#ctx = ctx; + this.#freshness = sourceFreshness(ctx.now); + this.#deploys = DEPLOYS.map((d) => ({ ...d, publishedAt: ctx.now - d.minutesAgo * MIN })); + } + + // ---- catalog and lineage ---- + + listCatalogNodes(scope: Scope): Promise { + const states = this.#states(scope); + const checks = this.#checks(scope, states); + return this.#ctx.reply(SNAPSHOT_NODES.map((sn) => this.#catalogNode(states.get(sn.key)!, checks))); + } + + async getCatalogNode(scope: Scope, key: string): Promise { + const states = this.#states(scope); + const state = states.get(key); + if (!state) return this.#ctx.reply(null); + const { sn, entries } = state; + const checks = this.#checks(scope, states); + const spec = NODE_SPECS[key]; + const builtEntries = entries.filter((e) => e.outcome === 'success' || e.outcome === 'warn'); + const latest = entries[0] ?? null; + const previous = builtEntries.find((e) => e !== builtEntries[0] && e.rows !== null); + const costed = this.#hasCost(scope); + const perBuild = (e: NodeRunEntry) => ((e.durationMs ?? 0) / 1000) * CREDITS_PER_SECOND; + const recent = builtEntries.filter((e) => Date.parse(e.startedAt) >= Date.now() - DAY); + const maintenance = await this.#ctx.platform.maintenanceHistory(scope); + const migrations = maintenance.migrations.filter((m) => m.destinationModel === key); + const raw = rawSql(sn); + const detail: CatalogNodeDetail = { + ...this.#catalogNode(state, checks), + downstream: SNAPSHOT_NODES.filter((n) => n.upstream.includes(key)).map((n) => n.key), + recentOutcomes: this.#recentOutcomes(key, entries), + lastRows: builtEntries[0]?.rows ?? null, + rowsDelta: builtEntries[0]?.rows != null && previous?.rows != null ? builtEntries[0].rows - previous.rows : null, + lastDurationMs: latest?.durationMs ?? null, + cost: costed && builtEntries.length ? { last24h: round(recent.reduce((s, e) => s + perBuild(e), 0)), perBuild: round(perBuild(builtEntries[0])), unit: 'credits' } : null, + columns: (spec?.columns ?? []).map((c) => ({ name: c.name, type: c.type, description: c.description, audits: c.audits ?? [] })), + sql: raw ? { raw, compiled: compileSql(raw) } : null, + code: PYTHON_SOURCE[key] ?? null, + builds: raw ? this.#builtDefinitions(sn, builtEntries) : [], + jobs: this.#ctx.operate.jobsCovering(key), + runs: entries.slice(0, 30), + checkResults: checks.filter((c) => c.node === key), + warehouse: this.#warehouse(state), + microbatch: this.#microbatch(state), + history: { + formerNames: [...new Set(migrations.map((m) => m.originModel ?? m.origin.name.toLowerCase()).filter((name) => name !== key))], + migrations, + oldNameViews: maintenance.oldNameViews.filter((v) => v.destinationModel === key) + } + }; + return structuredClone(detail); + } + + getColumnLineage(scope: Scope, key: string): Promise { + if (!snapshotNode(key)) return this.#ctx.reply(null); + const states = this.#states(scope); + const keys = new Set([key, ...ancestors(key), ...descendants(key)].filter((k) => NODE_SPECS[k]?.columns.length)); + const models = SNAPSHOT_NODES.filter((n) => keys.has(n.key)).map((n) => ({ + id: n.key, + name: n.key, + kind: NODE_SPECS[n.key].materialization ?? n.kind, + status: lineageStatus(states.get(n.key)!), + columns: NODE_SPECS[n.key].columns.map((c) => ({ name: c.name, type: c.type.toUpperCase() })) + })); + const edges = COLUMN_EDGES.filter(([from, to]) => keys.has(from.split('.')[0]) && keys.has(to.split('.')[0])); + const sql: Record = {}; + const expressions: Record> = {}; + for (const m of models) { + const spec = NODE_SPECS[m.id]; + sql[m.id] = rawSql(snapshotNode(m.id)!) ?? `-- ${snapshotNode(m.id)!.kind}: ${m.id}\n${spec.columns.map((c) => c.name).join(',\n')}`; + expressions[m.id] = Object.fromEntries(spec.columns.map((c) => [c.name, c.expr ?? c.name])); + } + return this.#ctx.reply({ models, edges, sql, expressions }); + } + + // ---- checks ---- + + listChecks(scope: Scope): Promise { + return this.#ctx.reply(this.#checks(scope, this.#states(scope))); + } + + getCheck(scope: Scope, id: string): Promise { + const spec = CHECK_SPECS.find((s) => s.id === id); + if (!spec) return this.#ctx.reply(null); + const states = this.#states(scope); + const history = this.#evaluations(spec, states, 40); + return this.#ctx.reply({ ...this.#check(spec, history), history }); + } + + getCheckSql(scope: Scope, checkId: string): Promise { + if (!this.#ctx.can('view.sql')) throw new ApiError('Missing permission: view.sql', 403); + const spec = CHECK_SPECS.find((s) => s.id === checkId); + const states = this.#states(scope); + const latest = spec && this.#evaluations(spec, states, 1)[0]; + if (!spec || !latest) throw new ApiError(`Unknown check ${checkId}`, 404); + const relation = spec.node ? relationOf(spec.node) : null; + const executedSql = spec.sql.replace('{relation}', relation ? `"${relation.database}"."${relation.schema}"."${relation.name}"` : ''); + const failed = latest.outcome === 'error' ? (states.get(spec.node ?? 'daily_revenue')?.entries ?? []).find((e) => e.outcome === 'failed') : undefined; + this.#ctx.platform.recordSqlRead(scope, checkId, `Read the SQL of ${spec.name} (${latest.id})`); + return this.#ctx.reply({ + evaluationId: latest.id, + executedSql, + measurementSql: spec.evaluationMode === 'measurement' ? executedSql : null, + evidenceSql: spec.evaluationMode === 'violations' ? executedSql : null, + executionError: failed?.message ?? null, + evidenceError: null + }); + } + + getCheckEvidence(scope: Scope, checkId: string, evaluationId: string): Promise { + if (!this.#ctx.can('view.evidence')) throw new ApiError('Missing permission: view.evidence', 403); + const spec = CHECK_SPECS.find((s) => s.id === checkId); + const evaluation = spec && this.#evaluations(spec, this.#states(scope), 40).find((e) => e.id === evaluationId); + if (!spec || !evaluation) throw new ApiError(`Unknown evaluation ${evaluationId}`, 404); + const columns = spec.evidenceColumns ?? (spec.column ? [spec.column] : []); + const count = Math.min(evaluation.evidenceCount ?? 0, 10); + const rows = Array.from({ length: count }, (_, i) => columns.map((col) => evidenceValue(col, hash(`${evaluationId}/${i}`)))); + this.#ctx.platform.recordEvidenceRead(scope, checkId, `Read ${count} evidence rows of ${spec.name} (${evaluationId})`); + return this.#ctx.reply({ evaluationId, columns, rows, truncated: evaluation.evidenceTruncated || (evaluation.evidenceCount ?? 0) > count }); + } + + // ---- change feed ---- + + listSnapshotChanges(scope: Scope, query: { limit?: number } = {}): Promise { + return this.#ctx.reply(this.#changes(scope).slice(0, query.limit ?? 50)); + } + + // ---- overview ---- + + async overview(scope: Scope): Promise { + const states = this.#states(scope); + const checks = this.#checks(scope, states); + const nodes = SNAPSHOT_NODES.map((sn) => this.#catalogNode(states.get(sn.key)!, checks)); + const runs = this.#ctx.operate.topLevelRuns(scope); + const now = Date.now(); + const dayAgo = now - DAY; + const recent = runs.filter((r) => Date.parse(r.queuedAt) >= dayAgo); + const weekAgo = now - 7 * DAY; + const thisWeek = this.#deploys.filter((d) => d.publishedAt >= weekAgo).flatMap((d) => d.nodes); + const isModelChange = (key: string) => MODEL_KINDS.has(snapshotNode(key)?.kind ?? 'table'); + + const changed = nodes.filter((n) => n.stale.some((s) => s.reason !== 'behind')).map((n) => n.key); + const behind = nodes.filter((n) => n.stale.some((s) => s.reason === 'behind')).map((n) => n.key); + const staleSources = nodes.filter((n) => n.sourceFreshness && n.sourceFreshness.status !== 'pass'); + + const attention: AttentionItem[] = []; + const pick = (r: Run) => ({ id: r.id, jobKey: r.jobKey, kind: r.kind, status: r.status, statusReason: r.statusReason, startedAt: r.startedAt, endedAt: r.endedAt }); + for (const r of runs.filter((r) => r.endedAt && Date.parse(r.endedAt) >= dayAgo && ['failed', 'partial', 'presumed_failed'].includes(r.status)).slice(0, 3)) { + attention.push({ kind: 'run_problem', run: pick(r) }); + } + for (const r of runs.filter((r) => r.status === 'unresponsive')) { + attention.push({ kind: 'run_unresponsive', run: pick(r), lastHeartbeatAt: this.#ctx.operate.lastHeartbeat(r.id) }); + } + if (changed.length || behind.length) attention.push({ kind: 'stale_models', changed, behind }); + if (staleSources.length) attention.push({ kind: 'stale_sources', sources: staleSources.map((n) => ({ key: n.key, ...n.sourceFreshness! })) }); + for (const event of this.#ctx.platformEvents().filter((e) => e.active)) attention.push({ kind: 'platform_event', event }); + const queue = await this.#ctx.operate.listQueue(scope); + for (const entry of queue.filter((q) => q.waits.length && Date.parse(q.run.queuedAt) < now - 10 * MIN && !q.run.parentRunId)) { + attention.push({ kind: 'queue_blocked', runId: entry.run.id, jobKey: entry.run.jobKey, queuedAt: entry.run.queuedAt, waits: entry.waits }); + } + const triggers = await this.#ctx.operate.listTriggers(scope); + for (const t of triggers.filter((t) => t.kind === 'calendar_schedule' && t.warning)) { + attention.push({ kind: 'calendar_low', triggerId: t.id, triggerName: t.name, detail: t.warning! }); + } + + const cost = this.#hasCost(scope) ? recent.reduce((s, r) => s + (r.cost?.value ?? 0), 0) : null; + const groups = new Map(); + for (const n of nodes) { + if (n.kind === 'source') continue; + const g = groups.get(n.group) ?? { group: n.group, total: 0, current: 0, changed: 0, behind: 0, failed: 0 }; + g.total += 1; + if (n.lastOutcome === 'failed') g.failed += 1; + if (n.stale.some((s) => s.reason !== 'behind')) g.changed += 1; + if (n.stale.some((s) => s.reason === 'behind')) g.behind += 1; + if (!n.stale.length && n.lastOutcome !== 'failed' && n.lastBuild) g.current += 1; + groups.set(n.group, g); + } + const latest = runs.find((r) => r.endedAt); + return this.#ctx.reply({ + metrics: { + models: { + total: nodes.filter((n) => MODEL_KINDS.has(n.kind)).length, + addedThisWeek: thisWeek.filter((c) => c.op === 'added' && isModelChange(c.key)).length, + removedThisWeek: thisWeek.filter((c) => c.op === 'removed').length + }, + runs24h: { + total: recent.length, + success: recent.filter((r) => r.status === 'success').length, + warn: recent.filter((r) => r.status === 'warn').length, + partial: recent.filter((r) => r.status === 'partial').length, + failed: recent.filter((r) => r.status === 'failed' || r.status === 'presumed_failed').length, + active: recent.filter((r) => ['queued', 'launched', 'running', 'unresponsive'].includes(r.status)).length + }, + checks: countChecks(checks), + stale: { nodes: new Set([...changed, ...behind]).size, changed: changed.length, behind: behind.length, sources: staleSources.length }, + // The mock holds one day of costed runs; the server's baseline is the rolling average of the same window. + cost: cost === null ? null : { last24h: round(cost), baseline: round(cost * 0.78), baselineDays: 7, unit: 'credits' } + }, + attention, + groups: [...groups.values()], + recentChanges: this.#changes(scope).slice(0, 4), + latestRun: latest ? { id: latest.id, status: latest.status, endedAt: latest.endedAt } : null + }); + } + + /** Nodes in the latest snapshot, for the navigation count. */ + nodeCount(): number { + return SNAPSHOT_NODES.length; + } + + failingCheckCount(scope: Scope): number { + const counts = countChecks(this.#checks(scope, this.#states(scope))); + return counts.failing + counts.warning; + } + + // ---- internals: build state ---- + + #hasCost(scope: Scope): boolean { + return this.#ctx.adapter(scope) === 'snowflake'; + } + + #states(scope: Scope): Map { + const states = new Map(); + for (const sn of SNAPSHOT_NODES) { + const entries = this.#entries(scope, sn.key); + const built = entries.find((e) => e.outcome === 'success' || e.outcome === 'warn'); + const lastBuild: NodeBuild | null = built + ? { fingerprintRunId: built.fingerprintRunId!, runId: built.runId, jobKey: built.jobKey, builtAt: built.startedAt, fingerprint: fingerprintOf(sn.key, built.fingerprintRunId!) } + : null; + states.set(sn.key, { sn, entries, lastBuild, lastOutcome: entries.find((e) => e.outcome !== 'skipped')?.outcome ?? null, stale: [] }); + } + for (const state of states.values()) state.stale = this.#stale(state, states); + return states; + } + + #entries(scope: Scope, key: string): NodeRunEntry[] { + const scheduler: NodeRunEntry[] = this.#ctx.operate + .nodeHistory(scope, key) + .filter(({ node }) => node.status === 'success' || node.status === 'warn' || node.status === 'failed' || node.status === 'skipped') + .map(({ run, node }) => ({ + runId: run.id, + fingerprintRunId: node.status === 'success' || node.status === 'warn' ? invocationId(run) : null, + jobKey: run.jobKey, + outcome: node.status as NodeOutcome, + startedAt: node.startedAt ?? run.queuedAt, + durationMs: node.startedAt && node.endedAt ? Date.parse(node.endedAt) - Date.parse(node.startedAt) : null, + rows: node.rowsAffected, + message: node.errorMessage ?? (node.skippedBecauseOf ? `Skipped: upstream ${node.skippedBecauseOf.nodeKey} failed` : null) + })); + const external: NodeRunEntry[] = EXTERNAL_BUILDS.filter((b) => b.node === key).map((b) => ({ + runId: null, + fingerprintRunId: b.fingerprintRunId, + jobKey: null, + outcome: b.outcome, + startedAt: iso(this.#ctx.now - b.minutesAgo * MIN), + durationMs: b.durationMs, + rows: b.rows, + message: null + })); + return [...scheduler, ...external].sort((a, b) => b.startedAt.localeCompare(a.startedAt)); + } + + #stale(state: NodeState, states: Map): StaleReason[] { + const { sn, lastBuild } = state; + if (sn.kind === 'source') return []; + const builtAt = lastBuild ? Date.parse(lastBuild.builtAt) : -Infinity; + const reasons: StaleReason[] = []; + const own = this.#deploys.find((d) => d.publishedAt > builtAt && d.nodes.some((n) => n.key === sn.key && n.op !== 'removed')); + if (own) { + const change = own.nodes.find((n) => n.key === sn.key)!; + reasons.push({ + reason: 'changed', + change: change.compiledOnly ? 'compiled' : 'definition', + detail: lastBuild ? `${change.detail} in generation ${own.generation}` : 'Never built in this target', + since: iso(own.publishedAt) + }); + } else if (!lastBuild) { + reasons.push({ reason: 'changed', change: 'definition', detail: 'Never built in this target', since: iso(this.#deploys[this.#deploys.length - 1].publishedAt) }); + } + const changedUpstream = [...ancestors(sn.key)].filter((up) => this.#deploys.some((d) => d.publishedAt > builtAt && d.nodes.some((n) => n.key === up && n.op !== 'removed'))); + if (changedUpstream.length && lastBuild) { + const since = Math.min(...this.#deploys.filter((d) => d.publishedAt > builtAt && d.nodes.some((n) => changedUpstream.includes(n.key))).map((d) => d.publishedAt)); + reasons.push({ reason: 'changed_upstream', upstream: changedUpstream, since: iso(since) }); + } + // Views read their upstream live, so only materialized nodes fall behind. + if (lastBuild && sn.kind !== 'view') { + const newer = sn.upstream.map((up) => ({ up, at: this.#dataTime(up, states) })).filter((u) => u.at > builtAt); + if (newer.length) { + reasons.push({ reason: 'behind', upstream: newer.map((u) => u.up), detail: 'Upstream has newer data than the last build', since: iso(Math.min(...newer.map((u) => u.at))) }); + } + } + return reasons; + } + + /** When a node's data last changed: a source's newest load, a view's upstream, or a build. */ + #dataTime(key: string, states: Map): number { + const state = states.get(key); + if (!state) return -Infinity; + if (state.sn.kind === 'source') { + const loaded = this.#freshness[key]?.loadedAt; + return loaded ? Date.parse(loaded) : -Infinity; + } + if (state.sn.kind === 'view') return Math.max(-Infinity, ...state.sn.upstream.map((up) => this.#dataTime(up, states))); + return state.lastBuild ? Date.parse(state.lastBuild.builtAt) : -Infinity; + } + + #catalogNode(state: NodeState, checks: Check[]): CatalogNode { + const { sn, lastBuild, lastOutcome, stale } = state; + const spec = NODE_SPECS[sn.key]; + return { + key: sn.key, + kind: sn.kind, + materialization: spec?.materialization ?? null, + group: sn.group, + tags: [...sn.tags], + owner: [...(spec?.owner ?? [])], + description: spec?.description ?? '', + path: sn.path, + upstream: [...sn.upstream], + relation: relationOf(sn.key), + columnCount: spec?.columns.length ?? 0, + lastBuild, + lastOutcome, + stale: structuredClone(stale), + sourceFreshness: this.#freshness[sn.key] ?? null, + checks: countChecks(checks.filter((c) => c.node === sn.key)) + }; + } + + #recentOutcomes(key: string, entries: NodeRunEntry[]): NodeOutcome[] { + const real = entries.map((e) => e.outcome).reverse(); + if (!real.length) return []; + const pad = Array.from({ length: Math.max(0, 40 - real.length) }, (_, i) => { + const h = hash(`${key}/${i}`) % 23; + return (h === 0 ? 'failed' : h < 3 ? 'warn' : 'success') as NodeOutcome; + }); + return [...pad, ...real].slice(-40); + } + + #builtDefinitions(sn: SnapshotNode, built: NodeRunEntry[]): BuiltDefinition[] { + return built.slice(0, 4).map((e) => { + const at = Date.parse(e.startedAt); + const generation = this.#generationAt(at); + const raw = rawSqlBefore(sn, generation + 1) ?? ''; + return { + build: { fingerprintRunId: e.fingerprintRunId!, runId: e.runId, jobKey: e.jobKey, builtAt: e.startedAt, fingerprint: fingerprintOf(sn.key, e.fingerprintRunId!) }, + raw, + compiled: compileSqlBefore(raw, generation + 1) + }; + }); + } + + /** The generation in force at `at`. */ + #generationAt(at: number): number { + return this.#deploys.find((d) => d.publishedAt <= at)?.generation ?? this.#deploys[this.#deploys.length - 1].generation - 1; + } + + #warehouse(state: NodeState): WarehouseObject | null { + const { sn, lastBuild } = state; + const relation = relationOf(sn.key); + const spec = NODE_SPECS[sn.key]; + if (!relation || !spec) return null; + const now = this.#ctx.now; + const builtAt = lastBuild ? Date.parse(lastBuild.builtAt) : null; + const missing = new Set( + this.#deploys + .filter((d) => builtAt === null || d.publishedAt > builtAt) + .flatMap((d) => d.nodes.filter((n) => n.key === sn.key).flatMap((n) => n.columns.filter((c) => c.op === 'added').map((c) => c.name))) + ); + const columns: WarehouseObject['columns'] = spec.columns.map((c) => ({ + name: c.name, + declaredType: c.type, + actualType: missing.has(c.name) ? null : warehouseType(c.type), + state: missing.has(c.name) ? 'missing' : 'match' + })); + if (sn.kind === 'source') columns.push({ name: '_synced_at', declaredType: null, actualType: 'TIMESTAMP_NTZ(9)', state: 'undeclared' }); + const isView = sn.kind === 'view'; + const base = 800 + (hash(sn.key) % 40_000); + const stats = isView + ? [] + : Array.from({ length: 28 }, (_, i) => { + const rows = Math.round(base * (0.82 + i * 0.0068)); + return { at: iso(now - (27 - i) * 6 * HOUR), rows, bytes: rows * (60 + spec.columns.length * 14) }; + }); + return { + relation, + objectType: isView ? 'view' : 'table', + createdAt: iso(now - (120 + (hash(sn.key) % 200)) * DAY), + lastAlteredAt: lastBuild?.builtAt ?? iso(now - 30 * DAY), + rows: stats.at(-1)?.rows ?? null, + bytes: stats.at(-1)?.bytes ?? null, + columns, + stats, + syncedAt: iso(now - 4 * MIN) + }; + } + + #microbatch(state: NodeState): MicrobatchCoverage | null { + const spec = NODE_SPECS[state.sn.key]; + if (!spec?.microbatch) return null; + const hourly = spec.microbatch.batchSize === '1h'; + const size = hourly ? HOUR : DAY; + const count = hourly ? 48 : 21; + const end = Math.floor(this.#ctx.now / size) * size; + const runs = state.entries.filter((e) => e.runId).map((e) => e.runId); + const gapAt = state.sn.key === 'hourly_order_activity' ? count - 9 : -1; + const replayFrom = state.sn.key === 'hourly_activity_ctx' ? count - 3 : -1; + const batches = Array.from({ length: count }, (_, i) => { + const start = end - (count - i) * size; + const state: MicrobatchCoverage['batches'][number]['state'] = i === gapAt ? 'gap' : replayFrom >= 0 && i >= replayFrom ? 'replay_required' : 'complete'; + return { start: iso(start), end: iso(start + size), state, runId: state === 'complete' ? (runs[(count - 1 - i) % Math.max(1, runs.length)] ?? null) : null }; + }); + const firstOpen = batches.findIndex((b) => b.state !== 'complete'); + return { + cursorColumn: spec.microbatch.cursorColumn, + cursorType: 'timestamp', + batchSize: spec.microbatch.batchSize, + earliest: iso(end - 90 * DAY), + contiguousFrontier: firstOpen >= 0 ? batches[firstOpen].start : batches[batches.length - 1].end, + knownGaps: batches.filter((b) => b.state === 'gap').length, + batches, + batchesTruncated: true + }; + } + + // ---- internals: checks ---- + + #checks(scope: Scope, states: Map): Check[] { + void scope; + return CHECK_SPECS.map((spec) => this.#check(spec, this.#evaluations(spec, states, 20))); + } + + #check(spec: CheckSpec, history: CheckEvaluation[]): Check { + return { + id: spec.id, + name: spec.name, + definition: spec.definition, + kind: spec.kind, + attachment: spec.attachment, + node: spec.node, + column: spec.column, + severity: spec.severity, + runScopePhase: spec.runScopePhase, + evaluationMode: spec.evaluationMode, + thresholds: structuredClone(spec.thresholds), + description: spec.description, + latest: history[0] ?? null, + recentOutcomes: history.slice(0, 20).map((e) => e.outcome).reverse() + }; + } + + /** Evaluations newest first: one per build of the attached node, then older synced results. */ + #evaluations(spec: CheckSpec, states: Map, limit: number): CheckEvaluation[] { + const anchor = spec.node ?? 'daily_revenue'; + const entries = (states.get(anchor)?.entries ?? []).filter((e) => e.outcome !== 'skipped'); + const measured = (i: number) => (spec.history.measuredBase !== undefined ? round(spec.history.measuredBase * (0.85 + (hash(`${spec.id}/${i}`) % 30) / 100), 3) : null); + const warnLevel = spec.thresholds.find((t) => t.level === 'warn'); + const breach = warnLevel && typeof warnLevel.value === 'number' ? round(warnLevel.value * (warnLevel.operator === 'below' ? 0.9 : 1.4), 3) : null; + const isViolations = spec.evaluationMode === 'violations'; + type Extra = { runId?: string | null; evaluatedAt: string; violations?: number; measured?: number }; + const at = (seed: string, index: number, outcome: CheckOutcome, extra: Extra): CheckEvaluation => { + const counted = outcome === 'pass' || outcome === 'warn' || outcome === 'fail'; + const violations = !isViolations || !counted ? null : outcome === 'pass' ? 0 : (extra.violations ?? 1 + (hash(seed) % 5)); + return { + id: `ar-${hash(`${spec.id}/${seed}`).toString(16)}`, + runId: extra.runId ?? null, + fingerprintRunId: seed, + evaluatedAt: extra.evaluatedAt, + outcome, + violationCount: violations, + measuredValue: isViolations || !counted ? null : (extra.measured ?? (outcome === 'pass' ? measured(index) : breach)), + evidenceCount: violations, + evidenceTruncated: (violations ?? 0) > 10 + }; + }; + // Audits run inside the build, so the node's outcome bounds the check's. + const out: CheckEvaluation[] = entries.map((e, i) => { + const seed = e.fingerprintRunId ?? invocationIdLike(e.runId ?? spec.id); + const base = { runId: e.runId, evaluatedAt: e.startedAt }; + if (e.outcome === 'failed') return spec.errorOnFailure ? at(seed, i, 'error', base) : at(seed, i, 'skipped', base); + const latest = i === 0 ? { violations: spec.latest.violations, measured: spec.latest.measured } : {}; + if (e.outcome === 'warn' && spec.latest.outcome === 'warn') return at(seed, i, 'warn', { ...base, ...latest, violations: i === 0 ? spec.latest.violations : undefined }); + return at(seed, i, 'pass', { ...base, ...(spec.latest.outcome === 'pass' ? latest : {}) }); + }); + // Older results from builds the scheduler did not run, every four hours. + const oldest = out.length ? Date.parse(out[out.length - 1].evaluatedAt) : this.#ctx.now - 30 * MIN; + for (let i = out.length; i < limit; i++) { + const seed = synthRunId(spec.id, i); + const fail = spec.history.failEvery && i % spec.history.failEvery === spec.history.failEvery - 1; + const warn = spec.history.warnEvery && i % spec.history.warnEvery === spec.history.warnEvery - 1; + const outcome: CheckOutcome = !out.length && i === 0 ? spec.latest.outcome : fail ? (spec.severity === 'error' ? 'fail' : 'warn') : warn ? 'warn' : 'pass'; + out.push(at(seed, i, outcome, { evaluatedAt: iso(oldest - (i - entries.length + 1) * 4 * HOUR), ...(i === 0 ? { violations: spec.latest.violations, measured: spec.latest.measured } : {}) })); + } + return out.slice(0, limit); + } + + // ---- internals: change feed ---- + + #changes(scope: Scope): SnapshotChange[] { + const runs = this.#ctx.operate.topLevelRuns(scope); + const costed = this.#hasCost(scope); + const windowRuns = (i: number) => { + const from = this.#deploys[i].publishedAt; + const until = i > 0 ? this.#deploys[i - 1].publishedAt : Infinity; + return runs.filter((r) => Date.parse(r.queuedAt) >= from && Date.parse(r.queuedAt) < until); + }; + return this.#deploys.map((d, i) => { + const after = windowRuns(i); + const before = i + 1 < this.#deploys.length ? windowRuns(i + 1) : []; + let costChanges: CostChange[] | null = null; + if (costed) { + const avg = (rs: Run[], job: string) => { + const costs = rs.filter((r) => r.jobKey === job && r.cost).map((r) => r.cost!.value); + return costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : null; + }; + costChanges = [...new Set(after.map((r) => r.jobKey).filter((k): k is string => !!k))].flatMap((job) => { + const b = avg(before, job); + const a = avg(after, job); + return a !== null && b !== null && Math.abs(a - b) / b >= 0.1 ? [{ jobKey: job, before: round(b), after: round(a), unit: 'credits' as const, runsAfter: after.filter((r) => r.jobKey === job && r.cost).length }] : []; + }); + } + return { + snapshotId: `snap-${d.gitRevision}`, + generation: d.generation, + previousGeneration: this.#deploys[i + 1]?.generation ?? null, + gitRevision: d.gitRevision, + gitDirty: d.gitDirty, + gitAuthor: d.gitAuthor, + publishedAt: iso(d.publishedAt), + publishedBy: d.publishedBy, + nodes: d.nodes.map((n) => ({ ...n, kind: snapshotNode(n.key)?.kind ?? null, columns: [...n.columns] })), + orchestration: d.orchestration.map((o) => ({ ...o, triggerId: (o.kind === 'trigger' || o.kind === 'sensor') && TRIGGER_DEFS.some((t) => t.name === o.key) ? triggerId(o.key) : null })), + roles: structuredClone(d.roles), + environment: d.environment ? { ...d.environment } : null, + followingRuns: [...after].reverse().slice(0, 6).map((r) => ({ id: r.id, jobKey: r.jobKey, kind: r.kind, status: r.status, startedAt: r.startedAt, queuedAt: r.queuedAt })), + costChanges + }; + }); + } +} + +function round(value: number, places = 4): number { + const f = 10 ** places; + return Math.round(value * f) / f; +} + +function countChecks(checks: Check[]): CheckCounts { + const of = (...outcomes: CheckOutcome[]) => checks.filter((c) => c.latest && outcomes.includes(c.latest.outcome)).length; + return { total: checks.length, passing: of('pass'), warning: of('warn'), failing: of('fail', 'error') }; +} + +function lineageStatus(state: NodeState): 'ok' | 'fail' | 'warn' | 'stale' | 'src' { + if (state.sn.kind === 'source') return 'src'; + if (state.lastOutcome === 'failed') return 'fail'; + if (state.stale.length) return 'stale'; + return state.lastOutcome === 'warn' ? 'warn' : 'ok'; +} + +function warehouseType(declared: string): string { + const t = declared.toLowerCase(); + if (t === 'bigint' || t === 'integer') return 'NUMBER(38,0)'; + if (t.startsWith('decimal')) return t.replace('decimal', 'NUMBER').toUpperCase(); + if (t === 'varchar') return 'VARCHAR(16777216)'; + if (t === 'timestamp') return 'TIMESTAMP_NTZ(9)'; + if (t === 'double') return 'FLOAT'; + return t.toUpperCase(); +} + +function evidenceValue(column: string, seed: number): string | number | null { + if (column.endsWith('_id') || column === 'customer_id') return 100_000 + (seed % 90_000); + if (column === 'n') return 2 + (seed % 3); + if (column.endsWith('_cents')) return seed % 2 ? -(seed % 900) : seed % 5000; + if (column === 'payment_method') return ['card', 'cash', 'gift_card', null][seed % 4]; + if (column.endsWith('_date')) return iso(Date.UTC(2026, 9, 1 + (seed % 28))).slice(0, 10); + return null; +} + +function synthRunId(seed: string, i: number): string { + const h = (n: number) => hash(`${seed}/${i}/${n}`).toString(16).padStart(8, '0'); + return `${h(1)}-${h(2).slice(0, 4)}-4${h(3).slice(0, 3)}-a${h(4).slice(0, 3)}-${h(5)}${h(6).slice(0, 4)}`; +} + +function invocationIdLike(seed: string): string { + return synthRunId(seed, 0); +} diff --git a/ui/src/lib/api/mock/fixtures.ts b/ui/src/lib/api/mock/fixtures.ts new file mode 100644 index 0000000000..e4eea051b5 --- /dev/null +++ b/ui/src/lib/api/mock/fixtures.ts @@ -0,0 +1,98 @@ +// Neutral example data for the mock client: a fictional waffle shop's orders, +// customers and payments. Times are relative to `now` so ages stay realistic. + +import type { BuiltInRole, Capability, Location, PlatformEvent, Snapshot } from '../types'; +import { CAPABILITIES } from '../types'; + +const MIN = 60_000; + +const OPERATE: Capability[] = ['view', 'view.sql', 'job.run', 'run.cancel', 'run.rerun', 'trigger.toggle', 'sensor.reset_cursor']; +const DEVELOP: Capability[] = [...OPERATE, 'view.evidence', 'run.rewrite_history', 'run.adhoc', 'connection.test', 'plan.preview']; + +/** Mock-only approximation of the built-in roles; the server owns the real grants. */ +export const ROLE_CAPABILITIES: Record = { + viewer: ['view'], + operator: OPERATE, + developer: DEVELOP, + admin: [...CAPABILITIES] +}; + +export const LOCATION_SHOP = '0d9c6a52-3f1e-4b7a-9c43-5b8e2f61a7d4'; +export const LOCATION_SHOP_STAGING = '5e8b1f37-a04c-4c2e-b6d9-2f7a91c3e508'; +export const LOCATION_SHOP_DEV = 'c2f47d90-1b6e-4a85-93fd-6e0a5b28c171'; +export const LOCATION_MARKETING = '7a41e0c8-62b5-4d19-8f3a-c1e95d0b2e66'; +/** The waffle-shop project's locations, one per target; they share the project's roles and providers. */ +export const SHOP_LOCATIONS: readonly string[] = [LOCATION_SHOP, LOCATION_SHOP_STAGING, LOCATION_SHOP_DEV]; + +export function createFixtures(now: number) { + const ago = (minutes: number) => new Date(now - minutes * MIN).toISOString(); + + const snapshot = (generation: number, revision: string, minutesAgo: number, author: string, dirty = false): Snapshot => ({ + id: `snap-${revision}`, + generation, + gitRevision: revision, + gitDirty: dirty, + gitAuthor: author, + publishedAt: ago(minutesAgo), + sqbVersion: '0.185.0' + }); + + const idleSync = (minutesAgo: number) => ({ + lastSyncedAt: ago(minutesAgo), + lastError: null + }); + + const locations: Location[] = [ + { + id: LOCATION_SHOP, + name: 'waffle-shop', + health: 'healthy', + lastSeenAt: ago(0.2), + target: { name: 'prod', adapter: 'snowflake', connectionSummary: 'WAFFLE_SHOP on example-account' }, + snapshot: snapshot(42, '7d2e41a', 190, 'Alex Rivera'), + warehouseSync: idleSync(4) + }, + { + id: LOCATION_SHOP_STAGING, + name: 'waffle-shop-staging', + health: 'degraded', + lastSeenAt: ago(0.1), + target: { name: 'staging', adapter: 'snowflake', connectionSummary: 'WAFFLE_SHOP_STAGING on example-account' }, + snapshot: snapshot(57, 'b81c09e', 35, 'Sam Okafor'), + warehouseSync: idleSync(11) + }, + { + id: LOCATION_SHOP_DEV, + name: 'waffle-shop-dev', + health: 'healthy', + lastSeenAt: ago(0.3), + target: { name: 'dev', adapter: 'duckdb', connectionSummary: 'warehouse.duckdb' }, + snapshot: snapshot(311, 'b81c09e', 2, 'Demo User', true), + warehouseSync: idleSync(1) + }, + { + id: LOCATION_MARKETING, + name: 'marketing-analytics', + health: 'falling_back', + lastSeenAt: ago(0.5), + target: { name: 'prod', adapter: 'postgres', connectionSummary: 'analytics on db.example.com' }, + snapshot: snapshot(18, '3e5f7aa', 1440, 'Jordan Lee'), + warehouseSync: idleSync(26) + } + ]; + + const platformEvents: PlatformEvent[] = [ + { id: 'pe-1', kind: 'location_broken', severity: 'warning', occurredAt: ago(48), active: true, locationId: LOCATION_MARKETING, subject: 'marketing-analytics', message: 'The latest deploy failed to load; still running the previous version.' }, + { id: 'pe-2', kind: 'tick_overdue', severity: 'warning', occurredAt: ago(12), active: true, locationId: LOCATION_SHOP, subject: 'segments_hourly', message: 'The segments_hourly schedule should have started a run 12 minutes ago.' }, + { id: 'pe-6', kind: 'warehouse_sync_failed', severity: 'warning', occurredAt: ago(95), active: true, locationId: LOCATION_SHOP_STAGING, subject: '_sqlbuild_janitor_events', message: 'Permission denied on _sqlbuild_janitor_events: the catalog connection’s role can’t use schema ARCHIVE, so cleanup history isn’t up to date.' }, + { id: 'pe-3', kind: 'heartbeat', severity: 'info', occurredAt: ago(1), active: false, locationId: null, subject: null, message: 'Server heartbeat.' } + ]; + const extraPlatformEvents: PlatformEvent[] = [ + { id: 'pe-4', kind: 'scheduler_no_leader', severity: 'critical', occurredAt: ago(2), active: true, locationId: null, subject: null, message: 'No server is running the scheduler, so schedules and sensors aren’t starting runs.' }, + { id: 'pe-5', kind: 'launch_failed', severity: 'critical', occurredAt: ago(6), active: true, locationId: LOCATION_SHOP, subject: 'revenue_refresh', message: 'The last 3 runs couldn’t start: the image couldn’t be downloaded.' } + ]; + + return { locations, platformEvents, extraPlatformEvents }; +} + +export type Fixtures = ReturnType; diff --git a/ui/src/lib/api/mock/mock-client.ts b/ui/src/lib/api/mock/mock-client.ts new file mode 100644 index 0000000000..dde29a1485 --- /dev/null +++ b/ui/src/lib/api/mock/mock-client.ts @@ -0,0 +1,431 @@ +import type { ApiClient } from '../client'; +import { ApiError } from '../client'; +import { LocalChangeBus, type ConnectionState } from '../signals'; +import type { + AdhocRunCheck, + AdhocRunRequest, + Bootstrap, + BuiltInRole, + Capability, + CurrentUser, + Job, + JobDetail, + JobLaunchOptions, + NavCounts, + PlatformEvent, + QueueEntry, + RerunMode, + Run, + RunDetail, + RunEventPage, + RunListQuery, + RunOutputLine, + RunStatement, + Scope, + SnapshotNode, + Timeline, + Trigger, + TriggerDetail, + TriggerState, + WarehouseSync +} from '../types'; +import { createFixtures, ROLE_CAPABILITIES, type Fixtures } from './fixtures'; +import { DEMO_USER } from './principals'; +import { MockOperate } from './operate-store'; +import type { PlatformApi } from '../platform-client'; +import { MockPlatformApi } from './platform'; +import { MockBuild } from './build'; + +export type MockOptions = { + role?: BuiltInRole; + /** `default` shows three warnings; `none` is all clear; `all` adds critical events. */ + events?: 'default' | 'none' | 'all'; + /** Simulated request latency in milliseconds. */ + latencyMs?: number; + /** Simulated state of the live-signal connection. */ + connection?: ConnectionState; +}; + +/** In-memory `ApiClient` with neutral example data. Mutations persist per page load. */ +export class MockApiClient implements ApiClient { + readonly changes = new LocalChangeBus(); + + supports(): boolean { + return true; + } + readonly #options: Required; + readonly #fixtures: Fixtures; + readonly #createdAt = Date.now(); + readonly #operate: MockOperate; + readonly #syncs = new Map(); + readonly #platform: MockPlatformApi; + readonly #build: MockBuild; + + constructor(options: MockOptions = {}) { + this.#options = { + role: options.role ?? 'admin', + events: options.events ?? 'default', + latencyMs: options.latencyMs ?? 120, + connection: options.connection ?? 'live' + }; + this.changes.setState(this.#options.connection); + this.#fixtures = createFixtures(this.#createdAt); + this.#operate = new MockOperate({ + changes: this.changes, + role: () => this.#options.role, + can: (capability) => ROLE_CAPABILITIES[this.#options.role].includes(capability), + user: () => DEMO_USER, + adapter: (scope) => this.#location(scope).target.adapter ?? 'duckdb', + target: (scope) => this.#location(scope).target.name, + reply: (value) => this.#reply(value) + }); + this.#platform = new MockPlatformApi({ + now: this.#createdAt, + role: this.#options.role, + events: this.#options.events, + serverVersion: '0.185.0', + user: DEMO_USER, + locations: this.#fixtures.locations, + changes: this.changes, + platformEvents: () => this.#platformEvents(), + reply: (value) => this.#reply(value) + }); + this.#build = new MockBuild({ + now: this.#createdAt, + can: (capability) => ROLE_CAPABILITIES[this.#options.role].includes(capability), + adapter: (scope) => this.#location(scope).target.adapter ?? 'duckdb', + reply: (value) => this.#reply(value), + operate: this.#operate, + platform: this.#platform, + platformEvents: () => this.#platformEvents() + }); + } + + async bootstrap(): Promise { + const user: CurrentUser = { ...DEMO_USER, kind: 'user', role: this.#options.role }; + return this.#reply({ + serverVersion: '0.185.0', + authMode: 'oidc', + user, + capabilities: ROLE_CAPABILITIES[this.#options.role], + locations: this.#fixtures.locations.map((location) => ({ + ...location, + warehouseSync: this.#sync({ locationId: location.id }) + })) + }); + } + + async navCounts(scope: Scope): Promise { + this.#location(scope); + return this.#reply({ + catalogNodes: this.#build.nodeCount(), + failingChecks: this.#build.failingCheckCount(scope), + activeRuns: this.#operate.activeRunCount(), + pausedTriggers: this.#operate.pausedTriggerCount(scope), + activePlatformEvents: this.#platformEvents().filter((e) => e.active).length + }); + } + + // ---- BUILD: delegated to the build store (build.ts) ---- + + overview(...args: Parameters) { + this.#location(args[0]); + return this.#build.overview(...args); + } + listCatalogNodes(...args: Parameters) { + this.#location(args[0]); + return this.#build.listCatalogNodes(...args); + } + getCatalogNode(...args: Parameters) { + this.#location(args[0]); + return this.#build.getCatalogNode(...args); + } + getColumnLineage(...args: Parameters) { + this.#location(args[0]); + return this.#build.getColumnLineage(...args); + } + listChecks(...args: Parameters) { + this.#location(args[0]); + return this.#build.listChecks(...args); + } + getCheck(...args: Parameters) { + this.#location(args[0]); + return this.#build.getCheck(...args); + } + async getCheckSql(...args: Parameters) { + this.#location(args[0]); + return this.#build.getCheckSql(...args); + } + async getCheckEvidence(...args: Parameters) { + this.#location(args[0]); + return this.#build.getCheckEvidence(...args); + } + listSnapshotChanges(...args: Parameters) { + this.#location(args[0]); + return this.#build.listSnapshotChanges(...args); + } + + // ---- end BUILD ---- + + // ---- OPERATE: delegated to the operate store (operate-store.ts) ---- + + async listRuns(scope: Scope, query: RunListQuery = {}): Promise { + this.#location(scope); + return this.#operate.listRuns(scope, query); + } + + listRunRows(...args: Parameters) { + this.#location(args[0]); + return this.#operate.listRunRows(...args); + } + + runTabCounts(...args: Parameters) { + this.#location(args[0]); + return this.#operate.runTabCounts(...args); + } + + cancelMatchingRuns(...args: Parameters) { + this.#location(args[0]); + return this.#operate.cancelMatchingRuns(...args); + } + + async getRun(scope: Scope, runId: string): Promise { + this.#location(scope); + return this.#operate.getRun(scope, runId); + } + + async getRunDetail(scope: Scope, runId: string): Promise { + this.#location(scope); + return this.#operate.getRunDetail(scope, runId); + } + + async listRunEvents(scope: Scope, runId: string, query?: { after?: number; limit?: number }): Promise { + this.#location(scope); + return this.#operate.listRunEvents(scope, runId, query); + } + + async listRunStatements(scope: Scope, runId: string): Promise { + this.#location(scope); + return this.#operate.listRunStatements(scope, runId); + } + + async listRunOutput(scope: Scope, runId: string): Promise { + this.#location(scope); + return this.#operate.listRunOutput(scope, runId); + } + + async listQueue(scope: Scope): Promise { + this.#location(scope); + return this.#operate.listQueue(scope); + } + + async timeline(scope: Scope, window: { since: string; until: string }): Promise { + this.#location(scope); + return this.#operate.timeline(scope, window); + } + + async cancelRun(scope: Scope, runId: string): Promise { + this.#location(scope); + return this.#operate.cancelRun(scope, runId); + } + + async rerunRun(scope: Scope, runId: string, mode: RerunMode, nodes?: string[]): Promise { + this.#location(scope); + return this.#operate.rerunRun(scope, runId, mode, nodes); + } + + getStatementSql(...args: Parameters) { + this.#location(args[0]); + return this.#operate.getStatementSql(...args); + } + + async listSnapshotNodes(scope: Scope): Promise { + this.#location(scope); + return this.#operate.listSnapshotNodes(scope); + } + + async checkAdhocRun(scope: Scope, request: AdhocRunRequest): Promise { + this.#location(scope); + return this.#operate.checkAdhocRun(scope, request); + } + + async launchAdhocRun(scope: Scope, request: AdhocRunRequest): Promise { + this.#location(scope); + return this.#operate.launchAdhocRun(scope, request); + } + + async listJobs(scope: Scope): Promise { + this.#location(scope); + return this.#operate.listJobs(scope); + } + + async getJob(scope: Scope, key: string): Promise { + this.#location(scope); + return this.#operate.getJob(scope, key); + } + + async launchJob(scope: Scope, key: string, options?: JobLaunchOptions): Promise { + this.#location(scope); + return this.#operate.launchJob(scope, key, options); + } + + async listTriggers(scope: Scope): Promise { + this.#location(scope); + return this.#operate.listTriggers(scope); + } + + async getTrigger(scope: Scope, id: string): Promise { + this.#location(scope); + return this.#operate.getTrigger(scope, id); + } + + async setTriggerStates(scope: Scope, ids: string[], state: TriggerState, reason?: string | null): Promise { + this.#location(scope); + return this.#operate.setTriggerStates(scope, ids, state, reason ?? null); + } + + async resetTriggerToDefault(scope: Scope, id: string): Promise { + this.#location(scope); + return this.#operate.resetTriggerToDefault(scope, id); + } + + async resetSensorCursor(scope: Scope, id: string, reason?: string | null): Promise { + this.#location(scope); + return this.#operate.resetSensorCursor(scope, id, reason ?? null); + } + + // ---- end OPERATE ---- + + async listPlatformEvents(query: { activeOnly: boolean }): Promise { + const events = this.#platformEvents(); + return this.#reply(query.activeOnly ? events.filter((e) => e.active) : events); + } + + async getWarehouseSync(scope: Scope): Promise { + this.#location(scope); + return this.#reply({ ...this.#sync(scope) }); + } + + // ---- platform and admin: delegated to MockPlatformApi (mock/platform.ts) ---- + + platformHealth(...args: Parameters) { + return this.#platform.platformHealth(...args); + } + listPlatformEventHistory(...args: Parameters) { + return this.#platform.listPlatformEventHistory(...args); + } + listLocations(...args: Parameters) { + return this.#platform.listLocations(...args); + } + getLocation(...args: Parameters) { + return this.#platform.getLocation(...args); + } + testConnection(...args: Parameters) { + return this.#platform.testConnection(...args); + } + listLocationTokens(...args: Parameters) { + return this.#platform.listLocationTokens(...args); + } + createLocationToken(...args: Parameters) { + return this.#platform.createLocationToken(...args); + } + rotateLocationToken(...args: Parameters) { + return this.#platform.rotateLocationToken(...args); + } + revokeLocationToken(...args: Parameters) { + return this.#platform.revokeLocationToken(...args); + } + listWarehouseConnections(...args: Parameters) { + return this.#platform.listWarehouseConnections(...args); + } + listProviders(...args: Parameters) { + return this.#platform.listProviders(...args); + } + maintenanceHistory(...args: Parameters) { + return this.#platform.maintenanceHistory(...args); + } + listAccounts(...args: Parameters) { + return this.#platform.listAccounts(...args); + } + disableAccount(...args: Parameters) { + return this.#platform.disableAccount(...args); + } + enableAccount(...args: Parameters) { + return this.#platform.enableAccount(...args); + } + listRoles(...args: Parameters) { + return this.#platform.listRoles(...args); + } + listRoleAssignments(...args: Parameters) { + return this.#platform.listRoleAssignments(...args); + } + createRoleAssignment(...args: Parameters) { + return this.#platform.createRoleAssignment(...args); + } + deleteRoleAssignment(...args: Parameters) { + return this.#platform.deleteRoleAssignment(...args); + } + listAuditEntries(...args: Parameters) { + return this.#platform.listAuditEntries(...args); + } + explainAccess(...args: Parameters) { + return this.#platform.explainAccess(...args); + } + getRole(...args: Parameters) { + return this.#platform.getRole(...args); + } + listSsoMappings(...args: Parameters) { + return this.#platform.listSsoMappings(...args); + } + lookupDecision(...args: Parameters) { + return this.#platform.lookupDecision(...args); + } + effectiveAccess(...args: Parameters) { + return this.#platform.effectiveAccess(...args); + } + listAccessOverview(...args: Parameters) { + return this.#platform.listAccessOverview(...args); + } + serverSettings(...args: Parameters) { + return this.#platform.serverSettings(...args); + } + + // ---- end platform and admin ---- + + #sync(scope: Scope): WarehouseSync { + const key = scopeKey(scope); + let sync = this.#syncs.get(key); + if (!sync) { + sync = { ...this.#location(scope).warehouseSync }; + this.#syncs.set(key, sync); + } + return sync; + } + + #platformEvents(): PlatformEvent[] { + const { platformEvents, extraPlatformEvents } = this.#fixtures; + if (this.#options.events === 'none') return platformEvents.filter((e) => !e.active); + if (this.#options.events === 'all') return [...extraPlatformEvents, ...platformEvents]; + return platformEvents; + } + + #location(scope: Scope) { + const location = this.#fixtures.locations.find((l) => l.id === scope.locationId); + if (!location) throw new ApiError(`Unknown location: ${scope.locationId}`, 404); + return location; + } + + #require(capability: Capability) { + if (!ROLE_CAPABILITIES[this.#options.role].includes(capability)) { + throw new ApiError(`Missing permission: ${capability}`, 403); + } + } + + #reply(value: T): Promise { + return new Promise((resolve) => setTimeout(() => resolve(value), this.#options.latencyMs)); + } +} + +function scopeKey(scope: Scope): string { + return scope.locationId; +} diff --git a/ui/src/lib/api/mock/operate-graph.ts b/ui/src/lib/api/mock/operate-graph.ts new file mode 100644 index 0000000000..cdeb98711d --- /dev/null +++ b/ui/src/lib/api/mock/operate-graph.ts @@ -0,0 +1,138 @@ +// The mock project's node graph and a small selector evaluator. The server +// resolves selectors against the real snapshot; this stands in for it. + +import type { NodeKind, SnapshotNode } from '../types'; + +function node( + key: string, + kind: NodeKind, + group: string, + upstream: string[], + options: { tags?: string[]; cursor?: SnapshotNode['cursor']; path?: string } = {} +): SnapshotNode { + const path = + options.path ?? + (kind === 'source' + ? 'models/sources.yml' + : kind === 'seed' + ? `seeds/${key}.csv` + : kind === 'task' || kind === 'loader' + ? `tasks/${key}.py` + : `models/${group}/${key}.sql`); + return { key, kind, group, tags: options.tags ?? [], path, cursor: options.cursor ?? null, upstream }; +} + +export const SNAPSHOT_NODES: SnapshotNode[] = [ + node('raw__customers', 'source', 'sources', []), + node('raw__payments', 'source', 'sources', []), + node('raw__orders', 'loader', 'sources', [], { tags: ['ingest'], cursor: 'timestamp' }), + node('country_codes', 'seed', 'staging', []), + node('stg_customers', 'view', 'staging', ['raw__customers']), + node('stg_orders', 'view', 'staging', ['raw__orders']), + node('stg_payments', 'view', 'staging', ['raw__payments']), + node('fact_orders', 'table', 'marts', ['stg_orders', 'stg_payments', 'stg_customers'], { tags: ['finance', 'core'] }), + node('dim_customers', 'table', 'marts', ['stg_customers', 'fact_orders', 'country_codes'], { tags: ['core'] }), + node('daily_revenue', 'table', 'marts', ['fact_orders'], { tags: ['finance', 'revenue'] }), + node('customer_status', 'incremental', 'marts', ['dim_customers'], { cursor: 'timestamp' }), + node('hourly_order_activity', 'incremental', 'activity', ['fact_orders'], { cursor: 'timestamp' }), + node('hourly_activity_ctx', 'incremental', 'activity', ['hourly_order_activity', 'daily_revenue'], { cursor: 'timestamp' }), + node('daily_activity_rollup', 'incremental', 'activity', ['hourly_activity_ctx'], { cursor: 'timestamp' }), + node('revenue_report', 'table', 'reporting', ['daily_revenue'], { tags: ['finance'] }), + node('activity_report', 'table', 'reporting', ['hourly_activity_ctx']), + node('export_revenue_csv', 'task', 'exports', ['revenue_report'], { tags: ['export'] }) +]; + +const BY_KEY = new Map(SNAPSHOT_NODES.map((n) => [n.key, n])); +const ORDER = new Map(SNAPSHOT_NODES.map((n, i) => [n.key, i])); +const DOWNSTREAM = new Map(); +for (const n of SNAPSHOT_NODES) { + for (const up of n.upstream) DOWNSTREAM.set(up, [...(DOWNSTREAM.get(up) ?? []), n.key]); +} + +export function snapshotNode(key: string): SnapshotNode | undefined { + return BY_KEY.get(key); +} + +/** Whether runs execute (and therefore write) the node; external sources are only read. */ +export function isWritable(key: string): boolean { + const n = BY_KEY.get(key); + return !!n && n.kind !== 'source'; +} + +function closure(start: string, next: (key: string) => string[]): Set { + const seen = new Set(); + const stack = [...next(start)]; + while (stack.length) { + const key = stack.pop()!; + if (seen.has(key)) continue; + seen.add(key); + stack.push(...next(key)); + } + return seen; +} + +export function ancestors(key: string): Set { + return closure(key, (k) => BY_KEY.get(k)?.upstream ?? []); +} + +export function descendants(key: string): Set { + return closure(key, (k) => DOWNSTREAM.get(k) ?? []); +} + +/** Lineage edges between the given nodes (direct parents only). */ +export function edgesWithin(keys: readonly string[]): { from: string; to: string }[] { + const set = new Set(keys); + return keys.flatMap((key) => (BY_KEY.get(key)?.upstream ?? []).filter((up) => set.has(up)).map((up) => ({ from: up, to: key }))); +} + +export function sortTopologically(keys: Iterable): string[] { + return [...new Set(keys)].sort((a, b) => (ORDER.get(a) ?? 0) - (ORDER.get(b) ?? 0)); +} + +function matchTerm(term: string): Set | null { + const up = term.startsWith('+'); + const down = term.endsWith('+') && term.length > 1; + const body = term.replace(/^\+/, '').replace(/\+$/, ''); + let base: SnapshotNode[]; + if (body === '*' || body === '') base = SNAPSHOT_NODES; + else if (body.startsWith('tag:')) base = SNAPSHOT_NODES.filter((n) => n.tags.includes(body.slice(4))); + else if (body.startsWith('group:')) base = SNAPSHOT_NODES.filter((n) => n.group === body.slice(6)); + else if (body.startsWith('kind:')) base = SNAPSHOT_NODES.filter((n) => n.kind === body.slice(5)); + else if (body.startsWith('path:')) base = SNAPSHOT_NODES.filter((n) => n.path.startsWith(body.slice(5))); + else { + const exact = BY_KEY.get(body); + if (!exact) return null; + base = [exact]; + } + const out = new Set(base.map((n) => n.key)); + for (const n of base) { + if (up) for (const a of ancestors(n.key)) out.add(a); + if (down) for (const d of descendants(n.key)) out.add(d); + } + return out; +} + +export type Resolution = { nodes: string[]; unknown: string[] }; + +/** Union of the `select` terms minus the `exclude` terms; an empty selection means the whole project. */ +export function resolveSelection(select: readonly string[], exclude: readonly string[] = []): Resolution { + const unknown: string[] = []; + const chosen = new Set(); + const terms = select.length ? select : ['*']; + for (const term of terms) { + const hit = matchTerm(term); + if (!hit) unknown.push(term); + else for (const k of hit) chosen.add(k); + } + for (const term of exclude) { + const hit = matchTerm(term); + if (!hit) unknown.push(term); + else for (const k of hit) chosen.delete(k); + } + return { nodes: sortTopologically(chosen), unknown }; +} + +/** Selected nodes a run would execute and write. */ +export function writeSetOf(select: readonly string[], exclude: readonly string[] = []): string[] { + return resolveSelection(select, exclude).nodes.filter(isWritable); +} diff --git a/ui/src/lib/api/mock/operate-store.ts b/ui/src/lib/api/mock/operate-store.ts new file mode 100644 index 0000000000..89037a9737 --- /dev/null +++ b/ui/src/lib/api/mock/operate-store.ts @@ -0,0 +1,1175 @@ +// In-memory stand-in for the server's OPERATE endpoints: runs, the queue and +// its claims, jobs, triggers and sensors. A small loop advances live runs so +// cancels, launches and claims behave like they do on the server. + +import { flagSpec } from '$lib/run/command-table'; +import { ApiError } from '../client'; +import type { LocalChangeBus } from '../signals'; +import type { + AdhocRunCheck, + AdhocRunRequest, + BuiltInRole, + BulkRunResult, + Capability, + JobDetail, + JobGroupMember, + JobLaunchOptions, + OverlapOutcome, + Principal, + QueueEntry, + QueueWait, + RerunMode, + Run, + RunDetail, + RunEventPage, + RunListQuery, + RunNode, + RunOutputLine, + RunRow, + RunRowPage, + RunRowQuery, + RunStatement, + RunTabCounts, + Scope, + SnapshotNode, + StatementSql, + Tick, + Timeline, + TimelineBar, + TimelineTickMarker, + Trigger, + TriggerDetail, + TriggerState, + TriggerStateEvent +} from '../types'; +import { + CONCURRENCY_LIMITS, + IMAGE_ID, + JOB_DEFS, + SNAPSHOT_GENERATION, + TRIGGER_DEFS, + concurrencyGroupsOf, + createOperateFixtures, + jobDef, + jobNodes, + resolveJobSelection, + selectorTerms, + triggerDef, + triggerId, + type OperateFixtures, + type TickRecord, + type TriggerDef +} from './operate'; +import { SCHEDULER } from './principals'; +import { ancestors, resolveSelection, SNAPSHOT_NODES, snapshotNode, sortTopologically, edgesWithin, writeSetOf } from './operate-graph'; +import { + commandOf, + computeNodes, + countNodes, + deriveTerminalStatus, + execution, + invocations, + isActive, + isTerminal, + liveness, + sqlFor, + statusReason, + type RunRecord +} from './run-detail'; + +const MIN = 60_000; + +function plainTick(t: TickRecord): Tick { + const { triggerName, ...tick } = t; + void triggerName; + return tick; +} +const HOLDING = new Set(['launched', 'running', 'unresponsive']); +const UNRESPONSIVE_MS = 45_000; +const PRESUMED_FAILED_MS = 600_000; +const CANCEL_EXIT_MS = 30_000; + +/** Node scope of the `run.adhoc` grant per mock role; null means every node. */ +const ADHOC_SCOPE: Record = { + viewer: [], + operator: [], + developer: ['group:staging', 'group:marts', 'group:activity', 'group:reporting'], + admin: null +}; + +type TriggerRuntime = { + state: TriggerState; + history: TriggerStateEvent[]; + cursor: string | null; + cursorUpdatedAt: number | null; +}; + +export type OperateContext = { + changes: LocalChangeBus; + role: () => BuiltInRole; + can: (capability: Capability) => boolean; + user: () => Principal; + adapter: (scope: Scope) => string; + /** Name of the location's one target. */ + target: (scope: Scope) => string; + reply: (value: T) => Promise; +}; + +export class MockOperate { + readonly #ctx: OperateContext; + readonly #fx: OperateFixtures; + readonly #records: RunRecord[]; + readonly #triggers = new Map>(); + #seq = 0; + + constructor(ctx: OperateContext, now = Date.now()) { + this.#ctx = ctx; + this.#fx = createOperateFixtures(now); + this.#records = this.#fx.records; + for (const rec of this.#records) this.#settle(rec, now); + if (typeof window !== 'undefined') setInterval(() => this.#advance(), 1500); + } + + // ---- reads: runs ---- + + listRuns(scope: Scope, query: RunListQuery = {}): Promise { + let recs = this.#records; + if (query.jobKey) recs = recs.filter((r) => r.run.jobKey === query.jobKey); + if (query.parentRunId) recs = recs.filter((r) => r.run.parentRunId === query.parentRunId); + else if (query.topLevelOnly) recs = recs.filter((r) => !r.run.parentRunId); + if (query.triggerId) recs = recs.filter((r) => r.run.triggerId === query.triggerId); + if (query.since) recs = recs.filter((r) => r.run.queuedAt >= query.since!); + if (query.until) recs = recs.filter((r) => r.run.queuedAt < query.until!); + let runs = recs.map((r) => this.#view(r, scope)); + if (query.statuses?.length) runs = runs.filter((r) => query.statuses!.includes(r.status)); + if (query.kinds?.length) runs = runs.filter((r) => query.kinds!.includes(r.kind)); + if (query.origins?.length) runs = runs.filter((r) => query.origins!.includes(r.origin)); + runs.sort((a, b) => b.queuedAt.localeCompare(a.queuedAt)); + if (query.limit) runs = runs.slice(0, query.limit); + return this.#ctx.reply(runs); + } + + listRunRows(scope: Scope, query: RunRowQuery = {}): Promise { + const now = Date.now(); + const matching = this.#matching(scope, query); + const offset = query.cursor ? Number(query.cursor.replace(/^c/, '')) || 0 : 0; + const limit = query.limit ?? 50; + const page = matching.slice(offset, offset + limit); + const waits = this.#queue(); + const rows: RunRow[] = page.map(({ rec, view }) => { + const nodes = this.#nodesOf(rec, now); + const done = nodes.filter((n) => n.status !== 'pending' && n.status !== 'running').length; + const cancel = rec.script.cancel; + return { + run: view, + progress: { done, total: nodes.length }, + waits: view.status === 'queued' ? (waits.get(view.id) ?? []) : [], + lastContactAt: view.status === 'unresponsive' || view.status === 'presumed_failed' ? (liveness(rec, now)?.lastHeartbeatAt ?? null) : null, + lostContactLimitSeconds: PRESUMED_FAILED_MS / 1000, + cancelRequestedAt: cancel && !cancel.terminatedAt ? cancel.requestedAt : null, + selection: view.kind === 'adhoc' ? rec.script.select : [], + rerunFromFailureCount: nodes.filter((n) => n.status === 'failed' || n.status === 'skipped').length + }; + }); + const next = offset + limit < matching.length ? `c${offset + limit}` : null; + return this.#ctx.reply({ rows, nextCursor: next }); + } + + runTabCounts(scope: Scope, query: RunRowQuery = {}): Promise { + const base = this.#matching(scope, { ...query, tab: 'all' }); + return this.#ctx.reply({ + active: base.filter(({ view }) => isActive(view.status)).length, + queued: base.filter(({ view }) => view.status === 'queued').length + }); + } + + cancelMatchingRuns(scope: Scope, query: RunRowQuery): Promise { + this.#need('run.cancel'); + const targets = this.#matching(scope, query).filter(({ view }) => isActive(view.status)); + const result: BulkRunResult = { done: [], failed: [] }; + for (const { rec } of targets) { + try { + void this.cancelRun(scope, rec.run.id); + result.done.push(rec.run.id); + } catch (e) { + result.failed.push({ runId: rec.run.id, error: e instanceof Error ? e.message : String(e) }); + } + } + return this.#ctx.reply(result); + } + + /** Records matching a runs-list query, newest first. */ + #matching(scope: Scope, query: RunRowQuery): { rec: RunRecord; view: Run }[] { + const f = query.filter ?? {}; + const lower = (xs?: string[]) => (xs?.length ? xs.map((x) => x.toLowerCase()) : null); + const ids = lower(f.ids)?.map((id) => id.replace(/^run-/, '')); + const jobs = lower(f.jobKeys); + const schedules = lower(f.schedules); + const sensors = lower(f.sensors); + const by = lower(f.requestedBy); + const nodeKeys = lower(f.nodeKeys); + const text = f.text?.trim().toLowerCase() ?? ''; + const tab = query.tab ?? 'all'; + const out: { rec: RunRecord; view: Run }[] = []; + for (const rec of this.#records) { + const r = rec.run; + if (query.parentRunId) { + if (r.parentRunId !== query.parentRunId) continue; + } else if (!query.includeChildren && r.parentRunId) continue; + if (query.since && r.queuedAt < query.since) continue; + if (query.until && r.queuedAt >= query.until) continue; + if (f.kinds?.length && !f.kinds.includes(r.kind)) continue; + if (f.origins?.length && !f.origins.includes(r.origin)) continue; + if (ids && !ids.some((id) => r.id.replace(/^run-/, '').toLowerCase().startsWith(id))) continue; + if (jobs && !jobs.includes((r.jobKey ?? '').toLowerCase())) continue; + const trigger = triggerDef(r.triggerName); + const isSchedule = trigger?.kind === 'cron_schedule' || trigger?.kind === 'calendar_schedule'; + if (schedules && !(isSchedule && schedules.includes((r.triggerName ?? '').toLowerCase()))) continue; + if (sensors && !(trigger && !isSchedule && sensors.includes((r.triggerName ?? '').toLowerCase()))) continue; + if (by && !by.includes(r.requestedBy.name.toLowerCase())) continue; + if (nodeKeys) { + const nodes = new Set([rec, ...this.#children(rec)].flatMap((x) => x.script.nodes.map((n) => n.toLowerCase()))); + if (!nodeKeys.some((n) => nodes.has(n))) continue; + } + if (text && !`${r.id} ${r.jobKey ?? ''} ${r.triggerName ?? ''} ${r.requestedBy.name} ${r.requestedBy.displayName} ${r.runKey ?? ''}`.toLowerCase().includes(text)) continue; + const view = this.#view(rec, scope); + if (f.statuses?.length && !f.statuses.includes(view.status)) continue; + if (tab === 'active' && !isActive(view.status)) continue; + if (tab === 'queued' && view.status !== 'queued') continue; + if (tab === 'failed' && !['failed', 'partial', 'presumed_failed'].includes(view.status)) continue; + if (tab === 'cancelled' && view.status !== 'cancelled') continue; + out.push({ rec, view }); + } + return out.sort((a, b) => b.view.queuedAt.localeCompare(a.view.queuedAt) || a.view.id.localeCompare(b.view.id)); + } + + getRun(scope: Scope, runId: string): Promise { + const rec = this.#byId(runId); + return this.#ctx.reply(rec ? this.#view(rec, scope) : null); + } + + getRunDetail(scope: Scope, runId: string): Promise { + const rec = this.#byId(runId); + if (!rec) return this.#ctx.reply(null); + const now = Date.now(); + const run = this.#view(rec, scope); + const children = this.#children(rec).map((c) => this.#view(c, scope)); + const parentRec = rec.run.parentRunId ? this.#byId(rec.run.parentRunId) : undefined; + const nodes = this.#nodesOf(rec, now); + const nodeKeys = nodes.map((n) => n.nodeKey); + const ownRecords = [rec, ...this.#children(rec)]; + const detail: RunDetail = { + run, + parent: parentRec ? this.#view(parentRec, scope) : null, + children, + nodes, + edges: edgesWithin(nodeKeys), + invocations: ownRecords.flatMap((r) => invocations(r)), + liveness: liveness(rec, now), + cancel: rec.script.cancel ? structuredClone(rec.script.cancel) : null, + provenance: { + snapshotId: rec.run.snapshotId, + snapshotGeneration: rec.script.snapshotGeneration, + gitRevision: rec.run.gitRevision, + gitDirty: false, + imageId: rec.script.imageId, + sqbVersion: rec.script.snapshotGeneration === SNAPSHOT_GENERATION ? '0.185.0' : '0.184.2', + selection: rec.script.select, + exclude: rec.script.exclude, + command: rec.script.nodes.length ? commandOf(rec) : `group ${rec.run.jobKey}: ${this.#children(rec).length} child runs` + }, + waits: rec.run.status === 'queued' ? this.#queue().get(rec.run.id) ?? [] : [], + rerunFromFailureNodes: nodes.filter((n) => n.status === 'failed' || n.status === 'skipped').map((n) => n.nodeKey), + rerunOf: rec.run.rerunOfRunId ? ((r) => (r ? this.#view(r, scope) : null))(this.#byId(rec.run.rerunOfRunId)) : null, + reruns: this.#records + .filter((r) => r.run.rerunOfRunId === rec.run.id) + .map((r) => this.#view(r, scope)) + .sort((a, b) => b.queuedAt.localeCompare(a.queuedAt)) + }; + return this.#ctx.reply(detail); + } + + listRunEvents(scope: Scope, runId: string, query: { after?: number; limit?: number } = {}): Promise { + const rec = this.#require(runId); + const now = Date.now(); + const all = [rec, ...this.#children(rec)] + .flatMap((r) => execution(r, this.#nodesOf(r, now)).events) + .sort((a, b) => a.occurredAt.localeCompare(b.occurredAt) || a.storageId - b.storageId) + .map((e, i) => ({ ...e, storageId: i + 1 })); + const after = query.after ?? 0; + const limit = query.limit ?? 500; + const page = all.filter((e) => e.storageId > after).slice(0, limit); + const more = all.length > after + page.length; + return this.#ctx.reply({ events: page, nextCursor: page.length ? (more || isActive(rec.run.status) ? page[page.length - 1].storageId : null) : after || null }); + } + + listRunStatements(scope: Scope, runId: string): Promise { + const rec = this.#require(runId); + const now = Date.now(); + return this.#ctx.reply([rec, ...this.#children(rec)].flatMap((r) => execution(r, this.#nodesOf(r, now), now).statements)); + } + + getStatementSql(scope: Scope, runId: string, statementId: string): Promise { + this.#need('view.sql'); + const rec = this.#require(runId); + const now = Date.now(); + const statement = [rec, ...this.#children(rec)] + .flatMap((r) => execution(r, this.#nodesOf(r, now), now).statements) + .find((st) => st.id === statementId); + if (!statement) throw new ApiError(`Unknown statement ${statementId}`, 404); + return this.#ctx.reply({ statementId, sql: statement.sqlRecorded ? sqlFor(statement.nodeKey, statement.kind, this.#ctx.target(scope)) : null }); + } + + listRunOutput(scope: Scope, runId: string): Promise { + const rec = this.#require(runId); + const now = Date.now(); + return this.#ctx.reply([rec, ...this.#children(rec)].flatMap((r) => execution(r, this.#nodesOf(r, now)).output)); + } + + listQueue(scope: Scope): Promise { + const waits = this.#queue(); + const queued = this.#ordered(); + return this.#ctx.reply( + queued.map((rec, i) => ({ + run: this.#view(rec, scope), + position: i + 1, + waits: waits.get(rec.run.id) ?? [], + overlap: this.#overlap(rec, queued) + })) + ); + } + + timeline(scope: Scope, window: { since: string; until: string }): Promise { + const now = Date.now(); + const bars: TimelineBar[] = []; + for (const rec of this.#records) { + const r = rec.run; + if (r.parentRunId || !r.jobKey || !r.startedAt) continue; + const end = r.endedAt ?? new Date(now).toISOString(); + if (end <= window.since || r.startedAt >= window.until) continue; + const view = this.#view(rec, scope); + bars.push({ runId: r.id, jobKey: r.jobKey, kind: r.kind, origin: r.origin, status: view.status, triggerName: r.triggerName, start: r.startedAt, end }); + } + // Only schedules and calendars have due times to project; sensors fire on events. + const until = Date.parse(window.until); + for (const t of this.#triggerViews(scope)) { + if (t.type !== 'schedule' || t.state !== 'running' || !t.jobKey) continue; + const typical = this.#typicalMs(t.jobKey); + for (const due of this.#upcoming(t.name, until)) { + if (due < Date.parse(window.since)) continue; + bars.push({ + runId: null, + jobKey: t.jobKey, + kind: jobDef(t.jobKey)?.kind === 'group' ? 'group' : 'job', + origin: 'schedule', + status: 'projected', + triggerName: t.name, + start: new Date(due).toISOString(), + end: new Date(due + typical).toISOString() + }); + } + } + const markers: TimelineTickMarker[] = this.#fx.ticks + .filter((t) => (t.outcome === 'covered' || t.outcome === 'skipped') && t.scheduledFor >= window.since && t.scheduledFor < window.until) + .map((t) => ({ + tickId: t.id, + triggerId: triggerId(t.triggerName), + triggerName: t.triggerName, + jobKey: triggerDef(t.triggerName)?.jobKey ?? t.triggerName, + scheduledFor: t.scheduledFor, + outcome: t.outcome as 'covered' | 'skipped', + coveredByRunId: t.coveredByRunId, + reason: t.reason + })); + const jobKeys = [...new Set([...JOB_DEFS.map((j) => j.key)])]; + return this.#ctx.reply({ now: new Date(now).toISOString(), jobKeys, bars: bars.sort((a, b) => a.start.localeCompare(b.start)), markers }); + } + + listSnapshotNodes(scope: Scope): Promise { + void scope; + return this.#ctx.reply(structuredClone(SNAPSHOT_NODES)); + } + + // ---- actions: runs ---- + + cancelRun(scope: Scope, runId: string): Promise { + this.#need('run.cancel'); + const rec = this.#require(runId); + if (isTerminal(rec.run.status)) throw new ApiError(`${runId} has already finished`, 409); + if (rec.script.cancel) return this.#ctx.reply(this.#view(rec, scope)); + const now = Date.now(); + for (const r of [rec, ...this.#children(rec)]) { + if (isTerminal(r.run.status)) continue; + r.script.cancel = { + requestedAt: new Date(now).toISOString(), + requestedBy: this.#ctx.user(), + sigtermAt: null, + graceSeconds: 60, + sigkillAt: null, + exitCode: null, + terminatedAt: null + }; + if (r.script.nodes.length && !r.run.startedAt) { + // Not started yet: nothing to signal, so the run ends straight away. + r.script.cancel.terminatedAt = r.script.cancel.requestedAt; + this.#finish(r, now, 'cancelled', `Cancelled by ${this.#ctx.user().displayName} before it started`); + } + } + this.#emit(rec); + return this.#ctx.reply(this.#view(rec, scope)); + } + + rerunRun(scope: Scope, runId: string, mode: RerunMode, selectedNodes: string[] = []): Promise { + this.#need('run.rerun'); + const source = this.#require(runId); + if (isActive(source.run.status)) throw new ApiError(`${runId} is still active`, 409); + if (source.run.rewriteHistory) this.#need('run.rewrite_history'); + if (mode === 'selected' && !selectedNodes.length) throw new ApiError('Choose at least one node to rerun', 422); + const now = Date.now(); + const affected = (rec: RunRecord) => + this.#nodesOf(rec, now) + .filter((n) => (mode === 'selected' ? selectedNodes.includes(n.nodeKey) : n.status === 'failed' || n.status === 'skipped')) + .map((n) => n.nodeKey); + const user = this.#ctx.user(); + const id = this.#newId(); + const job = jobDef(source.run.jobKey); + if (job?.kind === 'group') { + const members = this.#children(source).filter((c) => mode === 'all' || affected(c).length); + if (!members.length) throw new ApiError('Nothing to rerun: no node failed or was skipped', 409); + const parent = this.#create({ id, origin: 'rerun', jobKey: job.key, rerunOf: runId, by: user, select: [], exclude: [], flags: {}, nodes: [], priority: job.priority }); + members.forEach((c, i) => { + const nodes = mode === 'all' ? c.script.nodes : affected(c); + this.#create({ id: `${id}-${i + 1}`, origin: 'rerun', jobKey: c.run.jobKey, parent: id, rerunOf: c.run.id, by: user, select: mode === 'all' ? c.script.select : nodes, exclude: mode === 'all' ? c.script.exclude : [], flags: c.script.flags, nodes, priority: job.priority }); + }); + parent.run.childRunCount = members.length; + this.#ctx.changes.emit('runs'); + return this.#ctx.reply(this.#view(parent, scope)); + } + const nodes = mode === 'all' ? source.script.nodes : affected(source); + if (!nodes.length) throw new ApiError('Nothing to rerun: no node failed or was skipped', 409); + const rec = this.#create({ + id, + origin: 'rerun', + jobKey: source.run.jobKey, + rerunOf: runId, + by: user, + select: mode === 'all' ? source.script.select : nodes, + exclude: mode === 'all' ? source.script.exclude : [], + flags: source.script.flags, + nodes, + priority: source.run.priority + }); + this.#ctx.changes.emit('runs'); + return this.#ctx.reply(this.#view(rec, scope)); + } + + launchJob(scope: Scope, key: string, options: JobLaunchOptions = {}): Promise { + this.#need('job.run'); + const job = jobDef(key); + if (!job) throw new ApiError(`Unknown job ${key}`, 404); + if (job.targets && !job.targets.includes(this.#ctx.target(scope))) throw new ApiError(`${key} is limited to targets ${job.targets.join(', ')}`, 422); + const flags: Record = Object.fromEntries(Object.entries(job.settings).map(([k, v]) => [k, v === true ? true : String(v)])); + if (options.fullRefresh) flags['--full-refresh'] = true; + if (options.window) { + const suffix = options.window.type === 'integer' ? '-int' : '-ts'; + if (options.window.start) flags[`--start-cursor${suffix}`] = options.window.start; + if (options.window.end) flags[`--end-cursor${suffix}`] = options.window.end; + } + const rewrite = !!options.fullRefresh || !!options.window; + if (rewrite) this.#need('run.rewrite_history'); + const user = this.#ctx.user(); + const id = this.#newId(); + if (job.kind === 'group') { + const parent = this.#create({ id, origin: 'manual', jobKey: key, by: user, select: [], exclude: [], flags, nodes: [], priority: job.priority }); + job.members.forEach((m, i) => { + const member = jobDef(m)!; + this.#create({ id: `${id}-${i + 1}`, origin: 'manual', jobKey: m, parent: id, by: user, select: selectorTerms(member), exclude: member.exclude, flags: { ...flags, ...Object.fromEntries(Object.entries(member.settings).map(([k, v]) => [k, v === true ? true : String(v)])) }, nodes: jobNodes(m), priority: job.priority }); + }); + parent.run.childRunCount = job.members.length; + this.#ctx.changes.emit('runs'); + return this.#ctx.reply(this.#view(parent, scope)); + } + const rec = this.#create({ id, origin: 'manual', jobKey: key, by: user, select: selectorTerms(job), exclude: job.exclude, flags, nodes: jobNodes(key), priority: job.priority }); + this.#ctx.changes.emit('runs'); + return this.#ctx.reply(this.#view(rec, scope)); + } + + checkAdhocRun(scope: Scope, request: AdhocRunRequest): Promise { + return this.#ctx.reply(this.#check(scope, request)); + } + + launchAdhocRun(scope: Scope, request: AdhocRunRequest): Promise { + const check = this.#check(scope, request); + if (!check.adhoc.allowed) throw new ApiError(`Refused: run.adhoc doesn't cover ${check.adhoc.outOfScope.join(', ') || 'this run'}`, 403); + if (check.rewriteHistory.required && !check.rewriteHistory.allowed) throw new ApiError('Refused: requires run.rewrite_history', 403); + if (!check.ok) throw new ApiError(check.problems[0]?.message ?? 'Invalid request', 422); + const rec = this.#create({ + id: this.#newId(), + origin: 'manual', + jobKey: null, + by: this.#ctx.user(), + select: request.select, + exclude: request.exclude, + flags: request.flags, + nodes: check.writeSet.length ? check.writeSet : check.resolvedNodes.filter((n) => snapshotNode(n)?.kind !== 'source'), + priority: 0, + verb: request.verb + }); + this.#ctx.changes.emit('runs'); + return this.#ctx.reply(this.#view(rec, scope)); + } + + // ---- jobs ---- + + listJobs(scope: Scope): Promise { + return this.#ctx.reply(JOB_DEFS.map((j) => this.#jobView(j.key, scope))); + } + + getJob(scope: Scope, key: string): Promise { + return this.#ctx.reply(jobDef(key) ? this.#jobView(key, scope) : null); + } + + // ---- triggers ---- + + listTriggers(scope: Scope): Promise { + return this.#ctx.reply(this.#triggerViews(scope)); + } + + getTrigger(scope: Scope, id: string): Promise { + const def = TRIGGER_DEFS.find((t) => triggerId(t.name) === id); + return this.#ctx.reply(def ? this.#triggerDetail(def, scope) : null); + } + + setTriggerStates(scope: Scope, ids: string[], state: TriggerState, reason: string | null = null): Promise { + this.#need('trigger.toggle'); + const defs = ids.map((id) => { + const def = TRIGGER_DEFS.find((t) => triggerId(t.name) === id); + if (!def) throw new ApiError(`Unknown trigger ${id}`, 404); + return def; + }); + for (const def of defs) { + const rt = this.#runtime(scope, def.name); + if (rt.state === state) continue; + rt.state = state; + rt.history.unshift(this.#stateEvent(state === 'running' ? 'enabled' : 'paused', reason)); + } + this.#ctx.changes.emit('triggers'); + return this.#ctx.reply(this.#triggerViews(scope).filter((t) => ids.includes(t.id))); + } + + resetTriggerToDefault(scope: Scope, id: string): Promise { + this.#need('trigger.toggle'); + const def = TRIGGER_DEFS.find((t) => triggerId(t.name) === id); + if (!def) throw new ApiError(`Unknown trigger ${id}`, 404); + const rt = this.#runtime(scope, def.name); + rt.state = def.defaultState; + rt.history.unshift(this.#stateEvent('reset_to_default', null, def.defaultState)); + this.#ctx.changes.emit('triggers'); + return this.#ctx.reply(this.#triggerViews(scope).find((t) => t.id === id)!); + } + + resetSensorCursor(scope: Scope, id: string, reason: string | null = null): Promise { + this.#need('sensor.reset_cursor'); + const def = TRIGGER_DEFS.find((t) => triggerId(t.name) === id); + if (!def?.sensor) throw new ApiError(`${id} is not a sensor`, 422); + const rt = this.#runtime(scope, def.name); + rt.history.unshift(this.#stateEvent('cursor_reset', reason, rt.cursor)); + rt.cursor = null; + rt.cursorUpdatedAt = Date.now(); + this.#ctx.changes.emit('triggers'); + return this.#ctx.reply(this.#triggerDetail(def, scope)); + } + + // ---- shell counts ---- + + activeRunCount(): number { + return this.#records.filter((r) => !r.run.parentRunId && isActive(r.run.status)).length; + } + + pausedTriggerCount(scope: Scope): number { + return this.#triggerViews(scope).filter((t) => t.state === 'paused').length; + } + + topLevelRuns(scope: Scope): Run[] { + return this.#records + .filter((r) => !r.run.parentRunId) + .map((r) => this.#view(r, scope)) + .sort((a, b) => b.queuedAt.localeCompare(a.queuedAt)); + } + + // ---- synchronous reads for the BUILD mock (mock/build.ts) ---- + + /** Every run that executed `nodeKey`, with that node's result, newest first. */ + nodeHistory(scope: Scope, nodeKey: string): { run: Run; node: RunNode }[] { + const now = Date.now(); + return this.#records + .filter((r) => r.script.nodes.includes(nodeKey)) + .flatMap((r) => { + const node = computeNodes(r, this.#siblings(r), now).find((n) => n.nodeKey === nodeKey); + return node ? [{ run: this.#view(r, scope), node }] : []; + }) + .sort((a, b) => (b.node.startedAt ?? b.run.queuedAt).localeCompare(a.node.startedAt ?? a.run.queuedAt)); + } + + /** Jobs and groups whose selection includes `nodeKey`. */ + jobsCovering(nodeKey: string): { key: string; kind: 'job' | 'group'; triggerIds: string[] }[] { + return JOB_DEFS.filter((j) => (j.kind === 'group' ? jobNodes(j.key) : resolveJobSelection(j).nodes).includes(nodeKey)).map((j) => ({ + key: j.key, + kind: j.kind, + triggerIds: TRIGGER_DEFS.filter((t) => t.jobKey === j.key).map((t) => triggerId(t.name)) + })); + } + + /** Liveness of an active run, for the overview's unresponsive item. */ + lastHeartbeat(runId: string): string | null { + const rec = this.#byId(runId); + return rec ? (liveness(rec, Date.now())?.lastHeartbeatAt ?? null) : null; + } + + // ---- internals: records ---- + + #byId(id: string): RunRecord | undefined { + return this.#records.find((r) => r.run.id === id); + } + + #require(id: string): RunRecord { + const rec = this.#byId(id); + if (!rec) throw new ApiError(`Unknown run ${id}`, 404); + return rec; + } + + #children(rec: RunRecord): RunRecord[] { + return this.#records.filter((r) => r.run.parentRunId === rec.run.id); + } + + #siblings(rec: RunRecord): RunRecord[] { + return this.#records.filter((r) => r.run.rootRunId === rec.run.rootRunId); + } + + #nodesOf(rec: RunRecord, now: number): RunNode[] { + if (rec.script.nodes.length) return computeNodes(rec, this.#siblings(rec), now); + return this.#children(rec).flatMap((c) => this.#nodesOf(c, now)); + } + + #view(rec: RunRecord, scope: Scope): Run { + const nodes = this.#nodesOf(rec, Date.now()); + const run = { ...rec.run, locationId: scope.locationId, target: this.#ctx.target(scope) }; + run.nodeCounts = countNodes(nodes); + run.cost = null; + if (run.endedAt && run.startedAt && this.#ctx.adapter(scope) === 'snowflake') { + const seconds = nodes.reduce((sum, n) => sum + (n.startedAt && n.endedAt ? (Date.parse(n.endedAt) - Date.parse(n.startedAt)) / 1000 : 0), 0); + if (seconds > 0) run.cost = { value: Math.round(seconds * 9) / 10_000, unit: 'credits', source: 'Busy-compute estimate from statement history' }; + } + return run; + } + + /** Brings a record's projected status in line with its facts at `now`. */ + #settle(rec: RunRecord, now: number) { + const { run, script } = rec; + if (!script.nodes.length) { + const kids = this.#children(rec); + if (!kids.length) return; + if (kids.every((k) => isTerminal(k.run.status))) { + if (isTerminal(run.status) && run.endedAt && run.status !== 'success') return; + const nodes = this.#nodesOf(rec, now); + const status = script.cancel ? 'cancelled' : deriveTerminalStatus('complete', nodes); + run.status = status; + run.endedAt = kids.reduce((m, k) => (k.run.endedAt && k.run.endedAt > m ? k.run.endedAt : m), run.startedAt ?? run.queuedAt); + run.statusReason = run.statusReason ?? statusReason(status, nodes); + } else if (kids.some((k) => k.run.startedAt) && run.status !== 'running') { + run.status = 'running'; + run.startedAt ??= kids.find((k) => k.run.startedAt)!.run.startedAt; + } + return; + } + if (run.endedAt && run.status === 'success') { + const nodes = this.#nodesOf(rec, now); + const status = deriveTerminalStatus(script.end, nodes); + run.status = status; + run.statusReason = run.statusReason ?? statusReason(status, nodes); + return; + } + if (run.status === 'running' && script.lastHeartbeatAt != null && now - script.lastHeartbeatAt > UNRESPONSIVE_MS) { + run.status = 'unresponsive'; + run.statusReason = `No signal for ${Math.round((now - script.lastHeartbeatAt) / 1000)}s`; + } + } + + #finish(rec: RunRecord, at: number, status: Run['status'], reason: string | null) { + rec.run.endedAt = new Date(at).toISOString(); + rec.script.end = status === 'cancelled' ? 'cancelled' : status === 'presumed_failed' ? 'presumed_failed' : 'complete'; + const nodes = this.#nodesOf(rec, at); + rec.run.status = status === 'success' ? deriveTerminalStatus(rec.script.end, nodes) : status; + rec.run.statusReason = reason ?? statusReason(rec.run.status, nodes); + if (rec.run.parentRunId) { + const parent = this.#byId(rec.run.parentRunId); + if (parent) this.#settle(parent, at); + } + } + + /** One scheduler pass: liveness, cancels, launches and claims. */ + #advance() { + const now = Date.now(); + const changed = new Set(); + for (const rec of this.#records) { + const { run, script } = rec; + if (isTerminal(run.status) || !script.nodes.length) continue; + const cancel = script.cancel; + if (cancel && !cancel.terminatedAt) { + if (!cancel.sigtermAt && now - Date.parse(cancel.requestedAt) > 1000) { + cancel.sigtermAt = new Date(now).toISOString(); + changed.add(rec); + } else if (cancel.sigtermAt && now - Date.parse(cancel.sigtermAt) > CANCEL_EXIT_MS) { + cancel.exitCode = 143; + cancel.terminatedAt = new Date(now).toISOString(); + this.#finish(rec, now, 'cancelled', `Cancelled by ${cancel.requestedBy.displayName}`); + changed.add(rec); + } + continue; + } + if (run.status === 'launched' && run.launchedAt && now - Date.parse(run.launchedAt) > 2000) { + run.status = 'running'; + run.startedAt = new Date(now).toISOString(); + changed.add(rec); + } else if (run.status === 'running') { + if (script.lastHeartbeatAt != null) { + this.#settle(rec, now); + if (rec.run.status !== 'running') changed.add(rec); + } else if (run.startedAt && now >= Date.parse(run.startedAt) + script.plannedMs) { + this.#finish(rec, Date.parse(run.startedAt) + script.plannedMs, 'success', null); + changed.add(rec); + } + } else if (run.status === 'unresponsive' && script.lastHeartbeatAt != null && now - script.lastHeartbeatAt > PRESUMED_FAILED_MS) { + this.#finish(rec, script.lastHeartbeatAt + PRESUMED_FAILED_MS, 'presumed_failed', 'Lost contact: no heartbeat for 10 minutes, so the run was stopped and marked failed'); + changed.add(rec); + } + } + const waits = this.#queue(); + for (const rec of this.#ordered()) { + if ((waits.get(rec.run.id) ?? []).some((w) => w.kind !== 'priority')) continue; + rec.run.status = 'launched'; + rec.run.launchedAt = new Date(now).toISOString(); + if (!rec.script.nodes.length) { + rec.run.status = 'running'; + rec.run.startedAt = rec.run.launchedAt; + } + changed.add(rec); + break; // claims change the picture; re-evaluate on the next pass + } + for (const rec of this.#records) { + if (!rec.script.nodes.length && isActive(rec.run.status) && rec.run.status !== 'queued') { + const before = rec.run.status; + this.#settle(rec, now); + if (rec.run.status !== before) changed.add(rec); + } + } + for (const rec of changed) this.#emit(rec); + // Live runs append events continuously; the server signals "run X advanced". + for (const rec of this.#records) { + if (rec.run.status === 'running' && !changed.has(rec)) this.#ctx.changes.emit(`run:${rec.run.id}`); + } + } + + #emit(rec: RunRecord) { + this.#ctx.changes.emit('runs'); + this.#ctx.changes.emit(`run:${rec.run.id}`); + if (rec.run.parentRunId) this.#ctx.changes.emit(`run:${rec.run.parentRunId}`); + } + + #ordered(): RunRecord[] { + return this.#records + .filter((r) => r.run.status === 'queued') + .sort((a, b) => b.run.priority - a.run.priority || a.run.queuedAt.localeCompare(b.run.queuedAt)); + } + + /** Why each queued run waits, evaluated like the claim transaction would. */ + #queue(): Map { + const holders = this.#records.filter((r) => HOLDING.has(r.run.status)); + const out = new Map(); + const ahead: RunRecord[] = []; + for (const rec of this.#ordered()) { + const waits: QueueWait[] = []; + const mine = new Set(rec.script.nodes); + for (const h of holders) { + const overlap = h.script.nodes.filter((n) => mine.has(n)); + if (overlap.length) + waits.push({ kind: 'write_claim', runId: h.run.id, jobKey: h.run.jobKey, nodeKey: overlap[0], otherNodes: overlap.length - 1 }); + } + const fullGroups: string[] = []; + for (const group of rec.script.concurrencyGroups) { + const inGroup = holders.filter((h) => h.script.concurrencyGroups.includes(group)).map((h) => h.run.id); + const limit = CONCURRENCY_LIMITS[group] ?? 1; + if (inGroup.length >= limit) { + waits.push({ kind: 'concurrency_group', group, limit, holders: inGroup }); + fullGroups.push(group); + } + } + if (fullGroups.length) { + const before = ahead.filter((a) => a.script.concurrencyGroups.some((g) => fullGroups.includes(g))).map((a) => a.run.id); + if (before.length) waits.push({ kind: 'priority', aheadRunIds: before }); + } + if (rec.run.parentRunId) { + const parent = this.#byId(rec.run.parentRunId); + const group = jobDef(parent?.run.jobKey ?? null); + if (parent && group) { + const members = this.#groupMembers(group.key); + const me = members.find((m) => m.jobKey === rec.run.jobKey); + for (const up of me?.upstream ?? []) { + const sibling = this.#children(parent).find((c) => c.run.jobKey === up.jobKey); + if (sibling && isActive(sibling.run.status)) waits.push({ kind: 'upstream_job', runId: sibling.run.id, jobKey: up.jobKey }); + } + } + if (parent && parent.run.status === 'queued') waits.push({ kind: 'group_parent', runId: parent.run.id, jobKey: parent.run.jobKey ?? '' }); + } + out.set(rec.run.id, waits); + ahead.push(rec); + } + return out; + } + + #overlap(rec: RunRecord, queued: RunRecord[]): OverlapOutcome | null { + const job = jobDef(rec.run.jobKey); + if (!job || rec.run.parentRunId) return null; + const covered = this.#fx.ticks.filter((t) => t.coveredByRunId === rec.run.id).length; + if (job.overlap === 'coalesce') + return { + policy: 'coalesce', + coveredTicks: covered, + detail: covered ? `${covered} later request${covered === 1 ? '' : 's'} merged into this run` : 'New requests merge into this run while it waits' + }; + if (job.overlap === 'queue') { + const before = queued.filter((q) => q.run.jobKey === job.key && q.run.queuedAt < rec.run.queuedAt); + return { policy: 'queue', coveredTicks: 0, detail: before.length ? `Goes after ${before.map((b) => b.run.id.replace(/^run-/, '')).join(', ')}` : 'Next in line for this job' }; + } + return { policy: 'allow', coveredTicks: 0, detail: 'Waits only for runs updating the same data' }; + } + + #newId(): string { + let id: string; + do id = `run-${(0xa1000 + ++this.#seq * 0x9e37).toString(16).slice(-5)}`; + while (this.#byId(id)); + return id; + } + + #create(spec: { + id: string; + origin: Run['origin']; + jobKey: string | null; + by: Principal; + select: string[]; + exclude: string[]; + flags: Record; + nodes: string[]; + priority: number; + parent?: string; + rerunOf?: string; + verb?: AdhocRunRequest['verb']; + }): RunRecord { + const now = new Date().toISOString(); + const job = jobDef(spec.jobKey); + const rec: RunRecord = { + run: { + id: spec.id, + kind: spec.jobKey === null ? 'adhoc' : job?.kind === 'group' ? 'group' : 'job', + origin: spec.origin, + status: 'queued', + statusReason: null, + jobKey: spec.jobKey, + parentRunId: spec.parent ?? null, + rootRunId: spec.parent ?? spec.id, + childRunCount: 0, + triggerId: null, + triggerName: null, + tickId: null, + tickScheduledFor: null, + runKey: null, + rerunOfRunId: spec.rerunOf ?? null, + requestedBy: spec.by, + priority: spec.priority, + rewriteHistory: Object.keys(spec.flags).some((f) => flagSpec(spec.verb ?? 'build', f)?.rewritesHistory), + locationId: '', + target: '', + snapshotId: 'snap-7d2e41a', + gitRevision: '7d2e41a', + queuedAt: now, + launchedAt: null, + startedAt: null, + endedAt: null, + nodeCounts: { success: 0, warn: 0, failed: 0, skipped: 0, running: 0, pending: 0 }, + cost: null + }, + script: { + verb: spec.verb ?? 'build', + select: spec.select, + exclude: spec.exclude, + flags: spec.flags, + nodes: sortTopologically(spec.nodes), + fail: [], + warn: [], + errors: {}, + critical: job?.criticalNodes ?? [], + plannedMs: Math.max(12_000, spec.nodes.length * 2500), + end: 'complete', + cancel: null, + lastHeartbeatAt: null, + imageId: IMAGE_ID, + snapshotGeneration: SNAPSHOT_GENERATION, + concurrencyGroups: job?.concurrencyGroups ?? [] + } + }; + this.#records.unshift(rec); + return rec; + } + + #need(capability: Capability) { + if (!this.#ctx.can(capability)) throw new ApiError(`Missing permission: ${capability}`, 403); + } + + #check(scope: Scope, request: AdhocRunRequest): AdhocRunCheck { + const problems: AdhocRunCheck['problems'] = []; + const resolution = resolveSelection(request.select, request.exclude); + for (const term of resolution.unknown) problems.push({ code: 'unknown_selector', message: `No node or selector matches ${term}` }); + if (!resolution.nodes.length) problems.push({ code: 'empty_selection', message: 'The selection resolves to no nodes' }); + const writable = resolution.nodes.filter((n) => snapshotNode(n)?.kind !== 'source'); + const writeSet = + request.verb === 'build' + ? writable.filter((n) => !(request.flags['--no-load'] && snapshotNode(n)?.kind === 'loader')) + : request.verb === 'seed' + ? writable.filter((n) => snapshotNode(n)?.kind === 'seed') + : request.verb === 'load' + ? writable.filter((n) => snapshotNode(n)?.kind === 'loader') + : []; + const reasons: string[] = []; + for (const [flag, value] of Object.entries(request.flags)) { + const spec = flagSpec(request.verb, flag); + if (!spec) { + problems.push({ code: 'unknown_flag', message: `sqb ${request.verb} has no flag ${flag}` }); + continue; + } + if (spec.type !== 'bool' && value === true) problems.push({ code: 'missing_value', message: `${flag} needs a value` }); + if (spec.type === 'int' && value !== true && !/^\d+$/.test(value)) problems.push({ code: 'invalid_value', message: `${flag} must be a whole number` }); + if (spec.type === 'timestamp' && value !== true && Number.isNaN(Date.parse(value))) problems.push({ code: 'invalid_value', message: `${flag} must be a timestamp, e.g. 2026-10-01T00:00:00Z` }); + if (spec.rewritesHistory) reasons.push(flag); + if (flag.includes('cursor') && !writeSet.some((n) => snapshotNode(n)?.cursor)) problems.push({ code: 'no_cursor', message: `${flag}: no selected node has a cursor` }); + } + const scopeTerms = ADHOC_SCOPE[this.#ctx.role()]; + const granted = scopeTerms === null ? null : new Set(writeSetOf(scopeTerms.length ? scopeTerms : ['__none__'], [])); + const outOfScope = !this.#ctx.can('run.adhoc') ? writeSet : granted === null ? [] : writeSet.filter((n) => !granted.has(n)); + const allowed = this.#ctx.can('run.adhoc') && outOfScope.length === 0; + const holders = this.#records.filter((r) => HOLDING.has(r.run.status)); + const claimConflicts = writeSet.flatMap((n) => holders.filter((h) => h.script.nodes.includes(n)).map((h) => ({ runId: h.run.id, nodeKey: n }))); + const rewriteAllowed = this.#ctx.can('run.rewrite_history'); + void scope; + return { + resolvedNodes: resolution.nodes, + writeSet, + adhoc: { required: 'run.adhoc', allowed, outOfScope }, + rewriteHistory: { required: reasons.length > 0, reasons, allowed: rewriteAllowed }, + claimConflicts, + problems, + ok: problems.length === 0 && allowed && (!reasons.length || rewriteAllowed) + }; + } + + // ---- internals: jobs ---- + + #groupMembers(key: string): JobGroupMember[] { + const job = jobDef(key); + if (!job || job.kind !== 'group') return []; + const nodesOf = new Map(job.members.map((m) => [m, new Set(jobNodes(m))])); + const upstream = new Map(); + for (const b of job.members) { + const ups: JobGroupMember['upstream'] = []; + const needs = new Set([...nodesOf.get(b)!].flatMap((n) => [...ancestors(n)])); + for (const a of job.members) { + if (a === b) continue; + if ([...nodesOf.get(a)!].some((n) => needs.has(n))) ups.push({ jobKey: a, source: 'lineage' }); + } + for (const a of job.after[b] ?? []) if (!ups.some((u) => u.jobKey === a)) ups.push({ jobKey: a, source: 'after' }); + upstream.set(b, ups); + } + const stage = new Map(); + const stageOf = (m: string): number => { + if (stage.has(m)) return stage.get(m)!; + const s = Math.max(-1, ...(upstream.get(m) ?? []).map((u) => stageOf(u.jobKey))) + 1; + stage.set(m, s); + return s; + }; + return job.members.map((m) => ({ jobKey: m, upstream: upstream.get(m) ?? [], stage: stageOf(m) })); + } + + #jobView(key: string, scope: Scope): JobDetail { + const job = jobDef(key)!; + const latest = this.#records + .filter((r) => r.run.jobKey === key && !r.run.parentRunId) + .sort((a, b) => b.run.queuedAt.localeCompare(a.run.queuedAt))[0]; + const view = latest ? this.#view(latest, scope) : null; + const holders = this.#records.filter((r) => HOLDING.has(r.run.status)); + const resolved = job.kind === 'group' ? jobNodes(key) : resolveJobSelection(job).nodes; + const { after, ...rest } = job; + void after; + return structuredClone({ + ...rest, + nodeCount: resolved.filter((n) => snapshotNode(n)?.kind !== 'source').length, + resolvedNodes: resolved, + lastRun: view ? { id: view.id, status: view.status, startedAt: view.startedAt, endedAt: view.endedAt } : null, + triggerIds: TRIGGER_DEFS.filter((t) => t.jobKey === key).map((t) => triggerId(t.name)), + concurrency: concurrencyGroupsOf(job).map((g) => ({ ...g, holders: holders.filter((h) => h.script.concurrencyGroups.includes(g.key)).map((h) => h.run.id) })), + memberOf: JOB_DEFS.filter((g) => g.members.includes(key)).map((g) => g.key), + cursorType: resolved.map((n) => snapshotNode(n)?.cursor).find((c) => c) ?? null, + groupMembers: this.#groupMembers(key), + declaration: { ...job.declaration, code: this.#fillCrons(job.declaration.code) } + }); + } + + #typicalMs(jobKey: string): number { + const done = this.#records.filter((r) => r.run.jobKey === jobKey && !r.run.parentRunId && r.run.startedAt && r.run.endedAt).slice(0, 10); + if (!done.length) return 5 * MIN; + return done.reduce((s, r) => s + Date.parse(r.run.endedAt!) - Date.parse(r.run.startedAt!), 0) / done.length; + } + + #fillCrons(code: string): string { + return code.replace(/\{cron:([a-z_]+)\}/g, (_, name: string) => this.#fx.crons[name] ?? '* * * * *'); + } + + // ---- internals: triggers ---- + + #runtime(scope: Scope, name: string): TriggerRuntime { + const key = scope.locationId; + const dev = this.#ctx.target(scope) === 'dev'; + let map = this.#triggers.get(key); + if (!map) { + map = new Map(); + for (const t of TRIGGER_DEFS) { + const cursor = this.#fx.cursors[t.name]; + // `sqb dev` starts with every trigger paused. + map.set(t.name, { + state: dev ? 'paused' : t.state, + history: structuredClone(dev ? [] : (this.#fx.stateHistory[t.name] ?? [])), + cursor: cursor?.cursor ?? null, + cursorUpdatedAt: cursor?.updatedAt ?? null + }); + } + this.#triggers.set(key, map); + } + return map.get(name)!; + } + + #stateEvent(kind: TriggerStateEvent['kind'], reason: string | null, value: string | null = null): TriggerStateEvent { + return { id: `tse-${Date.now().toString(36)}-${++this.#seq}`, at: new Date().toISOString(), kind, actor: this.#ctx.user(), reason, value }; + } + + #every(name: string): number { + return (triggerDef(name)?.schedule?.everyMin ?? 60) * MIN; + } + + #upcoming(name: string, until: number, limit = 400): number[] { + const def = triggerDef(name)!; + const now = Date.now(); + if (def.kind === 'calendar_schedule') return this.#fx.calendar.times.filter((t) => t > now && t <= until).slice(0, limit); + if (!def.schedule) return []; + const every = this.#every(name); + const last = this.#fx.lastTickAt[name]; + const out: number[] = []; + let t = last + every; + if (!def.schedule.overdue) while (t <= now) t += every; + for (; t <= until && out.length < limit; t += every) out.push(t); + return out; + } + + #ticksOf(name: string): TickRecord[] { + return this.#fx.ticks.filter((t) => t.triggerName === name).sort((a, b) => b.scheduledFor.localeCompare(a.scheduledFor)); + } + + #triggerViews(scope: Scope): Trigger[] { + return TRIGGER_DEFS.map((def) => this.#triggerView(def, scope)); + } + + #triggerView(def: TriggerDef, scope: Scope): Trigger { + const rt = this.#runtime(scope, def.name); + const id = triggerId(def.name); + const lastTick = this.#ticksOf(def.name)[0] ?? null; + const lastRunRec = this.#records + .filter((r) => r.run.triggerId === id && !r.run.parentRunId) + .sort((a, b) => b.run.queuedAt.localeCompare(a.run.queuedAt))[0]; + const lastRun = lastRunRec ? this.#view(lastRunRec, scope) : null; + const isSchedule = def.kind === 'cron_schedule' || def.kind === 'calendar_schedule'; + const next = isSchedule && rt.state === 'running' ? (this.#upcoming(def.name, Date.now() + 40 * 1440 * MIN, 1)[0] ?? null) : null; + const calendarLow = def.kind === 'calendar_schedule' && this.#fx.calendar.storedUntil - Date.now() < 7 * 1440 * MIN; + const overdueMin = next != null && next < Date.now() ? Math.floor((Date.now() - next) / MIN) : null; + return { + id, + name: def.name, + type: isSchedule ? 'schedule' : 'sensor', + kind: def.kind, + description: def.description, + jobKey: def.jobKey, + cadence: def.cadence, + cron: def.kind === 'cron_schedule' ? (this.#fx.crons[def.name] ?? null) : null, + timezone: isSchedule ? 'UTC' : null, + defaultState: def.defaultState, + state: rt.state, + nextDueAt: next ? new Date(next).toISOString() : null, + lastTick: lastTick ? plainTick(lastTick) : null, + lastRun: lastRun ? { id: lastRun.id, status: lastRun.status, startedAt: lastRun.startedAt, endedAt: lastRun.endedAt } : null, + sourceFile: def.sourceFile, + definitionChanged: !!def.definitionChanged, + warning: calendarLow + ? `The calendar only has dates until ${new Date(this.#fx.calendar.storedUntil).toISOString().slice(0, 10)}, less than 7 days ahead` + : overdueMin != null + ? `Should have started a run at ${new Date(next!).toISOString().slice(11, 16)} UTC; ${overdueMin} min late` + : def.definitionChanged + ? 'Its code changed since it last checked' + : null + }; + } + + #triggerDetail(def: TriggerDef, scope: Scope): TriggerDetail { + const base = this.#triggerView(def, scope); + const rt = this.#runtime(scope, def.name); + const isSchedule = base.type === 'schedule'; + const evaluations = structuredClone(this.#fx.evaluations[def.name] ?? []); + const sensorFp = evaluations[0]?.definitionFingerprint ?? null; + const currentFp = def.definitionChanged ? `${sensorFp ?? 'fp:0'}-2` : (sensorFp ?? 'fp:0'); + const runRequests = (this.#fx.runRequests[def.name] ?? []).map((r) => { + const rec = this.#byId(r.runId); + return { ...r, runStatus: rec ? this.#view(rec, scope).status : r.runStatus }; + }); + const declaration = def.code + ? { sourceFile: def.sourceFile, line: def.line, code: this.#fillCrons(def.code) } + : (() => { + const job = jobDef(def.jobKey)!; + return { sourceFile: job.declaration.sourceFile, line: job.declaration.line, code: this.#fillCrons(job.declaration.code) }; + })(); + return { + ...base, + declaration, + targets: def.targets, + catchUp: def.catchUp, + upcoming: isSchedule && rt.state === 'running' ? this.#upcoming(def.name, Date.now() + 40 * 1440 * MIN, 8).map((t) => new Date(t).toISOString()) : [], + calendar: + def.kind === 'calendar_schedule' + ? { + horizon: '30d', + storedUntil: new Date(this.#fx.calendar.storedUntil).toISOString(), + lastRefreshedAt: new Date(this.#fx.calendar.lastRefreshedAt).toISOString(), + lowHorizonWarningBelow: '7d', + low: this.#fx.calendar.storedUntil - Date.now() < 7 * 1440 * MIN + } + : null, + sensor: def.sensor + ? { + ...def.sensor, + cursor: rt.cursor, + cursorUpdatedAt: rt.cursorUpdatedAt ? new Date(rt.cursorUpdatedAt).toISOString() : null, + currentFingerprint: currentFp, + lastEvaluatedFingerprint: sensorFp + } + : null, + ticks: this.#ticksOf(def.name).map(plainTick), + evaluations, + runRequests, + stateHistory: structuredClone(rt.history) + }; + } +} diff --git a/ui/src/lib/api/mock/operate.ts b/ui/src/lib/api/mock/operate.ts new file mode 100644 index 0000000000..35237ec9b7 --- /dev/null +++ b/ui/src/lib/api/mock/operate.ts @@ -0,0 +1,1141 @@ +// Neutral example data for the OPERATE area: a fictional shop's jobs, job +// groups, schedules, sensors and their run history. Everything is relative to +// `now`, and schedule crons are generated so their due times line up with the +// recorded ticks. + +import type { + CancelProgress, + CatchUpPolicy, + ConcurrencyGroup, + JobDetail, + LaunchVerb, + Principal, + Run, + RunOrigin, + SensorEvaluation, + SensorRunRequest, + Tick, + TriggerDetail, + TriggerKind, + TriggerState, + TriggerStateEvent +} from '../types'; +import { CI_ACCOUNT, DEMO_USER, OPS_USER, SCHEDULER, SENSOR_DAEMON } from './principals'; +import { resolveSelection, writeSetOf } from './operate-graph'; +import type { RunEnd, RunRecord, RunScript } from './run-detail'; + +const MIN = 60_000; +export const IMAGE_ID = 'sha256:4be1c0d97e2a51f3c6a8b0e9d4f7a2c1e5b8d3f6a9c2e7b4d1f8a5c3e6b9d2f4'; +export const PREVIOUS_IMAGE_ID = 'sha256:9a3f6c1e8d2b5a7f4c9e1d6b3a8f5c2e7d4b1a9f6c3e8d5b2a7f4c1e9d6b3a8f'; +export const SNAPSHOT_GENERATION = 42; + +// ---- jobs and groups ---- + +type JobDef = Omit & { + after: Record; +}; + +export const CONCURRENCY_LIMITS: Record = { warehouse_heavy: 1, 'target:prod': 4 }; + +const decl = (sourceFile: string, line: number, code: string) => ({ sourceFile, line, code }); + +export const JOB_DEFS: JobDef[] = [ + { + key: 'load_sources', + kind: 'job', + description: 'Ingest partner order files into raw__orders', + select: 'tag:ingest', + exclude: [], + members: [], + overlap: 'queue', + onFailure: 'skip_dependents', + concurrencyGroups: [], + priority: 5, + sourceFile: 'orchestration/jobs.py', + settings: {}, + targets: null, + owner: 'ingest', + criticalNodes: [], + after: {}, + declaration: decl( + 'orchestration/jobs.py', + 14, + `load_sources = job( + "load_sources", + description="Ingest partner order files into raw__orders", + select="tag:ingest", + overlap="queue", + priority=5, + owner="ingest", +)` + ) + }, + { + key: 'core_build', + kind: 'job', + description: 'Staging and marts models', + select: 'group:staging group:marts', + exclude: [], + members: [], + overlap: 'coalesce', + onFailure: 'skip_dependents', + concurrencyGroups: ['warehouse_heavy'], + priority: 0, + sourceFile: 'orchestration/jobs.py', + settings: { '--concurrency': '8' }, + targets: null, + owner: 'data-platform', + criticalNodes: ['fact_orders'], + after: {}, + declaration: decl( + 'orchestration/jobs.py', + 24, + `core_build = job( + "core_build", + description="Staging and marts models", + select=["group:staging", "group:marts"], + overlap="coalesce", + on_failure="skip_dependents", + concurrency_groups=[warehouse_heavy], + settings={"--concurrency": 8}, + owner="data-platform", + schedule=schedule(cron="{cron:core_build}", timezone="UTC"), +)` + ) + }, + { + key: 'marts_only', + kind: 'job', + description: 'Marts models only, reading the current staging layer', + select: 'group:marts', + exclude: [], + members: [], + overlap: 'coalesce', + onFailure: 'skip_dependents', + concurrencyGroups: [], + priority: 0, + sourceFile: 'orchestration/jobs.py', + settings: {}, + targets: ['prod', 'staging'], + owner: 'analytics', + criticalNodes: [], + after: {}, + declaration: decl( + 'orchestration/jobs.py', + 37, + `marts_only = job( + "marts_only", + description="Marts models only, reading the current staging layer", + select="group:marts", + targets=["prod", "staging"], + owner="analytics", +)` + ) + }, + { + key: 'activity_microbatch', + kind: 'job', + description: 'Incremental activity models, four microbatches at a time', + select: 'group:activity', + exclude: [], + members: [], + overlap: 'coalesce', + onFailure: 'skip_dependents', + concurrencyGroups: [], + priority: 0, + sourceFile: 'orchestration/activity.py', + settings: { '--max-microbatches': '4' }, + targets: null, + owner: 'analytics', + criticalNodes: [], + after: {}, + declaration: decl( + 'orchestration/activity.py', + 8, + `activity_microbatch = job( + "activity_microbatch", + description="Incremental activity models, four microbatches at a time", + select="group:activity", + settings={"--max-microbatches": 4}, + owner="analytics", + schedule=schedule(cron="{cron:activity_microbatch}", timezone="UTC", + catch_up=catch_up.all(max=3)), +)` + ) + }, + { + key: 'revenue_refresh', + kind: 'job', + description: 'Daily revenue, its report and the CSV export', + select: 'daily_revenue revenue_report export_revenue_csv', + exclude: [], + members: [], + overlap: 'coalesce', + onFailure: 'skip_dependents', + concurrencyGroups: [], + priority: 0, + sourceFile: 'orchestration/finance.py', + settings: { '--fail-fast': true }, + targets: ['prod'], + owner: 'finance-data', + criticalNodes: [], + after: {}, + declaration: decl( + 'orchestration/finance.py', + 11, + `revenue_refresh = job( + "revenue_refresh", + description="Daily revenue, its report and the CSV export", + select=["daily_revenue", "revenue_report", "export_revenue_csv"], + settings={"--fail-fast": True}, + targets=["prod"], + owner="finance-data", + schedule=schedule(cron="{cron:revenue_refresh}", timezone="UTC"), +)` + ) + }, + { + key: 'customer_segments', + kind: 'job', + description: 'Customer status segments; hourly and in the close-of-business group', + select: 'customer_status', + exclude: [], + members: [], + overlap: 'allow', + onFailure: 'skip_dependents', + concurrencyGroups: [], + priority: 0, + sourceFile: 'orchestration/finance.py', + settings: {}, + targets: null, + owner: 'analytics', + criticalNodes: [], + after: {}, + declaration: decl( + 'orchestration/finance.py', + 23, + `customer_segments = job( + "customer_segments", + description="Customer status segments; hourly and in the close-of-business group", + select="customer_status", + overlap="allow", + owner="analytics", +)` + ) + }, + { + key: 'reporting_exports', + kind: 'job', + description: 'Reporting tables and the revenue export', + select: 'group:reporting group:exports', + exclude: [], + members: [], + overlap: 'queue', + onFailure: 'skip_dependents', + concurrencyGroups: ['warehouse_heavy'], + priority: 10, + sourceFile: 'orchestration/reporting.py', + settings: {}, + targets: ['prod'], + owner: 'analytics', + criticalNodes: [], + after: {}, + declaration: decl( + 'orchestration/reporting.py', + 6, + `reporting_exports = job( + "reporting_exports", + description="Reporting tables and the revenue export", + select=["group:reporting", "group:exports"], + overlap="queue", + concurrency_groups=[warehouse_heavy], + priority=10, + targets=["prod"], +)` + ) + }, + { + key: 'nightly_all', + kind: 'group', + description: 'Sources, the core build, activity models, then reporting', + select: null, + exclude: [], + members: ['load_sources', 'core_build', 'activity_microbatch', 'reporting_exports'], + overlap: 'coalesce', + onFailure: 'skip_dependents', + concurrencyGroups: [], + priority: 0, + sourceFile: 'orchestration/groups.py', + settings: {}, + targets: ['prod'], + owner: 'data-platform', + criticalNodes: ['fact_orders'], + after: {}, + declaration: decl( + 'orchestration/groups.py', + 5, + `nightly_all = job_group( + "nightly_all", + description="Sources, the core build, activity models, then reporting", + jobs=[load_sources, core_build, activity_microbatch, reporting_exports], + critical=["fact_orders"], + targets=["prod"], + schedule=schedule(cron="{cron:nightly_all}", timezone="UTC"), +)` + ) + }, + { + key: 'close_of_business', + kind: 'group', + description: 'Revenue first, then activity and customer segments', + select: null, + exclude: [], + members: ['revenue_refresh', 'activity_microbatch', 'customer_segments'], + overlap: 'coalesce', + onFailure: 'stop_group', + concurrencyGroups: ['warehouse_heavy'], + priority: 20, + sourceFile: 'orchestration/groups.py', + settings: {}, + targets: ['prod'], + owner: 'finance-data', + criticalNodes: ['daily_revenue'], + after: { customer_segments: ['revenue_refresh'] }, + declaration: decl( + 'orchestration/groups.py', + 16, + `close_of_business = job_group( + "close_of_business", + description="Revenue first, then activity and customer segments", + jobs=[revenue_refresh, activity_microbatch, customer_segments], + # Segments read closed revenue through an external lookup lineage can't see. + after={customer_segments: [revenue_refresh]}, + on_failure="stop_group", + critical=["daily_revenue"], + concurrency_groups=[warehouse_heavy], + priority=20, + targets=["prod"], +)` + ) + } +]; + +export function jobDef(key: string | null): JobDef | undefined { + return key ? JOB_DEFS.find((j) => j.key === key) : undefined; +} + +export function selectorTerms(job: JobDef): string[] { + return job.select ? job.select.split(/\s+/).filter(Boolean) : []; +} + +/** Write set of a job; for groups, the union of its members. */ +export function jobNodes(key: string): string[] { + const job = jobDef(key); + if (!job) return []; + if (job.kind === 'group') return [...new Set(job.members.flatMap(jobNodes))]; + return writeSetOf(selectorTerms(job), job.exclude); +} + +export function concurrencyGroupsOf(job: JobDef | undefined): ConcurrencyGroup[] { + return (job?.concurrencyGroups ?? []).map((key) => ({ key, limit: CONCURRENCY_LIMITS[key] ?? 1, holders: [] })); +} + +// ---- triggers ---- + +type ScheduleSpec = { + everyMin: number; + lastTickAgoMin: number; + historyDays: number; + /** Nothing has evaluated the due time after the last tick, so the schedule is overdue. */ + overdue?: boolean; +}; + +export type TriggerDef = { + name: string; + kind: TriggerKind; + description: string; + jobKey: string | null; + cadence: string; + defaultState: TriggerState; + state: TriggerState; + sourceFile: string; + line: number; + code: string; + targets: string[] | null; + catchUp: CatchUpPolicy | null; + schedule?: ScheduleSpec; + sensor?: { + condition: string; + nodeConditions: string[]; + minimumIntervalSeconds: number; + timeoutSeconds: number | null; + }; + definitionChanged?: boolean; +}; + +export const triggerId = (name: string) => `trg-${name.replace(/_/g, '-')}`; + +export const TRIGGER_DEFS: TriggerDef[] = [ + { + name: 'core_build', + kind: 'cron_schedule', + description: 'Core build every four hours', + jobKey: 'core_build', + cadence: 'Every 4 hours', + defaultState: 'running', + state: 'running', + sourceFile: 'orchestration/jobs.py', + line: 33, + code: '', + targets: null, + catchUp: { mode: 'latest', max: null }, + schedule: { everyMin: 240, lastTickAgoMin: 25, historyDays: 8 } + }, + { + name: 'nightly_all', + kind: 'cron_schedule', + description: 'The nightly group run', + jobKey: 'nightly_all', + cadence: 'Daily', + defaultState: 'running', + state: 'running', + sourceFile: 'orchestration/groups.py', + line: 12, + code: '', + targets: ['prod'], + catchUp: { mode: 'latest', max: null }, + schedule: { everyMin: 1440, lastTickAgoMin: 9 * 60 + 40, historyDays: 8 } + }, + { + name: 'activity_microbatch', + kind: 'cron_schedule', + description: 'Activity microbatches every 15 minutes', + jobKey: 'activity_microbatch', + cadence: 'Every 15 minutes', + defaultState: 'running', + state: 'running', + sourceFile: 'orchestration/activity.py', + line: 14, + code: '', + targets: null, + catchUp: { mode: 'all', max: 3 }, + schedule: { everyMin: 15, lastTickAgoMin: 4, historyDays: 2 } + }, + { + name: 'revenue_refresh', + kind: 'cron_schedule', + description: 'Daily revenue refresh', + jobKey: 'revenue_refresh', + cadence: 'Daily', + defaultState: 'running', + state: 'paused', + sourceFile: 'orchestration/finance.py', + line: 18, + code: '', + targets: ['prod'], + catchUp: { mode: 'latest', max: null }, + schedule: { everyMin: 1440, lastTickAgoMin: 27 * 60, historyDays: 8 } + }, + { + name: 'segments_hourly', + kind: 'cron_schedule', + description: 'Customer segments every hour', + jobKey: 'customer_segments', + cadence: 'Hourly', + defaultState: 'running', + state: 'running', + sourceFile: 'orchestration/schedules.py', + line: 13, + code: `segments_hourly = schedule( + "segments_hourly", + description="Customer segments every hour", + job=customer_segments, + cron="{cron:segments_hourly}", + timezone="UTC", +)`, + targets: null, + catchUp: { mode: 'latest', max: null }, + schedule: { everyMin: 60, lastTickAgoMin: 72, historyDays: 2, overdue: true } + }, + { + name: 'marts_hourly', + kind: 'cron_schedule', + description: 'Hourly marts refresh, off unless enabled', + jobKey: 'marts_only', + cadence: 'Hourly', + defaultState: 'paused', + state: 'paused', + sourceFile: 'orchestration/schedules.py', + line: 4, + code: `marts_hourly = schedule( + "marts_hourly", + description="Hourly marts refresh, off unless enabled", + job=marts_only, + cron="{cron:marts_hourly}", + timezone="UTC", + default_status="stopped", +)`, + targets: null, + catchUp: { mode: 'latest', max: null }, + schedule: { everyMin: 60, lastTickAgoMin: 17, historyDays: 0 } + }, + { + name: 'business_days', + kind: 'calendar_schedule', + description: 'One hour after the office closes on business days, skipping public holidays', + jobKey: 'close_of_business', + cadence: 'Business days, computed 30 days ahead', + defaultState: 'running', + state: 'running', + sourceFile: 'orchestration/calendars.py', + line: 9, + code: `@calendar_schedule(job=close_of_business, horizon="30d", timezone="UTC", + description="One hour after the office closes on business days, " + "skipping public holidays") +def business_days(window, holidays): + return [day.close_at + timedelta(hours=1) + for day in holidays.business_days(window.start, window.end)]`, + targets: ['prod'], + catchUp: { mode: 'latest', max: null }, + schedule: { everyMin: 1440, lastTickAgoMin: 15 * 60 + 5, historyDays: 8 } + }, + { + name: 'on_payments_landed', + kind: 'node_sensor', + description: 'Refresh the marts when raw__payments has new data', + jobKey: 'marts_only', + cadence: 'When raw__payments has new data', + defaultState: 'running', + state: 'running', + sourceFile: 'orchestration/sensors.py', + line: 6, + code: `on_payments_landed = sensor( + "on_payments_landed", + description="Refresh the marts when raw__payments has new data", + when=source_has_new_data("raw__payments"), + run=marts_only, + minimum_interval="1m", +)`, + targets: null, + catchUp: null, + sensor: { condition: 'source_has_new_data("raw__payments")', nodeConditions: [], minimumIntervalSeconds: 60, timeoutSeconds: null } + }, + { + name: 'after_core_build', + kind: 'job_sensor', + description: 'Reporting after a successful core build that rebuilt daily_revenue', + jobKey: 'reporting_exports', + cadence: 'When core_build succeeds and daily_revenue executed', + defaultState: 'running', + state: 'running', + sourceFile: 'orchestration/sensors.py', + line: 15, + code: `after_core_build = sensor( + "after_core_build", + description="Reporting after a successful core build that rebuilt daily_revenue", + when=job_succeeded(core_build, nodes=all_executed_successfully(["daily_revenue"])), + run=reporting_exports, +)`, + targets: ['prod'], + catchUp: null, + sensor: { + condition: 'job_succeeded(core_build)', + nodeConditions: ['daily_revenue executed successfully in that run'], + minimumIntervalSeconds: 0, + timeoutSeconds: null + } + }, + { + name: 'on_fact_orders_rebuilt', + kind: 'node_sensor', + description: 'Activity models whenever fact_orders is rebuilt, in any run', + jobKey: 'activity_microbatch', + cadence: 'When fact_orders is rebuilt successfully (any run)', + defaultState: 'running', + state: 'running', + sourceFile: 'orchestration/sensors.py', + line: 23, + code: `on_fact_orders_rebuilt = sensor( + "on_fact_orders_rebuilt", + description="Activity models whenever fact_orders is rebuilt, in any run", + when=node_succeeded("fact_orders"), + run=activity_microbatch, +)`, + targets: null, + catchUp: null, + sensor: { condition: 'node_succeeded("fact_orders")', nodeConditions: [], minimumIntervalSeconds: 0, timeoutSeconds: null } + }, + { + name: 'platform_health_webhook', + kind: 'platform_sensor', + description: 'Post platform events to the team chat room', + jobKey: null, + cadence: 'On tick_overdue, scheduler_no_leader, location_broken, launch_failed', + defaultState: 'running', + state: 'running', + sourceFile: 'orchestration/health.py', + line: 4, + code: `@sensor(when=platform_event(kinds=[K.TICK_OVERDUE, K.SCHEDULER_NO_LEADER, + K.LOCATION_BROKEN, K.LAUNCH_FAILED]), + description="Post platform events to the team chat room") +def platform_health_webhook(ctx, chat_webhook): + for event in ctx.events: + chat_webhook.post(event.summary())`, + targets: null, + catchUp: null, + sensor: { + condition: 'platform_event(kinds=[tick_overdue, scheduler_no_leader, location_broken, launch_failed])', + nodeConditions: [], + minimumIntervalSeconds: 30, + timeoutSeconds: 30 + } + }, + { + name: 'partner_feed_poll', + kind: 'custom_sensor', + description: 'Load each new partner order file once', + jobKey: 'load_sources', + cadence: 'Every 5 minutes (minimum interval)', + defaultState: 'running', + state: 'running', + sourceFile: 'orchestration/sensors.py', + line: 31, + code: `@sensor(minimum_interval="5m", timeout="60s", + description="Load each new partner order file once") +def partner_feed_poll(ctx, orders_api): + files = orders_api.list_files(modified_after=ctx.cursor) + if not files: + return SkipReason("No new files") + for f in files: + yield RunRequest(job=load_sources, run_key=f.name) + ctx.cursor = files[-1].modified_at`, + targets: null, + catchUp: null, + definitionChanged: true, + sensor: { condition: 'partner_feed_poll(ctx, orders_api)', nodeConditions: [], minimumIntervalSeconds: 300, timeoutSeconds: 60 } + } +]; + +export function triggerDef(name: string | null): TriggerDef | undefined { + return name ? TRIGGER_DEFS.find((t) => t.name === name) : undefined; +} + +/** A cron expression whose due times include `lastTick` and repeat every `everyMin`. */ +export function cronFor(everyMin: number, lastTick: number): string { + const d = new Date(lastTick); + const m = d.getUTCMinutes(); + const h = d.getUTCHours(); + if (everyMin < 60) return `${m % everyMin}/${everyMin} * * * *`; + if (everyMin === 60) return `${m} * * * *`; + if (everyMin < 1440) return `${m} ${h % (everyMin / 60)}/${everyMin / 60} * * *`; + return `${m} ${h} * * *`; +} + +// ---- run records ---- + +type Seed = { + id: string; + origin: RunOrigin; + job: string | null; + trigger?: string; + tick?: number; + by: Principal; + queuedAgo: number; + startedAgo?: number; + durationMin?: number; + fail?: string[]; + warn?: string[]; + errors?: Record; + end?: RunEnd; + select?: string[]; + exclude?: string[]; + verb?: LaunchVerb; + flags?: Record; + nodes?: string[]; + priority?: number; + runKey?: string; + rerunOf?: string; + parent?: string; + cancel?: { requestedAgoSec: number; by: Principal; sigtermAgoSec: number | null; done: boolean }; + lastHeartbeatAgoSec?: number; + running?: boolean; + launchedNoStart?: boolean; + image?: string; + generation?: number; + reason?: string; +}; + +function rng(seed: number) { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export type TickRecord = Tick & { triggerName: string }; + +export type OperateFixtures = { + records: RunRecord[]; + ticks: TickRecord[]; + crons: Record; + /** Last recorded tick time per schedule. */ + lastTickAt: Record; + evaluations: Record; + runRequests: Record; + stateHistory: Record; + cursors: Record; + calendar: { storedUntil: number; lastRefreshedAt: number; times: number[] }; +}; + +export function createOperateFixtures(now: number): OperateFixtures { + const ago = (minutes: number) => now - minutes * MIN; + const isoAgo = (minutes: number) => new Date(ago(minutes)).toISOString(); + const floorMin = (t: number) => Math.floor(t / MIN) * MIN; + + const records: RunRecord[] = []; + const ticks: TickRecord[] = []; + const lastTickAt: Record = {}; + const crons: Record = {}; + for (const t of TRIGGER_DEFS) { + if (!t.schedule) continue; + lastTickAt[t.name] = floorMin(ago(t.schedule.lastTickAgoMin)); + crons[t.name] = cronFor(t.schedule.everyMin, lastTickAt[t.name]); + } + const tickAt = (name: string, index: number) => lastTickAt[name] - index * (triggerDef(name)!.schedule!.everyMin * MIN); + const tickId = (name: string, at: number) => `tick-${name.replace(/_/g, '-')}-${(at / MIN).toString(36)}`; + + function record(seed: Seed, at?: { queued: number; started: number | null }): RunRecord { + const job = jobDef(seed.job); + const trig = triggerDef(seed.trigger ?? null); + const queued = at?.queued ?? ago(seed.queuedAgo); + const started = at ? at.started : seed.startedAgo === undefined ? null : ago(seed.startedAgo); + const plannedMs = (seed.durationMin ?? 5) * MIN; + const neverStarted = seed.end === 'never_started'; + const ended = seed.running ? null : started != null ? started + plannedMs : neverStarted ? queued + 2.5 * MIN : null; + const launched = started != null ? started - 4000 : neverStarted || seed.launchedNoStart ? queued + 3000 : null; + const select = seed.select ?? (job ? selectorTerms(job) : []); + const exclude = seed.exclude ?? job?.exclude ?? []; + const flags = seed.flags ?? Object.fromEntries(Object.entries(job?.settings ?? {}).map(([k, v]) => [k, v === true ? true : String(v)])); + const nodes = job?.kind === 'group' ? [] : (seed.nodes ?? writeSetOf(select, exclude)); + const tick = seed.tick != null && trig ? tickAt(trig.name, seed.tick) : null; + let cancel: CancelProgress | null = null; + if (seed.cancel) { + const requested = seed.cancel.done && ended ? ended - seed.cancel.requestedAgoSec * 1000 : now - seed.cancel.requestedAgoSec * 1000; + const sigterm = seed.cancel.sigtermAgoSec == null ? null : seed.cancel.done && ended ? requested + 1200 : now - seed.cancel.sigtermAgoSec * 1000; + cancel = { + requestedAt: new Date(requested).toISOString(), + requestedBy: seed.cancel.by, + sigtermAt: sigterm ? new Date(sigterm).toISOString() : null, + graceSeconds: 60, + sigkillAt: null, + exitCode: seed.cancel.done ? 143 : null, + terminatedAt: seed.cancel.done && ended ? new Date(ended).toISOString() : null + }; + } + const script: RunScript = { + verb: seed.verb ?? 'build', + select, + exclude, + flags, + nodes, + fail: seed.fail ?? [], + warn: seed.warn ?? [], + errors: seed.errors ?? {}, + critical: job?.criticalNodes ?? [], + plannedMs, + end: seed.end ?? 'complete', + cancel, + lastHeartbeatAt: seed.lastHeartbeatAgoSec != null ? now - seed.lastHeartbeatAgoSec * 1000 : null, + imageId: seed.image ?? IMAGE_ID, + snapshotGeneration: seed.generation ?? SNAPSHOT_GENERATION, + concurrencyGroups: job?.concurrencyGroups ?? [] + }; + const run: Run = { + id: seed.id, + kind: seed.job === null ? 'adhoc' : job?.kind === 'group' ? 'group' : 'job', + origin: seed.origin, + // Terminal statuses are derived from node outcomes by the store. + status: ended ? 'success' : started != null ? 'running' : launched != null ? 'launched' : 'queued', + statusReason: seed.reason ?? null, + jobKey: seed.job, + parentRunId: seed.parent ?? null, + rootRunId: seed.parent ?? seed.id, + childRunCount: job?.kind === 'group' ? job.members.length : 0, + triggerId: trig ? triggerId(trig.name) : null, + triggerName: trig?.name ?? null, + tickId: tick != null && trig ? tickId(trig.name, tick) : null, + tickScheduledFor: tick != null ? new Date(tick).toISOString() : null, + runKey: seed.runKey ?? null, + rerunOfRunId: seed.rerunOf ?? null, + requestedBy: seed.by, + priority: seed.priority ?? job?.priority ?? 0, + rewriteHistory: '--full-refresh' in flags || Object.keys(flags).some((f) => f.includes('cursor')) || '--reload' in flags, + locationId: '', + target: 'prod', + snapshotId: 'snap-7d2e41a', + gitRevision: (seed.generation ?? SNAPSHOT_GENERATION) === SNAPSHOT_GENERATION ? '7d2e41a' : 'c31f0b9', + queuedAt: new Date(queued).toISOString(), + launchedAt: launched != null ? new Date(launched).toISOString() : null, + startedAt: started != null ? new Date(started).toISOString() : null, + endedAt: ended ? new Date(ended).toISOString() : null, + nodeCounts: { success: 0, warn: 0, failed: 0, skipped: 0, running: 0, pending: 0 }, + cost: null + }; + const rec = { run, script }; + records.push(rec); + return rec; + } + + /** A group parent plus one child run per member, staged by the group's derived order. */ + function groupRun(seed: Seed, childSeeds: Partial>>, stages: string[][]): void { + const parentQueued = seed.queuedAgo; + const parentStart = seed.startedAgo ?? parentQueued; + let offset = 0; + const children: RunRecord[] = []; + stages.forEach((stage) => { + let longest = 0; + for (const member of stage) { + const extra = childSeeds[member] ?? {}; + const dur = extra.durationMin ?? 4; + longest = Math.max(longest, dur); + children.push( + record({ + id: `${seed.id}-${member.slice(0, 3)}`, + origin: seed.origin, + job: member, + by: seed.by, + parent: seed.id, + trigger: seed.trigger, + tick: seed.tick, + queuedAgo: parentStart - 0.1, + startedAgo: parentStart - offset - 0.2, + durationMin: dur, + ...extra + }) + ); + } + offset += longest + 0.4; + }); + record({ ...seed, startedAgo: parentStart, durationMin: offset + 0.3 }); + } + + // ---- fixture runs: the ones the overview, checks and queue point at ---- + + const at = (name: string, index: number, delaySec = 8) => { + const t = tickAt(name, index); + return (now - t) / MIN - delaySec / 60; + }; + + record({ id: 'run-9d21', origin: 'manual', job: 'core_build', by: OPS_USER, queuedAgo: 3.2, startedAgo: 3, durationMin: 9, running: true, warn: ['stg_payments'] }); + record({ id: 'run-6b02', origin: 'sensor', job: 'load_sources', trigger: 'partner_feed_poll', runKey: 'orders_part_0412.csv', by: SENSOR_DAEMON, queuedAgo: 7.5, startedAgo: 7.2, durationMin: 12, running: true, cancel: { requestedAgoSec: 20, by: OPS_USER, sigtermAgoSec: 19, done: false } }); + record({ id: 'run-6a90', origin: 'schedule', job: 'activity_microbatch', trigger: 'activity_microbatch', tick: 2, by: SCHEDULER, queuedAgo: at('activity_microbatch', 2, 0), startedAgo: at('activity_microbatch', 2, 6), durationMin: 60, running: true, lastHeartbeatAgoSec: 95 }); + record({ id: 'run-5f31', origin: 'schedule', job: 'activity_microbatch', trigger: 'activity_microbatch', tick: 1, by: SCHEDULER, queuedAgo: at('activity_microbatch', 1, 0) }); + record({ id: 'run-5e10', origin: 'manual', job: 'revenue_refresh', by: DEMO_USER, queuedAgo: 1 }); + record({ id: 'run-5f77', origin: 'manual', job: 'reporting_exports', by: CI_ACCOUNT, queuedAgo: 2.4, priority: 10 }); + record({ id: 'run-5f80', origin: 'manual', job: 'reporting_exports', by: DEMO_USER, queuedAgo: 0.6, priority: 0 }); + record({ id: 'run-3c88', origin: 'rerun', job: 'core_build', by: OPS_USER, queuedAgo: 1.5, rerunOf: 'run-4f2a', select: ['dim_customers', 'customer_status'], nodes: ['dim_customers', 'customer_status'] }); + + record({ + id: 'run-4f2a', origin: 'schedule', job: 'core_build', trigger: 'core_build', tick: 0, by: SCHEDULER, + queuedAgo: at('core_build', 0, 0), startedAgo: at('core_build', 0, 10), durationMin: 6, + warn: ['stg_payments'], fail: ['dim_customers'], errors: { dim_customers: 'Numeric value out of range in lifetime_value' } + }); + record({ id: 'run-2b70', origin: 'schedule', job: 'core_build', trigger: 'core_build', tick: 1, by: SCHEDULER, queuedAgo: at('core_build', 1, 0), startedAgo: at('core_build', 1, 9), durationMin: 7 }); + record({ id: 'run-2b71', origin: 'sensor', job: 'reporting_exports', trigger: 'after_core_build', runKey: 'run-2b70', by: SENSOR_DAEMON, queuedAgo: at('core_build', 1, 9) - 7.2, startedAgo: at('core_build', 1, 9) - 7.4, durationMin: 3 }); + record({ id: 'run-0c7d', origin: 'rerun', job: 'core_build', by: CI_ACCOUNT, rerunOf: 'run-2b70', queuedAgo: 600, startedAgo: 599.8, durationMin: 3, end: 'cancelled', cancel: { requestedAgoSec: 75, by: CI_ACCOUNT, sigtermAgoSec: 74, done: true }, reason: 'Cancelled by svc-ci' }); + record({ id: 'run-91cd', origin: 'manual', job: 'marts_only', by: DEMO_USER, queuedAgo: 97, startedAgo: 96.8, durationMin: 5 }); + record({ id: 'run-3b22', origin: 'manual', job: null, by: DEMO_USER, queuedAgo: 120, startedAgo: 119.8, durationMin: 2, select: ['stg_customers', 'dim_customers'] }); + record({ id: 'run-88e1', origin: 'sensor', job: 'marts_only', trigger: 'on_payments_landed', runKey: 'raw__payments@81204', by: SENSOR_DAEMON, queuedAgo: 206, startedAgo: 205.8, durationMin: 4 }); + record({ id: 'run-1a05', origin: 'schedule', job: 'activity_microbatch', trigger: 'activity_microbatch', tick: 3, by: SCHEDULER, queuedAgo: at('activity_microbatch', 3, 0), startedAgo: at('activity_microbatch', 3, 7), durationMin: 3, warn: ['hourly_order_activity'] }); + record({ id: 'run-c40e', origin: 'manual', job: 'marts_only', by: CI_ACCOUNT, queuedAgo: 1080, startedAgo: 1079.8, durationMin: 4 }); + record({ id: 'run-e3f8', origin: 'schedule', job: 'activity_microbatch', trigger: 'activity_microbatch', tick: 101, by: SCHEDULER, queuedAgo: at('activity_microbatch', 101, 0), startedAgo: at('activity_microbatch', 101, 6), durationMin: 11, end: 'presumed_failed', lastHeartbeatAgoSec: (at('activity_microbatch', 101, 6) - 0.6) * 60, reason: 'Lost contact: no heartbeat for 10 minutes, so the run was stopped and marked failed' }); + record({ id: 'run-a7c1', origin: 'schedule', job: 'revenue_refresh', trigger: 'revenue_refresh', tick: 0, by: SCHEDULER, queuedAgo: at('revenue_refresh', 0, 0), startedAgo: at('revenue_refresh', 0, 8), durationMin: 2, fail: ['daily_revenue'], errors: { daily_revenue: 'Warehouse error: the warehouse was suspended while the statement ran' } }); + record({ id: 'run-f1a9', origin: 'sensor', job: 'load_sources', trigger: 'partner_feed_poll', runKey: 'orders_part_0398.csv', by: SENSOR_DAEMON, queuedAgo: 50 * 60, end: 'never_started', reason: 'Couldn’t start: the run didn’t report in within 2 minutes of launch (the image couldn’t be downloaded)', image: PREVIOUS_IMAGE_ID, generation: 41 }); + + const nightlyStages = [['load_sources'], ['core_build'], ['activity_microbatch'], ['reporting_exports']]; + groupRun( + { id: 'run-b7a0', origin: 'schedule', job: 'nightly_all', trigger: 'nightly_all', tick: 0, by: SCHEDULER, queuedAgo: at('nightly_all', 0, 0), startedAgo: at('nightly_all', 0, 9) }, + { + load_sources: { durationMin: 3 }, + core_build: { durationMin: 7, fail: ['daily_revenue'], errors: { daily_revenue: 'Division by zero in revenue_per_order' } }, + activity_microbatch: { durationMin: 5 }, + reporting_exports: { durationMin: 1 } + }, + nightlyStages + ); + groupRun( + { id: 'run-77b3', origin: 'schedule', job: 'nightly_all', trigger: 'nightly_all', tick: 1, by: SCHEDULER, queuedAgo: at('nightly_all', 1, 0), startedAgo: at('nightly_all', 1, 9) }, + { load_sources: { durationMin: 3 }, core_build: { durationMin: 8 }, activity_microbatch: { durationMin: 6 }, reporting_exports: { durationMin: 3 } }, + nightlyStages + ); + + // ---- generated schedule history ---- + + const fixtureTicks = new Map>(); + for (const r of records) { + if (!r.run.tickScheduledFor || !r.run.triggerName || r.run.parentRunId) continue; + const set = fixtureTicks.get(r.run.triggerName) ?? new Set(); + set.add(Date.parse(r.run.tickScheduledFor)); + fixtureTicks.set(r.run.triggerName, set); + } + const byTick = (name: string, t: number) => + records.find((r) => r.run.triggerName === name && !r.run.parentRunId && r.run.tickScheduledFor && Date.parse(r.run.tickScheduledFor) === t); + const tick = (name: string, t: number, outcome: Tick['outcome'], extra: Partial = {}) => + ticks.push({ + id: tickId(name, t), + triggerName: name, + scheduledFor: new Date(t).toISOString(), + evaluatedAt: new Date(t + 400).toISOString(), + outcome, + runId: null, + coveredByRunId: null, + reason: null, + lateBySeconds: 0, + catchUp: false, + ...extra + }); + + const pick = (rand: () => number, job: string): Partial => { + const nodes = jobNodes(job); + const r = rand(); + if (r < 0.06 && nodes.includes('stg_payments')) return { warn: ['stg_payments'] }; + if (r < 0.06 && nodes.length) return { warn: [nodes[0]] }; + if (r < 0.085 && nodes.length > 2) return { fail: [nodes[nodes.length - 2]], errors: { [nodes[nodes.length - 2]]: 'Statement timed out after max_duration' } }; + return {}; + }; + + const downtime = { from: ago(30 * 60), to: ago(27 * 60) }; + const usedIds = new Set(records.map((r) => r.run.id)); + const genId = (rand: () => number) => { + let id: string; + do id = `run-${Math.floor(rand() * 0xfffff).toString(16).padStart(5, '0')}`; + while (usedIds.has(id)); + usedIds.add(id); + return id; + }; + + TRIGGER_DEFS.forEach((t, index) => { + const spec = t.schedule; + if (!spec || !t.jobKey || spec.historyDays === 0) return; + const rand = rng(index * 7919 + 101); + const job = jobDef(t.jobKey)!; + const typical = job.kind === 'group' ? 0 : t.jobKey === 'core_build' ? 7 : t.jobKey === 'activity_microbatch' ? 3 : 4; + const count = Math.floor((spec.historyDays * 1440) / spec.everyMin); + const skip = fixtureTicks.get(t.name) ?? new Set(); + const missed: number[] = []; + for (let i = count; i >= 0; i--) { + const due = tickAt(t.name, i); + if (t.kind === 'calendar_schedule' && [0, 6].includes(new Date(due).getUTCDay())) continue; + if (due >= downtime.from && due < downtime.to && t.name === 'activity_microbatch') { + missed.push(due); + continue; + } + if (missed.length && t.name === 'activity_microbatch') { + // Catch-up after downtime: `all` with max 3; older missed times are skipped. + const cap = t.catchUp?.max ?? 1; + const caught = missed.slice(-cap); + for (const m of missed.slice(0, -cap)) tick(t.name, m, 'skipped', { evaluatedAt: new Date(downtime.to + 2000).toISOString(), lateBySeconds: Math.round((downtime.to - m) / 1000), catchUp: true, reason: `Beyond the catch-up limit of ${cap} missed times` }); + const runId = genId(rand); + const startAgo = (now - downtime.to) / MIN - 0.1; + record({ id: runId, origin: 'schedule', job: t.jobKey, trigger: t.name, by: SCHEDULER, queuedAgo: startAgo + 0.05, startedAgo: startAgo, durationMin: 6, reason: 'Made up after the scheduler was down' }, { queued: downtime.to + 1000, started: downtime.to + 5000 }); + const rec = records[records.length - 1]; + rec.run.tickScheduledFor = new Date(caught[0]).toISOString(); + rec.run.tickId = tickId(t.name, caught[0]); + tick(t.name, caught[0], 'requested', { runId, evaluatedAt: new Date(downtime.to + 1000).toISOString(), lateBySeconds: Math.round((downtime.to - caught[0]) / 1000), catchUp: true, reason: 'Missed while no server was running the scheduler' }); + for (const c of caught.slice(1)) tick(t.name, c, 'covered', { coveredByRunId: runId, evaluatedAt: new Date(downtime.to + 1000).toISOString(), lateBySeconds: Math.round((downtime.to - c) / 1000), catchUp: true, reason: 'Missed time merged into the make-up run (overlap = coalesce)' }); + missed.length = 0; + } + if (skip.has(due)) { + const fixture = byTick(t.name, due)!; + tick(t.name, due, 'requested', { runId: fixture.run.id }); + continue; + } + if (t.name === 'activity_microbatch' && i === 0) { + tick(t.name, due, 'covered', { coveredByRunId: 'run-5f31', reason: 'A run is already waiting (overlap = coalesce)' }); + continue; + } + if (t.name === 'nightly_all' && i === 4) { + tick(t.name, due, 'failed', { reason: 'The code location was unavailable, so no run started for this time' }); + continue; + } + const id = genId(rand); + const ageMin = (now - due) / MIN; + if (job.kind === 'group') { + const failing = rand() < 0.15; + const stages = t.jobKey === 'nightly_all' ? nightlyStages : [['revenue_refresh'], ['activity_microbatch', 'customer_segments']]; + const childSeeds: Partial>> = Object.fromEntries(stages.flat().map((m) => [m, { durationMin: 2 + rand() * 5 }])); + if (failing) childSeeds.activity_microbatch = { ...childSeeds.activity_microbatch, fail: ['daily_activity_rollup'], errors: { daily_activity_rollup: 'Statement timed out after max_duration' } }; + groupRun({ id, origin: 'schedule', job: t.jobKey, trigger: t.name, by: SCHEDULER, queuedAgo: ageMin, startedAgo: ageMin - 0.15 }, childSeeds, stages); + } else { + record({ id, origin: 'schedule', job: t.jobKey, trigger: t.name, by: SCHEDULER, queuedAgo: ageMin, startedAgo: ageMin - 0.15, durationMin: typical * (0.7 + rand() * 0.6), ...pick(rand, t.jobKey) }); + } + const rec = records.find((r) => r.run.id === id)!; + rec.run.tickScheduledFor = new Date(due).toISOString(); + rec.run.tickId = tickId(t.name, due); + if (job.kind === 'group') for (const child of records.filter((r) => r.run.parentRunId === id)) { + child.run.tickScheduledFor = rec.run.tickScheduledFor; + child.run.tickId = rec.run.tickId; + } + tick(t.name, due, 'requested', { runId: id }); + } + }); + + // The waiting coalesced run also covers the tick that requested it. + const waitingTick = ticks.find((t) => t.runId === 'run-5f31'); + if (!waitingTick) tick('activity_microbatch', tickAt('activity_microbatch', 1), 'requested', { runId: 'run-5f31' }); + + // ---- sensors ---- + + const evaluations: Record = {}; + const runRequests: Record = {}; + const fingerprint = (name: string, version = 1) => `fp:${name.length.toString(16)}${version}a${(name.charCodeAt(0) * 7919).toString(16)}`; + const evalOf = (name: string, seq: number, at: number, outcome: SensorEvaluation['outcome'], extra: Partial = {}): SensorEvaluation => ({ + id: `eval-${name.replace(/_/g, '-')}-${seq}`, + sequence: seq, + outcome, + requestedAt: new Date(at).toISOString(), + finishedAt: outcome === 'requested' ? null : new Date(at + 1400).toISOString(), + cursorBefore: null, + cursorAfter: null, + runRequests: 0, + skipReason: null, + error: null, + definitionFingerprint: fingerprint(name), + ...extra + }); + + { + const name = 'on_payments_landed'; + const list: SensorEvaluation[] = []; + for (let i = 40; i >= 0; i--) { + list.push(evalOf(name, 900 - i, ago(i * 1.05 + 0.3), 'completed', { cursorBefore: 'freshness_event:81204', cursorAfter: 'freshness_event:81204', skipReason: 'No new data in raw__payments since the cursor' })); + } + list.push(evalOf(name, 880, ago(206.1), 'completed', { cursorBefore: 'freshness_event:81160', cursorAfter: 'freshness_event:81204', runRequests: 1 })); + evaluations[name] = list.sort((a, b) => b.requestedAt.localeCompare(a.requestedAt)); + runRequests[name] = [{ runKey: 'raw__payments@81204', runId: 'run-88e1', evaluationId: `eval-on-payments-landed-880`, requestedAt: isoAgo(206), runStatus: 'success' }]; + } + { + const name = 'after_core_build'; + const builds = records + .filter((r) => r.run.jobKey === 'core_build' && r.run.origin === 'schedule' && !r.run.parentRunId && r.run.endedAt) + .sort((a, b) => a.run.queuedAt.localeCompare(b.run.queuedAt)); + const list: SensorEvaluation[] = []; + const reqs: SensorRunRequest[] = []; + const rand = rng(4242); + builds.forEach((b, i) => { + const t = Date.parse(b.run.endedAt!) + 2000; + const ok = !b.script.fail.length; + let runId = b.run.id === 'run-2b70' ? 'run-2b71' : null; + if (ok && !runId) { + runId = genId(rand); + const startAgo = (now - t) / MIN - 0.1; + record({ id: runId, origin: 'sensor', job: 'reporting_exports', trigger: name, runKey: b.run.id, by: SENSOR_DAEMON, queuedAgo: startAgo + 0.05, startedAgo: startAgo, durationMin: 3 }); + } + const evaluationId = `eval-after-core-build-${300 + i}`; + list.push( + evalOf(name, 300 + i, t, 'completed', { + cursorBefore: `run_event:${4000 + i * 37}`, + cursorAfter: `run_event:${4037 + i * 37}`, + runRequests: ok ? 1 : 0, + skipReason: ok ? null : `${b.run.id} was partial; on-success conditions don't fire on partial runs` + }) + ); + if (ok && runId) reqs.push({ runKey: b.run.id, runId, evaluationId, requestedAt: new Date(t).toISOString(), runStatus: 'success' }); + }); + evaluations[name] = list.reverse(); + runRequests[name] = reqs.reverse(); + } + { + const name = 'on_fact_orders_rebuilt'; + const t = Date.parse(records.find((r) => r.run.id === 'run-4f2a')!.run.endedAt!) + 1500; + evaluations[name] = [ + evalOf(name, 512, t, 'completed', { cursorBefore: 'run_event:9311', cursorAfter: 'run_event:9338', runRequests: 1 }), + evalOf(name, 511, t - 97 * MIN, 'completed', { cursorBefore: 'run_event:9290', cursorAfter: 'run_event:9311', runRequests: 1 }) + ]; + runRequests[name] = [ + { runKey: 'run-4f2a', runId: 'run-5f31', evaluationId: 'eval-on-fact-orders-rebuilt-512', requestedAt: new Date(t).toISOString(), runStatus: 'queued' }, + { runKey: 'run-91cd', runId: 'run-91cd', evaluationId: 'eval-on-fact-orders-rebuilt-511', requestedAt: new Date(t - 97 * MIN).toISOString(), runStatus: 'success' } + ]; + } + { + const name = 'platform_health_webhook'; + evaluations[name] = [ + evalOf(name, 77, ago(11.5), 'completed', { cursorBefore: 'platform_event:1201', cursorAfter: 'platform_event:1202', skipReason: null }), + evalOf(name, 76, ago(11.9), 'failed', { cursorBefore: 'platform_event:1201', cursorAfter: null, error: 'chat_webhook returned HTTP 503; the cursor was not advanced' }), + evalOf(name, 75, ago(48), 'completed', { cursorBefore: 'platform_event:1200', cursorAfter: 'platform_event:1201' }), + evalOf(name, 74, ago(300), 'completed', { cursorBefore: 'platform_event:1198', cursorAfter: 'platform_event:1200' }) + ]; + runRequests[name] = []; + } + { + const name = 'partner_feed_poll'; + const oldFp = fingerprint(name, 1); + const stamp = (minutes: number) => new Date(ago(minutes)).toISOString().slice(0, 19) + 'Z'; + evaluations[name] = [ + { ...evalOf(name, 1290, ago(0.4), 'requested', { cursorBefore: stamp(8) }), definitionFingerprint: oldFp }, + evalOf(name, 1289, ago(5.5), 'completed', { cursorBefore: stamp(70), cursorAfter: stamp(8), runRequests: 1, definitionFingerprint: oldFp }), + evalOf(name, 1288, ago(10.6), 'timed_out', { cursorBefore: stamp(70), error: 'No result within 60s; the worker was killed and restarted', definitionFingerprint: oldFp }), + evalOf(name, 1287, ago(15.7), 'completed', { cursorBefore: stamp(70), cursorAfter: stamp(70), skipReason: 'No new files', definitionFingerprint: oldFp }), + evalOf(name, 1286, ago(20.8), 'abandoned', { cursorBefore: stamp(70), error: 'The location worker restarted during the evaluation', definitionFingerprint: oldFp }), + evalOf(name, 1285, ago(25.9), 'failed', { cursorBefore: stamp(70), error: 'orders_api: connection reset by api.example.com', definitionFingerprint: oldFp }), + evalOf(name, 1284, ago(71), 'completed', { cursorBefore: stamp(3000), cursorAfter: stamp(70), runRequests: 1, definitionFingerprint: oldFp }) + ]; + runRequests[name] = [ + { runKey: 'orders_part_0412.csv', runId: 'run-6b02', evaluationId: 'eval-partner-feed-poll-1289', requestedAt: isoAgo(7.6), runStatus: 'running' }, + { runKey: 'orders_part_0398.csv', runId: 'run-f1a9', evaluationId: null, requestedAt: isoAgo(50 * 60), runStatus: 'failed' } + ]; + } + + const cursors: OperateFixtures['cursors'] = { + on_payments_landed: { cursor: 'freshness_event:81204', updatedAt: ago(0.3) }, + after_core_build: { cursor: evaluations.after_core_build[0]?.cursorAfter ?? null, updatedAt: ago(19) }, + on_fact_orders_rebuilt: { cursor: 'run_event:9338', updatedAt: ago(18) }, + platform_health_webhook: { cursor: 'platform_event:1202', updatedAt: ago(11.5) }, + partner_feed_poll: { cursor: new Date(ago(8)).toISOString().slice(0, 19) + 'Z', updatedAt: ago(5.5) } + }; + + const stateHistory: Record = { + revenue_refresh: [ + { id: 'tse-1', at: isoAgo(26 * 60), kind: 'paused', actor: DEMO_USER, reason: 'Pausing while the revenue source is migrated', value: null } + ], + core_build: [ + { id: 'tse-2', at: isoAgo(6 * 1440 + 30), kind: 'reset_to_default', actor: CI_ACCOUNT, reason: 'Deploy finished', value: 'running' }, + { id: 'tse-3', at: isoAgo(6 * 1440 + 55), kind: 'paused', actor: CI_ACCOUNT, reason: 'Deploy in progress', value: null } + ], + on_fact_orders_rebuilt: [ + { id: 'tse-4', at: isoAgo(2 * 1440), kind: 'enabled', actor: OPS_USER, reason: 'Backfill done', value: null }, + { id: 'tse-5', at: isoAgo(3 * 1440), kind: 'paused', actor: OPS_USER, reason: 'Backfilling activity models by hand', value: null } + ], + partner_feed_poll: [ + { id: 'tse-6', at: isoAgo(4 * 1440), kind: 'cursor_reset', actor: OPS_USER, reason: 'Reprocess files after the partner re-sent them', value: null } + ] + }; + + const calendarTimes: number[] = []; + const lastBiz = lastTickAt.business_days; + for (let d = 1; d <= 6; d++) { + const t = lastBiz + d * 1440 * MIN; + if (![0, 6].includes(new Date(t).getUTCDay())) calendarTimes.push(t); + } + + return { + records, + ticks, + crons, + lastTickAt, + evaluations, + runRequests, + stateHistory, + cursors, + calendar: { storedUntil: lastBiz + 5 * 1440 * MIN + 12 * 60 * MIN, lastRefreshedAt: ago(25 * 60), times: calendarTimes.filter((t) => t <= lastBiz + 5.5 * 1440 * MIN) } + }; +} + +export function resolveJobSelection(job: JobDef) { + return resolveSelection(selectorTerms(job), job.exclude); +} + +export type { JobDef, TriggerDetail }; diff --git a/ui/src/lib/api/mock/platform.ts b/ui/src/lib/api/mock/platform.ts new file mode 100644 index 0000000000..ebafb48a60 --- /dev/null +++ b/ui/src/lib/api/mock/platform.ts @@ -0,0 +1,993 @@ +// Mock data and behaviour for the Platform and Admin areas. Neutral example +// data only: a fictional waffle shop and its analytics team. + +import { ApiError } from '../client'; +import { CAPABILITIES } from '../types'; +import type { PlatformApi } from '../platform-client'; +import type { LocalChangeBus } from '../signals'; +import type { + AccessDecision, + AccessExplanation, + AccessOverview, + AccessQuery, + Account, + AuditEntry, + AuditQuery, + BuiltInRole, + Capability, + ConnectionTest, + EffectiveAccess, + IssuedToken, + JanitorEvent, + Location, + LocationDetail, + LocationSummary, + LocationToken, + MaintenanceHistory, + MigrationEvent, + OldNameView, + PlatformEvent, + PlatformEventLink, + PlatformEventReaction, + PlatformEventQuery, + PlatformEventRecord, + PlatformHealth, + Principal, + ProviderConnection, + RelationName, + RoleAssignment, + RoleAssignmentRequest, + RoleDefinition, + RoleDetail, + RoleGrant, + Scope, + ServerSettings, + SettingField, + SsoMapping, + WarehouseConnection +} from '../types'; +import { + ADMIN_ONLY, + baseGrantsOf, + definitionText, + effective, + explain, + missingForGrant, + plainAction, + recentlyMatchedCount, + roleAvailableAt, + selectorMatches, + type AccessData +} from './access'; +import { LOCATION_MARKETING, LOCATION_SHOP, LOCATION_SHOP_DEV, LOCATION_SHOP_STAGING, ROLE_CAPABILITIES, SHOP_LOCATIONS } from './fixtures'; +import { triggerId } from './operate'; +import { ALEX, CI_ACCOUNT, DEMO_USER, JORDAN, OPS_USER, REPORTING, RILEY, SAM, SYSTEM } from './principals'; + +const MIN = 60_000; +const DAY = 24 * 60 * MIN; + +export type MockPlatformContext = { + now: number; + role: BuiltInRole; + events: 'default' | 'none' | 'all'; + serverVersion: string; + user: Principal; + locations: Location[]; + changes: LocalChangeBus; + platformEvents: () => PlatformEvent[]; + reply: (value: T) => Promise; +}; + +function relation(schema: string, name: string): RelationName { + return { database: 'WAFFLE_SHOP', schema, name }; +} + +function randomHex(length: number): string { + const bytes = new Uint8Array(Math.ceil(length / 2)); + crypto.getRandomValues(bytes); + return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length); +} + +function createPlatformFixtures(now: number, role: BuiltInRole) { + const ago = (minutes: number) => new Date(now - minutes * MIN).toISOString(); + const ahead = (minutes: number) => new Date(now + minutes * MIN).toISOString(); + const days = (n: number) => n * 24 * 60; + + // ---- accounts ---- + const accounts: Account[] = [ + { ...DEMO_USER, kind: 'user', email: 'demo@example.com', source: 'oidc', ssoGroups: [], createdAt: ago(days(210)), lastSeenAt: ago(0.1), disabled: false, activeSessions: 2 }, + { ...ALEX, kind: 'user', email: 'alex@example.com', source: 'oidc', ssoGroups: ['analytics-eng'], createdAt: ago(days(180)), lastSeenAt: ago(25), disabled: false, activeSessions: 1 }, + { ...SAM, kind: 'user', email: 'sam@example.com', source: 'oidc', ssoGroups: ['analytics-eng', 'finance-analysts'], createdAt: ago(days(95)), lastSeenAt: ago(190), disabled: false, activeSessions: 1 }, + { ...JORDAN, kind: 'user', email: 'jordan@example.com', source: 'password', ssoGroups: [], createdAt: ago(days(40)), lastSeenAt: ago(days(3)), disabled: false, activeSessions: 0 }, + { ...OPS_USER, kind: 'user', email: 'ops-oncall@example.com', source: 'oidc', ssoGroups: ['data-platform'], createdAt: ago(days(150)), lastSeenAt: ago(1.5), disabled: false, activeSessions: 3 }, + { ...RILEY, kind: 'user', email: 'riley@example.com', source: 'oidc', ssoGroups: ['support-ops'], createdAt: ago(days(300)), lastSeenAt: ago(days(61)), disabled: true, activeSessions: 0 }, + { ...CI_ACCOUNT, kind: 'user', email: 'ci@example.com', source: 'password', ssoGroups: [], createdAt: ago(days(200)), lastSeenAt: ago(42), disabled: false, activeSessions: 0 }, + { ...REPORTING, kind: 'user', email: 'reporting@example.com', source: 'password', ssoGroups: [], createdAt: ago(days(120)), lastSeenAt: ago(300), disabled: false, activeSessions: 0 } + ]; + + const locationTokens: LocationToken[] = [ + { id: 'ltok-1', locationId: LOCATION_SHOP, prefix: 'sqbl_3f9a', createdAt: ago(days(30)), createdBy: DEMO_USER, lastUsedAt: ago(0.2), revokedAt: null, state: 'active' }, + { id: 'ltok-0', locationId: LOCATION_SHOP, prefix: 'sqbl_a210', createdAt: ago(days(210)), createdBy: DEMO_USER, lastUsedAt: ago(days(30)), revokedAt: ago(days(30)), state: 'revoked' }, + { id: 'ltok-2', locationId: LOCATION_MARKETING, prefix: 'sqbl_7be4', createdAt: ago(days(120)), createdBy: DEMO_USER, lastUsedAt: ago(0.5), revokedAt: null, state: 'active' } + ]; + + // ---- roles ---- + const builtIn = (name: BuiltInRole, description: string): RoleDefinition => ({ + name, + source: 'built_in', + definedIn: null, + description, + extends: null, + grants: [{ nodes: ['*'], permissions: ROLE_CAPABILITIES[name] }], + holders: 0, + recentlyMatched: 0 + }); + const custom = (role: Pick): RoleDefinition => ({ ...role, holders: 0, recentlyMatched: 0 }); + const roles: RoleDefinition[] = [ + builtIn('viewer', 'See everything except SQL and sample rows.'), + builtIn('operator', 'Run, cancel and rerun jobs; pause triggers.'), + builtIn('developer', 'Operator, plus ad-hoc runs, full refreshes, plan previews, connection tests and sample rows.'), + builtIn('admin', 'Everything, including people, roles, locations and the audit log.'), + custom({ + name: 'support_viewer', + source: 'server_config', + definedIn: 'sqb-server.toml:31', + description: 'Support team: see reporting models and their SQL.', + extends: 'viewer', + grants: [{ nodes: ['group:reporting'], permissions: ['view.sql'] }] + }), + custom({ + name: 'platform_on_call', + source: 'server_config', + definedIn: 'sqb-server.toml:38', + description: 'Platform on-call: operate everything and check connections and plans.', + extends: 'operator', + grants: [{ nodes: ['*'], permissions: ['connection.test', 'plan.preview'] }] + }), + custom({ + name: 'finance_operator', + source: 'project', + definedIn: 'roles.toml:1', + description: 'Finance analysts operate finance jobs.', + extends: 'viewer', + grants: [ + { nodes: ['tag:finance'], permissions: ['job.run', 'run.cancel', 'view.sql'] }, + { nodes: ['tag:finance,group:marts'], permissions: ['view.evidence'] } + ] + }), + custom({ + name: 'reporting_operator', + source: 'project', + definedIn: 'roles.toml:12', + description: 'Operates the reporting group.', + extends: 'viewer', + grants: [{ nodes: ['group:reporting'], permissions: ['job.run', 'run.cancel'] }] + }) + ]; + + const manual = (id: string, principal: Principal, role: string, locationId: string | null, minutesAgo: number, stale = false): RoleAssignment => ({ + id, principal, role, scope: { locationId }, source: 'manual', ssoGroup: null, definedIn: null, assignedBy: DEMO_USER, assignedAt: ago(minutesAgo), stale + }); + const sso = (id: string, principal: Principal, role: string, locationId: string | null, group: string, minutesAgo: number): RoleAssignment => ({ + id, principal, role, scope: { locationId }, source: 'sso', ssoGroup: group, definedIn: null, assignedBy: null, assignedAt: ago(minutesAgo), stale: false + }); + const people = [DEMO_USER, ALEX, SAM, JORDAN, OPS_USER, RILEY]; + const assignments: RoleAssignment[] = [ + { ...manual('asg-1', DEMO_USER, role, null, days(210)), assignedBy: SYSTEM }, + ...people.map((p, i): RoleAssignment => ({ + id: `asg-default-${p.name}`, principal: p, role: 'viewer', scope: { locationId: null }, source: 'built_in', ssoGroup: null, + definedIn: 'sqb-server.toml:24', assignedBy: null, assignedAt: accounts[i].createdAt, stale: false + })), + sso('asg-2', ALEX, 'developer', LOCATION_SHOP_STAGING, 'analytics-eng', days(180)), + manual('asg-3', ALEX, 'operator', LOCATION_SHOP, days(170)), + sso('asg-4', SAM, 'developer', LOCATION_SHOP_STAGING, 'analytics-eng', days(95)), + sso('asg-5', SAM, 'finance_operator', LOCATION_SHOP, 'finance-analysts', days(95)), + manual('asg-12', JORDAN, 'reporting_operator', LOCATION_SHOP, days(5)), + manual('asg-7', JORDAN, 'growth_analyst', LOCATION_SHOP, days(38), true), + { ...manual('asg-8', CI_ACCOUNT, 'operator', LOCATION_SHOP, days(200)), source: 'policy', definedIn: 'sqb-server.toml:61', assignedBy: null }, + manual('asg-9', REPORTING, 'viewer', LOCATION_MARKETING, days(120)), + manual('asg-11', OPS_USER, 'operator', null, days(150)), + sso('asg-13', OPS_USER, 'platform_on_call', null, 'data-platform', days(150)), + sso('asg-10', RILEY, 'support_viewer', null, 'support-ops', days(300)) + ]; + + const ssoMappings: SsoMapping[] = [ + { group: 'data-platform', role: 'platform_on_call', scope: { locationId: null }, definedIn: 'sqb-server.toml:47', membersSynced: 1, lastSyncedAt: ago(1.5) }, + { group: 'analytics-eng', role: 'developer', scope: { locationId: LOCATION_SHOP_STAGING }, definedIn: 'sqb-server.toml:52', membersSynced: 2, lastSyncedAt: ago(25) }, + { group: 'finance-analysts', role: 'finance_operator', scope: { locationId: LOCATION_SHOP }, definedIn: 'sqb-server.toml:57', membersSynced: 1, lastSyncedAt: ago(190) }, + { group: 'support-ops', role: 'support_viewer', scope: { locationId: null }, definedIn: 'sqb-server.toml:62', membersSynced: 1, lastSyncedAt: ago(days(61)) }, + { group: 'warehouse-admins', role: 'admin', scope: { locationId: null }, definedIn: 'sqb-server.toml:67', membersSynced: 0, lastSyncedAt: null } + ]; + + // ---- audit ---- + let auditSeq = 0; + const entry = (minutesAgo: number, e: Omit & { ip?: string; decisionId?: string }): AuditEntry => { + auditSeq += 1; + return { + id: `aud-${String(auditSeq).padStart(4, '0')}`, + at: ago(minutesAgo), + ip: e.ip ?? '10.0.4.17', + requestId: `req-${randomHex(12)}`, + decisionId: null, + ...e + }; + }; + const shopProd = { locationId: LOCATION_SHOP }; + const auditEntries: AuditEntry[] = [ + entry(2, { actor: DEMO_USER, action: 'view.sql', outcome: 'allowed', scope: { ...shopProd, subject: 'chk-fo-unique' }, grantingRole: role, evidenceRead: false, summary: 'Read the SQL of unique(order_id)', before: null, after: null }), + entry(18, { actor: SAM, action: 'view.evidence', outcome: 'allowed', scope: { ...shopProd, subject: 'fact_orders' }, grantingRole: 'finance_operator', evidenceRead: true, summary: 'Read sample rows of fact_orders', before: null, after: null, ip: '10.0.7.33' }), + entry(26, { actor: JORDAN, action: 'job.run', outcome: 'denied', decisionId: 'dec-4k7q2m', scope: { ...shopProd, subject: 'daily_revenue' }, grantingRole: null, evidenceRead: false, summary: 'Denied: reporting_operator runs jobs on group:reporting only', before: null, after: null, ip: '10.0.9.4' }), + entry(42, { actor: CI_ACCOUNT, action: 'job.run', outcome: 'allowed', scope: { ...shopProd, subject: 'checks_only' }, grantingRole: 'operator', evidenceRead: false, summary: 'Launched checks_only', before: null, after: 'run-c40e queued', ip: '10.0.2.8' }), + entry(55, { actor: ALEX, action: 'trigger.toggle', outcome: 'allowed', scope: { ...shopProd, subject: 'revenue_refresh' }, grantingRole: 'operator', evidenceRead: false, summary: 'Paused revenue_refresh', before: 'running', after: 'paused', ip: '10.0.7.21' }), + entry(70, { actor: ALEX, action: 'run.adhoc', outcome: 'denied', decisionId: 'dec-8h3n5w', scope: { ...shopProd, subject: 'daily_revenue+' }, grantingRole: null, evidenceRead: false, summary: 'Denied: operator doesn’t include ad-hoc runs in waffle-shop', before: null, after: null, ip: '10.0.7.21' }), + entry(720, { actor: JORDAN, action: 'connection.test', outcome: 'denied', decisionId: 'dec-2p9x6r', scope: { ...shopProd, subject: 'prod' }, grantingRole: null, evidenceRead: false, summary: 'Denied: testing connections needs developer or above', before: null, after: null, ip: '10.0.9.4' }), + entry(days(1) + 60, { actor: SAM, action: 'view.evidence', outcome: 'denied', decisionId: 'dec-6t1v8c', scope: { ...shopProd, subject: 'dim_customers' }, grantingRole: null, evidenceRead: false, summary: 'Denied: finance_operator shows sample rows only on tag:finance,group:marts', before: null, after: null, ip: '10.0.7.33' }), + entry(days(2), { actor: DEMO_USER, action: 'location.manage', outcome: 'allowed', scope: { locationId: LOCATION_SHOP, subject: 'ltok-1' }, grantingRole: role, evidenceRead: false, summary: 'Rotated the location token', before: 'sqbl_a210', after: 'sqbl_3f9a' }), + entry(days(4), { actor: DEMO_USER, action: 'run.rewrite_history', outcome: 'denied', decisionId: 'dec-5r2w9j', scope: { ...shopProd, subject: 'daily_revenue' }, grantingRole: null, evidenceRead: false, summary: 'Denied: no role allowed a full refresh of daily_revenue at the time', before: null, after: null }), + ]; + + // ---- locations ---- + const publishTest = (minutesAgo: number, durationMs: number, message: string): ConnectionTest => ({ + outcome: 'ok', + at: ago(minutesAgo), + durationMs, + trigger: 'publish', + by: null, + message + }); + const loadError = + "ImportError: cannot import name 'campaign_touches' from 'models.attribution' (orchestration/jobs.py, line 12)"; + type LocationExtras = Pick; + const locationExtras: Record = { + [LOCATION_SHOP]: { + createdAt: ago(days(210)), + brokenDeployPolicy: 'fallback', + failedDeploy: null, + lastLoadError: null, + imageId: 'sha256:4be1c07d9a52', + warehouseConnection: 'prod', + connectionTest: publishTest(190, 820, 'Connected as TRANSFORMER to WAFFLE_SHOP; warehouse TRANSFORMING_WH available'), + instances: [ + { id: 'location-waffle-shop-7c2d', imageId: 'sha256:4be1c07d9a52', gitRevision: '7d2e41a', startedAt: ago(192), lastSeenAt: ago(0.2), state: 'live' }, + { id: 'location-waffle-shop-1b90', imageId: 'sha256:ad37e9015b64', gitRevision: '5c1a9e2', startedAt: ago(days(3)), lastSeenAt: ago(193), state: 'gone' } + ] + }, + // A deploy in progress: the new process is loading while the old one keeps running. + [LOCATION_SHOP_STAGING]: { + createdAt: ago(days(180)), + brokenDeployPolicy: 'fallback', + failedDeploy: null, + lastLoadError: null, + imageId: 'sha256:9f02b6e4c1a8', + warehouseConnection: 'staging', + connectionTest: publishTest(35, 760, 'Connected as TRANSFORMER to WAFFLE_SHOP_STAGING'), + instances: [ + { id: 'location-waffle-shop-staging-c7e3', imageId: 'sha256:61d0a4f7b2e9', gitRevision: 'e09a6c3', startedAt: ago(1), lastSeenAt: ago(0.1), state: 'live' }, + { id: 'location-waffle-shop-staging-5a1f', imageId: 'sha256:9f02b6e4c1a8', gitRevision: 'b81c09e', startedAt: ago(36), lastSeenAt: ago(0.1), state: 'live' } + ] + }, + [LOCATION_SHOP_DEV]: { + createdAt: ago(days(14)), + brokenDeployPolicy: 'queue', + failedDeploy: null, + lastLoadError: null, + imageId: null, + warehouseConnection: 'dev', + connectionTest: publishTest(2, 12, 'Opened warehouse.duckdb'), + instances: [{ id: 'location-waffle-shop-dev-local', imageId: 'local', gitRevision: 'b81c09e', startedAt: ago(30), lastSeenAt: ago(0.3), state: 'live' }] + }, + [LOCATION_MARKETING]: { + createdAt: ago(days(120)), + brokenDeployPolicy: 'fallback', + failedDeploy: { gitRevision: '9c4b2d1', imageId: 'sha256:9c4b2d18e07f', attemptedAt: ago(48), error: loadError }, + lastLoadError: loadError, + imageId: 'sha256:3e5f7aa20c19', + warehouseConnection: 'prod', + connectionTest: publishTest(1440, 140, 'Connected to analytics as sqb_builder'), + instances: [ + { id: 'location-marketing-analytics-03f6', imageId: 'sha256:3e5f7aa20c19', gitRevision: '3e5f7aa', startedAt: ago(days(1) + 30), lastSeenAt: ago(0.5), state: 'live' }, + { id: 'location-marketing-analytics-e41a', imageId: 'sha256:9c4b2d18e07f', gitRevision: '9c4b2d1', startedAt: ago(48), lastSeenAt: ago(47), state: 'gone' } + ] + } + }; + + // ---- connections ---- + const snowflakeSettings = (database: string): { settings: SettingField[]; values: Record } => ({ + settings: [ + { key: 'account', type: 'string', required: true, description: 'Account identifier' }, + { key: 'database', type: 'string', required: true, description: 'Database the target builds into' }, + { key: 'schema', type: 'string', default: 'ANALYTICS', description: 'Default schema' }, + { key: 'warehouse', type: 'string', required: true, description: 'Compute warehouse' }, + { key: 'role', type: 'string', default: 'TRANSFORMER', description: 'Session role' }, + { key: 'threads', type: 'int', default: 8, min: 1, max: 64, description: 'Concurrent statements' }, + { key: 'authenticator', type: 'enum', enum: ['snowflake', 'snowflake_jwt', 'externalbrowser'], default: 'snowflake_jwt', description: 'Authentication method' }, + { key: 'private_key', type: 'secret', required: true, secretSource: 'env: SNOWFLAKE_PRIVATE_KEY', description: 'Key-pair private key' } + ], + values: { account: 'example-account', database, warehouse: 'TRANSFORMING_WH' } + }); + const warehouseByLocation: Record = { + [LOCATION_SHOP]: [ + { name: 'prod', target: 'prod', adapter: 'snowflake', purpose: 'target', sourceFile: 'sqlbuild.toml', ...snowflakeSettings('WAFFLE_SHOP'), lastTest: locationExtras[LOCATION_SHOP].connectionTest }, + { + name: 'prod_catalog', + target: 'prod', + adapter: 'snowflake', + purpose: 'catalog', + sourceFile: 'sqlbuild.toml', + settings: snowflakeSettings('WAFFLE_SHOP').settings.map((f) => (f.key === 'role' ? { ...f, default: 'CATALOG_READER' } : f)), + values: { account: 'example-account', database: 'WAFFLE_SHOP', warehouse: 'CATALOG_WH' }, + lastTest: null + } + ], + [LOCATION_SHOP_STAGING]: [ + { name: 'staging', target: 'staging', adapter: 'snowflake', purpose: 'target', sourceFile: 'sqlbuild.toml', ...snowflakeSettings('WAFFLE_SHOP_STAGING'), lastTest: locationExtras[LOCATION_SHOP_STAGING].connectionTest } + ], + [LOCATION_SHOP_DEV]: [ + { + name: 'dev', + target: 'dev', + adapter: 'duckdb', + purpose: 'target', + sourceFile: 'sqlbuild.toml', + settings: [ + { key: 'path', type: 'string', required: true, description: 'Database file' }, + { key: 'threads', type: 'int', default: 4, min: 1, max: 32, description: 'Concurrent statements' } + ], + values: { path: 'warehouse.duckdb' }, + lastTest: locationExtras[LOCATION_SHOP_DEV].connectionTest + } + ], + [LOCATION_MARKETING]: [ + { + name: 'prod', + target: 'prod', + adapter: 'postgres', + purpose: 'target', + sourceFile: 'sqlbuild.toml', + settings: [ + { key: 'host', type: 'string', required: true, description: 'Server host' }, + { key: 'port', type: 'int', default: 5432, min: 1, max: 65535, description: 'Server port' }, + { key: 'dbname', type: 'string', required: true, description: 'Database' }, + { key: 'user', type: 'string', required: true, description: 'Login role' }, + { key: 'password', type: 'secret', required: true, secretSource: 'env: PGPASSWORD', description: 'Login password' } + ], + values: { host: 'db.example.com', dbname: 'analytics', user: 'sqb_builder' }, + lastTest: locationExtras[LOCATION_MARKETING].connectionTest + } + ] + }; + const providers: ProviderConnection[] = [ + { + name: 'orders_api', + type: 'rest', + description: 'Partner orders API used by the raw orders loader.', + sourceFile: 'providers/orders_api.py', + settings: [ + { key: 'base_url', type: 'string', required: true, description: 'API base URL' }, + { key: 'page_size', type: 'int', default: 500, min: 1, max: 5000, description: 'Records per request' }, + { key: 'timeout_seconds', type: 'int', default: 30, min: 1, max: 300, description: 'Request timeout' }, + { key: 'api_key', type: 'secret', required: true, secretSource: 'env: ORDERS_API_KEY', description: 'API key' } + ], + values: { base_url: 'https://orders.example.com/v2' }, + usedBy: { nodes: ['raw__orders'], jobs: ['load_sources', 'nightly_all'], triggers: [] } + }, + { + name: 'export_bucket', + type: 's3', + description: 'Object storage for CSV exports of reporting models.', + sourceFile: 'providers/storage.py', + settings: [ + { key: 'bucket', type: 'string', required: true, description: 'Bucket name' }, + { key: 'prefix', type: 'string', default: 'exports/', description: 'Key prefix' }, + { key: 'region', type: 'string', required: true, description: 'Region' }, + { key: 'compression', type: 'enum', enum: ['none', 'gzip', 'zstd'], default: 'gzip', description: 'File compression' }, + { key: 'secret_access_key', type: 'secret', required: true, secretSource: 'secret store: exports/bucket', description: 'Access key secret' } + ], + values: { bucket: 'example-exports', region: 'eu-west-1' }, + usedBy: { nodes: ['export_revenue_csv'], jobs: ['revenue_refresh', 'reporting_exports'], triggers: [] } + }, + { + name: 'support_desk', + type: 'rest', + description: 'Support ticket system read by the tickets loader.', + sourceFile: 'providers/support_desk.py', + settings: [ + { key: 'base_url', type: 'string', required: true, description: 'API base URL' }, + { key: 'include_closed', type: 'bool', default: false, description: 'Also load closed tickets' }, + { key: 'token', type: 'secret', required: true, secretSource: 'env: SUPPORT_DESK_TOKEN', description: 'Access token' } + ], + values: { base_url: 'https://support.example.com/api', include_closed: true }, + usedBy: { nodes: ['raw__support_tickets'], jobs: ['load_sources'], triggers: [] } + }, + { + name: 'chat_webhook', + type: 'webhook', + description: 'Incoming webhook of the team chat room; sensors post messages to it.', + sourceFile: 'providers/chat.py', + settings: [ + { key: 'url', type: 'secret', required: true, secretSource: 'env: CHAT_WEBHOOK_URL', description: 'Webhook URL' }, + { key: 'timeout_seconds', type: 'int', default: 10, min: 1, max: 60, description: 'Request timeout' } + ], + values: {}, + usedBy: { nodes: [], jobs: [], triggers: [{ id: triggerId('platform_health_webhook'), name: 'platform_health_webhook' }] } + } + ]; + + // ---- maintenance ---- + const migrations: MigrationEvent[] = [ + { eventId: 'mig-5', targetName: 'prod', originModel: 'customer_status_v2', origin: relation('MARTS', 'CUSTOMER_STATUS_V2'), destinationModel: 'customer_status', destination: relation('MARTS', 'CUSTOMER_STATUS'), discovery: 'manual', decision: 'superseded_replace', runId: 'run-77b3', createdAt: ago(days(1) + 340) }, + { eventId: 'mig-4', targetName: 'prod', originModel: 'activity_hourly', origin: relation('ANALYTICS', 'ACTIVITY_HOURLY'), destinationModel: 'hourly_activity_ctx', destination: relation('ANALYTICS', 'HOURLY_ACTIVITY_CTX'), discovery: 'manual', decision: 'redo', runId: 'run-e3f8', createdAt: ago(days(2)) }, + { eventId: 'mig-3', targetName: 'prod', originModel: 'customer_segments', origin: relation('ANALYTICS', 'CUSTOMER_SEGMENTS'), destinationModel: 'customer_segments', destination: relation('MARTS', 'CUSTOMER_SEGMENTS'), discovery: 'manual', decision: 'migrate', runId: 'run-2b70', createdAt: ago(days(4)) }, + { eventId: 'mig-2', targetName: 'prod', originModel: 'revenue_by_day', origin: relation('ANALYTICS', 'REVENUE_BY_DAY'), destinationModel: 'daily_revenue', destination: relation('ANALYTICS', 'DAILY_REVENUE'), discovery: 'manual', decision: 'migrate', runId: 'run-91cd', createdAt: ago(days(9)) }, + { eventId: 'mig-1', targetName: 'prod', originModel: null, origin: relation('STAGING', 'STG_PAYMENT'), destinationModel: 'stg_payments', destination: relation('STAGING', 'STG_PAYMENTS'), discovery: 'automatic', decision: 'renamed', runId: 'run-77b3', createdAt: ago(days(20)) }, + { eventId: 'mig-0', targetName: 'prod', originModel: 'orders_v1', origin: relation('ANALYTICS', 'ORDERS_V1'), destinationModel: 'fact_orders', destination: relation('ANALYTICS', 'FACT_ORDERS'), discovery: 'manual', decision: 'migrate', runId: 'run-c40e', createdAt: ago(days(41)) } + ]; + const step = (eventType: OldNameView['steps'][number]['eventType'], minutesAgo: number, runId: string) => ({ eventType, at: ago(minutesAgo), runId }); + const oldNameViews: OldNameView[] = [ + { + migrationEventId: 'mig-5', destinationModel: 'customer_status', old: relation('MARTS', 'CUSTOMER_STATUS_V2'), new: relation('MARTS', 'CUSTOMER_STATUS'), retention: '30d', + archiveName: '_SQB_ARCHIVE__20261027T010000Z__CUSTOMER_STATUS_V2', columnAliases: [], grantsCopied: 2, expiresAt: null, + dropReason: 'Removed early on request (--drop-old-name-view)', + steps: [step('required', days(1) + 340, 'run-77b3'), step('origin_archived', days(1) + 339, 'run-77b3'), step('view_created', days(1) + 339, 'run-77b3'), step('view_dropped', 600, 'run-0c7d')] + }, + { + migrationEventId: 'mig-4', destinationModel: 'hourly_activity_ctx', old: relation('ANALYTICS', 'ACTIVITY_HOURLY'), new: relation('ANALYTICS', 'HOURLY_ACTIVITY_CTX'), retention: 'false', + archiveName: '_SQB_ARCHIVE__20261027T091500Z__ACTIVITY_HOURLY', columnAliases: [], grantsCopied: null, expiresAt: null, dropReason: null, + steps: [step('required', days(2), 'run-e3f8'), step('origin_archived', days(2), 'run-e3f8')] + }, + { + migrationEventId: 'mig-3', destinationModel: 'customer_segments', old: relation('ANALYTICS', 'CUSTOMER_SEGMENTS'), new: relation('MARTS', 'CUSTOMER_SEGMENTS'), retention: '7d', + archiveName: '_SQB_ARCHIVE__20261025T021200Z__CUSTOMER_SEGMENTS', columnAliases: [], grantsCopied: 1, expiresAt: ago(days(4) - days(7)), dropReason: null, + steps: [step('required', days(4), 'run-2b70'), step('origin_archived', days(4), 'run-2b70'), step('view_created', days(4), 'run-2b70')] + }, + { + migrationEventId: 'mig-2', destinationModel: 'daily_revenue', old: relation('ANALYTICS', 'REVENUE_BY_DAY'), new: relation('ANALYTICS', 'DAILY_REVENUE'), retention: '30d', + archiveName: '_SQB_ARCHIVE__20261020T021500Z__REVENUE_BY_DAY', columnAliases: [{ old: 'amount', new: 'revenue' }, { old: 'day', new: 'revenue_date' }], grantsCopied: 3, + expiresAt: ago(days(9) - days(30)), dropReason: null, + steps: [step('required', days(9), 'run-91cd'), step('origin_archived', days(9), 'run-91cd'), step('view_created', days(9), 'run-91cd')] + }, + { + migrationEventId: 'mig-0', destinationModel: 'fact_orders', old: relation('ANALYTICS', 'ORDERS_V1'), new: relation('ANALYTICS', 'FACT_ORDERS'), retention: '30d', + archiveName: '_SQB_ARCHIVE__20260918T020000Z__ORDERS_V1', columnAliases: [{ old: 'order_total', new: 'amount' }], grantsCopied: 4, expiresAt: ago(days(11)), dropReason: 'Removed at its end date', + steps: [step('required', days(41), 'run-c40e'), step('origin_archived', days(41), 'run-c40e'), step('view_created', days(41), 'run-c40e'), step('view_dropped', days(10), 'run-jan-8b1')] + }, + { + migrationEventId: 'mig-6', destinationModel: 'weekly_orders', old: relation('MARTS', 'ORDERS_WEEKLY'), new: relation('MARTS', 'WEEKLY_ORDERS'), retention: '14d', + archiveName: '_SQB_ARCHIVE__20261012T020000Z__ORDERS_WEEKLY', columnAliases: [], grantsCopied: 0, expiresAt: ago(days(3)), dropReason: null, + steps: [step('required', days(17), 'run-1a05'), step('origin_archived', days(17), 'run-1a05'), step('view_created', days(17), 'run-1a05')] + } + ]; + migrations.push({ eventId: 'mig-6', targetName: 'prod', originModel: 'orders_weekly', origin: relation('MARTS', 'ORDERS_WEEKLY'), destinationModel: 'weekly_orders', destination: relation('MARTS', 'WEEKLY_ORDERS'), discovery: 'automatic', decision: 'migrate', runId: 'run-1a05', createdAt: ago(days(17)) }); + migrations.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + const janitor = (id: string, eventType: 'archive' | 'delete', minutesAgo: number, schema: string, type: 'table' | 'view' | null, original: string | null, archive: string, archivedMinutesAgo: number, runId: string): JanitorEvent => ({ + eventId: id, + eventType, + occurredAt: ago(minutesAgo), + runId, + relationSchema: schema, + relationType: type, + originalQualifiedName: original, + archiveQualifiedName: archive, + archivedAt: ago(archivedMinutesAgo) + }); + const janitorEvents: JanitorEvent[] = [ + janitor('jan-9', 'archive', days(1) + 120, 'ANALYTICS', 'table', 'WAFFLE_SHOP.ANALYTICS.LEGACY_ORDER_ITEMS', 'WAFFLE_SHOP.ANALYTICS._SQB_ARCHIVE__20261028T040000Z__LEGACY_ORDER_ITEMS', days(1) + 120, 'run-jan-9c3'), + janitor('jan-8', 'archive', days(1) + 120, 'MARTS', 'view', 'WAFFLE_SHOP.MARTS.CUSTOMER_COHORTS_OLD', 'WAFFLE_SHOP.MARTS._SQB_ARCHIVE__20261028T040000Z__CUSTOMER_COHORTS_OLD', days(1) + 120, 'run-jan-9c3'), + janitor('jan-7', 'delete', days(1) + 121, 'ANALYTICS', 'table', 'WAFFLE_SHOP.ANALYTICS.PROMO_CODES_2025', 'WAFFLE_SHOP.ANALYTICS._SQB_ARCHIVE__20261013T040000Z__PROMO_CODES_2025', days(16), 'run-jan-9c3'), + janitor('jan-6', 'delete', days(1) + 121, 'ANALYTICS', null, null, 'WAFFLE_SHOP.ANALYTICS._SQB_ARCHIVE__20261012T091500Z__STG__A1B2C3', days(17), 'run-jan-9c3'), + janitor('jan-5', 'delete', days(10), 'ANALYTICS', 'table', 'WAFFLE_SHOP.ANALYTICS.ORDERS_V1', 'WAFFLE_SHOP.ANALYTICS._SQB_ARCHIVE__20260918T020000Z__ORDERS_V1', days(41), 'run-jan-8b1'), + janitor('jan-4', 'archive', days(16), 'ANALYTICS', 'table', 'WAFFLE_SHOP.ANALYTICS.PROMO_CODES_2025', 'WAFFLE_SHOP.ANALYTICS._SQB_ARCHIVE__20261013T040000Z__PROMO_CODES_2025', days(16), 'run-jan-6d0'), + janitor('jan-3', 'archive', days(16), 'STAGING', 'view', 'WAFFLE_SHOP.STAGING.STG_COUPONS', 'WAFFLE_SHOP.STAGING._SQB_ARCHIVE__20261013T040000Z__STG_COUPONS', days(16), 'run-jan-6d0') + ]; + + return { accounts, locationTokens, roles, assignments, ssoMappings, auditEntries, locationExtras, warehouseByLocation, providers, migrations, oldNameViews, janitorEvents }; +} + +/** How the platform-event sensor (see `platform_health_webhook` in operate.ts) handled an event. */ +function reaction(outcome: PlatformEventReaction['outcome'], sequence: number | null, attempts: number, at: string | null): PlatformEventReaction { + return { + triggerId: triggerId('platform_health_webhook'), + triggerName: 'platform_health_webhook', + locationId: LOCATION_SHOP, + outcome, + evaluationId: sequence === null ? null : `eval-platform-health-webhook-${sequence}`, + attempts, + at + }; +} + +/** Enrichment of the shared platform event fixtures, by event ID. */ +function eventDetails(now: number): Record> { + const ago = (minutes: number) => new Date(now - minutes * MIN).toISOString(); + const shop = { locationId: LOCATION_SHOP }; + return { + 'pe-1': { resolvedAt: null, links: [{ kind: 'location', id: LOCATION_MARKETING }], reactions: [reaction('consumed', 75, 1, ago(48))], payload: { failed_revision: '9c4b2d1', running_revision: '3e5f7aa', policy: 'fallback' } }, + 'pe-2': { resolvedAt: null, links: [{ kind: 'trigger', id: triggerId('segments_hourly'), name: 'segments_hourly', ...shop }], reactions: [reaction('consumed', 77, 2, ago(11.5))], payload: { trigger: 'segments_hourly', scheduled_for: ago(12), overdue_by: '12m' } }, + 'pe-3': { resolvedAt: ago(1), links: [], reactions: [], payload: { interval: '60s' } }, + 'pe-4': { resolvedAt: null, links: [], reactions: [reaction('pending', null, 0, null)], payload: { last_holder: 'sqb-server-1', lease_expired_at: ago(2) } }, + 'pe-5': { resolvedAt: null, links: [{ kind: 'job', key: 'revenue_refresh', ...shop }, { kind: 'run', id: 'run-a7c1', ...shop }], reactions: [reaction('pending', null, 0, null)], payload: { job: 'revenue_refresh', attempts: '3', error: 'image pull error' } }, + 'pe-6': { + resolvedAt: null, + links: [{ kind: 'location', id: LOCATION_SHOP_STAGING }], + reactions: [], + payload: { table: '_sqlbuild_janitor_events', error: 'Insufficient privileges to read schema ARCHIVE: the catalog connection role lacks USAGE', last_synced: ago(95) } + } + }; +} + +function historicalEvents(now: number): PlatformEventRecord[] { + const ago = (minutes: number) => new Date(now - minutes * MIN).toISOString(); + const shop = { locationId: LOCATION_SHOP }; + return [ + { id: 'pe-h3', kind: 'launch_failed', severity: 'critical', occurredAt: ago(1500), active: false, locationId: LOCATION_SHOP, subject: 'activity_microbatch', message: 'A run of activity_microbatch stopped reporting in and was marked failed.', resolvedAt: ago(1490), links: [{ kind: 'job', key: 'activity_microbatch', ...shop }, { kind: 'run', id: 'run-e3f8', ...shop }], reactions: [reaction('consumed', 52, 1, ago(1499))], payload: { job: 'activity_microbatch', attempts: '1', error: 'worker lost' } }, + { id: 'pe-h2', kind: 'scheduler_no_leader', severity: 'critical', occurredAt: ago(2 * 1440 + 30), active: false, locationId: null, subject: null, message: 'No server ran the scheduler for 41s while a server restarted.', resolvedAt: ago(2 * 1440 + 29), links: [], reactions: [reaction('consumed', 41, 1, ago(2 * 1440 + 29))], payload: { last_holder: 'sqb-server-2', lease_expired_at: ago(2 * 1440 + 30) } }, + { id: 'pe-h4', kind: 'connection_test_failed', severity: 'warning', occurredAt: ago(2 * 1440 + 400), active: false, locationId: LOCATION_MARKETING, subject: 'prod', message: 'The warehouse connection test after publishing failed: password authentication failed for user sqb_builder.', resolvedAt: ago(2 * 1440 + 380), links: [{ kind: 'location', id: LOCATION_MARKETING }], reactions: [], payload: { connection: 'prod', trigger: 'publish', error: 'password authentication failed' } }, + { id: 'pe-h1', kind: 'tick_overdue', severity: 'warning', occurredAt: ago(3 * 1440), active: false, locationId: LOCATION_SHOP, subject: 'core_build', message: 'The core_build schedule started 4 minutes late.', resolvedAt: ago(3 * 1440 - 4), links: [{ kind: 'trigger', id: triggerId('core_build'), name: 'core_build', ...shop }], reactions: [reaction('consumed', 33, 1, ago(3 * 1440 - 1))], payload: { trigger: 'core_build', scheduled_for: ago(3 * 1440), overdue_by: '4m' } } + ]; +} + +/** Mock implementation of `PlatformApi`, delegated to by `MockApiClient`. */ +export class MockPlatformApi implements PlatformApi { + readonly #ctx: MockPlatformContext; + readonly #data: ReturnType; + readonly #connectionTests = new Map(); + + constructor(ctx: MockPlatformContext) { + this.#ctx = ctx; + this.#data = createPlatformFixtures(ctx.now, ctx.role); + } + + /** Records an audited read of check evidence (`view.evidence`). */ + recordEvidenceRead(scope: Scope, subject: string, summary: string): void { + this.#audit('view.evidence', { ...scope, subject }, summary); + this.#data.auditEntries[this.#data.auditEntries.length - 1].evidenceRead = true; + } + + /** Records an audited read of a check's SQL (`view.sql`). */ + recordSqlRead(scope: Scope, subject: string, summary: string): void { + this.#audit('view.sql', { ...scope, subject }, summary); + } + + // ---- health ---- + + async platformHealth(): Promise { + const { now, events } = this.#ctx; + const ago = (minutes: number) => new Date(now - minutes * MIN).toISOString(); + const ahead = (seconds: number) => new Date(Date.now() + seconds * 1000).toISOString(); + // The heartbeat fires every minute; the mock reports the latest one. + const beat = new Date(Date.now() - ((Date.now() - now + 37_000) % 60_000)).toISOString(); + const noLeader = events === 'all'; + const failing = events === 'all'; + return this.#ctx.reply({ + scheduler: { + lease: noLeader ? null : { holder: 'sqb-server-1', acquiredAt: ago(2 * 1440 + 29), renewedAt: new Date(Date.now() - 4000).toISOString(), expiresAt: ahead(26) }, + instances: [ + { instanceId: 'sqb-server-1', version: this.#ctx.serverVersion, schedulerEnabled: true, startedAt: ago(2 * 1440 + 31), lastSeenAt: noLeader ? ago(3) : ago(0.05) }, + { instanceId: 'sqb-server-2', version: this.#ctx.serverVersion, schedulerEnabled: true, startedAt: ago(2 * 1440 + 28), lastSeenAt: ago(0.1) }, + { instanceId: 'sqb-server-api-1', version: this.#ctx.serverVersion, schedulerEnabled: false, startedAt: ago(5 * 1440), lastSeenAt: ago(0.1) } + ] + }, + heartbeat: { intervalSeconds: 60, lastEmittedAt: beat }, + sink: { + kind: 'webhook', + destinationSource: 'env: SQB_EVENT_SINK_URL', + eventKinds: [], + lastDeliveredAt: failing ? ago(7) : beat, + lastAttemptAt: beat, + pendingEvents: failing ? 6 : 0, + consecutiveFailures: failing ? 4 : 0, + lastError: failing ? 'HTTP 503 Service Unavailable from the sink endpoint' : null + } + }); + } + + async listPlatformEventHistory(query: PlatformEventQuery = {}): Promise { + const details = eventDetails(this.#ctx.now); + const current: PlatformEventRecord[] = this.#ctx.platformEvents().map((event) => ({ + ...event, + ...(details[event.id] ?? { resolvedAt: null, links: [], reactions: [], payload: {} }) + })); + let events = [...current, ...historicalEvents(this.#ctx.now)]; + if (query.kinds?.length) events = events.filter((e) => query.kinds!.includes(e.kind)); + else if (!query.includeHeartbeats) events = events.filter((e) => e.kind !== 'heartbeat'); + if (query.locationId !== undefined) events = events.filter((e) => e.locationId === query.locationId); + if (query.activeOnly) events = events.filter((e) => e.active); + events.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); + if (query.limit) events = events.slice(0, query.limit); + return this.#ctx.reply(events); + } + + // ---- locations ---- + + async listLocations(): Promise { + const details = this.#ctx.locations.map((l) => this.#locationDetail(l)); + return this.#ctx.reply( + details.map((d) => ({ + id: d.id, + name: d.name, + health: d.health, + brokenDeployPolicy: d.brokenDeployPolicy, + failedDeploy: d.failedDeploy, + activeProblems: this.#ctx.platformEvents().filter((e) => e.active && e.locationId === d.id && e.kind !== 'heartbeat').length, + liveInstances: d.instances.filter((i) => i.state === 'live').length, + lastSeenAt: d.instances.map((i) => i.lastSeenAt).sort().at(-1) ?? null, + target: d.target, + snapshot: d.snapshot, + connectionTest: d.connectionTest + })) + ); + } + + async getLocation(locationId: string): Promise { + const location = this.#ctx.locations.find((l) => l.id === locationId); + return this.#ctx.reply(location ? this.#locationDetail(location) : null); + } + + async testConnection(scope: Scope, connection: string): Promise { + this.#require('connection.test'); + const conn = this.#warehouse(scope).find((c) => c.name === connection); + if (!conn) throw new ApiError(`Unknown connection ${connection}`, 404); + const started = Date.now(); + await new Promise((resolve) => setTimeout(resolve, 900)); + const result: ConnectionTest = { + outcome: 'ok', + at: new Date().toISOString(), + durationMs: Date.now() - started, + trigger: 'manual', + by: this.#ctx.user, + message: conn.adapter === 'duckdb' ? `Opened ${conn.values.path}` : `Connected to ${conn.values.database ?? conn.values.dbname} (sqb debug: all checks passed)` + }; + this.#connectionTests.set(`${scope.locationId}/${connection}`, result); + this.#audit('connection.test', { ...scope, subject: connection }, `Tested connection ${connection} (${result.outcome})`); + return this.#ctx.reply(result); + } + + async listLocationTokens(locationId: string): Promise { + this.#require('location.manage'); + return this.#ctx.reply(structuredClone(this.#data.locationTokens.filter((t) => t.locationId === locationId))); + } + + async createLocationToken(locationId: string): Promise> { + this.#require('location.manage'); + if (!this.#ctx.locations.some((l) => l.id === locationId)) throw new ApiError('Unknown location', 404); + const issued = this.#issueLocationToken(locationId); + this.#audit('location.manage', { locationId, subject: issued.token.id }, 'Created a location token', null, issued.token.prefix); + return this.#ctx.reply(issued); + } + + async rotateLocationToken(tokenId: string): Promise> { + this.#require('location.manage'); + const old = this.#data.locationTokens.find((t) => t.id === tokenId && t.state === 'active'); + if (!old) throw new ApiError('Token not found or not active', 404); + old.state = 'revoked'; + old.revokedAt = new Date().toISOString(); + const issued = this.#issueLocationToken(old.locationId); + this.#audit('location.manage', { locationId: old.locationId, subject: issued.token.id }, 'Rotated the location token', old.prefix, issued.token.prefix); + return this.#ctx.reply(issued); + } + + async revokeLocationToken(tokenId: string): Promise { + this.#require('location.manage'); + const token = this.#data.locationTokens.find((t) => t.id === tokenId && t.state === 'active'); + if (!token) throw new ApiError('Token not found or not active', 404); + token.state = 'revoked'; + token.revokedAt = new Date().toISOString(); + this.#audit('location.manage', { locationId: token.locationId, subject: token.id }, 'Revoked a location token', token.prefix, null); + return this.#ctx.reply(structuredClone(token)); + } + + // ---- connections ---- + + async listWarehouseConnections(scope: Scope): Promise { + const warehouse = this.#warehouse(scope).map((c) => ({ + ...c, + lastTest: this.#connectionTests.get(`${scope.locationId}/${c.name}`) ?? c.lastTest + })); + return this.#ctx.reply(structuredClone(warehouse)); + } + + async listProviders(scope: Scope): Promise { + return this.#ctx.reply(structuredClone(SHOP_LOCATIONS.includes(scope.locationId) ? this.#data.providers : [])); + } + + // ---- maintenance ---- + + async maintenanceHistory(scope: Scope): Promise { + if (scope.locationId !== LOCATION_SHOP && scope.locationId !== LOCATION_SHOP_STAGING) { + return this.#ctx.reply({ migrations: [], oldNameViews: [], janitorEvents: [] }); + } + const { migrations, oldNameViews, janitorEvents } = this.#data; + const target = this.#ctx.locations.find((l) => l.id === scope.locationId)?.target.name ?? ''; + const retarget = (rows: T[]) => rows.map((r) => ({ ...r, targetName: target })); + return this.#ctx.reply(structuredClone({ migrations: retarget(migrations), oldNameViews, janitorEvents })); + } + + // ---- people ---- + + async listAccounts(): Promise { + this.#require('user.manage'); + return this.#ctx.reply(structuredClone(this.#data.accounts)); + } + + async disableAccount(name: string): Promise { + this.#require('user.manage'); + const account = this.#data.accounts.find((a) => a.name === name); + if (!account) throw new ApiError(`Unknown account ${name}`, 404); + if (account.name === this.#ctx.user.name) throw new ApiError("You can't disable your own account", 409); + if (account.disabled) throw new ApiError(`${name} is already disabled`, 409); + const sessions = account.activeSessions; + account.disabled = true; + account.activeSessions = 0; + const revoked = sessions === 1 ? '1 session' : `${sessions} sessions`; + this.#audit('user.manage', { locationId: null, subject: name }, `Disabled ${name} and revoked ${revoked}`, 'active', 'disabled'); + return this.#ctx.reply(structuredClone(account)); + } + + async enableAccount(name: string): Promise { + this.#require('user.manage'); + const account = this.#data.accounts.find((a) => a.name === name); + if (!account) throw new ApiError(`Unknown account ${name}`, 404); + if (!account.disabled) throw new ApiError(`${name} is already active`, 409); + account.disabled = false; + this.#audit('user.manage', { locationId: null, subject: name }, `Enabled ${name}`, 'disabled', 'active'); + return this.#ctx.reply(structuredClone(account)); + } + + // ---- roles ---- + + async listRoles(locationId: string): Promise { + this.#require('role.assign'); + const roles = this.#data.roles.filter((r) => roleAvailableAt(r, locationId)).map((r) => this.#withCounts(r)); + return this.#ctx.reply(structuredClone(roles)); + } + + async getRole(locationId: string, name: string): Promise { + this.#require('role.assign'); + const role = this.#data.roles.find((r) => r.name === name && roleAvailableAt(r, locationId)); + if (!role) return this.#ctx.reply(null); + const detail: RoleDetail = { + ...this.#withCounts(role), + baseGrants: baseGrantsOf(role), + selectors: selectorMatches(role), + assignments: this.#data.assignments.filter((a) => a.role === role.name), + definition: definitionText(role) + }; + return this.#ctx.reply(structuredClone(detail)); + } + + async listRoleAssignments(): Promise { + this.#require('role.assign'); + return this.#ctx.reply(structuredClone(this.#data.assignments)); + } + + async createRoleAssignment(request: RoleAssignmentRequest): Promise { + this.#require('role.assign'); + const principal = this.#data.accounts.find((a) => a.name === request.principal); + if (!principal) throw new ApiError(`Unknown account ${request.principal}`, 404); + const role = this.#data.roles.find((r) => r.name === request.role); + if (!role) throw new ApiError(`Unknown role ${request.role}`, 404); + const locationIds = request.scope.locationId === null ? this.#ctx.locations.map((l) => l.id) : [request.scope.locationId]; + if (request.scope.locationId !== null && !roleAvailableAt(role, request.scope.locationId)) { + throw new ApiError(`${role.name} isn't defined in ${this.#locationName(request.scope.locationId)}: it comes from another project's policy file`, 422); + } + const duplicate = this.#data.assignments.some( + (a) => a.principal.name === principal.name && a.role === request.role && a.scope.locationId === request.scope.locationId && a.source === 'manual' + ); + if (duplicate) throw new ApiError('That assignment already exists', 409); + const missing = missingForGrant(this.#access(), this.#ctx.user.name, role, locationIds); + if (missing.length) { + const m = missing[0]; + const more = missing.length > 1 ? ` (and ${missing.length - 1} more)` : ''; + throw new ApiError( + `You can only give roles you hold. ${role.name} includes ${m.action} (${plainAction(m.action)}) on ${m.selector ?? 'every node'} in ${this.#locationName(m.locationId)}, which you don't have${more}.`, + 403 + ); + } + const assignment: RoleAssignment = { + id: `asg-${randomHex(6)}`, + principal: { kind: principal.kind, name: principal.name, displayName: principal.displayName }, + role: request.role, + scope: request.scope, + source: 'manual', + ssoGroup: null, + definedIn: null, + assignedBy: this.#ctx.user, + assignedAt: new Date().toISOString(), + stale: false + }; + this.#data.assignments.push(assignment); + this.#audit('role.assign', { ...request.scope, subject: principal.name }, `Gave ${request.role} to ${principal.name}`, null, request.role); + return this.#ctx.reply(structuredClone(assignment)); + } + + async deleteRoleAssignment(assignmentId: string): Promise { + this.#require('role.assign'); + const index = this.#data.assignments.findIndex((a) => a.id === assignmentId); + if (index < 0) throw new ApiError('Assignment not found', 404); + const assignment = this.#data.assignments[index]; + if (assignment.source === 'sso') throw new ApiError(`This comes from the SSO group ${assignment.ssoGroup}; change the group mapping in the server config`, 409); + if (assignment.source === 'policy') throw new ApiError(`This is set in ${assignment.definedIn}; change it there`, 409); + if (assignment.source === 'built_in') throw new ApiError('Everyone who signs in gets this default role; change default_role in the server config', 409); + const ownAdmin = this.#data.assignments.filter((a) => a.principal.name === this.#ctx.user.name && a.role === 'admin' && !a.stale); + if (assignment.principal.name === this.#ctx.user.name && assignment.role === 'admin' && ownAdmin.length <= 1) { + const scope = { ...assignment.scope, subject: assignment.principal.name }; + this.#audit('role.assign', scope, `Refused removing ${assignment.principal.name}'s last admin assignment`, 'admin', 'admin', 'denied'); + throw new ApiError("You can't remove your own last admin role: you would lose administration immediately. Ask another admin to remove it.", 409); + } + this.#data.assignments.splice(index, 1); + this.#audit('role.assign', { ...assignment.scope, subject: assignment.principal.name }, `Removed ${assignment.role} from ${assignment.principal.name}`, assignment.role, null); + return this.#ctx.reply(undefined); + } + + async listSsoMappings(): Promise { + this.#require('role.assign'); + return this.#ctx.reply(structuredClone(this.#data.ssoMappings)); + } + + + // ---- audit and access ---- + + async listAuditEntries(query: AuditQuery = {}): Promise { + this.#require('audit.view'); + let entries = [...this.#data.auditEntries].sort((a, b) => b.at.localeCompare(a.at)); + if (query.actor) entries = entries.filter((e) => e.actor.name === query.actor); + if (query.action) entries = entries.filter((e) => e.action === query.action); + if (query.outcome) entries = entries.filter((e) => e.outcome === query.outcome); + if (query.evidenceOnly) entries = entries.filter((e) => e.evidenceRead); + if (query.limit) entries = entries.slice(0, query.limit); + return this.#ctx.reply(structuredClone(entries)); + } + + async explainAccess(query: AccessQuery): Promise { + this.#requireOther(query.principal); + if (!this.#data.accounts.some((a) => a.name === query.principal)) throw new ApiError(`Unknown account ${query.principal}`, 404); + return this.#ctx.reply(explain(this.#access(), query)); + } + + async lookupDecision(decisionId: string): Promise { + const id = decisionId.trim(); + const entry = this.#data.auditEntries.find((e) => e.decisionId === id); + if (!entry) throw new ApiError(`No denied request has decision ID ${id}. Check it was copied in full, e.g. dec-4k7q2m.`, 404); + if (entry.actor.name !== this.#ctx.user.name && !this.#can('audit.view')) throw new ApiError('That decision belongs to someone else; looking it up requires audit.view', 403); + const action: Capability = (CAPABILITIES as readonly string[]).includes(entry.action) ? (entry.action as Capability) : 'role.assign'; + return this.#ctx.reply({ + decisionId: id, + at: entry.at, + query: { principal: entry.actor.name, action, locationId: entry.scope.locationId ?? this.#ctx.locations[0].id, subject: entry.scope.subject }, + summary: entry.summary + }); + } + + async effectiveAccess(principal: string): Promise { + this.#requireOther(principal); + if (!this.#data.accounts.some((a) => a.name === principal)) throw new ApiError(`Unknown account ${principal}`, 404); + return this.#ctx.reply(effective(this.#access(), principal)); + } + + async listAccessOverview(): Promise { + if (!this.#can('user.manage') && !this.#can('role.assign')) throw new ApiError('Missing permission: user.manage', 403); + const data = this.#access(); + const overview = this.#data.accounts.map((a) => { + const cells = effective(data, a.name).cells; + const risky = [...new Set(cells.filter((c) => c.coverage !== 'none').map((c) => c.action))].filter((c) => RISKY.includes(c)); + return { principal: a.name, riskyActions: risky }; + }); + return this.#ctx.reply(overview); + } + + // ---- settings ---- + + async serverSettings(): Promise { + return this.#ctx.reply({ + serverVersion: this.#ctx.serverVersion, + storage: 'postgresql', + schemaVersion: 14, + latestSchemaVersion: 14, + schemaMigratedAt: new Date(this.#ctx.now - 5 * DAY).toISOString(), + authMode: 'oidc', + schedulerEnabled: true, + retention: [ + { name: 'Run history', value: '90 days', description: 'Runs, schedule history and their events.' }, + { name: 'Run logs', value: '30 days', description: 'Captured run output.' }, + { name: 'Platform events', value: '30 days', description: 'Problems such as late schedules. Where each sensor is up to is always kept.' }, + { name: 'Security audit log', value: '400 days', description: 'Can’t be changed or deleted early. Forward events to keep them longer.' }, + { name: 'Warehouse statistics', value: '90 days', description: 'Row counts and table sizes, sampled over time.' }, + { name: 'Activity telemetry', value: '14 days', description: 'Page views, kept apart from the audit log.' } + ] + }); + } + + // ---- helpers ---- + + #locationDetail(location: Location): LocationDetail { + const extras = this.#data.locationExtras[location.id]; + return structuredClone({ + id: location.id, + name: location.name, + health: location.health ?? 'healthy', + ...extras, + target: location.target, + snapshot: location.snapshot ?? null, + connectionTest: this.#connectionTests.get(`${location.id}/${extras.warehouseConnection}`) ?? extras.connectionTest + }); + } + + #warehouse(scope: Scope): WarehouseConnection[] { + return this.#data.warehouseByLocation[scope.locationId] ?? []; + } + + #locationName(locationId: string): string { + return this.#ctx.locations.find((l) => l.id === locationId)?.name ?? locationId; + } + + #access(): AccessData { + const { roles, assignments, accounts } = this.#data; + return { roles, assignments, accounts, locations: this.#ctx.locations }; + } + + #withCounts(role: RoleDefinition): RoleDefinition { + const holders = new Set(this.#data.assignments.filter((a) => a.role === role.name && !a.stale).map((a) => a.principal.name)).size; + return { ...role, holders, recentlyMatched: recentlyMatchedCount(role) }; + } + + /** Explaining another account needs role.assign or audit.view. */ + #requireOther(principal: string) { + if (principal !== this.#ctx.user.name && !this.#can('role.assign') && !this.#can('audit.view')) { + throw new ApiError('Checking someone else’s access requires role.assign or audit.view', 403); + } + } + + #issueLocationToken(locationId: string): IssuedToken { + const secret = `sqbl_${randomHex(40)}`; + const token: LocationToken = { + id: `ltok-${randomHex(6)}`, + locationId, + prefix: secret.slice(0, 9), + createdAt: new Date().toISOString(), + createdBy: this.#ctx.user, + lastUsedAt: null, + revokedAt: null, + state: 'active' + }; + this.#data.locationTokens.unshift(token); + return { token: structuredClone(token), secret }; + } + + #audit( + action: AuditEntry['action'], + scope: AuditEntry['scope'], + summary: string, + before: string | null = null, + after: string | null = null, + outcome: AuditEntry['outcome'] = 'allowed' + ) { + this.#data.auditEntries.push({ + id: `aud-${randomHex(8)}`, + at: new Date().toISOString(), + actor: this.#ctx.user, + action, + outcome, + scope, + grantingRole: outcome === 'allowed' ? this.#ctx.role : null, + decisionId: outcome === 'denied' ? `dec-${randomHex(6)}` : null, + evidenceRead: false, + summary, + before, + after, + ip: '127.0.0.1', + requestId: `req-${randomHex(12)}` + }); + } + + #can(capability: Capability): boolean { + return ROLE_CAPABILITIES[this.#ctx.role].includes(capability); + } + + #require(capability: Capability) { + if (!this.#can(capability)) throw new ApiError(`Missing permission: ${capability}`, 403); + } +} + +const RISKY: readonly Capability[] = ['run.rewrite_history', 'run.adhoc', ...ADMIN_ONLY]; + diff --git a/ui/src/lib/api/mock/principals.ts b/ui/src/lib/api/mock/principals.ts new file mode 100644 index 0000000000..fde15804b3 --- /dev/null +++ b/ui/src/lib/api/mock/principals.ts @@ -0,0 +1,13 @@ +import type { Principal } from '../types'; + +export const SCHEDULER: Principal = { kind: 'scheduler', name: 'scheduler', displayName: 'Scheduler' }; +export const SENSOR_DAEMON: Principal = { kind: 'sensor', name: 'sensors', displayName: 'Sensor daemon' }; +export const CI_ACCOUNT: Principal = { kind: 'user', name: 'ci', displayName: 'CI deploys' }; +export const DEMO_USER: Principal = { kind: 'user', name: 'demo', displayName: 'Demo User' }; +export const OPS_USER: Principal = { kind: 'user', name: 'ops-oncall', displayName: 'On-call Operator' }; +export const ALEX: Principal = { kind: 'user', name: 'alex', displayName: 'Alex Rivera' }; +export const SAM: Principal = { kind: 'user', name: 'sam', displayName: 'Sam Okafor' }; +export const JORDAN: Principal = { kind: 'user', name: 'jordan', displayName: 'Jordan Lee' }; +export const RILEY: Principal = { kind: 'user', name: 'riley', displayName: 'Riley Chen' }; +export const REPORTING: Principal = { kind: 'user', name: 'reporting', displayName: 'Reporting dashboards' }; +export const SYSTEM: Principal = { kind: 'system', name: 'system', displayName: 'System' }; diff --git a/ui/src/lib/api/mock/run-detail.ts b/ui/src/lib/api/mock/run-detail.ts new file mode 100644 index 0000000000..49e0fc7b64 --- /dev/null +++ b/ui/src/lib/api/mock/run-detail.ts @@ -0,0 +1,488 @@ +// Derives a mock run's execution detail (nodes, Recall events, statements, +// output) from its record and the clock, the way the server projects it from +// facts. Pure: the same record and clock always give the same detail. + +import type { + CancelProgress, + LaunchVerb, + NodeCounts, + Run, + RunEvent, + RunEventLevel, + RunInvocation, + RunLiveness, + RunNode, + RunNodeStatus, + RunOutputLine, + RunStatement, + RunStatus, + SkippedBecauseOf +} from '../types'; +import { ancestors, snapshotNode, sortTopologically } from './operate-graph'; + +export type RunEnd = 'complete' | 'cancelled' | 'presumed_failed' | 'never_started'; + +export type RunScript = { + verb: LaunchVerb; + select: string[]; + exclude: string[]; + flags: Record; + /** Nodes this run executes; empty for group parents. */ + nodes: string[]; + fail: string[]; + warn: string[]; + errors: Record; + critical: string[]; + plannedMs: number; + /** How the run ends, once terminal. */ + end: RunEnd; + cancel: CancelProgress | null; + /** Last heartbeat; node progress stops here for unresponsive and lost runs. */ + lastHeartbeatAt: number | null; + imageId: string; + snapshotGeneration: number; + /** Concurrency groups the run takes a slot in. */ + concurrencyGroups: string[]; +}; + +export type RunRecord = { run: Run; script: RunScript }; + +const ACTIVE: ReadonlySet = new Set(['queued', 'launched', 'running', 'unresponsive']); +export const isActive = (status: RunStatus) => ACTIVE.has(status); +export const isTerminal = (status: RunStatus) => !ACTIVE.has(status); + +function hash(text: string): number { + let h = 2166136261; + for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619); + return h >>> 0; +} + +const iso = (ms: number) => new Date(ms).toISOString(); +const ms = (value: string | null) => (value ? Date.parse(value) : null); + +type Timed = { key: string; start: number; end: number }; + +/** Natural schedule in relative units: each node starts when its in-run parents finish. */ +function schedule(nodes: string[]): { timed: Timed[]; total: number } { + const order = sortTopologically(nodes); + const set = new Set(order); + const endOf = new Map(); + const timed: Timed[] = []; + for (const key of order) { + const n = snapshotNode(key); + const parents = (n?.upstream ?? []).filter((p) => set.has(p)); + const start = Math.max(0, ...parents.map((p) => endOf.get(p) ?? 0)); + const weight = n?.kind === 'incremental' ? 2.2 : n?.kind === 'table' ? 1.6 : n?.kind === 'loader' ? 2.5 : 1; + const end = start + (1 + (hash(key) % 7)) * weight; + endOf.set(key, end); + timed.push({ key, start, end }); + } + return { timed, total: Math.max(1, ...timed.map((t) => t.end)) }; +} + +/** Failed nodes in the run's group siblings that have finished, for cross-job skips. */ +function siblingFailures(record: RunRecord, siblings: RunRecord[]): Map { + const out = new Map(); + for (const s of siblings) { + if (s.run.id === record.run.id || !isTerminal(s.run.status) || !s.run.startedAt) continue; + for (const key of s.script.fail) out.set(key, { runId: s.run.id, jobKey: s.run.jobKey }); + } + return out; +} + +/** The instant node progress is read at for this run. */ +function clockOf(record: RunRecord, now: number): number { + const { run, script } = record; + let clock = now; + if (run.endedAt) clock = Math.min(clock, Date.parse(run.endedAt)); + if (script.lastHeartbeatAt != null && run.status !== 'running') clock = Math.min(clock, script.lastHeartbeatAt); + if (script.cancel?.sigtermAt) clock = Math.min(clock, Date.parse(script.cancel.sigtermAt)); + return clock; +} + +export function computeNodes(record: RunRecord, siblings: RunRecord[], now: number): RunNode[] { + const { run, script } = record; + if (!script.nodes.length) return []; + const started = ms(run.startedAt); + const terminal = isTerminal(run.status); + const base = (key: string, status: RunNodeStatus): RunNode => ({ + nodeKey: key, + kind: snapshotNode(key)?.kind ?? 'table', + runId: run.id, + jobKey: run.jobKey, + status, + attempt: 1, + startedAt: null, + endedAt: null, + rowsAffected: null, + errorCode: null, + errorMessage: null, + errorLocation: null, + errorDetail: null, + skippedBecauseOf: null, + critical: script.critical.includes(key) + }); + if (started == null) { + const status: RunNodeStatus = terminal ? (run.status === 'cancelled' ? 'cancelled' : 'not_run') : 'pending'; + return sortTopologically(script.nodes).map((k) => base(k, status)); + } + + const { timed, total } = schedule(script.nodes); + const t0 = started + 1500; + const unit = Math.max(200, script.plannedMs - 3000) / total; + const clock = clockOf(record, now); + const failed = new Map(); + for (const [key, src] of siblingFailures(record, siblings)) failed.set(key, { nodeKey: key, ...src, at: started }); + + return timed.map(({ key, start, end }) => { + const s = t0 + start * unit; + const e = t0 + end * unit; + const ups = ancestors(key); + const because = [...failed.values()].filter((f) => ups.has(f.nodeKey)).sort((a, b) => b.at - a.at)[0]; + if (because) { + return { + ...base(key, 'skipped'), + skippedBecauseOf: { nodeKey: because.nodeKey, runId: because.runId, jobKey: because.jobKey }, + endedAt: iso(Math.max(because.at, started)) + }; + } + const finish = (): RunNode => { + const fails = script.fail.includes(key); + const status: RunNodeStatus = fails ? 'failed' : script.warn.includes(key) ? 'warn' : 'success'; + if (fails) failed.set(key, { nodeKey: key, runId: run.id, jobKey: run.jobKey, at: e }); + const message = fails ? (script.errors[key] ?? 'Statement failed in the warehouse') : null; + return { + ...base(key, status), + startedAt: iso(s), + endedAt: iso(e), + rowsAffected: fails || snapshotNode(key)?.kind === 'view' ? null : 120 + (hash(key + run.id) % 48000), + errorCode: fails ? 'E2103' : null, + errorMessage: message, + ...(fails ? failureDetail(key, run, message ?? '') : {}) + }; + }; + if (terminal && script.end === 'complete') return finish(); + if (e <= clock) return finish(); + if (s <= clock) { + if (!terminal) return { ...base(key, 'running'), startedAt: iso(s) }; + if (script.end === 'presumed_failed') + return { + ...base(key, 'failed'), + startedAt: iso(s), + endedAt: run.endedAt, + errorCode: 'worker_lost', + errorMessage: 'Lost contact with the run while this node was building', + errorDetail: 'The run stopped sending heartbeats while this node was building and was marked failed.\nThe warehouse statement may still have finished; rerunning is safe because the write is idempotent.' + }; + return { ...base(key, 'cancelled'), startedAt: iso(s), endedAt: run.endedAt }; + } + if (!terminal) return base(key, 'pending'); + return base(key, script.end === 'cancelled' ? 'cancelled' : 'not_run'); + }); +} + +/** Mock failure location and full text: the model file, and a line inside its main statement. */ +function failureDetail(key: string, run: Run, message: string): Pick { + const file = snapshotNode(key)?.path ?? `models/${key}.sql`; + const line = 4 + (hash(key) % 30); + const queryId = queryIdOf(statementIdOf(run, key)); + return { + errorLocation: { file, line }, + errorDetail: [ + `error[E2103]: ${message}`, + ` --> ${file}:${line}`, + ` |`, + `${String(line).padStart(3)} | ${key.includes('revenue') ? 'sum(amount) / count(distinct order_id) as revenue_per_order' : 'cast(lifetime_value as number(10, 2)) as lifetime_value'}`, + ` | ${'^'.repeat(24)}`, + ``, + `Warehouse query ${queryId} failed after ${1 + (hash(key) % 40)}s.`, + `Statement: ${STATEMENT_KIND[snapshotNode(key)?.kind ?? 'table'] ?? 'create_table_as'} ${key}` + ].join('\n') + }; +} + +export function statementIdOf(run: Run, nodeKey: string): string { + return `stmt-${run.id.replace(/^run-/, '')}-${nodeKey}`; +} + +/** A warehouse-style query ID, stable per statement. */ +export function queryIdOf(statementId: string): string { + const h = (n: number) => hash(`${statementId}:${n}`).toString(16).padStart(8, '0'); + return `${h(1)}-0000-${h(2).slice(0, 4)}-${h(3).slice(0, 4)}-${h(4)}${h(5).slice(0, 4)}`; +} + +export function countNodes(nodes: RunNode[]): NodeCounts { + const c: NodeCounts = { success: 0, warn: 0, failed: 0, skipped: 0, running: 0, pending: 0 }; + for (const n of nodes) { + if (n.status === 'success') c.success++; + else if (n.status === 'warn') c.warn++; + else if (n.status === 'failed') c.failed++; + else if (n.status === 'skipped' || n.status === 'cancelled' || n.status === 'not_run') c.skipped++; + else if (n.status === 'running') c.running++; + else c.pending++; + } + return c; +} + +/** Terminal status from node outcomes: partial when something failed and everything else completed. */ +export function deriveTerminalStatus(end: RunEnd, nodes: RunNode[]): RunStatus { + if (end === 'never_started') return 'failed'; + if (end === 'cancelled') return 'cancelled'; + if (end === 'presumed_failed') return 'presumed_failed'; + const c = countNodes(nodes); + const blocked = c.failed + nodes.filter((n) => n.skippedBecauseOf).length; + if (blocked) return c.success + c.warn > 0 ? 'partial' : 'failed'; + return c.warn ? 'warn' : 'success'; +} + +export function statusReason(status: RunStatus, nodes: RunNode[]): string | null { + const c = countNodes(nodes); + if (status === 'partial') + return c.failed + ? `${c.failed} node${c.failed === 1 ? '' : 's'} failed and ${c.skipped} that depend on ${c.failed === 1 ? 'it' : 'them'} were skipped; the other ${c.success + c.warn} finished` + : `${c.skipped} node${c.skipped === 1 ? '' : 's'} skipped because something they depend on failed; the other ${c.success + c.warn} finished`; + if (status === 'failed' && c.failed) { + const first = nodes.find((n) => n.status === 'failed'); + return first ? `${first.nodeKey} failed: ${first.errorMessage}` : null; + } + if (status === 'failed' && c.skipped) { + const why = nodes.find((n) => n.skippedBecauseOf)?.skippedBecauseOf; + return why ? `Every node was skipped: ${why.nodeKey} failed in run ${why.runId.replace(/^run-/, '')}` : null; + } + return null; +} + +// ---- Recall events, statements and output ---- + +const LEVEL: Record = { + invocation_started: 'info', + invocation_completed: 'info', + invocation_failed: 'error', + resource_attempt_started: 'debug', + resource_attempt_completed: 'info', + resource_attempt_failed: 'error', + resource_attempt_skipped: 'warn', + statement_submitted: 'debug', + statement_started: 'debug', + statement_completed: 'debug', + statement_failed: 'error', + audit_completed: 'info', + requested: 'info', + queued: 'info', + claimed: 'info', + launched: 'info', + cancel_requested: 'warn', + presumed_failed: 'error', + terminated: 'info' +}; + +export function invocationId(run: Run, attempt = 1): string { + return `inv-${run.id.replace(/^run-/, '')}-${attempt}`; +} + +export function commandOf(record: RunRecord): string { + const { script, run } = record; + const parts = [`sqb ${script.verb}`]; + if (script.select.length) parts.push(`--select ${script.select.join(' ')}`); + if (script.exclude.length) parts.push(`--exclude ${script.exclude.join(' ')}`); + for (const [flag, value] of Object.entries(script.flags)) parts.push(value === true ? flag : `${flag} ${value}`); + parts.push(`--target ${run.target}`); + return parts.join(' '); +} + +export function invocations(record: RunRecord): RunInvocation[] { + const { run } = record; + if (!run.startedAt || !record.script.nodes.length) return []; + const exitCode = !run.endedAt + ? null + : run.status === 'cancelled' + ? 143 + : run.status === 'presumed_failed' + ? 137 + : run.status === 'success' || run.status === 'warn' + ? 0 + : 1; + return [ + { + invocationId: invocationId(run), + runId: run.id, + attempt: 1, + command: commandOf(record), + startedAt: run.startedAt, + endedAt: run.endedAt, + exitCode + } + ]; +} + +const STATEMENT_KIND: Partial> = { + view: 'create_view', + table: 'create_table_as', + incremental: 'merge', + seed: 'load_seed', + loader: 'loader_insert' +}; + +export function sqlFor(key: string, kind: string, target: string): string { + const rel = `${target === 'dev' ? 'dev_demo' : 'analytics'}.${key}`; + const n = snapshotNode(key); + const from = (n?.upstream ?? []).map((u) => `analytics.${u}`); + const src = from[0] ?? 'staging.incoming_rows'; + if (kind === 'create_view') return `create or replace view ${rel} as\nselect *\nfrom ${src}`; + if (kind === 'merge') + return `merge into ${rel} as t\nusing (\n select * from ${src}\n where updated_at >= :start_cursor and updated_at < :end_cursor\n) as s\non t.id = s.id\nwhen matched then update set *\nwhen not matched then insert *`; + if (kind === 'load_seed') return `insert into ${rel}\nselect * from read_csv('seeds/${key}.csv')`; + if (kind === 'loader_insert') return `insert into ${rel}\nselect * from staging.${key}__batch`; + return `create or replace table ${rel}__sqb_stage as\nselect *\nfrom ${from.join('\njoin ') || src};\n\nalter table ${rel}__sqb_stage swap with ${rel}`; +} + +export type RunExecution = { events: RunEvent[]; statements: RunStatement[]; output: RunOutputLine[] }; + +/** Mock: SQL capture misses a few statements, as when capture was off or retention dropped the text. */ +export function sqlRecorded(statementId: string): boolean { + return hash(statementId) % 11 !== 0; +} + +export function execution(record: RunRecord, nodes: RunNode[], now?: number): RunExecution { + const { run, script } = record; + type Draft = Omit & { producer?: RunEvent['producer'] }; + const drafts: Draft[] = []; + const statements: RunStatement[] = []; + const output: RunOutputLine[] = []; + const inv = run.startedAt && script.nodes.length ? invocationId(run) : null; + const fact = (type: Draft['type'], at: string | number, message: string) => + drafts.push({ producer: 'scheduler', type, occurredAt: typeof at === 'number' ? iso(at) : at, invocationId: null, nodeKey: null, statementId: null, attempt: null, message }); + const ev = (type: Draft['type'], at: number, nodeKey: string | null, message: string, statementId: string | null = null) => + drafts.push({ type, occurredAt: iso(at), invocationId: inv, nodeKey, statementId, attempt: nodeKey ? 1 : null, message }); + const out = (stream: RunOutputLine['stream'], at: number, text: string) => inv && output.push({ invocationId: inv, stream, at: iso(at), text }); + + const queued = Date.parse(run.queuedAt); + fact('requested', queued - 200, `Requested by ${run.requestedBy.displayName}${run.triggerName ? ` via ${run.triggerName}` : ''}`); + fact('queued', queued, `Queued at priority ${run.priority}`); + if (run.launchedAt) { + const launched = Date.parse(run.launchedAt); + fact('claimed', launched - 300, script.nodes.length ? `Claimed ${script.nodes.length} write-set nodes${script.concurrencyGroups.length ? ` and a slot in ${script.concurrencyGroups.join(', ')}` : ''}` : 'Claimed'); + fact('launched', launched, script.nodes.length ? `Container started from image ${script.imageId.slice(0, 19)}` : `Started ${run.childRunCount} child runs in order`); + } + if (script.cancel) fact('cancel_requested', script.cancel.requestedAt, `Cancel requested by ${script.cancel.requestedBy.displayName}`); + + if (inv && run.startedAt) { + const start = Date.parse(run.startedAt); + ev('invocation_started', start, null, commandOf(record)); + out('stdout', start, `$ ${commandOf(record)}`); + out('stdout', start + 400, `Compiled ${script.nodes.length} nodes against snapshot generation ${script.snapshotGeneration}`); + for (const n of nodes) { + const k = snapshotNode(n.nodeKey)?.kind ?? 'table'; + if (n.status === 'skipped' && n.endedAt) { + const why = n.skippedBecauseOf; + const msg = why ? `Skipped: ${why.nodeKey} failed${why.runId !== run.id ? ` in run ${why.runId.replace(/^run-/, '')}` : ''}` : 'Skipped'; + ev('resource_attempt_skipped', Date.parse(n.endedAt), n.nodeKey, msg); + out('stdout', Date.parse(n.endedAt), `SKIP ${n.nodeKey} ${msg.toLowerCase()}`); + continue; + } + if (!n.startedAt) continue; + const s = Date.parse(n.startedAt); + const e = n.endedAt ? Date.parse(n.endedAt) : null; + ev('resource_attempt_started', s, n.nodeKey, `Started ${k} ${n.nodeKey}`); + const kind = STATEMENT_KIND[k]; + if (kind) { + const statementId = statementIdOf(run, n.nodeKey); + const stEnd = e != null ? e - 120 : null; + const failed = n.status === 'failed'; + const running = n.status === 'running'; + statements.push({ + id: statementId, + runId: run.id, + invocationId: inv, + sequence: statements.length + 1, + nodeKey: n.nodeKey, + kind, + status: running ? 'running' : n.status === 'cancelled' ? 'cancelled' : failed ? 'failed' : 'completed', + startedAt: iso(s + 60), + endedAt: stEnd == null ? null : iso(stEnd), + rowsAffected: n.rowsAffected, + rowsSoFar: running ? Math.round(((now ?? s) - s) / 1000) * (37 + (hash(n.nodeKey) % 400)) : null, + queryId: run.target === 'dev' ? null : queryIdOf(statementId), + sqlRecorded: sqlRecorded(statementId), + error: failed ? (n.errorDetail ?? n.errorMessage) : null + }); + ev('statement_submitted', s + 40, n.nodeKey, `${kind} submitted`, statementId); + ev('statement_started', s + 60, n.nodeKey, `${kind} running`, statementId); + if (stEnd != null) { + if (failed) ev('statement_failed', stEnd, n.nodeKey, n.errorMessage ?? 'Statement failed', statementId); + else if (n.status !== 'cancelled') ev('statement_completed', stEnd, n.nodeKey, n.rowsAffected != null ? `${n.rowsAffected.toLocaleString('en-GB')} rows` : 'Completed', statementId); + } + } + if (e == null) continue; + if (n.status === 'warn') { + ev('audit_completed', e - 60, n.nodeKey, 'not_null(payment_id): 2 failing rows (severity warn)'); + out('stdout', e - 60, `WARN ${n.nodeKey} audit not_null(payment_id): 2 rows`); + } else if (n.status === 'success' && k !== 'task' && k !== 'loader') { + ev('audit_completed', e - 60, n.nodeKey, 'All audits passed'); + } + const dur = ((e - s) / 1000).toFixed(2); + if (n.status === 'failed') { + ev('resource_attempt_failed', e, n.nodeKey, `${n.errorCode}: ${n.errorMessage}`); + out('stdout', e, `FAIL ${n.nodeKey} ${dur}s`); + out('stderr', e, `error[${n.errorCode}] ${n.nodeKey}: ${n.errorMessage}`); + } else if (n.status === 'cancelled') { + ev('resource_attempt_failed', e, n.nodeKey, 'Cancelled: its query was stopped by the cancel'); + out('stderr', e, `${n.nodeKey}: statement cancelled`); + } else { + ev('resource_attempt_completed', e, n.nodeKey, `${n.status === 'warn' ? 'Completed with warnings' : 'Completed'} in ${dur}s`); + out('stdout', e, `OK ${n.nodeKey} ${k} ${dur}s`); + } + } + if (run.endedAt) { + const end = Date.parse(run.endedAt) - 400; + const c = countNodes(nodes); + const summary = `${c.success} ok · ${c.warn} warn · ${c.failed} failed · ${c.skipped} skipped`; + const ok = run.status === 'success' || run.status === 'warn'; + ev(ok ? 'invocation_completed' : 'invocation_failed', end, null, summary); + out(ok ? 'stdout' : 'stderr', end, `${run.status === 'cancelled' ? 'cancelled' : ok ? 'done' : 'failed'} ${summary}`); + } + } + + if (run.status === 'presumed_failed' && run.endedAt) fact('presumed_failed', Date.parse(run.endedAt) - 100, run.statusReason ?? 'No heartbeat; worker killed'); + if (run.endedAt) fact('terminated', run.endedAt, `Terminated: ${run.status}${run.statusReason ? ` (${run.statusReason})` : ''}`); + + const events = drafts + .sort((a, b) => a.occurredAt.localeCompare(b.occurredAt)) + .map((d, i): RunEvent => ({ + id: `${run.id}-e${i + 1}`, + storageId: i + 1, + runId: run.id, + producer: d.producer ?? 'sqb', + type: d.type, + occurredAt: d.occurredAt, + level: LEVEL[d.type], + invocationId: d.invocationId, + nodeKey: d.nodeKey, + statementId: d.statementId, + attempt: d.attempt, + message: d.message + })); + return { events, statements, output: output.sort((a, b) => a.at.localeCompare(b.at)) }; +} + +export function liveness(record: RunRecord, now: number): RunLiveness | null { + const { run, script } = record; + if (!script.nodes.length || !run.launchedAt) return null; + const base = { + workerInstance: `worker-${run.id.slice(-4)}`, + containerId: `c${(hash(run.id) % 0xffffffff).toString(16).padStart(8, '0')}`, + unresponsiveAfterSeconds: 45, + presumedFailedAfterSeconds: 600, + startDeadlineSeconds: 120 + }; + const heartbeat = script.lastHeartbeatAt ?? (run.endedAt ? Date.parse(run.endedAt) - 1000 : now - 4000); + if (!run.startedAt) { + if (isTerminal(run.status)) return { ...base, state: 'never_started', lastHeartbeatAt: null, exitCode: null }; + return { ...base, state: 'healthy', lastHeartbeatAt: null, exitCode: null }; + } + const exitCode = invocations(record)[0]?.exitCode ?? null; + if (run.status === 'presumed_failed') return { ...base, state: 'presumed_failed', lastHeartbeatAt: iso(heartbeat), exitCode }; + if (run.status === 'unresponsive') return { ...base, state: 'unresponsive', lastHeartbeatAt: iso(heartbeat), exitCode: null }; + if (isTerminal(run.status)) return { ...base, state: 'exited', lastHeartbeatAt: iso(heartbeat), exitCode }; + return { ...base, state: 'healthy', lastHeartbeatAt: iso(heartbeat), exitCode: null }; +} diff --git a/ui/src/lib/api/platform-client.ts b/ui/src/lib/api/platform-client.ts new file mode 100644 index 0000000000..e1f7771434 --- /dev/null +++ b/ui/src/lib/api/platform-client.ts @@ -0,0 +1,104 @@ +import type { + AccessDecision, + AccessExplanation, + AccessOverview, + AccessQuery, + Account, + AuditEntry, + AuditQuery, + ConnectionTest, + IssuedToken, + LocationDetail, + LocationSummary, + LocationToken, + MaintenanceHistory, + PlatformEventQuery, + PlatformEventRecord, + PlatformHealth, + ProviderConnection, + RoleAssignment, + RoleAssignmentRequest, + RoleDefinition, + RoleDetail, + EffectiveAccess, + Scope, + ServerSettings, + SsoMapping, + WarehouseConnection +} from './types'; + +/** + * Platform (server status, locations, connections, providers, maintenance) + * and admin (users, roles, audit, settings) endpoints. + * Part of `ApiClient`. + * + * Mutations are audited server-side; the capability noted on each is the + * server's check, mirrored in the UI only to hide or disable controls. + */ +export interface PlatformApi { + // ---- server status and problems (shown on Locations) ---- + /** Scheduler, server replicas, heartbeat and event forwarding. */ + platformHealth(): Promise; + /** Problems recorded as platform events, newest first; by location, or server-wide with `locationId: null`. */ + listPlatformEventHistory(query?: PlatformEventQuery): Promise; + + // ---- locations ---- + listLocations(): Promise; + getLocation(locationId: string): Promise; + /** Runs `sqb debug`'s connection test in the location. `connection.test` */ + testConnection(scope: Scope, connection: string): Promise; + /** `location.manage` */ + listLocationTokens(locationId: string): Promise; + /** `location.manage` */ + createLocationToken(locationId: string): Promise>; + /** Issues a new token and revokes the old one. `location.manage` */ + rotateLocationToken(tokenId: string): Promise>; + /** `location.manage` */ + revokeLocationToken(tokenId: string): Promise; + + // ---- warehouse connections and providers ---- + /** Warehouse connections of the selected target. */ + listWarehouseConnections(scope: Scope): Promise; + /** Providers declared in the project, with where each one is used. Read-only. */ + listProviders(scope: Scope): Promise; + + // ---- maintenance ---- + maintenanceHistory(scope: Scope): Promise; + + // ---- people ---- + /** `user.manage` */ + listAccounts(): Promise; + /** Blocks sign-in and revokes the account's sessions. You can't disable yourself. `user.manage` */ + disableAccount(name: string): Promise; + /** Lets a disabled account sign in again. Sessions revoked on disable stay revoked. `user.manage` */ + enableAccount(name: string): Promise; + + // ---- roles ---- + /** Roles available in a location: built-in, server config and the location's project roles. `role.assign` */ + listRoles(locationId: string): Promise; + /** A role with what its selectors match today and who holds it. `role.assign` */ + getRole(locationId: string, name: string): Promise; + /** `role.assign` */ + listRoleAssignments(): Promise; + /** You can only give a role you hold in that scope. `role.assign` */ + createRoleAssignment(request: RoleAssignmentRequest): Promise; + /** Manual assignments only; SSO, policy and built-in ones follow their source. `role.assign` */ + deleteRoleAssignment(assignmentId: string): Promise; + /** SSO group mappings from the server config. Read-only. `role.assign` */ + listSsoMappings(): Promise; + + // ---- audit and access ---- + /** `audit.view` */ + listAuditEntries(query?: AuditQuery): Promise; + /** Explaining another account requires `role.assign` or `audit.view`. */ + explainAccess(query: AccessQuery): Promise; + /** Looks up a denial by the decision ID in its error message. Your own, or anyone's with `audit.view`. */ + lookupDecision(decisionId: string): Promise; + /** Every action × location for one account. Another account requires `role.assign` or `audit.view`. */ + effectiveAccess(principal: string): Promise; + /** Risky actions per account, for the People and Service accounts tables. `user.manage` or `role.assign` */ + listAccessOverview(): Promise; + + // ---- settings ---- + serverSettings(): Promise; +} diff --git a/ui/src/lib/api/signals.ts b/ui/src/lib/api/signals.ts new file mode 100644 index 0000000000..744ac5b84c --- /dev/null +++ b/ui/src/lib/api/signals.ts @@ -0,0 +1,69 @@ +import type { Scope } from './types'; + +// "Changed" signals: payload-free notifications that something changed on the +// server. Clients refetch through the normal endpoints when one arrives. + +export type ChangedChannel = + /** Refetch everything: sent on connect and whenever notices may have been missed. */ + | 'all' + | 'runs' + | `run:${string}` + | 'ticks' + | 'triggers' + | 'jobs' + | 'checks' + | 'platform_events' + | `snapshot:${string}` + | `node_status:${string}` + | `warehouse_sync:${string}`; + +export type ChangedSignal = { channel: ChangedChannel; at: string }; + +export type ConnectionState = 'connecting' | 'live' | 'reconnecting' | 'offline'; + +export interface ChangeSubscription { + /** Calls `handler` for every signal; returns an unsubscribe function. */ + subscribe(handler: (signal: ChangedSignal) => void): () => void; + /** Calls `handler` with the current connection state and on every change. */ + onConnectionState(handler: (state: ConnectionState) => void): () => void; + /** Follows the selected location's changes; `null` stops following. */ + follow(scope: Scope | null): void; +} + +/** Whether a signal on `channel` should refresh a view listening to `topics`. */ +export function channelMatches(channel: ChangedChannel, topics: readonly string[]): boolean { + if (channel === 'all') return true; + return topics.some((topic) => channel === topic || channel.startsWith(`${topic}:`)); +} + +/** In-process broadcaster used by the mock client (and by tests). */ +export class LocalChangeBus implements ChangeSubscription { + #handlers = new Set<(signal: ChangedSignal) => void>(); + #stateHandlers = new Set<(state: ConnectionState) => void>(); + #state: ConnectionState = 'live'; + + subscribe(handler: (signal: ChangedSignal) => void): () => void { + this.#handlers.add(handler); + return () => this.#handlers.delete(handler); + } + + onConnectionState(handler: (state: ConnectionState) => void): () => void { + this.#stateHandlers.add(handler); + handler(this.#state); + return () => this.#stateHandlers.delete(handler); + } + + follow(): void { + // Every mock signal belongs to the one in-process store. + } + + emit(channel: ChangedChannel): void { + const signal = { channel, at: new Date().toISOString() }; + for (const handler of this.#handlers) handler(signal); + } + + setState(state: ConnectionState): void { + this.#state = state; + for (const handler of this.#stateHandlers) handler(state); + } +} diff --git a/ui/src/lib/api/types.ts b/ui/src/lib/api/types.ts new file mode 100644 index 0000000000..2176c599ce --- /dev/null +++ b/ui/src/lib/api/types.ts @@ -0,0 +1,142 @@ +// Wire types for the SQLBuild server API. +// +// These mirror the server's resource shapes so that a client generated from the +// OpenAPI schema can replace them later. Timestamps are ISO-8601 strings and IDs +// are opaque strings. + +export type Iso = string; + +// ---- identity, roles and capabilities ---- + +export type BuiltInRole = 'viewer' | 'operator' | 'developer' | 'admin'; + +export const CAPABILITIES = [ + 'view', + 'view.sql', + 'view.evidence', + 'job.run', + 'run.cancel', + 'run.rerun', + 'run.rewrite_history', + 'trigger.toggle', + 'sensor.reset_cursor', + 'run.adhoc', + 'connection.test', + 'plan.preview', + 'location.manage', + 'user.manage', + 'role.assign', + 'audit.view' +] as const; +export type Capability = (typeof CAPABILITIES)[number]; + +export type PrincipalKind = 'user' | 'scheduler' | 'sensor' | 'system'; +export type Principal = { + kind: PrincipalKind; + /** Stable handle, e.g. a user name or `scheduler`. */ + name: string; + displayName: string; +}; + +export type CurrentUser = Principal & { + kind: 'user'; + /** The built-in role, when one role describes the person; the server reports actions instead. */ + role?: BuiltInRole; +}; + +export type AuthMode = 'oidc' | 'trusted_proxy' | 'password' | 'disabled'; + +// ---- locations, targets and snapshots ---- + +export type Snapshot = { + id: string; + /** Increases per (location, target); an older compile never replaces a newer one. */ + generation: number; + gitRevision: string; + gitDirty: boolean; + gitAuthor: string; + publishedAt: Iso; + sqbVersion: string; +}; + +/** The location reads SQLBuild's own tables back from the warehouse on its own schedule. */ +export type WarehouseSync = { + lastSyncedAt: Iso | null; + /** Why the latest sync failed; null when it succeeded. */ + lastError: string | null; +}; + +/** The one target a location is deployed against, e.g. `prod` on Snowflake. */ +export type LocationTarget = { + name: string; + /** Null until snapshots record the adapter. */ + adapter: string | null; + /** Where it builds, e.g. `WAFFLE_SHOP on example-account`; never secrets. Null when unknown. */ + connectionSummary: string | null; +}; + +export type LocationHealth = 'healthy' | 'degraded' | 'falling_back' | 'broken'; +/** A project deployed against exactly one target. Staging and dev are separate locations. */ +export type Location = { + /** Opaque server-generated UUID; the name is a mutable label. */ + id: string; + name: string; + /** Null when the server does not report health yet. */ + health: LocationHealth | null; + lastSeenAt: Iso | null; + target: LocationTarget; + /** Null when nothing is deployed; absent when the server does not report it here. */ + snapshot?: Snapshot | null; + warehouseSync: WarehouseSync; + /** Actions allowed somewhere in this location, added to the server-wide `capabilities`. */ + capabilities?: Capability[]; +}; + +export type Scope = { locationId: string }; + +// ---- bootstrap ---- + +export type Bootstrap = { + serverVersion?: string; + authMode: AuthMode; + user: CurrentUser; + capabilities: Capability[]; + locations: Location[]; +}; + +// ---- platform events ---- + +export type PlatformEventKind = + | 'tick_overdue' + | 'scheduler_no_leader' + | 'location_broken' + | 'launch_failed' + | 'connection_test_failed' + | 'warehouse_sync_failed' + | 'heartbeat'; +export type PlatformEventSeverity = 'info' | 'warning' | 'critical'; +export type PlatformEvent = { + id: string; + kind: PlatformEventKind; + severity: PlatformEventSeverity; + occurredAt: Iso; + /** Still unresolved; only active events surface in the banner. */ + active: boolean; + locationId: string | null; + subject: string | null; + message: string; +}; + +// ---- navigation ---- + +export type NavCounts = { + catalogNodes: number; + failingChecks: number; + activeRuns: number; + pausedTriggers: number; + activePlatformEvents: number; +}; + +export * from './types/operate'; +export * from './types/build'; +export * from './types/platform'; diff --git a/ui/src/lib/api/types/build.ts b/ui/src/lib/api/types/build.ts new file mode 100644 index 0000000000..ffd1e1d0fa --- /dev/null +++ b/ui/src/lib/api/types/build.ts @@ -0,0 +1,354 @@ +// Wire types for the BUILD area: the catalog and lineage, checks, the change +// feed, and the overview that summarises them. +// +// Definitions (graph, columns, SQL, column lineage) come from the current +// snapshot. Build state comes from synced warehouse state (fingerprints, audit +// results, source freshness, microbatches, migrations, information schema) +// joined to the scheduler's run nodes, so builds made outside the scheduler +// (CLI, CI) show too. + +import type { Iso, PlatformEvent, Principal } from '../types'; +import type { NodeKind, QueueWait, Run, RunStatus } from './operate'; +import type { MigrationEvent, OldNameView, RelationName } from './platform'; + +// ---- node build state ---- + +/** The outcome of a node's build attempt. Separate from whether it is stale. */ +export type NodeOutcome = 'success' | 'warn' | 'failed' | 'skipped'; + +/** + * Why a node is stale. Derived from the snapshot and synced state, never from + * a plan against the warehouse. + */ +export type StaleReason = + /** The node's own definition differs from what was last built in this target. */ + | { reason: 'changed'; change: 'definition' | 'compiled'; detail: string; since: Iso } + /** An upstream definition changed since this node's last build, so the next build would differ. */ + | { reason: 'changed_upstream'; upstream: string[]; since: Iso } + /** Sources or upstream models have newer data than this node's last build. */ + | { reason: 'behind'; upstream: string[]; detail: string; since: Iso }; + +/** A build recorded in `_sqlbuild_fingerprints`. */ +export type NodeBuild = { + /** The `sqb` run that wrote the fingerprint. */ + fingerprintRunId: string; + /** The scheduler run, when the build was one; null for builds outside the scheduler. */ + runId: string | null; + jobKey: string | null; + builtAt: Iso; + fingerprint: string; +}; + +/** The latest `_sqlbuild_source_freshness` result for a source. */ +export type SourceFreshness = { + status: 'pass' | 'warn' | 'error'; + /** Newest loaded-at value observed in the source. */ + loadedAt: Iso | null; + checkedAt: Iso; + warnAfterSeconds: number | null; + errorAfterSeconds: number | null; +}; + +export type CheckCounts = { total: number; passing: number; warning: number; failing: number }; + +export type CatalogNode = { + key: string; + kind: NodeKind; + /** e.g. `table`, `view`, `incremental · merge`, `incremental · microbatch`; null for sources and tasks. */ + materialization: string | null; + group: string; + tags: string[]; + /** Declared `owner`, or the project default. */ + owner: string[]; + description: string; + path: string; + upstream: string[]; + /** The warehouse object this node builds or reads. */ + relation: RelationName | null; + columnCount: number; + lastBuild: NodeBuild | null; + /** The latest build attempt's outcome, from run nodes and audit results. */ + lastOutcome: NodeOutcome | null; + stale: StaleReason[]; + /** Sources only. */ + sourceFreshness: SourceFreshness | null; + checks: CheckCounts; +}; + +// ---- node detail ---- + +export type ColumnDef = { + name: string; + type: string | null; + description: string; + /** Audits attached to the column, by definition name. */ + audits: string[]; +}; + +/** Raw and compiled SQL of a build, as stored with its fingerprint. */ +export type BuiltDefinition = { build: NodeBuild; raw: string; compiled: string }; + +/** One build attempt of the node: a scheduler run node or a build outside the scheduler. */ +export type NodeRunEntry = { + runId: string | null; + fingerprintRunId: string | null; + jobKey: string | null; + outcome: NodeOutcome; + startedAt: Iso; + durationMs: number | null; + rows: number | null; + message: string | null; +}; + +export type CostUnit = 'credits'; + +/** Cost rollup for the node; null where the adapter reports no cost. */ +export type NodeCost = { last24h: number; perBuild: number; unit: CostUnit }; + +export type WarehouseColumnState = 'match' | 'type_differs' | 'missing' | 'undeclared'; + +/** The synced information-schema object and its sampled statistics. */ +export type WarehouseObject = { + relation: RelationName; + objectType: 'table' | 'view'; + createdAt: Iso; + lastAlteredAt: Iso; + rows: number | null; + bytes: number | null; + columns: { name: string; declaredType: string | null; actualType: string | null; state: WarehouseColumnState }[]; + /** Sampled series, oldest first. */ + stats: { at: Iso; rows: number; bytes: number }[]; + syncedAt: Iso; +}; + +export type MicrobatchState = 'complete' | 'gap' | 'replay_required'; + +/** + * Projection of `_sqlbuild_microbatches` for a model's current relation. Coverage is + * tracked only for concurrent microbatch models; other models have none. + */ +export type MicrobatchCoverage = { + cursorColumn: string; + /** Bounds are timestamp text for `timestamp` cursors and integer text for `integer` cursors. */ + cursorType: 'timestamp' | 'integer'; + batchSize: string; + /** Start of the earliest completed partition. */ + earliest: string; + /** End of the completed range that starts at `earliest` without a hole. */ + contiguousFrontier: string | null; + /** Holes between completed partitions. */ + knownGaps: number; + /** The newest batches, oldest first. */ + batches: { start: string; end: string; state: MicrobatchState; runId: string | null }[]; + /** Older batches were left out of `batches`. */ + batchesTruncated: boolean; +}; + +/** Renames and moves recorded in `_sqlbuild_migrations`, with any old-name views. */ +export type NodeHistory = { + formerNames: string[]; + migrations: MigrationEvent[]; + oldNameViews: OldNameView[]; +}; + +export type NodeJob = { key: string; kind: 'job' | 'group'; triggerIds: string[] }; + +export type CatalogNodeDetail = CatalogNode & { + downstream: string[]; + /** Outcomes of the most recent builds, oldest first, up to 40 (`recent_outcomes`). */ + recentOutcomes: NodeOutcome[]; + lastRows: number | null; + rowsDelta: number | null; + lastDurationMs: number | null; + cost: NodeCost | null; + columns: ColumnDef[]; + /** Current snapshot SQL; null for sources and Python nodes. */ + sql: { raw: string; compiled: string } | null; + /** Python node source, stored by content hash for display. */ + code: { file: string; source: string } | null; + /** As built in this target, newest first. */ + builds: BuiltDefinition[]; + jobs: NodeJob[]; + runs: NodeRunEntry[]; + checkResults: Check[]; + warehouse: WarehouseObject | null; + microbatch: MicrobatchCoverage | null; + history: NodeHistory; +}; + +// ---- column lineage ---- + +export type ColumnLens = 'raw' | 'passthrough' | 'rename' | 'transform'; + +export type ColumnLineage = { + models: { id: string; name: string; kind: string; status: 'ok' | 'fail' | 'warn' | 'stale' | 'src'; columns: { name: string; type: string }[] }[]; + /** `model.column` pairs from upstream to downstream. */ + edges: [string, string, ColumnLens][]; + sql: Record; + /** The defining expression of each column, per model. */ + expressions: Record>; +}; + +// ---- checks (`_sqlbuild_audit_results`) ---- + +export type CheckOutcome = 'pass' | 'warn' | 'fail' | 'error' | 'insufficient' | 'skipped'; +export type CheckSeverity = 'warn' | 'error'; +export type RunScopePhase = 'delta_and_final' | 'final'; +export type EvaluationMode = 'violations' | 'measurement'; +export type CheckKind = 'audit' | 'python_check' | 'sql_test'; +/** What the check is attached to; `end` checks run at the end of a run. */ +export type CheckAttachment = 'model' | 'source' | 'seed' | 'end'; + +export type CheckThreshold = { + level: 'warn' | 'error'; + operator: 'below' | 'above' | 'outside'; + value: number | [number, number]; +}; + +export type CheckEvaluation = { + /** Audit result event ID. */ + id: string; + runId: string | null; + fingerprintRunId: string; + evaluatedAt: Iso; + outcome: CheckOutcome; + violationCount: number | null; + measuredValue: number | null; + evidenceCount: number | null; + evidenceTruncated: boolean; +}; + +/** One audit or check with its latest result. */ +export type Check = { + id: string; + name: string; + /** The audit definition, e.g. `not_null`; null for custom checks. */ + definition: string | null; + kind: CheckKind; + attachment: CheckAttachment; + node: string | null; + column: string | null; + severity: CheckSeverity; + runScopePhase: RunScopePhase; + evaluationMode: EvaluationMode; + thresholds: CheckThreshold[]; + description: string | null; + latest: CheckEvaluation | null; + /** Outcomes of recent evaluations, oldest first. */ + recentOutcomes: CheckOutcome[]; +}; + +export type CheckDetail = Check & { + history: CheckEvaluation[]; +}; + +/** The latest evaluation's SQL and error texts, which quote SQL. Reading them requires `view.sql` and is audited. */ +export type CheckSql = { + evaluationId: string; + executedSql: string | null; + measurementSql: string | null; + evidenceSql: string | null; + executionError: string | null; + evidenceError: string | null; +}; + +/** Sample rows stored with a result. Reading them requires `view.evidence` and is audited. */ +export type CheckEvidence = { + evaluationId: string; + /** Every row key, in sorted order; a row without a key holds null there. */ + columns: string[]; + rows: (string | number | boolean | null)[][]; + truncated: boolean; +}; + +// ---- change feed (snapshot diffs per deploy) ---- + +export type ChangeOp = 'added' | 'removed' | 'changed' | 'renamed'; + +export type NodeChange = { + key: string; + kind: NodeKind | null; + op: ChangeOp; + /** Renames: the previous key. */ + formerKey: string | null; + /** Only the compiled SQL changed, e.g. through a macro or variable. */ + compiledOnly: boolean; + detail: string; + columns: { name: string; op: ChangeOp; detail: string }[]; +}; + +export type OrchestrationChange = { + kind: 'job' | 'group' | 'trigger' | 'sensor'; + key: string; + /** Triggers and sensors: the trigger ID, while it still exists. */ + triggerId: string | null; + op: ChangeOp; + detail: string; +}; + +export type RoleChange = { + role: string; + op: ChangeOp; + detail: string; + /** Nodes the role's grants cover before and after. */ + coverage: { before: number; after: number } | null; +}; + +/** The environment fingerprint (lock file, Python and `sqb` versions) changed. */ +export type EnvironmentChange = { before: string; after: string; detail: string }; + +/** Average cost per run of a job before and after the deploy, from cost rollups. */ +export type CostChange = { jobKey: string; before: number; after: number; unit: CostUnit; runsAfter: number }; + +export type SnapshotChange = { + snapshotId: string; + generation: number; + previousGeneration: number | null; + gitRevision: string; + gitDirty: boolean; + gitAuthor: string; + publishedAt: Iso; + publishedBy: Principal; + nodes: NodeChange[]; + orchestration: OrchestrationChange[]; + roles: RoleChange[]; + environment: EnvironmentChange | null; + /** The first runs after the deploy, oldest first. */ + followingRuns: Pick[]; + /** Null when the adapter has no cost data. */ + costChanges: CostChange[] | null; +}; + +// ---- overview ---- + +export type AttentionRun = Pick; + +export type AttentionItem = + | { kind: 'run_problem'; run: AttentionRun } + | { kind: 'run_unresponsive'; run: AttentionRun; lastHeartbeatAt: Iso | null } + | { kind: 'stale_models'; changed: string[]; behind: string[] } + | { kind: 'stale_sources'; sources: ({ key: string } & SourceFreshness)[] } + | { kind: 'platform_event'; event: PlatformEvent } + | { kind: 'queue_blocked'; runId: string; jobKey: string | null; queuedAt: Iso; waits: QueueWait[] } + | { kind: 'calendar_low'; triggerId: string; triggerName: string; detail: string }; + +export type OverviewMetrics = { + models: { total: number; addedThisWeek: number; removedThisWeek: number }; + runs24h: { total: number; success: number; warn: number; partial: number; failed: number; active: number }; + checks: CheckCounts; + /** Distinct stale nodes, and how many are changed or behind (a node can be both). */ + stale: { nodes: number; changed: number; behind: number; sources: number }; + /** Null when the adapter has no cost data. */ + cost: { last24h: number; baseline: number; baselineDays: number; unit: CostUnit } | null; +}; + +/** Build status per group: current, stale (changed or behind) and failed. */ +export type GroupStatus = { group: string; total: number; current: number; changed: number; behind: number; failed: number }; + +export type Overview = { + metrics: OverviewMetrics; + attention: AttentionItem[]; + groups: GroupStatus[]; + recentChanges: SnapshotChange[]; + latestRun: (Pick & { status: RunStatus }) | null; +}; diff --git a/ui/src/lib/api/types/operate.ts b/ui/src/lib/api/types/operate.ts new file mode 100644 index 0000000000..32f616ccd6 --- /dev/null +++ b/ui/src/lib/api/types/operate.ts @@ -0,0 +1,673 @@ +// Wire types for the OPERATE area: runs, the queue, the timeline, the run +// panel's server check, jobs and job groups, and triggers and sensors. +// +// Execution detail (events, statements, output) mirrors Recall; the scheduler's +// own run facts share the same event stream with `producer = scheduler`. + +import type { Capability, Iso, Principal } from '../types'; + +// ---- runs ---- + +export const RUN_STATUSES = [ + 'queued', + 'launched', + 'running', + 'unresponsive', + 'success', + 'warn', + 'partial', + 'failed', + 'cancelled', + 'presumed_failed' +] as const; +export type RunStatus = (typeof RUN_STATUSES)[number]; + +/** What runs: a job, a job group (parent of one child run per member) or an ad-hoc selection. */ +export const RUN_KINDS = ['job', 'group', 'adhoc'] as const; +export type RunKind = (typeof RUN_KINDS)[number]; + +/** Why the run was requested. Group children inherit their parent's origin. */ +export const RUN_ORIGINS = ['manual', 'schedule', 'sensor', 'rerun'] as const; +export type RunOrigin = (typeof RUN_ORIGINS)[number]; + +export type NodeCounts = { + success: number; + warn: number; + failed: number; + skipped: number; + running: number; + pending: number; +}; + +/** Compute estimate for a finished run; null where the adapter reports no cost. */ +export type RunCost = { + value: number; + unit: 'credits'; + /** Where the figure comes from, e.g. busy-compute estimates per statement. */ + source: string; +}; + +export type Run = { + id: string; + kind: RunKind; + origin: RunOrigin; + status: RunStatus; + statusReason: string | null; + /** Job or group key; null for ad-hoc runs. */ + jobKey: string | null; + /** Set on the child runs of a group run. */ + parentRunId: string | null; + rootRunId: string; + childRunCount: number; + triggerId: string | null; + triggerName: string | null; + tickId: string | null; + /** The scheduled time of the tick that requested this run. */ + tickScheduledFor: Iso | null; + /** Sensor run requests are de-duplicated by run key. */ + runKey: string | null; + rerunOfRunId: string | null; + requestedBy: Principal; + priority: number; + /** Full refresh or an explicit cursor window was requested. */ + rewriteHistory: boolean; + locationId: string; + target: string; + snapshotId: string; + gitRevision: string; + queuedAt: Iso; + launchedAt: Iso | null; + startedAt: Iso | null; + endedAt: Iso | null; + nodeCounts: NodeCounts; + cost: RunCost | null; +}; + +export type RunListQuery = { + jobKey?: string; + statuses?: RunStatus[]; + kinds?: RunKind[]; + origins?: RunOrigin[]; + /** Children of this group run. */ + parentRunId?: string; + /** Hide child runs; group parents carry `childRunCount`. */ + topLevelOnly?: boolean; + triggerId?: string; + since?: Iso; + until?: Iso; + limit?: number; +}; + +// ---- runs list (cursor-paged rows with a plain status line) ---- + +/** Status tabs of the runs list. `active` is queued, launched, running or unresponsive; `failed` includes partial and lost runs. */ +export const RUN_TABS = ['all', 'active', 'queued', 'failed', 'cancelled'] as const; +export type RunTab = (typeof RUN_TABS)[number]; + +/** Structured filter parsed from the list's search tokens; every field narrows (AND), values within a field are OR. */ +export type RunFilter = { + kinds?: RunKind[]; + origins?: RunOrigin[]; + statuses?: RunStatus[]; + jobKeys?: string[]; + /** Schedule names (trigger type `schedule`). */ + schedules?: string[]; + /** Sensor names (trigger type `sensor`). */ + sensors?: string[]; + /** Runs that execute any of these nodes. */ + nodeKeys?: string[]; + /** Run ID prefixes, with or without the `run-` prefix. */ + ids?: string[]; + /** Principal names that requested the run. */ + requestedBy?: string[]; + /** Free text matched against IDs, jobs, triggers, run keys and principals. */ + text?: string; +}; + +export type RunRowQuery = { + tab?: RunTab; + filter?: RunFilter; + /** Queued at or after. */ + since?: Iso; + /** Queued before. */ + until?: Iso; + /** Include group children as their own rows; otherwise only top-level runs. */ + includeChildren?: boolean; + /** Only the children of this group run (inline expansion). */ + parentRunId?: string; + /** Opaque cursor from the previous page. */ + cursor?: string | null; + limit?: number; +}; + +export type RunRow = { + run: Run; + /** Nodes finished (any outcome) out of the run's planned nodes, across children for groups. */ + progress: { done: number; total: number }; + /** Why a queued run waits, as on the Queue view. */ + waits: QueueWait[]; + /** Last heartbeat, set while the run is unresponsive or once it was marked failed for silence. */ + lastContactAt: Iso | null; + /** Heartbeat silence after which the scheduler marks a run failed. */ + lostContactLimitSeconds: number; + /** Cancel requested and not yet finished. */ + cancelRequestedAt: Iso | null; + /** Ad-hoc runs: the selection as typed. */ + selection: string[]; + /** Nodes a rerun from failure would run; empty when nothing failed. */ + rerunFromFailureCount: number; +}; + +export type RunRowPage = { + rows: RunRow[]; + /** Pass back as `cursor` for the next page; null on the last page. */ + nextCursor: string | null; +}; + +/** Counts for the in-flight tabs, under the same filter as the rows. */ +export type RunTabCounts = { active: number; queued: number }; + +export type BulkRunResult = { + /** Runs the request applied to. */ + done: string[]; + failed: { runId: string; error: string }[]; +}; + +// ---- run detail ---- + +export type RunNodeStatus = + | 'pending' + | 'running' + | 'success' + | 'warn' + | 'failed' + | 'skipped' + | 'cancelled' + /** The run ended (lost worker, never started) before this node ran. */ + | 'not_run'; + +export type NodeKind = 'source' | 'loader' | 'seed' | 'view' | 'table' | 'incremental' | 'task'; + +export type SkippedBecauseOf = { + nodeKey: string; + /** The run the failed node belonged to; differs from this node's run across jobs. */ + runId: string; + jobKey: string | null; +}; + +export type RunNode = { + nodeKey: string; + kind: NodeKind; + /** The (child) run that executed this node. */ + runId: string; + jobKey: string | null; + status: RunNodeStatus; + attempt: number; + startedAt: Iso | null; + endedAt: Iso | null; + rowsAffected: number | null; + errorCode: string | null; + errorMessage: string | null; + /** Where in the project the failure points, e.g. the model file and line of the failing statement. */ + errorLocation: { file: string; line: number } | null; + /** Full error text (warehouse message, statement, trace); `errorMessage` is its first line. */ + errorDetail: string | null; + skippedBecauseOf: SkippedBecauseOf | null; + /** Node failure stops the whole group (`critical = true`). */ + critical: boolean; +}; + +export type RunEdge = { from: string; to: string }; + +export const RECALL_EVENT_TYPES = [ + 'invocation_started', + 'invocation_completed', + 'invocation_failed', + 'resource_attempt_started', + 'resource_attempt_completed', + 'resource_attempt_failed', + 'resource_attempt_skipped', + 'statement_submitted', + 'statement_started', + 'statement_completed', + 'statement_failed', + 'audit_completed' +] as const; +export type RecallEventType = (typeof RECALL_EVENT_TYPES)[number]; + +export const SCHEDULER_FACTS = [ + 'requested', + 'queued', + 'claimed', + 'launched', + 'cancel_requested', + 'presumed_failed', + 'terminated' +] as const; +export type SchedulerFact = (typeof SCHEDULER_FACTS)[number]; + +export type RunEventLevel = 'debug' | 'info' | 'warn' | 'error'; + +export type RunEvent = { + id: string; + /** Storage order; equal to commit order, used for paging. */ + storageId: number; + runId: string; + producer: 'sqb' | 'scheduler'; + type: RecallEventType | SchedulerFact; + occurredAt: Iso; + level: RunEventLevel; + invocationId: string | null; + nodeKey: string | null; + statementId: string | null; + attempt: number | null; + message: string; +}; + +export type RunEventPage = { + events: RunEvent[]; + /** Pass back as `after` for the next page; null when there are no more events yet. */ + nextCursor: number | null; +}; + +export type RunStatement = { + id: string; + runId: string; + invocationId: string; + /** 1-based order within the run. */ + sequence: number; + nodeKey: string; + kind: string; + status: 'running' | 'completed' | 'failed' | 'cancelled'; + startedAt: Iso; + endedAt: Iso | null; + rowsAffected: number | null; + /** Rows processed so far while running, when the warehouse reports progress. */ + rowsSoFar: number | null; + /** The warehouse's own ID for the query, for its query history; null when the adapter has none. */ + queryId: string | null; + /** False when SQL capture was off or the text was dropped by retention. */ + sqlRecorded: boolean; + error: string | null; +}; + +/** One statement's SQL, loaded on demand. Null when it was not recorded. */ +export type StatementSql = { statementId: string; sql: string | null }; + +export type RunOutputLine = { + invocationId: string; + stream: 'stdout' | 'stderr'; + at: Iso; + text: string; +}; + +export type RunInvocation = { + invocationId: string; + runId: string; + attempt: number; + command: string; + startedAt: Iso; + endedAt: Iso | null; + exitCode: number | null; +}; + +export type LivenessState = 'healthy' | 'unresponsive' | 'presumed_failed' | 'never_started' | 'exited'; + +export type RunLiveness = { + state: LivenessState; + workerInstance: string | null; + containerId: string | null; + lastHeartbeatAt: Iso | null; + /** Heartbeat silence after which a run is unresponsive, and then presumed failed. */ + unresponsiveAfterSeconds: number; + presumedFailedAfterSeconds: number; + /** No start report within this long after launch means the run never started. */ + startDeadlineSeconds: number; + exitCode: number | null; +}; + +/** cancel_requested → SIGTERM (statements cancelled, exit 143) → SIGKILL after the grace period. */ +export type CancelProgress = { + requestedAt: Iso; + requestedBy: Principal; + sigtermAt: Iso | null; + graceSeconds: number; + sigkillAt: Iso | null; + exitCode: number | null; + terminatedAt: Iso | null; +}; + +export type RunProvenance = { + snapshotId: string; + snapshotGeneration: number; + gitRevision: string; + gitDirty: boolean; + imageId: string; + sqbVersion: string; + /** The frozen selection the run was authorized for. */ + selection: string[]; + exclude: string[]; + command: string; +}; + +export type RunDetail = { + run: Run; + parent: Run | null; + children: Run[]; + nodes: RunNode[]; + edges: RunEdge[]; + invocations: RunInvocation[]; + liveness: RunLiveness | null; + cancel: CancelProgress | null; + provenance: RunProvenance; + /** Why the run is still queued; empty once claimed. */ + waits: QueueWait[]; + /** Nodes a rerun from failure would run: the failed ones plus exactly those skipped. */ + rerunFromFailureNodes: string[]; + /** The run this one reruns. */ + rerunOf: Run | null; + /** Later runs that rerun this one, newest first. */ + reruns: Run[]; +}; + +// ---- queue ---- + +export type QueueWait = + | { kind: 'write_claim'; runId: string; jobKey: string | null; nodeKey: string; otherNodes: number } + | { kind: 'concurrency_group'; group: string; limit: number; holders: string[] } + | { kind: 'priority'; aheadRunIds: string[] } + | { kind: 'upstream_job'; runId: string; jobKey: string } + /** A child run waits for its group run to be claimed. */ + | { kind: 'group_parent'; runId: string; jobKey: string }; + +export type OverlapOutcome = { + policy: OverlapPolicy; + /** Ticks merged into this waiting run (`coalesce`). */ + coveredTicks: number; + detail: string; +}; + +export type QueueEntry = { + run: Run; + position: number; + waits: QueueWait[]; + overlap: OverlapOutcome | null; +}; + +// ---- timeline ---- + +export type TimelineBar = { + /** Null for projected (future) ticks. */ + runId: string | null; + jobKey: string; + kind: RunKind; + origin: RunOrigin; + status: RunStatus | 'projected'; + /** The schedule or calendar a projected tick belongs to. */ + triggerName: string | null; + start: Iso; + /** For running runs, the time the window was read; for projected ticks, start plus typical duration. */ + end: Iso; +}; + +export type TimelineTickMarker = { + tickId: string; + triggerId: string; + triggerName: string; + jobKey: string; + scheduledFor: Iso; + outcome: 'covered' | 'skipped'; + coveredByRunId: string | null; + reason: string | null; +}; + +export type Timeline = { + now: Iso; + jobKeys: string[]; + bars: TimelineBar[]; + markers: TimelineTickMarker[]; +}; + +// ---- snapshot nodes (selector autocomplete) ---- + +export type SnapshotNode = { + key: string; + kind: NodeKind; + group: string; + tags: string[]; + path: string; + /** Has a cursor: incremental models and loaders accept cursor windows. */ + cursor: 'timestamp' | 'integer' | null; + upstream: string[]; +}; + +// ---- ad-hoc runs (the run panel) ---- + +export type LaunchVerb = 'build' | 'test' | 'seed' | 'load' | 'audit' | 'freshness'; + +export type AdhocRunRequest = { + verb: LaunchVerb; + select: string[]; + exclude: string[]; + /** Other flags exactly as typed, e.g. `{ '--full-refresh': true, '--max-microbatches': '4' }`. */ + flags: Record; +}; + +export type RunCheckProblem = { code: string; message: string }; + +export type AdhocRunCheck = { + resolvedNodes: string[]; + /** Nodes the run would write; empty for read-only verbs. */ + writeSet: string[]; + /** `run.adhoc` must cover every node in the write set; otherwise the whole request is refused. */ + adhoc: { required: Capability; allowed: boolean; outOfScope: string[] }; + rewriteHistory: { required: boolean; reasons: string[]; allowed: boolean }; + /** Active runs holding write claims on nodes in the write set: the run would queue behind them. */ + claimConflicts: { runId: string; nodeKey: string }[]; + problems: RunCheckProblem[]; + /** Launchable as requested. */ + ok: boolean; +}; + +/** Cursor window for a launch, typed from the selected nodes' cursor type. */ +export type CursorWindow = { + type: 'timestamp' | 'integer'; + start: string | null; + end: string | null; +}; + +export type JobLaunchOptions = { + window?: CursorWindow; + fullRefresh?: boolean; +}; + +/** `selected` reruns exactly the nodes passed with the request. */ +export type RerunMode = 'all' | 'from_failure' | 'selected'; + +// ---- jobs and job groups ---- + +export type OverlapPolicy = 'coalesce' | 'queue' | 'allow'; +export type OnFailure = 'skip_dependents' | 'skip_downstream_jobs' | 'stop_group'; +export type JobKind = 'job' | 'group'; + +export type RunRef = Pick; + +export type Job = { + key: string; + kind: JobKind; + description: string; + /** Selector for jobs; null for groups. */ + select: string | null; + /** Member jobs for groups. */ + members: string[]; + nodeCount: number; + overlap: OverlapPolicy; + onFailure: OnFailure; + concurrencyGroups: string[]; + priority: number; + owner: string | null; + sourceFile: string; + triggerIds: string[]; + lastRun: RunRef | null; +}; + +export type ConcurrencyGroup = { + key: string; + limit: number; + /** Active runs holding a slot. */ + holders: string[]; +}; + +export type JobGroupMember = { + jobKey: string; + /** Jobs this member waits for: lineage-derived plus declared `after`. */ + upstream: { jobKey: string; source: 'lineage' | 'after' }[]; + /** Position in the derived order; members in the same stage run side by side. */ + stage: number; +}; + +export type JobDetail = Job & { + declaration: { sourceFile: string; line: number; code: string }; + exclude: string[]; + /** Settings are CLI flags validated against the command table. */ + settings: Record; + resolvedNodes: string[]; + concurrency: ConcurrencyGroup[]; + /** Null when the job applies to every target. */ + targets: string[] | null; + groupMembers: JobGroupMember[]; + criticalNodes: string[]; + /** Groups this job belongs to. */ + memberOf: string[]; + /** Nodes with a cursor, so a cursor window applies. */ + cursorType: 'timestamp' | 'integer' | null; +}; + +// ---- triggers and sensors ---- + +export type TriggerState = 'running' | 'paused'; +export type TriggerType = 'schedule' | 'sensor'; +export type TriggerKind = + | 'cron_schedule' + | 'calendar_schedule' + /** Built-in: a job or group finished, with optional node conditions. */ + | 'job_sensor' + /** Built-in: a node or source changed in any run (rebuilt, failed, new data, stale). */ + | 'node_sensor' + /** Built-in condition on platform events, with a code action. */ + | 'platform_sensor' + /** Custom `@sensor` evaluated in the code location. */ + | 'custom_sensor'; + +export const TICK_OUTCOMES = ['requested', 'skipped', 'covered', 'failed'] as const; +export type TickOutcome = (typeof TICK_OUTCOMES)[number]; + +export type Tick = { + id: string; + scheduledFor: Iso; + evaluatedAt: Iso | null; + outcome: TickOutcome; + runId: string | null; + coveredByRunId: string | null; + reason: string | null; + /** Evaluated after the grace period (scheduler down or behind). */ + lateBySeconds: number; + /** Recorded during catch-up after downtime. */ + catchUp: boolean; +}; + +export type Trigger = { + /** Deterministic from (location, target, kind, name). */ + id: string; + name: string; + type: TriggerType; + kind: TriggerKind; + description: string; + /** Job or group the trigger launches; null for sensors that only run code. */ + jobKey: string | null; + /** Human-readable cadence or condition. */ + cadence: string; + cron: string | null; + timezone: string | null; + /** State declared in code. */ + defaultState: TriggerState; + /** Effective runtime state, after any override. */ + state: TriggerState; + nextDueAt: Iso | null; + lastTick: Tick | null; + lastRun: RunRef | null; + sourceFile: string; + /** Code changed since the last evaluation (sensors). */ + definitionChanged: boolean; + /** The watchdog's warning, e.g. an overdue tick or a low calendar horizon. */ + warning: string | null; +}; + +export type TriggerStateEventKind = 'enabled' | 'paused' | 'reset_to_default' | 'cursor_set' | 'cursor_reset'; + +export type TriggerStateEvent = { + id: string; + at: Iso; + kind: TriggerStateEventKind; + actor: Principal; + reason: string | null; + value: string | null; +}; + +export type SensorEvaluationOutcome = 'requested' | 'completed' | 'failed' | 'timed_out' | 'abandoned'; + +export type SensorEvaluation = { + id: string; + sequence: number; + /** The latest phase recorded; `requested` alone means in flight. */ + outcome: SensorEvaluationOutcome; + requestedAt: Iso; + finishedAt: Iso | null; + cursorBefore: string | null; + cursorAfter: string | null; + runRequests: number; + skipReason: string | null; + error: string | null; + definitionFingerprint: string; +}; + +export type SensorRunRequest = { + runKey: string; + runId: string; + evaluationId: string | null; + requestedAt: Iso; + runStatus: RunStatus; +}; + +export type CatchUpPolicy = { mode: 'latest' | 'all'; max: number | null }; + +export type TriggerDetail = Trigger & { + declaration: { sourceFile: string; line: number; code: string }; + targets: string[] | null; + catchUp: CatchUpPolicy | null; + /** Upcoming due times (schedules). */ + upcoming: Iso[]; + /** Calendar schedules: times are computed ahead and stored up to `storedUntil`. */ + calendar: { + horizon: string; + storedUntil: Iso; + lastRefreshedAt: Iso; + lowHorizonWarningBelow: string; + low: boolean; + } | null; + sensor: { + /** Built-in condition or the custom function, as declared. */ + condition: string; + nodeConditions: string[]; + minimumIntervalSeconds: number; + timeoutSeconds: number | null; + cursor: string | null; + cursorUpdatedAt: Iso | null; + currentFingerprint: string; + lastEvaluatedFingerprint: string | null; + } | null; + ticks: Tick[]; + evaluations: SensorEvaluation[]; + runRequests: SensorRunRequest[]; + stateHistory: TriggerStateEvent[]; +}; diff --git a/ui/src/lib/api/types/platform.ts b/ui/src/lib/api/types/platform.ts new file mode 100644 index 0000000000..4c130b04f8 --- /dev/null +++ b/ui/src/lib/api/types/platform.ts @@ -0,0 +1,511 @@ +// Wire types for the Platform (Locations with server status, Connections, +// Providers, Maintenance) and Admin (users, roles, audit, +// settings) areas. + +import type { + AuthMode, + BuiltInRole, + Capability, + Iso, + LocationHealth, + LocationTarget, + PlatformEvent, + Principal, + Snapshot +} from '../types'; + +// ---- health: system status and platform events ---- + +export type SchedulerInstance = { + /** Server replica ID, e.g. the container host name. */ + instanceId: string; + version: string; + /** False for replicas started with `--no-scheduler`. */ + schedulerEnabled: boolean; + startedAt: Iso; + lastSeenAt: Iso; +}; + +export type SchedulerLease = { + holder: string; + acquiredAt: Iso; + renewedAt: Iso; + expiresAt: Iso; +}; + +export type SchedulerStatus = { + /** Null when no replica holds the leader lease. */ + lease: SchedulerLease | null; + instances: SchedulerInstance[]; +}; + +export type HeartbeatStatus = { + intervalSeconds: number; + lastEmittedAt: Iso | null; +}; + +export type SinkKind = 'webhook' | 'kafka'; +/** The server-side platform-event sink. Server config; read-only in the UI. */ +export type SinkStatus = { + kind: SinkKind; + /** Where the destination comes from, never the value, e.g. `env: SQB_EVENT_SINK_URL`. */ + destinationSource: string; + /** Platform event kinds forwarded; empty means all. */ + eventKinds: PlatformEvent['kind'][]; + lastDeliveredAt: Iso | null; + lastAttemptAt: Iso | null; + /** Events recorded but not yet delivered (the sink's cursor lag). */ + pendingEvents: number; + consecutiveFailures: number; + lastError: string | null; +}; + +export type PlatformHealth = { + scheduler: SchedulerStatus; + heartbeat: HeartbeatStatus; + /** Null when no server-side sink is configured. */ + sink: SinkStatus | null; +}; + +export type PlatformEventLink = + | { kind: 'trigger'; id: string; name: string; locationId: string } + | { kind: 'job'; key: string; locationId: string } + | { kind: 'run'; id: string; locationId: string } + | { kind: 'location'; id: string }; + +/** + * How a sensor that consumes platform events handled one event: `consumed` + * once an evaluation advanced its cursor past it, `failed` while the latest + * evaluation covering it failed, `pending` until an evaluation reaches it. + */ +export type PlatformEventReactionOutcome = 'consumed' | 'failed' | 'pending'; + +export type PlatformEventReaction = { + triggerId: string; + triggerName: string; + locationId: string; + outcome: PlatformEventReactionOutcome; + /** The evaluation that consumed the event, or the latest one that tried. */ + evaluationId: string | null; + /** Evaluations that covered the event, including failed attempts. */ + attempts: number; + at: Iso | null; +}; + +export type PlatformEventRecord = PlatformEvent & { + resolvedAt: Iso | null; + links: PlatformEventLink[]; + /** Sensors whose `platform_event` condition matches this event's kind. */ + reactions: PlatformEventReaction[]; + /** Typed payload fields of the event kind, rendered as key/value pairs. */ + payload: Record; +}; + +export type PlatformEventQuery = { + kinds?: PlatformEvent['kind'][]; + activeOnly?: boolean; + /** Events of one location; `null` for server-wide events only (no location). */ + locationId?: string | null; + /** Heartbeats are left out unless asked for. */ + includeHeartbeats?: boolean; + limit?: number; +}; + +// ---- locations ---- + +export type TestOutcome = 'ok' | 'failed'; + +export type BrokenDeployPolicy = 'fallback' | 'queue'; + +export type FailedDeploy = { + gitRevision: string; + imageId: string; + attemptedAt: Iso; + /** The last load error reported by the location while compiling or publishing. */ + error: string; +}; + +export type ConnectionTest = { + outcome: TestOutcome; + at: Iso; + durationMs: number; + /** `publish` when the location ran it after publishing a snapshot. */ + trigger: 'publish' | 'manual'; + by: Principal | null; + message: string; +}; + +export type LocationInstance = { + id: string; + imageId: string; + gitRevision: string; + startedAt: Iso; + lastSeenAt: Iso; + /** `stale` once heartbeats stop; `gone` after deregistering. */ + state: 'live' | 'stale' | 'gone'; +}; + +export type LocationDetail = { + id: string; + name: string; + health: LocationHealth; + createdAt: Iso; + brokenDeployPolicy: BrokenDeployPolicy; + /** The latest deploy that never published; null when the latest deploy is current. */ + failedDeploy: FailedDeploy | null; + lastLoadError: string | null; + instances: LocationInstance[]; + target: LocationTarget; + snapshot: Snapshot | null; + /** Image the current snapshot was published from; runs start from it. Null for local processes. */ + imageId: string | null; + warehouseConnection: string; + connectionTest: ConnectionTest | null; +}; + +export type LocationSummary = Pick & { + /** Unresolved platform events of this location. */ + activeProblems: number; + liveInstances: number; + lastSeenAt: Iso | null; +}; + +// ---- tokens ---- + +export type TokenState = 'active' | 'revoked' | 'expired'; + +export type LocationToken = { + id: string; + locationId: string; + /** Non-secret prefix shown for identification. */ + prefix: string; + createdAt: Iso; + createdBy: Principal; + lastUsedAt: Iso | null; + revokedAt: Iso | null; + state: TokenState; +}; + +/** Returned once on create or rotate; the server stores only a hash. */ +export type IssuedToken = { token: T; secret: string }; + +// ---- connections ---- + +export type SettingField = { + key: string; + type: 'string' | 'int' | 'bool' | 'enum' | 'secret'; + required?: boolean; + default?: string | number | boolean; + enum?: string[]; + min?: number; + max?: number; + description?: string; + /** For secrets: where the value comes from, e.g. `env: WAREHOUSE_PASSWORD`. */ + secretSource?: string; +}; + +export type WarehouseConnection = { + name: string; + target: string; + adapter: string; + /** `catalog_connection` override used by the warehouse sync, if any. */ + purpose: 'target' | 'catalog'; + sourceFile: string; + settings: SettingField[]; + /** Configured values; secrets are never returned. */ + values: Record; + lastTest: ConnectionTest | null; +}; + +/** A connection to something other than the warehouse (an API, object storage, a chat webhook), declared in project code. */ +export type ProviderConnection = { + name: string; + type: string; + description: string; + sourceFile: string; + settings: SettingField[]; + values: Record; + /** `triggers` are sensors whose code uses the provider, by trigger ID and name. */ + usedBy: { nodes: string[]; jobs: string[]; triggers: { id: string; name: string }[] }; +}; + +// ---- maintenance (synced warehouse state tables) ---- + +export type RelationName = { database: string | null; schema: string; name: string }; + +/** Recorded decisions in `_sqlbuild_migrations`. */ +export type MigrationDecision = 'migrate' | 'redo' | 'superseded_replace' | 'forced_replace' | 'renamed'; + +/** One row of `_sqlbuild_migrations`. */ +export type MigrationEvent = { + eventId: string; + targetName: string; + /** Null when the move was discovered from the warehouse rather than declared. */ + originModel: string | null; + origin: RelationName; + destinationModel: string; + destination: RelationName; + discovery: 'manual' | 'automatic'; + decision: MigrationDecision; + runId: string; + createdAt: Iso; +}; + +export type OldNameEventType = 'required' | 'origin_archived' | 'view_created' | 'view_dropped'; +export type OldNameStep = { eventType: OldNameEventType; at: Iso; runId: string }; + +/** The projection of one move's `_sqlbuild_old_name_views` rows. */ +export type OldNameView = { + migrationEventId: string; + destinationModel: string; + old: RelationName; + new: RelationName; + /** Retention fixed when the move was recorded, e.g. `30d`, or `false` when no view is kept. */ + retention: string; + archiveName: string | null; + columnAliases: { old: string; new: string }[]; + grantsCopied: number | null; + expiresAt: Iso | null; + dropReason: string | null; + steps: OldNameStep[]; +}; + +/** One row of `_sqlbuild_janitor_events`. */ +export type JanitorEvent = { + eventId: string; + eventType: 'archive' | 'delete'; + occurredAt: Iso; + runId: string; + relationSchema: string; + relationType: 'table' | 'view' | null; + originalQualifiedName: string | null; + archiveQualifiedName: string; + archivedAt: Iso; +}; + +export type MaintenanceHistory = { + migrations: MigrationEvent[]; + oldNameViews: OldNameView[]; + janitorEvents: JanitorEvent[]; +}; + +// ---- users and roles ---- + +export type AccountSource = 'oidc' | 'trusted_proxy' | 'password'; + +export type Account = Principal & { + kind: 'user'; + email: string | null; + source: AccountSource; + ssoGroups: string[]; + createdAt: Iso; + lastSeenAt: Iso | null; + disabled: boolean; + /** Signed-in browser sessions; disabling the account revokes them. */ + activeSessions: number; +}; + +/** How much an action can do: look, operate runs, change warehouse data, or administer access. */ +export type PermissionRisk = 'read' | 'operate' | 'changes_data' | 'administer'; + +/** + * Actions on nodes. `nodes` entries are selectors (union); a comma inside an + * entry intersects, e.g. `tag:finance,group:marts`. `*` is every node. + */ +export type RoleGrant = { nodes: string[]; permissions: Capability[] }; + +export type RoleSource = 'built_in' | 'server_config' | 'project'; +export type RoleDefinition = { + name: string; + source: RoleSource; + /** Where the role is defined, `path:line`; null for built-in roles. */ + definedIn: string | null; + description: string; + /** The built-in role a custom role starts from (flat: no deeper inheritance). Null for built-in roles. */ + extends: BuiltInRole | null; + /** Built-in roles: their actions on every node. Custom roles: the additions on top of the base role. */ + grants: RoleGrant[]; + /** People holding the role somewhere. */ + holders: number; + /** Nodes the role's selectors started matching in the last 7 days. */ + recentlyMatched: number; +}; + +/** What one selector of a role matches in the current snapshot. */ +export type SelectorMatch = { + selector: string; + nodes: string[]; + /** Nodes matched now that weren't 7 days ago. */ + newSinceLastWeek: string[]; +}; + +export type RoleDetail = RoleDefinition & { + /** The base role's actions on every node; empty for built-in roles. */ + baseGrants: RoleGrant[]; + selectors: SelectorMatch[]; + assignments: RoleAssignment[]; + /** The role's definition as written, read-only. */ + definition: string; +}; + +/** `locationId: null` applies everywhere. */ +export type AssignmentScope = { locationId: string | null }; + +/** + * `manual`: given in this UI. `sso`: from an SSO group mapping in the server + * config. `policy`: declared in a policy file. `built_in`: the server's + * default role for everyone who signs in. Only manual ones can be removed here. + */ +export type AssignmentSource = 'manual' | 'sso' | 'policy' | 'built_in'; + +export type RoleAssignment = { + id: string; + principal: Principal; + role: string; + scope: AssignmentScope; + source: AssignmentSource; + /** For SSO-derived assignments: the mapped group. */ + ssoGroup: string | null; + /** For policy and built-in assignments: where they are set, `path:line`. */ + definedIn: string | null; + assignedBy: Principal | null; + assignedAt: Iso; + /** The role no longer exists in code; the assignment shows but grants nothing. */ + stale: boolean; +}; + +/** You can only give roles you hold in that scope. */ +export type RoleAssignmentRequest = { principal: string; role: string; scope: AssignmentScope }; + +/** An SSO group mapped to a role in the server config. Read-only. */ +export type SsoMapping = { + group: string; + role: string; + scope: AssignmentScope; + definedIn: string; + /** Members seen when they last signed in. Zero when nobody in the group has signed in yet. */ + membersSynced: number; + lastSyncedAt: Iso | null; +}; + +// ---- audit ---- + +export type AuditOutcome = 'allowed' | 'denied'; +export type AuditEntry = { + id: string; + at: Iso; + actor: Principal; + action: Capability | 'session.login'; + outcome: AuditOutcome; + scope: AssignmentScope & { subject: string | null }; + /** Role that granted the action, or for denials null. */ + grantingRole: string | null; + /** Denials: the decision ID returned in the permission error, for the access checker. */ + decisionId: string | null; + evidenceRead: boolean; + summary: string; + before: string | null; + after: string | null; + ip: string; + requestId: string; +}; + +export type AuditQuery = { + actor?: string; + action?: string; + outcome?: AuditOutcome; + evidenceOnly?: boolean; + limit?: number; +}; + +// ---- access checker ---- + +export type AccessQuery = { + principal: string; + action: Capability; + locationId: string; + /** Optional job, group, node or node selector the action applies to. */ + subject: string | null; +}; + +/** One assignment → role → grant → scope chain that allows the action. */ +export type AccessGrantPath = { + assignmentId: string; + assignmentSource: AssignmentSource; + ssoGroup: string | null; + definedIn: string | null; + scope: AssignmentScope; + role: string; + /** `base`: from the built-in role the role extends (or is); `addition`: one of the role's additions. */ + via: 'base' | 'addition'; + baseRole: BuiltInRole | null; + /** The grant's selector; null when it covers every node. */ + selector: string | null; +}; + +export type AccessNearMissReason = 'other_nodes' | 'other_location' | 'stale' | 'disabled' | 'admin_only'; +/** The closest thing they have that doesn't cover this request. */ +export type AccessNearMiss = { + reason: AccessNearMissReason; + role: string | null; + assignmentId: string | null; + /** Plain sentence, e.g. "Has Run jobs on group:reporting, not on daily_revenue". */ + detail: string; +}; + +export type AccessExplanation = { + query: AccessQuery; + allowed: boolean; + /** Every chain that allows the action (grants add up across roles). */ + grantedBy: AccessGrantPath[]; + nearMisses: AccessNearMiss[]; + /** Roles that would allow it if assigned here. */ + wouldBeGrantedBy: string[]; + summary: string; +}; + +/** A denied request, looked up by the decision ID from its error message. */ +export type AccessDecision = { + decisionId: string; + at: Iso; + query: AccessQuery; + summary: string; +}; + +/** One action at one location in someone's effective access. */ +export type EffectiveAccessCell = { + action: Capability; + locationId: string; + /** `all`: every node; `some`: only the listed selectors. */ + coverage: 'all' | 'some' | 'none'; + selectors: string[]; + roles: string[]; +}; + +export type EffectiveAccess = { + principal: string; + locationIds: string[]; + cells: EffectiveAccessCell[]; +}; + +/** Per-account access facts for the People and Service accounts tables. */ +export type AccessOverview = { + principal: string; + /** Actions that change data or administer access, anywhere. */ + riskyActions: Capability[]; +}; + +// ---- settings ---- + +export type RetentionSetting = { name: string; value: string; description: string }; + +export type ServerSettings = { + serverVersion: string; + storage: 'postgresql' | 'sqlite'; + schemaVersion: number; + latestSchemaVersion: number; + schemaMigratedAt: Iso; + authMode: AuthMode; + schedulerEnabled: boolean; + retention: RetentionSetting[]; +}; diff --git a/ui/src/lib/assets/favicon.svg b/ui/src/lib/assets/favicon.svg new file mode 100644 index 0000000000..cc5dc66a3a --- /dev/null +++ b/ui/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/ui/src/lib/build-area/attention.ts b/ui/src/lib/build-area/attention.ts new file mode 100644 index 0000000000..06a7444a9e --- /dev/null +++ b/ui/src/lib/build-area/attention.ts @@ -0,0 +1,107 @@ +// Presentation of the overview's attention items. Pure; no side effects. + +import type { AttentionItem } from '$lib/api'; +import { describeWait } from '$lib/operate/labels'; +import { eventKindLabel, problemHref } from '$lib/platform/labels'; +import { formatAgo } from '$lib/format/time'; +import { runStatusLabel } from '$lib/status'; + +export type AttentionTone = 'error' | 'warning' | 'stale' | 'info'; + +export type AttentionView = { + key: string; + tone: AttentionTone; + title: string; + detail: string; + since: string; + href: string; + action: string; +}; + +const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`; +const minutes = (seconds: number | null) => (seconds === null ? '—' : seconds % 3600 === 0 ? `${seconds / 3600}h` : `${Math.round(seconds / 60)}m`); + +export function attentionView(item: AttentionItem, now: number): AttentionView { + switch (item.kind) { + case 'run_problem': + return { + key: `run:${item.run.id}`, + tone: 'error', + title: `${item.run.jobKey ?? 'Ad-hoc run'} ${runStatusLabel[item.run.status].toLowerCase()}`, + detail: item.run.statusReason ?? item.run.id, + since: formatAgo(item.run.endedAt, now), + href: `/runs/${item.run.id}`, + action: 'View run' + }; + case 'run_unresponsive': + return { + key: `unresponsive:${item.run.id}`, + tone: 'warning', + title: `${item.run.jobKey ?? item.run.id} is unresponsive`, + detail: item.lastHeartbeatAt ? `Not heard from since ${formatAgo(item.lastHeartbeatAt, now)}` : 'Not heard from yet', + since: formatAgo(item.run.startedAt, now), + href: `/runs/${item.run.id}`, + action: 'View run' + }; + case 'stale_models': { + const total = new Set([...item.changed, ...item.behind]).size; + const parts = [item.changed.length && `${item.changed.length} changed`, item.behind.length && `${item.behind.length} behind`].filter(Boolean); + return { + key: 'stale', + tone: 'stale', + title: `${plural(total, 'node')} ${total === 1 ? 'is' : 'are'} stale`, + detail: `${parts.join(' · ')} · ${[...new Set([...item.changed, ...item.behind])].slice(0, 4).join(', ')}`, + since: 'since the last build', + href: '/catalog?stale=1', + action: 'View in catalog' + }; + } + case 'stale_sources': { + const first = item.sources[0]; + return { + key: 'sources', + tone: item.sources.some((s) => s.status === 'error') ? 'error' : 'warning', + title: `${plural(item.sources.length, 'source')} past the freshness threshold`, + detail: item.sources.map((s) => `${s.key} loaded ${formatAgo(s.loadedAt, now)}`).join(' · '), + since: `${first.status} after ${minutes(first.status === 'error' ? first.errorAfterSeconds : first.warnAfterSeconds)}`, + href: '/catalog?kind=source', + action: 'View sources' + }; + } + case 'platform_event': + return { + key: `event:${item.event.id}`, + tone: item.event.severity === 'critical' ? 'error' : item.event.severity === 'warning' ? 'warning' : 'info', + title: `${eventKindLabel[item.event.kind]}${item.event.subject ? ` · ${item.event.subject}` : ''}`, + detail: item.event.message, + since: formatAgo(item.event.occurredAt, now), + href: problemHref(item.event), + action: item.event.locationId ? 'View location' : 'View server status' + }; + case 'queue_blocked': + return { + key: `queue:${item.runId}`, + tone: 'warning', + title: `${item.jobKey ?? item.runId} is waiting in the queue`, + detail: item.waits.map((w) => describeWait(w).map((p) => p.text).join('')).join(' · '), + since: `queued ${formatAgo(item.queuedAt, now)}`, + href: '/runs?view=queue', + action: 'View queue' + }; + case 'calendar_low': + return { + key: `calendar:${item.triggerId}`, + tone: 'warning', + title: `${item.triggerName}: calendar horizon is low`, + detail: item.detail, + since: '', + href: `/triggers/${item.triggerId}`, + action: 'View trigger' + }; + } +} + +/** Error first, then warnings, stale and info. */ +export function attentionRank(tone: AttentionTone): number { + return { error: 0, warning: 1, stale: 2, info: 3 }[tone]; +} diff --git a/ui/src/lib/build-area/changes.ts b/ui/src/lib/build-area/changes.ts new file mode 100644 index 0000000000..dba0fbe9de --- /dev/null +++ b/ui/src/lib/build-area/changes.ts @@ -0,0 +1,16 @@ +// Summaries of change-feed entries. Pure; no side effects. + +import type { SnapshotChange } from '$lib/api'; + +const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`; + +/** e.g. "3 nodes · 1 trigger · 1 role · dependencies". */ +export function summarizeChange(change: SnapshotChange): string { + const parts: string[] = []; + if (change.nodes.length) parts.push(count(change.nodes.length, 'node')); + const orchestration = change.orchestration.length; + if (orchestration) parts.push(count(orchestration, 'job or trigger', 'jobs and triggers')); + if (change.roles.length) parts.push(count(change.roles.length, 'role')); + if (change.environment) parts.push('environment'); + return parts.length ? parts.join(' · ') : 'No definition changes'; +} diff --git a/ui/src/lib/build-area/labels.ts b/ui/src/lib/build-area/labels.ts new file mode 100644 index 0000000000..f0d09e08aa --- /dev/null +++ b/ui/src/lib/build-area/labels.ts @@ -0,0 +1,175 @@ +// Labels, tones and small mappings for the BUILD area's vocabulary. Pure; no side effects. + +import Database from '@lucide/svelte/icons/database'; +import Download from '@lucide/svelte/icons/download'; +import Eye from '@lucide/svelte/icons/eye'; +import GitMerge from '@lucide/svelte/icons/git-merge'; +import Layers from '@lucide/svelte/icons/layers'; +import Sprout from '@lucide/svelte/icons/sprout'; +import Table2 from '@lucide/svelte/icons/table-2'; +import Workflow from '@lucide/svelte/icons/workflow'; +import type { + BuiltDefinition, + CatalogNode, + ChangeOp, + CheckAttachment, + CheckThreshold, + EvaluationMode, + NodeBuild, + NodeKind, + RunScopePhase, + WarehouseColumnState +} from '$lib/api'; +import type { SqlVersion } from '$lib/sql/versions'; +import { formatAgo, formatStamp } from '$lib/format/time'; + +export const nodeKindLabel: Record = { + source: 'Source', + loader: 'Loader', + seed: 'Seed', + view: 'View', + table: 'Table', + incremental: 'Incremental', + task: 'Task' +}; + +export const nodeKindPlural: Record = { + source: 'Sources', + loader: 'Loaders', + seed: 'Seeds', + view: 'Views', + table: 'Tables', + incremental: 'Incremental', + task: 'Tasks' +}; + +export const NODE_KINDS: NodeKind[] = ['source', 'loader', 'seed', 'view', 'table', 'incremental', 'task']; + +/** Icon key used by the lineage cards, from the node kind and materialization. */ +export function nodeIconKey(node: Pick): string { + if (node.kind === 'incremental') return node.materialization?.includes('microbatch') ? 'microbatch' : node.materialization?.includes('merge') ? 'merge' : 'delete_insert'; + if (node.kind === 'task') return 'task'; + return node.kind; +} + +export const nodeIcon: Record = { + source: Database, + loader: Download, + seed: Sprout, + view: Eye, + table: Table2, + incremental: Layers, + merge: GitMerge, + delete_insert: Layers, + microbatch: Layers, + task: Workflow +}; + +/** `incremental · merge` style kind line, falling back to the node kind. */ +export function kindLine(node: Pick): string { + return node.materialization ?? nodeKindLabel[node.kind].toLowerCase(); +} + +export const runScopePhaseLabel: Record = { + delta_and_final: 'Each new batch before it is written, then the whole table', + final: 'Whole table, after writing' +}; + +export const evaluationModeLabel: Record = { + violations: 'Fails if any rows break the rule', + measurement: 'Measures a value' +}; + +export const attachmentLabel: Record = { + model: 'Model', + source: 'Source', + seed: 'Seed', + end: 'End of run' +}; + +export function thresholdLabel(t: CheckThreshold): string { + const value = Array.isArray(t.value) ? `${t.value[0]}–${t.value[1]}` : String(t.value); + return `${t.level} ${t.operator} ${value}`; +} + +export const changeOpLabel: Record = { + added: 'Added', + removed: 'Removed', + changed: 'Changed', + renamed: 'Renamed' +}; + +export const changeOpTone: Record = { + added: 'var(--sb-success)', + removed: 'var(--sb-error)', + changed: 'var(--sb-stale)', + renamed: 'var(--primary)' +}; + +export const warehouseColumnLabel: Record = { + match: 'Matches', + type_differs: 'Type differs', + missing: 'Missing in warehouse', + undeclared: 'Not declared' +}; + +export const warehouseColumnTone: Record = { + match: 'var(--sb-success)', + type_differs: 'var(--sb-warning)', + missing: 'var(--sb-stale)', + undeclared: 'var(--sb-text-faint)' +}; + +/** Short ID for a fingerprint run, which is a UUID or an invocation ID. */ +export function shortRunId(id: string): string { + return id.length > 12 ? id.slice(0, 8) : id; +} + +/** "run-4f2a" for scheduler builds, "from the command line · 0f9a3c27" otherwise. */ +export function buildSourceLabel(build: Pick): string { + return build.runId ?? `from the command line · ${shortRunId(build.fingerprintRunId)}`; +} + +/** Versions for the SQL viewer's drift mode, newest first. */ +export function sqlVersions(builds: BuiltDefinition[], now: number): SqlVersion[] { + return builds.map((b, i) => ({ + id: i === 0 ? 'built' : b.build.fingerprintRunId, + label: i === 0 ? 'Last build' : b.build.runId ? `Build ${b.build.runId}` : 'Build from the command line', + detail: `${i === 0 || !b.build.runId ? `${buildSourceLabel(b.build)} · ` : ''}${formatAgo(b.build.builtAt, now)} · ${formatStamp(b.build.builtAt)}`, + raw: b.raw, + compiled: b.compiled + })); +} + +export function formatCredits(value: number): string { + return `${value < 0.01 ? value.toFixed(4) : value.toFixed(3)} cr`; +} + +export function formatNumber(value: number | null | undefined): string { + return value == null ? '—' : value.toLocaleString('en-US'); +} + +export function formatBytes(bytes: number | null | undefined): string { + if (bytes == null) return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let v = bytes; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i += 1; + } + return `${v < 10 && i ? v.toFixed(1) : Math.round(v)} ${units[i]}`; +} + +export function formatMs(ms: number | null | undefined): string { + if (ms == null) return '—'; + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`; + return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; +} + +/** Relation as `DATABASE.SCHEMA.NAME`. */ +export function relationName(relation: { database: string | null; schema: string; name: string } | null): string { + if (!relation) return '—'; + return [relation.database, relation.schema, relation.name].filter(Boolean).join('.'); +} diff --git a/ui/src/lib/components/admin/access-checker.svelte b/ui/src/lib/components/admin/access-checker.svelte new file mode 100644 index 0000000000..788c70ed01 --- /dev/null +++ b/ui/src/lib/components/admin/access-checker.svelte @@ -0,0 +1,327 @@ + + +
+
+ + + {#if sampleDecision && !decisionId} + + {/if} +
+ {#if decision} +

+ {decision.decisionId}: {whoName(decision.query.principal)} was denied {formatAgo(decision.at, app.now)} + · “{decision.summary}”. Checked again below with their access today. + +

+ {/if} + +
+ + + + + +
+ + {#if failure}

{failure}

{/if} + + {#if result} +
+
+ {#if result.allowed}{:else}{/if} + + {result.allowed ? 'Yes' : 'No'}: {whoName(result.query.principal)} {result.allowed ? 'can' : 'can’t'} + + {#if result.query.subject}on {result.query.subject}{/if} in {locationName(locations, result.query.locationId)}. + +
+ + {#if result.grantedBy.length} +
Because
+
    + {#each result.grantedBy as g (g.assignmentId + g.role)} +
  1. + gives + + role {#if canSeeRoles}{g.role}{:else}{g.role}{/if} + + + {#if g.via === 'base' && g.baseRole && g.baseRole !== g.role}which includes {builtInLabel(g.baseRole)}{:else if g.via === 'base'}a built-in role{:else}an addition{/if} + {g.selector ? 'on' : 'on every node'} + {#if g.selector}{g.selector}{/if} + + + in {scopeWords(g.scope.locationId, locations)} +
  2. + {/each} +
+ {/if} + + {#if !result.allowed} + {#if result.nearMisses.length} +
Nearest miss
+
    + {#each result.nearMisses as m, i (i)}
  • {m.detail}
  • {/each} +
+ {/if} + {#if result.wouldBeGrantedBy.length} +
Roles that would allow it here
+

+ {#each result.wouldBeGrantedBy as r, i (r)}{#if i}, {/if}{#if canSeeRoles}{r}{:else}{r}{/if}{/each} +

+ {/if} + {#if !result.nearMisses.length && !result.wouldBeGrantedBy.length} +

No role in this location allows it.

+ {/if} + {/if} +
+ {/if} +
+ + diff --git a/ui/src/lib/components/admin/assignment-list.svelte b/ui/src/lib/components/admin/assignment-list.svelte new file mode 100644 index 0000000000..e790d4766d --- /dev/null +++ b/ui/src/lib/components/admin/assignment-list.svelte @@ -0,0 +1,178 @@ + + +
+ + + + + + + + + + + + {#each mine as a (a.id)} + {@const role = roleOf(a.role)} + {@const hint = lockedHint(a)} + + + + + + + + {:else} + + {/each} + +
RoleWhereFromSince
+ {#if role}{a.role}{:else}{a.role}{/if} + {#if role}
{roleSummary(role)}
{/if} + {#if a.stale}
This role no longer exists in the code, so it gives nothing
{/if} + {#if isOwnLastAdmin(a)}
Your last admin role: removing it locks you out of administration
{/if} +
{scopeWords(a.scope.locationId, locations)} + + {#if a.assignedBy}
by {a.assignedBy.name}
{/if} +
+ {#if a.source === 'manual'} + remove(a)}>Remove + {:else if a.source === 'sso'} + See SSO mapping + {:else} + {a.source === 'policy' ? `Change in ${a.definedIn}` : 'Default role'} + {/if} +
No roles yet.
+ + {#if app.can('role.assign')} +
+ + + Give role + You can only give roles you hold yourself in that location. +
+ {/if} + {#if error}

{error}

{/if} +
+ + diff --git a/ui/src/lib/components/admin/audit-mini.svelte b/ui/src/lib/components/admin/audit-mini.svelte new file mode 100644 index 0000000000..3b488f6243 --- /dev/null +++ b/ui/src/lib/components/admin/audit-mini.svelte @@ -0,0 +1,75 @@ + + +{#if !app.can('audit.view')} +

Requires “See the audit log” (audit.view).

+{:else if entries.data?.length} +
    + {#each entries.data as e (e.id)} +
  • + {formatAgo(e.at, app.now)} + + {e.outcome === 'denied' ? 'Denied: ' : ''}{auditActionLabel(e.action)}{#if e.scope.subject} · {e.scope.subject}{/if} + + {#if e.decisionId} + Check this + {/if} +
  • + {/each} +
+ All their activity → +{:else if entries.data} +

Nothing recorded yet.

+{:else if entries.error} +

Couldn't load: {entries.error}

+{:else} +

Loading…

+{/if} + + diff --git a/ui/src/lib/components/admin/effective-access-grid.svelte b/ui/src/lib/components/admin/effective-access-grid.svelte new file mode 100644 index 0000000000..a25c4069b3 --- /dev/null +++ b/ui/src/lib/components/admin/effective-access-grid.svelte @@ -0,0 +1,122 @@ + + +
+ + + + + {#each access.locationIds as id (id)}{/each} + + + + {#each PERMISSIONS_BY_RISK as group (group.risk)} + + {#each group.actions as action (action)} + + + {#each access.locationIds as id (id)} + {@const cell = cellOf(action, id)} + + {/each} + + {/each} + {/each} + +
Action{locationName(locations, id)}
+ {#if cell && cell.coverage !== 'none'} + + {:else if cell} + + {/if} +
+

✓ every node · ✓ selector only those nodes · — not allowed. Click any mark for the reason.

+
+ + diff --git a/ui/src/lib/components/admin/permission-name.svelte b/ui/src/lib/components/admin/permission-name.svelte new file mode 100644 index 0000000000..d1db462874 --- /dev/null +++ b/ui/src/lib/components/admin/permission-name.svelte @@ -0,0 +1,29 @@ + + + + {info.label} + {#if code}{action}{/if} + {#if risk}{/if} + + + diff --git a/ui/src/lib/components/admin/risk-tag.svelte b/ui/src/lib/components/admin/risk-tag.svelte new file mode 100644 index 0000000000..e452f99c3b --- /dev/null +++ b/ui/src/lib/components/admin/risk-tag.svelte @@ -0,0 +1,23 @@ + + +{RISK_LABEL[risk]} + + diff --git a/ui/src/lib/components/admin/role-cell.svelte b/ui/src/lib/components/admin/role-cell.svelte new file mode 100644 index 0000000000..7f7d44edbe --- /dev/null +++ b/ui/src/lib/components/admin/role-cell.svelte @@ -0,0 +1,89 @@ + + +{#if top} + + {top.role} + {#if !locationId}{top.scope.locationId === null ? 'everywhere' : `in ${scopeWords(top.scope.locationId, locations)}`}{/if} + {#if rest > 0} + + e.stopPropagation()}>+{rest} more + +
+ {locationId ? `Roles in ${scopeWords(locationId, locations)}` : 'All roles'} +
+
    + {#each shown as a (a.id)} +
  • + e.stopPropagation()}>{a.role} + {scopeWords(a.scope.locationId, locations)} + + {#if a.stale}gives nothing{/if} +
  • + {/each} +
+
+
+ {/if} +
+{:else} + No role here +{/if} + + diff --git a/ui/src/lib/components/admin/role-source-badge.svelte b/ui/src/lib/components/admin/role-source-badge.svelte new file mode 100644 index 0000000000..d920cea367 --- /dev/null +++ b/ui/src/lib/components/admin/role-source-badge.svelte @@ -0,0 +1,31 @@ + + +{roleSourceLabel[role.source]} +{#if role.definedIn}{role.definedIn}{/if} + + diff --git a/ui/src/lib/components/admin/source-chip.svelte b/ui/src/lib/components/admin/source-chip.svelte new file mode 100644 index 0000000000..f386fc95c1 --- /dev/null +++ b/ui/src/lib/components/admin/source-chip.svelte @@ -0,0 +1,39 @@ + + + + {#if hint}{/if} + {sourceChip(assignment)} + {#if assignment.source === 'policy' && assignment.definedIn}{assignment.definedIn}{/if} + + + diff --git a/ui/src/lib/components/app-sidebar.svelte b/ui/src/lib/components/app-sidebar.svelte new file mode 100644 index 0000000000..933625c20c --- /dev/null +++ b/ui/src/lib/components/app-sidebar.svelte @@ -0,0 +1,119 @@ + + + + + diff --git a/ui/src/lib/components/app-topbar.svelte b/ui/src/lib/components/app-topbar.svelte new file mode 100644 index 0000000000..fafbf2725d --- /dev/null +++ b/ui/src/lib/components/app-topbar.svelte @@ -0,0 +1,82 @@ + + +
+

{title}

+ {app.location ? `${app.location.name} · ${targetWords(app.location.target)}` : '—'} +
+ + + {connectionLabel[app.connection]} + {#if snapshot !== undefined} + · + {snapshot ? `Version ${snapshot.generation}, deployed ${formatAgo(snapshot.publishedAt, app.now)}` : 'nothing deployed yet'} + {/if} + + {#if app.client.supports('getWarehouseSync')} + · + + {/if} + {#if children}{@render children()}{/if} + +
+
+ + + diff --git a/ui/src/lib/components/build-area/build-source.svelte b/ui/src/lib/components/build-area/build-source.svelte new file mode 100644 index 0000000000..fdf1462e80 --- /dev/null +++ b/ui/src/lib/components/build-area/build-source.svelte @@ -0,0 +1,31 @@ + + +{#if !build} + Never built in this target +{:else if build.runId} + {build.runId}{#if time}{` · ${formatAgo(build.builtAt, app.now)}`}{/if} +{:else} + From the command line · {shortRunId(build.fingerprintRunId)}{#if time} · {formatAgo(build.builtAt, app.now)}{/if} +{/if} + + diff --git a/ui/src/lib/components/build-area/outcome-strip.svelte b/ui/src/lib/components/build-area/outcome-strip.svelte new file mode 100644 index 0000000000..fb82e1467b --- /dev/null +++ b/ui/src/lib/components/build-area/outcome-strip.svelte @@ -0,0 +1,30 @@ + + +
+ {#each outcomes as o, i (i)}{/each} +
+
{caption}latest
+ + diff --git a/ui/src/lib/components/build-area/stale-badges.svelte b/ui/src/lib/components/build-area/stale-badges.svelte new file mode 100644 index 0000000000..3ddc24a779 --- /dev/null +++ b/ui/src/lib/components/build-area/stale-badges.svelte @@ -0,0 +1,47 @@ + + +{#if reasons.length} + + {#each reasons as r, i (i)} + {staleReasonLabel(r)} + {/each} + +{:else if empty} + {empty} +{/if} + + diff --git a/ui/src/lib/components/code/code-preview.svelte b/ui/src/lib/components/code/code-preview.svelte new file mode 100644 index 0000000000..326d87c0cd --- /dev/null +++ b/ui/src/lib/components/code/code-preview.svelte @@ -0,0 +1,153 @@ + + +
+
+ {file} + {#if language === 'sql'} +
+ {#each [['raw', 'Raw'], ['compiled', 'Compiled']] as [m, label] (m)} + + {/each} +
+ {/if} + {#if onOpen} + + {/if} +
+
+ {#each lines.slice(0, maxLines) as line, i (i)} +
{i + 1}{@html line || ' '}
+ {/each} +
+ {#if lines.length > maxLines && onOpen} + + {/if} +
+ + diff --git a/ui/src/lib/components/code/sql-block.svelte b/ui/src/lib/components/code/sql-block.svelte new file mode 100644 index 0000000000..bde0474f48 --- /dev/null +++ b/ui/src/lib/components/code/sql-block.svelte @@ -0,0 +1,77 @@ + + +
+
+ {#if caption}{caption}{/if} + +
+
{@html html}
+
+ + diff --git a/ui/src/lib/components/code/sql-viewer.svelte b/ui/src/lib/components/code/sql-viewer.svelte new file mode 100644 index 0000000000..1cea2293e6 --- /dev/null +++ b/ui/src/lib/components/code/sql-viewer.svelte @@ -0,0 +1,658 @@ + + + e.key === 'Escape' && onClose?.()} /> + +
+
+ {name}.sql + {raw.split('\n').length} lines +
+ {#each [['raw', 'Raw'], ['compiled', 'Compiled'], ['diff', 'Side by side'], ...(versions.length ? [['drift', 'Drift']] : [])] as [m, label] (m)} + + {/each} +
+ + + {#if onClose} + + {/if} +
+ + {#if mode === 'drift'} +
+ +
+ {#each [['compiled', 'Compiled'], ['raw', 'Raw']] as [v, l] (v)} + + {/each} +
+
+ {#each [['unified', 'Unified'], ['split', 'Split']] as [v, l] (v)} + + {/each} +
+ +{stats.added}−{stats.removed} + +
+
{explanation.text}
+ {/if} + +
+ {#if mode !== 'diff' && mode !== 'drift'} + + {/if} + + + {#if mode === 'drift'} +
+ {#if stats.added + stats.removed === 0} +
No {driftSide} SQL differences from {baseline?.label ?? 'the baseline'}.
+ {:else if layout === 'unified'} +
+ {#each driftRows as r (r.op === 'fold' ? r.key : `${r.op}${r.a ?? ''}-${r.b ?? ''}`)} + {#if r.op === 'fold'} + + {:else} +
+ {r.a ?? ''}{r.b ?? ''} + {r.op === 'add' ? '+' : r.op === 'del' ? '−' : ''} + {@html lineHtml(r)} +
+ {/if} + {/each} +
+ {:else} +
+ {#each splitRows as r (r.key)} + {#if r.fold} + + {:else} +
+
+ {r.left?.a ?? ''}{@html r.left ? (r.left.op === 'same' ? afterHl[(r.left.b ?? 1) - 1] : lineHtml(r.left)) || ' ' : ' '} +
+
+ {r.right?.b ?? ''}{@html r.right ? lineHtml(r.right) : ' '} +
+
+ {/if} + {/each} +
+ {/if} +
+ {:else} +
+
+ {#if mode === 'diff'}
Raw
{/if} + {#each primaryLines as line, i (i)} +
+ {i + 1}{@html line || ' '} +
+ {/each} +
+ {#if mode === 'diff'} +
+
Compiled
+ {#each compiledLines as line, i (i)} +
{i + 1}{@html line || ' '}
+ {/each} +
+ {/if} +
+ {/if} +
+
+ + diff --git a/ui/src/lib/components/copy-button.svelte b/ui/src/lib/components/copy-button.svelte new file mode 100644 index 0000000000..b8f45c3aea --- /dev/null +++ b/ui/src/lib/components/copy-button.svelte @@ -0,0 +1,45 @@ + + + + + diff --git a/ui/src/lib/components/lineage/asset-node.svelte b/ui/src/lib/components/lineage/asset-node.svelte new file mode 100644 index 0000000000..182c52609f --- /dev/null +++ b/ui/src/lib/components/lineage/asset-node.svelte @@ -0,0 +1,266 @@ + + +
+ + +
+ + {d.name} + {#if f.kind}{kindShort}{/if} +
+ + {#if hasDetail} +
+ {#if f.description && d.description}

{d.description}

{/if} + {#if f.owner && d.owner?.length} {d.owner.join(', ')}{/if} + {#if f.columns && d.columnCount != null} {d.columnCount} columns{/if} + {#if f.tags && d.tags?.length}{d.tags.join(', ')}{/if} +
+ {/if} + + {#if f.status || (f.checks && d.checks) || (f.lastBuilt && when) || d.stale} +
+ {#if f.status} + + {statusLabel[d.status]} + {/if} + {#if f.lastBuilt && when}{when}{/if} + {#if d.stale} + {staleLabel[d.stale]} + {/if} + {#if f.checks && d.checks} + {d.checks} + {/if} +
+ {/if} + + +
+ + diff --git a/ui/src/lib/components/lineage/col-cell-node.svelte b/ui/src/lib/components/lineage/col-cell-node.svelte new file mode 100644 index 0000000000..c1f24b1380 --- /dev/null +++ b/ui/src/lib/components/lineage/col-cell-node.svelte @@ -0,0 +1,54 @@ + + +
+ + +
+ {d.column} +
+ +
+ + {d.model} + {#if d.lens && d.lensLabel} + {d.lensLabel[d.lens]} + {:else} + {d.kind} + {/if} +
+ +
diff --git a/ui/src/lib/components/lineage/inspector-section.svelte b/ui/src/lib/components/lineage/inspector-section.svelte new file mode 100644 index 0000000000..2254e45172 --- /dev/null +++ b/ui/src/lib/components/lineage/inspector-section.svelte @@ -0,0 +1,68 @@ + + +
+
+ + {#if meta}
{@render meta()}
{/if} +
+ {#if open}
{@render children()}
{/if} +
+ + diff --git a/ui/src/lib/components/lineage/node-fields-popover.svelte b/ui/src/lib/components/lineage/node-fields-popover.svelte new file mode 100644 index 0000000000..bbbd695444 --- /dev/null +++ b/ui/src/lib/components/lineage/node-fields-popover.svelte @@ -0,0 +1,37 @@ + + + + + Node fields + {count} + + +
+ Show on nodes +
+
+ {#each NODE_FIELDS as field (field.key)} + + {/each} +
+
+ +
+
+
diff --git a/ui/src/lib/components/list/choice-filter.svelte b/ui/src/lib/components/list/choice-filter.svelte new file mode 100644 index 0000000000..529c562503 --- /dev/null +++ b/ui/src/lib/components/list/choice-filter.svelte @@ -0,0 +1,63 @@ + + + + + {label}{#if current}{current}{/if} + + + + (value = v === '__all' ? '' : (v as T))}> + {all} + {#each options as o (o.value)} + + {o.label} + {#if o.count != null}{o.count}{/if} + + {/each} + + + + + diff --git a/ui/src/lib/components/list/filter-button.svelte b/ui/src/lib/components/list/filter-button.svelte new file mode 100644 index 0000000000..c3d75e676e --- /dev/null +++ b/ui/src/lib/components/list/filter-button.svelte @@ -0,0 +1,36 @@ + + + + + diff --git a/ui/src/lib/components/list/list-toolbar.svelte b/ui/src/lib/components/list/list-toolbar.svelte new file mode 100644 index 0000000000..2d97289837 --- /dev/null +++ b/ui/src/lib/components/list/list-toolbar.svelte @@ -0,0 +1,117 @@ + + +
+ + {#if filters}
{@render filters()}
{/if} + {#if actions}{@render actions()}{/if} +
+ {#each counts.filter((c) => c.count > 0) as c (c.label)} + {c.count} {c.label} + {/each} + {total} +
+
+ + diff --git a/ui/src/lib/components/list/load-row.svelte b/ui/src/lib/components/list/load-row.svelte new file mode 100644 index 0000000000..1853e0f560 --- /dev/null +++ b/ui/src/lib/components/list/load-row.svelte @@ -0,0 +1,31 @@ + + + + {#if resource.error} + + Couldn't load: {resource.error} + + + {:else if resource.loading && resource.data === undefined} + Loading… + {:else} + {empty} + {/if} + + + diff --git a/ui/src/lib/components/list/relative-time.svelte b/ui/src/lib/components/list/relative-time.svelte new file mode 100644 index 0000000000..afb0173575 --- /dev/null +++ b/ui/src/lib/components/list/relative-time.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/ui/src/lib/components/list/segmented-filter.svelte b/ui/src/lib/components/list/segmented-filter.svelte new file mode 100644 index 0000000000..cd86222f3f --- /dev/null +++ b/ui/src/lib/components/list/segmented-filter.svelte @@ -0,0 +1,52 @@ + + +
+ {#each options as o (o.value)} + + {/each} +
+ + diff --git a/ui/src/lib/components/list/status-label.svelte b/ui/src/lib/components/list/status-label.svelte new file mode 100644 index 0000000000..0e5209c961 --- /dev/null +++ b/ui/src/lib/components/list/status-label.svelte @@ -0,0 +1,27 @@ + + +{label} + + diff --git a/ui/src/lib/components/not-built.svelte b/ui/src/lib/components/not-built.svelte new file mode 100644 index 0000000000..d377752ea4 --- /dev/null +++ b/ui/src/lib/components/not-built.svelte @@ -0,0 +1,38 @@ + + + + +
+
+
+ {#if allowed}{:else}{/if} +
+ {#if allowed} +

Not built yet

+

Tracked in {area}. {description}

+ {:else} +

You don't have access to {title}

+

Ask an administrator for a role that grants it.

+ {/if} +
+
diff --git a/ui/src/lib/components/operate/gated-button.svelte b/ui/src/lib/components/operate/gated-button.svelte new file mode 100644 index 0000000000..4e83cbab13 --- /dev/null +++ b/ui/src/lib/components/operate/gated-button.svelte @@ -0,0 +1,44 @@ + + + + + + + diff --git a/ui/src/lib/components/operate/run-status-pill.svelte b/ui/src/lib/components/operate/run-status-pill.svelte new file mode 100644 index 0000000000..d3b20b1b1e --- /dev/null +++ b/ui/src/lib/components/operate/run-status-pill.svelte @@ -0,0 +1,55 @@ + + + + {runStatusLabel[status]} + + + diff --git a/ui/src/lib/components/platform/connection-test.svelte b/ui/src/lib/components/platform/connection-test.svelte new file mode 100644 index 0000000000..18ab76791a --- /dev/null +++ b/ui/src/lib/components/platform/connection-test.svelte @@ -0,0 +1,95 @@ + + +
+
+ {#if compact} + Warehouse + {#if testing} + + {:else if shown} + + + + {:else} + + {/if} + {#if failure}{failure}{/if} + {:else if testing} + + {:else if shown} + + + +
{shown.message}
+ {:else} + + {/if} + {#if failure}
{failure}
{/if} +
+ Test connection +
+ + diff --git a/ui/src/lib/components/platform/gated-button.svelte b/ui/src/lib/components/platform/gated-button.svelte new file mode 100644 index 0000000000..cf9255d871 --- /dev/null +++ b/ui/src/lib/components/platform/gated-button.svelte @@ -0,0 +1,26 @@ + + + diff --git a/ui/src/lib/components/platform/issued-secret.svelte b/ui/src/lib/components/platform/issued-secret.svelte new file mode 100644 index 0000000000..b81d050385 --- /dev/null +++ b/ui/src/lib/components/platform/issued-secret.svelte @@ -0,0 +1,72 @@ + + +
+ +
+
{label}
+

Copy it now: it's shown only this once and can't be retrieved later. If it's lost, rotate the token.

+ {secret} +
+
+ + +
+
+ + diff --git a/ui/src/lib/components/platform/key-values.svelte b/ui/src/lib/components/platform/key-values.svelte new file mode 100644 index 0000000000..2457ab0eef --- /dev/null +++ b/ui/src/lib/components/platform/key-values.svelte @@ -0,0 +1,39 @@ + + +
+ {#each rows as row (row.label)} +
{row.label}
+
+ {#if row.content}{@render row.content()}{:else}{row.value ?? '—'}{/if} +
+ {/each} +
+ + diff --git a/ui/src/lib/components/platform/location-tokens.svelte b/ui/src/lib/components/platform/location-tokens.svelte new file mode 100644 index 0000000000..5a5bc0ca71 --- /dev/null +++ b/ui/src/lib/components/platform/location-tokens.svelte @@ -0,0 +1,135 @@ + + +{#if !canManage} +

You don't have permission to see location tokens (location.manage).

+{:else} + {#if issued} +
+ (issued = null)} /> +
+ {/if} + {#if loadError}

Couldn't load tokens: {loadError}

{/if} + {#if actionError}

{actionError}

{/if} +
    + {#each tokens ?? [] as t (t.id)} +
  • + {t.prefix}… + + + created {formatAgo(t.createdAt, app.now)} · + {t.revokedAt ? `revoked ${formatAgo(t.revokedAt, app.now)}` : t.lastUsedAt ? `used ${formatAgo(t.lastUsedAt, app.now)}` : 'never used'} + + {#if t.state === 'active'} + + rotate(t)}>Rotate + revoke(t)}>Revoke + + {/if} +
  • + {:else} +
  • {tokens ? 'No tokens yet. Create one and give it to the location so it can connect.' : 'Loading…'}
  • + {/each} +
+
+ Create token +
+{/if} + + diff --git a/ui/src/lib/components/platform/no-access.svelte b/ui/src/lib/components/platform/no-access.svelte new file mode 100644 index 0000000000..099204da1a --- /dev/null +++ b/ui/src/lib/components/platform/no-access.svelte @@ -0,0 +1,22 @@ + + +
+
+
+ +
+

You don't have access to {what}

+

+ Ask an administrator for a role that allows + {#each list as c, i (c)}{#if i} or{' '}{/if}“{PERMISSIONS[c].label}” ({c}){/each}. +

+
+
diff --git a/ui/src/lib/components/platform/page-tabs.svelte b/ui/src/lib/components/platform/page-tabs.svelte new file mode 100644 index 0000000000..612042cd1c --- /dev/null +++ b/ui/src/lib/components/platform/page-tabs.svelte @@ -0,0 +1,49 @@ + + +
+ {#each tabs as t (t.value)} + + {/each} +
+ + diff --git a/ui/src/lib/components/platform/panel.svelte b/ui/src/lib/components/platform/panel.svelte new file mode 100644 index 0000000000..7cd85f86ac --- /dev/null +++ b/ui/src/lib/components/platform/panel.svelte @@ -0,0 +1,42 @@ + + +
+
+

{title}

+ {#if note}{note}{/if} + {#if actions}
{@render actions()}
{/if} +
+ {@render children()} +
+ + diff --git a/ui/src/lib/components/platform/problem-list.svelte b/ui/src/lib/components/platform/problem-list.svelte new file mode 100644 index 0000000000..579534d41c --- /dev/null +++ b/ui/src/lib/components/platform/problem-list.svelte @@ -0,0 +1,171 @@ + + + + + + + + + + + + + {#each events as e (e.id)} + + + + + + + {:else} + + + + {/each} + +
ProblemWhenAffectedSensors that reacted
+
+ +
+ {eventKindLabel[e.kind]}{#if e.active}ongoing{/if} +
{e.message}
+ {#if Object.keys(e.payload).length} +
+ Details +
{`event ${e.kind} · ${e.id}\n${payload(e.payload)}`}
+
+ {/if} +
+
+
+ +
+ {#if e.active}still open{:else if e.resolvedAt}fixed after {formatDuration(e.occurredAt, e.resolvedAt, app.now)}{:else}fixed{/if} +
+
+ {#each e.links.filter((l) => !(l.kind === 'location' && l.id === currentLocationId)) as link (eventLinkHref(link))} + followLink(link)}>{eventLinkLabel(link, app.bootstrap?.locations)} + {:else} + — + {/each} + + {#each e.reactions as r (r.triggerId)} + follow(r)} title={reactionTitle(r)}> + {r.triggerName} + {reactionLabel[r.outcome]} + + {:else} + — + {/each} +
+ {#if resource.error}Couldn't load problems: {resource.error}{:else if resource.loading && !events.length}Loading…{:else}{empty}{/if} +
+ + diff --git a/ui/src/lib/components/shell/platform-event-banner.svelte b/ui/src/lib/components/shell/platform-event-banner.svelte new file mode 100644 index 0000000000..f376d06cbd --- /dev/null +++ b/ui/src/lib/components/shell/platform-event-banner.svelte @@ -0,0 +1,93 @@ + + +{#if events.length} + +{/if} + + diff --git a/ui/src/lib/components/shell/scope-switcher.svelte b/ui/src/lib/components/shell/scope-switcher.svelte new file mode 100644 index 0000000000..501365164a --- /dev/null +++ b/ui/src/lib/components/shell/scope-switcher.svelte @@ -0,0 +1,106 @@ + + +
+ + + {app.location?.name.slice(0, 1).toUpperCase() ?? '·'} + + {app.location?.name ?? '—'} + {app.location ? targetWords(app.location.target) : 'Location'} + + {#if app.location?.health && app.location.health !== 'healthy'} + + {/if} + + + + Locations + app.selectLocation(id)}> + {#each locations as location (location.id)} + + {#if location.health} + + {/if} + + {location.name} + + {targetWords(location.target)}{location.snapshot === undefined ? '' : location.snapshot ? ` · version ${location.snapshot.generation}` : ' · nothing deployed'} + + + + {/each} + + + +
+ + diff --git a/ui/src/lib/components/shell/warehouse-sync-status.svelte b/ui/src/lib/components/shell/warehouse-sync-status.svelte new file mode 100644 index 0000000000..d56ad646cb --- /dev/null +++ b/ui/src/lib/components/shell/warehouse-sync-status.svelte @@ -0,0 +1,34 @@ + + +{text} + + diff --git a/ui/src/lib/components/timeline/run-timeline.svelte b/ui/src/lib/components/timeline/run-timeline.svelte new file mode 100644 index 0000000000..8e85dca4f4 --- /dev/null +++ b/ui/src/lib/components/timeline/run-timeline.svelte @@ -0,0 +1,581 @@ + + +
+
+ {#if showSearch} + + {/if} +
+ {#each Object.keys(WINDOWS) as w (w)} + + {/each} +
+
+ + + +
+ {#if actions}
{@render actions()}
{/if} +
+ +
+
+
Jobs {jobs.length}
+
+
+ {#each days as d (d.label)}{d.label}{/each} + {#if nowPct >= 0 && nowPct <= 100} +
+ {#if counts.running}{counts.running}{/if} + {counts.ok} + {#if counts.warn}{counts.warn}{/if} + {#if counts.fail}{counts.fail}{/if} +
+ {/if} +
+
+ {#each ticks.filter((t) => pct(t.t) < 97 && Math.abs(pct(t.t) - nowPct) > 3) as t (t.t)}{t.label}{/each} + {#if nowPct >= 0 && nowPct <= 100}Now{/if} +
+
+
+ +
+
+ {#each ticks as t (t.t)}{/each} + {#if nowPct >= 0 && nowPct <= 100}{/if} +
+ {#each jobs as job (job.name)} + + {:else} +
+ {#if timeline.error}Couldn't load the timeline: {timeline.error}{:else if !timeline.data}Loading…{:else if query}No jobs match “{query}”.{:else}No runs in this window.{/if} +
+ {/each} +
+
+
+ + + +{#if hover} + {@const r = hover.run} +
+
{label(r)}{r.jobKey}
+
+ {#if r.runId}
Run
{r.runId.replace(/^run-/, '')}
{/if} +
{r.status === 'projected' ? 'Due' : 'Started'}
{clock(r.startMs)}
+
Duration
{r.status === 'projected' ? `~${duration(r)} typical` : r.status === 'running' ? `${duration(r)} so far` : duration(r)}
+
Kind
{runKindLabel[r.kind]}
+
Started by
{r.triggerName ?? runOriginLabel[r.origin]}
+
+
+{/if} + +{#if markerHover} + {@const m = markerHover.marker} +
+
{m.outcome === 'covered' ? 'Merged into a waiting run' : 'Skipped'}{m.triggerName}
+
+
Due
{clock(m.atMs)}
+ {#if m.coveredByRunId}
Merged into
run {m.coveredByRunId.replace(/^run-/, '')} (already waiting)
{/if} + {#if m.reason && m.outcome !== 'covered'}
Why
{m.reason}
{/if} +
+
+{/if} + + diff --git a/ui/src/lib/components/ui/badge/badge.svelte b/ui/src/lib/components/ui/badge/badge.svelte new file mode 100644 index 0000000000..51bbc23e92 --- /dev/null +++ b/ui/src/lib/components/ui/badge/badge.svelte @@ -0,0 +1,49 @@ + + + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/badge/index.ts b/ui/src/lib/components/ui/badge/index.ts new file mode 100644 index 0000000000..64e0aa9b08 --- /dev/null +++ b/ui/src/lib/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +export { default as Badge } from "./badge.svelte"; +export { badgeVariants, type BadgeVariant } from "./badge.svelte"; diff --git a/ui/src/lib/components/ui/button/button.svelte b/ui/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000000..c38862edb6 --- /dev/null +++ b/ui/src/lib/components/ui/button/button.svelte @@ -0,0 +1,82 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/ui/src/lib/components/ui/button/index.ts b/ui/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000000..fb585d7688 --- /dev/null +++ b/ui/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/ui/src/lib/components/ui/card/card-action.svelte b/ui/src/lib/components/ui/card/card-action.svelte new file mode 100644 index 0000000000..7c48844a8f --- /dev/null +++ b/ui/src/lib/components/ui/card/card-action.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/card/card-content.svelte b/ui/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000000..082a7861ba --- /dev/null +++ b/ui/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/card/card-description.svelte b/ui/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000000..9b20ac701f --- /dev/null +++ b/ui/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,20 @@ + + +

+ {@render children?.()} +

diff --git a/ui/src/lib/components/ui/card/card-footer.svelte b/ui/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000000..591c3f7189 --- /dev/null +++ b/ui/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/card/card-header.svelte b/ui/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000000..21e9a170ab --- /dev/null +++ b/ui/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/card/card-title.svelte b/ui/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000000..7d20243be9 --- /dev/null +++ b/ui/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/card/card.svelte b/ui/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000000..ba46c045c6 --- /dev/null +++ b/ui/src/lib/components/ui/card/card.svelte @@ -0,0 +1,22 @@ + + +
img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg group/card flex flex-col", className)} + {...restProps} +> + {@render children?.()} +
diff --git a/ui/src/lib/components/ui/card/index.ts b/ui/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000000..4d3fce489d --- /dev/null +++ b/ui/src/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Root from "./card.svelte"; +import Content from "./card-content.svelte"; +import Description from "./card-description.svelte"; +import Footer from "./card-footer.svelte"; +import Header from "./card-header.svelte"; +import Title from "./card-title.svelte"; +import Action from "./card-action.svelte"; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction, +}; diff --git a/ui/src/lib/components/ui/checkbox/checkbox.svelte b/ui/src/lib/components/ui/checkbox/checkbox.svelte new file mode 100644 index 0000000000..e26543b121 --- /dev/null +++ b/ui/src/lib/components/ui/checkbox/checkbox.svelte @@ -0,0 +1,39 @@ + + + + {#snippet children({ checked, indeterminate })} +
+ {#if checked} + + {:else if indeterminate} + + {/if} +
+ {/snippet} +
diff --git a/ui/src/lib/components/ui/checkbox/index.ts b/ui/src/lib/components/ui/checkbox/index.ts new file mode 100644 index 0000000000..6d92d94580 --- /dev/null +++ b/ui/src/lib/components/ui/checkbox/index.ts @@ -0,0 +1,6 @@ +import Root from "./checkbox.svelte"; +export { + Root, + // + Root as Checkbox, +}; diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte new file mode 100644 index 0000000000..e0e1971879 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 0000000000..c04d294e02 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,44 @@ + + + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else if checked} + + {/if} + + {@render childrenProp?.()} + {/snippet} + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 0000000000..369bab1859 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 0000000000..433540fd25 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,22 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 0000000000..aca1f7bd55 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 0000000000..0fec5f5cb1 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 0000000000..ad6947a176 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte new file mode 100644 index 0000000000..274cfef78c --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 0000000000..189aef40e4 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 0000000000..d3888ccfb6 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,34 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 0000000000..90f1b6f10a --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 0000000000..ed7cc85ab5 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 0000000000..254de664b9 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 0000000000..a390e2d483 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte new file mode 100644 index 0000000000..f044581392 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 0000000000..cb053444d7 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte new file mode 100644 index 0000000000..cb4bc621c2 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/index.ts b/ui/src/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000000..7850c6a300 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,54 @@ +import Root from "./dropdown-menu.svelte"; +import Sub from "./dropdown-menu-sub.svelte"; +import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte"; +import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; +import Content from "./dropdown-menu-content.svelte"; +import Group from "./dropdown-menu-group.svelte"; +import Item from "./dropdown-menu-item.svelte"; +import Label from "./dropdown-menu-label.svelte"; +import RadioGroup from "./dropdown-menu-radio-group.svelte"; +import RadioItem from "./dropdown-menu-radio-item.svelte"; +import Separator from "./dropdown-menu-separator.svelte"; +import Shortcut from "./dropdown-menu-shortcut.svelte"; +import Trigger from "./dropdown-menu-trigger.svelte"; +import SubContent from "./dropdown-menu-sub-content.svelte"; +import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; +import GroupHeading from "./dropdown-menu-group-heading.svelte"; +import Portal from "./dropdown-menu-portal.svelte"; + +export { + CheckboxGroup, + CheckboxItem, + Content, + Portal, + Root as DropdownMenu, + CheckboxGroup as DropdownMenuCheckboxGroup, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Portal as DropdownMenuPortal, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +}; diff --git a/ui/src/lib/components/ui/popover/index.ts b/ui/src/lib/components/ui/popover/index.ts new file mode 100644 index 0000000000..5f18036395 --- /dev/null +++ b/ui/src/lib/components/ui/popover/index.ts @@ -0,0 +1,28 @@ +import Root from "./popover.svelte"; +import Close from "./popover-close.svelte"; +import Content from "./popover-content.svelte"; +import Description from "./popover-description.svelte"; +import Header from "./popover-header.svelte"; +import Title from "./popover-title.svelte"; +import Trigger from "./popover-trigger.svelte"; +import Portal from "./popover-portal.svelte"; + +export { + Root, + Content, + Description, + Header, + Title, + Trigger, + Close, + Portal, + // + Root as Popover, + Content as PopoverContent, + Description as PopoverDescription, + Header as PopoverHeader, + Title as PopoverTitle, + Trigger as PopoverTrigger, + Close as PopoverClose, + Portal as PopoverPortal, +}; diff --git a/ui/src/lib/components/ui/popover/popover-close.svelte b/ui/src/lib/components/ui/popover/popover-close.svelte new file mode 100644 index 0000000000..c360925ac7 --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/popover/popover-content.svelte b/ui/src/lib/components/ui/popover/popover-content.svelte new file mode 100644 index 0000000000..baab39748b --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/ui/src/lib/components/ui/popover/popover-description.svelte b/ui/src/lib/components/ui/popover/popover-description.svelte new file mode 100644 index 0000000000..c28ab236ee --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-description.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/popover/popover-header.svelte b/ui/src/lib/components/ui/popover/popover-header.svelte new file mode 100644 index 0000000000..b53953daa9 --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-header.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/popover/popover-portal.svelte b/ui/src/lib/components/ui/popover/popover-portal.svelte new file mode 100644 index 0000000000..dd8265fe80 --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/popover/popover-title.svelte b/ui/src/lib/components/ui/popover/popover-title.svelte new file mode 100644 index 0000000000..ee9f2ff54d --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/popover/popover-trigger.svelte b/ui/src/lib/components/ui/popover/popover-trigger.svelte new file mode 100644 index 0000000000..586323c719 --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover-trigger.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/popover/popover.svelte b/ui/src/lib/components/ui/popover/popover.svelte new file mode 100644 index 0000000000..6b1aa5f994 --- /dev/null +++ b/ui/src/lib/components/ui/popover/popover.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/separator/index.ts b/ui/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000000..82442d2cdc --- /dev/null +++ b/ui/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/ui/src/lib/components/ui/separator/separator.svelte b/ui/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000000..5fd8a42900 --- /dev/null +++ b/ui/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,23 @@ + + + diff --git a/ui/src/lib/components/ui/table/index.ts b/ui/src/lib/components/ui/table/index.ts new file mode 100644 index 0000000000..14695c8154 --- /dev/null +++ b/ui/src/lib/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Root from "./table.svelte"; +import Body from "./table-body.svelte"; +import Caption from "./table-caption.svelte"; +import Cell from "./table-cell.svelte"; +import Footer from "./table-footer.svelte"; +import Head from "./table-head.svelte"; +import Header from "./table-header.svelte"; +import Row from "./table-row.svelte"; + +export { + Root, + Body, + Caption, + Cell, + Footer, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow, +}; diff --git a/ui/src/lib/components/ui/table/table-body.svelte b/ui/src/lib/components/ui/table/table-body.svelte new file mode 100644 index 0000000000..935feae719 --- /dev/null +++ b/ui/src/lib/components/ui/table/table-body.svelte @@ -0,0 +1,15 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table-caption.svelte b/ui/src/lib/components/ui/table/table-caption.svelte new file mode 100644 index 0000000000..4696cff571 --- /dev/null +++ b/ui/src/lib/components/ui/table/table-caption.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table-cell.svelte b/ui/src/lib/components/ui/table/table-cell.svelte new file mode 100644 index 0000000000..a998bf6839 --- /dev/null +++ b/ui/src/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,15 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table-footer.svelte b/ui/src/lib/components/ui/table/table-footer.svelte new file mode 100644 index 0000000000..b9b14ebfac --- /dev/null +++ b/ui/src/lib/components/ui/table/table-footer.svelte @@ -0,0 +1,20 @@ + + +tr]:last:border-b-0", className)} + {...restProps} +> + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table-head.svelte b/ui/src/lib/components/ui/table/table-head.svelte new file mode 100644 index 0000000000..267c4e0826 --- /dev/null +++ b/ui/src/lib/components/ui/table/table-head.svelte @@ -0,0 +1,15 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table-header.svelte b/ui/src/lib/components/ui/table/table-header.svelte new file mode 100644 index 0000000000..f47d2597cf --- /dev/null +++ b/ui/src/lib/components/ui/table/table-header.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table-row.svelte b/ui/src/lib/components/ui/table/table-row.svelte new file mode 100644 index 0000000000..90b4e2a209 --- /dev/null +++ b/ui/src/lib/components/ui/table/table-row.svelte @@ -0,0 +1,15 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/table/table.svelte b/ui/src/lib/components/ui/table/table.svelte new file mode 100644 index 0000000000..d95a02eaef --- /dev/null +++ b/ui/src/lib/components/ui/table/table.svelte @@ -0,0 +1,17 @@ + + +
+ + {@render children?.()} +
+
diff --git a/ui/src/lib/components/ui/tabs/index.ts b/ui/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000000..31267e58d1 --- /dev/null +++ b/ui/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,18 @@ +import Root from "./tabs.svelte"; +import Content from "./tabs-content.svelte"; +import List, { tabsListVariants, type TabsListVariant } from "./tabs-list.svelte"; +import Trigger from "./tabs-trigger.svelte"; + +export { + Root, + Content, + List, + Trigger, + tabsListVariants, + type TabsListVariant, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, +}; diff --git a/ui/src/lib/components/ui/tabs/tabs-content.svelte b/ui/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000000..394ab3e820 --- /dev/null +++ b/ui/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/tabs/tabs-list.svelte b/ui/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000000..020507c998 --- /dev/null +++ b/ui/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,40 @@ + + + + + diff --git a/ui/src/lib/components/ui/tabs/tabs-trigger.svelte b/ui/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000000..4ae962ac13 --- /dev/null +++ b/ui/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,23 @@ + + + diff --git a/ui/src/lib/components/ui/tabs/tabs.svelte b/ui/src/lib/components/ui/tabs/tabs.svelte new file mode 100644 index 0000000000..bfb900db1f --- /dev/null +++ b/ui/src/lib/components/ui/tabs/tabs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/ui/src/lib/components/ui/tooltip/index.ts b/ui/src/lib/components/ui/tooltip/index.ts new file mode 100644 index 0000000000..1718604210 --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/index.ts @@ -0,0 +1,19 @@ +import Root from "./tooltip.svelte"; +import Trigger from "./tooltip-trigger.svelte"; +import Content from "./tooltip-content.svelte"; +import Provider from "./tooltip-provider.svelte"; +import Portal from "./tooltip-portal.svelte"; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, +}; diff --git a/ui/src/lib/components/ui/tooltip/tooltip-content.svelte b/ui/src/lib/components/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000000..0cf0694973 --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,52 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
+ {/snippet} +
+
+
diff --git a/ui/src/lib/components/ui/tooltip/tooltip-portal.svelte b/ui/src/lib/components/ui/tooltip/tooltip-portal.svelte new file mode 100644 index 0000000000..d234f7d7d1 --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/tooltip-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/tooltip/tooltip-provider.svelte b/ui/src/lib/components/ui/tooltip/tooltip-provider.svelte new file mode 100644 index 0000000000..6dba9a670d --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/tooltip-provider.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000000..1acdaa47be --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/tooltip/tooltip.svelte b/ui/src/lib/components/ui/tooltip/tooltip.svelte new file mode 100644 index 0000000000..0b0f9cef2a --- /dev/null +++ b/ui/src/lib/components/ui/tooltip/tooltip.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/config/config-form.svelte b/ui/src/lib/config/config-form.svelte new file mode 100644 index 0000000000..7477af14c9 --- /dev/null +++ b/ui/src/lib/config/config-form.svelte @@ -0,0 +1,178 @@ + + +
+ {#if baseline && overrideCount > 0} +
▲ {overrideCount} per-run override{overrideCount > 1 ? 's' : ''}
+ {/if} + +
+ {#each schema as f (f.key)} + {@const err = errorFor(f)} + {@const ov = isOverride(f)} +
+ +
+
+ {f.label ?? f.key} + {#if f.required}*{/if} + {#if ov}{/if} +
+ {#if f.description}
{f.description}
{/if} +
+ + +
+ {#if f.type === 'bool'} + + {:else if f.type === 'enum'} + + {:else if f.type === 'secret'} +
+
+ + +
+ {#if f.secretSource}{f.secretSource}{/if} +
+ {:else} + set(f, f.type === 'int' ? Number((e.currentTarget as HTMLInputElement).value) : (e.currentTarget as HTMLInputElement).value)} + /> + {/if} + +
+ {#if err} + ⚠ {err} + {:else if values[f.key] === undefined && f.default !== undefined} + default · {String(f.default)} + {/if} + {#if f.type === 'int' && (f.min !== undefined || f.max !== undefined)} + int · {f.min ?? '—'}–{f.max ?? '—'} + {/if} +
+
+
+ {/each} +
+ + +
+ +
+ {#if rawOpen} +
{yaml}
+ {/if} +
diff --git a/ui/src/lib/format/time.ts b/ui/src/lib/format/time.ts new file mode 100644 index 0000000000..0534e7f9c8 --- /dev/null +++ b/ui/src/lib/format/time.ts @@ -0,0 +1,50 @@ +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** Compact age, e.g. `just now`, `42s ago`, `4m ago`, `3h ago`, `2d ago`. */ +export function formatAgo(iso: string | null | undefined, now: number): string { + if (!iso) return '—'; + const diff = now - Date.parse(iso); + if (diff < 0) return formatIn(iso, now); + if (diff < 5 * SECOND) return 'just now'; + if (diff < MINUTE) return `${Math.floor(diff / SECOND)}s ago`; + if (diff < HOUR) return `${Math.floor(diff / MINUTE)}m ago`; + if (diff < DAY) return `${Math.floor(diff / HOUR)}h ago`; + return `${Math.floor(diff / DAY)}d ago`; +} + +/** Compact time until a future instant, e.g. `in 6m`. */ +export function formatIn(iso: string | null | undefined, now: number): string { + if (!iso) return '—'; + const diff = Date.parse(iso) - now; + if (diff <= 0) return 'now'; + if (diff < MINUTE) return `in ${Math.ceil(diff / SECOND)}s`; + if (diff < HOUR) return `in ${Math.round(diff / MINUTE)}m`; + if (diff < DAY) return `in ${Math.round(diff / HOUR)}h`; + return `in ${Math.round(diff / DAY)}d`; +} + +/** Elapsed seconds since an instant, floored at zero. */ +export function secondsSince(iso: string | null | undefined, now: number): number { + if (!iso) return 0; + return Math.max(0, Math.floor((now - Date.parse(iso)) / SECOND)); +} + +/** Duration between two instants, e.g. `1.4s`, `3m 12s`, `1h 5m`. */ +export function formatDuration(startIso: string | null, endIso: string | null, now: number): string { + if (!startIso) return '—'; + const ms = (endIso ? Date.parse(endIso) : now) - Date.parse(startIso); + if (ms < MINUTE) return `${(ms / SECOND).toFixed(ms < 10 * SECOND ? 1 : 0)}s`; + if (ms < HOUR) return `${Math.floor(ms / MINUTE)}m ${Math.floor((ms % MINUTE) / SECOND)}s`; + return `${Math.floor(ms / HOUR)}h ${Math.floor((ms % HOUR) / MINUTE)}m`; +} + +/** Local `YYYY-MM-DD HH:MM` for exact-time tooltips. */ +export function formatStamp(iso: string | null | undefined): string { + if (!iso) return '—'; + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} diff --git a/ui/src/lib/index.ts b/ui/src/lib/index.ts new file mode 100644 index 0000000000..856f2b6c38 --- /dev/null +++ b/ui/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/ui/src/lib/lineage/catalog-graph.ts b/ui/src/lib/lineage/catalog-graph.ts new file mode 100644 index 0000000000..5f76580332 --- /dev/null +++ b/ui/src/lib/lineage/catalog-graph.ts @@ -0,0 +1,58 @@ +// Lineage cards from catalog nodes. Pure; no side effects. + +import type { Edge, Node } from '@xyflow/svelte'; +import type { CatalogNode, StaleReason } from '$lib/api'; +import { kindLine, nodeIconKey } from '$lib/build-area/labels'; + +export type CardStatus = 'ok' | 'fail' | 'warn' | 'src' | 'none'; +export type CardStale = 'changed' | 'compiled' | 'upstream' | 'behind'; + +export function cardStatus(node: CatalogNode): CardStatus { + if (node.kind === 'source') return 'src'; + if (node.lastOutcome === 'failed') return 'fail'; + if (node.lastOutcome === 'warn') return 'warn'; + if (node.lastOutcome === 'success' || node.lastOutcome === 'skipped') return node.lastBuild ? 'ok' : 'none'; + return node.lastBuild ? 'ok' : 'none'; +} + +/** The stale reason to show on a card: own changes first, then upstream changes, then behind. */ +export function cardStale(reasons: StaleReason[]): CardStale | undefined { + const changed = reasons.find((r) => r.reason === 'changed'); + if (changed && changed.reason === 'changed') return changed.change === 'compiled' ? 'compiled' : 'changed'; + if (reasons.some((r) => r.reason === 'changed_upstream')) return 'upstream'; + if (reasons.some((r) => r.reason === 'behind')) return 'behind'; + return undefined; +} + +export function checksBadge(node: CatalogNode): { checks?: string; checksColor?: string } { + const c = node.checks; + if (!c.total) return {}; + if (c.failing) return { checks: `✕ ${c.failing}`, checksColor: 'var(--sb-error)' }; + if (c.warning) return { checks: `! ${c.warning}`, checksColor: 'var(--sb-warning)' }; + return c.passing === c.total ? { checks: `✓ ${c.passing}` } : { checks: `${c.passing}/${c.total}` }; +} + +export function cardData(node: CatalogNode): Record { + return { + name: node.key, + kind: kindLine(node), + group: node.group, + nodeKind: node.kind, + status: cardStatus(node), + stale: cardStale(node.stale), + icon: nodeIconKey(node), + lastBuiltAt: node.kind === 'source' ? (node.sourceFreshness?.loadedAt ?? undefined) : node.lastBuild?.builtAt, + description: node.description || undefined, + owner: node.owner, + tags: node.tags, + columnCount: node.columnCount || undefined, + ...checksBadge(node) + }; +} + +export function catalogGraph(nodes: CatalogNode[]): { nodes: Node[]; edges: Edge[] } { + return { + nodes: nodes.map((n) => ({ id: n.key, type: 'asset', position: { x: 0, y: 0 }, data: cardData(n) })), + edges: nodes.flatMap((n) => n.upstream.map((up) => ({ id: `${up}-${n.key}`, source: up, target: n.key, type: 'bezier' }))) + }; +} diff --git a/ui/src/lib/lineage/column-lineage.svelte b/ui/src/lib/lineage/column-lineage.svelte new file mode 100644 index 0000000000..b418a1ab07 --- /dev/null +++ b/ui/src/lib/lineage/column-lineage.svelte @@ -0,0 +1,553 @@ + + +
+ +
+
+
+ ⌕ + + {#if colQuery}{/if} +
+
+ {#if colQuery}{matchedColumns.length} of {totalColumns} columns{:else}{totalColumns} columns{/if} +
+
+
+ {#each focalColumns as c (c.name)} + {@const cid = `${focal}.${c.name}`} + {@const lens = incomingLens.get(cid)} + + {:else} +
No columns match.
+ {/each} + {#if truncated} +
+ Showing first {COL_LIST_CAP} of {matchedColumns.length} — search to narrow. +
+ {/if} +
+ + +
+ + +
+
+ {#if selectedCol} + {nameOf(modelOf(selectedCol))}.{colOf(selectedCol)} + {/if} +
+ {#each lenses as l (l)} + {lensLabel[l]} + {/each} + +
+
+
+ + + {#if selectedCol} + + {/if} +
+
+
+ + diff --git a/ui/src/lib/lineage/flow-controller.svelte b/ui/src/lib/lineage/flow-controller.svelte new file mode 100644 index 0000000000..62289b60bf --- /dev/null +++ b/ui/src/lib/lineage/flow-controller.svelte @@ -0,0 +1,23 @@ + diff --git a/ui/src/lib/lineage/graph-draw.ts b/ui/src/lib/lineage/graph-draw.ts new file mode 100644 index 0000000000..2be38a3e7d --- /dev/null +++ b/ui/src/lib/lineage/graph-draw.ts @@ -0,0 +1,96 @@ +import { COL_STEP, NODE_H, NODE_W, ROW_STEP, type GraphLayout, type GraphNode } from './graph-layout'; + +// Far-zoom rendering for large graphs: when the lineage page is zoomed out +// beyond the point where cards are legible, cards are hidden and this canvas +// draws every model as a status bar plus every edge, in the flow's coordinates. + +export type View = { x: number; y: number; zoom: number }; + +export type DrawState = { + layout: GraphLayout; + view: View; + width: number; + height: number; + dpr: number; + /** the page's lit set (selection lineage); everything else is dimmed */ + lit: Set | null; + selected: Set; +}; + +export function hitTest(sx: number, sy: number, layout: GraphLayout, view: View): GraphNode | null { + const wx = (sx - view.x) / view.zoom; + const wy = (sy - view.y) / view.zoom; + const d = Math.floor(wx / COL_STEP); + if (d < 0 || d >= layout.columns.length || wx - d * COL_STEP > NODE_W) return null; + const i = Math.floor((wy - layout.colTop[d]) / ROW_STEP); + const n = layout.columns[d][i]; + return n && wy >= n.y && wy - n.y <= NODE_H ? n : null; +} + +function css(name: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888'; +} + +export function drawFar(ctx: CanvasRenderingContext2D, s: DrawState): void { + const { layout, view } = s; + const k = view.zoom; + const c = { + edge: css('--sb-edge'), + primary: css('--primary'), + ok: css('--sb-success'), + stale: css('--sb-stale'), + fail: css('--sb-error'), + src: css('--sb-layer-source') + }; + const statusColour = (st: string) => (st === 'fail' ? c.fail : st === 'stale' ? c.stale : st === 'src' ? c.src : c.ok); + + ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + ctx.clearRect(0, 0, s.width, s.height); + ctx.setTransform(s.dpr * k, 0, 0, s.dpr * k, s.dpr * view.x, s.dpr * view.y); + + const left = -view.x / k; + const right = (s.width - view.x) / k; + const top = -view.y / k; + const bottom = (s.height - view.y) / k; + const lit = s.lit; + + const dim = new Path2D(); + const hot = new Path2D(); + for (const [a, b] of layout.edges) { + const p = layout.byId.get(a)!; + const q = layout.byId.get(b)!; + const x1 = p.x + NODE_W; + const x2 = q.x; + if (x2 < left || x1 > right) continue; + const y1 = p.y + NODE_H / 2; + const y2 = q.y + NODE_H / 2; + if (Math.max(y1, y2) < top || Math.min(y1, y2) > bottom) continue; + const path = lit && lit.has(a) && lit.has(b) ? hot : dim; + const mx = (x1 + x2) / 2; + path.moveTo(x1, y1); + path.bezierCurveTo(mx, y1, mx, y2, x2, y2); + } + ctx.lineWidth = 1 / k; + ctx.strokeStyle = c.edge; + ctx.globalAlpha = lit ? 0.12 : 0.5; + ctx.stroke(dim); + ctx.globalAlpha = 1; + ctx.strokeStyle = c.primary; + ctx.lineWidth = 1.5 / k; + ctx.stroke(hot); + + for (const n of layout.nodes) { + if (n.x > right || n.x + NODE_W < left || n.y > bottom || n.y + NODE_H < top) continue; + ctx.globalAlpha = lit && !lit.has(n.id) ? 0.15 : 1; + ctx.fillStyle = statusColour(n.status); + ctx.fillRect(n.x, n.y, NODE_W, NODE_H); + } + ctx.globalAlpha = 1; + + ctx.strokeStyle = c.primary; + ctx.lineWidth = 3 / k; + for (const id of s.selected) { + const n = layout.byId.get(id); + if (n) ctx.strokeRect(n.x - 8 / k, n.y - 8 / k, NODE_W + 16 / k, NODE_H + 16 / k); + } +} diff --git a/ui/src/lib/lineage/graph-layout.ts b/ui/src/lib/lineage/graph-layout.ts new file mode 100644 index 0000000000..85ffc1d2a9 --- /dev/null +++ b/ui/src/lib/lineage/graph-layout.ts @@ -0,0 +1,130 @@ +import type { Edge, Node } from '@xyflow/svelte'; + +// Whole-graph layered layout for large projects. Stand-in for what `sqb compile` +// would emit: columns are dependency depth, and each column is ordered by a few +// barycentre sweeps so connected models sit near each other. Sizes and gaps +// match the lineage page's default cards and dagre spacing. + +export const NODE_W = 236; +export const NODE_H = 61; +export const GAP_X = 76; +export const GAP_Y = 18; +export const COL_STEP = NODE_W + GAP_X; +export const ROW_STEP = NODE_H + GAP_Y; + +export type GraphNode = { + id: string; + name: string; + status: string; + kind: string; + depth: number; + x: number; + y: number; +}; + +export type GraphLayout = { + nodes: GraphNode[]; + byId: Map; + edges: [string, string][]; + upstream: Map; + downstream: Map; + columns: GraphNode[][]; + colTop: number[]; // world y of each column's first row (columns are top-aligned) + width: number; + height: number; +}; + +export function layoutDag(nodes: Node[], edges: Edge[], sweeps = 6): GraphLayout { + const byId = new Map(); + for (const n of nodes) { + const d = n.data as Record; + byId.set(n.id, { + id: n.id, + name: String(d.name), + status: d.stale ? 'stale' : String(d.status), + kind: String(d.kind), + depth: 0, + x: 0, + y: 0 + }); + } + const upstream = new Map(); + const downstream = new Map(); + for (const e of edges) { + (upstream.get(e.target) ?? upstream.set(e.target, []).get(e.target)!).push(e.source); + (downstream.get(e.source) ?? downstream.set(e.source, []).get(e.source)!).push(e.target); + } + + // depth = longest path from a source (Kahn's topological order) + const indegree = new Map(); + for (const id of byId.keys()) indegree.set(id, upstream.get(id)?.length ?? 0); + const queue = [...byId.keys()].filter((id) => indegree.get(id) === 0); + for (let i = 0; i < queue.length; i++) { + const id = queue[i]; + const depth = byId.get(id)!.depth; + for (const next of downstream.get(id) ?? []) { + const n = byId.get(next)!; + n.depth = Math.max(n.depth, depth + 1); + indegree.set(next, indegree.get(next)! - 1); + if (indegree.get(next) === 0) queue.push(next); + } + } + + const maxDepth = Math.max(0, ...[...byId.values()].map((n) => n.depth)); + const columns: GraphNode[][] = Array.from({ length: maxDepth + 1 }, () => []); + for (const n of [...byId.values()].sort((a, b) => a.name.localeCompare(b.name))) columns[n.depth].push(n); + + // barycentre sweeps: order each column by the average relative position of its neighbours + const rel = new Map(); + const index = () => { + for (const col of columns) col.forEach((n, i) => rel.set(n.id, col.length > 1 ? i / (col.length - 1) : 0.5)); + }; + index(); + for (let s = 0; s < sweeps; s++) { + const down = s % 2 === 0; + const order = down ? columns.slice(1) : columns.slice(0, -1).reverse(); + for (const col of order) { + const key = new Map(); + for (const n of col) { + const ns = (down ? upstream : downstream).get(n.id) ?? []; + key.set(n.id, ns.length ? ns.reduce((sum, id) => sum + rel.get(id)!, 0) / ns.length : rel.get(n.id)!); + } + col.sort((a, b) => key.get(a.id)! - key.get(b.id)!); + col.forEach((n, i) => rel.set(n.id, col.length > 1 ? i / (col.length - 1) : 0.5)); + } + } + + const tallest = Math.max(...columns.map((c) => c.length)); + const colTop = columns.map(() => 0); + columns.forEach((col, d) => + col.forEach((n, i) => { + n.x = d * COL_STEP; + n.y = colTop[d] + i * ROW_STEP; + }) + ); + + return { + nodes: [...byId.values()], + byId, + edges: edges.map((e) => [e.source, e.target]), + upstream, + downstream, + columns, + colTop, + width: columns.length * COL_STEP - GAP_X, + height: tallest * ROW_STEP - GAP_Y + }; +} + +/** Every model reachable from the given ids along `next` (inclusive). */ +export function closure(ids: Iterable, next: Map): Set { + const seen = new Set(); + const stack = [...ids]; + while (stack.length) { + const id = stack.pop()!; + if (seen.has(id)) continue; + seen.add(id); + for (const n of next.get(id) ?? []) stack.push(n); + } + return seen; +} diff --git a/ui/src/lib/lineage/layout.ts b/ui/src/lib/lineage/layout.ts new file mode 100644 index 0000000000..7248382b73 --- /dev/null +++ b/ui/src/lib/lineage/layout.ts @@ -0,0 +1,52 @@ +import dagre from '@dagrejs/dagre'; +import type { Node, Edge } from '@xyflow/svelte'; + +// Fallback dimensions used before a node has been measured by Svelte Flow. +const DEFAULT_WIDTH = 236; +const DEFAULT_HEIGHT = 61; + +export type LayoutOptions = { + /** gap between layers (columns) in LR mode */ + rankSep?: number; + /** gap between nodes within a layer */ + nodeSep?: number; +}; + +/** + * Layered left-to-right layout via dagre. Uses each node's measured size when + * available (so taller nodes — e.g. with extra Node fields enabled — get the + * right spacing and the graph reflows cleanly on toggle). + */ +export function layoutGraph(nodes: Node[], edges: Edge[], opts: LayoutOptions = {}): Node[] { + const g = new dagre.graphlib.Graph(); + g.setGraph({ + rankdir: 'LR', + ranksep: opts.rankSep ?? 76, + nodesep: opts.nodeSep ?? 18, + marginx: 24, + marginy: 24 + }); + g.setDefaultEdgeLabel(() => ({})); + + for (const node of nodes) { + const width = node.measured?.width ?? (node.width as number) ?? DEFAULT_WIDTH; + const height = node.measured?.height ?? (node.height as number) ?? DEFAULT_HEIGHT; + g.setNode(node.id, { width, height }); + } + for (const edge of edges) { + g.setEdge(edge.source, edge.target); + } + + dagre.layout(g); + + return nodes.map((node) => { + const pos = g.node(node.id); + const width = node.measured?.width ?? (node.width as number) ?? DEFAULT_WIDTH; + const height = node.measured?.height ?? (node.height as number) ?? DEFAULT_HEIGHT; + // dagre returns center coordinates; Svelte Flow expects top-left. + return { + ...node, + position: { x: pos.x - width / 2, y: pos.y - height / 2 } + }; + }); +} diff --git a/ui/src/lib/lineage/node-fields.svelte.ts b/ui/src/lib/lineage/node-fields.svelte.ts new file mode 100644 index 0000000000..456435439f --- /dev/null +++ b/ui/src/lib/lineage/node-fields.svelte.ts @@ -0,0 +1,73 @@ +// Node field preferences for the lineage graph ("Node fields" control). +// Global (applies to all nodes). Persisted to localStorage so a user's +// choice of what to show on nodes survives reloads. + +export type NodeFieldKey = + | 'kind' + | 'status' + | 'checks' + | 'lastBuilt' + | 'description' + | 'owner' + | 'tags' + | 'columns'; + +export type NodeField = { key: NodeFieldKey; label: string }; + +export const NODE_FIELDS: NodeField[] = [ + { key: 'kind', label: 'Kind' }, + { key: 'status', label: 'Status' }, + { key: 'checks', label: 'Checks' }, + { key: 'lastBuilt', label: 'Last built' }, + { key: 'description', label: 'Description' }, + { key: 'owner', label: 'Owner' }, + { key: 'tags', label: 'Tags' }, + { key: 'columns', label: 'Column count' } +]; + +const DEFAULTS: Record = { + kind: true, + status: true, + checks: true, + lastBuilt: true, + description: false, + owner: false, + tags: false, + columns: false +}; + +const STORAGE_KEY = 'sqlbuild.lineage.nodeFields'; + +function load(): Record { + if (typeof localStorage === 'undefined') return { ...DEFAULTS }; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { ...DEFAULTS }; + const saved = JSON.parse(raw) as Partial>; + return Object.fromEntries(Object.keys(DEFAULTS).map((k) => [k, saved[k] ?? DEFAULTS[k as NodeFieldKey]])) as Record; + } catch { + return { ...DEFAULTS }; + } +} + +class NodeFieldsState { + value = $state>(load()); + + toggle(key: NodeFieldKey) { + this.value = { ...this.value, [key]: !this.value[key] }; + this.persist(); + } + + reset() { + this.value = { ...DEFAULTS }; + this.persist(); + } + + private persist() { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.value)); + } + } +} + +export const nodeFields = new NodeFieldsState(); diff --git a/ui/src/lib/lineage/scoped-lineage.svelte b/ui/src/lib/lineage/scoped-lineage.svelte new file mode 100644 index 0000000000..9db90a9060 --- /dev/null +++ b/ui/src/lib/lineage/scoped-lineage.svelte @@ -0,0 +1,224 @@ + + +
+ +
+
+ Upstream +
+ + {upDepth} + +
+
+
+ Downstream +
+ + {downDepth} + +
+
+ + + +
+ --select {selectExpr} + +
+
+ + +
+ + + + + +
+
+ + diff --git a/ui/src/lib/lineage/trace.ts b/ui/src/lib/lineage/trace.ts new file mode 100644 index 0000000000..1bf5f89578 --- /dev/null +++ b/ui/src/lib/lineage/trace.ts @@ -0,0 +1,53 @@ +import type { Edge } from '@xyflow/svelte'; + +/** Immediate neighbours (edges touching the node) — used for hover. */ +export function neighbourEdgeIds(nodeId: string, edges: Edge[]): Set { + const ids = new Set(); + for (const e of edges) { + if (e.source === nodeId || e.target === nodeId) ids.add(e.id); + } + return ids; +} + +/** Full upstream + downstream path from a set of nodes — used for selection. */ +export function tracePath( + nodeIds: Set, + edges: Edge[] +): { nodes: Set; edges: Set } { + const outgoing = new Map(); + const incoming = new Map(); + for (const e of edges) { + (outgoing.get(e.source) ?? outgoing.set(e.source, []).get(e.source)!).push(e); + (incoming.get(e.target) ?? incoming.set(e.target, []).get(e.target)!).push(e); + } + + const litNodes = new Set(nodeIds); + const litEdges = new Set(); + + // downstream (follow outgoing) + const downStack = [...nodeIds]; + while (downStack.length) { + const id = downStack.pop()!; + for (const e of outgoing.get(id) ?? []) { + litEdges.add(e.id); + if (!litNodes.has(e.target)) { + litNodes.add(e.target); + downStack.push(e.target); + } + } + } + // upstream (follow incoming) + const upStack = [...nodeIds]; + while (upStack.length) { + const id = upStack.pop()!; + for (const e of incoming.get(id) ?? []) { + litEdges.add(e.id); + if (!litNodes.has(e.source)) { + litNodes.add(e.source); + upStack.push(e.source); + } + } + } + + return { nodes: litNodes, edges: litEdges }; +} diff --git a/ui/src/lib/mock/scale-graph.ts b/ui/src/lib/mock/scale-graph.ts new file mode 100644 index 0000000000..946c995cae --- /dev/null +++ b/ui/src/lib/mock/scale-graph.ts @@ -0,0 +1,154 @@ +import type { Edge, Node } from '@xyflow/svelte'; + +// Synthetic warehouse-shaped lineage for scale testing (?models=N on /lineage). +// Deterministic: the same N always produces the same graph. Models carry the +// same fields as the catalog-backed lineage cards (kind, icon, checks, stale, +// owner, tags, ...) so the real cards render in full. + +const LAYERS = ['raw', 'stg', 'int', 'int', 'fct', 'dim', 'mart', 'rpt'] as const; +// share of models per layer (sums to 1) +const LAYER_SHARE = [0.12, 0.2, 0.16, 0.12, 0.14, 0.08, 0.12, 0.06]; +const MODELS_PER_DOMAIN = 120; +const DOMAIN_NAMES = [ + 'orders', 'customers', 'payments', 'inventory', 'menu', 'stores', 'staff', 'suppliers', 'delivery', + 'loyalty', 'marketing', 'finance', 'pricing', 'reviews', 'support', 'ops', 'forecasting', 'logistics', + 'events', 'web', 'mobile', 'franchise', 'quality', 'audit' +]; +const OWNERS = ['analytics', 'finance-data', 'growth', 'platform', 'ops']; +const TAGS = ['core', 'finance', 'pii', 'daily', 'hourly', 'reporting']; + +export const domainName = (i: number) => + DOMAIN_NAMES[i % DOMAIN_NAMES.length] + (i >= DOMAIN_NAMES.length ? `_${Math.floor(i / DOMAIN_NAMES.length) + 1}` : ''); + +type Kind = { kind: string; icon: string }; +const KINDS: Record<(typeof LAYERS)[number], Kind[]> = { + raw: [ + { kind: 'source', icon: 'source' }, + { kind: 'source · merge', icon: 'loader' }, + { kind: 'loader · table', icon: 'loader' }, + { kind: 'seed', icon: 'seed' } + ], + stg: [{ kind: 'view', icon: 'view' }], + int: [ + { kind: 'view', icon: 'view' }, + { kind: 'table', icon: 'table' } + ], + fct: [ + { kind: 'table', icon: 'table' }, + { kind: 'incremental · merge', icon: 'merge' }, + { kind: 'incremental · delete_insert', icon: 'delete_insert' } + ], + dim: [ + { kind: 'table', icon: 'table' }, + { kind: 'incremental · merge', icon: 'merge' } + ], + mart: [ + { kind: 'table', icon: 'table' }, + { kind: 'incremental · delete_insert', icon: 'microbatch' } + ], + rpt: [ + { kind: 'view', icon: 'view' }, + { kind: 'table', icon: 'table' } + ] +}; + +function rng(seed: number) { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function scaleGraph(total: number): { nodes: Node[]; edges: Edge[] } { + const rand = rng(total); + const pick = (xs: readonly T[]) => xs[Math.floor(rand() * xs.length)]; + const domains = Math.max(1, Math.round(total / MODELS_PER_DOMAIN)); + const byLayer: string[][][] = LAYERS.map(() => Array.from({ length: domains }, () => [])); + const nodes: Node[] = []; + const edges: Edge[] = []; + const downstream = new Map(); + + let made = 0; + LAYERS.forEach((layer, li) => { + const count = li === LAYERS.length - 1 ? total - made : Math.round(total * LAYER_SHARE[li]); + for (let i = 0; i < count; i++) { + const domain = Math.floor(rand() * domains); + const id = `${layer}_${li}_${i}`; + const { kind, icon } = pick(KINDS[layer]); + const name = `${layer}__${domainName(domain)}__m${i}`; + const isSource = kind.startsWith('source'); + const fail = !isSource && rand() < 0.03; + const checkCount = isSource ? 0 : Math.floor(rand() * 5); + const warn = !fail && checkCount > 0 && rand() < 0.06; + nodes.push({ + id, + type: 'asset', + position: { x: 0, y: 0 }, + data: { + name, + kind, + icon, + status: isSource ? 'src' : fail ? 'fail' : 'ok', + lastBuiltAt: new Date(Date.now() - (1 + Math.floor(rand() * 59)) * 60_000).toISOString(), + ...(fail + ? { checks: '✕ 1', checksColor: 'var(--sb-error)' } + : warn + ? { checks: '! 1', checksColor: 'var(--sb-warning)' } + : checkCount + ? { checks: `✓ ${checkCount}` } + : {}), + ...(isSource + ? {} + : { + columnCount: 3 + Math.floor(rand() * 20), + owner: rand() < 0.6 ? [pick(OWNERS)] : undefined, + tags: rand() < 0.4 ? [pick(TAGS)] : undefined, + description: rand() < 0.5 ? `${layer} model for ${domainName(domain)}.` : undefined + }) + } + }); + byLayer[li][domain].push(id); + + if (li > 0) { + const parents = 1 + Math.floor(rand() * (layer === 'stg' ? 1.5 : 3.5)); + const seen = new Set(); + for (let p = 0; p < parents; p++) { + // mostly the previous layer, sometimes further back; mostly same domain + const back = 1 + (rand() < 0.7 ? 0 : Math.floor(rand() * Math.min(3, li))); + const pl = li - back; + const pd = rand() < 0.85 ? domain : Math.floor(rand() * domains); + const pool = byLayer[pl][pd].length ? byLayer[pl][pd] : byLayer[pl].flat(); + if (!pool.length) continue; + const src = pool[Math.floor(rand() * pool.length)]; + if (seen.has(src)) continue; + seen.add(src); + edges.push({ id: `${src}-${id}`, source: src, target: id, type: 'bezier' }); + (downstream.get(src) ?? downstream.set(src, []).get(src)!).push(id); + } + } + } + made += count; + }); + + // Staleness: a few models changed since the last build in this target (direct + // SQL change, or compiled-only change); everything downstream of them is stale + // with an upstream change. Staleness is shown separately from the last outcome. + const byId = new Map(nodes.map((n) => [n.id, n])); + const driftSources = nodes.filter((n) => n.data.status !== 'src' && rand() < 0.008); + for (const n of driftSources) n.data = { ...n.data, stale: rand() < 0.75 ? 'changed' : 'compiled' }; + const stack = driftSources.flatMap((n) => downstream.get(n.id) ?? []); + const seen = new Set(); + while (stack.length) { + const id = stack.pop()!; + if (seen.has(id)) continue; + seen.add(id); + const n = byId.get(id)!; + if (!n.data.stale) n.data = { ...n.data, stale: 'upstream' }; + stack.push(...(downstream.get(id) ?? [])); + } + return { nodes, edges }; +} diff --git a/ui/src/lib/nav.test.ts b/ui/src/lib/nav.test.ts new file mode 100644 index 0000000000..a163f01bda --- /dev/null +++ b/ui/src/lib/nav.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import type { ApiClient, Capability } from '$lib/api'; +import { pageAvailable, visibleNav } from './nav'; + +const serving = (...methods: (keyof ApiClient)[]) => ({ supports: (method: keyof ApiClient) => methods.includes(method) }); + +const labels = (client: ReturnType, capabilities: Capability[]) => + visibleNav(client, capabilities).map((group) => [group.section, group.items.map((item) => item.label)]); + +describe('visibleNav', () => { + it('shows only pages the server can load and drops empty groups', () => { + expect(labels(serving('overview', 'listRunRows', 'listChecks'), ['view'])).toEqual([ + [null, ['Overview']], + ['Build', ['Checks']], + ['Operate', ['Runs']] + ]); + }); + + it('hides admin pages from people without an admin capability, even when the server serves them', () => { + expect(labels(serving('listAccounts', 'listRoles', 'listAuditEntries'), ['view', 'job.run'])).toEqual([]); + }); + + it('shows each admin page only for its own capabilities', () => { + const client = serving('listAccounts', 'listRoles', 'listAuditEntries'); + expect(labels(client, ['audit.view'])).toEqual([['Admin', ['Audit log']]]); + expect(labels(client, ['user.manage'])).toEqual([['Admin', ['People']]]); + expect(labels(client, ['role.assign'])).toEqual([['Admin', ['People', 'Roles']]]); + }); +}); + +describe('pageAvailable', () => { + const client = serving('listRunRows'); + + it.each([ + ['/runs', true], + ['/runs/0191f0aa-run', true], + ['/jobsheet', true], + ['/jobs', false], + ['/', false], + ['/catalog/model:orders', false], + ['/settings', true] + ])('%s → %s', (path, expected) => { + expect(pageAvailable(client, path)).toBe(expected); + }); +}); diff --git a/ui/src/lib/nav.ts b/ui/src/lib/nav.ts new file mode 100644 index 0000000000..789191fae9 --- /dev/null +++ b/ui/src/lib/nav.ts @@ -0,0 +1,79 @@ +import CheckCircleIcon from '@lucide/svelte/icons/circle-check'; +import ClockIcon from '@lucide/svelte/icons/clock-3'; +import DatabaseIcon from '@lucide/svelte/icons/database'; +import GitBranchIcon from '@lucide/svelte/icons/git-branch'; +import GitCompareIcon from '@lucide/svelte/icons/git-compare'; +import UsersIcon from '@lucide/svelte/icons/users'; +import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'; +import ListIcon from '@lucide/svelte/icons/list'; +import MapPinIcon from '@lucide/svelte/icons/map-pin'; +import PlayIcon from '@lucide/svelte/icons/play'; +import PlugIcon from '@lucide/svelte/icons/plug'; +import PuzzleIcon from '@lucide/svelte/icons/puzzle'; +import ScrollTextIcon from '@lucide/svelte/icons/scroll-text'; +import ShieldIcon from '@lucide/svelte/icons/shield'; +import WrenchIcon from '@lucide/svelte/icons/wrench'; +import type { Component } from 'svelte'; +import { ADMIN_CAPABILITIES, hasAnyCapability, type ApiClient, type Capability, type NavCounts } from '$lib/api'; + +export type NavBadge = { value: (counts: NavCounts) => number; tone: 'muted' | 'warning' | 'error' | 'live' }; +/** `loads` is the client method the page cannot work without; the entry hides when the server lacks it. */ +export type NavItem = { label: string; href: string; icon: Component; loads: keyof ApiClient; badge?: NavBadge; requires?: readonly Capability[] }; +export type NavGroup = { section: string | null; items: NavItem[]; requires?: readonly Capability[] }; + +export const NAV: readonly NavGroup[] = [ + { section: null, items: [{ label: 'Overview', href: '/', icon: LayoutDashboardIcon, loads: 'overview' }] }, + { + section: 'Build', + items: [ + { label: 'Lineage', href: '/lineage', icon: GitBranchIcon, loads: 'listCatalogNodes' }, + { label: 'Catalog', href: '/catalog', icon: DatabaseIcon, loads: 'listCatalogNodes', badge: { value: (c) => c.catalogNodes, tone: 'muted' } }, + { label: 'Checks', href: '/checks', icon: CheckCircleIcon, loads: 'listChecks', badge: { value: (c) => c.failingChecks, tone: 'warning' } }, + { label: 'Changes', href: '/changes', icon: GitCompareIcon, loads: 'listSnapshotChanges' } + ] + }, + { + section: 'Operate', + items: [ + { label: 'Runs', href: '/runs', icon: ListIcon, loads: 'listRunRows', badge: { value: (c) => c.activeRuns, tone: 'live' } }, + { label: 'Jobs', href: '/jobs', icon: PlayIcon, loads: 'listJobs' }, + { label: 'Triggers', href: '/triggers', icon: ClockIcon, loads: 'listTriggers', badge: { value: (c) => c.pausedTriggers, tone: 'muted' } } + ] + }, + { + section: 'Platform', + items: [ + { label: 'Locations', href: '/locations', icon: MapPinIcon, loads: 'listLocations', badge: { value: (c) => c.activePlatformEvents, tone: 'error' } }, + { label: 'Connections', href: '/connections', icon: PlugIcon, loads: 'listWarehouseConnections' }, + { label: 'Providers', href: '/providers', icon: PuzzleIcon, loads: 'listProviders' }, + { label: 'Maintenance', href: '/maintenance', icon: WrenchIcon, loads: 'maintenanceHistory' } + ] + }, + { + section: 'Admin', + requires: ADMIN_CAPABILITIES, + items: [ + { label: 'People', href: '/admin/people', icon: UsersIcon, loads: 'listAccounts', requires: ['user.manage', 'role.assign'] }, + { label: 'Roles', href: '/admin/roles', icon: ShieldIcon, loads: 'listRoles', requires: ['role.assign'] }, + { label: 'Audit log', href: '/admin/audit', icon: ScrollTextIcon, loads: 'listAuditEntries', requires: ['audit.view'] } + ] + } +]; + +/** Entries the person may use and the server can load; groups left empty disappear. */ +export function visibleNav(client: Pick, capabilities: readonly Capability[]): NavGroup[] { + return NAV.filter((g) => !g.requires || hasAnyCapability(capabilities, g.requires)) + .map((g) => ({ + ...g, + items: g.items.filter((i) => client.supports(i.loads) && (!i.requires || hasAnyCapability(capabilities, i.requires))) + })) + .filter((g) => g.items.length > 0); +} + +/** Whether the server can load the page at `path`; pages outside the navigation are not gated here. */ +export function pageAvailable(client: Pick, path: string): boolean { + const item = NAV.flatMap((g) => g.items) + .filter((i) => (i.href === '/' ? path === '/' : path === i.href || path.startsWith(`${i.href}/`))) + .sort((a, b) => b.href.length - a.href.length)[0]; + return !item || client.supports(item.loads); +} diff --git a/ui/src/lib/next.test.ts b/ui/src/lib/next.test.ts new file mode 100644 index 0000000000..b581aa0e4e --- /dev/null +++ b/ui/src/lib/next.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { safeNext } from './next'; + +describe('safeNext', () => { + it.each([ + ['/runs?status=failed#latest', '/runs?status=failed#latest'], + ['/catalog/model:orders', '/catalog/model:orders'], + [null, '/'], + ['', '/'] + ])('keeps the same-site path %s', (next, expected) => { + expect(safeNext(next)).toBe(expected); + }); + + it.each([ + 'https://evil.example/runs', + '//evil.example/runs', + '/\\evil.example/runs', + '/\t/evil.example/runs', + '/.//evil.example/runs', + '/..//evil.example', + '/a/..//evil.example', + '/%2e//evil.example', + '/./\\evil.example', + 'javascript:alert(1)', + 'runs' + ])('refuses %s', (next) => { + expect(safeNext(next)).toBe('/'); + }); +}); diff --git a/ui/src/lib/next.ts b/ui/src/lib/next.ts new file mode 100644 index 0000000000..dacebeaaa8 --- /dev/null +++ b/ui/src/lib/next.ts @@ -0,0 +1,19 @@ +const PROBE = 'https://same-site.invalid'; + +/** + * Where to go after sign-in: the `next` path when it stays on this site, else `/`. + * Resolving against a fixed origin catches `//host`, `/\host` and control characters the + * browser would strip, which a prefix check misses. Resolution also removes dot segments, so + * `/.//host` comes out as `//host`; a result that starts with two slashes is refused too. + */ +export function safeNext(next: string | null): string { + if (!next?.startsWith('/')) return '/'; + let url: URL; + try { + url = new URL(next, PROBE); + } catch { + return '/'; + } + if (url.origin !== PROBE || url.pathname.startsWith('//')) return '/'; + return `${url.pathname}${url.search}${url.hash}`; +} diff --git a/ui/src/lib/operate/labels.ts b/ui/src/lib/operate/labels.ts new file mode 100644 index 0000000000..414f2b9f7c --- /dev/null +++ b/ui/src/lib/operate/labels.ts @@ -0,0 +1,250 @@ +// Labels and tones for the OPERATE area's vocabulary. Pure; no side effects. + +import type { + LivenessState, + OnFailure, + OverlapPolicy, + QueueWait, + RunCost, + RunEvent, + RunNodeStatus, + SensorEvaluationOutcome, + TickOutcome, + TriggerKind, + TriggerStateEventKind +} from '$lib/api'; + +export const nodeStatusTone: Record = { + pending: 'var(--sb-text-faint)', + running: 'var(--sb-running)', + success: 'var(--sb-success)', + warn: 'var(--sb-warning)', + failed: 'var(--sb-error)', + skipped: 'var(--sb-text-faint)', + cancelled: 'var(--sb-text-faint)', + not_run: 'var(--sb-text-faint)' +}; + +export const nodeStatusLabel: Record = { + pending: 'Pending', + running: 'Running', + success: 'Succeeded', + warn: 'Warned', + failed: 'Failed', + skipped: 'Skipped', + cancelled: 'Cancelled', + not_run: 'Not run' +}; + +/** Rail order: problems and live work first. */ +export const NODE_STATUS_ORDER: RunNodeStatus[] = ['failed', 'running', 'warn', 'success', 'skipped', 'cancelled', 'not_run', 'pending']; + +export const livenessLabel: Record = { + healthy: 'Heartbeating', + unresponsive: 'Unresponsive', + presumed_failed: 'Presumed failed', + never_started: 'Never started', + exited: 'Exited' +}; + +export const livenessTone: Record = { + healthy: 'var(--sb-success)', + unresponsive: 'var(--sb-warning)', + presumed_failed: 'var(--sb-error)', + never_started: 'var(--sb-error)', + exited: 'var(--sb-text-faint)' +}; + +export function formatCost(cost: RunCost | null): string { + if (!cost) return '—'; + return `${cost.value < 0.01 ? cost.value.toFixed(4) : cost.value.toFixed(3)} cr`; +} + +export const overlapLabel: Record = { + coalesce: 'coalesce', + queue: 'queue', + allow: 'allow' +}; + +export const overlapHelp: Record = { + coalesce: 'At most one run waits; new requests merge into it instead of starting another run', + queue: 'Every request gets its own run; they run one after another, in order', + allow: 'Can run alongside other runs of this job, as long as they don’t update the same data' +}; + +/** Short plain wording of an overlap policy; the policy name goes in a tooltip. */ +export const overlapPlain: Record = { + coalesce: 'Merge new requests', + queue: 'One run per request, in order', + allow: 'Can run side by side' +}; + +export const onFailureLabel: Record = { + skip_dependents: 'skip_dependents', + skip_downstream_jobs: 'skip_downstream_jobs', + stop_group: 'stop_group' +}; + +export const onFailureHelp: Record = { + skip_dependents: 'When a node fails, only what depends on it is skipped, even in other jobs', + skip_downstream_jobs: 'When a job fails, the jobs after it are skipped', + stop_group: 'Any failure stops the whole group' +}; + +/** Short plain wording of an on-failure policy. */ +export const onFailurePlain: Record = { + skip_dependents: 'Skip what depends on a failure', + skip_downstream_jobs: 'Skip later jobs', + stop_group: 'Stop the group' +}; + +export type WaitPart = { text: string; href?: string; code?: boolean }; + +/** `run-4f2a` → `4f2a`: the short form shown in lists; the full ID stays in links and copies. */ +export function shortRunId(id: string): string { + return id.replace(/^run-/, ''); +} + +/** A concurrency group's slots in words, e.g. `warehouse_heavy` → `warehouse heavy`, `target:prod` → `prod target`. */ +export function slotLabel(group: string): string { + if (group.startsWith('target:')) return `${group.slice('target:'.length)} target`; + return group.replace(/[_-]+/g, ' '); +} + +const runLink = (id: string): WaitPart => ({ text: `run ${shortRunId(id)}`, href: `/runs/${id}` }); + +/** A queue wait as a plain sentence of linkable parts, e.g. "Waiting: run 7f3a is updating orders". */ +export function describeWait(wait: QueueWait): WaitPart[] { + switch (wait.kind) { + case 'write_claim': + return [ + { text: 'Waiting: ' }, + runLink(wait.runId), + { text: ' is updating ' }, + { text: wait.nodeKey, href: `/catalog/${wait.nodeKey}` }, + ...(wait.otherNodes ? [{ text: ` and ${wait.otherNodes} more` }] : []) + ]; + case 'concurrency_group': + return [ + { text: `Waiting: ${wait.holders.length} of ${wait.limit} ${slotLabel(wait.group)} slot${wait.limit === 1 ? '' : 's'} in use` }, + ...(wait.holders.length ? [{ text: ' (by ' }, ...wait.holders.flatMap((id, i) => [...(i ? [{ text: ', ' }] : []), runLink(id)]), { text: ')' }] : []) + ]; + case 'priority': + return [ + { text: `Waiting: behind ${wait.aheadRunIds.length} earlier run${wait.aheadRunIds.length === 1 ? '' : 's'} (` }, + ...wait.aheadRunIds.flatMap((id, i) => [...(i ? [{ text: ', ' }] : []), runLink(id)]), + { text: ')' } + ]; + case 'upstream_job': + return [{ text: 'Waiting for ' }, { text: wait.jobKey || shortRunId(wait.runId), href: `/runs/${wait.runId}` }, { text: ' to finish first' }]; + case 'group_parent': + return [{ text: 'Waiting for its group run ' }, { text: shortRunId(wait.runId), href: `/runs/${wait.runId}` }, { text: ' to start' }]; + } +} + +/** The wait as one plain line without links, for status lines and summaries. */ +export function waitText(wait: QueueWait): string { + if (wait.kind === 'concurrency_group') return `Waiting: ${wait.holders.length} of ${wait.limit} ${slotLabel(wait.group)} slot${wait.limit === 1 ? '' : 's'} in use`; + if (wait.kind === 'priority') return `Waiting: behind ${wait.aheadRunIds.length} earlier run${wait.aheadRunIds.length === 1 ? '' : 's'}`; + return describeWait(wait) + .map((p) => p.text) + .join(''); +} + +/** The scheduler's own name for a wait, for tooltips and debugging. */ +export function waitTechnical(wait: QueueWait): string { + switch (wait.kind) { + case 'write_claim': + return `write claim held by ${wait.runId} on ${wait.nodeKey}${wait.otherNodes ? ` (+${wait.otherNodes})` : ''}`; + case 'concurrency_group': + return `concurrency group ${wait.group} full (${wait.holders.length}/${wait.limit}): ${wait.holders.join(', ')}`; + case 'priority': + return `behind ${wait.aheadRunIds.join(', ')} for the same slot (priority, then queue order)`; + case 'upstream_job': + return `upstream job ${wait.jobKey} (${wait.runId}) still active`; + case 'group_parent': + return `group run ${wait.runId} not claimed yet`; + } +} + +export const tickOutcomeTone: Record = { + requested: 'var(--sb-success)', + skipped: 'var(--sb-text-faint)', + covered: 'var(--sb-running)', + failed: 'var(--sb-error)' +}; + +export const tickOutcomeText: Record = { + requested: 'started a run', + skipped: 'skipped', + covered: 'merged', + failed: 'failed' +}; + +/** What each tick outcome means, with the scheduler's own name for debugging. */ +export const tickOutcomeHelp: Record = { + requested: 'It was due and started a run (requested)', + skipped: 'It was due but didn’t start a run, e.g. it was paused or caught up (skipped)', + covered: 'A run of this job was already waiting, so this one merged into it (covered)', + failed: 'The scheduler couldn’t start a run for it (failed)' +}; + +export const evaluationTone: Record = { + requested: 'var(--sb-running)', + completed: 'var(--sb-success)', + failed: 'var(--sb-error)', + timed_out: 'var(--sb-error)', + abandoned: 'var(--sb-warning)' +}; + +export const triggerKindLabel: Record = { + cron_schedule: 'Schedule', + calendar_schedule: 'Calendar schedule', + job_sensor: 'When a job finishes', + node_sensor: 'When data changes', + platform_sensor: 'When a problem happens', + custom_sensor: 'Custom sensor' +}; + +export const triggerKindHelp: Record = { + cron_schedule: 'Runs at times set by a cron expression (cron_schedule)', + calendar_schedule: 'Runs at times listed by a calendar, computed ahead of time (calendar_schedule)', + job_sensor: 'Starts a run when another job or group finishes (job_sensor)', + node_sensor: 'Starts a run when a model or source is rebuilt, fails or gets new data (node_sensor)', + platform_sensor: 'Runs code when a problem is recorded, e.g. to post a message (platform_sensor)', + custom_sensor: 'Your own @sensor function decides when to start runs (custom_sensor)' +}; + +export const stateEventLabel: Record = { + enabled: 'Turned on', + paused: 'Paused', + reset_to_default: 'Set back to what the code says', + cursor_set: 'Position set', + cursor_reset: 'Started over' +}; + +export const eventTone = (event: RunEvent): string => + event.level === 'error' + ? 'var(--sb-error)' + : event.level === 'warn' + ? 'var(--sb-warning)' + : event.producer === 'scheduler' + ? 'var(--sb-stale)' + : event.type.endsWith('_completed') + ? 'var(--sb-success)' + : 'var(--sb-running)'; + +/** e.g. `42s`, `3m 05s`, `2h 10m`. */ +export function formatSeconds(seconds: number): string { + const s = Math.max(0, Math.round(seconds)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`; + return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; +} + +/** `HH:MM:SS.mmm` local time for event tables. */ +export function clockTime(iso: string): string { + const d = new Date(iso); + const pad = (n: number, w = 2) => String(n).padStart(w, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`; +} diff --git a/ui/src/lib/platform/access.ts b/ui/src/lib/platform/access.ts new file mode 100644 index 0000000000..dd2e3fc644 --- /dev/null +++ b/ui/src/lib/platform/access.ts @@ -0,0 +1,96 @@ +// Pure helpers for the access admin pages: role summaries, assignment sources, +// highest role and check-access links. + +import type { AccessQuery, AssignmentSource, BuiltInRole, Capability, Location, RoleAssignment, RoleDefinition, RoleSource } from '$lib/api'; +import { locationName } from './labels'; + +const BUILT_IN_LABEL: Record = { viewer: 'Viewer', operator: 'Operator', developer: 'Developer', admin: 'Admin' }; +const RANK: Record = { viewer: 0, operator: 1, developer: 2, admin: 3 }; + +export function builtInLabel(role: BuiltInRole): string { + return BUILT_IN_LABEL[role]; +} + +/** Count of action-on-selector pairs a role adds. */ +export function additionCount(role: Pick): number { + return role.grants.reduce((sum, g) => sum + g.permissions.length * g.nodes.length, 0); +} + +/** e.g. "Operator + 2 additions", or "8 actions on everything" for built-in roles. */ +export function roleSummary(role: Pick): string { + if (role.source === 'built_in') { + const n = new Set(role.grants.flatMap((g) => g.permissions)).size; + return `${n} ${n === 1 ? 'action' : 'actions'} on everything`; + } + const n = additionCount(role); + const base = role.extends ? BUILT_IN_LABEL[role.extends] : 'Nothing'; + return `${base} + ${n} ${n === 1 ? 'addition' : 'additions'}`; +} + +export const roleSourceLabel: Record = { built_in: 'Built-in', server_config: 'Server config', project: 'Policy file' }; + +export const assignmentSourceLabel: Record = { manual: 'Manual', sso: 'SSO group', policy: 'Server config', built_in: 'Default for everyone' }; + +/** Chip text, e.g. `SSO group "analytics-eng"`. */ +export function sourceChip(a: Pick): string { + if (a.source === 'sso') return `SSO group “${a.ssoGroup}”`; + if (a.source === 'policy') return 'Server config'; + if (a.source === 'built_in') return 'Default for everyone'; + return 'Manual'; +} + +/** Why a derived assignment can't be removed here, and where to change it. */ +export function lockedHint(a: Pick): string | null { + if (a.source === 'sso') return `Comes from the SSO group ${a.ssoGroup}. Change the group's members in your identity provider, or the mapping in the server config.`; + if (a.source === 'policy') return `Set in ${a.definedIn}. Change it there and redeploy.`; + if (a.source === 'built_in') return `Everyone who signs in gets this default role (${a.definedIn ?? 'server config'}).`; + return null; +} + +/** For "highest role": built-in rank, with custom roles just above their base. Unknown or stale roles rank lowest. */ +export function roleRank(roleName: string, roles: readonly RoleDefinition[]): number { + if (roleName in RANK) return RANK[roleName as BuiltInRole]; + const role = roles.find((r) => r.name === roleName); + if (!role) return -1; + return (role.extends ? RANK[role.extends] : 0) + 0.5; +} + +/** The highest non-stale assignment, then the rest. */ +export function rankAssignments(assignments: readonly RoleAssignment[], roles: readonly RoleDefinition[]): RoleAssignment[] { + return [...assignments].sort((a, b) => Number(a.stale) - Number(b.stale) || roleRank(b.role, roles) - roleRank(a.role, roles)); +} + +/** Assignments that apply in a location: scoped to it or to every location. */ +export function assignmentsAt(assignments: readonly RoleAssignment[], locationId: string): RoleAssignment[] { + return assignments.filter((a) => a.scope.locationId === null || a.scope.locationId === locationId); +} + +export function scopeWords(locationId: string | null, locations: readonly Location[] | undefined): string { + return locationId === null ? 'every location' : locationName(locations, locationId); +} + +/** Pre-filled access-checker values. */ +export type CheckInput = Partial & { decision?: string }; + +/** Link to the access checker, pre-filled. */ +export function checkHref(query: CheckInput): string { + const params = new URLSearchParams(); + if (query.principal) params.set('principal', query.principal); + if (query.action) params.set('action', query.action); + if (query.locationId) params.set('location', query.locationId); + if (query.subject) params.set('subject', query.subject); + if (query.decision) params.set('decision', query.decision); + return `/admin/check-access?${params}`; +} + +export function personHref(principal: { name: string }): string { + return `/admin/people/${encodeURIComponent(principal.name)}`; +} + +export function roleHref(name: string): string { + return `/admin/roles/${encodeURIComponent(name)}`; +} + +export function isCapability(value: string | null, all: readonly Capability[]): value is Capability { + return value !== null && (all as readonly string[]).includes(value); +} diff --git a/ui/src/lib/platform/labels.ts b/ui/src/lib/platform/labels.ts new file mode 100644 index 0000000000..7d29b9b9a9 --- /dev/null +++ b/ui/src/lib/platform/labels.ts @@ -0,0 +1,148 @@ +// Labels, tones and pure helpers shared by the Platform and Admin pages. + +import type { + AccountSource, + AssignmentScope, + LocationHealth, + Location, + LocationSummary, + LocationTarget, + PlatformEventKind, + PlatformEventLink, + PlatformEventReaction, + PlatformEventReactionOutcome, + PlatformEventSeverity, + RelationName, + TestOutcome, + TokenState +} from '$lib/api'; + +export const healthTone: Record = { + healthy: 'var(--sb-success)', + degraded: 'var(--sb-warning)', + falling_back: 'var(--sb-warning)', + broken: 'var(--sb-error)' +}; + +export const healthLabel: Record = { + healthy: 'Healthy', + degraded: 'Needs attention', + falling_back: 'Running previous version', + broken: 'Not starting scheduled runs' +}; + +/** A location's health in words, e.g. "Deploy failed — running previous version 17". */ +export function healthWords(location: Pick): string { + const version = location.snapshot?.generation; + if (location.failedDeploy) { + if (location.brokenDeployPolicy === 'fallback') return `Deploy failed — running previous version${version != null ? ` ${version}` : ''}`; + return 'Deploy failed — scheduled runs wait until a deploy works'; + } + return healthLabel[location.health]; +} + +export const eventKindLabel: Record = { + tick_overdue: 'Schedule overdue', + scheduler_no_leader: 'Scheduler stopped', + location_broken: 'Deploy failed', + launch_failed: 'Couldn’t start runs', + connection_test_failed: 'Connection test failed', + warehouse_sync_failed: 'Warehouse sync failing', + heartbeat: 'Server heartbeat' +}; + +/** Where a problem is explained: its location's problem history, or the server status on Locations. */ +export function problemHref(event: { locationId: string | null }): string { + return event.locationId ? `/locations/${event.locationId}#problems` : '/locations#server'; +} + +export const severityTone: Record = { + critical: 'var(--sb-error)', + warning: 'var(--sb-warning)', + info: 'var(--sb-text-faint)' +}; + +export const reactionTone: Record = { + consumed: 'var(--sb-success)', + failed: 'var(--sb-error)', + pending: 'var(--sb-text-faint)' +}; + +export const reactionLabel: Record = { + consumed: 'handled', + failed: 'failing', + pending: 'not yet' +}; + +/** Deep link to the sensor that reacted, scoped to its location. */ +export function reactionHref(reaction: PlatformEventReaction): string { + return `/triggers/${encodeURIComponent(reaction.triggerId)}?location=${encodeURIComponent(reaction.locationId)}`; +} + +export const testTone: Record = { ok: 'var(--sb-success)', failed: 'var(--sb-error)' }; +export const testLabel: Record = { ok: 'Passed', failed: 'Failed' }; + +export const tokenTone: Record = { + active: 'var(--sb-success)', + revoked: 'var(--sb-text-faint)', + expired: 'var(--sb-text-faint)' +}; +export const tokenLabel: Record = { active: 'Active', revoked: 'Revoked', expired: 'Expired' }; + +export const accountSourceLabel: Record = { + oidc: 'Single sign-on', + trusted_proxy: 'Company proxy sign-in', + password: 'Password' +}; + +/** `SCHEMA.NAME`, or `DB.SCHEMA.NAME` when `withDatabase`. */ +export function formatRelation(relation: RelationName, withDatabase = false): string { + const parts = withDatabase && relation.database ? [relation.database, relation.schema, relation.name] : [relation.schema, relation.name]; + return parts.join('.'); +} + +/** Short image ID, e.g. `sha256:4be1c07d9a52` → `4be1c07`. */ +export function shortImage(imageId: string | null): string { + if (!imageId) return '—'; + return imageId.replace(/^sha256:/, '').slice(0, 7); +} + +export function locationName(locations: readonly Location[] | undefined, locationId: string | null): string { + if (!locationId) return 'All locations'; + return locations?.find((l) => l.id === locationId)?.name ?? locationId; +} + +/** e.g. `All locations` or `waffle-shop`. */ +export function formatAssignmentScope(scope: AssignmentScope, locations: readonly Location[] | undefined): string { + return locationName(locations, scope.locationId); +} + +/** A location's one target in words, e.g. `prod · Snowflake`, or `prod` while the adapter is unknown. */ +export function targetWords(target: Pick): string { + return target.adapter ? `${target.name} · ${adapterLabel(target.adapter)}` : target.name; +} + +const ADAPTER_LABELS: Record = { snowflake: 'Snowflake', duckdb: 'DuckDB', postgres: 'Postgres', bigquery: 'BigQuery', databricks: 'Databricks' }; +export function adapterLabel(adapter: string): string { + return ADAPTER_LABELS[adapter] ?? adapter; +} + +/** Deep link for an event link; scoped pages carry `?location=`. */ +export function eventLinkHref(link: PlatformEventLink): string { + if (link.kind === 'location') return `/locations/${link.id}`; + const scope = `location=${encodeURIComponent(link.locationId)}`; + if (link.kind === 'trigger') return `/triggers/${encodeURIComponent(link.id)}?${scope}`; + if (link.kind === 'job') return `/jobs/${encodeURIComponent(link.key)}?${scope}`; + return `/runs/${encodeURIComponent(link.id)}?${scope}`; +} + +export function eventLinkLabel(link: PlatformEventLink, locations: readonly Location[] | undefined): string { + if (link.kind === 'location') return `Location ${locationName(locations, link.id)}`; + if (link.kind === 'trigger') return link.name; + if (link.kind === 'job') return `Job ${link.key}`; + return `Run ${link.id.replace(/^run-/, '')}`; +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/ui/src/lib/platform/maintenance.ts b/ui/src/lib/platform/maintenance.ts new file mode 100644 index 0000000000..d4a787e4b9 --- /dev/null +++ b/ui/src/lib/platform/maintenance.ts @@ -0,0 +1,51 @@ +// Pure projections over synced migration, old-name view and janitor state. + +import type { MigrationDecision, MigrationEvent, OldNameEventType, OldNameView } from '$lib/api'; + +export type OldNameStatus = 'pending' | 'not_kept' | 'live' | 'expired' | 'dropped'; + +/** Current state of one compatibility view, derived from its append-only steps. */ +export function oldNameStatus(view: OldNameView, now: number): OldNameStatus { + const has = (type: OldNameEventType) => view.steps.some((s) => s.eventType === type); + if (has('view_dropped')) return 'dropped'; + if (has('view_created')) return view.expiresAt && Date.parse(view.expiresAt) <= now ? 'expired' : 'live'; + if (view.retention === 'false') return 'not_kept'; + return 'pending'; +} + +export const oldNameStatusLabel: Record = { + pending: 'Being set up', + not_kept: 'Old name not kept', + live: 'Old name works', + expired: 'Past its end date', + dropped: 'Old name removed' +}; + +export const oldNameStatusTone: Record = { + pending: 'var(--sb-running)', + not_kept: 'var(--sb-text-faint)', + live: 'var(--sb-success)', + expired: 'var(--sb-warning)', + dropped: 'var(--sb-text-faint)' +}; + +export const oldNameStepLabel: Record = { + required: 'Planned', + origin_archived: 'Old table archived', + view_created: 'Old name view added', + view_dropped: 'Old name view removed' +}; + +export const decisionLabel: Record = { + migrate: 'Data moved across', + redo: 'Rebuilt in the new place', + superseded_replace: 'Replaced (a newer move took over)', + forced_replace: 'Replaced on request', + renamed: 'Renamed in place' +}; + +/** A move changes schema (or database); a rename keeps it. */ +export function migrationKind(event: MigrationEvent): 'rename' | 'move' { + const { origin, destination } = event; + return origin.schema !== destination.schema || origin.database !== destination.database ? 'move' : 'rename'; +} diff --git a/ui/src/lib/platform/permission-catalog.ts b/ui/src/lib/platform/permission-catalog.ts new file mode 100644 index 0000000000..8686dd3bef --- /dev/null +++ b/ui/src/lib/platform/permission-catalog.ts @@ -0,0 +1,84 @@ +// Plain-language names and risk tags for every action the server checks. +// Shared by the admin pages, the access checker, the audit log and the +// "Requires …" hints on gated controls. + +import { CAPABILITIES, type AuditEntry, type Capability, type PermissionRisk } from '$lib/api'; + +export type PermissionInfo = { + /** Short plain name, e.g. "Run jobs". */ + label: string; + /** One sentence on what it allows. */ + description: string; + risk: PermissionRisk; +}; + +export const PERMISSIONS: Record = { + view: { label: 'See jobs, runs and results', description: 'Browse lineage, the catalog, runs, checks and triggers.', risk: 'read' }, + 'view.sql': { label: 'See SQL', description: 'Read model SQL and the statements runs sent to the warehouse.', risk: 'read' }, + 'view.evidence': { label: 'See sample rows', description: 'Open the sample and failing rows stored with check results. Every read is audited.', risk: 'read' }, + 'job.run': { label: 'Run jobs', description: 'Start a declared job or group.', risk: 'operate' }, + 'run.cancel': { label: 'Cancel runs', description: 'Stop a queued or running run.', risk: 'operate' }, + 'run.rerun': { label: 'Rerun runs', description: 'Run a finished run again, in full or from where it failed.', risk: 'operate' }, + 'run.rewrite_history': { + label: 'Full refresh or reprocess past data', + description: 'Rebuild tables from scratch or reprocess earlier periods, replacing data already built.', + risk: 'changes_data' + }, + 'trigger.toggle': { label: 'Pause and resume schedules and sensors', description: 'Stop or restart automatic runs.', risk: 'operate' }, + 'sensor.reset_cursor': { label: 'Reset where a sensor is up to', description: 'Move a sensor’s position so it looks at events again.', risk: 'operate' }, + 'run.adhoc': { label: 'Build any selection of nodes', description: 'Start an ad-hoc run of nodes outside a declared job, writing to the warehouse.', risk: 'changes_data' }, + 'connection.test': { label: 'Test warehouse connections', description: 'Run the connection check from a location.', risk: 'operate' }, + 'plan.preview': { label: 'Preview build plans', description: 'See what a run would build without running it.', risk: 'read' }, + 'location.manage': { label: 'Manage locations and their tokens', description: 'Register locations and create, rotate or revoke their tokens.', risk: 'administer' }, + 'user.manage': { label: 'Manage people', description: 'Disable or enable people.', risk: 'administer' }, + 'role.assign': { label: 'Give roles', description: 'Assign and remove roles.', risk: 'administer' }, + 'audit.view': { label: 'See the audit log', description: 'Read who did what, including denied requests.', risk: 'administer' } +}; + +export const RISK_LABEL: Record = { + read: 'read', + operate: 'operate', + changes_data: 'changes data', + administer: 'administer' +}; + +export const RISK_ORDER: readonly PermissionRisk[] = ['read', 'operate', 'changes_data', 'administer']; + +export const RISK_TONE: Record = { + read: 'var(--sb-text-faint)', + operate: 'var(--sb-running)', + changes_data: 'var(--sb-warning)', + administer: 'var(--sb-error)' +}; + +/** Actions that change warehouse data or administer access. */ +export function isRisky(action: Capability): boolean { + const risk = PERMISSIONS[action].risk; + return risk === 'changes_data' || risk === 'administer'; +} + +export function permissionLabel(action: Capability): string { + return PERMISSIONS[action].label; +} + +/** Every action, grouped by risk from least to most. */ +export const PERMISSIONS_BY_RISK: { risk: PermissionRisk; actions: Capability[] }[] = RISK_ORDER.map((risk) => ({ + risk, + actions: CAPABILITIES.filter((c) => PERMISSIONS[c].risk === risk) +})); + +/** e.g. `Requires "Give roles" (role.assign)`. */ +export function requiresText(action: Capability): string { + return `Requires “${PERMISSIONS[action].label}” (${action})`; +} + +const AUDIT_ONLY: Record, string> = { + 'session.login': 'Signed in' +}; + +/** Plain name of an audited action, including the audit-only ones. */ +export function auditActionLabel(action: AuditEntry['action']): string { + return action in PERMISSIONS ? PERMISSIONS[action as Capability].label : AUDIT_ONLY[action as keyof typeof AUDIT_ONLY]; +} + +export const AUDIT_ACTIONS: AuditEntry['action'][] = [...CAPABILITIES, 'session.login']; diff --git a/ui/src/lib/run/command-table.generated.json b/ui/src/lib/run/command-table.generated.json new file mode 100644 index 0000000000..7186bfc624 --- /dev/null +++ b/ui/src/lib/run/command-table.generated.json @@ -0,0 +1,711 @@ +{ + "source": "crates/sqlbuild-cli/src/argv/command_table.json", + "verbs": [ + { + "name": "build", + "description": "Build the selected nodes, running their tests and audits", + "flags": [ + { + "flag": "--no-sql-analysis", + "aliases": [ + "--no-sql-validation" + ], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "disable SQL syntax, binding, type inference, and semantic validation" + }, + { + "flag": "--no-cache", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Bypass the compile analysis cache for this invocation" + }, + { + "flag": "--defer-to", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Read unselected upstream nodes from another target" + }, + { + "flag": "--defer-clone-from", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Clone unselected upstream nodes from another target" + }, + { + "flag": "--defer-sources-to", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Read sources from another target" + }, + { + "flag": "--no-python", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Skip Python nodes" + }, + { + "flag": "--no-tests", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Skip unit tests during the build" + }, + { + "flag": "--no-audits", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Skip audits during the build" + }, + { + "flag": "--start-cursor", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "override the start cursor, typed from the selected models' cursor type" + }, + { + "flag": "--end-cursor", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "override the end cursor, typed from the selected models' cursor type" + }, + { + "flag": "--start-cursor-ts", + "aliases": [], + "type": "timestamp", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "timestamp start cursor override" + }, + { + "flag": "--end-cursor-ts", + "aliases": [], + "type": "timestamp", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "timestamp end cursor override" + }, + { + "flag": "--start-cursor-int", + "aliases": [], + "type": "int", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "integer start cursor override" + }, + { + "flag": "--end-cursor-int", + "aliases": [], + "type": "int", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "integer end cursor override" + }, + { + "flag": "--max-microbatches", + "aliases": [], + "type": "int", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Process at most this many microbatches per incremental model" + }, + { + "flag": "--load", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Run loaders as part of the build" + }, + { + "flag": "--no-load", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Skip loaders" + }, + { + "flag": "--reload", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "Reload loader sources from the beginning" + }, + { + "flag": "--fail-fast", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Stop at the first failed node" + }, + { + "flag": "--full-refresh", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "Rebuild incremental models and snapshots from scratch" + }, + { + "flag": "--allow-snapshot-full-refresh", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "Allow a full refresh of snapshot models" + }, + { + "flag": "--allow-table-type-downgrade", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Allow replacing a table with a less durable table type" + }, + { + "flag": "--allow-retention-decrease", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Allow lowering a table retention setting" + }, + { + "flag": "--allow-missing-migration-origin", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Allow a migration whose origin relation no longer exists" + }, + { + "flag": "--allow-snapshot-schema-change", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Allow a schema change on snapshot models" + }, + { + "flag": "--concurrency", + "aliases": [], + "type": "int", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Nodes to run in parallel within this run" + }, + { + "flag": "--test-timeout", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Per-test timeout for unit tests this build runs, such as '30s', or 'disabled'" + }, + { + "flag": "--verbose", + "aliases": [ + "-v" + ], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "More detailed output" + }, + { + "flag": "--select", + "aliases": [ + "-s" + ], + "type": "selector", + "choices": null, + "repeatable": true, + "rewritesHistory": false, + "description": "Selector expression, e.g. +fact_orders, tag:finance, group:marts" + }, + { + "flag": "--exclude", + "aliases": [], + "type": "selector", + "choices": null, + "repeatable": true, + "rewritesHistory": false, + "description": "Selector expression to leave out" + }, + { + "flag": "--vars", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Project variables as a JSON or key=value mapping" + }, + { + "flag": "--warehouse", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "warehouse for this invocation; overrides the target's command-group warehouse and the connection warehouse" + } + ] + }, + { + "name": "test", + "description": "Run unit and scenario tests for the selection", + "flags": [ + { + "flag": "--no-sql-analysis", + "aliases": [ + "--no-sql-validation" + ], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "disable SQL syntax, binding, type inference, and semantic validation" + }, + { + "flag": "--concurrency", + "aliases": [], + "type": "int", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Nodes to run in parallel within this run" + }, + { + "flag": "--test-timeout", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Per-test timeout for this run, such as '30s' or '2m', or 'disabled'; overrides TEST() and project settings" + }, + { + "flag": "--case", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Run one named parameter case" + }, + { + "flag": "--select", + "aliases": [ + "-s" + ], + "type": "selector", + "choices": null, + "repeatable": true, + "rewritesHistory": false, + "description": "Selector expression, e.g. +fact_orders, tag:finance, group:marts" + }, + { + "flag": "--exclude", + "aliases": [], + "type": "selector", + "choices": null, + "repeatable": true, + "rewritesHistory": false, + "description": "Selector expression to leave out" + }, + { + "flag": "--vars", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Project variables as a JSON or key=value mapping" + }, + { + "flag": "--warehouse", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "warehouse for this invocation; overrides the target's command-group warehouse and the connection warehouse" + } + ] + }, + { + "name": "seed", + "description": "Load seed files", + "flags": [ + { + "flag": "--concurrency", + "aliases": [], + "type": "int", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Nodes to run in parallel within this run" + }, + { + "flag": "--select", + "aliases": [ + "-s" + ], + "type": "selector", + "choices": null, + "repeatable": true, + "rewritesHistory": false, + "description": "Selector expression, e.g. +fact_orders, tag:finance, group:marts" + }, + { + "flag": "--exclude", + "aliases": [], + "type": "selector", + "choices": null, + "repeatable": true, + "rewritesHistory": false, + "description": "Selector expression to leave out" + }, + { + "flag": "--vars", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Project variables as a JSON or key=value mapping" + }, + { + "flag": "--warehouse", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "warehouse for this invocation; overrides the target's command-group warehouse and the connection warehouse" + } + ] + }, + { + "name": "load", + "description": "Run loaders into their source tables", + "flags": [ + { + "flag": "--reload", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "Reload loader sources from the beginning" + }, + { + "flag": "--concurrency", + "aliases": [], + "type": "int", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Nodes to run in parallel within this run" + }, + { + "flag": "--select", + "aliases": [ + "-s" + ], + "type": "selector", + "choices": null, + "repeatable": true, + "rewritesHistory": false, + "description": "Selector expression, e.g. +fact_orders, tag:finance, group:marts" + }, + { + "flag": "--exclude", + "aliases": [], + "type": "selector", + "choices": null, + "repeatable": true, + "rewritesHistory": false, + "description": "Selector expression to leave out" + }, + { + "flag": "--start-cursor", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "override the start cursor, typed from the selected models' cursor type" + }, + { + "flag": "--end-cursor", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "override the end cursor, typed from the selected models' cursor type" + }, + { + "flag": "--start-cursor-ts", + "aliases": [], + "type": "timestamp", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "timestamp start cursor override" + }, + { + "flag": "--end-cursor-ts", + "aliases": [], + "type": "timestamp", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "timestamp end cursor override" + }, + { + "flag": "--start-cursor-int", + "aliases": [], + "type": "int", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "integer start cursor override" + }, + { + "flag": "--end-cursor-int", + "aliases": [], + "type": "int", + "choices": null, + "repeatable": false, + "rewritesHistory": true, + "description": "integer end cursor override" + }, + { + "flag": "--vars", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Project variables as a JSON or key=value mapping" + }, + { + "flag": "--warehouse", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "warehouse for this invocation; overrides the target's command-group warehouse and the connection warehouse" + } + ] + }, + { + "name": "audit", + "description": "Evaluate audits against what is already built", + "flags": [ + { + "flag": "--no-sql-analysis", + "aliases": [ + "--no-sql-validation" + ], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "disable SQL syntax, binding, type inference, and semantic validation" + }, + { + "flag": "--defer-to", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Read unselected upstream nodes from another target" + }, + { + "flag": "--concurrency", + "aliases": [], + "type": "int", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Nodes to run in parallel within this run" + }, + { + "flag": "--select", + "aliases": [ + "-s" + ], + "type": "selector", + "choices": null, + "repeatable": true, + "rewritesHistory": false, + "description": "Selector expression, e.g. +fact_orders, tag:finance, group:marts" + }, + { + "flag": "--exclude", + "aliases": [], + "type": "selector", + "choices": null, + "repeatable": true, + "rewritesHistory": false, + "description": "Selector expression to leave out" + }, + { + "flag": "--vars", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Project variables as a JSON or key=value mapping" + }, + { + "flag": "--warehouse", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "warehouse for this invocation; overrides the target's command-group warehouse and the connection warehouse" + } + ] + }, + { + "name": "freshness", + "description": "Check source freshness", + "flags": [ + { + "flag": "--no-sql-analysis", + "aliases": [ + "--no-sql-validation" + ], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "disable SQL syntax, binding, type inference, and semantic validation" + }, + { + "flag": "--state", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Record freshness results in the state tables" + }, + { + "flag": "--fail-on-error", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Fail when a freshness check errors" + }, + { + "flag": "--fail-on-stale", + "aliases": [], + "type": "bool", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Fail when a source is stale" + }, + { + "flag": "--select", + "aliases": [ + "-s" + ], + "type": "selector", + "choices": null, + "repeatable": true, + "rewritesHistory": false, + "description": "Selector expression, e.g. +fact_orders, tag:finance, group:marts" + }, + { + "flag": "--exclude", + "aliases": [], + "type": "selector", + "choices": null, + "repeatable": true, + "rewritesHistory": false, + "description": "Selector expression to leave out" + }, + { + "flag": "--vars", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "Project variables as a JSON or key=value mapping" + }, + { + "flag": "--warehouse", + "aliases": [], + "type": "string", + "choices": null, + "repeatable": false, + "rewritesHistory": false, + "description": "warehouse for this invocation; overrides the target's command-group warehouse and the connection warehouse" + } + ] + } + ] +} diff --git a/ui/src/lib/run/command-table.ts b/ui/src/lib/run/command-table.ts new file mode 100644 index 0000000000..c8266b043f --- /dev/null +++ b/ui/src/lib/run/command-table.ts @@ -0,0 +1,29 @@ +// The launchable verbs and flags, generated from the CLI's command table by +// `npm run gen:command-table`. Never hand-edit the generated JSON. + +import type { LaunchVerb } from '$lib/api'; +import table from './command-table.generated.json'; + +export type FlagType = 'bool' | 'string' | 'int' | 'timestamp' | 'selector'; + +export type FlagSpec = { + flag: string; + aliases: string[]; + type: FlagType; + choices: string[] | null; + repeatable: boolean; + rewritesHistory: boolean; + description: string; +}; + +export type VerbSpec = { name: LaunchVerb; description: string; flags: FlagSpec[] }; + +export const VERBS = table.verbs as VerbSpec[]; + +export function verbSpec(name: LaunchVerb): VerbSpec { + return VERBS.find((v) => v.name === name) ?? VERBS[0]; +} + +export function flagSpec(verb: LaunchVerb, flag: string): FlagSpec | undefined { + return verbSpec(verb).flags.find((f) => f.flag === flag || f.aliases.includes(flag)); +} diff --git a/ui/src/lib/run/command.ts b/ui/src/lib/run/command.ts new file mode 100644 index 0000000000..5570861b7a --- /dev/null +++ b/ui/src/lib/run/command.ts @@ -0,0 +1,141 @@ +// Parses the run panel's command line into a launch request, and produces +// autocomplete suggestions from the command table and the snapshot's nodes. +// Pure: no side effects. + +import type { AdhocRunRequest, LaunchVerb, SnapshotNode } from '$lib/api'; +import { flagSpec, VERBS, type FlagSpec } from './command-table'; + +export type ParsedCommand = { + request: AdhocRunRequest; + /** Problems the panel reports before asking the server. */ + errors: string[]; +}; + +const SELECTOR_FLAGS = new Set(['--select', '-s', '--exclude']); + +export function tokenize(text: string): string[] { + return text.trim().split(/\s+/).filter(Boolean); +} + +export function parseCommand(text: string): ParsedCommand { + const tokens = tokenize(text); + const errors: string[] = []; + if (tokens[0] === 'sqb') tokens.shift(); + const verbName = tokens.shift() ?? 'build'; + const verb = (VERBS.find((v) => v.name === verbName)?.name ?? 'build') as LaunchVerb; + if (!VERBS.some((v) => v.name === verbName)) errors.push(`${verbName} can't be launched from the panel; use ${VERBS.map((v) => v.name).join(', ')}`); + const select: string[] = []; + const exclude: string[] = []; + const flags: Record = {}; + let mode: 'select' | 'exclude' | null = 'select'; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token.startsWith('-')) { + mode = null; + if (token === '--target') { + errors.push('The target is set by the location; remove --target'); + i++; + continue; + } + if (SELECTOR_FLAGS.has(token)) { + mode = token === '--exclude' ? 'exclude' : 'select'; + continue; + } + const spec = flagSpec(verb, token); + if (!spec) { + flags[token] = true; + continue; + } + if (spec.type === 'bool') flags[spec.flag] = true; + else { + const value = tokens[i + 1]; + if (value === undefined || value.startsWith('--')) flags[spec.flag] = true; + else { + flags[spec.flag] = value; + i++; + } + } + continue; + } + if (mode === 'exclude') exclude.push(token); + else if (mode === 'select') select.push(token); + else errors.push(`Unexpected ${token}; selectors go after --select or --exclude`); + } + return { request: { verb, select, exclude, flags }, errors }; +} + +export function buildCommand(request: AdhocRunRequest): string { + const parts = ['sqb', request.verb]; + if (request.select.length) parts.push('--select', ...request.select); + if (request.exclude.length) parts.push('--exclude', ...request.exclude); + for (const [flag, value] of Object.entries(request.flags)) parts.push(value === true ? flag : `${flag} ${value}`); + return parts.join(' '); +} + +/** Switches the verb, keeping the selection and the flags the new verb accepts. */ +export function withVerb(text: string, verb: LaunchVerb): string { + const { request } = parseCommand(text); + const flags = Object.fromEntries(Object.entries(request.flags).filter(([flag]) => flagSpec(verb, flag))); + return buildCommand({ ...request, verb, flags }) + ' '; +} + +export type Suggestion = { value: string; label: string; hint: string }; + +export type TokenContext = { start: number; end: number; text: string; previousFlag: string | null }; + +export function tokenAt(text: string, caret: number): TokenContext { + const before = text.slice(0, caret); + const start = before.search(/\S*$/); + const after = text.slice(caret).search(/\s|$/); + const end = caret + (after < 0 ? 0 : after); + const previous = tokenize(before.slice(0, start)).reverse(); + const lastFlag = previous.find((t) => t.startsWith('-')) ?? null; + return { start, end, text: text.slice(start, caret), previousFlag: lastFlag }; +} + +function selectorPool(nodes: SnapshotNode[]): { value: string; hint: string }[] { + const unique = (values: string[]) => [...new Set(values)].sort(); + return [ + ...unique(nodes.flatMap((n) => n.tags)).map((t) => ({ value: `tag:${t}`, hint: 'tag' })), + ...unique(nodes.map((n) => n.group)).map((g) => ({ value: `group:${g}`, hint: 'group' })), + ...unique(nodes.map((n) => n.kind)).map((k) => ({ value: `kind:${k}`, hint: 'kind' })), + ...unique(nodes.map((n) => n.path.replace(/\/[^/]+$/, '/'))).map((p) => ({ value: `path:${p}`, hint: 'path' })), + ...nodes.map((n) => ({ value: n.key, hint: n.kind })) + ]; +} + +export function suggest(text: string, caret: number, nodes: SnapshotNode[]): Suggestion[] { + const { request } = parseCommand(text); + const ctx = tokenAt(text, caret); + const verb = VERBS.find((v) => v.name === request.verb)!; + const tokens = tokenize(text); + if (ctx.text.startsWith('-')) { + return verb.flags + .filter((f) => f.flag.startsWith(ctx.text) && (f.repeatable || !tokens.includes(f.flag) || ctx.text === f.flag)) + .slice(0, 14) + .map((f) => ({ value: f.flag, label: f.flag, hint: `${f.type}${f.rewritesHistory ? ' · needs run.rewrite_history' : ''} · ${f.description}` })); + } + const spec: FlagSpec | undefined = ctx.previousFlag ? flagSpec(request.verb, ctx.previousFlag) : undefined; + const prevToken = tokenize(text.slice(0, ctx.start)).at(-1) ?? ''; + if (spec && spec.type !== 'selector' && prevToken === ctx.previousFlag) { + if (spec.choices) return spec.choices.filter((c) => c.startsWith(ctx.text)).map((c) => ({ value: c, label: c, hint: spec.flag })); + if (spec.type === 'timestamp' && !ctx.text) return [{ value: new Date().toISOString().slice(0, 10) + 'T00:00:00Z', label: 'today 00:00 UTC', hint: 'timestamp' }]; + return []; + } + const inSelector = !ctx.previousFlag || SELECTOR_FLAGS.has(ctx.previousFlag); + if (!inSelector || !ctx.text) return []; + const match = ctx.text.match(/^(\+?)(.*?)(\+?)$/); + const [, pre = '', body = '', post = ''] = match ?? []; + if (!body) return []; + return selectorPool(nodes) + .filter((p) => p.value.toLowerCase().includes(body.toLowerCase())) + .slice(0, 12) + .map((p) => ({ value: `${pre}${p.value}${post}`, label: `${pre}${p.value}${post}`, hint: p.hint })); +} + +/** Inserts `value` in place of the token at the caret; returns the new text and caret. */ +export function applySuggestion(text: string, caret: number, value: string): { text: string; caret: number } { + const ctx = tokenAt(text, caret); + const next = `${text.slice(0, ctx.start)}${value} ${text.slice(ctx.end).replace(/^\s+/, '')}`; + return { text: next, caret: ctx.start + value.length + 1 }; +} diff --git a/ui/src/lib/run/run-panel.svelte b/ui/src/lib/run/run-panel.svelte new file mode 100644 index 0000000000..b993daca1e --- /dev/null +++ b/ui/src/lib/run/run-panel.svelte @@ -0,0 +1,515 @@ + + +{#snippet chips(keys: string[], tone: 'plain' | 'bad' = 'plain')} +
+ {#each keys.slice(0, CHIP_CAP) as k (k)}{k}{/each} + {#if keys.length > CHIP_CAP}+{keys.length - CHIP_CAP} more{/if} +
+{/snippet} + +{#if open} + +
+
+ Run +
+ {#each VERBS as v (v.name)} + + {/each} +
+ {spec.description} + + {app.location?.name} · target {app.target?.name} + + +
+ +
+
+
+ Server check + {#if checking}checking…{:else if checkError}{checkError}{/if} +
+ {#if parsed.errors.length} + {#each parsed.errors as e (e)}

{e}

{/each} + {/if} + {#if check} +
+
Picks {check.resolvedNodes.length} node{check.resolvedNodes.length === 1 ? '' : 's'}{parsed.request.select.length ? '' : ' · whole project'}
+ {@render chips(check.resolvedNodes)} +
+
+
Updates {check.writeSet.length ? `${check.writeSet.length} node${check.writeSet.length === 1 ? '' : 's'}` : 'nothing'}
+ {#if check.writeSet.length}{@render chips(check.writeSet)}{:else}

sqb {verb} only reads, so it doesn't wait for other runs.

{/if} +
+
+
+ Permission + {#if check.adhoc.allowed}you can update all of these{:else}the whole run would be refused{/if} +
+ {#if !check.adhoc.allowed} +

+ {app.can('run.adhoc') ? `You can't update ${check.adhoc.outOfScope.length} of the nodes this run would update. Narrow the selection, or ask for a role that covers them (run.adhoc).` : "You don't have permission to start runs by hand in this target (run.adhoc)."} +

+ {@render chips(check.adhoc.outOfScope, 'bad')} + {/if} +
+ {#if check.rewriteHistory.required} +
+
+ Rebuilds history + {#if check.rewriteHistory.allowed}you're allowed to{:else}you need permission{/if} +
+

These options replace data that was already built: {check.rewriteHistory.reasons.join(', ')}

+
+ {/if} + {#if check.claimConflicts.length} +
+
Will wait other runs are updating the same data
+ {#each check.claimConflicts.slice(0, 6) as c (c.runId + c.nodeKey)} +

Run (open = false)}>{c.runId.replace(/^run-/, '')} is updating {c.nodeKey}

+ {/each} +
+ {/if} + {#each check.problems as p (p.code + p.message)}

{p.message}

{/each} + {:else if !checkError} +

Checking the request…

+ {/if} +
+ +
+
Options · sqb {verb}
+ {#each spec.flags.filter((f) => f.flag !== '--select' && f.flag !== '--exclude') as f (f.flag)} + + {/each} +

+ The same options as the command line. Type -- for options, or a node name, tag:, group:, kind: or path: after --select; + before or after adds upstream or downstream nodes. +

+
+
+ +
+ {#if acOpen && suggestions.length} +
+ {#each suggestions as s, i (s.value)} + + {/each} +
+ {/if} +
+ $ + (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && track()} + onkeydown={onKeydown} + onblur={() => setTimeout(() => (acOpen = false), 120)} + spellcheck="false" + placeholder="sqb build --select +fact_orders" + /> + --target {app.target?.name} + + ▷ Run + +
+ {#if launchError}

{launchError}

{:else if !canLaunch && blockedReason && fresh}

{blockedReason}

{/if} +
+
+{/if} + + diff --git a/ui/src/lib/sql/diff.ts b/ui/src/lib/sql/diff.ts new file mode 100644 index 0000000000..d0cfac4f90 --- /dev/null +++ b/ui/src/lib/sql/diff.ts @@ -0,0 +1,64 @@ +// Pure line diff (LCS) with context folding for the SQL drift view. + +export type DiffOp = 'same' | 'add' | 'del'; +export type DiffLine = { op: DiffOp; a?: number; b?: number; text: string }; +export type DiffRow = DiffLine | { op: 'fold'; count: number; key: string }; + +export function diffLines(before: string, after: string): DiffLine[] { + const a = before.split('\n'); + const b = after.split('\n'); + const n = a.length; + const m = b.length; + const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + const out: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + out.push({ op: 'same', a: i + 1, b: j + 1, text: b[j] }); + i++; + j++; + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + out.push({ op: 'del', a: i + 1, text: a[i] }); + i++; + } else { + out.push({ op: 'add', b: j + 1, text: b[j] }); + j++; + } + } + while (i < n) out.push({ op: 'del', a: ++i, text: a[i - 1] }); + while (j < m) out.push({ op: 'add', b: ++j, text: b[j - 1] }); + return out; +} + +/** Collapse runs of unchanged lines, keeping `context` lines around each change. */ +export function foldDiff(lines: DiffLine[], context = 3): DiffRow[] { + const keep = lines.map(() => false); + lines.forEach((l, idx) => { + if (l.op === 'same') return; + for (let k = Math.max(0, idx - context); k <= Math.min(lines.length - 1, idx + context); k++) keep[k] = true; + }); + const rows: DiffRow[] = []; + let run = 0; + lines.forEach((l, idx) => { + if (keep[idx]) { + if (run) rows.push({ op: 'fold', count: run, key: `f${idx}` }); + run = 0; + rows.push(l); + } else run++; + }); + if (run) rows.push({ op: 'fold', count: run, key: 'fend' }); + return rows; +} + +export function diffStats(lines: DiffLine[]) { + return { + added: lines.filter((l) => l.op === 'add').length, + removed: lines.filter((l) => l.op === 'del').length + }; +} diff --git a/ui/src/lib/sql/highlight.ts b/ui/src/lib/sql/highlight.ts new file mode 100644 index 0000000000..8dfa720634 --- /dev/null +++ b/ui/src/lib/sql/highlight.ts @@ -0,0 +1,52 @@ +// SQLBuild-aware syntax highlighting. +// +// SQLBuild SQL is a superset of SQL: a MODEL(...) config header (materialization, +// tags, description, columns + audits DSL) followed by a SELECT body, with +// __ref()/__source() reference functions. No stock grammar knows the header, so +// we layer a small SQLBuild grammar on top of Prism's base SQL grammar. +// +// Output is class-based (.token.*), themed via CSS variables in layout.css so it +// adapts to light/dark automatically. + +import Prism from 'prismjs'; +import 'prismjs/components/prism-sql'; + +let registered = false; + +export function ensureSqlbuildGrammar() { + if (registered) return; + registered = true; + + // clone Prism's base SQL grammar as the body language + const base = Prism.languages.sql; + + const sqlbuild = Prism.languages.extend('sql', {}); + // must exist before insertBefore('sqlbuild', ...) can extend it + Prism.languages.sqlbuild = sqlbuild; + + // SQLBuild reference functions: __ref('x'), __source('a','b') -> function color + Prism.languages.insertBefore('sqlbuild', 'function', { + 'sb-ref': { + pattern: /\b__(?:ref|source|var|env)\b(?=\s*\()/, + alias: 'function' + } + }); + + // MODEL config-header keywords (the DSL before SELECT) + Prism.languages.insertBefore('sqlbuild', 'keyword', { + 'sb-config': { + // MODEL / materialization kinds / config props / audit names + pattern: + /\b(?:MODEL|materialized|view|table|incremental|ephemeral|seed|source|loader|tags|description|columns|audits|owner|tags|cursor|unique_key|grain|cluster_by|partition_by|strategy|merge|delete_insert|append|microbatch|not_null|unique|accepted_values|relationships|expression_is_true|freshness)\b/i, + alias: 'keyword' + } + }); + + // register under both names so `language-sqlbuild` and `language-sql` both work + void base; +} + +export function highlightSqlbuild(code: string): string { + ensureSqlbuildGrammar(); + return Prism.highlight(code, Prism.languages.sqlbuild, 'sqlbuild'); +} diff --git a/ui/src/lib/sql/outline.ts b/ui/src/lib/sql/outline.ts new file mode 100644 index 0000000000..83089b8467 --- /dev/null +++ b/ui/src/lib/sql/outline.ts @@ -0,0 +1,72 @@ +// Pure helpers for the SQL viewer: structural outline, compiled rendering and +// line-level highlighting with clickable __ref() links. + +import { highlightSqlbuild } from './highlight'; + +export type OutlineItem = { label: string; line: number }; +export type SqlOutline = { + config: { key: string; value: string }[]; + refs: OutlineItem[]; + steps: OutlineItem[]; + finalSelect?: number; + columns: string[]; +}; + +const REF_RE = /__ref\(\s*"(\w+)"\s*\)/g; + +export function compileRefs(raw: string, database = 'waffle_shop', schema = 'prod'): string { + const withoutHeader = raw.replace(/^MODEL\s*\([\s\S]*?\n\);\s*\n+/, ''); + return withoutHeader.replace(REF_RE, (_m, name: string) => `"${database}"."${schema}"."${name}"`); +} + +export function outlineSql(raw: string): SqlOutline { + const lines = raw.split('\n'); + const config: { key: string; value: string }[] = []; + const header = raw.match(/^MODEL\s*\(([\s\S]*?)\n\);/); + if (header) { + for (const key of ['materialized', 'strategy', 'unique_key', 'tags', 'owner']) { + const m = header[1].match(new RegExp(`\\b${key}\\s+(\\[[^\\]]*\\]|[^,\\n]+)`)); + if (m) config.push({ key, value: m[1].replace(/[[\]"]/g, '').replace(/,\s*/g, ', ').trim() }); + } + } + + const refs: OutlineItem[] = []; + const seen = new Set(); + const steps: OutlineItem[] = []; + let finalSelect: number | undefined; + lines.forEach((line, i) => { + for (const m of line.matchAll(REF_RE)) { + if (!seen.has(m[1])) { + seen.add(m[1]); + refs.push({ label: m[1], line: i + 1 }); + } + } + const cte = line.match(/^\s*(?:WITH\s+|,\s*)?(\w+)\s+AS\s*\($/i); + if (cte) steps.push({ label: cte[1], line: i + 1 }); + if (/^SELECT\b/i.test(line)) finalSelect = i + 1; + }); + + const columns: string[] = []; + if (finalSelect) { + for (const line of lines.slice(finalSelect)) { + if (/^\s*FROM\b/i.test(line)) break; + const col = line.trim().replace(/,$/, ''); + if (!col) continue; + const alias = col.match(/\bAS\s+(\w+)$/i)?.[1] ?? col.split('.').pop()?.split(/\s+/).pop(); + if (alias && /^\w+$/.test(alias)) columns.push(alias); + } + } + return { config, refs, steps, finalSelect, columns }; +} + +/** Highlight SQL and split into per-line HTML; raw refs become clickable links. */ +export function highlightLines(code: string, linkRefs: boolean): string[] { + let html = highlightSqlbuild(code); + if (linkRefs) { + html = html.replace( + /(__ref<\/span>\(<\/span>)"(\w+)"<\/span>/g, + '$1' + ); + } + return html.split('\n'); +} diff --git a/ui/src/lib/sql/versions.ts b/ui/src/lib/sql/versions.ts new file mode 100644 index 0000000000..3d41611734 --- /dev/null +++ b/ui/src/lib/sql/versions.ts @@ -0,0 +1,2 @@ +/** A stored version of a model's SQL to compare against, e.g. what a build ran. */ +export type SqlVersion = { id: string; label: string; detail: string; raw: string; compiled?: string }; diff --git a/ui/src/lib/state/access-directory.svelte.ts b/ui/src/lib/state/access-directory.svelte.ts new file mode 100644 index 0000000000..ad6bd39db1 --- /dev/null +++ b/ui/src/lib/state/access-directory.svelte.ts @@ -0,0 +1,50 @@ +import type { AccessOverview, Account, RoleAssignment, RoleDefinition } from '$lib/api'; +import { app } from './app.svelte'; +import { createResource } from './resource.svelte'; + +/** + * Accounts with their role assignments, the roles of every location, and + * per-account risky actions: what the People pages show. + * Must be created during component initialisation. + */ +export function createAccessDirectory() { + const canAssign = () => app.can('role.assign'); + const accounts = createResource((client) => (app.can('user.manage') || canAssign() ? client.listAccounts().catch(() => [] as Account[]) : Promise.resolve([] as Account[]))); + const assignments = createResource((client) => (canAssign() ? client.listRoleAssignments() : Promise.resolve([] as RoleAssignment[]))); + const overview = createResource((client) => (app.can('user.manage') || canAssign() ? client.listAccessOverview() : Promise.resolve([] as AccessOverview[]))); + const roles = createResource(async (client) => { + const map = new Map(); + if (!canAssign()) return map; + const locations = app.bootstrap?.locations ?? []; + const lists = await Promise.all(locations.map((l) => client.listRoles(l.id))); + locations.forEach((l, i) => map.set(l.id, lists[i])); + return map; + }); + + const rolesByLocation = $derived(roles.data ?? new Map()); + const allRoles = $derived([...new Map([...rolesByLocation.values()].flat().map((r) => [r.name, r])).values()]); + const riskyOf = $derived(new Map((overview.data ?? []).map((o) => [o.principal, o.riskyActions]))); + + return { + accounts, + assignments, + overview, + roles, + get rolesByLocation() { + return rolesByLocation; + }, + get allRoles() { + return allRoles; + }, + assignmentsOf: (name: string) => (assignments.data ?? []).filter((a) => a.principal.name === name), + riskyOf: (name: string) => riskyOf.get(name) ?? [], + refresh() { + accounts.refresh(); + assignments.refresh(); + overview.refresh(); + roles.refresh(); + } + }; +} + +export type AccessDirectory = ReturnType; diff --git a/ui/src/lib/state/account-table.svelte.ts b/ui/src/lib/state/account-table.svelte.ts new file mode 100644 index 0000000000..3f0bf00701 --- /dev/null +++ b/ui/src/lib/state/account-table.svelte.ts @@ -0,0 +1,80 @@ +import type { Account, AssignmentSource } from '$lib/api'; +import { assignmentsAt } from '$lib/platform/access'; +import { createAccessDirectory } from './access-directory.svelte'; + +/** The People or Service accounts table: accounts with their roles, filtered. Must be created during component initialisation. */ +export function createAccountTable(kind: Account['kind']) { + const directory = createAccessDirectory(); + + let query = $state(''); + let role = $state(''); + let locationId = $state(''); + let source = $state(''); + let riskyOnly = $state(false); + + const people = $derived((directory.accounts.data ?? []).filter((a) => a.kind === kind)); + const roleNames = $derived([...new Set((directory.assignments.data ?? []).filter((a) => !a.stale).map((a) => a.role))].sort()); + + function relevant(name: string) { + const all = directory.assignmentsOf(name); + return locationId ? assignmentsAt(all, locationId) : all; + } + + const shown = $derived.by(() => { + const q = query.trim().toLowerCase(); + return people.filter((p) => { + const mine = relevant(p.name); + if (q && !`${p.name} ${p.displayName} ${p.email ?? ''} ${mine.map((m) => m.role).join(' ')}`.toLowerCase().includes(q)) return false; + if (role && !mine.some((m) => m.role === role && !m.stale)) return false; + if (locationId && !mine.length) return false; + if (source && !mine.some((m) => m.source === source)) return false; + if (riskyOnly && !directory.riskyOf(p.name).length) return false; + return true; + }); + }); + + return { + directory, + /** Every account of the kind, before filters. */ + get accounts() { + return people; + }, + get shown() { + return shown; + }, + get roleNames() { + return roleNames; + }, + relevant, + get query() { + return query; + }, + set query(v: string) { + query = v; + }, + get role() { + return role; + }, + set role(v: string) { + role = v; + }, + get locationId() { + return locationId; + }, + set locationId(v: string) { + locationId = v; + }, + get source() { + return source; + }, + set source(v: AssignmentSource | '') { + source = v; + }, + get riskyOnly() { + return riskyOnly; + }, + set riskyOnly(v: boolean) { + riskyOnly = v; + } + }; +} diff --git a/ui/src/lib/state/app.svelte.ts b/ui/src/lib/state/app.svelte.ts new file mode 100644 index 0000000000..1aec50e41a --- /dev/null +++ b/ui/src/lib/state/app.svelte.ts @@ -0,0 +1,171 @@ +import { browser } from '$app/environment'; +import { logout } from '$lib/api/http-client'; +import { MockApiClient } from '$lib/api/mock/mock-client'; +import { + ApiError, + channelMatches, + createClient, + hasCapability, + type ApiClient, + type Bootstrap, + type Capability, + type ConnectionState, + type NavCounts, + type PlatformEvent, + type Scope, + type WarehouseSync +} from '$lib/api'; + +const SCOPE_KEY = 'sb-scope'; + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * App-wide state: the bootstrap, the selected location, and the + * shell's live data (navigation counts, platform events, warehouse sync). + */ +class AppState { + client: ApiClient; + bootstrap = $state(null); + bootError = $state(null); + locationId = $state(''); + /** Wall clock, ticking every second, for ages and elapsed times. */ + now = $state(Date.now()); + connection = $state('connecting'); + navCounts = $state(null); + platformEvents = $state([]); + warehouseSync = $state(null); + + readonly scope = $derived(this.locationId ? { locationId: this.locationId } : null); + readonly location = $derived(this.bootstrap?.locations.find((l) => l.id === this.locationId) ?? null); + /** The selected location's one target. */ + readonly target = $derived(this.location?.target ?? null); + readonly user = $derived(this.bootstrap?.user ?? null); + /** Server-wide actions plus those allowed somewhere in the selected location. */ + readonly capabilities = $derived([...(this.bootstrap?.capabilities ?? []), ...(this.location?.capabilities ?? [])]); + + #started = false; + + constructor() { + this.client = createClient(new URLSearchParams(browser ? window.location.search : ''), () => this.#signIn()); + this.signOut = this.client instanceof MockApiClient ? null : async () => { + await logout(); + this.#signIn(); + }; + } + + /** Sends the browser to the login page, which returns here after sign-in. */ + #signIn() { + if (!browser || window.location.pathname === '/login') return; + const back = window.location.pathname + window.location.search; + window.location.assign(`/login?next=${encodeURIComponent(back)}`); + } + + /** Ends the session; only the server client has one. */ + readonly signOut: (() => Promise) | null; + + can(capability: Capability): boolean { + return hasCapability(this.capabilities, capability); + } + + async start(): Promise { + if (this.#started) return; + this.#started = true; + setInterval(() => (this.now = Date.now()), 1000); + this.client.changes.onConnectionState((state) => (this.connection = state)); + this.client.changes.subscribe((signal) => { + if (channelMatches(signal.channel, ['platform_events'])) void this.#loadPlatformEvents(); + if (channelMatches(signal.channel, ['warehouse_sync'])) void this.#loadWarehouseSync(); + if (channelMatches(signal.channel, ['runs', 'triggers', 'checks'])) void this.#loadNavCounts(); + }); + try { + this.bootstrap = await this.client.bootstrap(); + } catch (error) { + // A rejected session is already on its way to the login page. + if (error instanceof ApiError && error.status === 401) return; + this.bootError = message(error); + return; + } + this.#restoreScope(); + this.client.changes.follow(this.scope); + void this.#loadPlatformEvents(); + this.#loadScoped(); + } + + selectLocation(locationId: string): void { + const location = this.bootstrap?.locations.find((l) => l.id === locationId); + if (!location) return; + this.locationId = location.id; + this.#scopeChanged(); + } + + refreshNavCounts(): void { + void this.#loadNavCounts(); + } + + #scopeChanged() { + if (browser) localStorage.setItem(SCOPE_KEY, JSON.stringify(this.scope)); + this.client.changes.follow(this.scope); + this.navCounts = null; + this.warehouseSync = null; + this.#loadScoped(); + } + + #restoreScope() { + const locations = this.bootstrap?.locations ?? []; + const search = browser ? new URLSearchParams(window.location.search) : new URLSearchParams(); + let saved: Partial = {}; + try { + saved = browser ? JSON.parse(localStorage.getItem(SCOPE_KEY) ?? '{}') ?? {} : {}; + } catch { + saved = {}; + } + const wantedLocation = search.get('location') ?? saved.locationId; + const location = + locations.find((l) => l.id === wantedLocation || l.name === wantedLocation) ?? locations[0]; + if (!location) return; + this.locationId = location.id; + } + + #loadScoped() { + void this.#loadNavCounts(); + void this.#loadWarehouseSync(); + } + + async #loadNavCounts() { + if (!this.client.supports('navCounts')) return; + const scope = this.scope; + if (!scope) return; + try { + const counts = await this.client.navCounts(scope); + if (this.scope === scope) this.navCounts = counts; + } catch { + // Counts are decoration; the pages report their own errors. + } + } + + async #loadWarehouseSync() { + if (!this.client.supports('getWarehouseSync')) return; + const scope = this.scope; + if (!scope) return; + try { + const sync = await this.client.getWarehouseSync(scope); + if (this.scope === scope) this.warehouseSync = sync; + } catch { + this.warehouseSync = null; + } + } + + async #loadPlatformEvents() { + if (!this.client.supports('listPlatformEvents')) return; + try { + this.platformEvents = await this.client.listPlatformEvents({ activeOnly: true }); + } catch { + this.platformEvents = []; + } + } +} + +export const app = new AppState(); diff --git a/ui/src/lib/state/resource.svelte.ts b/ui/src/lib/state/resource.svelte.ts new file mode 100644 index 0000000000..79361e5e0f --- /dev/null +++ b/ui/src/lib/state/resource.svelte.ts @@ -0,0 +1,74 @@ +import type { ApiClient, Scope } from '$lib/api'; +import { channelMatches } from '$lib/api'; +import { app } from './app.svelte'; + +export type Resource = { + readonly data: T | undefined; + readonly error: string | null; + readonly loading: boolean; + refresh(): void; +}; + +/** + * Loads data for the selected location and target, reloading when the scope + * changes and when a "changed" signal arrives on one of `topics`. + * + * Must be called during component initialisation. Reactive values read + * synchronously inside `load` (such as route params) are also dependencies. + */ +export function createResource( + load: (client: ApiClient, scope: Scope) => Promise, + topics: readonly string[] = [] +): Resource { + let data = $state(undefined); + let error = $state(null); + let loading = $state(true); + let latest = 0; + + function run(scope: Scope) { + const request = ++latest; + loading = true; + load(app.client, scope).then( + (value) => { + if (request !== latest) return; + data = value; + error = null; + loading = false; + }, + (reason: unknown) => { + if (request !== latest) return; + error = reason instanceof Error ? reason.message : String(reason); + loading = false; + } + ); + } + + function refresh() { + if (app.scope) run(app.scope); + } + + $effect(() => { + const scope = app.scope; + if (scope) run(scope); + }); + + $effect(() => { + if (!topics.length) return; + return app.client.changes.subscribe((signal) => { + if (channelMatches(signal.channel, topics)) refresh(); + }); + }); + + return { + get data() { + return data; + }, + get error() { + return error; + }, + get loading() { + return loading; + }, + refresh + }; +} diff --git a/ui/src/lib/status.ts b/ui/src/lib/status.ts new file mode 100644 index 0000000000..76e24d8449 --- /dev/null +++ b/ui/src/lib/status.ts @@ -0,0 +1,150 @@ +import type { CheckOutcome, NodeCounts, NodeOutcome, StaleReason, RunKind, RunOrigin, RunStatus, TickOutcome, TriggerState } from '$lib/api/types'; + +export const runStatusTone: Record = { + queued: 'var(--sb-text-faint)', + launched: 'var(--sb-running)', + running: 'var(--sb-running)', + unresponsive: 'var(--sb-warning)', + success: 'var(--sb-success)', + warn: 'var(--sb-warning)', + partial: 'color-mix(in srgb, var(--sb-error) 55%, var(--sb-warning))', + failed: 'var(--sb-error)', + cancelled: 'var(--sb-text-faint)', + presumed_failed: 'var(--sb-error)' +}; + +export const runStatusLabel: Record = { + queued: 'Queued', + launched: 'Starting', + running: 'Running', + unresponsive: 'No signal', + success: 'Succeeded', + warn: 'Warnings', + partial: 'Partial', + failed: 'Failed', + cancelled: 'Cancelled', + presumed_failed: 'Lost contact' +}; + +/** Tooltip for each status: what it means, with the server's status name for debugging. */ +export const runStatusHelp: Record = { + queued: 'Waiting for its turn (queued)', + launched: 'The container was started and hasn’t reported in yet (launched)', + running: 'Running (running)', + unresponsive: 'The run stopped sending heartbeats; it is marked failed if the silence continues (unresponsive)', + success: 'Every node succeeded (success)', + warn: 'Finished; some checks warned (warn)', + partial: 'Some nodes failed or were skipped; the rest finished (partial)', + failed: 'Failed (failed)', + cancelled: 'Cancelled (cancelled)', + presumed_failed: 'The run went silent and was marked failed; rerunning is safe (presumed_failed)' +}; + +const ACTIVE: ReadonlySet = new Set(['queued', 'launched', 'running', 'unresponsive']); +const PROBLEM: ReadonlySet = new Set(['failed', 'partial', 'presumed_failed']); + +/** Not yet terminal: waiting, starting or executing. */ +export function isActiveRun(status: RunStatus): boolean { + return ACTIVE.has(status); +} + +/** Terminal with at least one failure. */ +export function isProblemRun(status: RunStatus): boolean { + return PROBLEM.has(status); +} + +/** Sort weight: problems and live runs first. */ +export function runStatusRank(status: RunStatus): number { + if (PROBLEM.has(status)) return 3; + if (ACTIVE.has(status)) return 2; + if (status === 'warn') return 1; + return 0; +} + +export const tickOutcomeLabel: Record = { + requested: 'Started a run', + skipped: 'Skipped', + covered: 'Merged into a waiting run', + failed: 'Couldn’t start a run' +}; + +export const triggerStateLabel: Record = { + running: 'On', + paused: 'Paused' +}; + +export const checkOutcomeTone: Record = { + pass: 'var(--sb-success)', + warn: 'var(--sb-warning)', + fail: 'var(--sb-error)', + error: 'var(--sb-error)', + insufficient: 'var(--sb-text-faint)', + skipped: 'var(--sb-text-faint)' +}; + +export const checkOutcomeLabel: Record = { + pass: 'Passed', + warn: 'Warning', + fail: 'Failed', + error: 'Error', + insufficient: 'Insufficient data', + skipped: 'Skipped' +}; + +export const nodeOutcomeTone: Record = { + success: 'var(--sb-success)', + warn: 'var(--sb-warning)', + failed: 'var(--sb-error)', + skipped: 'var(--sb-text-faint)' +}; + +export const nodeOutcomeLabel: Record = { + success: 'Success', + warn: 'Warning', + failed: 'Failed', + skipped: 'Skipped' +}; + +/** Short label for a stale reason, e.g. `changed`, `changed (upstream)`, `behind`. */ +export function staleReasonLabel(reason: StaleReason): string { + if (reason.reason === 'changed') return reason.change === 'compiled' ? 'changed (compiled)' : 'changed'; + if (reason.reason === 'changed_upstream') return 'changed (upstream)'; + return 'behind'; +} + +/** One-line explanation of a stale reason. */ +export function staleReasonDetail(reason: StaleReason): string { + if (reason.reason === 'changed') return reason.detail; + if (reason.reason === 'changed_upstream') return `Upstream definition changed: ${reason.upstream.join(', ')}`; + return `Newer data in ${reason.upstream.join(', ')}`; +} + +/** e.g. `8 ok · 1 warn · 1 failed · 3 skipped`, or `4 running · 10 pending`. */ +export function formatNodeCounts(counts: NodeCounts): string { + const parts: string[] = []; + if (counts.running) parts.push(`${counts.running} running`); + if (counts.success) parts.push(`${counts.success} ok`); + if (counts.warn) parts.push(`${counts.warn} warn`); + if (counts.failed) parts.push(`${counts.failed} failed`); + if (counts.skipped) parts.push(`${counts.skipped} skipped`); + if (counts.pending) parts.push(`${counts.pending} pending`); + return parts.join(' · ') || '—'; +} + +export const runKindLabel: Record = { + job: 'Job', + group: 'Group', + adhoc: 'Ad hoc' +}; + +export const runOriginLabel: Record = { + manual: 'Manual', + schedule: 'Schedule', + sensor: 'Sensor', + rerun: 'Rerun' +}; + +/** One-line "what · why" label, e.g. "Group · Schedule". */ +export function runKindOriginLabel(run: { kind: RunKind; origin: RunOrigin }): string { + return `${runKindLabel[run.kind]} · ${runOriginLabel[run.origin]}`; +} diff --git a/ui/src/lib/theme.svelte.ts b/ui/src/lib/theme.svelte.ts new file mode 100644 index 0000000000..2b38f68d48 --- /dev/null +++ b/ui/src/lib/theme.svelte.ts @@ -0,0 +1,21 @@ +import { browser } from '$app/environment'; + +type Theme = 'dark' | 'light'; + +function read(): Theme { + if (!browser) return 'dark'; + return (localStorage.getItem('sb-theme') as Theme) ?? 'dark'; +} + +export const theme = $state<{ value: Theme }>({ value: read() }); + +function apply(t: Theme) { + if (!browser) return; + document.documentElement.classList.toggle('dark', t === 'dark'); + localStorage.setItem('sb-theme', t); +} + +export function toggleTheme() { + theme.value = theme.value === 'dark' ? 'light' : 'dark'; + apply(theme.value); +} diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts new file mode 100644 index 0000000000..55b3a918e7 --- /dev/null +++ b/ui/src/lib/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte new file mode 100644 index 0000000000..af0d6916e7 --- /dev/null +++ b/ui/src/routes/+layout.svelte @@ -0,0 +1,59 @@ + + + + + + +{#if signingIn} + {@render children()} +{:else if app.bootError} +
+
+

Can't reach the SQLBuild server

+

{app.bootError}

+ +
+
+{:else if app.bootstrap && app.bootstrap.locations.length === 0} +
+
+

No locations to show

+

Either nothing is deployed yet, or you have no role on a location. Ask an administrator for access, then reload this page.

+
+
+{:else if !app.scope} +
Loading…
+{:else} +
+ +
+ {#if pageAvailable(app.client, page.url.pathname)} + {@render children()} + {:else} + +
+
+

Not available on this server yet

+

This server version doesn't provide this page. Choose another page from the menu.

+
+
+ {/if} +
+
+{/if} diff --git a/ui/src/routes/+layout.ts b/ui/src/routes/+layout.ts new file mode 100644 index 0000000000..3cf2c06326 --- /dev/null +++ b/ui/src/routes/+layout.ts @@ -0,0 +1,5 @@ +// SPA mode: no SSR, no prerender. All data is fetched client-side, so the build +// is a static bundle served by the SQLBuild server. +export const ssr = false; +export const prerender = false; +export const trailingSlash = 'never'; diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte new file mode 100644 index 0000000000..0be8b21009 --- /dev/null +++ b/ui/src/routes/+page.svelte @@ -0,0 +1,266 @@ + + + + + + + + +
+
+ {#if overview.error}
{overview.error}
{/if} +
+
+
+ {#if issues.length}{:else}{/if} +
+
+ {issues.length ? `${app.location?.name} needs attention` : `${app.location?.name} is healthy`} + {issues.length ? `${issues.length} ${issues.length === 1 ? 'item' : 'items'} · ${latestText}` : latestText} +
+ {#if m} +
+ {#if m.stale.changed}{m.stale.changed} changed{/if} + {#if m.stale.behind}{m.stale.behind} behind{/if} + {#if m.checks.failing}{m.checks.failing} failing {m.checks.failing === 1 ? 'check' : 'checks'}{/if} +
+ {/if} + {#if latest}View latest run →{/if} +
+ {#if issues.length} +
+ {#each issues as { item, view } (view.key)} + {@const Glyph = attentionIcon[view.tone]} +
+ + {view.title} + {view.detail} + {view.since} + + {#if item.kind === 'stale_models'} + (rebuildOpen = true)}>Rebuild {staleSelection.length} + {/if} + {view.action} → + +
+ {/each} +
+ {/if} +
+ +
+ {#if m} +
+ Models +
{m.models.total}deployed
+ 0}>{m.models.addedThisWeek || m.models.removedThisWeek ? `+${m.models.addedThisWeek} / −${m.models.removedThisWeek} this week` : 'No changes this week'} +
+
+ Runs · 24h +
{m.runs24h.total}{successRate === null ? 'none finished' : `${successRate}% succeeded`}
+ 0}>{m.runs24h.partial} partial · {m.runs24h.failed} failed · {m.runs24h.active} active +
+
+ Checks passing +
{m.checks.passing}of {m.checks.total}
+ 0}>{m.checks.failing} failing · {m.checks.warning} warning +
+
+ Stale +
{m.stale.nodes}nodes
+ {m.stale.changed} changed · {m.stale.behind} behind · {m.stale.sources} {m.stale.sources === 1 ? 'source' : 'sources'} late +
+ {#if m.cost} +
+ Cost · 24h +
{formatCredits(m.cost.last24h)}
+ = 20}>{costChange === null ? '—' : `${costChange >= 0 ? '+' : ''}${costChange}%`} vs {m.cost.baselineDays}-day average +
+ {/if} + {/if} +
+ +
+
+

Activity

Past runs, and when schedules will run next
+ Open full timeline → +
+ +
+ +
+
+
+

Build status by group

Current, stale and failed nodes · {app.location?.name}
+ Open catalog → +
+
+
GroupCurrentChangedBehindFailed
+ {#each groups as g (g.group)} + + {g.group} + + + + + + {g.current}/{g.total} + {g.changed || '—'} + {g.behind || '—'} + {g.failed || '—'} + + {:else} +
{overview.loading ? 'Loading…' : 'Nothing deployed yet. Deploy the project to see its models here.'}
+ {/each} +
+
+
+
+

Recent changes

Recent deploys
+ All changes → +
+ +
+
+
+
+ + diff --git a/ui/src/routes/admin/audit/+page.svelte b/ui/src/routes/admin/audit/+page.svelte new file mode 100644 index 0000000000..fe37712c67 --- /dev/null +++ b/ui/src/routes/admin/audit/+page.svelte @@ -0,0 +1,185 @@ + + + + + + +{#if !app.can('audit.view')} + +{:else} +
+ + + + + {rows.length} entries +
+ +
+ + + + + + + + + + + + + + {#each rows as e (e.id)} + + + + + + + + + + {:else} + + {/each} + +
WhenWhoActionWhereResultAllowed by roleWhat happened
+ {#if e.actor.kind === 'user'}{e.actor.name}{:else}{e.actor.name}{/if} +
{e.ip}
+
+ {auditActionLabel(e.action)} + {#if e.evidenceRead} read sample rows{/if} + + {formatAssignmentScope(e.scope, locations)} + {#if e.scope.subject}
{e.scope.subject}
{/if} +
+ + {#if e.decisionId} +
+ {e.decisionId} + + Check this +
+ {/if} +
{e.grantingRole ?? '—'} + {e.summary} +
+ {#if e.before !== null || e.after !== null}{e.before ?? '∅'} → {e.after ?? '∅'} ·{/if} + {e.requestId} +
+
+
+{/if} + + diff --git a/ui/src/routes/admin/check-access/+page.svelte b/ui/src/routes/admin/check-access/+page.svelte new file mode 100644 index 0000000000..30e116f1e1 --- /dev/null +++ b/ui/src/routes/admin/check-access/+page.svelte @@ -0,0 +1,54 @@ + + + + +
+

+ Can someone do something, and why? Pick a person, an action and where. Or paste the denial ID from a “permission denied” message to check that request again. + {#if !canCheckOthers}You can check your own access; checking others needs “Give roles” or “See the audit log”.{/if} +

+
+ +
+

← People

+
+ + diff --git a/ui/src/routes/admin/people/+page.svelte b/ui/src/routes/admin/people/+page.svelte new file mode 100644 index 0000000000..91ad7db394 --- /dev/null +++ b/ui/src/routes/admin/people/+page.svelte @@ -0,0 +1,148 @@ + + + + + + +{#if !allowed} + +{:else} +
+

+ Everyone who has signed in, with what they can do in each location. People sign in with single sign-on, or sqb login on the command line. + Open someone to see their effective access and why. +

+
+ + {#snippet filters()} + ({ value: r, label: r }))} all="Any role" /> + ({ value: l.id, label: l.name }))} all="All locations" /> + ({ value: s, label: assignmentSourceLabel[s] }))} all="Any source" /> + + {/snippet} + {#snippet actions()} + + {/snippet} + +
+ + + + + + + + + + + + {#each people.shown as a (a.name)} + {@const risky = d.riskyOf(a.name)} + goto(personHref(a))}> + + + + + + + {:else} + + {/each} + +
Person{people.locationId ? `Role in ${locations.find((l) => l.id === people.locationId)?.name}` : 'Highest role'}FromLast activeStatus
+
+ +
+ e.stopPropagation()}>{a.displayName} +
{a.name} · {a.source === 'oidc' ? 'SSO' : accountSourceLabel[a.source]}
+
+
+
+ + {#if risky.length} +
`${permissionLabel(r)} (${r})`).join('\n')}> + Can {permissionLabel(risky[0]).toLowerCase()}{risky.length > 1 ? ` and ${risky.length - 1} more risky actions` : ''} +
+ {/if} +
+
+ {#each chips(a.name) as c (`${c.source}|${c.ssoGroup}`)}{/each} +
+
+ + {#if a.name === app.user?.name}
you
{/if} +
+
+{/if} + + diff --git a/ui/src/routes/admin/people/[name]/+page.svelte b/ui/src/routes/admin/people/[name]/+page.svelte new file mode 100644 index 0000000000..5bebd8f738 --- /dev/null +++ b/ui/src/routes/admin/people/[name]/+page.svelte @@ -0,0 +1,191 @@ + + + + + + +{#if !canSee} + +{:else} +
+
+ ← People + + {account?.displayName ?? name} + {name} + {#if account} + +
+ {#if account.name === app.user?.name} + This is you + {:else} + toggle(account)}>{account.disabled ? 'Enable' : 'Disable'} + {/if} +
+ {/if} +
+ {#if error}

{error}

{/if} + + {#if d.accounts.data && !account} +

No person named {name}. They appear here after they first sign in.

+ {:else if account} +
+
+
+ + {#if app.can('role.assign')} + + {:else} +

Seeing roles requires “Give roles and approve role changes” (role.assign).

+ {/if} +
+ + + {#if !canExplain} +

Requires “Give roles” (role.assign) or “See the audit log” (audit.view).

+ {:else if access.data} + + {:else if access.error} +

Couldn't load: {access.error}

+ {:else} +

Loading…

+ {/if} +
+ + {#if canExplain} +
+ + + +
+ {/if} +
+ +
+ + + + + + +
+
+
+ {:else} +

Loading…

+ {/if} +
+{/if} + + diff --git a/ui/src/routes/admin/roles/+page.svelte b/ui/src/routes/admin/roles/+page.svelte new file mode 100644 index 0000000000..ac8cb2ab20 --- /dev/null +++ b/ui/src/routes/admin/roles/+page.svelte @@ -0,0 +1,47 @@ + + + + + + +{#if !app.can('role.assign')} + +{:else} + tab, select} + tabs={[ + { value: 'roles', label: 'Roles', count: rs.roles.data?.length }, + { value: 'sso', label: 'SSO mappings', count: rs.sso.data?.length } + ]} + /> +
+ {#if tab === 'roles'} + + {:else} + + {/if} +
+{/if} + diff --git a/ui/src/routes/admin/roles/[name]/+page.svelte b/ui/src/routes/admin/roles/[name]/+page.svelte new file mode 100644 index 0000000000..3d998169d2 --- /dev/null +++ b/ui/src/routes/admin/roles/[name]/+page.svelte @@ -0,0 +1,328 @@ + + + + + + +{#if !app.can('role.assign')} + +{:else} +
+
+ ← Roles + {name} + {#if r} + + {roleSummary(r)} + {/if} +
+ + {#if role.data === null && !role.loading} +

No role named {name} in {app.location?.name}. Project roles only exist in the locations deployed from that project.

+ {:else if r} +
+
+
+

{r.description}

+ + {#if r.source === 'built_in'} + +
    + {#each ownActions as a (a)}
  • {/each} +
+
+ {:else} + + {#snippet actions()} + + {/snippet} + {#if r.extends} +

+ Starts from {builtInLabel(r.extends)}: {plural(baseActions.size, 'action')} on every node, then adds the actions below. Roles don't + inherit further than one built-in role. +

+ {:else} +

Starts from nothing; only the additions below apply.

+ {/if} + {#if compare} + + + + + + {#each CAPABILITIES as c (c)} + {@const inBase = baseActions.has(c)} + {@const added = addedOn(c)} + {#if inBase || added.length} + + + + + + {/if} + {/each} + +
Action{r.extends ? builtInLabel(r.extends) : 'Base'}{r.name}
{inBase ? '✓ everywhere' : '—'}{inBase ? '✓ everywhere' : `✓ ${added.join(', ')}`}
+ {/if} +
+ + + + + + + + + + + + {#each additions as a (a.action + a.selector)} + {@const m = matchOf(a.selector)} + + + + + + {:else} + + {/each} + +
ActionOnMatches today
+ +
{PERMISSIONS[a.action].description}
+
{a.selector === '*' ? 'every node' : a.selector} + {#if a.selector === '*'} + Every node + {:else if m} + {plural(m.nodes.length, 'node')}{#if m.newSinceLastWeek.length}, +{m.newSinceLastWeek.length} since last week{/if} + {/if} +
No additions.
+
+ + {#if r.selectors.length} + +
    + {#each r.selectors as s (s.selector)} +
  • + + {#if open[s.selector]} +
    + {#each s.nodes as n (n)} + {n} + {:else} + Matches nothing today. + {/each} +
    + {/if} +
  • + {/each} +
+
+ {/if} + {/if} + + + + +
+ +
+ +
    + {#each r.assignments as a (a.id)} +
  • + {a.principal.displayName} + {scopeWords(a.scope.locationId, locations)} + +
  • + {:else} +
  • Nobody has this role yet.
  • + {/each} +
+
+
+
+
+ {:else if role.error} +

Couldn't load: {role.error}

+ {:else} +

Loading…

+ {/if} +
+{/if} + + diff --git a/ui/src/routes/admin/roles/roles-tab.svelte b/ui/src/routes/admin/roles/roles-tab.svelte new file mode 100644 index 0000000000..38e0b1a219 --- /dev/null +++ b/ui/src/routes/admin/roles/roles-tab.svelte @@ -0,0 +1,65 @@ + + +
+

+ Roles in {app.location?.name}. Each custom role is a built-in role plus additions on chosen nodes. Roles are defined in code (the project's policy file or the server + config) and are read-only here. +

+ + + + + + + + + + + {#each rs.roles.data ?? [] as r (r.name)} + goto(roleHref(r.name))}> + + + + + + {:else} + + {/each} + +
RoleDefined inWhat it givesWho has it
+ e.stopPropagation()}>{r.name} +
{r.description}
+
+ {roleSummary(r)} + {#if r.recentlyMatched}
+{plural(r.recentlyMatched, 'node')} matched this week
{/if} +
{r.holders}
+
+ + diff --git a/ui/src/routes/admin/roles/sso-tab.svelte b/ui/src/routes/admin/roles/sso-tab.svelte new file mode 100644 index 0000000000..d6175dfe1a --- /dev/null +++ b/ui/src/routes/admin/roles/sso-tab.svelte @@ -0,0 +1,78 @@ + + +
+

+ Members of these identity-provider groups get the role when they sign in. Configured in the server config; read-only here. To change who's in a group, use your identity + provider. +

+ {#if unseen.length} +

+ {plural(unseen.length, 'group')} in the config {unseen.length === 1 ? 'has' : 'have'} no members seen yet: {#each unseen as m, i (m.group)}{#if i}, {/if}{m.group}{/each}. + Check the group name matches your identity provider. +

+ {/if} + + + + + + + + + + + + + {#each mappings as m (m.group + m.role)} + + + + + + + + + {:else} + + {/each} + +
GroupGets roleWhereMembers seenLast syncedConfigured in
{m.group}{m.role}{scopeWords(m.scope.locationId, locations)}{m.membersSynced === 0 ? 'none yet' : m.membersSynced}{#if m.lastSyncedAt}{:else}Never{/if}Server config {m.definedIn}
+
+ + diff --git a/ui/src/routes/admin/roles/state.svelte.ts b/ui/src/routes/admin/roles/state.svelte.ts new file mode 100644 index 0000000000..098666121a --- /dev/null +++ b/ui/src/routes/admin/roles/state.svelte.ts @@ -0,0 +1,21 @@ +import type { RoleDefinition, SsoMapping } from '$lib/api'; +import { app } from '$lib/state/app.svelte'; +import { createResource } from '$lib/state/resource.svelte'; + +/** Roles of the selected location and SSO mappings. Must be created during component initialisation. */ +export function createRolesState() { + const can = () => app.can('role.assign'); + const roles = createResource((client, scope) => (can() ? client.listRoles(scope.locationId) : Promise.resolve([] as RoleDefinition[]))); + const sso = createResource((client) => (can() ? client.listSsoMappings() : Promise.resolve([] as SsoMapping[]))); + + return { + roles, + sso, + refresh() { + roles.refresh(); + sso.refresh(); + } + }; +} + +export type RolesState = ReturnType; diff --git a/ui/src/routes/admin/roles/utils.ts b/ui/src/routes/admin/roles/utils.ts new file mode 100644 index 0000000000..6fcf8e64ce --- /dev/null +++ b/ui/src/routes/admin/roles/utils.ts @@ -0,0 +1,11 @@ +// Pure helpers for the Roles pages. + +export type RolesTab = 'roles' | 'sso'; + +export function parseTab(value: string | null): RolesTab { + return value === 'sso' ? value : 'roles'; +} + +export function plural(n: number, one: string, many = `${one}s`): string { + return `${n} ${n === 1 ? one : many}`; +} diff --git a/ui/src/routes/catalog/+page.svelte b/ui/src/routes/catalog/+page.svelte new file mode 100644 index 0000000000..f2f5dba0bc --- /dev/null +++ b/ui/src/routes/catalog/+page.svelte @@ -0,0 +1,159 @@ + + + + + (runOpen = true)}>▷ Execute{narrowed ? ` ${filteredRunnable.length}` : ''} + + + + + + {#snippet filters()} + ({ value: k, label: nodeKindPlural[k], count: all.filter((n) => n.kind === k).length }))]} + /> + ({ ...o, count: all.filter((n) => matchesStatus(n, o.value)).length }))} /> + ({ value: o, label: o, count: all.filter((n) => n.owner.includes(o)).length }))} /> + ({ value: g, label: g, count: all.filter((n) => n.group === g).length }))} /> + {/snippet} + + +
+ + + + + + + + + + + + + + {#each filtered as n (n.key)} + {@const Glyph = nodeIcon[nodeIconKey(n)] ?? nodeIcon.table} + goto(`/catalog/${n.key}`)}> + + + + + + + + + {:else} + + {/each} + +
NameKindOwnerLast outcomeStaleChecksLast built
+
+ +
+ e.stopPropagation()}>{n.key} +
{n.description}
+
+
+
{kindLine(n)}
{n.group}
+ {#if n.owner.length}
{#each n.owner as o (o)}{o}{/each}
+ {:else}—{/if} +
+ {#if n.kind === 'source'} + {#if n.sourceFreshness} + {:else}External{/if} + {:else if n.lastOutcome} + + {:else}Not built{/if} + + {n.checks.total ? `${n.checks.passing} / ${n.checks.total}` : '—'} + + {#if n.kind === 'source'} + + {:else if n.lastBuild} + + {:else}Never{/if} +
+
diff --git a/ui/src/routes/catalog/[name]/+page.svelte b/ui/src/routes/catalog/[name]/+page.svelte new file mode 100644 index 0000000000..016d17e9c8 --- /dev/null +++ b/ui/src/routes/catalog/[name]/+page.svelte @@ -0,0 +1,10 @@ + + +{#key name} + +{/key} diff --git a/ui/src/routes/catalog/[name]/checks-tab.svelte b/ui/src/routes/catalog/[name]/checks-tab.svelte new file mode 100644 index 0000000000..eaa26c7459 --- /dev/null +++ b/ui/src/routes/catalog/[name]/checks-tab.svelte @@ -0,0 +1,43 @@ + + + + + + {#each node.checkResults as c (c.id)} + {@const o = c.latest?.outcome ?? 'skipped'} + goto(`/checks/${c.id}`)}> + + + + + + + + + {:else} + + {/each} + +
CheckOutcomeSeverityChecksResult typeResultLast checked
{c.name}{#if c.column}
column {c.column}
{/if}
{c.severity}{runScopePhaseLabel[c.runScopePhase]}{evaluationModeLabel[c.evaluationMode]}{checkValue(c.latest, c.evaluationMode)}
No audits or checks attach to this node.
+ + diff --git a/ui/src/routes/catalog/[name]/drift-tab.svelte b/ui/src/routes/catalog/[name]/drift-tab.svelte new file mode 100644 index 0000000000..6aa2afe1c9 --- /dev/null +++ b/ui/src/routes/catalog/[name]/drift-tab.svelte @@ -0,0 +1,57 @@ + + +
+
+ As built in this location + + {#if node.lastBuild}last build {node.lastBuild.fingerprint.slice(0, 12)}{/if} + r.reason !== 'behind')} empty="Matches the last build" /> +
+
{@render children()}
+
+ + diff --git a/ui/src/routes/catalog/[name]/history-tab.svelte b/ui/src/routes/catalog/[name]/history-tab.svelte new file mode 100644 index 0000000000..8cc25043b3 --- /dev/null +++ b/ui/src/routes/catalog/[name]/history-tab.svelte @@ -0,0 +1,88 @@ + + +
+ {#if h.formerNames.length} +

Formerly known as {#each h.formerNames as f, i (f)}{i ? ', ' : ''}{f}{/each}

+ {/if} + + + {#if h.migrations.length} + + + + {#each h.migrations as m (m.eventId)} + + + + + + + + + {/each} + +
WhenChangeFromToWhat happenedRun
{formatAgo(m.createdAt, app.now)}{migrationKind(m)} · {m.discovery}{#if m.originModel}{m.originModel}
{formatRelation(m.origin)}
{:else}{formatRelation(m.origin)}
found in the warehouse
{/if}
{m.destinationModel}
{formatRelation(m.destination)}
{decisionLabel[m.decision]}{m.runId}
+ {:else} +

No renames or moves recorded for {node.key} in this location.

+ {/if} +
+ + {#if h.oldNameViews.length} + + + + + {#each h.oldNameViews as v (v.migrationEventId)} + {@const status = oldNameStatus(v, app.now)} + + + + + + + + {/each} + +
Old nameStatusKept forRenamed columnsHistory
{formatRelation(v.old)} + + {#if v.expiresAt}
{Date.parse(v.expiresAt) > app.now ? 'expires' : 'expired'} {formatStamp(v.expiresAt)}
{/if} + {#if v.dropReason}
{v.dropReason}
{/if} +
{v.retention === 'false' ? 'not kept' : v.retention}{v.columnAliases.length ? v.columnAliases.map((a) => `${a.old} → ${a.new}`).join(', ') : '—'}{v.steps.map((s) => oldNameStepLabel[s.eventType]).join(' → ')}
+
+ {/if} +

All renames, moves and archived relations are on the Maintenance page.

+
+ + diff --git a/ui/src/routes/catalog/[name]/node-view.svelte b/ui/src/routes/catalog/[name]/node-view.svelte new file mode 100644 index 0000000000..a2c0cec463 --- /dev/null +++ b/ui/src/routes/catalog/[name]/node-view.svelte @@ -0,0 +1,296 @@ + + + + + {#if n && n.kind !== 'source'} + execute([name])}>▷ Build + {/if} + + + + +
+
+ +
+ +

{name}

+ {#if n} + {#if n.kind === 'source'} + + {:else if n.lastOutcome} + + {:else} + + {/if} + + {/if} + +
+ {#if n} +
+ {kindLine(n)}{n.group}{#if n.owner.length}{n.owner.join(', ')}{/if} + {#if n.kind === 'source'}{n.sourceFreshness?.loadedAt ? `Loaded ${formatAgo(n.sourceFreshness.loadedAt, app.now)}` : 'No freshness data'} + {:else}{n.lastBuild ? `Last built ${formatAgo(n.lastBuild.builtAt, app.now)}` : 'Never built'}{/if} +
+ {/if} +
+ {#each tabs as [t, label] (t)} + + {/each} +
+
+ + {#if res.error}
Couldn't load {name}: {res.error}
{:else if res.data === null}
There's no node {name} in the version deployed to {app.location?.name}.
{/if} + + {#if n} +
+ {#if tab === 'overview'} + { viewerMode = mode; setTab('sql'); }} /> + {:else if tab === 'columns'} + + + + {#each n.columns as c (c.name)} + + + + + + + {/each} + +
ColumnTypeDescriptionAudits
{c.name}{c.type ?? '—'}{c.description}
{#each c.audits as a (a)}{a}{:else}—{/each}
+ {:else if tab === 'sql'} + {#if n.sql} + goto(`/catalog/${m}?tab=sql`)} /> + {:else if n.code} +
+ {/if} + {:else if tab === 'drift' && n.sql} + + goto(`/catalog/${m}?tab=drift`)} /> + + {:else if tab === 'lineage'} + {#if graph.nodes.length} + + {/if} + {:else if tab === 'collineage'} + {#if colLineage.data} + + {/if} + {:else if tab === 'runs'} + + {:else if tab === 'checks'} + + {:else if tab === 'warehouse' && n.warehouse} + + {:else if tab === 'history'} + + {/if} +
+ {/if} +
+ + diff --git a/ui/src/routes/catalog/[name]/overview-tab.svelte b/ui/src/routes/catalog/[name]/overview-tab.svelte new file mode 100644 index 0000000000..270ad9db38 --- /dev/null +++ b/ui/src/routes/catalog/[name]/overview-tab.svelte @@ -0,0 +1,197 @@ + + +
+
+ {#if source} +
+
+ + {#if node.sourceFreshness}newest data loaded {formatAgo(node.sourceFreshness.loadedAt, app.now)}{/if} +
+ {#if node.sourceFreshness} +
+
Warn after{minutes(node.sourceFreshness.warnAfterSeconds)}
+
Error after{minutes(node.sourceFreshness.errorAfterSeconds)}
+
Checked{formatAgo(node.sourceFreshness.checkedAt, app.now)}
+
Checks{node.checks.total ? `${node.checks.passing}/${node.checks.total}` : '—'}
+
+ {/if} +
+ {:else} +
+
+ {#if node.lastOutcome}{:else}{/if} + Last built: +
+
+
Rows{formatNumber(node.lastRows)}{#if node.rowsDelta} 0}>{node.rowsDelta > 0 ? '+' : ''}{formatNumber(node.rowsDelta)}{/if}
+
Duration{formatMs(node.lastDurationMs)}
+
Checks{node.checks.total ? `${node.checks.passing}/${node.checks.total}` : '—'}
+
Builds · 40{node.recentOutcomes.filter((o) => o === 'failed').length} failed
+ {#if node.cost}
Cost per build{formatCredits(node.cost.perBuild)}{formatCredits(node.cost.last24h)} / 24h
{/if} +
+ {#if node.recentOutcomes.length} +
+ {/if} + {#each node.stale as r, i (i)} + + {/each} +
+ {/if} + + {#if node.description} +
+

Description

+

{node.description}

+
+ {/if} + + {#if node.sql} +
+ onSql(sqlMode)} /> +
+ {:else if node.code} +
+ onTab('sql')} /> +
+ {/if} + + {#if node.checkResults.length} +
+

Checks

+
+ {#each node.checkResults as c (c.id)} + {@const o = c.latest?.outcome ?? 'skipped'} + {c.name}{runScopePhaseLabel[c.runScopePhase]} · {c.severity} + {/each} +
+
+ {/if} + + {#if node.columns.length} +
+

Columns

+
+ {#each node.columns.slice(0, 5) as c (c.name)} +
{c.name}{c.description}{c.type}
+ {/each} +
+
+ {/if} + +
+
+

Upstream {node.upstream.length}

+ {#each node.upstream as u (u)}{u}{:else}None{/each} +
+
+

Downstream {node.downstream.length}

+ {#each node.downstream as d (d)}{d}{:else}None{/each} +
+
+
+ + +
+ + diff --git a/ui/src/routes/catalog/[name]/runs-tab.svelte b/ui/src/routes/catalog/[name]/runs-tab.svelte new file mode 100644 index 0000000000..c736199e6b --- /dev/null +++ b/ui/src/routes/catalog/[name]/runs-tab.svelte @@ -0,0 +1,54 @@ + + + + + + {#each node.runs as r, i (`${r.runId ?? r.fingerprintRunId}-${i}`)} + + + + + + + + + {:else} + + {/each} + +
OutcomeRunJobRowsDurationStarted
+ + {#if r.message}
{r.message}
{/if} +
+ {#if r.runId}{r.runId} + {:else}from the command line · {shortRunId(r.fingerprintRunId ?? '')}{/if} + {#if r.jobKey}{r.jobKey}{:else}{r.runId ? 'started by hand' : '—'}{/if}{formatNumber(r.rows)}{formatMs(r.durationMs)}
No builds recorded in this location.
+ + diff --git a/ui/src/routes/catalog/[name]/utils.ts b/ui/src/routes/catalog/[name]/utils.ts new file mode 100644 index 0000000000..9eac2101aa --- /dev/null +++ b/ui/src/routes/catalog/[name]/utils.ts @@ -0,0 +1,25 @@ +// Pure helpers for the catalog node page. + +import type { CatalogNode } from '$lib/api'; + +export type Tab = 'overview' | 'columns' | 'sql' | 'drift' | 'lineage' | 'collineage' | 'runs' | 'checks' | 'warehouse' | 'history'; + +export type DriftKind = 'direct' | 'compiled' | 'upstream'; + +/** Drift against what was last built in this target, from the node's stale reasons. */ +export function driftOf(node: Pick): DriftKind | null { + for (const r of node.stale) if (r.reason === 'changed') return r.change === 'compiled' ? 'compiled' : 'direct'; + return node.stale.some((r) => r.reason === 'changed_upstream') ? 'upstream' : null; +} + +export const driftLabel: Record = { + direct: 'Definition changed', + compiled: 'Compiled SQL differs', + upstream: 'Upstream definition changed' +}; + +/** The SQL viewer's note when only an upstream definition changed. */ +export function upstreamNote(node: Pick): string | undefined { + const r = node.stale.find((s) => s.reason === 'changed_upstream'); + return r && r.reason === 'changed_upstream' ? `${r.upstream.join(', ')} changed since this node’s last build.` : undefined; +} diff --git a/ui/src/routes/catalog/[name]/warehouse-tab.svelte b/ui/src/routes/catalog/[name]/warehouse-tab.svelte new file mode 100644 index 0000000000..725881866f --- /dev/null +++ b/ui/src/routes/catalog/[name]/warehouse-tab.svelte @@ -0,0 +1,158 @@ + + +
+
+ + + + {#if warehouse.stats.length} + + + + +
+ {formatStamp(warehouse.stats[0].at)} · {formatNumber(warehouse.stats[0].rows)} rows + {formatNumber(warehouse.stats.at(-1)?.rows)} rows · now +
+
+ {/if} +
+ + + + + + {#each warehouse.columns as c (c.name)} + + + + + + + {/each} + +
ColumnDeclaredIn the warehouseState
{c.name}{c.declaredType ?? '—'}{c.actualType ?? '—'}
+
+ + {#if microbatch} + +
+ {#each microbatch.batches as b (b.start)} + {batchLabel[b.state]} + {/each} +
+
+ complete + {microbatch.knownGaps} known {microbatch.knownGaps === 1 ? 'gap' : 'gaps'} + replay required + {microbatch.batchesTruncated ? 'Newest batches shown · ' : ''}tracked since {bound(microbatch.earliest)} +
+
+ {/if} +
+ + diff --git a/ui/src/routes/changes/+page.svelte b/ui/src/routes/changes/+page.svelte new file mode 100644 index 0000000000..dbb27390dd --- /dev/null +++ b/ui/src/routes/changes/+page.svelte @@ -0,0 +1,333 @@ + + + + + + + + {#snippet filters()} + ({ ...o, count: all.filter((c) => hasArea(c, o.value)).length }))} /> + {/snippet} + + +
+ {#if changes.error}
Couldn't load the change feed: {changes.error}
{/if} + {#each filtered as c, i (c.snapshotId)} +
+
+ +
+ Version {c.generation} + {c.gitRevision}{c.gitDirty ? ' · with uncommitted changes' : ''} + {summarizeChange(c)} +
+
+ {c.gitAuthor} + deployed by {c.publishedBy.displayName} · {formatAgo(c.publishedAt, app.now)} +
+
+ +
+ {#if c.nodes.length} +
+

Nodes {c.nodes.length}

+ {#each c.nodes as n (n.key + n.op)} +
+ {changeOpLabel[n.op]} + {#if n.op === 'removed'}{n.key}{:else}{n.key}{/if} + {n.detail}{#if n.compiledOnly}compiled only{/if} +
+ {#each n.columns as col (col.name)} +
+ {changeOpLabel[col.op]} + {n.key}.{col.name} + {col.detail} +
+ {/each} + {/each} +
+ {/if} + + {#if c.orchestration.length} +
+

Jobs, triggers and sensors {c.orchestration.length}

+ {#each c.orchestration as o (o.kind + o.key)} + {@const href = orchestrationHref(o)} +
+ {changeOpLabel[o.op]} + {o.kind} + {#if href}{o.key}{:else}{o.key}{/if} + {o.detail} +
+ {/each} +
+ {/if} + + {#if c.roles.length} +
+

Roles {c.roles.length}

+ {#each c.roles as r (r.role)} +
+ {changeOpLabel[r.op]} + {r.role} + + {#if r.coverage}now covers {r.coverage.after} {r.coverage.after === 1 ? 'node' : 'nodes'}{`${r.coverage.before !== r.coverage.after ? ` (was ${r.coverage.before})` : ''} · `}{/if}{r.detail} + +
+ {/each} +
+ {/if} + + {#if c.environment} +
+

Environment

+
+ + {c.environment.detail} + {c.environment.before} → {c.environment.after} +
+
+ {/if} + + {#if c.costChanges?.length} +
+

Cost since this deploy

+ {#each c.costChanges as cc (cc.jobKey)} + {@const pct = percentChange(cc.before, cc.after)} +
+ {cc.jobKey} + {formatCredits(cc.before)} → {formatCredits(cc.after)} per run 0} class:down={pct < 0}>{pct > 0 ? '+' : ''}{pct}% over {cc.runsAfter} {cc.runsAfter === 1 ? 'run' : 'runs'} since revision {c.gitRevision} +
+ {/each} +
+ {/if} + +
+

Runs that followed {c.followingRuns.length}{i > 0 ? ' before the next deploy' : ''}

+ {#if c.followingRuns.length} + + {:else} +

No runs of this version yet.

+ {/if} +
+
+
+ {:else} +
{changes.loading ? 'Loading…' : all.length ? 'No deploys match the filter.' : 'Nothing has been deployed to this location yet.'}
+ {/each} +
+ + diff --git a/ui/src/routes/changes/utils.ts b/ui/src/routes/changes/utils.ts new file mode 100644 index 0000000000..145a2de178 --- /dev/null +++ b/ui/src/routes/changes/utils.ts @@ -0,0 +1,13 @@ +// Pure helpers for the Changes page. + +import type { OrchestrationChange } from '$lib/api'; + +export function orchestrationHref(change: OrchestrationChange): string | null { + if (change.op === 'removed') return null; + if (change.kind === 'job' || change.kind === 'group') return `/jobs/${change.key}`; + return change.triggerId ? `/triggers/${change.triggerId}` : null; +} + +export function percentChange(before: number, after: number): number { + return before ? Math.round(((after - before) / before) * 100) : 0; +} diff --git a/ui/src/routes/checks/+page.svelte b/ui/src/routes/checks/+page.svelte new file mode 100644 index 0000000000..932dfe2251 --- /dev/null +++ b/ui/src/routes/checks/+page.svelte @@ -0,0 +1,156 @@ + + + + + (runOpen = true)}>▷ Run checks + + + + + + {#snippet filters()} + ({ ...o, count: counts[o.value] }))} /> + ({ value: a, label: attachmentLabel[a], count: all.filter((c) => c.attachment === a).length }))} /> + ({ value: n, label: n, count: all.filter((c) => c.node === n).length }))} /> + {/snippet} + + +
+ + + + + + + + + + + + + + + + {#each filtered as c (c.id)} + {@const o = outcomeOf(c)} + goto(`/checks/${c.id}`)}> + + + + + + + + + + + {:else} + + {/each} + +
CheckOutcomeAttached toSeverityChecksResult typeResultRecentLast checked
+
+ +
+ e.stopPropagation()}>{c.name} +
{c.kind === 'audit' ? `audit · ${c.definition ?? 'custom'}` : c.kind.replace('_', ' ')}
+
+
+
+ {#if c.node} e.stopPropagation()}>{c.node}{#if c.column}.{c.column}{/if} + {:else}{attachmentLabel[c.attachment]}{/if} + {c.severity}{runScopePhaseLabel[c.runScopePhase]}{evaluationModeLabel[c.evaluationMode]}{checkValue(c.latest, c.evaluationMode)}{#each c.recentOutcomes as r, i (i)}{/each}
+
+ + diff --git a/ui/src/routes/checks/[id]/+page.svelte b/ui/src/routes/checks/[id]/+page.svelte new file mode 100644 index 0000000000..80b2be3487 --- /dev/null +++ b/ui/src/routes/checks/[id]/+page.svelte @@ -0,0 +1,10 @@ + + +{#key id} + +{/key} diff --git a/ui/src/routes/checks/[id]/check-view.svelte b/ui/src/routes/checks/[id]/check-view.svelte new file mode 100644 index 0000000000..5ab4d60dbc --- /dev/null +++ b/ui/src/routes/checks/[id]/check-view.svelte @@ -0,0 +1,272 @@ + + + + {#if c} + (runOpen = true)}>▷ Run check + {/if} + + + + +
+
+ ← Checks + {c?.name ?? id} + {#if c} + + {c.severity} severity · {runScopePhaseLabel[c.runScopePhase].toLowerCase()} · {evaluationModeLabel[c.evaluationMode].toLowerCase()} + {/if} +
+ {#if res.error}
Couldn't load this check: {res.error}
{:else if res.data === null}
There's no check {id} in the version deployed to {app.location?.name}.
{/if} + + {#if c} +
+
+ {#if c.description}

{c.description}

{/if} + + e.outcome)} tones={checkOutcomeTone} caption="Last {c.history.length} results" /> + + + + + + + + + {#each c.history as e (e.id)} + + + + + + + + {/each} + +
CheckedOutcome{c.evaluationMode === 'measurement' ? 'Measured' : 'Violations'}RunEvidence
{formatAgo(e.evaluatedAt, app.now)} + + {checkValue(e, c.evaluationMode)} + {#if e.runId}{e.runId} + {:else}from the command line · {shortRunId(e.fingerprintRunId)}{/if} + + {#if e.evidenceCount} + showEvidence(e.id)}>{e.evidenceCount}{e.evidenceTruncated ? '+' : ''} rows + {:else}—{/if} +
+
+ + {#if evidenceError}
{evidenceError}
{/if} + {#if evidence} + + {#snippet actions()}{/snippet} + + {#each evidence.columns as col (col)}{/each} + + {#each evidence.rows as row, i (i)} + {#each row as v, j (j)}{/each} + {:else} + + {/each} + +
{col}
{v === null ? 'NULL' : String(v)}
No rows were stored.
+ {#if evidence.truncated}

More rows matched than were stored.

{/if} +
+ {/if} + + + {#if sql} + {#if sql.executionError}
{sql.executionError}
{/if} + {#if sql.evidenceError}
Evidence couldn't be collected: {sql.evidenceError}
{/if} + {#each [sql.executedSql, sql.measurementSql, sql.evidenceSql].filter((text, i, all) => text && all.indexOf(text) === i) as text, i (i)} + + {:else} +

No SQL was stored with the latest result.

+ {/each} + {:else if !app.can('view.sql')} +

You don't have permission to see SQL (view.sql)

+ {:else} + Show SQL and errors + {#if sqlError}
{sqlError}
{/if} + {/if} +
+
+ +
+ {/if} +
+ + diff --git a/ui/src/routes/checks/utils.ts b/ui/src/routes/checks/utils.ts new file mode 100644 index 0000000000..aa9b109742 --- /dev/null +++ b/ui/src/routes/checks/utils.ts @@ -0,0 +1,16 @@ +// Pure helpers for the Checks pages. + +import type { Check, CheckEvaluation, CheckOutcome, EvaluationMode } from '$lib/api'; + +export function outcomeOf(check: Pick): CheckOutcome { + return check.latest?.outcome ?? 'skipped'; +} + +/** Violations for violation checks, the measured value for measurement checks. */ +export function checkValue(evaluation: CheckEvaluation | null, mode: EvaluationMode): string { + if (!evaluation) return '—'; + if (evaluation.outcome === 'error') return 'error'; + if (mode === 'measurement') return evaluation.measuredValue === null ? '—' : String(evaluation.measuredValue); + if (evaluation.violationCount === null) return '—'; + return evaluation.violationCount === 0 ? '0 rows' : `${evaluation.violationCount.toLocaleString('en-US')} ${evaluation.violationCount === 1 ? 'row' : 'rows'}`; +} diff --git a/ui/src/routes/connections/+page.svelte b/ui/src/routes/connections/+page.svelte new file mode 100644 index 0000000000..ccc0af3f02 --- /dev/null +++ b/ui/src/routes/connections/+page.svelte @@ -0,0 +1,134 @@ + + + + + + +
+

+ How SQLBuild reaches the warehouse for {app.location?.name} (target {app.target?.name}). Set in the project config and read-only here; secrets show only where they come from, never their value. + APIs, storage and webhooks are under Providers. +

+ + {#if connections.error} +

Couldn't load connections: {connections.error}

+ {/if} + + + {#each warehouse as c (c.name)} +
+
+ + {c.name} + {c.adapter} · {c.purpose === 'catalog' ? 'used to read SQLBuild’s own tables back (warehouse sync)' : `builds target ${c.target}`} · {c.sourceFile} +
+
+ {#each c.settings as f (f.key)} +
+
{f.key}
+
{shownValue(c, f)}
+
+ {/each} +
+ +
+ {:else} +

{connections.data ? 'This location’s target has no warehouse connection. Add one to the target in the project config.' : connections.error ? '' : 'Loading…'}

+ {/each} +
+ +
+ + diff --git a/ui/src/routes/connections/[id]/+page.svelte b/ui/src/routes/connections/[id]/+page.svelte new file mode 100644 index 0000000000..4c73c23cf7 --- /dev/null +++ b/ui/src/routes/connections/[id]/+page.svelte @@ -0,0 +1,66 @@ + + + + + + +
+
+ ← Connections + {name} + {#if warehouse} + Warehouse · {warehouse.adapter} · target {warehouse.target} + {/if} +
+ + {#if connections.error} +
Couldn't load connections: {connections.error}
+ {:else if connections.data && !warehouse} +
+ {app.location?.name} has no warehouse connection named {name}. Looking for an API or storage connection? See Providers. +
+ {:else if warehouse} +
+
+
+
+ +
+
+ + Read-only. Set in {warehouse.sourceFile}; secrets show only where they come from. +
+ +
+
+ + +
+ {:else} +
Loading…
+ {/if} +
diff --git a/ui/src/routes/jobs/+page.svelte b/ui/src/routes/jobs/+page.svelte new file mode 100644 index 0000000000..3046ca2745 --- /dev/null +++ b/ui/src/routes/jobs/+page.svelte @@ -0,0 +1,187 @@ + + + + + + + + {#snippet filters()} + j.kind === 'job').length }, + { value: 'group', label: 'Groups', count: all.filter((j) => j.kind === 'group').length } + ]} + /> + + {/snippet} + {#snippet actions()} + {#if actionError}{actionError}{/if} + {/snippet} + + +
+ + + + + + + + + + + + + + {#each filtered as j (j.key)} + {@const jobTriggers = j.triggerIds.map((id) => triggerById.get(id)).filter((t) => t !== undefined)} + goto(`/jobs/${j.key}`)}> + + + + + + + + + {:else} + + {/each} + +
JobSelectionNodesIf already runningStarted byOwnerLast run
+
+ {#if j.kind === 'group'}{:else}{/if} +
+ e.stopPropagation()}>{j.key} +
{j.description}
+
+
+
+ {#if j.kind === 'group'} + Group · {j.members.join(', ')} + {:else} + {j.select} + {/if} + {j.nodeCount}{overlapPlain[j.overlap]}{#if j.priority} · priority {j.priority}{/if} + {#if jobTriggers.length === 0} + Only by hand + {:else} +
+ {#each jobTriggers as t (t.id)} +
+ + + + e.stopPropagation()}>{t.name} +
+ {/each} +
+ {/if} +
{j.owner ?? '—'} + {#if j.lastRun} + e.stopPropagation()}> + + {#if j.lastRun.startedAt}{/if} + + {:else} + Never run + {/if} +
+
+ + diff --git a/ui/src/routes/jobs/[name]/+page.svelte b/ui/src/routes/jobs/[name]/+page.svelte new file mode 100644 index 0000000000..ddfc40ad09 --- /dev/null +++ b/ui/src/routes/jobs/[name]/+page.svelte @@ -0,0 +1,10 @@ + + +{#key name} + +{/key} diff --git a/ui/src/routes/jobs/[name]/group-order.svelte b/ui/src/routes/jobs/[name]/group-order.svelte new file mode 100644 index 0000000000..3ad0c95d05 --- /dev/null +++ b/ui/src/routes/jobs/[name]/group-order.svelte @@ -0,0 +1,101 @@ + + +
+ {#each stages as stage, i (i)} + + {#if i < stages.length - 1}
→
{/if} + {/each} +
+{#if job.criticalNodes.length} +

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

+{/if} + + diff --git a/ui/src/routes/jobs/[name]/job-view.svelte b/ui/src/routes/jobs/[name]/job-view.svelte new file mode 100644 index 0000000000..9503871597 --- /dev/null +++ b/ui/src/routes/jobs/[name]/job-view.svelte @@ -0,0 +1,376 @@ + + + +
+ (launchOpen = !launchOpen)} + >▷ Run now + {#if launchOpen && def} (launchOpen = false)} />{/if} +
+
+ +
+
+ ← Jobs + {name} + {#if def} + {def.kind === 'group' ? `Group of ${def.members.length} jobs` : 'Job'} · {def.nodeCount} nodes + {def.description} + {/if} +
+ {#if job.error} +
Couldn't load this job: {job.error}
+ {:else if job.data === null} +
There's no job or group named {name} in the version deployed to {app.location?.name}.
+ {/if} + {#if actionError}
{actionError}
{/if} + + {#if def} +
+
+
+

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

+ +
+ + {#if def.kind === 'group'} +
+

Jobs in this group, in order from the data each job reads and writes, plus any after in the code

+ +
+ {/if} + +
+

Recent runs

+ {#if runs.length} + + + {#each runs as r (r.id)} + goto(`/runs/${r.id}`)}> + + + + + + + + + + {/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.name}{formatCost(r.cost)}
+ {:else} +

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

+ {/if} +
+ +
+

What it builds · {def.resolvedNodes.length} nodes {def.select ? `--select ${def.select}` : 'everything its jobs build'}{def.exclude.length ? ` --exclude ${def.exclude.join(' ')}` : ''}

+ +
+
+ + +
+ {/if} +
+ + diff --git a/ui/src/routes/jobs/[name]/launch-form.svelte b/ui/src/routes/jobs/[name]/launch-form.svelte new file mode 100644 index 0000000000..aa2edd2976 --- /dev/null +++ b/ui/src/routes/jobs/[name]/launch-form.svelte @@ -0,0 +1,148 @@ + + +
+

Run {job.kind === 'group' ? 'group' : 'job'} {job.key} now

+

+ {job.kind === 'group' ? `Starts a group run, with one run per job in it (${job.members.length}).` : `Builds ${job.nodeCount} nodes with the job's options.`} If another run is updating the same data, it waits for it to finish. +

+
+ + {#if job.cursorType} + + {#if useWindow} +
+ + to + +
+ {#if windowInvalid}

{windowInvalid}

{/if} + {/if} + {/if} + {#if !canRewrite}

Rebuilding or reprocessing a range needs extra permission (run.rewrite_history).

{:else if rewrites}

This replaces data that was already built. It's recorded in the audit log.

{/if} +
+ {#if error}

{error}

{/if} +
+ + +
+
+ + diff --git a/ui/src/routes/layout.css b/ui/src/routes/layout.css new file mode 100644 index 0000000000..1ef83407c2 --- /dev/null +++ b/ui/src/routes/layout.css @@ -0,0 +1,477 @@ +@import 'tailwindcss'; +@import "tw-animate-css"; +@import "shadcn-svelte/tailwind.css"; +@import "@fontsource-variable/inter"; +@import "@fontsource-variable/space-grotesk"; +@import "@fontsource/ibm-plex-mono/400.css"; +@import "@fontsource/ibm-plex-mono/500.css"; + +@custom-variant dark (&:is(.dark *)); +@plugin '@tailwindcss/typography'; + +/* Refined SQLBuild light theme: crisp white chrome over a restrained cool-grey canvas. */ +:root { + /* experiment: panels are outlined in light mode, filled (borderless) in dark mode */ + /* light: the card edge is the strongest line on the page; lines inside cards stay quieter */ + --panel-border: rgba(28, 32, 38, 0.2); + --panel-shadow: none; + --inner-line: rgba(28, 32, 38, 0.055); + --node-border: var(--border-strong); + --radius: 2px; /* experiment: boxy corners */ + + --background: #ffffff; + --foreground: #171a1f; + --card: #ffffff; + --card-foreground: #171a1f; + --popover: #ffffff; + --popover-foreground: #171a1f; + --primary: #2677d9; + --primary-foreground: #ffffff; + --secondary: #f6f7f9; + --secondary-foreground: #171a1f; + --muted: #f6f7f9; + --muted-foreground: #626a76; + --accent: #f3f5f7; + --accent-foreground: #171a1f; + --destructive: #c74757; + --border: rgba(31, 41, 55, 0.12); + --input: rgba(31, 41, 55, 0.18); + --ring: #2677d9; + --chart-1: #2677d9; + --chart-2: #16825c; + --chart-3: #7756b9; + --chart-4: #aa6c16; + --chart-5: #16825c; + --sidebar: #ffffff; + --sidebar-foreground: #626a76; + --sidebar-primary: #2677d9; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: rgba(38, 119, 217, 0.10); + --sidebar-accent-foreground: #171a1f; + --sidebar-border: rgba(28, 32, 38, 0.10); + --sidebar-ring: #2677d9; + + /* SQLBuild semantic + structural tokens (light) */ + --sb-success: #16825c; + --sb-warning: #aa6c16; + --sb-error: #c74757; + --sb-running: #2677d9; + --sb-stale: #7756b9; + --sb-secondary: #16826f; + --sb-text-faint: #98a0ad; + --sb-surface-low: #f7f8fa; + --sb-surface-high: #ffffff; + --sb-inset: #fbfcfe; + --sb-hover: #f5f7fa; + --sb-layer-source: #8b9098; + --sb-layer-staging: #16826f; + --sb-layer-intermediate: #aa6c16; + --sb-layer-marts: #2677d9; + --sb-elev: 0 1px 2px rgba(16,24,40,0.035), 0 5px 16px rgba(16,24,40,0.045); + + /* structural border + graph tokens (light) */ + --border-subtle: rgba(28, 32, 38, 0.075); + --border-strong: rgba(28, 32, 38, 0.22); + --sb-edge: rgba(28, 32, 38, 0.24); + --sb-edge-dim: rgba(28, 32, 38, 0.08); + --sb-grid: rgba(28, 32, 38, 0.075); + --sb-mono: #464b53; + --sb-mono-err: #aa3445; + + /* SQL syntax tokens (light) */ + --syn-keyword: #1a56c4; + --syn-string: #0a7a3f; + --syn-number: #9a6700; + --syn-comment: #8a909c; + --syn-function: #7c3aed; + --syn-operator: #3a3f4a; + --syn-punct: #5b616e; +} + +/* Neutral operational dark theme: no warm tint, with SQLBuild blue reserved for action. */ +.dark { + --panel-border: transparent; + --panel-shadow: none; + --inner-line: rgba(176, 184, 198, 0.09); + --node-border: rgba(176, 184, 198, 0.1); + --background: #0d0f12; + --foreground: #e9ebef; + --card: #171a1f; + --card-foreground: #e9ebef; + --popover: #191c21; + --popover-foreground: #e9ebef; + --primary: #3788e8; + --primary-foreground: #ffffff; + --secondary: #20242a; + --secondary-foreground: #e9ebef; + --muted: #191c21; + --muted-foreground: #9ba1ac; + --accent: #20242a; + --accent-foreground: #e9ebef; + --destructive: #eb6475; + --border: rgba(176, 184, 198, 0.14); + --input: rgba(176, 184, 198, 0.22); + --ring: #3788e8; + --chart-1: #3788e8; + --chart-2: #4dd6c4; + --chart-3: #a78bfa; + --chart-4: #f0c267; + --chart-5: #54cf8e; + --sidebar: #080a0d; + --sidebar-foreground: #9ba1ac; + --sidebar-primary: #3788e8; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: rgba(55, 136, 232, 0.14); + --sidebar-accent-foreground: #e9ebef; + --sidebar-border: rgba(176, 184, 198, 0.12); + --sidebar-ring: #69aaf6; + + /* SQLBuild semantic + structural tokens (used by custom widgets) */ + --sb-success: #54cf8e; + --sb-warning: #f0c267; + --sb-error: #f47089; + --sb-running: #69aaf6; + --sb-stale: #a78bfa; + --sb-secondary: #4dd6c4; + --sb-text-faint: #626975; + --sb-surface-low: #080a0d; + --sb-surface-high: #20242a; + --sb-inset: #090b0e; + --sb-hover: #20242a; + --sb-layer-source: #59627a; + --sb-layer-staging: #4dd6c4; + --sb-layer-intermediate: #f0c267; + --sb-layer-marts: #3788e8; + --sb-elev: 0 12px 32px rgba(0,0,0,0.3), 0 0 0 1px rgba(176,184,198,0.04), inset 0 1px 0 rgba(255,255,255,0.035); + + /* structural border + graph tokens (dark) */ + --border-subtle: rgba(176, 184, 198, 0.09); + --border-strong: rgba(176, 184, 198, 0.28); + --sb-edge: rgba(176, 184, 198, 0.28); + --sb-edge-dim: rgba(176, 184, 198, 0.09); + --sb-grid: rgba(176, 184, 198, 0.09); + --sb-mono: #b6bbc4; + --sb-mono-err: #d6a6ae; + + /* SQL syntax tokens (dark) */ + --syn-keyword: #5aa9e6; + --syn-string: #79c98a; + --syn-number: #e3b341; + --syn-comment: #5c606b; + --syn-function: #c79bf0; + --syn-operator: #aeb3bd; + --syn-punct: #8b8f9b; +} + +@theme inline { + --font-sans: 'Inter Variable', sans-serif; + --font-display: 'Space Grotesk Variable', 'Inter Variable', sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, monospace; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} + +/* Shared list-table treatment (Catalog/Runs/Jobs/Triggers/Checks/Providers). + Dagster-like: row dividers only, muted sentence-case header, one text scale, + mono only for genuinely code-y values (.code / ). */ +.sb-list { + width: 100%; + font-family: var(--font-sans); + font-size: 12.5px; + border-collapse: separate !important; + border-spacing: 0; +} +.sb-list thead th { + position: sticky; + top: 0; + z-index: 5; + height: 36px; + padding: 0 16px; + border-bottom: 1px solid var(--border); + background: var(--background); + color: var(--muted-foreground); + font-size: 12px; + font-weight: 500; + text-align: left; + white-space: nowrap; +} +.sb-list tbody td { + padding: 10px 16px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.sb-list th:first-child, +.sb-list td:first-child { + padding-left: 20px; +} +.sb-list th:last-child, +.sb-list td:last-child { + padding-right: 20px; +} +.sb-list tbody tr { + cursor: pointer; +} +.sb-list tbody tr:hover { + background: var(--sb-hover); +} +.sb-list .code, +.sb-list code { + font-family: var(--font-mono); + font-size: 12px; +} +.sb-list .num { + text-align: right; + font-variant-numeric: tabular-nums; +} +.sb-list .muted { + color: var(--muted-foreground); +} +.sb-list .cell-name { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.sb-list .cell-name > svg { + flex: 0 0 auto; + color: var(--muted-foreground); +} +.sb-list .cell-name a, +.sb-list .cell-name strong { + color: var(--foreground); + font-size: 13px; + font-weight: 500; +} +.sb-list .cell-name a:hover { + color: var(--primary); +} +.sb-list .cell-desc { + margin-top: 1px; + overflow: hidden; + color: var(--muted-foreground); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} +.sb-list .link { + color: var(--primary); +} +.sb-list .link:hover { + text-decoration: underline; +} +.sb-list .empty { + padding: 48px 20px; + color: var(--muted-foreground); + text-align: center; + cursor: default; +} +.sb-list-wrap { + min-height: 0; + flex: 1; + overflow: auto; +} +/* table pill (Dagster-style): wrap discrete values so cells read as objects */ +.sb-tag { + display: inline-flex; + align-items: center; + gap: 5px; + background: var(--sb-hover); + border: 0; + border-radius: 2px; + padding: 2px 7px; + font-size: 11.5px; + line-height: 1.45; + color: var(--muted-foreground); + white-space: nowrap; +} +.sb-tag .sb-tag-ico { + color: var(--sb-text-faint); +} +/* status label: coloured dot + plain label (no tinted pill) */ +.sb-status { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--foreground); + font-size: 12.5px; + font-weight: 500; + white-space: nowrap; + text-transform: capitalize; +} +.sb-status > span:first-child { + width: 8px !important; + height: 8px !important; + border-radius: 50% !important; + background: var(--tone) !important; +} +/* styled checkbox (replaces the chunky native accent-color box) */ +.sb-check { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + border: 1.5px solid var(--border-strong); + border-radius: 2px; + background: transparent; + cursor: pointer; + position: relative; + vertical-align: middle; + transition: + background 0.12s, + border-color 0.12s; +} +.sb-check:hover { + border-color: var(--muted-foreground); +} +.sb-check:checked { + background: var(--primary); + border-color: var(--primary); +} +.sb-check:checked::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 8px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} +.sb-check:indeterminate { + background: var(--primary); + border-color: var(--primary); +} +.sb-check:indeterminate::after { + content: ''; + position: absolute; + left: 2px; + top: 4.5px; + width: 7px; + height: 2px; + background: #fff; +} +.sb-check:disabled { + cursor: not-allowed; + opacity: 0.45; +} +/* paused/disabled row: dim the row content (toggle keeps its own colour) */ +.sb-row-off td { + opacity: 0.6; +} +.sb-toggle { + position: relative; + width: 28px; + height: 16px; + flex: 0 0 28px; + border: 0; + border-radius: 999px; + background: var(--border-strong); + transition: background 0.15s; +} +.sb-toggle.on { + background: var(--sb-success); +} +.sb-toggle::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: #fff; + transition: left 0.15s; +} +.sb-toggle.on::after { + left: 14px; +} +.sb-toggle:disabled { + cursor: not-allowed; + opacity: 0.55; +} +/* Prism token theme — mapped to SQLBuild syntax tokens (works light + dark) */ +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: var(--syn-comment); + font-style: italic; +} +.token.keyword { + color: var(--syn-keyword); +} +.token.string, +.token.char, +.token.attr-value { + color: var(--syn-string); +} +.token.number, +.token.boolean { + color: var(--syn-number); +} +.token.function, +.token.sb-ref { + color: var(--syn-function); +} +.token.operator, +.token.entity, +.token.url { + color: var(--syn-operator); +} +.token.punctuation { + color: var(--syn-punct); +} +.token.sb-config { + color: var(--syn-keyword); + font-weight: 500; +} + diff --git a/ui/src/routes/lineage/+page.svelte b/ui/src/routes/lineage/+page.svelte new file mode 100644 index 0000000000..9db2c4d92e --- /dev/null +++ b/ui/src/routes/lineage/+page.svelte @@ -0,0 +1,1529 @@ + + + + + (runOpen = true)}>▷ Execute{runSelection.length ? ` ${runSelection.length}` : ''} + + + + + +
+
+
+ +
+ + {#if modelQuery.trim() && !browserOpen} +
+ {#each browserNodes.slice(0, 8) as node (node.id)} + + {:else} +
No matching models
+ {/each} +
+ {/if} +
+ {#if !scaleMode} +
+ + + + ({ ...o, count: catalogNodes.filter((c) => matchesFilterStatus(c, o.value)).length }))} /> +
+ {/if} + + + + +
+ {#each [['ok', 'var(--sb-success)', 'last build succeeded'], ['warn', 'var(--sb-warning)', 'last build warned'], ['stale', 'var(--sb-stale)', 'stale'], ['fail', 'var(--sb-error)', 'last build failed']] as [st, c, label] (st)} + {@const n = nodes.filter((x) => (st === 'stale' ? !!x.data.stale : x.data.status === st)).length} + {#if n}{n}{/if} + {/each} +
+ +
+ +
+ {#if browserOpen} + + {/if} + +
+ (hoveredId = node.id)} + onnodepointerleave={() => (hoveredId = null)} + > + + + + + {#if scaleMode} + +
{scaleModels.toLocaleString()} models · {far ? 'zoomed out: status overview' : 'cards'} · zoom {Math.round(viewport.zoom * 100)}%
+ {/if} +
+ + {#if sqlOpen && d?.sql} +
+ (sqlOpen = false)} /> +
+ {/if} + + + {#if inspectorOpen} +
+ + + +
+ {/if} +
+
+
+ + diff --git a/ui/src/routes/locations/+page.svelte b/ui/src/routes/locations/+page.svelte new file mode 100644 index 0000000000..548b28e2bd --- /dev/null +++ b/ui/src/routes/locations/+page.svelte @@ -0,0 +1,97 @@ + + + + + + + + + + +
+ + + + + + + + + + + + + {#each filtered as l (l.id)} + goto(`/locations/${l.id}`)}> + + + + + + + + {:else} + + {/each} + +
LocationHealthDeployed versionWarehouseLocation processesIf a deploy fails
+
+ +
+ e.stopPropagation()}>{l.name} +
{targetWords(l.target)}
+
+
+
+ + {#if l.activeProblems} + + {/if} + + {#if l.snapshot} +
Version {l.snapshot.generation}
+
deployed {formatAgo(l.snapshot.publishedAt, app.now)}
+ {:else} + Nothing deployed yet + {/if} +
+ {#if l.connectionTest} + + + +
tested {formatAgo(l.connectionTest.at, app.now)}
+ {:else} + + {/if} +
+
{l.liveInstances === 1 ? 'Running' : l.liveInstances === 0 ? 'Not running' : `${l.liveInstances} running`}
+ +
{l.brokenDeployPolicy === 'fallback' ? 'Keep running the previous version' : 'Hold scheduled runs until a deploy works'}
+
diff --git a/ui/src/routes/locations/[id]/+page.svelte b/ui/src/routes/locations/[id]/+page.svelte new file mode 100644 index 0000000000..30e4004923 --- /dev/null +++ b/ui/src/routes/locations/[id]/+page.svelte @@ -0,0 +1,303 @@ + + + + + + +
+ {#if location.error} +
Couldn't load this location: {location.error}
+ {:else if location.data === null} +
← Locations No location with ID {id}.
+ {:else if !detail} +
Loading…
+ {:else} +
+
+ ← Locations + {detail.name} + · {targetWords(detail.target)} + + + {#if openProblems}· {openProblems} open problem{openProblems === 1 ? '' : 's'}{/if} + +
+ +
+ {#if detail.snapshot} + + Version {detail.snapshot.generation} · commit {detail.snapshot.gitRevision}{#if detail.snapshot.gitDirty}{' '}with uncommitted changes{/if} + · by {detail.snapshot.gitAuthor} · deployed {formatAgo(detail.snapshot.publishedAt, app.now)} · sqb {detail.snapshot.sqbVersion} + + {:else} + Nothing deployed yet + {/if} +
+ +
+ + {#if processes.live.length === 1 && running} + Running · + {#if detail.imageId}image {shortImage(running.imageId)}{:else}local process{/if} + · last check-in {formatAgo(running.lastSeenAt, app.now)} + {:else if processes.live.length > 1} + {processes.live.length} processes running · usually a deploy in progress: the new one takes over once it loads + {:else} + Not running · no process of this location is checking in, so no runs can start + {/if} + + + + +
+ + {#if processes.listed.length} +
    + {#each processes.listed as i (i.id)} +
  • + + {i.id} + image {shortImage(i.imageId)} · commit {i.gitRevision} + started · last check-in +
  • + {/each} +
+ {/if} + + {#if detail.failedDeploy} +
+ + + + Deploy failed {formatAgo(detail.failedDeploy.attemptedAt, app.now)} + — {detail.brokenDeployPolicy === 'fallback' ? `still running ${detail.snapshot ? `version ${detail.snapshot.generation}` : 'the previous version'}` : 'scheduled runs wait until a deploy works'} + · {firstLine(detail.failedDeploy.error)} + + details + +
+

Commit {detail.failedDeploy.gitRevision} (image {shortImage(detail.failedDeploy.imageId)}) didn't load. Fix the error and deploy again; a deploy only takes over once it loads without errors.

+
{detail.failedDeploy.error}
+
+
+ {:else if detail.lastLoadError} +
+ + + The last load reported an error · {firstLine(detail.lastLoadError)} + details + +
{detail.lastLoadError}
+
+ {/if} + +
+ Details +
+ +
+
+
+ +
+
+ + {#snippet actions()} + + {/snippet} + + +
+ + {#if app.can('location.manage')} + + + + {/if} +
+ {/if} +
+ + diff --git a/ui/src/routes/locations/[id]/utils.ts b/ui/src/routes/locations/[id]/utils.ts new file mode 100644 index 0000000000..22a297b028 --- /dev/null +++ b/ui/src/routes/locations/[id]/utils.ts @@ -0,0 +1,27 @@ +// Pure helpers for the location detail page. + +import type { LocationInstance } from '$lib/api'; + +export type ProcessSummary = { + /** Live processes, newest first. */ + live: LocationInstance[]; + /** The processes to list below the header line; empty when exactly one is running. */ + listed: LocationInstance[]; +}; + +/** + * One running process is the normal case and fits on the header line. Show a + * list when none is running (the recent ones, to see when it stopped) or more + * than one is (usually a deploy overlap while a new process loads). + */ +export function summariseProcesses(instances: readonly LocationInstance[]): ProcessSummary { + const byStart = [...instances].sort((a, b) => b.startedAt.localeCompare(a.startedAt)); + const live = byStart.filter((i) => i.state === 'live'); + if (live.length === 1) return { live, listed: [] }; + return { live, listed: live.length > 1 ? live : byStart }; +} + +/** The first line of a load error, for a one-line summary. */ +export function firstLine(text: string): string { + return text.split('\n', 1)[0].trim(); +} diff --git a/ui/src/routes/locations/server-status.svelte b/ui/src/routes/locations/server-status.svelte new file mode 100644 index 0000000000..a0857eaf34 --- /dev/null +++ b/ui/src/routes/locations/server-status.svelte @@ -0,0 +1,183 @@ + + +
+ {#if health.error} + Couldn't load the server status: {health.error} + {:else if !h || !scheduler || !sink} + Loading server status… + {:else} + + Scheduler {scheduler.text} + {#if scheduler.checkIn}· last check-in {scheduler.checkIn}{/if} + + · + Event forwarding {sink.text} + {#if openServerProblems}·{openServerProblems} open server problem{openServerProblems === 1 ? '' : 's'}{/if} + {/if} + +
+ +{#if open && h} +
+
+
+

Servers

+
    + {#each h.scheduler.instances as i (i.instanceId)} +
  • + + {i.instanceId} + + {!i.schedulerEnabled ? 'web and API only' : h.scheduler.lease?.holder === i.instanceId ? 'running the scheduler' : 'standing by to take over'} + + seen {formatAgo(i.lastSeenAt, app.now)} +
  • + {/each} +
+ {#if h.scheduler.lease} +

+ Leader lease held by {h.scheduler.lease.holder} for {formatDuration(h.scheduler.lease.acquiredAt, null, app.now)}; renewed {formatAgo(h.scheduler.lease.renewedAt, app.now)}, expires {formatIn(h.scheduler.lease.expiresAt, app.now)}. +

+ {/if} +
+
+

Heartbeat

+

+ {h.heartbeat.lastEmittedAt ? `Sent ${formatAgo(h.heartbeat.lastEmittedAt, app.now)}` : 'Never sent'}{late ? ' — late' : ''} +

+

The server sends a heartbeat event every {h.heartbeat.intervalSeconds}s. If it stops arriving where you forward events, the server is down.

+
+
+

Event forwarding

+ {#if h.sink} +

Sends problems as they happen to a {h.sink.kind} set in the server config ({h.sink.destinationSource}).

+

+ {h.sink.eventKinds.length ? h.sink.eventKinds.join(', ') : 'Every kind of event'} · {h.sink.pendingEvents} waiting · last tried {formatAgo(h.sink.lastAttemptAt, app.now)} +

+ {#if h.sink.lastError}

{h.sink.lastError}

{/if} + {:else} +

Not set up. Sensors still see every problem; forwarding pushes them out without code.

+ {/if} +
+
+

Server problems

+ +
+{/if} + + diff --git a/ui/src/routes/locations/utils.ts b/ui/src/routes/locations/utils.ts new file mode 100644 index 0000000000..a921a88ed1 --- /dev/null +++ b/ui/src/routes/locations/utils.ts @@ -0,0 +1,47 @@ +// Server status in plain words for the Locations page. Pure. + +import type { HeartbeatStatus, SchedulerStatus, SinkStatus } from '$lib/api'; +import { formatAgo, formatIn, formatStamp } from '$lib/format/time'; + +export type StatusWords = { text: string; tone: string; title: string; checkIn?: string }; + +/** "running (server-2)" while a replica holds the leader lease; the lease details go in the tooltip. */ +export function schedulerWords(scheduler: SchedulerStatus, now: number): StatusWords { + const lease = scheduler.lease; + if (!lease) { + return { + text: 'stopped', + tone: 'var(--sb-error)', + title: 'No server holds the scheduler lease, so schedules and sensors aren’t starting runs. Another server takes over automatically when one is available.' + }; + } + return { + text: `running (${lease.holder})`, + tone: 'var(--sb-success)', + checkIn: formatAgo(lease.renewedAt, now), + title: `Leader lease held by ${lease.holder} since ${formatStamp(lease.acquiredAt)}; renewed ${formatAgo(lease.renewedAt, now)}, expires ${formatIn(lease.expiresAt, now)}` + }; +} + +export function sinkWords(sink: SinkStatus | null, now: number): StatusWords { + if (!sink) return { text: 'not set up', tone: 'var(--sb-text-faint)', title: 'No event forwarding in the server config' }; + if (sink.lastError) { + return { + text: `failing · ${sink.pendingEvents} waiting`, + tone: 'var(--sb-error)', + title: `${sink.consecutiveFailures} failed attempts; last delivered ${formatAgo(sink.lastDeliveredAt, now)}. ${sink.lastError}` + }; + } + return { text: 'ok', tone: 'var(--sb-success)', title: `Delivered ${formatAgo(sink.lastDeliveredAt, now)} via ${sink.kind} (${sink.destinationSource})` }; +} + +/** Late once two intervals pass without a heartbeat. */ +export function heartbeatLate(heartbeat: HeartbeatStatus, now: number): boolean { + if (!heartbeat.lastEmittedAt) return true; + return now - Date.parse(heartbeat.lastEmittedAt) > 2 * heartbeat.intervalSeconds * 1000; +} + +/** A server is live while it was seen within the last minute. */ +export function replicaLive(lastSeenAt: string, now: number): boolean { + return now - Date.parse(lastSeenAt) < 60_000; +} diff --git a/ui/src/routes/login/+page.svelte b/ui/src/routes/login/+page.svelte new file mode 100644 index 0000000000..298181bdd4 --- /dev/null +++ b/ui/src/routes/login/+page.svelte @@ -0,0 +1,55 @@ + + +Sign in · SQLBuild + +
+
+ SQLBuild + +

Sign in

+ + + {#if problem}{/if} + +
+
diff --git a/ui/src/routes/maintenance/+page.svelte b/ui/src/routes/maintenance/+page.svelte new file mode 100644 index 0000000000..673f0f9d82 --- /dev/null +++ b/ui/src/routes/maintenance/+page.svelte @@ -0,0 +1,240 @@ + + + + + + + + + + {#snippet actions()} + + {#if tab === 'janitor'}Tables and views SQLBuild archived or deleted when cleaning up (sqb janitor).{:else if tab === 'views'}After a rename or move, a view under the old name keeps old queries working until its end date.{:else}Models renamed or moved to another schema (sqb rename, sqb mv).{/if} + Read-only; read back from the warehouse. + + {/snippet} + + +
+ {#if tab === 'migrations'} + + + + + + + + + + + + + {#each shownMigrations as m (m.eventId)} + + + + + + + + + {:else} + + {/each} + +
WhenWhatFromToWhat happened to the dataRun
+ {migrationKind(m) === 'move' ? 'Move' : 'Rename'} +
{m.discovery === 'automatic' ? 'Spotted automatically' : 'Set in the code'}
+
+ {m.originModel ?? formatRelation(m.origin)} +
{formatRelation(m.origin)}
+
+ {m.destinationModel} +
{formatRelation(m.destination)}
+
{decisionLabel[m.decision]}{m.runId.replace(/^run-/, '')}
+ {:else if tab === 'views'} + + + + + + + + + + + + + {#each shownViews as v (v.migrationEventId)} + {@const status = oldNameStatus(v, app.now)} + + + + + + + + + {:else} + + {/each} + +
Old nameNow points toStatusRenamed columnsGrants copiedHistory
+ {formatRelation(v.old)} +
{v.retention === 'false' ? 'Not kept' : `Kept for ${v.retention}`}
+
+ {v.destinationModel} +
{formatRelation(v.new)}
+
+ +
{viewDetail(v)}
+
+ {#each v.columnAliases as a (a.old)} +
{a.new} AS {a.old}
+ {:else} + — + {/each} +
{v.grantsCopied ?? '—'} +
    + {#each v.steps as s (s.eventType)} +
  1. {oldNameStepLabel[s.eventType]} {formatAgo(s.at, app.now)}
  2. + {/each} +
+
+ {:else} + + + + + + + + + + + + + {#each shownJanitor as j (j.eventId)} + + + + + + + + + {:else} + + {/each} + +
WhenWhatOriginal nameArchived asArchivedRun
+ +
{j.relationType ?? 'relation'} in {j.relationSchema}
+
{#if j.originalQualifiedName}{j.originalQualifiedName}{:else}Temporary build table{/if}{j.archiveQualifiedName}{j.runId.replace(/^run-/, '')}
+ {/if} +
+ + diff --git a/ui/src/routes/providers/+page.svelte b/ui/src/routes/providers/+page.svelte new file mode 100644 index 0000000000..049b9484c9 --- /dev/null +++ b/ui/src/routes/providers/+page.svelte @@ -0,0 +1,68 @@ + + + + + + + + {#snippet actions()} + Connections to APIs, storage and webhooks that models, loaders and sensors use. Declared in code; read-only here. + {/snippet} + + +
+ + + + + + + + + + + {#each shown as p (p.name)} + goto(`/providers/${p.name}`)}> + + + + + + {:else} + + {/each} + +
ProviderTypeUsed byDefined in
+
+ +
+ e.stopPropagation()}>{p.name} +
{p.description}
+
+
+
{p.type}{usedBySummary(p)}{p.sourceFile}
+
+ + diff --git a/ui/src/routes/providers/[name]/+page.svelte b/ui/src/routes/providers/[name]/+page.svelte new file mode 100644 index 0000000000..29db33bfa4 --- /dev/null +++ b/ui/src/routes/providers/[name]/+page.svelte @@ -0,0 +1,108 @@ + + + + + + +
+
+ ← Providers + {name} + {#if provider}{provider.type}{/if} +
+ + {#if providers.error} +
Couldn't load providers: {providers.error}
+ {:else if providers.data && !provider} +
No provider named {name} in {app.location?.name ?? 'this location'}.
+ {:else if provider} +
+
+ + {#if tab === 'settings'} +
+
+ + Read-only. Declared in {provider.sourceFile}; secrets show only where they come from. +
+ +
+ {:else} +
+ {#if !usedCount(provider)} +

Nothing uses this provider yet.

+ {/if} + {#if provider.usedBy.nodes.length} +
+
Nodes · {provider.usedBy.nodes.length}
+
+ {#each provider.usedBy.nodes as m (m)} + {m} + {/each} +
+
+ {/if} + {#if provider.usedBy.jobs.length} +
+
Jobs · {provider.usedBy.jobs.length}
+
+ {#each provider.usedBy.jobs as j (j)} + {j} + {/each} +
+
+ {/if} + {#if provider.usedBy.triggers.length} +
+
Sensors · {provider.usedBy.triggers.length}
+
+ {#each provider.usedBy.triggers as t (t.id)} + {t.name} + {/each} +
+
+ {/if} +
+ {/if} +
+ + +
+ {:else} +
Loading…
+ {/if} +
diff --git a/ui/src/routes/providers/utils.ts b/ui/src/routes/providers/utils.ts new file mode 100644 index 0000000000..0d2167e55c --- /dev/null +++ b/ui/src/routes/providers/utils.ts @@ -0,0 +1,17 @@ +import type { ProviderConnection } from '$lib/api'; + +const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? '' : 's'}`; + +/** e.g. `1 node · 2 jobs · 1 sensor`, or `Not used yet`. */ +export function usedBySummary(p: ProviderConnection): string { + const parts = [ + p.usedBy.nodes.length ? plural(p.usedBy.nodes.length, 'node') : null, + p.usedBy.jobs.length ? plural(p.usedBy.jobs.length, 'job') : null, + p.usedBy.triggers.length ? plural(p.usedBy.triggers.length, 'sensor') : null + ].filter(Boolean); + return parts.join(' · ') || 'Not used yet'; +} + +export function usedCount(p: ProviderConnection): number { + return p.usedBy.nodes.length + p.usedBy.jobs.length + p.usedBy.triggers.length; +} diff --git a/ui/src/routes/runs/+page.svelte b/ui/src/routes/runs/+page.svelte new file mode 100644 index 0000000000..ca212214a2 --- /dev/null +++ b/ui/src/routes/runs/+page.svelte @@ -0,0 +1,61 @@ + + + + (panelOpen = true)}>▷ New run + + + + +{#if p.view === 'timeline'} +
+{:else if p.view === 'queue'} + +{:else} + + +{/if} + + + + diff --git a/ui/src/routes/runs/[id]/+page.svelte b/ui/src/routes/runs/[id]/+page.svelte new file mode 100644 index 0000000000..81565b641d --- /dev/null +++ b/ui/src/routes/runs/[id]/+page.svelte @@ -0,0 +1,10 @@ + + +{#key runId} + +{/key} diff --git a/ui/src/routes/runs/[id]/events-tab.svelte b/ui/src/routes/runs/[id]/events-tab.svelte new file mode 100644 index 0000000000..00786090f0 --- /dev/null +++ b/ui/src/routes/runs/[id]/events-tab.svelte @@ -0,0 +1,196 @@ + + +
+
+ {#each LEVELS as l (l.value)} + + {/each} +
+
+ {#each EVENT_CATEGORIES as c (c.value)} + + {/each} +
+ +
+ + + + + {#if grouped}{/if} + + + + + + + {#if grouped}{/if} + + + + + + {#each shown as ev (ev.id)} + + + {#if grouped}{/if} + + + + {:else} + + {/each} + +
TimeJob runNodeWhat happened
{clockTime(ev.occurredAt)}{shortRunId(ev.runId)}{ev.nodeKey ?? ''} + + {eventSummary(ev)} +
{events.length ? 'No events match these filters.' : 'No events yet.'}
+ + diff --git a/ui/src/routes/runs/[id]/failures-panel.svelte b/ui/src/routes/runs/[id]/failures-panel.svelte new file mode 100644 index 0000000000..65b5469788 --- /dev/null +++ b/ui/src/routes/runs/[id]/failures-panel.svelte @@ -0,0 +1,140 @@ + + +{#if failed.length} +
+ + {#if !collapsed} +
    + {#each failed as n (`${n.runId}/${n.nodeKey}`)} + {@const key = `${n.runId}/${n.nodeKey}`} +
  • +
    + + {#if n.runId !== view.run?.id}in {n.jobKey ?? shortRunId(n.runId)}{/if} + {n.errorMessage ?? 'Failed'} + {#if n.errorLocation} + {n.errorLocation.file}:{n.errorLocation.line} + + {/if} + {#if n.errorDetail} + + {/if} +
    + {#if open.has(key) && n.errorDetail} +
    {n.errorDetail}
    + {#if n.errorCode}

    Error code {n.errorCode}

    {/if} + {/if} +
  • + {/each} +
+ {/if} +
+{/if} + + diff --git a/ui/src/routes/runs/[id]/live-statements.svelte b/ui/src/routes/runs/[id]/live-statements.svelte new file mode 100644 index 0000000000..efbe63c42c --- /dev/null +++ b/ui/src/routes/runs/[id]/live-statements.svelte @@ -0,0 +1,71 @@ + + +{#if statements.length} +
+ {#each statements.slice(0, 3) as st (st.id)} + + {/each} + {#if statements.length > 3}+{statements.length - 3} more running{/if} +
+{/if} + + diff --git a/ui/src/routes/runs/[id]/node-panel.svelte b/ui/src/routes/runs/[id]/node-panel.svelte new file mode 100644 index 0000000000..b1443646f3 --- /dev/null +++ b/ui/src/routes/runs/[id]/node-panel.svelte @@ -0,0 +1,154 @@ + + +
+ {#each groups as g (g.key)} +
+ + {#if !closed.has(g.key)} +
    + {#each g.nodes as n (`${n.runId}/${n.nodeKey}`)} +
  • + + {#if n.skippedBecauseOf} + {@const why = n.skippedBecauseOf} +
    + skipped: + {#if why.runId === ownRun || view.nodes.some((x) => x.nodeKey === why.nodeKey)} + + {:else} + {why.nodeKey} + {/if} + failed{#if why.runId !== n.runId} in {why.jobKey ?? shortRunId(why.runId)}{/if} +
    + {:else if notRunReason(n)} +
    {notRunReason(n)}
    + {/if} +
  • + {/each} +
+ {/if} +
+ {:else} +

No nodes yet.

+ {/each} +
+ + diff --git a/ui/src/routes/runs/[id]/output-tab.svelte b/ui/src/routes/runs/[id]/output-tab.svelte new file mode 100644 index 0000000000..b43ee5a93d --- /dev/null +++ b/ui/src/routes/runs/[id]/output-tab.svelte @@ -0,0 +1,114 @@ + + +
+ l.stream === 'stdout').length }, + { value: 'stderr', label: 'stderr', count: lines.filter((l) => l.stream === 'stderr').length } + ]} + /> + + +
+{#if shown.length} +
{#each shown as line, i (i)}{clockTime(line.at).slice(0, 8)}  {line.text}
+{/each}
+{:else} +
{total ? 'No output matches these filters.' : 'No output yet.'}
+{/if} + + diff --git a/ui/src/routes/runs/[id]/related-runs.svelte b/ui/src/routes/runs/[id]/related-runs.svelte new file mode 100644 index 0000000000..cfa263257a --- /dev/null +++ b/ui/src/routes/runs/[id]/related-runs.svelte @@ -0,0 +1,172 @@ + + +{#if d} + {#if related.length} + + {/if} + {#if view.showDetails} + {@const p = d.provenance} +
+
+
Project version
Version {p.snapshotGeneration} · commit {p.gitRevision}{p.gitDirty ? ' (with uncommitted changes)' : ''} · snapshot {p.snapshotId}
+
Command
{p.command}
+ {#if p.selection.length || p.exclude.length} +
Selection
{p.selection.join(' ') || '—'}{#if p.exclude.length} excluding {p.exclude.join(' ')}{/if}
+ {/if} +
Image
{shortImage(p.imageId)} · sqb {p.sqbVersion}
+
Rebuilds history
{d.run.rewriteHistory ? 'Yes: a full refresh or a set time window, so it needs extra permission to rerun' : 'No'}
+
Priority
{d.run.priority}
+
Run ID
{d.run.id}
+ {#if d.run.runKey}
Sensor run key
{d.run.runKey}
{/if} + {#if d.run.tickScheduledFor}
Scheduled for
{formatStamp(d.run.tickScheduledFor)} · tick {d.run.tickId}
{/if} + {#if d.liveness} +
+
Worker
+
+ {d.liveness.workerInstance ?? '—'} · container {d.liveness.containerId ?? '—'} + {#if d.liveness.lastHeartbeatAt} · last heartbeat {formatAgo(d.liveness.lastHeartbeatAt, app.now)}{/if} + {#if d.liveness.exitCode != null} · exit code {d.liveness.exitCode}{/if} +
+
+ {/if} +
+ {#if d.invocations.length} + + + + {#each d.invocations as inv (inv.invocationId)} + + + + + + + + + {/each} + +
sqb invocationRunAttemptStartedEndedExit code
{inv.invocationId}{shortRunId(inv.runId)}{inv.attempt}{formatStamp(inv.startedAt)}{inv.endedAt ? formatStamp(inv.endedAt) : '—'}{inv.exitCode ?? '—'}
+ {/if} +
+ {/if} +{/if} + + diff --git a/ui/src/routes/runs/[id]/rerun-button.svelte b/ui/src/routes/runs/[id]/rerun-button.svelte new file mode 100644 index 0000000000..0e98daff7d --- /dev/null +++ b/ui/src/routes/runs/[id]/rerun-button.svelte @@ -0,0 +1,94 @@ + + +{#if primary} +
+ + + + + + + + + {#each view.choices as c (c.mode)} + view.rerun(c.mode)}> +
+ {c.label} + {c.disabledReason ?? c.help} +
+
+ {/each} +
+
+
+{/if} + + diff --git a/ui/src/routes/runs/[id]/run-banners.svelte b/ui/src/routes/runs/[id]/run-banners.svelte new file mode 100644 index 0000000000..768a4ecf04 --- /dev/null +++ b/ui/src/routes/runs/[id]/run-banners.svelte @@ -0,0 +1,85 @@ + + +{#each banners as b (b.key)} + +{/each} + + diff --git a/ui/src/routes/runs/[id]/run-gantt.svelte b/ui/src/routes/runs/[id]/run-gantt.svelte new file mode 100644 index 0000000000..800b582dd4 --- /dev/null +++ b/ui/src/routes/runs/[id]/run-gantt.svelte @@ -0,0 +1,437 @@ + + +
+
+ {nodes.length} node{nodes.length === 1 ? '' : 's'} +
+ + + +
+ +
+ +
+
+
(scrolledX = e.currentTarget.scrollLeft > 1)}> +
+
+ {#each axisTicks as tk (tk)}{/each} +
+
+
Node
+
+ {#each axisTicks as tk (tk)}{formatTick(tk, tickStep)}{/each} +
+
+
{ + gutterDragging = true; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }} + onpointermove={onGutter} + onpointerup={() => (gutterDragging = false)} + ondblclick={() => (gutterW = autoFit())} + role="separator" + aria-label="Resize the node column (double-click to fit)" + tabindex="-1" + >
+ {#each rows as row (row.type === 'group' ? `g-${row.group.runId}` : `${row.node.runId}-${row.node.nodeKey}`)} + {#if row.type === 'group'} +
+
+ + {row.group.label} + {row.count} +
+
+ {:else} + {@const n = row.node} + {@const s = startOf(n)} + {@const e = endOf(n)} + + {/if} + {/each} +
+
+
+
+ + diff --git a/ui/src/routes/runs/[id]/run-graph-node.svelte b/ui/src/routes/runs/[id]/run-graph-node.svelte new file mode 100644 index 0000000000..8fdda2f6a4 --- /dev/null +++ b/ui/src/routes/runs/[id]/run-graph-node.svelte @@ -0,0 +1,96 @@ + + +
+ +
+ {n.nodeKey} + {n.kind} +
+
+ {nodeStatusLabel[n.status]} + {#if n.jobKey && n.runId !== ownRunId}{n.jobKey}{/if} +
+ {#if n.skippedBecauseOf} +
needs {n.skippedBecauseOf.nodeKey}{n.skippedBecauseOf.runId !== n.runId ? ` · ${n.skippedBecauseOf.jobKey ?? n.skippedBecauseOf.runId}` : ''}
+ {:else if n.errorMessage} +
{n.errorMessage}
+ {/if} + +
+ + diff --git a/ui/src/routes/runs/[id]/run-graph.svelte b/ui/src/routes/runs/[id]/run-graph.svelte new file mode 100644 index 0000000000..957018b6e8 --- /dev/null +++ b/ui/src/routes/runs/[id]/run-graph.svelte @@ -0,0 +1,71 @@ + + +
+ + + + +
+ + diff --git a/ui/src/routes/runs/[id]/run-header.svelte b/ui/src/routes/runs/[id]/run-header.svelte new file mode 100644 index 0000000000..cc22436f73 --- /dev/null +++ b/ui/src/routes/runs/[id]/run-header.svelte @@ -0,0 +1,221 @@ + + +
+
+ + {#if run} + +
+ {#if run.startedAt} + Started {formatAgo(run.startedAt, app.now)} + · + {view.active ? 'running for ' : 'took '}{formatDuration(run.startedAt, run.endedAt, app.now)} + {:else} + Queued {formatAgo(run.queuedAt, app.now)} + {/if} + · + by {run.requestedBy.displayName} + {#if view.progress.total} + + + {view.progress.done}/{view.progress.total} nodes + + {/if} + +
+ {:else if view.detailRes.error} +

Couldn't load this run: {view.detailRes.error}

+ {:else if view.detailRes.data === null} +

There's no run {runId} in this location. It may belong to another location.

+ {/if} +
+ {#if run} +
+ {#if view.active} + + + + {/if} + +
+ {/if} +
+{#if view.actionError}{/if} + + diff --git a/ui/src/routes/runs/[id]/run-tabs.svelte b/ui/src/routes/runs/[id]/run-tabs.svelte new file mode 100644 index 0000000000..563dafc2a1 --- /dev/null +++ b/ui/src/routes/runs/[id]/run-tabs.svelte @@ -0,0 +1,121 @@ + + +
+ {#each TABS as t (t.value)} + + {/each} + {#if filtering} + + Filtered: {chipText} + + + {:else} + Click a node above to filter + {/if} +
+ +
+ {#if view.tab === 'statements'} + + {:else if view.tab === 'events'} + + {:else} + + {/if} +
+ + diff --git a/ui/src/routes/runs/[id]/run-view.svelte b/ui/src/routes/runs/[id]/run-view.svelte new file mode 100644 index 0000000000..a8322ea96b --- /dev/null +++ b/ui/src/routes/runs/[id]/run-view.svelte @@ -0,0 +1,175 @@ + + + + +
+ + + {#if detail} + + + + +
+
+
+ Nodes over time + {#if detail.children.length}{detail.children.length} job runs · nodes from all of them{/if} +
+ + +
+
+
+
+ {#if !nodes.length} +
{detail.children.length ? 'The job runs in this group have no nodes yet.' : 'No nodes yet: the run hasn’t started.'}
+ {:else if topView === 'timeline'} + view.selectNode(key, add)} /> + {:else} + + {/if} +
+ +
+
+ + + +
{ + dragging = true; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }} + onpointermove={onDrag} + onpointerup={() => (dragging = false)} + onkeydown={onKey} + role="separator" + aria-orientation="horizontal" + aria-label="Resize the timeline and the tabs below" + aria-valuenow={Math.round(topFrac * 100)} + tabindex="0" + >
+ +
+ view.focusNode(key, 'statements')} /> + +
+
+ {:else if view.detailRes.loading} +
Loading…
+ {/if} +
+ + diff --git a/ui/src/routes/runs/[id]/state.svelte.ts b/ui/src/routes/runs/[id]/state.svelte.ts new file mode 100644 index 0000000000..66439be1ee --- /dev/null +++ b/ui/src/routes/runs/[id]/state.svelte.ts @@ -0,0 +1,163 @@ +import { goto } from '$app/navigation'; +import type { RerunMode } from '$lib/api'; +import { app } from '$lib/state/app.svelte'; +import { createResource } from '$lib/state/resource.svelte'; +import { isActiveRun } from '$lib/status'; +import type { BottomTab } from './types'; +import { bannersFor, primaryChoice, progressOf, rerunChoices } from './utils'; + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Run detail state: data, the node selection that filters the bottom pane, and actions. */ +export function createRunDetailState(runId: string) { + const topics = [`run:${runId}`, 'runs']; + const detailRes = createResource((client, scope) => client.getRunDetail(scope, runId), topics); + const eventsRes = createResource((client, scope) => client.listRunEvents(scope, runId, { limit: 5000 }), topics); + const outputRes = createResource((client, scope) => client.listRunOutput(scope, runId), topics); + const statementsRes = createResource((client, scope) => client.listRunStatements(scope, runId), topics); + + const detail = $derived(detailRes.data ?? null); + const run = $derived(detail?.run ?? null); + const nodes = $derived(detail?.nodes ?? []); + const active = $derived(!!run && isActiveRun(run.status)); + const progress = $derived(progressOf(nodes)); + const banners = $derived(detail ? bannersFor(detail, app.now) : []); + const statements = $derived(statementsRes.data ?? []); + // Progress reported by a run with no signal is stale, so the live card only shows while it's running. + const runningStatements = $derived(run?.status === 'running' ? statements.filter((s) => s.status === 'running') : []); + let showDetails = $state(false); + + // ---- node selection: the one filter model for timeline, panel and bottom tabs ---- + let selected = $state([]); + let tab = $state('statements'); + + /** Click selects one node (or clears it); with `add`, toggles it in a multi-selection. */ + function selectNode(key: string, add = false) { + if (add) selected = selected.includes(key) ? selected.filter((k) => k !== key) : [...selected, key]; + else selected = selected.length === 1 && selected[0] === key ? [] : [key]; + } + + function clearSelection() { + selected = []; + } + + function focusNode(key: string, nextTab: BottomTab) { + selected = [key]; + tab = nextTab; + } + + // ---- actions ---- + const choices = $derived(detail ? rerunChoices(detail, selected, (c) => app.can(c)) : []); + const primary = $derived(detail && choices.length ? primaryChoice(detail, choices) : null); + let busy = $state<'cancel' | RerunMode | null>(null); + let actionError = $state(null); + let cancelArmed = $state(false); + + async function cancel() { + const scope = app.scope; + if (!scope || !run) return; + if (!cancelArmed) { + cancelArmed = true; + return; + } + cancelArmed = false; + busy = 'cancel'; + actionError = null; + try { + await app.client.cancelRun(scope, run.id); + detailRes.refresh(); + } catch (e) { + actionError = message(e); + } finally { + busy = null; + } + } + + async function rerun(mode: RerunMode) { + const scope = app.scope; + if (!scope || !run) return; + busy = mode; + actionError = null; + try { + const next = await app.client.rerunRun(scope, run.id, mode, mode === 'selected' ? selected : undefined); + await goto(`/runs/${next.id}`); + } catch (e) { + actionError = message(e); + } finally { + busy = null; + } + } + + return { + detailRes, + eventsRes, + outputRes, + statementsRes, + get detail() { + return detail; + }, + get run() { + return run; + }, + get nodes() { + return nodes; + }, + get active() { + return active; + }, + get progress() { + return progress; + }, + get banners() { + return banners; + }, + get statements() { + return statements; + }, + get runningStatements() { + return runningStatements; + }, + get selected() { + return selected; + }, + get showDetails() { + return showDetails; + }, + toggleDetails() { + showDetails = !showDetails; + }, + get tab() { + return tab; + }, + set tab(value: BottomTab) { + tab = value; + }, + selectNode, + clearSelection, + focusNode, + get choices() { + return choices; + }, + get primary() { + return primary; + }, + get busy() { + return busy; + }, + get actionError() { + return actionError; + }, + get cancelArmed() { + return cancelArmed; + }, + disarmCancel() { + cancelArmed = false; + }, + cancel, + rerun + }; +} + +export type RunDetailState = ReturnType; diff --git a/ui/src/routes/runs/[id]/statements-tab.svelte b/ui/src/routes/runs/[id]/statements-tab.svelte new file mode 100644 index 0000000000..1338956280 --- /dev/null +++ b/ui/src/routes/runs/[id]/statements-tab.svelte @@ -0,0 +1,252 @@ + + +{#if statements.length} + + + + + + + {#if grouped}{/if} + + + + + + + + + {#each statements as st (st.id)} + toggle(st)}> + + + + {#if grouped}{/if} + + + + + + + {#if st.error} + + + + {/if} + {#if open.has(st.id)} + {@const loaded = sql[st.id]} + + + + {/if} + {/each} + +
#TimeJob runNodeOutcomeRowsTookQuery ID
{st.sequence}{clockTime(st.startedAt)}{shortRunId(st.runId)}{st.nodeKey} + + {#if st.status === 'completed'}{:else if st.status === 'failed'}{:else if st.status === 'running'}{:else}{/if} + {outcome[st.status]} + + {st.rowsAffected != null ? st.rowsAffected.toLocaleString('en-GB') : st.rowsSoFar != null ? `${st.rowsSoFar.toLocaleString('en-GB')} so far` : '—'}{formatSpan(elapsed(st))} + {#if st.queryId} + {st.queryId.slice(0, 13)}… + + {:else} + — + {/if} +
+ +
+ {#if !app.can('view.sql')} +

You need permission to see SQL (view.sql).

+ {:else if !st.sqlRecorded || (loaded?.state === 'ok' && loaded.sql === null)} +

SQL was not recorded for this statement. Capture may have been off, or the text has passed its retention.

+ {:else if loaded?.state === 'ok' && loaded.sql !== null} + + {:else if loaded?.state === 'error'} +

Couldn't load the SQL: {loaded.message}

+ {:else} +

Loading SQL…

+ {/if} +
+{:else} +
{total ? 'No queries match the filter.' : 'No queries yet. They appear here as the run sends them to the warehouse.'}
+{/if} + + diff --git a/ui/src/routes/runs/[id]/types.ts b/ui/src/routes/runs/[id]/types.ts new file mode 100644 index 0000000000..62eff6800f --- /dev/null +++ b/ui/src/routes/runs/[id]/types.ts @@ -0,0 +1,16 @@ +import type { RunNode, RunNodeStatus } from '$lib/api'; + +export type BottomTab = 'statements' | 'events' | 'output'; + +/** The node panel's groups, in display order. */ +export type NodeGroupKey = 'running' | 'failed' | 'skipped' | 'succeeded' | 'not_run'; +export type NodeGroup = { key: NodeGroupKey; label: string; tone: string; statuses: RunNodeStatus[]; nodes: RunNode[] }; + +/** Event categories for the Events tab filter. */ +export type EventCategory = 'run' | 'nodes' | 'queries' | 'checks'; + +export type BannerTone = 'info' | 'warn' | 'error'; +export type BannerPart = { text: string; href?: string }; +export type Banner = { key: string; tone: BannerTone; title: string; body: BannerPart[]; detail?: string }; + +export type RerunChoice = { mode: 'all' | 'from_failure' | 'selected'; label: string; help: string; disabledReason: string | null }; diff --git a/ui/src/routes/runs/[id]/utils/axis.ts b/ui/src/routes/runs/[id]/utils/axis.ts new file mode 100644 index 0000000000..76fa9b3309 --- /dev/null +++ b/ui/src/routes/runs/[id]/utils/axis.ts @@ -0,0 +1,34 @@ +// Time axis for the node timeline: adaptive steps from milliseconds to hours. + +const STEPS = [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200]; + +/** The smallest "nice" step that keeps at most `maxTicks` ticks over `totalSeconds`. */ +export function tickStep(totalSeconds: number, maxTicks: number): number { + const raw = totalSeconds / Math.max(2, maxTicks); + return STEPS.find((s) => s >= raw) ?? STEPS[STEPS.length - 1]; +} + +export function axisTicks(totalSeconds: number, step: number): number[] { + const out: number[] = []; + for (let i = 0; i * step <= totalSeconds + 1e-9 && out.length < 500; i++) out.push(Number((i * step).toFixed(3))); + return out; +} + +/** Tick label in the step's unit: `250ms`, `1.5s`, `45s`, `2m`, `1m 30s`, `1h 30m`. */ +export function formatTick(seconds: number, step: number): string { + if (seconds === 0) return '0'; + if (step < 1) return seconds < 1 ? `${Math.round(seconds * 1000)}ms` : `${Number(seconds.toFixed(3))}s`; + if (step < 60) return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${seconds % 60 ? ` ${seconds % 60}s` : ''}`; + if (step < 3600) return seconds < 3600 ? `${seconds / 60}m` : `${Math.floor(seconds / 3600)}h${seconds % 3600 ? ` ${(seconds % 3600) / 60}m` : ''}`; + return `${seconds / 3600}h`; +} + +/** A node's duration: `340ms`, `4.2s`, `3m 05s`. */ +export function formatSpan(seconds: number): string { + if (seconds < 1) return `${Math.max(0, Math.round(seconds * 1000))}ms`; + if (seconds < 10) return `${seconds.toFixed(1)}s`; + if (seconds < 60) return `${Math.round(seconds)}s`; + const s = Math.round(seconds); + if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`; + return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; +} diff --git a/ui/src/routes/runs/[id]/utils/banners.ts b/ui/src/routes/runs/[id]/utils/banners.ts new file mode 100644 index 0000000000..1575750b09 --- /dev/null +++ b/ui/src/routes/runs/[id]/utils/banners.ts @@ -0,0 +1,85 @@ +// Plain banners above the run: lost contact, waiting, and cancel progress. + +import type { CancelProgress, RunDetail } from '$lib/api'; +import { formatAgo, formatStamp } from '$lib/format/time'; +import { describeWait, formatSeconds, waitTechnical } from '$lib/operate/labels'; +import type { Banner } from '../types'; + +const short = (seconds: number) => formatSeconds(seconds).replace(/^(\d+)m 00s$/, '$1m'); + +/** Cancel progress in words, e.g. "Stopping: queries cancelled 12s ago; force stop in 48s if still running". */ +export function cancelWords(cancel: CancelProgress, now: number): string { + const by = cancel.requestedBy.displayName; + if (cancel.terminatedAt) { + if (cancel.sigkillAt) return `Cancelled by ${by}. It didn't stop within ${cancel.graceSeconds}s, so it was force-stopped.`; + if (!cancel.sigtermAt) return `Cancelled by ${by} before it started.`; + return `Cancelled by ${by} ${formatAgo(cancel.terminatedAt, now)}.`; + } + if (!cancel.sigtermAt) return `Cancel requested by ${by} ${formatAgo(cancel.requestedAt, now)}. Asking the run to stop…`; + const killAt = Date.parse(cancel.sigtermAt) + cancel.graceSeconds * 1000; + const left = Math.max(0, (killAt - now) / 1000); + return `Stopping: running queries were cancelled ${formatAgo(cancel.sigtermAt, now)}. ${left > 0 ? `Force stop in ${short(left)} if it's still running.` : 'Force-stopping now.'}`; +} + +function cancelTechnical(cancel: CancelProgress): string { + return [ + `cancel_requested ${formatStamp(cancel.requestedAt)} by ${cancel.requestedBy.name}`, + `SIGTERM ${cancel.sigtermAt ? formatStamp(cancel.sigtermAt) : '—'} (grace ${cancel.graceSeconds}s)`, + `SIGKILL ${cancel.sigkillAt ? formatStamp(cancel.sigkillAt) : '—'}`, + `exit code ${cancel.exitCode ?? '—'}` + ].join('\n'); +} + +export function bannersFor(detail: RunDetail, now: number): Banner[] { + const out: Banner[] = []; + const { run, liveness: live, cancel } = detail; + if (live?.state === 'unresponsive' && live.lastHeartbeatAt) { + const silent = (now - Date.parse(live.lastHeartbeatAt)) / 1000; + const left = Math.max(0, live.presumedFailedAfterSeconds - silent); + out.push({ + key: 'lost', + tone: 'warn', + title: `No signal for ${short(silent)}.`, + body: [{ text: `Rerunning is safe once it's marked failed${left > 0 ? `, in about ${short(left)} if the silence continues` : ''}. Nothing to do if it comes back.` }], + detail: `Last heartbeat ${formatStamp(live.lastHeartbeatAt)} · unresponsive after ${live.unresponsiveAfterSeconds}s, presumed failed after ${live.presumedFailedAfterSeconds}s · ${live.workerInstance ?? ''} ${live.containerId ?? ''}` + }); + } + if (run.status === 'presumed_failed') { + out.push({ + key: 'lost-final', + tone: 'error', + title: `Lost contact — marked failed after ${short(live?.presumedFailedAfterSeconds ?? 600)} of silence.`, + body: [{ text: 'Rerunning is safe: the run has been stopped. Use Rerun from failure to finish the nodes it didn’t complete.' }], + detail: run.statusReason ?? undefined + }); + } + if (live?.state === 'never_started') { + out.push({ + key: 'never', + tone: 'error', + title: "Couldn't start.", + body: [{ text: `The run never reported in within ${short(live.startDeadlineSeconds)} of launch, so nothing ran. Check the location's latest deploy, then rerun.` }], + detail: run.statusReason ?? undefined + }); + } + if (run.status === 'queued' && detail.waits.length) { + out.push({ + key: 'waiting', + tone: 'info', + title: 'Waiting in the queue.', + body: detail.waits.flatMap((w, i) => [...(i ? [{ text: ' · ' }] : []), ...describeWait(w)]), + detail: detail.waits.map(waitTechnical).join('\n') + }); + } + if (cancel && !cancel.terminatedAt) { + const stuck = live?.state === 'unresponsive'; + out.push({ + key: 'cancel', + tone: stuck ? 'error' : 'warn', + title: stuck ? "Cancel can't reach the run." : 'Cancelling.', + body: [{ text: stuck ? 'There is no signal from the run, so it can’t be told to stop. It will be marked failed if the silence continues.' : cancelWords(cancel, now) }], + detail: cancelTechnical(cancel) + }); + } + return out; +} diff --git a/ui/src/routes/runs/[id]/utils/events.ts b/ui/src/routes/runs/[id]/utils/events.ts new file mode 100644 index 0000000000..311a636b57 --- /dev/null +++ b/ui/src/routes/runs/[id]/utils/events.ts @@ -0,0 +1,61 @@ +// Plain one-line summaries of run events; the raw type stays available as a tooltip. + +import type { RunEvent } from '$lib/api'; +import type { EventCategory } from '../types'; + +export const EVENT_CATEGORIES: { value: EventCategory; label: string }[] = [ + { value: 'run', label: 'Run' }, + { value: 'nodes', label: 'Nodes' }, + { value: 'queries', label: 'Queries' }, + { value: 'checks', label: 'Checks' } +]; + +export function eventCategory(e: RunEvent): EventCategory { + if (e.type.startsWith('statement_')) return 'queries'; + if (e.type.startsWith('resource_attempt_')) return 'nodes'; + if (e.type === 'audit_completed') return 'checks'; + return 'run'; +} + +export function eventSummary(e: RunEvent): string { + switch (e.type) { + case 'requested': + return e.message; + case 'queued': + return 'Waiting for its turn'; + case 'claimed': + return 'Its turn came: the data it writes is reserved for this run'; + case 'launched': + return 'Started a container for the run'; + case 'cancel_requested': + return e.message; + case 'presumed_failed': + return 'Lost contact with the run; marked failed'; + case 'terminated': + return `Finished${e.message.match(/^Terminated: (\w+)/) ? `: ${e.message.match(/^Terminated: (\w+)/)![1].replace('_', ' ')}` : ''}`; + case 'invocation_started': + return 'sqb started'; + case 'invocation_completed': + return `sqb finished: ${e.message}`; + case 'invocation_failed': + return `sqb finished with failures: ${e.message}`; + case 'resource_attempt_started': + return 'Started'; + case 'resource_attempt_completed': + return e.message; + case 'resource_attempt_failed': + return e.message.replace(/^[A-Za-z0-9_]+: /, 'Failed: '); + case 'resource_attempt_skipped': + return e.message; + case 'statement_submitted': + return 'Query sent to the warehouse'; + case 'statement_started': + return 'Query running'; + case 'statement_completed': + return `Query finished · ${e.message}`; + case 'statement_failed': + return `Query failed: ${e.message}`; + case 'audit_completed': + return `Checks: ${e.message}`; + } +} diff --git a/ui/src/routes/runs/[id]/utils/index.ts b/ui/src/routes/runs/[id]/utils/index.ts new file mode 100644 index 0000000000..52a44a2f79 --- /dev/null +++ b/ui/src/routes/runs/[id]/utils/index.ts @@ -0,0 +1,5 @@ +export { axisTicks, formatSpan, formatTick, tickStep } from './axis'; +export { bannersFor, cancelWords } from './banners'; +export { EVENT_CATEGORIES, eventCategory, eventSummary } from './events'; +export { groupNodes, notRunReason, progressOf } from './nodes'; +export { primaryChoice, rerunChoices } from './rerun'; diff --git a/ui/src/routes/runs/[id]/utils/nodes.ts b/ui/src/routes/runs/[id]/utils/nodes.ts new file mode 100644 index 0000000000..3670b2f744 --- /dev/null +++ b/ui/src/routes/runs/[id]/utils/nodes.ts @@ -0,0 +1,28 @@ +import type { RunNode } from '$lib/api'; +import type { NodeGroup } from '../types'; + +const GROUPS: Omit[] = [ + { key: 'running', label: 'Running', tone: 'var(--sb-running)', statuses: ['running'] }, + { key: 'failed', label: 'Failed', tone: 'var(--sb-error)', statuses: ['failed'] }, + { key: 'skipped', label: 'Skipped', tone: 'var(--sb-warning)', statuses: ['skipped'] }, + { key: 'succeeded', label: 'Succeeded', tone: 'var(--sb-success)', statuses: ['success', 'warn'] }, + { key: 'not_run', label: 'Not run', tone: 'var(--sb-text-faint)', statuses: ['pending', 'cancelled', 'not_run'] } +]; + +/** Nodes by outcome, in panel order; empty groups are dropped. */ +export function groupNodes(nodes: readonly RunNode[]): NodeGroup[] { + return GROUPS.map((g) => ({ ...g, nodes: nodes.filter((n) => g.statuses.includes(n.status)) })).filter((g) => g.nodes.length); +} + +/** Why a node didn't run, in words. */ +export function notRunReason(n: RunNode): string | null { + if (n.status === 'pending') return 'not started yet'; + if (n.status === 'cancelled') return 'stopped by the cancel'; + if (n.status === 'not_run') return 'the run ended before it started'; + return null; +} + +/** Nodes done (any outcome) out of all. */ +export function progressOf(nodes: readonly RunNode[]): { done: number; total: number } { + return { done: nodes.filter((n) => n.status !== 'pending' && n.status !== 'running').length, total: nodes.length }; +} diff --git a/ui/src/routes/runs/[id]/utils/rerun.ts b/ui/src/routes/runs/[id]/utils/rerun.ts new file mode 100644 index 0000000000..69233e5a56 --- /dev/null +++ b/ui/src/routes/runs/[id]/utils/rerun.ts @@ -0,0 +1,48 @@ +// Rerun choices with their disabled reasons, for the header's split button. + +import type { Capability, RunDetail } from '$lib/api'; +import { isActiveRun } from '$lib/status'; +import type { RerunChoice } from '../types'; + +const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`; + +export function rerunChoices(detail: RunDetail, selected: readonly string[], can: (c: Capability) => boolean): RerunChoice[] { + const { run } = detail; + const needs: Capability[] = run.rewriteHistory ? ['run.rerun', 'run.rewrite_history'] : ['run.rerun']; + const missing = needs.filter((c) => !can(c)); + const common = isActiveRun(run.status) + ? 'Wait for the run to finish, or cancel it first' + : missing.length + ? `You don't have permission to rerun this (${missing.join(', ')})` + : null; + const failures = detail.rerunFromFailureNodes.length; + const choices: RerunChoice[] = []; + if (selected.length) { + choices.push({ + mode: 'selected', + label: `Rerun ${plural(selected.length, 'selected node')}`, + help: `Runs only ${selected.slice(0, 3).join(', ')}${selected.length > 3 ? '…' : ''}`, + disabledReason: common + }); + } + choices.push({ + mode: 'from_failure', + label: 'Rerun from failure', + help: failures ? `Runs the ${plural(failures, 'node')} that failed or were skipped because of a failure` : 'Nothing failed', + disabledReason: common ?? (failures ? null : 'Nothing failed or was skipped in this run') + }); + choices.push({ + mode: 'all', + label: 'Rerun all', + help: `Runs the whole ${run.kind === 'group' ? 'group' : run.kind === 'adhoc' ? 'selection' : 'job'} again`, + disabledReason: common + }); + return choices; +} + +/** The split button's main action: from failure after a failure, otherwise all. */ +export function primaryChoice(detail: RunDetail, choices: RerunChoice[]): RerunChoice { + const failedLike = detail.run.status === 'failed' || detail.run.status === 'partial' || detail.run.status === 'presumed_failed'; + const wanted = failedLike && detail.rerunFromFailureNodes.length ? 'from_failure' : 'all'; + return choices.find((c) => c.mode === wanted)!; +} diff --git a/ui/src/routes/runs/bulk-bar.svelte b/ui/src/routes/runs/bulk-bar.svelte new file mode 100644 index 0000000000..277e0a8301 --- /dev/null +++ b/ui/src/routes/runs/bulk-bar.svelte @@ -0,0 +1,119 @@ + + +{#if total || (scopedTab && matchingActive)} +
0}> + {#if total} + {total} selected + {#each ACTIONS as action (action)} + {@const count = list.eligibleFor[action].length} + + + + {/each} + + {/if} + {#if scopedTab && matchingActive} + + + + {/if} + {#if list.armed}Click again to confirm.{/if} +
+{/if} +{#if list.result} +
+ {list.result.text} + +
+{/if} + + diff --git a/ui/src/routes/runs/run-list.svelte b/ui/src/routes/runs/run-list.svelte new file mode 100644 index 0000000000..d5e1d9e39f --- /dev/null +++ b/ui/src/routes/runs/run-list.svelte @@ -0,0 +1,323 @@ + + +{#snippet chip(key: TokenKey, value: string, label: string, title: string)} + +{/snippet} + +
(scrollTop = e.currentTarget.scrollTop)}> + + + + + + + + + + + + + + + + + + + + + + + {#if first > 0}{/if} + {#each visible as d (d.key)} + {@const r = d.row.run} + {@const line = statusLine(d.row, app.now)} + open(e, r.id)} onauxclick={(e) => e.button === 1 && open(e, r.id)}> + + + + + + + + + {/each} + {#if last < rows.length}{/if} + {#if !rows.length} + + + + {:else if list.loadingMore || list.hasMore} + + {:else if rows.length > 20} + + {/if} + +
+ 0 && !list.allVisibleSelected} onchange={() => list.toggleAll()} aria-label="Select all loaded runs" /> + RunWhatStarted byStatusStartedTook
+ + list.toggleSelected(r.id)} aria-label="Select run {shortRunId(r.id)}" /> + +
+ {shortRunId(r.id)} + + {#if r.childRunCount && !d.child && !list.params.children} + + {/if} +
+ {#if d.child || (list.params.children && r.parentRunId)} + + {/if} +
+
+ {#if r.jobKey} + {@render chip('job', r.jobKey, r.jobKey, `Show only runs of ${r.jobKey}`)} + {:else} + ad hoc: {d.row.selection.join(' ') || 'whole project'} + {/if} + {@render chip('kind', kindToken(d.row), runKindLabel[r.kind].toLowerCase(), `Show only ${runKindLabel[r.kind].toLowerCase()} runs`)} +
+
+
+ {#if r.origin === 'schedule' && r.triggerName} + + {@render chip('schedule', r.triggerName, r.triggerName, `Show only runs started by the ${r.triggerName} schedule`)} + ↗ + {:else if r.origin === 'sensor' && r.triggerName} + + {@render chip('sensor', r.triggerName, r.triggerName, `Show only runs started by the ${r.triggerName} sensor`)} + ↗ + {:else} + + {@render chip('by', r.requestedBy.name, r.requestedBy.displayName, `Show only runs started by ${r.requestedBy.displayName} (${r.requestedBy.name})`)} + {/if} +
+ {#if r.rerunOfRunId} + + {:else if r.origin === 'schedule' && r.tickScheduledFor} +
due {formatStamp(r.tickScheduledFor).slice(11)}
+ {:else if r.runKey} +
for {r.runKey}
+ {/if} +
+ +
{line.text}
+
+ {#if r.startedAt} + + {:else} + + {/if} + + {r.startedAt ? formatDuration(r.startedAt, r.endedAt, app.now) : '—'} +
+ {#if list.error} + Couldn't load runs: {list.error} + {:else if list.loading} + Loading… + {:else} + {emptyText} + {/if} +
Loading more…
That's all {rows.length} runs.
+
+ + diff --git a/ui/src/routes/runs/run-queue.svelte b/ui/src/routes/runs/run-queue.svelte new file mode 100644 index 0000000000..f0107ce1e4 --- /dev/null +++ b/ui/src/routes/runs/run-queue.svelte @@ -0,0 +1,100 @@ + + +{#if error}
{error}
{/if} +
+ + + + + + + + + + + + + + + {#each entries as q (q.run.id)} + goto(`/runs/${q.run.id}`)}> + + + + + + + + + + {:else} + + {/each} + +
#RunJobPriorityWaiting sinceWhy it waitsIf more requests arrive
{q.position} + e.stopPropagation()}>{shortRunId(q.run.id)} +
{runKindOriginLabel(q.run)} · {q.run.requestedBy.name}
+
+ {#if q.run.jobKey} e.stopPropagation()}>{q.run.jobKey}{:else}Ad hoc{/if} + {#if q.run.parentRunId}{/if} + {q.run.priority}{formatAgo(q.run.queuedAt, app.now)} + {#each q.waits as w, i (i)} +
+ {#each describeWait(w) as part, j (j)}{#if part.href} e.stopPropagation()}>{part.text}{:else if part.code}{part.text}{:else}{part.text}{/if}{/each} +
+ {:else} + Nothing in the way; it starts on the next scheduler pass + {/each} +
+ {#if q.overlap}{overlapPlain[q.overlap.policy]}
{q.overlap.detail}
{:else}—{/if} +
+ + + e.stopPropagation()}> + cancel(q.run.id)}>Cancel + +
+
+ + diff --git a/ui/src/routes/runs/runs-toolbar.svelte b/ui/src/routes/runs/runs-toolbar.svelte new file mode 100644 index 0000000000..84b03e508f --- /dev/null +++ b/ui/src/routes/runs/runs-toolbar.svelte @@ -0,0 +1,349 @@ + + +
+
+ {#each tabs as t (t.value)} + + {/each} +
+
+ p.view, (v: RunsView) => list.update({ view: v })} + options={[ + { value: 'list', label: 'List' }, + { value: 'timeline', label: 'Timeline' }, + { value: 'queue', label: 'Queue', count: queued ?? undefined } + ]} + /> + + {#if list.updatedAt}updated {formatAgo(new Date(list.updatedAt).toISOString(), app.now)}{/if} + {freshnessText[list.freshness]} + + +
+
+ +{#if p.view === 'list'} +
+ +
+ + {#if showHelp} + + {/if} +
+ (p.range === 'any' ? '' : p.range), (v: RangePreset | '') => list.update({ range: v || 'any' })} + all="Any time" + options={[ + { value: '24h', label: 'Last 24 hours' }, + { value: '7d', label: 'Last 7 days' }, + { value: '30d', label: 'Last 30 days' }, + { value: 'custom', label: 'Between dates…' } + ]} + /> + {#if p.range === 'custom'} + list.update({ from: e.currentTarget.value })} aria-label="From date" /> + to + list.update({ to: e.currentTarget.value })} aria-label="To date" /> + {/if} + +
+ {#if tokens.length} +
+ {#each tokens as t (`${t.key}:${t.value}`)} + + {t.key}{t.value} + + + {/each} + +
+ {/if} +{/if} + + diff --git a/ui/src/routes/runs/state.svelte.ts b/ui/src/routes/runs/state.svelte.ts new file mode 100644 index 0000000000..619e47abd5 --- /dev/null +++ b/ui/src/routes/runs/state.svelte.ts @@ -0,0 +1,337 @@ +import { goto } from '$app/navigation'; +import { page } from '$app/state'; +import { untrack } from 'svelte'; +import { channelMatches, 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'; + +const PAGE = 50; +/** Live signals refetch at most this often: the first immediately, then one trailing. */ +const SIGNAL_GAP_MS = 300; +/** Safety refresh in case a signal was missed. */ +const SAFETY_MS = 5 * 60_000; + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Runs list state: URL-backed filters, cursor-paged rows, tab counts, selection and bulk actions. */ +export function createRunsState() { + const params = $derived(readParams(page.url.searchParams)); + /** Changes when the rows need reloading from the start (the view switch doesn't count). */ + const key = $derived(JSON.stringify({ ...params, view: undefined })); + + let rows = $state([]); + let nextCursor = $state(null); + let loading = $state(true); + let loadingMore = $state(false); + let error = $state(null); + let updatedAt = $state(null); + let counts = $state(null); + let countsError = $state(false); + let expanded = $state>(new Set()); + let children = $state>({}); + let request = 0; + + function update(patch: Partial) { + void goto(writeParams(page.url, { ...params, ...patch }), { replaceState: true, noScroll: true, keepFocus: true }); + } + + async function loadRows(mode: 'reset' | 'refresh' | 'more') { + const scope = app.scope; + if (!scope) return; + const id = ++request; + const query = toQuery(params, Date.now()); + if (mode === 'more') { + if (!nextCursor || loadingMore) return; + loadingMore = true; + } else { + loading = true; + } + try { + const result = await app.client.listRunRows(scope, { + ...query, + cursor: mode === 'more' ? nextCursor : null, + limit: mode === 'refresh' ? Math.max(PAGE, rows.length) : PAGE + }); + if (id !== request) return; + rows = mode === 'more' ? [...rows, ...result.rows] : result.rows; + nextCursor = result.nextCursor; + error = null; + updatedAt = Date.now(); + } catch (e) { + if (id === request) error = message(e); + } finally { + if (id === request) { + loading = false; + loadingMore = false; + } + } + } + + async function loadCounts() { + const scope = app.scope; + if (!scope) return; + try { + counts = await app.client.runTabCounts(scope, toQuery(params, Date.now(), 'all')); + countsError = false; + } catch { + countsError = true; + } + } + + async function loadChildren(parentId: string) { + const scope = app.scope; + if (!scope) return; + try { + const result = await app.client.listRunRows(scope, { parentRunId: parentId, includeChildren: true, limit: 200 }); + children = { ...children, [parentId]: [...result.rows].sort((a, b) => a.run.queuedAt.localeCompare(b.run.queuedAt) || a.run.id.localeCompare(b.run.id)) }; + } catch { + children = { ...children, [parentId]: [] }; + } + } + + function refresh() { + void loadRows('refresh'); + void loadCounts(); + for (const id of expanded) void loadChildren(id); + } + + // Reload from the start whenever the scope or the filters change. + $effect(() => { + void key; + if (!app.scope) return; + untrack(() => { + rows = []; + nextCursor = null; + selected = new Set(); + armed = null; + void loadRows('reset'); + void loadCounts(); + }); + }); + + // Live signals, rate-capped, paused while the tab is hidden; plus a slow safety refresh. + $effect(() => { + let last = 0; + let trailing: ReturnType | null = null; + let missed = false; + const fire = () => { + last = Date.now(); + refresh(); + }; + const onSignal = () => { + if (document.hidden) { + missed = true; + return; + } + const wait = SIGNAL_GAP_MS - (Date.now() - last); + if (wait <= 0) fire(); + else if (!trailing) trailing = setTimeout(() => ((trailing = null), fire()), wait); + }; + const off = app.client.changes.subscribe((signal) => { + if (channelMatches(signal.channel, ['runs'])) onSignal(); + }); + const onVisible = () => { + if (!document.hidden && missed) { + missed = false; + fire(); + } + }; + document.addEventListener('visibilitychange', onVisible); + const safety = setInterval(() => !document.hidden && fire(), SAFETY_MS); + return () => { + off(); + document.removeEventListener('visibilitychange', onVisible); + clearInterval(safety); + if (trailing) clearTimeout(trailing); + }; + }); + + function toggleExpanded(id: string) { + const next = new Set(expanded); + if (next.has(id)) next.delete(id); + else { + next.add(id); + void loadChildren(id); + } + expanded = next; + } + + const display = $derived.by((): DisplayRow[] => { + const out: DisplayRow[] = []; + for (const row of rows) { + out.push({ key: row.run.id, row, child: false }); + if (expanded.has(row.run.id)) for (const c of children[row.run.id] ?? []) out.push({ key: `${row.run.id}/${c.run.id}`, row: c, child: true }); + } + return out; + }); + + // ---- live indicator ---- + const freshness = $derived.by((): Freshness => { + if (app.connection === 'offline') return 'stale'; + if (updatedAt != null && app.now - updatedAt > SAFETY_MS + 30_000) return 'stale'; + if (app.connection === 'live') return 'live'; + return 'reconnecting'; + }); + + // ---- selection and bulk actions ---- + let selected = $state>(new Set()); + let armed = $state(null); + let busy = $state(null); + let result = $state<{ text: string; failed: boolean } | null>(null); + + const known = $derived(new Map([...rows, ...Object.values(children).flat()].map((r) => [r.run.id, r]))); + const selectedRows = $derived([...selected].map((id) => known.get(id)).filter((r): r is RunRow => !!r)); + const can = (c: Parameters[0]) => app.can(c); + const eligibleFor = $derived({ + cancel: eligibleIds('cancel', selectedRows, can), + rerun: eligibleIds('rerun', selectedRows, can), + from_failure: eligibleIds('from_failure', selectedRows, can) + }); + + function setSelected(next: Set) { + selected = next; + armed = null; + } + + function toggleSelected(id: string) { + const next = new Set(selected); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelected(next); + } + + const allVisibleSelected = $derived(display.length > 0 && display.every((d) => selected.has(d.row.run.id))); + function toggleAll() { + setSelected(allVisibleSelected ? new Set() : new Set(display.map((d) => d.row.run.id))); + } + + /** First click arms, second click runs; any selection change disarms. */ + async function bulk(action: BulkAction) { + if (armed !== action) { + armed = action; + return; + } + const scope = app.scope; + const ids = eligibleFor[action]; + if (!scope || !ids.length) return; + armed = null; + busy = action; + result = null; + const outcomes = await Promise.allSettled( + ids.map((id) => (action === 'cancel' ? app.client.cancelRun(scope, id) : app.client.rerunRun(scope, id, action === 'rerun' ? 'all' : 'from_failure'))) + ); + const failed = ids.filter((_, i) => outcomes[i].status === 'rejected'); + const firstError = outcomes.find((o): o is PromiseRejectedResult => o.status === 'rejected'); + const verb = action === 'cancel' ? 'Cancel requested for' : 'Started reruns of'; + const ok = ids.length - failed.length; + result = failed.length + ? { text: `${ok ? `${verb} ${ok} run${ok === 1 ? '' : 's'}. ` : ''}${failed.length} failed: ${message(firstError?.reason)}. The failed runs stay selected.`, failed: true } + : { text: `${verb} ${ok} run${ok === 1 ? '' : 's'}.`, failed: false }; + selected = new Set(failed); + busy = null; + refresh(); + } + + async function cancelAll() { + if (armed !== 'cancel_all') { + armed = 'cancel_all'; + return; + } + const scope = app.scope; + if (!scope) return; + armed = null; + busy = 'cancel_all'; + result = null; + try { + const res = await app.client.cancelMatchingRuns(scope, toQuery(params, Date.now())); + result = res.failed.length + ? { text: `Cancel requested for ${res.done.length} runs; ${res.failed.length} failed: ${res.failed[0].error}`, failed: true } + : { text: `Cancel requested for ${res.done.length} run${res.done.length === 1 ? '' : 's'}.`, failed: false }; + selected = new Set(res.failed.map((f) => f.runId)); + } catch (e) { + result = { text: message(e), failed: true }; + } finally { + busy = null; + refresh(); + } + } + + return { + get params() { + return params; + }, + update, + get rows() { + return rows; + }, + get display() { + return display; + }, + get loading() { + return loading; + }, + get loadingMore() { + return loadingMore; + }, + get hasMore() { + return nextCursor !== null; + }, + get error() { + return error; + }, + get updatedAt() { + return updatedAt; + }, + get counts() { + return counts; + }, + get countsError() { + return countsError; + }, + get freshness() { + return freshness; + }, + get expanded() { + return expanded; + }, + get selected() { + return selected; + }, + get selectedRows() { + return selectedRows; + }, + get eligibleFor() { + return eligibleFor; + }, + get allVisibleSelected() { + return allVisibleSelected; + }, + get armed() { + return armed; + }, + get busy() { + return busy; + }, + get result() { + return result; + }, + dismissResult() { + result = null; + }, + clearSelection() { + setSelected(new Set()); + }, + loadMore: () => loadRows('more'), + refresh, + toggleExpanded, + toggleSelected, + toggleAll, + bulk, + cancelAll + }; +} + +export type RunsState = ReturnType; diff --git a/ui/src/routes/runs/types.ts b/ui/src/routes/runs/types.ts new file mode 100644 index 0000000000..72db7a3c5b --- /dev/null +++ b/ui/src/routes/runs/types.ts @@ -0,0 +1,35 @@ +import type { RunRow, RunTab } from '$lib/api'; + +export type RunsView = 'list' | 'timeline' | 'queue'; + +export type RangePreset = 'any' | '24h' | '7d' | '30d' | 'custom'; + +/** Everything the list shows, as kept in the URL. */ +export type ListParams = { + view: RunsView; + tab: RunTab; + /** The search box: filter tokens plus free text. */ + q: string; + range: RangePreset; + /** `YYYY-MM-DD`, for the custom range. */ + from: string; + to: string; + /** Show group children as their own rows. */ + children: boolean; +}; + +export const TOKEN_KEYS = ['kind', 'origin', 'status', 'job', 'schedule', 'sensor', 'node', 'id', 'by'] as const; +export type TokenKey = (typeof TOKEN_KEYS)[number]; +export type FilterToken = { key: TokenKey; value: string }; + +export type StatusTone = 'muted' | 'warn' | 'error' | 'live'; +/** The plain second line under a run's status pill. */ +export type StatusLine = { text: string; tone: StatusTone; title?: string }; + +export type BulkAction = 'cancel' | 'rerun' | 'from_failure'; + +/** A row in the virtualised list: a run, or one of a group's children shown inline. */ +export type DisplayRow = { key: string; row: RunRow; child: boolean }; + +/** The list's live-update indicator. */ +export type Freshness = 'live' | 'reconnecting' | 'stale'; diff --git a/ui/src/routes/runs/utils/bulk.ts b/ui/src/routes/runs/utils/bulk.ts new file mode 100644 index 0000000000..c936f8984b --- /dev/null +++ b/ui/src/routes/runs/utils/bulk.ts @@ -0,0 +1,36 @@ +// Which selected runs each bulk action applies to. Pure. + +import type { Capability, RunRow } from '$lib/api'; +import { isActiveRun } from '$lib/status'; +import type { BulkAction } from '../types'; + +export const bulkLabel: Record = { + cancel: 'Cancel', + rerun: 'Rerun', + from_failure: 'Rerun from failure' +}; + +export const bulkCapability: Record = { + cancel: 'run.cancel', + rerun: 'run.rerun', + from_failure: 'run.rerun' +}; + +/** Whether `action` applies to the run at all (permissions aside). */ +export function eligible(action: BulkAction, row: RunRow): boolean { + const active = isActiveRun(row.run.status); + if (action === 'cancel') return active && !row.cancelRequestedAt; + if (action === 'rerun') return !active; + return !active && row.rerunFromFailureCount > 0; +} + +/** Why an otherwise eligible run can't be acted on by this principal, or null. */ +export function blockedBy(action: BulkAction, row: RunRow, can: (c: Capability) => boolean): Capability | null { + if (!can(bulkCapability[action])) return bulkCapability[action]; + if (action !== 'cancel' && row.run.rewriteHistory && !can('run.rewrite_history')) return 'run.rewrite_history'; + return null; +} + +export function eligibleIds(action: BulkAction, rows: readonly RunRow[], can: (c: Capability) => boolean): string[] { + return rows.filter((r) => eligible(action, r) && !blockedBy(action, r, can)).map((r) => r.run.id); +} diff --git a/ui/src/routes/runs/utils/filter-tokens.ts b/ui/src/routes/runs/utils/filter-tokens.ts new file mode 100644 index 0000000000..ca28324008 --- /dev/null +++ b/ui/src/routes/runs/utils/filter-tokens.ts @@ -0,0 +1,91 @@ +// Search tokens for the runs list, e.g. `job:core_build status:failed orders`. +// Pure: parses the box into a structured filter and edits the token string. + +import { RUN_KINDS, RUN_ORIGINS, RUN_STATUSES, type RunFilter, type RunKind, type RunOrigin, type RunStatus } from '$lib/api'; +import { TOKEN_KEYS, type FilterToken, type TokenKey } from '../types'; + +const TOKEN_RE = /^([a-z]+):(.+)$/; + +/** Splits the search text into recognised `key:value` tokens and the remaining free text. */ +export function parseTokens(q: string): { tokens: FilterToken[]; text: string } { + const tokens: FilterToken[] = []; + const text: string[] = []; + for (const part of q.trim().split(/\s+/).filter(Boolean)) { + const m = part.match(TOKEN_RE); + const key = m?.[1] as TokenKey | undefined; + if (m && key && (TOKEN_KEYS as readonly string[]).includes(key)) { + for (const value of m[2].split(',').filter(Boolean)) tokens.push({ key, value }); + } else { + text.push(part); + } + } + return { tokens, text: text.join(' ') }; +} + +const pick = (values: string[], allowed: readonly T[]): T[] => + values.map((v) => v.toLowerCase()).filter((v): v is T => (allowed as readonly string[]).includes(v)); + +/** The structured filter the server takes. Unknown status, kind or origin values are dropped. */ +export function toFilter(q: string): RunFilter { + const { tokens, text } = parseTokens(q); + const values = (key: TokenKey) => tokens.filter((t) => t.key === key).map((t) => t.value); + const statuses = values('status').flatMap((v) => (v === 'lost' ? ['presumed_failed'] : [v])); + const filter: RunFilter = { + kinds: pick(values('kind').map((v) => (v === 'ad-hoc' ? 'adhoc' : v)), RUN_KINDS), + origins: pick(values('origin'), RUN_ORIGINS), + statuses: pick(statuses, RUN_STATUSES), + jobKeys: values('job'), + schedules: values('schedule'), + sensors: values('sensor'), + nodeKeys: values('node'), + ids: values('id'), + requestedBy: values('by'), + text: text || undefined + }; + return Object.fromEntries(Object.entries(filter).filter(([, v]) => (Array.isArray(v) ? v.length : v))) as RunFilter; +} + +/** Tokens that don't narrow anything, e.g. `status:bogus`, so the UI can flag them. */ +export function invalidTokens(q: string): FilterToken[] { + const allowed: Partial> = { + kind: [...RUN_KINDS, 'ad-hoc'], + origin: RUN_ORIGINS, + status: [...RUN_STATUSES, 'lost'] + }; + return parseTokens(q).tokens.filter((t) => allowed[t.key] && !allowed[t.key]!.includes(t.value.toLowerCase())); +} + +/** Adds `key:value` unless an equal token is already there. */ +export function addToken(q: string, key: TokenKey, value: string): string { + const { tokens } = parseTokens(q); + if (tokens.some((t) => t.key === key && t.value.toLowerCase() === value.toLowerCase())) return q; + return `${q.trim()} ${key}:${value}`.trim(); +} + +/** Removes one `key:value`, also from comma lists like `status:failed,partial`. */ +export function removeToken(q: string, key: TokenKey, value: string): string { + return q + .trim() + .split(/\s+/) + .filter(Boolean) + .map((part) => { + const m = part.match(TOKEN_RE); + if (!m || m[1] !== key) return part; + const rest = m[2].split(',').filter((v) => v.toLowerCase() !== value.toLowerCase()); + return rest.length ? `${key}:${rest.join(',')}` : ''; + }) + .filter(Boolean) + .join(' '); +} + +export const TOKEN_HELP: Record = { + kind: 'kind:job, kind:group or kind:adhoc', + origin: 'origin:manual, origin:schedule, origin:sensor or origin:rerun', + status: 'status:running, status:failed, status:lost …', + job: 'job:', + schedule: 'schedule:', + sensor: 'sensor:', + node: 'node:: runs that build it', + id: 'id:', + by: 'by:' +}; diff --git a/ui/src/routes/runs/utils/index.ts b/ui/src/routes/runs/utils/index.ts new file mode 100644 index 0000000000..de715f4b86 --- /dev/null +++ b/ui/src/routes/runs/utils/index.ts @@ -0,0 +1,4 @@ +export { addToken, invalidTokens, parseTokens, removeToken, toFilter, TOKEN_HELP } from './filter-tokens'; +export { statusLine } from './status-line'; +export { blockedBy, bulkCapability, bulkLabel, eligible, eligibleIds } from './bulk'; +export { readParams, toQuery, writeParams } from './params'; diff --git a/ui/src/routes/runs/utils/params.ts b/ui/src/routes/runs/utils/params.ts new file mode 100644 index 0000000000..46c898184c --- /dev/null +++ b/ui/src/routes/runs/utils/params.ts @@ -0,0 +1,55 @@ +// The list's URL parameters, read and written in one place. Pure. + +import { RUN_TABS, type RunRowQuery, type RunTab } from '$lib/api'; +import type { ListParams, RangePreset, RunsView } from '../types'; +import { toFilter } from './filter-tokens'; + +const VIEWS: readonly RunsView[] = ['list', 'timeline', 'queue']; +const RANGES: readonly RangePreset[] = ['any', '24h', '7d', '30d', 'custom']; + +export function readParams(search: URLSearchParams): ListParams { + const view = search.get('view'); + const tab = search.get('tab'); + const range = search.get('range'); + return { + view: VIEWS.find((v) => v === view) ?? 'list', + tab: RUN_TABS.find((t) => t === tab) ?? 'all', + q: search.get('q') ?? '', + range: RANGES.find((r) => r === range) ?? 'any', + from: search.get('from') ?? '', + to: search.get('to') ?? '', + children: search.get('children') === '1' + }; +} + +/** Writes non-default values only, so a plain `/runs` stays clean. */ +export function writeParams(url: URL, params: ListParams): URL { + const next = new URL(url); + const set = (key: string, value: string, fallback: string) => { + if (value && value !== fallback) next.searchParams.set(key, value); + else next.searchParams.delete(key); + }; + set('view', params.view, 'list'); + set('tab', params.tab, 'all'); + set('q', params.q.trim(), ''); + set('range', params.range, 'any'); + set('from', params.range === 'custom' ? params.from : '', ''); + set('to', params.range === 'custom' ? params.to : '', ''); + set('children', params.children ? '1' : '', ''); + return next; +} + +const HOURS: Record, number> = { '24h': 24, '7d': 168, '30d': 720 }; + +/** The server query for the current parameters (no cursor). */ +export function toQuery(params: ListParams, now: number, tab: RunTab = params.tab): RunRowQuery { + let since: string | undefined; + let until: string | undefined; + if (params.range === 'custom') { + if (params.from) since = new Date(`${params.from}T00:00:00`).toISOString(); + if (params.to) until = new Date(Date.parse(`${params.to}T00:00:00`) + 86_400_000).toISOString(); + } else if (params.range !== 'any') { + since = new Date(now - HOURS[params.range] * 3_600_000).toISOString(); + } + return { tab, filter: toFilter(params.q), since, until, includeChildren: params.children }; +} diff --git a/ui/src/routes/runs/utils/status-line.ts b/ui/src/routes/runs/utils/status-line.ts new file mode 100644 index 0000000000..96512b8694 --- /dev/null +++ b/ui/src/routes/runs/utils/status-line.ts @@ -0,0 +1,60 @@ +// The plain second line under a run's status: what is happening, in words. + +import type { RunRow } from '$lib/api'; +import { formatAgo, formatStamp } from '$lib/format/time'; +import { formatSeconds, waitTechnical, waitText } from '$lib/operate/labels'; +import type { StatusLine } from '../types'; + +const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`; + +function outcomeCounts(row: RunRow): string { + const c = row.run.nodeCounts; + const parts = [c.failed ? `${c.failed} failed` : null, c.skipped ? `${c.skipped} skipped` : null].filter(Boolean); + return parts.join(', '); +} + +export function statusLine(row: RunRow, now: number): StatusLine { + const { run, progress } = row; + const nodes = progress.total ? `${progress.done}/${progress.total} nodes` : null; + if (row.cancelRequestedAt && (run.status === 'running' || run.status === 'launched' || run.status === 'unresponsive' || run.status === 'queued')) { + return { text: `Cancelling… requested ${formatAgo(row.cancelRequestedAt, now)}`, tone: 'warn', title: `Cancel requested ${formatStamp(row.cancelRequestedAt)}` }; + } + switch (run.status) { + case 'queued': + if (!row.waits.length) return { text: 'Starting on the next scheduler pass', tone: 'muted' }; + return { + text: waitText(row.waits[0]) + (row.waits.length > 1 ? ` (+${row.waits.length - 1} more)` : ''), + tone: 'muted', + title: row.waits.map(waitTechnical).join('\n') + }; + case 'launched': + return { text: 'Starting…', tone: 'live', title: 'Container launched; waiting for it to report in' }; + case 'running': + return { text: nodes ?? 'Running', tone: 'live' }; + case 'unresponsive': { + const limit = formatSeconds(row.lostContactLimitSeconds); + return { + text: `Lost contact ${formatAgo(row.lastContactAt, now)}`, + tone: 'warn', + title: `No heartbeat since ${formatStamp(row.lastContactAt)}. Marked failed after ${limit} of silence; rerunning is safe then.` + }; + } + case 'success': + return { text: progress.total > 1 ? `All ${progress.total} nodes succeeded` : progress.total ? '1 node succeeded' : 'Succeeded', tone: 'muted' }; + case 'warn': + return { text: `Succeeded with ${plural(run.nodeCounts.warn, 'warning')}`, tone: 'warn' }; + case 'partial': + return { text: `Partial · ${outcomeCounts(row)}`, tone: 'error', title: run.statusReason ?? undefined }; + case 'failed': + if (!run.startedAt) return { text: "Couldn't start", tone: 'error', title: run.statusReason ?? undefined }; + return { text: outcomeCounts(row) || 'Failed', tone: 'error', title: run.statusReason ?? undefined }; + case 'presumed_failed': + return { + text: `Lost contact — marked failed after ${formatSeconds(row.lostContactLimitSeconds).replace(/m 00s$/, ' min')}`, + tone: 'error', + title: run.statusReason ?? undefined + }; + case 'cancelled': + return { text: run.statusReason?.startsWith('Cancelled') ? run.statusReason : 'Cancelled', tone: 'muted' }; + } +} diff --git a/ui/src/routes/settings/+page.svelte b/ui/src/routes/settings/+page.svelte new file mode 100644 index 0000000000..70c94682f2 --- /dev/null +++ b/ui/src/routes/settings/+page.svelte @@ -0,0 +1,157 @@ + + + + +
+
+
+ + {#if settings.error} +

Couldn't load: {settings.error}

+ {:else if s} + + {:else} +

Loading…

+ {/if} +
+ + + + + {#each s?.retention ?? [] as r (r.name)} + + + + + {/each} + +
+ {r.name} +
{r.description}
+
{r.value}
+
+
+ +
+ + {#if app.user} + + + + {#each PERMISSIONS_BY_RISK as group (group.risk)} + {#each group.actions as c, i (c)} + + + + + + {/each} + {/each} + +
{i === 0 ? RISK_LABEL[group.risk] : ''}{app.can(c) ? '✓' : '—'}
+

On the command line, sign in with sqb login: it opens this browser and keeps a short-lived session, so you don't need an API token.

+ {/if} +
+ + + +
+
+
+ + diff --git a/ui/src/routes/triggers/+page.svelte b/ui/src/routes/triggers/+page.svelte new file mode 100644 index 0000000000..101e9ac135 --- /dev/null +++ b/ui/src/routes/triggers/+page.svelte @@ -0,0 +1,302 @@ + + + + + + + + {#snippet filters()} + t.type === 'schedule').length }, + { value: 'sensor', label: 'Sensors', count: all.filter((t) => t.type === 'sensor').length } + ]} + /> + ({ value: k, label: triggerKindLabel[k], count: all.filter((t) => t.kind === k).length }))} /> + + {/snippet} + {#snippet actions()} + {#if selected.size} + {selected.size} selected + + + + {/if} + {#if actionError}{actionError}{/if} + {/snippet} + + +
+ + + + + + + + + + + + + + + {#each filtered as t (t.id)} + goto(`/triggers/${t.id}`)}> + + + + + + + + + + + + {:else} + + {/each} + +
+ + NameKindRunsStatusNext runLast time dueLast run
e.stopPropagation()} title={canToggle ? undefined : "You don't have permission to pause or turn on schedules and sensors (trigger.toggle)"}> + toggleSel(t.id)} class="sb-check" aria-label="Select {t.name}" disabled={!canToggle} /> + +
+ + + + + {#if t.warning}{/if} +
+
+ + {#if t.kind === 'cron_schedule'}{:else if t.kind === 'calendar_schedule'}{:else if t.kind === 'custom_sensor'}{:else}{/if} + {triggerKindLabel[t.kind]} + + + {#if t.jobKey} e.stopPropagation()}>{t.jobKey}{:else}Runs its own code{/if} + + {t.state === 'running' ? 'On' : 'Paused'} + {#if t.state !== t.defaultState} +
+ code says {t.defaultState === 'running' ? 'on' : 'paused'} · + {#if canToggle} + + {:else} + reset + {/if} +
+ {:else} +
as in the code
+ {/if} +
{nextDue(t)} + {#if t.lastTick} + {tickOutcomeText[t.lastTick.outcome]} + {#if t.lastTick.coveredByRunId}into run e.stopPropagation()}>{shortRunId(t.lastTick.coveredByRunId)}{/if} +
{formatAgo(t.lastTick.scheduledFor, app.now)}
+ {:else} + {t.type === 'sensor' ? 'See its checks' : 'Not due yet'} + {/if} +
+ {#if t.lastRun} + e.stopPropagation()}> + + {#if t.lastRun.startedAt}{/if} + + {:else} + — + {/if} +
+
+ + diff --git a/ui/src/routes/triggers/[id]/+page.svelte b/ui/src/routes/triggers/[id]/+page.svelte new file mode 100644 index 0000000000..6ef64a2f6c --- /dev/null +++ b/ui/src/routes/triggers/[id]/+page.svelte @@ -0,0 +1,10 @@ + + +{#key id} + +{/key} diff --git a/ui/src/routes/triggers/[id]/sensor-panel.svelte b/ui/src/routes/triggers/[id]/sensor-panel.svelte new file mode 100644 index 0000000000..b85ec51d57 --- /dev/null +++ b/ui/src/routes/triggers/[id]/sensor-panel.svelte @@ -0,0 +1,243 @@ + + +
+

When it starts a run

+

{sensor.condition}

+ {#each sensor.nodeConditions as c (c)}

and {c}

{/each} +

+ {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.

+ {/if} + {#if changed} +

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

+ {/if} +
+ +
+

Where it's up to

+
+ {sensor.cursor ?? '(the start: nothing handled yet)'} + {#if sensor.cursorUpdatedAt}updated {formatAgo(sensor.cursorUpdatedAt, app.now)}{/if} + (confirmReset = !confirmReset)}>Start over +
+ {#if confirmReset} +
+

The next check looks at everything again from the start. Anything it already started a run for (same run key) doesn't start a second run.

+ +
+ Start over + +
+
+ {/if} + {#if error}

{error}

{/if} +
+ +
+

Checks · {trigger.evaluations.length}

+ + + + {#each trigger.evaluations.slice(0, shown) as e (e.id)} + + + + + + + + + {:else} + + {/each} + +
#WhenResultPositionRuns startedDetail
{e.sequence}{formatAgo(e.requestedAt, app.now)}{outcomeText[e.outcome] ?? e.outcome}{e.cursorBefore ?? '∅'}{#if e.cursorAfter && e.cursorAfter !== e.cursorBefore} → {e.cursorAfter}{/if}{e.runRequests} + {e.error ?? e.skipReason ?? ''} + {#if e.definitionFingerprint !== sensor.currentFingerprint}older code{/if} +
It hasn't checked yet.
+ {#if trigger.evaluations.length > shown}{/if} +
+ +
+

Runs it started · {trigger.runRequests.length}

+ {#if trigger.runRequests.length} + + + + {#each trigger.runRequests as r (r.runKey)} + + + + + + + {/each} + +
Run keyRunStatusRequested
{r.runKey}{shortRunId(r.runId)}{runStatusLabel[r.runStatus]}{formatAgo(r.requestedAt, app.now)}
+

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} +
+ + diff --git a/ui/src/routes/triggers/[id]/tick-log.svelte b/ui/src/routes/triggers/[id]/tick-log.svelte new file mode 100644 index 0000000000..b89a836d7f --- /dev/null +++ b/ui/src/routes/triggers/[id]/tick-log.svelte @@ -0,0 +1,119 @@ + + +
+ ({ value: o, label: tickOutcomeText[o], count: ticks.filter((t) => t.outcome === o).length })), + { value: 'late' as const, label: 'Late, or made up after downtime', count: ticks.filter((t) => t.lateBySeconds > LATE_AFTER_SECONDS || t.catchUp).length } + ]} + /> + {filtered.length} times due +
+ + + + + + {#each filtered.slice(0, shown) as t (t.id)} + + + + + + + + + {:else} + + {/each} + +
DueHandledWhat happenedRunLate byWhy
{formatStamp(t.scheduledFor)}{t.evaluatedAt ? formatStamp(t.evaluatedAt) : 'not yet'}{tickOutcomeText[t.outcome]}{#if t.catchUp}made up{/if} + {#if t.runId}{shortRunId(t.runId)} + {:else if t.coveredByRunId}Merged into run {shortRunId(t.coveredByRunId)} (already waiting) + {:else}—{/if} + {t.lateBySeconds > LATE_AFTER_SECONDS ? formatSeconds(t.lateBySeconds) : '—'}{t.outcome === 'covered' ? '' : (t.reason ?? '')}
{ticks.length ? 'Nothing matches the filter.' : 'It hasn’t been due yet.'}
+{#if filtered.length > shown} + +{/if} + + diff --git a/ui/src/routes/triggers/[id]/trigger-view.svelte b/ui/src/routes/triggers/[id]/trigger-view.svelte new file mode 100644 index 0000000000..a0026c7959 --- /dev/null +++ b/ui/src/routes/triggers/[id]/trigger-view.svelte @@ -0,0 +1,278 @@ + + + + {#if t} + + {#if t.state !== t.defaultState} + Set back to the code ({t.defaultState === 'running' ? 'on' : 'paused'}) + {/if} + {#if t.state === 'running'} + setState('paused')}>Pause + {:else} + setState('running')}>Turn on + {/if} + {/if} + + +
+
+ ← Schedules and sensors + {t?.name ?? id} + {#if t} + {t.state === 'running' ? 'on' : 'paused'} + {t.state === t.defaultState ? 'as in the code' : `switched here; the code says ${t.defaultState === 'running' ? 'on' : 'paused'}`} + · {triggerKindLabel[t.kind]} · {t.cadence} + {/if} +
+ {#if res.error}
Couldn't load this: {res.error}
{:else if res.data === null}
There's no schedule or sensor {id} in the version deployed to {app.location?.name}.
{/if} + {#if error}
{error}
{/if} + + {#if t} + {#if t.warning || t.calendar?.low} +
{t.warning}
+ {/if} +
+
+ {#if isSchedule} +
+

Times due · {t.ticks.length}

+ +
+ {:else} + + {/if} +
+

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

+ +
+
+ + +
+ {/if} +
+ + diff --git a/ui/static/favicon.png b/ui/static/favicon.png new file mode 100644 index 0000000000..97e37653b5 Binary files /dev/null and b/ui/static/favicon.png differ diff --git a/ui/static/logo-light.png b/ui/static/logo-light.png new file mode 100644 index 0000000000..521c6a9ca2 Binary files /dev/null and b/ui/static/logo-light.png differ diff --git a/ui/static/logo.png b/ui/static/logo.png new file mode 100644 index 0000000000..2968ae0665 Binary files /dev/null and b/ui/static/logo.png differ diff --git a/ui/static/robots.txt b/ui/static/robots.txt new file mode 100644 index 0000000000..b6dd6670cb --- /dev/null +++ b/ui/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/ui/svelte.config.js b/ui/svelte.config.js new file mode 100644 index 0000000000..9f142e7774 --- /dev/null +++ b/ui/svelte.config.js @@ -0,0 +1,23 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) + }, + kit: { + // Wire types come from the client generated from the server's OpenAPI document. + alias: { $control: '../clients/typescript/src' }, + // SPA mode: static build with client-side routing. There is no Node + // server in production; the SQLBuild server serves the built assets. + adapter: adapter({ + fallback: 'index.html', + pages: 'build', + assets: 'build', + precompress: false + }) + } +}; + +export default config; diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000000..2c2ed3c4d8 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000000..0d1f947111 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,27 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { loadEnv } from 'vite'; +import { defineConfig } from 'vitest/config'; + +// `SQB_SERVER_URL` proxies `/api` to a running `sqb server` during development. +// The server only accepts browser sessions from its configured HTTPS origin, so +// `SQB_SERVER_ORIGIN` names that origin and the proxy presents it upstream. +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), 'SQB_'); + const target = env.SQB_SERVER_URL; + const origin = env.SQB_SERVER_ORIGIN; + return { + plugins: [tailwindcss(), sveltekit()], + test: { include: ['src/**/*.test.ts'], environment: 'node' }, + server: target + ? { + proxy: { + '/api': { + target, + headers: origin ? { host: new URL(origin).host, origin } : undefined + } + } + } + : undefined + }; +});