Compare commits

..
6 Commits
Author SHA1 Message Date
DenozordecandCursor f5d97e463a fix(services): показывать живой health и историю выхода из пула
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]>
2026-08-20 16:43:22 +07:00
DenozordecandCursor be8f94143f fix(health): не затирать график uptime пробой down в том же бакете
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
CD / quality (push) Successful in 1m7s
CD / publish (push) Successful in 1m38s
Если в минутном бакете есть живой IP, линия строится по его пингу, а не по null от Down.

Co-authored-by: Cursor <[email protected]>
2026-08-20 16:07:24 +07:00
DenozordecandCursor fa7d2b6df3 feat(services): считать сервис живым при одном OK и писать историю failover
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 1m1s
CD / quality (push) Successful in 2m30s
CD / publish (push) Successful in 2m4s
Сервис up, если жив хотя бы один IP. Failover показывает Down по FQDN и журнал add/remove A-записей.

Co-authored-by: Cursor <[email protected]>
2026-08-20 15:49:17 +07:00
DenozordecandCursor 7938d2f707 fix(services): считать failover по IP Health а не по строке ноды
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / web (push) Successful in 52s
quality / api (push) Successful in 42s
CD / quality (push) Successful in 1m50s
CD / publish (push) Successful in 1m43s
Панель брала service_nodes, которую затирал group apply.
Теперь тот же binding ip_health, что таблица активов; group не пишет в ноду.

Co-authored-by: Cursor <[email protected]>
2026-08-20 15:08:10 +07:00
DenozordecandCursor 994e79e118 fix(services): считать failover по DNS-пулу а не по статусу ноды
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
CD / quality (push) Canceled after 34s
CD / publish (push) Canceled after 0s
quality / web (push) Canceled after 22s
Красный инцидент только если адреса нет в active_addresses, даже при unhealthy.

Co-authored-by: Cursor <[email protected]>
2026-08-20 14:53:44 +07:00
DenozordecandCursor 87b0f1a894 fix(services): оформить failover timeline в стиле страницы сервиса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m6s
CD / publish (push) Successful in 1m39s
Frame stacked как у мониторинга, Alert и semantic timeline вместо плоского степпера.

