Files
cloudflare-domain-manager/apps/web/src/lib/health-log.ts
T
DenozordecandCursor f5d97e463a
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m14s
CD / publish (push) Successful in 2m3s
fix(services): показывать живой health и историю выхода из пула
Таблица IP брала hysteresis unknown вместо последней пробы. Failover теперь показывает выход и возврат в пул, а не только DNS add.

Co-authored-by: Cursor <[email protected]>
2026-08-20 16:43:22 +07:00

197 lines
5.9 KiB
TypeScript

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,
)
}
/** Any up → up; else degraded, then down, then unknown. */
export function bestAliveHealthStatus(
statuses: readonly HealthLogStatus[],
): HealthLogStatus {
if (statuses.length === 0) return 'unknown'
if (statuses.some((status) => status === 'up')) return 'up'
if (statuses.some((status) => status === 'degraded')) return 'degraded'
if (statuses.some((status) => status === 'down')) return 'down'
return 'unknown'
}
/** Latest probe per IP for a provider, then any-up 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] = bestAliveHealthStatus(statuses)
}
return result
}
export interface IpDisplayHealth {
status: HealthLogStatus
latency_ms: number | null
last_checked_at: string
last_error: string | null
colo: string | null
provider: HealthCheckProvider
}
/**
* Latest probe per provider+IP, then any-up among those providers.
* Used by the IP table so hysteresis `unknown` in ip_health does not hide a live OK.
*/
export function latestHealthByIp(
items: readonly HealthLogProbe[],
): Map<string, IpDisplayHealth> {
const latest = 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 (!latest.has(key)) latest.set(key, item)
}
const byIp = new Map<string, HealthLogProbe[]>()
for (const item of latest.values()) {
const list = byIp.get(item.ip)
if (list) list.push(item)
else byIp.set(item.ip, [item])
}
const result = new Map<string, IpDisplayHealth>()
for (const [ip, probes] of byIp) {
const status = bestAliveHealthStatus(probes.map((probe) => probe.status))
const preferred =
probes.find((probe) => probe.status === status) ?? probes[0]!
result.set(ip, {
status,
latency_ms: preferred.latency_ms,
last_checked_at: preferred.checked_at,
last_error: preferred.error,
colo: preferred.colo,
provider: preferred.provider,
})
}
return result
}
/** Prefer a concrete live probe over stored hysteresis `unknown`. */
export function resolveIpDisplayHealth(
stored: HealthLogStatus | undefined,
live: HealthLogStatus | undefined,
): HealthLogStatus {
if (live && live !== 'unknown') return live
return stored ?? live ?? 'unknown'
}