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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
## [Unreleased]

### Added
- **Dashboard (phase 1): open your favorited Library queries as a read-only
dashboard in a new tab** (#149). A new **File ▾ → "Open as dashboard"** item
(enabled once at least one query is starred) opens `/sql/dashboard` — the same
single served artifact, reached by a client-side route — and renders each
favorited, chartable query as a live chart tile, reusing the existing Chart.js
result view. The new tab is authenticated by a **one-time, same-origin
`postMessage` credential handoff** from the opener (both the target origin and
the peer window are verified); a cold/bookmarked visit falls back to the normal
login flow, which returns to the dashboard after sign-in. Tile queries run
**read-only** (`readonly=2`), so a favorite that happens to contain a write is
rejected server-side rather than executed on open/refresh. Tiles fetch with a
bounded concurrency (so a large favorites list doesn't stampede the cluster),
the auth token is resolved once before they fan out (no intra-tab refresh
race), and a handed-off-but-expired token is refreshed rather than forcing a
re-login. Single-row (KPI) and non-chartable favorites are skipped for now with
an "N not shown" note. KPI tiles, global filters, drag-to-arrange layout,
per-tile controls, and export arrive in later phases (#149 D2–D7). Known
limitation: two tabs independently refreshing a *rotating* OAuth refresh token
can race (BroadcastChannel sync deferred).
- **Schema-aware, FROM-driven autocompletion** (#84) — column completion now
fires *while you type*, driven by the statement's `FROM`/`JOIN` clause, so you
no longer have to expand a table in the sidebar first. A new pure module
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,12 @@ 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.
2. Register the redirect URI `https://<ch-host>/sql` with your OAuth IdP.
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.
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
5 changes: 4 additions & 1 deletion build/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,10 @@ def do_GET(self):
if path.endswith("/config.json"):
self._send(CONFIG, "application/json; charset=utf-8")
return
if path.rstrip("/") in ("", "/sql", "/sql.html"):
# 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"):
try:
with open(SPA, "rb") as f:
html = f.read()
Expand Down
6 changes: 5 additions & 1 deletion deploy/http_handlers.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

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 @@ -23,7 +24,10 @@
<clickhouse>
<http_handlers>
<rule name="sql_browser_spa">
<url>regex:^/sql/?$</url>
<!-- 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>
<methods>GET</methods>
<handler>
<type>static</type>
Expand Down
54 changes: 54 additions & 0 deletions src/core/auth-handoff.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Pure helpers for the one-time cross-tab auth handoff (#149 D1). No DOM.
//
// "Open as dashboard" opens a new same-origin tab whose sessionStorage starts
// empty. Rather than force a second sign-in, the opener grants its live
// credentials once via postMessage: the child requests them, the opener replies
// with a snapshot of its auth session keys, and the child restores them into its
// own (per-tab) sessionStorage. Everything here is pure — the postMessage wiring
// + origin/source checks live in the app controller (over injected window seams);
// these are the message contract, the key set, and the origin/source predicates,
// kept here so they are trivially 100% testable.

/** The sessionStorage keys that carry a live auth session (OAuth or basic). */
export const AUTH_SS_KEYS = [
'oauth_id_token', 'oauth_refresh_token', 'oauth_idp', 'oauth_origin',
'ch_basic_auth', 'ch_basic_user', 'ch_basic_origin',
];

/** postMessage `data.type` values for the handoff handshake. */
export const AUTH_REQUEST = 'asb-auth-request';
export const AUTH_GRANT = 'asb-auth-grant';

/** Read the present auth keys out of a sessionStorage-like object. */
export function snapshotAuth(ss) {
const snap = {};
for (const k of AUTH_SS_KEYS) {
const v = ss.getItem(k);
if (v != null) snap[k] = v;
}
return snap;
}

/** Write a snapshot's auth keys into a sessionStorage-like object. */
export function restoreAuth(ss, snap) {
for (const k of AUTH_SS_KEYS) {
if (snap && snap[k] != null) ss.setItem(k, snap[k]);
}
}

/** Does a snapshot carry usable credentials (an OAuth token or basic creds)? */
export function hasAuth(snap) {
return !!(snap && (snap.oauth_id_token || snap.ch_basic_auth));
}

/** A well-formed credential *request* from the expected origin + source window. */
export function isAuthRequest(e, origin, source) {
return !!e && e.origin === origin && e.source === source
&& !!e.data && e.data.type === AUTH_REQUEST;
}

/** A well-formed credential *grant* from the expected origin + source window. */
export function isAuthGrant(e, origin, source) {
return !!e && e.origin === origin && e.source === source
&& !!e.data && e.data.type === AUTH_GRANT;
}
85 changes: 85 additions & 0 deletions src/core/dashboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Pure logic for the Dashboard view (#149). No DOM, no globals.
//
// A dashboard is "the favorited subset of the Library, rendered together" — no
// new schema. This module holds the route helpers, the ClickHouse `FORMAT JSON`
// → array-rows transform the chart layer expects, and the per-tile
// classification (chart vs skip). KPI tiles (single-row) and non-chartable
// favorites are skipped in D1 (KPIs arrive in D2); the render layer counts them
// for the header's "N not shown" note.

import { autoChart, chartCfgValid, cloneChartCfg, normalizeChartCfg } from './chart-data.js';
import { withTrailingFormat } from './format.js';

/**
* True on the standalone dashboard route (a path ending in `/dashboard`,
* trailing slash ok). Matches on the `/dashboard` suffix rather than a pinned
* `/sql/dashboard` so it stays consistent with `configBase` (which strips the
* same suffix) and survives the SPA being mounted somewhere other than `/sql`.
* The server only serves the artifact at its SPA routes, so nothing unexpected
* reaches this predicate.
*/
export function isDashboardRoute(pathname) {
return /\/dashboard\/?$/.test(pathname || '');
}

/**
* The SPA base path for config.json / OAuth resolution, independent of the
* dashboard sub-route: `/sql/dashboard` → `/sql` so `loadConfigDoc` fetches
* `/sql/config.json` (not the non-existent `/sql/dashboard/config.json`).
*/
export function configBase(pathname) {
return (pathname || '').replace(/\/dashboard\/?$/, '');
}

/**
* A favorite's SQL prepared for a one-shot tile fetch: `FORMAT JSON` appended
* unless the query already ends in its own trailing `FORMAT` clause (which we
* leave intact; a non-JSON format just errors the tile gracefully rather than
* being silently doubled). Delegates to `withTrailingFormat`, which strips a
* trailing `;`/comments and reuses `detectSqlFormat` (handling ClickHouse's
* `FORMAT x SETTINGS y` ordering). Empty input → '' (no favorite is empty).
*/
export function dashboardTileSql(sql) {
return withTrailingFormat(sql, 'JSON').sql;
}

/**
* Transform a ClickHouse `FORMAT JSON` response into the shape the chart layer
* wants: `columns` = `meta` ([{name,type}]), `rows` = array-of-arrays (row[i]
* by column position), plus a small footer meta ({rows, ms, bytes}).
*/
export function parseJsonResult(json) {
const columns = json.meta || [];
const data = json.data || [];
const rows = data.map((o) => columns.map((c) => o[c.name]));
const stats = json.statistics || {};
return {
columns,
rows,
meta: {
rows: json.rows != null ? json.rows : rows.length,
ms: stats.elapsed != null ? Math.round(stats.elapsed * 1000) : null,
bytes: stats.bytes_read != null ? stats.bytes_read : null,
},
};
}

/**
* Classify a favorite's result into a dashboard tile. In D1:
* - 0 rows → skip (empty)
* - exactly 1 row → skip (a KPI — rendered in D2)
* - saved chart cfg valid for these columns → chart with that cfg
* - else autoChart → chart, or skip when nothing is plottable
* `savedChart` is the favorite's persisted `{cfg, key}` (or undefined). The
* returned cfg is a normalized clone — never an alias of the saved entry.
*/
export function classifyTile(columns, rows, savedChart) {
if (rows.length === 0) return { kind: 'skip', reason: 'empty' };
if (rows.length === 1) return { kind: 'skip', reason: 'kpi' };
const saved = savedChart && savedChart.cfg;
const cfg = chartCfgValid(saved, columns)
? normalizeChartCfg(cloneChartCfg(saved))
: autoChart(columns);
if (!cfg) return { kind: 'skip', reason: 'nonChartable' };
return { kind: 'chart', cfg };
}
41 changes: 29 additions & 12 deletions src/core/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,37 @@ export function detectSqlFormat(sql) {
}

/**
* Resolve an editor query for a full (uncapped) export. If it already ends in
* a `FORMAT <name>` clause (detectSqlFormat), the SQL is kept as-is and that
* format is reported; otherwise `FORMAT TabSeparatedWithNames` is appended. A
* trailing `;` is peeled either way (FORMAT must be the last clause). Empty
* input → `{ sql: '', format: 'TabSeparatedWithNames' }` — the caller no-ops
* on an empty `sql`. Pure.
* Peel a trailing `;` and any trailing SQL comments (line `-- …` / block
* `/* … *​/`) from `sql`, then resolve its output format: if what remains already
* ends in a `FORMAT <name>` clause (detectSqlFormat) that format is kept and
* reported; otherwise `fallbackFormat` is appended. Comments are peeled *before*
* the check so a `… FORMAT JSON -- note` isn't mis-read as unformatted (which
* would double the FORMAT) and so an appended clause lands after real SQL rather
* than after a line comment that would swallow it. Empty input → `{ sql: '',
* format: fallbackFormat }` (nothing is appended to an empty query). Pure —
* shared by the export prep and the dashboard tile fetch so this edge handling
* lives in one place.
*/
export function withTrailingFormat(sql, fallbackFormat) {
let s = String(sql || '').trim().replace(/;+\s*$/, '').trim();
let prev;
do {
prev = s;
s = s.replace(/--[^\n]*$/, '').replace(/\/\*[\s\S]*?\*\/\s*$/, '').trim();
} while (s !== prev);
const fmt = detectSqlFormat(s);
if (fmt) return { sql: s, format: fmt };
return { sql: s ? s + '\nFORMAT ' + fallbackFormat : s, format: fallbackFormat };
}

/**
* Resolve an editor query for a full (uncapped) export: its own trailing
* `FORMAT`, or `FORMAT TabSeparatedWithNames`. See `withTrailingFormat`. Empty
* input → `{ sql: '', format: 'TabSeparatedWithNames' }` — the caller no-ops on
* an empty `sql`. Pure.
*/
export function prepareExportSql(sql) {
const s = String(sql || '').trim().replace(/;+\s*$/, '').trim();
if (!s) return { sql: '', format: 'TabSeparatedWithNames' };
const fmt = detectSqlFormat(s);
return fmt
? { sql: s, format: fmt }
: { sql: s + '\nFORMAT TabSeparatedWithNames', format: 'TabSeparatedWithNames' };
return withTrailingFormat(sql, 'TabSeparatedWithNames');
}

const SCHEMA_MUTATING_RE = /^(CREATE|DROP|ALTER|RENAME|TRUNCATE|ATTACH|DETACH|EXCHANGE)\b/i;
Expand Down
51 changes: 36 additions & 15 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ import { handleKeydown } from './ui/shortcuts.js';
import { exchangeCodeForTokens, bearerFromTokens } from './net/oauth.js';
import { decodeShare } from './core/share.js';
import { cloneChartCfg } from './core/chart-data.js';
import { isDashboardRoute } from './core/dashboard.js';

export async function bootstrap(app, env) {
const loc = env.location;
const ss = env.sessionStorage;
const hist = env.history;
// The standalone dashboard route (#149) reuses this same bootstrap + app: it
// shares the OAuth-callback handling below, but renders the dashboard instead
// of the workbench and skips editor-only share-link seeding.
const dash = isDashboardRoute(loc.pathname);
const u = new URL(loc.href);
const code = u.searchParams.get('code');
const stateParam = u.searchParams.get('state');
Expand Down Expand Up @@ -55,21 +60,36 @@ export async function bootstrap(app, env) {
// A shared query (SQL + chart config) rides in the URL hash, which is lost
// through the OAuth redirect (and we strip it below). Stash it in
// sessionStorage so it survives the round-trip and restore it once we're back.
let shared = decodeShare(loc.hash);
if (shared.sql) ss.setItem('oauth_shared', JSON.stringify(shared));
else {
try { shared = JSON.parse(ss.getItem('oauth_shared') || 'null') || { sql: '', chart: null }; }
catch { shared = { sql: '', chart: null }; }
}
if (shared.sql) {
const t0 = app.state.tabs.value[0];
t0.sql = shared.sql;
t0.name = 'Shared query';
if (shared.chart && shared.chart.cfg) {
t0.chartCfg = cloneChartCfg(shared.chart.cfg);
t0.chartKey = shared.chart.key ?? null;
// The dashboard route has no editor tab to seed, so it skips this entirely.
if (!dash) {
let shared = decodeShare(loc.hash);
if (shared.sql) ss.setItem('oauth_shared', JSON.stringify(shared));
else {
try { shared = JSON.parse(ss.getItem('oauth_shared') || 'null') || { sql: '', chart: null }; }
catch { shared = { sql: '', chart: null }; }
}
if (shared.sql) {
const t0 = app.state.tabs.value[0];
t0.sql = shared.sql;
t0.name = 'Shared query';
if (shared.chart && shared.chart.cfg) {
t0.chartCfg = cloneChartCfg(shared.chart.cfg);
t0.chartKey = shared.chart.key ?? null;
}
hist.replaceState(null, '', loc.pathname + loc.search);
}
hist.replaceState(null, '', loc.pathname + loc.search);
}

// A freshly-opened dashboard tab is signed out (per-tab sessionStorage); try a
// one-time credential handoff from the opener before deciding what to render.
// A cold/bookmarked visit has no opener → falls through to the login screen,
// which after sign-in returns to /sql/dashboard and renders the dashboard.
if (dash && !app.isSignedIn()) {
await app.receiveAuthHandoff(env);
// The opener may hand over an *expired* id_token whose refresh token is still
// good (an idle opener refreshes only lazily). Attempt a refresh before
// giving up — otherwise a valid handoff would still bounce to a full re-login.
if (!app.isSignedIn()) await app.ensureFreshToken();
}

if (app.isSignedIn()) {
Expand All @@ -79,7 +99,7 @@ export async function bootstrap(app, env) {
// ch_auth=basic username, not the raw email claim) on first paint.
// (ensureConfig is a no-op in basic mode.)
await app.ensureConfig();
app.renderApp();
if (dash) app.renderDashboard(); else app.renderApp();
} else {
app.showLogin(callbackError);
}
Expand All @@ -95,6 +115,7 @@ if (typeof document !== 'undefined' && !globalThis.__ASB_NO_AUTOSTART__) {
sessionStorage: window.sessionStorage,
history: window.history,
fetch: window.fetch.bind(window),
opener: window.opener, // dashboard tab reads its opener for the auth handoff
});
}
/* c8 ignore stop */
21 changes: 18 additions & 3 deletions src/net/ch-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,28 @@ export async function authedFetch(ctx, url, sql, signal) {
}
}

/** Run a query and return parsed JSON (FORMAT JSON). Throws on CH error. `signal` (optional) aborts the request. */
export async function queryJson(ctx, sql, signal) {
const resp = await authedFetch(ctx, chUrl(ctx.origin, { format: 'JSON' }), sql, signal);
/**
* Run a query and return parsed JSON (FORMAT JSON). Throws on CH error. `signal`
* (optional) aborts the request. `extra` (optional) adds HTTP query-string
* settings (e.g. `{ readonly: 2 }` for a read-only tile).
*/
export async function queryJson(ctx, sql, signal, extra) {
const resp = await authedFetch(ctx, chUrl(ctx.origin, { format: 'JSON', extra }), sql, signal);
if (!resp.ok) throw new Error(parseExceptionText(await resp.text()));
return resp.json();
}

/**
* Run a favorite's SQL for a read-only dashboard tile (#149): `FORMAT JSON` plus
* the `readonly=2` HTTP setting, so a favorite that happens to contain a write
* (INSERT / ALTER / DROP / …) is rejected server-side rather than executed when
* the dashboard opens or refreshes — level 2 still permits SELECT and
* query-level `SETTINGS`. Returns parsed JSON; throws CH's reason on error.
*/
export function queryDashboardTile(ctx, sql, signal) {
return queryJson(ctx, sql, signal, { readonly: 2 });
}

/**
* Run a `system.tables`/`system.columns` query (`sqlBody`, without its FORMAT
* clause) with data-lake-catalog visibility enabled, falling back to the plain
Expand Down
Loading