From dd553f5a033a5ab6a15821b72b77e7bea136dd8a Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 21 Aug 2026 14:59:12 +0100 Subject: [PATCH 1/9] Initial UX rework --- app/components/OxqlEditor.tsx | 262 +++++++++++++ app/components/TimeSeriesChart.tsx | 2 +- app/components/form/fields/OxqlField.tsx | 30 -- app/layouts/SystemLayout.tsx | 4 +- app/pages/system/OxqlPage.tsx | 353 ++++++++++++------ app/routes.tsx | 5 +- .../__snapshots__/path-builder.spec.ts.snap | 6 +- app/util/path-builder.spec.ts | 2 +- app/util/path-builder.ts | 2 +- package-lock.json | 224 ++++++++--- package.json | 5 + test/e2e/oxql.e2e.ts | 47 ++- 12 files changed, 716 insertions(+), 226 deletions(-) create mode 100644 app/components/OxqlEditor.tsx delete mode 100644 app/components/form/fields/OxqlField.tsx diff --git a/app/components/OxqlEditor.tsx b/app/components/OxqlEditor.tsx new file mode 100644 index 000000000..388466224 --- /dev/null +++ b/app/components/OxqlEditor.tsx @@ -0,0 +1,262 @@ +/* + * 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 } from '@codemirror/commands' +import { bracketMatching } from '@codemirror/language' +import { Compartment, RangeSetBuilder } from '@codemirror/state' +import { + Decoration, + EditorView, + highlightActiveLine, + keymap, + ViewPlugin, + type DecorationSet, + type ViewUpdate, +} from '@codemirror/view' +import cn from 'classnames' +import { useEffect, useRef } from 'react' +import { + createHighlighterCoreSync, + type LanguageRegistration, + type ThemeRegistrationAny, +} from 'shiki/core' +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' + +// OxQL grammar copied from the design system so we can highlight queries +// without pulling in its full asciidoc bundle. One addition over the source: +// single-quoted strings, which OxQL supports and our examples use. +// https://github.com/oxidecomputer/design-system/blob/main/components/src/asciidoc/langs/oxql.tmLanguage.json +const oxqlGrammar = { + name: 'oxql', + scopeName: 'source.oxql', + repository: {}, + patterns: [ + { name: 'keyword.control.oxql', match: '\\b(get|join|align|filter|group_by)\\b' }, + { + name: 'string.quoted.double.oxql', + begin: '"', + end: '"', + patterns: [{ name: 'constant.character.escape.oxql', match: '\\\\.' }], + }, + { + name: 'string.quoted.single.oxql', + begin: "'", + end: "'", + patterns: [{ name: 'constant.character.escape.oxql', match: '\\\\.' }], + }, + { name: 'constant.numeric.oxql', match: '\\b\\d+[smhdw]\\b' }, + { + name: 'constant.numeric.datetime.oxql', + match: '@\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}', + }, + { name: 'constant.numeric.function.oxql', match: '@now\\(\\)' }, + { name: 'constant.numeric.oxql', match: '\\b\\d+\\b' }, + { name: 'comment.block.oxql', begin: '/\\*', end: '\\*/' }, + { name: 'comment.line.double-slash.oxql', match: '//.*$' }, + { name: 'keyword.operator.oxql', match: '\\|' }, + ], +} satisfies LanguageRegistration + +// Subset of the design system's Oxide syntax theme covering the scopes the +// OxQL grammar emits. The --syntax-* vars come from the design system +// stylesheets already imported in app/ui/styles/index.css, so this follows +// the current theme automatically. +// https://github.com/oxidecomputer/design-system/blob/main/components/src/asciidoc/oxide-syntax.json +const oxideTheme = { + name: 'oxide', + colors: { + 'editor.background': 'transparent', + 'editor.foreground': 'var(--syntax-fg)', + }, + tokenColors: [ + { scope: ['comment'], settings: { foreground: 'var(--syntax-comment)' } }, + { scope: ['string'], settings: { foreground: 'var(--syntax-string)' } }, + { + scope: ['constant.character.escape'], + settings: { foreground: 'var(--syntax-escape)' }, + }, + { scope: ['constant.numeric'], settings: { foreground: 'var(--syntax-number)' } }, + { scope: ['keyword'], settings: { foreground: 'var(--syntax-keyword)' } }, + { scope: ['keyword.operator'], settings: { foreground: 'var(--syntax-operator)' } }, + ], +} satisfies ThemeRegistrationAny + +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: 'oxide', + })) { + 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 } +) + +// Ported from the editor theme in mitos (app/components/code-editor.tsx), +// with its hardcoded dark-palette hexes swapped for the equivalent design +// system vars so light mode works too. Text selection is native, so the +// console's global ::selection style applies without any theming here. The +// font comes from the wrapper (text-mono-code), hence the `inherit`s. +const cmTheme = EditorView.theme({ + '&': { + backgroundColor: 'var(--syntax-bg)', + color: 'var(--syntax-fg)', + // fixed height of ~6 lines; longer queries scroll inside the editor + height: '7.5rem', + }, + '.cm-scroller': { overflow: 'auto' }, + // the wrapper carries the focus ring (focus-within), so hide CM's own outline + '&.cm-focused': { outline: 'none' }, + '.cm-content': { + fontFamily: 'inherit', + padding: '10px 0', + caretColor: 'var(--syntax-fg)', + }, + '.cm-line': { padding: '0 12px' }, + '.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--syntax-fg)' }, + '.cm-activeLine': { backgroundColor: 'var(--surface-secondary)' }, + '.cm-matchingBracket, .cm-nonmatchingBracket': { + backgroundColor: 'var(--surface-hover)', + outline: 'none', + }, + '.cm-matchingBracket': { color: 'var(--syntax-fg)' }, + '.cm-nonmatchingBracket': { color: 'var(--content-destructive)' }, +}) + +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 + 'aria-label': string +} + +/** A CodeMirror editor for OxQL queries with shiki syntax highlighting */ +export function OxqlEditor({ + value, + onChange, + onSubmit, + error = false, + '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 }) + useEffect(() => { + callbacks.current = { onChange, onSubmit } + }) + + 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, + ]), + EditorView.lineWrapping, + highlightActiveLine(), + bracketMatching(), + shikiPlugin, + cmTheme, + 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]) + + return ( +
+ ) +} diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index d1da5ddcb..e11a16965 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, diff --git a/app/components/form/fields/OxqlField.tsx b/app/components/form/fields/OxqlField.tsx deleted file mode 100644 index d663ece6a..000000000 --- a/app/components/form/fields/OxqlField.tsx +++ /dev/null @@ -1,30 +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 type { FieldPath, FieldValues } from 'react-hook-form' - -import type { TextAreaProps } from '~/ui/lib/TextInput' - -import { TextField, type TextFieldProps } from './TextField' - -export function OxqlField< - TFieldValues extends FieldValues, - TName extends FieldPath, ->( - props: Omit, 'validate'> & Omit -) { - return ( - - typeof value === 'string' && value.trim() ? undefined : 'Enter a query' - } - {...props} - /> - ) -} 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..5ac61ba7c 100644 --- a/app/pages/system/OxqlPage.tsx +++ b/app/pages/system/OxqlPage.tsx @@ -6,8 +6,8 @@ * Copyright Oxide Computer Company */ import { useWindowVirtualizer } from '@tanstack/react-virtual' -import { useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useForm } from 'react-hook-form' +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' @@ -18,6 +18,7 @@ import { camelToSnake, type Timeseries, type Points, + type OxqlQueryResult, type OxqlTable, type TimeseriesQuery, type Values, @@ -25,15 +26,25 @@ import { import { Monitoring16Icon, Monitoring24Icon } from '@oxide/design-system/icons/react' 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 { 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 { Divider } from '~/ui/lib/Divider' -import * as DropdownMenu from '~/ui/lib/DropdownMenu' +import * as Dropdown from '~/ui/lib/DropdownMenu' import { Message } from '~/ui/lib/Message' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { TextInputError } from '~/ui/lib/TextInput' import { docLinks } from '~/util/links' +import { pluralize } from '~/util/str' const exampleItems: { label: string; value: string }[] = [ { @@ -69,7 +80,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) @@ -310,28 +321,24 @@ const toDisplays = (groups: ChartGroup[]): ChartDisplay[] => const { startTime, endTime } = g return match(g) .with({ kind: 'unaligned' }, ({ charts }) => - charts.map( - (chart, i): ChartDisplay => ({ - kind: 'line', - key: `t${t}.${i}`, - showDivider: i === 0, - startTime, - endTime, - chart, - }) - ) + 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, - }) - ) + charts.map((chart, i): ChartDisplay => ({ + kind: 'multiline', + key: `t${t}.${i}`, + showDivider: i === 0, + startTime, + endTime, + chart, + })) ) .exhaustive() }) @@ -413,14 +420,6 @@ function LineChart({ function ChartEntry({ display, trim }: { display: ChartDisplay; trim: Trim }) { 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) => ) @@ -430,7 +429,104 @@ function ChartEntry({ display, trim }: { display: ChartDisplay; trim: Trim }) { ) } -const getTextareaHeightForQuery = (q: string): number => Math.max(q.split('\n').length, 4) +// 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') +} + +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)} + +
+ ) +} + +const copyText = (text: string, toastMessage: string) => { + window.navigator.clipboard.writeText(text).then(() => addToast(toastMessage)) +} + +// wrap in single quotes, escaping any embedded ones, so the multiline query +// survives pasting into a shell +const shellQuote = (s: string) => `'${s.replaceAll("'", "'\\''")}'` + +// The CLI equivalent of this page's query endpoint. See "API and CLI access": +// https://docs.oxide.computer/guides/metrics/oxql-tutorial#_api_and_cli_access +const toCliCommand = (query: string) => + `oxide experimental system timeseries query --query ${shellQuote(query)}` + +function ResultsMenu({ data, query }: { data?: OxqlQueryResult; query?: string }) { + // 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" + /> + query && copyText(toCliCommand(query), 'CLI command copied')} + label="Copy CLI command" + /> + + ) +} + +// 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) @@ -439,14 +535,16 @@ export default function OxqlPage() { 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) @@ -501,7 +599,7 @@ export default function OxqlPage() { <>
- }>OxQL Explorer + }>Metrics Explorer } @@ -509,95 +607,116 @@ export default function OxqlPage() { links={[docLinks.oxql, docLinks.oxqlSchemas]} /> -
-
- - - Try an example - - } - /> - + + + +
+ {query.status === 'success' && ( + <> + + + )} + + +
+
+ +
+ form.handleSubmit(onSubmit)()} + /> + {fieldState.error?.message && ( + {fieldState.error.message} + )} +
+
+ 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 })} + > + {label} + ))} - - -
- - +
+ +
- {match(query) - .with( - { status: 'success' }, - () => - hasTrimmableCharts && ( -
- -
- ) - ) - .otherwise(() => '')}
{match(query) - .with({ status: 'idle' }, () => null) + .with({ status: 'idle' }, () => ( + + + {/* the loading skeleton, minus the shimmer and bouncing indicator */} + {null} + + + )) .with({ status: 'pending' }, () => ( - - - + + + + + )) .with({ status: 'error' }, (q) => ( - {q.error.message}} - /> + + {q.error.message}} + /> + )) .with({ status: 'success' }, () => ( -
- {virtualizer.getVirtualItems().map((item) => ( -
- + + {hasTrimmableCharts && ( +
+
- ))} -
+ )} +
+ {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/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index a05832ff5..c241879a9 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -889,10 +889,10 @@ exports[`breadcrumbs 2`] = ` "path": "/system/networking/", }, ], - "systemOxql (/system/oxql)": [ + "systemOxql (/system/metrics-explorer)": [ { - "label": "OxQL Explorer", - "path": "/system/oxql", + "label": "Metrics Explorer", + "path": "/system/metrics-explorer", }, ], "systemUpdate (/system/update)": [ diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 11ec3323f..1e04d5eb8 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -114,7 +114,7 @@ test('path builder', () => { "subnetPoolMemberAdd": "/system/networking/subnet-pools/sp/members-add", "subnetPools": "/system/networking/subnet-pools", "subnetPoolsNew": "/system/networking/subnet-pools-new", - "systemOxql": "/system/oxql", + "systemOxql": "/system/metrics-explorer", "systemUpdate": "/system/update", "systemUtilization": "/system/utilization", "vpc": "/projects/p/vpcs/v/firewall-rules", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 5de0438b4..ea2b6070b 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -115,7 +115,7 @@ export const pb = { siloImage: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}`, fleetAccess: () => '/system/access', - systemOxql: () => '/system/oxql', + systemOxql: () => '/system/metrics-explorer', systemUtilization: () => '/system/utilization', ipPools: () => '/system/networking/ip-pools', diff --git a/package-lock.json b/package-lock.json index e43489414..b673d6af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,10 @@ "license": "MPL-2.0", "dependencies": { "@base-ui/react": "^1.1.0", + "@codemirror/commands": "^6.11.0", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.9", "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.9", "@oxide/design-system": "^6.3.0", @@ -45,6 +49,7 @@ "react-stately": "^3.32.2", "remeda": "^2.30.0", "semver": "^7.7.3", + "shiki": "^3.23.0", "simplebar-react": "^3.2.6", "ts-pattern": "^5.8.0", "tslib": "^2.7.0", @@ -381,6 +386,53 @@ "tough-cookie": "^4.1.4" } }, + "node_modules/@codemirror/commands": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.0.tgz", + "integrity": "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.9", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.9.tgz", + "integrity": "sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@commander-js/extra-typings": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", @@ -1152,6 +1204,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, "node_modules/@mswjs/http-middleware": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@mswjs/http-middleware/-/http-middleware-0.10.3.tgz", @@ -4990,60 +5072,60 @@ "license": "MIT" }, "node_modules/@shikijs/core": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.13.0.tgz", - "integrity": "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.13.0", + "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "node_modules/@shikijs/engine-javascript": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.13.0.tgz", - "integrity": "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.13.0", + "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.3" + "oniguruma-to-es": "^4.3.4" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.13.0.tgz", - "integrity": "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.13.0", + "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "node_modules/@shikijs/langs": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.13.0.tgz", - "integrity": "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.13.0" + "@shikijs/types": "3.23.0" } }, "node_modules/@shikijs/themes": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.13.0.tgz", - "integrity": "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.13.0" + "@shikijs/types": "3.23.0" } }, "node_modules/@shikijs/types": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.13.0.tgz", - "integrity": "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -5734,9 +5816,9 @@ "license": "MIT" }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -6219,9 +6301,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "license": "ISC" }, "node_modules/@vitejs/plugin-basic-ssl": { @@ -7115,6 +7197,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9626,19 +9714,19 @@ } }, "node_modules/oniguruma-parser": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", - "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==", + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", "license": "MIT" }, "node_modules/oniguruma-to-es": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.3.tgz", - "integrity": "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", "license": "MIT", "dependencies": { - "oniguruma-parser": "^0.12.1", - "regex": "^6.0.1", + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, @@ -10351,9 +10439,9 @@ "license": "MIT" }, "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "license": "MIT", "funding": { "type": "github", @@ -10691,9 +10779,9 @@ "license": "Apache-2.0" }, "node_modules/regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", - "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", "license": "MIT", "dependencies": { "regex-utilities": "^2.3.0" @@ -11002,17 +11090,17 @@ } }, "node_modules/shiki": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.13.0.tgz", - "integrity": "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", "license": "MIT", "dependencies": { - "@shikijs/core": "3.13.0", - "@shikijs/engine-javascript": "3.13.0", - "@shikijs/engine-oniguruma": "3.13.0", - "@shikijs/langs": "3.13.0", - "@shikijs/themes": "3.13.0", - "@shikijs/types": "3.13.0", + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } @@ -11241,6 +11329,12 @@ "node": ">=8" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -11678,9 +11772,9 @@ "peer": true }, "node_modules/unist-util-is": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", - "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -11717,9 +11811,9 @@ } }, "node_modules/unist-util-visit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", - "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -11732,9 +11826,9 @@ } }, "node_modules/unist-util-visit-parents": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", - "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -12179,6 +12273,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index a1476ac82..27d1ff184 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,10 @@ "private": true, "dependencies": { "@base-ui/react": "^1.1.0", + "@codemirror/commands": "^6.11.0", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.9", "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.9", "@oxide/design-system": "^6.3.0", @@ -70,6 +74,7 @@ "react-stately": "^3.32.2", "remeda": "^2.30.0", "semver": "^7.7.3", + "shiki": "^3.23.0", "simplebar-react": "^3.2.6", "ts-pattern": "^5.8.0", "tslib": "^2.7.0", diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts index 71fcf490a..2f3673ce2 100644 --- a/test/e2e/oxql.e2e.ts +++ b/test/e2e/oxql.e2e.ts @@ -9,6 +9,7 @@ import { expect, test, type Page, type Locator } from '@playwright/test' import { oxqlQueries } from './oxql-queries' +import { expectToast } from './utils' const runQuery = async (page: Page, query?: string) => { if (query !== undefined) await page.getByRole('textbox').fill(query) @@ -21,8 +22,8 @@ const runQuery = async (page: Page, query?: string) => { } test.beforeEach(async ({ page }) => { - await page.goto('/system/oxql') - await expect(page.getByRole('heading', { name: 'OxQL Explorer' })).toBeVisible() + await page.goto('/system/metrics-explorer') + await expect(page.getByRole('heading', { name: 'Metrics Explorer' })).toBeVisible() }) test('unaligned multi-table query renders a chart per series', async ({ page }) => { @@ -122,14 +123,39 @@ test('results list is virtualized', async ({ page }) => { }) test('picking an example populates the query and renders a chart', async ({ page }) => { - await page.getByRole('button', { name: 'Try an example' }).click() - await page.getByRole('menuitem', { name: 'Power shelf fan speeds' }).click() - await expect(page.getByRole('textbox')).toHaveValue(/get hardware_component:fan_speed/) + await page.getByRole('button', { name: 'Power shelf fan speeds' }).click() + // the editor is a contenteditable, so assert on text rather than value + await expect(page.getByRole('textbox')).toContainText('get hardware_component:fan_speed') await runQuery(page) await expect(page.getByRole('figure').first()).toBeVisible() }) +test('results can be copied as JSON or CSV', async ({ page }) => { + await runQuery(page, oxqlQueries.basicTctl) + + // result summary is visible in the query card header + await expect(page.getByText('1 timeseries', { exact: true })).toBeVisible() + + await page.getByRole('button', { name: 'Results actions' }).click() + await page.getByRole('menuitem', { name: 'Copy as JSON' }).click() + await expectToast(page, 'Results copied as JSON') + + await page.getByRole('button', { name: 'Results actions' }).click() + await page.getByRole('menuitem', { name: 'Copy as CSV' }).click() + await expectToast(page, 'Results copied as CSV') + + await page.getByRole('button', { name: 'Results actions' }).click() + await page.getByRole('menuitem', { name: 'Copy CLI command' }).click() + await expectToast(page, 'CLI command copied') +}) + +test('copy actions are disabled before a query has run', async ({ page }) => { + await page.getByRole('button', { name: 'Results actions' }).click() + await expect(page.getByRole('menuitem', { name: 'Copy as JSON' })).toBeDisabled() + await expect(page.getByRole('menuitem', { name: 'Copy CLI command' })).toBeDisabled() +}) + test('empty query is blocked by client-side validation', async ({ page }) => { const textbox = page.getByRole('textbox') await textbox.fill('') @@ -151,12 +177,17 @@ test('a query the backend rejects surfaces an error instead of a chart', async ( }) test('pages reads the initial query from the URL', async ({ page }) => { - await page.goto(`/system/oxql?query=${encodeURIComponent(oxqlQueries.basicTctl)}`) - await expect(page.getByRole('textbox')).toHaveValue(oxqlQueries.basicTctl) + await page.goto( + `/system/metrics-explorer?query=${encodeURIComponent(oxqlQueries.basicTctl)}` + ) + const textbox = page.getByRole('textbox') + // the editor is a contenteditable, so assert line by line rather than on value + await expect(textbox).toContainText('get hardware_component:amd_cpu_tctl') + await expect(textbox).toContainText('| filter timestamp > @now() - 1m') }) test('pages writes the query to the URL after a successful run', async ({ page }) => { - await page.goto('/system/oxql') + await page.goto('/system/metrics-explorer') await runQuery(page, oxqlQueries.basicTctl) await expect From 0848d7aa826bb9904004f6b4f6c949892ddbdea9 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 21 Aug 2026 16:20:39 +0100 Subject: [PATCH 2/9] Add completions --- app/components/OxqlEditor.tsx | 51 +++++++- app/components/oxql-autocomplete.spec.ts | 131 +++++++++++++++++++ app/components/oxql-autocomplete.ts | 144 +++++++++++++++++++++ app/pages/system/OxqlPage.tsx | 93 ++++++------- mock-api/index.ts | 1 + mock-api/msw/handlers.ts | 6 +- mock-api/timeseries-schema.ts | 158 +++++++++++++++++++++++ package-lock.json | 13 ++ package.json | 1 + test/e2e/oxql.e2e.ts | 49 ++++++- 10 files changed, 591 insertions(+), 56 deletions(-) create mode 100644 app/components/oxql-autocomplete.spec.ts create mode 100644 app/components/oxql-autocomplete.ts create mode 100644 mock-api/timeseries-schema.ts diff --git a/app/components/OxqlEditor.tsx b/app/components/OxqlEditor.tsx index 388466224..d127a73d2 100644 --- a/app/components/OxqlEditor.tsx +++ b/app/components/OxqlEditor.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' import { bracketMatching } from '@codemirror/language' import { Compartment, RangeSetBuilder } from '@codemirror/state' import { @@ -13,6 +13,7 @@ import { EditorView, highlightActiveLine, keymap, + placeholder, ViewPlugin, type DecorationSet, type ViewUpdate, @@ -26,6 +27,10 @@ import { } from 'shiki/core' import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' +import type { TimeseriesSchema } from '@oxide/api' + +import { oxqlAutocomplete } from '~/components/oxql-autocomplete' + // OxQL grammar copied from the design system so we can highlight queries // without pulling in its full asciidoc bundle. One addition over the source: // single-quoted strings, which OxQL supports and our examples use. @@ -146,7 +151,9 @@ const cmTheme = EditorView.theme({ // fixed height of ~6 lines; longer queries scroll inside the editor height: '7.5rem', }, - '.cm-scroller': { overflow: 'auto' }, + // CM's base theme hardcodes font-family: monospace here; inherit the + // wrapper's font (text-mono-code) instead + '.cm-scroller': { overflow: 'auto', fontFamily: 'inherit' }, // the wrapper carries the focus ring (focus-within), so hide CM's own outline '&.cm-focused': { outline: 'none' }, '.cm-content': { @@ -163,6 +170,36 @@ const cmTheme = EditorView.theme({ }, '.cm-matchingBracket': { color: 'var(--syntax-fg)' }, '.cm-nonmatchingBracket': { color: 'var(--content-destructive)' }, + // completion popup. fontFamily inherits mono from the wrapper because the + // tooltip renders inside the editor element + '.cm-tooltip': { + backgroundColor: 'var(--surface-raise)', + border: '1px solid var(--stroke-secondary)', + borderRadius: '0.125rem', + overflow: 'hidden', + fontFamily: 'inherit', + }, + // the autocomplete base theme hardcodes font-family: monospace on the list + '.cm-tooltip.cm-tooltip-autocomplete > ul': { fontFamily: 'inherit' }, + '.cm-tooltip.cm-tooltip-autocomplete > ul > li': { padding: '2px 8px' }, + '.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]': { + backgroundColor: 'var(--surface-hover)', + color: 'inherit', + }, + '.cm-completionMatchedText': { + textDecoration: 'none', + color: 'var(--content-accent-secondary)', + }, + '.cm-completionDetail': { + color: 'var(--content-quaternary)', + fontStyle: 'normal', + marginLeft: '1rem', + }, + '.cm-tooltip.cm-completionInfo': { padding: '4px 8px', maxWidth: '22rem' }, + // match the placeholder color of the regular text inputs + '.cm-placeholder': { color: 'var(--content-tertiary)' }, + // placeholder region of an accepted snippet, e.g. mean_within(period) + '.cm-snippetField': { backgroundColor: 'var(--surface-secondary)' }, }) const contentAttrs = (ariaLabel: string, error: boolean) => @@ -177,6 +214,8 @@ type OxqlEditorProps = { /** Called on cmd+enter / ctrl+enter */ onSubmit: () => void error?: boolean + /** Timeseries schemas backing name and field completions. May load after mount. */ + schemas?: TimeseriesSchema[] 'aria-label': string } @@ -186,6 +225,7 @@ export function OxqlEditor({ onChange, onSubmit, error = false, + schemas, 'aria-label': ariaLabel, }: OxqlEditorProps) { const containerRef = useRef(null) @@ -194,8 +234,10 @@ export function OxqlEditor({ // 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(() => { @@ -215,10 +257,15 @@ export function OxqlEditor({ }, ...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'), highlightActiveLine(), bracketMatching(), + oxqlAutocomplete(() => schemasRef.current ?? []), shikiPlugin, cmTheme, attrsCompartment.current.of(contentAttrs(ariaLabel, error)), 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/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx index 5ac61ba7c..cb6e054a9 100644 --- a/app/pages/system/OxqlPage.tsx +++ b/app/pages/system/OxqlPage.tsx @@ -5,6 +5,7 @@ * * 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' @@ -14,6 +15,7 @@ import { match } from 'ts-pattern' import { api, + q, useApiMutation, camelToSnake, type Timeseries, @@ -43,6 +45,7 @@ import * as Dropdown from '~/ui/lib/DropdownMenu' import { Message } from '~/ui/lib/Message' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TextInputError } from '~/ui/lib/TextInput' +import { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' import { pluralize } from '~/util/str' @@ -321,24 +324,28 @@ const toDisplays = (groups: ChartGroup[]): ChartDisplay[] => const { startTime, endTime } = g return match(g) .with({ kind: 'unaligned' }, ({ charts }) => - charts.map((chart, i): ChartDisplay => ({ - kind: 'line', - key: `t${t}.${i}`, - showDivider: i === 0, - startTime, - endTime, - chart, - })) + 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, - })) + charts.map( + (chart, i): ChartDisplay => ({ + kind: 'multiline', + key: `t${t}.${i}`, + showDivider: i === 0, + startTime, + endTime, + chart, + }) + ) ) .exhaustive() }) @@ -467,33 +474,11 @@ const tablesToCsv = (tables: OxqlTable[]): string => { return rows.map((row) => row.map(csvCell).join(',')).join('\n') } -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)} - -
- ) -} - const copyText = (text: string, toastMessage: string) => { window.navigator.clipboard.writeText(text).then(() => addToast(toastMessage)) } -// wrap in single quotes, escaping any embedded ones, so the multiline query -// survives pasting into a shell -const shellQuote = (s: string) => `'${s.replaceAll("'", "'\\''")}'` - -// The CLI equivalent of this page's query endpoint. See "API and CLI access": -// https://docs.oxide.computer/guides/metrics/oxql-tutorial#_api_and_cli_access -const toCliCommand = (query: string) => - `oxide experimental system timeseries query --query ${shellQuote(query)}` - -function ResultsMenu({ data, query }: { data?: OxqlQueryResult; query?: string }) { +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 @@ -511,15 +496,23 @@ function ResultsMenu({ data, query }: { data?: OxqlQueryResult; query?: string } onSelect={() => data && copyText(tablesToCsv(data.tables), 'Results copied as CSV')} label="Copy as CSV" /> - query && copyText(toCliCommand(query), 'CLI command copied')} - label="Copy CLI command" - /> ) } +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)} + +
+ ) +} + // Rendered in every query state so the layout doesn't shift when results arrive const ResultsSection = ({ children }: { children: ReactNode }) => ( <> @@ -531,6 +524,10 @@ const ResultsSection = ({ children }: { children: ReactNode }) => ( 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 @@ -619,7 +616,7 @@ export default function OxqlPage() { - +
@@ -630,6 +627,7 @@ export default function OxqlPage() { value={field.value} onChange={field.onChange} onSubmit={() => form.handleSubmit(onSubmit)()} + schemas={schemas.data?.items} /> {fieldState.error?.message && ( {fieldState.error.message} @@ -642,7 +640,10 @@ export default function OxqlPage() { key={label} 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 })} + onClick={() => { + form.setValue('query', value, { shouldValidate: true }) + form.handleSubmit(onSubmit)() + }} > {label} diff --git a/mock-api/index.ts b/mock-api/index.ts index 3620d30c2..3e1945b88 100644 --- a/mock-api/index.ts +++ b/mock-api/index.ts @@ -27,6 +27,7 @@ export * from './subnet-pool' export * from './sshKeys' export * from './switch' export * from './system-update' +export * from './timeseries-schema' export * from './token' export * from './user' export * from './user-group' diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 29a35adbf..f0846f01b 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -36,6 +36,7 @@ import { commaSeries } from '~/util/str' import { GiB } from '~/util/units' import { defaultSilo, toIdp } from '../silo' +import { timeseriesSchemas } from '../timeseries-schema' import { getTimestamps } from '../util' import { defaultFirewallRules } from '../vpc' import { @@ -2095,6 +2096,10 @@ export const handlers = makeHandlers({ await delay(1000) return handleOxqlMetrics(body) }, + systemTimeseriesSchemaList({ cookies }) { + requireFleetViewer(cookies) + return { items: timeseriesSchemas } + }, siloMetric: handleMetrics, systemUpdateRepositoryList: ({ cookies }) => { requireFleetViewer(cookies) @@ -2742,7 +2747,6 @@ export const handlers = makeHandlers({ systemNetworkingSettingsUpdate: NotImplemented, systemNetworkingSettingsView: NotImplemented, systemQuotasList: NotImplemented, - systemTimeseriesSchemaList: NotImplemented, systemUpdateRecoveryFinish: NotImplemented, systemUpdateRepositoryView: NotImplemented, systemUpdateTrustRootCreate: NotImplemented, diff --git a/mock-api/timeseries-schema.ts b/mock-api/timeseries-schema.ts new file mode 100644 index 000000000..5a4a76881 --- /dev/null +++ b/mock-api/timeseries-schema.ts @@ -0,0 +1,158 @@ +/* + * 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 type { TimeseriesSchema } from '@oxide/api' + +import type { Json } from './json-type' + +// Field sets are trimmed-down versions of the real schemas in Omicron; they +// only need to be realistic enough to exercise editor autocomplete and any +// future schema browsing UI. +// https://github.com/oxidecomputer/omicron/blob/main/oximeter/oximeter/schema/hardware-component.toml +// https://github.com/oxidecomputer/omicron/blob/main/oximeter/oximeter/schema/sled-data-link.toml + +const hardwareComponentFields: Json['field_schema'] = [ + { + name: 'rack_id', + field_type: 'uuid', + source: 'target', + description: 'ID of the rack containing the component', + }, + { + name: 'sled_id', + field_type: 'uuid', + source: 'target', + description: 'ID of the sled reporting the component', + }, + { + name: 'chassis_kind', + field_type: 'string', + source: 'target', + description: 'What kind of thing the component is a part of', + }, + { + name: 'chassis_serial', + field_type: 'string', + source: 'target', + description: 'Serial number of the chassis', + }, + { + name: 'slot', + field_type: 'u32', + source: 'target', + description: 'Slot number of the chassis', + }, + { + name: 'component_id', + field_type: 'string', + source: 'target', + description: 'ID of the component', + }, + { + name: 'sensor', + field_type: 'string', + source: 'metric', + description: 'Name of the sensor', + }, +] + +const sledDataLinkFields: Json['field_schema'] = [ + { + name: 'rack_id', + field_type: 'uuid', + source: 'target', + description: 'ID of the rack containing the link', + }, + { + name: 'sled_id', + field_type: 'uuid', + source: 'target', + description: 'ID of the sled containing the link', + }, + { + name: 'serial', + field_type: 'string', + source: 'target', + description: 'Serial number of the sled', + }, + { + name: 'kind', + field_type: 'string', + source: 'target', + description: 'Kind of the data link (physical or virtual)', + }, + { + name: 'link_name', + field_type: 'string', + source: 'target', + description: 'Name of the data link', + }, +] + +const common = { + authz_scope: 'fleet', + version: 1, + created: '2025-01-01T00:00:00Z', +} as const + +export const timeseriesSchemas: Json[] = [ + { + ...common, + timeseries_name: 'hardware_component:fan_speed', + description: { + target: 'A hardware component on a compute sled, switch, or power shelf', + metric: 'A fan speed measurement', + }, + field_schema: hardwareComponentFields, + datum_type: 'f32', + units: 'rpm', + }, + { + ...common, + timeseries_name: 'hardware_component:temperature', + description: { + target: 'A hardware component on a compute sled, switch, or power shelf', + metric: 'A temperature measurement', + }, + field_schema: hardwareComponentFields, + datum_type: 'f32', + units: 'degrees_celsius', + }, + { + ...common, + timeseries_name: 'hardware_component:amd_cpu_tctl', + description: { + target: 'A hardware component on a compute sled, switch, or power shelf', + metric: 'A CPU Tctl reading (dimensionless)', + }, + field_schema: hardwareComponentFields, + datum_type: 'f32', + units: 'none', + }, + { + ...common, + timeseries_name: 'sled_data_link:bytes_sent', + description: { + target: 'A network data link on a compute sled', + metric: 'Total number of bytes sent on the link', + }, + field_schema: sledDataLinkFields, + datum_type: 'cumulative_u64', + units: 'bytes', + }, + { + ...common, + timeseries_name: 'sled_data_link:bytes_received', + description: { + target: 'A network data link on a compute sled', + metric: 'Total number of bytes received on the link', + }, + field_schema: sledDataLinkFields, + datum_type: 'cumulative_u64', + units: 'bytes', + }, +] diff --git a/package-lock.json b/package-lock.json index b673d6af6..fd8f055bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MPL-2.0", "dependencies": { "@base-ui/react": "^1.1.0", + "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.11.0", "@codemirror/language": "^6.12.4", "@codemirror/state": "^6.7.1", @@ -386,6 +387,18 @@ "tough-cookie": "^4.1.4" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, "node_modules/@codemirror/commands": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.0.tgz", diff --git a/package.json b/package.json index 27d1ff184..1ef55c931 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "private": true, "dependencies": { "@base-ui/react": "^1.1.0", + "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.11.0", "@codemirror/language": "^6.12.4", "@codemirror/state": "^6.7.1", diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts index 2f3673ce2..6fdc4db45 100644 --- a/test/e2e/oxql.e2e.ts +++ b/test/e2e/oxql.e2e.ts @@ -122,15 +122,54 @@ test('results list is virtualized', async ({ page }) => { await expect.poll(getFirstRenderedIndex).not.toBe(0) }) -test('picking an example populates the query and renders a chart', async ({ page }) => { +test('picking an example populates the query and runs it', async ({ page }) => { await page.getByRole('button', { name: 'Power shelf fan speeds' }).click() // the editor is a contenteditable, so assert on text rather than value await expect(page.getByRole('textbox')).toContainText('get hardware_component:fan_speed') - await runQuery(page) + // the query runs automatically, no need to click "Run query" + const loading = page.getByLabel('Chart loading') + await expect(loading).toBeVisible() + await expect(loading).toBeHidden() await expect(page.getByRole('figure').first()).toBeVisible() }) +test('editor autocompletes timeseries names, fields, and operations', async ({ page }) => { + const textbox = page.getByRole('textbox') + await textbox.click() + await page.keyboard.type('get hardware') + + // ctrl-space explicitly re-requests completions in case the schema list + // hadn't loaded when typing started + const options = page.getByRole('listbox').getByRole('option') + await expect(async () => { + await page.keyboard.press('Control+Space') + await expect(options.first()).toBeVisible({ timeout: 1000 }) + }).toPass() + + // accept with the keyboard rather than clicking: the info tooltip can + // overlap the option and intercept pointer events + await expect(options.getByText('hardware_component:fan_speed')).toBeVisible() + await page.keyboard.type('_component:fan') // narrow until fan_speed is the top match + await page.keyboard.press('Enter') + await expect(textbox).toContainText('get hardware_component:fan_speed') + + // table ops complete at the start of a clause. type the word out instead of + // accepting: a second Enter-accept can race the popup closing and insert a + // newline, breaking the clause for the next step + await page.keyboard.type(' | fil') + await expect(options.getByText('filter', { exact: true })).toBeVisible() + await page.keyboard.type('ter') + + // fields of the get-ed timeseries complete inside the filter + await page.keyboard.type(' chass') + await expect(options.getByText('chassis_kind')).toBeVisible() + await page.keyboard.press('Enter') + await expect(textbox).toContainText( + 'get hardware_component:fan_speed | filter chassis_kind' + ) +}) + test('results can be copied as JSON or CSV', async ({ page }) => { await runQuery(page, oxqlQueries.basicTctl) @@ -144,16 +183,12 @@ test('results can be copied as JSON or CSV', async ({ page }) => { await page.getByRole('button', { name: 'Results actions' }).click() await page.getByRole('menuitem', { name: 'Copy as CSV' }).click() await expectToast(page, 'Results copied as CSV') - - await page.getByRole('button', { name: 'Results actions' }).click() - await page.getByRole('menuitem', { name: 'Copy CLI command' }).click() - await expectToast(page, 'CLI command copied') }) test('copy actions are disabled before a query has run', async ({ page }) => { await page.getByRole('button', { name: 'Results actions' }).click() await expect(page.getByRole('menuitem', { name: 'Copy as JSON' })).toBeDisabled() - await expect(page.getByRole('menuitem', { name: 'Copy CLI command' })).toBeDisabled() + await expect(page.getByRole('menuitem', { name: 'Copy as CSV' })).toBeDisabled() }) test('empty query is blocked by client-side validation', async ({ page }) => { From e0c3c3632d03379b72e55a73556f8ea9727a7053 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Mon, 24 Aug 2026 17:56:15 +0100 Subject: [PATCH 3/9] Metrics updates contd --- app/components/OxqlEditor.tsx | 45 ++++++++++++++++- app/components/oxql-error.spec.ts | 78 +++++++++++++++++++++++++++++ app/components/oxql-error.ts | 40 +++++++++++++++ app/pages/system/OxqlPage.tsx | 83 +++++++++++++++++++++++-------- app/ui/lib/InlineCode.tsx | 1 + app/ui/styles/index.css | 13 +++++ mock-api/msw/util.ts | 39 ++++++++++++++- package-lock.json | 12 +++++ package.json | 1 + test/e2e/oxql.e2e.ts | 34 +++++++++++-- 10 files changed, 320 insertions(+), 26 deletions(-) create mode 100644 app/components/oxql-error.spec.ts create mode 100644 app/components/oxql-error.ts diff --git a/app/components/OxqlEditor.tsx b/app/components/OxqlEditor.tsx index d127a73d2..7c0f6e1e3 100644 --- a/app/components/OxqlEditor.tsx +++ b/app/components/OxqlEditor.tsx @@ -7,7 +7,8 @@ */ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' import { bracketMatching } from '@codemirror/language' -import { Compartment, RangeSetBuilder } from '@codemirror/state' +import { setDiagnostics, type Diagnostic } from '@codemirror/lint' +import { Compartment, RangeSetBuilder, type Text } from '@codemirror/state' import { Decoration, EditorView, @@ -30,6 +31,7 @@ import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' import type { TimeseriesSchema } from '@oxide/api' import { oxqlAutocomplete } from '~/components/oxql-autocomplete' +import type { OxqlDiagnostic } from '~/components/oxql-error' // OxQL grammar copied from the design system so we can highlight queries // without pulling in its full asciidoc bundle. One addition over the source: @@ -196,12 +198,41 @@ const cmTheme = EditorView.theme({ marginLeft: '1rem', }, '.cm-tooltip.cm-completionInfo': { padding: '4px 8px', maxWidth: '22rem' }, + // the lint extension draws its underline as a data: URI background image, + // which our Content-Security-Policy blocks. Use a plain CSS wavy underline + '.cm-lintRange-error': { + backgroundImage: 'none', + textDecoration: 'underline wavy var(--content-destructive)', + textDecorationSkipInk: 'none', + textUnderlineOffset: '3px', + }, + // hover tooltip for the diagnostic; the border color default is a hardcoded red + '.cm-tooltip .cm-diagnostic-error': { + borderLeftColor: 'var(--content-destructive)', + }, // match the placeholder color of the regular text inputs '.cm-placeholder': { color: 'var(--content-tertiary)' }, // placeholder region of an accepted snippet, e.g. mean_within(period) '.cm-snippetField': { backgroundColor: 'var(--surface-secondary)' }, }) +// Convert a 1-based line:column server error position into a CodeMirror +// diagnostic covering the offending token. Positions are clamped so a stale +// or out-of-range position can't crash the editor. +const toCmDiagnostic = ( + doc: Text, + { line, column, message }: OxqlDiagnostic +): Diagnostic => { + 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) + return { from, to, severity: 'error', message } +} + const contentAttrs = (ariaLabel: string, error: boolean) => EditorView.contentAttributes.of({ 'aria-label': ariaLabel, @@ -214,6 +245,8 @@ type OxqlEditorProps = { /** 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 @@ -225,6 +258,7 @@ export function OxqlEditor({ onChange, onSubmit, error = false, + diagnostic, schemas, 'aria-label': ariaLabel, }: OxqlEditorProps) { @@ -294,6 +328,15 @@ export function OxqlEditor({ }) }, [ariaLabel, error]) + // underline the position a server-side parse error points at. setDiagnostics + // pulls in the lint state on demand, so no linter extension is needed above + useEffect(() => { + const view = viewRef.current + if (!view) return + const diags = diagnostic ? [toCmDiagnostic(view.state.doc, diagnostic)] : [] + view.dispatch(setDiagnostics(view.state, diags)) + }, [diagnostic]) + return (
{ + 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) + }) +}) diff --git a/app/components/oxql-error.ts b/app/components/oxql-error.ts new file mode 100644 index 000000000..e615f2be1 --- /dev/null +++ b/app/components/oxql-error.ts @@ -0,0 +1,40 @@ +/* + * 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)/, '') + +/** + * 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/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx index cb6e054a9..bc3f02e88 100644 --- a/app/pages/system/OxqlPage.tsx +++ b/app/pages/system/OxqlPage.tsx @@ -29,6 +29,7 @@ import { Monitoring16Icon, Monitoring24Icon } from '@oxide/design-system/icons/r import { DocsPopover } from '~/components/DocsPopover' import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { parseOxqlQueryError, stripCaretLine } from '~/components/oxql-error' import { OxqlEditor } from '~/components/OxqlEditor' import { ChartContainer, @@ -40,8 +41,10 @@ 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 Dropdown from '~/ui/lib/DropdownMenu' +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' @@ -513,6 +516,43 @@ function ResultsSummary({ tables }: { tables: OxqlTable[] }) { ) } +// 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: the query +// excerpt between fmt_parse_error's `..` markers (which stay outside the chip, +// reading as ellipses), peg's double-quoted expected tokens, and the +// backtick-quoted names in semantic errors. Split with a capture group, so +// odd indices are the code segments. +const codeSegment = /(\.\. [\s\S]*? \.\.|"[^"\n]*"|`[^`\n]*`)/ +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) + 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 }) => ( <> @@ -562,6 +602,13 @@ 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] @@ -623,15 +670,18 @@ export default function OxqlPage() {
form.handleSubmit(onSubmit)()} schemas={schemas.data?.items} /> - {fieldState.error?.message && ( + {fieldState.error?.message ? ( {fieldState.error.message} - )} + ) : query.error ? ( + + ) : null}
Examples @@ -655,7 +705,9 @@ export default function OxqlPage() {
{match(query) - .with({ status: 'idle' }, () => ( + // 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 */} @@ -677,27 +729,16 @@ export default function OxqlPage() { )) - .with({ status: 'error' }, (q) => ( - - {q.error.message}} - /> - - )) .with({ status: 'success' }, () => ( {hasTrimmableCharts && (
- +
)}
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/index.css b/app/ui/styles/index.css index 521e87a6c..282b13f82 100644 --- a/app/ui/styles/index.css +++ b/app/ui/styles/index.css @@ -105,6 +105,19 @@ margin-right: var(--content-gutter); } +/* Inline code chip that takes its tint from the surrounding text color, so it + works on any background (e.g., inside an error message) */ +@utility inline-code { + font-size: 0.825em; + letter-spacing: 0; + vertical-align: 1px; + margin: 0 1px; + padding: 0 0.125rem; + background-color: color-mix(in srgb, currentColor 8%, transparent); + border: 1px solid color-mix(in srgb, currentColor 10%, transparent); + border-radius: var(--radius-md); +} + @utility link-with-underline { @apply text-raise; text-decoration: underline; diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 1be94c53a..51d1e475d 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -596,7 +596,8 @@ const getVibe = (query: string): OxqlVibe => { const [firstTable, ...moreTables] = [...query.matchAll(/get ([a-z_]+:[a-z_]+)/g)].map( (m) => m[1] as OxqlMetricName ) - if (!firstTable) throw new Error(`no "get " found in query: ${query}`) + // a query with no `get` can't parse, and the caret goes at the very start + if (!firstTable) throw fmtOxqlParseError(query, 0, 'one of "get", "{"') const alignment = query.match(/\bjoin\b/) ? 'joined' @@ -675,7 +676,43 @@ function getMultipleTables(vibe: OxqlVibe) { .exhaustive() } +/** + * Faithful port of omicron's `fmt_parse_error` so the mock's OxQL parse errors + * look exactly like real ones: an `Error at :` header with a + * context excerpt, a caret line pointing at the failure, and an `Expected:` + * line (whose peg Display repeats the position). Thrown as a string so the + * handler wrapper turns it into a 400 like Nexus's `invalid_request`. + * https://github.com/oxidecomputer/omicron/blob/6db4c7e/oximeter/db/src/oxql/mod.rs + */ +export function fmtOxqlParseError(source: string, offset: number, expected: string) { + const before = source.slice(0, offset) + const line = before.split('\n').length + const column = offset - before.lastIndexOf('\n') + let out = `Error at ${line}:${column}` + const context = 24 + const start = Math.max(0, offset - context) + const end = Math.min(source.length, offset + context) + const prefixLen = out.length + 2 + out += `: .. ${source.slice(start, end)} ..\n` + const leftPad = offset - start + 3 + prefixLen + const rightPad = end - offset + 3 + prefixLen + out += `${' '.repeat(leftPad)}^${' '.repeat(rightPad)}\n` + out += `Expected: error at ${line}:${column}: expected ${expected}\n` + return out +} + export function handleOxqlMetrics({ query }: TimeseriesQuery): Json { + // Sentinel for exercising the console's parse error handling: `oops` + // anywhere in a query fails with a realistic parse error pointing at it + const oops = query.indexOf('oops') + if (oops !== -1) { + throw fmtOxqlParseError( + query, + oops, + 'one of "align", "filter", "first", "get", "group_by", "join", "last"' + ) + } + const vibe = getVibe(query) if (vibe.moreTables.length > 0) return getMultipleTables(vibe) diff --git a/package-lock.json b/package-lock.json index fd8f055bc..2e514b1ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.11.0", "@codemirror/language": "^6.12.4", + "@codemirror/lint": "^6.9.7", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.9", "@floating-ui/react": "^0.26.23", @@ -425,6 +426,17 @@ "style-mod": "^4.0.0" } }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, "node_modules/@codemirror/state": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", diff --git a/package.json b/package.json index 1ef55c931..c3a97ccbb 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.11.0", "@codemirror/language": "^6.12.4", + "@codemirror/lint": "^6.9.7", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.9", "@floating-ui/react": "^0.26.23", diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts index 6fdc4db45..799f15bcd 100644 --- a/test/e2e/oxql.e2e.ts +++ b/test/e2e/oxql.e2e.ts @@ -18,7 +18,7 @@ const runQuery = async (page: Page, query?: string) => { const loading = page.getByLabel('Chart loading') await expect(loading).toBeVisible() await expect(loading).toBeHidden() - await expect(page.getByText('Query failed')).toBeHidden() + await expect(page.getByRole('alert')).toBeHidden() } test.beforeEach(async ({ page }) => { @@ -204,13 +204,41 @@ test('empty query is blocked by client-side validation', async ({ page }) => { test('a query the backend rejects surfaces an error instead of a chart', async ({ page, }) => { - await page.getByRole('textbox').fill('junk junk junk!') + const textbox = page.getByRole('textbox') + await textbox.fill('junk junk junk!') await page.getByRole('button', { name: 'Run query' }).click() - await expect(page.getByText('Query failed')).toBeVisible() + // the server's parse error is shown below the editor, minus the caret + // line, which assumes a monospace terminal + const error = page.getByRole('alert') + await expect(error).toContainText('Error at 1:1') + await expect(error).toContainText('Expected: error at 1:1') + await expect(error).not.toContainText('^') + // and the editor border turns red + await expect(textbox).toHaveAttribute('aria-invalid', 'true') await expect(page.getByRole('figure')).toHaveCount(0) }) +test('parse errors underline the offending spot in the editor', async ({ page }) => { + const textbox = page.getByRole('textbox') + await textbox.fill('get sled_data_link:bytes_sent | oops') + await page.getByRole('button', { name: 'Run query' }).click() + + await expect(page.getByRole('alert')).toBeVisible() + + // CM lint underlines have no semantic representation, so target the class + const underlined = page.locator('.cm-lintRange-error') + await expect(underlined).toHaveText('oops') + // guard against the underline existing but not rendering: the lint default + // is a data: URI background image, which our CSP blocks, so we draw a CSS + // underline instead + await expect(underlined).toHaveCSS('text-decoration-line', 'underline') + + // editing the query invalidates the position, clearing the underline + await textbox.pressSequentially('x') + await expect(underlined).toBeHidden() +}) + test('pages reads the initial query from the URL', async ({ page }) => { await page.goto( `/system/metrics-explorer?query=${encodeURIComponent(oxqlQueries.basicTctl)}` From 5b3901feb2912c54b9cee2ca67844eb2168e15ad Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Mon, 24 Aug 2026 18:38:53 +0100 Subject: [PATCH 4/9] Cleanup --- app/components/OxqlEditor.tsx | 204 ++++++----------------- app/ui/styles/components/oxql-editor.css | 115 +++++++++++++ app/ui/styles/index.css | 3 + package-lock.json | 176 ++++++++++++++++--- package.json | 3 +- test/e2e/oxql.e2e.ts | 8 +- 6 files changed, 321 insertions(+), 188 deletions(-) create mode 100644 app/ui/styles/components/oxql-editor.css diff --git a/app/components/OxqlEditor.tsx b/app/components/OxqlEditor.tsx index 7c0f6e1e3..b058d4c61 100644 --- a/app/components/OxqlEditor.tsx +++ b/app/components/OxqlEditor.tsx @@ -7,8 +7,13 @@ */ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' import { bracketMatching } from '@codemirror/language' -import { setDiagnostics, type Diagnostic } from '@codemirror/lint' -import { Compartment, RangeSetBuilder, type Text } from '@codemirror/state' +import { + Compartment, + RangeSetBuilder, + StateEffect, + StateField, + type Text, +} from '@codemirror/state' import { Decoration, EditorView, @@ -21,77 +26,17 @@ import { } from '@codemirror/view' import cn from 'classnames' import { useEffect, useRef } from 'react' -import { - createHighlighterCoreSync, - type LanguageRegistration, - type ThemeRegistrationAny, -} from 'shiki/core' +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' -// OxQL grammar copied from the design system so we can highlight queries -// without pulling in its full asciidoc bundle. One addition over the source: -// single-quoted strings, which OxQL supports and our examples use. -// https://github.com/oxidecomputer/design-system/blob/main/components/src/asciidoc/langs/oxql.tmLanguage.json -const oxqlGrammar = { - name: 'oxql', - scopeName: 'source.oxql', - repository: {}, - patterns: [ - { name: 'keyword.control.oxql', match: '\\b(get|join|align|filter|group_by)\\b' }, - { - name: 'string.quoted.double.oxql', - begin: '"', - end: '"', - patterns: [{ name: 'constant.character.escape.oxql', match: '\\\\.' }], - }, - { - name: 'string.quoted.single.oxql', - begin: "'", - end: "'", - patterns: [{ name: 'constant.character.escape.oxql', match: '\\\\.' }], - }, - { name: 'constant.numeric.oxql', match: '\\b\\d+[smhdw]\\b' }, - { - name: 'constant.numeric.datetime.oxql', - match: '@\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}', - }, - { name: 'constant.numeric.function.oxql', match: '@now\\(\\)' }, - { name: 'constant.numeric.oxql', match: '\\b\\d+\\b' }, - { name: 'comment.block.oxql', begin: '/\\*', end: '\\*/' }, - { name: 'comment.line.double-slash.oxql', match: '//.*$' }, - { name: 'keyword.operator.oxql', match: '\\|' }, - ], -} satisfies LanguageRegistration - -// Subset of the design system's Oxide syntax theme covering the scopes the -// OxQL grammar emits. The --syntax-* vars come from the design system -// stylesheets already imported in app/ui/styles/index.css, so this follows -// the current theme automatically. -// https://github.com/oxidecomputer/design-system/blob/main/components/src/asciidoc/oxide-syntax.json -const oxideTheme = { - name: 'oxide', - colors: { - 'editor.background': 'transparent', - 'editor.foreground': 'var(--syntax-fg)', - }, - tokenColors: [ - { scope: ['comment'], settings: { foreground: 'var(--syntax-comment)' } }, - { scope: ['string'], settings: { foreground: 'var(--syntax-string)' } }, - { - scope: ['constant.character.escape'], - settings: { foreground: 'var(--syntax-escape)' }, - }, - { scope: ['constant.numeric'], settings: { foreground: 'var(--syntax-number)' } }, - { scope: ['keyword'], settings: { foreground: 'var(--syntax-keyword)' } }, - { scope: ['keyword.operator'], settings: { foreground: 'var(--syntax-operator)' } }, - ], -} satisfies ThemeRegistrationAny - +// 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], @@ -109,7 +54,7 @@ const buildDecorations = (view: EditorView): DecorationSet => { let pos = 0 for (const line of highlighter.codeToTokensBase(code, { lang: 'oxql', - theme: 'oxide', + theme: oxideTheme.name, })) { for (const token of line) { const end = pos + token.content.length @@ -141,88 +86,10 @@ const shikiPlugin = ViewPlugin.fromClass( { decorations: (v) => v.decorations } ) -// Ported from the editor theme in mitos (app/components/code-editor.tsx), -// with its hardcoded dark-palette hexes swapped for the equivalent design -// system vars so light mode works too. Text selection is native, so the -// console's global ::selection style applies without any theming here. The -// font comes from the wrapper (text-mono-code), hence the `inherit`s. -const cmTheme = EditorView.theme({ - '&': { - backgroundColor: 'var(--syntax-bg)', - color: 'var(--syntax-fg)', - // fixed height of ~6 lines; longer queries scroll inside the editor - height: '7.5rem', - }, - // CM's base theme hardcodes font-family: monospace here; inherit the - // wrapper's font (text-mono-code) instead - '.cm-scroller': { overflow: 'auto', fontFamily: 'inherit' }, - // the wrapper carries the focus ring (focus-within), so hide CM's own outline - '&.cm-focused': { outline: 'none' }, - '.cm-content': { - fontFamily: 'inherit', - padding: '10px 0', - caretColor: 'var(--syntax-fg)', - }, - '.cm-line': { padding: '0 12px' }, - '.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--syntax-fg)' }, - '.cm-activeLine': { backgroundColor: 'var(--surface-secondary)' }, - '.cm-matchingBracket, .cm-nonmatchingBracket': { - backgroundColor: 'var(--surface-hover)', - outline: 'none', - }, - '.cm-matchingBracket': { color: 'var(--syntax-fg)' }, - '.cm-nonmatchingBracket': { color: 'var(--content-destructive)' }, - // completion popup. fontFamily inherits mono from the wrapper because the - // tooltip renders inside the editor element - '.cm-tooltip': { - backgroundColor: 'var(--surface-raise)', - border: '1px solid var(--stroke-secondary)', - borderRadius: '0.125rem', - overflow: 'hidden', - fontFamily: 'inherit', - }, - // the autocomplete base theme hardcodes font-family: monospace on the list - '.cm-tooltip.cm-tooltip-autocomplete > ul': { fontFamily: 'inherit' }, - '.cm-tooltip.cm-tooltip-autocomplete > ul > li': { padding: '2px 8px' }, - '.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]': { - backgroundColor: 'var(--surface-hover)', - color: 'inherit', - }, - '.cm-completionMatchedText': { - textDecoration: 'none', - color: 'var(--content-accent-secondary)', - }, - '.cm-completionDetail': { - color: 'var(--content-quaternary)', - fontStyle: 'normal', - marginLeft: '1rem', - }, - '.cm-tooltip.cm-completionInfo': { padding: '4px 8px', maxWidth: '22rem' }, - // the lint extension draws its underline as a data: URI background image, - // which our Content-Security-Policy blocks. Use a plain CSS wavy underline - '.cm-lintRange-error': { - backgroundImage: 'none', - textDecoration: 'underline wavy var(--content-destructive)', - textDecorationSkipInk: 'none', - textUnderlineOffset: '3px', - }, - // hover tooltip for the diagnostic; the border color default is a hardcoded red - '.cm-tooltip .cm-diagnostic-error': { - borderLeftColor: 'var(--content-destructive)', - }, - // match the placeholder color of the regular text inputs - '.cm-placeholder': { color: 'var(--content-tertiary)' }, - // placeholder region of an accepted snippet, e.g. mean_within(period) - '.cm-snippetField': { backgroundColor: 'var(--surface-secondary)' }, -}) - -// Convert a 1-based line:column server error position into a CodeMirror -// diagnostic covering the offending token. Positions are clamped so a stale -// or out-of-range position can't crash the editor. -const toCmDiagnostic = ( - doc: Text, - { line, column, message }: OxqlDiagnostic -): Diagnostic => { +// 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 @@ -230,9 +97,34 @@ const toCmDiagnostic = ( 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) - return { from, to, severity: 'error', message } + // 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, @@ -301,7 +193,7 @@ export function OxqlEditor({ bracketMatching(), oxqlAutocomplete(() => schemasRef.current ?? []), shikiPlugin, - cmTheme, + errorRangeField, attrsCompartment.current.of(contentAttrs(ariaLabel, error)), EditorView.updateListener.of((update) => { if (update.docChanged) callbacks.current.onChange(update.state.doc.toString()) @@ -328,21 +220,19 @@ export function OxqlEditor({ }) }, [ariaLabel, error]) - // underline the position a server-side parse error points at. setDiagnostics - // pulls in the lint state on demand, so no linter extension is needed above useEffect(() => { const view = viewRef.current if (!view) return - const diags = diagnostic ? [toCmDiagnostic(view.state.doc, diagnostic)] : [] - view.dispatch(setDiagnostics(view.state, diags)) + const range = diagnostic ? toErrorRange(view.state.doc, diagnostic) : null + view.dispatch({ effects: setErrorRange.of(range) }) }, [diagnostic]) return (
tags at mount time. The doubled .cm-editor + * keeps every rule here above that. + */ + +.oxql-editor .cm-editor.cm-editor { + background-color: var(--syntax-bg); + color: var(--syntax-fg); + height: 7.5rem; + + &.cm-focused { + outline: none; + } + + .cm-scroller { + overflow: auto; + font-family: inherit; + } + + .cm-content { + font-family: inherit; + padding: 10px 0; + caret-color: var(--syntax-fg); + } + + .cm-line { + padding: 0 12px; + } + + .cm-cursor, + .cm-dropCursor { + border-left-color: var(--syntax-fg); + } + + .cm-activeLine { + background-color: var(--surface-secondary); + } + + .cm-matchingBracket, + .cm-nonmatchingBracket { + background-color: var(--surface-hover); + outline: none; + } + + .cm-matchingBracket { + color: var(--syntax-fg); + } + + .cm-nonmatchingBracket { + color: var(--content-destructive); + } + + .cm-tooltip { + background-color: var(--surface-raise); + border: 1px solid var(--stroke-secondary); + border-radius: 0.125rem; + overflow: hidden; + font-family: inherit; + } + + .cm-tooltip.cm-tooltip-autocomplete > ul { + font-family: inherit; + } + + .cm-tooltip.cm-tooltip-autocomplete > ul > li { + padding: 2px 8px; + } + + .cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] { + background-color: var(--surface-hover); + color: inherit; + } + + .cm-completionMatchedText { + text-decoration: none; + color: var(--content-accent-secondary); + } + + .cm-completionDetail { + color: var(--content-quaternary); + font-style: normal; + margin-left: 1rem; + } + + .cm-tooltip.cm-completionInfo { + padding: 4px 8px; + max-width: 22rem; + } + + /* wavy underline under the token a server-side parse error points at */ + .oxql-error-underline { + text-decoration: underline wavy var(--content-destructive); + text-decoration-skip-ink: none; + text-underline-offset: 3px; + } + + /* match the placeholder color of the regular text inputs */ + .cm-placeholder { + color: var(--content-tertiary); + } + + /* placeholder region of an accepted snippet, e.g. mean_within(period) */ + .cm-snippetField { + background-color: var(--surface-secondary); + } +} diff --git a/app/ui/styles/index.css b/app/ui/styles/index.css index 282b13f82..26f652468 100644 --- a/app/ui/styles/index.css +++ b/app/ui/styles/index.css @@ -52,6 +52,9 @@ /* layer(components) on this one results in visual changes, holding off for now */ @import './components/table.css'; +/* deliberately unlayered: must beat CodeMirror's injected unlayered styles */ +@import './components/oxql-editor.css'; + @import '@xterm/xterm/css/xterm.css' layer(components); @source '../../../node_modules/@oxide/design-system/dist'; @custom-variant light (&:where([data-theme=light], [data-theme=light] *)); diff --git a/package-lock.json b/package-lock.json index 2e514b1ac..66afddd3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,12 +14,11 @@ "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.11.0", "@codemirror/language": "^6.12.4", - "@codemirror/lint": "^6.9.7", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.9", "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.9", - "@oxide/design-system": "^6.3.0", + "@oxide/design-system": "^6.5.4-canary.fb43c60", "@peculiar/x509": "^1.12.3", "@react-aria/live-announcer": "^3.3.4", "@tailwindcss/container-queries": "^0.1.1", @@ -426,17 +425,6 @@ "style-mod": "^4.0.0" } }, - "node_modules/@codemirror/lint": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", - "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", - "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.42.0", - "crelt": "^1.0.5" - } - }, "node_modules/@codemirror/state": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", @@ -1670,9 +1658,9 @@ } }, "node_modules/@oxide/design-system": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-6.3.0.tgz", - "integrity": "sha512-AX3zviv4RuH6AKZkETGMqLnqVucNRSPaAw4zVMqYzJdhDfQUqJUljJsQA7jqWnSWn7BK9T0gvEKrrL7+jrtX4w==", + "version": "6.5.4-canary.fb43c60", + "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-6.5.4-canary.fb43c60.tgz", + "integrity": "sha512-EnvsgFfoooyULVJBptdHb4nt3MEXcvJPZuTgycZlzclHi0aUP7X10/eyjMyF5aFaZazxztKilwY8Y2FZuuX7xw==", "license": "MPL 2.0", "dependencies": { "@floating-ui/react": "^0.27.16", @@ -1681,11 +1669,11 @@ "@radix-ui/react-tabs": "^1.1.13", "classnames": "^2.5.1", "prettier-plugin-tailwindcss": "^0.6.14", - "shiki": "^3.13.0" + "shiki": "^4.4.3" }, "peerDependencies": { "@asciidoctor/core": "^3.0.0", - "@oxide/react-asciidoc": "^1.3.0", + "@oxide/react-asciidoc": "^2.1.1", "react": ">=18.0.0", "react-dom": ">=18.0.0" } @@ -1705,6 +1693,105 @@ "react-dom": ">=17.0.0" } }, + "node_modules/@oxide/design-system/node_modules/@shikijs/core": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@oxide/design-system/node_modules/@shikijs/engine-javascript": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@oxide/design-system/node_modules/@shikijs/engine-oniguruma": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@oxide/design-system/node_modules/@shikijs/langs": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@oxide/design-system/node_modules/@shikijs/themes": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@oxide/design-system/node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@oxide/design-system/node_modules/shiki": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@oxide/openapi-gen-ts": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@oxide/openapi-gen-ts/-/openapi-gen-ts-0.14.0.tgz", @@ -1741,12 +1828,13 @@ } }, "node_modules/@oxide/react-asciidoc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@oxide/react-asciidoc/-/react-asciidoc-1.3.0.tgz", - "integrity": "sha512-0QNKE03z3nh82AjmJD7Gx7uxpEa0Io4bb/3Zlqh7b87c6iyQ99nV1bdiIel/Ja7FfSdC/qqIfmcedxutvKuD7Q==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@oxide/react-asciidoc/-/react-asciidoc-2.2.1.tgz", + "integrity": "sha512-X8DDHIxY/13GlxaoVxXg/hypzd3vT8IcU6MohPKA1hw7Dxyx0WdeFVlKLtDqWGNI8ld+wyaJpWscIx7chELQzQ==", "license": "MPL 2.0", "peer": true, "dependencies": { + "entities": "^6.0.1", "html-react-parser": "^5.2.6" }, "peerDependencies": { @@ -5138,6 +5226,33 @@ "@shikijs/types": "3.23.0" } }, + "node_modules/@shikijs/primitive": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive/node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@shikijs/themes": { "version": "3.23.0", "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", @@ -7429,6 +7544,19 @@ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", @@ -7526,9 +7654,9 @@ } }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "license": "BSD-2-Clause", "peer": true, "engines": { diff --git a/package.json b/package.json index c3a97ccbb..5801cc708 100644 --- a/package.json +++ b/package.json @@ -39,12 +39,11 @@ "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.11.0", "@codemirror/language": "^6.12.4", - "@codemirror/lint": "^6.9.7", "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.9", "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.9", - "@oxide/design-system": "^6.3.0", + "@oxide/design-system": "^6.5.4-canary.fb43c60", "@peculiar/x509": "^1.12.3", "@react-aria/live-announcer": "^3.3.4", "@tailwindcss/container-queries": "^0.1.1", diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts index 799f15bcd..d78f28d24 100644 --- a/test/e2e/oxql.e2e.ts +++ b/test/e2e/oxql.e2e.ts @@ -226,12 +226,10 @@ test('parse errors underline the offending spot in the editor', async ({ page }) await expect(page.getByRole('alert')).toBeVisible() - // CM lint underlines have no semantic representation, so target the class - const underlined = page.locator('.cm-lintRange-error') + // the error underline has no semantic representation, so target the class + const underlined = page.locator('.oxql-error-underline') await expect(underlined).toHaveText('oops') - // guard against the underline existing but not rendering: the lint default - // is a data: URI background image, which our CSP blocks, so we draw a CSS - // underline instead + // guard against the mark existing but the CSS not applying await expect(underlined).toHaveCSS('text-decoration-line', 'underline') // editing the query invalidates the position, clearing the underline From 587341f3ca068d204b41691e199830c71417d566 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Mon, 24 Aug 2026 22:14:38 +0100 Subject: [PATCH 5/9] Trimming and tweaking --- app/components/TimeSeriesChart.tsx | 12 +-- app/components/oxql-error.spec.ts | 65 +++++++++++++- app/components/oxql-error.ts | 18 +++- app/pages/system/OxqlPage.tsx | 109 ++++++++++++++++++++--- app/ui/styles/components/oxql-editor.css | 3 + test/e2e/oxql.e2e.ts | 57 +++--------- test/visual/regression.e2e.ts | 18 ++-- 7 files changed, 206 insertions(+), 76 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index e11a16965..661163eb5 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -186,7 +186,7 @@ export const SkeletonMetric = ({ className )} > -
+
{[...Array(4)].map((_e, i) => (
))} @@ -197,7 +197,7 @@ export 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) => ( -
    • +
    • { + 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 index e615f2be1..dd812a2c9 100644 --- a/app/components/oxql-error.ts +++ b/app/components/oxql-error.ts @@ -18,7 +18,23 @@ export type OxqlDiagnostic = { * assumes a monospace terminal, and the editor underline already points at * the position. */ -export const stripCaretLine = (message: string) => message.replace(/\n *\^ *(?=\n)/, '') +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)|(? = { name: string - description?: string + description?: ReactNode timestamps: number[] data: Data } @@ -185,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 @@ -222,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: @@ -255,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], })), @@ -431,7 +494,26 @@ function ChartEntry({ display, trim }: { display: ChartDisplay; trim: Trim }) { return ( <> {match(display) - .with({ kind: 'empty' }, () =>

No results

) + .with({ kind: 'empty' }, () => ( + + + {/* gradient uses the surface-default token so it works in both themes */} +
+
+ +
+ + + )) .with({ kind: 'multiline' }, (r) => ) .with({ kind: 'line' }, (r) => ) .exhaustive()} @@ -520,18 +602,17 @@ function ResultsSummary({ tables }: { tables: OxqlTable[] }) { // 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: the query -// excerpt between fmt_parse_error's `..` markers (which stay outside the chip, -// reading as ellipses), peg's double-quoted expected tokens, and the -// backtick-quoted names in semantic errors. Split with a capture group, so -// odd indices are the code segments. -const codeSegment = /(\.\. [\s\S]*? \.\.|"[^"\n]*"|`[^`\n]*`)/ +// 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('.. ') && '.. '} diff --git a/app/ui/styles/components/oxql-editor.css b/app/ui/styles/components/oxql-editor.css index 660632088..0f38f8cc4 100644 --- a/app/ui/styles/components/oxql-editor.css +++ b/app/ui/styles/components/oxql-editor.css @@ -34,6 +34,9 @@ .cm-line { padding: 0 12px; + text-transform: none !important; + letter-spacing: 0 !important; + @apply text-mono-sm; } .cm-cursor, diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts index d78f28d24..a1f2e92f5 100644 --- a/test/e2e/oxql.e2e.ts +++ b/test/e2e/oxql.e2e.ts @@ -134,7 +134,9 @@ test('picking an example populates the query and runs it', async ({ page }) => { await expect(page.getByRole('figure').first()).toBeVisible() }) -test('editor autocompletes timeseries names, fields, and operations', async ({ page }) => { +test('editor completions are wired to live timeseries schemas', async ({ page }) => { + // the completion logic itself is unit-tested in oxql-autocomplete.spec.ts; + // here we only check the editor is hooked up to the schema list from the API const textbox = page.getByRole('textbox') await textbox.click() await page.keyboard.type('get hardware') @@ -153,21 +155,6 @@ test('editor autocompletes timeseries names, fields, and operations', async ({ p await page.keyboard.type('_component:fan') // narrow until fan_speed is the top match await page.keyboard.press('Enter') await expect(textbox).toContainText('get hardware_component:fan_speed') - - // table ops complete at the start of a clause. type the word out instead of - // accepting: a second Enter-accept can race the popup closing and insert a - // newline, breaking the clause for the next step - await page.keyboard.type(' | fil') - await expect(options.getByText('filter', { exact: true })).toBeVisible() - await page.keyboard.type('ter') - - // fields of the get-ed timeseries complete inside the filter - await page.keyboard.type(' chass') - await expect(options.getByText('chassis_kind')).toBeVisible() - await page.keyboard.press('Enter') - await expect(textbox).toContainText( - 'get hardware_component:fan_speed | filter chassis_kind' - ) }) test('results can be copied as JSON or CSV', async ({ page }) => { @@ -185,22 +172,6 @@ test('results can be copied as JSON or CSV', async ({ page }) => { await expectToast(page, 'Results copied as CSV') }) -test('copy actions are disabled before a query has run', async ({ page }) => { - await page.getByRole('button', { name: 'Results actions' }).click() - await expect(page.getByRole('menuitem', { name: 'Copy as JSON' })).toBeDisabled() - await expect(page.getByRole('menuitem', { name: 'Copy as CSV' })).toBeDisabled() -}) - -test('empty query is blocked by client-side validation', async ({ page }) => { - const textbox = page.getByRole('textbox') - await textbox.fill('') - await page.getByRole('button', { name: 'Run query' }).click() - - await expect(textbox).toHaveAttribute('aria-invalid', 'true') - await expect(page.getByText('Enter a query').first()).toBeVisible() - await expect(page.getByRole('figure')).toHaveCount(0) -}) - test('a query the backend rejects surfaces an error instead of a chart', async ({ page, }) => { @@ -237,21 +208,17 @@ test('parse errors underline the offending spot in the editor', async ({ page }) await expect(underlined).toBeHidden() }) -test('pages reads the initial query from the URL', async ({ page }) => { - await page.goto( - `/system/metrics-explorer?query=${encodeURIComponent(oxqlQueries.basicTctl)}` - ) - const textbox = page.getByRole('textbox') - // the editor is a contenteditable, so assert line by line rather than on value - await expect(textbox).toContainText('get hardware_component:amd_cpu_tctl') - await expect(textbox).toContainText('| filter timestamp > @now() - 1m') -}) - -test('pages writes the query to the URL after a successful run', async ({ page }) => { - await page.goto('/system/metrics-explorer') +test('query round-trips through the URL', async ({ page }) => { + // a successful run writes the query to the URL await runQuery(page, oxqlQueries.basicTctl) - await expect .poll(() => new URL(page.url()).searchParams.get('query')) .toBe(oxqlQueries.basicTctl) + + // and a fresh load of that URL populates the editor from the query param. + // the editor is a contenteditable, so assert line by line rather than on value + await page.goto(page.url()) + const textbox = page.getByRole('textbox') + await expect(textbox).toContainText('get hardware_component:amd_cpu_tctl') + await expect(textbox).toContainText('| filter timestamp > @now() - 1m') }) diff --git a/test/visual/regression.e2e.ts b/test/visual/regression.e2e.ts index 7b0ed7c8d..026998591 100644 --- a/test/visual/regression.e2e.ts +++ b/test/visual/regression.e2e.ts @@ -258,13 +258,13 @@ test.describe('Visual Regression', { tag: '@visual' }, () => { }) }) - for (const [name, query] of Object.entries(oxqlQueries)) { - test(`oxql ${name}`, async ({ page }) => { - await page.goto('/system/oxql', { waitUntil: 'networkidle' }) - await page.getByRole('textbox').fill(query) - await page.getByRole('button', { name: 'Run query' }).click() - await expect(page.locator('figure').first()).toBeVisible() - await expect(page).toHaveScreenshot(`oxql-${name}.png`, fullPage) - }) - } + // one representative query is enough for styling coverage: the joined query + // exercises multiple charts, legends, and the results list in one screenshot + test('oxql metrics explorer', async ({ page }) => { + await page.goto('/system/metrics-explorer', { waitUntil: 'networkidle' }) + await page.getByRole('textbox').fill(oxqlQueries.multiJoinedTables) + await page.getByRole('button', { name: 'Run query' }).click() + await expect(page.locator('figure').first()).toBeVisible() + await expect(page).toHaveScreenshot('oxql-metrics-explorer.png', fullPage) + }) }) From a2a496724b67bee71b8d6fca2555faf8e6150ec9 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Mon, 24 Aug 2026 22:36:18 +0100 Subject: [PATCH 6/9] Memo trim --- app/pages/system/OxqlPage.tsx | 127 +++++++++++++--------------------- 1 file changed, 49 insertions(+), 78 deletions(-) diff --git a/app/pages/system/OxqlPage.tsx b/app/pages/system/OxqlPage.tsx index 4ea1f8529..1acfe8cf9 100644 --- a/app/pages/system/OxqlPage.tsx +++ b/app/pages/system/OxqlPage.tsx @@ -106,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)[] => @@ -375,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 }] @@ -392,96 +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) - return ( - - - - - ) -} - -function LineChart({ - display, - trim, -}: { - display: Extract - 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 ChartCard({ display }: { display: Extract }) { return ( - + {match(display) @@ -514,8 +481,7 @@ function ChartEntry({ display, trim }: { display: ChartDisplay; trim: Trim }) { )) - .with({ kind: 'multiline' }, (r) => ) - .with({ kind: 'line' }, (r) => ) + .with({ kind: 'chart' }, (r) => ) .exhaustive()} ) @@ -696,9 +662,14 @@ export default function OxqlPage() { ) 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 @@ -835,7 +806,7 @@ export default function OxqlPage() { className="absolute top-0 left-0 w-full pb-4" style={{ transform: `translateY(${item.start - scrollMargin}px)` }} > - +
))}
From b077b8a89ddaba408f4d0bc5e6b8f44fd6e05fcf Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Mon, 24 Aug 2026 22:36:22 +0100 Subject: [PATCH 7/9] Fix link --- app/util/links.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/util/links.ts b/app/util/links.ts index 78e021f7c..18d7c8e6b 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -91,7 +91,7 @@ export const docLinks = { linkText: 'Instance Actions', }, oxql: { - href: 'https://docs.oxide.computer/guides/metrics/oxql-tutorial#_oxql_quickstart', + href: 'https://docs.oxide.computer/guides/metrics/oxql-tutorial#_oxql_quick_start', linkText: 'OxQL', }, oxqlSchemas: { From c5a8543bb60d8274f20098caca89987c1524c859 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Tue, 25 Aug 2026 11:45:55 +0100 Subject: [PATCH 8/9] Upgrade dep --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 66afddd3f..a9f9140bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@codemirror/view": "^6.43.9", "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.9", - "@oxide/design-system": "^6.5.4-canary.fb43c60", + "@oxide/design-system": "^6.6.0", "@peculiar/x509": "^1.12.3", "@react-aria/live-announcer": "^3.3.4", "@tailwindcss/container-queries": "^0.1.1", @@ -1658,9 +1658,9 @@ } }, "node_modules/@oxide/design-system": { - "version": "6.5.4-canary.fb43c60", - "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-6.5.4-canary.fb43c60.tgz", - "integrity": "sha512-EnvsgFfoooyULVJBptdHb4nt3MEXcvJPZuTgycZlzclHi0aUP7X10/eyjMyF5aFaZazxztKilwY8Y2FZuuX7xw==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-6.6.0.tgz", + "integrity": "sha512-NhrovzCCzwUuk/gvz4w9tZEwta0/LpFjelb0xSnMd0K6c2Z103zlAL6h+poFIQ0HVbSZnpDFOnYb4o6U2WnenA==", "license": "MPL 2.0", "dependencies": { "@floating-ui/react": "^0.27.16", diff --git a/package.json b/package.json index 5801cc708..ced1d75cd 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@codemirror/view": "^6.43.9", "@floating-ui/react": "^0.26.23", "@headlessui/react": "^2.2.9", - "@oxide/design-system": "^6.5.4-canary.fb43c60", + "@oxide/design-system": "^6.6.0", "@peculiar/x509": "^1.12.3", "@react-aria/live-announcer": "^3.3.4", "@tailwindcss/container-queries": "^0.1.1", From 683bdfdf9b4b39c26b0fc48d245f27478d0f8d7f Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 25 Aug 2026 15:58:59 -0500 Subject: [PATCH 9/9] Draw the OxQL editor cursor with CodeMirror instead of the native caret --- app/components/OxqlEditor.tsx | 5 +++++ app/ui/styles/components/oxql-editor.css | 10 +++++++++- test/e2e/oxql.e2e.ts | 13 +++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/components/OxqlEditor.tsx b/app/components/OxqlEditor.tsx index b058d4c61..26f0b739e 100644 --- a/app/components/OxqlEditor.tsx +++ b/app/components/OxqlEditor.tsx @@ -16,6 +16,7 @@ import { } from '@codemirror/state' import { Decoration, + drawSelection, EditorView, highlightActiveLine, keymap, @@ -189,6 +190,10 @@ export function OxqlEditor({ ]), 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 ?? []), diff --git a/app/ui/styles/components/oxql-editor.css b/app/ui/styles/components/oxql-editor.css index 0f38f8cc4..8011f231c 100644 --- a/app/ui/styles/components/oxql-editor.css +++ b/app/ui/styles/components/oxql-editor.css @@ -29,7 +29,6 @@ .cm-content { font-family: inherit; padding: 10px 0; - caret-color: var(--syntax-fg); } .cm-line { @@ -39,11 +38,20 @@ @apply text-mono-sm; } + /* drawSelection() hides the native caret and selection and draws its own */ .cm-cursor, .cm-dropCursor { border-left-color: var(--syntax-fg); } + /* + * Matches the global ::selection color in index.css. CodeMirror's own + * focused-selection rule is unusually specific, hence the repeated class. + */ + .cm-scroller .cm-selectionLayer .cm-selectionBackground.cm-selectionBackground { + @apply bg-accent-inverse/30; + } + .cm-activeLine { background-color: var(--surface-secondary); } diff --git a/test/e2e/oxql.e2e.ts b/test/e2e/oxql.e2e.ts index a1f2e92f5..db06e1a38 100644 --- a/test/e2e/oxql.e2e.ts +++ b/test/e2e/oxql.e2e.ts @@ -222,3 +222,16 @@ test('query round-trips through the URL', async ({ page }) => { await expect(textbox).toContainText('get hardware_component:amd_cpu_tctl') await expect(textbox).toContainText('| filter timestamp > @now() - 1m') }) + +test('cursor sits at the start of the line when the query is empty', async ({ page }) => { + // CodeMirror draws its own cursor because Firefox puts the native caret in + // the wrong spot when the line contains nothing but the placeholder widget. + // The cursor has no semantic representation, so target the class. + await page.getByRole('textbox').click() + const cursor = await page.locator('.cm-cursor').boundingBox() + const placeholder = await page.locator('.cm-placeholder').boundingBox() + + // the cursor sits where the placeholder text starts, give or take its own width + expect(Math.abs(cursor!.x - placeholder!.x)).toBeLessThan(2) + expect(cursor!.y).toEqual(placeholder!.y) +})