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
52 changes: 52 additions & 0 deletions apps/web/src/components/dashboard/widget-config-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ const translations: Record<string, string> = {
'common.metrics.health': 'Health',
'common.metrics.cpu': 'CPU',
'common.metrics.memory': 'Memory',
'widgets.common.placeholders.selectServer': 'Select server',
'widgets.common.empty.noServers': 'No servers',
'common.timeRange.realtime': 'Realtime',
'common.timeRange.1hour': '1 hour',
'common.timeRange.6hours': '6 hours',
'common.timeRange.7days': '7 days',
'common.timeRange.24hours': '24 hours',
'common.timeRange.30days': '30 days',
'common.timeRange.60days': '60 days',
Expand Down Expand Up @@ -291,6 +296,53 @@ describe('WidgetConfigDialog', () => {
expect(screen.getByText('90 days')).toBeInTheDocument()
})

it('renders server + range (with realtime) for network-latency widget', () => {
render(
<WidgetConfigDialog
onOpenChange={noop}
onSubmit={noop}
open
servers={mockServers as never}
widgetType="network-latency"
/>
)

expect(screen.getByText('Server')).toBeInTheDocument()
expect(screen.getByText('Time Range')).toBeInTheDocument()
expect(screen.getByText('Realtime')).toBeInTheDocument()
expect(screen.getByText('Server 1')).toBeInTheDocument()
})

it('renders a server select for network-quality widget', () => {
render(
<WidgetConfigDialog
onOpenChange={noop}
onSubmit={noop}
open
servers={mockServers as never}
widgetType="network-quality"
/>
)

expect(screen.getByText('Server')).toBeInTheDocument()
expect(screen.getByText('Server 1')).toBeInTheDocument()
})

it('renders a server multi-select for network-overview widget', () => {
render(
<WidgetConfigDialog
onOpenChange={noop}
onSubmit={noop}
open
servers={mockServers as never}
widgetType="network-overview"
/>
)

expect(screen.getByText('Servers')).toBeInTheDocument()
expect(screen.getByText('Server 1')).toBeInTheDocument()
})

describe('module widgets', () => {
const moduleId = 'com.test.cfg-dialog'
const fakeManifest: WidgetManifest = {
Expand Down
125 changes: 125 additions & 0 deletions apps/web/src/components/dashboard/widget-config-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import type {
MetricCardConfig,
MetricCardMetric,
MultiLineConfig,
NetworkLatencyConfig,
NetworkOverviewConfig,
NetworkQualityConfig,
ServerCardsConfig,
StatNumberConfig,
TopNConfig,
Expand Down Expand Up @@ -101,6 +104,16 @@ function useRangeOptions(t: (key: string) => string): { label: string; value: st
]
}

function useNetworkRangeOptions(t: (key: string) => string): { label: string; value: string }[] {
return [
{ label: t('common.timeRange.realtime'), value: '0' },
{ label: t('common.timeRange.1hour'), value: '1' },
{ label: t('common.timeRange.6hours'), value: '6' },
{ label: t('common.timeRange.24hours'), value: '24' },
{ label: t('common.timeRange.7days'), value: '168' }
]
}

function parseExistingConfig(widget?: DashboardWidget): WidgetConfig | null {
if (!widget) {
return null
Expand Down Expand Up @@ -709,6 +722,94 @@ function UptimeTimelineForm({
)
}

function NetworkLatencyForm({
config,
servers,
onChange,
t
}: {
config: Partial<NetworkLatencyConfig>
onChange: (c: Partial<NetworkLatencyConfig>) => void
servers: ServerMetrics[]
t: (key: string) => string
}) {
const NETWORK_RANGE_OPTIONS = useNetworkRangeOptions(t)
return (
<>
<ServerSelect
label={t('widgets.common.labels.server')}
onChange={(v) => onChange({ ...config, server_id: v })}
placeholder={t('widgets.common.placeholders.selectServer')}
servers={servers}
value={config.server_id ?? ''}
/>
<div className="space-y-1.5">
<Label>{t('widgets.common.labels.timeRange')}</Label>
<Select
items={NETWORK_RANGE_OPTIONS}
onValueChange={(v) => v !== null && onChange({ ...config, hours: Number(v) })}
value={String(config.hours ?? '24')}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t('widgets.common.placeholders.selectRange')} />
</SelectTrigger>
<SelectContent>
{NETWORK_RANGE_OPTIONS.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)
}

function NetworkQualityForm({
config,
servers,
onChange,
t
}: {
config: Partial<NetworkQualityConfig>
onChange: (c: Partial<NetworkQualityConfig>) => void
servers: ServerMetrics[]
t: (key: string) => string
}) {
return (
<ServerSelect
label={t('widgets.common.labels.server')}
onChange={(v) => onChange({ ...config, server_id: v })}
placeholder={t('widgets.common.placeholders.selectServer')}
servers={servers}
value={config.server_id ?? ''}
/>
)
}

function NetworkOverviewForm({
config,
servers,
onChange,
t
}: {
config: Partial<NetworkOverviewConfig>
onChange: (c: Partial<NetworkOverviewConfig>) => void
servers: ServerMetrics[]
t: (key: string) => string
}) {
return (
<ServerMultiSelect
emptyMessage={t('widgets.common.empty.noServers')}
label={t('widgets.common.labels.servers')}
onChange={(ids) => onChange({ ...config, server_ids: ids })}
selected={config.server_ids ?? []}
servers={servers}
/>
)
}

// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: dispatcher renders one form per widget_type; refactoring to a table is more work than the value
export function WidgetConfigDialog({
open,
Expand Down Expand Up @@ -813,6 +914,30 @@ export function WidgetConfigDialog({
t={t}
/>
)}
{widgetType === 'network-latency' && (
<NetworkLatencyForm
config={config as Partial<NetworkLatencyConfig>}
onChange={setConfig}
servers={servers}
t={t}
/>
)}
{widgetType === 'network-quality' && (
<NetworkQualityForm
config={config as Partial<NetworkQualityConfig>}
onChange={setConfig}
servers={servers}
t={t}
/>
)}
{widgetType === 'network-overview' && (
<NetworkOverviewForm
config={config as Partial<NetworkOverviewConfig>}
onChange={setConfig}
servers={servers}
t={t}
/>
)}
{isModule && moduleEntry && <ModuleForm config={config} entry={moduleEntry} onChange={setConfig} t={t} />}
{moduleMissing && (
<p className="text-destructive text-sm">
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/dashboard/widget-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ const WIDGET_ICONS: Record<string, typeof Server> = {
'disk-io': HardDrive,
'server-map': Globe,
markdown: FileText,
'uptime-timeline': Activity
'uptime-timeline': Activity,
'network-latency': LineChart,
'network-quality': Gauge,
'network-overview': Network
}

const CATEGORY_ORDER: WidgetCategory[] = ['Real-time', 'Charts', 'Status']
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ function getWidgetServerScope(widget: DashboardWidget): ServerScope {
return selectedServerScope(config.server_ids, 'map')
case 'uptime-timeline':
return configuredServerScope(config.server_ids, 'name')
case 'network-latency':
case 'network-quality':
return singleServerScope(config.server_id, 'name')
case 'network-overview':
return selectedServerScope(config.server_ids, 'name')
case 'markdown':
case 'service-status':
return { mode: 'none' }
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/components/dashboard/widget-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import type {
MarkdownConfig,
MetricCardConfig,
MultiLineConfig,
NetworkLatencyConfig,
NetworkOverviewConfig,
NetworkQualityConfig,
ServerCardsConfig,
ServerMapConfig,
ServiceStatusConfig,
Expand All @@ -27,6 +30,9 @@ 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 { NetworkLatencyWidget } from './widgets/network-latency-widget'
import { NetworkOverviewWidget } from './widgets/network-overview-widget'
import { NetworkQualityWidget } from './widgets/network-quality'
import { ServerCardsWidget } from './widgets/server-cards'
import { ServerMapWidget } from './widgets/server-map'
import { ServiceStatusWidget } from './widgets/service-status'
Expand Down Expand Up @@ -119,6 +125,12 @@ function WidgetContent({ widget, servers }: WidgetRendererProps) {
return <MarkdownWidget config={config as unknown as MarkdownConfig} />
case 'uptime-timeline':
return <UptimeTimelineWidget config={config as unknown as UptimeTimelineConfig} servers={servers} />
case 'network-quality':
return <NetworkQualityWidget config={config as unknown as NetworkQualityConfig} servers={servers} />
case 'network-latency':
return <NetworkLatencyWidget config={config as unknown as NetworkLatencyConfig} servers={servers} />
case 'network-overview':
return <NetworkOverviewWidget config={config as unknown as NetworkOverviewConfig} servers={servers} />
default:
return (
<div className="flex h-full items-center justify-center rounded-lg border bg-card text-muted-foreground text-sm">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { NetworkProbeRecord, NetworkServerSummary } from '@/lib/network-types'
import { NetworkLatencyWidget } from './network-latency-widget'

const NO_DATA_RE = /no network probe data/i

const recordsMock = vi.fn<() => { records: NetworkProbeRecord[]; isLoading: boolean }>()
const summaryMock = vi.fn<() => { data: NetworkServerSummary | undefined; isLoading: boolean }>()

vi.mock('@/hooks/use-network-chart-records', () => ({
useNetworkChartRecords: () => recordsMock()
}))

vi.mock('@/hooks/use-network-api', () => ({
useNetworkServerSummary: () => summaryMock()
}))

vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (_k: string, fallback?: string) => fallback ?? _k })
}))

// LatencyChart is exercised in its own context; stub it so this test focuses on the widget shell.
vi.mock('@/components/network/latency-chart', () => ({
LatencyChart: ({ records, embedded }: { records: NetworkProbeRecord[]; embedded?: boolean }) => (
<div data-embedded={embedded ? 'true' : 'false'} data-testid="latency-chart">
{records.length} points
</div>
)
}))

const summary: NetworkServerSummary = {
server_id: 'srv-1',
server_name: 'Server 1',
online: true,
last_probe_at: null,
anomaly_count: 0,
latency_sparkline: [],
loss_sparkline: [],
targets: [
{
target_id: 't-1',
target_name: 'China Telecom',
provider: 'ct',
avg_latency: 20,
min_latency: 18,
max_latency: 25,
packet_loss: 0,
availability: 100
}
]
}

const sampleRecords: NetworkProbeRecord[] = [
{
id: 1,
server_id: 'srv-1',
target_id: 't-1',
timestamp: '2026-05-29T10:00:00.000Z',
avg_latency: 20,
min_latency: 18,
max_latency: 25,
packet_loss: 0,
packet_sent: 10,
packet_received: 10
}
]

describe('NetworkLatencyWidget', () => {
it('renders the latency chart with merged records', () => {
summaryMock.mockReturnValue({ data: summary, isLoading: false })
recordsMock.mockReturnValue({ records: sampleRecords, isLoading: false })
render(<NetworkLatencyWidget config={{ server_id: 'srv-1', hours: 24 }} servers={[]} />)
expect(screen.getByTestId('latency-chart')).toHaveTextContent('1 points')
})

it('renders the chart in embedded mode so it fills the widget cell without nested card chrome', () => {
summaryMock.mockReturnValue({ data: summary, isLoading: false })
recordsMock.mockReturnValue({ records: sampleRecords, isLoading: false })
render(<NetworkLatencyWidget config={{ server_id: 'srv-1', hours: 24 }} servers={[]} />)
expect(screen.getByTestId('latency-chart')).toHaveAttribute('data-embedded', 'true')
})

it('renders a skeleton while records are loading instead of flashing the empty state', () => {
summaryMock.mockReturnValue({ data: summary, isLoading: false })
recordsMock.mockReturnValue({ records: [], isLoading: true })
const { container } = render(<NetworkLatencyWidget config={{ server_id: 'srv-1', hours: 24 }} servers={[]} />)
expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument()
expect(screen.queryByText(NO_DATA_RE)).not.toBeInTheDocument()
expect(screen.queryByTestId('latency-chart')).not.toBeInTheDocument()
})

it('renders a skeleton while the summary (chart targets) is still loading', () => {
summaryMock.mockReturnValue({ data: undefined, isLoading: true })
recordsMock.mockReturnValue({ records: sampleRecords, isLoading: false })
const { container } = render(<NetworkLatencyWidget config={{ server_id: 'srv-1', hours: 24 }} servers={[]} />)
expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument()
expect(screen.queryByTestId('latency-chart')).not.toBeInTheDocument()
})

it('renders empty state when there are no records', () => {
summaryMock.mockReturnValue({ data: summary, isLoading: false })
recordsMock.mockReturnValue({ records: [], isLoading: false })
render(<NetworkLatencyWidget config={{ server_id: 'srv-1', hours: 24 }} servers={[]} />)
expect(screen.getByText(NO_DATA_RE)).toBeInTheDocument()
})
})
Loading
Loading