diff --git a/app/components/OxqlEditor.tsx b/app/components/OxqlEditor.tsx new file mode 100644 index 000000000..26f0b739e --- /dev/null +++ b/app/components/OxqlEditor.tsx @@ -0,0 +1,247 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { bracketMatching } from '@codemirror/language' +import { + Compartment, + RangeSetBuilder, + StateEffect, + StateField, + type Text, +} from '@codemirror/state' +import { + Decoration, + drawSelection, + EditorView, + highlightActiveLine, + keymap, + placeholder, + ViewPlugin, + type DecorationSet, + type ViewUpdate, +} from '@codemirror/view' +import cn from 'classnames' +import { useEffect, useRef } from 'react' +import { createHighlighterCoreSync } from 'shiki/core' +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' + +import type { TimeseriesSchema } from '@oxide/api' +import { oxideTheme, oxqlGrammar } from '@oxide/design-system/syntax' + +import { oxqlAutocomplete } from '~/components/oxql-autocomplete' +import type { OxqlDiagnostic } from '~/components/oxql-error' + +// the --syntax-* vars in the theme come from the design system stylesheets +// already imported in app/ui/styles/index.css, so colors follow the theme +const highlighter = createHighlighterCoreSync({ + langs: [oxqlGrammar], + themes: [oxideTheme], + engine: createJavaScriptRegexEngine(), +}) + +/** + * Tokenize the whole doc with shiki and turn the tokens into CodeMirror mark + * decorations. Queries are small, so retokenizing everything on each change + * is cheap. + */ +const buildDecorations = (view: EditorView): DecorationSet => { + const builder = new RangeSetBuilder() + const code = view.state.doc.toString() + let pos = 0 + for (const line of highlighter.codeToTokensBase(code, { + lang: 'oxql', + theme: oxideTheme.name, + })) { + for (const token of line) { + const end = pos + token.content.length + // default-colored tokens don't need a decoration + if (token.color && token.color !== 'var(--syntax-fg)') { + builder.add( + pos, + end, + Decoration.mark({ attributes: { style: `color: ${token.color}` } }) + ) + } + pos = end + } + pos += 1 // newline + } + return builder.finish() +} + +const shikiPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet + constructor(view: EditorView) { + this.decorations = buildDecorations(view) + } + update(update: ViewUpdate) { + if (update.docChanged) this.decorations = buildDecorations(update.view) + } + }, + { decorations: (v) => v.decorations } +) + +// Convert a 1-based line:column server error position into an editor range +// covering the offending token. Positions are clamped so a stale or +// out-of-range position can't crash the editor. +const toErrorRange = (doc: Text, { line, column }: OxqlDiagnostic) => { + const lineInfo = doc.line(Math.max(1, Math.min(line, doc.lines))) + let from = Math.min(lineInfo.from + column - 1, lineInfo.to) + // underline through the end of the token under the caret, or one char minimum + const token = /^[@\w:]+/.exec(doc.sliceString(from, lineInfo.to)) + const to = Math.min(from + (token?.[0].length || 1), lineInfo.to) + // at end of line there's nothing after the caret, so underline the char before + if (from === to) from = Math.max(lineInfo.from, to - 1) + // mark decorations may not be empty, so an empty line gets no underline + return from < to ? { from, to } : null +} + +const errorMark = Decoration.mark({ class: 'oxql-error-underline' }) + +const setErrorRange = StateEffect.define<{ from: number; to: number } | null>() + +// Underline the position a server-side parse error points at. The error +// message itself is shown below the editor, so no lint tooltip is needed. +// A StateField (rather than a plain decoration facet) so the range remaps +// when the user edits elsewhere in the doc. +const errorRangeField = StateField.define({ + create: () => Decoration.none, + update(deco, tr) { + let mapped = deco.map(tr.changes) + for (const effect of tr.effects) { + if (effect.is(setErrorRange)) { + mapped = effect.value + ? Decoration.set([errorMark.range(effect.value.from, effect.value.to)]) + : Decoration.none + } + } + return mapped + }, + provide: (f) => EditorView.decorations.from(f), +}) + +const contentAttrs = (ariaLabel: string, error: boolean) => + EditorView.contentAttributes.of({ + 'aria-label': ariaLabel, + 'aria-invalid': error ? 'true' : 'false', + }) + +type OxqlEditorProps = { + value: string + onChange: (value: string) => void + /** Called on cmd+enter / ctrl+enter */ + onSubmit: () => void + error?: boolean + /** Server-reported parse error position, underlined in the editor */ + diagnostic?: OxqlDiagnostic + /** Timeseries schemas backing name and field completions. May load after mount. */ + schemas?: TimeseriesSchema[] + 'aria-label': string +} + +/** A CodeMirror editor for OxQL queries with shiki syntax highlighting */ +export function OxqlEditor({ + value, + onChange, + onSubmit, + error = false, + diagnostic, + schemas, + 'aria-label': ariaLabel, +}: OxqlEditorProps) { + const containerRef = useRef(null) + const viewRef = useRef(null) + const attrsCompartment = useRef(new Compartment()) + + // let the mount-once extensions see the latest props without reconfiguring + const callbacks = useRef({ onChange, onSubmit }) + const schemasRef = useRef(schemas) + useEffect(() => { + callbacks.current = { onChange, onSubmit } + schemasRef.current = schemas + }) + + useEffect(() => { + const view = new EditorView({ + // container div is always mounted when this effect runs + parent: containerRef.current!, + doc: value, + extensions: [ + history(), + keymap.of([ + { + key: 'Mod-Enter', + run: () => { + callbacks.current.onSubmit() + return true + }, + }, + ...defaultKeymap, + ...historyKeymap, + // tab indents instead of moving focus. the standard escape hatch + // still works: Ctrl-m (from defaultKeymap) toggles tab focus mode + indentWithTab, + ]), + EditorView.lineWrapping, + placeholder('get sled_data_link:bytes_sent | filter timestamp > @now() - 5m'), + // draw the cursor and selection ourselves. Firefox puts the native + // caret in the wrong spot when the doc is empty and the line contains + // only the placeholder widget + drawSelection(), + highlightActiveLine(), + bracketMatching(), + oxqlAutocomplete(() => schemasRef.current ?? []), + shikiPlugin, + errorRangeField, + attrsCompartment.current.of(contentAttrs(ariaLabel, error)), + EditorView.updateListener.of((update) => { + if (update.docChanged) callbacks.current.onChange(update.state.doc.toString()) + }), + ], + }) + viewRef.current = view + return () => view.destroy() + // value and the aria attrs are synced by the effects below + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // sync external value changes (e.g., clicking an example) into the editor + useEffect(() => { + const view = viewRef.current + if (view && value !== view.state.doc.toString()) { + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } }) + } + }, [value]) + + useEffect(() => { + viewRef.current?.dispatch({ + effects: attrsCompartment.current.reconfigure(contentAttrs(ariaLabel, error)), + }) + }, [ariaLabel, error]) + + useEffect(() => { + const view = viewRef.current + if (!view) return + const range = diagnostic ? toErrorRange(view.state.doc, diagnostic) : null + view.dispatch({ effects: setErrorRange.of(range) }) + }, [diagnostic]) + + return ( +
+ ) +} diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index d1da5ddcb..661163eb5 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -169,7 +169,7 @@ type TimeSeriesChartProps = { } // this top margin is also in the chart, probably want a way of unifying the sizing between the two -const SkeletonMetric = ({ +export const SkeletonMetric = ({ children, shimmer = false, className, @@ -186,7 +186,7 @@ const SkeletonMetric = ({ className )} > -
+
{[...Array(4)].map((_e, i) => (
))} @@ -197,7 +197,7 @@ const SkeletonMetric = ({ ))}
-
+
{children}
@@ -576,7 +576,7 @@ export const ChartContainer = classed.div`flex w-full grow flex-col rounded-lg b type ChartHeaderProps = { title: string label: string - description?: string + description?: ReactNode children?: ReactNode } @@ -585,7 +585,7 @@ export function ChartHeader({ title, label, description, children }: ChartHeader

-
{title}
+
{title}
{label}

{description}
@@ -613,9 +613,9 @@ function ChartLegend({ theme: ChartTheme }) { return ( -
    +
      {Array.from({ length: count }, (_, i) => ( -
    • +
    • , ->( - props: Omit, 'validate'> & Omit -) { - return ( - - typeof value === 'string' && value.trim() ? undefined : 'Enter a query' - } - {...props} - /> - ) -} diff --git a/app/components/oxql-autocomplete.spec.ts b/app/components/oxql-autocomplete.spec.ts new file mode 100644 index 000000000..6a7b903d7 --- /dev/null +++ b/app/components/oxql-autocomplete.spec.ts @@ -0,0 +1,131 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { CompletionContext, type CompletionResult } from '@codemirror/autocomplete' +import { EditorState } from '@codemirror/state' +import { expect, it } from 'vitest' + +import type { TimeseriesSchema } from '@oxide/api' + +import { oxqlCompletionSource } from './oxql-autocomplete' + +const schemas: TimeseriesSchema[] = [ + { + authzScope: 'fleet', + created: new Date(0), + datumType: 'f32', + description: { target: 'A hardware component', metric: 'A fan speed measurement' }, + fieldSchema: [ + { + name: 'chassis_kind', + fieldType: 'string', + source: 'target', + description: 'What kind of thing the component is a part of', + }, + { + name: 'sled_id', + fieldType: 'uuid', + source: 'target', + description: 'ID of the sled', + }, + ], + timeseriesName: 'hardware_component:fan_speed', + units: 'rpm', + version: 1, + }, + { + authzScope: 'fleet', + created: new Date(0), + datumType: 'cumulative_u64', + description: { target: 'A sled data link', metric: 'Bytes sent on the link' }, + fieldSchema: [ + { + name: 'sled_id', + fieldType: 'uuid', + source: 'target', + description: 'ID of the sled', + }, + { + name: 'link_name', + fieldType: 'string', + source: 'target', + description: 'Name of the link', + }, + ], + timeseriesName: 'sled_data_link:bytes_sent', + units: 'bytes', + version: 1, + }, +] + +/** Run the completion source on `doc` with the cursor at the end */ +const complete = (doc: string): CompletionResult | null => + oxqlCompletionSource(() => schemas)( + new CompletionContext(EditorState.create({ doc }), doc.length, false) + ) + +const labels = (doc: string) => complete(doc)?.options.map((o) => o.label) + +it('completes table operations at the start of a clause', () => { + expect(labels('g')).toContain('get') + expect(labels('get hardware_component:fan_speed | f')).toContain('filter') + // after a pipe and a space, all ops are offered with an empty prefix + expect(labels('get hardware_component:fan_speed | ')).toContain('group_by') +}) + +it('completes timeseries names after get', () => { + expect(labels('get ')).toEqual([ + 'hardware_component:fan_speed', + 'sled_data_link:bytes_sent', + ]) + expect(labels('get hardware_com')).toEqual([ + 'hardware_component:fan_speed', + 'sled_data_link:bytes_sent', + ]) + // from points at the start of the name so CM's own prefix filtering applies + const result = complete('get hardware_com') + expect(result?.from).toBe('get '.length) +}) + +it('completes fields of the queried timeseries in filter', () => { + const result = labels('get hardware_component:fan_speed | filter ch') + expect(result).toContain('chassis_kind') + expect(result).toContain('sled_id') + expect(result).toContain('timestamp') + expect(result).toContain('@now()') + // fields of timeseries the query doesn't get are not offered + expect(result).not.toContain('link_name') +}) + +it('dedupes fields across multiple gets in a subquery', () => { + const doc = + '{ get hardware_component:fan_speed; get sled_data_link:bytes_sent } | filter ' + const result = labels(doc) + expect(result).toContain('link_name') + expect(result?.filter((l) => l === 'sled_id')).toHaveLength(1) +}) + +it('still completes filter fields after a logical operator', () => { + const doc = "get hardware_component:fan_speed | filter chassis_kind == 'power' || sl" + expect(labels(doc)).toContain('sled_id') +}) + +it('completes fields inside group_by brackets and reducers after them', () => { + expect(labels('get hardware_component:fan_speed | group_by [sl')).toContain('sled_id') + expect(labels('get hardware_component:fan_speed | group_by [sled_id], ')).toEqual([ + 'mean', + 'sum', + ]) +}) + +it('completes alignment functions after align', () => { + expect(labels('get hardware_component:fan_speed | align m')).toEqual(['mean_within']) +}) + +it('offers nothing after a complete get clause', () => { + expect(complete('get hardware_component:fan_speed ')).toBeNull() +}) diff --git a/app/components/oxql-autocomplete.ts b/app/components/oxql-autocomplete.ts new file mode 100644 index 000000000..72bf078b0 --- /dev/null +++ b/app/components/oxql-autocomplete.ts @@ -0,0 +1,144 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { + autocompletion, + closeBrackets, + closeBracketsKeymap, + snippetCompletion, + type Completion, + type CompletionContext, + type CompletionResult, +} from '@codemirror/autocomplete' +import type { Extension } from '@codemirror/state' +import { keymap } from '@codemirror/view' + +import type { TimeseriesSchema } from '@oxide/api' + +// The OxQL language surface below comes from RFD 463 +// https://rfd.shared.oxide.computer/rfd/463 + +const tableOps: Completion[] = [ + { label: 'get', info: 'Retrieve a table by its timeseries name' }, + { label: 'filter', info: 'Filter timeseries by field values or timestamps' }, + { label: 'align', info: "Temporally align a table's samples" }, + { + label: 'group_by', + info: 'Group timeseries by the listed fields, reducing along the rest', + }, + { label: 'join', info: 'Natural inner join between two or more tables' }, + { label: 'first', info: 'Limit each timeseries to its first k samples' }, + { label: 'last', info: 'Limit each timeseries to its last k samples' }, +] + +const alignFns: Completion[] = [ + snippetCompletion('mean_within(${period})', { + label: 'mean_within', + info: 'Average samples within each period, e.g. mean_within(30s)', + }), +] + +const reducers: Completion[] = [ + { label: 'mean', info: 'Average the values in each group' }, + { label: 'sum', info: 'Sum the values in each group' }, +] + +// identifiers that are valid in filter expressions alongside field names +const filterExtras: Completion[] = [ + { label: 'timestamp', info: 'The timestamp of each sample' }, + { label: 'start_time', info: 'The start time of each cumulative sample' }, + { label: '@now()', info: 'The current time, e.g. timestamp > @now() - 1m' }, +] + +const fieldCompletions = ( + context: CompletionContext, + schemas: TimeseriesSchema[] +): Completion[] => { + // offer the fields of every timeseries the query `get`s, deduped by name + // since subquery filters can apply across tables + const doc = context.state.doc.toString() + const named = new Set(Array.from(doc.matchAll(/\bget\s+([\w:]+)/g), (m) => m[1])) + const seen = new Set() + const options: Completion[] = [] + for (const schema of schemas) { + if (!named.has(schema.timeseriesName)) continue + for (const field of schema.fieldSchema) { + if (seen.has(field.name)) continue + seen.add(field.name) + options.push({ label: field.name, detail: field.fieldType, info: field.description }) + } + } + return options +} + +const schemaCompletion = (s: TimeseriesSchema): Completion => ({ + label: s.timeseriesName, + detail: s.units === 'none' ? s.datumType : `${s.datumType}, ${s.units}`, + info: s.description.metric, +}) + +/** + * Complete based on which clause the cursor is in, determined with regexes + * rather than a real parser: OxQL clauses are short and always start with a + * table operation, so "text since the last pipe" is nearly always enough. + * + * Exported for tests; use {@link oxqlAutocomplete} in the editor. + */ +export const oxqlCompletionSource = + (getSchemas: () => TimeseriesSchema[]) => + (context: CompletionContext): CompletionResult | null => { + // the token being completed: word chars plus ':' (timeseries names) and '@' (@now()) + const word = context.matchBefore(/[@\w:]*/) + if (!word) return null + + const before = context.state + .sliceDoc(0, context.pos) + // blank out logical operators (preserving length) so `filter a == 1 || b` + // reads as one filter clause when we split on pipes below + .replaceAll('||', ' ') + // clauses are delimited by pipes and, in subqueries, braces and semicolons + const clauseStart = + Math.max(before.lastIndexOf('|'), before.lastIndexOf('{'), before.lastIndexOf(';')) + + 1 + const clause = before.slice(clauseStart) + + const result = (options: Completion[]): CompletionResult | null => + options.length > 0 ? { from: word.from, options, validFor: /^[@\w:]*$/ } : null + + // after `get`, complete timeseries names from the schema list + if (/^\s*get\s+[\w:]*$/.test(clause)) { + return result(getSchemas().map(schemaCompletion)) + } + + if (/^\s*align\s+\w*$/.test(clause)) return result(alignFns) + + // inside group_by's bracket list → fields; after the list and a comma → reducers + if (/^\s*group_by\s*\[[^\]]*$/.test(clause)) { + return result(fieldCompletions(context, getSchemas())) + } + if (/^\s*group_by\s*\[[^\]]*\]\s*,\s*\w*$/.test(clause)) return result(reducers) + + // anywhere in a filter expression, offer fields and time identifiers + if (/^\s*filter\b/.test(clause)) { + return result([...fieldCompletions(context, getSchemas()), ...filterExtras]) + } + + // otherwise, if we're at the start of a clause, offer table operations + if (/^\s*\w*$/.test(clause)) return result(tableOps) + + return null + } + +/** + * OxQL completions plus bracket/quote auto-closing. `getSchemas` is called on + * each completion request, so the schema list can arrive after editor mount. + */ +export const oxqlAutocomplete = (getSchemas: () => TimeseriesSchema[]): Extension => [ + autocompletion({ override: [oxqlCompletionSource(getSchemas)], icons: false }), + closeBrackets(), + keymap.of(closeBracketsKeymap), +] diff --git a/app/components/oxql-error.spec.ts b/app/components/oxql-error.spec.ts new file mode 100644 index 000000000..ff212484e --- /dev/null +++ b/app/components/oxql-error.spec.ts @@ -0,0 +1,141 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { describe, expect, it } from 'vitest' + +import { codeSegment, parseOxqlQueryError, stripCaretLine } from './oxql-error' + +// realistic examples of omicron's fmt_parse_error output +const parseError = `Error at 1:1: .. junk junk junk! .. + ^ +Expected: error at 1:1: expected one of "get", "{" +` + +const multilineError = `Error at 2:5: .. :bytes_sent + | oops .. + ^ +Expected: error at 2:5: expected one of "align", "filter" +` + +describe('parseOxqlQueryError', () => { + it('extracts position and the expected clause', () => { + expect(parseOxqlQueryError(parseError)).toEqual({ + line: 1, + column: 1, + message: 'expected one of "get", "{"', + }) + }) + + it('handles positions past line 1', () => { + expect(parseOxqlQueryError(multilineError)).toEqual({ + line: 2, + column: 5, + message: 'expected one of "align", "filter"', + }) + }) + + it('falls back to the whole message when the Expected line is missing', () => { + const result = parseOxqlQueryError('Error at 3:7: something odd') + expect(result).toEqual({ + line: 3, + column: 7, + message: 'Error at 3:7: something odd', + }) + }) + + it('returns null for non-parse errors', () => { + expect(parseOxqlQueryError('Input tables to a `group_by` must be aligned')).toBeNull() + expect(parseOxqlQueryError('Internal Server Error')).toBeNull() + }) +}) + +describe('stripCaretLine', () => { + it('removes the caret line, leaving header and Expected intact', () => { + expect(stripCaretLine(parseError)).toEqual( + `Error at 1:1: .. junk junk junk! .. +Expected: error at 1:1: expected one of "get", "{" +` + ) + }) + + it('handles trailing spaces after the caret', () => { + expect(stripCaretLine('Error at 1:5: .. x ..\n ^ \nExpected: y\n')).toEqual( + 'Error at 1:5: .. x ..\nExpected: y\n' + ) + }) + + it('leaves messages without a caret line alone', () => { + const semantic = 'Input tables to a `group_by` must be aligned' + expect(stripCaretLine(semantic)).toEqual(semantic) + // a ^ used inside the query context is not a caret line + const withCaretChar = 'Error at 1:9: .. filter a ^ b ..\nExpected: y\n' + expect(stripCaretLine(withCaretChar)).toEqual(withCaretChar) + }) + + it('removes a caret line at the end of the message', () => { + expect(stripCaretLine('Error at 1:5: .. x ..\n ^')).toEqual('Error at 1:5: .. x ..') + }) +}) + +describe('codeSegment', () => { + // odd indices are the code segments + const split = (message: string) => message.split(codeSegment) + + it('splits out excerpt markers, quoted tokens, and backticked names', () => { + expect(split('Error at 1:1: .. junk junk! ..\nExpected: one of "get", "{"')).toEqual([ + 'Error at 1:1: ', + '.. junk junk! ..', + '\nExpected: one of ', + '"get"', + ', ', + '"{"', + '', + ]) + expect(split('Input tables to a `group_by` must be aligned')).toEqual([ + 'Input tables to a ', + '`group_by`', + ' must be aligned', + ]) + }) + + it('keeps a filter expression with nested quotes in one segment', () => { + // omicron interpolates the raw expression, so quotes inside it are unescaped + expect(split('The filter expression "kind == "power"" is not valid, because')).toEqual([ + 'The filter expression ', + '"kind == "power""', + ' is not valid, because', + ]) + // nested quotes mid-expression, where the inner closing quote is followed + // by a delimiter and could be mistaken for the end of the segment + expect( + split('The filter expression "kind == "power" && sled == 1" is not valid, because') + ).toEqual([ + 'The filter expression ', + '"kind == "power" && sled == 1"', + ' is not valid, because', + ]) + }) + + it('splits identifier lists into one segment per name', () => { + expect( + split('Invalid identifiers: ["chassis_kind"], valid: ["datum", "peer"]') + ).toEqual([ + 'Invalid identifiers: [', + '"chassis_kind"', + '], valid: [', + '"datum"', + ', ', + '"peer"', + ']', + ]) + }) + + it('leaves unbalanced quotes alone', () => { + const message = 'something with a stray " quote' + expect(split(message)).toEqual([message]) + }) +}) diff --git a/app/components/oxql-error.ts b/app/components/oxql-error.ts new file mode 100644 index 000000000..dd812a2c9 --- /dev/null +++ b/app/components/oxql-error.ts @@ -0,0 +1,56 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +export type OxqlDiagnostic = { + /** 1-based line in the query */ + line: number + /** 1-based column in the line */ + column: number + message: string +} + +/** + * Drop the caret line (whitespace + `^`) from a parse error: its alignment + * assumes a monospace terminal, and the editor underline already points at + * the position. + */ +export const stripCaretLine = (message: string) => message.replace(/\n *\^ *(?=\n|$)/, '') + +/** + * Split pattern for the code-ish parts of an error message: the query excerpt + * between fmt_parse_error's `..` markers, peg's double-quoted expected tokens, + * and the backtick- or double-quoted names in semantic errors. Used with + * `String.split`, so the capture group puts code segments at odd indices. + * + * Quoted filter expressions can contain unescaped nested quotes (omicron + * interpolates the raw expression, e.g. `The filter expression "kind == + * "power"" is not valid`), so that known frame gets a greedy context-anchored + * alternative, and elsewhere a quote only closes a segment when followed by a + * delimiter rather than a word char or another quote. + * https://github.com/oxidecomputer/omicron/blob/6db4c7e/oximeter/db/src/oxql/plan/filter.rs + */ +export const codeSegment = + /(\.\. [\s\S]*? \.\.|(?<=The filter expression )"[^\n]*"(?= is not valid)|(?:` header and an + * `Expected:` line whose peg Display redundantly repeats the position. + * Returns null for errors that aren't parse errors (e.g., semantic ones). + * https://github.com/oxidecomputer/omicron/blob/6db4c7e/oximeter/db/src/oxql/mod.rs + */ +export function parseOxqlQueryError(message: string): OxqlDiagnostic | null { + const position = /^Error at (\d+):(\d+)/.exec(message) + if (!position) return null + const expected = /^Expected: (?:error at \d+:\d+: )?(.+)$/m.exec(message)?.[1] + return { + line: parseInt(position[1], 10), + column: parseInt(position[2], 10), + message: expected ? expected.trim() : message, + } +} diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 647b7a820..df572da1b 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -58,7 +58,7 @@ export default function SystemLayout() { { value: 'Subnet Pools', path: pb.subnetPools() }, { value: 'System Update', path: pb.systemUpdate() }, { value: 'Fleet Access', path: pb.fleetAccess() }, - { value: 'OxQL Explorer', path: pb.systemOxql() }, + { value: 'Metrics Explorer', path: pb.systemOxql() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -110,7 +110,7 @@ export default function SystemLayout() { Fleet Access - OxQL Explorer + Metrics Explorer diff --git a/app/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx index 1d1f1426d..1acfe8cf9 100644 --- a/app/pages/system/OxqlPage.tsx +++ b/app/pages/system/OxqlPage.tsx @@ -5,35 +5,56 @@ * * Copyright Oxide Computer Company */ +import { useQuery } from '@tanstack/react-query' import { useWindowVirtualizer } from '@tanstack/react-virtual' -import { useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useForm } from 'react-hook-form' +import { Fragment, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useController, useForm } from 'react-hook-form' import { useSearchParams } from 'react-router' import * as R from 'remeda' import { match } from 'ts-pattern' import { api, + q, useApiMutation, camelToSnake, type Timeseries, type Points, + type OxqlQueryResult, type OxqlTable, type TimeseriesQuery, type Values, } from '@oxide/api' import { Monitoring16Icon, Monitoring24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' import { DocsPopover } from '~/components/DocsPopover' -import { OxqlField } from '~/components/form/fields/OxqlField' -import { ChartContainer, ChartHeader, TimeSeriesChart } from '~/components/TimeSeriesChart' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { codeSegment, parseOxqlQueryError, stripCaretLine } from '~/components/oxql-error' +import { OxqlEditor } from '~/components/OxqlEditor' +import { + ChartContainer, + ChartHeader, + SkeletonMetric, + TimeSeriesChart, +} from '~/components/TimeSeriesChart' import { useElementSize } from '~/hooks/use-element-size' +import { addToast } from '~/stores/toast' import { Button } from '~/ui/lib/Button' +import { CardBlock } from '~/ui/lib/CardBlock' +import { Checkbox } from '~/ui/lib/Checkbox' import { Divider } from '~/ui/lib/Divider' -import * as DropdownMenu from '~/ui/lib/DropdownMenu' +import * as Dropdown from '~/ui/lib/DropdownMenu' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { ErrorInlineCode } from '~/ui/lib/InlineCode' import { Message } from '~/ui/lib/Message' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { TextInputError } from '~/ui/lib/TextInput' +import { Tooltip } from '~/ui/lib/Tooltip' +import { Truncate, truncate } from '~/ui/lib/Truncate' +import { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' +import { pluralize } from '~/util/str' const exampleItems: { label: string; value: string }[] = [ { @@ -69,7 +90,7 @@ const defaultValues: TimeseriesQuery = { query: '', } -export const handle = { crumb: 'OxQL Explorer' } +export const handle = { crumb: 'Metrics Explorer' } const narrowToNumbers = (vs: Values): (number | null)[] => match(vs.values) @@ -85,8 +106,8 @@ const narrowToNumbers = (vs: Values): (number | null)[] => ) ) .with({ type: 'string' }, () => []) // these don't exist in practice - .with({ type: 'integer_distribution' }, () => []) // by only calling this on aligned/joined tables, we know this is unreachable - .with({ type: 'double_distribution' }, () => []) // these don't exist in practice, and are also unreachable per above + .with({ type: 'integer_distribution' }, () => []) // heatmaps! + .with({ type: 'double_distribution' }, () => []) // these don't exist in practice .exhaustive() const leftPad = (items: T[], length: number): (T | null)[] => @@ -151,7 +172,7 @@ const getAlignedTimestamps = ( type Chart = { name: string - description?: string + description?: ReactNode timestamps: number[] data: Data } @@ -168,9 +189,68 @@ type ChartGroup = const getFormattedFields = (t: Timeseries): string => Object.entries(t.fields) - // hello my evil friend. .map(([fieldName, x]) => `${camelToSnake(fieldName)}: ${x.value}`) - .join(' \u2022 ') + .join(' / ') + +const FIELDS_SHOWN = 5 +// long enough for names/serials; a UUID (36 chars) gets middle-truncated +const FIELD_VALUE_MAX_LEN = 24 + +const FieldBadge = ({ fieldName, value }: { fieldName: string; value: string }) => { + const truncated = value.length > FIELD_VALUE_MAX_LEN + const badge = ( + + {camelToSnake(fieldName)} + + {truncated ? truncate(value, FIELD_VALUE_MAX_LEN, 'middle') : value} + + + ) + if (!truncated) return badge + return ( + + {/* Badge doesn't take a ref, so the tooltip needs a host element target */} + {badge} + + ) +} + +// JSX version of getFormattedFields for chart descriptions: each field is a +// badge, capped at FIELDS_SHOWN with a +N tooltip listing the rest +const FieldsList = ({ timeseries }: { timeseries: Timeseries }) => { + const fields = Object.entries(timeseries.fields) + const overflow = fields.slice(FIELDS_SHOWN) + return ( +
      + {fields.slice(0, FIELDS_SHOWN).map(([fieldName, x]) => ( + + ))} + {overflow.length > 0 && ( + + {overflow.map(([fieldName, x]) => ( + + + {camelToSnake(fieldName)} + + + + ))} +
      + } + > +
      +{overflow.length}
      + + )} +
