Redesign query/search: syntax highlighting, autocomplete, richer results
QueryEditor.svelte wraps CodeMirror 6, not a hand-rolled
textarea-plus-overlay highlighter -- autocomplete needs real
cursor-aware popup positioning a plain textarea can't give. language.ts
is a StreamLanguage tokenizer for the pipe grammar; its token() function
must return real @lezer/highlight tag names looked up by string
('controlKeyword', 'operatorKeyword', 'name.function' for tag+modifier
pairs) -- a custom Tag.define() looks plausible but silently highlights
nothing. completions.ts is context-aware: stage keywords after `|`,
stats functions after `stats`, field names elsewhere.
A two-way-binding race between the editor's updateListener and an
external-sync $effect could drop characters on rapid/bulk input --
fixed with a lastEmitted guard so the sync effect only reacts to
genuinely external value changes, not its own echoes.
ResultsTable gets sortable columns (a real <button> in the <th>, so
sorting is keyboard-operable for free), resizable columns
(pointer-drag, deliberately mouse-only -- the resize handle stays out
of the tab order, same as most apps treat column resize), and
expandable rows. The row-expand affordance was originally a bare `<tr
onclick>` with no keyboard equivalent at all; fixed with
tabindex/role="button"/aria-expanded and an Enter/Space handler.
AddToDashboardModal lets a query built on the Search page become a
saved panel without hand-copying the query string.
This commit is contained in:
+24
-30
@@ -4,6 +4,8 @@
|
|||||||
// deliberately just the input+run affordance, not results/history,
|
// deliberately just the input+run affordance, not results/history,
|
||||||
// which differ per consumer.
|
// which differ per consumer.
|
||||||
import type { Language } from '$lib/api';
|
import type { Language } from '$lib/api';
|
||||||
|
import { Button } from '$lib/components/ui';
|
||||||
|
import QueryEditor from '$lib/query-editor/QueryEditor.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
query = $bindable(''),
|
query = $bindable(''),
|
||||||
@@ -28,20 +30,10 @@
|
|||||||
}
|
}
|
||||||
let detected = $derived(detectedLanguage(query));
|
let detected = $derived(detectedLanguage(query));
|
||||||
let effectiveLanguage = $derived(language === '' ? detected : language);
|
let effectiveLanguage = $derived(language === '' ? detected : language);
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
|
||||||
// Cmd/Ctrl+Enter runs the query -- textarea's own Enter key needs
|
|
||||||
// to stay newline-for-pipe-stage-formatting, so this isn't a bare
|
|
||||||
// Enter binding.
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
onRun();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="query-bar">
|
<div class="query-bar">
|
||||||
<textarea bind:value={query} onkeydown={onKeydown} rows="4" spellcheck="false" {placeholder}></textarea>
|
<QueryEditor bind:value={query} {onRun} {placeholder} />
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<label>
|
<label>
|
||||||
Language:
|
Language:
|
||||||
@@ -54,40 +46,42 @@
|
|||||||
<span class="detected-badge" class:sql={effectiveLanguage === 'sql'}>
|
<span class="detected-badge" class:sql={effectiveLanguage === 'sql'}>
|
||||||
{effectiveLanguage === 'sql' ? 'SQL' : 'pipe syntax'}
|
{effectiveLanguage === 'sql' ? 'SQL' : 'pipe syntax'}
|
||||||
</span>
|
</span>
|
||||||
<button onclick={onRun} disabled={loading || query.trim() === ''}>
|
<Button variant="primary" onclick={onRun} disabled={loading || query.trim() === ''}>
|
||||||
{loading ? 'Running…' : 'Run query'}
|
{loading ? 'Running…' : 'Run query'}
|
||||||
</button>
|
</Button>
|
||||||
<span class="hint">⌘/Ctrl+Enter to run</span>
|
<span class="hint">⌘/Ctrl+Enter to run</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.query-bar textarea {
|
|
||||||
width: 100%;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.controls {
|
.controls {
|
||||||
margin-top: 0.5rem;
|
margin-top: var(--space-3);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: var(--space-3);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.controls select {
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
height: var(--control-height);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
.detected-badge {
|
.detected-badge {
|
||||||
font-size: 0.75rem;
|
font-size: var(--text-xs);
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem var(--space-2);
|
||||||
border-radius: 1rem;
|
border-radius: var(--radius-full);
|
||||||
background: #eef;
|
background: var(--color-sev-info-bg);
|
||||||
color: #224;
|
color: var(--color-sev-info);
|
||||||
}
|
}
|
||||||
.detected-badge.sql {
|
.detected-badge.sql {
|
||||||
background: #fee;
|
background: var(--color-sev-warn-bg);
|
||||||
color: #422;
|
color: var(--color-sev-warn);
|
||||||
}
|
}
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 0.8rem;
|
font-size: var(--text-sm);
|
||||||
color: #777;
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+188
-19
@@ -2,58 +2,227 @@
|
|||||||
// Shared between the SQL query page and the free-text search page —
|
// Shared between the SQL query page and the free-text search page —
|
||||||
// both /api endpoints return the same {columns, rows} shape
|
// both /api endpoints return the same {columns, rows} shape
|
||||||
// specifically so this component didn't need to exist twice.
|
// specifically so this component didn't need to exist twice.
|
||||||
|
// Phase 5: sortable columns (client-side -- the rows are already
|
||||||
|
// fetched, re-sorting them here doesn't need a round trip), resizable
|
||||||
|
// columns (a plain drag handle, not a dependency -- this is a small
|
||||||
|
// enough interaction to hand-roll), and expandable rows for full
|
||||||
|
// structured-field inspection (useful the moment a query has more
|
||||||
|
// columns than comfortably fit, or a Map(String,String) attributes
|
||||||
|
// column whose JSON got cut off).
|
||||||
|
import Table from '$lib/components/ui/Table.svelte';
|
||||||
|
import SeverityBadge from '$lib/components/ui/SeverityBadge.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
hasRun = false
|
hasRun = false
|
||||||
}: { columns: string[]; rows: unknown[][]; hasRun?: boolean } = $props();
|
}: { columns: string[]; rows: unknown[][]; hasRun?: boolean } = $props();
|
||||||
|
|
||||||
|
let severityCol = $derived(columns.indexOf('severity'));
|
||||||
|
|
||||||
function formatCell(value: unknown): string {
|
function formatCell(value: unknown): string {
|
||||||
if (value === null || value === undefined) return '';
|
if (value === null || value === undefined) return '';
|
||||||
if (typeof value === 'object') return JSON.stringify(value);
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let sortCol = $state<number | null>(null);
|
||||||
|
let sortDir = $state<1 | -1>(1);
|
||||||
|
|
||||||
|
function toggleSort(i: number) {
|
||||||
|
if (sortCol === i) {
|
||||||
|
sortDir = sortDir === 1 ? -1 : 1;
|
||||||
|
} else {
|
||||||
|
sortCol = i;
|
||||||
|
sortDir = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedRows = $derived.by(() => {
|
||||||
|
if (sortCol === null) return rows;
|
||||||
|
const i = sortCol;
|
||||||
|
const dir = sortDir;
|
||||||
|
return [...rows].sort((a, b) => {
|
||||||
|
const av = a[i];
|
||||||
|
const bv = b[i];
|
||||||
|
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
||||||
|
return String(av ?? '').localeCompare(String(bv ?? '')) * dir;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let widths = $state<Record<number, number>>({});
|
||||||
|
let resizing: { col: number; startX: number; startWidth: number } | null = null;
|
||||||
|
|
||||||
|
function startResize(e: PointerEvent, i: number, currentWidth: number) {
|
||||||
|
resizing = { col: i, startX: e.clientX, startWidth: currentWidth };
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
function onResizeMove(e: PointerEvent) {
|
||||||
|
if (!resizing) return;
|
||||||
|
const delta = e.clientX - resizing.startX;
|
||||||
|
widths = { ...widths, [resizing.col]: Math.max(60, resizing.startWidth + delta) };
|
||||||
|
}
|
||||||
|
function onResizeEnd() {
|
||||||
|
resizing = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expanded = $state<Set<number>>(new Set());
|
||||||
|
function toggleExpanded(i: number) {
|
||||||
|
const next = new Set(expanded);
|
||||||
|
if (next.has(i)) next.delete(i);
|
||||||
|
else next.add(i);
|
||||||
|
expanded = next;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if hasRun}
|
{#if hasRun}
|
||||||
<p>{rows.length} row(s)</p>
|
<p class="row-count">{rows.length} row(s)</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if columns.length > 0}
|
{#if columns.length > 0}
|
||||||
<table>
|
<Table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{#each columns as col (col)}
|
<th class="expand-col" aria-hidden="true"></th>
|
||||||
<th>{col}</th>
|
{#each columns as col, i (col)}
|
||||||
|
<th style:width={widths[i] ? `${widths[i]}px` : undefined}>
|
||||||
|
<button type="button" class="sort-btn" onclick={() => toggleSort(i)}>
|
||||||
|
{col}
|
||||||
|
{#if sortCol === i}<span class="sort-ind">{sortDir === 1 ? '▲' : '▼'}</span>{/if}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="resize-handle"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize {col} column"
|
||||||
|
onpointerdown={(e) => startResize(e, i, (e.currentTarget.previousElementSibling as HTMLElement)?.offsetWidth ?? 140)}
|
||||||
|
onpointermove={onResizeMove}
|
||||||
|
onpointerup={onResizeEnd}
|
||||||
|
></span>
|
||||||
|
</th>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each rows as row, i (i)}
|
{#each sortedRows as row, i (i)}
|
||||||
<tr>
|
<tr
|
||||||
|
class="data-row"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-expanded={expanded.has(i)}
|
||||||
|
onclick={() => toggleExpanded(i)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleExpanded(i);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td class="expand-col">
|
||||||
|
<span class="chevron" class:open={expanded.has(i)} aria-hidden="true">›</span>
|
||||||
|
</td>
|
||||||
{#each row as cell, j (j)}
|
{#each row as cell, j (j)}
|
||||||
<td>{formatCell(cell)}</td>
|
<td style:width={widths[j] ? `${widths[j]}px` : undefined}>
|
||||||
|
{#if j === severityCol}
|
||||||
|
<SeverityBadge severity={formatCell(cell)} />
|
||||||
|
{:else}
|
||||||
|
<span class="cell-text">{formatCell(cell)}</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
{#if expanded.has(i)}
|
||||||
|
<tr class="detail-row">
|
||||||
|
<td colspan={columns.length + 1}>
|
||||||
|
<dl>
|
||||||
|
{#each columns as col, j (col)}
|
||||||
|
<dt>{col}</dt>
|
||||||
|
<dd>{formatCell(row[j])}</dd>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</Table>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
table {
|
.row-count {
|
||||||
border-collapse: collapse;
|
color: var(--color-text-muted);
|
||||||
width: 100%;
|
font-size: var(--text-sm);
|
||||||
margin-top: 1rem;
|
margin: var(--space-3) 0 0;
|
||||||
}
|
}
|
||||||
th,
|
.expand-col {
|
||||||
td {
|
width: 1.5rem;
|
||||||
border: 1px solid #ccc;
|
}
|
||||||
padding: 0.25rem 0.5rem;
|
.chevron {
|
||||||
text-align: left;
|
display: inline-block;
|
||||||
font-size: 0.85rem;
|
color: var(--color-text-muted);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
.chevron.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.sort-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
.sort-ind {
|
||||||
|
font-size: 0.6em;
|
||||||
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
th {
|
th {
|
||||||
background: #f0f0f0;
|
position: relative;
|
||||||
|
}
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 6px;
|
||||||
|
cursor: col-resize;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.resize-handle:hover {
|
||||||
|
background: var(--color-accent);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.data-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cell-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.detail-row td {
|
||||||
|
background: var(--color-bg);
|
||||||
|
padding: var(--space-3) var(--row-padding-x);
|
||||||
|
}
|
||||||
|
.detail-row dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: var(--space-1) var(--space-4);
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.detail-row dt {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.detail-row dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The Search page's "add as panel" affordance -- any query result can
|
||||||
|
// become a dashboard panel without leaving the query bar. Reuses the
|
||||||
|
// same addPanel call PanelEditor uses; this modal is deliberately
|
||||||
|
// simpler than PanelEditor (no live preview -- the result the user is
|
||||||
|
// looking at *is* the preview) since its only job is picking a
|
||||||
|
// destination and a chart type for a query that's already been run.
|
||||||
|
import { Modal, Button, Select } from '$lib/components/ui';
|
||||||
|
import { listDashboards, getDashboard, addPanel, type Dashboard, type VizType, type Language } from '$lib/api';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
query,
|
||||||
|
language
|
||||||
|
}: { open?: boolean; query: string; language: Language } = $props();
|
||||||
|
|
||||||
|
let dashboards = $state<Dashboard[]>([]);
|
||||||
|
let loadingDashboards = $state(false);
|
||||||
|
let targetId = $state('');
|
||||||
|
let title = $state('');
|
||||||
|
let vizType = $state<VizType>('table');
|
||||||
|
let saving = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let done = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
done = false;
|
||||||
|
error = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingDashboards = true;
|
||||||
|
listDashboards()
|
||||||
|
.then((d) => {
|
||||||
|
dashboards = d;
|
||||||
|
if (d.length && !targetId) targetId = d[0].id;
|
||||||
|
})
|
||||||
|
.catch((e) => (error = e instanceof Error ? e.message : String(e)))
|
||||||
|
.finally(() => (loadingDashboards = false));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!targetId) return;
|
||||||
|
saving = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const target = await getDashboard(targetId);
|
||||||
|
const nextY = target.panels?.length ? Math.max(...target.panels.map((p) => p.position_y + p.height)) : 0;
|
||||||
|
await addPanel(targetId, {
|
||||||
|
title: title || query,
|
||||||
|
query,
|
||||||
|
query_language: language,
|
||||||
|
viz_type: vizType,
|
||||||
|
position_x: 0,
|
||||||
|
position_y: nextY,
|
||||||
|
width: 6,
|
||||||
|
height: 4
|
||||||
|
});
|
||||||
|
done = true;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:open title="Add to dashboard">
|
||||||
|
{#if done}
|
||||||
|
<p>Added. <a href="/dashboards/{targetId}">Open the dashboard</a> to arrange it.</p>
|
||||||
|
{:else if loadingDashboards}
|
||||||
|
<p class="muted">Loading dashboards…</p>
|
||||||
|
{:else if dashboards.length === 0}
|
||||||
|
<p class="muted">No dashboards yet — <a href="/dashboards">create one first</a>.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="form">
|
||||||
|
<label class="field">
|
||||||
|
Dashboard
|
||||||
|
<Select bind:value={targetId}>
|
||||||
|
{#each dashboards as d (d.id)}
|
||||||
|
<option value={d.id}>{d.name}</option>
|
||||||
|
{/each}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
Panel title (optional)
|
||||||
|
<input placeholder={query} bind:value={title} />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
Visualization
|
||||||
|
<Select bind:value={vizType}>
|
||||||
|
{#each vizOptions as opt (opt.value)}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
{#if error}<p class="error">{error}</p>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
{#if done}
|
||||||
|
<Button variant="primary" onclick={() => (open = false)}>Close</Button>
|
||||||
|
{:else}
|
||||||
|
<Button variant="ghost" onclick={() => (open = false)}>Cancel</Button>
|
||||||
|
<Button variant="primary" onclick={save} disabled={saving || !targetId}>
|
||||||
|
{saving ? 'Adding…' : 'Add panel'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.field input {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-2);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// CodeMirror wrapper -- replaces QueryBar's plain <textarea>. Chosen
|
||||||
|
// over hand-rolling syntax highlighting (the classic "transparent
|
||||||
|
// textarea over a colored <pre>" overlay trick) because autocomplete
|
||||||
|
// needs real cursor/viewport-aware popup positioning and keyboard
|
||||||
|
// handling that trick doesn't give you for free, and CodeMirror is a
|
||||||
|
// well-established dependency for exactly this job (the same
|
||||||
|
// "reach for a real editor primitive" reasoning ECharts and GridStack
|
||||||
|
// already followed elsewhere in Phase 5).
|
||||||
|
import { EditorView, keymap, placeholder as placeholderExt } from '@codemirror/view';
|
||||||
|
import { autocompletion, closeBrackets, completionKeymap } from '@codemirror/autocomplete';
|
||||||
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||||
|
import { pipeLanguage, pipeSyntaxHighlighting } from './language';
|
||||||
|
import { pipeCompletions } from './completions';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
onRun,
|
||||||
|
placeholder = '',
|
||||||
|
ariaLabel = 'Query'
|
||||||
|
}: { value?: string; onRun?: () => void; placeholder?: string; ariaLabel?: string } = $props();
|
||||||
|
|
||||||
|
let container: HTMLDivElement | undefined = $state();
|
||||||
|
let view: EditorView | undefined;
|
||||||
|
// Tracks the last value *this component* pushed into `value`, so the
|
||||||
|
// external-sync effect below can tell "value changed because someone
|
||||||
|
// else set the bindable prop" (e.g. clicking a history entry) apart
|
||||||
|
// from "value changed because the updateListener below just set it
|
||||||
|
// from the user's own typing" -- without this, every keystroke would
|
||||||
|
// round-trip through both effects, and on fast/multi-character input
|
||||||
|
// (e.g. automation tools that insert text in one burst) the second
|
||||||
|
// effect could dispatch a stale sync in between keystrokes and drop
|
||||||
|
// characters.
|
||||||
|
let lastEmitted = '';
|
||||||
|
|
||||||
|
const theme = EditorView.theme({
|
||||||
|
'&': {
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 'var(--text-base)',
|
||||||
|
backgroundColor: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)'
|
||||||
|
},
|
||||||
|
'&.cm-focused': {
|
||||||
|
outline: 'none',
|
||||||
|
borderColor: 'var(--color-accent)'
|
||||||
|
},
|
||||||
|
'.cm-content': { padding: 'var(--space-3)', color: 'var(--color-text)', caretColor: 'var(--color-accent)' },
|
||||||
|
'.cm-cursor': { borderLeftColor: 'var(--color-accent)' },
|
||||||
|
'.cm-scroller': { minHeight: '4.5rem' },
|
||||||
|
'.cm-placeholder': { color: 'var(--color-text-faint)' },
|
||||||
|
'.cm-tooltip-autocomplete': {
|
||||||
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 'var(--text-sm)'
|
||||||
|
},
|
||||||
|
'.cm-tooltip-autocomplete ul li[aria-selected]': {
|
||||||
|
backgroundColor: 'var(--color-accent)',
|
||||||
|
color: 'var(--color-on-accent)'
|
||||||
|
},
|
||||||
|
'.cm-selectionBackground': { backgroundColor: 'color-mix(in srgb, var(--color-accent) 25%, transparent) !important' }
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!container) return;
|
||||||
|
lastEmitted = value;
|
||||||
|
view = new EditorView({
|
||||||
|
doc: value,
|
||||||
|
parent: container,
|
||||||
|
extensions: [
|
||||||
|
pipeLanguage,
|
||||||
|
pipeSyntaxHighlighting,
|
||||||
|
theme,
|
||||||
|
history(),
|
||||||
|
closeBrackets(),
|
||||||
|
autocompletion({ override: [pipeCompletions] }),
|
||||||
|
placeholderExt(placeholder),
|
||||||
|
EditorView.contentAttributes.of({ 'aria-label': ariaLabel }),
|
||||||
|
keymap.of([
|
||||||
|
...completionKeymap,
|
||||||
|
...defaultKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
{
|
||||||
|
key: 'Mod-Enter',
|
||||||
|
run: () => {
|
||||||
|
onRun?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
lastEmitted = update.state.doc.toString();
|
||||||
|
value = lastEmitted;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return () => view?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// External changes to `value` (e.g. clicking a history entry) need to
|
||||||
|
// push into the editor -- the updateListener above only covers the
|
||||||
|
// other direction (editor -> value). Compares against `lastEmitted`,
|
||||||
|
// not the view's live doc: comparing against the view's doc directly
|
||||||
|
// re-fires on every one of *this component's own* edits too (each
|
||||||
|
// keystroke changes `value`, which re-runs this effect), and on fast
|
||||||
|
// or multi-character input that redundant dispatch can land between
|
||||||
|
// keystrokes and clobber characters the updateListener just applied.
|
||||||
|
$effect(() => {
|
||||||
|
if (!view) return;
|
||||||
|
if (value !== lastEmitted) {
|
||||||
|
lastEmitted = value;
|
||||||
|
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={container} class="query-editor"></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.query-editor {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Context-sensitive completions for the pipe syntax -- what's offered
|
||||||
|
// depends on what precedes the cursor (right after `|` -> stage names;
|
||||||
|
// right after `stats` -> aggregate functions; otherwise -> field
|
||||||
|
// names), matching /docs/query-language-reference.md's grammar. Real
|
||||||
|
// structured field names (`timestamp`/`host`/`service`/`severity`/
|
||||||
|
// `message`/`record_id`) are always offered since they're always valid
|
||||||
|
// wherever a field name is; a log's own attribute names aren't known
|
||||||
|
// client-side (they're schema-on-read, per-record), so those aren't
|
||||||
|
// suggested -- this is a real limitation, not an oversight.
|
||||||
|
import type { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||||
|
|
||||||
|
const FIELDS = [
|
||||||
|
{ label: 'timestamp', type: 'property' },
|
||||||
|
{ label: 'host', type: 'property' },
|
||||||
|
{ label: 'service', type: 'property' },
|
||||||
|
{ label: 'severity', type: 'property' },
|
||||||
|
{ label: 'message', type: 'property' },
|
||||||
|
{ label: 'record_id', type: 'property' },
|
||||||
|
{ label: 'earliest', type: 'property', info: 'e.g. earliest=-1h' },
|
||||||
|
{ label: 'latest', type: 'property', info: 'e.g. latest=now' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const STAGES = [
|
||||||
|
{ label: 'where', type: 'keyword', info: 'additional filter' },
|
||||||
|
{ label: 'stats', type: 'keyword', info: 'aggregate' },
|
||||||
|
{ label: 'sort', type: 'keyword', info: 'order results' },
|
||||||
|
{ label: 'fields', type: 'keyword', info: 'choose columns' },
|
||||||
|
{ label: 'head', type: 'keyword', info: 'first N results' },
|
||||||
|
{ label: 'tail', type: 'keyword', info: 'last N results' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATS_FUNCTIONS = [
|
||||||
|
{ label: 'count', type: 'function', info: 'count() or count' },
|
||||||
|
{ label: 'sum', type: 'function', info: 'sum(field)' },
|
||||||
|
{ label: 'avg', type: 'function', info: 'avg(field)' },
|
||||||
|
{ label: 'min', type: 'function', info: 'min(field)' },
|
||||||
|
{ label: 'max', type: 'function', info: 'max(field)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function pipeCompletions(context: CompletionContext): CompletionResult | null {
|
||||||
|
const word = context.matchBefore(/[\w.]*/);
|
||||||
|
if (!word) return null;
|
||||||
|
if (word.from === word.to && !context.explicit) return null;
|
||||||
|
|
||||||
|
const textBefore = context.state.sliceDoc(0, word.from);
|
||||||
|
// Nearest preceding pipe stage keyword, if any, and whether we're
|
||||||
|
// still within that same stage (no later `|` between it and the
|
||||||
|
// cursor).
|
||||||
|
const lastPipe = textBefore.lastIndexOf('|');
|
||||||
|
const currentStage = textBefore
|
||||||
|
.slice(lastPipe + 1)
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)[0]
|
||||||
|
?.toLowerCase();
|
||||||
|
|
||||||
|
// Right after a `|` (only whitespace since it, or nothing typed yet
|
||||||
|
// this stage) -> offer stage keywords.
|
||||||
|
const sincePipe = textBefore.slice(lastPipe + 1);
|
||||||
|
if (lastPipe >= 0 && /^\s*$/.test(sincePipe)) {
|
||||||
|
return { from: word.from, options: STAGES, validFor: /^\w*$/ };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStage === 'stats') {
|
||||||
|
return { from: word.from, options: STATS_FUNCTIONS, validFor: /^\w*$/ };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStage === 'sort' || currentStage === 'fields' || /\bby\s*$/.test(textBefore)) {
|
||||||
|
return { from: word.from, options: FIELDS, validFor: /^[\w.]*$/ };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base search or `where` -- field names are always valid here.
|
||||||
|
return { from: word.from, options: FIELDS, validFor: /^[\w.]*$/ };
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// A hand-rolled StreamLanguage tokenizer for the pipe syntax
|
||||||
|
// (/docs/query-language-reference.md), not a full Lezer grammar --
|
||||||
|
// the language is small and mostly flat (no nested expressions beyond
|
||||||
|
// one comparison per term), so a single-pass stream tokenizer covers it
|
||||||
|
// without the added build complexity a real parser grammar would need.
|
||||||
|
// Raw SQL (a query starting with SELECT) is deliberately NOT
|
||||||
|
// highlighted by this -- it's an escape hatch, not the primary UX
|
||||||
|
// target, and reusing @codemirror/lang-sql for it would be a second
|
||||||
|
// grammar to maintain for a path most queries don't take.
|
||||||
|
//
|
||||||
|
// token()'s return value is looked up directly against @lezer/highlight's
|
||||||
|
// `tags` export by name (see @codemirror/language's StreamLanguage
|
||||||
|
// implementation) -- it must be one of those real tag names, not an
|
||||||
|
// arbitrary string. `where`/`stats`/`sort`/`fields`/`head`/`tail`
|
||||||
|
// (pipeline-stage keywords) use `controlKeyword`; `and`/`or`/`by`/`as`
|
||||||
|
// (connective words within a stage) use `operatorKeyword` -- two
|
||||||
|
// distinct real tags, chosen so the two keyword classes render
|
||||||
|
// differently without inventing a custom tag StreamLanguage can't
|
||||||
|
// resolve.
|
||||||
|
import { StreamLanguage, HighlightStyle, syntaxHighlighting, type StringStream } from '@codemirror/language';
|
||||||
|
import { tags as t } from '@lezer/highlight';
|
||||||
|
|
||||||
|
const STAGE_KEYWORDS = new Set(['where', 'stats', 'sort', 'fields', 'head', 'tail']);
|
||||||
|
const CONNECTIVES = new Set(['and', 'or', 'by', 'as']);
|
||||||
|
const STATS_FUNCTIONS = new Set(['count', 'sum', 'avg', 'min', 'max']);
|
||||||
|
const TIME_FIELDS = new Set(['earliest', 'latest']);
|
||||||
|
|
||||||
|
export const pipeLanguage = StreamLanguage.define({
|
||||||
|
name: 'sentry-pipe',
|
||||||
|
startState() {
|
||||||
|
return { afterPipe: true };
|
||||||
|
},
|
||||||
|
token(stream: StringStream, state: { afterPipe: boolean }) {
|
||||||
|
if (stream.eatSpace()) return null;
|
||||||
|
|
||||||
|
if (stream.match('|')) {
|
||||||
|
state.afterPipe = true;
|
||||||
|
return 'punctuation';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream.peek() === '"') {
|
||||||
|
stream.next();
|
||||||
|
while (!stream.eol()) {
|
||||||
|
if (stream.next() === '"' && stream.string[stream.pos - 2] !== '\\') break;
|
||||||
|
}
|
||||||
|
return 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream.match(/^-?\d+(\.\d+)?/)) return 'number';
|
||||||
|
|
||||||
|
if (stream.match(/^(>=|<=|!=|=|>|<)/)) return 'compareOperator';
|
||||||
|
|
||||||
|
if (stream.match(/^[+-](?=\w)/)) return 'compareOperator'; // sort direction sigil
|
||||||
|
|
||||||
|
if (stream.match(/^[A-Za-z_][\w.]*/)) {
|
||||||
|
const word = stream.current().toLowerCase();
|
||||||
|
const wasAfterPipe = state.afterPipe;
|
||||||
|
state.afterPipe = false;
|
||||||
|
if (wasAfterPipe && STAGE_KEYWORDS.has(word)) return 'controlKeyword';
|
||||||
|
if (CONNECTIVES.has(word)) return 'operatorKeyword';
|
||||||
|
if (STATS_FUNCTIONS.has(word) && stream.peek() === '(') return 'name.function';
|
||||||
|
if (TIME_FIELDS.has(word)) return 'atom';
|
||||||
|
return 'variableName';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream.match(/^[(),]/)) return 'punctuation';
|
||||||
|
|
||||||
|
stream.next();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const highlightStyle = HighlightStyle.define([
|
||||||
|
{ tag: t.controlKeyword, color: 'var(--color-accent)', fontWeight: '600' },
|
||||||
|
{ tag: t.operatorKeyword, color: 'var(--color-sev-info)' },
|
||||||
|
{ tag: t.function(t.name), color: 'var(--color-sev-warn)' },
|
||||||
|
{ tag: t.atom, color: 'var(--color-sev-info)' },
|
||||||
|
{ tag: t.string, color: 'var(--color-sev-quiet)' },
|
||||||
|
{ tag: t.number, color: 'var(--color-text)' },
|
||||||
|
{ tag: t.compareOperator, color: 'var(--color-sev-error)' },
|
||||||
|
{ tag: t.variableName, color: 'var(--color-text)' },
|
||||||
|
{ tag: t.punctuation, color: 'var(--color-text-muted)' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const pipeSyntaxHighlighting = syntaxHighlighting(highlightStyle);
|
||||||
+55
-13
@@ -8,7 +8,10 @@
|
|||||||
|
|
||||||
import ResultsTable from '$lib/ResultsTable.svelte';
|
import ResultsTable from '$lib/ResultsTable.svelte';
|
||||||
import QueryBar from '$lib/QueryBar.svelte';
|
import QueryBar from '$lib/QueryBar.svelte';
|
||||||
import { runQuery as apiRunQuery, type Language } from '$lib/api';
|
import AddToDashboardModal from '$lib/components/AddToDashboardModal.svelte';
|
||||||
|
import { Button } from '$lib/components/ui';
|
||||||
|
import { runQuery as apiRunQuery, injectTimeRange, type Language } from '$lib/api';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
type HistoryEntry = { query: string; language: Language; at: number };
|
type HistoryEntry = { query: string; language: Language; at: number };
|
||||||
|
|
||||||
@@ -66,6 +69,25 @@
|
|||||||
hasRun = true;
|
hasRun = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drill-down landing: a chart's "click to drill into query"
|
||||||
|
// (see $lib/charts/drilldown.ts) navigates here with ?q=&earliest=&latest=.
|
||||||
|
// Runs once per navigation, not on every reactive change, so editing
|
||||||
|
// the query bar afterwards doesn't keep re-injecting the original
|
||||||
|
// drill-down range.
|
||||||
|
let consumedDrillDownParams = false;
|
||||||
|
$effect(() => {
|
||||||
|
const params = page.url.searchParams;
|
||||||
|
const q = params.get('q');
|
||||||
|
if (!q || consumedDrillDownParams) return;
|
||||||
|
consumedDrillDownParams = true;
|
||||||
|
const earliest = params.get('earliest');
|
||||||
|
const latest = params.get('latest');
|
||||||
|
query = earliest ? injectTimeRange(q, earliest, latest ?? 'now') : q;
|
||||||
|
runQuery();
|
||||||
|
});
|
||||||
|
|
||||||
|
let addToDashboardOpen = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -82,8 +104,18 @@
|
|||||||
<p class="error">Error: {error}</p>
|
<p class="error">Error: {error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if hasRun && !error && columns.length > 0}
|
||||||
|
<div class="results-actions">
|
||||||
|
<Button size="sm" variant="secondary" onclick={() => (addToDashboardOpen = true)}>
|
||||||
|
+ Add as panel to dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<ResultsTable {columns} {rows} {hasRun} />
|
<ResultsTable {columns} {rows} {hasRun} />
|
||||||
|
|
||||||
|
<AddToDashboardModal bind:open={addToDashboardOpen} {query} {language} />
|
||||||
|
|
||||||
{#if history.length > 0}
|
{#if history.length > 0}
|
||||||
<details class="history">
|
<details class="history">
|
||||||
<summary>Query history ({history.length})</summary>
|
<summary>Query history ({history.length})</summary>
|
||||||
@@ -120,40 +152,50 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
main {
|
main {
|
||||||
font-family: system-ui, sans-serif;
|
max-width: 64rem;
|
||||||
max-width: 960px;
|
}
|
||||||
margin: 2rem auto;
|
h1 {
|
||||||
padding: 0 1rem;
|
font-size: var(--text-xl);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
color: #b00020;
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
.results-actions {
|
||||||
|
margin: var(--space-3) 0;
|
||||||
}
|
}
|
||||||
.history ul {
|
.history ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0.5rem 0 0;
|
margin: var(--space-2) 0 0;
|
||||||
}
|
}
|
||||||
.history-item {
|
.history-item {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.25rem 0;
|
padding: var(--space-1) 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #06c;
|
color: var(--color-accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
.history-item:hover {
|
.history-item:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.cheatsheet {
|
.cheatsheet {
|
||||||
margin-top: 1.5rem;
|
margin-top: var(--space-5);
|
||||||
font-size: 0.85rem;
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.cheatsheet table {
|
.cheatsheet table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin-top: 0.5rem;
|
margin-top: var(--space-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
.cheatsheet td {
|
.cheatsheet td {
|
||||||
padding: 0.2rem 0.75rem 0.2rem 0;
|
padding: var(--space-1) var(--space-4) var(--space-1) 0;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user