Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
## [Unreleased]

### Added
- **Unified `/sql` routing for Workbench and Dashboard surfaces** (#407).
Workspace identity, surface, and presentation mode now live in canonical
`ws`, `surface`, and `mode` query parameters. Workbench/Dashboard switches
stay in the same tab with useful Back-button history, while View/Edit uses
replacement history. Dashboard view mode renders the same live workspace
document without authoring controls; an explicit missing workspace never
falls back, and an empty workspace offers Create only in edit mode. The old
surface becomes an inert loading route before cross-workspace navigation,
while same-workspace Back/Forward switches immediately. Dashboard uses the
shared header row for its surface, tile count, File, active-style menu, name,
View/Edit, update, Refresh, and theme controls. Global Workbench shortcuts
fail closed outside a ready matching route, and renderer generations let in-flight
durable writes finish without obsolete Dashboard or Workbench callbacks
repainting the selected surface. Direct Dashboard startup now shares the
authenticated server-version probe. Empty Dashboard routes also react when
another tab creates the Dashboard. The old `/sql/dashboard` bootstrap split,
detached Dashboard snapshot stores,
one-time state handoff, and cross-tab credential handoff have been removed.
- **Multi-workspace local persistence foundation** (#406). Stored workspaces
now use the V2 contract with separate immutable opaque `id`, immutable
human-readable URL `key`, and mutable display `name`. IndexedDB stores one
Expand Down
18 changes: 8 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,10 +532,11 @@ npm run dev # build + serve dist/ at http://localhost:8900
### Run in Docker

The production image is a static **nginx** server for the single-file SPA — no
application backend. It serves `/sql` (and the `/sql/dashboard` client route),
serves a `config.json` you provide at `/sql/config.json`, and answers `/healthz`
for probes. Queries are **not** proxied: the browser POSTs them straight to the
chosen ClickHouse cluster, exactly like the on-cluster deployment.
application backend. It serves `/sql`, including Workbench and Dashboard
surfaces selected by query parameters, serves a `config.json` you provide at
`/sql/config.json`, and answers `/healthz` for probes. Queries are **not**
proxied: the browser POSTs them straight to the chosen ClickHouse cluster,
exactly like the on-cluster deployment.

Pull the published multi-arch image (`linux/amd64` + `linux/arm64`):

Expand Down Expand Up @@ -697,12 +698,9 @@ see "Security headers" below), and uploads the SPA + config into ClickHouse

1. Add the rendered `dist/http_handlers.xml` to the server's `config.d/` (or push
it as an ACM cluster setting `config.d/sql-browser.xml`) and reload ClickHouse.
The SPA handler serves both `/sql` (the workbench) and `/sql/dashboard` (the
favorites dashboard) from the same file.
2. Register the redirect URI `https://<ch-host>/sql` with your OAuth IdP. If users
will open `/sql/dashboard` via a cold/bookmarked link (rather than from the app,
which hands credentials over in-session), also register
`https://<ch-host>/sql/dashboard` so that direct sign-in can complete.
The SPA handler serves `/sql`; Workbench and Dashboard bookmarks differ only
by the `ws`, `surface`, and optional `mode` query parameters.
2. Register the single redirect URI `https://<ch-host>/sql` with your OAuth IdP.
3. Make sure ClickHouse accepts the bearer JWT — either a CH
`<token_processors>` entry validating your IdP's JWKS, or a delegated
`<http_authentication_servers>` verifier. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
Expand Down
2 changes: 1 addition & 1 deletion build/check-boundaries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// service layer coordinates state and network, it does not reach into DOM
// rendering or the CodeMirror editor adapters.
// Phase 3 — the route sessions must not import each other's implementation
// modules (ui/workbench ↔ ui/dashboard), and the dashboard route must not
// modules (ui/workbench ↔ ui/dashboard), and the Dashboard surface must not
// depend on the editor ports at all.
// Phase 5 — neither route shell (ui/workbench/**, ui/dashboard.ts +
// ui/dashboard/**) may import src/ui/app.ts: both receive everything they
Expand Down
6 changes: 2 additions & 4 deletions build/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,8 @@ def do_GET(self):
if path.endswith("/config.json"):
self._send(CONFIG, "application/json; charset=utf-8")
return
# Serve the SPA for the workbench (/sql) and the client-side dashboard
# route (/sql/dashboard) — mirrors the production http_handlers rule so
# "Open as dashboard" works under `npm run local` too (#149 D1).
if path.rstrip("/") in ("", "/sql", "/sql.html", "/sql/dashboard"):
# Workbench and Dashboard are query-selected surfaces of /sql.
if path.rstrip("/") in ("", "/sql", "/sql.html"):
try:
with open(SPA, "rb") as f:
html = f.read()
Expand Down
7 changes: 2 additions & 5 deletions deploy/http_handlers.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

