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
1 change: 1 addition & 0 deletions src/core/stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function newResult(fmt) {
rawFormat: fmt,
progress: { rows: 0, bytes: 0, elapsed_ns: 0 },
error: null,
cancelled: false,
pct: 0,
};
}
Expand Down
21 changes: 20 additions & 1 deletion src/net/ch-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ export async function queryJson(ctx, sql) {
return resp.json();
}

/**
* Best-effort `KILL QUERY` for the given query_id (the client also aborts the
* stream; this stops the server-side work). Swallows errors — cancellation must
* never throw at the call site, and the user lacking the privilege is non-fatal.
*/
export async function killQuery(ctx, queryId, sqlString) {
if (!queryId) return;
try {
await queryJson(ctx, 'KILL QUERY WHERE query_id = ' + sqlString(queryId) + ' ASYNC');
} catch { /* best-effort */ }
}

/** Fetch `version()` + `uptime()`. Returns the version string ('' on shape miss). */
export async function loadServerVersion(ctx) {
const json = await queryJson(ctx, 'SELECT version() AS v, uptime() AS u FORMAT JSON');
Expand Down Expand Up @@ -139,7 +151,14 @@ export async function runQuery(ctx, sql, o = {}) {
: 'JSONCompact';
const url = chUrl(ctx.origin, {
format: fmtParam,
extra: { wait_end_of_query: 1, add_http_cors_header: 1 },
// wait_end_of_query buffers the whole response server-side so the HTTP
// status reflects errors — but it defeats progressive streaming (first rows
// wait for the query to finish: ~16s vs ~0.5s on a 1.3M-row scan). Keep it
// only for raw modes (read whole anyway); the streaming Table path drops it
// and surfaces mid-stream errors via the in-band `exception` line instead.
extra: { ...(isStreaming ? {} : { wait_end_of_query: 1 }), add_http_cors_header: 1 },
// Tagging the request with a query_id lets Cancel issue KILL QUERY for it.
params: o.queryId ? { query_id: o.queryId } : {},
});
const resp = await authedFetch(ctx, url, sql, o.signal);

Expand Down
42 changes: 31 additions & 11 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -866,17 +866,37 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
border-radius: 4px; color: var(--fg-mute);
}

/* progress */
.progress-bar {
position: absolute; left: 0; right: 0; bottom: 0;
height: 2px; background: transparent;
overflow: hidden; pointer-events: none;
}
.progress-bar > i {
display: block; height: 100%;
background: var(--accent);
width: var(--progress, 0%);
transition: width .15s linear;
/* streaming progress strip + live run state */
.stream-strip {
position: absolute; left: 0; right: 0; top: 0;
height: 2px; background: var(--bg-chip);
overflow: hidden; pointer-events: none; z-index: 2;
}
.stream-strip > i { display: block; height: 100%; background: var(--accent); }
.stream-strip > i.fill { transition: width .3s linear; }
.stream-strip > i.sweep { width: 40%; animation: runsweep 1.1s ease-in-out infinite; }
@keyframes runsweep {
0% { transform: translateX(-110%); }
100% { transform: translateX(310%); }
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { display: inline-flex; animation: spin .8s linear infinite; }
.placeholder.starting { color: var(--fg-mute); }
.placeholder.starting .spin { color: var(--accent); }
/* live run counters (accent) shown in the results toolbar while streaming */
.stat.live, .stat.live .ic, .stat.live .v { color: var(--accent); }
.res-act.cancel-act:hover {
background: color-mix(in oklab, #ef4444 14%, transparent);
color: #ef4444; border-color: transparent;
}
.res-act kbd {
font-family: var(--mono); font-size: 9.5px; opacity: .7;
padding: 1px 4px; background: var(--bg-chip); border-radius: 3px; margin-left: 2px;
}
.cancelled-badge {
font-family: var(--mono); font-size: 10.5px; color: #ef4444;
padding: 2px 7px; border-radius: 4px;
background: color-mix(in oklab, #ef4444 12%, transparent);
}

/* scrollbars */
Expand Down
48 changes: 38 additions & 10 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,29 +249,41 @@ export function createApp(env = {}) {
}

// --- query run ---------------------------------------------------------
const now = () => (env.now || (() => win.performance.now()))();
// Milliseconds since the running query started (0 when idle). Used for the
// live counter, computed fresh so each render/tick shows the current value.
app.elapsedMs = () => (app.state.runT0 != null ? now() - app.state.runT0 : 0);
// Update only the live elapsed-ms readout (no table re-render). Driven by an
// interval while running so it ticks even for queries that emit no rows (sleep).
function tickElapsed() {
if (app.dom.runElapsedEl) app.dom.runElapsedEl.textContent = app.elapsedMs().toFixed(0) + ' ms';
}
app.tickElapsed = tickElapsed;

async function run() {
if (app.state.running) {
if (app.state.abortController) app.state.abortController.abort();
return;
}
if (app.state.running) return; // already running — cancel via cancel()/Esc
const tab = app.activeTab();
if (!tab.sql.trim()) return;
await ensureConfig();
if (!(await getToken())) { chCtx.onSignedOut(); return; }

const fmt = app.state.outputFormat || 'Table';
const t0 = (env.now || (() => win.performance.now()))();
const t0 = now();
tab.result = newResult(fmt);
app.state.resultSort = { col: null, dir: 'asc' };
app.state.resultView = 'table';
app.state.running = true;
app.state.runT0 = t0;
app.state.runQueryId = cryptoObj.randomUUID ? cryptoObj.randomUUID() : 'q' + t0;
setRunBtn(true);
renderResults(app);
app.state.abortController = new AbortController();
app.state.runTick = setInterval(tickElapsed, 100);

try {
const out = await ch.runQuery(chCtx, tab.sql, {
format: fmt,
queryId: app.state.runQueryId,
signal: app.state.abortController.signal,
onLine: (json) => applyStreamLine(json, tab.result),
onChunk: () => renderResults(app),
Expand All @@ -282,24 +294,39 @@ export function createApp(env = {}) {
tab.result.progress.bytes = out.raw.length;
}
} catch (e) {
if (e.name === 'AbortError') tab.result.error = 'Query was cancelled';
// Cancel = abort: keep whatever streamed in, flag it partial (no error).
if (e.name === 'AbortError') tab.result.cancelled = true;
else if (e instanceof TypeError) tab.result.error = 'Network error';
else tab.result.error = String((e && e.message) || e);
} finally {
clearInterval(app.state.runTick);
app.state.runTick = null;
app.state.running = false;
app.state.abortController = null;
tab.result.progress.elapsed_ns = ((env.now || (() => win.performance.now()))() - t0) * 1e6;
app.state.runQueryId = null;
app.state.runT0 = null;
tab.result.progress.elapsed_ns = (now() - t0) * 1e6;
setRunBtn(false);
renderResults(app);
if (!tab.result.error) app.recordHistory(tab);
if (!tab.result.error && !tab.result.cancelled) app.recordHistory(tab);
}
}
// Stop an in-flight query: abort the stream and KILL QUERY on the server.
function cancel() {
if (!app.state.running) return;
if (app.state.abortController) app.state.abortController.abort();
ch.killQuery(chCtx, app.state.runQueryId, sqlString);
}
function setRunBtn(running) {
if (!app.dom.runBtn) return;
app.dom.runBtn.disabled = running;
app.dom.runBtn.replaceChildren(Icon.play(), h('span', null, running ? 'Running…' : 'Run'),
running ? null : h('kbd', null, '⌘↵'));
// Build the children and drop the null (replaceChildren would otherwise
// coerce a null arg into a "null" text node → "Running…null").
app.dom.runBtn.replaceChildren(
...[Icon.play(), h('span', null, running ? 'Running…' : 'Run'),
running ? null : h('kbd', null, '⌘↵')].filter(Boolean));
}
app.setRunBtn = setRunBtn;

// Pretty-print the editor's SQL via ClickHouse's formatQuery(), in place.
async function formatQuery() {
Expand Down Expand Up @@ -524,6 +551,7 @@ export function createApp(env = {}) {
// --- actions registry --------------------------------------------------
app.actions = {
run,
cancel,
newTab: () => newTab(app),
selectTab: (id) => selectTab(app, id),
closeTab: (id) => closeTab(app, id),
Expand Down
1 change: 1 addition & 0 deletions src/ui/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const Icon = {
play: () => svgFilled('M3 2l7 4-7 4z'),
plus: () => svg('M6 2v8M2 6h8', 12, 12, { stroke: 1.6 }),
close: () => svg('M2 2l6 6M8 2l-6 6', 10, 10, { stroke: 1.6 }),
spinner: () => svg('M6 1.2a4.8 4.8 0 1 1-4.8 4.8', 12, 12, { stroke: 1.6 }),
search: () => iconEl('<circle cx="5" cy="5" r="3"/><path d="M7.5 7.5L10 10"/>', 12, 12, 1.5),
sun: () => iconEl('<circle cx="7" cy="7" r="2.4"/><path d="M7 1.5v1.4M7 11.1v1.4M1.5 7h1.4M11.1 7h1.4M3 3l1 1M10 10l1 1M11 3l-1 1M4 10l-1 1"/>'),
moon: () => svg('M11 7.5A4 4 0 1 1 6.5 3a3.2 3.2 0 0 0 4.5 4.5z', 14, 14),
Expand Down
39 changes: 31 additions & 8 deletions src/ui/results.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,14 @@ export function renderResults(app) {
body.appendChild(buildToolbar(app, r));

const inner = h('div', { class: 'res-body' });
// While running, pin a streaming strip to the top of the body: a determinate
// fill at read/total when known, else an indeterminate sweep.
if (app.state.running) inner.appendChild(streamStrip(r));
const streamingBlank = app.state.running && (!r || (r.rows.length === 0 && r.rawText == null));
if (streamingBlank) {
inner.appendChild(h('div', { class: 'progress-bar', style: { '--progress': (r ? r.pct : 0) + '%' } }, h('i')));
inner.appendChild(h('div', { class: 'placeholder' },
h('div', null, 'Streaming results…'),
r ? h('code', null, formatRows(r.progress.rows) + ' rows · ' + formatBytes(r.progress.bytes)) : null));
inner.appendChild(h('div', { class: 'placeholder starting' },
h('span', { class: 'spin' }, Icon.spinner()),
h('div', null, 'Starting query…')));
} else if (!r) {
inner.appendChild(h('div', { class: 'empty-results' },
h('div', { class: 'chip' }, Icon.play()),
Expand All @@ -100,14 +102,19 @@ export function renderResults(app) {
inner.appendChild(renderChart(r));
} else {
inner.appendChild(renderTable(app, r));
if (app.state.running) {
inner.appendChild(h('div', { class: 'progress-bar', style: { '--progress': r.pct + '%' } }, h('i')));
}
}
body.appendChild(inner);
region.replaceChildren(body);
}

// 2px progress strip atop the results body while a query streams.
function streamStrip(r) {
return h('div', { class: 'stream-strip' },
r && r.pct > 0
? h('i', { class: 'fill', style: { width: r.pct + '%' } })
: h('i', { class: 'sweep' }));
}

function buildToolbar(app, r) {
const isRaw = r && r.rawText != null;
const toolbar = h('div', { class: 'res-toolbar' });
Expand All @@ -128,7 +135,23 @@ function buildToolbar(app, r) {
}
toolbar.appendChild(tabs);
toolbar.appendChild(h('div', { style: { flex: '1' } }));
if (r) {
if (app.state.running) {
// Live counters (accent, mono) + Cancel — replaces the static stats while
// streaming. The ms element is updated in place by app.tickElapsed().
app.dom.runElapsedEl = h('span', { class: 'v' }, app.elapsedMs().toFixed(0) + ' ms');
toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic spin' }, Icon.spinner()), app.dom.runElapsedEl));
toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic' }, Icon.rows()),
h('span', { class: 'v' }, formatRows(r ? r.progress.rows : 0) + ' rows')));
toolbar.appendChild(h('div', { class: 'stat live' }, h('span', { class: 'ic' }, Icon.bytes()),
h('span', { class: 'v' }, formatBytes(r ? r.progress.bytes : 0))));
toolbar.appendChild(h('button', {
class: 'res-act cancel-act', title: 'Cancel query (Esc)',
onclick: () => app.actions.cancel(),
}, Icon.close(), h('span', null, 'Cancel'), h('kbd', null, 'Esc')));
} else if (r) {
if (r.cancelled) {
toolbar.appendChild(h('span', { class: 'cancelled-badge' }, 'Cancelled · partial'));
}
const ms = (r.progress.elapsed_ns / 1e6).toFixed(0);
toolbar.appendChild(h('div', { class: 'stat' }, h('span', { class: 'ic' }, Icon.clock()), h('span', { class: 'v' }, ms + ' ms')));
toolbar.appendChild(h('div', { class: 'stat' }, h('span', { class: 'ic' }, Icon.rows()),
Expand Down
6 changes: 6 additions & 0 deletions src/ui/shortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ export function openShortcuts(app) {
export function handleKeydown(e, app) {
const mod = e.metaKey || e.ctrlKey;
const signedIn = app.isSignedIn();
// Esc cancels an in-flight query (aborts the stream + KILL QUERY).
if (e.key === 'Escape' && app.state.running) {
e.preventDefault();
app.actions.cancel();
return 'cancel';
}
if (mod && e.key === 'Enter') {
// ⌘/Ctrl+Shift+Enter = format (gated by sign-in); ⌘/Ctrl+Enter = run.
if (e.shiftKey) {
Expand Down
2 changes: 2 additions & 0 deletions tests/helpers/fake-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function makeApp(over = {}) {
savePref: vi.fn(),
saveJSON: vi.fn(),
updateSaveBtn: vi.fn(),
elapsedMs: () => 0,
editingSavedId: null,
showLogin: vi.fn(),
signOut: vi.fn(),
Expand All @@ -33,6 +34,7 @@ export function makeApp(over = {}) {
},
actions: {
run: vi.fn(),
cancel: vi.fn(),
newTab: vi.fn(),
selectTab: vi.fn(),
closeTab: vi.fn(),
Expand Down
47 changes: 42 additions & 5 deletions tests/unit/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,47 @@ describe('query run', () => {
await app.actions.run();
expect(app.activeTab().result).toBeNull();
});
it('aborts a running query on a second invocation', async () => {
it('run() while already running is a no-op (cancel is separate)', async () => {
const { app } = appForRun([]);
app.state.running = true;
app.state.abortController = { abort: vi.fn() };
const ac = { abort: vi.fn() };
app.state.abortController = ac;
await app.actions.run();
expect(app.state.abortController).toBeTruthy();
expect(ac.abort).not.toHaveBeenCalled(); // re-running no longer aborts
expect(app.state.running).toBe(true);
});
it('setRunBtn: "Running…" with no trailing "null"; "Run" + kbd when idle', () => {
const { app } = appForRun([]);
app.setRunBtn(true);
expect(app.dom.runBtn.disabled).toBe(true);
expect(app.dom.runBtn.textContent).toBe('Running…'); // regression: not "Running…null"
app.setRunBtn(false);
expect(app.dom.runBtn.disabled).toBe(false);
expect(app.dom.runBtn.textContent).toContain('Run');
expect(app.dom.runBtn.querySelector('kbd')).not.toBeNull();
});
it('tickElapsed updates the live ms readout, and no-ops without the element', () => {
const { app } = appForRun([]);
app.state.runT0 = 0;
app.dom.runElapsedEl = document.createElement('span');
app.tickElapsed(); // env.now → 0
expect(app.dom.runElapsedEl.textContent).toBe('0 ms');
app.dom.runElapsedEl = null;
expect(() => app.tickElapsed()).not.toThrow();
});
it('cancel() aborts + issues KILL QUERY when running; no-op when idle', async () => {
const { app, e } = appForRun([]);
app.actions.cancel(); // idle → no-op, no throw
const abort = vi.fn();
app.state.running = true;
app.state.abortController = { abort, signal: {} };
app.state.runQueryId = 'qid-1';
app.actions.cancel();
expect(abort).toHaveBeenCalled();
await new Promise((r) => setTimeout(r)); // let the fire-and-forget KILL QUERY run
const kill = e.fetch.mock.calls.find((c) => /KILL QUERY/.test((c[1] && c[1].body) || ''));
expect(kill).toBeTruthy();
expect(kill[1].body).toContain("query_id = 'qid-1'");
});
it('surfaces a query error', async () => {
const { app } = appForRun([
Expand Down Expand Up @@ -589,13 +624,15 @@ describe('exhaustive controller coverage', () => {
await app.actions.run();
expect(app.activeTab().result.error).toBe('Network error');
});
it('run(): AbortError → "Query was cancelled"', async () => {
it('run(): AbortError marks the result cancelled (keeps partial rows, no error)', async () => {
const e = env({ fetch: vi.fn(async () => { const err = new Error('x'); err.name = 'AbortError'; throw err; }) });
const app = createApp(e);
app.renderApp();
app.activeTab().sql = 'SELECT 1';
await app.actions.run();
expect(app.activeTab().result.error).toBe('Query was cancelled');
expect(app.activeTab().result.cancelled).toBe(true);
expect(app.activeTab().result.error).toBeNull();
expect(app.state.history.length).toBe(0); // cancelled runs are not recorded
});
it('run(): generic error → message', async () => {
const e = env({ fetch: vi.fn(async () => { throw new Error('weird'); }) });
Expand Down
Loading
Loading