Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
dd553f5
Initial UX rework
benjaminleonard Aug 21, 2026
0848d7a
Add completions
benjaminleonard Aug 21, 2026
e0c3c36
Metrics updates contd
benjaminleonard Aug 24, 2026
5b3901f
Cleanup
benjaminleonard Aug 24, 2026
587341f
Trimming and tweaking
benjaminleonard Aug 24, 2026
a2a4967
Memo trim
benjaminleonard Aug 24, 2026
b077b8a
Fix link
benjaminleonard Aug 24, 2026
c5a8543
Upgrade dep
benjaminleonard Aug 25, 2026
683bdfd
Draw the OxQL editor cursor with CodeMirror instead of the native caret
david-crespo Aug 25, 2026
df221bb
Improve regex completer: string-aware clauses, literal positions, sub…
david-crespo Aug 25, 2026
538b2f0
merge main and resolve conflicts
david-crespo Aug 27, 2026
4af469c
Rename OxqlPage -> MetricsExplorer
fakemonster Sep 9, 2026
015c232
Just snapshot the schemas
fakemonster Sep 9, 2026
dda0945
Revert the Claude lie
fakemonster Sep 9, 2026
7083b49
Remove active line highlight when not focused
fakemonster Sep 11, 2026
e716e3b
fix highlighting
fakemonster Sep 11, 2026
71c0998
Add autocompletion with tab
fakemonster Sep 11, 2026
4f6f63f
Merge branch 'oxql-page' into oxql-page-design
fakemonster Sep 11, 2026
062ee79
refactor: abstract the code snippet detection a bit
fakemonster Sep 14, 2026
6328c4b
fix broken safari oxql test, minor refactors to others
fakemonster Sep 14, 2026
5365c13
tiny refactors, add some TODOs for metrics page
fakemonster Sep 14, 2026
6475c5f
Make field values copyable
fakemonster Sep 14, 2026
dad5354
Improve "drop first point" detection and give an explainer
fakemonster Sep 14, 2026
045e83d
Remove dead showDivider field
fakemonster Sep 14, 2026
d991f9d
drop the convert-to-CSV path
fakemonster Sep 17, 2026
773d722
fix copied JSON
fakemonster Sep 17, 2026
83fd258
update clipboard comment/skip explanations
fakemonster Sep 17, 2026
9117a1b
Bump shiki to 4 to match design system
david-crespo Oct 5, 2026
4e7e3b4
add two-step autocomplete for oxql target:metric pairs
fakemonster Oct 6, 2026
c3eabbc
shrink the timeseries snapshot for the mock api
fakemonster Oct 6, 2026
7ccdcc9
Copy full field value, not the truncated one
david-crespo Oct 6, 2026
2f51c48
Raise contrast of completion detail text
david-crespo Oct 6, 2026
9b72a4e
Format metrics explorer ticks with Intl compact
david-crespo Oct 6, 2026
257433b
Show field values in their original case
david-crespo Oct 6, 2026
d379d35
Show up to 10 fields before collapsing
david-crespo Oct 6, 2026
ebc3e0c
Truncate UUID field values more aggressively
david-crespo Oct 6, 2026
fe5e364
Add a bit more space between field key and value
david-crespo Oct 7, 2026
b24cf4b
Annotate project access rows type for tsc
david-crespo Oct 7, 2026
6b6e182
Generate timeseries metadata from omicron schemas
david-crespo Oct 7, 2026
0da9bb5
Show timeseries docs in a side modal from chart titles
david-crespo Oct 7, 2026
26cb60a
tiny bit of space around colon in metric names
david-crespo Oct 7, 2026
ca683a1
Brighten chart axis text to tertiary
david-crespo Oct 7, 2026
0069da2
Label chart Y axis with timeseries unit
david-crespo Oct 7, 2026
5882c07
Adjust modal description/title
benjaminleonard Oct 7, 2026
367c0ae
Adjust field badge and icon colours
benjaminleonard Oct 7, 2026
c30b315
Adjust schema modal style to be more like docs
benjaminleonard Oct 7, 2026
86bd885
Auto expand editor up to a max height
benjaminleonard Oct 7, 2026
6b2aa46
Fix timeseries docs e2e tests after modal retitle
david-crespo Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,841 changes: 2,841 additions & 0 deletions app/api/__generated__/timeseries-metadata.ts

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions app/api/__tests__/safety.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import path from 'path'
import { expect, it } from 'vitest'

