Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion OMICRON_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5438a551ec03db97f67da170882ba458ed708280
4cf991a10b8919625f3358fa4e0eb978eeda8da9
58 changes: 31 additions & 27 deletions app/components/SystemMetric.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
// import type { SystemMetricName } from '@oxide/api'
// import { useApiQuery } from '@oxide/api'
import type { MeasurementResultsPage } from '@oxide/api'
import React, { Suspense } from 'react'

import type { SystemMetricName } from '@oxide/api'
import { useApiQuery } from '@oxide/api'
import { Spinner } from '@oxide/ui'

import { TimeSeriesAreaChart } from './TimeSeriesChart'
const TimeSeriesChart = React.lazy(() => import('./TimeSeriesChart'))

type SystemMetricProps = {
title: string
startTime: Date
endTime: Date
metricName: string
// metricName: SystemMetricName
metricName: SystemMetricName
/** Resource to filter data by. Can be fleet, silo, org, project. */
filterId: string
valueTransform?: (n: number) => number
Expand All @@ -20,22 +20,22 @@ export function SystemMetric({
title,
startTime,
endTime,
metricName,
filterId,
valueTransform = (x) => x,
}: SystemMetricProps) {
// 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(
// 'systemMetric',
// { id: filterId, metricName, startTime, endTime },
// {
// // TODO: this is actually kind of useless unless the time interval slides forward as time passes
// refetchInterval: 5000,
// // avoid graphs flashing blank while loading when you change the time
// keepPreviousData: true,
// }
// )
const metrics: MeasurementResultsPage = { items: [] }
const isLoading = false
const { data: metrics, isLoading } = useApiQuery(
'systemMetric',
{ path: { metricName }, query: { id: filterId, startTime, endTime } },
{
// TODO: this is actually kind of useless unless the time interval slides forward as time passes
refetchInterval: 5000,
// avoid graphs flashing blank while loading when you change the time
keepPreviousData: true,
}
)

const data = (metrics?.items || []).map(({ datum, timestamp }) => ({
timestamp: timestamp.getTime(),
Expand Down Expand Up @@ -65,15 +65,19 @@ export function SystemMetric({
<h2 className="flex items-center text-mono-md text-secondary">
{title} {isLoading && <Spinner className="ml-2" />}
</h2>
{/* TODO: this is supposed to be full width */}
<TimeSeriesAreaChart
className="mt-4"
data={data}
title={title}
width={480}
height={240}
interpolation="stepAfter"
/>
{/* TODO: proper skeleton for empty chart */}
<Suspense fallback={<div className="mt-4 h-[300px]" />}>
<TimeSeriesChart
className="mt-4"
data={data}
title={title}
width={480}
height={240}
interpolation="stepAfter"
startTime={startTime}
endTime={endTime}
/>
</Suspense>
</div>
)
}
99 changes: 57 additions & 42 deletions app/components/TimeSeriesChart.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import cn from 'classnames'
import { format } from 'date-fns'
import {
Area,
AreaChart,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
Expand All @@ -19,25 +20,42 @@ function getTicks(data: { timestamp: number }[], n: number): number[] {
if (data.length === 0) return []
if (n < 2) throw Error('n must be at least 2 because of the start and end ticks')
// bring the last tick in a bit from the end
const maxIdx = data.length > 10 ? Math.floor((data.length - 1) * 0.9) : data.length - 1
const maxIdx = data.length > 10 ? Math.floor((data.length - 1) * 0.8) : data.length - 1
const startOffset = Math.floor((data.length - maxIdx) * 0.6)
// if there are 4 ticks, their positions are 0/3, 1/3, 2/3, 3/3 (as fractions of maxIdx)
const idxs = new Array(n).fill(0).map((_, i) => Math.floor((maxIdx * i) / (n - 1)))
const idxs = new Array(n)
.fill(0)
.map((_, i) => Math.floor((maxIdx * i) / (n - 1) + startOffset))
return idxs.map((i) => data[i].timestamp)
}

/**
* Check if the start and end time are on the same day
* If they are we can omit the day/month in the date time format
*/
function isSameDay(d1: Date, d2: Date) {
return (
d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate()
)
}

const shortDateTime = (ts: number) => format(new Date(ts), 'M/d HH:mm')
const shortTime = (ts: number) => format(new Date(ts), 'HH:mm')
const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy HH:mm:ss zz')

// TODO: change these to theme colors so they work in light mode
const LIGHT_GRAY = 'var(--stroke-default)'
const GRID_GRAY = 'var(--stroke-secondary)'
const GREEN = 'var(--base-green-500)'
const GREEN_400 = 'var(--theme-accent-400)'
const GREEN_600 = 'var(--theme-accent-600)'
const GREEN_800 = 'var(--theme-accent-800)'

// TODO: figure out how to do this with TW classes instead. As far as I can tell
// ticks only take direct styling
const textMonoMd = {
fontSize: '0.75rem',
fontSize: '0.6875rem',
fontFamily: '"GT America Mono", monospace',
fill: 'var(--content-quaternary)',
}

function renderTooltip(props: TooltipProps<number, string>) {
Expand All @@ -50,10 +68,10 @@ function renderTooltip(props: TooltipProps<number, string>) {
} = payload[0]
if (!timestamp || !value) return null
return (
<div className="border outline-0 text-sans-md text-secondary bg-raise border-secondary">
<div className="rounded border outline-0 text-sans-md text-tertiary bg-raise border-secondary elevation-2">
<div className="border-b py-2 px-3 border-secondary">{longDateTime(timestamp)}</div>
<div className="py-2 px-3">
<div className="text-default">{name}</div>
<div className="text-secondary">{name}</div>
<div>{value}</div>
{/* TODO: unit on value if relevant */}
</div>
Expand All @@ -75,45 +93,33 @@ type Props = {
width: number
height: number
interpolation?: 'linear' | 'stepAfter'
customXTicks?: boolean
startTime: Date
endTime: Date
}

// Limitations
// - Only one dataset — can't do overlapping area chart yet

export function TimeSeriesAreaChart({
export default function TimeSeriesChart({
className,
data,
title,
width,
height,
interpolation = 'linear',
customXTicks,
startTime,
endTime,
}: Props) {
return (
<ResponsiveContainer width="100%" height={300}>
<AreaChart
<LineChart
width={width}
height={height}
data={data}
margin={{ top: 0, right: 20, bottom: 5, left: 0 }}
className={className}
margin={{ top: 0, right: 0, bottom: 16, left: 0 }}
className={cn(className, 'rounded-lg border border-default')}
>
<CartesianGrid stroke={GRID_GRAY} vertical={false} />
<Area
dataKey="value"
name={title}
type={interpolation}
stroke={GREEN}
strokeWidth={1}
// cheating to make this a line chart
fillOpacity={0}
isAnimationActive={false}
activeDot={{ fill: LIGHT_GRAY, r: 2, strokeWidth: 0 }}
/>
<XAxis
axisLine={{ stroke: 'var(--stroke-default)' }}
tickLine={{ stroke: 'var(--stroke-default)' }}
axisLine={{ stroke: GRID_GRAY }}
tickLine={{ stroke: GRID_GRAY }}
// TODO: show full given date range in the chart even if the data doesn't fill the range
domain={['auto', 'auto']}
dataKey="timestamp"
Expand All @@ -122,29 +128,38 @@ export function TimeSeriesAreaChart({
// TODO: use Date directly as x-axis values
type="number"
name="Time"
ticks={customXTicks ? getTicks(data, 5) : undefined}
// TODO: decide timestamp format based on time range of chart
tickFormatter={shortDateTime}
ticks={getTicks(data, 5)}
tickFormatter={isSameDay(startTime, endTime) ? shortTime : shortDateTime}
tick={textMonoMd}
tickMargin={4}
padding={{ right: 20 }}
tickMargin={8}
/>
<YAxis
axisLine={{ stroke: 'var(--stroke-default)' }}
tickLine={{ stroke: 'var(--stroke-default)' }}
axisLine={{ stroke: GRID_GRAY }}
tickLine={{ stroke: GRID_GRAY }}
orientation="right"
tick={textMonoMd}
tickSize={0}
tickSize={6}
tickMargin={8}
padding={{ top: 32 }}
/>
{/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */}
<Tooltip
isAnimationActive={false}
content={renderTooltip}
cursor={{ stroke: 'var(--base-green-400)', strokeDasharray: '3,3' }}
cursor={{ stroke: GREEN_400, strokeDasharray: '3,3' }}
wrapperStyle={{ outline: 'none' }}
/>
</AreaChart>
<Line
dataKey="value"
name={title}
type={interpolation}
stroke={GREEN_600}
// cheating to make this a line chart
isAnimationActive={false}
dot={false}
activeDot={{ fill: GREEN_800, r: 3, strokeWidth: 0 }}
/>
</LineChart>
</ResponsiveContainer>
)
}
4 changes: 2 additions & 2 deletions app/pages/SiloUtilizationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import { useDateTimeRangePicker } from 'app/components/form'
const DEFAULT_SILO_ID = '001de000-5110-4000-8000-000000000000'
const ALL_PROJECTS = '|ALL_PROJECTS|'

const toListboxItem = (x: { name: string; id: string }) => ({ label: x.name, value: x.id })

SiloUtilizationPage.loader = async () => {
await apiQueryClient.prefetchQuery('organizationList', {})
return null
}

const toListboxItem = (x: { name: string; id: string }) => ({ label: x.name, value: x.id })

export function SiloUtilizationPage() {
// this will come from /session/me
const siloId = DEFAULT_SILO_ID
Expand Down
Loading