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
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user