diff --git a/benchmarks/duckshop/edge_cases/orchestration/maintenance_history.json b/benchmarks/duckshop/edge_cases/orchestration/maintenance_history.json index 860b31bec6..af7c6e61e1 100644 --- a/benchmarks/duckshop/edge_cases/orchestration/maintenance_history.json +++ b/benchmarks/duckshop/edge_cases/orchestration/maintenance_history.json @@ -40,14 +40,16 @@ "destination_model": "orders", "event_type": "view_created", "created_at": "2026-02-03T04:05:07Z", - "column_aliases": "{\"order_id\":\"legacy_order_id\"}" + "column_aliases": "{\"order_id\":\"legacy_order_id\"}", + "grants_copied": "[\"GRANT SELECT ON main.orders_legacy TO fulfillment_readers\", \"GRANT SELECT ON main.orders_legacy TO support_analysts\"]" }, { "event_id": "view-customers-1", "migration_event_id": "move-customers-1", "destination_model": "customers", "event_type": "required", - "created_at": "2026-02-02T04:05:06Z" + "created_at": "2026-02-02T04:05:06Z", + "grants_copied": "GRANT SELECT ON main.customers_legacy TO support_analysts" }, { "event_id": "view-orders-old-1", diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 3efe664d23..32140c3e3f 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -1641,7 +1641,38 @@ export interface components { janitor_events: components["schemas"]["WarehouseJanitorRecord"][]; migrations: components["schemas"]["WarehouseMigrationRecord"][]; /** @description Every step recorded for the listed moves, oldest first; same-time steps are not in step order. */ - old_name_views: components["schemas"]["WarehouseOldNameRecord"][]; + old_name_views: components["schemas"]["MaintenanceOldNameView"][]; + }; + /** @description One old-name view step as people read it: copied grants are counted, never returned as statements. */ + MaintenanceOldNameView: { + archive_name?: string | null; + column_aliases?: string | null; + /** Format: date-time */ + created_at: string; + destination_model: string; + drop_reason?: string | null; + event_id: string; + event_type: string; + /** Format: date-time */ + expires_at?: string | null; + /** + * Format: int64 + * @description How many grants were copied onto the view; null until it exists or when the stored list is unreadable. + */ + grants_copied?: number | null; + migration_event_id: string; + new_database?: string | null; + new_name: string; + new_schema: string; + old_database?: string | null; + old_name: string; + old_schema?: string | null; + run_id: string; + state_database?: string | null; + state_schema: string; + target_name?: string | null; + view_retention?: string | null; + view_sql?: string | null; }; /** @description One bounded page of one state table from the registered reader of a published generation. */ MetadataBatch: { @@ -2560,33 +2591,6 @@ export interface components { /** Format: date-time */ observed_at: string; }; - /** @description Preserve nullable compatibility-view facts without projecting live warehouse existence. */ - WarehouseOldNameRecord: { - archive_name?: string | null; - column_aliases?: string | null; - /** Format: date-time */ - created_at: string; - destination_model: string; - drop_reason?: string | null; - event_id: string; - event_type: string; - /** Format: date-time */ - expires_at?: string | null; - grants_copied?: string | null; - migration_event_id: string; - new_database?: string | null; - new_name: string; - new_schema: string; - old_database?: string | null; - old_name: string; - old_schema?: string | null; - run_id: string; - state_database?: string | null; - state_schema: string; - target_name?: string | null; - view_retention?: string | null; - view_sql?: string | null; - }; /** @description Physical namespace of SQLBuild's source-freshness state table, never a source query. */ WarehouseStateLocation: { database?: string | null; diff --git a/crates/sqlbuild-orchestration/src/warehouse_history/_helpers/maintenance.rs b/crates/sqlbuild-orchestration/src/warehouse_history/_helpers/maintenance.rs index 6c52ab9094..8e2cfaa832 100644 --- a/crates/sqlbuild-orchestration/src/warehouse_history/_helpers/maintenance.rs +++ b/crates/sqlbuild-orchestration/src/warehouse_history/_helpers/maintenance.rs @@ -2,12 +2,13 @@ use serde::Deserialize; use serde::de::DeserializeOwned; +use sqlbuild_state::events::main::decoded_grants::decoded_grants; use uuid::Uuid; use crate::store::errors::ControlError; use crate::store::main::query_records::query_records; use crate::store::models::{ControlTransaction, Parameter}; -use crate::warehouse_history::models::MaintenanceHistory; +use crate::warehouse_history::models::{MaintenanceHistory, MaintenanceOldNameView}; use crate::warehouse_metadata::models::{ WarehouseJanitorRecord, WarehouseMigrationRecord, WarehouseOldNameRecord, }; @@ -55,7 +56,7 @@ pub(crate) async fn maintenance_history( } }; let mut transaction = transaction; - let mut old_name_views: Vec = Vec::new(); + let mut stored_views: Vec = Vec::new(); if !migrations.is_empty() { let mut parameters = scope.clone(); parameters.extend( @@ -64,7 +65,7 @@ pub(crate) async fn maintenance_history( .map(|migration| Parameter::Text(migration.event_id.clone())), ); let placeholders = Parameter::placeholders(3..=parameters.len()); - (transaction, old_name_views) = records(transaction, + (transaction, stored_views) = records(transaction, &format!("SELECT record_json AS record FROM sqb_warehouse_old_name_views WHERE location_id=$1 AND target=$2 \ AND migration_event_id IN ({placeholders}) ORDER BY observed_at,event_id"), parameters).await?; @@ -80,8 +81,37 @@ pub(crate) async fn maintenance_history( transaction, MaintenanceHistory { migrations, - old_name_views, + old_name_views: stored_views.into_iter().map(old_name_view).collect(), janitor_events, }, )) } + +/// The stored step with its grant statements reduced to their number, read the way local `sqb` reads them. +fn old_name_view(record: WarehouseOldNameRecord) -> MaintenanceOldNameView { + MaintenanceOldNameView { + grants_copied: decoded_grants(record.grants_copied.as_deref()) + .map(|statements| statements.len() as u64), + state_database: record.state_database, + state_schema: record.state_schema, + event_id: record.event_id, + target_name: record.target_name, + event_type: record.event_type, + migration_event_id: record.migration_event_id, + destination_model: record.destination_model, + old_database: record.old_database, + old_schema: record.old_schema, + old_name: record.old_name, + new_database: record.new_database, + new_schema: record.new_schema, + new_name: record.new_name, + view_retention: record.view_retention, + archive_name: record.archive_name, + column_aliases: record.column_aliases, + view_sql: record.view_sql, + expires_at: record.expires_at, + drop_reason: record.drop_reason, + run_id: record.run_id, + created_at: record.created_at, + } +} diff --git a/crates/sqlbuild-orchestration/src/warehouse_history/models.rs b/crates/sqlbuild-orchestration/src/warehouse_history/models.rs index 57c26430cb..70aee07613 100644 --- a/crates/sqlbuild-orchestration/src/warehouse_history/models.rs +++ b/crates/sqlbuild-orchestration/src/warehouse_history/models.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::warehouse_metadata::models::{ - WarehouseAuditRecord, WarehouseJanitorRecord, WarehouseMigrationRecord, WarehouseOldNameRecord, + WarehouseAuditRecord, WarehouseJanitorRecord, WarehouseMigrationRecord, }; /// One stored check result. Its SQL, error texts and evidence rows need their own permissions. @@ -96,12 +96,40 @@ pub struct CheckEvidence { pub truncated: Option, } +/// One old-name view step as people read it: copied grants are counted, never returned as statements. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct MaintenanceOldNameView { + pub state_database: Option, + pub state_schema: String, + pub event_id: String, + pub target_name: Option, + pub event_type: String, + pub migration_event_id: String, + pub destination_model: String, + pub old_database: Option, + pub old_schema: Option, + pub old_name: String, + pub new_database: Option, + pub new_schema: String, + pub new_name: String, + pub view_retention: Option, + pub archive_name: Option, + pub column_aliases: Option, + /// How many grants were copied onto the view; null until it exists or when the stored list is unreadable. + pub grants_copied: Option, + pub view_sql: Option, + pub expires_at: Option>, + pub drop_reason: Option, + pub run_id: String, + pub created_at: DateTime, +} + /// Recorded moves, their old-name view steps and janitor cleanups, newest moves first. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] pub struct MaintenanceHistory { pub migrations: Vec, /// Every step recorded for the listed moves, oldest first; same-time steps are not in step order. - pub old_name_views: Vec, + pub old_name_views: Vec, /// Empty when the history is for one model; cleanups are not tied to models. pub janitor_events: Vec, } diff --git a/crates/sqlbuild-orchestration/tests/api/test_maintenance.rs b/crates/sqlbuild-orchestration/tests/api/test_maintenance.rs index c19db6b760..f395d47821 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_maintenance.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_maintenance.rs @@ -110,6 +110,32 @@ fn given_moves_views_and_cleanups_when_people_read_maintenance_then_each_model_s "another target's steps never appear" ); assert_eq!(ids(&all, "janitor_events"), ["cleanup-2", "cleanup-1"]); + let grants: Vec<(&str, &Value)> = all["old_name_views"] + .as_array() + .expect("views") + .iter() + .map(|view| { + ( + view["event_id"].as_str().expect("id"), + &view["grants_copied"], + ) + }) + .collect(); + assert_eq!( + grants, + [ + ("view-orders-old-1", &Value::Null), + ("view-customers-1", &Value::Null), + ("view-orders-1", &Value::Null), + ("view-orders-2", &json!(2)) + ], + "copied grants are counted, and unreadable stored text reads as unknown" + ); + let body = all.to_string(); + assert!( + !body.contains("GRANT") && !body.contains("support_analysts"), + "no grant statement or grantee reaches people: {body}" + ); assert_eq!(all["migrations"][0]["origin_model"], Value::Null); assert_eq!(all["migrations"][0]["discovery"], "automatic"); diff --git a/ui/src/lib/api/http-client.test.ts b/ui/src/lib/api/http-client.test.ts index 1145cff64a..1368d6c0c3 100644 --- a/ui/src/lib/api/http-client.test.ts +++ b/ui/src/lib/api/http-client.test.ts @@ -354,6 +354,120 @@ describe('overview', () => { }); }); +describe('locations', () => { + const access = (target: string) => ({ location_id: '0191f0aa-orders', name: 'Orders', target, actions: [] }); + const report = (session: string, secondsAgo: number) => ({ + received_at: new Date(Date.now() - secondsAgo * 1000).toISOString(), + report: { event_id: `e-${session}-${secondsAgo}`, session_id: session, target: 'development', health: { kind: 'heartbeat' } } + }); + + /** Orders development is readable and orders preview refuses its health read, as for a partial viewer. */ + function server() { + const paths: string[] = []; + vi.stubGlobal('document', { cookie: '' }); + vi.stubGlobal( + 'fetch', + vi.fn(async (request: Request) => { + const path = new URL(request.url).pathname; + paths.push(path); + if (path.endsWith('/bootstrap')) return Response.json({ login_mode: 'password', access: { name: 'ada', principal_id: 'p', actions: [], locations: [access('development'), access('preview')] } }); + if (path.endsWith('/platform/problems')) + return Response.json({ + scheduler_leader: true, + scheduler_lease_expires_at: '2026-11-09T10:00:30Z', + broken_locations: [{ location_id: '0191f0aa-orders', location_name: 'Orders', target: 'development', last_seen_at: '2026-11-09T09:00:00Z' }], + overdue_ticks: [] + }); + if (path.includes('/targets/preview/')) return Response.json({ error: 'refused' }, { status: 403 }); + if (path.endsWith('/health')) return Response.json([report('s-2', 10), report('s-1', 400), report('s-2', 20)]); + return Response.json([]); + }) + ); + return { paths, client: createHttpClient({ baseUrl: 'http://ui.test', onUnauthorized: () => {} }) }; + } + + it('opens a readable location with one process per session', async () => { + const detail = await server().client.getLocation('0191f0aa-orders:development'); + expect(detail?.instances.map((i) => [i.id, i.state])).toEqual([ + ['s-2', 'live'], + ['s-1', 'stale'] + ]); + }); + + it('fails the page with the refusal when the person may not read the target health', async () => { + await expect(server().client.getLocation('0191f0aa-orders:preview')).rejects.toMatchObject({ status: 403 }); + }); + + it('is null for a location the person is not given, without reading its health', async () => { + const { paths, client } = server(); + expect(await client.getLocation('0191f0aa-orders:production')).toBeNull(); + expect(paths.some((p) => p.endsWith('/health'))).toBe(false); + }); + + it('counts open problems per location target', async () => { + const listed = await server().client.listLocations(); + expect(listed.map((l) => [l.id, l.activeProblems, l.health])).toEqual([ + ['0191f0aa-orders:development', 1, 'healthy'], + ['0191f0aa-orders:preview', 0, null] + ]); + }); +}); + +describe('launchJob', () => { + function server(answer: Response) { + const posts: unknown[] = []; + vi.stubGlobal('document', { cookie: 'sqb-csrf=token-1' }); + vi.stubGlobal( + 'fetch', + vi.fn(async (request: Request) => { + if (request.method === 'POST') { + posts.push(await request.json()); + return answer.clone(); + } + return Response.json([]); + }) + ); + return { posts, client: createHttpClient({ baseUrl: 'http://ui.test', onUnauthorized: () => {} }) }; + } + + it('sends a backfill window with the job run request', async () => { + const { posts, client } = server(Response.json({ error: 'stop here' }, { status: 409 })); + await expect(client.launchJob(ORDERS, 'daily', { window: { type: 'timestamp', start: '1200', end: '1500' } })).rejects.toThrow(); + expect(posts[0]).toMatchObject({ job_key: 'daily', target: 'development', window: { kind: 'integer', start: 1200, end: 1500 } }); + }); + + it('leaves the window out of an ordinary run', async () => { + const { posts, client } = server(Response.json({ error: 'stop here' }, { status: 409 })); + await expect(client.launchJob(ORDERS, 'daily')).rejects.toThrow(); + expect(posts[0]).not.toHaveProperty('window'); + }); + + it("shows a plain-text refusal of a body the server couldn't read", async () => { + const text = 'Failed to deserialize the JSON body into the target type: window: input contains invalid characters'; + const { client } = server(new Response(text, { status: 422, headers: { 'content-type': 'text/plain; charset=utf-8' } })); + await expect(client.launchJob(ORDERS, 'daily', { window: { type: 'timestamp', start: '2026-10-01', end: '2026-10-31T00:00:00Z' } })).rejects.toMatchObject({ status: 422, message: text }); + }); + + it('shows the status, not an HTML error page from something in front of the server', async () => { + const { client } = server(new Response('Bad gateway', { status: 502, statusText: 'Bad Gateway', headers: { 'content-type': 'text/html' } })); + await expect(client.launchJob(ORDERS, 'daily')).rejects.toMatchObject({ status: 502, message: '502 Bad Gateway' }); + }); + + it('shows only the status for a plain-text server failure, which can name internal types', async () => { + const text = 'Missing request extension: Extension of type `example::AuditContext` was not found.'; + const { client } = server(new Response(text, { status: 500, statusText: 'Internal Server Error', headers: { 'content-type': 'text/plain; charset=utf-8' } })); + await expect(client.launchJob(ORDERS, 'daily')).rejects.toMatchObject({ status: 500, message: '500 Internal Server Error' }); + }); + + it("shows the server's refusal of a window as it comes", async () => { + const { client } = server(Response.json({ error: 'backfill window start must not follow end' }, { status: 400 })); + await expect(client.launchJob(ORDERS, 'daily', { window: { type: 'timestamp', start: '20', end: '10' } })).rejects.toMatchObject({ + status: 400, + message: expect.stringContaining('backfill window start must not follow end') + }); + }); +}); + describe('administration writes', () => { const ada = { principal_id: 'p-ada', name: 'ada', created_at: '2026-11-01T00:00:00Z', disabled_at: null, has_password: true, groups: [], active_sessions: 2, last_signed_in_at: null }; diff --git a/ui/src/lib/api/http-client.ts b/ui/src/lib/api/http-client.ts index 4d6d125f21..f7aec54ce7 100644 --- a/ui/src/lib/api/http-client.ts +++ b/ui/src/lib/api/http-client.ts @@ -5,19 +5,19 @@ import { checkDetailOf, toCheck, toCheckEvidence, toCheckSql } from './http-chec import { findNode, nodeDetailOf, toCatalogNodes, nodeJobsOf, toNodeRuns, toColumnLineage } from './http-catalog'; import { auditMatches, toAuditEntry } from './http-audit'; import { toSnapshotChanges } from './http-changes'; -import { manualConnectionTest, toLocationSummary, warehouseSyncOf } from './http-locations'; +import { manualConnectionTest, toLocationDetail, toLocationSummary, warehouseSyncOf } from './http-locations'; import { toAccount, toRoleAssignments } from './http-people'; import { toMaintenanceHistory } from './http-maintenance'; import { overviewOf } from './http-overview'; -import { toPlatformEvents } from './http-problems'; +import { toPlatformEvents, toPlatformHealth } from './http-problems'; import { roleDetailOf, toRoleDefinition } from './http-roles'; -import { jobDetailOf, toJob, toTick, toTrigger, triggerDetailOf } from './http-jobs'; +import { jobDetailOf, runWindowOf, toJob, toTick, toTrigger, triggerDetailOf } from './http-jobs'; import { RUN_WINDOW, toAdhocCheck, runDetailOf, runRowPage, runsMatching, runTabCountsOf, toRunNode, toRuns, toRunEvent, toRunOutput, toRunStatements } from './http-runs'; import type { ChangeSubscription, ChangedChannel, ChangedSignal, ConnectionState } from './signals'; import type { AuthMode, Bootstrap, Capability, Location, PlatformEvent, Scope, WarehouseSync } from './types'; -import type { Account, AuditEntry, AuditQuery, ConnectionTest, LocationSummary, MaintenanceHistory, RoleAssignment, RoleAssignmentRequest, RoleDefinition, RoleDetail } from './types/platform'; +import type { Account, AuditEntry, AuditQuery, ConnectionTest, LocationDetail, LocationSummary, PlatformHealth, MaintenanceHistory, RoleAssignment, RoleAssignmentRequest, RoleDefinition, RoleDetail } from './types/platform'; import type { CatalogNode, CatalogNodeDetail, Check, CheckDetail, CheckEvidence, CheckSql, Overview, SnapshotChange, ColumnLineage } from './types/build'; -import type { AdhocRunCheck, AdhocRunRequest, Job, JobDetail, RerunMode, SnapshotNode, Run, RunDetail, RunListQuery, RunRowPage, RunRowQuery, RunTabCounts, Trigger, TriggerDetail, TriggerState, RunEventPage, RunOutputLine, RunStatement, StatementSql } from './types/operate'; +import type { AdhocRunCheck, AdhocRunRequest, Job, JobDetail, JobLaunchOptions, RerunMode, SnapshotNode, Run, RunDetail, RunListQuery, RunRowPage, RunRowQuery, RunTabCounts, Trigger, TriggerDetail, TriggerState, RunEventPage, RunOutputLine, RunStatement, StatementSql } from './types/operate'; type Wire = components['schemas']; @@ -62,7 +62,9 @@ const SUPPORTED: ReadonlySet = new Set([ 'disableAccount', 'enableAccount', 'listLocations', + 'getLocation', 'listPlatformEvents', + 'platformHealth', 'listRoles', 'getRole', 'getWarehouseSync', @@ -319,9 +321,11 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt const wire = jobs.find((job) => job.job_key === key); return wire ? jobDetailOf(toJob(wire, triggers, runs), wire) : null; }, - async launchJob(scope: Scope, key: string): Promise { + /** A window makes it a backfill, which the server also checks against `run.rewrite_history`. */ + async launchJob(scope: Scope, key: string, options?: JobLaunchOptions): Promise { const { locationId, target } = parseLocationKey(scope.locationId); - const body = { run_id: crypto.randomUUID(), location_id: locationId, target, job_key: key, priority: 0, reason: 'Started from the web UI' }; + const window = options?.window ? runWindowOf(options.window) : undefined; + const body = { run_id: crypto.randomUUID(), location_id: locationId, target, job_key: key, priority: 0, reason: window ? 'Backfill started from the web UI' : 'Started from the web UI', window }; const { error, response } = await http.POST('/api/control/runs', { body, params: { header: { 'x-sqb-csrf': csrfToken(document.cookie) } } }); inFlight.clear(); if (response.status === 401) onUnauthorized(); @@ -420,30 +424,27 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt if (!data) throw new ApiError(errorText(error, response), response.status); return toMaintenanceHistory(data); }, - async listPlatformEvents(): Promise { - const { data, error, response } = await http.GET('/api/control/platform/problems'); - if (response.status === 401) onUnauthorized(); - if (!data) throw new ApiError(errorText(error, response), response.status); - return toPlatformEvents(data, Date.now()); - }, + listPlatformEvents: async (): Promise => toPlatformEvents(await problems(), Date.now()), + platformHealth: async (): Promise => toPlatformHealth(await problems()), /** Each location the person sees, with health only where they may list the whole target. */ async listLocations(): 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); + const [locations, events] = await Promise.all([bootstrapLocations(), problems().then((p) => toPlatformEvents(p, Date.now()))]); return Promise.all( - toBootstrap(data).locations.map(async (location) => { - const { locationId, target } = parseLocationKey(location.id); - const path = { location_id: locationId, target }; - const [health, snapshots] = await Promise.all([ - http.GET('/api/control/locations/{location_id}/targets/{target}/health', { params: { path } }), - http.GET('/api/control/locations/{location_id}/targets/{target}/snapshots', { params: { path, query: { limit: 1 } } }) - ]); - if (health.response.status === 401 || snapshots.response.status === 401) onUnauthorized(); - return toLocationSummary(location, health.data ?? null, snapshots.data?.[0] ?? null, Date.now()); + locations.map(async (location) => { + const [health, snapshot] = await locationHealth(location.id); + const open = events.filter((e) => e.locationId === location.id).length; + return toLocationSummary(location, health.data ?? null, snapshot, open, Date.now()); }) ); }, + /** Null for a location the person can't see; a refused health read fails the page, as the target's listings do. */ + async getLocation(locationId: string): Promise { + const location = (await bootstrapLocations()).find((l) => l.id === locationId); + if (!location) return null; + const [health, snapshot] = await locationHealth(location.id); + if (!health.data) throw new ApiError(errorText(health.error, health.response), health.response.status); + return toLocationDetail(location, health.data, snapshot, Date.now()); + }, listAccounts: async (): Promise => (await people()).map(toAccount), listRoles: async (locationId: string): Promise => (await roleOverviews(locationId)).map(toRoleDefinition), async getRole(locationId: string, name: string): Promise { @@ -673,6 +674,32 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt return data; } + async function problems() { + const { data, error, response } = await http.GET('/api/control/platform/problems'); + if (response.status === 401) onUnauthorized(); + if (!data) throw new ApiError(errorText(error, response), response.status); + return data; + } + + async function bootstrapLocations() { + 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).locations; + } + + /** The target's health read, kept whole so a refusal can be shown or left out, and its newest snapshot. */ + async function locationHealth(locationKey: string) { + const { locationId, target } = parseLocationKey(locationKey); + const path = { location_id: locationId, target }; + const [health, snapshots] = await Promise.all([ + http.GET('/api/control/locations/{location_id}/targets/{target}/health', { params: { path } }), + http.GET('/api/control/locations/{location_id}/targets/{target}/snapshots', { params: { path, query: { limit: 1 } } }) + ]); + if (health.response.status === 401 || snapshots.response.status === 401) onUnauthorized(); + return [health, snapshots.data?.[0] ?? null] as const; + } + /** The newest result of every check, or one check's results newest first. */ async function checkResults(scope: Scope, bindingKey?: string) { const { locationId, target } = parseLocationKey(scope.locationId); @@ -753,7 +780,10 @@ export function createHttpClient({ baseUrl = '', onUnauthorized }: HttpClientOpt }) as unknown as ApiClient; } +/** The server's message: a JSON error body's text, or a 4xx plain-text body such as a request the route couldn't read; never an HTML page. */ function errorText(error: unknown, response: Response): string { + // Plain text comes from the server's request rejections; a 5xx one can name internal types, so it shows as the status. + if (typeof error === 'string' && error.trim() && response.status < 500 && response.headers.get('content-type')?.startsWith('text/plain')) return error.trim(); 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}`; } diff --git a/ui/src/lib/api/http-jobs.test.ts b/ui/src/lib/api/http-jobs.test.ts index de992dfd7d..246be981fc 100644 --- a/ui/src/lib/api/http-jobs.test.ts +++ b/ui/src/lib/api/http-jobs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { components } from '$control/schema.d.ts'; -import { cadenceOf, jobDetailOf, toJob, toTick, toTrigger, triggerDetailOf, triggerKindOf } from './http-jobs'; +import { cadenceOf, jobDetailOf, runWindowOf, toJob, toTick, toTrigger, triggerDetailOf, triggerKindOf } from './http-jobs'; import type { Run } from './types/operate'; type W = components['schemas']; @@ -69,3 +69,49 @@ describe('job mapping', () => { expect(toJob({ ...wire, job_key: 'nightly', kind: 'group' }, triggers, []).kind).toBe('group'); }); }); + +describe('runWindowOf', () => { + const typed = (start: string, end: string) => runWindowOf({ type: 'timestamp', start, end }); + + it('sends whole numbers on both ends as an integer window, negatives included', () => { + expect(typed(' -3 ', '11')).toEqual({ kind: 'integer', start: -3, end: 11 }); + }); + + it('sends anything else as typed for the server to validate, without reading zones or order', () => { + expect(typed('2026-10-31T00:00:00+02:00', '2026-10-01')).toEqual({ kind: 'timestamp', start: '2026-10-31T00:00:00+02:00', end: '2026-10-01' }); + expect(typed('1200', 'yesterday')).toEqual({ kind: 'timestamp', start: '1200', end: 'yesterday' }); + }); + + it('refuses a whole number JSON would round instead of sending another value', () => { + expect(() => typed('9007199254740993', '9007199254740995')).toThrow('exactly'); + }); +}); + +describe('schedule due times', () => { + // Stored out of order on purpose; one time is before the server's next due time. + const times = ['2026-11-10T09:00:00Z', '2026-11-06T09:00:00Z', '2026-11-07T09:00:00Z', '2026-11-08T09:00:00Z']; + const calendar = (nextDueAt: string | null) => ({ + ...trigger('t-cal', 'business_days', 'orders', { description: 'Business days', job_key: 'orders', schedule: { kind: 'calendar', times, refresh_at: '2026-11-08T00:00:00Z' } }), + next_due_at: nextDueAt + }); + const detailOf = (wire: W['TriggerRecord']) => triggerDetailOf(toTrigger(wire, [], null), wire, []); + + it('lists a calendar from the next due time on, in time order, with the last stored time and next refresh', () => { + const detail = detailOf(calendar('2026-11-07T09:00:00Z')); + expect(detail.upcoming).toEqual(['2026-11-07T09:00:00Z', '2026-11-08T09:00:00Z', '2026-11-10T09:00:00Z']); + expect(detail.calendar).toMatchObject({ storedUntil: '2026-11-10T09:00:00.000Z', nextRefreshAt: '2026-11-08T00:00:00Z', horizon: null, low: false }); + }); + + it('has no upcoming calendar times once the server has no next due time', () => { + expect(detailOf(calendar(null)).upcoming).toEqual([]); + }); + + it('shows only the next due time for cron, without working out later ones', () => { + expect(detailOf(daily).upcoming).toEqual(['2026-11-07T06:00:00Z']); + expect(detailOf(daily).calendar).toBeNull(); + }); + + it('has no due times for a sensor', () => { + expect(detailOf(onInventory).upcoming).toBeNull(); + }); +}); diff --git a/ui/src/lib/api/http-jobs.ts b/ui/src/lib/api/http-jobs.ts index af8de57e62..97d95e4f61 100644 --- a/ui/src/lib/api/http-jobs.ts +++ b/ui/src/lib/api/http-jobs.ts @@ -1,5 +1,6 @@ import type { components } from '$control/schema.d.ts'; -import type { Job, JobDetail, Run, RunRef, Tick, Trigger, TriggerDetail, TriggerKind } from './types/operate'; +import { ApiError } from './client'; +import type { CursorWindow, Job, JobDetail, Run, RunRef, Tick, Trigger, TriggerDetail, TriggerKind } from './types/operate'; type Wire = components['schemas']; @@ -78,16 +79,40 @@ export function toTrigger(wire: Wire['TriggerRecord'], runs: Run[], lastTick: Ti }; } +/** How many upcoming due times a calendar schedule lists. */ +const UPCOMING_LIMIT = 5; + +/** + * The server's next due time, and for calendars the stored times from it on, which the scheduler + * takes in order. Later cron times would need cron evaluation, which stays on the server. + */ +function upcomingOf(d: Wire['TriggerDeclaration'], nextDueAt: string | null): string[] | null { + if (!isSchedule(d)) return null; + if (d.schedule.kind === 'cron') return nextDueAt ? [nextDueAt] : []; + if (!nextDueAt) return []; + const next = Date.parse(nextDueAt); + return d.schedule.times.filter((t) => Date.parse(t) >= next).sort((a, b) => Date.parse(a) - Date.parse(b)).slice(0, UPCOMING_LIMIT); +} + /** A trigger's detail; parts with no server source are null and their widgets stay hidden. */ export function triggerDetailOf(trigger: Trigger, wire: Wire['TriggerRecord'], ticks: Tick[]): TriggerDetail { const d = wire.definition_json; + const calendar = isSchedule(d) && d.schedule.kind === 'calendar' ? d.schedule : null; + const stored = calendar ? calendar.times.map(Date.parse) : []; return { ...trigger, declaration: null, targets: null, catchUp: null, - upcoming: null, - calendar: null, + upcoming: upcomingOf(d, wire.next_due_at ?? null), + calendar: calendar && { + horizon: null, + storedUntil: stored.length ? new Date(Math.max(...stored)).toISOString() : null, + lastRefreshedAt: null, + nextRefreshAt: calendar.refresh_at, + lowHorizonWarningBelow: null, + low: false + }, sensor: isSchedule(d) ? null : { @@ -143,3 +168,16 @@ export function jobDetailOf(job: Job, wire: Wire['JobRecord']): JobDetail { cursorType: null }; } + +/** + * A backfill's bounds as typed: whole numbers on both ends make an integer window, anything else + * goes as timestamps for the server to validate. Only integers JSON would round are refused here. + */ +export function runWindowOf(window: CursorWindow): Wire['RunWindow'] { + const start = (window.start ?? '').trim(); + const end = (window.end ?? '').trim(); + if (![start, end].every((v) => /^-?\d+$/.test(v))) return { kind: 'timestamp', start, end }; + const [from, to] = [start, end].map(Number); + if (![from, to].every(Number.isSafeInteger)) throw new ApiError(`Whole numbers beyond ${Number.MAX_SAFE_INTEGER} can't be sent exactly`, 400); + return { kind: 'integer', start: from, end: to }; +} diff --git a/ui/src/lib/api/http-locations.test.ts b/ui/src/lib/api/http-locations.test.ts index 6d07abda32..5547d25018 100644 --- a/ui/src/lib/api/http-locations.test.ts +++ b/ui/src/lib/api/http-locations.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { connectionTestOf, healthOf, warehouseSyncOf } from './http-locations'; +import { connectionTestOf, healthOf, instancesOf, toLocationDetail, warehouseSyncOf } from './http-locations'; const now = Date.parse('2026-11-08T12:00:00Z'); const at = (secondsAgo: number) => new Date(now - secondsAgo * 1000).toISOString(); @@ -32,6 +32,30 @@ describe('location health', () => { }); }); +describe('location processes', () => { + it('lists each session once by its newest check-in, newest first, and stale after 90 seconds', () => { + const observations = [seen(200, { kind: 'heartbeat' }, 'old'), seen(30, { kind: 'heartbeat' }), seen(95, { kind: 'heartbeat' }, 'old'), seen(5, { kind: 'connection', generation: 2, healthy: true })]; + expect(instancesOf(observations, now).map((i) => [i.id, i.lastSeenAt, i.state])).toEqual([ + ['s-1', at(5), 'live'], + ['old', at(95), 'stale'] + ]); + }); +}); + +describe('toLocationDetail', () => { + const location = { id: 'loc:prod', name: 'orders', health: null, lastSeenAt: null, target: { name: 'prod', adapter: null, connectionSummary: null }, warehouseSync: { lastSyncedAt: null, lastError: null } }; + + it('shows a failed reload as its health, without inventing a failed deploy or error text', () => { + const detail = toLocationDetail(location, [seen(5, { kind: 'heartbeat' }), seen(20, { kind: 'compile_failed', generation: 3 }), seen(40, { kind: 'connection', generation: 2, healthy: true })], null, now); + expect([detail.health, detail.failedDeploy, detail.lastLoadError, detail.snapshot]).toEqual(['falling_back', null, null, null]); + }); + + it('is healthy again once a newer generation connects', () => { + const detail = toLocationDetail(location, [seen(5, { kind: 'connection', generation: 4, healthy: true }), seen(20, { kind: 'compile_failed', generation: 3 })], null, now); + expect([detail.health, detail.connectionTest?.outcome]).toEqual(['healthy', 'ok']); + }); +}); + describe('warehouseSyncOf', () => { const sync = (at: string, table: 'catalog' | 'statistics', failure: 'read' | 'delivery' | null) => ({ received_at: at, diff --git a/ui/src/lib/api/http-locations.ts b/ui/src/lib/api/http-locations.ts index cc674f3c05..b10cc818d8 100644 --- a/ui/src/lib/api/http-locations.ts +++ b/ui/src/lib/api/http-locations.ts @@ -1,6 +1,6 @@ import type { components } from '$control/schema.d.ts'; -import type { Location, LocationHealth, WarehouseSync } from './types'; -import type { ConnectionTest, LocationSummary } from './types/platform'; +import type { Location, LocationHealth, Snapshot, WarehouseSync } from './types'; +import type { ConnectionTest, LocationDetail, LocationInstance, LocationSummary } from './types/platform'; type Wire = components['schemas']; @@ -53,8 +53,28 @@ export function manualConnectionTest(record: Wire['LocationWorkRecord'] | null): }; } -/** `observations` is null when the person may not list the whole target. */ -export function toLocationSummary(location: Location, observations: Wire['LocationHealthObservation'][] | null, published: Wire['PublishedSnapshot'] | null, now: number): LocationSummary { +function snapshotOf(published: Wire['PublishedSnapshot'] | null): Snapshot | null { + return published + ? { + id: published.snapshot_id, + generation: published.generation, + gitRevision: published.git_revision ?? '', + gitDirty: published.git_dirty, + gitAuthor: published.git_author ?? '', + publishedAt: published.published_at, + sqbVersion: published.sqb_version + } + : null; +} + +/** `observations` is null when the person may not list the whole target; `activeProblems` counts the problems read's events. */ +export function toLocationSummary( + location: Location, + observations: Wire['LocationHealthObservation'][] | null, + published: Wire['PublishedSnapshot'] | null, + activeProblems: number, + now: number +): LocationSummary { const known = observations ? healthOf(observations, now) : { health: null, liveInstances: 0, lastSeenAt: null }; return { id: location.id, @@ -64,18 +84,39 @@ export function toLocationSummary(location: Location, observations: Wire['Locati brokenDeployPolicy: null, failedDeploy: null, connectionTest: observations ? connectionTestOf(observations) : null, - activeProblems: 0, - snapshot: published - ? { - id: published.snapshot_id, - generation: published.generation, - gitRevision: published.git_revision ?? '', - gitDirty: published.git_dirty, - gitAuthor: published.git_author ?? '', - publishedAt: published.published_at, - sqbVersion: published.sqb_version - } - : null + activeProblems, + snapshot: snapshotOf(published) + }; +} + +/** One process per reporting session, newest check-in first; the server keeps no image, commit or start time for them. */ +export function instancesOf(observations: Wire['LocationHealthObservation'][], now: number): LocationInstance[] { + const lastSeen = new Map(); + for (const o of observations) { + const seen = lastSeen.get(o.report.session_id); + if (!seen || o.received_at > seen) lastSeen.set(o.report.session_id, o.received_at); + } + return [...lastSeen] + .sort(([, a], [, b]) => b.localeCompare(a)) + .map(([id, lastSeenAt]) => ({ id, imageId: null, gitRevision: null, startedAt: null, lastSeenAt, state: now - Date.parse(lastSeenAt) <= LIVE_WINDOW_MS ? 'live' : 'stale' })); +} + +/** A location's page from its health observations and newest snapshot; locations never send compile error text, so a failed reload shows only as its health. */ +export function toLocationDetail(location: Location, observations: Wire['LocationHealthObservation'][], published: Wire['PublishedSnapshot'] | null, now: number): LocationDetail { + const { health } = healthOf(observations, now); + return { + id: location.id, + name: location.name, + health, + createdAt: null, + brokenDeployPolicy: null, + failedDeploy: null, + lastLoadError: null, + instances: instancesOf(observations, now), + target: location.target, + snapshot: snapshotOf(published), + warehouseConnection: null, + connectionTest: connectionTestOf(observations) }; } diff --git a/ui/src/lib/api/http-maintenance.test.ts b/ui/src/lib/api/http-maintenance.test.ts index c94783fd59..01cb4533c4 100644 --- a/ui/src/lib/api/http-maintenance.test.ts +++ b/ui/src/lib/api/http-maintenance.test.ts @@ -25,7 +25,7 @@ function migration(eventId: string, originSchema: string | null, createdAt: stri }; } -function step(migrationEventId: string, eventType: string, createdAt: string, facts: Partial = {}): W['WarehouseOldNameRecord'] { +function step(migrationEventId: string, eventType: string, createdAt: string, facts: Partial = {}): W['MaintenanceOldNameView'] { return { state_schema: 'sqlbuild_state', event_id: `${migrationEventId}-${eventType}`, @@ -49,7 +49,7 @@ describe('maintenance history mapping', () => { old_name_views: [ step('orders', 'required', '2026-11-07T10:00:00Z', { view_retention: '30d' }), step('customers', 'required', '2026-11-08T10:00:00Z', { view_retention: 'false' }), - step('orders', 'view_created', '2026-11-07T10:01:00Z', { expires_at: '2026-12-07T10:01:00Z', column_aliases: '{"amount":"total","id":"order_id"}', grants_copied: '["GRANT SELECT"]' }), + step('orders', 'view_created', '2026-11-07T10:01:00Z', { expires_at: '2026-12-07T10:01:00Z', column_aliases: '{"amount":"total","id":"order_id"}', grants_copied: 3 }), step('orders', 'view_dropped', '2026-11-08T11:00:00Z', { drop_reason: 'Removed by hand' }) ], janitor_events: [] @@ -64,7 +64,7 @@ describe('maintenance history mapping', () => { it('takes each fact from the step that recorded it', () => { const orders = history.oldNameViews[1]; - expect([orders.retention, orders.expiresAt, orders.dropReason, orders.grantsCopied]).toEqual(['30d', '2026-12-07T10:01:00Z', 'Removed by hand', 1]); + expect([orders.retention, orders.expiresAt, orders.dropReason, orders.grantsCopied]).toEqual(['30d', '2026-12-07T10:01:00Z', 'Removed by hand', 3]); expect(orders.columnAliases).toEqual([ { old: 'amount', new: 'total' }, { old: 'id', new: 'order_id' } diff --git a/ui/src/lib/api/http-maintenance.ts b/ui/src/lib/api/http-maintenance.ts index 8a82971247..300ab4835c 100644 --- a/ui/src/lib/api/http-maintenance.ts +++ b/ui/src/lib/api/http-maintenance.ts @@ -25,19 +25,16 @@ function columnAliases(text: string | null | undefined): OldNameView['columnAlia return text ? Object.entries(JSON.parse(text) as Record).map(([old, renamed]) => ({ old, new: renamed })) : []; } -/** Stored as a JSON list of the grant statements copied to the view. */ -const grantCount = (text: string | null | undefined): number | null => (text ? (JSON.parse(text) as string[]).length : null); - /** One view per move, from its append-only steps (oldest first); each fact comes from the newest step that records it. */ -function toOldNameViews(rows: Wire['WarehouseOldNameRecord'][], migrationOrder: string[]): OldNameView[] { - const steps = new Map(); +function toOldNameViews(rows: Wire['MaintenanceOldNameView'][], migrationOrder: string[]): OldNameView[] { + const steps = new Map(); for (const row of rows) steps.set(row.migration_event_id, [...(steps.get(row.migration_event_id) ?? []), row]); const rank = (id: string) => (migrationOrder.includes(id) ? migrationOrder.indexOf(id) : migrationOrder.length); return [...steps.entries()] .sort(([a], [b]) => rank(a) - rank(b)) .map(([migrationEventId, recorded]) => { const first = recorded[0]; - const newest = (key: K) => [...recorded].reverse().find((r) => r[key] != null)?.[key] ?? null; + const newest = (key: K) => [...recorded].reverse().find((r) => r[key] != null)?.[key] ?? null; return { migrationEventId, destinationModel: first.destination_model, @@ -46,7 +43,7 @@ function toOldNameViews(rows: Wire['WarehouseOldNameRecord'][], migrationOrder: retention: (newest('view_retention') as string | null) ?? '', archiveName: newest('archive_name') as string | null, columnAliases: columnAliases(newest('column_aliases') as string | null), - grantsCopied: grantCount(newest('grants_copied') as string | null), + grantsCopied: newest('grants_copied') as number | null, expiresAt: newest('expires_at') as string | null, dropReason: newest('drop_reason') as string | null, steps: recorded.map((r) => ({ eventType: r.event_type as OldNameEventType, at: r.created_at, runId: r.run_id })) diff --git a/ui/src/lib/api/http-problems.test.ts b/ui/src/lib/api/http-problems.test.ts index 9074446a06..553fa62ed0 100644 --- a/ui/src/lib/api/http-problems.test.ts +++ b/ui/src/lib/api/http-problems.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { toPlatformEvents } from './http-problems'; +import { toPlatformEvents, toPlatformHealth } from './http-problems'; const NOW = Date.parse('2026-11-08T12:00:00Z'); const tick = (name: string, location: string, scheduledFor: string) => ({ @@ -46,3 +46,15 @@ describe('toPlatformEvents', () => { ]); }); }); + +describe('toPlatformHealth', () => { + it('reports a lease only while a server leads, with the replica and heartbeat left unknown', () => { + const leading = toPlatformHealth({ scheduler_leader: true, scheduler_lease_expires_at: '2026-11-08T12:00:10Z', broken_locations: [], overdue_ticks: [] }); + expect(leading).toEqual({ scheduler: { lease: { holder: null, acquiredAt: null, renewedAt: null, expiresAt: '2026-11-08T12:00:10Z' }, instances: null }, heartbeat: null }); + expect('sink' in leading).toBe(false); + }); + + it('has no lease once the last one ran out, even though its expiry is still reported', () => { + expect(toPlatformHealth({ scheduler_leader: false, scheduler_lease_expires_at: '2026-11-08T11:50:00Z', broken_locations: [], overdue_ticks: [] }).scheduler.lease).toBeNull(); + }); +}); diff --git a/ui/src/lib/api/http-problems.ts b/ui/src/lib/api/http-problems.ts index 9b1e44dfb2..53c466a61d 100644 --- a/ui/src/lib/api/http-problems.ts +++ b/ui/src/lib/api/http-problems.ts @@ -1,6 +1,7 @@ import type { components } from '$control/schema.d.ts'; import { locationKey as keyOf } from './http-client'; import type { PlatformEvent } from './types'; +import type { PlatformHealth } from './types/platform'; type Wire = components['schemas']; @@ -54,3 +55,12 @@ export function toPlatformEvents(problems: Wire['PlatformProblems'], now: number } return events; } + +/** The scheduler from the problems read: whether a lease is live and when it runs out. Replicas, heartbeat and forwarding aren't reported. */ +export function toPlatformHealth(problems: Wire['PlatformProblems']): PlatformHealth { + const expiresAt = problems.scheduler_lease_expires_at; + return { + scheduler: { lease: problems.scheduler_leader && expiresAt ? { holder: null, acquiredAt: null, renewedAt: null, expiresAt } : null, instances: null }, + heartbeat: null + }; +} diff --git a/ui/src/lib/api/types/operate.ts b/ui/src/lib/api/types/operate.ts index 195b9fbd38..4f21e277e9 100644 --- a/ui/src/lib/api/types/operate.ts +++ b/ui/src/lib/api/types/operate.ts @@ -657,10 +657,14 @@ export type TriggerDetail = Trigger & { upcoming: Iso[] | null; /** Calendar schedules: times are computed ahead and stored up to `storedUntil`. */ calendar: { - horizon: string; - storedUntil: Iso; - lastRefreshedAt: Iso; - lowHorizonWarningBelow: string; + /** Null where the server doesn't report it. */ + horizon: string | null; + /** The last stored time; null when none is stored. */ + storedUntil: Iso | null; + lastRefreshedAt: Iso | null; + /** When the location next works out the calendar; absent where the server doesn't report it. */ + nextRefreshAt?: Iso | null; + lowHorizonWarningBelow: string | null; low: boolean; } | null; sensor: { diff --git a/ui/src/lib/api/types/platform.ts b/ui/src/lib/api/types/platform.ts index caf3aad99c..823fbe6630 100644 --- a/ui/src/lib/api/types/platform.ts +++ b/ui/src/lib/api/types/platform.ts @@ -27,16 +27,18 @@ export type SchedulerInstance = { }; export type SchedulerLease = { - holder: string; - acquiredAt: Iso; - renewedAt: Iso; + /** Null when the server doesn't say which replica holds it, nor since when. */ + holder: string | null; + acquiredAt: Iso | null; + renewedAt: Iso | null; expiresAt: Iso; }; export type SchedulerStatus = { /** Null when no replica holds the leader lease. */ lease: SchedulerLease | null; - instances: SchedulerInstance[]; + /** Null when the server doesn't list its replicas. */ + instances: SchedulerInstance[] | null; }; export type HeartbeatStatus = { @@ -62,9 +64,10 @@ export type SinkStatus = { export type PlatformHealth = { scheduler: SchedulerStatus; - heartbeat: HeartbeatStatus; - /** Null when no server-side sink is configured. */ - sink: SinkStatus | null; + /** Null when the server doesn't report its heartbeat. */ + heartbeat: HeartbeatStatus | null; + /** Null when no server-side sink is configured; absent when the server doesn't report forwarding. */ + sink?: SinkStatus | null; }; export type PlatformEventLink = @@ -137,9 +140,10 @@ export type ConnectionTest = { export type LocationInstance = { id: string; - imageId: string; - gitRevision: string; - startedAt: Iso; + /** Null when the server doesn't report what the process runs or when it started. */ + imageId: string | null; + gitRevision: string | null; + startedAt: Iso | null; lastSeenAt: Iso; /** `stale` once heartbeats stop; `gone` after deregistering. */ state: 'live' | 'stale' | 'gone'; @@ -149,17 +153,20 @@ export type LocationDetail = { id: string; name: string; health: LocationHealth; - createdAt: Iso; - brokenDeployPolicy: BrokenDeployPolicy; + /** Null when the server doesn't report it. */ + createdAt: Iso | null; + /** Null when the server doesn't report it. */ + brokenDeployPolicy: BrokenDeployPolicy | null; /** 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; + /** Image the current snapshot was published from; runs start from it. Null for local processes; absent when the server doesn't report it. */ + imageId?: string | null; + /** Null when the server doesn't name it. */ + warehouseConnection: string | null; connectionTest: ConnectionTest | null; }; diff --git a/ui/src/routes/jobs/[name]/launch-form.svelte b/ui/src/routes/jobs/[name]/launch-form.svelte index 8da2536351..fc202ae288 100644 --- a/ui/src/routes/jobs/[name]/launch-form.svelte +++ b/ui/src/routes/jobs/[name]/launch-form.svelte @@ -16,15 +16,8 @@ const canRewrite = $derived(app.can('run.rewrite_history')); const canFullRefresh = $derived(app.client.supports('launchJobFromScratch')); const rewrites = $derived(fullRefresh || useWindow); - const placeholder = $derived(job.cursorType === 'integer' ? 'e.g. 1200' : 'e.g. 2026-10-01T00:00:00Z'); - const windowInvalid = $derived( - useWindow && - (!start && !end - ? 'Give a start or an end' - : job.cursorType === 'integer' - ? [start, end].some((v) => v && !/^\d+$/.test(v)) && 'Use whole numbers' - : [start, end].some((v) => v && Number.isNaN(Date.parse(v))) && 'Use a date and time like 2026-10-01T00:00:00Z') - ); + // The server checks the bounds against the job's cursors and reports anything it refuses. + const windowInvalid = $derived(useWindow && (!start.trim() || !end.trim()) && 'Give a start and an end'); async function launch() { const scope = app.scope; @@ -32,7 +25,7 @@ busy = true; error = null; try { - const options = { window: useWindow && job.cursorType ? { type: job.cursorType, start: start || null, end: end || null } : undefined }; + const options = { window: useWindow ? { type: job.cursorType ?? 'timestamp', start, end } : undefined }; const run = fullRefresh ? await app.client.launchJobFromScratch(scope, job.key, options) : await app.client.launchJob(scope, job.key, options); onclose(); await goto(`/runs/${run.id}`); @@ -49,7 +42,7 @@

{job.kind === 'group' ? `Starts a group run, with one run per job in it${job.members ? ` (${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 canFullRefresh || job.cursorType} + {#if canFullRefresh || canRewrite}
{#if canFullRefresh} {/if} - {#if job.cursorType} -
@@ -93,8 +102,8 @@
  • {i.id} - image {shortImage(i.imageId)} · commit {i.gitRevision} - started · last check-in + {#if i.imageId && i.gitRevision}image {shortImage(i.imageId)} · commit {i.gitRevision}{/if} + {#if i.startedAt}started ·{/if} last check-in
  • {/each} @@ -125,6 +134,14 @@
    {detail.lastLoadError}
    + {:else if detail.health === 'falling_back'} +
    + + + The last reload failed — still running {detail.snapshot ? `version ${detail.snapshot.generation}` : 'the last version that loaded'}. The location keeps the error: see its output, or run + sqb compile in its project. + +
    {/if}
    @@ -133,11 +150,14 @@ !!row)} />
    @@ -145,21 +165,24 @@
    - + {#snippet actions()} - + />{/if} {/snippet}
    - {#if app.can('location.manage')} + {#if app.can('location.manage') && app.client.supports('listLocationTokens')} @@ -257,6 +280,20 @@ .alert[open] .more { visibility: hidden; } + .note { + display: flex; + align-items: baseline; + gap: 8px; + padding: 7px 10px; + } + .note > :global(svg) { + flex: 0 0 auto; + align-self: center; + color: var(--sb-warning); + } + .note .summary-text { + white-space: normal; + } .alert-body { padding: 0 10px 10px 32px; color: var(--muted-foreground); diff --git a/ui/src/routes/locations/[id]/utils.ts b/ui/src/routes/locations/[id]/utils.ts index 22a297b028..76baab17d8 100644 --- a/ui/src/routes/locations/[id]/utils.ts +++ b/ui/src/routes/locations/[id]/utils.ts @@ -15,7 +15,7 @@ export type ProcessSummary = { * 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 byStart = [...instances].sort((a, b) => (b.startedAt ?? b.lastSeenAt).localeCompare(a.startedAt ?? a.lastSeenAt)); const live = byStart.filter((i) => i.state === 'live'); if (live.length === 1) return { live, listed: [] }; return { live, listed: live.length > 1 ? live : byStart }; diff --git a/ui/src/routes/locations/server-status.svelte b/ui/src/routes/locations/server-status.svelte index a0857eaf34..8950388bc2 100644 --- a/ui/src/routes/locations/server-status.svelte +++ b/ui/src/routes/locations/server-status.svelte @@ -3,10 +3,17 @@ import { formatAgo, formatDuration, formatIn, formatStamp } from '$lib/format/time'; import { app } from '$lib/state/app.svelte'; import { createResource } from '$lib/state/resource.svelte'; - import { heartbeatLate, replicaLive, schedulerWords, sinkWords } from './utils'; + import { heartbeatLate, openRecord, replicaLive, schedulerWords, sinkWords } from './utils'; const health = createResource((client) => client.platformHealth(), ['platform_events']); - const serverEvents = createResource((client) => client.listPlatformEventHistory({ locationId: null }), ['platform_events']); + // Without recorded history, the open server problems are the ones the problems read derives now. + const serverEvents = createResource( + (client) => + client.supports('listPlatformEventHistory') + ? client.listPlatformEventHistory({ locationId: null }) + : client.listPlatformEvents({ activeOnly: true }).then((events) => events.filter((e) => e.locationId === null).map(openRecord)), + ['platform_events'] + ); // Scheduler, replicas and forwarding are liveness signals; poll them while the page is open. $effect(() => { @@ -16,8 +23,8 @@ const h = $derived(health.data ?? null); const scheduler = $derived(h ? schedulerWords(h.scheduler, app.now) : null); - const sink = $derived(h ? sinkWords(h.sink, app.now) : null); - const late = $derived(!!h && heartbeatLate(h.heartbeat, app.now)); + const sink = $derived(h && h.sink !== undefined ? sinkWords(h.sink, app.now) : null); + const late = $derived(!!h?.heartbeat && heartbeatLate(h.heartbeat, app.now)); const openServerProblems = $derived((serverEvents.data ?? []).filter((e) => e.active).length); let open = $state(false); @@ -30,65 +37,78 @@
    {#if health.error} Couldn't load the server status: {health.error} - {:else if !h || !scheduler || !sink} + {:else if !h || !scheduler} Loading server status… {:else} Scheduler {scheduler.text} {#if scheduler.checkIn}· last check-in {scheduler.checkIn}{/if} - · - Event forwarding {sink.text} + {#if sink} + · + Event forwarding {sink.text} + {/if} {#if openServerProblems}·{openServerProblems} open server problem{openServerProblems === 1 ? '' : 's'}{/if} {/if}
    {#if open && h} + {@const lease = h.scheduler.lease}
    -
    -
    -

    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 h.scheduler.instances || h.heartbeat || h.sink !== undefined} +
    + {#if h.scheduler.instances} +
    +

    Servers

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

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

    + {/if} +
    {/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 h.heartbeat} +
    +

    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.

    +
    + {/if} + {#if h.sink !== undefined} +
    +

    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} +
    {/if}
    -
    -

    Server problems

    - +

    Server problems

    + {:else} +

    Server problems

    + {/if} +
    {/if} diff --git a/ui/src/routes/locations/utils.ts b/ui/src/routes/locations/utils.ts index a921a88ed1..41070749c8 100644 --- a/ui/src/routes/locations/utils.ts +++ b/ui/src/routes/locations/utils.ts @@ -1,6 +1,6 @@ // Server status in plain words for the Locations page. Pure. -import type { HeartbeatStatus, SchedulerStatus, SinkStatus } from '$lib/api'; +import type { HeartbeatStatus, PlatformEvent, PlatformEventRecord, SchedulerStatus, SinkStatus } from '$lib/api'; import { formatAgo, formatIn, formatStamp } from '$lib/format/time'; export type StatusWords = { text: string; tone: string; title: string; checkIn?: string }; @@ -15,6 +15,9 @@ export function schedulerWords(scheduler: SchedulerStatus, now: number): StatusW title: 'No server holds the scheduler lease, so schedules and sensors aren’t starting runs. Another server takes over automatically when one is available.' }; } + if (!lease.holder || !lease.acquiredAt || !lease.renewedAt) { + return { text: 'running', tone: 'var(--sb-success)', title: `A server holds the scheduler lease and renews it before it runs out ${formatIn(lease.expiresAt, now)}` }; + } return { text: `running (${lease.holder})`, tone: 'var(--sb-success)', @@ -45,3 +48,8 @@ export function heartbeatLate(heartbeat: HeartbeatStatus, now: number): boolean export function replicaLive(lastSeenAt: string, now: number): boolean { return now - Date.parse(lastSeenAt) < 60_000; } + +/** A problem the server derives now, as a record without history, links or sensor reactions. */ +export function openRecord(event: PlatformEvent): PlatformEventRecord { + return { ...event, resolvedAt: null, links: [], reactions: [], payload: {} }; +} diff --git a/ui/src/routes/triggers/[id]/trigger-view.svelte b/ui/src/routes/triggers/[id]/trigger-view.svelte index c3f7e79940..26597418ab 100644 --- a/ui/src/routes/triggers/[id]/trigger-view.svelte +++ b/ui/src/routes/triggers/[id]/trigger-view.svelte @@ -122,10 +122,11 @@ {#if t.calendar}

    Calendar

    -
    Worked out ahead
    {t.calendar.horizon}
    -
    Known until
    {formatStamp(t.calendar.storedUntil)}
    -
    Last updated
    {formatAgo(t.calendar.lastRefreshedAt, app.now)}
    -
    Warns when under
    {t.calendar.lowHorizonWarningBelow}
    + {#if t.calendar.horizon}
    Worked out ahead
    {t.calendar.horizon}
    {/if} +
    Known until
    {t.calendar.storedUntil ? formatStamp(t.calendar.storedUntil) : 'no times stored'}
    + {#if t.calendar.lastRefreshedAt}
    Last updated
    {formatAgo(t.calendar.lastRefreshedAt, app.now)}
    {/if} + {#if t.calendar.nextRefreshAt}
    Next update
    {Date.parse(t.calendar.nextRefreshAt) > app.now ? formatIn(t.calendar.nextRefreshAt, app.now) : `due ${formatAgo(t.calendar.nextRefreshAt, app.now)}`}
    {/if} + {#if t.calendar.lowHorizonWarningBelow}
    Warns when under
    {t.calendar.lowHorizonWarningBelow}
    {/if}

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

    {/if}