Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ec9403c
docs: spec for metric-card dashboard widget
ZingerLittleBee May 27, 2026
7303874
docs: implementation plan for metric-card widget
ZingerLittleBee May 27, 2026
a376067
feat(web): add network and disk_io metric extractors
ZingerLittleBee May 27, 2026
c0c3e0c
feat(web): register metric-card widget type
ZingerLittleBee May 27, 2026
6f1f8a8
feat(web): metric-card per-metric spec map
ZingerLittleBee May 27, 2026
f25faad
feat(web): use-metric-series hook for metric-card
ZingerLittleBee May 27, 2026
5fe4fb2
feat(web): metric-card header subcomponent
ZingerLittleBee May 27, 2026
2cb4e50
feat(web): metric-card value + delta subcomponent
ZingerLittleBee May 27, 2026
4fecb7f
feat(web): metric-card sparkline subcomponent
ZingerLittleBee May 27, 2026
780732a
feat(web): metric-card stats subcomponent
ZingerLittleBee May 27, 2026
e031af4
docs: add gauge widget redesign spec
ZingerLittleBee May 27, 2026
e64556a
feat(web): MetricCardWidget composing subcomponents
ZingerLittleBee May 27, 2026
cc03ce3
docs: add gauge widget redesign implementation plan
ZingerLittleBee May 27, 2026
adf2ab9
test(web): add failing render contract for redesigned gauge widget
ZingerLittleBee May 27, 2026
ea37051
fix(web): stable testids and negative-delta coverage for metric-card
ZingerLittleBee May 27, 2026
8d33a0e
feat(web): render metric-card widget
ZingerLittleBee May 27, 2026
7da1705
feat(web): redesign gauge widget with svg ring and gradient stroke
ZingerLittleBee May 27, 2026
f08002c
feat(web): metric-card icon in widget picker
ZingerLittleBee May 27, 2026
7b60e4e
feat(web): metric-card config form
ZingerLittleBee May 27, 2026
7291882
feat(web): i18n strings for metric-card widget
ZingerLittleBee May 27, 2026
d76fd58
refactor(web): simplify metric-card form onChange handlers
ZingerLittleBee May 27, 2026
f405dd1
Merge branch 'cambridge-v2'
ZingerLittleBee May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/web/src/components/dashboard/widget-config-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type {
GaugeConfig,
LineChartConfig,
MarkdownConfig,
MetricCardConfig,
MetricCardMetric,
MultiLineConfig,
ServerCardsConfig,
StatNumberConfig,
Expand Down Expand Up @@ -68,6 +70,15 @@ function useLineMetrics(t: (key: string) => string): { label: string; value: str
]
}

function useMetricCardMetrics(t: (key: string) => string): { label: string; value: MetricCardMetric }[] {
return [
{ label: t('common.metrics.cpu'), value: 'cpu' },
{ label: t('common.metrics.memory'), value: 'memory' },
{ label: t('common.metrics.network'), value: 'network' },
{ label: t('common.metrics.diskIo'), value: 'disk_io' }
]
}

