diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index 4107c38..4d08bcb 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -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( diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index 5646ab1..7ad20fa 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -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 { 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 { 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(); - 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( diff --git a/apps/api/test/failover-log.test.ts b/apps/api/test/failover-log.test.ts new file mode 100644 index 0000000..dedcf19 --- /dev/null +++ b/apps/api/test/failover-log.test.ts @@ -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>) { + 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"]); + }); +}); diff --git a/apps/api/test/health-check.test.ts b/apps/api/test/health-check.test.ts index 9d2b29c..cd2deb4 100644 --- a/apps/api/test/health-check.test.ts +++ b/apps/api/test/health-check.test.ts @@ -397,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"); + }); }); diff --git a/apps/web/src/components/failover-timeline.tsx b/apps/web/src/components/failover-timeline.tsx index 9718d76..14b4b0b 100644 --- a/apps/web/src/components/failover-timeline.tsx +++ b/apps/web/src/components/failover-timeline.tsx @@ -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 ( @@ -62,55 +78,88 @@ export function FailoverTimeline({ events }: { events: FailoverEvent[] }) { } return ( - - {events.map((event, index) => { - const checkedIso = event.lastCheckAt - ? (sqliteUtcToIso(event.lastCheckAt) ?? event.lastCheckAt) - : null - const isChecking = event.status === 'checking' +
+ {events.length > 0 ? ( + + {events.map((event, index) => { + const checkedIso = event.lastCheckAt + ? (sqliteUtcToIso(event.lastCheckAt) ?? event.lastCheckAt) + : null - return ( - - - - - - {event.address} - - - - {event.consecutiveFailures > 0 - ? failStreakLabel(event.consecutiveFailures) - : null} - {checkedIso - ? `${event.consecutiveFailures > 0 ? ' · ' : ''}${formatRelative(checkedIso)} · ${formatDate(checkedIso)}` - : null} - - - -

- {isChecking - ? 'Снята с DNS, идёт восстановление' - : 'Снята с DNS — в A-записях этого адреса нет'} -

- {event.lastFailureReason ? ( - - {event.lastFailureReason} - - ) : null} -
-
- ) - })} -
+ return ( + + + + + + {event.address} + + + + {event.consecutiveFailures > 0 + ? failStreakLabel(event.consecutiveFailures) + : null} + {checkedIso + ? `${event.consecutiveFailures > 0 ? ' · ' : ''}${formatRelative(checkedIso)} · ${formatDate(checkedIso)}` + : null} + + + +

+ {failoverEventCopy(event)} +

+ {event.lastFailureReason ? ( + + {event.lastFailureReason} + + ) : null} +
+
+ ) + })} + + ) : null} + + {history.length > 0 ? ( + + {history.map((item, index) => { + const checkedIso = sqliteUtcToIso(item.created_at) ?? item.created_at + const tone = item.action === 'added' ? 'added' : 'removed' + + return ( + + + + + + {item.ip} + + {item.fqdn} + + + + {formatRelative(checkedIso)} · {formatDate(checkedIso)} + + + +

+ {failoverHistoryCopy(item)} +

+
+
+ ) + })} +
+ ) : null} +
) } diff --git a/apps/web/src/components/reui-kit/service-failover-panel.tsx b/apps/web/src/components/reui-kit/service-failover-panel.tsx index 0dfb845..7cfb1d2 100644 --- a/apps/web/src/components/reui-kit/service-failover-panel.tsx +++ b/apps/web/src/components/reui-kit/service-failover-panel.tsx @@ -3,8 +3,10 @@ import { UnplugIcon } from 'lucide-react' import { FailoverTimeline } from '@/components/failover-timeline' import { toFailoverEvents, + 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 @@ -40,12 +42,15 @@ function failoverCountLabel(count: number): string { */ export function ServiceFailoverPanel({ ipHealth, - activeAddresses, + bindings, + history, }: { ipHealth: readonly FailoverHealthInput[] - activeAddresses: readonly string[] + bindings: readonly FailoverBindingPool[] + history: readonly FailoverLogEntry[] }) { - const events = toFailoverEvents(ipHealth, activeAddresses) + const events = toFailoverEvents(ipHealth, bindings) + const removedCount = events.filter((event) => event.kind === 'removed').length return ( @@ -64,7 +69,7 @@ export function ServiceFailoverPanel({ )} - Только down из IP Health вне DNS-пула · как таблица активов + Текущие Down и история A-записей по FQDN @@ -73,12 +78,14 @@ export function ServiceFailoverPanel({