From 0e37ca6669ffaf664190a881b5f38d62b33c2913 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 16 Aug 2026 12:36:01 -0700 Subject: [PATCH] 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 + ⌘/Ctrl+Enter to run diff --git a/web/src/lib/ResultsTable.svelte b/web/src/lib/ResultsTable.svelte index 8ea1720..3b40bd7 100644 --- a/web/src/lib/ResultsTable.svelte +++ b/web/src/lib/ResultsTable.svelte @@ -2,58 +2,227 @@ // Shared between the SQL query page and the free-text search page — // both /api endpoints return the same {columns, rows} shape // 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 { columns, rows, hasRun = false }: { columns: string[]; rows: unknown[][]; hasRun?: boolean } = $props(); + let severityCol = $derived(columns.indexOf('severity')); + function formatCell(value: unknown): string { if (value === null || value === undefined) return ''; if (typeof value === 'object') return JSON.stringify(value); return String(value); } + + let sortCol = $state(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>({}); + 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>(new Set()); + function toggleExpanded(i: number) { + const next = new Set(expanded); + if (next.has(i)) next.delete(i); + else next.add(i); + expanded = next; + } {#if hasRun} -

{rows.length} row(s)

+

{rows.length} row(s)

{/if} {#if columns.length > 0} - +
- {#each columns as col (col)} - + + {#each columns as col, i (col)} + {/each} - {#each rows as row, i (i)} - + {#each sortedRows as row, i (i)} + toggleExpanded(i)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleExpanded(i); + } + }} + > + {#each row as cell, j (j)} - + {/each} + {#if expanded.has(i)} + + + + {/if} {/each} -
{col} + + startResize(e, i, (e.currentTarget.previousElementSibling as HTMLElement)?.offsetWidth ?? 140)} + onpointermove={onResizeMove} + onpointerup={onResizeEnd} + > +
+ + {formatCell(cell)} + {#if j === severityCol} + + {:else} + {formatCell(cell)} + {/if} +
+
+ {#each columns as col, j (col)} +
{col}
+
{formatCell(row[j])}
+ {/each} +
+
+ {/if} diff --git a/web/src/lib/components/AddToDashboardModal.svelte b/web/src/lib/components/AddToDashboardModal.svelte new file mode 100644 index 0000000..08083cd --- /dev/null +++ b/web/src/lib/components/AddToDashboardModal.svelte @@ -0,0 +1,149 @@ + + + + {#if done} +

Added. Open the dashboard to arrange it.

+ {:else if loadingDashboards} +

Loading dashboards…

+ {:else if dashboards.length === 0} +

No dashboards yet — create one first.

+ {:else} +
+ + + + {#if error}

{error}

{/if} +
+ {/if} + + {#snippet footer()} + {#if done} + + {:else} + + + {/if} + {/snippet} +
+ + diff --git a/web/src/lib/query-editor/QueryEditor.svelte b/web/src/lib/query-editor/QueryEditor.svelte new file mode 100644 index 0000000..3f8fc20 --- /dev/null +++ b/web/src/lib/query-editor/QueryEditor.svelte @@ -0,0 +1,128 @@ + + +
+ + diff --git a/web/src/lib/query-editor/completions.ts b/web/src/lib/query-editor/completions.ts new file mode 100644 index 0000000..7c31d83 --- /dev/null +++ b/web/src/lib/query-editor/completions.ts @@ -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.]*$/ }; +} diff --git a/web/src/lib/query-editor/language.ts b/web/src/lib/query-editor/language.ts new file mode 100644 index 0000000..aefff55 --- /dev/null +++ b/web/src/lib/query-editor/language.ts @@ -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); diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 4f7188c..922026b 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -8,7 +8,10 @@ import ResultsTable from '$lib/ResultsTable.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 }; @@ -66,6 +69,25 @@ 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);
@@ -82,8 +104,18 @@

Error: {error}

{/if} + {#if hasRun && !error && columns.length > 0} +
+ +
+ {/if} + + + {#if history.length > 0}
Query history ({history.length}) @@ -120,40 +152,50 @@