Skip to content

Commit be82e63

Browse files
feat: open favorited Library queries as a read-only Dashboard (#149 D1)
Phase D1 of the Dashboard epic: a standalone `/sql/dashboard` route (the same served artifact, reached by a client-side branch) that renders the favorited subset of the Library as read-only chart tiles. No new runtime dependency, no build change. - Route: widen the SPA `http_handlers` regex to `^/sql(/dashboard)?/?$` and branch on `location.pathname` in `bootstrap` (renderDashboard vs renderApp). Config/OAuth resolve from the `/sql` base on the dashboard route (configBase). - Auth: a one-time, same-origin `postMessage` credential handoff from the opener (both target origin and peer window pinned), re-seeding the child's in-memory auth (token/authMode/idp/chCtx.origin) and its own per-tab sessionStorage. A cold/bookmarked visit falls back to the normal login flow. - Tiles: each favorite runs read-only (`readonly=2`, writes rejected server-side) and renders via the existing `renderChart`/`autoChart` seam; single-row (KPI) and non-chartable favorites are skipped with an "N not shown" header note. - File menu gains "Open as dashboard" (enabled once ≥1 query is favorited). Pure logic in `core/dashboard.js` + `core/auth-handoff.js` (100%); net helper `queryDashboardTile`; render in `ui/dashboard.js`. Tests added to the per-file coverage gate; README (routes + redirect-URI note) and CHANGELOG updated. KPI tiles, global filters, drag-to-arrange layout, per-tile controls, and export follow in #149 D2–D7. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GyLqZGyUkm7mP6WhZCkodj
1 parent 341828b commit be82e63

16 files changed

Lines changed: 1011 additions & 22 deletions

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,24 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
99

1010
## [Unreleased]
1111

12+
### Added
13+
- **Dashboard (phase 1): open your favorited Library queries as a read-only
14+
dashboard in a new tab** (#149). A new **File ▾ → "Open as dashboard"** item
15+
(enabled once at least one query is starred) opens `/sql/dashboard` — the same
16+
single served artifact, reached by a client-side route — and renders each
17+
favorited, chartable query as a live chart tile, reusing the existing Chart.js
18+
result view. The new tab is authenticated by a **one-time, same-origin
19+
`postMessage` credential handoff** from the opener (both the target origin and
20+
the peer window are verified); a cold/bookmarked visit falls back to the normal
21+
login flow, which returns to the dashboard after sign-in. Tile queries run
22+
**read-only** (`readonly=2`), so a favorite that happens to contain a write is
23+
rejected server-side rather than executed on open/refresh. Single-row (KPI) and
24+
non-chartable favorites are skipped for now with an "N not shown" note. KPI
25+
tiles, global filters, drag-to-arrange layout, per-tile controls, and export
26+
arrive in later phases (#149 D2–D7). Known limitation: two tabs independently
27+
refreshing a *rotating* OAuth refresh token can race (BroadcastChannel sync
28+
deferred).
29+
1230
### Fixed
1331
- **Editor scrollbars are back, and the whole UI's scrollbars behave
1432
consistently again.** The console no longer renders at 1.2× via `html{zoom}`

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,12 @@ see "Security headers" below), and uploads the SPA + config into ClickHouse
374374

375375
1. Add the rendered `dist/http_handlers.xml` to the server's `config.d/` (or push
376376
it as an ACM cluster setting `config.d/sql-browser.xml`) and reload ClickHouse.
377-
2. Register the redirect URI `https://<ch-host>/sql` with your OAuth IdP.
377+
The SPA handler serves both `/sql` (the workbench) and `/sql/dashboard` (the
378+
favorites dashboard) from the same file.
379+
2. Register the redirect URI `https://<ch-host>/sql` with your OAuth IdP. If users
380+
will open `/sql/dashboard` via a cold/bookmarked link (rather than from the app,
381+
which hands credentials over in-session), also register
382+
`https://<ch-host>/sql/dashboard` so that direct sign-in can complete.
378383
3. Make sure ClickHouse accepts the bearer JWT — either a CH
379384
`<token_processors>` entry validating your IdP's JWKS, or a delegated
380385
`<http_authentication_servers>` verifier. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).