Routes:
GET /sql -> the SPA (dist/sql.html in user_files)
GET /sql/dashboard -> the SAME SPA (client-side route: the favorites dashboard)
GET /sql/config.json -> the OAuth config (issuer + client_id [+ audience])

The SPA POSTs queries to "/" with `Authorization: Bearer <id_token>`; that is
Expand All @@ -24,10 +23,8 @@
<clickhouse>
<http_handlers>
<rule name="sql_browser_spa">
<!-- Serves the SPA for both the workbench (/sql) and the client-side
dashboard route (/sql/dashboard) from the same sql.html. The
config.json rule below is anchored separately, so it still matches. -->
<url>regex:^/sql(/dashboard)?/?$</url>
<!-- Workbench and Dashboard are query-selected surfaces of /sql. -->
<url>regex:^/sql/?$</url>
<methods>GET</methods>
<handler>
<type>static</type>
Expand Down
8 changes: 4 additions & 4 deletions deploy/nginx/default.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ server {
return 302 /sql;
}

# The workbench (/sql) and the client-side dashboard route (/sql/dashboard)
# are the SAME single-file SPA. alias to a file (no captured path appended
# inside a regex location). The CSP's connect-src bounds where the
# Workbench and Dashboard are query-selected surfaces of /sql. Alias to a
# file (no captured path appended inside a regex location). CSP connect-src
# bounds where the
# sessionStorage tokens can be sent; script-src/style-src need
# 'unsafe-inline' because the JS+CSS are inlined into this one HTML file.
location ~ ^/sql(/dashboard)?/?$ {
location ~ ^/sql/?$ {
alias /app/sql.html;
# NB: set the charset via the `charset` directive, NOT inside
# default_type — a "text/html; charset=…" content_type string defeats
Expand Down
236 changes: 89 additions & 147 deletions docs/ADR-0003-dashboard-viewing.md
Original file line number Diff line number Diff line change
@@ -1,160 +1,102 @@
# ADR-0003: Dashboard viewing — edit vs view modes, and the one-time cross-tab state handoff
# ADR-0003: Dashboard viewing and unified `/sql` routes

- **Status:** Accepted — 2026-07-18 (#288 + #302, Dashboard v1 phase 6, the final
phase of epic #280). Implemented on branch `feat/dashboard-viewing-288`.
- **Date:** 2026-07-18
- **Context tracking:** roadmap #68 (Dashboard track); epic #280; closes #153.
- **Related:** #149 (original standalone-dashboard route + postMessage credential
handoff — its *state*-transport decision is superseded here), #284 (IndexedDB
`StoredWorkspaceV1` persistence, whose adapter pattern this reuses), #287
(portable bundle codecs + transactional import planner this builds on).
- **Status:** Accepted; detached-snapshot decision superseded by #407 on
2026-07-23
- **Date:** 2026-07-18; revised 2026-07-23
- **Context tracking:** roadmap #68; #288, #302, #406, #407

## Context

Phases 1–5 of #280 made the Dashboard a first-class module: an explicit
`StoredWorkspaceV1` aggregate persisted atomically to IndexedDB (#284), an
independent read-only `DashboardViewerSession` + `flow@1` layout (#286), and a
canonical `PortableBundleV1` with a transactional import planner (#287). Phase 6
(#288) is the viewing surface: how a Dashboard is *opened* — bookmarkably for the
current workspace, and in a detached read-only tab — without the read-only path
dragging in Workbench or editor construction. In parallel, #302 asked to move
Dashboard navigation and Dashboard-scoped file operations out of the (overloaded)
Workbench File menu and next to the Dashboard resource itself.

The original #288 spec framed the second open kind as a *temporary, one-time,
non-bookmarkable* "session-bundle" for full-screen preview and external-bundle
viewing. During planning the owner refined this into a cleaner, more durable
product model, which this ADR records.
The original Dashboard viewing design separated an editable primary workspace
from durable read-only snapshots. Opening a view created a second workspace
record through a one-time IndexedDB handoff and opened a separate
`/sql/dashboard` application tab. That model duplicated local data, required
parallel stores and credential/state transport, and made view mode diverge from
the workspace users were actually editing.

## Decision

### Two explicit viewing modes
Multi-workspace persistence (#406) established an immutable human-readable
workspace `key` as the canonical URL identity. Unified routing (#407) uses that
identity for both application surfaces and treats View/Edit as presentation
modes over one live workspace.

| | **Edit mode** | **View mode** |
|---|---|---|
| Entry | Workbench header `Dashboard →` control | Dashboard header **File → "Open for viewing…"** |
| Tab | new tab | new tab |
| Open source | `current-workspace` (`?ws=&dash=`) | detached snapshot of the current dashboard |
| Storage | **shared** primary workspace collection (`asb-workspaces-v2`) | its **own** detached store (`asb-dashboard-views`), fresh id |
| Editable | yes — drag reorder + layout preset persist to the shared aggregate | **read-only** |
| Auth | existing postMessage credential handoff (#149) | same |
| Survives relogin / reload | yes (shared store) | yes (own persisted record) |

`Dashboard →` opens a **new tab** (an owner override of #302's "current tab"
wording), keeping the Workbench tab available and reusing the existing new-tab
credential handoff.
## Decision

### The `DashboardOpenSource` contract
Workbench and Dashboard are surfaces of the same `/sql` application:

```ts
type DashboardOpenSource =
| { kind: 'current-workspace'; workspaceKey: string; dashboardId: string }
| { kind: 'session-bundle'; token: string; dashboardId: string };
```text
/sql?ws=clickhouse_operations
/sql?ws=clickhouse_operations&surface=dashboard
/sql?ws=clickhouse_operations&surface=dashboard&mode=view
```

Encoded as **query params on `/dashboard`** — never path segments — so the
pathname stays exactly `/dashboard` and `isDashboardRoute`/`configBase`/the OAuth
`redirect_uri` (all keyed on the `/dashboard` suffix) are untouched, and the
params survive `bootstrap`'s OAuth-param strip automatically. `?ws=&dash=` is
current-workspace; `?st=&dash=` is session-bundle.

> **Critical constraint:** the session token param is **`st`**, never `state`.
> `bootstrap` (`src/main.ts`) reads `?state` as the OAuth CSRF value; a token
> there would raise "OAuth state mismatch".

**Mode is discriminated by which store the `ws` key resolves in**, not by a
spoofable URL flag: the key present in the primary workspace store → edit mode;
present in the detached views store → view mode; in neither → a not-found panel
that executes nothing (never silently opens a different dashboard).

### The one-time cross-tab state handoff

"Open for viewing…" hands the dashboard *state* (not credentials) to the new tab
through a one-time IndexedDB token, then materializes a durable detached copy:

1. Snapshot the current dashboard's dependency closure into a `PortableBundleV1`
(`buildDashboardExportBundle`).
2. Generate an unguessable 256-bit token (`crypto.getRandomValues`); write a
record `{ text: bundleJSON, dashboardId, detachedWorkspaceId, expiresAt }` to
a dedicated IndexedDB database (`asb-dashboard-handoff`) **before** opening the
tab.
3. `window.open('/dashboard?st=<token>&dash=<id>')` and grant the credential
handoff.
4. The new tab **atomically consumes + deletes** the record in one readwrite
transaction (`take` = get+delete, then reject if expired), and strips `st`
from the URL via `history.replaceState`.
5. It **materializes** the bundle into the detached store under
`detachedWorkspaceId`, assigns the detached copy a stable local key, rewrites
the URL to `?ws=<detachedWorkspaceKey>&dash=`,
and renders read-only.

Auth is orthogonal and unchanged: the new tab still restores credentials via the
#149 postMessage handoff, falling back to a normal OAuth relogin (which the
detached `?ws=` URL survives).

## Consequences / deliberate evolutions of #288

- **"session-bundle is not bookmarkable" is refined, not violated.** The `?st=`
token URL is genuinely one-time and dead after consumption — not bookmarkable.
The *detached view it produces* (`?ws=<detachedId>`) is persistent and
bookmarkable/relogin-surviving. This is the intended product behavior: a
view-mode tab you can leave open, reload, and re-authenticate into.
- **External-file *viewing* + the untrusted-bundle "Run Dashboard on `<host>`"
trust preflight are descoped.** External `.json` files go through #302's
transactional **Import Dashboard…** (validate + commit via the planner).
"Open for viewing…" detaches the workspace's *own*, already-trusted dashboard,
so there is no untrusted external SQL to gate. The viewer's existing safety
limits (row/byte caps, bounded concurrency, per-tile cancellation, stale-wave
protection, no Setup execution) still apply.
- **A new IndexedDB store family.** Two dedicated databases join the primary
workspace database (now `asb-workspaces-v2`):
`asb-dashboard-handoff` (one-time tokens) and `asb-dashboard-views`
(multi-record detached snapshots, keyed by workspace id, with a small retention
cap so abandoned views don't grow unbounded). Both reuse the #284 adapter
pattern (lazy cached open, single readwrite-txn atomicity) behind injected
seams, so the pure/seam logic stays 100%-covered and tests use in-memory fakes.
- **Read-only path stays clean.** The viewer path constructs no Workbench/editor
modules; the `check:arch` boundary guard + `dashboard-boundaries.test.js` are
extended to the new `dashboard/application` + `workspace` modules.
- **File-menu ownership clarified (#302).** The Workbench File menu owns
workspace/query operations only; Dashboard navigation moves to a Workbench
header `Dashboard →` control, and Dashboard import/export + "Open for viewing…"
move to a resource-scoped File menu on the Dashboard header.
The pure route contract owns `ws`, `surface`, and `mode`:

- absent or unknown `surface` means Workbench;
- `surface=dashboard` means Dashboard;
- Dashboard edit is the default, so `mode=edit` is accepted but omitted from
canonical URLs;
- `mode=view` renders the same current workspace dashboard without mutation
controls;
- unrelated parameters are preserved until their owning flow consumes them.

An explicit `ws` resolves exactly that workspace key. Failure renders
**Workspace not found** and never falls back. An implicit `/sql` open resolves
the last-used workspace, deterministically selects an existing workspace when
needed, or provisions the default workspace, then rewrites the URL with its
canonical key.

Surface changes use same-tab `history.pushState()` so browser Back remains
useful. View/Edit changes use `history.replaceState()` so presentation toggles
do not pollute history. The Dashboard header exposes
`[Workspace | Dashboard] [View | Edit]`.

Each workspace owns zero or one dashboard:

- edit mode shows **Create dashboard** when none exists, but visiting does not
create one;
- view mode shows **This workspace has no dashboard** and executes no queries;
- view mode registers no reorder, resize, delete, layout-persistence, or other
authoring paths.

## Superseded implementation

The following original ADR decisions are intentionally retired:

- pathname-based `/sql/dashboard` bootstrapping;
- default new-tab Workbench/Dashboard navigation;
- `DashboardOpenSource` current-workspace/session-bundle discrimination;
- the `st` one-time state transport parameter;
- `asb-dashboard-handoff` and `asb-dashboard-views`;
- detached workspace materialization and retention;
- **Open for viewing…** snapshot creation;
- store-membership-based edit/view discrimination;
- dashboard-specific cross-tab credential handoff.

There is no migration requirement for those development-era URLs or IndexedDB
records.

## Consequences

- Edit and view always observe the same canonical workspace/dashboard data.
- A bookmarked view remains read-only in presentation, not authorization; the
local user can switch back to edit.
- Workbench and Dashboard share authentication, configuration, workspace
refresh, import/export, and query execution without parallel bootstrap
applications.
- Dashboard route resources are disposed when switching surfaces or rebuilding
the current surface; the Workbench shell likewise disposes signal and media
listeners before remounting.
- OAuth uses one `/sql` redirect URI. Callback cleanup retains route parameters
while removing only OAuth callback parameters.

## Alternatives considered

- **Same-tab navigation for `Dashboard →`** (#302 as literally written): rejected
by the owner in favor of a new tab, to keep the Workbench visible.
- **Ephemeral one-time view (strict original #288):** the view would not survive
relogin/close. Rejected — the owner wants a durable, detached view surface;
the token remains the *transport*, persistence is layered on top.
- **Path-segment routes (`/dashboard/ws/<id>`):** rejected — breaks
`isDashboardRoute`/`configBase`/OAuth-redirect, all keyed on the `/dashboard`
suffix.
- **A `?view=1` mode flag:** rejected in favor of store-membership discrimination
(not spoofable, and naturally yields the not-found case).

## Addendum — 2026-07-23: stable workspace keys and collection persistence (#406)

`StoredWorkspaceV2` replaces the single fixed `current` aggregate with one
IndexedDB record per workspace. Workspace identity is deliberately split:

- `id` is the immutable opaque object-store key;
- `key` is the immutable, unique lowercase ASCII URL identity resolved by
`?ws=`;
- `name` is mutable display metadata and never rewrites bookmarks.

The primary store uses `id` as its key path and a unique `key` index. Create,
replace, delete, and last-opened metadata updates are transaction-scoped;
replace cannot create a missing record or alter its key. Portable workspace
import creates a fresh local ID/key instead of replacing the active workspace.

Implicit opens persist the successfully opened key in a separate preferences
store and stamp store-owned `lastOpenedAt` metadata using the injected wall
clock. If that preference is absent or invalid, resolution chooses the valid
workspace with the newest timestamp, breaking ties by key; records without a
timestamp fall back deterministically by key. Explicit `?ws=` opens never fall
back to another workspace.

The detached-view store follows the same key-based `?ws=` resolution contract,
while its generated local key remains separate from its opaque detached ID.
- **Durable detached snapshots:** rejected because they silently diverge from
the live workspace and require duplicate persistence and transport.
- **New-tab navigation by default:** rejected because it encourages concurrent
editing and makes Back navigation ineffective.
- **Path-segment routes:** rejected because workspace, surface, and mode are
independent application state and query parameters preserve the single SPA
handler and OAuth redirect.
- **View as authorization:** out of scope. View mode is a local presentation
choice, not an access-control boundary.
Loading
Loading