Co-authored-by: Cursor <[email protected]>
2026-08-20 14:31:30 +07:00
28 changed files with 1808 additions and 93 deletions
+12
View File
@@ -78,6 +78,18 @@ export async function serviceRoutes(app: FastifyInstance) {
}; };
}); });
app.get("/services/:id/failover-log", async (request) => {
const { id } = request.params as { id: string };
repos.getService(request.server.db, Number(id));
return {
items: repos.listFailoverLogForService(
request.server.db,
Number(id),
200,
),
};
});
app.get("/services/:id/certificates", async (request) => { app.get("/services/:id/certificates", async (request) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
return certificateService.listServiceCertificates( return certificateService.listServiceCertificates(
@@ -386,7 +386,8 @@ function applyAggregatedStatus(
{ colo, provider: statusProvider }, { colo, provider: statusProvider },
); );
const matchedNode = repos.findNodeByIp(db, target.ip); const matchedNode = repos.findNodeByIp(db, target.ip);
if (matchedNode && matchedNode.enabled) { // Binding-scope only: group apply must not clobber node with its own fetch failed.
if (matchedNode && matchedNode.enabled && target.scope === "binding") {
repos.updateNode(db, matchedNode.id, { repos.updateNode(db, matchedNode.id, {
health_status: node, health_status: node,
consecutive_failures: failures, consecutive_failures: failures,
@@ -39,6 +39,43 @@ import {
export type { LbIpRow, LbTargetConfig }; export type { LbIpRow, LbTargetConfig };
export { selectActiveIpsByMode }; export { selectActiveIpsByMode };
export function failoverARecordDiff(
existingA: readonly string[],
desiredIps: readonly string[],
): { added: string[]; removed: string[] } {
const before = new Set(existingA);
const after = new Set(desiredIps);
return {
added: desiredIps.filter((ip) => !before.has(ip)),
removed: existingA.filter((ip) => !after.has(ip)),
};
}
function recordFailoverDnsDiff(
db: Db,
bindingId: number,
hostname: string,
zoneName: string,
existingRecords: DnsRecord[],
desiredIps: string[],
): void {
const existingA = existingRecords
.filter((record) => record.record_type.toUpperCase() === "A")
.map((record) => record.content);
const { added, removed } = failoverARecordDiff(existingA, desiredIps);
if (added.length === 0 && removed.length === 0) return;
const binding = repos.getBinding(db, bindingId);
repos.insertFailoverLog(db, {
serviceId: binding.service_id,
bindingId,
fqdn: fqdnToDisplay(hostname, zoneName),
entries: [
...added.map((ip) => ({ ip, action: "added" as const })),
...removed.map((ip) => ({ ip, action: "removed" as const })),
],
});
}
export interface ServiceDomainInput { export interface ServiceDomainInput {
fqdn: string; fqdn: string;
target_ips?: string[]; target_ips?: string[];
@@ -285,6 +322,11 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1; if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
} }
const { config, rows } = getBindingLbState(db, binding.id);
const bindingActiveIps = targetCname
? []
: selectActiveIpsByMode(config, rows);
return { return {
binding_id: binding.id, binding_id: binding.id,
domain_id: binding.domain_id, domain_id: binding.domain_id,
@@ -312,13 +354,13 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
health_check_aggregate: binding.health_check_aggregate ?? "majority", health_check_aggregate: binding.health_check_aggregate ?? "majority",
cert_monitoring: binding.cert_monitoring ?? "auto", cert_monitoring: binding.cert_monitoring ?? "auto",
sync_status: aggregateSyncStatus(statuses), sync_status: aggregateSyncStatus(statuses),
active_ips: bindingActiveIps,
}; };
}); });
const activeIps = new Set<string>(); const activeIps = new Set<string>();
for (const binding of bindings) { for (const domain of domainViews) {
const { config, rows } = getBindingLbState(db, binding.id); for (const ip of domain.active_ips) {
for (const ip of selectActiveIpsByMode(config, rows)) {
activeIps.add(ip); activeIps.add(ip);
} }
} }
@@ -677,6 +719,14 @@ async function syncBindingADns(
if (desiredIps.length === 0) { if (desiredIps.length === 0) {
repos.setBindingDnsRecordId(db, bindingId, null); repos.setBindingDnsRecordId(db, bindingId, null);
recordFailoverDnsDiff(
db,
bindingId,
hostname,
zoneName,
existingRecords,
desiredIps,
);
return; return;
} }
@@ -725,6 +775,14 @@ async function syncBindingADns(
} }
repos.setBindingDnsRecordId(db, bindingId, primaryId); repos.setBindingDnsRecordId(db, bindingId, primaryId);
recordFailoverDnsDiff(
db,
bindingId,
hostname,
zoneName,
existingRecords,
desiredIps,
);
} }
async function cleanupBindingDns( async function cleanupBindingDns(
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { failoverARecordDiff } from "../src/services/service-config-service.js";
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
const config = loadConfig();
const res = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: config.adminUsername, password: "admin" },
});
expect(res.statusCode).toBe(200);
const { token } = res.json() as { token: string };
return { authorization: `Bearer ${token}` };
}
describe("failoverARecordDiff", () => {
it("diffs added and removed A contents", () => {
expect(
failoverARecordDiff(
["130.49.213.153", "93.115.203.183"],
["93.115.203.183"],
),
).toEqual({
added: [],
removed: ["130.49.213.153"],
});
expect(failoverARecordDiff(["10.0.0.1"], ["10.0.0.1", "10.0.0.2"])).toEqual({
added: ["10.0.0.2"],
removed: [],
});
});
});
describe("GET /services/:id/failover-log", () => {
it("returns add/remove rows for the service", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const domain = repos.createDomain(app.db, null, "rkns.top", "zone-1");
const service = repos.createService(app.db, "MSK Hip", "msk-hip");
const binding = repos.insertBinding(app.db, domain.id, service.id, "gt", null);
repos.insertFailoverLog(app.db, {
serviceId: service.id,
bindingId: binding.id,
fqdn: "gt.rkns.top",
entries: [
{ ip: "130.49.213.153", action: "removed" },
{ ip: "93.115.203.183", action: "added" },
],
});
const res = await app.inject({
method: "GET",
url: `/api/v1/services/${service.id}/failover-log`,
headers,
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
items: Array<{ ip: string; fqdn: string; action: string }>;
};
expect(body.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
ip: "130.49.213.153",
fqdn: "gt.rkns.top",
action: "removed",
}),
expect.objectContaining({
ip: "93.115.203.183",
fqdn: "gt.rkns.top",
action: "added",
}),
]),
);
await app.close();
});
});
describe("failover_log table", () => {
it("lists newest first", () => {
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "example.com", "zone-1");
const service = repos.createService(db, "Panel", "panel");
const binding = repos.insertBinding(db, domain.id, service.id, "panel", null);
repos.insertFailoverLog(db, {
serviceId: service.id,
bindingId: binding.id,
fqdn: "panel.example.com",
entries: [{ ip: "1.1.1.1", action: "removed" }],
});
repos.insertFailoverLog(db, {
serviceId: service.id,
bindingId: binding.id,
fqdn: "panel.example.com",
entries: [{ ip: "1.1.1.1", action: "added" }],
});
const rows = repos.listFailoverLogForService(db, service.id);
expect(rows.map((row) => row.action)).toEqual(["added", "removed"]);
});
});
+110
View File
@@ -291,6 +291,77 @@ describe("health-check state derivation via runAllChecks", () => {
expect(targets[0]?.ip).toBe("2.59.161.102"); expect(targets[0]?.ip).toBe("2.59.161.102");
expect(targets[0]?.hostname).toBe("s.rkns.top"); expect(targets[0]?.hostname).toBe("s.rkns.top");
}); });
it("does not mark node unhealthy when binding majority is OK and group local fails", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const tcp = await startTcpServer();
try {
const domain = repos.createDomain(db, null, "example.com", "zone-id");
const group = repos.createServiceGroup(
db,
"VPN",
"vpn",
null,
"vpn.example.com",
{
health_check_enabled: true,
health_check_type: "http",
health_check_port: 1,
health_check_timeout_ms: 200,
health_check_path: "/",
},
);
const service = repos.createService(db, "Svc", "svc");
repos.setServiceGroup(db, service.id, group.id);
repos.setServiceEnabled(db, service.id, true);
const binding = repos.insertBinding(db, domain.id, service.id, "@", null);
repos.updateBindingLbConfig(db, binding.id, {
health_check_enabled: true,
health_check_type: "tcp",
health_check_port: tcp.port,
health_check_timeout_ms: 500,
});
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "127.0.0.1", weight: 1, priority: 1 },
]);
const node = repos.findNodeByIp(db, "127.0.0.1");
expect(node).not.toBeNull();
await healthCheckService.runAllChecks(db, {
probeGapMs: 0,
thresholds: {
degradedFailures: 1,
downFailures: 1,
latencyWarnMs: 1000,
},
});
const bindingHealth = repos.getIpHealthStatusRow(
db,
"binding",
binding.id,
"127.0.0.1",
);
const groupHealth = repos.getIpHealthStatusRow(
db,
"group",
group.id,
"127.0.0.1",
);
const after = repos.getNode(db, node!.id);
expect(bindingHealth?.status).toBe("up");
expect(groupHealth?.status).toBe("down");
expect(after.health_status).toBe("healthy");
expect(after.consecutive_failures).toBe(0);
expect(after.last_failure_reason).toBeNull();
} finally {
await new Promise<void>((resolve) => tcp.server.close(() => resolve()));
}
});
}); });
describe("CNAME health mapped onto service IPs", () => { describe("CNAME health mapped onto service IPs", () => {
@@ -326,4 +397,43 @@ describe("CNAME health mapped onto service IPs", () => {
}), }),
]); ]);
}); });
it("getView is up when any binding IP is up", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { getView } = await import("../src/services/service-config-service.js");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "MSK Hip", "msk-hip");
repos.replaceServiceIps(db, service.id, ["10.0.0.1", "10.0.0.2"]);
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: "10.0.0.1", weight: 1, priority: 1 },
{ ip: "10.0.0.2", weight: 1, priority: 1 },
]);
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"10.0.0.1",
"up",
12,
0,
null,
);
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"10.0.0.2",
"down",
null,
5,
"timeout",
);
const view = await getView(db, service.id);
expect(view.health_status).toBe("up");
});
}); });
+142 -21
View File
@@ -1,40 +1,161 @@
import { ShieldCheckIcon } from 'lucide-react'
import { import {
Timeline, Timeline,
TimelineContent, TimelineContent,
TimelineDate,
TimelineHeader, TimelineHeader,
TimelineIndicator, TimelineIndicator,
TimelineItem, TimelineItem,
TimelineSeparator, TimelineSeparator,
TimelineTitle, TimelineTitle,
} from '@/components/reui/timeline' } from '@/components/reui/timeline'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { EmptyState } from '@/components/empty-state'
import { formatDate, formatRelative, sqliteUtcToIso } from '@/lib/format'
import {
failoverEventCopy,
type FailoverEvent,
type FailoverHistoryItem,
} from '@/lib/failover-events'
import { cn } from '@cfdm/ui/lib/utils'
import type { ComponentProps } from 'react'
export interface FailoverEvent { type HealthBadgeStatus = ComponentProps<typeof HealthCheckBadge>['status']
id: string
title: string function failStreakLabel(count: number): string {
detail: string const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) return `${count} ошибка подряд`
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return `${count} ошибки подряд`
}
return `${count} ошибок подряд`
} }
export function FailoverTimeline({ events }: { events: FailoverEvent[] }) { function indicatorClass(tone: 'down' | 'added' | 'removed'): string {
if (events.length === 0) { if (tone === 'added') {
return 'border-success bg-success/15 group-data-completed/timeline-item:border-success'
}
return 'border-destructive bg-destructive/15 group-data-completed/timeline-item:border-destructive'
}
function separatorClass(tone: 'down' | 'added' | 'removed'): string {
if (tone === 'added') return 'bg-success/25'
return 'bg-destructive/25'
}
/**
* Failover как sibling «Смены статуса»: ReUI Timeline + Badge.
* Preview: https://reui.io/preview/base/components/c-timeline-10
* Preview: https://reui.io/preview/base/empty-state-12
* Docs: https://reui.io/docs/components/base/timeline
* Docs: https://reui.io/docs/components/base/badge
*/
export function FailoverTimeline({
events,
history = [],
}: {
events: FailoverEvent[]
history?: readonly FailoverHistoryItem[]
}) {
if (events.length === 0 && history.length === 0) {
return ( return (
<p className="text-muted-foreground text-sm"> <EmptyState
Событий failover пока нет. icon={ShieldCheckIcon}
</p> title="Нет инцидентов Failover"
description="Нет Down и нет выходов из пула"
stackedIcon={false}
centered={false}
/>
) )
} }
return ( return (
<Timeline defaultValue={events.length} className="w-full"> <div className="flex flex-col gap-4">
{events.map((event, index) => ( {events.length > 0 ? (
<TimelineItem key={event.id} step={index + 1}> <Timeline defaultValue={0} className="gap-0">
<TimelineSeparator /> {events.map((event, index) => {
<TimelineIndicator /> const checkedIso = event.lastCheckAt
<TimelineHeader> ? (sqliteUtcToIso(event.lastCheckAt) ?? event.lastCheckAt)
<TimelineTitle>{event.title}</TimelineTitle> : null
</TimelineHeader>
<TimelineContent>{event.detail}</TimelineContent> return (
</TimelineItem> <TimelineItem key={event.id} step={index + 1}>
))} <TimelineSeparator className={separatorClass('down')} />
</Timeline> <TimelineIndicator className={indicatorClass('down')} />
<TimelineHeader>
<TimelineTitle className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">{event.address}</span>
<HealthCheckBadge
status={event.status as HealthBadgeStatus}
lastError={event.lastFailureReason}
lastCheckedAt={checkedIso}
size="xs"
/>
</TimelineTitle>
<TimelineDate>
{event.consecutiveFailures > 0
? failStreakLabel(event.consecutiveFailures)
: null}
{checkedIso
? `${event.consecutiveFailures > 0 ? ' · ' : ''}${formatRelative(checkedIso)} · ${formatDate(checkedIso)}`
: null}
</TimelineDate>
</TimelineHeader>
<TimelineContent className="flex flex-col gap-2">
<p className="text-foreground text-sm">
{failoverEventCopy(event)}
</p>
{event.lastFailureReason ? (
<code
className={cn(
'bg-muted block overflow-x-auto rounded-md px-2 py-1.5 font-mono text-xs',
)}
>
{event.lastFailureReason}
</code>
) : null}
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
) : null}
{history.length > 0 ? (
<Timeline defaultValue={0} className="gap-0">
{history.map((item, index) => {
const checkedIso = sqliteUtcToIso(item.created_at) ?? item.created_at
const tone = item.action === 'added' ? 'added' : 'removed'
return (
<TimelineItem key={item.id} step={index + 1}>
<TimelineSeparator className={separatorClass(tone)} />
<TimelineIndicator className={indicatorClass(tone)} />
<TimelineHeader>
<TimelineTitle className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">{item.ip}</span>
<HealthCheckBadge
status={item.action === 'added' ? 'up' : 'down'}
size="xs"
/>
<span className="text-muted-foreground text-xs">
{item.fqdn}
</span>
</TimelineTitle>
<TimelineDate>
{formatRelative(checkedIso)} · {formatDate(checkedIso)}
</TimelineDate>
</TimelineHeader>
<TimelineContent>
<p className="text-foreground text-sm">{item.copy}</p>
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
) : null}
</div>
) )
} }
@@ -1,5 +1,6 @@
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart' export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
export { ServiceHealthMonitor } from './service-health-monitor' export { ServiceHealthMonitor } from './service-health-monitor'
export { ServiceFailoverPanel } from './service-failover-panel'
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'
@@ -0,0 +1,119 @@
import { UnplugIcon } from 'lucide-react'
import { FailoverTimeline } from '@/components/failover-timeline'
import {
mergeFailoverHistory,
toFailoverEvents,
type FailoverBindingPool,
type FailoverHealthInput,
} from '@/lib/failover-events'
import {
latestHealthByIp,
resolveIpDisplayHealth,
type HealthLogProbe,
type HealthLogStatus,
} from '@/lib/health-log'
import type { FailoverLogEntry } from '@/lib/schemas'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
function failoverCountLabel(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) return `${count} адрес Down`
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return `${count} адреса Down`
}
return `${count} адресов Down`
}
/**
* Failover — текущие Down + кто вышел из пула и кто вернулся.
* Preview: https://reui.io/preview/base/components/c-timeline-10
* Preview: https://reui.io/preview/base/empty-state-12
* Docs: https://reui.io/docs/components/base/frame
* Docs: https://reui.io/docs/components/base/timeline
* Docs: https://reui.io/docs/components/base/badge
* Docs: https://reui.io/docs/components/base/alert
*/
export function ServiceFailoverPanel({
ipHealth,
bindings,
history,
probes = [],
}: {
ipHealth: readonly FailoverHealthInput[]
bindings: readonly FailoverBindingPool[]
history: readonly FailoverLogEntry[]
probes?: readonly HealthLogProbe[]
}) {
const liveByIp = latestHealthByIp(probes)
const overlayHealth = ipHealth.map((row) => {
const live = liveByIp.get(row.ip)
return {
...row,
status: resolveIpDisplayHealth(
row.status as HealthLogStatus,
live?.status,
),
last_error:
live && live.status !== 'unknown' ? live.last_error : row.last_error,
last_checked_at:
live && live.status !== 'unknown'
? live.last_checked_at
: row.last_checked_at,
}
})
const events = toFailoverEvents(overlayHealth, bindings)
const mergedHistory = mergeFailoverHistory(history, probes, bindings)
const removedCount = events.filter((event) => event.kind === 'removed').length
return (
<Frame stacked spacing="sm" className="min-w-0 w-full">
<FramePanel className="flex flex-col gap-3">
<FrameHeader className="gap-1 px-0 py-0">
<FrameTitle className="flex flex-wrap items-center gap-2">
Failover
{events.length > 0 ? (
<Badge variant="destructive-light" size="xs" radius="full">
{events.length}
</Badge>
) : (
<Badge variant="success-light" size="xs" radius="full">
OK
</Badge>
)}
</FrameTitle>
<FrameDescription>
Текущие Down и история: кто вышел из пула и кто вернулся
</FrameDescription>
</FrameHeader>
{events.length > 0 ? (
<Alert variant="destructive">
<UnplugIcon aria-hidden="true" />
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
<AlertDescription>
{removedCount > 0
? 'Сняты с FQDN или остались last-resort'
: 'Остались в A-записях как last-resort'}
</AlertDescription>
</Alert>
) : null}
<FailoverTimeline events={events} history={mergedHistory} />
</FramePanel>
</Frame>
)
}
@@ -45,6 +45,28 @@ describe('toAlignedSeries', () => {
expect(keys).toEqual(['local', 'cloudflare', 'globalping']) expect(keys).toEqual(['local', 'cloudflare', 'globalping'])
}) })
it('keeps live latency when another IP in the same bucket is down', () => {
const { points } = toAlignedSeries([
probe({
id: 1,
latency_ms: 18,
checked_at: '2026-01-01T00:00:10.000Z',
}),
probe({
id: 2,
status: 'down',
ok: false,
latency_ms: null,
checked_at: '2026-01-01T00:00:12.000Z',
}),
])
expect(points).toHaveLength(1)
expect(points[0]?.local).toBe(18)
expect(points[0]?.localOk).toBe(true)
expect(points[0]?.ok).toBe(true)
})
it('does not plot down probes as latency 0', () => { it('does not plot down probes as latency 0', () => {
const { points } = toAlignedSeries([ const { points } = toAlignedSeries([
probe({ probe({
@@ -124,8 +124,13 @@ export function toAlignedSeries(items: UptimeProbe[]): {
} }
const ok = probeOk(item) const ok = probeOk(item)
row[`${key}Ok`] = ok const prevOk = row[`${key}Ok`]
row[key] = ok ? item.latency_ms : null row[`${key}Ok`] = prevOk === true || ok
if (ok && item.latency_ms != null) {
row[key] = item.latency_ms
} else if (row[key] === undefined) {
row[key] = null
}
} }
const points = [...buckets.entries()] const points = [...buckets.entries()]
@@ -18,6 +18,11 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
import { IconTile } from '@/components/reui/icon-tile' import { IconTile } from '@/components/reui/icon-tile'
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters' import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
import { ResourcePage } from '@/components/reui-kit' import { ResourcePage } from '@/components/reui-kit'
import {
latestHealthByIp,
resolveIpDisplayHealth,
type HealthLogProbe,
} from '@/lib/health-log'
import { certRelativeBadge } from '@/components/columns/certificates-columns' import { certRelativeBadge } from '@/components/columns/certificates-columns'
import { certMonitoringOptions } from '@/lib/cert-monitoring' import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatDate } from '@/lib/format' import { formatDate } from '@/lib/format'
@@ -99,8 +104,12 @@ function mapNodeHealth(status: string): HealthStatus {
return 'unknown' return 'unknown'
} }
function buildIpRows(service: ServiceView): ServiceIpRow[] { function buildIpRows(
service: ServiceView,
probes: readonly HealthLogProbe[] = [],
): ServiceIpRow[] {
const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row])) const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row]))
const liveByIp = latestHealthByIp(probes)
const weights = Object.assign( const weights = Object.assign(
{}, {},
...service.domains.map((domain) => domain.target_ip_weights ?? {}), ...service.domains.map((domain) => domain.target_ip_weights ?? {}),
@@ -113,19 +122,25 @@ function buildIpRows(service: ServiceView): ServiceIpRow[] {
return service.ips.map((ip) => { return service.ips.map((ip) => {
const health = healthByIp.get(ip) const health = healthByIp.get(ip)
const live = liveByIp.get(ip)
const status = resolveIpDisplayHealth(health?.status, live?.status)
const extras = live && live.status !== 'unknown' ? live : health
return { return {
id: ip, id: ip,
ip, ip,
status: health?.status ?? 'unknown', status,
enabled: service.ip_enabled[ip] !== false, enabled: service.ip_enabled[ip] !== false,
active: activeSet.has(ip), active: activeSet.has(ip),
weight: weights[ip] ?? 1, weight: weights[ip] ?? 1,
priority: priorities[ip] ?? 1, priority: priorities[ip] ?? 1,
latency_ms: health?.latency_ms ?? null, latency_ms: extras?.latency_ms ?? null,
last_checked_at: health?.last_checked_at ?? null, last_checked_at: extras?.last_checked_at ?? null,
last_error: health?.last_error ?? null, last_error:
colo: health?.colo ?? null, live && live.status !== 'unknown'
provider: health?.provider ?? null, ? live.last_error
: (health?.last_error ?? null),
colo: extras?.colo ?? null,
provider: extras?.provider ?? null,
} }
}) })
} }
@@ -151,17 +166,23 @@ function buildNodeRows(
weight: number weight: number
priority: number priority: number
}>, }>,
probes: readonly HealthLogProbe[] = [],
): ServiceNodeRow[] { ): ServiceNodeRow[] {
return nodes.map((node) => ({ const liveByIp = latestHealthByIp(probes)
id: String(node.id), return nodes.map((node) => {
nodeId: node.id, const stored = mapNodeHealth(node.health_status)
address: node.address, const live = liveByIp.get(node.address)
protocol: node.protocol, return {
port: node.port, id: String(node.id),
health_status: mapNodeHealth(node.health_status), nodeId: node.id,
weight: node.weight, address: node.address,
priority: node.priority, protocol: node.protocol,
})) port: node.port,
health_status: resolveIpDisplayHealth(stored, live?.status),
weight: node.weight,
priority: node.priority,
}
})
} }
function NameCell({ function NameCell({
@@ -199,6 +220,7 @@ interface ServiceDetailGridProps {
weight: number weight: number
priority: number priority: number
}> }>
probes?: readonly HealthLogProbe[]
togglingIp: string | null togglingIp: string | null
onToggleIp: (ip: string, enabled: boolean) => void onToggleIp: (ip: string, enabled: boolean) => void
onChangeIp: (row: ServiceFqdnRow) => void onChangeIp: (row: ServiceFqdnRow) => void
@@ -211,6 +233,7 @@ interface ServiceDetailGridProps {
export function ServiceDetailGrid({ export function ServiceDetailGrid({
service, service,
nodes, nodes,
probes = [],
togglingIp, togglingIp,
onToggleIp, onToggleIp,
onChangeIp, onChangeIp,
@@ -283,9 +306,9 @@ export function ServiceDetailGrid({
}, },
}) })
const ipRows = useMemo(() => buildIpRows(service), [service]) const ipRows = useMemo(() => buildIpRows(service, probes), [service, probes])
const fqdnRows = useMemo(() => buildFqdnRows(service), [service]) const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
const nodeRows = useMemo(() => buildNodeRows(nodes), [nodes]) const nodeRows = useMemo(() => buildNodeRows(nodes, probes), [nodes, probes])
const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted' const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted'
const tabs = TABS.map((entry) => ({ const tabs = TABS.map((entry) => ({
+294
View File
@@ -0,0 +1,294 @@
import { describe, expect, it } from 'vitest'
import {
failoverEventCopy,
isFailoverEventStatus,
mergeFailoverHistory,
toFailoverEvents,
toIpAliveTransitions,
type FailoverBindingPool,
type FailoverHealthInput,
} from '@/lib/failover-events'
import type { FailoverLogEntry } from '@/lib/schemas'
import type { HealthLogProbe } from '@/lib/health-log'
function row(
overrides: Partial<FailoverHealthInput> & Pick<FailoverHealthInput, 'ip'>,
): FailoverHealthInput {
return {
status: 'up',
consecutive_failures: 0,
last_error: null,
last_checked_at: null,
...overrides,
}
}
const mskHip: FailoverBindingPool[] = [
{
fqdn: 'gt.rkns.top',
configured: ['130.49.213.153', '93.115.203.183'],
active: ['93.115.203.183'],
},
{
fqdn: 'nsgt.rkns.top',
configured: ['130.49.213.153'],
active: ['130.49.213.153'],
},
{
fqdn: 'rutg.rkns.top',
configured: ['93.115.203.183'],
active: ['93.115.203.183'],
},
]
describe('toFailoverEvents', () => {
it('инцидент только при binding down, не при unhealthy ноды', () => {
expect(isFailoverEventStatus('down')).toBe(true)
expect(isFailoverEventStatus('up')).toBe(false)
expect(isFailoverEventStatus('degraded')).toBe(false)
expect(isFailoverEventStatus('unknown')).toBe(false)
expect(isFailoverEventStatus('unhealthy')).toBe(false)
})
it('Down всегда виден, даже без A-записей', () => {
const events = toFailoverEvents([
row({
ip: '130.49.213.153',
status: 'down',
consecutive_failures: 9,
last_error: 'fetch failed',
}),
])
expect(events).toEqual([
{
id: '130.49.213.153',
address: '130.49.213.153',
status: 'down',
kind: 'last-resort',
fqdns: [],
consecutiveFailures: 9,
lastFailureReason: 'fetch failed',
lastCheckAt: null,
},
])
expect(failoverEventCopy(events[0]!)).toBe('Down, в A-записях last-resort')
})
it('OK вне пула не инцидент (standby)', () => {
const events = toFailoverEvents(
[
row({ ip: '10.0.0.1', status: 'up' }),
row({ ip: '130.49.213.153', status: 'up' }),
],
[
{
fqdn: 'pool.example.com',
configured: ['10.0.0.1', '130.49.213.153'],
active: ['10.0.0.1'],
},
],
)
expect(events).toEqual([])
})
it('unknown и degraded вне пула не инцидент', () => {
const events = toFailoverEvents(
[
row({ ip: '10.0.0.1', status: 'up' }),
row({ ip: '10.0.0.2', status: 'unknown' }),
row({ ip: '10.0.0.3', status: 'degraded' }),
],
[
{
fqdn: 'pool.example.com',
configured: ['10.0.0.1', '10.0.0.2', '10.0.0.3'],
active: ['10.0.0.1'],
},
],
)
expect(events).toEqual([])
})
it('Down на доп. FQDN last-resort, снятая с общего пула', () => {
const events = toFailoverEvents(
[
row({
ip: '130.49.213.153',
status: 'down',
consecutive_failures: 9,
last_error: 'fetch failed',
last_checked_at: '2026-08-20 07:00:00',
}),
row({ ip: '93.115.203.183', status: 'up' }),
],
mskHip,
)
expect(events).toEqual([
{
id: '130.49.213.153',
address: '130.49.213.153',
status: 'down',
kind: 'removed',
fqdns: ['gt.rkns.top'],
consecutiveFailures: 9,
lastFailureReason: 'fetch failed',
lastCheckAt: '2026-08-20 07:00:00',
},
])
expect(failoverEventCopy(events[0]!)).toBe('Снята с gt.rkns.top')
})
it('Down только last-resort на своём FQDN', () => {
const events = toFailoverEvents(
[row({ ip: '130.49.213.153', status: 'down' })],
[
{
fqdn: 'nsgt.rkns.top',
configured: ['130.49.213.153'],
active: ['130.49.213.153'],
},
],
)
expect(events[0]?.kind).toBe('last-resort')
expect(events[0]?.fqdns).toEqual(['nsgt.rkns.top'])
expect(failoverEventCopy(events[0]!)).toBe(
'Down, в A-записях last-resort на nsgt.rkns.top',
)
})
it('down без A-записи на configured FQDN — снятие', () => {
const events = toFailoverEvents(
[
row({
ip: '130.49.213.153',
status: 'down',
consecutive_failures: 9,
last_error: 'fetch failed',
last_checked_at: '2026-08-20 07:00:00',
}),
],
[
{
fqdn: 'pool.example.com',
configured: ['10.0.0.1', '130.49.213.153'],
active: ['10.0.0.1'],
},
],
)
expect(events).toEqual([
{
id: '130.49.213.153',
address: '130.49.213.153',
status: 'down',
kind: 'removed',
fqdns: ['pool.example.com'],
consecutiveFailures: 9,
lastFailureReason: 'fetch failed',
lastCheckAt: '2026-08-20 07:00:00',
},
])
})
})
function probe(
overrides: Partial<HealthLogProbe> & Pick<HealthLogProbe, 'id' | 'status' | 'checked_at'>,
): HealthLogProbe {
return {
ip: '130.49.213.153',
provider: 'local',
ok: overrides.status === 'up',
latency_ms: 12,
colo: null,
error: null,
...overrides,
}
}
function dns(
overrides: Partial<FailoverLogEntry> & Pick<FailoverLogEntry, 'id' | 'action' | 'created_at'>,
): FailoverLogEntry {
return {
service_id: 13,
binding_id: 1,
fqdn: 'gt.rkns.top',
ip: '130.49.213.153',
...overrides,
}
}
describe('toIpAliveTransitions', () => {
it('emits leave then return, not the initial up', () => {
const transitions = toIpAliveTransitions([
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
probe({ id: 2, status: 'up', checked_at: '2026-08-20T09:01:00Z' }),
probe({ id: 3, status: 'down', checked_at: '2026-08-20T09:02:00Z' }),
probe({ id: 4, status: 'down', checked_at: '2026-08-20T09:03:00Z' }),
probe({ id: 5, status: 'up', checked_at: '2026-08-20T09:04:00Z' }),
])
expect(transitions).toEqual([
{ id: 'probe:3', ip: '130.49.213.153', alive: false, at: '2026-08-20T09:02:00Z' },
{ id: 'probe:5', ip: '130.49.213.153', alive: true, at: '2026-08-20T09:04:00Z' },
])
})
it('any-up across providers: leave only when every source is down', () => {
const transitions = toIpAliveTransitions([
probe({
id: 1,
provider: 'local',
status: 'up',
checked_at: '2026-08-20T09:00:00Z',
}),
probe({
id: 2,
provider: 'cloudflare',
status: 'down',
checked_at: '2026-08-20T09:01:00Z',
}),
probe({
id: 3,
provider: 'local',
status: 'down',
checked_at: '2026-08-20T09:02:00Z',
}),
probe({
id: 4,
provider: 'local',
status: 'up',
checked_at: '2026-08-20T09:03:00Z',
}),
])
expect(transitions.map((item) => item.id)).toEqual(['probe:3', 'probe:4'])
})
})
describe('mergeFailoverHistory', () => {
it('fills leave/return from probes when DNS has only the add', () => {
const history = mergeFailoverHistory(
[dns({ id: 10, action: 'added', created_at: '2026-08-20 09:26:00' })],
[
probe({ id: 1, status: 'up', checked_at: '2026-08-20T09:00:00Z' }),
probe({ id: 2, status: 'down', checked_at: '2026-08-20T09:10:00Z' }),
probe({ id: 3, status: 'up', checked_at: '2026-08-20T09:26:30Z' }),
],
mskHip,
)
expect(history.map((item) => `${item.action}:${item.source}`)).toEqual([
'added:dns',
'removed:probe',
])
expect(history[0]?.copy).toBe('130.49.213.153 добавлена на gt.rkns.top')
expect(history[1]?.copy).toContain('вышла из пула')
})
it('keeps DNS over a probe return in the same 2-minute window', () => {
const history = mergeFailoverHistory(
[dns({ id: 10, action: 'added', created_at: '2026-08-20 09:26:00' })],
[probe({ id: 3, status: 'up', checked_at: '2026-08-20T09:26:30Z' })],
mskHip,
)
expect(history).toHaveLength(1)
expect(history[0]?.source).toBe('dns')
})
})
+231
View File
@@ -0,0 +1,231 @@
import {
bestAliveHealthStatus,
probeTime,
type HealthLogProbe,
type HealthLogStatus,
} from '@/lib/health-log'
import type { FailoverLogEntry } from '@/lib/schemas'
export type FailoverEventKind = 'removed' | 'last-resort'
export interface FailoverHistoryItem {
id: string
ip: string
fqdn: string
action: 'added' | 'removed'
created_at: string
copy: string
source: 'dns' | 'probe'
}
export interface FailoverEvent {
id: string
address: string
status: string
kind: FailoverEventKind
fqdns: string[]
consecutiveFailures: number
lastFailureReason: string | null
lastCheckAt?: string | null
}
/** Binding IP health — тот же контур, что таблица активов. */
export interface FailoverHealthInput {
ip: string
status: string
consecutive_failures?: number
last_error?: string | null
last_checked_at?: string | null
}
export interface FailoverBindingPool {
fqdn: string
configured: readonly string[]
active: readonly string[]
}
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
export function isFailoverEventStatus(status: string): boolean {
return status === 'down'
}
export function failoverEventCopy(event: FailoverEvent): string {
if (event.kind === 'removed') {
return event.fqdns.length > 0
? `Снята с ${event.fqdns.join(', ')}`
: 'Снята с DNS'
}
return event.fqdns.length > 0
? `Down, в A-записях last-resort на ${event.fqdns.join(', ')}`
: 'Down, в A-записях last-resort'
}
/**
* Текущие Down всегда в панели. Per-FQDN: снята с hostname или last-resort.
* Standby (up / degraded / unknown) не инцидент.
*/
export function toFailoverEvents(
ipHealth: readonly FailoverHealthInput[],
bindings: readonly FailoverBindingPool[] = [],
): FailoverEvent[] {
return ipHealth.filter((row) => isFailoverEventStatus(row.status)).map((row) => {
const removedFqdns: string[] = []
const lastResortFqdns: string[] = []
for (const binding of bindings) {
const configured = binding.configured.includes(row.ip)
const active = binding.active.includes(row.ip)
if (configured && !active) removedFqdns.push(binding.fqdn)
else if (configured && active) lastResortFqdns.push(binding.fqdn)
}
const kind: FailoverEventKind =
removedFqdns.length > 0 ? 'removed' : 'last-resort'
return {
id: row.ip,
address: row.ip,
status: row.status,
kind,
fqdns: kind === 'removed' ? removedFqdns : lastResortFqdns,
consecutiveFailures: row.consecutive_failures ?? 0,
lastFailureReason: row.last_error ?? null,
lastCheckAt: row.last_checked_at ?? null,
}
})
}
const DEDUPE_WINDOW_MS = 2 * 60 * 1000
function fqdnsForIp(
ip: string,
bindings: readonly FailoverBindingPool[],
): string[] {
return bindings.filter((binding) => binding.configured.includes(ip)).map((binding) => binding.fqdn)
}
function fqdnLabel(fqdns: string[]): string {
return fqdns.join(', ') || 'пул'
}
function isAliveStatus(status: HealthLogStatus): boolean {
return status === 'up' || status === 'degraded'
}
/**
* Per-IP any-up flips: down вышла из пула, up после down вернулась.
* Initial state is not an event.
*/
export function toIpAliveTransitions(
probes: readonly HealthLogProbe[],
): Array<{ id: string; ip: string; alive: boolean; at: string }> {
const byIp = new Map<string, HealthLogProbe[]>()
for (const item of probes) {
const list = byIp.get(item.ip)
if (list) list.push(item)
else byIp.set(item.ip, [item])
}
const out: Array<{ id: string; ip: string; alive: boolean; at: string }> = []
for (const [ip, list] of byIp) {
list.sort(
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
)
const latestByProvider = new Map<string, HealthLogProbe>()
let prevAlive: boolean | undefined
for (const probe of list) {
latestByProvider.set(probe.provider, probe)
const status = bestAliveHealthStatus(
[...latestByProvider.values()].map((item) => item.status),
)
if (status === 'unknown') continue
const alive = isAliveStatus(status)
if (prevAlive !== undefined && alive !== prevAlive) {
out.push({
id: `probe:${probe.id}`,
ip,
alive,
at: probe.checked_at,
})
}
prevAlive = alive
}
}
return out
}
function dnsHistoryItem(item: FailoverLogEntry): FailoverHistoryItem {
return {
id: `dns:${item.id}`,
ip: item.ip,
fqdn: item.fqdn,
action: item.action,
created_at: item.created_at,
copy:
item.action === 'removed'
? `${item.ip} убрана с ${item.fqdn}`
: `${item.ip} добавлена на ${item.fqdn}`,
source: 'dns',
}
}
function probeHistoryItem(
transition: { id: string; ip: string; alive: boolean; at: string },
bindings: readonly FailoverBindingPool[],
): FailoverHistoryItem {
const fqdns = fqdnsForIp(transition.ip, bindings)
const fqdn = fqdnLabel(fqdns)
const action = transition.alive ? 'added' : 'removed'
return {
id: transition.id,
ip: transition.ip,
fqdn,
action,
created_at: transition.at,
copy:
action === 'removed'
? `${transition.ip} вышла из пула (${fqdn})`
: `${transition.ip} вернулась в пул (${fqdn})`,
source: 'probe',
}
}
function eventTime(value: string): number {
return probeTime(value)
}
/**
* DNS add/remove + health leave/return, newest first.
* Same IP+action within 2 minutes: keep the DNS row (it has a concrete FQDN).
*/
export function mergeFailoverHistory(
dns: readonly FailoverLogEntry[],
probes: readonly HealthLogProbe[],
bindings: readonly FailoverBindingPool[] = [],
): FailoverHistoryItem[] {
const merged = [
...dns.map(dnsHistoryItem),
...toIpAliveTransitions(probes).map((transition) =>
probeHistoryItem(transition, bindings),
),
]
merged.sort(
(a, b) => eventTime(b.created_at) - eventTime(a.created_at) || a.id.localeCompare(b.id),
)
const kept: FailoverHistoryItem[] = []
for (const item of merged) {
const duplicate = kept.find(
(other) =>
other.ip === item.ip &&
other.action === item.action &&
Math.abs(eventTime(other.created_at) - eventTime(item.created_at)) <=
DEDUPE_WINDOW_MS,
)
if (!duplicate) {
kept.push(item)
continue
}
if (duplicate.source === 'probe' && item.source === 'dns') {
kept[kept.indexOf(duplicate)] = item
}
}
return kept
}
+53 -2
View File
@@ -1,9 +1,12 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
bestAliveHealthStatus,
collapseStatusChanges, collapseStatusChanges,
enabledHealthProviders, enabledHealthProviders,
latestHealthByIp,
providerHealthStatuses, providerHealthStatuses,
resolveIpDisplayHealth,
worstHealthStatus, worstHealthStatus,
type HealthLogProbe, type HealthLogProbe,
} from '@/lib/health-log' } from '@/lib/health-log'
@@ -73,7 +76,7 @@ describe('enabledHealthProviders', () => {
}) })
describe('providerHealthStatuses', () => { describe('providerHealthStatuses', () => {
it('uses worst latest-per-ip status', () => { it('uses any-up among latest-per-ip statuses', () => {
const items = [ const items = [
probe({ id: 1, ip: '1.1.1.1', status: 'up', checked_at: '2026-01-01T00:02:00Z' }), 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: 2, ip: '2.2.2.2', status: 'down', checked_at: '2026-01-01T00:01:00Z' }),
@@ -84,7 +87,7 @@ describe('providerHealthStatuses', () => {
checked_at: '2026-01-01T00:00:00Z', checked_at: '2026-01-01T00:00:00Z',
}), }),
] ]
expect(providerHealthStatuses(items, ['local']).local).toBe('down') expect(providerHealthStatuses(items, ['local']).local).toBe('up')
}) })
}) })
@@ -95,3 +98,51 @@ describe('worstHealthStatus', () => {
expect(worstHealthStatus([])).toBe('unknown') expect(worstHealthStatus([])).toBe('unknown')
}) })
}) })
describe('bestAliveHealthStatus', () => {
it('is up if any IP is up', () => {
expect(bestAliveHealthStatus(['up', 'down'])).toBe('up')
expect(bestAliveHealthStatus(['degraded', 'down'])).toBe('degraded')
expect(bestAliveHealthStatus(['down'])).toBe('down')
expect(bestAliveHealthStatus([])).toBe('unknown')
})
})
describe('latestHealthByIp', () => {
it('any-up among latest-per-provider probes', () => {
const items = [
probe({
id: 1,
ip: '130.49.213.153',
provider: 'local',
status: 'up',
checked_at: '2026-08-20T09:00:00Z',
}),
probe({
id: 2,
ip: '130.49.213.153',
provider: 'cloudflare',
status: 'down',
checked_at: '2026-08-20T09:00:01Z',
}),
probe({
id: 3,
ip: '93.115.203.183',
status: 'unknown',
checked_at: '2026-08-20T08:59:00Z',
}),
]
const byIp = latestHealthByIp(items)
expect(byIp.get('130.49.213.153')?.status).toBe('up')
expect(byIp.get('93.115.203.183')?.status).toBe('unknown')
})
})
describe('resolveIpDisplayHealth', () => {
it('prefers a live probe over stored unknown', () => {
expect(resolveIpDisplayHealth('unknown', 'up')).toBe('up')
expect(resolveIpDisplayHealth('down', 'up')).toBe('up')
expect(resolveIpDisplayHealth('up', 'unknown')).toBe('up')
expect(resolveIpDisplayHealth('unknown', undefined)).toBe('unknown')
})
})
+71 -2
View File
@@ -98,7 +98,18 @@ export function worstHealthStatus(statuses: readonly HealthLogStatus[]): HealthL
) )
} }
/** Latest probe per IP for a provider, then worst among those IPs. */ /** 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( export function providerHealthStatuses(
items: readonly HealthLogProbe[], items: readonly HealthLogProbe[],
providers: readonly HealthCheckProvider[], providers: readonly HealthCheckProvider[],
@@ -120,8 +131,66 @@ export function providerHealthStatuses(
const statuses = [...latestByIp.values()] const statuses = [...latestByIp.values()]
.filter((item) => item.provider === provider) .filter((item) => item.provider === provider)
.map((item) => item.status) .map((item) => item.status)
result[provider] = worstHealthStatus(statuses) result[provider] = bestAliveHealthStatus(statuses)
} }
return result 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'
}
+13
View File
@@ -84,6 +84,7 @@ export const serviceDomainBindingSchema = z
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'), health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'), cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
sync_status: z.string().nullable().default(null), sync_status: z.string().nullable().default(null),
active_ips: z.array(z.string()).default([]),
}) })
.transform((binding) => ({ .transform((binding) => ({
...binding, ...binding,
@@ -125,6 +126,18 @@ export const healthProbeLogSchema = z.object({
checked_at: z.string(), checked_at: z.string(),
}) })
export const failoverLogSchema = z.object({
id: z.number(),
service_id: z.number(),
binding_id: z.number(),
fqdn: z.string(),
ip: z.string(),
action: z.enum(['added', 'removed']),
created_at: z.string(),
})
export type FailoverLogEntry = z.infer<typeof failoverLogSchema>
export const serviceViewSchema = serviceSchema.extend({ export const serviceViewSchema = serviceSchema.extend({
subdomain: z.string().default(''), subdomain: z.string().default(''),
enabled: z.coerce.boolean().default(false), enabled: z.coerce.boolean().default(false),
+11
View File
@@ -1,6 +1,7 @@
import { queryOptions } from '@tanstack/react-query' import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { import {
failoverLogSchema,
healthProbeLogSchema, healthProbeLogSchema,
serviceBindingSchema, serviceBindingSchema,
serviceGroupsResponseSchema, serviceGroupsResponseSchema,
@@ -89,6 +90,7 @@ export const serviceDetailKeys = {
overview: (id: number) => [...serviceKeys.all, id, 'overview'] as const, overview: (id: number) => [...serviceKeys.all, id, 'overview'] as const,
nodes: (id: number) => [...serviceKeys.all, id, 'nodes'] as const, nodes: (id: number) => [...serviceKeys.all, id, 'nodes'] as const,
healthLog: (id: number) => [...serviceKeys.all, id, 'health-log'] as const, healthLog: (id: number) => [...serviceKeys.all, id, 'health-log'] as const,
failoverLog: (id: number) => [...serviceKeys.all, id, 'failover-log'] as const,
view: (id: number) => [...serviceKeys.all, id, 'view'] as const, view: (id: number) => [...serviceKeys.all, id, 'view'] as const,
} }
@@ -110,6 +112,15 @@ export const serviceHealthLogQueryOptions = (id: number) =>
}, },
}) })
export const serviceFailoverLogQueryOptions = (id: number) =>
queryOptions({
queryKey: serviceDetailKeys.failoverLog(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/services/${id}/failover-log`)
return z.object({ items: z.array(failoverLogSchema) }).parse(data)
},
})
export const serviceOverviewQueryOptions = (id: number) => export const serviceOverviewQueryOptions = (id: number) =>
queryOptions({ queryOptions({
queryKey: serviceDetailKeys.overview(id), queryKey: serviceDetailKeys.overview(id),
@@ -14,7 +14,6 @@ import {
import { ChangeDomainSheet } from '@/components/change-domain-sheet' import { ChangeDomainSheet } from '@/components/change-domain-sheet'
import { ChangeIpSheet } from '@/components/change-ip-sheet' import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { EmptyState } from '@/components/empty-state' import { EmptyState } from '@/components/empty-state'
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'
@@ -29,15 +28,9 @@ import {
import { LbModeTile } from '@/components/services/service-unit-card' import { LbModeTile } from '@/components/services/service-unit-card'
import { import {
KpiStatGrid, KpiStatGrid,
ServiceFailoverPanel,
ServiceHealthMonitor, ServiceHealthMonitor,
} from '@/components/reui-kit' } from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { import {
enabledHealthProviders, enabledHealthProviders,
@@ -51,6 +44,7 @@ import {
domainsListQueryOptions, domainsListQueryOptions,
serviceBindingKeys, serviceBindingKeys,
serviceDetailKeys, serviceDetailKeys,
serviceFailoverLogQueryOptions,
serviceGroupKeys, serviceGroupKeys,
serviceGroupsQueryOptions, serviceGroupsQueryOptions,
serviceHealthLogQueryOptions, serviceHealthLogQueryOptions,
@@ -84,6 +78,7 @@ interface OverviewPayload {
priority: number priority: number
consecutive_failures: number consecutive_failures: number
last_failure_reason: string | null last_failure_reason: string | null
last_check_at?: string | null
}> }>
} }
@@ -96,6 +91,7 @@ function ServiceDetailPage() {
const viewQuery = useQuery(serviceViewQueryOptions(id)) const viewQuery = useQuery(serviceViewQueryOptions(id))
const overviewQuery = useQuery(serviceOverviewQueryOptions(id)) const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id)) const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const failoverLogQuery = useQuery(serviceFailoverLogQueryOptions(id))
const nodesQuery = useQuery(serviceNodesQueryOptions(id)) const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const groupsQuery = useQuery(serviceGroupsQueryOptions()) const groupsQuery = useQuery(serviceGroupsQueryOptions())
const domainsQuery = useQuery(domainsListQueryOptions()) const domainsQuery = useQuery(domainsListQueryOptions())
@@ -106,6 +102,19 @@ function ServiceDetailPage() {
() => logQuery.data?.items ?? [], () => logQuery.data?.items ?? [],
[logQuery.data?.items], [logQuery.data?.items],
) )
const failoverHistory = useMemo(
() => failoverLogQuery.data?.items ?? [],
[failoverLogQuery.data?.items],
)
const failoverBindings = useMemo(
() =>
(service?.domains ?? []).map((domain) => ({
fqdn: domain.fqdn,
configured: domain.target_ips,
active: domain.active_ips ?? [],
})),
[service?.domains],
)
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? [] const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
const [editOpen, setEditOpen] = useState(false) const [editOpen, setEditOpen] = useState(false)
@@ -135,6 +144,7 @@ function ServiceDetailPage() {
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }), queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }), queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }), queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.failoverLog(id) }),
]) ])
} }
@@ -209,22 +219,6 @@ function ServiceDetailPage() {
const isError = viewQuery.isError || overviewQuery.isError const isError = viewQuery.isError || overviewQuery.isError
const error = viewQuery.error ?? overviewQuery.error const error = viewQuery.error ?? overviewQuery.error
const failoverEvents =
(nodes.length > 0 ? nodes : (overview?.nodes ?? []))
.filter(
(node) =>
node.health_status === 'unhealthy' ||
node.health_status === 'down' ||
node.health_status === 'checking',
)
.map((node) => ({
id: node.address,
title: `${node.address}: ${node.health_status}`,
detail: node.last_failure_reason
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
: `fail ${node.consecutive_failures}`,
}))
const enabledProviders = useMemo( const enabledProviders = useMemo(
() => enabledHealthProviders(service?.domains ?? []), () => enabledHealthProviders(service?.domains ?? []),
[service], [service],
@@ -333,17 +327,12 @@ function ServiceDetailPage() {
statuses={providerStatuses} statuses={providerStatuses}
isLoading={logQuery.isLoading} isLoading={logQuery.isLoading}
/> />
<Frame dense spacing="sm" className="min-w-0 w-full"> <ServiceFailoverPanel
<FrameHeader> ipHealth={service.ip_health}
<FrameTitle>Failover</FrameTitle> bindings={failoverBindings}
<FrameDescription> history={failoverHistory}
Нездоровые ноды и причины последней ошибки probes={logItems}
</FrameDescription> />
</FrameHeader>
<FramePanel>
<FailoverTimeline events={failoverEvents} />
</FramePanel>
</Frame>
</section> </section>
{service.ips.length === 0 && service.domains.length === 0 ? ( {service.ips.length === 0 && service.domains.length === 0 ? (
@@ -356,6 +345,7 @@ function ServiceDetailPage() {
<ServiceDetailGrid <ServiceDetailGrid
service={service} service={service}
nodes={nodes} nodes={nodes}
probes={logItems}
togglingIp={togglingIp} togglingIp={togglingIp}
onToggleIp={(ip, enabled) => { onToggleIp={(ip, enabled) => {
setTogglingIp(ip) setTogglingIp(ip)
@@ -1,6 +1,7 @@
import { createFileRoute, Outlet } from '@tanstack/react-router' import { createFileRoute, Outlet } from '@tanstack/react-router'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { import {
serviceFailoverLogQueryOptions,
serviceHealthLogQueryOptions, serviceHealthLogQueryOptions,
serviceNodesQueryOptions, serviceNodesQueryOptions,
serviceOverviewQueryOptions, serviceOverviewQueryOptions,
@@ -14,6 +15,7 @@ export const Route = createFileRoute('/_auth/services/$serviceId')({
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(serviceFailoverLogQueryOptions(id)),
queryClient.ensureQueryData(serviceNodesQueryOptions(id)), queryClient.ensureQueryData(serviceNodesQueryOptions(id)),
]) ])
return { breadcrumb: view.name } return { breadcrumb: view.name }
+295 -3
View File
File diff suppressed because one or more lines are too long
+40 -1
View File
@@ -355,6 +355,15 @@ var notificationLog = sqliteTable("notification_log", {
message: text("message").notNull(), message: text("message").notNull(),
created_at: text("created_at").notNull().default(sql`datetime('now')`) created_at: text("created_at").notNull().default(sql`datetime('now')`)
}); });
var failoverLog = sqliteTable("failover_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
binding_id: integer("binding_id").notNull().references(() => serviceBindings.id, { onDelete: "cascade" }),
fqdn: text("fqdn").notNull(),
ip: text("ip").notNull(),
action: text("action").notNull(),
created_at: text("created_at").notNull().default(sql`datetime('now')`)
});
var auditLog = sqliteTable("audit_log", { var auditLog = sqliteTable("audit_log", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
event_id: text("event_id"), event_id: text("event_id"),
@@ -395,6 +404,7 @@ var schema = {
domainMonitorResults, domainMonitorResults,
healthProbeLog, healthProbeLog,
notificationLog, notificationLog,
failoverLog,
auditLog auditLog
}; };
@@ -715,6 +725,7 @@ __export(repos_exports, {
getSyncJob: () => getSyncJob, getSyncJob: () => getSyncJob,
insertBinding: () => insertBinding, insertBinding: () => insertBinding,
insertDnsRecord: () => insertDnsRecord, insertDnsRecord: () => insertDnsRecord,
insertFailoverLog: () => insertFailoverLog,
insertHealthProbeLog: () => insertHealthProbeLog, insertHealthProbeLog: () => insertHealthProbeLog,
insertNotificationLog: () => insertNotificationLog, insertNotificationLog: () => insertNotificationLog,
linkBindingRecord: () => linkBindingRecord, linkBindingRecord: () => linkBindingRecord,
@@ -738,6 +749,7 @@ __export(repos_exports, {
listDomains: () => listDomains, listDomains: () => listDomains,
listDomainsEnriched: () => listDomainsEnriched, listDomainsEnriched: () => listDomainsEnriched,
listEnabledDomainMonitors: () => listEnabledDomainMonitors, listEnabledDomainMonitors: () => listEnabledDomainMonitors,
listFailoverLogForService: () => listFailoverLogForService,
listGroupDnsRecords: () => listGroupDnsRecords, listGroupDnsRecords: () => listGroupDnsRecords,
listGroups: () => listGroups, listGroups: () => listGroups,
listHealthCheckTargets: () => listHealthCheckTargets, listHealthCheckTargets: () => listHealthCheckTargets,
@@ -1995,6 +2007,12 @@ var WORST_HEALTH_SQL = sql2.raw(`CASE
END) = 1 THEN 'up' END) = 1 THEN 'up'
ELSE 'unknown' ELSE 'unknown'
END`); END`);
var BEST_ALIVE_HEALTH_SQL = sql2.raw(`CASE
WHEN MAX(CASE WHEN status = 'up' THEN 1 ELSE 0 END) = 1 THEN 'up'
WHEN MAX(CASE WHEN status = 'degraded' THEN 1 ELSE 0 END) = 1 THEN 'degraded'
WHEN MAX(CASE WHEN status = 'down' THEN 1 ELSE 0 END) = 1 THEN 'down'
ELSE 'unknown'
END`);
function aggregateIpHealthByRefs(db, scope, refIds) { function aggregateIpHealthByRefs(db, scope, refIds) {
const result = /* @__PURE__ */ new Map(); const result = /* @__PURE__ */ new Map();
if (refIds.length === 0) return result; if (refIds.length === 0) return result;
@@ -2055,7 +2073,7 @@ function aggregateIpHealthByServiceIds(db, serviceIds) {
); );
const rows = db.all(sql2` const rows = db.all(sql2`
SELECT sb.service_id AS service_id, SELECT sb.service_id AS service_id,
${WORST_HEALTH_SQL} AS health_status, ${BEST_ALIVE_HEALTH_SQL} AS health_status,
MAX(ihs.latency_ms) AS health_latency_ms MAX(ihs.latency_ms) AS health_latency_ms
FROM ip_health_status ihs FROM ip_health_status ihs
INNER JOIN service_bindings sb INNER JOIN service_bindings sb
@@ -2545,6 +2563,26 @@ function listNotificationLog(db, limit = 50) {
LIMIT ${limit} LIMIT ${limit}
`); `);
} }
function insertFailoverLog(db, input) {
for (const entry of input.entries) {
db.insert(failoverLog).values({
service_id: input.serviceId,
binding_id: input.bindingId,
fqdn: input.fqdn,
ip: entry.ip,
action: entry.action
}).run();
}
}
function listFailoverLogForService(db, serviceId, limit = 100) {
return db.all(sql2`
SELECT id, service_id, binding_id, fqdn, ip, action, created_at
FROM failover_log
WHERE service_id = ${serviceId}
ORDER BY created_at DESC, id DESC
LIMIT ${limit}
`);
}
export { export {
ConflictError, ConflictError,
NotFoundError, NotFoundError,
@@ -2560,6 +2598,7 @@ export {
domainMonitors, domainMonitors,
domainTags, domainTags,
domains, domains,
failoverLog,
getAppSettings, getAppSettings,
getAppSettingsSecrets, getAppSettingsSecrets,
groups, groups,
@@ -0,0 +1,11 @@
CREATE TABLE failover_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
binding_id INTEGER NOT NULL REFERENCES service_bindings(id) ON DELETE CASCADE,
fqdn TEXT NOT NULL,
ip TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('added', 'removed')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_failover_log_service ON failover_log(service_id, created_at DESC);
+59 -2
View File
@@ -49,6 +49,7 @@ import {
nodes, nodes,
bindingNodes, bindingNodes,
notificationLog, notificationLog,
failoverLog,
serviceBindingIps, serviceBindingIps,
serviceBindingRecords, serviceBindingRecords,
serviceBindings, serviceBindings,
@@ -2163,6 +2164,14 @@ const WORST_HEALTH_SQL = sql.raw(`CASE
ELSE 'unknown' ELSE 'unknown'
END`); END`);
/** Service is up if any binding IP is up. */
const BEST_ALIVE_HEALTH_SQL = sql.raw(`CASE
WHEN MAX(CASE WHEN status = 'up' THEN 1 ELSE 0 END) = 1 THEN 'up'
WHEN MAX(CASE WHEN status = 'degraded' THEN 1 ELSE 0 END) = 1 THEN 'degraded'
WHEN MAX(CASE WHEN status = 'down' THEN 1 ELSE 0 END) = 1 THEN 'down'
ELSE 'unknown'
END`);
/** Worst status across ip_health_status rows for each ref_id in a scope. */ /** Worst status across ip_health_status rows for each ref_id in a scope. */
export function aggregateIpHealthByRefs( export function aggregateIpHealthByRefs(
db: Db, db: Db,
@@ -2236,7 +2245,7 @@ export function aggregateGroupScopeHealthByIds(
return result; return result;
} }
/** Worst binding-scope health rolled up per service_id. */ /** Binding-scope health rolled up per service_id: any up → service up. */
export function aggregateIpHealthByServiceIds( export function aggregateIpHealthByServiceIds(
db: Db, db: Db,
serviceIds: number[], serviceIds: number[],
@@ -2253,7 +2262,7 @@ export function aggregateIpHealthByServiceIds(
health_latency_ms: number | null; health_latency_ms: number | null;
}>(sql` }>(sql`
SELECT sb.service_id AS service_id, SELECT sb.service_id AS service_id,
${WORST_HEALTH_SQL} AS health_status, ${BEST_ALIVE_HEALTH_SQL} AS health_status,
MAX(ihs.latency_ms) AS health_latency_ms MAX(ihs.latency_ms) AS health_latency_ms
FROM ip_health_status ihs FROM ip_health_status ihs
INNER JOIN service_bindings sb INNER JOIN service_bindings sb
@@ -3055,3 +3064,51 @@ export function listNotificationLog(
`); `);
} }
export type FailoverLogAction = "added" | "removed";
export type FailoverLogRow = {
id: number;
service_id: number;
binding_id: number;
fqdn: string;
ip: string;
action: FailoverLogAction;
created_at: string;
};
export function insertFailoverLog(
db: Db,
input: {
serviceId: number;
bindingId: number;
fqdn: string;
entries: Array<{ ip: string; action: FailoverLogAction }>;
},
): void {
for (const entry of input.entries) {
db.insert(failoverLog)
.values({
service_id: input.serviceId,
binding_id: input.bindingId,
fqdn: input.fqdn,
ip: entry.ip,
action: entry.action,
})
.run();
}
}
export function listFailoverLogForService(
db: Db,
serviceId: number,
limit = 100,
): FailoverLogRow[] {
return db.all<FailoverLogRow>(sql`
SELECT id, service_id, binding_id, fqdn, ip, action, created_at
FROM failover_log
WHERE service_id = ${serviceId}
ORDER BY created_at DESC, id DESC
LIMIT ${limit}
`);
}
+17
View File
@@ -497,6 +497,22 @@ export const notificationLog = sqliteTable("notification_log", {
.default(sql`datetime('now')`), .default(sql`datetime('now')`),
}); });
export const failoverLog = sqliteTable("failover_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
service_id: integer("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
binding_id: integer("binding_id")
.notNull()
.references(() => serviceBindings.id, { onDelete: "cascade" }),
fqdn: text("fqdn").notNull(),
ip: text("ip").notNull(),
action: text("action").notNull(),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
});
export const auditLog = sqliteTable("audit_log", { export const auditLog = sqliteTable("audit_log", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
event_id: text("event_id"), event_id: text("event_id"),
@@ -540,5 +556,6 @@ export const schema = {
domainMonitorResults, domainMonitorResults,
healthProbeLog, healthProbeLog,
notificationLog, notificationLog,
failoverLog,
auditLog, auditLog,
}; };
+30 -1
View File
@@ -143,6 +143,7 @@ interface ServiceDomainBindingView {
fqdn: string; fqdn: string;
record_type: "A" | "CNAME"; record_type: "A" | "CNAME";
target_ips: string[]; target_ips: string[];
active_ips: string[];
target_ip_weights: Record<string, number>; target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>; target_ip_priorities: Record<string, number>;
target_cname: string | null; target_cname: string | null;
@@ -505,6 +506,19 @@ declare const healthProbeLogSchema: z.ZodObject<{
checked_at: z.ZodString; checked_at: z.ZodString;
}, z.core.$strip>; }, z.core.$strip>;
type HealthProbeLog = z.infer<typeof healthProbeLogSchema>; type HealthProbeLog = z.infer<typeof healthProbeLogSchema>;
declare const failoverLogSchema: z.ZodObject<{
id: z.ZodNumber;
service_id: z.ZodNumber;
binding_id: z.ZodNumber;
fqdn: z.ZodString;
ip: z.ZodString;
action: z.ZodEnum<{
added: "added";
removed: "removed";
}>;
created_at: z.ZodString;
}, z.core.$strip>;
type FailoverLogEntry = z.infer<typeof failoverLogSchema>;
declare const groupSchema: z.ZodObject<{ declare const groupSchema: z.ZodObject<{
id: z.ZodNumber; id: z.ZodNumber;
name: z.ZodString; name: z.ZodString;
@@ -643,6 +657,7 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
skipped: "skipped"; skipped: "skipped";
}>>; }>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>; sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
target_ips: string[]; target_ips: string[];
target_ip_weights: Record<string, number>; target_ip_weights: Record<string, number>;
@@ -668,6 +683,7 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority"; health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped"; cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null; sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
}, { }, {
binding_id: number; binding_id: number;
@@ -690,6 +706,7 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority"; health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped"; cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null; sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined; target_ip_weights?: Record<string, number> | undefined;
@@ -763,6 +780,7 @@ declare const serviceViewSchema: z.ZodObject<{
skipped: "skipped"; skipped: "skipped";
}>>; }>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>; sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
target_ips: string[]; target_ips: string[];
target_ip_weights: Record<string, number>; target_ip_weights: Record<string, number>;
@@ -788,6 +806,7 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority"; health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped"; cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null; sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
}, { }, {
binding_id: number; binding_id: number;
@@ -810,6 +829,7 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority"; health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped"; cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null; sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined; target_ip_weights?: Record<string, number> | undefined;
@@ -965,6 +985,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{
skipped: "skipped"; skipped: "skipped";
}>>; }>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>; sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
target_ips: string[]; target_ips: string[];
target_ip_weights: Record<string, number>; target_ip_weights: Record<string, number>;
@@ -990,6 +1011,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority"; health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped"; cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null; sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
}, { }, {
binding_id: number; binding_id: number;
@@ -1012,6 +1034,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority"; health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped"; cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null; sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined; target_ip_weights?: Record<string, number> | undefined;
@@ -1176,6 +1199,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
skipped: "skipped"; skipped: "skipped";
}>>; }>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>; sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
target_ips: string[]; target_ips: string[];
target_ip_weights: Record<string, number>; target_ip_weights: Record<string, number>;
@@ -1201,6 +1225,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority"; health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped"; cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null; sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
}, { }, {
binding_id: number; binding_id: number;
@@ -1223,6 +1248,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority"; health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped"; cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null; sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined; target_ip_weights?: Record<string, number> | undefined;
@@ -1338,6 +1364,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
skipped: "skipped"; skipped: "skipped";
}>>; }>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>; sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
target_ips: string[]; target_ips: string[];
target_ip_weights: Record<string, number>; target_ip_weights: Record<string, number>;
@@ -1363,6 +1390,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority"; health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped"; cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null; sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
}, { }, {
binding_id: number; binding_id: number;
@@ -1385,6 +1413,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority"; health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped"; cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null; sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined; target_ip_weights?: Record<string, number> | undefined;
@@ -2412,4 +2441,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
}, z.core.$strip>; }, z.core.$strip>;
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>; type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>;
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, HEALTH_CHECK_AGGREGATES, HEALTH_CHECK_PROVIDERS, HEALTH_KV_CURSOR_KEY, HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, HEALTH_PROBE_BATCH, HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, HEALTH_STATUS_PROVIDERS, type HealthCheckAggregate, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthProbeResultItem, type HealthProbeResultsDoc, type HealthProbeTargetItem, type HealthProbeTargetsDoc, type HealthStatusProvider, type HealthStatusQuery, type HealthWorkerStatus, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceCertificateRow, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, aggregateHealthOk, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, clampGlobalpingLimit, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, derivePrimaryProvider, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckAggregateSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckProvidersSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusProviderSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, normalizeProbeProvider, normalizeStatusProvider, notificationLogSchema, originHealthCheckSchema, parseFqdn, parseGlobalpingLocations, parseHealthAggregate, parseHealthProviders, reorderServicesSchema, serializeHealthProviders, serviceBindingSchema, serviceCertificateRowSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, targetHasProvider, targetProviders, toggleEnabledSchema, toggleServiceIpSchema, uniqueHealthProviders, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema }; export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type FailoverLogEntry, type Group, type GroupWithStats, HEALTH_CHECK_AGGREGATES, HEALTH_CHECK_PROVIDERS, HEALTH_KV_CURSOR_KEY, HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, HEALTH_PROBE_BATCH, HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, HEALTH_STATUS_PROVIDERS, type HealthCheckAggregate, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthProbeResultItem, type HealthProbeResultsDoc, type HealthProbeTargetItem, type HealthProbeTargetsDoc, type HealthStatusProvider, type HealthStatusQuery, type HealthWorkerStatus, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceCertificateRow, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, aggregateHealthOk, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, clampGlobalpingLimit, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, derivePrimaryProvider, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, failoverLogSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckAggregateSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckProvidersSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusProviderSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, normalizeProbeProvider, normalizeStatusProvider, notificationLogSchema, originHealthCheckSchema, parseFqdn, parseGlobalpingLocations, parseHealthAggregate, parseHealthProviders, reorderServicesSchema, serializeHealthProviders, serviceBindingSchema, serviceCertificateRowSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, targetHasProvider, targetProviders, toggleEnabledSchema, toggleServiceIpSchema, uniqueHealthProviders, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
+12 -1
View File
@@ -322,6 +322,15 @@ var healthProbeLogSchema = z.object({
error: z.string().nullable(), error: z.string().nullable(),
checked_at: z.string() checked_at: z.string()
}); });
var failoverLogSchema = z.object({
id: z.number(),
service_id: z.number(),
binding_id: z.number(),
fqdn: z.string(),
ip: z.string(),
action: z.enum(["added", "removed"]),
created_at: z.string()
});
var groupSchema = z.object({ var groupSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
@@ -399,7 +408,8 @@ var serviceDomainBindingSchema = z.object({
health_check_providers: healthCheckProvidersSchema.catch(["local"]), health_check_providers: healthCheckProvidersSchema.catch(["local"]),
health_check_aggregate: healthCheckAggregateSchema.catch("majority"), health_check_aggregate: healthCheckAggregateSchema.catch("majority"),
cert_monitoring: certMonitoringSchema.default("auto"), cert_monitoring: certMonitoringSchema.default("auto"),
sync_status: z.string().nullable().default(null) sync_status: z.string().nullable().default(null),
active_ips: z.array(z.string()).default([])
}).transform((binding) => ({ }).transform((binding) => ({
...binding, ...binding,
target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [], target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [],
@@ -1019,6 +1029,7 @@ export {
domainMonitorSchema, domainMonitorSchema,
domainMonitorTypeSchema, domainMonitorTypeSchema,
domainSchema, domainSchema,
failoverLogSchema,
fqdnToDisplay, fqdnToDisplay,
groupSchema, groupSchema,
groupWithStatsSchema, groupWithStatsSchema,
+13
View File
@@ -100,6 +100,18 @@ export const healthProbeLogSchema = z.object({
export type HealthProbeLog = z.infer<typeof healthProbeLogSchema> export type HealthProbeLog = z.infer<typeof healthProbeLogSchema>
export const failoverLogSchema = z.object({
id: z.number(),
service_id: z.number(),
binding_id: z.number(),
fqdn: z.string(),
ip: z.string(),
action: z.enum(['added', 'removed']),
created_at: z.string(),
})
export type FailoverLogEntry = z.infer<typeof failoverLogSchema>
export const groupSchema = z.object({ export const groupSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
@@ -184,6 +196,7 @@ export const serviceDomainBindingSchema = z
health_check_aggregate: healthCheckAggregateSchema.catch('majority'), health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
cert_monitoring: certMonitoringSchema.default('auto'), cert_monitoring: certMonitoringSchema.default('auto'),
sync_status: z.string().nullable().default(null), sync_status: z.string().nullable().default(null),
active_ips: z.array(z.string()).default([]),
}) })
.transform((binding) => ({ .transform((binding) => ({
...binding, ...binding,
+1
View File
@@ -190,6 +190,7 @@ export interface ServiceDomainBindingView {
fqdn: string; fqdn: string;
record_type: "A" | "CNAME"; record_type: "A" | "CNAME";
target_ips: string[]; target_ips: string[];
active_ips: string[];
target_ip_weights: Record<string, number>; target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>; target_ip_priorities: Record<string, number>;
target_cname: string | null; target_cname: string | null;