Skip to content

Commit 1345322

Browse files
authored
feat: serve the web UI from sqb server and ship it in the wheels (#1329)
* feat(ui): add the orchestration UI prototype as a SvelteKit app Port the prototype pages, components and styles into ui/ as a static single-page app, with neutral example owners and data. * fix(ui): resolve the prototype's type-check errors Wire the lineage hover handlers to the current event names, declare the run gantt element refs as state and add Node types for the generated tsconfig. * feat(ui): add a typed API client layer with a mock implementation Pages and the shell read through an ApiClient interface shaped like the server API, with changed-signal subscriptions for live refetches. A mock client provides neutral example data, a coalescing warehouse sync and per-target trigger state. * feat(ui): add the orchestration shell with location and target switchers The sidebar follows the new navigation with capability-gated admin items and live counts. The top bar shows location / target, connection state, snapshot age and warehouse sync freshness with a coalescing refresh, above a platform-alert banner. Pages without content yet render a not-built state. * feat(ui): read prototype pages through the API client Overview, Runs, Jobs, Triggers, Checks and Connections load from the client and refetch on changed signals. Run statuses use the orchestration vocabulary, trigger state shows the code default, and gated actions are disabled without the matching capability. * feat(ui): add platform and admin API types and mock client * feat(ui): gate the warehouse refresh behind warehouse.sync * feat(ui): build the Alerts page with platform health and event history * feat(ui): build Locations and rework Connections into warehouse and providers * feat(ui): build the Maintenance page from synced migration and janitor state * feat(ui): build Admin pages and read-only Settings * feat(ui): rebuild the runs list, queue, timeline and run detail Runs list with the design's statuses and kinds, trigger and tick links, requested-by, node counts and nullable cost, with group parents expanding to their child runs. A Queue tab shows why each queued run waits (write claims, full concurrency groups, priority, upstream jobs) and its overlap outcome. The timeline projects schedule and calendar ticks only and marks covered and skipped ticks. Run detail keeps the Gantt, status rail and tabs, and now shows nodes with skipped-because-of across jobs (kept in the graph), Recall events and scheduler facts, output, per-statement SQL, liveness, cancel progress, provenance and invocations. Cancel, Rerun and Rerun from failure are capability-gated. The mock client gains an operate store that derives node outcomes, events and run status from run records, and a small scheduler loop so claims, cancels and launches progress like they do on the server. * feat(ui): rebuild the run panel from the CLI command table The panel's verbs and flags are generated from the CLI's command table by scripts/extract-command-table.mjs (npm run gen:command-table; --check fails when the generated file is stale, for CI). Selectors autocomplete from the snapshot's nodes. Before launch the server check shows the resolved selection, the write set, run.adhoc coverage (refused as a whole, listing out-of-scope nodes), whether run.rewrite_history is needed, and the claims the run would queue behind. The target is locked to the target switcher, and launching opens the queued run. * feat(ui): show jobs and job groups with their code declarations One list for jobs and groups with a kind filter and no create button. Detail shows the read-only declaration and its source file, the resolved nodes, settings, overlap, on_failure, concurrency groups with current holders, priority, targets and owner; for groups, the members in their lineage-derived order with after edges and critical nodes. Attached triggers can be paused or enabled (an audited state fact), recent runs are listed, and Launch takes an optional cursor window or full refresh gated by run.rewrite_history. * feat(ui): show triggers and sensors with tick logs and evaluations The list covers cron and calendar schedules, built-in job, node and platform-event sensors and custom @sensors, with running or paused state against the code default, reset to code default, and bulk pause or enable recorded as audited facts with an optional reason. Detail shows the tick log (requested, skipped, covered by run X, failed, deferred, late and catch-up), next due times, the calendar's stored horizon with its low-horizon warning, and for sensors the condition, evaluations (requested, completed, failed, timed_out, abandoned), the cursor with a reset gated by sensor.reset_cursor, run requests by run key, a code-changed-since-last-evaluation notice and the override history. * fix(ui): explain queued group children waiting for their parent run A child run whose group run hasn't been claimed now says so, instead of reading as waiting for an upstream job. The README documents regenerating the run panel's command-table extract. * feat(ui): split run kind into what runs and why it was requested Runs now carry kind (job, group, adhoc) and origin (manual, schedule, sensor, rerun). Group children are job runs that inherit the parent's origin. The run list gains an origin filter and labels show both. * refactor(ui): drop the deferred tick outcome Tick outcomes are requested, skipped, covered and failed. The tick that could not be evaluated while the code location was unavailable is now a failed tick. * fix(ui): point the overdue tick alert at an overdue schedule The tick_overdue event named activity_microbatch, which ticks normally. A new hourly segments_hourly schedule has its last tick 72 minutes ago and no evaluation since, so its next due time is in the past and the triggers list shows it overdue with a watchdog warning. Platform fixtures that still named the removed nightly_build job now point at existing jobs and nodes. * feat(ui): disable users and create service accounts Users admin gains Disable (revokes the account's sessions, confirmed first) and New service account (name and description; it can then be issued API tokens). Both need user.manage and are audited by the mock. The mock refuses disabling yourself and removing your own last admin assignment, recording the refusal as a denied audit entry, and the roles page flags that assignment with a stronger warning. * refactor(ui): delete the superseded connection list API The reworked Connections page reads the connections overview, so the unused listConnections endpoint, the old Connection type and its fixtures go. Mock principals are imported from principals.ts directly. * feat(ui): re-enable disabled accounts * feat(ui): rebuild the overview on a BUILD data layer Add the BUILD block to the API client (overview, catalog nodes, column lineage, checks, evidence and the snapshot change feed) with wire types in types/build.ts and a mock that derives build state from the run history, builds outside the scheduler and the change feed. The overview now shows typed attention items (failed and partial runs, stale nodes split into changed and behind with a gated Rebuild, late sources, platform events, unresponsive runs, blocked queue entries, low calendar horizons and pending role approvals), metrics with partial runs counted separately and 24-hour cost against a rolling baseline where the adapter reports cost, build status by group, and recent snapshots with their git author. * feat(ui): show latest check results with history and audited evidence Checks list the latest audit result per check with severity, run scope phase, evaluation mode, outcome and violations or measured value, plus a strip of recent outcomes. Each check has a page with its history, the executed SQL (view.sql) and stored evidence rows (view.evidence, audited). Run checks opens the run panel on sqb audit over the visible checks' nodes. * feat(ui): back lineage and catalog with build state from the API Lineage and Catalog now read catalog nodes from the client instead of inline prototype data. Last outcome and stale reasons (changed, changed upstream, behind) are shown separately, build age is labelled Last built, and drift and SQL compare against the definitions stored with each fingerprint in this target; builds outside the scheduler show their fingerprint run and time. Nodes carry an owner list that can be filtered. The catalog page gains Drift, Runs, Checks, Warehouse (object, declared against actual columns, row statistics and microbatch coverage) and History (renames, former names and old-name views) tabs. Graph selections and the toolbar filters feed the run panel, and the large-graph canvas is kept. * feat(ui): show the snapshot change feed per deploy Changes lists each published snapshot with its generation, git revision, author and publisher: nodes added, removed, changed or renamed with their column changes, jobs, triggers and sensors, roles with the nodes they now cover, and environment fingerprint changes. Entries link to the affected nodes and to the runs that followed, and show per-run cost changes by job where the adapter reports cost. * feat(ui): replace Alerts with a Health page and drop notification channels Alerting is done by sensors, and where their messages go is an ordinary provider used by the sensor's code, so the separate channel concept is gone: no Channels panels, types, client method or mock data. A chat_webhook provider shows the pattern, with the platform-event sensor as its user. The Alerts page becomes Health under Platform. It shows the scheduler lease, server replicas, the heartbeat and the read-only server-side platform-event sink, followed by the platform event history. Each event links to the trigger, job, run or location it affects and to the sensors that reacted to it, with how each handled it. The banner, overview attention items and nav badge now point at /health. * feat(ui): redesign the runs list with status tabs, URL filters and bulk actions * feat(ui): redesign run detail with header actions, failures and a filtering timeline * feat(ui): fold server health and problem history into Locations * feat(ui): give providers their own pages apart from warehouse connections * feat(ui): split people from service accounts and drop personal API tokens * feat(ui): rewrite page copy in plain language * fix(ui): match check wording to how audits run * fix(ui): use the shared checkbox style on the runs list * feat(ui): deploy each location against exactly one target A location is now one project deployed against one target, so staging and dev become their own locations. The sidebar keeps a single location switcher that reads "name · target · adapter", the target switcher and the ?target= deep link are gone, and the data layer scopes everything by location only. The location page drops the Targets, Warehouse sync and processes tables and the side rail: the header shows the deployed version, a one-line process summary (listing processes only when none or several run), the warehouse connection and a collapsible Details; access tokens move into the page. * feat(ui): report warehouse sync failures as location problems Adds the warehouse_sync_failed platform event kind, labelled "Warehouse sync failing", so a sync that can't read one of SQLBuild's tables shows up in the location's Problems and the banner instead of a per-table sync table. The staging example location demonstrates it. * fix(ui): show open location problems by default and hide tokens without access * feat(ui): model custom roles as a base role plus additions Roles declare the built-in role they extend and their additions. Assignments carry their source (manual, SSO group, policy file or built-in), denials carry a decision ID, and approvals carry the definition diff and who gains what. Adds a shared permission catalog with a plain name and risk for every action, used by the Requires hints. * feat(ui): explain access with the chain, nearest miss and denial IDs One access checker for Settings, person pages and a Check access page. It accepts a denial ID from a permission error, shows the assignment, role, grant and scope behind a yes, and for a no the nearest miss and whether a pending change would allow it. * feat(ui): make People the default admin page with per-person access Users becomes People: role per location with source chips and filters for role, location, source and risky actions. Each person's page lists their roles (derived ones locked), an effective-access grid whose marks open the reason, recent activity and disable or enable. Giving a role is limited to roles you hold. * feat(ui): list service accounts in a table with an account page The same shape as People plus owner, token count and last use. Each account's page has its tokens, roles, effective access and recent activity. * feat(ui): split Roles into roles, SSO mappings and approvals Roles show base plus additions, where they are defined and who has them; each role's page explains its additions with risk tags, what its selectors match today and their growth, and can compare with the base role. SSO mappings are read-only. Approvals show who gains what, the definition diff and narrowing already applied, need a comment, and block approving your own commit. * feat(ui): show plain action names and denial IDs in the audit log Actions read in plain words with the code on hover. Denied rows show their copyable decision ID and a Check this link to the access checker. * fix(ui): check approval refusals as the role permission behind them * fix(ui): label config-assigned roles as server config and the default role plainly * feat(ui): match check, maintenance, coverage and sync reads to the control API A check's SQL and execution error come from a separate audited view.sql read, loaded on request, because driver errors quote SQL. Evidence cells can be booleans and columns are the rows' sorted keys. A move discovered from the warehouse has no origin model, so pages show its origin relation. Microbatch coverage bounds can be integer or timestamp text, the frontier can be missing, and the panel says coverage is tracked only for concurrent microbatch models, with known gaps meaning holes between completed partitions. The warehouse sync runs on the location's schedule, so the shell shows when it last synced and the on-demand refresh and its permission are gone. * build(ui): sync the lockfile with package.json npm ci refused the lockfile because two optional build dependencies were missing from it. Regenerated with npm install; the locked @lucide/svelte stays at 1.17.0, which type-checks cleanly. * feat(ui): drop role approvals and service accounts The control server has neither: roles defined in code apply when their snapshot is published, and people sign in with single sign-on, a password or sqb login. The approvals tab, its banner, attention item, nav badge and change-feed tag go, along with the service-account screens, API tokens, the token.manage permission and the service-account principal kind. Mock CI and reporting actors become ordinary people. * wip: person bootstrap read and person-authorized change feed Checkpoint, not ready for review. The grant decision is now a pure function shared by authorize_person (which still audits denials) and an unaudited capability read. GET /api/control/bootstrap lists the locations a person can view; the person change feed needs View on every node of the target, rechecks the session and grants before each notice, and drops per-run notices. Validated: targeted orchestration tests on SQLite and PostgreSQL, clippy, fensu, and the TypeScript client check and test. No UI changes yet. * fix: name no nodes in refused listings A refused location listing named the nodes the person could not view and read differently for a location that does not exist. Listing refusals now carry one fixed message with the decision ID, identical for unassigned and unknown locations. The audit event keeps the missing nodes. * wip: live shell UI on the control API Checkpoint, not ready for review. The UI talks to the server unless ?mock or VITE_SQB_MOCK=1 selects the mock: password sign-in with return to the requested page, sign-out, the bootstrap's locations in the switcher, the selected location's change feed with live, reconnecting and offline states, and navigation entries hidden when the server has no endpoint for them. Validated: svelte-check, and a browser walk-through against sqb server with a SQLite store: login rejection and success, location switch, server outage and recovery, sign-out, and a person with no roles. No UI unit tests yet. * fix: name no nodes in authorization denials Every refusal from person authorization now shows fixed text per action and its decision ID; the audit event keeps the missing nodes. Unknown jobs and triggers are refused only after the caller is authorized for the target, so an unknown object reads the same as a forbidden one. * test(ui): unit-test the change feed, navigation gating and sign-in redirect The sign-in redirect now resolves next against a fixed origin, so backslash and control-character variants of //host are refused too. * ci: type-check and unit-test the UI when it changes * ci: run the UI job when the verify workflow changes * fix(ui): stop retrying a refused change feed and dot-segment redirects Before a retry the feed now asks the feed URL itself and stops on 401 or 403. The bootstrap lists a location when any node is viewable, so a partial viewer retried the refused feed forever, auditing each refusal. The sign-in redirect also refuses paths that resolve to //host. * ci: run the UI job when the generated control client changes * wip: serve the built UI from sqb server behind a ui feature Checkpoint, not ready for review. With the ui cargo feature, a build script embeds ui/build (or SQLBUILD_UI_DIST) and sqb server answers GET and HEAD outside /api and /auth with the matching file, or the index for client routes. Hashed assets are immutable; the page carries a hash-based CSP meta policy and the server adds frame-ancestors. The theme script moved to a static file so no inline script needs a hash. Validated: a fixture-table contract test of routing, caching and CSP headers, with a mutation check; clippy with all features; fensu; svelte-check and vitest; and a real sqb server built with the feature behind HTTPS, where a deep link redirected to sign-in, sign-in returned to the page, and the shell rendered its empty state. Not yet: wheel builds, a populated location, make check-ci. * wip: harden UI path handling and ship the UI in the wheels Checkpoint, not ready for review. A UI path containing a double slash, a backslash, a dot or dot-dot segment, or a percent-encoded dot, slash or backslash is refused instead of reaching an asset or the shell. Every wheel job builds ui/ with Node and builds sqb with the ui feature; the linux-x86_64 smoke starts the packaged sqb server and requires the shell and an asset. Changes under ui/ now select the wheel builds. Validated: a raw-request contract test of 22 path cases (dot segments, encoded traversal, slashes, backslashes, /api versus /apix, query strings and a fragment) with a mutation check; clippy with all features; fensu; the smoke commands against a locally built wheel, which fail on a wheel built without the feature; the scope classifier on a ui-only change. Not yet: make check-ci, CI wheel builds on every platform, review. * build: fail wheel builds that would ship sqb server without its UI Wheel jobs set SQLBUILD_UI_REQUIRED, which turns the missing-UI build warning into a build failure on every platform, not only the one the smoke step covers. Local builds keep the warning. The theme script now follows the page's CSP meta tag so the policy governs it. * fix(ui): explain a server without person sign-in instead of reporting it unreachable A server started without password, OIDC or trusted-proxy sign-in has no bootstrap endpoint, so the shell reported a 404 as an unreachable server with a Retry button that cannot help. It now says the server has no person sign-in configured and links to the server documentation.
1 parent cf07c40 commit 1345322

