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:
@@ -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>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
importDashboard,
|
importDashboard,
|
||||||
type Dashboard
|
type Dashboard
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
|
import { Button, Input, EmptyState, Skeleton } from '$lib/components/ui';
|
||||||
|
|
||||||
let dashboards = $state<Dashboard[]>([]);
|
let dashboards = $state<Dashboard[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -65,12 +66,12 @@
|
|||||||
{#if error}<p class="error">Error: {error}</p>{/if}
|
{#if error}<p class="error">Error: {error}</p>{/if}
|
||||||
|
|
||||||
<div class="create-row">
|
<div class="create-row">
|
||||||
<input
|
<Input
|
||||||
placeholder="New dashboard name"
|
placeholder="New dashboard name"
|
||||||
bind:value={newName}
|
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">
|
<label class="import-label">
|
||||||
Import JSON
|
Import JSON
|
||||||
<input type="file" accept="application/json" onchange={onImportFile} hidden />
|
<input type="file" accept="application/json" onchange={onImportFile} hidden />
|
||||||
@@ -78,9 +79,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#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}
|
{: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}
|
{:else}
|
||||||
<ul class="dashboard-list">
|
<ul class="dashboard-list">
|
||||||
{#each dashboards as d (d.id)}
|
{#each dashboards as d (d.id)}
|
||||||
@@ -96,24 +105,31 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
main {
|
main {
|
||||||
font-family: system-ui, sans-serif;
|
max-width: 48rem;
|
||||||
max-width: 960px;
|
}
|
||||||
margin: 2rem auto;
|
h1 {
|
||||||
padding: 0 1rem;
|
font-size: var(--text-xl);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
color: #b00020;
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
.skeleton-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
.create-row {
|
.create-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: var(--space-3);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: var(--space-5);
|
||||||
}
|
}
|
||||||
.import-label {
|
.import-label {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #06c;
|
color: var(--color-accent);
|
||||||
font-size: 0.85rem;
|
font-size: var(--text-sm);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.dashboard-list {
|
.dashboard-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -122,26 +138,30 @@
|
|||||||
.dashboard-list li {
|
.dashboard-list li {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: var(--space-3);
|
||||||
padding: 0.5rem 0;
|
padding: var(--space-3) 0;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.dashboard-list a {
|
.dashboard-list a {
|
||||||
font-weight: 600;
|
font-weight: var(--font-weight-medium);
|
||||||
color: #06c;
|
color: var(--color-text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
.dashboard-list a:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
.desc {
|
.desc {
|
||||||
color: #777;
|
color: var(--color-text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
.delete {
|
.delete {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: #b00020;
|
color: var(--color-danger);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid #b00020;
|
border: 1px solid var(--color-danger);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem var(--space-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
font-family: var(--font-ui);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { GridStack, type GridStackNode } from 'gridstack';
|
import { GridStack, type GridStackNode } from 'gridstack';
|
||||||
import 'gridstack/dist/gridstack.min.css';
|
import 'gridstack/dist/gridstack.min.css';
|
||||||
import QueryBar from '$lib/QueryBar.svelte';
|
|
||||||
import PanelViz from '$lib/PanelViz.svelte';
|
import PanelViz from '$lib/PanelViz.svelte';
|
||||||
|
import PanelEditor from '$lib/components/PanelEditor.svelte';
|
||||||
|
import { Button, Card, EmptyState, Skeleton } from '$lib/components/ui';
|
||||||
import {
|
import {
|
||||||
getDashboard,
|
getDashboard,
|
||||||
updateDashboard,
|
updateDashboard,
|
||||||
deleteDashboard as apiDeleteDashboard,
|
deleteDashboard as apiDeleteDashboard,
|
||||||
addPanel,
|
|
||||||
deletePanel as apiDeletePanel,
|
deletePanel as apiDeletePanel,
|
||||||
updatePanel as apiUpdatePanel,
|
updatePanel as apiUpdatePanel,
|
||||||
exportDashboard,
|
exportDashboard,
|
||||||
@@ -17,8 +17,6 @@
|
|||||||
injectTimeRange,
|
injectTimeRange,
|
||||||
type Dashboard,
|
type Dashboard,
|
||||||
type Panel,
|
type Panel,
|
||||||
type VizType,
|
|
||||||
type Language,
|
|
||||||
type QueryResult
|
type QueryResult
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
|
|
||||||
@@ -37,11 +35,29 @@
|
|||||||
let gridEl: HTMLDivElement | undefined = $state();
|
let gridEl: HTMLDivElement | undefined = $state();
|
||||||
let grid: GridStack | undefined;
|
let grid: GridStack | undefined;
|
||||||
|
|
||||||
let showAddPanel = $state(false);
|
let editorOpen = $state(false);
|
||||||
let newTitle = $state('');
|
let editingPanel = $state<Panel | null>(null);
|
||||||
let newQuery = $state('');
|
|
||||||
let newLanguage = $state<Language>('');
|
function openNewPanel() {
|
||||||
let newVizType = $state<VizType>('table');
|
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() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -99,30 +115,6 @@
|
|||||||
return Math.max(...dashboard.panels.map((p) => p.position_y + p.height));
|
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) {
|
async function removePanel(panelId: string) {
|
||||||
try {
|
try {
|
||||||
await apiDeletePanel(dashboardId, panelId);
|
await apiDeletePanel(dashboardId, panelId);
|
||||||
@@ -193,15 +185,23 @@
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
{#if loading}
|
{#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}
|
{:else if !dashboard}
|
||||||
<p class="error">Error: {error}</p>
|
<EmptyState icon="⚠" title="Couldn't load this dashboard" description={error} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>{dashboard.name}</h1>
|
<h1>{dashboard.name}</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button onclick={doExport}>Export JSON</button>
|
<Button variant="secondary" onclick={doExport}>Export JSON</Button>
|
||||||
<button class="delete" onclick={removeDashboard}>Delete dashboard</button>
|
<Button variant="danger" onclick={removeDashboard}>Delete dashboard</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if dashboard.description}<p class="desc">{dashboard.description}</p>{/if}
|
{#if dashboard.description}<p class="desc">{dashboard.description}</p>{/if}
|
||||||
@@ -210,8 +210,8 @@
|
|||||||
<div class="time-range">
|
<div class="time-range">
|
||||||
<label>Earliest <input bind:value={earliestInput} placeholder="-1h" /></label>
|
<label>Earliest <input bind:value={earliestInput} placeholder="-1h" /></label>
|
||||||
<label>Latest <input bind:value={latestInput} placeholder="now" /></label>
|
<label>Latest <input bind:value={latestInput} placeholder="now" /></label>
|
||||||
<button onclick={applyTimeRange}>Apply to all panels</button>
|
<Button size="sm" onclick={applyTimeRange}>Apply to all panels</Button>
|
||||||
<span class="hint">Per-panel overrides win over this default -- see the panel editor.</span>
|
<span class="hint">Per-panel overrides win over this default, and a time-series panel's zoom updates this automatically.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if dashboard.panels && dashboard.panels.length > 0}
|
{#if dashboard.panels && dashboard.panels.length > 0}
|
||||||
@@ -229,8 +229,10 @@
|
|||||||
>
|
>
|
||||||
<div class="grid-stack-item-content panel">
|
<div class="grid-stack-item-content panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">{panel.title || panel.query}</span>
|
<button class="panel-title" onclick={() => openEditPanel(panel)} title="Edit panel">
|
||||||
<button class="panel-delete" onclick={() => removePanel(panel.id)}>×</button>
|
{panel.title || panel.query}
|
||||||
|
</button>
|
||||||
|
<button class="panel-delete" onclick={() => removePanel(panel.id)} aria-label="Delete panel">×</button>
|
||||||
</div>
|
</div>
|
||||||
{#if panelErrors[panel.id]}
|
{#if panelErrors[panel.id]}
|
||||||
<p class="error">Error: {panelErrors[panel.id]}</p>
|
<p class="error">Error: {panelErrors[panel.id]}</p>
|
||||||
@@ -239,49 +241,49 @@
|
|||||||
result={panelResults[panel.id]}
|
result={panelResults[panel.id]}
|
||||||
vizType={panel.viz_type}
|
vizType={panel.viz_type}
|
||||||
vizConfig={panel.viz_config}
|
vizConfig={panel.viz_config}
|
||||||
|
query={panel.query}
|
||||||
|
onZoom={onPanelZoom}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<p>Loading…</p>
|
<Skeleton height="100%" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
|
|
||||||
|
{#if dashboard.panels && dashboard.panels.length > 0}
|
||||||
<div class="add-panel">
|
<div class="add-panel">
|
||||||
<button onclick={() => (showAddPanel = !showAddPanel)}>
|
<Button onclick={openNewPanel}>+ Add panel</Button>
|
||||||
{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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
<PanelEditor
|
||||||
|
bind:open={editorOpen}
|
||||||
|
{dashboardId}
|
||||||
|
panel={editingPanel}
|
||||||
|
dashboardEarliest={earliestInput}
|
||||||
|
dashboardLatest={latestInput}
|
||||||
|
{nextY}
|
||||||
|
onSaved={load}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
main {
|
main {
|
||||||
font-family: system-ui, sans-serif;
|
max-width: 75rem;
|
||||||
max-width: 1200px;
|
|
||||||
margin: 2rem auto;
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
}
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -290,73 +292,90 @@
|
|||||||
}
|
}
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
.desc {
|
.desc {
|
||||||
color: #555;
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
color: #b00020;
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
.time-range {
|
.time-range {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: var(--space-3);
|
||||||
margin: 1rem 0;
|
margin: var(--space-4) 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.time-range input {
|
.time-range input {
|
||||||
width: 6rem;
|
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 {
|
.hint {
|
||||||
font-size: 0.8rem;
|
font-size: var(--text-sm);
|
||||||
color: #777;
|
color: var(--color-text-muted);
|
||||||
}
|
|
||||||
.delete {
|
|
||||||
color: #b00020;
|
|
||||||
background: none;
|
|
||||||
border: 1px solid #b00020;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid #ddd;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: var(--space-2) var(--space-3);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: white;
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-weight: 600;
|
gap: var(--space-2);
|
||||||
margin-bottom: 0.5rem;
|
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 {
|
.panel-delete {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: var(--text-md);
|
||||||
color: #999;
|
color: var(--color-text-muted);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.panel-delete:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
.add-panel {
|
.add-panel {
|
||||||
margin-top: 1.5rem;
|
margin-top: var(--space-5);
|
||||||
}
|
}
|
||||||
.add-panel-form {
|
.skeleton-header {
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
margin-bottom: var(--space-5);
|
||||||
max-width: 640px;
|
|
||||||
}
|
}
|
||||||
.add-panel-form input {
|
.skeleton-grid {
|
||||||
box-sizing: border-box;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user