Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69119a08a4 |
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ActivityIcon,
|
||||
ArrowLeftIcon,
|
||||
GlobeIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
@@ -19,7 +18,6 @@ import { FailoverTimeline } from '@/components/failover-timeline'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
@@ -29,7 +27,11 @@ import {
|
||||
type ServiceFqdnRow,
|
||||
} from '@/components/services/service-detail-grid'
|
||||
import { LbModeTile } from '@/components/services/service-unit-card'
|
||||
import { KpiStatGrid, UptimeChart } from '@/components/reui-kit'
|
||||
import {
|
||||
HealthProviderStatusTiles,
|
||||
KpiStatGrid,
|
||||
ServiceHealthMonitor,
|
||||
} from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
@@ -38,7 +40,12 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { api } from '@/lib/api-client'
|
||||
import {
|
||||
enabledHealthProviders,
|
||||
providerHealthStatuses,
|
||||
} from '@/lib/health-log'
|
||||
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||
import {
|
||||
createServiceNode,
|
||||
deleteServiceNode,
|
||||
@@ -56,6 +63,11 @@ import {
|
||||
} from '@/queries'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
||||
component: ServiceDetailPage,
|
||||
@@ -92,11 +104,17 @@ function ServiceDetailPage() {
|
||||
|
||||
const service = viewQuery.data
|
||||
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 [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
|
||||
@@ -212,6 +230,20 @@ function ServiceDetailPage() {
|
||||
: `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 (
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
@@ -236,14 +268,22 @@ function ServiceDetailPage() {
|
||||
<>
|
||||
<LbModeTile mode={service.lb_mode} />
|
||||
<HealthCheckBadge status={service.health_status} />
|
||||
<Button size="sm" onClick={() => setEditOpen(true)}>
|
||||
<PencilIcon className="size-4" aria-hidden />
|
||||
Изменить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" render={<Link to="/services" />}>
|
||||
<ArrowLeftIcon className="size-4" aria-hidden />
|
||||
К каталогу
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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
|
||||
aria-label="Мониторинг"
|
||||
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">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Failover</FrameTitle>
|
||||
@@ -311,30 +362,6 @@ function ServiceDetailPage() {
|
||||
</Frame>
|
||||
</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 ? (
|
||||
<EmptyState
|
||||
title="Пустой сервис"
|
||||
|
||||
@@ -8,14 +8,15 @@ import {
|
||||
} from '@/queries'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
||||
loader: ({ context: { queryClient }, params }) => {
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
const id = Number(params.serviceId)
|
||||
return Promise.all([
|
||||
const [view] = await Promise.all([
|
||||
queryClient.ensureQueryData(serviceViewQueryOptions(id)),
|
||||
queryClient.ensureQueryData(serviceOverviewQueryOptions(id)),
|
||||
queryClient.ensureQueryData(serviceHealthLogQueryOptions(id)),
|
||||
queryClient.ensureQueryData(serviceNodesQueryOptions(id)),
|
||||
])
|
||||
return { breadcrumb: view.name }
|
||||
},
|
||||
component: ServiceLayout,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user