From 2c3d3bffeb86a246af8917549323ef05212e2374 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 17 Jan 2025 15:45:59 -0800 Subject: [PATCH 01/75] Add stub of Disk Metrics using OxQL --- app/api/util.ts | 14 ++ .../instances/instance/InstancePage.tsx | 2 +- .../instances/instance/tabs/MetricsTab.tsx | 132 +++++++++++++++++- app/util/date.ts | 4 + 4 files changed, 149 insertions(+), 3 deletions(-) diff --git a/app/api/util.ts b/app/api/util.ts index 954cd77b0e..f47a2cdd53 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -17,6 +17,7 @@ import type { InstanceState, IpPoolUtilization, Measurement, + OxqlQueryResult, SiloUtilization, Sled, VpcFirewallRule, @@ -259,3 +260,16 @@ export function parseIpUtilization({ ipv4, ipv6 }: IpPoolUtilization) { }, } } + +export const getChartData = (data: OxqlQueryResult | undefined): ChartDatum[] => { + if (!data) return [] + const ts = Object.values(data.tables[0].timeseries) + return ts.flatMap((t) => { + const { timestamps, values } = t.points + const v = values[0].values.values as number[] + return timestamps.map((timestamp, idx) => ({ + timestamp: new Date(timestamp).getTime(), + value: v[idx], + })) + }) +} diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 22acc7c97e..14c388a160 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -238,7 +238,7 @@ export function InstancePage() { Storage - Metrics + Disk Metrics Networking Connect diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 0e2304fda7..14c096811b 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -10,8 +10,10 @@ import type { LoaderFunctionArgs } from 'react-router' import { apiQueryClient, + getChartData, useApiQuery, usePrefetchedApiQuery, + type ChartDatum, type Cumulativeint64, type DiskMetricName, } from '@oxide/api' @@ -23,6 +25,7 @@ import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Listbox } from '~/ui/lib/Listbox' import { Spinner } from '~/ui/lib/Spinner' import { TableEmptyBox } from '~/ui/lib/Table' +import { getDurationMinutes } from '~/util/date' const TimeSeriesChart = React.lazy(() => import('~/components/TimeSeriesChart')) @@ -36,6 +39,122 @@ export function getCycleCount(num: number, base: number) { return cycleCount } +type OxqlDiskMetricParams = { + title: string + unit: 'Bytes' | 'Count' + metric: 'reads' | 'bytes_read' + startTime: Date + endTime: Date + diskSelector: { + project: string + disk: string + diskId: string + } +} + +function OxqlDiskMetric({ + title, + unit, + metric, + startTime, + endTime, + diskSelector, +}: OxqlDiskMetricParams) { + // you need to update the startTime and endTime to account for the user's timezone; should still be a Date + const utcStartTime = new Date(startTime.toISOString()) + const utcEndTime = new Date(endTime.toISOString()) + + const { diskId } = diskSelector + + const duration = getDurationMinutes({ start: utcStartTime, end: utcEndTime }) + const { data: metrics } = useApiQuery('systemTimeseriesQuery', { + body: { + query: `get virtual_disk:${metric} | filter timestamp > @now() - ${duration}m && disk_id == "${diskId}" | align mean_within(30s)`, + }, + }) + const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) + + const isBytesChart = unit === 'Bytes' + + const largestValue = useMemo(() => { + if (!chartData || chartData.length === 0) return 0 + return chartData.reduce((max, i) => Math.max(max, i.value), 0) + }, [chartData]) + + // We'll need to divide each number in the set by a consistent exponent + // of 1024 (for Bytes) or 1000 (for Counts) + const base = isBytesChart ? 1024 : 1000 + // Figure out what that exponent is: + const cycleCount = getCycleCount(largestValue, base) + + // Now that we know how many cycles of "divide by 1024 || 1000" to run through + // (via cycleCount), we can determine the proper unit for the set + let unitForSet = '' + let label = '' + if (chartData.length > 0) { + if (isBytesChart) { + const byteUnits = ['BYTES', 'KiB', 'MiB', 'GiB', 'TiB'] + unitForSet = byteUnits[cycleCount] + label = `(${unitForSet})` + } else { + label = '(COUNT)' + } + } + + const divisor = base ** cycleCount + + const data = useMemo( + () => + (chartData || []).map(({ timestamp, value }) => ({ + timestamp, + // All of these metrics are cumulative ints. + // The value passed in is what will render in the tooltip. + value: isBytesChart + ? // We pass a pre-divided value to the chart if the unit is Bytes + value / divisor + : // If the unit is Count, we pass the raw value + value, + })), + [isBytesChart, divisor, chartData] + ) + + // Create a label for the y-axis ticks. "Count" charts will be + // abbreviated and will have a suffix (e.g. "k") appended. Because + // "Bytes" charts will have already been divided by the divisor + // before the yAxis is created, we can use their given value. + const yAxisTickFormatter = (val: number) => { + if (isBytesChart) { + return val.toLocaleString() + } + const tickValue = (val / divisor).toFixed(2) + const countUnits = ['', 'k', 'M', 'B', 'T'] + const unitForTick = countUnits[cycleCount] + return `${tickValue}${unitForTick}` + } + + return ( +
+

+ {title}
{label}
+ {!metrics && } +

+ }> + + +
+ ) +} + type DiskMetricParams = { title: string unit: 'Bytes' | 'Count' @@ -176,6 +295,7 @@ export function Component() { // disks, in which case we show the fallback UI and diskName is never used. We // only need to do it this way because hooks cannot be called conditionally. const [diskName, setDiskName] = useState(disks[0]?.name || '') + const [diskId, setDiskId] = useState(disks[0]?.id || '') const diskItems = disks.map(({ name }) => ({ label: name, value: name })) if (disks.length === 0) { @@ -193,7 +313,7 @@ export function Component() { const commonProps = { startTime, endTime, - diskSelector: { project, disk: diskName }, + diskSelector: { project, disk: diskName, diskId }, } return ( @@ -206,7 +326,10 @@ export function Component() { selected={diskName} items={diskItems} onChange={(val) => { - if (val) setDiskName(val) + if (val) { + setDiskName(val) + setDiskId(disks.find((d) => d.name === val)?.id || '') + } }} /> {dateTimeRangePicker} @@ -220,6 +343,11 @@ export function Component() { +
+ + +
+
diff --git a/app/util/date.ts b/app/util/date.ts index 9f504267df..bd08af1a97 100644 --- a/app/util/date.ts +++ b/app/util/date.ts @@ -53,3 +53,7 @@ export const toLocaleTimeString = (d: Date, locale?: string) => export const toLocaleDateTimeString = (d: Date, locale?: string) => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d) + +/** get the number of minutes between two date objects */ +export const getDurationMinutes = ({ start, end }: { start: Date; end: Date }) => + (end.getTime() - start.getTime()) / (1000 * 60) From b313aac420d1985ed983960fc5f4172db5b392aa Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 27 Jan 2025 11:39:32 -0800 Subject: [PATCH 02/75] Refactoring; getting chart working; needs better default situation --- .../instances/instance/tabs/MetricsTab.tsx | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 14c096811b..6ee053c741 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -39,6 +39,22 @@ export function getCycleCount(num: number, base: number) { return cycleCount } +type getOxqlQueryParams = { + metric: string + diskId: string + startTime: Date + endTime: Date +} + +const getOxqlQuery = ({ metric, diskId, startTime, endTime }: getOxqlQueryParams) => { + const utcStartTime = new Date(startTime.toISOString()) + const utcEndTime = new Date(endTime.toISOString()) + const duration = getDurationMinutes({ start: utcStartTime, end: utcEndTime }) + const query = `get virtual_disk:${metric} | filter timestamp > @now() - ${duration}m && disk_id == "${diskId}" | align mean_within(30s)` + // console.log('query', query) + return query +} + type OxqlDiskMetricParams = { title: string unit: 'Bytes' | 'Count' @@ -61,17 +77,10 @@ function OxqlDiskMetric({ diskSelector, }: OxqlDiskMetricParams) { // you need to update the startTime and endTime to account for the user's timezone; should still be a Date - const utcStartTime = new Date(startTime.toISOString()) - const utcEndTime = new Date(endTime.toISOString()) - const { diskId } = diskSelector - const duration = getDurationMinutes({ start: utcStartTime, end: utcEndTime }) - const { data: metrics } = useApiQuery('systemTimeseriesQuery', { - body: { - query: `get virtual_disk:${metric} | filter timestamp > @now() - ${duration}m && disk_id == "${diskId}" | align mean_within(30s)`, - }, - }) + const query = getOxqlQuery({ metric, diskId, startTime, endTime }) + const { data: metrics } = useApiQuery('systemTimeseriesQuery', { body: { query } }) const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) const isBytesChart = unit === 'Bytes' @@ -84,7 +93,7 @@ function OxqlDiskMetric({ // We'll need to divide each number in the set by a consistent exponent // of 1024 (for Bytes) or 1000 (for Counts) const base = isBytesChart ? 1024 : 1000 - // Figure out what that exponent is: + // Figure out what that exponent is const cycleCount = getCycleCount(largestValue, base) // Now that we know how many cycles of "divide by 1024 || 1000" to run through From 4c61bafc46fee0da7295e3c3db5f598c49ce1b0d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 28 Jan 2025 20:53:03 -0800 Subject: [PATCH 03/75] refacotrs; remove old DiskMetrics; add writes and flushes charts --- .../instances/instance/InstancePage.tsx | 2 +- .../instances/instance/tabs/MetricsTab.tsx | 155 ++++-------------- 2 files changed, 29 insertions(+), 128 deletions(-) diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 14c388a160..9f27d80a25 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -238,8 +238,8 @@ export function InstancePage() { Storage - Disk Metrics Networking + Metrics Connect {resizeInstance && ( diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 6ee053c741..21d403bf79 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -14,8 +14,6 @@ import { useApiQuery, usePrefetchedApiQuery, type ChartDatum, - type Cumulativeint64, - type DiskMetricName, } from '@oxide/api' import { Storage24Icon } from '@oxide/design-system/icons/react' @@ -25,7 +23,6 @@ import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Listbox } from '~/ui/lib/Listbox' import { Spinner } from '~/ui/lib/Spinner' import { TableEmptyBox } from '~/ui/lib/Table' -import { getDurationMinutes } from '~/util/date' const TimeSeriesChart = React.lazy(() => import('~/components/TimeSeriesChart')) @@ -39,6 +36,9 @@ export function getCycleCount(num: number, base: number) { return cycleCount } +/** convert to UTC and return the timezone-free format required by OxQL */ +const oxqlTimestamp = (date: Date) => date.toISOString().replace('Z', '') + type getOxqlQueryParams = { metric: string diskId: string @@ -47,18 +47,28 @@ type getOxqlQueryParams = { } const getOxqlQuery = ({ metric, diskId, startTime, endTime }: getOxqlQueryParams) => { - const utcStartTime = new Date(startTime.toISOString()) - const utcEndTime = new Date(endTime.toISOString()) - const duration = getDurationMinutes({ start: utcStartTime, end: utcEndTime }) - const query = `get virtual_disk:${metric} | filter timestamp > @now() - ${duration}m && disk_id == "${diskId}" | align mean_within(30s)` - // console.log('query', query) - return query + const start = oxqlTimestamp(startTime) + const end = oxqlTimestamp(endTime) + return `get virtual_disk:${metric} | filter timestamp >= @${start} && timestamp < @${end} && disk_id == "${diskId}" | align mean_within(30s)` } +// Should probaby be generated in Api.ts +type OxqlDiskMetricName = + | 'bytes_read' + | 'bytes_written' + | 'failed_flushes' + | 'failed_reads' + | 'failed_writes' + | 'flushes' + | 'io_latency' + | 'io_size' + | 'reads' + | 'writes' + type OxqlDiskMetricParams = { title: string unit: 'Bytes' | 'Count' - metric: 'reads' | 'bytes_read' + metric: OxqlDiskMetricName startTime: Date endTime: Date diskSelector: { @@ -76,7 +86,6 @@ function OxqlDiskMetric({ endTime, diskSelector, }: OxqlDiskMetricParams) { - // you need to update the startTime and endTime to account for the user's timezone; should still be a Date const { diskId } = diskSelector const query = getOxqlQuery({ metric, diskId, startTime, endTime }) @@ -164,115 +173,6 @@ function OxqlDiskMetric({ ) } -type DiskMetricParams = { - title: string - unit: 'Bytes' | 'Count' - startTime: Date - endTime: Date - metric: DiskMetricName - diskSelector: { - project: string - disk: string - } -} - -function DiskMetric({ - title, - unit, - startTime, - endTime, - metric, - diskSelector: { project, disk }, -}: DiskMetricParams) { - // TODO: we're only pulling the first page. Should we bump the cap to 10k? - // Fetch multiple pages if 10k is not enough? That's a bit much. - const { data: metrics, isLoading } = useApiQuery( - 'diskMetricsList', - { - path: { disk, metric }, - query: { project, startTime, endTime, limit: 3000 }, - }, - // avoid graphs flashing blank while loading when you change the time - { placeholderData: (x) => x } - ) - - const isBytesChart = unit === 'Bytes' - - const largestValue = useMemo(() => { - if (!metrics || metrics.items.length === 0) return 0 - return Math.max(...metrics.items.map((m) => (m.datum.datum as Cumulativeint64).value)) - }, [metrics]) - - // We'll need to divide each number in the set by a consistent exponent - // of 1024 (for Bytes) or 1000 (for Counts) - const base = isBytesChart ? 1024 : 1000 - // Figure out what that exponent is: - const cycleCount = getCycleCount(largestValue, base) - - // Now that we know how many cycles of "divide by 1024 || 1000" to run through - // (via cycleCount), we can determine the proper unit for the set - let unitForSet = '' - let label = '(COUNT)' - if (isBytesChart) { - const byteUnits = ['BYTES', 'KiB', 'MiB', 'GiB', 'TiB'] - unitForSet = byteUnits[cycleCount] - label = `(${unitForSet})` - } - - const divisor = base ** cycleCount - - const data = useMemo( - () => - (metrics?.items || []).map(({ datum, timestamp }) => ({ - timestamp: timestamp.getTime(), - // All of these metrics are cumulative ints. - // The value passed in is what will render in the tooltip. - value: isBytesChart - ? // We pass a pre-divided value to the chart if the unit is Bytes - (datum.datum as Cumulativeint64).value / divisor - : // If the unit is Count, we pass the raw value - (datum.datum as Cumulativeint64).value, - })), - [metrics, isBytesChart, divisor] - ) - - // Create a label for the y-axis ticks. "Count" charts will be - // abbreviated and will have a suffix (e.g. "k") appended. Because - // "Bytes" charts will have already been divided by the divisor - // before the yAxis is created, we can use their given value. - const yAxisTickFormatter = (val: number) => { - if (isBytesChart) { - return val.toLocaleString() - } - const tickValue = (val / divisor).toFixed(2) - const countUnits = ['', 'k', 'M', 'B', 'T'] - const unitForTick = countUnits[cycleCount] - return `${tickValue}${unitForTick}` - } - - return ( -
-

- {title}
{label}
- {isLoading && } -

- }> - - -
- ) -} - // We could figure out how to prefetch the metrics data, but it would be // annoying because it relies on the default date range, plus there are 5 calls. // Considering the data is going to be swapped out as soon as they change the @@ -347,10 +247,6 @@ export function Component() {
{/* see the following link for the source of truth on what these mean https://github.com/oxidecomputer/crucible/blob/258f162b/upstairs/src/stats.rs#L9-L50 */} -
- - -
@@ -358,12 +254,17 @@ export function Component() {
- - + +
- +
From 3a94b31de3d463237d9a15243a40ae9591f6d5e5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 29 Jan 2025 04:41:26 -0800 Subject: [PATCH 04/75] initial stub for CPU metrics --- .../instances/instance/tabs/MetricsTab.tsx | 111 ++++++++++++------ 1 file changed, 73 insertions(+), 38 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 21d403bf79..9b40b9bc1c 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -24,6 +24,22 @@ import { Listbox } from '~/ui/lib/Listbox' import { Spinner } from '~/ui/lib/Spinner' import { TableEmptyBox } from '~/ui/lib/Table' +type OxqlDiskMetricName = + | 'virtual_disk:bytes_read' + | 'virtual_disk:bytes_written' + | 'virtual_disk:failed_flushes' + | 'virtual_disk:failed_reads' + | 'virtual_disk:failed_writes' + | 'virtual_disk:flushes' + | 'virtual_disk:io_latency' + | 'virtual_disk:io_size' + | 'virtual_disk:reads' + | 'virtual_disk:writes' + +type OxqlVmMetricName = 'virtual_machine:vcpu_usage' + +type OxqlMetricName = OxqlDiskMetricName | OxqlVmMetricName + const TimeSeriesChart = React.lazy(() => import('~/components/TimeSeriesChart')) export function getCycleCount(num: number, base: number) { @@ -40,55 +56,54 @@ export function getCycleCount(num: number, base: number) { const oxqlTimestamp = (date: Date) => date.toISOString().replace('Z', '') type getOxqlQueryParams = { - metric: string - diskId: string + metricName: OxqlMetricName startTime: Date endTime: Date + // for disk metrics + diskId?: string + // for vm metrics + vmId?: string } -const getOxqlQuery = ({ metric, diskId, startTime, endTime }: getOxqlQueryParams) => { +const getOxqlQuery = ({ + metricName, + startTime, + endTime, + diskId, + vmId, +}: getOxqlQueryParams) => { const start = oxqlTimestamp(startTime) const end = oxqlTimestamp(endTime) - return `get virtual_disk:${metric} | filter timestamp >= @${start} && timestamp < @${end} && disk_id == "${diskId}" | align mean_within(30s)` + const filters = [`timestamp >= @${start}`, `timestamp < @${end}`] + if (diskId) { + filters.push(`disk_id == "${diskId}"`) + } + if (vmId) { + filters.push(`vm_id == "${vmId}"`) + } + return `get ${metricName} | filter ${filters.join(' && ')} | align mean_within(30s)` } -// Should probaby be generated in Api.ts -type OxqlDiskMetricName = - | 'bytes_read' - | 'bytes_written' - | 'failed_flushes' - | 'failed_reads' - | 'failed_writes' - | 'flushes' - | 'io_latency' - | 'io_size' - | 'reads' - | 'writes' - -type OxqlDiskMetricParams = { +type OxqlBaseMetricParams = { title: string unit: 'Bytes' | 'Count' - metric: OxqlDiskMetricName + metricName: OxqlMetricName startTime: Date endTime: Date - diskSelector: { - project: string - disk: string - diskId: string - } } -function OxqlDiskMetric({ +type OxqlDiskMetricParams = OxqlBaseMetricParams & { diskId?: string; vmId?: never } +type OxqlVmMetricParams = OxqlBaseMetricParams & { diskId?: never; vmId?: string } +function OxqlMetric({ title, unit, - metric, + metricName, startTime, endTime, - diskSelector, -}: OxqlDiskMetricParams) { - const { diskId } = diskSelector - - const query = getOxqlQuery({ metric, diskId, startTime, endTime }) + diskId, + vmId, +}: OxqlDiskMetricParams | OxqlVmMetricParams) { + const query = getOxqlQuery({ metricName, startTime, endTime, diskId, vmId }) const { data: metrics } = useApiQuery('systemTimeseriesQuery', { body: { query } }) const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) @@ -222,7 +237,7 @@ export function Component() { const commonProps = { startTime, endTime, - diskSelector: { project, disk: diskName, diskId }, + diskId, } return ( @@ -249,22 +264,42 @@ export function Component() { https://github.com/oxidecomputer/crucible/blob/258f162b/upstairs/src/stats.rs#L9-L50 */}
- - + +
- - +
- +
From 503a47e6069f024ae8d5f5219a8fc6051c118291 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 29 Jan 2025 04:54:23 -0800 Subject: [PATCH 05/75] file reorg --- .../instance/tabs/MetricsTab.spec.ts | 2 +- .../instances/instance/tabs/MetricsTab.tsx | 175 +---------------- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 177 ++++++++++++++++++ 3 files changed, 181 insertions(+), 173 deletions(-) create mode 100644 app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.spec.ts b/app/pages/project/instances/instance/tabs/MetricsTab.spec.ts index 220c63258e..0b973470e5 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.spec.ts +++ b/app/pages/project/instances/instance/tabs/MetricsTab.spec.ts @@ -7,7 +7,7 @@ */ import { expect, test } from 'vitest' -import { getCycleCount } from './MetricsTab' +import { getCycleCount } from './MetricsTab/OxqlMetric' test('getCycleCount', () => { expect(getCycleCount(5, 1000)).toEqual(0) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 9b40b9bc1c..380857ad7c 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -5,188 +5,19 @@ * * Copyright Oxide Computer Company */ -import React, { Suspense, useMemo, useState } from 'react' +import React, { useMemo, useState } from 'react' import type { LoaderFunctionArgs } from 'react-router' -import { - apiQueryClient, - getChartData, - useApiQuery, - usePrefetchedApiQuery, - type ChartDatum, -} from '@oxide/api' +import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' import { Storage24Icon } from '@oxide/design-system/icons/react' import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Listbox } from '~/ui/lib/Listbox' -import { Spinner } from '~/ui/lib/Spinner' import { TableEmptyBox } from '~/ui/lib/Table' -type OxqlDiskMetricName = - | 'virtual_disk:bytes_read' - | 'virtual_disk:bytes_written' - | 'virtual_disk:failed_flushes' - | 'virtual_disk:failed_reads' - | 'virtual_disk:failed_writes' - | 'virtual_disk:flushes' - | 'virtual_disk:io_latency' - | 'virtual_disk:io_size' - | 'virtual_disk:reads' - | 'virtual_disk:writes' - -type OxqlVmMetricName = 'virtual_machine:vcpu_usage' - -type OxqlMetricName = OxqlDiskMetricName | OxqlVmMetricName - -const TimeSeriesChart = React.lazy(() => import('~/components/TimeSeriesChart')) - -export function getCycleCount(num: number, base: number) { - let cycleCount = 0 - let transformedValue = num - while (transformedValue > base) { - transformedValue = transformedValue / base - cycleCount++ - } - return cycleCount -} - -/** convert to UTC and return the timezone-free format required by OxQL */ -const oxqlTimestamp = (date: Date) => date.toISOString().replace('Z', '') - -type getOxqlQueryParams = { - metricName: OxqlMetricName - startTime: Date - endTime: Date - // for disk metrics - diskId?: string - // for vm metrics - vmId?: string -} - -const getOxqlQuery = ({ - metricName, - startTime, - endTime, - diskId, - vmId, -}: getOxqlQueryParams) => { - const start = oxqlTimestamp(startTime) - const end = oxqlTimestamp(endTime) - const filters = [`timestamp >= @${start}`, `timestamp < @${end}`] - if (diskId) { - filters.push(`disk_id == "${diskId}"`) - } - if (vmId) { - filters.push(`vm_id == "${vmId}"`) - } - return `get ${metricName} | filter ${filters.join(' && ')} | align mean_within(30s)` -} - -type OxqlBaseMetricParams = { - title: string - unit: 'Bytes' | 'Count' - metricName: OxqlMetricName - startTime: Date - endTime: Date -} - -type OxqlDiskMetricParams = OxqlBaseMetricParams & { diskId?: string; vmId?: never } -type OxqlVmMetricParams = OxqlBaseMetricParams & { diskId?: never; vmId?: string } -function OxqlMetric({ - title, - unit, - metricName, - startTime, - endTime, - diskId, - vmId, -}: OxqlDiskMetricParams | OxqlVmMetricParams) { - const query = getOxqlQuery({ metricName, startTime, endTime, diskId, vmId }) - const { data: metrics } = useApiQuery('systemTimeseriesQuery', { body: { query } }) - const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) - - const isBytesChart = unit === 'Bytes' - - const largestValue = useMemo(() => { - if (!chartData || chartData.length === 0) return 0 - return chartData.reduce((max, i) => Math.max(max, i.value), 0) - }, [chartData]) - - // We'll need to divide each number in the set by a consistent exponent - // of 1024 (for Bytes) or 1000 (for Counts) - const base = isBytesChart ? 1024 : 1000 - // Figure out what that exponent is - const cycleCount = getCycleCount(largestValue, base) - - // Now that we know how many cycles of "divide by 1024 || 1000" to run through - // (via cycleCount), we can determine the proper unit for the set - let unitForSet = '' - let label = '' - if (chartData.length > 0) { - if (isBytesChart) { - const byteUnits = ['BYTES', 'KiB', 'MiB', 'GiB', 'TiB'] - unitForSet = byteUnits[cycleCount] - label = `(${unitForSet})` - } else { - label = '(COUNT)' - } - } - - const divisor = base ** cycleCount - - const data = useMemo( - () => - (chartData || []).map(({ timestamp, value }) => ({ - timestamp, - // All of these metrics are cumulative ints. - // The value passed in is what will render in the tooltip. - value: isBytesChart - ? // We pass a pre-divided value to the chart if the unit is Bytes - value / divisor - : // If the unit is Count, we pass the raw value - value, - })), - [isBytesChart, divisor, chartData] - ) - - // Create a label for the y-axis ticks. "Count" charts will be - // abbreviated and will have a suffix (e.g. "k") appended. Because - // "Bytes" charts will have already been divided by the divisor - // before the yAxis is created, we can use their given value. - const yAxisTickFormatter = (val: number) => { - if (isBytesChart) { - return val.toLocaleString() - } - const tickValue = (val / divisor).toFixed(2) - const countUnits = ['', 'k', 'M', 'B', 'T'] - const unitForTick = countUnits[cycleCount] - return `${tickValue}${unitForTick}` - } - - return ( -
-

- {title}
{label}
- {!metrics && } -

- }> - - -
- ) -} +import { OxqlMetric } from './MetricsTab/OxqlMetric' // We could figure out how to prefetch the metrics data, but it would be // annoying because it relies on the default date range, plus there are 5 calls. diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx new file mode 100644 index 0000000000..243363beda --- /dev/null +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -0,0 +1,177 @@ +/* + * 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 React, { Suspense, useMemo } from 'react' + +import { getChartData, useApiQuery, type ChartDatum } from '@oxide/api' + +import { Spinner } from '~/ui/lib/Spinner' + +const TimeSeriesChart = React.lazy(() => import('~/components/TimeSeriesChart')) + +/** convert to UTC and return the timezone-free format required by OxQL */ +const oxqlTimestamp = (date: Date) => date.toISOString().replace('Z', '') + +export function getCycleCount(num: number, base: number) { + let cycleCount = 0 + let transformedValue = num + while (transformedValue > base) { + transformedValue = transformedValue / base + cycleCount++ + } + return cycleCount +} + +type OxqlDiskMetricName = + | 'virtual_disk:bytes_read' + | 'virtual_disk:bytes_written' + | 'virtual_disk:failed_flushes' + | 'virtual_disk:failed_reads' + | 'virtual_disk:failed_writes' + | 'virtual_disk:flushes' + | 'virtual_disk:io_latency' + | 'virtual_disk:io_size' + | 'virtual_disk:reads' + | 'virtual_disk:writes' + +type OxqlVmMetricName = 'virtual_machine:vcpu_usage' + +type OxqlMetricName = OxqlDiskMetricName | OxqlVmMetricName + +type getOxqlQueryParams = { + metricName: OxqlMetricName + startTime: Date + endTime: Date + // for disk metrics + diskId?: string + // for vm metrics + vmId?: string +} + +const getOxqlQuery = ({ + metricName, + startTime, + endTime, + diskId, + vmId, +}: getOxqlQueryParams) => { + const start = oxqlTimestamp(startTime) + const end = oxqlTimestamp(endTime) + const filters = [`timestamp >= @${start}`, `timestamp < @${end}`] + if (diskId) { + filters.push(`disk_id == "${diskId}"`) + } + if (vmId) { + filters.push(`vm_id == "${vmId}"`) + } + return `get ${metricName} | filter ${filters.join(' && ')} | align mean_within(30s)` +} + +type OxqlBaseMetricParams = { + title: string + unit: 'Bytes' | 'Count' + metricName: OxqlMetricName + startTime: Date + endTime: Date +} + +type OxqlDiskMetricParams = OxqlBaseMetricParams & { diskId?: string; vmId?: never } +type OxqlVmMetricParams = OxqlBaseMetricParams & { diskId?: never; vmId?: string } +export function OxqlMetric({ + title, + unit, + metricName, + startTime, + endTime, + diskId, + vmId, +}: OxqlDiskMetricParams | OxqlVmMetricParams) { + const query = getOxqlQuery({ metricName, startTime, endTime, diskId, vmId }) + const { data: metrics } = useApiQuery('systemTimeseriesQuery', { body: { query } }) + const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) + + const isBytesChart = unit === 'Bytes' + + const largestValue = useMemo(() => { + if (!chartData || chartData.length === 0) return 0 + return chartData.reduce((max, i) => Math.max(max, i.value), 0) + }, [chartData]) + + // We'll need to divide each number in the set by a consistent exponent + // of 1024 (for Bytes) or 1000 (for Counts) + const base = isBytesChart ? 1024 : 1000 + // Figure out what that exponent is + const cycleCount = getCycleCount(largestValue, base) + + // Now that we know how many cycles of "divide by 1024 || 1000" to run through + // (via cycleCount), we can determine the proper unit for the set + let unitForSet = '' + let label = '' + if (chartData.length > 0) { + if (isBytesChart) { + const byteUnits = ['BYTES', 'KiB', 'MiB', 'GiB', 'TiB'] + unitForSet = byteUnits[cycleCount] + label = `(${unitForSet})` + } else { + label = '(COUNT)' + } + } + + const divisor = base ** cycleCount + + const data = useMemo( + () => + (chartData || []).map(({ timestamp, value }) => ({ + timestamp, + // All of these metrics are cumulative ints. + // The value passed in is what will render in the tooltip. + value: isBytesChart + ? // We pass a pre-divided value to the chart if the unit is Bytes + value / divisor + : // If the unit is Count, we pass the raw value + value, + })), + [isBytesChart, divisor, chartData] + ) + + // Create a label for the y-axis ticks. "Count" charts will be + // abbreviated and will have a suffix (e.g. "k") appended. Because + // "Bytes" charts will have already been divided by the divisor + // before the yAxis is created, we can use their given value. + const yAxisTickFormatter = (val: number) => { + if (isBytesChart) { + return val.toLocaleString() + } + const tickValue = (val / divisor).toFixed(2) + const countUnits = ['', 'k', 'M', 'B', 'T'] + const unitForTick = countUnits[cycleCount] + return `${tickValue}${unitForTick}` + } + + return ( +
+

+ {title}
{label}
+ {!metrics && } +

+ }> + + +
+ ) +} From 1bd508d5bdd834673e39b77b16c03902e40c6d28 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 29 Jan 2025 20:08:21 -0800 Subject: [PATCH 06/75] More CPU metrics, though we mgiht need to rethink the long-term plan --- .../instances/instance/tabs/MetricsTab.tsx | 196 ++++++++++++------ .../instance/tabs/MetricsTab/OxqlMetric.tsx | 102 +++++++-- 2 files changed, 216 insertions(+), 82 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 380857ad7c..8f0d716ddd 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import React, { useMemo, useState } from 'react' -import type { LoaderFunctionArgs } from 'react-router' +import { Link, type LoaderFunctionArgs } from 'react-router' import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' import { Storage24Icon } from '@oxide/design-system/icons/react' @@ -16,6 +16,7 @@ import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Listbox } from '~/ui/lib/Listbox' import { TableEmptyBox } from '~/ui/lib/Table' +import { pb } from '~/util/path-builder' import { OxqlMetric } from './MetricsTab/OxqlMetric' @@ -26,10 +27,16 @@ import { OxqlMetric } from './MetricsTab/OxqlMetric' export async function loader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) - await apiQueryClient.prefetchQuery('instanceDiskList', { - path: { instance }, - query: { project }, - }) + await Promise.all([ + apiQueryClient.prefetchQuery('instanceDiskList', { + path: { instance }, + query: { project }, + }), + apiQueryClient.prefetchQuery('instanceView', { + path: { instance }, + query: { project }, + }), + ]) return null } @@ -40,6 +47,10 @@ export function Component() { path: { instance }, query: { project }, }) + const { data: instanceData } = usePrefetchedApiQuery('instanceView', { + path: { instance }, + query: { project }, + }) const disks = useMemo(() => data?.items || [], [data]) const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ @@ -72,67 +83,132 @@ export function Component() { } return ( - <> -
- { - if (val) { - setDiskName(val) - setDiskId(disks.find((d) => d.name === val)?.id || '') - } - }} - /> - {dateTimeRangePicker} +
+
+ CPU + Utilization + Time + Disk + Network
- -
- {/* see the following link for the source of truth on what these mean - https://github.com/oxidecomputer/crucible/blob/258f162b/upstairs/src/stats.rs#L9-L50 */} - -
- - +
+ { + if (val) { + setDiskName(val) + setDiskId(disks.find((d) => d.name === val)?.id || '') + } + }} /> + {dateTimeRangePicker}
+
+ {/* see the following link for the source of truth on what these mean + https://github.com/oxidecomputer/crucible/blob/258f162b/upstairs/src/stats.rs#L9-L50 */} -
- - -
+
+ + +
-
- +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+ +
- +
) } diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 243363beda..af0a6f90b3 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -6,11 +6,17 @@ * Copyright Oxide Computer Company */ +/* + * OxQL Metrics Schema: + * https://github.com/oxidecomputer/omicron/tree/main/oximeter/oximeter/schema + */ + import React, { Suspense, useMemo } from 'react' import { getChartData, useApiQuery, type ChartDatum } from '@oxide/api' import { Spinner } from '~/ui/lib/Spinner' +import { getDurationMinutes } from '~/util/date' const TimeSeriesChart = React.lazy(() => import('~/components/TimeSeriesChart')) @@ -43,6 +49,14 @@ type OxqlVmMetricName = 'virtual_machine:vcpu_usage' type OxqlMetricName = OxqlDiskMetricName | OxqlVmMetricName +type OxqlVcpuState = 'run' | 'idle' | 'waiting' | 'emulation' + +/** determine the mean window for the given time range */ +const getMeanWindow = (start: Date, end: Date) => { + const duration = getDurationMinutes({ start, end }) + return `${Math.round(duration / 480)}m` +} + type getOxqlQueryParams = { metricName: OxqlMetricName startTime: Date @@ -50,7 +64,10 @@ type getOxqlQueryParams = { // for disk metrics diskId?: string // for vm metrics - vmId?: string + instanceId?: string + vcpuId?: string + state?: OxqlVcpuState + join?: boolean } const getOxqlQuery = ({ @@ -58,7 +75,10 @@ const getOxqlQuery = ({ startTime, endTime, diskId, - vmId, + instanceId, + vcpuId, + state, + join, }: getOxqlQueryParams) => { const start = oxqlTimestamp(startTime) const end = oxqlTimestamp(endTime) @@ -66,22 +86,43 @@ const getOxqlQuery = ({ if (diskId) { filters.push(`disk_id == "${diskId}"`) } - if (vmId) { - filters.push(`vm_id == "${vmId}"`) + if (vcpuId) { + filters.push(`vcpu_id == ${vcpuId}`) } - return `get ${metricName} | filter ${filters.join(' && ')} | align mean_within(30s)` + if (instanceId) { + filters.push(`instance_id == "${instanceId}"`) + } + if (state) { + filters.push(`state == "${state}"`) + } + const meanWindow = getMeanWindow(startTime, endTime) + const query = `get ${metricName} | filter ${filters.join(' && ')} | align mean_within(${meanWindow}) ${join ? '| group_by [], sum' : ''}` + // console.log(query) // 🚨🚨🚨🚨🚨 + return query } type OxqlBaseMetricParams = { title: string - unit: 'Bytes' | 'Count' + unit: 'Bytes' | 'Count' | '%' metricName: OxqlMetricName startTime: Date endTime: Date } -type OxqlDiskMetricParams = OxqlBaseMetricParams & { diskId?: string; vmId?: never } -type OxqlVmMetricParams = OxqlBaseMetricParams & { diskId?: never; vmId?: string } +type OxqlDiskMetricParams = OxqlBaseMetricParams & { + diskId?: string + instanceId?: never + vcpuId?: never + state?: never + join?: never +} +type OxqlVmMetricParams = OxqlBaseMetricParams & { + diskId?: never + instanceId?: string + vcpuId?: string + state?: OxqlVcpuState + join?: boolean +} export function OxqlMetric({ title, unit, @@ -89,13 +130,26 @@ export function OxqlMetric({ startTime, endTime, diskId, - vmId, + instanceId, + vcpuId, + state, + join, }: OxqlDiskMetricParams | OxqlVmMetricParams) { - const query = getOxqlQuery({ metricName, startTime, endTime, diskId, vmId }) + const query = getOxqlQuery({ + metricName, + startTime, + endTime, + diskId, + instanceId, + vcpuId, + state, + join, + }) const { data: metrics } = useApiQuery('systemTimeseriesQuery', { body: { query } }) const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) const isBytesChart = unit === 'Bytes' + const isPercentChart = unit === '%' const largestValue = useMemo(() => { if (!chartData || chartData.length === 0) return 0 @@ -118,25 +172,25 @@ export function OxqlMetric({ unitForSet = byteUnits[cycleCount] label = `(${unitForSet})` } else { - label = '(COUNT)' + label = `(${unit.toUpperCase()})` + unitForSet = isPercentChart ? '%' : '' } } - const divisor = base ** cycleCount + // We need to determine the divisor for the data set. + // - If the unit is Bytes, we divide by 1024 ** cycleCount + // - If the unit is %, we divide by the number of nanoseconds in a minute + // - If the unit is Count, we just return the raw value + const divisor = isBytesChart ? base ** cycleCount : isPercentChart ? 600000000 : 1 const data = useMemo( () => (chartData || []).map(({ timestamp, value }) => ({ timestamp, - // All of these metrics are cumulative ints. - // The value passed in is what will render in the tooltip. - value: isBytesChart - ? // We pass a pre-divided value to the chart if the unit is Bytes - value / divisor - : // If the unit is Count, we pass the raw value - value, + // The value passed in is what will render in the tooltip + value: value / divisor, })), - [isBytesChart, divisor, chartData] + [divisor, chartData] ) // Create a label for the y-axis ticks. "Count" charts will be @@ -147,7 +201,10 @@ export function OxqlMetric({ if (isBytesChart) { return val.toLocaleString() } - const tickValue = (val / divisor).toFixed(2) + if (isPercentChart) { + return `${val}%` + } + const tickValue = val / divisor const countUnits = ['', 'k', 'M', 'B', 'T'] const unitForTick = countUnits[cycleCount] return `${tickValue}${unitForTick}` @@ -163,7 +220,8 @@ export function OxqlMetric({ Date: Thu, 30 Jan 2025 11:58:51 -0800 Subject: [PATCH 07/75] working group_by --- .../instances/instance/tabs/MetricsTab/OxqlMetric.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index af0a6f90b3..2e27c1fd1e 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -54,7 +54,9 @@ type OxqlVcpuState = 'run' | 'idle' | 'waiting' | 'emulation' /** determine the mean window for the given time range */ const getMeanWindow = (start: Date, end: Date) => { const duration = getDurationMinutes({ start, end }) - return `${Math.round(duration / 480)}m` + // the number of points in the chart + const points = 100 + return `${Math.round(duration / points)}m` } type getOxqlQueryParams = { @@ -96,8 +98,7 @@ const getOxqlQuery = ({ filters.push(`state == "${state}"`) } const meanWindow = getMeanWindow(startTime, endTime) - const query = `get ${metricName} | filter ${filters.join(' && ')} | align mean_within(${meanWindow}) ${join ? '| group_by [], sum' : ''}` - // console.log(query) // 🚨🚨🚨🚨🚨 + const query = `get ${metricName} | filter ${filters.join(' && ')} | align mean_within(${meanWindow})${join ? ' | group_by [instance_id], sum' : ''}` return query } @@ -221,7 +222,6 @@ export function OxqlMetric({ className="mt-3" data={data} title={title} - legend="asdasd" unit={unitForSet} width={480} height={240} From b7267fbd278c3f9c843879c79809955d13339d04 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 30 Jan 2025 14:05:12 -0800 Subject: [PATCH 08/75] Updates to routes to handle sub-tabs --- .../instances/instance/tabs/MetricsTab.tsx | 24 +- .../tabs/MetricsTab/DiskMetricsTab.tsx | 212 ++++++++++++++++++ app/routes.tsx | 13 +- app/util/path-builder.ts | 1 + 4 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 8f0d716ddd..47d33d86bb 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import React, { useMemo, useState } from 'react' -import { Link, type LoaderFunctionArgs } from 'react-router' +import { Link, Outlet, type LoaderFunctionArgs } from 'react-router' import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' import { Storage24Icon } from '@oxide/design-system/icons/react' @@ -20,6 +20,24 @@ import { pb } from '~/util/path-builder' import { OxqlMetric } from './MetricsTab/OxqlMetric' +export const MetricsTab = () => { + const { project, instance } = useInstanceSelector() + return ( +
+
+ CPU + Utilization + Time + Disk + Network +
+
+ +
+
+ ) +} + // We could figure out how to prefetch the metrics data, but it would be // annoying because it relies on the default date range, plus there are 5 calls. // Considering the data is going to be swapped out as soon as they change the @@ -40,7 +58,7 @@ export async function loader({ params }: LoaderFunctionArgs) { return null } -Component.displayName = 'MetricsTab' +Component.displayName = 'MetricsTabOld' export function Component() { const { project, instance } = useInstanceSelector() const { data } = usePrefetchedApiQuery('instanceDiskList', { @@ -88,7 +106,7 @@ export function Component() { CPU Utilization Time - Disk + Disk Network
diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx new file mode 100644 index 0000000000..870972447e --- /dev/null +++ b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx @@ -0,0 +1,212 @@ +/* + * 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 + */ + +/* + * 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 React, { useMemo, useState } from 'react' +import { type LoaderFunctionArgs } from 'react-router' + +import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' +import { Storage24Icon } from '@oxide/design-system/icons/react' + +import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker' +import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { Listbox } from '~/ui/lib/Listbox' +import { TableEmptyBox } from '~/ui/lib/Table' + +import { OxqlMetric } from './OxqlMetric' + +// We could figure out how to prefetch the metrics data, but it would be +// annoying because it relies on the default date range, plus there are 5 calls. +// Considering the data is going to be swapped out as soon as they change the +// date range, I'm inclined to punt. + +export async function loader({ params }: LoaderFunctionArgs) { + const { project, instance } = getInstanceSelector(params) + await Promise.all([ + apiQueryClient.prefetchQuery('instanceDiskList', { + path: { instance }, + query: { project }, + }), + apiQueryClient.prefetchQuery('instanceView', { + path: { instance }, + query: { project }, + }), + ]) + return null +} + +Component.displayName = 'DiskMetricsTab' +export function Component() { + const { project, instance } = useInstanceSelector() + const { data } = usePrefetchedApiQuery('instanceDiskList', { + path: { instance }, + query: { project }, + }) + const { data: instanceData } = usePrefetchedApiQuery('instanceView', { + path: { instance }, + query: { project }, + }) + const disks = useMemo(() => data?.items || [], [data]) + + const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ + initialPreset: 'lastDay', + }) + + // The fallback here is kind of silly — it is only invoked when there are no + // disks, in which case we show the fallback UI and diskName is never used. We + // only need to do it this way because hooks cannot be called conditionally. + const [diskName, setDiskName] = useState(disks[0]?.name || '') + const [diskId, setDiskId] = useState(disks[0]?.id || '') + const diskItems = disks.map(({ name }) => ({ label: name, value: name })) + + if (disks.length === 0) { + return ( + + } + title="No metrics available" + body="Metrics are only available if there are disks attached" + /> + + ) + } + + const commonProps = { + startTime, + endTime, + diskId, + } + + return ( + <> +
+ { + if (val) { + setDiskName(val) + setDiskId(disks.find((d) => d.name === val)?.id || '') + } + }} + /> + {dateTimeRangePicker} +
+
+ {/* see the following link for the source of truth on what these mean + https://github.com/oxidecomputer/crucible/blob/258f162b/upstairs/src/stats.rs#L9-L50 */} + +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 780e603b5c..1f9e4cce60 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -60,7 +60,8 @@ import { ImagesPage } from './pages/project/images/ImagesPage' import { InstancePage } from './pages/project/instances/instance/InstancePage' import * as SerialConsole from './pages/project/instances/instance/SerialConsolePage' import * as ConnectTab from './pages/project/instances/instance/tabs/ConnectTab' -import * as MetricsTab from './pages/project/instances/instance/tabs/MetricsTab' +import { MetricsTab } from './pages/project/instances/instance/tabs/MetricsTab' +import * as DiskMetricsTab from './pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab' import * as NetworkingTab from './pages/project/instances/instance/tabs/NetworkingTab' import * as StorageTab from './pages/project/instances/instance/tabs/StorageTab' import { InstancesPage } from './pages/project/instances/InstancesPage' @@ -293,7 +294,15 @@ export const routes = createRoutesFromElements( path="networking" handle={{ crumb: 'Networking' }} /> - + {/* */} + } + path="metrics" + handle={{ crumb: 'Metrics' }} + > + } /> + + diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 5b5ab823f9..0817aa69e2 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -40,6 +40,7 @@ export const pb = { instance: (params: PP.Instance) => pb.instanceStorage(params), instanceMetrics: (params: PP.Instance) => `${instanceBase(params)}/metrics`, + instanceDiskMetrics: (params: PP.Instance) => `${instanceBase(params)}/metrics/disk`, instanceStorage: (params: PP.Instance) => `${instanceBase(params)}/storage`, instanceConnect: (params: PP.Instance) => `${instanceBase(params)}/connect`, instanceNetworking: (params: PP.Instance) => `${instanceBase(params)}/networking`, From 8745becd6f1a6e41b2ea1d4a254b5ee767e43149 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 30 Jan 2025 14:38:37 -0800 Subject: [PATCH 09/75] Dropdown working on CPU charts --- .../instances/instance/tabs/MetricsTab.tsx | 210 +----------------- .../tabs/MetricsTab/CpuMetricsTab.tsx | 159 +++++++++++++ .../tabs/MetricsTab/DiskMetricsTab.tsx | 71 +----- .../OxqlMetric.spec.ts} | 2 +- app/routes.tsx | 3 +- app/util/path-builder.ts | 1 + 6 files changed, 172 insertions(+), 274 deletions(-) create mode 100644 app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx rename app/pages/project/instances/instance/tabs/{MetricsTab.spec.ts => MetricsTab/OxqlMetric.spec.ts} (94%) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 47d33d86bb..fac96cb946 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -5,27 +5,18 @@ * * Copyright Oxide Computer Company */ -import React, { useMemo, useState } from 'react' -import { Link, Outlet, type LoaderFunctionArgs } from 'react-router' +import React from 'react' +import { Link, Outlet } from 'react-router' -import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' -import { Storage24Icon } from '@oxide/design-system/icons/react' - -import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker' -import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { Listbox } from '~/ui/lib/Listbox' -import { TableEmptyBox } from '~/ui/lib/Table' +import { useInstanceSelector } from '~/hooks/use-params' import { pb } from '~/util/path-builder' -import { OxqlMetric } from './MetricsTab/OxqlMetric' - export const MetricsTab = () => { const { project, instance } = useInstanceSelector() return (
- CPU + CPU Utilization Time Disk @@ -37,196 +28,3 @@ export const MetricsTab = () => {
) } - -// We could figure out how to prefetch the metrics data, but it would be -// annoying because it relies on the default date range, plus there are 5 calls. -// Considering the data is going to be swapped out as soon as they change the -// date range, I'm inclined to punt. - -export async function loader({ params }: LoaderFunctionArgs) { - const { project, instance } = getInstanceSelector(params) - await Promise.all([ - apiQueryClient.prefetchQuery('instanceDiskList', { - path: { instance }, - query: { project }, - }), - apiQueryClient.prefetchQuery('instanceView', { - path: { instance }, - query: { project }, - }), - ]) - return null -} - -Component.displayName = 'MetricsTabOld' -export function Component() { - const { project, instance } = useInstanceSelector() - const { data } = usePrefetchedApiQuery('instanceDiskList', { - path: { instance }, - query: { project }, - }) - const { data: instanceData } = usePrefetchedApiQuery('instanceView', { - path: { instance }, - query: { project }, - }) - const disks = useMemo(() => data?.items || [], [data]) - - const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ - initialPreset: 'lastDay', - }) - - // The fallback here is kind of silly — it is only invoked when there are no - // disks, in which case we show the fallback UI and diskName is never used. We - // only need to do it this way because hooks cannot be called conditionally. - const [diskName, setDiskName] = useState(disks[0]?.name || '') - const [diskId, setDiskId] = useState(disks[0]?.id || '') - const diskItems = disks.map(({ name }) => ({ label: name, value: name })) - - if (disks.length === 0) { - return ( - - } - title="No metrics available" - body="Metrics are only available if there are disks attached" - /> - - ) - } - - const commonProps = { - startTime, - endTime, - diskId, - } - - return ( -
-
- CPU - Utilization - Time - Disk - Network -
-
-
- { - if (val) { - setDiskName(val) - setDiskId(disks.find((d) => d.name === val)?.id || '') - } - }} - /> - {dateTimeRangePicker} -
-
- {/* see the following link for the source of truth on what these mean - https://github.com/oxidecomputer/crucible/blob/258f162b/upstairs/src/stats.rs#L9-L50 */} - -
- - -
- -
- - -
- -
- -
- -
- - -
- -
- - -
-
- -
-
-
-
- ) -} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx new file mode 100644 index 0000000000..5166e57061 --- /dev/null +++ b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx @@ -0,0 +1,159 @@ +/* + * 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 + */ + +/* + * 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 React, { useState } from 'react' +import { type LoaderFunctionArgs } from 'react-router' + +import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' + +import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker' +import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' +import { Listbox } from '~/ui/lib/Listbox' + +import { OxqlMetric } from './OxqlMetric' + +// We could figure out how to prefetch the metrics data, but it would be +// annoying because it relies on the default date range, plus there are 5 calls. +// Considering the data is going to be swapped out as soon as they change the +// date range, I'm inclined to punt. + +export async function loader({ params }: LoaderFunctionArgs) { + const { project, instance } = getInstanceSelector(params) + await Promise.all([ + apiQueryClient.prefetchQuery('instanceDiskList', { + path: { instance }, + query: { project }, + }), + apiQueryClient.prefetchQuery('instanceView', { + path: { instance }, + query: { project }, + }), + ]) + return null +} + +Component.displayName = 'CpuMetricsTab' +export function Component() { + const { project, instance } = useInstanceSelector() + const { data: instanceData } = usePrefetchedApiQuery('instanceView', { + path: { instance }, + query: { project }, + }) + const ncpus = instanceData?.ncpus || 1 + + const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ + initialPreset: 'lastDay', + }) + + // The fallback here is kind of silly — it is only invoked when there are no + // disks, in which case we show the fallback UI and diskName is never used. We + // only need to do it this way because hooks cannot be called conditionally. + const [cpuId, setCpuId] = useState('all') + // Revisit this for selecting a CPU + // const [diskId, setDiskId] = useState(disks[0]?.id || '') + + // an array, of label and values, with the first one being 'all' and value is undefined, then the rest of the cpus + // up to ncpus, with each label being a string of the number, and the value being the number; start with 0, then go up to one less than the total number of cpus + const cpuItems = [ + { label: 'All', value: 'all' }, + ...Array.from({ length: ncpus }, (_, i) => ({ + label: i.toString(), + value: i.toString(), + })), + ] + + const vcpuId = cpuId === 'all' ? undefined : cpuId + + return ( + <> +
+ { + setCpuId(val) + }} + /> + {dateTimeRangePicker} +
+
+
+ +
+
+ + +
+ +
+ + +
+
+ + ) +} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx index 870972447e..0d18489911 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx @@ -54,10 +54,6 @@ export function Component() { path: { instance }, query: { project }, }) - const { data: instanceData } = usePrefetchedApiQuery('instanceView', { - path: { instance }, - query: { project }, - }) const disks = useMemo(() => data?.items || [], [data]) const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ @@ -114,13 +110,13 @@ export function Component() {
@@ -129,13 +125,13 @@ export function Component() {
@@ -144,68 +140,11 @@ export function Component() {
- -
- - -
- -
- - -
-
- -
) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.spec.ts b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts similarity index 94% rename from app/pages/project/instances/instance/tabs/MetricsTab.spec.ts rename to app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts index 0b973470e5..a083425ce0 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.spec.ts +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts @@ -7,7 +7,7 @@ */ import { expect, test } from 'vitest' -import { getCycleCount } from './MetricsTab/OxqlMetric' +import { getCycleCount } from './OxqlMetric' test('getCycleCount', () => { expect(getCycleCount(5, 1000)).toEqual(0) diff --git a/app/routes.tsx b/app/routes.tsx index 1f9e4cce60..3e2d4ec21f 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -61,6 +61,7 @@ import { InstancePage } from './pages/project/instances/instance/InstancePage' import * as SerialConsole from './pages/project/instances/instance/SerialConsolePage' import * as ConnectTab from './pages/project/instances/instance/tabs/ConnectTab' import { MetricsTab } from './pages/project/instances/instance/tabs/MetricsTab' +import * as CpuMetricsTab from './pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab' import * as DiskMetricsTab from './pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab' import * as NetworkingTab from './pages/project/instances/instance/tabs/NetworkingTab' import * as StorageTab from './pages/project/instances/instance/tabs/StorageTab' @@ -294,7 +295,6 @@ export const routes = createRoutesFromElements( path="networking" handle={{ crumb: 'Networking' }} /> - {/* */} } path="metrics" @@ -302,6 +302,7 @@ export const routes = createRoutesFromElements( > } /> + diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 0817aa69e2..3e4f370a51 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -41,6 +41,7 @@ export const pb = { instanceMetrics: (params: PP.Instance) => `${instanceBase(params)}/metrics`, instanceDiskMetrics: (params: PP.Instance) => `${instanceBase(params)}/metrics/disk`, + instanceCpuMetrics: (params: PP.Instance) => `${instanceBase(params)}/metrics/cpu`, instanceStorage: (params: PP.Instance) => `${instanceBase(params)}/storage`, instanceConnect: (params: PP.Instance) => `${instanceBase(params)}/connect`, instanceNetworking: (params: PP.Instance) => `${instanceBase(params)}/networking`, From 3faf22b9832b4b5bb1fc2d2b789ab68ef1d29793 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 30 Jan 2025 15:08:48 -0800 Subject: [PATCH 10/75] cleanup --- .../instance/tabs/MetricsTab/CpuMetricsTab.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx index 5166e57061..433c1d0479 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx @@ -24,11 +24,6 @@ import { Listbox } from '~/ui/lib/Listbox' import { OxqlMetric } from './OxqlMetric' -// We could figure out how to prefetch the metrics data, but it would be -// annoying because it relies on the default date range, plus there are 5 calls. -// Considering the data is going to be swapped out as soon as they change the -// date range, I'm inclined to punt. - export async function loader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) await Promise.all([ @@ -57,15 +52,8 @@ export function Component() { initialPreset: 'lastDay', }) - // The fallback here is kind of silly — it is only invoked when there are no - // disks, in which case we show the fallback UI and diskName is never used. We - // only need to do it this way because hooks cannot be called conditionally. const [cpuId, setCpuId] = useState('all') - // Revisit this for selecting a CPU - // const [diskId, setDiskId] = useState(disks[0]?.id || '') - // an array, of label and values, with the first one being 'all' and value is undefined, then the rest of the cpus - // up to ncpus, with each label being a string of the number, and the value being the number; start with 0, then go up to one less than the total number of cpus const cpuItems = [ { label: 'All', value: 'all' }, ...Array.from({ length: ncpus }, (_, i) => ({ From 67eb9792bab98bed492e36543126b329b44ea4d0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 31 Jan 2025 09:44:36 -0800 Subject: [PATCH 11/75] more work on charts; networking --- .../instances/instance/tabs/MetricsTab.tsx | 2 +- .../tabs/MetricsTab/DiskMetricsTab.tsx | 6 +- .../tabs/MetricsTab/NetworkMetricsTab.tsx | 127 ++++++++++++++++++ .../instance/tabs/MetricsTab/OxqlMetric.tsx | 21 ++- app/routes.tsx | 8 +- app/util/path-builder.ts | 4 +- 6 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index fac96cb946..9ce3487b93 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -20,7 +20,7 @@ export const MetricsTab = () => { Utilization Time Disk - Network + Network
diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx index 0d18489911..9772b76dfb 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx @@ -13,7 +13,7 @@ * * Copyright Oxide Computer Company */ -import React, { useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { type LoaderFunctionArgs } from 'react-router' import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' @@ -122,7 +122,7 @@ export function Component() { />
-
+
-
+
interfaceData.items.map(({ name, id }) => ({ name, id })), + [interfaceData] + ) + + const [nic, setNic] = useState({ + name: networks[0]?.name || '', + id: networks[0]?.id || '', + }) + const items = networks.map(({ name }) => ({ label: name, value: name })) + + const commonProps = { + startTime, + endTime, + instanceId: instanceData.id, + } + + return ( + <> +
+ { + if (val) { + setNic({ name: val, id: networks.find((n) => n.name === val)?.id || '' }) + } + }} + /> + {dateTimeRangePicker} +
+
+
+ + +
+
+ + +
+
+ +
+
+ + ) +} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 2e27c1fd1e..80a35174ca 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -47,7 +47,16 @@ type OxqlDiskMetricName = type OxqlVmMetricName = 'virtual_machine:vcpu_usage' -type OxqlMetricName = OxqlDiskMetricName | OxqlVmMetricName +type OxqlNetworkMetricName = + | 'instance_network_interface:bytes_received' + | 'instance_network_interface:bytes_sent' + | 'instance_network_interface:errors_received' + | 'instance_network_interface:errors_sent' + | 'instance_network_interface:packets_dropped' + | 'instance_network_interface:packets_received' + | 'instance_network_interface:packets_sent' + +type OxqlMetricName = OxqlDiskMetricName | OxqlVmMetricName | OxqlNetworkMetricName type OxqlVcpuState = 'run' | 'idle' | 'waiting' | 'emulation' @@ -99,6 +108,7 @@ const getOxqlQuery = ({ } const meanWindow = getMeanWindow(startTime, endTime) const query = `get ${metricName} | filter ${filters.join(' && ')} | align mean_within(${meanWindow})${join ? ' | group_by [instance_id], sum' : ''}` + // console.log(query) return query } @@ -124,6 +134,13 @@ type OxqlVmMetricParams = OxqlBaseMetricParams & { state?: OxqlVcpuState join?: boolean } +type OxqlNetworkMetricParams = OxqlBaseMetricParams & { + instanceId?: string + diskId?: never + vcpuId?: never + state?: never + join?: never +} export function OxqlMetric({ title, unit, @@ -135,7 +152,7 @@ export function OxqlMetric({ vcpuId, state, join, -}: OxqlDiskMetricParams | OxqlVmMetricParams) { +}: OxqlDiskMetricParams | OxqlVmMetricParams | OxqlNetworkMetricParams) { const query = getOxqlQuery({ metricName, startTime, diff --git a/app/routes.tsx b/app/routes.tsx index 3e2d4ec21f..533f2486cb 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -63,6 +63,7 @@ import * as ConnectTab from './pages/project/instances/instance/tabs/ConnectTab' import { MetricsTab } from './pages/project/instances/instance/tabs/MetricsTab' import * as CpuMetricsTab from './pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab' import * as DiskMetricsTab from './pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab' +import * as NetworkMetricsTab from './pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab' import * as NetworkingTab from './pages/project/instances/instance/tabs/NetworkingTab' import * as StorageTab from './pages/project/instances/instance/tabs/StorageTab' import { InstancesPage } from './pages/project/instances/InstancesPage' @@ -301,8 +302,13 @@ export const routes = createRoutesFromElements( handle={{ crumb: 'Metrics' }} > } /> - + + diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 3e4f370a51..35e0c2e2e2 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -40,8 +40,10 @@ export const pb = { instance: (params: PP.Instance) => pb.instanceStorage(params), instanceMetrics: (params: PP.Instance) => `${instanceBase(params)}/metrics`, - instanceDiskMetrics: (params: PP.Instance) => `${instanceBase(params)}/metrics/disk`, instanceCpuMetrics: (params: PP.Instance) => `${instanceBase(params)}/metrics/cpu`, + instanceDiskMetrics: (params: PP.Instance) => `${instanceBase(params)}/metrics/disk`, + instanceNetworkMetrics: (params: PP.Instance) => + `${instanceBase(params)}/metrics/network`, instanceStorage: (params: PP.Instance) => `${instanceBase(params)}/storage`, instanceConnect: (params: PP.Instance) => `${instanceBase(params)}/connect`, instanceNetworking: (params: PP.Instance) => `${instanceBase(params)}/networking`, From 26dfd35c7277bad3bb4b46b3e86efb8a6cb5e241 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 31 Jan 2025 10:00:35 -0800 Subject: [PATCH 12/75] Standardize wrapper components --- .../tabs/MetricsTab/CpuMetricsTab.tsx | 22 +++++++++---------- .../tabs/MetricsTab/DiskMetricsTab.tsx | 22 +++++++++---------- .../tabs/MetricsTab/NetworkMetricsTab.tsx | 22 +++++++++---------- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 10 +++++++++ 4 files changed, 43 insertions(+), 33 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx index 433c1d0479..b9da81c385 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx @@ -22,7 +22,7 @@ import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePi import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { Listbox } from '~/ui/lib/Listbox' -import { OxqlMetric } from './OxqlMetric' +import { MetricCollection, MetricHeader, MetricRow, OxqlMetric } from './OxqlMetric' export async function loader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) @@ -66,7 +66,7 @@ export function Component() { return ( <> -
+ {dateTimeRangePicker} -
-
-
+ + + -
-
+ + -
+ -
+ -
-
+ + ) } diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx index 9772b76dfb..a43c8c4067 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx @@ -25,7 +25,7 @@ import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Listbox } from '~/ui/lib/Listbox' import { TableEmptyBox } from '~/ui/lib/Table' -import { OxqlMetric } from './OxqlMetric' +import { MetricCollection, MetricHeader, MetricRow, OxqlMetric } from './OxqlMetric' // We could figure out how to prefetch the metrics data, but it would be // annoying because it relies on the default date range, plus there are 5 calls. @@ -87,7 +87,7 @@ export function Component() { return ( <> -
+ {dateTimeRangePicker} -
-
+ + {/* see the following link for the source of truth on what these mean https://github.com/oxidecomputer/crucible/blob/258f162b/upstairs/src/stats.rs#L9-L50 */} -
+ -
+ -
+ -
+ -
+ -
-
+ + ) } diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx index 0b85acfab2..925e40494f 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx @@ -16,7 +16,7 @@ import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { Listbox } from '~/ui/lib/Listbox' import { ALL_ISH } from '~/util/consts' -import { OxqlMetric } from './OxqlMetric' +import { MetricCollection, MetricHeader, MetricRow, OxqlMetric } from './OxqlMetric' export async function loader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) @@ -69,7 +69,7 @@ export function Component() { return ( <> -
+ {dateTimeRangePicker} -
-
-
+ + + -
-
+ + -
-
+ + -
-
+ + ) } diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 80a35174ca..bd4a841a05 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -250,3 +250,13 @@ export function OxqlMetric({
) } + +export const MetricHeader = ({ children }: { children: React.ReactNode }) => ( +
{children}
+) +export const MetricCollection = ({ children }: { children: React.ReactNode }) => ( +
{children}
+) +export const MetricRow = ({ children }: { children: React.ReactNode }) => ( +
{children}
+) From 4723da2838db213b25ce05afe66434562f620f6f Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 31 Jan 2025 10:24:58 -0800 Subject: [PATCH 13/75] Reorder charts a bit --- .../tabs/MetricsTab/DiskMetricsTab.tsx | 12 +++++----- .../tabs/MetricsTab/NetworkMetricsTab.tsx | 24 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx index a43c8c4067..2bf79fcda5 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx @@ -116,18 +116,18 @@ export function Component() { /> From 40bd0c519dcd48b8e522fff4e953567c86c01b58 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 31 Jan 2025 10:43:27 -0800 Subject: [PATCH 14/75] getting side tabs into place --- app/components/RouteTabs.tsx | 32 +++++++++++++++++++ .../instances/instance/tabs/MetricsTab.tsx | 21 ++++-------- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/app/components/RouteTabs.tsx b/app/components/RouteTabs.tsx index a35272539e..535998c5a0 100644 --- a/app/components/RouteTabs.tsx +++ b/app/components/RouteTabs.tsx @@ -72,3 +72,35 @@ export const Tab = ({ to, children }: TabProps) => { ) } + +export const RouteSideTabs = ({ children }: { children: ReactNode }) => ( +
+ {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */} +
+ {children} +
+ {/* TODO: Add aria-describedby for active tab */} +
+ +
+
+) + +export const SideTab = ({ to, children }: { to: string; children: ReactNode }) => { + const isActive = useIsActivePath({ to }) + return ( + +
{children}
+ + ) +} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 9ce3487b93..e35f18eb7a 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -5,26 +5,17 @@ * * Copyright Oxide Computer Company */ -import React from 'react' -import { Link, Outlet } from 'react-router' - +import { RouteSideTabs, SideTab } from '~/components/RouteTabs' import { useInstanceSelector } from '~/hooks/use-params' import { pb } from '~/util/path-builder' export const MetricsTab = () => { const { project, instance } = useInstanceSelector() return ( -
-
- CPU - Utilization - Time - Disk - Network -
-
- -
-
+ + CPU + Disk + Network + ) } From d3321808fc8aece1546cae6ea86f5234960774d2 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 31 Jan 2025 13:59:50 -0800 Subject: [PATCH 15/75] Update side tab CSS --- app/components/RouteTabs.tsx | 6 +++--- .../instances/instance/tabs/MetricsTab.tsx | 1 + app/ui/styles/components/Tabs.css | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/components/RouteTabs.tsx b/app/components/RouteTabs.tsx index 535998c5a0..97cd486577 100644 --- a/app/components/RouteTabs.tsx +++ b/app/components/RouteTabs.tsx @@ -74,11 +74,11 @@ export const Tab = ({ to, children }: TabProps) => { } export const RouteSideTabs = ({ children }: { children: ReactNode }) => ( -
+
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
{children} @@ -96,7 +96,7 @@ export const SideTab = ({ to, children }: { to: string; children: ReactNode }) = diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index e35f18eb7a..454cb40b83 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -11,6 +11,7 @@ import { pb } from '~/util/path-builder' export const MetricsTab = () => { const { project, instance } = useInstanceSelector() + // Find the in RouteSideTabs return ( CPU diff --git a/app/ui/styles/components/Tabs.css b/app/ui/styles/components/Tabs.css index a04c581434..5883114c1b 100644 --- a/app/ui/styles/components/Tabs.css +++ b/app/ui/styles/components/Tabs.css @@ -65,3 +65,20 @@ .ox-tab.is-selected > .ox-badge { @apply bg-accent-secondary; } + +.ox-side-tab { + @apply rounded space-x-2 whitespace-nowrap p-2 py-1.5 !no-underline text-sans-md text-secondary; +} + +.ox-side-tab[data-state='active'], +.ox-side-tab.is-selected { + @apply text-accent bg-accent-secondary; +} + +.ox-side-tab:not([data-state='active'], .is-selected):hover { + @apply text-default bg-hover; +} + +.ox-tab > * { + @apply rounded; +} From 77bd509c99e96dbda4e00a777dc52afa6ed38174 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 31 Jan 2025 16:25:07 -0800 Subject: [PATCH 16/75] Consolidate SideTabs into legacy tabs, using props to control layout --- app/components/RouteTabs.tsx | 55 ++++++------------- .../instances/instance/tabs/MetricsTab.tsx | 19 +++++-- app/routes.tsx | 2 +- app/ui/styles/components/Tabs.css | 8 +-- 4 files changed, 35 insertions(+), 49 deletions(-) diff --git a/app/components/RouteTabs.tsx b/app/components/RouteTabs.tsx index 97cd486577..5a518efb80 100644 --- a/app/components/RouteTabs.tsx +++ b/app/components/RouteTabs.tsx @@ -38,16 +38,25 @@ const selectTab = (e: React.KeyboardEvent) => { export interface RouteTabsProps { children: ReactNode fullWidth?: boolean + sideTabs?: boolean } -export function RouteTabs({ children, fullWidth }: RouteTabsProps) { +/** Tabbed views, controlling both the layout and functioning of tabs and the panel contents. + * For tabs on top of the panel, keep sideTabs as false. For tabs on the side, set sideTabs to true. + */ +export function RouteTabs({ children, fullWidth, sideTabs = false }: RouteTabsProps) { + const wrapperClasses = sideTabs + ? 'ox-side-tabs flex' + : cn('ox-tabs', { 'full-width': fullWidth }) + const tabListClasses = sideTabs ? 'ox-side-tabs-list' : 'ox-tabs-list' + const panelClasses = cn('ox-tabs-panel', { 'flex-grow': sideTabs }) return ( -
+
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */} -
+
{children}
{/* TODO: Add aria-describedby for active tab */} -
+
@@ -57,46 +66,16 @@ export function RouteTabs({ children, fullWidth }: RouteTabsProps) { export interface TabProps { to: string children: ReactNode + sideTab?: boolean } -export const Tab = ({ to, children }: TabProps) => { - const isActive = useIsActivePath({ to }) - return ( - -
{children}
- - ) -} - -export const RouteSideTabs = ({ children }: { children: ReactNode }) => ( -
- {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */} -
- {children} -
- {/* TODO: Add aria-describedby for active tab */} -
- -
-
-) - -export const SideTab = ({ to, children }: { to: string; children: ReactNode }) => { +export const Tab = ({ to, children, sideTab = false }: TabProps) => { const isActive = useIsActivePath({ to }) + const baseClass = sideTab ? 'ox-side-tab' : 'ox-tab' return ( diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 454cb40b83..b824302b23 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -5,7 +5,8 @@ * * Copyright Oxide Computer Company */ -import { RouteSideTabs, SideTab } from '~/components/RouteTabs' + +import { RouteTabs, Tab } from '~/components/RouteTabs' import { useInstanceSelector } from '~/hooks/use-params' import { pb } from '~/util/path-builder' @@ -13,10 +14,16 @@ export const MetricsTab = () => { const { project, instance } = useInstanceSelector() // Find the in RouteSideTabs return ( - - CPU - Disk - Network - + + + CPU + + + Disk + + + Network + + ) } diff --git a/app/routes.tsx b/app/routes.tsx index 533f2486cb..7961ace39e 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -301,7 +301,7 @@ export const routes = createRoutesFromElements( path="metrics" handle={{ crumb: 'Metrics' }} > - } /> + } /> * { - @apply rounded; -} From e3e2382940a5d41c19fead82a107780fb1b1cb89 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 3 Feb 2025 17:26:43 -0800 Subject: [PATCH 17/75] refactoring; getting rollups working for disks and network interfaces --- .../tabs/MetricsTab/CpuMetricsTab.tsx | 40 +++++---------- .../tabs/MetricsTab/DiskMetricsTab.tsx | 29 +++++++---- .../tabs/MetricsTab/NetworkMetricsTab.tsx | 18 +++---- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 51 ++++++++++++++----- 4 files changed, 81 insertions(+), 57 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx index b9da81c385..d8a55f1517 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx @@ -49,7 +49,7 @@ export function Component() { const ncpus = instanceData?.ncpus || 1 const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ - initialPreset: 'lastDay', + initialPreset: 'lastHour', }) const [cpuId, setCpuId] = useState('all') @@ -64,6 +64,14 @@ export function Component() { const vcpuId = cpuId === 'all' ? undefined : cpuId + const commonProps = { + startTime, + endTime, + instanceId: instanceData.id, + vcpuId, + group: cpuId === 'all', + } + return ( <> @@ -82,63 +90,43 @@ export function Component() { diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx index 2bf79fcda5..fa3864da0a 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx @@ -54,18 +54,25 @@ export function Component() { path: { instance }, query: { project }, }) + const { data: instanceData } = usePrefetchedApiQuery('instanceView', { + path: { instance }, + query: { project }, + }) + const instanceId = instanceData?.id const disks = useMemo(() => data?.items || [], [data]) const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ - initialPreset: 'lastDay', + initialPreset: 'lastHour', }) // The fallback here is kind of silly — it is only invoked when there are no // disks, in which case we show the fallback UI and diskName is never used. We // only need to do it this way because hooks cannot be called conditionally. - const [diskName, setDiskName] = useState(disks[0]?.name || '') - const [diskId, setDiskId] = useState(disks[0]?.id || '') - const diskItems = disks.map(({ name }) => ({ label: name, value: name })) + const [diskId, setDiskId] = useState('all') + const diskItems = [ + { label: 'All', value: 'all' }, + ...disks.map(({ name, id }) => ({ label: name, value: id })), + ] if (disks.length === 0) { return ( @@ -82,7 +89,12 @@ export function Component() { const commonProps = { startTime, endTime, - diskId, + attachedInstanceId: instanceId, + // does this need instanceId as well? + // Would think so, as these are instance metrics, + // but maybe the key is the attachedInstanceId + diskId: diskId === 'all' ? undefined : diskId, + group: diskId === 'all', } return ( @@ -92,13 +104,10 @@ export function Component() { className="w-64" aria-label="Choose disk" name="disk-name" - selected={diskName} + selected={diskId} items={diskItems} onChange={(val) => { - if (val) { - setDiskName(val) - setDiskId(disks.find((d) => d.name === val)?.id || '') - } + setDiskId(val) }} /> {dateTimeRangePicker} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx index 2aed7dd13e..ec5ee61291 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx @@ -47,24 +47,26 @@ export function Component() { }) const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ - initialPreset: 'lastDay', + initialPreset: 'lastHour', }) const networks = useMemo( - () => interfaceData.items.map(({ name, id }) => ({ name, id })), + () => [ + { name: 'All', id: 'all' }, + ...interfaceData.items.map(({ name, id }) => ({ name, id })), + ], [interfaceData] ) - const [nic, setNic] = useState({ - name: networks[0]?.name || '', - id: networks[0]?.id || '', - }) + const [nic, setNic] = useState({ name: 'All', id: 'all' }) const items = networks.map(({ name }) => ({ label: name, value: name })) const commonProps = { startTime, endTime, instanceId: instanceData.id, + interfaceId: nic.id === 'all' ? undefined : nic.id, + group: nic.id === 'all', } return ( @@ -77,9 +79,7 @@ export function Component() { selected={nic.name} items={items} onChange={(val) => { - if (val) { - setNic({ name: val, id: networks.find((n) => n.name === val)?.id || '' }) - } + setNic({ name: val, id: networks.find((n) => n.name === val)?.id || 'all' }) }} /> {dateTimeRangePicker} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index bd4a841a05..7ca1585b4f 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -72,24 +72,29 @@ type getOxqlQueryParams = { metricName: OxqlMetricName startTime: Date endTime: Date - // for disk metrics - diskId?: string - // for vm metrics instanceId?: string + // for cpu metrics vcpuId?: string + // for disk metrics + diskId?: string + attachedInstanceId?: string + // for network metrics + interfaceId?: string state?: OxqlVcpuState - join?: boolean + group?: boolean } const getOxqlQuery = ({ metricName, startTime, endTime, - diskId, instanceId, + diskId, + attachedInstanceId, + interfaceId, vcpuId, state, - join, + group, }: getOxqlQueryParams) => { const start = oxqlTimestamp(startTime) const end = oxqlTimestamp(endTime) @@ -103,11 +108,23 @@ const getOxqlQuery = ({ if (instanceId) { filters.push(`instance_id == "${instanceId}"`) } + if (interfaceId) { + filters.push(`interface_id == "${interfaceId}"`) + } + if (attachedInstanceId) { + filters.push(`attached_instance_id == "${attachedInstanceId}"`) + } if (state) { filters.push(`state == "${state}"`) } const meanWindow = getMeanWindow(startTime, endTime) - const query = `get ${metricName} | filter ${filters.join(' && ')} | align mean_within(${meanWindow})${join ? ' | group_by [instance_id], sum' : ''}` + const groupByString = + group && attachedInstanceId + ? ' | group_by [attached_instance_id], sum' + : group && instanceId + ? ' | group_by [instance_id], sum' + : '' + const query = `get ${metricName} | filter ${filters.join(' && ')} | align mean_within(${meanWindow})${groupByString}` // console.log(query) return query } @@ -121,25 +138,31 @@ type OxqlBaseMetricParams = { } type OxqlDiskMetricParams = OxqlBaseMetricParams & { + attachedInstanceId?: string diskId?: string instanceId?: never + interfaceId?: never vcpuId?: never state?: never - join?: never + group?: boolean } type OxqlVmMetricParams = OxqlBaseMetricParams & { + attachedInstanceId?: never diskId?: never instanceId?: string + interfaceId?: never vcpuId?: string state?: OxqlVcpuState - join?: boolean + group?: boolean } type OxqlNetworkMetricParams = OxqlBaseMetricParams & { + attachedInstanceId?: never instanceId?: string + interfaceId?: string diskId?: never vcpuId?: never state?: never - join?: never + group?: never } export function OxqlMetric({ title, @@ -148,20 +171,24 @@ export function OxqlMetric({ startTime, endTime, diskId, + attachedInstanceId, instanceId, + interfaceId, vcpuId, state, - join, + group, }: OxqlDiskMetricParams | OxqlVmMetricParams | OxqlNetworkMetricParams) { const query = getOxqlQuery({ metricName, startTime, endTime, + attachedInstanceId, diskId, instanceId, + interfaceId, vcpuId, state, - join, + group, }) const { data: metrics } = useApiQuery('systemTimeseriesQuery', { body: { query } }) const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) From 450fc9865ca16b652ac7ce8ebd58462eea256465 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 4 Feb 2025 15:13:31 -0800 Subject: [PATCH 18/75] Pass pre-formed query string to metric component --- .../tabs/MetricsTab/CpuMetricsTab.tsx | 61 +++++----- .../tabs/MetricsTab/DiskMetricsTab.tsx | 58 +++++----- .../tabs/MetricsTab/NetworkMetricsTab.tsx | 55 +++++---- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 107 +++++++----------- 4 files changed, 138 insertions(+), 143 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx index d8a55f1517..d331036517 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx @@ -22,7 +22,15 @@ import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePi import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { Listbox } from '~/ui/lib/Listbox' -import { MetricCollection, MetricHeader, MetricRow, OxqlMetric } from './OxqlMetric' +import { + getOxqlQuery, + MetricCollection, + MetricHeader, + MetricRow, + OxqlMetric, + type OxqlVcpuState, + type OxqlVmMetricName, +} from './OxqlMetric' export async function loader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) @@ -64,13 +72,16 @@ export function Component() { const vcpuId = cpuId === 'all' ? undefined : cpuId - const commonProps = { - startTime, - endTime, - instanceId: instanceData.id, - vcpuId, - group: cpuId === 'all', - } + const getQuery = (metricName: OxqlVmMetricName, state?: OxqlVcpuState) => + getOxqlQuery({ + metricName, + startTime, + endTime, + instanceId: instanceData.id, + vcpuId, + state, + group: cpuId === 'all', + }) return ( <> @@ -90,43 +101,39 @@ export function Component() { diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx index fa3864da0a..c60fb43831 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx @@ -25,7 +25,14 @@ import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Listbox } from '~/ui/lib/Listbox' import { TableEmptyBox } from '~/ui/lib/Table' -import { MetricCollection, MetricHeader, MetricRow, OxqlMetric } from './OxqlMetric' +import { + getOxqlQuery, + MetricCollection, + MetricHeader, + MetricRow, + OxqlMetric, + type OxqlDiskMetricName, +} from './OxqlMetric' // We could figure out how to prefetch the metrics data, but it would be // annoying because it relies on the default date range, plus there are 5 calls. @@ -86,16 +93,15 @@ export function Component() { ) } - const commonProps = { - startTime, - endTime, - attachedInstanceId: instanceId, - // does this need instanceId as well? - // Would think so, as these are instance metrics, - // but maybe the key is the attachedInstanceId - diskId: diskId === 'all' ? undefined : diskId, - group: diskId === 'all', - } + const getQuery = (metricName: OxqlDiskMetricName) => + getOxqlQuery({ + metricName, + startTime, + endTime, + attachedInstanceId: instanceId, + diskId: diskId === 'all' ? undefined : diskId, + group: diskId === 'all', + }) return ( <> @@ -118,40 +124,40 @@ export function Component() { diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx index ec5ee61291..1dd9cd3efb 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx @@ -16,7 +16,14 @@ import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { Listbox } from '~/ui/lib/Listbox' import { ALL_ISH } from '~/util/consts' -import { MetricCollection, MetricHeader, MetricRow, OxqlMetric } from './OxqlMetric' +import { + getOxqlQuery, + MetricCollection, + MetricHeader, + MetricRow, + OxqlMetric, + type OxqlNetworkMetricName, +} from './OxqlMetric' export async function loader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) @@ -61,13 +68,15 @@ export function Component() { const [nic, setNic] = useState({ name: 'All', id: 'all' }) const items = networks.map(({ name }) => ({ label: name, value: name })) - const commonProps = { - startTime, - endTime, - instanceId: instanceData.id, - interfaceId: nic.id === 'all' ? undefined : nic.id, - group: nic.id === 'all', - } + const getQuery = (metricName: OxqlNetworkMetricName) => + getOxqlQuery({ + metricName, + startTime, + endTime, + instanceId: instanceData.id, + interfaceId: nic.id === 'all' ? undefined : nic.id, + group: nic.id === 'all', + }) return ( <> @@ -87,38 +96,38 @@ export function Component() { diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 7ca1585b4f..9a89523a97 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -15,6 +15,7 @@ import React, { Suspense, useMemo } from 'react' import { getChartData, useApiQuery, type ChartDatum } from '@oxide/api' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { Spinner } from '~/ui/lib/Spinner' import { getDurationMinutes } from '~/util/date' @@ -33,7 +34,7 @@ export function getCycleCount(num: number, base: number) { return cycleCount } -type OxqlDiskMetricName = +export type OxqlDiskMetricName = | 'virtual_disk:bytes_read' | 'virtual_disk:bytes_written' | 'virtual_disk:failed_flushes' @@ -45,9 +46,9 @@ type OxqlDiskMetricName = | 'virtual_disk:reads' | 'virtual_disk:writes' -type OxqlVmMetricName = 'virtual_machine:vcpu_usage' +export type OxqlVmMetricName = 'virtual_machine:vcpu_usage' -type OxqlNetworkMetricName = +export type OxqlNetworkMetricName = | 'instance_network_interface:bytes_received' | 'instance_network_interface:bytes_sent' | 'instance_network_interface:errors_received' @@ -56,9 +57,9 @@ type OxqlNetworkMetricName = | 'instance_network_interface:packets_received' | 'instance_network_interface:packets_sent' -type OxqlMetricName = OxqlDiskMetricName | OxqlVmMetricName | OxqlNetworkMetricName +export type OxqlMetricName = OxqlDiskMetricName | OxqlVmMetricName | OxqlNetworkMetricName -type OxqlVcpuState = 'run' | 'idle' | 'waiting' | 'emulation' +export type OxqlVcpuState = 'run' | 'idle' | 'waiting' | 'emulation' /** determine the mean window for the given time range */ const getMeanWindow = (start: Date, end: Date) => { @@ -84,7 +85,7 @@ type getOxqlQueryParams = { group?: boolean } -const getOxqlQuery = ({ +export const getOxqlQuery = ({ metricName, startTime, endTime, @@ -129,70 +130,26 @@ const getOxqlQuery = ({ return query } -type OxqlBaseMetricParams = { - title: string - unit: 'Bytes' | 'Count' | '%' - metricName: OxqlMetricName - startTime: Date - endTime: Date -} - -type OxqlDiskMetricParams = OxqlBaseMetricParams & { - attachedInstanceId?: string - diskId?: string - instanceId?: never - interfaceId?: never - vcpuId?: never - state?: never - group?: boolean -} -type OxqlVmMetricParams = OxqlBaseMetricParams & { - attachedInstanceId?: never - diskId?: never - instanceId?: string - interfaceId?: never - vcpuId?: string - state?: OxqlVcpuState - group?: boolean -} -type OxqlNetworkMetricParams = OxqlBaseMetricParams & { - attachedInstanceId?: never - instanceId?: string - interfaceId?: string - diskId?: never - vcpuId?: never - state?: never - group?: never -} export function OxqlMetric({ title, - unit, - metricName, + query, startTime, endTime, - diskId, - attachedInstanceId, - instanceId, - interfaceId, - vcpuId, - state, - group, -}: OxqlDiskMetricParams | OxqlVmMetricParams | OxqlNetworkMetricParams) { - const query = getOxqlQuery({ - metricName, - startTime, - endTime, - attachedInstanceId, - diskId, - instanceId, - interfaceId, - vcpuId, - state, - group, - }) +}: { + title: string + query: string + startTime: Date + endTime: Date +}) { const { data: metrics } = useApiQuery('systemTimeseriesQuery', { body: { query } }) const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) + const unit = title.includes('Bytes') + ? 'Bytes' + : title.includes('Utilization') + ? '%' + : 'Count' + const isBytesChart = unit === 'Bytes' const isPercentChart = unit === '%' @@ -255,12 +212,28 @@ export function OxqlMetric({ return `${tickValue}${unitForTick}` } + const menuActions = useMemo( + () => [ + { + label: 'Copy query', + onActivate() { + window.navigator.clipboard.writeText(query) + }, + }, + ], + [query] + ) + return (
-

- {title}
{label}
- {!metrics && } -

+
+

+ {title}
{label}
+ {!metrics && } +

+ {/* TODO: show formatted string to user so they can see it before copying */} + +
}> Date: Tue, 4 Feb 2025 17:08:47 -0800 Subject: [PATCH 19/75] Move date selector up a level, using useContext --- app/components/RouteTabs.tsx | 2 +- .../instances/instance/tabs/MetricsTab.tsx | 46 ++++++++++++++----- .../tabs/MetricsTab/CpuMetricsTab.tsx | 38 ++------------- .../tabs/MetricsTab/DiskMetricsTab.tsx | 6 +-- .../tabs/MetricsTab/NetworkMetricsTab.tsx | 6 +-- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 8 ++-- 6 files changed, 49 insertions(+), 57 deletions(-) diff --git a/app/components/RouteTabs.tsx b/app/components/RouteTabs.tsx index 5a518efb80..7d3c4b40e6 100644 --- a/app/components/RouteTabs.tsx +++ b/app/components/RouteTabs.tsx @@ -41,7 +41,7 @@ export interface RouteTabsProps { sideTabs?: boolean } /** Tabbed views, controlling both the layout and functioning of tabs and the panel contents. - * For tabs on top of the panel, keep sideTabs as false. For tabs on the side, set sideTabs to true. + * sideTabs: Whether the tabs are displayed on the side of the panel. Default is false. */ export function RouteTabs({ children, fullWidth, sideTabs = false }: RouteTabsProps) { const wrapperClasses = sideTabs diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index b824302b23..5b1c6b09dd 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -6,24 +6,48 @@ * Copyright Oxide Computer Company */ +import { createContext, useContext, type ReactNode } from 'react' + +import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker' import { RouteTabs, Tab } from '~/components/RouteTabs' import { useInstanceSelector } from '~/hooks/use-params' import { pb } from '~/util/path-builder' +// useContext will need default values for startTime and endTime +const oneHourAgo = new Date() +oneHourAgo.setHours(oneHourAgo.getHours() - 1) +const startTime = oneHourAgo +const endTime = new Date() + +const MetricsContext = createContext<{ + startTime: Date + endTime: Date + dateTimeRangePicker: ReactNode +}>({ startTime, endTime, dateTimeRangePicker: <> }) + +export const useMetricsContext = () => useContext(MetricsContext) + export const MetricsTab = () => { const { project, instance } = useInstanceSelector() + + const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ + initialPreset: 'lastHour', + }) + // Find the in RouteSideTabs return ( - - - CPU - - - Disk - - - Network - - + + + + CPU + + + Disk + + + Network + + + ) } diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx index d331036517..d3aefa940a 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx @@ -13,15 +13,13 @@ * * Copyright Oxide Computer Company */ -import React, { useState } from 'react' import { type LoaderFunctionArgs } from 'react-router' import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' -import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' -import { Listbox } from '~/ui/lib/Listbox' +import { useMetricsContext } from '../MetricsTab' import { getOxqlQuery, MetricCollection, @@ -54,23 +52,8 @@ export function Component() { path: { instance }, query: { project }, }) - const ncpus = instanceData?.ncpus || 1 - const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ - initialPreset: 'lastHour', - }) - - const [cpuId, setCpuId] = useState('all') - - const cpuItems = [ - { label: 'All', value: 'all' }, - ...Array.from({ length: ncpus }, (_, i) => ({ - label: i.toString(), - value: i.toString(), - })), - ] - - const vcpuId = cpuId === 'all' ? undefined : cpuId + const { startTime, endTime, dateTimeRangePicker } = useMetricsContext() const getQuery = (metricName: OxqlVmMetricName, state?: OxqlVcpuState) => getOxqlQuery({ @@ -78,26 +61,13 @@ export function Component() { startTime, endTime, instanceId: instanceData.id, - vcpuId, state, - group: cpuId === 'all', + group: true, }) return ( <> - - { - setCpuId(val) - }} - /> - {dateTimeRangePicker} - + {dateTimeRangePicker} data?.items || [], [data]) - const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ - initialPreset: 'lastHour', - }) + const { startTime, endTime, dateTimeRangePicker } = useMetricsContext() // The fallback here is kind of silly — it is only invoked when there are no // disks, in which case we show the fallback UI and diskName is never used. We diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx index 1dd9cd3efb..6de0e40c04 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx @@ -11,11 +11,11 @@ import { type LoaderFunctionArgs } from 'react-router' import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' -import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { Listbox } from '~/ui/lib/Listbox' import { ALL_ISH } from '~/util/consts' +import { useMetricsContext } from '../MetricsTab' import { getOxqlQuery, MetricCollection, @@ -53,9 +53,7 @@ export function Component() { query: { project, instance, limit: ALL_ISH }, }) - const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ - initialPreset: 'lastHour', - }) + const { startTime, endTime, dateTimeRangePicker } = useMetricsContext() const networks = useMemo( () => [ diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 9a89523a97..ed84c99140 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -251,9 +251,11 @@ export function OxqlMetric({ ) } -export const MetricHeader = ({ children }: { children: React.ReactNode }) => ( -
{children}
-) +export const MetricHeader = ({ children }: { children: React.ReactNode }) => { + // If header has only one child, align it to the end of the container + const value = React.Children.toArray(children).length === 1 ? 'end' : 'between' + return
{children}
+} export const MetricCollection = ({ children }: { children: React.ReactNode }) => (
{children}
) From dd9605131bb95c32d6d748140a1e21a133ccb066 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 4 Feb 2025 20:50:21 -0800 Subject: [PATCH 20/75] Update routes in path-builder test --- .../instances/instance/tabs/MetricsTab.tsx | 2 +- .../__snapshots__/path-builder.spec.ts.snap | 78 +++++++++++++++++++ app/util/path-builder.spec.ts | 3 + 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 5b1c6b09dd..7c5c8607b5 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -34,7 +34,7 @@ export const MetricsTab = () => { initialPreset: 'lastHour', }) - // Find the in RouteSideTabs + // Find the relevant in RouteTabs return ( diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 7142b88c84..cb9a6aa2e8 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -127,6 +127,58 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances/i/connect", }, ], + "instanceCpuMetrics (/projects/p/instances/i/metrics/cpu)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Instances", + "path": "/projects/p/instances", + }, + { + "label": "i", + "path": "/projects/p/instances/i/storage", + }, + { + "label": "Metrics", + "path": "/projects/p/instances/i/metrics", + }, + { + "label": "CPU", + "path": "/projects/p/instances/i/metrics/cpu", + }, + ], + "instanceDiskMetrics (/projects/p/instances/i/metrics/disk)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Instances", + "path": "/projects/p/instances", + }, + { + "label": "i", + "path": "/projects/p/instances/i/storage", + }, + { + "label": "Metrics", + "path": "/projects/p/instances/i/metrics", + }, + { + "label": "Disk", + "path": "/projects/p/instances/i/metrics/disk", + }, + ], "instanceMetrics (/projects/p/instances/i/metrics)": [ { "label": "Projects", @@ -149,6 +201,32 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances/i/metrics", }, ], + "instanceNetworkMetrics (/projects/p/instances/i/metrics/network)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Instances", + "path": "/projects/p/instances", + }, + { + "label": "i", + "path": "/projects/p/instances/i/storage", + }, + { + "label": "Metrics", + "path": "/projects/p/instances/i/metrics", + }, + { + "label": "Network", + "path": "/projects/p/instances/i/metrics/network", + }, + ], "instanceNetworking (/projects/p/instances/i/networking)": [ { "label": "Projects", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index e81423bb4f..e463e6e304 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -48,7 +48,10 @@ test('path builder', () => { "floatingIpsNew": "/projects/p/floating-ips-new", "instance": "/projects/p/instances/i/storage", "instanceConnect": "/projects/p/instances/i/connect", + "instanceCpuMetrics": "/projects/p/instances/i/metrics/cpu", + "instanceDiskMetrics": "/projects/p/instances/i/metrics/disk", "instanceMetrics": "/projects/p/instances/i/metrics", + "instanceNetworkMetrics": "/projects/p/instances/i/metrics/network", "instanceNetworking": "/projects/p/instances/i/networking", "instanceStorage": "/projects/p/instances/i/storage", "instances": "/projects/p/instances", From 4ad427123c24806700932080d59dc5f471763561 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 5 Feb 2025 11:46:23 -0800 Subject: [PATCH 21/75] Small refactor to align approach to useState and dropdowns for network and disk metrics tabs --- .../tabs/MetricsTab/DiskMetricsTab.tsx | 30 +++++++++++-------- .../tabs/MetricsTab/NetworkMetricsTab.tsx | 13 ++++---- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx index 9cce402e9d..13a9a9eafb 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx @@ -57,7 +57,7 @@ export async function loader({ params }: LoaderFunctionArgs) { Component.displayName = 'DiskMetricsTab' export function Component() { const { project, instance } = useInstanceSelector() - const { data } = usePrefetchedApiQuery('instanceDiskList', { + const { data: diskData } = usePrefetchedApiQuery('instanceDiskList', { path: { instance }, query: { project }, }) @@ -66,18 +66,21 @@ export function Component() { query: { project }, }) const instanceId = instanceData?.id - const disks = useMemo(() => data?.items || [], [data]) + const disks = useMemo( + () => [ + { name: 'All disks', id: 'all' }, + ...diskData.items.map(({ name, id }) => ({ name, id })), + ], + [diskData] + ) const { startTime, endTime, dateTimeRangePicker } = useMetricsContext() // The fallback here is kind of silly — it is only invoked when there are no // disks, in which case we show the fallback UI and diskName is never used. We // only need to do it this way because hooks cannot be called conditionally. - const [diskId, setDiskId] = useState('all') - const diskItems = [ - { label: 'All', value: 'all' }, - ...disks.map(({ name, id }) => ({ label: name, value: id })), - ] + const [disk, setDisk] = useState(disks[0]) + const items = disks.map(({ name, id }) => ({ label: name, value: id })) if (disks.length === 0) { return ( @@ -97,8 +100,8 @@ export function Component() { startTime, endTime, attachedInstanceId: instanceId, - diskId: diskId === 'all' ? undefined : diskId, - group: diskId === 'all', + diskId: disk.id === 'all' ? undefined : disk.id, + group: disk.id === 'all', }) return ( @@ -108,10 +111,13 @@ export function Component() { className="w-64" aria-label="Choose disk" name="disk-name" - selected={diskId} - items={diskItems} + selected={disk.id} + items={items} onChange={(val) => { - setDiskId(val) + setDisk({ + name: disks.find((n) => n.id === val)?.name || 'All disks', + id: val, + }) }} /> {dateTimeRangePicker} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx index 6de0e40c04..a7adb6cc76 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx @@ -57,14 +57,14 @@ export function Component() { const networks = useMemo( () => [ - { name: 'All', id: 'all' }, + { name: 'All NICs', id: 'all' }, ...interfaceData.items.map(({ name, id }) => ({ name, id })), ], [interfaceData] ) - const [nic, setNic] = useState({ name: 'All', id: 'all' }) - const items = networks.map(({ name }) => ({ label: name, value: name })) + const [nic, setNic] = useState(networks[0]) + const items = networks.map(({ name, id }) => ({ label: name, value: id })) const getQuery = (metricName: OxqlNetworkMetricName) => getOxqlQuery({ @@ -83,10 +83,13 @@ export function Component() { className="w-64" aria-label="Choose disk" name="disk-name" - selected={nic.name} + selected={nic.id} items={items} onChange={(val) => { - setNic({ name: val, id: networks.find((n) => n.name === val)?.id || 'all' }) + setNic({ + name: networks.find((n) => n.id === val)?.name || 'All NICs', + id: val, + }) }} /> {dateTimeRangePicker} From 8d80808b9e88454bbe8fc5cc6a67280f5fa14281 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 6 Feb 2025 14:34:01 -0800 Subject: [PATCH 22/75] Add static values for metrics for testing and mock service worker --- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 32 ++- mock-api/msw/handlers.ts | 6 +- mock-api/msw/util.ts | 25 ++ mock-api/oxql-metrics.ts | 241 ++++++++++++++++++ 4 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 mock-api/oxql-metrics.ts diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index ed84c99140..cf037f15a2 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -61,6 +61,31 @@ export type OxqlMetricName = OxqlDiskMetricName | OxqlVmMetricName | OxqlNetwork export type OxqlVcpuState = 'run' | 'idle' | 'waiting' | 'emulation' +// This is to avoid some TS casting, but feels a bit sketchy to me +export const isValidOxqlMetricName = (name: string): name is OxqlMetricName => { + const validNames = [ + 'virtual_disk:bytes_read', + 'virtual_disk:bytes_written', + 'virtual_disk:failed_flushes', + 'virtual_disk:failed_reads', + 'virtual_disk:failed_writes', + 'virtual_disk:flushes', + 'virtual_disk:io_latency', + 'virtual_disk:io_size', + 'virtual_disk:reads', + 'virtual_disk:writes', + 'virtual_machine:vcpu_usage', + 'instance_network_interface:bytes_received', + 'instance_network_interface:bytes_sent', + 'instance_network_interface:errors_received', + 'instance_network_interface:errors_sent', + 'instance_network_interface:packets_dropped', + 'instance_network_interface:packets_received', + 'instance_network_interface:packets_sent', + ] + return !!validNames.includes(name) +} + /** determine the mean window for the given time range */ const getMeanWindow = (start: Date, end: Date) => { const duration = getDurationMinutes({ start, end }) @@ -143,7 +168,12 @@ export function OxqlMetric({ }) { const { data: metrics } = useApiQuery('systemTimeseriesQuery', { body: { query } }) const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) - + // console.log('title', title, 'metrics', metrics) + // console.log( + // Object.values(chartData) + // .map((i) => i.value) + // .join(', ') + // ) const unit = title.includes('Bytes') ? 'Bytes' : title.includes('Utilization') diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 376ad49714..323b09bee0 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -47,6 +47,7 @@ import { forbiddenErr, getStartAndEndTime, handleMetrics, + handleOxqlMetrics, ipInAnyRange, ipRangeLen, NotImplemented, @@ -1539,6 +1540,10 @@ export const handlers = makeHandlers({ requireFleetViewer(params.cookies) return handleMetrics(params) }, + systemTimeseriesQuery(params) { + requireFleetViewer(params.cookies) + return handleOxqlMetrics(params.body) + }, siloMetric: handleMetrics, // Misc endpoints we're not using yet in the console @@ -1611,7 +1616,6 @@ export const handlers = makeHandlers({ switchView: NotImplemented, systemPolicyUpdate: NotImplemented, systemQuotasList: NotImplemented, - systemTimeseriesQuery: NotImplemented, systemTimeseriesSchemaList: NotImplemented, timeseriesQuery: NotImplemented, userBuiltinList: NotImplemented, diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 82abab7d24..edb26f88ee 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -9,6 +9,7 @@ import { differenceInSeconds, subHours } from 'date-fns' // Works without the .js for dev server and prod build in MSW mode, but // playwright wants the .js. No idea why, let's just add the .js. import { IPv4, IPv6 } from 'ip-num/IPNumber.js' +import { getMockOxqlInstanceData } from 'mock-api/oxql-metrics' import { FLEET_ID, @@ -21,10 +22,15 @@ import { type Sled, type SystemMetricName, type SystemMetricQueryParams, + type TimeseriesQuery, type User, } from '@oxide/api' import { json, type Json } from '~/api/__generated__/msw-handlers' +import { + isValidOxqlMetricName, + type OxqlVcpuState, +} from '~/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric' import { parseIp } from '~/util/ip' import { GiB, TiB } from '~/util/units' @@ -413,3 +419,22 @@ export function updateDesc( resource.description = update.description } } + +// The metric name is the second word in the query string +const getMetricNameFromQuery = (query: string) => query.split(' ')[1] + +// The state value is the string in quotes after 'state == ' in the query string +// It might not be present in the string +const getCpuStateFromQuery = (query: string): OxqlVcpuState | undefined => { + const stateRegex = /state\s*==\s*"([^"]+)"/ + const match = query.match(stateRegex) + return match ? (match[1] as OxqlVcpuState) : undefined +} + +export function handleOxqlMetrics({ query }: TimeseriesQuery) { + const metricName = getMetricNameFromQuery(query) + const stateValue = getCpuStateFromQuery(query) + return isValidOxqlMetricName(metricName) + ? getMockOxqlInstanceData(metricName, stateValue) + : [] +} diff --git a/mock-api/oxql-metrics.ts b/mock-api/oxql-metrics.ts new file mode 100644 index 0000000000..b54b0a3d53 --- /dev/null +++ b/mock-api/oxql-metrics.ts @@ -0,0 +1,241 @@ +/* + * 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 { OxqlQueryResult } from '~/api' +import type { + OxqlMetricName, + OxqlVcpuState, +} from '~/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric' + +import { instances } from './instance' +import type { Json } from './json-type' + +const oneHourAgo = new Date() +oneHourAgo.setHours(oneHourAgo.getHours() - 1) +const now = new Date() +const timestamps: string[] = [] +// Generate timestamps for the last hour +for (let i = oneHourAgo.getTime(); i < now.getTime(); i += 60000) { + timestamps.push(new Date(i).toISOString()) +} + +type ValueType = { [key in OxqlMetricName]: number[] } + +// We use static numbers for these mocks so that the tests will be deterministic +const mockOxqlValues: ValueType = { + 'instance_network_interface:bytes_received': [ + 19589220.623748355, 24553203.242848497, 89094997.39982976, 88911367.62801822, + 89564328.13052855, 36132222.97561263, 35083093.4423788, 45992685.125303574, + 52667932.85079793, 53255578.251586914, 50642139.72662767, 53016799.10056307, + 53453471.83148456, 17812773.001480814, 17652089.511599142, 17400992.998094894, + 23783344.52673782, 87510120.57284649, 88415586.70307633, 89809422.42279987, + 36294110.18462029, 35816494.883518465, 50898547.59673651, 52888621.42617168, + 52022765.40817507, 51545607.28884054, 50940589.28290987, 45175697.87988242, + 18574000.425671637, 18727470.05461767, 19270084.616364624, 19699874.56110518, + 89088859.22025315, 88411150.33482315, 88127023.98263156, 42293581.61247408, + 38734001.65471564, 49297923.93599405, 51553782.50758036, 51244054.148059346, + 51728001.475473344, 51534232.55254867, 51047390.06649641, 10061318.072499434, + 9044264.254223755, 9134684.412748203, 9050554.118707722, 9035379.9069059, + 9110098.444441319, 9041842.197785899, 9035280.981180245, 9020950.700432273, + 8977950.29915887, 27381863.86000137, 89824944.62147991, 88766745.59432606, + 79294879.24604356, 16279008.5071202, 16297706.567097535, 14187828.016217524, + ], + 'instance_network_interface:bytes_sent': [ + 61967407.4690769, 62659750.37321169, 94615638.21567552, 94830488.5349947, + 95073373.66159962, 114515161.09650381, 114513480.4104714, 67598551.7442919, + 60878378.37459309, 61284428.313176505, 58812438.49990403, 61108040.21864518, + 61438901.75645653, 61604633.377452575, 65329473.79495335, 64454685.17365676, + 66649624.01594997, 93106698.60171998, 94118093.74878043, 95640017.17327368, + 127418480.32586104, 125125303.39594136, 60007588.507188044, 60696339.00900922, + 59533389.07721457, 59727164.03291828, 59385691.85417155, 56385978.939908296, + 58482326.04577281, 58931768.40465623, 60684771.505763024, 57382770.31131127, + 95175199.9700111, 94054057.74380775, 93627971.79398167, 114539414.03000657, + 122141655.90741396, 65057155.0238452, 59508603.56693583, 58810729.534921914, + 60332025.83279615, 59443444.112106115, 61066382.357687324, 889838412.7357846, + 919649082.456614, 927770089.9058081, 916228327.514903, 917966272.0036994, + 920090782.1096474, 917910750.6941862, 920093111.3043944, 916360373.2879275, + 913486103.4169887, 701279770.7176225, 94828852.77350871, 94669806.35246424, + 264210805.15261075, 1652357411.4296043, 1652078679.222105, 1441081863.6751692, + ], + 'instance_network_interface:errors_received': [], + 'instance_network_interface:errors_sent': [], + 'instance_network_interface:packets_dropped': [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, + ], + 'instance_network_interface:packets_received': [ + 51626.338736467435, 56229.94338102455, 128282.11254611396, 128564.80636165978, + 128989.41383010168, 94510.81161989772, 93665.31941652698, 81383.75535723146, + 85221.6209098, 85822.6271386839, 81979.71993495703, 85474.23160386541, + 85925.16400885995, 47190.750325350964, 48573.93626348796, 47820.90911392931, + 54921.4389477908, 126219.29470494478, 127521.29665159812, 129587.02976700662, + 96154.70608651743, 94899.91699370155, 83202.28709218279, 85022.07185646542, + 83143.82839526674, 83634.04637295769, 82936.67722227327, 75158.50165989484, + 46606.838817035314, 46982.238183297195, 48419.786227343524, 47103.65158021678, + 128437.39090478497, 127591.93090182958, 127012.8207799399, 96573.31083839823, + 97082.5916684147, 83120.52587402581, 83263.52753849649, 82238.52685222514, + 84450.35016762071, 83067.65700170219, 82959.56398332072, 43759.59507033672, + 42923.47803011315, 43431.627985928084, 43069.7969693797, 43059.457017705565, + 43602.54319799071, 42897.2872618157, 42942.59379466726, 43066.817156663514, + 42566.8082280084, 61438.10807642717, 128724.06662712415, 128398.5929964637, + 119640.40559043291, 77724.4463363611, 77964.54792540593, 67988.15593674716, + ], + 'instance_network_interface:packets_sent': [ + 52137.86783412454, 60154.82944915184, 170294.14156959817, 170629.15143145437, + 171299.78746072564, 95653.37095286128, 94097.58449760282, 99751.44401011993, + 110115.72250064427, 111057.99080788075, 106270.53166977571, 110597.50442558025, + 111123.32382798912, 48025.948755741374, 48572.51133205648, 47830.51582947457, + 58746.02990920361, 167518.38571626123, 169424.45152829404, 172155.99300919194, + 97316.28791466833, 96245.79297178097, 107429.92883606884, 109784.39976567338, + 107553.39026304561, 108101.21920026942, 107437.55840575877, 95608.73106548574, + 46657.90593104405, 46993.78703923178, 48436.42161541242, 48224.26619269178, + 170430.61313726509, 169333.80006289986, 168692.38480195502, 100688.99025787185, + 96994.62254120565, 104651.51085988498, 107656.18505288263, 106218.07389709905, + 109280.36747812887, 107509.04304010724, 108436.27060103651, 599505.2441023788, + 617622.3592543039, 623021.7338257399, 615594.7135637688, 616645.60095252, + 618255.2460245651, 616543.6810593881, 617902.5773599739, 615661.570085495, + 613548.5849304599, 495794.7258951948, 171019.15081407645, 170315.39920711354, + 270113.18497460126, 1109817.5984217522, 1109609.9278124508, 967843.945139089, + ], + // Adding these in once dogfood is updated and I can access it again + 'virtual_disk:bytes_read': [], + 'virtual_disk:bytes_written': [], + 'virtual_disk:failed_flushes': [], + 'virtual_disk:failed_reads': [], + 'virtual_disk:failed_writes': [], + 'virtual_disk:flushes': [], + 'virtual_disk:io_latency': [], + 'virtual_disk:io_size': [], + 'virtual_disk:reads': [], + 'virtual_disk:writes': [], + 'virtual_machine:vcpu_usage': [ + 19999244176.55652, 19999262821.770588, 19999247268.325813, 19999256098.18279, + 19998439646.11277, 20000052732.75744, 19999249051.297325, 19999217827.11486, + 19999266289.63109, 19999241540.31689, 19999249894.71948, 19999242798.001175, + 19999280239.558437, 19994941581.138977, 20003329156.62153, 19999072571.09516, + 19999655903.167236, 19999252992.10523, 19999253748.71165, 19992247707.50293, + 20006248335.620003, 19999247240.07144, 19999273682.889553, 19999218798.17299, + 19999239203.481194, 19999249419.079346, 19999201320.508316, 19999007439.8747, + 19998831011.520527, 19985625078.743458, 20012870767.05469, 19999943122.09549, + 19999258008.962036, 19999235779.196056, 19999261171.498962, 19999234237.071968, + 19999276401.99784, 19999251043.36012, 19999255851.52835, 21817322095.449245, + 19999247080.304607, 19999239956.749664, 19997819209.46573, 20000649872.910446, + 19999244996.832375, 19998339267.189194, 20000104884.53485, 19999024248.62954, + 19998728205.98129, 19999177172.982388, 20000001479.01578, 19998692258.842594, + 19999555462.049706, 19999706760.684425, 19999254285.7761, 19999252824.529217, + 19999198522.691017, 19999210125.45697, 19999277909.5477, 18486165951.591064, + ], +} +const mockOxqlVcpuStateValues: { [key in OxqlVcpuState]: number[] } = { + emulation: [ + 3470554833.148136, 3794070909.515587, 7484378129.162313, 7499079902.563132, + 7338835189.397617, 5769255305.989975, 5793666851.660778, 6119828972.189391, + 6192838967.607052, 6273090517.736208, 6261136594.729165, 6279690022.892301, + 6150635429.510835, 2617373465.0024853, 2625742373.0438843, 2824193724.714241, + 3489569857.2487335, 7492246700.321343, 7389143259.273447, 7412789197.287161, + 5344290281.149027, 5348811353.956265, 6214808219.939896, 6142813148.398594, + 6263147833.517369, 6140102356.026721, 6097867882.707549, 5512181842.324122, + 2675175006.969673, 2740554097.2691655, 2935853047.772752, 2952071677.9638834, + 7546253339.280237, 7486359660.612784, 7463726017.643381, 5444178394.571739, + 5458803598.9091625, 6069021988.3498335, 6311119065.385571, 6608265832.110596, + 6140054386.472407, 6156116158.796333, 5984356298.189145, 2600359658.3245726, + 2584828431.2729354, 2612574521.5035424, 2574626267.071462, 2575424351.817318, + 2575860329.041943, 2567504242.071083, 2556404696.4707775, 2584769411.916215, + 2573581252.249218, 3875742582.642765, 7480290855.256716, 7482930333.717411, + 6712204742.082178, 4122376428.7989264, 4152372065.6415825, 3863464457.366618, + ], + run: [ + 4619676122.735266, 4857545477.342192, 8402544727.918413, 8469408271.742976, + 8397709487.587893, 8235421457.76976, 8120922799.543821, 6272452681.033177, + 6267237148.905216, 6405902623.449686, 6235219717.385138, 6398062430.261708, + 6329545013.406312, 3743057749.562631, 3866830299.056041, 3877936562.542739, + 4425697223.024854, 8331231027.144771, 8731238036.810953, 8436275940.988695, + 7724787180.289036, 7421040627.989263, 6207097957.785152, 6269698371.290064, + 6233708559.353093, 6245099441.99803, 6206635848.447873, 5671648062.870315, + 3880305795.215807, 3875751695.230483, 3988389511.2728863, 3968133898.58712, + 8460653600.512621, 8482934157.833692, 8470281707.11432, 7716246696.863734, + 7879121504.416253, 6175956981.387113, 6274726366.106583, 6687937195.180018, + 6304116079.054577, 6228856601.421579, 6197732229.89284, 4515917281.7321205, + 4526156837.634188, 4586949674.211767, 4533778060.940317, 4540982515.3752165, + 4509970799.179172, 4545668948.312252, 4558275564.279504, 4557736508.337857, + 4546925189.508583, 5446687815.952005, 8393488265.14876, 8495614671.511756, + 8318175268.861474, 8115602131.165577, 8195384483.89658, 7630602363.416914, + ], + idle: [ + 11890486373.736158, 11327336534.2248, 4072236015.299429, 3990868655.143588, + 4222573581.249241, 5962618572.6317425, 6052579902.475908, 7579872335.1262665, + 7511465726.918255, 7291900935.990071, 7476049526.072393, 7293334638.012905, + 7491397018.306957, 13619777498.565498, 13495512553.987572, 13281072225.49385, + 12065723282.259262, 4135925261.2616005, 3838889582.975318, 4103088241.392046, + 6904574232.112017, 7198040692.687229, 7550246841.704985, 7559108767.725425, + 7475454104.796597, 7586948506.819914, 7667447990.534256, 8790943969.082865, + 13427888693.820496, 13353587097.462336, 13072393058.06295, 13063295807.40974, + 3952236740.057042, 3988310356.962084, 4024368551.1408396, 6806539447.657919, + 6629148471.374325, 7727269240.680851, 7386056709.533335, 8490705357.374731, + 7527818681.865936, 7586906151.716732, 7788412019.6819935, 12875297306.728258, + 12879240685.46618, 12789932802.353428, 12882754469.322874, 12873708824.54279, + 12903908469.721508, 12877224686.357244, 12876486792.841892, 12847143157.459385, + 12870118909.918638, 10660224475.817593, 4085312757.9752684, 3980043434.9097376, + 4933929614.263907, 7748255370.657822, 7637946572.8268585, 7055896926.060759, + ], + waiting: [ + 18526846.9369582, 20309900.6880109, 40088395.94565911, 39899268.73309269, + 39321387.87802228, 32757396.365961064, 32079497.61681621, 27063838.766029697, + 27724446.200565398, 28347463.140924186, 26844056.532780677, 28155706.8342591, + 27702778.33433362, 14732868.008363519, 15243930.534035914, 15870058.344331108, + 18665540.634387445, 39850003.37751644, 39982869.651927434, 40094327.83502642, + 32596642.069917694, 31354565.438681442, 27120663.45952057, 27598510.758910026, + 26928705.814134356, 27099114.234683096, 27249598.8186354, 24233565.597403165, + 15461515.514547125, 15732188.78147373, 16235149.946102526, 16441738.134746596, + 40114329.11212887, 41631603.78749817, 40884895.6004194, 32269697.97857898, + 32202827.29810212, 27002832.942324653, 27353710.50286381, 30413710.78390165, + 27257932.91168526, 27361044.815017074, 27318661.701747734, 9075626.125494912, + 9019042.45907371, 8882269.12045705, 8946087.20019712, 8908556.894216128, + 8988608.038667796, 8779296.241807759, 8834425.423605012, 9043181.129137222, + 8930110.373266453, 17051886.27206035, 40162407.395353094, 40664384.3903117, + 34888897.48345601, 12976194.834645452, 13574787.182678783, 12451431.648927957, + ], +} + +export const getMockOxqlInstanceData = ( + name: OxqlMetricName, + state?: OxqlVcpuState +): Json => { + const values = state ? mockOxqlVcpuStateValues[state] : mockOxqlValues[name] + return { + tables: [ + { + name: name, + timeseries: { + // This is a fake metric ID + '10607783231231666598': { + fields: { + instanceId: { + type: 'uuid', + value: instances[0].id, // project: mock-project; instance: db1 + }, + }, + points: { + start_times: [], + timestamps: timestamps, + values: [ + { + values: { + type: 'double', + values: values, + }, + metric_type: 'gauge', + }, + ], + }, + }, + }, + }, + ], + } +} From 51ef360e05a66907b556c012c7e7674f696c7721 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 6 Feb 2025 16:37:52 -0800 Subject: [PATCH 23/75] Removes TS guard that was a bit onerous; relying on casting now, though, which isn't great --- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 25 ------------------- mock-api/msw/util.ts | 11 ++++---- 2 files changed, 5 insertions(+), 31 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index cf037f15a2..1e0e6c65f4 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -61,31 +61,6 @@ export type OxqlMetricName = OxqlDiskMetricName | OxqlVmMetricName | OxqlNetwork export type OxqlVcpuState = 'run' | 'idle' | 'waiting' | 'emulation' -// This is to avoid some TS casting, but feels a bit sketchy to me -export const isValidOxqlMetricName = (name: string): name is OxqlMetricName => { - const validNames = [ - 'virtual_disk:bytes_read', - 'virtual_disk:bytes_written', - 'virtual_disk:failed_flushes', - 'virtual_disk:failed_reads', - 'virtual_disk:failed_writes', - 'virtual_disk:flushes', - 'virtual_disk:io_latency', - 'virtual_disk:io_size', - 'virtual_disk:reads', - 'virtual_disk:writes', - 'virtual_machine:vcpu_usage', - 'instance_network_interface:bytes_received', - 'instance_network_interface:bytes_sent', - 'instance_network_interface:errors_received', - 'instance_network_interface:errors_sent', - 'instance_network_interface:packets_dropped', - 'instance_network_interface:packets_received', - 'instance_network_interface:packets_sent', - ] - return !!validNames.includes(name) -} - /** determine the mean window for the given time range */ const getMeanWindow = (start: Date, end: Date) => { const duration = getDurationMinutes({ start, end }) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index edb26f88ee..3c899ea87b 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -18,6 +18,7 @@ import { totalCapacity, type DiskCreate, type IpRange, + type OxqlQueryResult, type RoleKey, type Sled, type SystemMetricName, @@ -28,7 +29,7 @@ import { import { json, type Json } from '~/api/__generated__/msw-handlers' import { - isValidOxqlMetricName, + type OxqlNetworkMetricName, type OxqlVcpuState, } from '~/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric' import { parseIp } from '~/util/ip' @@ -431,10 +432,8 @@ const getCpuStateFromQuery = (query: string): OxqlVcpuState | undefined => { return match ? (match[1] as OxqlVcpuState) : undefined } -export function handleOxqlMetrics({ query }: TimeseriesQuery) { - const metricName = getMetricNameFromQuery(query) +export function handleOxqlMetrics({ query }: TimeseriesQuery): Json { + const metricName = getMetricNameFromQuery(query) as OxqlNetworkMetricName const stateValue = getCpuStateFromQuery(query) - return isValidOxqlMetricName(metricName) - ? getMockOxqlInstanceData(metricName, stateValue) - : [] + return getMockOxqlInstanceData(metricName, stateValue) } From 0ad871e6a8439b002690c7bcac390ee64aebb411 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 7 Feb 2025 09:23:05 -0800 Subject: [PATCH 24/75] Updated mock data for disks --- mock-api/oxql-metrics.ts | 93 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 7 deletions(-) diff --git a/mock-api/oxql-metrics.ts b/mock-api/oxql-metrics.ts index b54b0a3d53..9029be71c3 100644 --- a/mock-api/oxql-metrics.ts +++ b/mock-api/oxql-metrics.ts @@ -25,7 +25,6 @@ for (let i = oneHourAgo.getTime(); i < now.getTime(); i += 60000) { type ValueType = { [key in OxqlMetricName]: number[] } -// We use static numbers for these mocks so that the tests will be deterministic const mockOxqlValues: ValueType = { 'instance_network_interface:bytes_received': [ 19589220.623748355, 24553203.242848497, 89094997.39982976, 88911367.62801822, @@ -102,17 +101,97 @@ const mockOxqlValues: ValueType = { 613548.5849304599, 495794.7258951948, 171019.15081407645, 170315.39920711354, 270113.18497460126, 1109817.5984217522, 1109609.9278124508, 967843.945139089, ], - // Adding these in once dogfood is updated and I can access it again - 'virtual_disk:bytes_read': [], - 'virtual_disk:bytes_written': [], + // Current bytes_read and reads aren't super helpful; find a better one + 'virtual_disk:bytes_read': [ + 134668408.94260278, 134114600.98348178, 134708158.30609903, 134746207.08323193, + 134757768.45246968, 135554970.52717522, 134400831.89327943, 134491792.51736572, + 134338108.90250394, 135251533.05339137, 134282853.1940323, 134601751.89569664, + 134120627.28247309, 135038025.54527146, 134611932.4494742, 135500486.29084927, + 134875807.5854877, 134621603.81059673, 134738692.42169353, 134534877.9584674, + 134424164.3767796, 134465893.49951467, 134757304.5764649, 135374966.46820828, + 134186580.68346651, 134756565.30676734, 135074451.95680845, 134506691.99236247, + 134657886.35695586, 134390897.60452515, 135226063.33329535, 135490924.9492251, + 135093288.02499872, 135100378.1836631, 134114153.76629321, 135362836.38550094, + 134744217.06305423, 134451662.97109315, 134829077.22112235, 134418157.3689324, + 134916146.93966413, 134440891.3867167, 134787176.5549223, 134660350.45820063, + 133810022.8238135, 134168764.48050334, 135189035.65126267, 134021049.90450476, + 134903916.71328273, 134656718.33510137, 134943815.04202753, 134797406.3526269, + 134497553.75692537, 134920089.16782558, 133689536.22922617, 134521160.31905597, + 134616922.7392796, 134751380.6347449, 134657054.91462126, 127405348.82547487, + ], + 'virtual_disk:bytes_written': [ + 330917410.0482145, 329709105.37664956, 331010610.525867, 331211447.18977416, + 331261458.46301866, 333203011.6858776, 330456579.5368512, 330592407.1195608, + 330065103.9267523, 332386493.7934591, 330080544.3288696, 330856509.54466516, + 329583271.1448502, 331910771.9026434, 330734831.34536916, 333011595.5327931, + 331531620.2096405, 331001777.99884146, 331080665.690497, 330676206.2135374, + 330371180.3795449, 330571852.06149286, 331177327.99516386, 332699589.00015104, + 329821302.64082146, 331333622.32089734, 331917511.87458974, 330670793.11512905, + 330897663.28819346, 330432756.5197515, 332382792.14725393, 332948541.27419853, + 332114288.1251069, 332207939.69306546, 329606223.2347984, 332657542.783183, + 331140266.22763973, 330498134.19539064, 331298446.3134648, 330326704.5539919, + 331676323.2274062, 330452510.56756794, 331234773.76926625, 330999989.46997416, + 328842918.9281494, 329990105.5430548, 332240433.71217155, 329266790.9013924, + 331503173.65867263, 331081244.1591726, 331573649.42449266, 331311794.6993156, + 330621841.69141155, 331641719.1576536, 328762769.94054705, 330623704.34390384, + 330847001.6152951, 331134733.01985526, 331042258.9105762, 313118482.8823368, + ], 'virtual_disk:failed_flushes': [], 'virtual_disk:failed_reads': [], 'virtual_disk:failed_writes': [], - 'virtual_disk:flushes': [], + 'virtual_disk:flushes': [ + 2.5656143915805463, 1.9999135844522231, 1.9999446704324797, 1.9999073598971984, + 2.1666111719868897, 2.4999294767917375, 1.9999226151786687, 1.999938312586908, + 1.999915522549354, 1.9999275117618545, 2.3332884612693365, 1.9999124233153838, + 1.999944451971838, 1.9999203504646035, 2.166586557020626, 2.4999415493091197, + 1.999913840239971, 1.999914091891102, 1.9999696801143712, 1.9999003696836775, + 2.3332789978652286, 1.9999212082005924, 1.9999288190914821, 1.9999268048571393, + 2.1666043432612048, 2.3332758513205545, 1.9999206482596599, 1.9999187185712357, + 1.9999448261708366, 1.9999289269036664, 2.16658295228307, 1.9999438917460528, + 1.9999163542044818, 1.9999354273728318, 2.1665857733228306, 2.499940138876416, + 1.9999269799553812, 1.9999326381486526, 1.9999066832216723, 1.9999346044578068, + 2.1666116384639307, 1.9999251699281135, 1.999935875819479, 1.9999353427672795, + 2.166590416093396, 2.158013910180325, 2.3418311421693914, 1.9999320742404887, + 1.99992307542628, 1.9999462544352449, 2.104945905608691, 2.2282473403754888, + 1.9999123729582473, 1.9999424608149798, 2.1665833832461114, 2.156921598533039, + 2.5096092476281466, 1.9999174120513497, 1.947702169825175, 1.7811086517690107, + ], 'virtual_disk:io_latency': [], 'virtual_disk:io_size': [], - 'virtual_disk:reads': [], - 'virtual_disk:writes': [], + 'virtual_disk:reads': [ + 32875.24156785288, 32742.822328425584, 32887.73594062226, 32897.01974676766, + 32899.84724796665, 33094.47384514138, 32812.70485366211, 32834.916712611426, + 32797.39635816606, 33020.40179708033, 32783.878124461684, 32861.77521851955, + 32744.281992359556, 32968.260751779875, 32864.23786157581, 33081.17527008203, + 32928.69283690152, 32866.58995986923, 32895.17448355364, 32845.429148303105, + 32818.40214316456, 32828.58538573866, 32899.731331754025, 33050.526570446236, + 32760.398000638772, 32899.54913489171, 32977.16272905804, 32838.54751554444, + 32875.460840451786, 32810.27714114574, 33014.17444085401, 33078.83922496485, + 32981.76696360247, 32983.48461803678, 32742.71720367538, 33047.564057713025, + 32896.53737696453, 32825.10937564101, 32917.25798549699, 32816.93647036122, + 32938.520033769215, 32822.474496116076, 32907.021717779, 32876.0623673061, + 32668.46702400371, 32756.04290102374, 33005.1346861714, 32719.98266455284, + 32935.52571082768, 32875.18333304324, 32945.261869929476, 32909.522866100415, + 32836.316731826875, 32939.4800212177, 32639.043333491893, 32842.07741645839, + 32865.460697366696, 32898.286514038606, 32875.25480902649, 31104.79807562703, + ], + 'virtual_disk:writes': [ + 80779.20034056324, 80488.39802006751, 80805.80225310395, 80855.15821565613, + 80864.79880246993, 81337.46751659793, 80670.72338169033, 80703.19837050632, + 80575.48791844303, 81141.19990943027, 80578.06175089431, 80766.87480048476, + 80458.1290404043, 81025.74988033365, 80736.79941004828, 81292.50035163287, + 80933.50479856148, 80802.81559906728, 80822.73100302315, 80724.33802652155, + 80648.69871675129, 80698.66854576267, 80847.01468567258, 81216.99268513004, + 80513.73959795143, 80883.5923126427, 81027.21407677184, 80722.84066884329, + 80777.89346683757, 80664.96049183104, 81140.42624987042, 81279.5057398679, + 81074.68704853921, 81098.17386192539, 80461.52192157002, 81205.76385295736, + 80838.0203641813, 80680.73319761816, 80875.17961103571, 80638.83573602898, + 80968.38368631831, 80669.89293196103, 80860.46783615886, 80802.87760515751, + 80274.85330533002, 80553.90436153534, 81106.55873376083, 80380.39855063528, + 80926.057056781, 80823.21953454925, 80942.22206587721, 80879.53974235548, + 80711.39311776291, 80959.22322515622, 80255.65776427352, 80710.42477501076, + 80762.95003772335, 80835.45706264429, 80813.69749658779, 76437.65290335608, + ], 'virtual_machine:vcpu_usage': [ 19999244176.55652, 19999262821.770588, 19999247268.325813, 19999256098.18279, 19998439646.11277, 20000052732.75744, 19999249051.297325, 19999217827.11486, From 6fd2397bf5ad16fe433aa2be67e13288eb4e4042 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 7 Feb 2025 14:56:47 -0800 Subject: [PATCH 25/75] small refactor before integrating Ben's PR --- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 1e0e6c65f4..4071088363 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -24,15 +24,8 @@ const TimeSeriesChart = React.lazy(() => import('~/components/TimeSeriesChart')) /** convert to UTC and return the timezone-free format required by OxQL */ const oxqlTimestamp = (date: Date) => date.toISOString().replace('Z', '') -export function getCycleCount(num: number, base: number) { - let cycleCount = 0 - let transformedValue = num - while (transformedValue > base) { - transformedValue = transformedValue / base - cycleCount++ - } - return cycleCount -} +export const getCycleCount = (num: number, base: number) => + Math.floor(Math.log(num) / Math.log(base)) export type OxqlDiskMetricName = | 'virtual_disk:bytes_read' @@ -211,7 +204,11 @@ export function OxqlMetric({ if (isPercentChart) { return `${val}%` } - const tickValue = val / divisor + const tickValue = val.toLocaleString(undefined, { + maximumSignificantDigits: 3, + maximumFractionDigits: 0, + minimumFractionDigits: 0, + }) const countUnits = ['', 'k', 'M', 'B', 'T'] const unitForTick = countUnits[cycleCount] return `${tickValue}${unitForTick}` From c673784a8bc75e838a2de6603d942846b616ee27 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 11 Feb 2025 07:47:28 -0800 Subject: [PATCH 26/75] Refactoring chart logic --- .../tabs/MetricsTab/OxqlMetric.spec.ts | 56 +++- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 281 ++++++++++-------- 2 files changed, 204 insertions(+), 133 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts index a083425ce0..a3729d2d75 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts @@ -7,22 +7,46 @@ */ import { expect, test } from 'vitest' -import { getCycleCount } from './OxqlMetric' +import { getOrderOfMagnitude, getUnit } from './OxqlMetric' -test('getCycleCount', () => { - expect(getCycleCount(5, 1000)).toEqual(0) - expect(getCycleCount(1000, 1000)).toEqual(0) - expect(getCycleCount(1001, 1000)).toEqual(1) - expect(getCycleCount(10 ** 6, 1000)).toEqual(1) - expect(getCycleCount(10 ** 6 + 1, 1000)).toEqual(2) - expect(getCycleCount(10 ** 9, 1000)).toEqual(2) - expect(getCycleCount(10 ** 9 + 1, 1000)).toEqual(3) +test('getOrderOfMagnitude', () => { + expect(getOrderOfMagnitude(5, 1000)).toEqual(0) + expect(getOrderOfMagnitude(1000, 1000)).toEqual(1) + expect(getOrderOfMagnitude(1001, 1000)).toEqual(1) + expect(getOrderOfMagnitude(10 ** 6, 1000)).toEqual(2) + expect(getOrderOfMagnitude(10 ** 6 + 1, 1000)).toEqual(2) + expect(getOrderOfMagnitude(10 ** 9, 1000)).toEqual(3) + expect(getOrderOfMagnitude(10 ** 9 + 1, 1000)).toEqual(3) - expect(getCycleCount(5, 1024)).toEqual(0) - expect(getCycleCount(1024, 1024)).toEqual(0) - expect(getCycleCount(1025, 1024)).toEqual(1) - expect(getCycleCount(2 ** 20, 1024)).toEqual(1) - expect(getCycleCount(2 ** 20 + 1, 1024)).toEqual(2) - expect(getCycleCount(2 ** 30, 1024)).toEqual(2) - expect(getCycleCount(2 ** 30 + 1, 1024)).toEqual(3) + // Bytes + expect(getOrderOfMagnitude(5, 1024)).toEqual(0) + // KiBs + expect(getOrderOfMagnitude(1024, 1024)).toEqual(1) + expect(getOrderOfMagnitude(1025, 1024)).toEqual(1) + expect(getOrderOfMagnitude(2 ** 20 - 1, 1024)).toEqual(1) + // MiBs + expect(getOrderOfMagnitude(2 ** 20, 1024)).toEqual(2) + expect(getOrderOfMagnitude(2 ** 20 + 1, 1024)).toEqual(2) + expect(getOrderOfMagnitude(2 ** 30 - 1, 1024)).toEqual(2) + // GiBs + expect(getOrderOfMagnitude(2 ** 30, 1024)).toEqual(3) + expect(getOrderOfMagnitude(2 ** 30 + 1, 1024)).toEqual(3) +}) + +// test('yAxisLabelForCountChart', () => { +// expect(yAxisLabelForCountChart(5, 0)).toEqual('5') +// expect(yAxisLabelForCountChart(1000, 1)).toEqual('1k') +// expect(yAxisLabelForCountChart(1001, 1)).toEqual('1k') +// expect(yAxisLabelForCountChart(10 ** 6, 2)).toEqual('1M') +// expect(yAxisLabelForCountChart(10 ** 6 + 1, 2)).toEqual('1M') +// expect(yAxisLabelForCountChart(10 ** 9, 3)).toEqual('1B') +// expect(yAxisLabelForCountChart(10 ** 9 + 1, 3)).toEqual('1B') +// expect(yAxisLabelForCountChart(10 ** 12, 4)).toEqual('1T') +// expect(yAxisLabelForCountChart(10 ** 12 + 1, 4)).toEqual('1T') +// }) + +test('getUnit', () => { + expect(getUnit('CPU Utilization')).toEqual('%') + expect(getUnit('Bytes Read')).toEqual('Bytes') + expect(getUnit('Disk reads')).toEqual('Count') }) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 4071088363..f2c931cf49 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -24,9 +24,6 @@ const TimeSeriesChart = React.lazy(() => import('~/components/TimeSeriesChart')) /** convert to UTC and return the timezone-free format required by OxQL */ const oxqlTimestamp = (date: Date) => date.toISOString().replace('Z', '') -export const getCycleCount = (num: number, base: number) => - Math.floor(Math.log(num) / Math.log(base)) - export type OxqlDiskMetricName = | 'virtual_disk:bytes_read' | 'virtual_disk:bytes_written' @@ -54,12 +51,13 @@ export type OxqlMetricName = OxqlDiskMetricName | OxqlVmMetricName | OxqlNetwork export type OxqlVcpuState = 'run' | 'idle' | 'waiting' | 'emulation' -/** determine the mean window for the given time range */ -const getMeanWindow = (start: Date, end: Date) => { - const duration = getDurationMinutes({ start, end }) - // the number of points in the chart +/** determine the mean window for the given time range; + * returns a string representing N seconds, e.g. '60s' */ +export const getMeanWindow = (start: Date, end: Date) => { + const duration = getDurationMinutes({ start, end }) * 60 + // the number of points we want to see in the chart const points = 100 - return `${Math.round(duration / points)}m` + return `${Math.round(duration / points)}s` } type getOxqlQueryParams = { @@ -92,25 +90,16 @@ export const getOxqlQuery = ({ }: getOxqlQueryParams) => { const start = oxqlTimestamp(startTime) const end = oxqlTimestamp(endTime) - const filters = [`timestamp >= @${start}`, `timestamp < @${end}`] - if (diskId) { - filters.push(`disk_id == "${diskId}"`) - } - if (vcpuId) { - filters.push(`vcpu_id == ${vcpuId}`) - } - if (instanceId) { - filters.push(`instance_id == "${instanceId}"`) - } - if (interfaceId) { - filters.push(`interface_id == "${interfaceId}"`) - } - if (attachedInstanceId) { - filters.push(`attached_instance_id == "${attachedInstanceId}"`) - } - if (state) { - filters.push(`state == "${state}"`) - } + const filters = [ + `timestamp >= @${start}`, + `timestamp < @${end}`, + diskId && `disk_id == "${diskId}"`, + vcpuId && `vcpu_id == ${vcpuId}`, + instanceId && `instance_id == "${instanceId}"`, + interfaceId && `interface_id == "${interfaceId}"`, + attachedInstanceId && `attached_instance_id == "${attachedInstanceId}"`, + state && `state == "${state}"`, + ].filter(Boolean) // Removes falsy values const meanWindow = getMeanWindow(startTime, endTime) const groupByString = group && attachedInstanceId @@ -123,6 +112,132 @@ export const getOxqlQuery = ({ return query } +export type ChartUnitType = 'Bytes' | '%' | 'Count' +export const getUnit = (title: string): ChartUnitType => { + if (title.includes('Bytes')) return 'Bytes' + if (title.includes('Utilization')) return '%' + return 'Count' +} + +export const getLargestValue = (data: ChartDatum[]) => + Math.max(0, ...data.map((d) => d.value)) + +// not sure this is the best name +export const getOrderOfMagnitude = (largestValue: number, base: number) => + Math.max(Math.floor(Math.log(largestValue) / Math.log(base)), 0) + +// These need better names +// What each function will receive +type OxqlMetricChartComponentsProps = { chartData: ChartDatum[] } +// What each function will return +type OxqlMetricChartProps = { + data: ChartDatum[] + label: string + unitForSet: string + yAxisTickFormatter: (n: number) => string +} + +export const getBytesChartProps = ({ + chartData, +}: OxqlMetricChartComponentsProps): OxqlMetricChartProps => { + // Bytes charts use 1024 as the base + const base = 1024 + const byteUnits = ['BYTES', 'KiB', 'MiB', 'GiB', 'TiB'] + const largestValue = getLargestValue(chartData) + const orderOfMagnitude = getOrderOfMagnitude(largestValue, base) + const bytesChartDivisor = base ** orderOfMagnitude + const data = chartData.map((d) => ({ + ...d, + value: d.value / bytesChartDivisor, + })) + const unitForSet = byteUnits[orderOfMagnitude] + return { + data, + label: `(${unitForSet})`, + unitForSet, + yAxisTickFormatter: (n: number) => n.toLocaleString(), + } +} + +export const yAxisLabelForCountChart = ( + val: number, + base: number, + orderOfMagnitude: number +) => { + const tickValue = val / base ** orderOfMagnitude + const formattedTickValue = tickValue.toLocaleString(undefined, { + maximumSignificantDigits: 3, + maximumFractionDigits: 0, + minimumFractionDigits: 0, + }) + return `${formattedTickValue}${['', 'k', 'M', 'B', 'T'][orderOfMagnitude]}` +} + +/** + * The primary job of the Count chart components helper is to format the Y-axis ticks, + * which requires knowing the largest value in the dataset, to get a consistent scale. + * The data isn't modified for Count charts; the label and unitForSet are basic as well. + */ +export const getCountChartProps = ({ + chartData, +}: OxqlMetricChartComponentsProps): OxqlMetricChartProps => { + // Count charts use 1000 as the base + const base = 1_000 + const largestValue = getLargestValue(chartData) + const orderOfMagnitude = getOrderOfMagnitude(largestValue, base) + const yAxisTickFormatter = (val: number) => + yAxisLabelForCountChart(val, base, orderOfMagnitude) + return { data: chartData, label: '(COUNT)', unitForSet: '', yAxisTickFormatter } +} + +export const getPercentDivisor = (startTime: Date, endTime: Date) => { + const meanWindowSeconds = parseInt(getMeanWindow(startTime, endTime), 10) + // console.log('meanWindowSeconds', meanWindowSeconds) + // We actually need to know the number of nanoseconds + return meanWindowSeconds * 1_000_000_000 +} + +/** + * The Percent chart components helper modifies the data, as the values are some percentage + * of a whole. For now, all Percent charts are based on CPU utilization time. Because queries + * can be dynamic in terms of the `mean_within` window, we use that value to determine the + * divisor for the data. + */ +export const getPercentChartProps = ({ + chartData, + startTime, + endTime, +}: OxqlMetricChartComponentsProps & { + startTime: Date + endTime: Date +}): OxqlMetricChartProps => { + const data = chartData.map(({ timestamp, value }) => ({ + timestamp, + value: value / getPercentDivisor(startTime, endTime), + })) + return { data, label: '(%)', unitForSet: '%', yAxisTickFormatter: (n: number) => `${n}%` } +} + +export const getOxqlMetricChartComponents = ({ + unit, + chartData, + startTime, + endTime, +}: { + unit: ChartUnitType + chartData: ChartDatum[] + startTime: Date + endTime: Date +}) => { + if (unit === 'Bytes') { + return getBytesChartProps({ chartData }) + } + if (unit === 'Count') { + return getCountChartProps({ chartData }) + } + return getPercentChartProps({ chartData, startTime, endTime }) +} + export function OxqlMetric({ title, query, @@ -136,95 +251,27 @@ export function OxqlMetric({ }) { const { data: metrics } = useApiQuery('systemTimeseriesQuery', { body: { query } }) const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) - // console.log('title', title, 'metrics', metrics) - // console.log( - // Object.values(chartData) - // .map((i) => i.value) - // .join(', ') - // ) - const unit = title.includes('Bytes') - ? 'Bytes' - : title.includes('Utilization') - ? '%' - : 'Count' - - const isBytesChart = unit === 'Bytes' - const isPercentChart = unit === '%' - - const largestValue = useMemo(() => { - if (!chartData || chartData.length === 0) return 0 - return chartData.reduce((max, i) => Math.max(max, i.value), 0) - }, [chartData]) - - // We'll need to divide each number in the set by a consistent exponent - // of 1024 (for Bytes) or 1000 (for Counts) - const base = isBytesChart ? 1024 : 1000 - // Figure out what that exponent is - const cycleCount = getCycleCount(largestValue, base) - - // Now that we know how many cycles of "divide by 1024 || 1000" to run through - // (via cycleCount), we can determine the proper unit for the set - let unitForSet = '' - let label = '' - if (chartData.length > 0) { - if (isBytesChart) { - const byteUnits = ['BYTES', 'KiB', 'MiB', 'GiB', 'TiB'] - unitForSet = byteUnits[cycleCount] - label = `(${unitForSet})` - } else { - label = `(${unit.toUpperCase()})` - unitForSet = isPercentChart ? '%' : '' - } - } - - // We need to determine the divisor for the data set. - // - If the unit is Bytes, we divide by 1024 ** cycleCount - // - If the unit is %, we divide by the number of nanoseconds in a minute - // - If the unit is Count, we just return the raw value - const divisor = isBytesChart ? base ** cycleCount : isPercentChart ? 600000000 : 1 - - const data = useMemo( + // console.log(title, chartData) + const unit = getUnit(title) + const { data, label, unitForSet, yAxisTickFormatter } = useMemo( () => - (chartData || []).map(({ timestamp, value }) => ({ - timestamp, - // The value passed in is what will render in the tooltip - value: value / divisor, - })), - [divisor, chartData] + getOxqlMetricChartComponents({ + unit, + chartData, + startTime, + endTime, + }), + [unit, chartData, startTime, endTime] ) - // Create a label for the y-axis ticks. "Count" charts will be - // abbreviated and will have a suffix (e.g. "k") appended. Because - // "Bytes" charts will have already been divided by the divisor - // before the yAxis is created, we can use their given value. - const yAxisTickFormatter = (val: number) => { - if (isBytesChart) { - return val.toLocaleString() - } - if (isPercentChart) { - return `${val}%` - } - const tickValue = val.toLocaleString(undefined, { - maximumSignificantDigits: 3, - maximumFractionDigits: 0, - minimumFractionDigits: 0, - }) - const countUnits = ['', 'k', 'M', 'B', 'T'] - const unitForTick = countUnits[cycleCount] - return `${tickValue}${unitForTick}` - } - - const menuActions = useMemo( - () => [ - { - label: 'Copy query', - onActivate() { - window.navigator.clipboard.writeText(query) - }, + const actions = [ + { + label: 'Copy query', + onActivate() { + window.navigator.clipboard.writeText(query) }, - ], - [query] - ) + }, + ] return (
@@ -234,19 +281,19 @@ export function OxqlMetric({ {!metrics && } {/* TODO: show formatted string to user so they can see it before copying */} - +
}>
From 2c4d130723edc9db392523e322e24d3e25aa2048 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 11 Feb 2025 12:18:08 -0800 Subject: [PATCH 27/75] Add tests for OxQL charts --- app/api/util.ts | 14 -- .../tabs/MetricsTab/DiskMetricsTab.tsx | 28 +-- .../tabs/MetricsTab/NetworkMetricsTab.tsx | 28 +-- .../tabs/MetricsTab/OxqlMetric.spec.ts | 183 ++++++++++++++++-- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 53 +++-- 5 files changed, 233 insertions(+), 73 deletions(-) diff --git a/app/api/util.ts b/app/api/util.ts index f47a2cdd53..954cd77b0e 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -17,7 +17,6 @@ import type { InstanceState, IpPoolUtilization, Measurement, - OxqlQueryResult, SiloUtilization, Sled, VpcFirewallRule, @@ -260,16 +259,3 @@ export function parseIpUtilization({ ipv4, ipv6 }: IpPoolUtilization) { }, } } - -export const getChartData = (data: OxqlQueryResult | undefined): ChartDatum[] => { - if (!data) return [] - const ts = Object.values(data.tables[0].timeseries) - return ts.flatMap((t) => { - const { timestamps, values } = t.points - const v = values[0].values.values as number[] - return timestamps.map((timestamp, idx) => ({ - timestamp: new Date(timestamp).getTime(), - value: v[idx], - })) - }) -} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx index 13a9a9eafb..534ebd2cd5 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx @@ -107,19 +107,21 @@ export function Component() { return ( <> - { - setDisk({ - name: disks.find((n) => n.id === val)?.name || 'All disks', - id: val, - }) - }} - /> + {disks.length > 2 && ( + { + setDisk({ + name: disks.find((n) => n.id === val)?.name || 'All disks', + id: val, + }) + }} + /> + )} {dateTimeRangePicker} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx index a7adb6cc76..2d46f4e45d 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx @@ -79,19 +79,21 @@ export function Component() { return ( <> - { - setNic({ - name: networks.find((n) => n.id === val)?.name || 'All NICs', - id: val, - }) - }} - /> + {networks.length > 2 && ( + { + setNic({ + name: networks.find((n) => n.id === val)?.name || 'All NICs', + id: val, + }) + }} + /> + )} {dateTimeRangePicker} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts index a3729d2d75..26f9f0fd9b 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts @@ -5,9 +5,153 @@ * * Copyright Oxide Computer Company */ -import { expect, test } from 'vitest' +import { describe, expect, it, test } from 'vitest' -import { getOrderOfMagnitude, getUnit } from './OxqlMetric' +import { + getLargestValue, + getMeanWindow, + getOrderOfMagnitude, + getOxqlQuery, + getUnit, + oxqlTimestamp, + yAxisLabelForCountChart, +} from './OxqlMetric' + +test('oxqlTimestamp', () => { + const date1 = new Date('2025-02-11T00:00:00Z') + expect(oxqlTimestamp(date1)).toEqual('2025-02-11T00:00:00.000') + const datePST = new Date('2025-02-11T00:00:00-08:00') + expect(oxqlTimestamp(datePST)).toEqual('2025-02-11T08:00:00.000') +}) + +describe('getMeanWindow', () => { + const start = new Date('2025-02-11T00:00:00Z') + test('calculates the mean window for a 10-minute range', () => { + const end = new Date('2025-02-11T00:10:00Z') // 10 minutes later + expect(getMeanWindow(start, end)).toBe('10s') + }) + + test('calculates the mean window for a 1-hour range', () => { + const end = new Date('2025-02-11T01:00:00Z') // 60 minutes later + expect(getMeanWindow(start, end)).toBe('60s') + }) + + test('calculates the mean window for a 24-hour range', () => { + const end = new Date('2025-02-12T00:00:00Z') // 24 hours later + expect(getMeanWindow(start, end)).toBe('1440s') + }) + + test('calculates the mean window for a 1-week range', () => { + const end = new Date('2025-02-18T00:00:00Z') // 1 week later + expect(getMeanWindow(start, end)).toBe('10080s') + }) + + test('calculates the mean window for a 10-minute range, but with only 5 datapoints', () => { + const end = new Date('2025-02-11T00:10:00Z') // 10 minutes later + const datapoints = 5 + expect(getMeanWindow(start, end, datapoints)).toBe('120s') + }) +}) + +describe('getOxqlQuery', () => { + const startTime = new Date('2024-01-01T00:00:00Z') + const endTime = new Date('2024-01-01T01:00:00Z') + it('generates a query for disk metrics without extra filters', () => { + const query = getOxqlQuery({ + metricName: 'virtual_disk:bytes_read', + startTime, + endTime, + }) + expect(query).toBe( + 'get virtual_disk:bytes_read | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 | align mean_within(60s)' + ) + }) + + it('generates a query for vm metrics with instanceId filter', () => { + const query = getOxqlQuery({ + metricName: 'virtual_machine:vcpu_usage', + startTime, + endTime, + instanceId: 'vm-123', + }) + expect(query).toBe( + 'get virtual_machine:vcpu_usage | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 && instance_id == "vm-123" | align mean_within(60s)' + ) + }) + + it('generates a query for network metrics with interfaceId filter', () => { + const query = getOxqlQuery({ + metricName: 'instance_network_interface:bytes_sent', + startTime, + endTime, + interfaceId: 'eth0', + }) + expect(query).toBe( + 'get instance_network_interface:bytes_sent | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 && interface_id == "eth0" | align mean_within(60s)' + ) + }) + + it('generates a query with vcpu state filter', () => { + const query = getOxqlQuery({ + metricName: 'virtual_machine:vcpu_usage', + startTime, + endTime, + state: 'run', + }) + expect(query).toBe( + 'get virtual_machine:vcpu_usage | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 && state == "run" | align mean_within(60s)' + ) + }) + + it('generates a query with group by instanceId', () => { + const query = getOxqlQuery({ + metricName: 'virtual_disk:bytes_written', + startTime, + endTime, + group: true, + instanceId: 'vm-123', + }) + expect(query).toBe( + 'get virtual_disk:bytes_written | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 && instance_id == "vm-123" | align mean_within(60s) | group_by [instance_id], sum' + ) + }) + + it('generates a query with group by attachedInstanceId', () => { + const query = getOxqlQuery({ + metricName: 'virtual_disk:io_latency', + startTime, + endTime, + group: true, + attachedInstanceId: 'attached-1', + }) + expect(query).toBe( + 'get virtual_disk:io_latency | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 && attached_instance_id == "attached-1" | align mean_within(60s) | group_by [attached_instance_id], sum' + ) + }) + + it('handles missing optional parameters gracefully', () => { + const query = getOxqlQuery({ + metricName: 'virtual_disk:flushes', + startTime, + endTime, + }) + expect(query).toBe( + 'get virtual_disk:flushes | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 | align mean_within(60s)' + ) + }) + + it('correctly handles a range of disk and network metrics', () => { + const query = getOxqlQuery({ + metricName: 'instance_network_interface:bytes_received', + startTime, + endTime, + interfaceId: 'eth0', + }) + expect(query).toBe( + 'get instance_network_interface:bytes_received | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 && interface_id == "eth0" | align mean_within(60s)' + ) + }) +}) test('getOrderOfMagnitude', () => { expect(getOrderOfMagnitude(5, 1000)).toEqual(0) @@ -31,22 +175,35 @@ test('getOrderOfMagnitude', () => { // GiBs expect(getOrderOfMagnitude(2 ** 30, 1024)).toEqual(3) expect(getOrderOfMagnitude(2 ** 30 + 1, 1024)).toEqual(3) + + expect(getOrderOfMagnitude(0, 1000)).toEqual(0) }) -// test('yAxisLabelForCountChart', () => { -// expect(yAxisLabelForCountChart(5, 0)).toEqual('5') -// expect(yAxisLabelForCountChart(1000, 1)).toEqual('1k') -// expect(yAxisLabelForCountChart(1001, 1)).toEqual('1k') -// expect(yAxisLabelForCountChart(10 ** 6, 2)).toEqual('1M') -// expect(yAxisLabelForCountChart(10 ** 6 + 1, 2)).toEqual('1M') -// expect(yAxisLabelForCountChart(10 ** 9, 3)).toEqual('1B') -// expect(yAxisLabelForCountChart(10 ** 9 + 1, 3)).toEqual('1B') -// expect(yAxisLabelForCountChart(10 ** 12, 4)).toEqual('1T') -// expect(yAxisLabelForCountChart(10 ** 12 + 1, 4)).toEqual('1T') -// }) +test('yAxisLabelForCountChart', () => { + expect(yAxisLabelForCountChart(5, 0)).toEqual('5') + expect(yAxisLabelForCountChart(1000, 1)).toEqual('1k') + expect(yAxisLabelForCountChart(1001, 1)).toEqual('1k') + expect(yAxisLabelForCountChart(10 ** 6, 2)).toEqual('1M') + expect(yAxisLabelForCountChart(10 ** 6 + 1, 2)).toEqual('1M') + expect(yAxisLabelForCountChart(10 ** 9, 3)).toEqual('1B') + expect(yAxisLabelForCountChart(10 ** 9 + 1, 3)).toEqual('1B') + expect(yAxisLabelForCountChart(10 ** 12, 4)).toEqual('1T') + expect(yAxisLabelForCountChart(10 ** 12 + 1, 4)).toEqual('1T') +}) test('getUnit', () => { expect(getUnit('CPU Utilization')).toEqual('%') expect(getUnit('Bytes Read')).toEqual('Bytes') expect(getUnit('Disk reads')).toEqual('Count') + expect(getUnit('Anything else')).toEqual('Count') +}) + +test('getLargestValue', () => { + const sampleData = [ + { timestamp: 1739232000000, value: 5 }, + { timestamp: 1739232060000, value: 10 }, + { timestamp: 1739232120000, value: 15 }, + ] + expect(getLargestValue(sampleData)).toEqual(15) + expect(getLargestValue([])).toEqual(0) }) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index f2c931cf49..f42916766c 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -13,16 +13,31 @@ import React, { Suspense, useMemo } from 'react' -import { getChartData, useApiQuery, type ChartDatum } from '@oxide/api' +import { useApiQuery, type ChartDatum, type OxqlQueryResult } from '@oxide/api' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { Spinner } from '~/ui/lib/Spinner' import { getDurationMinutes } from '~/util/date' +// An OxQL Query Result can have multiple tables, but in the web console we only ever call +// aligned timeseries queries, which always have exactly one table. +export const getChartData = (data: OxqlQueryResult | undefined): ChartDatum[] => { + if (!data) return [] + const ts = Object.values(data.tables[0].timeseries) + return ts.flatMap((t) => { + const { timestamps, values } = t.points + const v = values[0].values.values as number[] + return timestamps.map((timestamp, idx) => ({ + timestamp: new Date(timestamp).getTime(), + value: v[idx], + })) + }) +} + const TimeSeriesChart = React.lazy(() => import('~/components/TimeSeriesChart')) /** convert to UTC and return the timezone-free format required by OxQL */ -const oxqlTimestamp = (date: Date) => date.toISOString().replace('Z', '') +export const oxqlTimestamp = (date: Date) => date.toISOString().replace('Z', '') export type OxqlDiskMetricName = | 'virtual_disk:bytes_read' @@ -52,12 +67,16 @@ export type OxqlMetricName = OxqlDiskMetricName | OxqlVmMetricName | OxqlNetwork export type OxqlVcpuState = 'run' | 'idle' | 'waiting' | 'emulation' /** determine the mean window for the given time range; - * returns a string representing N seconds, e.g. '60s' */ -export const getMeanWindow = (start: Date, end: Date) => { - const duration = getDurationMinutes({ start, end }) * 60 - // the number of points we want to see in the chart - const points = 100 - return `${Math.round(duration / points)}s` + * returns a string representing N seconds, e.g. '60s' + * points = the number of datapoints we want to see in the chart + * (default is 60, to show 1 point per minute on a 1-hour chart) + * We could dynamically adjust this based on the duration of the range, + * like … for 1 week, show 1 datapoint per hour, for 1 day, show 1 datapoint per minute, etc. + * */ +export const getMeanWindow = (start: Date, end: Date, datapoints = 60) => { + const durationMinutes = getDurationMinutes({ start, end }) + const durationSeconds = durationMinutes * 60 + return `${Math.round(durationSeconds / datapoints)}s` } type getOxqlQueryParams = { @@ -119,8 +138,9 @@ export const getUnit = (title: string): ChartUnitType => { return 'Count' } +// Returns 0 if there are no data points export const getLargestValue = (data: ChartDatum[]) => - Math.max(0, ...data.map((d) => d.value)) + data.length ? Math.max(0, ...data.map((d) => d.value)) : 0 // not sure this is the best name export const getOrderOfMagnitude = (largestValue: number, base: number) => @@ -159,12 +179,8 @@ export const getBytesChartProps = ({ } } -export const yAxisLabelForCountChart = ( - val: number, - base: number, - orderOfMagnitude: number -) => { - const tickValue = val / base ** orderOfMagnitude +export const yAxisLabelForCountChart = (val: number, orderOfMagnitude: number) => { + const tickValue = val / 1_000 ** orderOfMagnitude const formattedTickValue = tickValue.toLocaleString(undefined, { maximumSignificantDigits: 3, maximumFractionDigits: 0, @@ -181,12 +197,9 @@ export const yAxisLabelForCountChart = ( export const getCountChartProps = ({ chartData, }: OxqlMetricChartComponentsProps): OxqlMetricChartProps => { - // Count charts use 1000 as the base - const base = 1_000 const largestValue = getLargestValue(chartData) - const orderOfMagnitude = getOrderOfMagnitude(largestValue, base) - const yAxisTickFormatter = (val: number) => - yAxisLabelForCountChart(val, base, orderOfMagnitude) + const orderOfMagnitude = getOrderOfMagnitude(largestValue, 1_000) + const yAxisTickFormatter = (val: number) => yAxisLabelForCountChart(val, orderOfMagnitude) return { data: chartData, label: '(COUNT)', unitForSet: '', yAxisTickFormatter } } From ef48320439fede0dcfa4fed6c5e326d143920b26 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 11 Feb 2025 14:45:02 -0800 Subject: [PATCH 28/75] Better handle cumulative_u64 data with initial sum value --- .../tabs/MetricsTab/OxqlMetric.spec.ts | 16 +++++----- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 30 ++++++++++++------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts index 26f9f0fd9b..94c35e42eb 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts @@ -63,7 +63,7 @@ describe('getOxqlQuery', () => { endTime, }) expect(query).toBe( - 'get virtual_disk:bytes_read | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 | align mean_within(60s)' + 'get virtual_disk:bytes_read | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 | align mean_within(60s)' ) }) @@ -75,7 +75,7 @@ describe('getOxqlQuery', () => { instanceId: 'vm-123', }) expect(query).toBe( - 'get virtual_machine:vcpu_usage | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 && instance_id == "vm-123" | align mean_within(60s)' + 'get virtual_machine:vcpu_usage | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && instance_id == "vm-123" | align mean_within(60s)' ) }) @@ -87,7 +87,7 @@ describe('getOxqlQuery', () => { interfaceId: 'eth0', }) expect(query).toBe( - 'get instance_network_interface:bytes_sent | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 && interface_id == "eth0" | align mean_within(60s)' + 'get instance_network_interface:bytes_sent | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && interface_id == "eth0" | align mean_within(60s)' ) }) @@ -99,7 +99,7 @@ describe('getOxqlQuery', () => { state: 'run', }) expect(query).toBe( - 'get virtual_machine:vcpu_usage | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 && state == "run" | align mean_within(60s)' + 'get virtual_machine:vcpu_usage | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && state == "run" | align mean_within(60s)' ) }) @@ -112,7 +112,7 @@ describe('getOxqlQuery', () => { instanceId: 'vm-123', }) expect(query).toBe( - 'get virtual_disk:bytes_written | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 && instance_id == "vm-123" | align mean_within(60s) | group_by [instance_id], sum' + 'get virtual_disk:bytes_written | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && instance_id == "vm-123" | align mean_within(60s) | group_by [instance_id], sum' ) }) @@ -125,7 +125,7 @@ describe('getOxqlQuery', () => { attachedInstanceId: 'attached-1', }) expect(query).toBe( - 'get virtual_disk:io_latency | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 && attached_instance_id == "attached-1" | align mean_within(60s) | group_by [attached_instance_id], sum' + 'get virtual_disk:io_latency | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && attached_instance_id == "attached-1" | align mean_within(60s) | group_by [attached_instance_id], sum' ) }) @@ -136,7 +136,7 @@ describe('getOxqlQuery', () => { endTime, }) expect(query).toBe( - 'get virtual_disk:flushes | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 | align mean_within(60s)' + 'get virtual_disk:flushes | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 | align mean_within(60s)' ) }) @@ -148,7 +148,7 @@ describe('getOxqlQuery', () => { interfaceId: 'eth0', }) expect(query).toBe( - 'get instance_network_interface:bytes_received | filter timestamp >= @2024-01-01T00:00:00.000 && timestamp < @2024-01-01T01:00:00.000 && interface_id == "eth0" | align mean_within(60s)' + 'get instance_network_interface:bytes_received | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && interface_id == "eth0" | align mean_within(60s)' ) }) }) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index f42916766c..e1c07713ff 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -24,14 +24,16 @@ import { getDurationMinutes } from '~/util/date' export const getChartData = (data: OxqlQueryResult | undefined): ChartDatum[] => { if (!data) return [] const ts = Object.values(data.tables[0].timeseries) - return ts.flatMap((t) => { - const { timestamps, values } = t.points - const v = values[0].values.values as number[] - return timestamps.map((timestamp, idx) => ({ - timestamp: new Date(timestamp).getTime(), - value: v[idx], - })) - }) + return ts + .flatMap((t) => { + const { timestamps, values } = t.points + const v = values[0].values.values as number[] + return timestamps.map((timestamp, idx) => ({ + timestamp: new Date(timestamp).getTime(), + value: v[idx], + })) + }) + .slice(1) // first datapoint can be a sum of all datapoints preceding it, so we remove before displaying } const TimeSeriesChart = React.lazy(() => import('~/components/TimeSeriesChart')) @@ -107,7 +109,13 @@ export const getOxqlQuery = ({ state, group, }: getOxqlQueryParams) => { - const start = oxqlTimestamp(startTime) + const meanWindow = getMeanWindow(startTime, endTime) + // we adjust the start time back by 2x the mean window so that we can + // 1) drop the first datapoint (the cumulative sum of all previous datapoints) + // 2) ensure that the first datapoint we display on the chart matches the actual start time + const secondsToAdjust = parseInt(meanWindow, 10) * 2 + const adjustedStart = new Date(startTime.getTime() - secondsToAdjust * 1000) + const start = oxqlTimestamp(adjustedStart) const end = oxqlTimestamp(endTime) const filters = [ `timestamp >= @${start}`, @@ -119,7 +127,7 @@ export const getOxqlQuery = ({ attachedInstanceId && `attached_instance_id == "${attachedInstanceId}"`, state && `state == "${state}"`, ].filter(Boolean) // Removes falsy values - const meanWindow = getMeanWindow(startTime, endTime) + const groupByString = group && attachedInstanceId ? ' | group_by [attached_instance_id], sum' @@ -264,7 +272,7 @@ export function OxqlMetric({ }) { const { data: metrics } = useApiQuery('systemTimeseriesQuery', { body: { query } }) const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) - // console.log(title, chartData) + // console.log(title, query, chartData) const unit = getUnit(title) const { data, label, unitForSet, yAxisTickFormatter } = useMemo( () => From bb8d32bc6cd1ca28584f5383eac88a8f1a6f1921 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Wed, 12 Feb 2025 21:04:00 +0000 Subject: [PATCH 29/75] Instance metrics design tweaks (#2676) Co-authored-by: David Crespo Co-authored-by: Charlie Park --- app/api/__tests__/safety.spec.ts | 1 + app/components/MoreActionsMenu.tsx | 14 +- app/components/RefetchIntervalPicker.tsx | 27 +++- app/components/RouteTabs.tsx | 16 ++- app/components/TimeSeriesChart.tsx | 19 ++- app/pages/SiloUtilizationPage.tsx | 2 + .../instances/instance/tabs/MetricsTab.tsx | 24 +++- .../tabs/MetricsTab/CpuMetricsTab.tsx | 7 +- .../tabs/MetricsTab/DiskMetricsTab.tsx | 41 +++--- .../tabs/MetricsTab/NetworkMetricsTab.tsx | 40 +++--- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 77 ++++++----- app/pages/system/UtilizationPage.tsx | 2 + app/ui/lib/DatePicker.tsx | 2 +- app/ui/lib/DateRangePicker.tsx | 14 +- app/ui/lib/Listbox.tsx | 46 ++++--- app/ui/styles/components/Tabs.css | 8 +- mock-api/oxql-metrics.ts | 4 +- package-lock.json | 120 ++---------------- package.json | 1 + tailwind.config.ts | 2 + 20 files changed, 248 insertions(+), 219 deletions(-) diff --git a/app/api/__tests__/safety.spec.ts b/app/api/__tests__/safety.spec.ts index 3259cf2895..cb12f83d5b 100644 --- a/app/api/__tests__/safety.spec.ts +++ b/app/api/__tests__/safety.spec.ts @@ -51,6 +51,7 @@ it('mock-api is only referenced in test files', () => { "app/main.tsx", "app/msw-mock-api.ts", "docs/mock-api-differences.md", + "mock-api/msw/util.ts", "package.json", "test/e2e/utils.ts", "test/unit/server.ts", diff --git a/app/components/MoreActionsMenu.tsx b/app/components/MoreActionsMenu.tsx index 9aa608ba1a..774ad7c257 100644 --- a/app/components/MoreActionsMenu.tsx +++ b/app/components/MoreActionsMenu.tsx @@ -5,6 +5,8 @@ * * Copyright Oxide Computer Company */ +import cn from 'classnames' + import { More12Icon } from '@oxide/design-system/icons/react' import type { MenuAction } from '~/table/columns/action-col' @@ -16,13 +18,21 @@ interface MoreActionsMenuProps { /** The accessible name for the menu button */ label: string actions: MenuAction[] + isSmall?: boolean } -export const MoreActionsMenu = ({ actions, label }: MoreActionsMenuProps) => { +export const MoreActionsMenu = ({ + actions, + label, + isSmall = false, +}: MoreActionsMenuProps) => { return ( diff --git a/app/components/RefetchIntervalPicker.tsx b/app/components/RefetchIntervalPicker.tsx index 639e14c26d..9ad73f77c3 100644 --- a/app/components/RefetchIntervalPicker.tsx +++ b/app/components/RefetchIntervalPicker.tsx @@ -37,9 +37,19 @@ type Props = { enabled: boolean isLoading: boolean fn: () => void + showLastFetched?: boolean + className?: string + isSlim?: boolean } -export function useIntervalPicker({ enabled, isLoading, fn }: Props) { +export function useIntervalPicker({ + enabled, + isLoading, + fn, + showLastFetched = false, + className, + isSlim = false, +}: Props) { const [intervalPreset, setIntervalPreset] = useState('10s') const [lastFetched, setLastFetched] = useState(new Date()) @@ -53,11 +63,13 @@ export function useIntervalPicker({ enabled, isLoading, fn }: Props) { return { intervalMs: (enabled && intervalPresets[intervalPreset]) || undefined, intervalPicker: ( -
-
- Refreshed{' '} - {toLocaleTimeString(lastFetched)} -
+
+ {showLastFetched && ( +
+ Refreshed{' '} + {toLocaleTimeString(lastFetched)} +
+ )}
diff --git a/app/components/RouteTabs.tsx b/app/components/RouteTabs.tsx index 7d3c4b40e6..ae82a9ba08 100644 --- a/app/components/RouteTabs.tsx +++ b/app/components/RouteTabs.tsx @@ -39,20 +39,30 @@ export interface RouteTabsProps { children: ReactNode fullWidth?: boolean sideTabs?: boolean + tabListClassName?: string } /** Tabbed views, controlling both the layout and functioning of tabs and the panel contents. * sideTabs: Whether the tabs are displayed on the side of the panel. Default is false. */ -export function RouteTabs({ children, fullWidth, sideTabs = false }: RouteTabsProps) { +export function RouteTabs({ + children, + fullWidth, + sideTabs = false, + tabListClassName, +}: RouteTabsProps) { const wrapperClasses = sideTabs ? 'ox-side-tabs flex' : cn('ox-tabs', { 'full-width': fullWidth }) const tabListClasses = sideTabs ? 'ox-side-tabs-list' : 'ox-tabs-list' - const panelClasses = cn('ox-tabs-panel', { 'flex-grow': sideTabs }) + const panelClasses = cn('ox-tabs-panel @container', { 'ml-5 flex-grow': sideTabs }) return (
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */} -
+
{children}
{/* TODO: Add aria-describedby for active tab */} diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 5fd8f6c2c4..6a8e060ae7 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -21,6 +21,8 @@ import type { TooltipProps } from 'recharts/types/component/Tooltip' import type { ChartDatum } from '@oxide/api' +import { Spinner } from '~/ui/lib/Spinner' + // Recharts's built-in ticks behavior is useless and probably broken /** * Split the data into n evenly spaced ticks, with one at the left end and one a @@ -110,6 +112,7 @@ type TimeSeriesChartProps = { endTime: Date unit?: string yAxisTickFormatter?: (val: number) => string + hasBorder?: boolean } const TICK_COUNT = 6 @@ -132,6 +135,7 @@ export default function TimeSeriesChart({ endTime, unit, yAxisTickFormatter = (val) => val.toLocaleString(), + hasBorder = true, }: TimeSeriesChartProps) { // We use the largest data point +20% for the graph scale. !rawData doesn't // mean it's empty (it will never be empty because we fill in artificial 0s at @@ -153,9 +157,22 @@ export default function TimeSeriesChart({ // re-render on every render of the parent when the data is undefined const data = useMemo(() => rawData || [], [rawData]) + if (!data || data.length === 0) { + return ( +
+
+ +
+
+ ) + } + return (
- + {/* temporary until we migrate the old metrics to the new style */} + 0, // sliding the range forward is sufficient to trigger a refetch fn: () => onRangeChange(preset), + showLastFetched: true, + className: 'mb-12', }) const commonProps = { diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index 7c5c8607b5..f81064c983 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -6,9 +6,11 @@ * Copyright Oxide Computer Company */ +import { useIsFetching } from '@tanstack/react-query' import { createContext, useContext, type ReactNode } from 'react' import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker' +import { useIntervalPicker } from '~/components/RefetchIntervalPicker' import { RouteTabs, Tab } from '~/components/RouteTabs' import { useInstanceSelector } from '~/hooks/use-params' import { pb } from '~/util/path-builder' @@ -23,21 +25,33 @@ const MetricsContext = createContext<{ startTime: Date endTime: Date dateTimeRangePicker: ReactNode -}>({ startTime, endTime, dateTimeRangePicker: <> }) + intervalPicker: ReactNode +}>({ startTime, endTime, dateTimeRangePicker: <>, intervalPicker: <> }) export const useMetricsContext = () => useContext(MetricsContext) export const MetricsTab = () => { const { project, instance } = useInstanceSelector() - const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ - initialPreset: 'lastHour', + const { preset, onRangeChange, startTime, endTime, dateTimeRangePicker } = + useDateTimeRangePicker({ + initialPreset: 'lastHour', + }) + + const { intervalPicker } = useIntervalPicker({ + enabled: preset !== 'custom', + isLoading: useIsFetching({ queryKey: ['siloMetric'] }) > 0, + // sliding the range forward is sufficient to trigger a refetch + fn: () => onRangeChange(preset), + isSlim: true, }) // Find the relevant in RouteTabs return ( - - + + CPU diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx index d3aefa940a..f310147bce 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx @@ -53,7 +53,7 @@ export function Component() { query: { project }, }) - const { startTime, endTime, dateTimeRangePicker } = useMetricsContext() + const { startTime, endTime, dateTimeRangePicker, intervalPicker } = useMetricsContext() const getQuery = (metricName: OxqlVmMetricName, state?: OxqlVcpuState) => getOxqlQuery({ @@ -67,11 +67,14 @@ export function Component() { return ( <> - {dateTimeRangePicker} + + {intervalPicker} {dateTimeRangePicker} + - {disks.length > 2 && ( - { - setDisk({ - name: disks.find((n) => n.id === val)?.name || 'All disks', - id: val, - }) - }} - /> - )} +
+ {intervalPicker} + + {disks.length > 2 && ( + { + setDisk({ + name: disks.find((n) => n.id === val)?.name || 'All disks', + id: val, + }) + }} + /> + )} +
{dateTimeRangePicker}
@@ -131,12 +135,14 @@ export function Component() { [ @@ -79,33 +79,39 @@ export function Component() { return ( <> - {networks.length > 2 && ( - { - setNic({ - name: networks.find((n) => n.id === val)?.name || 'All NICs', - id: val, - }) - }} - /> - )} +
+ {intervalPicker} + + {networks.length > 2 && ( + { + setNic({ + name: networks.find((n) => n.id === val)?.name || 'All NICs', + id: val, + }) + }} + /> + )} +
{dateTimeRangePicker}
{ // Bytes charts use 1024 as the base const base = 1024 - const byteUnits = ['BYTES', 'KiB', 'MiB', 'GiB', 'TiB'] + const byteUnits = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'] const largestValue = getLargestValue(chartData) const orderOfMagnitude = getOrderOfMagnitude(largestValue, base) const bytesChartDivisor = base ** orderOfMagnitude @@ -208,7 +207,7 @@ export const getCountChartProps = ({ const largestValue = getLargestValue(chartData) const orderOfMagnitude = getOrderOfMagnitude(largestValue, 1_000) const yAxisTickFormatter = (val: number) => yAxisLabelForCountChart(val, orderOfMagnitude) - return { data: chartData, label: '(COUNT)', unitForSet: '', yAxisTickFormatter } + return { data: chartData, label: '(Count)', unitForSet: '', yAxisTickFormatter } } export const getPercentDivisor = (startTime: Date, endTime: Date) => { @@ -261,16 +260,23 @@ export const getOxqlMetricChartComponents = ({ export function OxqlMetric({ title, + description, query, startTime, endTime, }: { title: string + description?: string query: string startTime: Date endTime: Date }) { - const { data: metrics } = useApiQuery('systemTimeseriesQuery', { body: { query } }) + const { data: metrics } = useApiQuery( + 'systemTimeseriesQuery', + { body: { query } }, + // avoid graphs flashing blank while loading when you change the time + { placeholderData: (x) => x } + ) const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) // console.log(title, query, chartData) const unit = getUnit(title) @@ -295,28 +301,34 @@ export function OxqlMetric({ ] return ( -
-
-

- {title}
{label}
- {!metrics && } -

+
+
+
+

+
{title}
+
{label}
+

+
{description}
+
{/* TODO: show formatted string to user so they can see it before copying */} - + +
+
+ }> + +
- }> - -
) } @@ -324,11 +336,18 @@ export function OxqlMetric({ export const MetricHeader = ({ children }: { children: React.ReactNode }) => { // If header has only one child, align it to the end of the container const value = React.Children.toArray(children).length === 1 ? 'end' : 'between' - return
{children}
+ return ( + // prettier-ignore +
+ {children} +
+ ) } export const MetricCollection = ({ children }: { children: React.ReactNode }) => ( -
{children}
-) -export const MetricRow = ({ children }: { children: React.ReactNode }) => ( -
{children}
+
{children}
) +export const MetricRow = ({ children }: { children: React.ReactNode }) => + // prettier-ignore +
+ {children} +
diff --git a/app/pages/system/UtilizationPage.tsx b/app/pages/system/UtilizationPage.tsx index 3c77268897..b11940e5fc 100644 --- a/app/pages/system/UtilizationPage.tsx +++ b/app/pages/system/UtilizationPage.tsx @@ -113,6 +113,8 @@ const MetricsTab = () => { isLoading: useIsFetching({ queryKey: ['systemMetric'] }) > 0, // sliding the range forward is sufficient to trigger a refetch fn: () => onRangeChange(preset), + showLastFetched: true, + className: 'mb-12', }) const commonProps = { diff --git a/app/ui/lib/DatePicker.tsx b/app/ui/lib/DatePicker.tsx index 151e6b57d6..4636584bd9 100644 --- a/app/ui/lib/DatePicker.tsx +++ b/app/ui/lib/DatePicker.tsx @@ -36,7 +36,7 @@ export function DatePicker(props: DatePickerProps) { const formatter = useDateFormatter({ dateStyle: 'short', timeStyle: 'short', - hourCycle: 'h24', + hourCycle: 'h23', }) const label = useMemo(() => { diff --git a/app/ui/lib/DateRangePicker.tsx b/app/ui/lib/DateRangePicker.tsx index 118a827997..8ea5544823 100644 --- a/app/ui/lib/DateRangePicker.tsx +++ b/app/ui/lib/DateRangePicker.tsx @@ -36,7 +36,7 @@ export function DateRangePicker(props: DateRangePickerProps) { const formatter = useDateFormatter({ dateStyle: 'short', timeStyle: 'short', - hourCycle: 'h24', + hourCycle: 'h23', }) const label = useMemo(() => { @@ -45,10 +45,10 @@ export function DateRangePicker(props: DateRangePickerProps) { // unset the value through the UI. if (!state.dateRange) return 'No range selected' - return formatter.formatRange( - state.dateRange.start.toDate(getLocalTimeZone()), - state.dateRange.end.toDate(getLocalTimeZone()) - ) + const from = state.dateRange.start.toDate(getLocalTimeZone()) + const to = state.dateRange.end.toDate(getLocalTimeZone()) + + return formatter.formatRange(from, to) }, [state.dateRange, formatter]) return ( @@ -68,8 +68,8 @@ export function DateRangePicker(props: DateRangePickerProps) { : 'border-default ring-accent-secondary' )} > -
- {label} +
+
{label}
{state.isInvalid && (
diff --git a/app/ui/lib/Listbox.tsx b/app/ui/lib/Listbox.tsx index c1617bb758..6b1cf14564 100644 --- a/app/ui/lib/Listbox.tsx +++ b/app/ui/lib/Listbox.tsx @@ -43,6 +43,7 @@ export interface ListboxProps { /** Necessary if you want RHF to be able to focus it on error */ buttonRef?: Ref hideOptionalTag?: boolean + hideSelected?: boolean } export const Listbox = ({ @@ -61,6 +62,7 @@ export const Listbox = ({ isLoading = false, buttonRef, hideOptionalTag, + hideSelected = false, ...props }: ListboxProps) => { const selectedItem = selected && items.find((i) => i.value === selected) @@ -99,7 +101,7 @@ export const Listbox = ({ id={id} name={name} className={cn( - `flex h-11 w-full items-center justify-between rounded border text-sans-md`, + `flex h-11 items-center justify-between rounded border text-sans-md`, hasError ? 'focus-error border-error-secondary hover:border-error' : 'border-default hover:border-hover', @@ -108,32 +110,44 @@ export const Listbox = ({ isDisabled ? 'cursor-not-allowed text-disabled bg-disabled !border-default' : 'bg-default', - isDisabled && hasError && '!border-error-secondary' + isDisabled && hasError && '!border-error-secondary', + hideSelected ? 'w-auto' : 'w-full' )} ref={buttonRef} {...props} > -
- {selectedItem ? ( - // selectedLabel is one line, which is what we need when label is a ReactNode - selectedItem.selectedLabel || selectedItem.label - ) : ( - - {noItems ? noItemsPlaceholder : placeholder} - - )} -
- {!isDisabled && } + {!hideSelected && ( + <> +
+ {selectedItem ? ( + // selectedLabel is one line, which is what we need when label is a ReactNode + selectedItem.selectedLabel || selectedItem.label + ) : ( + + {noItems ? noItemsPlaceholder : placeholder} + + )} +
+ {!isDisabled && } + + )}
const mockOxqlValues: ValueType = { 'instance_network_interface:bytes_received': [ @@ -210,7 +210,7 @@ const mockOxqlValues: ValueType = { 19999198522.691017, 19999210125.45697, 19999277909.5477, 18486165951.591064, ], } -const mockOxqlVcpuStateValues: { [key in OxqlVcpuState]: number[] } = { +const mockOxqlVcpuStateValues: Record = { emulation: [ 3470554833.148136, 3794070909.515587, 7484378129.162313, 7499079902.563132, 7338835189.397617, 5769255305.989975, 5793666851.660778, 6119828972.189391, diff --git a/package-lock.json b/package-lock.json index 7d25fed159..3a90617d35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@radix-ui/react-focus-guards": "1.0.1", "@radix-ui/react-tabs": "^1.1.0", "@react-aria/live-announcer": "^3.3.4", + "@tailwindcss/container-queries": "^0.1.1", "@tanstack/react-query": "^5.56.2", "@tanstack/react-query-devtools": "^5.56.2", "@tanstack/react-table": "^8.20.5", @@ -122,7 +123,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -1335,7 +1335,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -1353,7 +1352,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1366,7 +1364,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -1382,7 +1379,6 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/set-array": "^1.2.1", @@ -1397,7 +1393,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1407,7 +1402,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1428,14 +1422,12 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1488,7 +1480,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -1502,7 +1493,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -1512,7 +1502,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -1771,7 +1760,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -4572,6 +4560,15 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tailwindcss/container-queries": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz", + "integrity": "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.2.0" + } + }, "node_modules/@tanstack/query-core": { "version": "5.56.2", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.56.2.tgz", @@ -5598,7 +5595,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5608,14 +5604,12 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -5629,7 +5623,6 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -5950,7 +5943,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6021,7 +6013,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6172,7 +6163,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -6269,7 +6259,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -6559,7 +6548,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -6676,7 +6664,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -7057,14 +7044,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, "license": "MIT" }, "node_modules/doctrine": { @@ -7208,7 +7193,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/ee-first": { @@ -7245,7 +7229,6 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/emoji-regex-xs": { @@ -8364,7 +8347,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -8395,7 +8377,6 @@ "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -8450,7 +8431,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -8558,7 +8538,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", @@ -8673,7 +8652,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8688,7 +8666,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8839,7 +8816,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -9014,7 +8990,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -9454,7 +9429,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -9503,7 +9477,6 @@ "version": "2.15.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -9554,7 +9527,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9599,7 +9571,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -9644,7 +9615,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -9840,7 +9810,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -9865,7 +9834,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -10000,7 +9968,6 @@ "version": "1.21.6", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", - "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -10229,7 +10196,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -10242,7 +10208,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -10425,7 +10390,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -10534,7 +10498,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -10594,7 +10557,6 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -10620,7 +10582,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -10746,7 +10707,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -10758,7 +10718,6 @@ "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", - "dev": true, "funding": [ { "type": "github", @@ -10823,7 +10782,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10872,7 +10830,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -11176,7 +11133,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/param-case": { @@ -11497,7 +11453,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11507,14 +11462,12 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -11531,7 +11484,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/path-to-regexp": { @@ -11561,14 +11513,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -11581,7 +11531,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11591,7 +11540,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -11705,7 +11653,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, "license": "MIT", "dependencies": { "camelcase-css": "^2.0.1" @@ -11725,7 +11672,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -11761,7 +11707,6 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -11774,7 +11719,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -11898,7 +11842,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -11912,7 +11855,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -12153,7 +12095,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -12493,7 +12434,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -12503,7 +12443,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -12707,7 +12646,6 @@ "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.13.0", @@ -12754,7 +12692,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -12874,7 +12811,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -13146,7 +13082,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -13159,7 +13094,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13266,7 +13200,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -13302,7 +13235,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -13374,7 +13306,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -13393,7 +13324,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -13408,14 +13338,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13425,7 +13353,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -13438,7 +13365,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -13581,7 +13507,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -13595,7 +13520,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -13671,7 +13595,6 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -13694,7 +13617,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -13704,7 +13626,6 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -13725,7 +13646,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13781,7 +13701,6 @@ "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", - "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -13819,7 +13738,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -13832,7 +13750,6 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -13883,7 +13800,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -13893,7 +13809,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -13989,7 +13904,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -14074,7 +13988,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/ts-pattern": { @@ -14552,7 +14465,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/utils-merge": { @@ -15412,7 +15324,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -15543,7 +15454,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -15562,7 +15472,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -15580,7 +15489,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -15596,7 +15504,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -15609,21 +15516,18 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15633,7 +15537,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -15648,7 +15551,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -15661,7 +15563,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -15674,7 +15575,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" diff --git a/package.json b/package.json index 864ff96e5f..a0ea04eeae 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@radix-ui/react-focus-guards": "1.0.1", "@radix-ui/react-tabs": "^1.1.0", "@react-aria/live-announcer": "^3.3.4", + "@tailwindcss/container-queries": "^0.1.1", "@tanstack/react-query": "^5.56.2", "@tanstack/react-query-devtools": "^5.56.2", "@tanstack/react-table": "^8.20.5", diff --git a/tailwind.config.ts b/tailwind.config.ts index e68c978a34..8f081c1a8f 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -6,6 +6,7 @@ * Copyright Oxide Computer Company */ +import containerQueriesPlugin from '@tailwindcss/container-queries' import { type Config } from 'tailwindcss' import plugin from 'tailwindcss/plugin' @@ -74,5 +75,6 @@ export default { addUtilities(colorUtilities) addUtilities(elevationUtilities) }), + containerQueriesPlugin, ], } satisfies Config From bfc9714a833016740db35d57cf1cdb3176c87c39 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 13 Feb 2025 11:14:11 -0600 Subject: [PATCH 30/75] a little code cleanup --- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 81 +++++++------------ package-lock.json | 1 - 2 files changed, 30 insertions(+), 52 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 24c3b21cd4..2d485a8199 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -12,10 +12,12 @@ */ import React, { Suspense, useMemo } from 'react' +import * as R from 'remeda' import { useApiQuery, type ChartDatum, type OxqlQueryResult } from '@oxide/api' import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { classed } from '~/util/classed' import { getDurationMinutes } from '~/util/date' // An OxQL Query Result can have multiple tables, but in the web console we only ever call @@ -80,7 +82,7 @@ export const getMeanWindow = (start: Date, end: Date, datapoints = 60) => { return `${Math.round(durationSeconds / datapoints)}s` } -type getOxqlQueryParams = { +type GetOxqlQueryParams = { metricName: OxqlMetricName startTime: Date endTime: Date @@ -107,7 +109,7 @@ export const getOxqlQuery = ({ vcpuId, state, group, -}: getOxqlQueryParams) => { +}: GetOxqlQueryParams) => { const meanWindow = getMeanWindow(startTime, endTime) // we adjust the start time back by 2x the mean window so that we can // 1) drop the first datapoint (the cumulative sum of all previous datapoints) @@ -125,14 +127,17 @@ export const getOxqlQuery = ({ interfaceId && `interface_id == "${interfaceId}"`, attachedInstanceId && `attached_instance_id == "${attachedInstanceId}"`, state && `state == "${state}"`, - ].filter(Boolean) // Removes falsy values - - const groupByString = - group && attachedInstanceId - ? ' | group_by [attached_instance_id], sum' - : group && instanceId - ? ' | group_by [instance_id], sum' - : '' + ].filter(R.isTruthy) // Removes falsy values + + let groupByString = '' + if (group) { + if (attachedInstanceId) { + groupByString = '| group_by [attached_instance_id], sum' + } else if (instanceId) { + groupByString = '| group_by [instance_id], sum' + } + } + const query = `get ${metricName} | filter ${filters.join(' && ')} | align mean_within(${meanWindow})${groupByString}` // console.log(query) return query @@ -156,6 +161,7 @@ export const getOrderOfMagnitude = (largestValue: number, base: number) => // These need better names // What each function will receive type OxqlMetricChartComponentsProps = { chartData: ChartDatum[] } + // What each function will return type OxqlMetricChartProps = { data: ChartDatum[] @@ -238,26 +244,6 @@ export const getPercentChartProps = ({ return { data, label: '(%)', unitForSet: '%', yAxisTickFormatter: (n: number) => `${n}%` } } -export const getOxqlMetricChartComponents = ({ - unit, - chartData, - startTime, - endTime, -}: { - unit: ChartUnitType - chartData: ChartDatum[] - startTime: Date - endTime: Date -}) => { - if (unit === 'Bytes') { - return getBytesChartProps({ chartData }) - } - if (unit === 'Count') { - return getCountChartProps({ chartData }) - } - return getPercentChartProps({ chartData, startTime, endTime }) -} - export function OxqlMetric({ title, description, @@ -280,16 +266,15 @@ export function OxqlMetric({ const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) // console.log(title, query, chartData) const unit = getUnit(title) - const { data, label, unitForSet, yAxisTickFormatter } = useMemo( - () => - getOxqlMetricChartComponents({ - unit, - chartData, - startTime, - endTime, - }), - [unit, chartData, startTime, endTime] - ) + const { data, label, unitForSet, yAxisTickFormatter } = useMemo(() => { + if (unit === 'Bytes') { + return getBytesChartProps({ chartData }) + } + if (unit === 'Count') { + return getCountChartProps({ chartData }) + } + return getPercentChartProps({ chartData, startTime, endTime }) + }, [unit, chartData, startTime, endTime]) const actions = [ { @@ -335,19 +320,13 @@ export function OxqlMetric({ export const MetricHeader = ({ children }: { children: React.ReactNode }) => { // If header has only one child, align it to the end of the container - const value = React.Children.toArray(children).length === 1 ? 'end' : 'between' + const justify = React.Children.count(children) === 1 ? 'justify-end' : 'justify-between' return ( - // prettier-ignore -
+
{children}
) } -export const MetricCollection = ({ children }: { children: React.ReactNode }) => ( -
{children}
-) -export const MetricRow = ({ children }: { children: React.ReactNode }) => - // prettier-ignore -
- {children} -
+export const MetricCollection = classed.div`mt-4 flex flex-col gap-4` + +export const MetricRow = classed.div`flex w-full flex-col gap-4 @[48rem]:flex-row` diff --git a/package-lock.json b/package-lock.json index 3a90617d35..256e197279 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11606,7 +11606,6 @@ "version": "8.5.1", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", - "dev": true, "funding": [ { "type": "opencollective", From c143e717a3eee4d6b2bb40c68aa72be4407bcc70 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 13 Feb 2025 12:00:58 -0600 Subject: [PATCH 31/75] make getOxqlQuery args more generic and structured --- .../tabs/MetricsTab/CpuMetricsTab.tsx | 5 +- .../tabs/MetricsTab/DiskMetricsTab.tsx | 9 ++- .../tabs/MetricsTab/NetworkMetricsTab.tsx | 8 ++- .../tabs/MetricsTab/OxqlMetric.spec.ts | 28 ++++++--- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 63 ++++++++----------- 5 files changed, 58 insertions(+), 55 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx index f310147bce..f1844e5ef1 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx @@ -60,9 +60,8 @@ export function Component() { metricName, startTime, endTime, - instanceId: instanceData.id, - state, - group: true, + eqFilters: { instance_id: instanceData.id, state }, + groupBy: { cols: ['instance_id'], op: 'sum' }, }) return ( diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx index f395a079c8..9306d874c1 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx @@ -99,9 +99,12 @@ export function Component() { metricName, startTime, endTime, - attachedInstanceId: instanceId, - diskId: disk.id === 'all' ? undefined : disk.id, - group: disk.id === 'all', + eqFilters: { + attached_instance_id: instanceId, + disk_id: disk.id === 'all' ? undefined : disk.id, + }, + groupBy: + disk.id === 'all' ? { cols: ['attached_instance_id'], op: 'sum' } : undefined, }) return ( diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx index c8629524a9..03c192e159 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx @@ -71,9 +71,11 @@ export function Component() { metricName, startTime, endTime, - instanceId: instanceData.id, - interfaceId: nic.id === 'all' ? undefined : nic.id, - group: nic.id === 'all', + eqFilters: { + instance_id: instanceData.id, + interface_id: nic.id === 'all' ? undefined : nic.id, + }, + groupBy: nic.id === 'all' ? { cols: ['instance_id'], op: 'sum' } : undefined, }) return ( diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts index 94c35e42eb..bbf8aa62f9 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts @@ -72,7 +72,9 @@ describe('getOxqlQuery', () => { metricName: 'virtual_machine:vcpu_usage', startTime, endTime, - instanceId: 'vm-123', + eqFilters: { + instance_id: 'vm-123', + }, }) expect(query).toBe( 'get virtual_machine:vcpu_usage | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && instance_id == "vm-123" | align mean_within(60s)' @@ -84,7 +86,9 @@ describe('getOxqlQuery', () => { metricName: 'instance_network_interface:bytes_sent', startTime, endTime, - interfaceId: 'eth0', + eqFilters: { + interface_id: 'eth0', + }, }) expect(query).toBe( 'get instance_network_interface:bytes_sent | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && interface_id == "eth0" | align mean_within(60s)' @@ -96,7 +100,9 @@ describe('getOxqlQuery', () => { metricName: 'virtual_machine:vcpu_usage', startTime, endTime, - state: 'run', + eqFilters: { + state: 'run', + }, }) expect(query).toBe( 'get virtual_machine:vcpu_usage | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && state == "run" | align mean_within(60s)' @@ -108,8 +114,10 @@ describe('getOxqlQuery', () => { metricName: 'virtual_disk:bytes_written', startTime, endTime, - group: true, - instanceId: 'vm-123', + eqFilters: { + instance_id: 'vm-123', + }, + groupBy: { cols: ['instance_id'], op: 'sum' }, }) expect(query).toBe( 'get virtual_disk:bytes_written | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && instance_id == "vm-123" | align mean_within(60s) | group_by [instance_id], sum' @@ -121,8 +129,10 @@ describe('getOxqlQuery', () => { metricName: 'virtual_disk:io_latency', startTime, endTime, - group: true, - attachedInstanceId: 'attached-1', + eqFilters: { + attached_instance_id: 'attached-1', + }, + groupBy: { cols: ['attached_instance_id'], op: 'sum' }, }) expect(query).toBe( 'get virtual_disk:io_latency | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && attached_instance_id == "attached-1" | align mean_within(60s) | group_by [attached_instance_id], sum' @@ -145,7 +155,9 @@ describe('getOxqlQuery', () => { metricName: 'instance_network_interface:bytes_received', startTime, endTime, - interfaceId: 'eth0', + eqFilters: { + interface_id: 'eth0', + }, }) expect(query).toBe( 'get instance_network_interface:bytes_received | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && interface_id == "eth0" | align mean_within(60s)' diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 2d485a8199..e2f5532729 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -12,7 +12,6 @@ */ import React, { Suspense, useMemo } from 'react' -import * as R from 'remeda' import { useApiQuery, type ChartDatum, type OxqlQueryResult } from '@oxide/api' @@ -82,33 +81,33 @@ export const getMeanWindow = (start: Date, end: Date, datapoints = 60) => { return `${Math.round(durationSeconds / datapoints)}s` } +type FilterKey = + | 'instance_id' + // for cpu metrics + | 'vcpu_id' + | 'state' + // for disk metrics + | 'disk_id' + | 'attached_instance_id' + // for network metrics + | 'interface_id' + +type GroupByCol = 'instance_id' | 'attached_instance_id' + type GetOxqlQueryParams = { metricName: OxqlMetricName startTime: Date endTime: Date - instanceId?: string - // for cpu metrics - vcpuId?: string - // for disk metrics - diskId?: string - attachedInstanceId?: string - // for network metrics - interfaceId?: string - state?: OxqlVcpuState - group?: boolean + groupBy?: { cols: GroupByCol[]; op: 'sum' } + eqFilters?: Partial> } export const getOxqlQuery = ({ metricName, startTime, endTime, - instanceId, - diskId, - attachedInstanceId, - interfaceId, - vcpuId, - state, - group, + groupBy, + eqFilters = {}, }: GetOxqlQueryParams) => { const meanWindow = getMeanWindow(startTime, endTime) // we adjust the start time back by 2x the mean window so that we can @@ -116,27 +115,15 @@ export const getOxqlQuery = ({ // 2) ensure that the first datapoint we display on the chart matches the actual start time const secondsToAdjust = parseInt(meanWindow, 10) * 2 const adjustedStart = new Date(startTime.getTime() - secondsToAdjust * 1000) - const start = oxqlTimestamp(adjustedStart) - const end = oxqlTimestamp(endTime) const filters = [ - `timestamp >= @${start}`, - `timestamp < @${end}`, - diskId && `disk_id == "${diskId}"`, - vcpuId && `vcpu_id == ${vcpuId}`, - instanceId && `instance_id == "${instanceId}"`, - interfaceId && `interface_id == "${interfaceId}"`, - attachedInstanceId && `attached_instance_id == "${attachedInstanceId}"`, - state && `state == "${state}"`, - ].filter(R.isTruthy) // Removes falsy values - - let groupByString = '' - if (group) { - if (attachedInstanceId) { - groupByString = '| group_by [attached_instance_id], sum' - } else if (instanceId) { - groupByString = '| group_by [instance_id], sum' - } - } + `timestamp >= @${oxqlTimestamp(adjustedStart)}`, + `timestamp < @${oxqlTimestamp(endTime)}`, + ...Object.entries(eqFilters).map(([k, v]) => `${k} == "${v}"`), + ] + + const groupByString = groupBy + ? ` | group_by [${groupBy.cols.join(', ')}], ${groupBy.op}` + : '' const query = `get ${metricName} | filter ${filters.join(' && ')} | align mean_within(${meanWindow})${groupByString}` // console.log(query) From 14372394d2316dad5f02369c8f7d0af4d5f379f0 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 13 Feb 2025 14:19:49 -0600 Subject: [PATCH 32/75] view/copy oxql modal --- .../tabs/MetricsTab/OxqlMetric.spec.ts | 14 ++++++ .../instance/tabs/MetricsTab/OxqlMetric.tsx | 45 ++++++++++++------- app/util/cli-cmd.ts | 12 ----- 3 files changed, 43 insertions(+), 28 deletions(-) delete mode 100644 app/util/cli-cmd.ts diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts index bbf8aa62f9..f97ac7d20d 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts @@ -163,6 +163,20 @@ describe('getOxqlQuery', () => { 'get instance_network_interface:bytes_received | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 && interface_id == "eth0" | align mean_within(60s)' ) }) + + it('leaves out filters that are present but with falsy values', () => { + const query = getOxqlQuery({ + metricName: 'virtual_machine:vcpu_usage', + startTime, + endTime, + eqFilters: { + state: undefined, + }, + }) + expect(query).toBe( + 'get virtual_machine:vcpu_usage | filter timestamp >= @2023-12-31T23:58:00.000 && timestamp < @2024-01-01T01:00:00.000 | align mean_within(60s)' + ) + }) }) test('getOrderOfMagnitude', () => { diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index e2f5532729..3cbfc0198f 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -15,7 +15,7 @@ import React, { Suspense, useMemo } from 'react' import { useApiQuery, type ChartDatum, type OxqlQueryResult } from '@oxide/api' -import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { CopyCode } from '~/components/CopyCode' import { classed } from '~/util/classed' import { getDurationMinutes } from '~/util/date' @@ -94,11 +94,16 @@ type FilterKey = type GroupByCol = 'instance_id' | 'attached_instance_id' -type GetOxqlQueryParams = { +type GroupBy = { + cols: GroupByCol[] + op: 'sum' +} + +type OxqlQuery = { metricName: OxqlMetricName startTime: Date endTime: Date - groupBy?: { cols: GroupByCol[]; op: 'sum' } + groupBy?: GroupBy eqFilters?: Partial> } @@ -108,7 +113,7 @@ export const getOxqlQuery = ({ endTime, groupBy, eqFilters = {}, -}: GetOxqlQueryParams) => { +}: OxqlQuery) => { const meanWindow = getMeanWindow(startTime, endTime) // we adjust the start time back by 2x the mean window so that we can // 1) drop the first datapoint (the cumulative sum of all previous datapoints) @@ -118,7 +123,11 @@ export const getOxqlQuery = ({ const filters = [ `timestamp >= @${oxqlTimestamp(adjustedStart)}`, `timestamp < @${oxqlTimestamp(endTime)}`, - ...Object.entries(eqFilters).map(([k, v]) => `${k} == "${v}"`), + ...Object.entries(eqFilters) + // filter out key present but with falsy value. note that this will also + // filter out empty string, meaning we can't filter by value = "" + .filter(([_, v]) => !!v) + .map(([k, v]) => `${k} == "${v}"`), ] const groupByString = groupBy @@ -263,18 +272,9 @@ export function OxqlMetric({ return getPercentChartProps({ chartData, startTime, endTime }) }, [unit, chartData, startTime, endTime]) - const actions = [ - { - label: 'Copy query', - onActivate() { - window.navigator.clipboard.writeText(query) - }, - }, - ] - return (
-
+

{title}
@@ -283,7 +283,7 @@ export function OxqlMetric({
{description}

{/* TODO: show formatted string to user so they can see it before copying */} - +
}> @@ -317,3 +317,16 @@ export const MetricHeader = ({ children }: { children: React.ReactNode }) => { export const MetricCollection = classed.div`mt-4 flex flex-col gap-4` export const MetricRow = classed.div`flex w-full flex-col gap-4 @[48rem]:flex-row` + +export function OxqlModal({ query }: { query: string }) { + return ( + + {query.split(' | ').join('\n | ').split(' && ').join('\n && ')} + + ) +} diff --git a/app/util/cli-cmd.ts b/app/util/cli-cmd.ts deleted file mode 100644 index 00e413811c..0000000000 --- a/app/util/cli-cmd.ts +++ /dev/null @@ -1,12 +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 - */ - -export const cliCmd = { - serialConsole: ({ project, instance }: { project: string; instance: string }) => - `oxide instance serial console \\\n--project ${project} \\\n--instance ${instance}`, -} From 520ee8b96fec1b2d396384f116d7cdef3fd00256 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 13 Feb 2025 16:07:32 -0600 Subject: [PATCH 33/75] inline oxql query modal, remove comment about showing query --- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 3cbfc0198f..600ee093b9 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -282,8 +282,14 @@ export function OxqlMetric({
{description}
- {/* TODO: show formatted string to user so they can see it before copying */} - + + {query.split(' | ').join('\n | ').split(' && ').join('\n && ')} +
}> @@ -317,16 +323,3 @@ export const MetricHeader = ({ children }: { children: React.ReactNode }) => { export const MetricCollection = classed.div`mt-4 flex flex-col gap-4` export const MetricRow = classed.div`flex w-full flex-col gap-4 @[48rem]:flex-row` - -export function OxqlModal({ query }: { query: string }) { - return ( - - {query.split(' | ').join('\n | ').split(' && ').join('\n && ')} - - ) -} From d1f8e8558b1728c44eb04ec6d19dc953b577ca39 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 13 Feb 2025 16:15:34 -0600 Subject: [PATCH 34/75] NonEmptyArray whaaaaaaat --- .../project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx | 2 +- types/util.d.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 600ee093b9..195b5d69c4 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -95,7 +95,7 @@ type FilterKey = type GroupByCol = 'instance_id' | 'attached_instance_id' type GroupBy = { - cols: GroupByCol[] + cols: NonEmptyArray op: 'sum' } diff --git a/types/util.d.ts b/types/util.d.ts index 421557658c..37b6af163d 100644 --- a/types/util.d.ts +++ b/types/util.d.ts @@ -55,3 +55,5 @@ type Assign = Omit & P2 type NoExtraKeys = FewerKeys & { [K in Exclude]?: never } + +type NonEmptyArray = [T, ...T[]] From a7ba787be4b46eb3e2ec5cbd1f34c022310ae2ca Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 13 Feb 2025 17:06:35 -0600 Subject: [PATCH 35/75] highlight oxql --- app/components/CopyCode.tsx | 4 +- .../tabs/MetricsTab/CpuMetricsTab.tsx | 27 +++--- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 82 +++++++++++++++++-- 3 files changed, 90 insertions(+), 23 deletions(-) diff --git a/app/components/CopyCode.tsx b/app/components/CopyCode.tsx index 63addffa1b..d2cf4b730b 100644 --- a/app/components/CopyCode.tsx +++ b/app/components/CopyCode.tsx @@ -51,7 +51,7 @@ export function CopyCode({ -
+          
             {children}
           
@@ -93,7 +93,7 @@ export function EquivalentCliCommand({ project, instance }: EquivProps) { copyButtonText="Copy command" modalTitle="CLI command" > -
$
+ $ {cmdParts.join(' \\\n')} ) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx index f1844e5ef1..3ba77c8e1e 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx @@ -26,6 +26,7 @@ import { MetricHeader, MetricRow, OxqlMetric, + type OxqlQuery, type OxqlVcpuState, type OxqlVmMetricName, } from './OxqlMetric' @@ -55,14 +56,13 @@ export function Component() { const { startTime, endTime, dateTimeRangePicker, intervalPicker } = useMetricsContext() - const getQuery = (metricName: OxqlVmMetricName, state?: OxqlVcpuState) => - getOxqlQuery({ - metricName, - startTime, - endTime, - eqFilters: { instance_id: instanceData.id, state }, - groupBy: { cols: ['instance_id'], op: 'sum' }, - }) + const getQuery = (metricName: OxqlVmMetricName, state?: OxqlVcpuState): OxqlQuery => ({ + metricName, + startTime, + endTime, + eqFilters: { instance_id: instanceData.id, state }, + groupBy: { cols: ['instance_id'], op: 'sum' }, + }) return ( <> @@ -74,7 +74,8 @@ export function Component() { @@ -82,13 +83,13 @@ export function Component() { @@ -97,13 +98,13 @@ export function Component() { diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 195b5d69c4..f7a071b785 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -16,6 +16,7 @@ import React, { Suspense, useMemo } from 'react' import { useApiQuery, type ChartDatum, type OxqlQueryResult } from '@oxide/api' import { CopyCode } from '~/components/CopyCode' +import { intersperse } from '~/util/array' import { classed } from '~/util/classed' import { getDurationMinutes } from '~/util/date' @@ -38,6 +39,9 @@ export const getChartData = (data: OxqlQueryResult | undefined): ChartDatum[] => const TimeSeriesChart = React.lazy(() => import('~/components/TimeSeriesChart')) +// TODO: we could probably do without the fractional seconds. it would make the +// rendered OxQL look a little nicer + /** convert to UTC and return the timezone-free format required by OxQL */ export const oxqlTimestamp = (date: Date) => date.toISOString().replace('Z', '') @@ -99,7 +103,7 @@ type GroupBy = { op: 'sum' } -type OxqlQuery = { +export type OxqlQuery = { metricName: OxqlMetricName startTime: Date endTime: Date @@ -130,13 +134,69 @@ export const getOxqlQuery = ({ .map(([k, v]) => `${k} == "${v}"`), ] - const groupByString = groupBy - ? ` | group_by [${groupBy.cols.join(', ')}], ${groupBy.op}` - : '' + const query = [ + `get ${metricName}`, + `filter ${filters.join(' && ')}`, + `align mean_within(${meanWindow})`, + ] + + if (groupBy) query.push(`group_by [${groupBy.cols.join(', ')}], ${groupBy.op}`) + + return query.join(' | ') +} + +const Keyword = classed.span`text-[#C6A5EA]` // purple +// light green +/** Includes
, two space indent, and pipe. no trailing space */ +const NewlinePipe = () => ( + <> +
+ {' '} + | + +) +const StringLit = classed.span`text-[#68D9A7]` // green +const NumLit = classed.span`text-[#EDD5A6]` // light yellow + +const FilterSep = () => ( + <> +
+ {' && '} + +) + +export function HighlightedOxqlQuery({ + metricName, + startTime, + endTime, + groupBy, + eqFilters = {}, +}: OxqlQuery) { + const meanWindow = getMeanWindow(startTime, endTime) + const secondsToAdjust = parseInt(meanWindow, 10) * 2 + const adjustedStart = new Date(startTime.getTime() - secondsToAdjust * 1000) + // prettier-ignore + const filters = [ + <>timestamp >= @{oxqlTimestamp(adjustedStart)}, + <>timestamp < @{oxqlTimestamp(endTime)}, + ...Object.entries(eqFilters) + .filter(([_, v]) => !!v) + .map(([k, v]) => <>{k} == "{v}"), + ] - const query = `get ${metricName} | filter ${filters.join(' && ')} | align mean_within(${meanWindow})${groupByString}` - // console.log(query) - return query + return ( + <> + get {metricName} + filter {intersperse(filters, )} + align mean_within({meanWindow}) + {groupBy && ( + <> + group_by [{groupBy.cols.join(', ')}],{' '} + {groupBy.op} + + )} + + ) } export type ChartUnitType = 'Bytes' | '%' | 'Count' @@ -246,12 +306,14 @@ export function OxqlMetric({ query, startTime, endTime, + q, }: { title: string description?: string query: string startTime: Date endTime: Date + q?: OxqlQuery }) { const { data: metrics } = useApiQuery( 'systemTimeseriesQuery', @@ -288,7 +350,11 @@ export function OxqlMetric({ copyButtonText="Copy query" modalTitle="OxQL query" > - {query.split(' | ').join('\n | ').split(' && ').join('\n && ')} + {q ? ( + + ) : ( + query.split(' | ').join('\n | ').split(' && ').join('\n && ') + )}
From b518077a8f7b79fe19f84a9fcccc78dde1d01e20 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 13 Feb 2025 16:57:44 -0800 Subject: [PATCH 36/75] Add 'More about OxQL queries' button/link to modal --- app/components/CopyCode.tsx | 18 +++++++++++++++--- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 6 +++--- app/util/links.ts | 1 + 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/app/components/CopyCode.tsx b/app/components/CopyCode.tsx index d2cf4b730b..8adf7661cf 100644 --- a/app/components/CopyCode.tsx +++ b/app/components/CopyCode.tsx @@ -5,13 +5,15 @@ * * Copyright Oxide Computer Company */ +import cn from 'classnames' import { useState, type ReactNode } from 'react' -import { Success12Icon } from '@oxide/design-system/icons/react' +import { OpenLink12Icon, Success12Icon } from '@oxide/design-system/icons/react' -import { Button } from '~/ui/lib/Button' +import { Button, buttonStyle } from '~/ui/lib/Button' import { Modal } from '~/ui/lib/Modal' import { useTimeout } from '~/ui/lib/use-timeout' +import { links } from '~/util/links' type CopyCodeProps = { code: string @@ -71,7 +73,17 @@ export function CopyCode({ } - /> + > + + More about OxQL queries + + + ) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index f7a071b785..319d3a5cdf 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -177,11 +177,11 @@ export function HighlightedOxqlQuery({ const adjustedStart = new Date(startTime.getTime() - secondsToAdjust * 1000) // prettier-ignore const filters = [ - <>timestamp >= @{oxqlTimestamp(adjustedStart)}, - <>timestamp < @{oxqlTimestamp(endTime)}, + timestamp >= @{oxqlTimestamp(adjustedStart)}, + timestamp < @{oxqlTimestamp(endTime)}, ...Object.entries(eqFilters) .filter(([_, v]) => !!v) - .map(([k, v]) => <>{k} == "{v}"), + .map(([k, v]) => {k} == "{v}"), ] return ( diff --git a/app/util/links.ts b/app/util/links.ts index aecb1abf3d..e03ed3e9e9 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -32,6 +32,7 @@ export const links = { 'https://docs.oxide.computer/guides/key-entities-and-concepts#iam-policy', keyConceptsProjectsDocs: 'https://docs.oxide.computer/guides/key-entities-and-concepts#_projects', + oxqlDocs: 'https://docs.oxide.computer/guides/operator/system-metrics#_oxql_quickstart', projectsDocs: 'https://docs.oxide.computer/guides/onboarding-projects', quickStart: 'https://docs.oxide.computer/guides/quickstart', routersDocs: From 17446b012ab08499ef609fbc3b738a94113cf76d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 13 Feb 2025 18:04:41 -0600 Subject: [PATCH 37/75] test for rendered oxql in modal --- ...OxqlMetric.spec.ts => OxqlMetric.spec.tsx} | 65 +++++++++++++++++++ .../instance/tabs/MetricsTab/OxqlMetric.tsx | 44 ++++++------- 2 files changed, 85 insertions(+), 24 deletions(-) rename app/pages/project/instances/instance/tabs/MetricsTab/{OxqlMetric.spec.ts => OxqlMetric.spec.tsx} (79%) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.tsx similarity index 79% rename from app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts rename to app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.tsx index f97ac7d20d..dc43a3603d 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.ts +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.tsx @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { render } from '@testing-library/react' import { describe, expect, it, test } from 'vitest' import { @@ -13,6 +14,7 @@ import { getOrderOfMagnitude, getOxqlQuery, getUnit, + HighlightedOxqlQuery, oxqlTimestamp, yAxisLabelForCountChart, } from './OxqlMetric' @@ -179,6 +181,69 @@ describe('getOxqlQuery', () => { }) }) +describe('HighlightedOxqlQuery indentation', () => { + const startTime = new Date('2024-01-01T00:00:00Z') + const endTime = new Date('2024-01-01T01:00:00Z') + it('no filters', () => { + const pre = render( + + ) + // we have to do the assert this way because toHaveTextContent did not preserve the newlines + expect(pre.container.textContent).toMatchInlineSnapshot(` + "get virtual_machine:vcpu_usage + | filter timestamp >= @2023-12-31T23:58:00.000 + && timestamp < @2024-01-01T01:00:00.000 + | align mean_within(60s)" + `) + }) + + it('with filters', () => { + const pre = render( + + ) + expect(pre.container.textContent).toMatchInlineSnapshot(` + "get virtual_machine:vcpu_usage + | filter timestamp >= @2023-12-31T23:58:00.000 + && timestamp < @2024-01-01T01:00:00.000 + && instance_id == "an-instance-id" + && vcpu_id == "a-cpu-id" + | align mean_within(60s)" + `) + }) + + it('with groupby', () => { + const pre = render( + + ) + expect(pre.container.textContent).toMatchInlineSnapshot(` + "get virtual_machine:vcpu_usage + | filter timestamp >= @2023-12-31T23:58:00.000 + && timestamp < @2024-01-01T01:00:00.000 + && instance_id == "an-instance-id" + | align mean_within(60s) + | group_by [instance_id], sum" + `) + }) +}) + test('getOrderOfMagnitude', () => { expect(getOrderOfMagnitude(5, 1000)).toEqual(0) expect(getOrderOfMagnitude(1000, 1000)).toEqual(1) diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 319d3a5cdf..92ac06a0b9 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -11,7 +11,7 @@ * https://github.com/oxidecomputer/omicron/tree/main/oximeter/oximeter/schema */ -import React, { Suspense, useMemo } from 'react' +import React, { Fragment, Suspense, useMemo } from 'react' import { useApiQuery, type ChartDatum, type OxqlQueryResult } from '@oxide/api' @@ -146,24 +146,11 @@ export const getOxqlQuery = ({ } const Keyword = classed.span`text-[#C6A5EA]` // purple -// light green -/** Includes
, two space indent, and pipe. no trailing space */ -const NewlinePipe = () => ( - <> -
- {' '} - | - -) +const NewlinePipe = () => {'\n | '} // light green const StringLit = classed.span`text-[#68D9A7]` // green const NumLit = classed.span`text-[#EDD5A6]` // light yellow -const FilterSep = () => ( - <> -
- {' && '} - -) +const FilterSep = () => '\n && ' export function HighlightedOxqlQuery({ metricName, @@ -175,24 +162,33 @@ export function HighlightedOxqlQuery({ const meanWindow = getMeanWindow(startTime, endTime) const secondsToAdjust = parseInt(meanWindow, 10) * 2 const adjustedStart = new Date(startTime.getTime() - secondsToAdjust * 1000) - // prettier-ignore const filters = [ - timestamp >= @{oxqlTimestamp(adjustedStart)}, - timestamp < @{oxqlTimestamp(endTime)}, + + timestamp >= @{oxqlTimestamp(adjustedStart)} + , + + timestamp < @{oxqlTimestamp(endTime)} + , ...Object.entries(eqFilters) .filter(([_, v]) => !!v) - .map(([k, v]) => {k} == "{v}"), + .map(([k, v]) => ( + + {k} == "{v}" + + )), ] return ( <> get {metricName} - filter {intersperse(filters, )} - align mean_within({meanWindow}) + + filter {intersperse(filters, )} + + align mean_within({meanWindow}) {groupBy && ( <> - group_by [{groupBy.cols.join(', ')}],{' '} - {groupBy.op} + + group_by [{groupBy.cols.join(', ')}], {groupBy.op} )} From 3d31bd25e38c2232e05d7a7c736ef9af28f0d0d6 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 13 Feb 2025 17:34:42 -0800 Subject: [PATCH 38/75] Better link style for OxQL docs --- app/components/CopyCode.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/app/components/CopyCode.tsx b/app/components/CopyCode.tsx index 8adf7661cf..ff2e40ce3b 100644 --- a/app/components/CopyCode.tsx +++ b/app/components/CopyCode.tsx @@ -5,13 +5,13 @@ * * Copyright Oxide Computer Company */ -import cn from 'classnames' import { useState, type ReactNode } from 'react' -import { OpenLink12Icon, Success12Icon } from '@oxide/design-system/icons/react' +import { Success12Icon } from '@oxide/design-system/icons/react' -import { Button, buttonStyle } from '~/ui/lib/Button' +import { Button } from '~/ui/lib/Button' import { Modal } from '~/ui/lib/Modal' +import { LearnMore } from '~/ui/lib/SettingsGroup' import { useTimeout } from '~/ui/lib/use-timeout' import { links } from '~/util/links' @@ -74,15 +74,9 @@ export function CopyCode({ } > - - More about OxQL queries - - + + + From 4614ac8e3ffb61a20595b237084ba06570f6c27a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 13 Feb 2025 17:37:43 -0800 Subject: [PATCH 39/75] slightly smaller text --- app/components/CopyCode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/CopyCode.tsx b/app/components/CopyCode.tsx index ff2e40ce3b..385f5924c4 100644 --- a/app/components/CopyCode.tsx +++ b/app/components/CopyCode.tsx @@ -74,7 +74,7 @@ export function CopyCode({ } > - + From aefe263874d2f96c1fbfa8616fb5693968f223a4 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 13 Feb 2025 19:58:01 -0600 Subject: [PATCH 40/75] clean up my weird half-finished metrics props change --- app/components/CopyCode.tsx | 2 +- .../tabs/MetricsTab/CpuMetricsTab.tsx | 51 +++---- .../tabs/MetricsTab/DiskMetricsTab.tsx | 130 ++++++++---------- .../tabs/MetricsTab/NetworkMetricsTab.tsx | 55 ++++---- .../tabs/MetricsTab/OxqlMetric.spec.tsx | 22 +-- .../instance/tabs/MetricsTab/OxqlMetric.tsx | 62 +++------ types/util.d.ts | 2 +- 7 files changed, 134 insertions(+), 190 deletions(-) diff --git a/app/components/CopyCode.tsx b/app/components/CopyCode.tsx index 385f5924c4..65b2a26e77 100644 --- a/app/components/CopyCode.tsx +++ b/app/components/CopyCode.tsx @@ -100,7 +100,7 @@ export function EquivalentCliCommand({ project, instance }: EquivProps) { modalTitle="CLI command" > $ - {cmdParts.join(' \\\n')} + {cmdParts.join(' \\\n ')} ) } diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx index 3ba77c8e1e..8e210fc1a8 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/CpuMetricsTab.tsx @@ -13,6 +13,7 @@ * * Copyright Oxide Computer Company */ +import { useMemo } from 'react' import { type LoaderFunctionArgs } from 'react-router' import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' @@ -20,16 +21,7 @@ import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { useMetricsContext } from '../MetricsTab' -import { - getOxqlQuery, - MetricCollection, - MetricHeader, - MetricRow, - OxqlMetric, - type OxqlQuery, - type OxqlVcpuState, - type OxqlVmMetricName, -} from './OxqlMetric' +import { MetricCollection, MetricHeader, MetricRow, OxqlMetric } from './OxqlMetric' export async function loader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) @@ -56,13 +48,16 @@ export function Component() { const { startTime, endTime, dateTimeRangePicker, intervalPicker } = useMetricsContext() - const getQuery = (metricName: OxqlVmMetricName, state?: OxqlVcpuState): OxqlQuery => ({ - metricName, + const queryBase = { + metricName: 'virtual_machine:vcpu_usage' as const, startTime, endTime, - eqFilters: { instance_id: instanceData.id, state }, - groupBy: { cols: ['instance_id'], op: 'sum' }, - }) + groupBy: { cols: ['instance_id'], op: 'sum' } as const, + } + + // all this memoization is ridiculous, but we need the filters referentially + // table or everything will re-render too much + const filterBase = useMemo(() => ({ instance_id: instanceData.id }), [instanceData.id]) return ( <> @@ -74,39 +69,33 @@ export function Component() { ({ ...filterBase, state: 'run' }), [filterBase])} + {...queryBase} /> ({ ...filterBase, state: 'idle' }), [filterBase])} + {...queryBase} /> ({ ...filterBase, state: 'waiting' }), [filterBase])} + {...queryBase} /> ({ ...filterBase, state: 'emulation' }), [filterBase])} + {...queryBase} /> diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx index 9306d874c1..fea50bb325 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx @@ -16,7 +16,13 @@ import { useMemo, useState } from 'react' import { type LoaderFunctionArgs } from 'react-router' -import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' +import { + apiq, + apiQueryClient, + Instance, + usePrefetchedApiQuery, + type Disk, +} from '@oxide/api' import { Storage24Icon } from '@oxide/design-system/icons/react' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' @@ -26,19 +32,13 @@ import { TableEmptyBox } from '~/ui/lib/Table' import { useMetricsContext } from '../MetricsTab' import { - getOxqlQuery, MetricCollection, MetricHeader, MetricRow, OxqlMetric, - type OxqlDiskMetricName, + type OxqlQuery, } from './OxqlMetric' -// We could figure out how to prefetch the metrics data, but it would be -// annoying because it relies on the default date range, plus there are 5 calls. -// Considering the data is going to be swapped out as soon as they change the -// date range, I'm inclined to punt. - export async function loader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) await Promise.all([ @@ -54,10 +54,13 @@ export async function loader({ params }: LoaderFunctionArgs) { return null } +// out here so we don't have to memoize it +const groupByAttachedInstanceId = { cols: ['attached_instance_id'], op: 'sum' } as const + Component.displayName = 'DiskMetricsTab' export function Component() { const { project, instance } = useInstanceSelector() - const { data: diskData } = usePrefetchedApiQuery('instanceDiskList', { + const { data: disks } = usePrefetchedApiQuery('instanceDiskList', { path: { instance }, query: { project }, }) @@ -65,24 +68,7 @@ export function Component() { path: { instance }, query: { project }, }) - const instanceId = instanceData?.id - const disks = useMemo( - () => [ - { name: 'All disks', id: 'all' }, - ...diskData.items.map(({ name, id }) => ({ name, id })), - ], - [diskData] - ) - - const { startTime, endTime, dateTimeRangePicker, intervalPicker } = useMetricsContext() - - // The fallback here is kind of silly — it is only invoked when there are no - // disks, in which case we show the fallback UI and diskName is never used. We - // only need to do it this way because hooks cannot be called conditionally. - const [disk, setDisk] = useState(disks[0]) - const items = disks.map(({ name, id }) => ({ label: name, value: id })) - - if (disks.length === 0) { + if (disks.items.length === 0) { return ( - getOxqlQuery({ - metricName, - startTime, - endTime, - eqFilters: { - attached_instance_id: instanceId, - disk_id: disk.id === 'all' ? undefined : disk.id, - }, - groupBy: - disk.id === 'all' ? { cols: ['attached_instance_id'], op: 'sum' } : undefined, - }) + return +} + +/** Only rendered if there is at least one disk in the list */ +function DiskMetrics({ disks, instance }: { disks: Disk[]; instance: Instance }) { + const { startTime, endTime, dateTimeRangePicker, intervalPicker } = useMetricsContext() + + const diskItems = useMemo( + () => [ + { label: 'All disks', value: 'all' }, + ...disks.map(({ name, id }) => ({ label: name, value: id })), + ], + [disks] + ) + + const [selectedDisk, setSelectedDisk] = useState(diskItems.at(0)!.value) + + const queryBase: Omit = { + startTime, + endTime, + eqFilters: useMemo( + () => ({ + attached_instance_id: instance.id, + disk_id: selectedDisk === 'all' ? undefined : selectedDisk, + }), + [instance.id, selectedDisk] + ), + groupBy: selectedDisk === 'all' ? groupByAttachedInstanceId : undefined, + } return ( <>
{intervalPicker} - - {disks.length > 2 && ( - { - setDisk({ - name: disks.find((n) => n.id === val)?.name || 'All disks', - id: val, - }) - }} - /> - )} + setSelectedDisk(value)} + />
{dateTimeRangePicker}
@@ -139,16 +134,14 @@ export function Component() { @@ -156,16 +149,14 @@ export function Component() { @@ -173,9 +164,8 @@ export function Component() { diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx index 03c192e159..c6ea03e5ed 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/NetworkMetricsTab.tsx @@ -16,14 +16,7 @@ import { Listbox } from '~/ui/lib/Listbox' import { ALL_ISH } from '~/util/consts' import { useMetricsContext } from '../MetricsTab' -import { - getOxqlQuery, - MetricCollection, - MetricHeader, - MetricRow, - OxqlMetric, - type OxqlNetworkMetricName, -} from './OxqlMetric' +import { MetricCollection, MetricHeader, MetricRow, OxqlMetric } from './OxqlMetric' export async function loader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) @@ -44,6 +37,8 @@ export async function loader({ params }: LoaderFunctionArgs) { return null } +const groupByInstanceId = { cols: ['instance_id'], op: 'sum' } as const + Component.displayName = 'NetworkMetricsTab' export function Component() { const { project, instance } = useInstanceSelector() @@ -66,17 +61,18 @@ export function Component() { const [nic, setNic] = useState(networks[0]) const items = networks.map(({ name, id }) => ({ label: name, value: id })) - const getQuery = (metricName: OxqlNetworkMetricName) => - getOxqlQuery({ - metricName, - startTime, - endTime, - eqFilters: { + const queryBase = { + startTime, + endTime, + eqFilters: useMemo( + () => ({ instance_id: instanceData.id, interface_id: nic.id === 'all' ? undefined : nic.id, - }, - groupBy: nic.id === 'all' ? { cols: ['instance_id'], op: 'sum' } : undefined, - }) + }), + [instanceData.id, nic.id] + ), + groupBy: nic.id === 'all' ? groupByInstanceId : undefined, + } return ( <> @@ -107,40 +103,35 @@ export function Component() { diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.tsx index dc43a3603d..4b945586dc 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.spec.tsx @@ -12,10 +12,10 @@ import { getLargestValue, getMeanWindow, getOrderOfMagnitude, - getOxqlQuery, getUnit, HighlightedOxqlQuery, oxqlTimestamp, + toOxqlStr, yAxisLabelForCountChart, } from './OxqlMetric' @@ -55,11 +55,11 @@ describe('getMeanWindow', () => { }) }) -describe('getOxqlQuery', () => { +describe('toOxqlStr', () => { const startTime = new Date('2024-01-01T00:00:00Z') const endTime = new Date('2024-01-01T01:00:00Z') it('generates a query for disk metrics without extra filters', () => { - const query = getOxqlQuery({ + const query = toOxqlStr({ metricName: 'virtual_disk:bytes_read', startTime, endTime, @@ -70,7 +70,7 @@ describe('getOxqlQuery', () => { }) it('generates a query for vm metrics with instanceId filter', () => { - const query = getOxqlQuery({ + const query = toOxqlStr({ metricName: 'virtual_machine:vcpu_usage', startTime, endTime, @@ -84,7 +84,7 @@ describe('getOxqlQuery', () => { }) it('generates a query for network metrics with interfaceId filter', () => { - const query = getOxqlQuery({ + const query = toOxqlStr({ metricName: 'instance_network_interface:bytes_sent', startTime, endTime, @@ -98,7 +98,7 @@ describe('getOxqlQuery', () => { }) it('generates a query with vcpu state filter', () => { - const query = getOxqlQuery({ + const query = toOxqlStr({ metricName: 'virtual_machine:vcpu_usage', startTime, endTime, @@ -112,7 +112,7 @@ describe('getOxqlQuery', () => { }) it('generates a query with group by instanceId', () => { - const query = getOxqlQuery({ + const query = toOxqlStr({ metricName: 'virtual_disk:bytes_written', startTime, endTime, @@ -127,7 +127,7 @@ describe('getOxqlQuery', () => { }) it('generates a query with group by attachedInstanceId', () => { - const query = getOxqlQuery({ + const query = toOxqlStr({ metricName: 'virtual_disk:io_latency', startTime, endTime, @@ -142,7 +142,7 @@ describe('getOxqlQuery', () => { }) it('handles missing optional parameters gracefully', () => { - const query = getOxqlQuery({ + const query = toOxqlStr({ metricName: 'virtual_disk:flushes', startTime, endTime, @@ -153,7 +153,7 @@ describe('getOxqlQuery', () => { }) it('correctly handles a range of disk and network metrics', () => { - const query = getOxqlQuery({ + const query = toOxqlStr({ metricName: 'instance_network_interface:bytes_received', startTime, endTime, @@ -167,7 +167,7 @@ describe('getOxqlQuery', () => { }) it('leaves out filters that are present but with falsy values', () => { - const query = getOxqlQuery({ + const query = toOxqlStr({ metricName: 'virtual_machine:vcpu_usage', startTime, endTime, diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 92ac06a0b9..774a57c189 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -111,7 +111,7 @@ export type OxqlQuery = { eqFilters?: Partial> } -export const getOxqlQuery = ({ +export const toOxqlStr = ({ metricName, startTime, endTime, @@ -210,10 +210,6 @@ export const getLargestValue = (data: ChartDatum[]) => export const getOrderOfMagnitude = (largestValue: number, base: number) => Math.max(Math.floor(Math.log(largestValue) / Math.log(base)), 0) -// These need better names -// What each function will receive -type OxqlMetricChartComponentsProps = { chartData: ChartDatum[] } - // What each function will return type OxqlMetricChartProps = { data: ChartDatum[] @@ -222,9 +218,7 @@ type OxqlMetricChartProps = { yAxisTickFormatter: (n: number) => string } -export const getBytesChartProps = ({ - chartData, -}: OxqlMetricChartComponentsProps): OxqlMetricChartProps => { +const getBytesChartProps = (chartData: ChartDatum[]): OxqlMetricChartProps => { // Bytes charts use 1024 as the base const base = 1024 const byteUnits = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'] @@ -259,9 +253,7 @@ export const yAxisLabelForCountChart = (val: number, orderOfMagnitude: number) = * which requires knowing the largest value in the dataset, to get a consistent scale. * The data isn't modified for Count charts; the label and unitForSet are basic as well. */ -export const getCountChartProps = ({ - chartData, -}: OxqlMetricChartComponentsProps): OxqlMetricChartProps => { +export const getCountChartProps = (chartData: ChartDatum[]): OxqlMetricChartProps => { const largestValue = getLargestValue(chartData) const orderOfMagnitude = getOrderOfMagnitude(largestValue, 1_000) const yAxisTickFormatter = (val: number) => yAxisLabelForCountChart(val, orderOfMagnitude) @@ -281,14 +273,11 @@ export const getPercentDivisor = (startTime: Date, endTime: Date) => { * can be dynamic in terms of the `mean_within` window, we use that value to determine the * divisor for the data. */ -export const getPercentChartProps = ({ - chartData, - startTime, - endTime, -}: OxqlMetricChartComponentsProps & { - startTime: Date +export const getPercentChartProps = ( + chartData: ChartDatum[], + startTime: Date, endTime: Date -}): OxqlMetricChartProps => { +): OxqlMetricChartProps => { const data = chartData.map(({ timestamp, value }) => ({ timestamp, value: value / getPercentDivisor(startTime, endTime), @@ -296,38 +285,27 @@ export const getPercentChartProps = ({ return { data, label: '(%)', unitForSet: '%', yAxisTickFormatter: (n: number) => `${n}%` } } -export function OxqlMetric({ - title, - description, - query, - startTime, - endTime, - q, -}: { +type OxqlMetricProps = OxqlQuery & { title: string description?: string - query: string - startTime: Date - endTime: Date - q?: OxqlQuery -}) { +} + +export function OxqlMetric({ title, description, ...queryObj }: OxqlMetricProps) { + const query = toOxqlStr(queryObj) const { data: metrics } = useApiQuery( 'systemTimeseriesQuery', { body: { query } }, // avoid graphs flashing blank while loading when you change the time { placeholderData: (x) => x } ) + + const { startTime, endTime } = queryObj const chartData: ChartDatum[] = useMemo(() => getChartData(metrics), [metrics]) - // console.log(title, query, chartData) const unit = getUnit(title) const { data, label, unitForSet, yAxisTickFormatter } = useMemo(() => { - if (unit === 'Bytes') { - return getBytesChartProps({ chartData }) - } - if (unit === 'Count') { - return getCountChartProps({ chartData }) - } - return getPercentChartProps({ chartData, startTime, endTime }) + if (unit === 'Bytes') return getBytesChartProps(chartData) + if (unit === 'Count') return getCountChartProps(chartData) + return getPercentChartProps(chartData, startTime, endTime) }, [unit, chartData, startTime, endTime]) return ( @@ -346,11 +324,7 @@ export function OxqlMetric({ copyButtonText="Copy query" modalTitle="OxQL query" > - {q ? ( - - ) : ( - query.split(' | ').join('\n | ').split(' && ').join('\n && ') - )} +
diff --git a/types/util.d.ts b/types/util.d.ts index 37b6af163d..cca81fc35d 100644 --- a/types/util.d.ts +++ b/types/util.d.ts @@ -56,4 +56,4 @@ type NoExtraKeys = FewerKeys & { [K in Exclude]?: never } -type NonEmptyArray = [T, ...T[]] +type NonEmptyArray = readonly [T, ...T[]] From d476b3aaf5c573b5f4bd61fae7e8648f882b5d7e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 13 Feb 2025 21:54:13 -0600 Subject: [PATCH 41/75] CopyCode footer --- app/components/CopyCode.tsx | 8 +++----- .../instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx | 8 +------- .../instances/instance/tabs/MetricsTab/OxqlMetric.tsx | 3 +++ app/ui/lib/Modal.tsx | 2 +- app/ui/lib/SettingsGroup.tsx | 4 ++-- 5 files changed, 10 insertions(+), 15 deletions(-) diff --git a/app/components/CopyCode.tsx b/app/components/CopyCode.tsx index 65b2a26e77..930be06651 100644 --- a/app/components/CopyCode.tsx +++ b/app/components/CopyCode.tsx @@ -11,15 +11,14 @@ import { Success12Icon } from '@oxide/design-system/icons/react' import { Button } from '~/ui/lib/Button' import { Modal } from '~/ui/lib/Modal' -import { LearnMore } from '~/ui/lib/SettingsGroup' import { useTimeout } from '~/ui/lib/use-timeout' -import { links } from '~/util/links' type CopyCodeProps = { code: string modalButtonText: string copyButtonText: string modalTitle: string + footer?: ReactNode /** rendered code */ children?: ReactNode } @@ -30,6 +29,7 @@ export function CopyCode({ copyButtonText, modalTitle, children, + footer, }: CopyCodeProps) { const [isOpen, setIsOpen] = useState(false) const [hasCopied, setHasCopied] = useState(false) @@ -74,9 +74,7 @@ export function CopyCode({ } > - - - + {footer} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx index fea50bb325..dc4b519ab3 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/DiskMetricsTab.tsx @@ -16,13 +16,7 @@ import { useMemo, useState } from 'react' import { type LoaderFunctionArgs } from 'react-router' -import { - apiq, - apiQueryClient, - Instance, - usePrefetchedApiQuery, - type Disk, -} from '@oxide/api' +import { apiQueryClient, usePrefetchedApiQuery, type Disk, type Instance } from '@oxide/api' import { Storage24Icon } from '@oxide/design-system/icons/react' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' diff --git a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx index 774a57c189..6f106f5ce9 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab/OxqlMetric.tsx @@ -16,9 +16,11 @@ import React, { Fragment, Suspense, useMemo } from 'react' import { useApiQuery, type ChartDatum, type OxqlQueryResult } from '@oxide/api' import { CopyCode } from '~/components/CopyCode' +import { LearnMore } from '~/ui/lib/SettingsGroup' import { intersperse } from '~/util/array' import { classed } from '~/util/classed' import { getDurationMinutes } from '~/util/date' +import { links } from '~/util/links' // An OxQL Query Result can have multiple tables, but in the web console we only ever call // aligned timeseries queries, which always have exactly one table. @@ -323,6 +325,7 @@ export function OxqlMetric({ title, description, ...queryObj }: OxqlMetricProps) modalButtonText="OxQL" copyButtonText="Copy query" modalTitle="OxQL query" + footer={} > diff --git a/app/ui/lib/Modal.tsx b/app/ui/lib/Modal.tsx index 869ed33476..ba48886dde 100644 --- a/app/ui/lib/Modal.tsx +++ b/app/ui/lib/Modal.tsx @@ -126,7 +126,7 @@ Modal.Footer = ({ disabled = false, formId, }: FooterProps) => ( -