21 files changed

Lines changed: 695 additions & 23 deletions

File tree

‎.github/workflows/build-distributions.yml‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,18 @@ jobs:
123123
if: inputs.prebuilt-run-id == ''
124124
with:
125125
toolchain: stable
126+
- uses: actions/setup-node@v4
127+
if: inputs.prebuilt-run-id == ''
128+
with:
129+
node-version: "22"
130+
cache: npm
131+
cache-dependency-path: ui/package-lock.json
132+
- name: Build the web UI that sqb server embeds
133+
if: inputs.prebuilt-run-id == ''
134+
working-directory: ui
135+
run: |
136+
npm ci
137+
npm run build
126138
- uses: PyO3/maturin-action@v1
127139
if: inputs.prebuilt-run-id == ''
128140
with:
@@ -142,10 +154,12 @@ jobs:
142154
env:
143155
RUSTFLAGS: >-
144156
${{ runner.os == 'macOS' && '-C link-arg=-undefined -C link-arg=dynamic_lookup' || '' }}
157+
# A wheel must never ship a sqb server without its UI.
158+
SQLBUILD_UI_REQUIRED: "1"
145159
with:
146160
command: build
147161
args: >-
148-
--release --out dist --manifest-path crates/sqlbuild-cli/Cargo.toml
162+
--release --out dist --manifest-path crates/sqlbuild-cli/Cargo.toml --features ui
149163
${{ matrix.platform.name == 'linux-x86_64' && '--timings' || '' }}
150164
target: ${{ matrix.platform.target }}
151165
manylinux: auto
@@ -213,6 +227,19 @@ jobs:
213227
printf 'MODEL();\nSELECT 1 AS id\n' > "${project}/models/orders.sql"
214228
"${smoke}/sqb" --project-dir "${project}" scope model:orders --json
215229
"${smoke}/sqlbuild" --project-dir "${project}" scope model:orders
230+
# The packaged sqb server must serve the embedded UI, not only the API.
231+
server_dir="${RUNNER_TEMP}/ui-server-smoke"
232+
mkdir -p "${server_dir}"
233+
(cd "${server_dir}" && env -u SQLBUILD_CONTROL_DATABASE_URL \
234+
"${smoke}/sqb" server --host 127.0.0.1 --port 8765 --no-scheduler) &
235+
server_pid=$!
236+
for _ in $(seq 1 60); do
237+
curl -sf -o /dev/null http://127.0.0.1:8765/api/openapi.json && break
238+
sleep 1
239+
done
240+
curl -sf http://127.0.0.1:8765/runs | grep -q '/_app/immutable/'
241+
curl -sfI http://127.0.0.1:8765/theme.js | grep -qi '^content-type: text/javascript'
242+
kill "${server_pid}"
216243
- uses: actions/upload-artifact@v4
217244
if: inputs.upload
218245
with:

