feat(health): объединить источники, график и таймлайн в один блок мониторинга
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Failing after 49s
CD / quality (push) Failing after 1m1s
CD / publish (push) Skipped

Переключатель серий в шапке Frame меняет график uptime и смены статуса без отдельного списка плиток.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-08-20 01:44:16 +07:00
co-authored by Cursor
parent 5d84c7bf6c
commit 7a8bacade9
4 changed files with 243 additions and 66 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
export { UptimeChart, type UptimeProbe, type UptimePeriodKey } from './uptime-chart'
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
export { ServiceHealthMonitor } from './service-health-monitor'
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
@@ -1,6 +1,8 @@
import { useMemo, useState } from 'react'
import { useMemo, useState, type ReactNode } from 'react'
import { LayersIcon } from 'lucide-react'
import { HealthTimeline } from '@/components/health/health-timeline'
import { HealthCheckBadge } from '@/components/health-check-badge'
import {
Frame,
FrameDescription,
@@ -8,41 +10,145 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart'
import { IconTile } from '@/components/reui/icon-tile'
import { Badge } from '@/components/reui/badge'
import {
lastProbeLatency,
probeUptimePercent,
UptimeChart,
UPTIME_PERIODS,
type UptimePeriodKey,
} from '@/components/reui-kit/uptime-chart'
import { HEALTH_PROVIDER_ITEMS } from '@/components/reui-kit/health-source-tiles'
import {
collapseStatusChanges,
filterByPeriod,
filterByProviders,
type HealthLogProbe,
type HealthLogStatus,
} from '@/lib/health-log'
import { cn } from '@cfdm/ui/lib/utils'
import type { HealthCheckProvider } from '@cfdm/shared'
type SourceTab = 'all' | HealthCheckProvider
const ALL_TAB: SourceTab = 'all'
function formatUptime(value: number | null): string {
if (value == null) return '—'
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
}
function formatLatency(ms: number | null): string {
if (ms == null) return 'нет проб'
return `${ms} мс`
}
function statusTileClass(status: HealthLogStatus | undefined): string {
if (status === 'down') return 'text-destructive'
if (status === 'degraded') return 'text-warning'
if (status === 'up') return 'text-success'
return 'text-muted-foreground'
}
/**
* 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
* Единый блок мониторинга: переключатель источников (dashboard-4) + график
* (chart-17) + таймлайн смен статуса (solution-ai-ops-1 / timeline).
*
* Preview: https://reui.io/preview/base/dashboard-4
* Preview: https://reui.io/preview/base/chart-17
* Preview: https://reui.io/preview/base/solution-ai-ops-1
* Docs: https://reui.io/docs/components/base/frame
* Docs: https://reui.io/docs/components/base/icon-tile
* Docs: https://reui.io/docs/components/base/timeline
* Docs: https://reui.io/docs/components/base/badge
*/
export function ServiceHealthMonitor({
items,
selectedProviders,
enabledProviders,
statuses,
isLoading = false,
}: {
items: HealthLogProbe[]
selectedProviders: readonly HealthCheckProvider[]
enabledProviders: readonly HealthCheckProvider[]
statuses: Partial<Record<HealthCheckProvider, HealthLogStatus>>
isLoading?: boolean
}) {
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
const [source, setSource] = useState<SourceTab>(ALL_TAB)
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
const enabledItems = useMemo(
() => HEALTH_PROVIDER_ITEMS.filter((item) => enabledProviders.includes(item.id)),
[enabledProviders],
)
const selectedProviders = useMemo<HealthCheckProvider[]>(() => {
if (source === ALL_TAB) return [...enabledProviders]
if (enabledProviders.includes(source)) return [source]
return [...enabledProviders]
}, [enabledProviders, source])
const periodItems = useMemo(
() => filterByPeriod(items, days),
[items, days],
)
const filtered = useMemo(
() => filterByProviders(filterByPeriod(items, days), selectedProviders),
[items, days, selectedProviders],
() => filterByProviders(periodItems, selectedProviders),
[periodItems, selectedProviders],
)
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
const showAllTab = enabledItems.length > 1
const tabCount = enabledItems.length + (showAllTab ? 1 : 0)
const activeSource: SourceTab =
source === ALL_TAB || enabledProviders.includes(source)
? source
: ALL_TAB
return (
<Frame stacked spacing="sm" className="min-w-0 w-full">
<FrameHeader className="p-0!">
<div
className={cn(
'grid',
tabCount <= 2 && 'grid-cols-2',
tabCount === 3 && 'grid-cols-1 sm:grid-cols-3',
tabCount >= 4 && 'grid-cols-2',
)}
>
{showAllTab ? (
<SourceMetricButton
selected={activeSource === ALL_TAB}
icon={<LayersIcon />}
iconClassName="text-muted-foreground"
label="Все источники"
value={formatUptime(probeUptimePercent(periodItems))}
hint={`${periodItems.length} проб`}
onSelect={() => setSource(ALL_TAB)}
/>
) : null}
{enabledItems.map((item) => {
const series = filterByProviders(periodItems, [item.id])
return (
<SourceMetricButton
key={item.id}
selected={activeSource === item.id}
icon={item.icon}
iconClassName={item.iconClassName}
label={item.title}
value={formatUptime(probeUptimePercent(series))}
hint={formatLatency(lastProbeLatency(series))}
status={statuses[item.id] ?? 'unknown'}
onSelect={() => setSource(item.id)}
/>
)
})}
</div>
</FrameHeader>
<UptimeChart
items={filtered}
isLoading={isLoading}
@@ -50,12 +156,15 @@ export function ServiceHealthMonitor({
onPeriodChange={setPeriod}
skipPeriodFilter
embedded
hideHeader
/>
<FramePanel className="flex flex-col gap-3">
<FrameHeader className="px-0 py-0">
<FrameTitle>Смены статуса</FrameTitle>
<FrameDescription>
Только переходы up / degraded / down · Cloudflare = Worker, не Health Checks API
Только переходы up / degraded / down · Cloudflare = Worker, не Health
Checks API
</FrameDescription>
</FrameHeader>
<HealthTimeline
@@ -77,3 +186,65 @@ export function ServiceHealthMonitor({
</Frame>
)
}
function SourceMetricButton({
selected,
icon,
iconClassName,
label,
value,
hint,
status,
onSelect,
}: {
selected: boolean
icon: ReactNode
iconClassName: string
label: string
value: string
hint: string
status?: HealthLogStatus
onSelect: () => void
}) {
return (
<button
type="button"
aria-pressed={selected}
aria-label={`${label}: ${value}`}
onClick={onSelect}
className={cn(
'focus-visible:ring-ring/50 hover:bg-muted/40 relative flex min-w-0 items-start gap-3 border-e border-b p-4 text-start transition-colors last:border-e-0 focus-visible:ring-2 focus-visible:outline-none sm:border-b-0',
selected && 'bg-muted/40',
)}
>
<IconTile
variant="elevated"
className={cn(
'size-10.5',
status ? statusTileClass(status) : iconClassName,
)}
aria-hidden="true"
>
{icon}
</IconTile>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center justify-between gap-2">
<span className="text-muted-foreground text-sm font-medium">{label}</span>
{status ? (
<HealthCheckBadge status={status} size="xs" />
) : (
<Badge variant="outline" size="sm">
{hint}
</Badge>
)}
</span>
<span className="text-foreground text-2xl leading-none font-bold tabular-nums">
{value}
</span>
{status ? (
<span className="text-muted-foreground text-xs">{hint}</span>
) : null}
</span>
</button>
)
}
@@ -112,6 +112,23 @@ function UptimeDelta({ delta }: { delta: number }) {
)
}
export function probeUptimePercent(items: UptimeProbe[]): number | null {
return uptimePercent(toSeries(items))
}
export function lastProbeLatency(items: UptimeProbe[]): number | null {
if (items.length === 0) return null
const latest = [...items].sort(
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at),
)[0]
return latest?.latency_ms ?? null
}
function formatUptime(value: number | null): string {
if (value == null) return '—'
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
}
interface UptimeChartProps {
items: UptimeProbe[]
isLoading?: boolean
@@ -119,6 +136,8 @@ interface UptimeChartProps {
onPeriodChange?: (period: UptimePeriodKey) => void
skipPeriodFilter?: boolean
embedded?: boolean
/** dashboard-4: chrome живёт в родительском FrameHeader (переключатель серий). */
hideHeader?: boolean
}
export function UptimeChart({
@@ -128,6 +147,7 @@ export function UptimeChart({
onPeriodChange,
skipPeriodFilter = false,
embedded = false,
hideHeader = false,
}: UptimeChartProps) {
const gradientId = useId().replace(/:/g, '')
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
@@ -155,43 +175,45 @@ export function UptimeChart({
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
variant="elevated"
className={`size-10.5 ${tileClass}`}
aria-hidden="true"
>
<ActivityIcon />
</IconTile>
<div className="flex flex-col justify-center">
<h3 className="text-base font-semibold">Uptime</h3>
<p className="text-muted-foreground text-sm">
Пробы health-check за период
</p>
</div>
</div>
<TooltipProvider delay={150}>
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="О графике uptime"
className="text-muted-foreground/70 -mr-1"
size="icon-sm"
type="button"
variant="ghost"
/>
}
{hideHeader ? null : (
<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
variant="elevated"
className={`size-10.5 ${tileClass}`}
aria-hidden="true"
>
<InfoIcon data-icon="inline-start" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<ActivityIcon />
</IconTile>
<div className="flex flex-col justify-center">
<h3 className="text-base font-semibold">Uptime</h3>
<p className="text-muted-foreground text-sm">
Пробы health-check за период
</p>
</div>
</div>
<TooltipProvider delay={150}>
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="О графике uptime"
className="text-muted-foreground/70 -mr-1"
size="icon-sm"
type="button"
variant="ghost"
/>
}
>
<InfoIcon data-icon="inline-start" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{isLoading ? (
<div className="bg-muted h-40 w-full animate-pulse rounded-xl" />
@@ -207,7 +229,7 @@ export function UptimeChart({
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<div className="text-foreground text-3xl font-semibold tabular-nums">
{uptime == null ? '—' : `${uptime.toFixed(uptime >= 99.95 ? 2 : 1)}%`}
{formatUptime(uptime)}
</div>
<div className="flex items-center gap-2 text-sm">
{delta == null ? (
@@ -28,7 +28,6 @@ import {
} from '@/components/services/service-detail-grid'
import { LbModeTile } from '@/components/services/service-unit-card'
import {
HealthProviderStatusTiles,
KpiStatGrid,
ServiceHealthMonitor,
} from '@/components/reui-kit'
@@ -45,7 +44,6 @@ import {
providerHealthStatuses,
} from '@/lib/health-log'
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
import type { HealthCheckProvider } from '@cfdm/shared'
import {
createServiceNode,
deleteServiceNode,
@@ -112,9 +110,6 @@ function ServiceDetailPage() {
const [editOpen, setEditOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [selectedProviders, setSelectedProviders] = useState<
HealthCheckProvider[] | null
>(null)
const [togglingIp, setTogglingIp] = useState<string | null>(null)
const [changeIp, setChangeIp] = useState<{
bindingId: number
@@ -234,11 +229,6 @@ function ServiceDetailPage() {
() => enabledHealthProviders(service?.domains ?? []),
[service],
)
const activeProviders =
selectedProviders?.filter((provider) => enabledProviders.includes(provider)) ??
enabledProviders
const effectiveProviders =
activeProviders.length > 0 ? activeProviders : enabledProviders
const providerStatuses = useMemo(
() => providerHealthStatuses(logItems, enabledProviders),
[logItems, enabledProviders],
@@ -333,20 +323,14 @@ function ServiceDetailPage() {
]}
/>
<HealthProviderStatusTiles
enabled={enabledProviders}
selected={effectiveProviders}
statuses={providerStatuses}
onChange={setSelectedProviders}
/>
<section
aria-label="Мониторинг"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
<ServiceHealthMonitor
items={logItems}
selectedProviders={effectiveProviders}
enabledProviders={enabledProviders}
statuses={providerStatuses}
isLoading={logQuery.isLoading}
/>
<Frame dense spacing="sm" className="min-w-0 w-full">