Compare commits

...
4 Commits
Author SHA1 Message Date
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
2026-08-20 14:53:44 +07:00
26 changed files with 1232 additions and 143 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) => {
const { id } = request.params as { id: string };
return certificateService.listServiceCertificates(
@@ -386,7 +386,8 @@ function applyAggregatedStatus(
{ colo, provider: statusProvider },
);
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, {
health_status: node,
consecutive_failures: failures,
@@ -39,6 +39,43 @@ import {
export type { LbIpRow, LbTargetConfig };
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 {
fqdn: 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;
}
const { config, rows } = getBindingLbState(db, binding.id);
const bindingActiveIps = targetCname
? []
: selectActiveIpsByMode(config, rows);
return {
binding_id: binding.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",
cert_monitoring: binding.cert_monitoring ?? "auto",
sync_status: aggregateSyncStatus(statuses),
active_ips: bindingActiveIps,
};
});
const activeIps = new Set<string>();
for (const binding of bindings) {
const { config, rows } = getBindingLbState(db, binding.id);
for (const ip of selectActiveIpsByMode(config, rows)) {
for (const domain of domainViews) {
for (const ip of domain.active_ips) {
activeIps.add(ip);
}
}
@@ -677,6 +719,14 @@ async function syncBindingADns(
if (desiredIps.length === 0) {
repos.setBindingDnsRecordId(db, bindingId, null);
recordFailoverDnsDiff(
db,
bindingId,
hostname,
zoneName,
existingRecords,
desiredIps,
);
return;
}
@@ -725,6 +775,14 @@ async function syncBindingADns(
}
repos.setBindingDnsRecordId(db, bindingId, primaryId);
recordFailoverDnsDiff(
db,
bindingId,
hostname,
zoneName,
existingRecords,
desiredIps,
);
}
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]?.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", () => {
@@ -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");
});
});
+109 -58
View File
@@ -13,7 +13,11 @@ import {
import { HealthCheckBadge } from '@/components/health-check-badge'
import { EmptyState } from '@/components/empty-state'
import { formatDate, formatRelative, sqliteUtcToIso } from '@/lib/format'
import type { FailoverEvent } from '@/lib/failover-events'
import {
failoverEventCopy,
type FailoverEvent,
} from '@/lib/failover-events'
import type { FailoverLogEntry } from '@/lib/schemas'
import { cn } from '@cfdm/ui/lib/utils'
import type { ComponentProps } from 'react'
@@ -29,32 +33,44 @@ function failStreakLabel(count: number): string {
return `${count} ошибок подряд`
}
function indicatorClass(status: string): string {
if (status === 'checking') {
return 'border-warning bg-warning/15 group-data-completed/timeline-item:border-warning'
function indicatorClass(tone: 'down' | 'added' | 'removed'): string {
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(status: string): string {
if (status === 'checking') return 'bg-warning/25'
function separatorClass(tone: 'down' | 'added' | 'removed'): string {
if (tone === 'added') return 'bg-success/25'
return 'bg-destructive/25'
}
function failoverHistoryCopy(item: FailoverLogEntry): string {
return item.action === 'removed'
? `${item.ip} убрана с ${item.fqdn}`
: `${item.ip} добавлена на ${item.fqdn}`
}
/**
* Failover как sibling «Смены статуса»: ReUI Timeline + Badge, не степпер.
* 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 }: { events: FailoverEvent[] }) {
if (events.length === 0) {
export function FailoverTimeline({
events,
history = [],
}: {
events: FailoverEvent[]
history?: readonly FailoverLogEntry[]
}) {
if (events.length === 0 && history.length === 0) {
return (
<EmptyState
icon={ShieldCheckIcon}
title="Пул стабилен"
description="События появятся, когда нода станет unhealthy или down и будет выведена из пула"
title="Нет инцидентов Failover"
description="Нет Down и нет смен A-записей по FQDN"
stackedIcon={false}
centered={false}
/>
@@ -62,53 +78,88 @@ export function FailoverTimeline({ events }: { events: FailoverEvent[] }) {
}
return (
<Timeline defaultValue={0} className="gap-0">
{events.map((event, index) => {
const checkedIso = event.lastCheckAt
? (sqliteUtcToIso(event.lastCheckAt) ?? event.lastCheckAt)
: null
const isChecking = event.status === 'checking'
<div className="flex flex-col gap-4">
{events.length > 0 ? (
<Timeline defaultValue={0} className="gap-0">
{events.map((event, index) => {
const checkedIso = event.lastCheckAt
? (sqliteUtcToIso(event.lastCheckAt) ?? event.lastCheckAt)
: null
return (
<TimelineItem key={event.id} step={index + 1}>
<TimelineSeparator className={separatorClass(event.status)} />
<TimelineIndicator className={indicatorClass(event.status)} />
<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>
{failStreakLabel(event.consecutiveFailures)}
{checkedIso
? ` · ${formatRelative(checkedIso)} · ${formatDate(checkedIso)}`
: null}
</TimelineDate>
</TimelineHeader>
<TimelineContent className="flex flex-col gap-2">
<p className="text-foreground text-sm">
{isChecking
? 'Health-check ещё не завершён — нода не в активном пуле'
: 'Нода выведена из активного пула'}
</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>
return (
<TimelineItem key={event.id} step={index + 1}>
<TimelineSeparator className={separatorClass('down')} />
<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>
<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">
{failoverHistoryCopy(item)}
</p>
</TimelineContent>
</TimelineItem>
)
})}
</Timeline>
) : null}
</div>
)
}
@@ -3,8 +3,10 @@ import { UnplugIcon } from 'lucide-react'
import { FailoverTimeline } from '@/components/failover-timeline'
import {
toFailoverEvents,
type FailoverNodeInput,
type FailoverBindingPool,
type FailoverHealthInput,
} from '@/lib/failover-events'
import type { FailoverLogEntry } from '@/lib/schemas'
import { Badge } from '@/components/reui/badge'
import {
Frame,
@@ -22,15 +24,15 @@ import {
function failoverCountLabel(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) return `${count} нода вне пула`
if (mod10 === 1 && mod100 !== 11) return `${count} адрес Down`
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return `${count} ноды вне пула`
return `${count} адреса Down`
}
return `${count} нод вне пула`
return `${count} адресов Down`
}
/**
* Failover — sibling ServiceHealthMonitor: Frame stacked + Alert + Timeline.
* Failover — текущие Down + журнал add/remove по FQDN.
* 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
@@ -39,11 +41,16 @@ function failoverCountLabel(count: number): string {
* Docs: https://reui.io/docs/components/base/alert
*/
export function ServiceFailoverPanel({
nodes,
ipHealth,
bindings,
history,
}: {
nodes: readonly FailoverNodeInput[]
ipHealth: readonly FailoverHealthInput[]
bindings: readonly FailoverBindingPool[]
history: readonly FailoverLogEntry[]
}) {
const events = toFailoverEvents(nodes)
const events = toFailoverEvents(ipHealth, bindings)
const removedCount = events.filter((event) => event.kind === 'removed').length
return (
<Frame stacked spacing="sm" className="min-w-0 w-full">
@@ -62,7 +69,7 @@ export function ServiceFailoverPanel({
)}
</FrameTitle>
<FrameDescription>
Нездоровые ноды выведены из пула · причина последней ошибки
Текущие Down и история A-записей по FQDN
</FrameDescription>
</FrameHeader>
@@ -71,12 +78,14 @@ export function ServiceFailoverPanel({
<UnplugIcon aria-hidden="true" />
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
<AlertDescription>
Трафик на эти адреса не идёт, пока health не восстановится
{removedCount > 0
? 'Сняты с FQDN или остались last-resort'
: 'Остались в A-записях как last-resort'}
</AlertDescription>
</Alert>
) : null}
<FailoverTimeline events={events} />
<FailoverTimeline events={events} history={history} />
</FramePanel>
</Frame>
)
@@ -45,6 +45,28 @@ describe('toAlignedSeries', () => {
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', () => {
const { points } = toAlignedSeries([
probe({
@@ -124,8 +124,13 @@ export function toAlignedSeries(items: UptimeProbe[]): {
}
const ok = probeOk(item)
row[`${key}Ok`] = ok
row[key] = ok ? item.latency_ms : null
const prevOk = row[`${key}Ok`]
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()]
+170 -35
View File
@@ -1,53 +1,188 @@
import { describe, expect, it } from 'vitest'
import {
failoverEventCopy,
isFailoverEventStatus,
toFailoverEvents,
type FailoverNodeInput,
type FailoverBindingPool,
type FailoverHealthInput,
} from '@/lib/failover-events'
function node(
overrides: Partial<FailoverNodeInput> & Pick<FailoverNodeInput, 'address'>,
): FailoverNodeInput {
function row(
overrides: Partial<FailoverHealthInput> & Pick<FailoverHealthInput, 'ip'>,
): FailoverHealthInput {
return {
id: overrides.id ?? overrides.address,
health_status: 'healthy',
status: 'up',
consecutive_failures: 0,
last_failure_reason: null,
last_check_at: null,
last_error: null,
last_checked_at: null,
...overrides,
}
}
describe('toFailoverEvents', () => {
it('оставляет только unhealthy / down / checking', () => {
const events = toFailoverEvents([
node({ address: '10.0.0.1', health_status: 'healthy' }),
node({
address: '130.49.213.153',
health_status: 'unhealthy',
consecutive_failures: 9,
last_failure_reason: 'fetch failed',
last_check_at: '2026-08-20 07:00:00',
}),
node({ address: '10.0.0.3', health_status: 'checking' }),
node({ address: '10.0.0.4', health_status: 'disabled' }),
])
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'],
},
]
expect(events.map((event) => event.address)).toEqual([
'130.49.213.153',
'10.0.0.3',
])
expect(events[0]).toMatchObject({
consecutiveFailures: 9,
lastFailureReason: 'fetch failed',
lastCheckAt: '2026-08-20 07:00:00',
status: 'unhealthy',
})
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('не считает healthy failover-событием', () => {
expect(isFailoverEventStatus('healthy')).toBe(false)
expect(isFailoverEventStatus('unhealthy')).toBe(true)
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',
},
])
})
})
+57 -18
View File
@@ -1,36 +1,75 @@
export type FailoverEventKind = 'removed' | 'last-resort'
export interface FailoverEvent {
id: string
address: string
status: string
kind: FailoverEventKind
fqdns: string[]
consecutiveFailures: number
lastFailureReason: string | null
lastCheckAt?: string | null
}
export interface FailoverNodeInput {
id?: number | string
address: string
health_status: string
consecutive_failures: number
last_failure_reason: string | null
last_check_at?: string | null
/** Binding IP health — тот же контур, что таблица активов. */
export interface FailoverHealthInput {
ip: string
status: string
consecutive_failures?: number
last_error?: string | null
last_checked_at?: string | null
}
const FAILOVER_STATUSES = new Set(['unhealthy', 'down', 'checking'])
export interface FailoverBindingPool {
fqdn: string
configured: readonly string[]
active: readonly string[]
}
/** Инцидент только при down. up / degraded / unknown — не вывод из пула. */
export function isFailoverEventStatus(status: string): boolean {
return FAILOVER_STATUSES.has(status)
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(
nodes: readonly FailoverNodeInput[],
ipHealth: readonly FailoverHealthInput[],
bindings: readonly FailoverBindingPool[] = [],
): FailoverEvent[] {
return nodes.filter((node) => isFailoverEventStatus(node.health_status)).map((node) => ({
id: String(node.id ?? node.address),
address: node.address,
status: node.health_status,
consecutiveFailures: node.consecutive_failures,
lastFailureReason: node.last_failure_reason,
lastCheckAt: node.last_check_at ?? null,
}))
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,
}
})
}
+12 -2
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
bestAliveHealthStatus,
collapseStatusChanges,
enabledHealthProviders,
providerHealthStatuses,
@@ -73,7 +74,7 @@ describe('enabledHealthProviders', () => {
})
describe('providerHealthStatuses', () => {
it('uses worst latest-per-ip status', () => {
it('uses any-up among latest-per-ip statuses', () => {
const items = [
probe({ id: 1, ip: '1.1.1.1', status: 'up', checked_at: '2026-01-01T00:02:00Z' }),
probe({ id: 2, ip: '2.2.2.2', status: 'down', checked_at: '2026-01-01T00:01:00Z' }),
@@ -84,7 +85,7 @@ describe('providerHealthStatuses', () => {
checked_at: '2026-01-01T00:00:00Z',
}),
]
expect(providerHealthStatuses(items, ['local']).local).toBe('down')
expect(providerHealthStatuses(items, ['local']).local).toBe('up')
})
})
@@ -95,3 +96,12 @@ describe('worstHealthStatus', () => {
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')
})
})
+13 -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(
items: readonly HealthLogProbe[],
providers: readonly HealthCheckProvider[],
@@ -120,7 +131,7 @@ export function providerHealthStatuses(
const statuses = [...latestByIp.values()]
.filter((item) => item.provider === provider)
.map((item) => item.status)
result[provider] = worstHealthStatus(statuses)
result[provider] = bestAliveHealthStatus(statuses)
}
return result
+13
View File
@@ -84,6 +84,7 @@ export const serviceDomainBindingSchema = z
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
sync_status: z.string().nullable().default(null),
active_ips: z.array(z.string()).default([]),
})
.transform((binding) => ({
...binding,
@@ -125,6 +126,18 @@ export const healthProbeLogSchema = z.object({
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({
subdomain: z.string().default(''),
enabled: z.coerce.boolean().default(false),
+11
View File
@@ -1,6 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
failoverLogSchema,
healthProbeLogSchema,
serviceBindingSchema,
serviceGroupsResponseSchema,
@@ -89,6 +90,7 @@ export const serviceDetailKeys = {
overview: (id: number) => [...serviceKeys.all, id, 'overview'] as const,
nodes: (id: number) => [...serviceKeys.all, id, 'nodes'] 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,
}
@@ -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) =>
queryOptions({
queryKey: serviceDetailKeys.overview(id),
@@ -44,6 +44,7 @@ import {
domainsListQueryOptions,
serviceBindingKeys,
serviceDetailKeys,
serviceFailoverLogQueryOptions,
serviceGroupKeys,
serviceGroupsQueryOptions,
serviceHealthLogQueryOptions,
@@ -90,6 +91,7 @@ function ServiceDetailPage() {
const viewQuery = useQuery(serviceViewQueryOptions(id))
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const failoverLogQuery = useQuery(serviceFailoverLogQueryOptions(id))
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const groupsQuery = useQuery(serviceGroupsQueryOptions())
const domainsQuery = useQuery(domainsListQueryOptions())
@@ -100,6 +102,19 @@ function ServiceDetailPage() {
() => 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 [editOpen, setEditOpen] = useState(false)
@@ -129,6 +144,7 @@ function ServiceDetailPage() {
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.failoverLog(id) }),
])
}
@@ -203,8 +219,6 @@ function ServiceDetailPage() {
const isError = viewQuery.isError || overviewQuery.isError
const error = viewQuery.error ?? overviewQuery.error
const failoverNodes = nodes.length > 0 ? nodes : (overview?.nodes ?? [])
const enabledProviders = useMemo(
() => enabledHealthProviders(service?.domains ?? []),
[service],
@@ -313,7 +327,11 @@ function ServiceDetailPage() {
statuses={providerStatuses}
isLoading={logQuery.isLoading}
/>
<ServiceFailoverPanel nodes={failoverNodes} />
<ServiceFailoverPanel
ipHealth={service.ip_health}
bindings={failoverBindings}
history={failoverHistory}
/>
</section>
{service.ips.length === 0 && service.domains.length === 0 ? (
@@ -1,6 +1,7 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { PageShell } from '@/components/page-shell'
import {
serviceFailoverLogQueryOptions,
serviceHealthLogQueryOptions,
serviceNodesQueryOptions,
serviceOverviewQueryOptions,
@@ -14,6 +15,7 @@ export const Route = createFileRoute('/_auth/services/$serviceId')({
queryClient.ensureQueryData(serviceViewQueryOptions(id)),
queryClient.ensureQueryData(serviceOverviewQueryOptions(id)),
queryClient.ensureQueryData(serviceHealthLogQueryOptions(id)),
queryClient.ensureQueryData(serviceFailoverLogQueryOptions(id)),
queryClient.ensureQueryData(serviceNodesQueryOptions(id)),
])
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(),
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", {
id: text("id").primaryKey(),
event_id: text("event_id"),
@@ -395,6 +404,7 @@ var schema = {
domainMonitorResults,
healthProbeLog,
notificationLog,
failoverLog,
auditLog
};
@@ -715,6 +725,7 @@ __export(repos_exports, {
getSyncJob: () => getSyncJob,
insertBinding: () => insertBinding,
insertDnsRecord: () => insertDnsRecord,
insertFailoverLog: () => insertFailoverLog,
insertHealthProbeLog: () => insertHealthProbeLog,
insertNotificationLog: () => insertNotificationLog,
linkBindingRecord: () => linkBindingRecord,
@@ -738,6 +749,7 @@ __export(repos_exports, {
listDomains: () => listDomains,
listDomainsEnriched: () => listDomainsEnriched,
listEnabledDomainMonitors: () => listEnabledDomainMonitors,
listFailoverLogForService: () => listFailoverLogForService,
listGroupDnsRecords: () => listGroupDnsRecords,
listGroups: () => listGroups,
listHealthCheckTargets: () => listHealthCheckTargets,
@@ -1995,6 +2007,12 @@ var WORST_HEALTH_SQL = sql2.raw(`CASE
END) = 1 THEN 'up'
ELSE 'unknown'
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) {
const result = /* @__PURE__ */ new Map();
if (refIds.length === 0) return result;
@@ -2055,7 +2073,7 @@ function aggregateIpHealthByServiceIds(db, serviceIds) {
);
const rows = db.all(sql2`
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
FROM ip_health_status ihs
INNER JOIN service_bindings sb
@@ -2545,6 +2563,26 @@ function listNotificationLog(db, limit = 50) {
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 {
ConflictError,
NotFoundError,
@@ -2560,6 +2598,7 @@ export {
domainMonitors,
domainTags,
domains,
failoverLog,
getAppSettings,
getAppSettingsSecrets,
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,
bindingNodes,
notificationLog,
failoverLog,
serviceBindingIps,
serviceBindingRecords,
serviceBindings,
@@ -2163,6 +2164,14 @@ const WORST_HEALTH_SQL = sql.raw(`CASE
ELSE 'unknown'
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. */
export function aggregateIpHealthByRefs(
db: Db,
@@ -2236,7 +2245,7 @@ export function aggregateGroupScopeHealthByIds(
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(
db: Db,
serviceIds: number[],
@@ -2253,7 +2262,7 @@ export function aggregateIpHealthByServiceIds(
health_latency_ms: number | null;
}>(sql`
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
FROM ip_health_status ihs
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')`),
});
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", {
id: text("id").primaryKey(),
event_id: text("event_id"),
@@ -540,5 +556,6 @@ export const schema = {
domainMonitorResults,
healthProbeLog,
notificationLog,
failoverLog,
auditLog,
};
+30 -1
View File
@@ -143,6 +143,7 @@ interface ServiceDomainBindingView {
fqdn: string;
record_type: "A" | "CNAME";
target_ips: string[];
active_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
target_cname: string | null;
@@ -505,6 +506,19 @@ declare const healthProbeLogSchema: z.ZodObject<{
checked_at: z.ZodString;
}, z.core.$strip>;
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<{
id: z.ZodNumber;
name: z.ZodString;
@@ -643,6 +657,7 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
@@ -668,6 +683,7 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined;
}, {
binding_id: number;
@@ -690,6 +706,7 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
@@ -763,6 +780,7 @@ declare const serviceViewSchema: z.ZodObject<{
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
@@ -788,6 +806,7 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined;
}, {
binding_id: number;
@@ -810,6 +829,7 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
@@ -965,6 +985,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
@@ -990,6 +1011,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined;
}, {
binding_id: number;
@@ -1012,6 +1034,7 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
@@ -1176,6 +1199,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
@@ -1201,6 +1225,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined;
}, {
binding_id: number;
@@ -1223,6 +1248,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
@@ -1338,6 +1364,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
target_ip_weights: Record<string, number>;
@@ -1363,6 +1390,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ip?: string | null | undefined;
}, {
binding_id: number;
@@ -1385,6 +1413,7 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
active_ips: string[];
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
target_ip_weights?: Record<string, number> | undefined;
@@ -2412,4 +2441,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
}, z.core.$strip>;
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(),
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({
id: z.number(),
name: z.string(),
@@ -399,7 +408,8 @@ var serviceDomainBindingSchema = z.object({
health_check_providers: healthCheckProvidersSchema.catch(["local"]),
health_check_aggregate: healthCheckAggregateSchema.catch("majority"),
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) => ({
...binding,
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,
domainMonitorTypeSchema,
domainSchema,
failoverLogSchema,
fqdnToDisplay,
groupSchema,
groupWithStatsSchema,
+13
View File
@@ -100,6 +100,18 @@ export const healthProbeLogSchema = z.object({
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({
id: z.number(),
name: z.string(),
@@ -184,6 +196,7 @@ export const serviceDomainBindingSchema = z
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
cert_monitoring: certMonitoringSchema.default('auto'),
sync_status: z.string().nullable().default(null),
active_ips: z.array(z.string()).default([]),
})
.transform((binding) => ({
...binding,
+1
View File
@@ -190,6 +190,7 @@ export interface ServiceDomainBindingView {
fqdn: string;
record_type: "A" | "CNAME";
target_ips: string[];
active_ips: string[];
target_ip_weights: Record<string, number>;
target_ip_priorities: Record<string, number>;
target_cname: string | null;