feat(health): enhance HealthTimeline and HealthSourceTiles components
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 10s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m1s
CD / quality (push) Successful in 1m15s
CD / publish (push) Successful in 2m11s

- Added optional props `emptyTitle` and `emptyDescription` to HealthTimeline for customizable empty state messages.
- Refactored HealthSourceTiles to export `HEALTH_PROVIDER_ITEMS` and introduced a new `HealthProviderStatusTiles` component for improved health monitoring.
- Updated UptimeChart to support period selection and filtering, enhancing data visualization capabilities.
- Improved ServiceDetailPage by integrating HealthProviderStatusTiles and ServiceHealthMonitor for better service health insights.
This commit is contained in:
Denozordec
2026-08-20 00:09:55 +07:00
parent ba03d2be9d
commit 69119a08a4
10 changed files with 504 additions and 76 deletions
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import {
collapseStatusChanges,
enabledHealthProviders,
providerHealthStatuses,
worstHealthStatus,
type HealthLogProbe,
} from '@/lib/health-log'
function probe(
overrides: Partial<HealthLogProbe> & Pick<HealthLogProbe, 'id' | 'status' | 'checked_at'>,
): HealthLogProbe {
return {
ip: '1.1.1.1',
provider: 'local',
ok: overrides.status === 'up',
latency_ms: 12,
colo: null,
error: null,
...overrides,
}
}
describe('collapseStatusChanges', () => {
it('keeps only status transitions per ip+provider', () => {
const items = [
probe({ id: 1, status: 'up', checked_at: '2026-01-01T00:00:00Z' }),
probe({ id: 2, status: 'up', checked_at: '2026-01-01T00:01:00Z' }),
probe({ id: 3, status: 'down', checked_at: '2026-01-01T00:02:00Z' }),
probe({ id: 4, status: 'down', checked_at: '2026-01-01T00:03:00Z' }),
probe({ id: 5, status: 'up', checked_at: '2026-01-01T00:04:00Z' }),
]
const changes = collapseStatusChanges(items)
expect(changes.map((item) => item.id)).toEqual([5, 3, 1])
})
it('tracks series independently by provider', () => {
const items = [
probe({ id: 1, provider: 'local', status: 'up', checked_at: '2026-01-01T00:00:00Z' }),
probe({
id: 2,
provider: 'cloudflare',
status: 'up',
checked_at: '2026-01-01T00:00:00Z',
}),
probe({ id: 3, provider: 'local', status: 'up', checked_at: '2026-01-01T00:01:00Z' }),
probe({
id: 4,
provider: 'cloudflare',
status: 'down',
checked_at: '2026-01-01T00:01:00Z',
}),
]
const changes = collapseStatusChanges(items)
expect(changes.map((item) => item.id).sort()).toEqual([1, 2, 4])
})
})
describe('enabledHealthProviders', () => {
it('unions bindings in registry order', () => {
expect(
enabledHealthProviders([
{ health_check_providers: ['globalping'] },
{ health_check_providers: ['local', 'cloudflare'] },
]),
).toEqual(['local', 'cloudflare', 'globalping'])
})
it('falls back to local', () => {
expect(enabledHealthProviders([])).toEqual(['local'])
})
})
describe('providerHealthStatuses', () => {
it('uses worst latest-per-ip status', () => {
const items = [
probe({ id: 1, ip: '1.1.1.1', status: 'up', checked_at: '2026-01-01T00:02:00Z' }),
probe({ id: 2, ip: '2.2.2.2', status: 'down', checked_at: '2026-01-01T00:01:00Z' }),
probe({
id: 3,
ip: '2.2.2.2',
status: 'up',
checked_at: '2026-01-01T00:00:00Z',
}),
]
expect(providerHealthStatuses(items, ['local']).local).toBe('down')
})
})
describe('worstHealthStatus', () => {
it('ranks down over degraded over up', () => {
expect(worstHealthStatus(['up', 'degraded'])).toBe('degraded')
expect(worstHealthStatus(['degraded', 'down'])).toBe('down')
expect(worstHealthStatus([])).toBe('unknown')
})
})
+127
View File
@@ -0,0 +1,127 @@
import type { HealthCheckProvider } from '@cfdm/shared'
import { HEALTH_CHECK_PROVIDERS, uniqueHealthProviders } from '@cfdm/shared'
import { sqliteUtcToIso } from '@/lib/format'
import type { IpHealthStatus } from '@/lib/schemas'
export type HealthLogStatus = IpHealthStatus['status']
export interface HealthLogProbe {
id: number
ip: string
provider: HealthCheckProvider
status: HealthLogStatus
ok: boolean
latency_ms: number | null
colo: string | null
error: string | null
checked_at: string
}
const STATUS_RANK: Record<HealthLogStatus, number> = {
unknown: 0,
up: 1,
degraded: 2,
down: 3,
}
export function probeTime(checkedAt: string): number {
const iso = sqliteUtcToIso(checkedAt) ?? checkedAt
const time = new Date(iso).getTime()
return Number.isNaN(time) ? 0 : time
}
export function filterByPeriod<T extends { checked_at: string }>(
items: T[],
days: number,
): T[] {
const cutoff = Date.now() - days * 86_400_000
return items.filter((item) => probeTime(item.checked_at) >= cutoff)
}
export function filterByProviders<T extends { provider: string }>(
items: T[],
providers: readonly HealthCheckProvider[],
): T[] {
if (providers.length === 0) return items
const allowed = new Set(providers)
return items.filter((item) => allowed.has(item.provider as HealthCheckProvider))
}
/**
* Keep the first probe of each ip+provider series and every later probe
* whose status differs from the previous one. Newest first.
*/
export function collapseStatusChanges<T extends HealthLogProbe>(items: T[]): T[] {
const byKey = new Map<string, T[]>()
for (const item of items) {
const key = `${item.ip}\0${item.provider}`
const list = byKey.get(key)
if (list) list.push(item)
else byKey.set(key, [item])
}
const changes: T[] = []
for (const list of byKey.values()) {
list.sort(
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
)
let previous: HealthLogStatus | undefined
for (const item of list) {
if (item.status !== previous) {
changes.push(item)
previous = item.status
}
}
}
changes.sort(
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
)
return changes
}
export function enabledHealthProviders(
domains: Array<{ health_check_providers?: readonly HealthCheckProvider[] | null }>,
): HealthCheckProvider[] {
const collected = uniqueHealthProviders(
domains.flatMap((domain) => domain.health_check_providers ?? []),
)
if (collected.length === 0) return ['local']
return HEALTH_CHECK_PROVIDERS.filter((provider) => collected.includes(provider))
}
export function worstHealthStatus(statuses: readonly HealthLogStatus[]): HealthLogStatus {
if (statuses.length === 0) return 'unknown'
return statuses.reduce((worst, status) =>
STATUS_RANK[status] > STATUS_RANK[worst] ? status : worst,
)
}
/** Latest probe per IP for a provider, then worst among those IPs. */
export function providerHealthStatuses(
items: readonly HealthLogProbe[],
providers: readonly HealthCheckProvider[],
): Record<HealthCheckProvider, HealthLogStatus> {
const latestByIp = new Map<string, HealthLogProbe>()
const sorted = [...items].sort(
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
)
for (const item of sorted) {
const key = `${item.provider}\0${item.ip}`
if (!latestByIp.has(key)) latestByIp.set(key, item)
}
const result = Object.fromEntries(
HEALTH_CHECK_PROVIDERS.map((provider) => [provider, 'unknown' as HealthLogStatus]),
) as Record<HealthCheckProvider, HealthLogStatus>
for (const provider of providers) {
const statuses = [...latestByIp.values()]
.filter((item) => item.provider === provider)
.map((item) => item.status)
result[provider] = worstHealthStatus(statuses)
}
return result
}