‎.github/workflows/verify.yml‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,13 @@ jobs:
148148
*) docs_only=false ;;
149149
esac
150150
case "${path}" in
151-
crates/*|Cargo.toml|Cargo.lock|pyproject.toml|.github/workflows/verify.yml|.github/workflows/build-distributions.yml|deploy/Dockerfile|deploy/project/*|deploy/compose*|compose.yml|.dockerignore|Makefile|tests/integration/src/sqlbuild/compiler/compiled_project/publication/test_compose.py|tests/integration/src/sqlbuild/compiler/compiled_project/publication/docker/compose/*|benchmarks/duckshop/edge_cases/orchestration/container_deployment.json)
151+
crates/*|ui/*|Cargo.toml|Cargo.lock|pyproject.toml|.github/workflows/verify.yml|.github/workflows/build-distributions.yml|deploy/Dockerfile|deploy/project/*|deploy/compose*|compose.yml|.dockerignore|Makefile|tests/integration/src/sqlbuild/compiler/compiled_project/publication/test_compose.py|tests/integration/src/sqlbuild/compiler/compiled_project/publication/docker/compose/*|benchmarks/duckshop/edge_cases/orchestration/container_deployment.json)
152152
wheels=true
153153
;;
154154
esac
155155
case "${path}" in
156156
ui/*|clients/typescript/*|.github/workflows/verify.yml)
157-
# The web UI is not packaged; its own checks and unit tests cover it. It compiles
157+
# The web UI's own checks and unit tests; the wheels embed its build. It compiles
158158
# against the generated control client, so client changes run them too.
159159
ui=true
160160
;;
@@ -1144,6 +1144,8 @@ jobs:
11441144
run: npm run check
11451145
- name: Unit tests
11461146
run: npm test
1147+
- name: Build the static bundle sqb server embeds
1148+
run: npm run build
11471149

11481150
verify:
11491151
# The required Verify of a Release Please pull request is the commit status that the trusted

‎crates/sqlbuild-cli/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ path = "src/bin/sqb.rs"
1515
name = "sqlbuild"
1616
path = "src/bin/sqlbuild.rs"
1717

18+
[features]
19+
ui = ["sqlbuild-orchestration/ui"]
20+
1821
[dependencies]
1922
blake3.workspace = true
2023
chrono.workspace = true

‎crates/sqlbuild-orchestration/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ license.workspace = true
77
publish.workspace = true
88
rust-version.workspace = true
99

10+
[features]
11+
ui = []
12+
1013
[dependencies]
1114
argon2.workspace = true
1215
axum.workspace = true
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
//! With the `ui` feature, embed the built web UI (`ui/build`, or `SQLBUILD_UI_DIST`) for `sqb server`.
2+
3+
use std::path::{Path, PathBuf};
4+
5+
fn main() {
6+
println!("cargo:rerun-if-env-changed=SQLBUILD_UI_DIST");
7+
println!("cargo:rerun-if-env-changed=SQLBUILD_UI_REQUIRED");
8+
let output = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")).join("ui_assets.rs");
9+
let mut files = Vec::new();
10+
if std::env::var_os("CARGO_FEATURE_UI").is_some() {
11+
let dist = std::env::var_os("SQLBUILD_UI_DIST").map_or_else(
12+
|| {
13+
Path::new(&std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest"))
14+
.join("../../ui/build")
15+
},
16+
PathBuf::from,
17+
);
18+
println!("cargo:rerun-if-changed={}", dist.display());
19+
if dist.join("index.html").is_file() {
20+
collect(&dist, &dist, &mut files);
21+
} else {
22+
let message = format!(
23+
"sqb server will not serve the UI: {} has no index.html; run `npm ci && npm run build` in ui/ or set SQLBUILD_UI_DIST",
24+
dist.display()
25+
);
26+
assert!(
27+
std::env::var_os("SQLBUILD_UI_REQUIRED").is_none(),
28+
"{message}"
29+
);
30+
println!("cargo:warning={message}");
31+
}
32+
}
33+
files.sort();
34+
let entries: String = files
35+
.iter()
36+
.map(|(route, file)| {
37+
format!(
38+
" UiAsset {{ path: {route:?}, body: include_bytes!({:?}) }},\n",
39+
file.display()
40+
)
41+
})
42+
.collect();
43+
std::fs::write(
44+
output,
45+
format!("pub(crate) static UI_ASSETS: &[UiAsset] = &[\n{entries}];\n"),
46+
)
47+
.expect("write the embedded UI table");
48+
}
49+
50+
fn collect(root: &Path, directory: &Path, files: &mut Vec<(String, PathBuf)>) {
51+
for entry in std::fs::read_dir(directory).expect("read the UI build") {
52+
let path = entry.expect("UI build entry").path();
53+
if path.is_dir() {
54+
collect(root, &path, files);
55+
} else {
56+
let relative = path.strip_prefix(root).expect("inside the UI build");
57+
let route = relative
58+
.components()
59+
.map(|part| part.as_os_str().to_string_lossy().into_owned())
60+
.collect::<Vec<_>>()
61+
.join("/");
62+
files.push((
63+
format!("/{route}"),
64+
path.canonicalize().expect("absolute UI asset path"),
65+
));
66+
}
67+
}
68+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ pub(crate) mod run_reads;
1616
pub(crate) mod runs;
1717
pub(crate) mod snapshots;
1818
pub(crate) mod trigger_reads;
19+
pub(crate) mod ui_assets;
1920
pub(crate) mod warehouse_metadata;
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//! Serve the embedded web UI for every GET outside the API, falling back to its index for client routes.
2+
3+
use axum::Router;
4+
use axum::http::{HeaderValue, Method, StatusCode, Uri, header};
5+
use axum::response::{IntoResponse, Response};
6+
7+
use crate::api::constants::{
8+
API_PATH, API_PREFIX, AUTH_PREFIX, ROOT_PATH, UI_DOT_SEGMENTS, UI_DOUBLE_SLASH,
9+
UI_FRAME_POLICY, UI_IMMUTABLE_PREFIX, UI_INDEX_PATH, UI_REFUSED_ENCODINGS,
10+
};
11+
use crate::api::models::UiAsset;
12+
13+
#[cfg(feature = "ui")]
14+
include!(concat!(env!("OUT_DIR"), "/ui_assets.rs"));
15+
#[cfg(not(feature = "ui"))]
16+
pub(crate) static UI_ASSETS: &[UiAsset] = &[];
17+
18+
pub(crate) fn serve_ui(application: Router, assets: &'static [UiAsset]) -> Router {
19+
if assets.is_empty() {
20+
return application;
21+
}
22+
application.fallback(move |method: Method, uri: Uri| async move {
23+
respond(assets, &method, uri.path())
24+
})
25+
}
26+
27+
fn respond(assets: &'static [UiAsset], method: &Method, path: &str) -> Response {
28+
let outside_ui = path == API_PATH
29+
|| path.starts_with(API_PREFIX)
30+
|| path.starts_with(AUTH_PREFIX)
31+
|| !canonical(path);
32+
if outside_ui || (method != Method::GET && method != Method::HEAD) {
33+
return StatusCode::NOT_FOUND.into_response();
34+
}
35+
let wanted = if path == ROOT_PATH {
36+
UI_INDEX_PATH
37+
} else {
38+
path
39+
};
40+
let file_like = wanted
41+
.rsplit('/')
42+
.next()
43+
.is_some_and(|name| name.contains('.'));
44+
let found = assets.iter().find(|asset| asset.path == wanted);
45+
let asset = match found {
46+
Some(asset) => asset,
47+
None if file_like => return StatusCode::NOT_FOUND.into_response(),
48+
None => match assets.iter().find(|asset| asset.path == UI_INDEX_PATH) {
49+
Some(index) => index,
50+
None => return StatusCode::NOT_FOUND.into_response(),
51+
},
52+
};
53+
let caching = if asset.path.starts_with(UI_IMMUTABLE_PREFIX) {
54+
"public, max-age=31536000, immutable"
55+
} else {
56+
"no-cache"
57+
};
58+
let mut response = asset.body.into_response();
59+
let headers = response.headers_mut();
60+
headers.insert(
61+
header::CONTENT_TYPE,
62+
HeaderValue::from_static(content_type(asset.path)),
63+
);
64+
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(caching));
65+
headers.insert(
66+
header::X_CONTENT_TYPE_OPTIONS,
67+
HeaderValue::from_static("nosniff"),
68+
);
69+
headers.insert(
70+
header::REFERRER_POLICY,
71+
HeaderValue::from_static("no-referrer"),
72+
);
73+
headers.insert(
74+
header::CONTENT_SECURITY_POLICY,
75+
HeaderValue::from_static(UI_FRAME_POLICY),
76+
);
77+
response
78+
}
79+
80+
/// Only plain, already-normalised paths can name an embedded file or the shell.
81+
fn canonical(path: &str) -> bool {
82+
let lowered = path.to_ascii_lowercase();
83+
!path.contains('\\')
84+
&& !path.contains(UI_DOUBLE_SLASH)
85+
&& !UI_REFUSED_ENCODINGS
86+
.iter()
87+
.any(|encoded| lowered.contains(encoded))
88+
&& path
89+
.split('/')
90+
.all(|segment| !UI_DOT_SEGMENTS.contains(&segment))
91+
}
92+
93+
fn content_type(path: &str) -> &'static str {
94+
match path.rsplit('.').next().unwrap_or_default() {
95+
"html" => "text/html; charset=utf-8",
96+
"js" => "text/javascript; charset=utf-8",
97+
"css" => "text/css; charset=utf-8",
98+
"json" => "application/json",
99+
"txt" => "text/plain; charset=utf-8",
100+
"png" => "image/png",
101+
"svg" => "image/svg+xml",
102+
"ico" => "image/x-icon",
103+
"woff" => "font/woff",
104+
"woff2" => "font/woff2",
105+
_ => "application/octet-stream",
106+
}
107+
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,14 @@ pub(crate) const OIDC_RESPONSE_BYTES: usize = 1024 * 1024;
1313
pub(crate) const DOCKER_IMAGE_HEX_LENGTH: usize = 64;
1414
pub(crate) const HTTPS_SCHEME: &str = "https";
1515
pub(crate) const ROOT_PATH: &str = "/";
16+
pub(crate) const UI_INDEX_PATH: &str = "/index.html";
17+
pub(crate) const UI_IMMUTABLE_PREFIX: &str = "/_app/immutable/";
18+
/// The page's own meta policy carries every other directive; a meta tag cannot set frame-ancestors.
19+
pub(crate) const UI_FRAME_POLICY: &str = "frame-ancestors 'none'";
20+
pub(crate) const API_PATH: &str = "/api";
21+
pub(crate) const API_PREFIX: &str = "/api/";
22+
pub(crate) const AUTH_PREFIX: &str = "/auth/";
23+
/// Percent-encoded dot, slash and backslash: a UI path carrying one is refused, never decoded.
24+
pub(crate) const UI_REFUSED_ENCODINGS: [&str; 3] = ["%2e", "%2f", "%5c"];
25+
pub(crate) const UI_DOT_SEGMENTS: [&str; 2] = [".", ".."];
26+
pub(crate) const UI_DOUBLE_SLASH: &str = "//";

‎crates/sqlbuild-orchestration/src/api/main/serve_api.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use axum::routing::get;
44
use axum::{Extension, Json, Router, middleware};
55

66
use crate::api::_helpers::authentication::{bearer::local_request, configure::configure};
7+
use crate::api::_helpers::resources::ui_assets::{UI_ASSETS, serve_ui};
78
use crate::api::_helpers::resources::{changes, router::router};
89
use crate::api::main::openapi_document::openapi_document;
910
use crate::api::models::{
@@ -77,6 +78,7 @@ fn development_application(
7778
scope: Option<DevelopmentScope>,
7879
address: std::net::SocketAddr,
7980
) -> Router {
81+
let application = serve_ui(application, UI_ASSETS);
8082
match scope {
8183
Some(scope) => application
8284
.layer(Extension(scope))

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,3 +418,10 @@ pub(crate) struct ControlClient {
418418
pub(crate) authorization: reqwest::header::HeaderValue,
419419
pub(crate) base_url: reqwest::Url,
420420
}
421+
422+
/// One file of the built web UI, embedded at compile time and served at `path`.
423+
#[derive(Debug)]
424+
pub(crate) struct UiAsset {
425+
pub(crate) path: &'static str,
426+
pub(crate) body: &'static [u8],
427+
}

0 commit comments

Comments
 (0)