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 @@ -120,6 +120,24 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
and the exception-code Filter and "Errors over time" panel are removed.

### Changed
- **Explicit, favorited KPI queries now render as full-width Dashboard KPI
bands instead of nested inside a generic gray tile** (#240). A KPI band
spans every Dashboard column regardless of the selected Full width/Report/
2-column/3-column layout; consecutive explicit `panel.cfg.type==='kpi'`
favorites merge into one flat, wrapping card stream (favorite order, then
result-column order), with no per-favorite name, description, or
rows/time/bytes footer. Loading, missing-parameter, and error states render
as compact in-stream state cards (naming the source query); one source's
failure never hides its band siblings; warnings render below the band,
each naming its query. Cards use controlled, content-driven widths
(160–320px desktop, full row under 520px). An auto-detected (unconfigured)
one-row KPI result is unaffected — it remains an ordinary tile, following
the selected layout, exactly as before. `src/ui/kpi-panel.js` gained a
lower-level `renderKpiCards()` primitive (the individual card nodes,
decoupled from the workbench's `.kpi-panel/.kpi-grid` wrapper) that both the
workbench preview and the new `src/ui/dashboard-kpi-band.js` module share;
`core/dashboard.js` gained the pure `partitionKpiBands()` grouping. No
schema change, no new runtime dependency.
- **`src/core/clickhouse-type.js` is now the sole ClickHouse type-expression
parser** (#238), replacing `param-type.js`'s independent regex parser; the
latter is now a thin compatibility projection deriving everything from the
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,17 @@ The implemented **KPI** panel turns an exactly-one-row result into responsive
cards: numeric scalar columns become simple KPIs, while named ClickHouse
`Tuple(value numeric, delta Nullable(numeric))` columns add an optional delta.
SQL owns the values; `panel.fieldConfig` owns labels, descriptions, units,
rounding, colors, NULL text, visibility, and delta semantics. The complete
rounding, colors, NULL text, visibility, and delta semantics. The card
rendering itself — labels, values, deltas, colors — is identical on both
surfaces; the surrounding composition differs by design (#240): the workbench
Panel preview and an unconfigured Dashboard KPI tile show the cards inside the
ordinary `.kpi-panel` grid, while a **favorited, explicitly-KPI-typed** Dashboard
query instead joins a full-width **KPI band** — a flat, wrapping card stream
with no per-favorite name, description, or statistics footer, spanning every
Dashboard layout (Full width/Report/2/3 columns). Consecutive explicit KPI
favorites merge into one shared band. The complete
[`kpi-panel.json`](examples/kpi-panel.json) Library example can be opened from
**File ▾ → Open** and renders identically in the workbench and Dashboard.
**File ▾ → Open** to see both.
When constructing a named tuple from expressions, either enable alias-derived
member names for the query:

Expand Down
25 changes: 25 additions & 0 deletions src/core/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,28 @@ export const DASH_TABLE_DISPLAY_CAP = 1000;
// `fieldControls(analysis)` in param-pipeline.js replaces the old
// `dashboardParams(favorites)` union — the analysis view also sees params
// confined to optional blocks, which readStatementParams never could.)

/**
* Partition the ordered Panel-role favorites into Dashboard layout items
* (#240): a maximal consecutive run of explicit KPI favorites becomes one
* `{kind:'kpi-band', indices}` (a full-width shared card stream); every other
* favorite is its own `{kind:'tile', index}`. `isKpiFlags[i]` is true only for
* an EXPLICIT `panel.cfg.type === 'kpi'` favorite — an auto-detected one-row
* result must never join a band, so the caller derives this from the saved
* cfg, never from a query's executed result. Structural only (no query
* results involved), so bands are fixed before any tile issues a request.
*/
export function partitionKpiBands(isKpiFlags) {
const items = [];
let run = null;
isKpiFlags.forEach((isKpi, index) => {
if (isKpi) {
if (!run) { run = { kind: 'kpi-band', indices: [] }; items.push(run); }
run.indices.push(index);
} else {
run = null;
items.push({ kind: 'tile', index });
}
});
return items;
}
10 changes: 10 additions & 0 deletions src/core/panel-execution.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { detectSqlFormat } from './format.js';
import { queryPanel } from './saved-query.js';

export function isKpiPanel(panel) {
return panel?.cfg?.type === 'kpi';
}

/** A saved query's explicit, known-typed panel payload, or null. Unknown
* panel-cfg shapes stay non-null-ish only through resolvePanel's diagnostic
* fallback. Shared by the Dashboard's ordinary-tile path and its KPI-band
* partitioning/execution (#240) so eligibility can never drift between them. */
export function explicitPanel(query) {
const panel = queryPanel(query);
return panel && panel.cfg && typeof panel.cfg === 'object' ? panel : null;
}

/** Resolve the transport owned by an explicit panel without changing SQL. */
export function panelExecution(panel, sql, defaults = {}) {
if (!isKpiPanel(panel)) return { ...defaults, owned: false, error: null, params: { ...(defaults.params || {}) } };
Expand Down
37 changes: 37 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2280,6 +2280,43 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
border-top: 1px solid var(--border-faint); font-family: var(--mono);
font-size: 10.5px; color: var(--fg-faint);
}
/* KPI bands (#240): a full-width composition primitive, not a tile — spans
every Dashboard grid column regardless of the selected Full width/Report/
2 columns/3 columns layout, so several consecutive explicit KPI favorites
merge into one dense card stream instead of nested per-tile grids. */
.dash-kpi-band { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 8px; }
.dash-kpi-stream {
display: flex; flex-wrap: wrap; align-items: stretch; gap: 10px;
}
.dash-kpi-stream .kpi-card {
flex: 0 1 auto; inline-size: fit-content; min-inline-size: 160px; max-inline-size: 320px;
}
/* A source's stable host contributes its children (cards, or a state card)
directly to the stream's flex-wrap — it is never itself a visible box. */
.dash-kpi-source { display: contents; }
.dash-kpi-state-card {
--kpi-accent: var(--accent);
min-width: 160px; max-width: 320px; padding: 14px 16px;
border: 1px solid var(--border); border-top: 3px solid var(--kpi-accent);
border-radius: 8px; background: var(--bg-modal);
}
.dash-kpi-state-label {
color: var(--fg-mute); font-size: 12px; font-weight: 600; letter-spacing: .02em;
}
.dash-kpi-state-message {
margin-top: 6px; display: flex; flex-direction: column; gap: 4px;
color: var(--fg); font-size: 13px; line-height: 1.4;
}
.dash-kpi-state-loading { display: flex; align-items: center; gap: 8px; }
.dash-kpi-state-card[role='alert'] .dash-kpi-state-message { color: var(--error-fg); }
.dash-kpi-warnings { display: grid; gap: 4px; }
.dash-kpi-warning { color: var(--fg-mute); font-size: 12px; }
@media (max-width: 520px) {
.dash-kpi-stream .kpi-card, .dash-kpi-state-card {
flex-basis: 100%; min-inline-size: 0; max-inline-size: none; inline-size: 100%;
min-width: 0; max-width: none; width: 100%;
}
}
@media (max-width: 640px) {
.dash-grid { grid-template-columns: 1fr; padding: 12px; }
.dash-header { padding: 10px 12px; gap: 8px; }
Expand Down
151 changes: 151 additions & 0 deletions src/ui/dashboard-kpi-band.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// The Dashboard KPI band (#240): consecutive explicit `panel.cfg.type==='kpi'`
// favorites render as one full-width, flat card stream instead of each being
// its own gray tile with a nested KPI grid. Isolated from `src/ui/dashboard.js`
// so its own branches (state cards, warning aggregation) get independent test
// coverage rather than inflating that file's already-100%-covered functions.
//
// A band owns `{ el, stream, warningHost, sources }`: `stream` is the flex-wrap
// `.dash-kpi-stream` card row (spans every Dashboard grid column via CSS,
// independent of the selected tile layout); `warningHost` is the band's shared
// `.dash-kpi-warnings` area below it. Each member favorite gets one stable
// `.dash-kpi-source` slot (`display:contents` — its children participate
// directly in the stream's flex-wrap without adding a visible box) appended to
// `stream` in favorite order at band-build time and never reordered; a source's
// own request lifecycle only ever replaces ITS host's children in place
// (loading → success cards | state card), mirroring the ordinary tile slot's
// "never remove/reappend" discipline (dashboard.js's buildTileSlot) so the
// #193 stable-slot-identity/generation/abort guarantees extend unchanged to
// KPI sources.

import { h } from './dom.js';
import { Icon } from './icons.js';
import { resolvePanel } from '../core/panel-cfg.js';
import { renderKpiCards, KPI_STREAM_ARIA } from './kpi-panel.js';

/** One compact white state card (loading/unfilled/error) — the query name
* plus a message, replacing the KPI success cards in a source's stable host
* while it has none to show. `role` drives assistive-tech behavior: `status`
* (+ `aria-live=polite` for loading) or `alert` for errors. */
function kpiStateCard(name, role, live, ...messageChildren) {
const attrs = { class: 'dash-kpi-state-card', role, 'aria-label': name };
if (live) attrs['aria-live'] = 'polite';
return h('div', attrs,
h('div', { class: 'dash-kpi-state-label' }, name),
h('div', { class: 'dash-kpi-state-message' }, ...messageChildren));
}

/** Build one KPI band container: a full-width `.dash-kpi-band` holding the
* card `stream` and a `warningHost` for its shared warning area (rendered
* only while non-empty). `sources` accumulates this band's member slots in
* favorite order, read back by `refreshBandWarnings`. */
export function buildKpiBand() {
const stream = h('div', { class: 'dash-kpi-stream', ...KPI_STREAM_ARIA });
const warningHost = h('div', { class: 'dash-kpi-warnings', style: { display: 'none' } });
const el = h('div', { class: 'dash-kpi-band' }, stream, warningHost);
return { el, stream, warningHost, sources: [] };
}

/** Build one favorite's stable KPI source slot, append its host into the
* band's stream (favorite order), and register it on the band for warning
* aggregation. `explicit` (the favorite's saved `cfg.type==='kpi'` panel) is
* cached on the slot once, here, at the same structural build time
* `partitionKpiBands` already establishes eligibility — so a later wave's
* dispatch (dashboard.js's runPlan) reads `slot.explicit` instead of
* re-deriving it every Refresh/filter-affected run. `abortController` mirrors
* buildTileSlot's field exactly, so runFavoriteSource's existing generation-
* guard/abort dispatch works unchanged over a KPI source. */
export function buildKpiSourceSlot(band, explicit, name) {
const host = h('div', { class: 'dash-kpi-source' });
const slot = {
kind: 'kpi-source', host, band, name, explicit, warnings: [],
gen: 0, status: null, abortController: null, loadLabel: null,
};
band.sources.push(slot);
band.stream.appendChild(host);
return slot;
}

/** Rebuild a band's shared warning area from every member source's current
* `warnings`, in source order (favorite order, set at band-build time) then
* diagnostic order — a stale rerun replaces it wholesale, never appends.
* Every entry is always `severity:'warning'` (renderKpiCards's `warnings`
* output is pre-filtered to that severity) so the role/class are fixed,
* not diagnostic-driven — a blocking (`error`) diagnostic is a state card,
* never a band warning. */
export function refreshBandWarnings(band) {
const all = band.sources.flatMap((slot) => slot.warnings);
band.warningHost.style.display = all.length ? '' : 'none';
band.warningHost.replaceChildren(...all.map((w) => h('div', {
class: 'dash-kpi-warning', role: 'status',
}, `${w.sourceName}: ${w.message}`)));
}

/** One compact loading state card, in the source's stable position. Returns
* the live message text node so streamed row progress (onChunk, #193) can
* update just its text, exactly like the ordinary tile's loading label.
* Does NOT itself call `refreshBandWarnings` — a Refresh wave marks every
* affected source loading in one synchronous pass (dashboard.js's runPlan),
* and rebuilding the shared band DOM once per source in that pass would be
* N redundant O(N) rebuilds before the first ever paints; the caller
* refreshes each distinct touched band exactly once after the pass instead. */
export function setKpiSourceLoading(slot) {
slot.status = 'loading';
slot.warnings = [];
const label = h('span', null, 'Loading…');
slot.loadLabel = label;
const row = h('div', { class: 'dash-kpi-state-loading' }, Icon.spinner(), label);
slot.host.replaceChildren(kpiStateCard(slot.name, 'status', true, row));
return label;
}

/** A KPI source blocked on an empty/invalid `{name:Type}` value (#170) — one
* filter value away from rendering, so it stays in its stable position with
* a neutral prompt rather than an error. */
export function setKpiSourceUnfilled(slot, names) {
slot.status = 'unfilled';
slot.warnings = [];
slot.host.replaceChildren(kpiStateCard(slot.name, 'status', false, 'Enter a value for: ' + names.join(', ')));
refreshBandWarnings(slot.band);
}

/** Apply a completed (or errored) result to one KPI source: a transport/SQL
* error or a blocking KPI diagnostic (zero rows, wrong row count, no
* eligible fields) renders as one state card; otherwise the normalized KPI
* cards replace the source's host contents and its warnings feed the band's
* shared area. `explicit` is always a `cfg.type==='kpi'` panel here — band
* membership is gated on exactly that at partition time (core/dashboard.js's
* `partitionKpiBands`), so `resolvePanel`'s kpi branch is unconditional and
* its non-kpi fallback path can't be reached from a KPI source. Streamed row
* progress during the fetch updates `label.textContent` directly (see
* dashboard.js), never re-entering this function mid-stream. */
export function applyKpiSourceResult(app, explicit, slot, r) {
const name = slot.name;
if (r.error != null) {
slot.status = 'error';
slot.warnings = [];
slot.host.replaceChildren(kpiStateCard(name, 'alert', false, r.error));
refreshBandWarnings(slot.band);
return;
}
const resolved = resolvePanel(explicit, {
columns: r.columns, rows: r.rows, fieldConfig: explicit.fieldConfig, serverVersion: app.state.serverVersion,
});
const { cards, warnings, errors } = renderKpiCards(resolved.kpi);
if (errors.length) {
slot.status = 'error';
slot.warnings = [];
// Every blocking diagnostic stacks as its own line in the ONE state card
// (never dropped) — the workbench's renderKpiPanel renders the same
// `errors` list in full (kpi-panel.js), so the two surfaces show
// identical diagnostic detail for identical data.
const role = errors.some((d) => d.severity === 'error') ? 'alert' : 'status';
const lines = errors.map((d) => h('div', null, d.message));
slot.host.replaceChildren(kpiStateCard(name, role, false, ...lines));
refreshBandWarnings(slot.band);
return;
}
slot.status = 'panel';
slot.warnings = warnings.map((w) => ({ ...w, sourceName: name }));
slot.host.replaceChildren(...cards);
refreshBandWarnings(slot.band);
}
Loading