function useTopNMetrics(t: (key: string) => string): { label: string; value: string }[] {
return [
{ label: t('common.metrics.cpu'), value: 'cpu' },
Expand Down Expand Up @@ -259,6 +270,50 @@ function StatNumberForm({
)
}

function MetricCardForm({
config,
servers,
onChange,
t
}: {
config: Partial<MetricCardConfig>
onChange: (c: Partial<MetricCardConfig>) => void
servers: ServerMetrics[]
t: (key: string) => string
}) {
const METRIC_CARD_METRICS = useMetricCardMetrics(t)
const metric = (config.metric ?? 'cpu') as MetricCardMetric
const serverId = config.server_id ?? ''
const label = config.label ?? ''

return (
<>
<ServerSelect
label={t('widgets.common.labels.server')}
onChange={(v) => onChange({ ...config, server_id: v })}
placeholder={t('widgets.common.placeholders.selectServer')}
servers={servers}
value={serverId}
/>
<MetricSelect
label={t('widgets.common.labels.metric')}
metrics={METRIC_CARD_METRICS}
onChange={(v) => onChange({ ...config, metric: v as MetricCardMetric })}
placeholder={t('widgets.common.placeholders.selectMetric')}
value={metric}
/>
<div className="space-y-1.5">
<Label>{t('widgets.common.labels.labelOptional')}</Label>
<Input
onChange={(e) => onChange({ ...config, label: e.target.value })}
placeholder={t('widgets.common.placeholders.optionalLabel')}
value={label}
/>
</div>
</>
)
}

function GaugeForm({
config,
servers,
Expand Down Expand Up @@ -677,6 +732,9 @@ export function WidgetConfigDialog({
{widgetType === 'stat-number' && (
<StatNumberForm config={config as Partial<StatNumberConfig>} onChange={setConfig} t={t} />
)}
{widgetType === 'metric-card' && (
<MetricCardForm config={config as Partial<MetricCardConfig>} onChange={setConfig} servers={servers} t={t} />
)}
{widgetType === 'gauge' && (
<GaugeForm config={config as Partial<GaugeConfig>} onChange={setConfig} servers={servers} t={t} />
)}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/dashboard/widget-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Activity,
BarChart3,
Cpu,
FileText,
Gauge,
Globe,
Expand All @@ -26,6 +27,7 @@ interface WidgetPickerProps {

const WIDGET_ICONS: Record<string, typeof Server> = {
'stat-number': TrendingUp,
'metric-card': Cpu,
'server-cards': LayoutGrid,
gauge: Gauge,
'line-chart': LineChart,
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/dashboard/widget-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
GaugeConfig,
LineChartConfig,
MarkdownConfig,
MetricCardConfig,
MultiLineConfig,
ServerCardsConfig,
ServerMapConfig,
Expand All @@ -23,6 +24,7 @@ import { DiskIoWidget } from './widgets/disk-io'
import { GaugeWidget } from './widgets/gauge'
import { LineChartWidget } from './widgets/line-chart-widget'
import { MarkdownWidget } from './widgets/markdown'
import { MetricCardWidget } from './widgets/metric-card'
import { MultiLineWidget } from './widgets/multi-line'
import { ServerCardsWidget } from './widgets/server-cards'
import { ServerMapWidget } from './widgets/server-map'
Expand Down Expand Up @@ -86,6 +88,8 @@ function WidgetContent({ widget, servers }: WidgetRendererProps) {
switch (widget.widget_type) {
case 'stat-number':
return <StatNumberWidget config={config as unknown as StatNumberConfig} servers={servers} />
case 'metric-card':
return <MetricCardWidget config={config as unknown as MetricCardConfig} servers={servers} />
case 'server-cards':
return <ServerCardsWidget config={config as unknown as ServerCardsConfig} servers={servers} />
case 'gauge':
Expand Down
123 changes: 123 additions & 0 deletions apps/web/src/components/dashboard/widgets/gauge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { ServerMetrics } from '@/hooks/use-servers-ws'
import { GaugeWidget } from './gauge'

function makeServer(id: string, overrides: Partial<ServerMetrics> = {}): ServerMetrics {
return {
id,
name: `Server ${id}`,
online: true,
cpu: 50,
mem_used: 4_000_000_000,
mem_total: 8_000_000_000,
swap_used: 0,
swap_total: 0,
disk_used: 20_000_000_000,
disk_total: 40_000_000_000,
disk_read_bytes_per_sec: 0,
disk_write_bytes_per_sec: 0,
net_in_speed: 1024,
net_out_speed: 2048,
net_in_transfer: 1,
net_out_transfer: 1,
load1: 0.5,
load5: 0.4,
load15: 0.3,
tcp_conn: 10,
udp_conn: 5,
process_count: 100,
uptime: 86_400,
country_code: 'US',
os: 'Linux',
cpu_name: 'Test CPU',
last_active: Date.now(),
region: null,
group_id: null,
...overrides
}
}

function getStops(container: HTMLElement): { start: string | null; end: string | null } {
const gradient = container.querySelector('[data-testid="gauge-gradient"]')
if (!gradient) {
return { start: null, end: null }
}
const stops = gradient.querySelectorAll('stop')
return {
start: stops[0]?.getAttribute('stop-color') ?? null,
end: stops[1]?.getAttribute('stop-color') ?? null
}
}

describe('GaugeWidget', () => {
it('renders the empty state when the configured server is not in the list', () => {
render(<GaugeWidget config={{ metric: 'cpu', server_id: 'missing' }} servers={[makeServer('1')]} />)

expect(screen.getByText('Server not found')).toBeInTheDocument()
expect(screen.queryByTestId('gauge-svg')).not.toBeInTheDocument()
})

it('renders label, formatted value, and server-name subtitle', () => {
const { container } = render(
<GaugeWidget
config={{ label: 'CPU Usage', metric: 'cpu', server_id: '1' }}
servers={[makeServer('1', { cpu: 50 })]}
/>
)

expect(screen.getByTestId('gauge-label')).toHaveTextContent('CPU Usage')
expect(screen.getByTestId('gauge-value')).toHaveTextContent('50.0%')
expect(screen.getByTestId('gauge-subtitle')).toHaveTextContent('Server 1')
expect(container.querySelector('[data-testid="gauge-svg"]')).not.toBeNull()
})

it('uses the normal-range gradient (chart-1 → chart-2) when value < 70', () => {
const { container } = render(
<GaugeWidget config={{ metric: 'cpu', server_id: '1' }} servers={[makeServer('1', { cpu: 50 })]} />
)

expect(getStops(container)).toEqual({
start: 'var(--chart-1)',
end: 'var(--chart-2)'
})
})

it('uses the warning gradient (chart-3 → chart-5) when value is in [70, 90)', () => {
const { container } = render(
<GaugeWidget config={{ metric: 'cpu', server_id: '1' }} servers={[makeServer('1', { cpu: 75 })]} />
)

expect(getStops(container)).toEqual({
start: 'var(--chart-3)',
end: 'var(--chart-5)'
})
})

it('uses the critical gradient (chart-4 → chart-3) when value >= 90', () => {
const { container } = render(
<GaugeWidget config={{ metric: 'cpu', server_id: '1' }} servers={[makeServer('1', { cpu: 95 })]} />
)

expect(getStops(container)).toEqual({
start: 'var(--chart-4)',
end: 'var(--chart-3)'
})
})

it('clamps values above the configured max', () => {
render(<GaugeWidget config={{ max: 80, metric: 'cpu', server_id: '1' }} servers={[makeServer('1', { cpu: 95 })]} />)

expect(screen.getByTestId('gauge-value')).toHaveTextContent('80.0%')
})

it('hides the progress arc and end-cap balls when value is zero', () => {
const { container } = render(
<GaugeWidget config={{ metric: 'cpu', server_id: '1' }} servers={[makeServer('1', { cpu: 0 })]} />
)

expect(container.querySelector('[data-testid="gauge-progress"]')).toBeNull()
expect(container.querySelector('[data-testid="gauge-endcaps"]')).toBeNull()
expect(container.querySelector('[data-testid="gauge-track"]')).not.toBeNull()
})
})
Loading
Loading