Rebuild dashboard panels on the new chart layer

Drag-and-drop grid stays on GridStack (already a Phase 3 dependency --
no new library needed). PanelEditor.svelte (a Modal) replaces the old
inline add-panel form: a debounced live preview reuses PanelViz
directly, so the preview is pixel-identical to what renders on save
instead of drifting from a separate preview renderer. Dashboards list
and detail pages get EmptyState/Skeleton for empty/loading states
instead of a blank panel or a raw error string, and panel titles are
now clickable buttons that open the editor.
This commit is contained in:
2026-08-16 12:35:49 -07:00
parent 5e8b3d8edd
commit 45e0865a0c
3 changed files with 477 additions and 128 deletions
+310
View File
@@ -0,0 +1,310 @@
<script lang="ts">
// Add and edit share one editor: creating a panel and changing its
// query/viz afterwards are the same form, just seeded differently and
// calling addPanel vs updatePanel on save. The preview pane below the
// form runs the actual query (debounced -- not on every keystroke)
// and renders it through the real PanelViz, not a separate mock-up,
// so what you see here is exactly what lands on the dashboard, not
// an approximation of it.
import { Modal, Button, Input, Select, Tabs } from '$lib/components/ui';
import QueryBar from '$lib/QueryBar.svelte';
import PanelViz from '$lib/PanelViz.svelte';
import {
runQuery,
injectTimeRange,
addPanel,
updatePanel,
type Panel,
type VizType,
type Language,
type QueryResult
} from '$lib/api';
let {
open = $bindable(false),
dashboardId,
panel = null,
dashboardEarliest,
dashboardLatest,
nextY,
onSaved
}: {
open?: boolean;
dashboardId: string;
panel?: Panel | null;
dashboardEarliest: string;
dashboardLatest: string;
// Only consulted when adding a new panel -- stacks it below
// whatever's already on the grid. The dashboard page owns panel
// layout, so it owns this calculation too.
nextY: () => number;
onSaved: () => void;
} = $props();
let title = $state('');
let query = $state('');
let language = $state<Language>('');
let vizType = $state<VizType>('table');
let vizConfig = $state<Record<string, string>>({});
let earliestOverride = $state('');
let latestOverride = $state('');
let saving = $state(false);
let saveError = $state('');
// Re-seed whenever the editor opens (not on every `panel` change --
// the dashboard page keeps `panel` pointed at the same object while
// editing, this should only reset when a *different* panel or a
// fresh "new panel" session opens).
let seededFor: string | null = null;
$effect(() => {
if (!open) {
seededFor = null;
return;
}
const key = panel?.id ?? '__new__';
if (seededFor === key) return;
seededFor = key;
title = panel?.title ?? '';
query = panel?.query ?? '';
language = panel?.query_language ?? '';
vizType = panel?.viz_type ?? 'table';
vizConfig = { ...(panel?.viz_config ?? {}) };
earliestOverride = panel?.earliest_override ?? '';
latestOverride = panel?.latest_override ?? '';
saveError = '';
});
let previewResult = $state<QueryResult | null>(null);
let previewError = $state('');
let previewLoading = $state(false);
let debounceHandle: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
// Deliberate dependency list: re-run the preview when any of these
// change, debounced so typing a query doesn't fire a request per
// keystroke.
query;
language;
earliestOverride;
latestOverride;
if (!open || !query.trim()) {
previewResult = null;
return;
}
clearTimeout(debounceHandle);
debounceHandle = setTimeout(runPreview, 400);
return () => clearTimeout(debounceHandle);
});
async function runPreview() {
previewLoading = true;
previewError = '';
try {
const earliest = earliestOverride || dashboardEarliest;
const latest = latestOverride || dashboardLatest;
previewResult = await runQuery(injectTimeRange(query, earliest, latest), language);
} catch (e) {
previewError = e instanceof Error ? e.message : String(e);
previewResult = null;
} finally {
previewLoading = false;
}
}
async function save() {
if (!query.trim()) return;
saving = true;
saveError = '';
try {
const input = {
title,
query,
query_language: language,
viz_type: vizType,
viz_config: vizConfig,
earliest_override: earliestOverride || null,
latest_override: latestOverride || null
};
if (panel) {
await updatePanel(dashboardId, { ...panel, ...input });
} else {
await addPanel(dashboardId, {
...input,
position_x: 0,
position_y: nextY(),
width: 6,
height: 4
});
}
open = false;
onSaved();
} catch (e) {
saveError = e instanceof Error ? e.message : String(e);
} finally {
saving = false;
}
}
function setConfig(key: string, value: string) {
vizConfig = { ...vizConfig, [key]: value };
}
const vizOptions: { value: VizType; label: string }[] = [
{ value: 'table', label: 'Table' },
{ value: 'line', label: 'Line chart' },
{ value: 'bar', label: 'Bar chart' },
{ value: 'single_stat', label: 'Single stat' },
{ value: 'top_n', label: 'Top-N' },
{ value: 'heatmap', label: 'Heatmap' }
];
let tabs = [
{ id: 'query', label: 'Query' },
{ id: 'preview', label: 'Preview' }
];
let activeTab = $state('query');
</script>
<Modal bind:open title={panel ? 'Edit panel' : 'Add panel'}>
<div class="editor">
<Input placeholder="Panel title" bind:value={title} />
<label class="field-label" for="viz-type">Visualization</label>
<Select id="viz-type" bind:value={vizType}>
{#each vizOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</Select>
{#if vizType === 'line' || vizType === 'bar'}
<div class="config-row">
<Input placeholder="x column (default: 1st)" bind:value={() => vizConfig.x_column ?? '', (v) => setConfig('x_column', v)} />
<Input
placeholder="value column (default: 2nd)"
bind:value={() => vizConfig.value_column ?? '', (v) => setConfig('value_column', v)}
/>
<Input
placeholder="series column (optional)"
bind:value={() => vizConfig.series_column ?? '', (v) => setConfig('series_column', v)}
/>
</div>
{#if vizType === 'bar'}
<label class="checkbox">
<input
type="checkbox"
checked={vizConfig.stacked === 'true'}
onchange={(e) => setConfig('stacked', String(e.currentTarget.checked))}
/>
Stack series
</label>
{/if}
{:else if vizType === 'top_n'}
<div class="config-row">
<Input placeholder="label column (default: 1st)" bind:value={() => vizConfig.label_column ?? '', (v) => setConfig('label_column', v)} />
<Input
placeholder="value column (default: numeric)"
bind:value={() => vizConfig.value_column ?? '', (v) => setConfig('value_column', v)}
/>
</div>
{:else if vizType === 'heatmap'}
<div class="config-row">
<Input placeholder="x column (default: 1st)" bind:value={() => vizConfig.x_column ?? '', (v) => setConfig('x_column', v)} />
<Input placeholder="y column (default: 2nd)" bind:value={() => vizConfig.y_column ?? '', (v) => setConfig('y_column', v)} />
<Input
placeholder="value column (default: 3rd)"
bind:value={() => vizConfig.value_column ?? '', (v) => setConfig('value_column', v)}
/>
</div>
{:else if vizType === 'single_stat'}
<div class="config-row">
<Input
placeholder="value column (default: 2nd)"
bind:value={() => vizConfig.value_column ?? '', (v) => setConfig('value_column', v)}
/>
<Input placeholder="unit (e.g. ms, %)" bind:value={() => vizConfig.unit ?? '', (v) => setConfig('unit', v)} />
</div>
<label class="checkbox">
<input
type="checkbox"
checked={vizConfig.higher_is_worse === 'true'}
onchange={(e) => setConfig('higher_is_worse', String(e.currentTarget.checked))}
/>
Rising trend means something is wrong (colors an increase as an error, not neutral)
</label>
{/if}
<div class="overrides">
<Input placeholder="Earliest override (e.g. -6h)" bind:value={earliestOverride} />
<Input placeholder="Latest override (e.g. now)" bind:value={latestOverride} />
</div>
<Tabs {tabs} bind:active={activeTab} />
<div id="panel-query" role="tabpanel" aria-labelledby="tab-query" hidden={activeTab !== 'query'}>
<QueryBar bind:query bind:language onRun={runPreview} loading={previewLoading} />
</div>
<div id="panel-preview" role="tabpanel" aria-labelledby="tab-preview" hidden={activeTab !== 'preview'} class="preview">
{#if previewLoading && !previewResult}
<p class="muted">Running…</p>
{:else if previewError}
<p class="error">Error: {previewError}</p>
{:else if previewResult}
<PanelViz result={previewResult} {vizType} {vizConfig} />
{:else}
<p class="muted">Run a query to preview it here.</p>
{/if}
</div>
{#if saveError}<p class="error">{saveError}</p>{/if}
</div>
{#snippet footer()}
<Button variant="ghost" onclick={() => (open = false)}>Cancel</Button>
<Button variant="primary" onclick={save} disabled={saving || !query.trim()}>
{saving ? 'Saving…' : 'Save panel'}
</Button>
{/snippet}
</Modal>
<style>
.editor {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.field-label {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-top: var(--space-1);
}
.config-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: var(--space-2);
}
.overrides {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2);
}
.checkbox {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.preview {
min-height: 12rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
}
.muted {
color: var(--color-text-muted);
}
.error {
color: var(--color-danger);
}
</style>
+45 -25
View File
@@ -6,6 +6,7 @@
importDashboard,
type Dashboard
} from '$lib/api';
import { Button, Input, EmptyState, Skeleton } from '$lib/components/ui';
let dashboards = $state<Dashboard[]>([]);
let loading = $state(true);
@@ -65,12 +66,12 @@
{#if error}<p class="error">Error: {error}</p>{/if}
<div class="create-row">
<input
<Input
placeholder="New dashboard name"
bind:value={newName}
onkeydown={(e) => e.key === 'Enter' && create()}
onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && create()}
/>
<button onclick={create} disabled={!newName.trim()}>Create</button>
<Button onclick={create} disabled={!newName.trim()}>Create</Button>
<label class="import-label">
Import JSON
<input type="file" accept="application/json" onchange={onImportFile} hidden />
@@ -78,9 +79,17 @@
</div>
{#if loading}
<p>Loading…</p>
<div class="skeleton-list">
{#each Array(3) as _, i (i)}
<Skeleton height="2.25rem" />
{/each}
</div>
{:else if dashboards.length === 0}
<p>No dashboards yet.</p>
<EmptyState
icon="▤"
title="No dashboards yet"
description="Build a query on the Search page and save it here, or create an empty dashboard above and add panels to it."
/>
{:else}
<ul class="dashboard-list">
{#each dashboards as d (d.id)}
@@ -96,24 +105,31 @@
<style>
main {
font-family: system-ui, sans-serif;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
max-width: 48rem;
}
h1 {
font-size: var(--text-xl);
margin-bottom: var(--space-4);
}
.error {
color: #b00020;
color: var(--color-danger);
}
.skeleton-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.create-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.import-label {
cursor: pointer;
color: #06c;
font-size: 0.85rem;
color: var(--color-accent);
font-size: var(--text-sm);
white-space: nowrap;
}
.dashboard-list {
list-style: none;
@@ -122,26 +138,30 @@
.dashboard-list li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border);
}
.dashboard-list a {
font-weight: 600;
color: #06c;
font-weight: var(--font-weight-medium);
color: var(--color-text);
text-decoration: none;
}
.dashboard-list a:hover {
color: var(--color-accent);
}
.desc {
color: #777;
font-size: 0.85rem;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.delete {
margin-left: auto;
color: #b00020;
color: var(--color-danger);
background: none;
border: 1px solid #b00020;
border-radius: 4px;
padding: 0.15rem 0.5rem;
border: 1px solid var(--color-danger);
border-radius: var(--radius-sm);
padding: 0.15rem var(--space-2);
cursor: pointer;
font-family: var(--font-ui);
}
</style>
+122 -103
View File
@@ -2,13 +2,13 @@
import { page } from '$app/state';
import { GridStack, type GridStackNode } from 'gridstack';
import 'gridstack/dist/gridstack.min.css';
import QueryBar from '$lib/QueryBar.svelte';
import PanelViz from '$lib/PanelViz.svelte';
import PanelEditor from '$lib/components/PanelEditor.svelte';
import { Button, Card, EmptyState, Skeleton } from '$lib/components/ui';
import {
getDashboard,
updateDashboard,
deleteDashboard as apiDeleteDashboard,
addPanel,
deletePanel as apiDeletePanel,
updatePanel as apiUpdatePanel,
exportDashboard,
@@ -17,8 +17,6 @@
injectTimeRange,
type Dashboard,
type Panel,
type VizType,
type Language,
type QueryResult
} from '$lib/api';
@@ -37,11 +35,29 @@
let gridEl: HTMLDivElement | undefined = $state();
let grid: GridStack | undefined;
let showAddPanel = $state(false);
let newTitle = $state('');
let newQuery = $state('');
let newLanguage = $state<Language>('');
let newVizType = $state<VizType>('table');
let editorOpen = $state(false);
let editingPanel = $state<Panel | null>(null);
function openNewPanel() {
editingPanel = null;
editorOpen = true;
}
function openEditPanel(panel: Panel) {
editingPanel = panel;
editorOpen = true;
}
// Zoom on a time-series panel becomes the dashboard's new global
// range -- the brief's "zoomed range able to feed back into the
// global dashboard time-range picker" requirement. Reuses the exact
// same applyTimeRange() path the manual earliest/latest inputs use,
// so a zoom and a typed range behave identically (persisted, re-runs
// every panel), not two divergent code paths.
async function onPanelZoom(range: { earliest: string; latest: string }) {
earliestInput = range.earliest;
latestInput = range.latest;
await applyTimeRange();
}
async function load() {
loading = true;
@@ -99,30 +115,6 @@
return Math.max(...dashboard.panels.map((p) => p.position_y + p.height));
}
async function submitAddPanel() {
if (!newQuery.trim()) return;
try {
await addPanel(dashboardId, {
title: newTitle,
query: newQuery,
query_language: newLanguage,
viz_type: newVizType,
position_x: 0,
position_y: nextY(),
width: 6,
height: 4
});
showAddPanel = false;
newTitle = '';
newQuery = '';
newLanguage = '';
newVizType = 'table';
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
async function removePanel(panelId: string) {
try {
await apiDeletePanel(dashboardId, panelId);
@@ -193,15 +185,23 @@
<main>
{#if loading}
<p>Loading…</p>
<div class="skeleton-header">
<Skeleton width="16rem" height="1.75rem" />
<Skeleton width="8rem" height="1.5rem" />
</div>
<div class="skeleton-grid">
{#each Array(4) as _, i (i)}
<Card><Skeleton height="10rem" /></Card>
{/each}
</div>
{:else if !dashboard}
<p class="error">Error: {error}</p>
<EmptyState icon="⚠" title="Couldn't load this dashboard" description={error} />
{:else}
<div class="header">
<h1>{dashboard.name}</h1>
<div class="header-actions">
<button onclick={doExport}>Export JSON</button>
<button class="delete" onclick={removeDashboard}>Delete dashboard</button>
<Button variant="secondary" onclick={doExport}>Export JSON</Button>
<Button variant="danger" onclick={removeDashboard}>Delete dashboard</Button>
</div>
</div>
{#if dashboard.description}<p class="desc">{dashboard.description}</p>{/if}
@@ -210,8 +210,8 @@
<div class="time-range">
<label>Earliest <input bind:value={earliestInput} placeholder="-1h" /></label>
<label>Latest <input bind:value={latestInput} placeholder="now" /></label>
<button onclick={applyTimeRange}>Apply to all panels</button>
<span class="hint">Per-panel overrides win over this default -- see the panel editor.</span>
<Button size="sm" onclick={applyTimeRange}>Apply to all panels</Button>
<span class="hint">Per-panel overrides win over this default, and a time-series panel's zoom updates this automatically.</span>
</div>
{#if dashboard.panels && dashboard.panels.length > 0}
@@ -229,8 +229,10 @@
>
<div class="grid-stack-item-content panel">
<div class="panel-header">
<span class="panel-title">{panel.title || panel.query}</span>
<button class="panel-delete" onclick={() => removePanel(panel.id)}>×</button>
<button class="panel-title" onclick={() => openEditPanel(panel)} title="Edit panel">
{panel.title || panel.query}
</button>
<button class="panel-delete" onclick={() => removePanel(panel.id)} aria-label="Delete panel">×</button>
</div>
{#if panelErrors[panel.id]}
<p class="error">Error: {panelErrors[panel.id]}</p>
@@ -239,49 +241,49 @@
result={panelResults[panel.id]}
vizType={panel.viz_type}
vizConfig={panel.viz_config}
query={panel.query}
onZoom={onPanelZoom}
/>
{:else}
<p>Loading…</p>
<Skeleton height="100%" />
{/if}
</div>
</div>
{/each}
</div>
{:else}
<p>No panels yet. Add one below.</p>
<EmptyState
icon="▤"
title="No panels yet"
description="Build a query on the Search page, then add it here — or start straight from a blank panel below."
>
{#snippet action()}
<Button variant="primary" onclick={openNewPanel}>+ Add your first panel</Button>
{/snippet}
</EmptyState>
{/if}
<div class="add-panel">
<button onclick={() => (showAddPanel = !showAddPanel)}>
{showAddPanel ? 'Cancel' : '+ Add panel'}
</button>
{#if showAddPanel}
<div class="add-panel-form">
<input placeholder="Panel title" bind:value={newTitle} />
<QueryBar bind:query={newQuery} bind:language={newLanguage} onRun={submitAddPanel} />
<label>
Visualization:
<select bind:value={newVizType}>
<option value="table">Table</option>
<option value="line">Line chart</option>
<option value="bar">Bar chart</option>
<option value="single_stat">Single stat</option>
<option value="top_n">Top-N</option>
</select>
</label>
<button onclick={submitAddPanel} disabled={!newQuery.trim()}>Add panel</button>
</div>
{/if}
</div>
{#if dashboard.panels && dashboard.panels.length > 0}
<div class="add-panel">
<Button onclick={openNewPanel}>+ Add panel</Button>
</div>
{/if}
<PanelEditor
bind:open={editorOpen}
{dashboardId}
panel={editingPanel}
dashboardEarliest={earliestInput}
dashboardLatest={latestInput}
{nextY}
onSaved={load}
/>
{/if}
</main>
<style>
main {
font-family: system-ui, sans-serif;
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
max-width: 75rem;
}
.header {
display: flex;
@@ -290,73 +292,90 @@
}
.header-actions {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
}
.desc {
color: #555;
color: var(--color-text-muted);
}
.error {
color: #b00020;
color: var(--color-danger);
}
.time-range {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1rem 0;
gap: var(--space-3);
margin: var(--space-4) 0;
flex-wrap: wrap;
}
.time-range input {
width: 6rem;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-2);
font-family: var(--font-mono);
}
.hint {
font-size: 0.8rem;
color: #777;
}
.delete {
color: #b00020;
background: none;
border: 1px solid #b00020;
border-radius: 4px;
padding: 0.15rem 0.5rem;
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.panel {
border: 1px solid #ddd;
border-radius: 6px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
height: 100%;
box-sizing: border-box;
overflow: auto;
background: white;
background: var(--color-surface);
color: var(--color-text);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
margin-bottom: 0.5rem;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.panel-title {
background: none;
border: none;
padding: 0;
font-family: var(--font-ui);
font-weight: var(--font-weight-medium);
font-size: var(--text-base);
color: var(--color-text);
text-align: left;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panel-title:hover {
color: var(--color-accent);
}
.panel-delete {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: #999;
font-size: var(--text-md);
color: var(--color-text-muted);
flex: none;
}
.panel-delete:hover {
color: var(--color-danger);
}
.add-panel {
margin-top: 1.5rem;
margin-top: var(--space-5);
}
.add-panel-form {
border: 1px solid #ddd;
border-radius: 6px;
padding: 1rem;
margin-top: 0.5rem;
.skeleton-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 640px;
justify-content: space-between;
margin-bottom: var(--space-5);
}
.add-panel-form input {
box-sizing: border-box;
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
gap: var(--space-4);
}
</style>