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
@@ -29,15 +29,21 @@ export interface HealthTimelineEvent {
interface HealthTimelineProps {
events: HealthTimelineEvent[]
emptyTitle?: string
emptyDescription?: string
}
export function HealthTimeline({ events }: HealthTimelineProps) {
export function HealthTimeline({
events,
emptyTitle = 'Нет событий',
emptyDescription = 'Результаты проверок появятся после первого прогона',
}: HealthTimelineProps) {
if (events.length === 0) {
return (
<EmptyState
icon={Link2Icon}
title="Нет событий"
description="Результаты проверок появятся после первого прогона"
title={emptyTitle}
description={emptyDescription}
centered={false}
/>
)
@@ -36,6 +36,13 @@ function getBreadcrumbs(
return [{ label: 'Панель управления', href: '/' }]
}
if (pathname.match(/^\/services\/\d+$/)) {
return [
{ label: 'Сервисы', href: '/services' },
{ label: dynamicLabels[pathname] ?? 'Сервис', href: pathname },
]
}
if (pathname.match(/^\/groups\/\d+$/)) {
return [
{ label: 'Группы доменов', href: '/groups' },
@@ -12,8 +12,10 @@ import {
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { cn } from '@cfdm/ui/lib/utils'
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
import type { HealthLogStatus } from '@/lib/health-log'
export type HealthProvider = HealthCheckProvider
export type HealthAggregate = HealthCheckAggregate
@@ -48,7 +50,7 @@ function GlobalpingMark() {
)
}
const PROVIDER_ITEMS: Array<{
export const HEALTH_PROVIDER_ITEMS: Array<{
id: HealthProvider
title: string
description: string
@@ -118,6 +120,7 @@ function ChoicePanel({
icon,
iconClassName,
role,
trailing,
onActivate,
}: {
selected: boolean
@@ -126,6 +129,7 @@ function ChoicePanel({
icon: ReactNode
iconClassName?: string
role: 'checkbox' | 'radio'
trailing?: ReactNode
onActivate: () => void
}) {
return (
@@ -157,12 +161,8 @@ function ChoicePanel({
<ItemTitle className="w-full min-w-0">{title}</ItemTitle>
<ItemDescription>{description}</ItemDescription>
</ItemContent>
{selected ? (
<ItemActions className="shrink-0">
<Badge variant="outline" size="sm">
Выбрано
</Badge>
</ItemActions>
{trailing ? (
<ItemActions className="shrink-0">{trailing}</ItemActions>
) : null}
</Item>
</FramePanel>
@@ -202,7 +202,7 @@ export function HealthSourceTiles({
return (
<ChoiceFrame>
{PROVIDER_ITEMS.map((item) => (
{HEALTH_PROVIDER_ITEMS.map((item) => (
<ChoicePanel
key={item.id}
selected={selected.includes(item.id)}
@@ -211,6 +211,13 @@ export function HealthSourceTiles({
icon={item.icon}
iconClassName={item.iconClassName}
role="checkbox"
trailing={
selected.includes(item.id) ? (
<Badge variant="outline" size="sm">
Выбрано
</Badge>
) : null
}
onActivate={() => toggle(item.id)}
/>
))}
@@ -240,9 +247,71 @@ export function HealthAggregateTiles({
description={item.description}
icon={item.icon}
role="radio"
trailing={
selected === item.id ? (
<Badge variant="outline" size="sm">
Выбрано
</Badge>
) : null
}
onActivate={() => onChange(item.id)}
/>
))}
</ChoiceFrame>
)
}
/**
* Read-only status tiles for enabled probe sources; click filters the monitor.
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
*/
export function HealthProviderStatusTiles({
enabled,
selected,
statuses,
onChange,
}: {
enabled: HealthProvider[]
selected: HealthProvider[]
statuses: Partial<Record<HealthProvider, HealthLogStatus>>
onChange: (next: HealthProvider[]) => void
}) {
const visible = HEALTH_PROVIDER_ITEMS.filter((item) => enabled.includes(item.id))
if (visible.length === 0) return null
const active = selected.length > 0 ? selected : enabled
function toggle(id: HealthProvider) {
if (active.includes(id)) {
if (active.length === 1) return
onChange(active.filter((item) => item !== id))
return
}
onChange([...active, id])
}
return (
<ChoiceFrame>
{visible.map((item) => (
<ChoicePanel
key={item.id}
selected={active.includes(item.id)}
title={item.title}
description={item.description}
icon={item.icon}
iconClassName={item.iconClassName}
role="checkbox"
trailing={
<HealthCheckBadge
status={statuses[item.id] ?? 'unknown'}
provider={item.id}
size="xs"
/>
}
onActivate={() => toggle(item.id)}
/>
))}
</ChoiceFrame>
)
}
+3 -1
View File
@@ -1,4 +1,5 @@
export { UptimeChart, type UptimeProbe } from './uptime-chart'
export { UptimeChart, type UptimeProbe, type UptimePeriodKey } from './uptime-chart'
export { ServiceHealthMonitor } from './service-health-monitor'
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
@@ -22,6 +23,7 @@ export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export {
HealthSourceTiles,
HealthAggregateTiles,
HealthProviderStatusTiles,
type HealthProvider,
type HealthAggregate,
} from './health-source-tiles'
@@ -0,0 +1,79 @@
import { useMemo, useState } from 'react'
import { HealthTimeline } from '@/components/health/health-timeline'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart'
import {
collapseStatusChanges,
filterByPeriod,
filterByProviders,
type HealthLogProbe,
} from '@/lib/health-log'
import type { HealthCheckProvider } from '@cfdm/shared'
/**
* Combined uptime chart + status-change timeline (solution-analytics-8 DNA).
* Preview: https://reui.io/preview/base/solution-analytics-8 · https://reui.io/preview/base/chart-17
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/timeline
*/
export function ServiceHealthMonitor({
items,
selectedProviders,
isLoading = false,
}: {
items: HealthLogProbe[]
selectedProviders: readonly HealthCheckProvider[]
isLoading?: boolean
}) {
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
const filtered = useMemo(
() => filterByProviders(filterByPeriod(items, days), selectedProviders),
[items, days, selectedProviders],
)
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
return (
<Frame stacked spacing="sm" className="min-w-0 w-full">
<UptimeChart
items={filtered}
isLoading={isLoading}
period={period}
onPeriodChange={setPeriod}
skipPeriodFilter
embedded
/>
<FramePanel className="flex flex-col gap-3">
<FrameHeader className="px-0 py-0">
<FrameTitle>Смены статуса</FrameTitle>
<FrameDescription>
Только переходы up / degraded / down · Cloudflare = Worker, не Health Checks API
</FrameDescription>
</FrameHeader>
<HealthTimeline
events={changes.map((row) => ({
id: row.id,
hostname: row.ip,
type: row.provider,
status: row.status,
latency_ms: row.latency_ms,
error: row.error,
checked_at: row.checked_at,
colo: row.colo,
provider: row.provider,
}))}
emptyTitle="Нет смен статуса"
emptyDescription="События появятся при переходе up / degraded / down"
/>
</FramePanel>
</Frame>
)
}
@@ -6,7 +6,8 @@ import { EmptyState } from '@/components/empty-state'
import { Badge } from '@/components/reui/badge'
import { Frame, FramePanel } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { formatDate, sqliteUtcToIso } from '@/lib/format'
import { filterByPeriod, probeTime } from '@/lib/health-log'
import { formatDate } from '@/lib/format'
import { Button } from '@cfdm/ui/components/button'
import {
ChartContainer,
@@ -38,7 +39,7 @@ export interface UptimeProbe {
export type UptimePeriodKey = '5D' | '2W' | '1M'
const PERIODS: { key: UptimePeriodKey; label: string; days: number }[] = [
export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number }[] = [
{ key: '5D', label: '5D', days: 5 },
{ key: '2W', label: '2W', days: 14 },
{ key: '1M', label: '1M', days: 30 },
@@ -59,17 +60,6 @@ interface ChartPoint {
status: UptimeProbe['status']
}
function probeTime(checkedAt: string): number {
const iso = sqliteUtcToIso(checkedAt) ?? checkedAt
const time = new Date(iso).getTime()
return Number.isNaN(time) ? 0 : time
}
function filterByPeriod(items: UptimeProbe[], days: number): UptimeProbe[] {
const cutoff = Date.now() - days * 86_400_000
return items.filter((item) => probeTime(item.checked_at) >= cutoff)
}
function toSeries(items: UptimeProbe[]): ChartPoint[] {
return [...items]
.sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at))
@@ -118,25 +108,41 @@ function UptimeTooltip({ active, payload }: UptimeTooltipProps) {
interface UptimeChartProps {
items: UptimeProbe[]
isLoading?: boolean
period?: UptimePeriodKey
onPeriodChange?: (period: UptimePeriodKey) => void
skipPeriodFilter?: boolean
embedded?: boolean
}
export function UptimeChart({ items, isLoading = false }: UptimeChartProps) {
export function UptimeChart({
items,
isLoading = false,
period: periodProp,
onPeriodChange,
skipPeriodFilter = false,
embedded = false,
}: UptimeChartProps) {
const gradientId = useId().replace(/:/g, '')
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
const days = PERIODS.find((entry) => entry.key === period)?.days ?? 5
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
const period = periodProp ?? internalPeriod
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
function handlePeriodChange(next: UptimePeriodKey) {
onPeriodChange?.(next)
if (periodProp == null) setInternalPeriod(next)
}
const points = useMemo(
() => toSeries(filterByPeriod(items, days)),
[items, days],
() => toSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
[items, days, skipPeriodFilter],
)
const uptime = uptimePercent(points)
const delta = deltaPercent(points)
const lastOk = points.at(-1)?.ok ?? true
const tileClass = lastOk ? 'text-success' : 'text-destructive'
return (
<Frame spacing="sm" className="min-w-0 w-full">
<FramePanel className="flex flex-col gap-6">
const panel = (
<FramePanel className="flex flex-col gap-6">
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
<div className="flex items-center gap-2.5">
<IconTile
@@ -285,10 +291,10 @@ export function UptimeChart({ items, isLoading = false }: UptimeChartProps) {
<Tabs
value={period}
onValueChange={(value) => setPeriod(value as UptimePeriodKey)}
onValueChange={(value) => handlePeriodChange(value as UptimePeriodKey)}
>
<TabsList className="w-full">
{PERIODS.map((entry) => (
{UPTIME_PERIODS.map((entry) => (
<TabsTrigger key={entry.key} value={entry.key} className="flex-1">
{entry.label}
</TabsTrigger>
@@ -296,6 +302,13 @@ export function UptimeChart({ items, isLoading = false }: UptimeChartProps) {
</TabsList>
</Tabs>
</FramePanel>
)
if (embedded) return panel
return (
<Frame spacing="sm" className="min-w-0 w-full">
{panel}
</Frame>
)
}