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: 2 additions & 0 deletions app/components/SystemMetric.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export function SystemMetric({
width={480}
height={240}
interpolation="stepAfter"
startTime={startTime}
endTime={endTime}
/>
</div>
)
Expand Down
133 changes: 108 additions & 25 deletions app/components/TimeSeriesChart.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import cn from 'classnames'
import { format } from 'date-fns'
import {
Area,
AreaChart,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
Expand All @@ -19,25 +22,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 +70,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 @@ -76,6 +96,8 @@ type Props = {
height: number
interpolation?: 'linear' | 'stepAfter'
customXTicks?: boolean
startTime: Date
endTime: Date
}

// Limitations
Expand All @@ -89,31 +111,83 @@ export function TimeSeriesAreaChart({
height,
interpolation = 'linear',
customXTicks,
startTime,
endTime,
}: Props) {
return (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={280}>
<AreaChart
width={width}
height={height}
data={data}
margin={{ top: 0, right: 20, bottom: 5, left: 0 }}
margin={{ top: 0, right: 0, bottom: 16, left: 0 }}
className={className}
>
<CartesianGrid stroke={GRID_GRAY} vertical={false} />
<XAxis
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"
name="Time"
ticks={customXTicks ? getTicks(data, 5) : undefined}
tickFormatter={isSameDay(startTime, endTime) ? shortTime : shortDateTime}
tick={textMonoMd}
tickMargin={8}
/>
<YAxis
axisLine={{ stroke: GRID_GRAY }}
tickLine={{ stroke: GRID_GRAY }}
orientation="right"
tick={textMonoMd}
tickMargin={8}
/>
{/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */}
<Tooltip
isAnimationActive={false}
content={renderTooltip}
cursor={{ stroke: GREEN_400, strokeDasharray: '3,3' }}
wrapperStyle={{ outline: 'none' }}
/>
<Area
dataKey="value"
name={title}
type={interpolation}
stroke={GREEN}
stroke={GREEN_600}
strokeWidth={1}
// cheating to make this a line chart
fillOpacity={0}
isAnimationActive={false}
activeDot={{ fill: LIGHT_GRAY, r: 2, strokeWidth: 0 }}
activeDot={{ fill: GREEN_800, r: 3, strokeWidth: 0 }}
/>
</AreaChart>
</ResponsiveContainer>
)
}

export function TimeSeriesLineChart({
className,
data,
title,
width,
height,
interpolation = 'linear',
customXTicks,
startTime,
endTime,
}: Props) {
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart
width={width}
height={height}
data={data}
margin={{ top: 0, right: 0, bottom: 16, left: 0 }}
className={cn(className, 'rounded-lg border border-default')}
>
<CartesianGrid stroke={GRID_GRAY} vertical={false} />
<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 @@ -123,28 +197,37 @@ export function TimeSeriesAreaChart({
type="number"
name="Time"
ticks={customXTicks ? getTicks(data, 5) : undefined}
// TODO: decide timestamp format based on time range of chart
tickFormatter={shortDateTime}
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>
)
}
51 changes: 35 additions & 16 deletions app/pages/project/instances/instance/tabs/MetricsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import type { Cumulativeint64, Disk, DiskMetricName } from '@oxide/api'
import { useApiQuery } from '@oxide/api'
import { Listbox, Spinner } from '@oxide/ui'

import { TimeSeriesAreaChart } from 'app/components/TimeSeriesChart'
import { TimeSeriesLineChart } from 'app/components/TimeSeriesChart'
import { useDateTimeRangePicker } from 'app/components/form'
import { useRequiredParams } from 'app/hooks'

type DiskMetricParams = {
title: string
unit?: string
startTime: Date
endTime: Date
metricName: DiskMetricName
Expand All @@ -20,6 +21,7 @@ type DiskMetricParams = {

function DiskMetric({
title,
unit,
startTime,
endTime,
metricName,
Expand Down Expand Up @@ -47,17 +49,20 @@ function DiskMetric({
// in the tooltip. could be just once on the end of the x-axis like GCP

return (
<div>
<h2 className="flex items-center text-mono-sm text-secondary">
{title} {isLoading && <Spinner className="ml-2" />}
<div className="flex w-1/2 flex-grow flex-col">
<h2 className="ml-3 flex items-center text-mono-xs text-secondary">
{title} {unit && <div className="ml-1 text-quaternary">{unit}</div>}
{isLoading && <Spinner className="ml-2" />}
</h2>
<TimeSeriesAreaChart
className="mt-4"
<TimeSeriesLineChart
className="mt-3"
data={data}
title={title}
width={480}
height={240}
customXTicks
startTime={startTime}
endTime={endTime}
/>
</div>
)
Expand Down Expand Up @@ -96,18 +101,32 @@ function DiskMetrics({ disks }: { disks: Disk[] }) {
{dateTimeRangePicker}
</div>

{/* TODO: separate "Reads" from "(count)" so we can
a) style them differently in the title, and
b) show "Reads" but not "(count)" in the Tooltip?
*/}
<div className="mt-8 space-y-8">
<div className="mt-8 space-y-12">
{/* 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 */}
<DiskMetric {...commonProps} title="Reads (Count)" metricName="read" />
<DiskMetric {...commonProps} title="Read (Bytes)" metricName="read_bytes" />
<DiskMetric {...commonProps} title="Writes (Count)" metricName="write" />
<DiskMetric {...commonProps} title="Write (Bytes)" metricName="write_bytes" />
<DiskMetric {...commonProps} title="Flushes (Count)" metricName="flush" />
<div className="flex w-full space-x-4">
<DiskMetric {...commonProps} title="Reads" unit="(Count)" metricName="read" />
<DiskMetric
{...commonProps}
title="Read"
unit="(Bytes)"
metricName="read_bytes"
/>
</div>

<div className="flex w-full space-x-4">
<DiskMetric {...commonProps} title="Writes" unit="(Count)" metricName="write" />
<DiskMetric
{...commonProps}
title="Write"
unit="(Bytes)"
metricName="write_bytes"
/>
</div>

<div className="flex w-full space-x-4">
<DiskMetric {...commonProps} title="Flushes" unit="(Count)" metricName="flush" />
</div>
</div>
</>
)
Expand Down