deploy/http_handlers.xml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
77
Routes:
88
GET /sql -> the SPA (dist/sql.html in user_files)
9+
GET /sql/dashboard -> the SAME SPA (client-side route: the favorites dashboard)
910
GET /sql/config.json -> the OAuth config (issuer + client_id [+ audience])
1011
1112
The SPA POSTs queries to "/" with `Authorization: Bearer <id_token>`; that is
@@ -23,7 +24,10 @@
2324
<clickhouse>
2425
<http_handlers>
2526
<rule name="sql_browser_spa">
26-
<url>regex:^/sql/?$</url>
27+
<!-- Serves the SPA for both the workbench (/sql) and the client-side
28+
dashboard route (/sql/dashboard) from the same sql.html. The
29+
config.json rule below is anchored separately, so it still matches. -->
30+
<url>regex:^/sql(/dashboard)?/?$</url>
2731
<methods>GET</methods>
2832
<handler>
2933
<type>static</type>

src/core/auth-handoff.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Pure helpers for the one-time cross-tab auth handoff (#149 D1). No DOM.
2+
//
3+
// "Open as dashboard" opens a new same-origin tab whose sessionStorage starts
4+
// empty. Rather than force a second sign-in, the opener grants its live
5+
// credentials once via postMessage: the child requests them, the opener replies
6+
// with a snapshot of its auth session keys, and the child restores them into its
7+
// own (per-tab) sessionStorage. Everything here is pure — the postMessage wiring
8+
// + origin/source checks live in the app controller (over injected window seams);
9+
// these are the message contract, the key set, and the origin/source predicates,
10+
// kept here so they are trivially 100% testable.
11+
12+
/** The sessionStorage keys that carry a live auth session (OAuth or basic). */
13+
export const AUTH_SS_KEYS = [
14+
'oauth_id_token', 'oauth_refresh_token', 'oauth_idp', 'oauth_origin',
15+
'ch_basic_auth', 'ch_basic_user', 'ch_basic_origin',
16+
];
17+
18+
/** postMessage `data.type` values for the handoff handshake. */
19+
export const AUTH_REQUEST = 'asb-auth-request';
20+
export const AUTH_GRANT = 'asb-auth-grant';
21+
22+
/** Read the present auth keys out of a sessionStorage-like object. */
23+
export function snapshotAuth(ss) {
24+
const snap = {};
25+
for (const k of AUTH_SS_KEYS) {
26+
const v = ss.getItem(k);
27+
if (v != null) snap[k] = v;
28+
}
29+
return snap;
30+
}
31+
32+
/** Write a snapshot's auth keys into a sessionStorage-like object. */
33+
export function restoreAuth(ss, snap) {
34+
for (const k of AUTH_SS_KEYS) {
35+
if (snap && snap[k] != null) ss.setItem(k, snap[k]);
36+
}
37+
}
38+
39+
/** Does a snapshot carry usable credentials (an OAuth token or basic creds)? */
40+
export function hasAuth(snap) {
41+
return !!(snap && (snap.oauth_id_token || snap.ch_basic_auth));
42+
}
43+
44+
/** A well-formed credential *request* from the expected origin + source window. */
45+
export function isAuthRequest(e, origin, source) {
46+
return !!e && e.origin === origin && e.source === source
47+
&& !!e.data && e.data.type === AUTH_REQUEST;
48+
}
49+
50+
/** A well-formed credential *grant* from the expected origin + source window. */
51+
export function isAuthGrant(e, origin, source) {
52+
return !!e && e.origin === origin && e.source === source
53+
&& !!e.data && e.data.type === AUTH_GRANT;
54+
}

src/core/dashboard.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Pure logic for the Dashboard view (#149). No DOM, no globals.
2+
//
3+
// A dashboard is "the favorited subset of the Library, rendered together" — no
4+
// new schema. This module holds the route helpers, the ClickHouse `FORMAT JSON`
5+
// → array-rows transform the chart layer expects, and the per-tile
6+
// classification (chart vs skip). KPI tiles (single-row) and non-chartable
7+
// favorites are skipped in D1 (KPIs arrive in D2); the render layer counts them
8+
// for the header's "N not shown" note.
9+
10+
import { autoChart, chartCfgValid, cloneChartCfg, normalizeChartCfg } from './chart-data.js';
11+
import { detectSqlFormat } from './format.js';
12+
13+
/** True on the standalone dashboard route (`/sql/dashboard`, trailing slash ok). */
14+
export function isDashboardRoute(pathname) {
15+
return /\/sql\/dashboard\/?$/.test(pathname || '');
16+
}
17+
18+
/**
19+
* The SPA base path for config.json / OAuth resolution, independent of the
20+
* dashboard sub-route: `/sql/dashboard` → `/sql` so `loadConfigDoc` fetches
21+
* `/sql/config.json` (not the non-existent `/sql/dashboard/config.json`).
22+
*/
23+
export function configBase(pathname) {
24+
return (pathname || '').replace(/\/dashboard\/?$/, '');
25+
}
26+
27+
/**
28+
* A favorite's SQL prepared for a one-shot tile fetch: strip a trailing `;` and
29+
* append `FORMAT JSON` — unless the query already ends in its own trailing
30+
* `FORMAT` clause (which we leave intact; a non-JSON format just errors the tile
31+
* gracefully rather than being silently doubled). Trailing-FORMAT detection
32+
* reuses `detectSqlFormat`, which correctly handles ClickHouse's
33+
* `FORMAT x SETTINGS y` ordering.
34+
*/
35+
export function dashboardTileSql(sql) {
36+
const trimmed = String(sql || '').replace(/;\s*$/, '').trimEnd();
37+
return detectSqlFormat(trimmed) ? trimmed : trimmed + '\nFORMAT JSON';
38+
}
39+
40+
/**
41+
* Transform a ClickHouse `FORMAT JSON` response into the shape the chart layer
42+
* wants: `columns` = `meta` ([{name,type}]), `rows` = array-of-arrays (row[i]
43+
* by column position), plus a small footer meta ({rows, ms, bytes}).
44+
*/
45+
export function parseJsonResult(json) {
46+
const columns = json.meta || [];
47+
const data = json.data || [];
48+
const rows = data.map((o) => columns.map((c) => o[c.name]));
49+
const stats = json.statistics || {};
50+
return {
51+
columns,
52+
rows,
53+
meta: {
54+
rows: json.rows != null ? json.rows : rows.length,
55+
ms: stats.elapsed != null ? Math.round(stats.elapsed * 1000) : null,
56+
bytes: stats.bytes_read != null ? stats.bytes_read : null,
57+
},
58+
};
59+
}
60+
61+
/**
62+
* Classify a favorite's result into a dashboard tile. In D1:
63+
* - 0 rows → skip (empty)
64+
* - exactly 1 row → skip (a KPI — rendered in D2)
65+
* - saved chart cfg valid for these columns → chart with that cfg
66+
* - else autoChart → chart, or skip when nothing is plottable
67+
* `savedChart` is the favorite's persisted `{cfg, key}` (or undefined). The
68+
* returned cfg is a normalized clone — never an alias of the saved entry.
69+
*/
70+
export function classifyTile(columns, rows, savedChart) {
71+
if (rows.length === 0) return { kind: 'skip', reason: 'empty' };
72+
if (rows.length === 1) return { kind: 'skip', reason: 'kpi' };
73+
const saved = savedChart && savedChart.cfg;
74+
const cfg = chartCfgValid(saved, columns)
75+
? normalizeChartCfg(cloneChartCfg(saved))
76+
: autoChart(columns);
77+
if (!cfg) return { kind: 'skip', reason: 'nonChartable' };
78+
return { kind: 'chart', cfg };
79+
}

src/main.js

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,16 @@ import { handleKeydown } from './ui/shortcuts.js';
1111
import { exchangeCodeForTokens, bearerFromTokens } from './net/oauth.js';
1212
import { decodeShare } from './core/share.js';
1313
import { cloneChartCfg } from './core/chart-data.js';
14+
import { isDashboardRoute } from './core/dashboard.js';
1415

1516
export async function bootstrap(app, env) {
1617
const loc = env.location;
1718
const ss = env.sessionStorage;
1819
const hist = env.history;
20+
// The standalone dashboard route (#149) reuses this same bootstrap + app: it
21+
// shares the OAuth-callback handling below, but renders the dashboard instead
22+
// of the workbench and skips editor-only share-link seeding.
23+
const dash = isDashboardRoute(loc.pathname);
1924
const u = new URL(loc.href);
2025
const code = u.searchParams.get('code');
2126
const stateParam = u.searchParams.get('state');
@@ -55,31 +60,40 @@ export async function bootstrap(app, env) {
5560
// A shared query (SQL + chart config) rides in the URL hash, which is lost
5661
// through the OAuth redirect (and we strip it below). Stash it in
5762
// sessionStorage so it survives the round-trip and restore it once we're back.
58-
let shared = decodeShare(loc.hash);
59-
if (shared.sql) ss.setItem('oauth_shared', JSON.stringify(shared));
60-
else {
61-
try { shared = JSON.parse(ss.getItem('oauth_shared') || 'null') || { sql: '', chart: null }; }
62-
catch { shared = { sql: '', chart: null }; }
63-
}
64-
if (shared.sql) {
65-
const t0 = app.state.tabs.value[0];
66-
t0.sql = shared.sql;
67-
t0.name = 'Shared query';
68-
if (shared.chart && shared.chart.cfg) {
69-
t0.chartCfg = cloneChartCfg(shared.chart.cfg);
70-
t0.chartKey = shared.chart.key ?? null;
63+
// The dashboard route has no editor tab to seed, so it skips this entirely.
64+
if (!dash) {
65+
let shared = decodeShare(loc.hash);
66+
if (shared.sql) ss.setItem('oauth_shared', JSON.stringify(shared));
67+
else {
68+
try { shared = JSON.parse(ss.getItem('oauth_shared') || 'null') || { sql: '', chart: null }; }
69+
catch { shared = { sql: '', chart: null }; }
70+
}
71+
if (shared.sql) {
72+
const t0 = app.state.tabs.value[0];
73+
t0.sql = shared.sql;
74+
t0.name = 'Shared query';
75+
if (shared.chart && shared.chart.cfg) {
76+
t0.chartCfg = cloneChartCfg(shared.chart.cfg);
77+
t0.chartKey = shared.chart.key ?? null;
78+
}
79+
hist.replaceState(null, '', loc.pathname + loc.search);
7180
}
72-
hist.replaceState(null, '', loc.pathname + loc.search);
7381
}
7482

83+
// A freshly-opened dashboard tab is signed out (per-tab sessionStorage); try a
84+
// one-time credential handoff from the opener before deciding what to render.
85+
// A cold/bookmarked visit has no opener → falls through to the login screen,
86+
// which after sign-in returns to /sql/dashboard and renders the dashboard.
87+
if (dash && !app.isSignedIn()) await app.receiveAuthHandoff(env);
88+
7589
if (app.isSignedIn()) {
7690
// Signed in either via a valid OAuth token or a restored basic session.
7791
ss.removeItem('oauth_shared'); // consumed
7892
// Resolve config first so the header shows the real CH identity (the
7993
// ch_auth=basic username, not the raw email claim) on first paint.
8094
// (ensureConfig is a no-op in basic mode.)
8195
await app.ensureConfig();
82-
app.renderApp();
96+
if (dash) app.renderDashboard(); else app.renderApp();
8397
} else {
8498
app.showLogin(callbackError);
8599
}
@@ -95,6 +109,7 @@ if (typeof document !== 'undefined' && !globalThis.__ASB_NO_AUTOSTART__) {
95109
sessionStorage: window.sessionStorage,
96110
history: window.history,
97111
fetch: window.fetch.bind(window),
112+
opener: window.opener, // dashboard tab reads its opener for the auth handoff
98113
});
99114
}
100115
/* c8 ignore stop */

src/net/ch-client.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,19 @@ export async function queryJson(ctx, sql, signal) {
8484
return resp.json();
8585
}
8686

87+
/**
88+
* Run a favorite's SQL for a read-only dashboard tile (#149): `FORMAT JSON` plus
89+
* the `readonly=2` HTTP setting, so a favorite that happens to contain a write
90+
* (INSERT / ALTER / DROP / …) is rejected server-side rather than executed when
91+
* the dashboard opens or refreshes — level 2 still permits SELECT and
92+
* query-level `SETTINGS`. Returns parsed JSON; throws CH's reason on error.
93+
*/
94+
export async function queryDashboardTile(ctx, sql, signal) {
95+
const resp = await authedFetch(ctx, chUrl(ctx.origin, { format: 'JSON', extra: { readonly: 2 } }), sql, signal);
96+
if (!resp.ok) throw new Error(parseExceptionText(await resp.text()));
97+
return resp.json();
98+
}
99+
87100
/**
88101
* Run a `system.tables`/`system.columns` query (`sqlBody`, without its FORMAT
89102
* clause) with data-lake-catalog visibility enabled, falling back to the plain

src/styles.css

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1737,3 +1737,73 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
17371737
/* Cell-detail drawer → full-width, non-resizable (its handle is hidden above). */
17381738
.cd-panel { width: 100vw !important; min-width: 0; }
17391739
}
1740+
1741+
/* ── Dashboard (#149 D1) ───────────────────────────────────────────────────
1742+
The standalone /sql/dashboard page: sticky header + a responsive grid of
1743+
read-only chart tiles rendered from favorited Library queries. */
1744+
.dash-header {
1745+
position: sticky; top: 0; z-index: 40; display: flex; align-items: center;
1746+
gap: 12px; padding: 11px 20px; background: var(--bg-header);
1747+
border-bottom: 1px solid var(--border); flex-wrap: wrap;
1748+
}
1749+
.dash-back {
1750+
display: inline-flex; align-items: center; gap: 6px; color: var(--fg-mute);
1751+
text-decoration: none; font-size: 12px; padding: 4px 9px; border-radius: 6px;
1752+
border: 1px solid var(--border);
1753+
}
1754+
.dash-back:hover { background: var(--bg-hover); color: var(--fg); }
1755+
.dash-back svg { transform: scaleX(-1); }
1756+
.dash-title { font-size: 16px; font-weight: 700; color: var(--fg); letter-spacing: -.01em; }
1757+
.dash-chip {
1758+
display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
1759+
color: var(--fg-mute); background: var(--bg-chip); padding: 3px 9px; border-radius: 20px;
1760+
}
1761+
.dash-fav { color: var(--accent); }
1762+
.dash-src { font-family: var(--mono); }
1763+
.dash-dot { width: 6px; height: 6px; border-radius: 6px; background: #22C55E; }
1764+
.dash-skip { font-size: 11px; color: var(--fg-faint); }
1765+
.dash-updated { font-size: 11px; color: var(--fg-faint); font-family: var(--mono); }
1766+
.dash-btn {
1767+
display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px;
1768+
background: var(--bg-chip); color: var(--fg); border: 1px solid var(--border);
1769+
border-radius: 7px; font: 500 12px var(--ui); cursor: pointer;
1770+
}
1771+
.dash-btn:hover { background: var(--bg-hover); }
1772+
.dash-btn:disabled { opacity: .55; cursor: default; }
1773+
.dash-grid {
1774+
display: grid; gap: 14px; padding: 18px 20px 40px;
1775+
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
1776+
max-width: 1560px; margin: 0 auto;
1777+
}
1778+
.dash-empty { padding: 60px 20px; text-align: center; color: var(--fg-mute); font-size: 13px; }
1779+
.dash-tile {
1780+
display: flex; flex-direction: column; overflow: hidden; min-height: 300px;
1781+
background: var(--bg-side); border: 1px solid var(--border); border-radius: 10px;
1782+
}
1783+
.dash-tile-head {
1784+
padding: 11px 12px 9px; border-bottom: 1px solid var(--border-faint);
1785+
}
1786+
.dash-tile-name {
1787+
font-size: 13px; font-weight: 600; color: var(--fg);
1788+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block;
1789+
}
1790+
.dash-tile-body { flex: 1; min-height: 0; padding: 6px 8px; display: flex; }
1791+
.dash-tile-body > .chart-view { flex: 1; min-width: 0; }
1792+
.dash-tile-load {
1793+
display: flex; align-items: center; gap: 8px; margin: auto;
1794+
color: var(--fg-faint); font-size: 12px;
1795+
}
1796+
.dash-tile-error {
1797+
margin: auto; padding: 12px; color: var(--error-fg); background: var(--error-bg);
1798+
border: 1px solid var(--error-bd); border-radius: 8px; font-size: 12px;
1799+
font-family: var(--mono); max-width: 90%;
1800+
}
1801+
.dash-tile-foot {
1802+
display: flex; align-items: center; gap: 12px; padding: 7px 12px;
1803+
border-top: 1px solid var(--border-faint); font-family: var(--mono);
1804+
font-size: 10.5px; color: var(--fg-faint);
1805+
}
1806+
@media (max-width: 640px) {
1807+
.dash-grid { grid-template-columns: 1fr; padding: 12px; }
1808+
.dash-header { padding: 10px 12px; gap: 8px; }
1809+
}

0 commit comments

Comments
 (0)