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
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:
@@ -29,15 +29,21 @@ export interface HealthTimelineEvent {
|
|||||||
|
|
||||||
interface HealthTimelineProps {
|
interface HealthTimelineProps {
|
||||||
events: HealthTimelineEvent[]
|
events: HealthTimelineEvent[]
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HealthTimeline({ events }: HealthTimelineProps) {
|
export function HealthTimeline({
|
||||||
|
events,
|
||||||
|
emptyTitle = 'Нет событий',
|
||||||
|
emptyDescription = 'Результаты проверок появятся после первого прогона',
|
||||||
|
}: HealthTimelineProps) {
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Link2Icon}
|
icon={Link2Icon}
|
||||||
title="Нет событий"
|
title={emptyTitle}
|
||||||
description="Результаты проверок появятся после первого прогона"
|
description={emptyDescription}
|
||||||
centered={false}
|
centered={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ function getBreadcrumbs(
|
|||||||
return [{ label: 'Панель управления', href: '/' }]
|
return [{ label: 'Панель управления', href: '/' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.match(/^\/services\/\d+$/)) {
|
||||||
|
return [
|
||||||
|
{ label: 'Сервисы', href: '/services' },
|
||||||
|
{ label: dynamicLabels[pathname] ?? 'Сервис', href: pathname },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.match(/^\/groups\/\d+$/)) {
|
if (pathname.match(/^\/groups\/\d+$/)) {
|
||||||
return [
|
return [
|
||||||
{ label: 'Группы доменов', href: '/groups' },
|
{ label: 'Группы доменов', href: '/groups' },
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
ItemMedia,
|
ItemMedia,
|
||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from '@cfdm/ui/components/item'
|
} from '@cfdm/ui/components/item'
|
||||||
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
import type { HealthLogStatus } from '@/lib/health-log'
|
||||||
|
|
||||||
export type HealthProvider = HealthCheckProvider
|
export type HealthProvider = HealthCheckProvider
|
||||||
export type HealthAggregate = HealthCheckAggregate
|
export type HealthAggregate = HealthCheckAggregate
|
||||||
@@ -48,7 +50,7 @@ function GlobalpingMark() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDER_ITEMS: Array<{
|
export const HEALTH_PROVIDER_ITEMS: Array<{
|
||||||
id: HealthProvider
|
id: HealthProvider
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@@ -118,6 +120,7 @@ function ChoicePanel({
|
|||||||
icon,
|
icon,
|
||||||
iconClassName,
|
iconClassName,
|
||||||
role,
|
role,
|
||||||
|
trailing,
|
||||||
onActivate,
|
onActivate,
|
||||||
}: {
|
}: {
|
||||||
selected: boolean
|
selected: boolean
|
||||||
@@ -126,6 +129,7 @@ function ChoicePanel({
|
|||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
role: 'checkbox' | 'radio'
|
role: 'checkbox' | 'radio'
|
||||||
|
trailing?: ReactNode
|
||||||
onActivate: () => void
|
onActivate: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -157,12 +161,8 @@ function ChoicePanel({
|
|||||||
<ItemTitle className="w-full min-w-0">{title}</ItemTitle>
|
<ItemTitle className="w-full min-w-0">{title}</ItemTitle>
|
||||||
<ItemDescription>{description}</ItemDescription>
|
<ItemDescription>{description}</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
{selected ? (
|
{trailing ? (
|
||||||
<ItemActions className="shrink-0">
|
<ItemActions className="shrink-0">{trailing}</ItemActions>
|
||||||
<Badge variant="outline" size="sm">
|
|
||||||
Выбрано
|
|
||||||
</Badge>
|
|
||||||
</ItemActions>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Item>
|
</Item>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
@@ -202,7 +202,7 @@ export function HealthSourceTiles({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChoiceFrame>
|
<ChoiceFrame>
|
||||||
{PROVIDER_ITEMS.map((item) => (
|
{HEALTH_PROVIDER_ITEMS.map((item) => (
|
||||||
<ChoicePanel
|
<ChoicePanel
|
||||||
key={item.id}
|
key={item.id}
|
||||||
selected={selected.includes(item.id)}
|
selected={selected.includes(item.id)}
|
||||||
@@ -211,6 +211,13 @@ export function HealthSourceTiles({
|
|||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
iconClassName={item.iconClassName}
|
iconClassName={item.iconClassName}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
|
trailing={
|
||||||
|
selected.includes(item.id) ? (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
Выбрано
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
onActivate={() => toggle(item.id)}
|
onActivate={() => toggle(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -240,9 +247,71 @@ export function HealthAggregateTiles({
|
|||||||
description={item.description}
|
description={item.description}
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
role="radio"
|
role="radio"
|
||||||
|
trailing={
|
||||||
|
selected === item.id ? (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
Выбрано
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
onActivate={() => onChange(item.id)}
|
onActivate={() => onChange(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ChoiceFrame>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||||
@@ -22,6 +23,7 @@ export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
|||||||
export {
|
export {
|
||||||
HealthSourceTiles,
|
HealthSourceTiles,
|
||||||
HealthAggregateTiles,
|
HealthAggregateTiles,
|
||||||
|
HealthProviderStatusTiles,
|
||||||
type HealthProvider,
|
type HealthProvider,
|
||||||
type HealthAggregate,
|
type HealthAggregate,
|
||||||
} from './health-source-tiles'
|
} 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 { Badge } from '@/components/reui/badge'
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import { IconTile } from '@/components/reui/icon-tile'
|
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 { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
@@ -38,7 +39,7 @@ export interface UptimeProbe {
|
|||||||
|
|
||||||
export type UptimePeriodKey = '5D' | '2W' | '1M'
|
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: '5D', label: '5D', days: 5 },
|
||||||
{ key: '2W', label: '2W', days: 14 },
|
{ key: '2W', label: '2W', days: 14 },
|
||||||
{ key: '1M', label: '1M', days: 30 },
|
{ key: '1M', label: '1M', days: 30 },
|
||||||
@@ -59,17 +60,6 @@ interface ChartPoint {
|
|||||||
status: UptimeProbe['status']
|
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[] {
|
function toSeries(items: UptimeProbe[]): ChartPoint[] {
|
||||||
return [...items]
|
return [...items]
|
||||||
.sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at))
|
.sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at))
|
||||||
@@ -118,25 +108,41 @@ function UptimeTooltip({ active, payload }: UptimeTooltipProps) {
|
|||||||
interface UptimeChartProps {
|
interface UptimeChartProps {
|
||||||
items: UptimeProbe[]
|
items: UptimeProbe[]
|
||||||
isLoading?: boolean
|
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 gradientId = useId().replace(/:/g, '')
|
||||||
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
|
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
||||||
const days = PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
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(
|
const points = useMemo(
|
||||||
() => toSeries(filterByPeriod(items, days)),
|
() => toSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
|
||||||
[items, days],
|
[items, days, skipPeriodFilter],
|
||||||
)
|
)
|
||||||
const uptime = uptimePercent(points)
|
const uptime = uptimePercent(points)
|
||||||
const delta = deltaPercent(points)
|
const delta = deltaPercent(points)
|
||||||
const lastOk = points.at(-1)?.ok ?? true
|
const lastOk = points.at(-1)?.ok ?? true
|
||||||
const tileClass = lastOk ? 'text-success' : 'text-destructive'
|
const tileClass = lastOk ? 'text-success' : 'text-destructive'
|
||||||
|
|
||||||
return (
|
const panel = (
|
||||||
<Frame spacing="sm" className="min-w-0 w-full">
|
<FramePanel className="flex flex-col gap-6">
|
||||||
<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="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<IconTile
|
<IconTile
|
||||||
@@ -285,10 +291,10 @@ export function UptimeChart({ items, isLoading = false }: UptimeChartProps) {
|
|||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={period}
|
value={period}
|
||||||
onValueChange={(value) => setPeriod(value as UptimePeriodKey)}
|
onValueChange={(value) => handlePeriodChange(value as UptimePeriodKey)}
|
||||||
>
|
>
|
||||||
<TabsList className="w-full">
|
<TabsList className="w-full">
|
||||||
{PERIODS.map((entry) => (
|
{UPTIME_PERIODS.map((entry) => (
|
||||||
<TabsTrigger key={entry.key} value={entry.key} className="flex-1">
|
<TabsTrigger key={entry.key} value={entry.key} className="flex-1">
|
||||||
{entry.label}
|
{entry.label}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -296,6 +302,13 @@ export function UptimeChart({ items, isLoading = false }: UptimeChartProps) {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (embedded) return panel
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame spacing="sm" className="min-w-0 w-full">
|
||||||
|
{panel}
|
||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
ActivityIcon,
|
ActivityIcon,
|
||||||
ArrowLeftIcon,
|
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
NetworkIcon,
|
NetworkIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
@@ -19,7 +18,6 @@ import { FailoverTimeline } from '@/components/failover-timeline'
|
|||||||
import { FormFieldSimple } from '@/components/form-field'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { HealthTimeline } from '@/components/health/health-timeline'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
@@ -29,7 +27,11 @@ import {
|
|||||||
type ServiceFqdnRow,
|
type ServiceFqdnRow,
|
||||||
} from '@/components/services/service-detail-grid'
|
} from '@/components/services/service-detail-grid'
|
||||||
import { LbModeTile } from '@/components/services/service-unit-card'
|
import { LbModeTile } from '@/components/services/service-unit-card'
|
||||||
import { KpiStatGrid, UptimeChart } from '@/components/reui-kit'
|
import {
|
||||||
|
HealthProviderStatusTiles,
|
||||||
|
KpiStatGrid,
|
||||||
|
ServiceHealthMonitor,
|
||||||
|
} from '@/components/reui-kit'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
@@ -38,7 +40,12 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
|
import {
|
||||||
|
enabledHealthProviders,
|
||||||
|
providerHealthStatuses,
|
||||||
|
} from '@/lib/health-log'
|
||||||
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||||
|
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||||
import {
|
import {
|
||||||
createServiceNode,
|
createServiceNode,
|
||||||
deleteServiceNode,
|
deleteServiceNode,
|
||||||
@@ -56,6 +63,11 @@ import {
|
|||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
||||||
component: ServiceDetailPage,
|
component: ServiceDetailPage,
|
||||||
@@ -92,11 +104,17 @@ function ServiceDetailPage() {
|
|||||||
|
|
||||||
const service = viewQuery.data
|
const service = viewQuery.data
|
||||||
const overview = overviewQuery.data as OverviewPayload | undefined
|
const overview = overviewQuery.data as OverviewPayload | undefined
|
||||||
const logItems = logQuery.data?.items ?? []
|
const logItems = useMemo(
|
||||||
|
() => logQuery.data?.items ?? [],
|
||||||
|
[logQuery.data?.items],
|
||||||
|
)
|
||||||
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
|
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [selectedProviders, setSelectedProviders] = useState<
|
||||||
|
HealthCheckProvider[] | null
|
||||||
|
>(null)
|
||||||
const [togglingIp, setTogglingIp] = useState<string | null>(null)
|
const [togglingIp, setTogglingIp] = useState<string | null>(null)
|
||||||
const [changeIp, setChangeIp] = useState<{
|
const [changeIp, setChangeIp] = useState<{
|
||||||
bindingId: number
|
bindingId: number
|
||||||
@@ -212,6 +230,20 @@ function ServiceDetailPage() {
|
|||||||
: `fail ${node.consecutive_failures}`,
|
: `fail ${node.consecutive_failures}`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const enabledProviders = useMemo(
|
||||||
|
() => 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],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryState
|
<QueryState
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -236,14 +268,22 @@ function ServiceDetailPage() {
|
|||||||
<>
|
<>
|
||||||
<LbModeTile mode={service.lb_mode} />
|
<LbModeTile mode={service.lb_mode} />
|
||||||
<HealthCheckBadge status={service.health_status} />
|
<HealthCheckBadge status={service.health_status} />
|
||||||
<Button size="sm" onClick={() => setEditOpen(true)}>
|
<Tooltip>
|
||||||
<PencilIcon className="size-4" aria-hidden />
|
<TooltipTrigger
|
||||||
Изменить
|
render={
|
||||||
</Button>
|
<Button
|
||||||
<Button variant="outline" size="sm" render={<Link to="/services" />}>
|
type="button"
|
||||||
<ArrowLeftIcon className="size-4" aria-hidden />
|
variant="ghost"
|
||||||
К каталогу
|
size="icon"
|
||||||
</Button>
|
aria-label="Изменить"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PencilIcon aria-hidden="true" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Изменить</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -293,11 +333,22 @@ function ServiceDetailPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<HealthProviderStatusTiles
|
||||||
|
enabled={enabledProviders}
|
||||||
|
selected={effectiveProviders}
|
||||||
|
statuses={providerStatuses}
|
||||||
|
onChange={setSelectedProviders}
|
||||||
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
aria-label="Мониторинг"
|
aria-label="Мониторинг"
|
||||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||||
>
|
>
|
||||||
<UptimeChart items={logItems} isLoading={logQuery.isLoading} />
|
<ServiceHealthMonitor
|
||||||
|
items={logItems}
|
||||||
|
selectedProviders={effectiveProviders}
|
||||||
|
isLoading={logQuery.isLoading}
|
||||||
|
/>
|
||||||
<Frame dense spacing="sm" className="min-w-0 w-full">
|
<Frame dense spacing="sm" className="min-w-0 w-full">
|
||||||
<FrameHeader>
|
<FrameHeader>
|
||||||
<FrameTitle>Failover</FrameTitle>
|
<FrameTitle>Failover</FrameTitle>
|
||||||
@@ -311,30 +362,6 @@ function ServiceDetailPage() {
|
|||||||
</Frame>
|
</Frame>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Frame dense spacing="sm" className="w-full">
|
|
||||||
<FrameHeader>
|
|
||||||
<FrameTitle>Журнал проб</FrameTitle>
|
|
||||||
<FrameDescription>
|
|
||||||
Cloudflare = Worker с edge, не Health Checks API
|
|
||||||
</FrameDescription>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel>
|
|
||||||
<HealthTimeline
|
|
||||||
events={logItems.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,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
{service.ips.length === 0 && service.domains.length === 0 ? (
|
{service.ips.length === 0 && service.domains.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Пустой сервис"
|
title="Пустой сервис"
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ import {
|
|||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
||||||
loader: ({ context: { queryClient }, params }) => {
|
loader: async ({ context: { queryClient }, params }) => {
|
||||||
const id = Number(params.serviceId)
|
const id = Number(params.serviceId)
|
||||||
return Promise.all([
|
const [view] = await Promise.all([
|
||||||
queryClient.ensureQueryData(serviceViewQueryOptions(id)),
|
queryClient.ensureQueryData(serviceViewQueryOptions(id)),
|
||||||
queryClient.ensureQueryData(serviceOverviewQueryOptions(id)),
|
queryClient.ensureQueryData(serviceOverviewQueryOptions(id)),
|
||||||
queryClient.ensureQueryData(serviceHealthLogQueryOptions(id)),
|
queryClient.ensureQueryData(serviceHealthLogQueryOptions(id)),
|
||||||
queryClient.ensureQueryData(serviceNodesQueryOptions(id)),
|
queryClient.ensureQueryData(serviceNodesQueryOptions(id)),
|
||||||
])
|
])
|
||||||
|
return { breadcrumb: view.name }
|
||||||
},
|
},
|
||||||
component: ServiceLayout,
|
component: ServiceLayout,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user