+ ) +} const tableToGroup = (table: OxqlTable): ChartGroup => { const { name, timeseries } = table @@ -205,7 +285,7 @@ const tableToGroup = (table: OxqlTable): ChartGroup => { // no further charts: timeseries.map((series) => ({ name, - description: getFormattedFields(series), + description: , timestamps: toPosix(series.points.timestamps), data: series.points.values.map((v, i) => ({ label: @@ -238,7 +318,7 @@ const tableToGroup = (table: OxqlTable): ChartGroup => { .filter((s) => s.points.values.length > 0) .map((series) => ({ name, - description: getFormattedFields(series), + description: , timestamps: toPosix(series.points.timestamps), data: series.points.values[0], })), @@ -295,15 +375,25 @@ const groupHasPointWorthDropping = (g: ChartGroup): boolean => ) .exhaustive() -// A simplified representation of a single chart. +// A render-ready representation of a single chart. Keep the data arrays memoized: uplot-react +// deep-compares the whole dataset whenever their identity changes (see TimeSeriesChart.spec.tsx) type ChartDisplay = { key: string; showDivider: boolean } & ( | { kind: 'empty' } - | { kind: 'multiline'; startTime: Date; endTime: Date; chart: Multiline } - | { kind: 'line'; startTime: Date; endTime: Date; chart: Chart } + | { + kind: 'chart' + startTime: Date + endTime: Date + name: string + description?: ReactNode + timestamps: number[] + data: (number | null)[][] + /** only set for multi-series charts, where it enables the legend */ + seriesLabels?: string[] + } ) // Virtualization relies on a list of near-same-size items, so we flatten out all the groups -const toDisplays = (groups: ChartGroup[]): ChartDisplay[] => +const toDisplays = (groups: ChartGroup[], trim: Trim): ChartDisplay[] => groups.flatMap((g, t): ChartDisplay[] => { if (g === 'empty-timeseries') return [{ kind: 'empty', key: `t${t}`, showDivider: true }] @@ -312,54 +402,53 @@ const toDisplays = (groups: ChartGroup[]): ChartDisplay[] => .with({ kind: 'unaligned' }, ({ charts }) => charts.map( (chart, i): ChartDisplay => ({ - kind: 'line', + kind: 'chart', key: `t${t}.${i}`, showDivider: i === 0, startTime, endTime, - chart, + name: chart.name, + description: chart.description, + ...trim({ + timestamps: chart.timestamps, + data: [narrowToNumbers(chart.data)], + }), }) ) ) .with({ kind: 'joined' }, { kind: 'aligned' }, ({ charts }) => charts.map( (chart, i): ChartDisplay => ({ - kind: 'multiline', + kind: 'chart', key: `t${t}.${i}`, showDivider: i === 0, startTime, endTime, - chart, + name: chart.name, + description: chart.description, + seriesLabels: chart.data.map((l) => l.label), + ...trim({ + timestamps: chart.timestamps, + data: chart.data.map((d) => d.values), + }), }) ) ) .exhaustive() }) -function MultilineChart({ - display, - trim, -}: { - display: Extract - trim: Trim -}) { - const { chart, startTime, endTime } = display - const trimmed = trim({ - timestamps: chart.timestamps, - data: chart.data.map((d) => d.values), - }) - const seriesLabels = chart.data.map((l) => l.label) +function ChartCard({ display }: { display: Extract }) { return ( - + - trim: Trim -}) { - const { chart, startTime, endTime } = display - const data = match(chart.data.values) - .with({ type: 'integer' }, ({ values }) => values) - .with({ type: 'double' }, ({ values }) => values) - .with({ type: 'boolean' }, ({ values }) => - values.map((b) => - match(b) - .with(true, () => 1) - .with(false, () => 0) - .with(null, () => null) - .exhaustive() - ) - ) - .with({ type: 'string' }, () => []) // these don't exist in practice - .with({ type: 'integer_distribution' }, { type: 'double_distribution' }, () => []) // heatmaps! - .exhaustive() - const trimmed = trim({ data: [data], timestamps: chart.timestamps }) +function ChartEntry({ display }: { display: ChartDisplay }) { return ( - - - + {match(display) + .with({ kind: 'empty' }, () => ( + + + {/* gradient uses the surface-default token so it works in both themes */} +
+
+ +
+ + + )) + .with({ kind: 'chart' }, (r) => ) + .exhaustive()} + + ) +} + +// covers the header strings plus every member of ValueArray['values'] +type CsvValue = string | number | boolean | object | null | undefined + +const csvCell = (v: CsvValue): string => { + const s = + v === null || v === undefined + ? '' + : typeof v === 'object' + ? JSON.stringify(v) + : String(v) + return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s +} + +const tablesToCsv = (tables: OxqlTable[]): string => { + const rows: CsvValue[][] = [['table', 'fields', 'metric', 'timestamp', 'value']] + for (const table of tables) { + // like the chart labels, joined tables get their per-line metric names + // from the comma-joined table name + const metricNames = table.name.split(',').map((s) => s.trim()) + for (const series of table.timeseries) { + const fields = getFormattedFields(series) + series.points.values.forEach((v, i) => { + const metric = metricNames[i] ?? table.name + series.points.timestamps.forEach((ts, j) => { + rows.push([ + table.name, + fields, + metric, + new Date(ts).toISOString(), + v.values.values[j], + ]) + }) + }) + } + } + return rows.map((row) => row.map(csvCell).join(',')).join('\n') +} + +const copyText = (text: string, toastMessage: string) => { + window.navigator.clipboard.writeText(text).then(() => addToast(toastMessage)) +} + +function ResultsMenu({ data }: { data?: OxqlQueryResult }) { + // the menu is always visible so the header doesn't jump around, but the + // actions only make sense once a query has succeeded + const noResults = data === undefined ? 'Run a query first' : undefined + return ( + + + data && copyText(JSON.stringify(data, null, 2), 'Results copied as JSON') + } + label="Copy as JSON" /> - + data && copyText(tablesToCsv(data.tables), 'Results copied as CSV')} + label="Copy as CSV" + /> + ) } -function ChartEntry({ display, trim }: { display: ChartDisplay; trim: Trim }) { +function ResultsSummary({ tables }: { tables: OxqlTable[] }) { + const timeseries = tables.flatMap((t) => t.timeseries) + const nPoints = R.sumBy(timeseries, (t) => t.points.timestamps.length) return ( - <> - {display.showDivider ? ( - // Use padding for spacing so the virtualizer can measure the bounding box properly -
- -
- ) : ( -
- )} - {match(display) - .with({ kind: 'empty' }, () =>

No results

) - .with({ kind: 'multiline' }, (r) => ) - .with({ kind: 'line' }, (r) => ) - .exhaustive()} - +
+ {timeseries.length} timeseries /{' '} + + {nPoints.toLocaleString()} {pluralize('point', nPoints)} + +
) } -const getTextareaHeightForQuery = (q: string): number => Math.max(q.split('\n').length, 4) +// Server-side query errors render below the editor in the same Message box we +// use for API errors elsewhere (e.g., side modal forms). role=alert announces +// the failure to screen readers on arrival; mono + pre-wrap preserve the parse +// errors' caret alignment. +// The code-ish parts of an error message get inline code styling via +// codeSegment (see oxql-error.ts). The `..` excerpt markers stay outside the +// chip, reading as ellipses. +const ErrorMessage = ({ message }: { message: string }) => ( + + {message.split(codeSegment).map((part, i) => { + if (i % 2 === 0) return part + // the chip delimits the code, so drop the markers/quotes around it + const code = part.startsWith('.. ') ? part.slice(3, -3) : part.slice(1, -1) + // an empty chip is just visual noise; show the raw text instead + if (!code) return part + return ( + + {part.startsWith('.. ') && '.. '} + {code} + {part.startsWith('.. ') && ' ..'} + + ) + })} + +) + +const QueryError = ({ message }: { message: string }) => ( +
+ } + /> +
+) + +// Rendered in every query state so the layout doesn't shift when results arrive +const ResultsSection = ({ children }: { children: ReactNode }) => ( + <> + + {children} + +) export default function OxqlPage() { const query = useApiMutation(api.systemTimeseriesQuery) + // powers editor autocomplete. no loading state needed: completions are a + // progressive enhancement and simply appear once this resolves + const schemas = useQuery(q(api.systemTimeseriesSchemaList, { query: { limit: ALL_ISH } })) + const [searchParams, setSearchParams] = useSearchParams() const defaultQuery = searchParams.get('query') ?? defaultValues.query - const [textareaRowCount, setTextareaRowCount] = useState( - getTextareaHeightForQuery(defaultQuery) - ) - const form = useForm({ defaultValues: { query: defaultQuery }, }) - const control = form.control + const { field, fieldState } = useController({ + name: 'query', + control: form.control, + rules: { + validate: (value) => (value.trim() ? undefined : 'Enter a query'), + }, + }) const [dropFirstPoint, setDropFirstPoint] = useState(true) @@ -467,15 +649,27 @@ export default function OxqlPage() { ) } + // Parse errors carry a line:column position we can point at in the editor. + // Only show the diagnostic while the editor still holds the exact query that + // failed; as soon as the user edits, the position no longer applies. + const oxqlError = query.error ? parseOxqlQueryError(query.error.message) : null + const diagnostic = + oxqlError && field.value === query.variables?.body.query ? oxqlError : undefined + const chartGroups: ChartGroup[] | null = useMemo( () => (query.data ? query.data.tables.map(tableToGroup) : null), [query.data] ) const hasTrimmableCharts = chartGroups?.some(groupHasPointWorthDropping) ?? false - const trim = firstPointDropper(dropFirstPoint && hasTrimmableCharts) - const charts = useMemo(() => (chartGroups ? toDisplays(chartGroups) : []), [chartGroups]) + const charts = useMemo( + () => + chartGroups + ? toDisplays(chartGroups, firstPointDropper(dropFirstPoint && hasTrimmableCharts)) + : [], + [chartGroups, dropFirstPoint, hasTrimmableCharts] + ) // Since the whole window is the scroll container, the virtualizer needs to // know the offset from the top. By reacting to height changes in everything @@ -501,7 +695,7 @@ export default function OxqlPage() { <>
- }>OxQL Explorer + }>Metrics Explorer } @@ -509,95 +703,114 @@ export default function OxqlPage() { links={[docLinks.oxql, docLinks.oxqlSchemas]} /> -
-
- - - Try an example - - } - /> - + + + +
+ {query.status === 'success' && ( + <> + + + )} + + +
+
+ +
+ form.handleSubmit(onSubmit)()} + schemas={schemas.data?.items} + /> + {fieldState.error?.message ? ( + {fieldState.error.message} + ) : query.error ? ( + + ) : null} +
+
+ Examples {exampleItems.map(({ label, value }) => ( - { - setTextareaRowCount(getTextareaHeightForQuery(value)) - form.setValue('query', value) + type="button" + className="text-mono-xs border-default text-secondary hover:bg-hover rounded border px-2 py-1" + onClick={() => { + form.setValue('query', value, { shouldValidate: true }) + form.handleSubmit(onSubmit)() }} - /> + > + {label} + ))} - - -
- - +
+ +
- {match(query) - .with( - { status: 'success' }, - () => - hasTrimmableCharts && ( -
- -
- ) - ) - .otherwise(() => '')}
{match(query) - .with({ status: 'idle' }, () => null) - .with({ status: 'pending' }, () => ( - - - + // on error the message renders below the editor, so the results + // section just shows the same empty chart as the idle state + .with({ status: 'idle' }, { status: 'error' }, () => ( + + + {/* the loading skeleton, minus the shimmer and bouncing indicator */} + {null} + + )) - .with({ status: 'error' }, (q) => ( - {q.error.message}} - /> + .with({ status: 'pending' }, () => ( + + + + + )) .with({ status: 'success' }, () => ( -
- {virtualizer.getVirtualItems().map((item) => ( -
- + + {hasTrimmableCharts && ( +
+ setDropFirstPoint(e.target.checked)} + > + Drop first point +
- ))} -
+ )} +
+ {virtualizer.getVirtualItems().map((item) => ( +
+ +
+ ))} +
+ )) .exhaustive()} diff --git a/app/routes.tsx b/app/routes.tsx index 02b6e0c56..259bb55f4 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -176,7 +176,10 @@ export const routes = createRoutesFromElements( path="utilization" lazy={() => import('./pages/system/UtilizationPage').then(convert)} /> - import('./pages/system/OxqlPage').then(convert)} /> + import('./pages/system/OxqlPage').then(convert)} + /> import('./pages/system/inventory/InventoryPage.tsx').then(convert)} diff --git a/app/ui/lib/InlineCode.tsx b/app/ui/lib/InlineCode.tsx index 4d6d28649..97394b314 100644 --- a/app/ui/lib/InlineCode.tsx +++ b/app/ui/lib/InlineCode.tsx @@ -9,3 +9,4 @@ import { classed } from '~/util/classed' export const InlineCode = classed.code`whitespace-nowrap rounded-sm px-[3px] py-px text-mono-sm normal-case! bg-raise border border-secondary mx-px` +export const ErrorInlineCode = classed.code`inline-code font-mono whitespace-nowrap` diff --git a/app/ui/styles/components/oxql-editor.css b/app/ui/styles/components/oxql-editor.css new file mode 100644 index 000000000..8011f231c --- /dev/null +++ b/app/ui/styles/components/oxql-editor.css @@ -0,0 +1,126 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +/* + * Styles for the CodeMirror editor in OxqlEditor.tsx. CodeMirror injects its + * base theme as unlayered