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
Таблица IP брала hysteresis unknown вместо последней пробы. Failover теперь показывает выход и возврат в пул, а не только DNS add. Co-authored-by: Cursor <[email protected]>
197 lines
5.9 KiB
TypeScript
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'
|
|
}
|