diff --git a/app/components/OxqlEditor.tsx b/app/components/OxqlEditor.tsx new file mode 100644 index 000000000..e0c59014d --- /dev/null +++ b/app/components/OxqlEditor.tsx @@ -0,0 +1,250 @@ +import { acceptCompletion } from '@codemirror/autocomplete' +/* + * 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 accepts/indents instead of moving focus. the standard escape + // hatch still works: Ctrl-m (from defaultKeymap) toggles tab focus + // mode + { key: 'Tab', run: acceptCompletion }, + 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 55ce19fb2..7481f5e7d 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -68,7 +68,7 @@ export const SkeletonMetric = ({ className )} > -
+
{[...Array(4)].map((_e, i) => (
))} @@ -79,7 +79,7 @@ export const SkeletonMetric = ({ ))}
-
+
{children}
@@ -385,7 +385,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 } @@ -394,7 +394,7 @@ export function ChartHeader({ title, label, description, children }: ChartHeader

-
{title}
+
{title}
{label}

{description}
@@ -422,9 +422,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..dfe64f3a8 --- /dev/null +++ b/app/components/oxql-autocomplete.spec.ts @@ -0,0 +1,213 @@ +/* + * 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') + // @now() is a literal, not an identifier, so it's not offered here + expect(result).not.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('offers only literals at literal positions, not fields', () => { + const literals = ['@now()', 'true', 'false'] + expect(labels('get hardware_component:fan_speed | filter chassis_kind == ')).toEqual( + literals + ) + expect(labels('get hardware_component:fan_speed | filter timestamp > @no')).toEqual( + literals + ) +}) + +it('offers only get at the start of a query branch', () => { + expect(labels('')).toEqual(['get']) + expect(labels('g')).toEqual(['get']) + expect(labels('{ get hardware_component:fan_speed; ')).toEqual(['get']) +}) + +it('does not offer get after a pipe', () => { + const result = labels('get hardware_component:fan_speed | ') + expect(result).not.toContain('get') + expect(result).toContain('filter') +}) + +it('offers nothing at positions expecting an operator', () => { + const filter = 'get hardware_component:fan_speed | filter ' + // after a complete literal of any kind, quoted or not, spaced or not + expect(complete(`${filter}timestamp > @now()`)).toBeNull() + expect(complete(`${filter}chassis_kind == 'power' `)).toBeNull() + expect(complete(`${filter}sled_id == 5 `)).toBeNull() + // duration arithmetic after @now() + expect(complete(`${filter}timestamp > @now() - `)).toBeNull() + // after an identifier, where a comparison operator is expected + expect(complete(`${filter}sled_id `)).toBeNull() + // after a closing paren + expect(complete(`${filter}(sled_id == 5) `)).toBeNull() +}) + +it('offers fields at the start of each boolean operand', () => { + const filter = 'get hardware_component:fan_speed | filter ' + expect(labels(`${filter}chassis_kind == 'power' && `)).toContain('sled_id') + expect(labels(`${filter}chassis_kind == 'power' ^ `)).toContain('sled_id') + expect(labels(`${filter}(`)).toContain('sled_id') + expect(labels(`${filter}!`)).toContain('sled_id') +}) + +it('ignores a get inside a string literal', () => { + const doc = + "get hardware_component:fan_speed | filter chassis_kind == 'get sled_data_link:bytes_sent' && " + const result = labels(doc) + expect(result).toContain('chassis_kind') + expect(result).not.toContain('link_name') +}) + +it('offers nothing after a complete quoted literal', () => { + expect( + complete("get hardware_component:fan_speed | filter chassis_kind == 'power'") + ).toBeNull() +}) + +it('offers nothing inside a string literal', () => { + expect( + complete("get hardware_component:fan_speed | filter chassis_kind == 'pow") + ).toBeNull() +}) + +it('ignores pipes and semicolons inside strings when finding the clause', () => { + const doc = "get hardware_component:fan_speed | filter chassis_kind == 'a | b; c' || sl" + expect(labels(doc)).toContain('sled_id') +}) + +it('scopes fields to the innermost subquery branch', () => { + const doc = + '{ get hardware_component:fan_speed; get sled_data_link:bytes_sent | filter li' + const result = labels(doc) + expect(result).toContain('link_name') + expect(result).not.toContain('chassis_kind') +}) + +it('offers datum alongside fields in filter', () => { + expect(labels('get hardware_component:fan_speed | filter ')).toContain('datum') +}) + +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..15bc39b20 --- /dev/null +++ b/app/components/oxql-autocomplete.ts @@ -0,0 +1,228 @@ +/* + * 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 + +// every query/subquery starts with `get`, and `get` cannot appear again after +// a pipe, so it is offered separately from the other table operations +const getOp: Completion = { label: 'get', info: 'Retrieve a table by its timeseries name' } + +const pipeOps: Completion[] = [ + { 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' }, +] + +const atNow: Completion = { + label: '@now()', + info: 'The current time, e.g. timestamp > @now() - 1m', +} + +// completable literals for the right-hand side of a filter comparison +const literals: Completion[] = [atNow, { label: 'true' }, { label: 'false' }] + +// 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: 'datum', info: 'The value of each sample' }, +] + +/** + * Walk the document up to `pos`, skipping string literals, to find (1) whether + * the cursor is inside an unterminated string, (2) where the current clause + * starts (last `|`/`{`/`;`/`}` outside strings, with `||` ignored), and + * (3) where the innermost query branch containing the cursor starts: a `{` + * opens a subquery, `;` starts a sibling branch, and `}` returns to the + * enclosing query's scope. + */ +const scanQuery = (doc: string, pos: number) => { + let quote: string | null = null + let escaped = false + let clauseStart = 0 + let scopeStart = 0 + const enclosingScopes: number[] = [] + for (let i = 0; i < pos; i++) { + const c = doc[i] + if (escaped) { + escaped = false + continue + } + if (quote) { + if (c === '\\') escaped = true + else if (c === quote) quote = null + continue + } + switch (c) { + case "'": + case '"': + quote = c + break + case '|': + // logical || is not a clause boundary + if (doc[i + 1] === '|') i++ + else clauseStart = i + 1 + break + case '{': + enclosingScopes.push(scopeStart) + scopeStart = i + 1 + clauseStart = i + 1 + break + case ';': + scopeStart = i + 1 + clauseStart = i + 1 + break + case '}': + scopeStart = enclosingScopes.pop() ?? 0 + clauseStart = i + 1 + break + } + } + return { inString: quote !== null, clauseStart, scopeStart } +} + +const fieldCompletions = ( + context: CompletionContext, + scopeStart: number, + schemas: TimeseriesSchema[] +): Completion[] => { + // offer the fields of every timeseries the innermost query branch `get`s, + // deduped by name since subquery filters can apply across tables. Blank out + // string literals so quoted text can't contribute a phantom `get` + const scope = context.state + .sliceDoc(scopeStart, context.pos) + .replace(/'[^']*'|"[^"]*"/g, '') + const named = new Set(Array.from(scope.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 doc = context.state.doc.toString() + const { inString, clauseStart, scopeStart } = scanQuery(doc, context.pos) + + // no completions inside a string literal + if (inString) return null + + const clause = doc.slice(clauseStart, context.pos) + + 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, scopeStart, getSchemas())) + } + if (/^\s*group_by\s*\[[^\]]*\]\s*,\s*\w*$/.test(clause)) return result(reducers) + + if (/^\s*filter\b/.test(clause)) { + // comparisons are strictly `ident op literal`, so each cursor position + // allows exactly one kind of completion. Right after a comparison + // operator, only literals are legal + if (/(?:==|!=|>=|<=|<|>|~=)\s*[@\w:]*$/.test(clause)) return result(literals) + // identifiers are legal only at the start of a boolean operand: after + // `filter` itself, a logical operator, an open paren, or negation + if (/(?:\bfilter|&&|\|\||\^|\(|!)\s*[@\w:]*$/.test(clause)) { + return result([ + ...fieldCompletions(context, scopeStart, getSchemas()), + ...filterExtras, + ]) + } + // any other position (after a complete literal or identifier, closing + // paren, etc.) expects an operator, which we don't complete + return null + } + + // otherwise, at the start of a clause, offer `get` if this is the first + // clause of a query branch and the other table operations after a pipe. + // Known quirk: right after `}` only a pipe is legal, but we offer ops + // anyway — distinguishing that case means tracking boundary kind in the + // scanner, more state than this heuristic approach warrants + if (/^\s*\w*$/.test(clause)) { + return result(clauseStart === scopeStart ? [getOp] : pipeOps) + } + + 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..086f9681b --- /dev/null +++ b/app/components/oxql-error.spec.ts @@ -0,0 +1,156 @@ +/* + * 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 { errorMessageToSegments, 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('errorMessageToSegments', () => { + const t = (text: string) => ({ type: 'text', text }) + const c = (raw: string, code: string, isTruncated = false) => ({ + type: 'code', + isTruncated, + code, + raw, + }) + + it('splits out excerpt markers, quoted tokens, and backticked names', () => { + expect( + errorMessageToSegments('Error at 1:1: .. junk junk! ..\nExpected: one of "get", "{"') + ).toEqual([ + t('Error at 1:1: '), + c('.. junk junk! ..', 'junk junk!', true), + t('\nExpected: one of '), + c('"get"', 'get'), + t(', '), + c('"{"', '{'), + t(''), + ]) + expect(errorMessageToSegments('Input tables to a `group_by` must be aligned')).toEqual([ + t('Input tables to a '), + c('`group_by`', 'group_by'), + t(' 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( + errorMessageToSegments( + 'The filter expression "kind == "power"" is not valid, because' + ) + ).toEqual([ + t('The filter expression '), + c('"kind == "power""', 'kind == "power"'), + t(' 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( + errorMessageToSegments( + 'The filter expression "kind == "power" && sled == 1" is not valid, because' + ) + ).toEqual([ + t('The filter expression '), + c('"kind == "power" && sled == 1"', 'kind == "power" && sled == 1'), + t(' is not valid, because'), + ]) + }) + + it('splits identifier lists into one segment per name', () => { + expect( + errorMessageToSegments( + 'Invalid identifiers: ["chassis_kind"], valid: ["datum", "peer"]' + ) + ).toEqual([ + t('Invalid identifiers: ['), + c('"chassis_kind"', 'chassis_kind'), + t('], valid: ['), + c('"datum"', 'datum'), + t(', '), + c('"peer"', 'peer'), + t(']'), + ]) + }) + + it('leaves unbalanced quotes alone', () => { + const message = 'something with a stray " quote' + expect(errorMessageToSegments(message)).toEqual([t(message)]) + }) +}) diff --git a/app/components/oxql-error.ts b/app/components/oxql-error.ts new file mode 100644 index 000000000..022c05423 --- /dev/null +++ b/app/components/oxql-error.ts @@ -0,0 +1,81 @@ +/* + * 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 +} + +export type Segment = + | { + type: 'code' + isTruncated: boolean + code: string + raw: string + } + | { type: 'text'; text: 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 + */ +const codeSegment = + /(\.\. [\s\S]*? \.\.|(?<=The filter expression )"[^\n]*"(?= is not valid)|(? { + if (i % 2 === 0) return { type: 'text', text: part } + + // code snippets are always wrapped in quotes, backticks, or `.. ..` + const isTruncated = part.startsWith('.. ') + const wrapperLength = isTruncated ? 3 : 1 + return { + type: 'code', + isTruncated, + code: part.slice(wrapperLength, -wrapperLength), + raw: part, + } + }) +} + +/** + * Pull the position and expectation out of an OxQL parse error so it can be + * shown as a diagnostic in the editor. The message format comes from + * omicron's `fmt_parse_error`: an `Error at :` 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 31f54d437..a6b079377 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -62,7 +62,7 @@ export default function SystemLayout() { { value: 'Alerts', path: pb.alerts() }, { value: 'System Update', path: pb.systemUpdate() }, { value: 'Fleet Access', path: pb.fleetAccess() }, - { value: 'OxQL Explorer', path: pb.systemOxql() }, + { value: 'Metrics Explorer', path: pb.systemOxql() }, { value: 'Audit Log', path: pb.auditLog() }, ] // filter out the entry for the path we're currently on @@ -118,7 +118,7 @@ export default function SystemLayout() { Fleet Access - OxQL Explorer + Metrics Explorer Audit Log diff --git a/app/pages/system/MetricsExplorer.tsx b/app/pages/system/MetricsExplorer.tsx new file mode 100644 index 000000000..51e9b1008 --- /dev/null +++ b/app/pages/system/MetricsExplorer.tsx @@ -0,0 +1,984 @@ +/* + * 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 { useQuery } from '@tanstack/react-query' +import { useWindowVirtualizer } from '@tanstack/react-virtual' +import { 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 Distributiondouble, + type MetricType, + type OxqlQueryResult, + type OxqlTable, + type Points, + type Timeseries, + type TimeseriesQuery, + type ValueArray, + type FieldValue, + type TimeseriesSchema, +} 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 { Heatmap } from '~/components/Heatmap' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { + errorMessageToSegments, + 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 { CopyToClipboard } from '~/ui/lib/CopyToClipboard' +import { Divider } from '~/ui/lib/Divider' +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 { TipIcon } from '~/ui/lib/TipIcon' +import { Tooltip } from '~/ui/lib/Tooltip' +import { 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 }[] = [ + { + label: 'Power shelf fan speeds', + value: `get hardware_component:fan_speed + | filter chassis_kind == 'power' + | filter timestamp > @now() - 1m`, + }, + { + label: 'AMD CPU TCTL measurements per slot', + value: `get hardware_component:amd_cpu_tctl + | align mean_within(20s) + | group_by [slot] + | filter timestamp > @now() - 10m`, + }, + { + label: 'Bytes sent & received per sled', + value: `{ + get sled_data_link:bytes_sent + | align mean_within(30s) + | group_by [kind, sled_id]; + get sled_data_link:bytes_received + | align mean_within(30s) + | group_by [kind, sled_id] +} + | filter kind == 'physical' + | filter timestamp > @now() - 10m + | join`, + }, + { + label: 'Virtual disk write latencies', + value: `get virtual_disk:io_latency + | filter timestamp > @now() - 10m + | filter io_kind == 'write'`, + }, +] + +const defaultValues: TimeseriesQuery = { + query: '', +} + +export const handle = { crumb: 'Metrics Explorer' } + +const narrowToNumbers = (vs: ValueArray): (number | null)[] => + match(vs) + .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 + // by only calling this on non-distribution tables (distributions can't be + // aligned/joined), we know this is unreachable + .with({ type: 'integer_distribution' }, () => []) + .with({ type: 'double_distribution' }, () => []) // these don't exist in practice, and are also unreachable per above + .exhaustive() + +const narrowToDistributions = (vs: ValueArray): (Distributiondouble | null)[] => + match(vs) + .with( + { type: 'integer_distribution' }, + { type: 'double_distribution' }, + ({ values }) => values + ) + .otherwise(() => []) + +const leftPad = (items: T[], length: number): (T | null)[] => + items.length >= length ? items : [...Array(length - items.length).fill(null), ...items] + +// The generated client types timestamps as Date, but the wire format is actually ISO strings. Oops! +// `new Date` accepts both. +type OxqlTimestamp = Points['timestamps'][number] +const parseTs = (ts: OxqlTimestamp): number => new Date(ts).getTime() +const toPosix = (timestamps: OxqlTimestamp[]): number[] => timestamps.map(parseTs) + +// Distributions always carry start_times in practice, but this isn't a +// guaranteed invariant. But start times are generally "the timestamp +// preceding the current one", so we can derive that ourselves. +const fakeStartTimes = (timestamps: number[]): number[] => { + if (timestamps.length === 0) return [] + const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : 0 + return [timestamps[0] - interval, ...timestamps.slice(0, -1)] +} + +type TimeseriesKind = 'joined' | 'aligned' | 'unaligned' + +/** + * When aligning a series, the timestamps are all on the same grid, but values at the beginning may + * be missing (e.g. [10,20,30] in one timestamp array, and [20,30] in another). As long as we can + * prove there's a regular grid all the way through, no big deal. + */ +const getAlignedTimestamps = ( + items: Timeseries[] +): { type: 'some'; timestamps: number[] } | { type: 'none' } => { + // aligned tables never have start times + if (!items[0] || items[0].points.startTimes) return { type: 'none' } + // similarly, aligned tables are always doubles (even if their inputs were integers!) + if (!items[0].points.values.every(({ values }) => values.type === 'double')) + return { type: 'none' } + + const longestSeries = R.firstBy(items, (i) => -i.points.timestamps.length) + if (!longestSeries || longestSeries.points.timestamps.length === 0) + return { type: 'none' } + + // converting to posix numbers knocks us down to millisecond precision, but uplot is going to + // plot by second anyways + const posixes = toPosix(longestSeries.points.timestamps) + + const end = R.last(posixes) + // aligned series may not share the same start time, but they will always have a common final + // timestamp + if ( + !items.every(({ points }) => { + const last = R.last(points.timestamps) + // no timestamps at all is fine; otherwise the final one must match the shared end + return last === undefined || parseTs(last) === end + }) + ) + return { type: 'none' } + + if (posixes.length === 1) return { type: 'some', timestamps: posixes } + + const [start, second] = posixes + + const step = second - start + // we'll assume all timestamp lists are aligned if every timestamp on our longest timestamp list + // is aligned, i.e. some `step` away from the first one we look at + if (!posixes.every((time) => (time - start) % step === 0)) return { type: 'none' } + + return { + type: 'some', + timestamps: posixes, + } +} + +type Chart = { + name: string + description?: ReactNode + timestamps: number[] + data: Data +} + +type Multiline = Chart<{ label: string; values: (number | null)[] }[]> +type LineChartData = Chart<(number | null)[]> & { metricType: MetricType } +type HeatmapChartData = Chart<(Distributiondouble | null)[]> & { + metricType: MetricType + startTimes: number[] +} + +type ChartGroup = + | 'empty-timeseries' + | ({ startTime: Date; endTime: Date } & ( + | { kind: 'unaligned'; charts: LineChartData[] } + | { kind: 'distributions'; charts: HeatmapChartData[] } + | { kind: 'aligned'; charts: Multiline[] } + | { kind: 'joined'; charts: Multiline[] } + )) + +const getFormattedFields = (t: Timeseries): string => + Object.entries(t.fields) + .map(([fieldName, x]) => `${camelToSnake(fieldName)}: ${x.value}`) + .join(' / ') + +const DEFAULT_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 text = truncate(value, FIELD_VALUE_MAX_LEN, 'middle') + const badge = ( + +
      + {camelToSnake(fieldName)} + {text} + +
      +
      + ) + if (!truncated) return badge + return ( + + {/* Tooltip applies a ref to its child, but Badge doesn't forward refs */} + {badge} + + ) +} + +const FieldsList = ({ fields }: { fields: Record }) => { + const [showOverflow, setShowOverflow] = useState(false) + const entries = Object.entries(fields) + const toShow = showOverflow ? entries : entries.slice(0, DEFAULT_FIELDS_SHOWN) + + return ( +
      + {toShow.map(([fieldName, x]) => ( + + ))} + {!showOverflow && entries.length > DEFAULT_FIELDS_SHOWN && ( + + )} +
      + ) +} + +// In a joined table, the table name is component metric names, comma-joined. +// e.g. bfd_session:timeout_expired,hardware_component:current +const retrieveMetricNames = (tableName: string): string[] => + tableName.split(',').map((s) => s.trim()) + +const tableToGroup = (table: OxqlTable): ChartGroup => { + const { name, timeseries } = table + if (timeseries.length === 0) return 'empty-timeseries' + const kind: + | Exclude + | { kind: 'aligned'; timestamps: number[] } = + // we expect all values arrays to be the same length, so if the first isn't longer than 1, we + // expect singletons across the board + timeseries[0]?.points.values.length > 1 + ? ('joined' as const) + : match(getAlignedTimestamps(timeseries)) + .with({ type: 'none' }, () => 'unaligned' as const) + .with({ type: 'some' }, ({ timestamps }) => ({ + kind: 'aligned' as const, + timestamps, + })) + .exhaustive() + + const chart = match(kind) + .with('joined', (kind) => { + const metricNames = retrieveMetricNames(name) + + return { + kind, + // when joined, each timeseries is _also_ aligned, but we assume that users want to focus on + // cross-referencing between metrics, so we join the values within a given timeseries, going + // no further + charts: timeseries.map((series) => ({ + name, + description: , + timestamps: toPosix(series.points.timestamps), + data: series.points.values.map((v, i) => ({ + label: + metricNames[i] || + // should be unreachable + `${getFormattedFields(series)} #${i + 1}`, + values: narrowToNumbers(v.values), + })), + })), + } + }) + .with({ kind: 'aligned' }, ({ kind, timestamps }) => ({ + kind, + charts: [ + { + name, + timestamps, + data: timeseries + .filter((s) => s.points.values.length > 0) + .map((series) => ({ + label: getFormattedFields(series), + values: leftPad( + narrowToNumbers(series.points.values[0].values), + timestamps.length + ), + })), + }, + ], + })) + .with('unaligned', () => { + const seriesList = timeseries.filter((s) => s.points.values.length > 0) + // all schemas in a table are the same, so we can just check the first + // https://github.com/oxidecomputer/omicron/blob/3de7e909b196c07811025bbf41aaa8a35e6fa3cf/oximeter/oxql-types/src/table.rs#L280 + const valueType = seriesList[0]?.points.values[0]?.values.type + // if no series had any values, there's nothing to chart + if (valueType === undefined) return { kind: 'unaligned' as const, charts: [] } + + return match(valueType) + .with('integer_distribution', 'double_distribution', () => ({ + kind: 'distributions' as const, + charts: seriesList.map((series): HeatmapChartData => { + const timestamps = toPosix(series.points.timestamps) + return { + name, + description: , + timestamps, + metricType: series.points.values[0].metricType, + startTimes: + series.points.startTimes?.map(parseTs) ?? fakeStartTimes(timestamps), + data: narrowToDistributions(series.points.values[0].values), + } + }), + })) + .with('integer', 'double', 'boolean', 'string', () => ({ + kind: 'unaligned' as const, + charts: seriesList.map( + (series): LineChartData => ({ + name, + description: , + timestamps: toPosix(series.points.timestamps), + metricType: series.points.values[0].metricType, + data: narrowToNumbers(series.points.values[0].values), + }) + ), + })) + .exhaustive() + }) + .exhaustive() + const timestamps = chart.charts.flatMap(({ timestamps }) => timestamps) + const min = R.firstBy(timestamps, (t) => t) + const max = R.firstBy(timestamps, (t) => -t) + + return { + ...chart, + // i figure any chart collection probably benefits from sharing their X-axis, even if they're + // rendered in sequence + startTime: new Date(min ?? 0), + endTime: new Date(max ?? 0), + } +} + +const TICK_UNITS = [ + // TODO: this doesn't quite match the suffixes in the oxql-metrics util, but i'm leaving it + // because i don't understand those + [1e12, 't'], + [1e9, 'b'], + [1e6, 'm'], + [1e3, 'k'], +] as const +const formatTick = (n: number): string => { + const [divisor, suffix] = TICK_UNITS.find(([min]) => Math.abs(n) >= min) ?? [1, ''] + return (n / divisor).toLocaleString() + suffix +} + +// Drops (or keeps, without copying) the first sample of a series. +const dropFirst = + (drop: boolean) => + (xs: T[]): T[] => + drop ? xs.slice(1) : xs + +// We trim timestamps and data at the same time to be confident they're in sync. +const trimSeries = ( + trim: boolean, + { timestamps, data }: { timestamps: number[]; data: T[][] } +) => { + const d = dropFirst(trim) + return { timestamps: d(timestamps), data: data.map(d) } +} +const trimHeatmap = ( + trim: boolean, + { + timestamps, + startTimes, + data, + }: { timestamps: number[]; startTimes: number[]; data: T[] } +) => { + const d = dropFirst(trim) + return { timestamps: d(timestamps), startTimes: d(startTimes), data: d(data) } +} + +// The first aligned point of a cumulative counter is diffed against the counter's start_time, +// collapsing all pre-window history into one giant bucket. It's not "erroneous" but it's usually +// not useful, and you'd want to hide it to get a more useful y-axis for the rest of your data. +const queryHasCumulativeData = ( + queryResult: OxqlQueryResult, + schemas: TimeseriesSchema[] +): boolean => { + const metricNames = new Set( + queryResult.tables.flatMap((t) => retrieveMetricNames(t.name)) + ) + return schemas.some( + (schema) => + metricNames.has(schema.timeseriesName) && + match(schema.datumType) + .with( + 'bool', + 'i8', + 'u8', + 'i16', + 'u16', + 'i32', + 'u32', + 'i64', + 'u64', + 'f32', + 'f64', + 'string', + 'bytes', + () => false + ) + .with( + 'cumulative_i64', + 'cumulative_u64', + 'cumulative_f32', + 'cumulative_f64', + // histogram data is cumulative by definition + 'histogram_i8', + 'histogram_u8', + 'histogram_i16', + 'histogram_u16', + 'histogram_i32', + 'histogram_u32', + 'histogram_i64', + 'histogram_u64', + 'histogram_f32', + 'histogram_f64', + () => true + ) + .exhaustive() + ) +} + +// If the schemas haven't loaded, this is a decent heuristic +const groupHasPointWorthDropping = (g: ChartGroup): boolean => + match(g) + .with('empty-timeseries', () => false) + // Aligned/joined tables may be derived from cumulatives, so we assume it's worth offering + .with({ kind: 'joined' }, { kind: 'aligned' }, () => true) + // Gauges are, by definition, not cumulative, so you'll never see a giant first point + .with({ kind: 'unaligned' }, { kind: 'distributions' }, ({ charts }) => + charts.some((c) => c.metricType !== 'gauge') + ) + .exhaustive() + +type ChartDisplay = { key: string } & ( + | { kind: 'empty' } + | { + kind: 'chart' + startTime: Date + endTime: Date + name: string + description?: ReactNode + timestamps: number[] + data: (number | null)[][] + seriesLabels?: string[] + } + | { + kind: 'heatmap' + name: string + description?: ReactNode + timestamps: number[] + startTimes: number[] + data: (Distributiondouble | null)[] + } +) + +// Virtualization relies on a list of near-same-size items, so we flatten out all the groups +const toDisplays = (groups: ChartGroup[], trim: boolean): ChartDisplay[] => + groups.flatMap((g, t): ChartDisplay[] => { + if (g === 'empty-timeseries') return [{ kind: 'empty', key: `t${t}` }] + const { startTime, endTime } = g + return match(g) + .with({ kind: 'distributions' }, ({ charts }) => + charts.map( + (chart, i): ChartDisplay => ({ + kind: 'heatmap', + key: `t${t}.${i}`, + name: chart.name, + description: chart.description, + ...trimHeatmap(trim, { + timestamps: chart.timestamps, + startTimes: chart.startTimes, + data: chart.data, + }), + }) + ) + ) + .with({ kind: 'unaligned' }, ({ charts }) => + charts.map( + (chart, i): ChartDisplay => ({ + kind: 'chart', + key: `t${t}.${i}`, + startTime, + endTime, + name: chart.name, + description: chart.description, + ...trimSeries(trim, { + timestamps: chart.timestamps, + data: [chart.data], + }), + }) + ) + ) + .with({ kind: 'joined' }, { kind: 'aligned' }, ({ charts }) => + charts.map( + (chart, i): ChartDisplay => ({ + kind: 'chart', + key: `t${t}.${i}`, + startTime, + endTime, + name: chart.name, + description: chart.description, + seriesLabels: chart.data.map((l) => l.label), + ...trimSeries(trim, { + timestamps: chart.timestamps, + data: chart.data.map((d) => d.values), + }), + }) + ) + ) + .exhaustive() + }) + +function ChartCard({ display }: { display: Extract }) { + return ( + + + + + ) +} + +function HeatmapCard({ display }: { display: Extract }) { + return ( + + + + + ) +} + +function ChartEntry({ display }: { display: ChartDisplay }) { + return ( + <> + {match(display) + .with({ kind: 'empty' }, () => ( + + +
      +
      + +
      + + + )) + .with({ kind: 'chart' }, (r) => ) + .with({ kind: 'heatmap' }, (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 = retrieveMetricNames(table.name) + 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 ResultsSummary({ tables }: { tables: OxqlTable[] }) { + const timeseries = tables.flatMap((t) => t.timeseries) + const nPoints = R.sumBy(timeseries, (t) => t.points.timestamps.length) + return ( +
      + {timeseries.length} timeseries /{' '} + + {nPoints.toLocaleString()} {pluralize('point', nPoints)} + +
      + ) +} + +// 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 }) => ( + + {errorMessageToSegments(message).map((segment, i) => + match(segment) + .with({ type: 'text' }, ({ text }) => text) + .with({ type: 'code' }, ({ isTruncated, code, raw }) => { + // an empty chip is just visual noise; show the raw text instead + if (!code) return raw + return ( + + {isTruncated && '.. '} + {code} + {isTruncated && ' ..'} + + ) + }) + .exhaustive() + )} + +) + +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 MetricsExplorer() { + const query = useApiMutation(api.systemTimeseriesQuery) + + // powers editor autocomplete. no loading state needed: completions are a + // progressive enhancement + const schemas = useQuery(q(api.systemTimeseriesSchemaList, { query: { limit: ALL_ISH } })) + + const [searchParams, setSearchParams] = useSearchParams() + + const defaultQuery = searchParams.get('query') ?? defaultValues.query + + const form = useForm({ + defaultValues: { query: defaultQuery }, + }) + const { field, fieldState } = useController({ + name: 'query', + control: form.control, + rules: { + validate: (value) => (value.trim() ? undefined : 'Enter a query'), + }, + }) + + const [dropFirstPoint, setDropFirstPoint] = useState(true) + + const onSubmit = (body: TimeseriesQuery) => { + query.mutate( + { body }, + { + onSuccess: () => { + setSearchParams( + (params) => { + params.set('query', body.query) + return params + }, + { replace: true, preventScrollReset: true, state: { skipLoadingBar: true } } + ) + }, + } + ) + } + + // 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 = + schemas.data && query.data + ? queryHasCumulativeData(query.data, schemas.data.items) + : (chartGroups?.some(groupHasPointWorthDropping) ?? false) + const trim = dropFirstPoint && hasTrimmableCharts + + const charts = useMemo( + () => (chartGroups ? toDisplays(chartGroups, trim) : []), + [chartGroups, trim] + ) + + // 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 + // prior to the virtualized area, we can keep the list's offset height in sync. + const [preChartsSize, preChartsRef] = useElementSize() + const chartsRef = useRef(null) + const [scrollMargin, setScrollMargin] = useState(0) + useLayoutEffect(() => { + if (chartsRef.current) { + setScrollMargin(chartsRef.current.getBoundingClientRect().top + window.scrollY) + } + }, [preChartsSize?.height, charts.length]) + + const virtualizer = useWindowVirtualizer({ + count: charts.length, + estimateSize: () => 500, + overscan: 4, + scrollMargin, + getItemKey: (i) => charts[i].key, + }) + + const errorMessage = fieldState.error?.message ? ( + {fieldState.error.message} + ) : query.error ? ( + + ) : null + + return ( + <> +
      + + }>Metrics Explorer + } + summary="The Oximeter Query Language is a domain-specific language for interrogating telemetry data from software and hardware components across the rack." + links={[docLinks.oxql, docLinks.oxqlSchemas]} + /> + +
      + + +
      + {query.status === 'success' && ( + + )} + + +
      +
      + +
      + form.handleSubmit(onSubmit)()} + schemas={schemas.data?.items} + /> + {errorMessage} +
      +
      + Examples + {exampleItems.map(({ label, value }) => ( + + ))} +
      +
      +
      +
      +
      + + {match(query) + // 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' }, () => ( + + + {null} + + + )) + .with({ status: 'pending' }, () => ( + + + + + + )) + .with({ status: 'success' }, () => ( + + {hasTrimmableCharts && ( +
      + setDropFirstPoint(e.target.checked)} + > + Drop first data point + + + With deltas and cumulative counters, the initial point is a delta from the + earliest observed value. It's therefore typically much larger, and not + worth comparing to the rest of your data. + +
      + )} +
      + {virtualizer.getVirtualItems().map((item) => ( +
      + +
      + ))} +
      +
      + )) + .exhaustive()} + + ) +} diff --git a/app/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx deleted file mode 100644 index f82a68de5..000000000 --- a/app/pages/system/OxqlPage.tsx +++ /dev/null @@ -1,719 +0,0 @@ -/* - * 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 { useWindowVirtualizer } from '@tanstack/react-virtual' -import { useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useForm } from 'react-hook-form' -import { useSearchParams } from 'react-router' -import * as R from 'remeda' -import { match } from 'ts-pattern' - -import { - api, - useApiMutation, - camelToSnake, - type Distributiondouble, - type MetricType, - type OxqlTable, - type Points, - type Timeseries, - type TimeseriesQuery, - type ValueArray, -} from '@oxide/api' -import { Monitoring16Icon, Monitoring24Icon } from '@oxide/design-system/icons/react' - -import { DocsPopover } from '~/components/DocsPopover' -import { OxqlField } from '~/components/form/fields/OxqlField' -import { Heatmap } from '~/components/Heatmap' -import { - ChartContainer, - ChartHeader, - SkeletonMetric, - MetricsEmpty, - TimeSeriesChart, -} from '~/components/TimeSeriesChart' -import { useElementSize } from '~/hooks/use-element-size' -import { Button } from '~/ui/lib/Button' -import { Divider } from '~/ui/lib/Divider' -import * as DropdownMenu from '~/ui/lib/DropdownMenu' -import { Message } from '~/ui/lib/Message' -import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' -import { docLinks } from '~/util/links' - -const exampleItems: { label: string; value: string }[] = [ - { - label: 'Power shelf fan speeds', - value: `get hardware_component:fan_speed - | filter chassis_kind == 'power' - | filter timestamp > @now() - 1m`, - }, - { - label: 'AMD CPU TCTL measurements per slot', - value: `get hardware_component:amd_cpu_tctl - | align mean_within(20s) - | group_by [slot] - | filter timestamp > @now() - 10m`, - }, - { - label: 'Bytes sent & received per sled', - value: `{ - get sled_data_link:bytes_sent - | align mean_within(30s) - | group_by [kind, sled_id]; - get sled_data_link:bytes_received - | align mean_within(30s) - | group_by [kind, sled_id] -} - | filter kind == 'physical' - | filter timestamp > @now() - 10m - | join`, - }, - { - label: 'Virtual disk write latencies', - value: `get virtual_disk:io_latency - | filter timestamp > @now() - 10m - | filter io_kind == 'write'`, - }, -] - -const defaultValues: TimeseriesQuery = { - query: '', -} - -export const handle = { crumb: 'OxQL Explorer' } - -const narrowToNumbers = (vs: ValueArray): (number | null)[] => - match(vs) - .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 - // by only calling this on non-distribution tables (distributions can't be - // aligned/joined), we know this is unreachable - .with({ type: 'integer_distribution' }, () => []) - .with({ type: 'double_distribution' }, () => []) // these don't exist in practice, and are also unreachable per above - .exhaustive() - -const narrowToDistributions = (vs: ValueArray): (Distributiondouble | null)[] => - match(vs) - .with( - { type: 'integer_distribution' }, - { type: 'double_distribution' }, - ({ values }) => values - ) - .otherwise(() => []) - -const leftPad = (items: T[], length: number): (T | null)[] => - items.length >= length ? items : [...Array(length - items.length).fill(null), ...items] - -// The generated client types timestamps as Date, but the wire format is actually ISO strings. Oops! -// `new Date` accepts both. -type OxqlTimestamp = Points['timestamps'][number] -const parseTs = (ts: OxqlTimestamp): number => new Date(ts).getTime() -const toPosix = (timestamps: OxqlTimestamp[]): number[] => timestamps.map(parseTs) - -// Distributions always carry start_times in practice, but this isn't a -// guaranteed invariant. But start times are generally "the timestamp -// preceding the current one", so we can derive that ourselves. -const fakeStartTimes = (timestamps: number[]): number[] => { - if (timestamps.length === 0) return [] - const interval = timestamps.length > 1 ? timestamps[1] - timestamps[0] : 0 - return [timestamps[0] - interval, ...timestamps.slice(0, -1)] -} - -type TimeseriesKind = 'joined' | 'aligned' | 'unaligned' - -/** - * When aligning a series, the timestamps are all on the same grid, but values at the beginning may - * be missing (e.g. [10,20,30] in one timestamp array, and [20,30] in another). As long as we can - * prove there's a regular grid all the way through, no big deal. - */ -const getAlignedTimestamps = ( - items: Timeseries[] -): { type: 'some'; timestamps: number[] } | { type: 'none' } => { - // aligned tables never have start times - if (!items[0] || items[0].points.startTimes) return { type: 'none' } - // similarly, aligned tables are always doubles (even if their inputs were integers!) - if (!items[0].points.values.every(({ values }) => values.type === 'double')) - return { type: 'none' } - - const longestSeries = R.firstBy(items, (i) => -i.points.timestamps.length) - if (!longestSeries || longestSeries.points.timestamps.length === 0) - return { type: 'none' } - - // converting to posix numbers knocks us down to millisecond precision, but uplot is going to - // plot by second anyways - const posixes = toPosix(longestSeries.points.timestamps) - - const end = R.last(posixes) - // aligned series may not share the same start time, but they will always have a common final - // timestamp - if ( - !items.every(({ points }) => { - const last = R.last(points.timestamps) - // no timestamps at all is fine; otherwise the final one must match the shared end - return last === undefined || parseTs(last) === end - }) - ) - return { type: 'none' } - - if (posixes.length === 1) return { type: 'some', timestamps: posixes } - - const [start, second] = posixes - - const step = second - start - // we'll assume all timestamp lists are aligned if every timestamp on our longest timestamp list - // is aligned, i.e. some `step` away from the first one we look at - if (!posixes.every((time) => (time - start) % step === 0)) return { type: 'none' } - - return { - type: 'some', - timestamps: posixes, - } -} - -type Chart = { - name: string - description?: string - timestamps: number[] - data: Data -} - -type Multiline = Chart<{ label: string; values: (number | null)[] }[]> -type Line = Chart<(number | null)[]> & { metricType: MetricType } -type Heatmap = Chart<(Distributiondouble | null)[]> & { - metricType: MetricType - startTimes: number[] -} - -type ChartGroup = - | 'empty-timeseries' - | ({ startTime: Date; endTime: Date } & ( - | { kind: 'unaligned'; charts: Line[] } - | { kind: 'distributions'; charts: Heatmap[] } - | { kind: 'aligned'; charts: Multiline[] } - | { kind: 'joined'; charts: Multiline[] } - )) - -const getFormattedFields = (t: Timeseries): string => - Object.entries(t.fields) - // hello my evil friend. - .map(([fieldName, x]) => `${camelToSnake(fieldName)}: ${x.value}`) - .join(' \u2022 ') - -const tableToGroup = (table: OxqlTable): ChartGroup => { - const { name, timeseries } = table - if (timeseries.length === 0) return 'empty-timeseries' - const kind: - | Exclude - | { kind: 'aligned'; timestamps: number[] } = - // we expect all values arrays to be the same length, so if the first isn't longer than 1, we - // expect singletons across the board - timeseries[0]?.points.values.length > 1 - ? ('joined' as const) - : match(getAlignedTimestamps(timeseries)) - .with({ type: 'none' }, () => 'unaligned' as const) - .with({ type: 'some' }, ({ timestamps }) => ({ - kind: 'aligned' as const, - timestamps, - })) - .exhaustive() - - const chart = match(kind) - .with('joined', (kind) => { - // In a joined table, each Values item is a distinct metric:target and the - // table name is those metric names comma-joined, index-aligned to the Values. - // So the line labels come from the table name, not the (identical-per-line) - // joined field. - const metricNames = name.split(',').map((s) => s.trim()) - - return { - kind, - // when joined, each timeseries is _also_ aligned, but we assume that users want to focus on - // cross-referencing between metrics, so we join the values within a given timeseries, going - // no further - charts: timeseries.map((series) => ({ - name, - description: getFormattedFields(series), - timestamps: toPosix(series.points.timestamps), - data: series.points.values.map((v, i) => ({ - label: - metricNames[i] || - // should be unreachable - `${getFormattedFields(series)} #${i + 1}`, - values: narrowToNumbers(v.values), - })), - })), - } - }) - .with({ kind: 'aligned' }, ({ kind, timestamps }) => ({ - kind, - charts: [ - { - name, - timestamps, - data: timeseries - .filter((s) => s.points.values.length > 0) - .map((series) => ({ - label: getFormattedFields(series), - values: leftPad( - narrowToNumbers(series.points.values[0].values), - timestamps.length - ), - })), - }, - ], - })) - .with('unaligned', () => { - const seriesList = timeseries.filter((s) => s.points.values.length > 0) - // all schemas in a table are the same, so we can just check the first - // https://github.com/oxidecomputer/omicron/blob/3de7e909b196c07811025bbf41aaa8a35e6fa3cf/oximeter/oxql-types/src/table.rs#L280 - const valueType = seriesList[0]?.points.values[0]?.values.type - // if no series had any values, there's nothing to chart - if (valueType === undefined) return { kind: 'unaligned' as const, charts: [] } - - return match(valueType) - .with('integer_distribution', 'double_distribution', () => ({ - kind: 'distributions' as const, - charts: seriesList.map((series): Heatmap => { - const timestamps = toPosix(series.points.timestamps) - return { - name, - description: getFormattedFields(series), - timestamps, - metricType: series.points.values[0].metricType, - startTimes: - series.points.startTimes?.map(parseTs) ?? fakeStartTimes(timestamps), - data: narrowToDistributions(series.points.values[0].values), - } - }), - })) - .with('integer', 'double', 'boolean', 'string', () => ({ - kind: 'unaligned' as const, - charts: seriesList.map( - (series): Line => ({ - name, - description: getFormattedFields(series), - timestamps: toPosix(series.points.timestamps), - metricType: series.points.values[0].metricType, - data: narrowToNumbers(series.points.values[0].values), - }) - ), - })) - .exhaustive() - }) - .exhaustive() - const timestamps = chart.charts.flatMap(({ timestamps }) => timestamps) - const min = R.firstBy(timestamps, (t) => t) - const max = R.firstBy(timestamps, (t) => -t) - - return { - ...chart, - // i figure any chart collection probably benefits from sharing their X-axis, even if they're - // rendered in sequence - startTime: new Date(min ?? 0), - endTime: new Date(max ?? 0), - } -} - -const TICK_UNITS = [ - // TODO: this doesn't quite match the suffixes in the oxql-metrics util, but i'm leaving it - // because i don't understand those - [1e12, 't'], - [1e9, 'b'], - [1e6, 'm'], - [1e3, 'k'], -] as const -const formatTick = (n: number): string => { - const [divisor, suffix] = TICK_UNITS.find(([min]) => Math.abs(n) >= min) ?? [1, ''] - return (n / divisor).toLocaleString() + suffix -} - -// Drops (or keeps, without copying) the first sample of a series. -const dropFirst = - (drop: boolean) => - (xs: T[]): T[] => - drop ? xs.slice(1) : xs - -// We trim timestamps and data at the same time to be confident they're in sync. -const trimSeries = ( - trim: boolean, - { timestamps, data }: { timestamps: number[]; data: T[][] } -) => { - const d = dropFirst(trim) - return { timestamps: d(timestamps), data: data.map(d) } -} -const trimHeatmap = ( - trim: boolean, - { - timestamps, - startTimes, - data, - }: { timestamps: number[]; startTimes: number[]; data: T[] } -) => { - const d = dropFirst(trim) - return { timestamps: d(timestamps), startTimes: d(startTimes), data: d(data) } -} - -// The first aligned point of a cumulative counter is diffed against the counter's start_time, -// collapsing all pre-window history into one giant bucket. It's not "erroneous" but it's usually -// not useful, and you'd want to hide it to get a more useful y-axis for the rest of your data. -const groupHasPointWorthDropping = (g: ChartGroup): boolean => - match(g) - .with('empty-timeseries', () => false) - // Aligned/joined tables may be derived from cumulatives, so we assume it's worth offering - .with({ kind: 'joined' }, { kind: 'aligned' }, () => true) - // Gauges are, by definition, not cumulative, so you'll never see a giant first point - .with({ kind: 'unaligned' }, { kind: 'distributions' }, ({ charts }) => - charts.some((c) => c.metricType !== 'gauge') - ) - .exhaustive() - -// A flattened representation of a single chart. -type ChartDisplay = { key: string; showDivider: boolean } & ( - | { kind: 'empty' } - | { kind: 'multiline'; startTime: Date; endTime: Date; chart: Multiline } - | { kind: 'line'; startTime: Date; endTime: Date; chart: Line } - | { kind: 'heatmap'; chart: Heatmap } -) - -// Virtualization relies on a list of near-same-size items, so we flatten out all the groups -const toDisplays = (groups: ChartGroup[]): ChartDisplay[] => - groups.flatMap((g, t): ChartDisplay[] => { - if (g === 'empty-timeseries') - return [{ kind: 'empty', key: `t${t}`, showDivider: true }] - const { startTime, endTime } = g - return match(g) - .with({ kind: 'distributions' }, ({ charts }) => - charts.map( - (chart, i): ChartDisplay => ({ - kind: 'heatmap', - key: `t${t}.${i}`, - showDivider: i === 0, - chart, - }) - ) - ) - .with({ kind: 'unaligned' }, ({ charts }) => - charts.map( - (chart, i): ChartDisplay => ({ - kind: 'line', - key: `t${t}.${i}`, - showDivider: i === 0, - startTime, - endTime, - chart, - }) - ) - ) - .with({ kind: 'joined' }, { kind: 'aligned' }, ({ charts }) => - charts.map( - (chart, i): ChartDisplay => ({ - kind: 'multiline', - key: `t${t}.${i}`, - showDivider: i === 0, - startTime, - endTime, - chart, - }) - ) - ) - .exhaustive() - }) - -function MultilineChart({ - display, - trim, -}: { - display: Extract - trim: boolean -}) { - const { chart, startTime, endTime } = display - const trimmed = trimSeries(trim, { - timestamps: chart.timestamps, - data: chart.data.map((d) => d.values), - }) - const seriesLabels = chart.data.map((l) => l.label) - return ( - - - - - ) -} - -function LineChart({ - display, - trim, -}: { - display: Extract - trim: boolean -}) { - const { chart, startTime, endTime } = display - const trimmed = trimSeries(trim, { data: [chart.data], timestamps: chart.timestamps }) - return ( - - - - - ) -} - -function HeatmapChart({ - display, - trim, -}: { - display: Extract - trim: boolean -}) { - const { chart } = display - const trimmed = trimHeatmap(trim, { - timestamps: chart.timestamps, - startTimes: chart.startTimes, - data: chart.data, - }) - return ( - - - - - ) -} - -function ChartEntry({ display, trim }: { display: ChartDisplay; trim: boolean }) { - return ( - <> - {display.showDivider ? ( - // Use padding for spacing so the virtualizer can measure the bounding box properly -
      - -
      - ) : ( -
      - )} - {match(display) - .with({ kind: 'empty' }, () => ( - - - - )) - .with({ kind: 'multiline' }, (r) => ) - .with({ kind: 'line' }, (r) => ) - .with({ kind: 'heatmap' }, (r) => ) - .exhaustive()} - - ) -} - -const getTextareaHeightForQuery = (q: string): number => Math.max(q.split('\n').length, 4) - -export default function OxqlPage() { - const query = useApiMutation(api.systemTimeseriesQuery) - - 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 [dropFirstPoint, setDropFirstPoint] = useState(true) - - const onSubmit = (body: TimeseriesQuery) => { - query.mutate( - { body }, - { - onSuccess: () => { - setSearchParams( - (params) => { - params.set('query', body.query) - return params - }, - { replace: true, preventScrollReset: true, state: { skipLoadingBar: true } } - ) - }, - } - ) - } - - const chartGroups: ChartGroup[] | null = useMemo( - () => (query.data ? query.data.tables.map(tableToGroup) : null), - [query.data] - ) - - const hasTrimmableCharts = chartGroups?.some(groupHasPointWorthDropping) ?? false - const trim = dropFirstPoint && hasTrimmableCharts - - const charts = useMemo(() => (chartGroups ? toDisplays(chartGroups) : []), [chartGroups]) - - // 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 - // prior to the virtualized area, we can keep the list's offset height in sync. - const [preChartsSize, preChartsRef] = useElementSize() - const chartsRef = useRef(null) - const [scrollMargin, setScrollMargin] = useState(0) - useLayoutEffect(() => { - if (chartsRef.current) { - setScrollMargin(chartsRef.current.getBoundingClientRect().top + window.scrollY) - } - }, [preChartsSize?.height, charts.length]) - - const virtualizer = useWindowVirtualizer({ - count: charts.length, - estimateSize: () => 500, - overscan: 4, - scrollMargin, - getItemKey: (i) => charts[i].key, - }) - - return ( - <> -
      - - }>OxQL Explorer - } - summary="The Oximeter Query Language is a domain-specific language for interrogating telemetry data from software and hardware components across the rack." - links={[docLinks.oxql, docLinks.oxqlSchemas]} - /> - -
      -
      - - - Try an example - - } - /> - - {exampleItems.map(({ label, value }) => ( - { - setTextareaRowCount(getTextareaHeightForQuery(value)) - form.setValue('query', value) - }} - /> - ))} - - -
      - - - - {match(query) - .with( - { status: 'success' }, - () => - hasTrimmableCharts && ( -
      - -
      - ) - ) - .otherwise(() => '')} -
      - - {match(query) - .with({ status: 'idle' }, () => null) - .with({ status: 'pending' }, () => ( - - - - )) - .with({ status: 'error' }, (q) => ( - {q.error.message}} - /> - )) - .with({ status: 'success' }, () => ( -
      - {virtualizer.getVirtualItems().map((item) => ( -
      - -
      - ))} -
      - )) - .exhaustive()} - - ) -} diff --git a/app/routes.tsx b/app/routes.tsx index 63affa364..d890ade12 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/MetricsExplorer.tsx').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..8b7b3eaf0 --- /dev/null +++ b/app/ui/styles/components/oxql-editor.css @@ -0,0 +1,139 @@ +/* + * 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