import viteConfigFn from '../../../vite.config'
import { omicronVersion as timeseriesMetadataVersion } from '../__generated__/timeseries-metadata'

it('Generated API client version matches API version specified for deployment', () => {
const generatedVersion = fs
Expand All @@ -26,6 +27,8 @@ it('Generated API client version matches API version specified for deployment',
// if this test fails, most likely you have updated the API_VERSION in
// console/OMICRON_VERSION without re-running `npm run gen-api`
expect(generatedVersion).toEqual(pinnedVersion)
// same deal: re-run `npm run gen-api`
expect(timeseriesMetadataVersion).toEqual(pinnedVersion)
})

// omicron releng reads API_VERSION at our pinned commit to check the console
Expand Down
5 changes: 5 additions & 0 deletions app/components/Heatmap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ type HeatmapProps = {
distributions: (HeatmapDistribution | null)[]
yAxisTickFormatter?: (val: number) => string
unit?: string
/** Drawn rotated alongside the y-axis tick labels, e.g. a unit */
yAxisLabel?: string
}

const defaultYAxisTickFormatter = (val: number) => val.toLocaleString()
Expand All @@ -102,6 +104,7 @@ export function Heatmap({
distributions,
yAxisTickFormatter = defaultYAxisTickFormatter,
unit,
yAxisLabel,
}: HeatmapProps) {
const theme = useChartTheme()
const [hover, setHover] = useState<Hover | null>(null)
Expand Down Expand Up @@ -253,6 +256,7 @@ export function Heatmap({
xTimeAxis({ theme, formatTime }),
yValueAxis({
theme,
label: yAxisLabel,
grid: { show: false },
values: (_u, splits) =>
splits.map((v) => {
Expand All @@ -277,6 +281,7 @@ export function Heatmap({
drawCells,
tooltipPlugin,
formatterRef,
yAxisLabel,
])

const data = useMemo<uPlot.AlignedData>(
Expand Down
250 changes: 250 additions & 0 deletions app/components/OxqlEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import { acceptCompletion } from '@codemirror/autocomplete'
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { bracketMatching } from '@codemirror/language'
import {
Compartment,
RangeSetBuilder,
StateEffect,
StateField,
type Text,
} from '@codemirror/state'
import {
Decoration,
drawSelection,
EditorView,
highlightActiveLine,
keymap,
placeholder,
ViewPlugin,
type DecorationSet,
type ViewUpdate,
} from '@codemirror/view'
import cn from 'classnames'
import { useEffect, useRef } from 'react'
import { createHighlighterCoreSync } from 'shiki/core'
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'

import type { TimeseriesSchema } from '@oxide/api'
import { oxideTheme, oxqlGrammar } from '@oxide/design-system/syntax'

import { oxqlAutocomplete } from '~/components/oxql-autocomplete'
import type { OxqlDiagnostic } from '~/components/oxql-error'

// the --syntax-* vars in the theme come from the design system stylesheets
// already imported in app/ui/styles/index.css, so colors follow the theme
const highlighter = createHighlighterCoreSync({
langs: [oxqlGrammar],
themes: [oxideTheme],
engine: createJavaScriptRegexEngine(),
})

/**
* Tokenize the whole doc with shiki and turn the tokens into CodeMirror mark
* decorations. Queries are small, so retokenizing everything on each change
* is cheap.
*/
const buildDecorations = (view: EditorView): DecorationSet => {
const builder = new RangeSetBuilder<Decoration>()
const code = view.state.doc.toString()
let pos = 0
for (const line of highlighter.codeToTokensBase(code, {
lang: 'oxql',
theme: oxideTheme.name,
})) {
for (const token of line) {
const end = pos + token.content.length
// default-colored tokens don't need a decoration
if (token.color && token.color !== 'var(--syntax-fg)') {
builder.add(
pos,
end,
Decoration.mark({ attributes: { style: `color: ${token.color}` } })
)
}
pos = end
}
pos += 1 // newline
}
return builder.finish()
}

const shikiPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = buildDecorations(view)
}
update(update: ViewUpdate) {
if (update.docChanged) this.decorations = buildDecorations(update.view)
}
},
{ decorations: (v) => v.decorations }
)

// Convert a 1-based line:column server error position into an editor range
// covering the offending token. Positions are clamped so a stale or
// out-of-range position can't crash the editor.
const toErrorRange = (doc: Text, { line, column }: OxqlDiagnostic) => {
const lineInfo = doc.line(Math.max(1, Math.min(line, doc.lines)))
let from = Math.min(lineInfo.from + column - 1, lineInfo.to)
// underline through the end of the token under the caret, or one char minimum
const token = /^[@\w:]+/.exec(doc.sliceString(from, lineInfo.to))
const to = Math.min(from + (token?.[0].length || 1), lineInfo.to)
// at end of line there's nothing after the caret, so underline the char before
if (from === to) from = Math.max(lineInfo.from, to - 1)
// mark decorations may not be empty, so an empty line gets no underline
return from < to ? { from, to } : null
}

const errorMark = Decoration.mark({ class: 'oxql-error-underline' })

const setErrorRange = StateEffect.define<{ from: number; to: number } | null>()

// Underline the position a server-side parse error points at. The error
// message itself is shown below the editor, so no lint tooltip is needed.
// A StateField (rather than a plain decoration facet) so the range remaps
// when the user edits elsewhere in the doc.
const errorRangeField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
let mapped = deco.map(tr.changes)
for (const effect of tr.effects) {
if (effect.is(setErrorRange)) {
mapped = effect.value
? Decoration.set([errorMark.range(effect.value.from, effect.value.to)])
: Decoration.none
}
}
return mapped
},
provide: (f) => EditorView.decorations.from(f),
})

const contentAttrs = (ariaLabel: string, error: boolean) =>
EditorView.contentAttributes.of({
'aria-label': ariaLabel,
'aria-invalid': error ? 'true' : 'false',
})

type OxqlEditorProps = {
value: string
onChange: (value: string) => void
/** Called on cmd+enter / ctrl+enter */
onSubmit: () => void
error?: boolean
/** Server-reported parse error position, underlined in the editor */
diagnostic?: OxqlDiagnostic
/** Timeseries schemas backing name and field completions. May load after mount. */
schemas?: TimeseriesSchema[]
'aria-label': string
}

/** A CodeMirror editor for OxQL queries with shiki syntax highlighting */
export function OxqlEditor({
value,
onChange,
onSubmit,
error = false,
diagnostic,
schemas,
'aria-label': ariaLabel,
}: OxqlEditorProps) {
const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const attrsCompartment = useRef(new Compartment())

// let the mount-once extensions see the latest props without reconfiguring
const callbacks = useRef({ onChange, onSubmit })
const schemasRef = useRef(schemas)
useEffect(() => {
callbacks.current = { onChange, onSubmit }
schemasRef.current = schemas
})

useEffect(() => {
const view = new EditorView({
// container div is always mounted when this effect runs
parent: containerRef.current!,
doc: value,
extensions: [
history(),
keymap.of([
{
key: 'Mod-Enter',
run: () => {
callbacks.current.onSubmit()
return true
},
},
...defaultKeymap,
...historyKeymap,
// tab accepts/indents instead of moving focus. the standard escape
// hatch still works: Ctrl-m (from defaultKeymap) toggles tab focus
// mode
{ key: 'Tab', run: acceptCompletion },
indentWithTab,
]),
EditorView.lineWrapping,
placeholder('get sled_data_link:bytes_sent | filter timestamp > @now() - 5m'),
// draw the cursor and selection ourselves. Firefox puts the native
// caret in the wrong spot when the doc is empty and the line contains
// only the placeholder widget
drawSelection(),
highlightActiveLine(),
bracketMatching(),
oxqlAutocomplete(() => schemasRef.current ?? []),
shikiPlugin,
errorRangeField,
attrsCompartment.current.of(contentAttrs(ariaLabel, error)),
EditorView.updateListener.of((update) => {
if (update.docChanged) callbacks.current.onChange(update.state.doc.toString())
}),
],
})
viewRef.current = view
return () => view.destroy()
// value and the aria attrs are synced by the effects below
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

// sync external value changes (e.g., clicking an example) into the editor
useEffect(() => {
const view = viewRef.current
if (view && value !== view.state.doc.toString()) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
}
}, [value])

useEffect(() => {
viewRef.current?.dispatch({
effects: attrsCompartment.current.reconfigure(contentAttrs(ariaLabel, error)),
})
}, [ariaLabel, error])

useEffect(() => {
const view = viewRef.current
if (!view) return
const range = diagnostic ? toErrorRange(view.state.doc, diagnostic) : null
view.dispatch({ effects: setErrorRange.of(range) })
}, [diagnostic])

return (
<div
ref={containerRef}
className={cn(
// oxql-editor scopes the CodeMirror styles in oxql-editor.css
'oxql-editor text-mono-code overflow-hidden rounded-md border focus-within:outline-2 focus-within:outline-solid',
error
? 'border-error-secondary hover:border-error focus-within:outline-error-secondary'
: 'border-default hover:border-raise focus-within:outline-accent-secondary'
)}
/>
)
}
18 changes: 11 additions & 7 deletions app/components/TimeSeriesChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ type TimeSeriesChartProps = {
startTime: Date
endTime: Date
unit?: string
/** Drawn rotated alongside the y-axis tick labels, e.g. a unit */
yAxisLabel?: string
yAxisTickFormatter?: (val: number) => string
hasError?: boolean
loading: boolean
Expand Down Expand Up @@ -68,7 +70,7 @@ export const SkeletonMetric = ({
className
)}
>
<div className="flex h-full flex-col justify-between">
<div className="flex h-full flex-col justify-around">
{[...Array(4)].map((_e, i) => (
<div key={i} className="h-px w-full bg-(--stroke-secondary)" />
))}
Expand All @@ -79,7 +81,7 @@ export const SkeletonMetric = ({
))}
</div>
</div>
<div className="relative flex h-full w-full items-center justify-center pb-11">
<div className="relative flex h-full w-full items-center justify-center py-8">
{children}
</div>
</div>
Expand Down Expand Up @@ -110,6 +112,7 @@ export function TimeSeriesChart({
startTime,
endTime,
unit,
yAxisLabel,
yAxisTickFormatter = defaultYAxisTickFormatter,
hasError = false,
loading,
Expand Down Expand Up @@ -213,6 +216,7 @@ export function TimeSeriesChart({
xTimeAxis({ theme, formatTime }),
yValueAxis({
theme,
label: yAxisLabel,
grid: { show: true, stroke: theme.axisLine, width: 1 },
values: (_u, yValues) =>
yValues.map((v) => (v === 0 ? '' : formatterRef.current(v))),
Expand All @@ -236,7 +240,7 @@ export function TimeSeriesChart({
legend: { show: false },
plugins: [tooltipPlugin],
}) satisfies UPlotOptions,
[dataLength, formatTime, tooltipPlugin, interpolation, theme, formatterRef]
[dataLength, formatTime, tooltipPlugin, interpolation, theme, formatterRef, yAxisLabel]
)

const aligned = useMemo<uPlot.AlignedData>(() => {
Expand Down Expand Up @@ -385,7 +389,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
}

Expand All @@ -394,7 +398,7 @@ export function ChartHeader({ title, label, description, children }: ChartHeader
<div className="border-secondary flex items-center justify-between border-b px-5 pt-5 pb-4">
<div>
<h2 className="flex items-baseline gap-1.5">
<div className="text-sans-semi-lg">{title}</div>
<div className="text-sans-semi-lg text-default">{title}</div>
<div className="text-sans-md text-secondary">{label}</div>
</h2>
<div className="text-sans-md text-secondary mt-0.5">{description}</div>
Expand Down Expand Up @@ -422,9 +426,9 @@ function ChartLegend({
theme: ChartTheme
}) {
return (
<ul className="mt-2 flex max-h-24 flex-wrap gap-x-4 gap-y-1.5 overflow-y-auto pl-5">
<ul className="mt-4 flex max-h-24 flex-wrap gap-x-4 gap-y-1.5 overflow-y-auto pl-5">
{Array.from({ length: count }, (_, i) => (
<li key={i} className="text-mono-xs text-secondary flex items-center gap-2">
<li key={i} className="text-sans-md text-secondary flex items-center gap-2">
<span
className="h-0.5 w-3 shrink-0 rounded-full"
style={{ backgroundColor: seriesColor(i, theme) }}
Expand Down
3 changes: 2 additions & 1 deletion app/components/form/ReadOnlySideModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Button } from '~/ui/lib/Button'
import { SideModal } from '~/ui/lib/SideModal'

type ReadOnlySideModalFormProps = {
title: string
title: ReactNode
subtitle?: ReactNode
onDismiss: () => void
children: ReactNode
Expand All @@ -39,6 +39,7 @@ export function ReadOnlySideModalForm({
title={title}
subtitle={subtitle}
animate={animate ?? animateDefault}
focusTitle
>
<SideModal.Body>
<div className="ox-form">{children}</div>
Expand Down
Loading
Loading