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,
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user