Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be8f94143f | ||
|
|
fa7d2b6df3 | ||
|
|
7938d2f707 | ||
|
|
994e79e118 | ||
|
|
87b0f1a894 | ||
|
|
ba4e04a224 | ||
|
|
4c59780ff0 | ||
|
|
1457389ae7 | ||
|
|
153be28799 | ||
|
|
39fac7834f | ||
|
|
d267e40157 |
@@ -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,
|
||||
|
||||
@@ -29,6 +29,7 @@ import * as domainService from "./domain-service.js";
|
||||
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
||||
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
||||
import {
|
||||
isHealthy,
|
||||
selectActiveIpsByMode,
|
||||
withBindingLock,
|
||||
type LbIpRow,
|
||||
@@ -38,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[];
|
||||
@@ -284,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,
|
||||
@@ -311,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);
|
||||
}
|
||||
}
|
||||
@@ -676,6 +719,14 @@ async function syncBindingADns(
|
||||
|
||||
if (desiredIps.length === 0) {
|
||||
repos.setBindingDnsRecordId(db, bindingId, null);
|
||||
recordFailoverDnsDiff(
|
||||
db,
|
||||
bindingId,
|
||||
hostname,
|
||||
zoneName,
|
||||
existingRecords,
|
||||
desiredIps,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -724,6 +775,14 @@ async function syncBindingADns(
|
||||
}
|
||||
|
||||
repos.setBindingDnsRecordId(db, bindingId, primaryId);
|
||||
recordFailoverDnsDiff(
|
||||
db,
|
||||
bindingId,
|
||||
hostname,
|
||||
zoneName,
|
||||
existingRecords,
|
||||
desiredIps,
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupBindingDns(
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,4 +78,53 @@ describe("service bindings prune", () => {
|
||||
expect(bindings).toHaveLength(2);
|
||||
expect(bindings.map((b) => b.hostname).sort()).toEqual(["api", "www"]);
|
||||
});
|
||||
|
||||
it("updateConfig with extra FQDN per IP does not throw when group health-check is on", async () => {
|
||||
const db = setupDb();
|
||||
const cf = mockCf();
|
||||
const health = {
|
||||
health_check_enabled: true,
|
||||
health_check_type: "tcp" as const,
|
||||
health_check_port: 443,
|
||||
health_check_providers: ["local", "cloudflare", "globalping"] as const,
|
||||
health_check_aggregate: "majority" as const,
|
||||
};
|
||||
|
||||
repos.createDomain(db, null, "example.com", "cf-zone-example");
|
||||
const group = repos.createServiceGroup(
|
||||
db,
|
||||
"VPN",
|
||||
"vpn",
|
||||
null,
|
||||
"vpn.example.com",
|
||||
{ ...health },
|
||||
);
|
||||
const service = repos.createService(db, "GT", "gt");
|
||||
repos.setServiceGroup(db, service.id, group.id);
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
|
||||
const view = await updateConfig(db, cf, service.id, {
|
||||
ips: ["93.115.203.183", "130.49.213.153"],
|
||||
domains: [
|
||||
{
|
||||
fqdn: "gt.example.com",
|
||||
target_ips: ["93.115.203.183", "130.49.213.153"],
|
||||
...health,
|
||||
},
|
||||
{
|
||||
fqdn: "rutg.example.com",
|
||||
target_ips: ["93.115.203.183"],
|
||||
...health,
|
||||
},
|
||||
{
|
||||
fqdn: "nsgt.example.com",
|
||||
target_ips: ["130.49.213.153"],
|
||||
...health,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(view.domains).toHaveLength(3);
|
||||
expect(view.ips.sort()).toEqual(["130.49.213.153", "93.115.203.183"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,165 @@
|
||||
import { ShieldCheckIcon } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} 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,
|
||||
} from '@/lib/failover-events'
|
||||
import type { FailoverLogEntry } from '@/lib/schemas'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
export interface FailoverEvent {
|
||||
id: string
|
||||
title: string
|
||||
detail: string
|
||||
type HealthBadgeStatus = ComponentProps<typeof HealthCheckBadge>['status']
|
||||
|
||||
function failStreakLabel(count: number): 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[] }) {
|
||||
if (events.length === 0) {
|
||||
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(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.
|
||||
* 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 FailoverLogEntry[]
|
||||
}) {
|
||||
if (events.length === 0 && history.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Событий failover пока нет.
|
||||
</p>
|
||||
<EmptyState
|
||||
icon={ShieldCheckIcon}
|
||||
title="Нет инцидентов Failover"
|
||||
description="Нет Down и нет смен A-записей по FQDN"
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Timeline defaultValue={events.length} className="w-full">
|
||||
{events.map((event, index) => (
|
||||
<TimelineItem key={event.id} step={index + 1}>
|
||||
<TimelineSeparator />
|
||||
<TimelineIndicator />
|
||||
<TimelineHeader>
|
||||
<TimelineTitle>{event.title}</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineContent>{event.detail}</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
<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('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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -12,82 +12,12 @@ import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||
import { getBreadcrumbs } from '@/lib/breadcrumbs'
|
||||
|
||||
export interface RouteBreadcrumbLoaderData {
|
||||
breadcrumb?: string
|
||||
}
|
||||
|
||||
const routeTitles: Record<string, string> = {
|
||||
'/': 'Панель управления',
|
||||
'/domains': 'Домены',
|
||||
'/groups': 'Группы доменов',
|
||||
'/services': 'Сервисы',
|
||||
'/certificates': 'Сертификаты',
|
||||
'/settings/appearance': 'Внешний вид',
|
||||
'/settings/health': 'Health-check',
|
||||
'/settings/integrations': 'Интеграции',
|
||||
}
|
||||
|
||||
function getBreadcrumbs(
|
||||
pathname: string,
|
||||
dynamicLabels: Record<string, string>,
|
||||
) {
|
||||
if (pathname === '/') {
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/services\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Сервисы', href: '/services' },
|
||||
{ label: dynamicLabels[pathname] ?? 'Сервис', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/groups\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Группы доменов', href: '/groups' },
|
||||
{ label: dynamicLabels[pathname] ?? 'Группа', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/domains\/\d+\/dns$/)) {
|
||||
const domainId = pathname.split('/')[2]
|
||||
const domainPath = `/domains/${domainId}`
|
||||
return [
|
||||
{ label: 'Домены', href: '/domains' },
|
||||
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
|
||||
{ label: 'DNS', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/domains\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Домены', href: '/domains' },
|
||||
{ label: dynamicLabels[pathname] ?? 'Обзор домена', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/settings')) {
|
||||
return [
|
||||
{ label: 'Настройки', href: '/settings/appearance' },
|
||||
...(pathname === '/settings/integrations'
|
||||
? [{ label: 'Интеграции', href: pathname }]
|
||||
: pathname === '/settings/health'
|
||||
? [{ label: 'Health-check', href: pathname }]
|
||||
: pathname === '/settings/appearance'
|
||||
? [{ label: 'Внешний вид', href: pathname }]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
const title = routeTitles[pathname]
|
||||
if (title) {
|
||||
return [{ label: title, href: pathname }]
|
||||
}
|
||||
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
function useDynamicBreadcrumbLabels() {
|
||||
const matches = useMatches()
|
||||
return useMemo(() => {
|
||||
@@ -106,7 +36,10 @@ function useDynamicBreadcrumbLabels() {
|
||||
export function SiteHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
|
||||
const crumbs = useMemo(
|
||||
() => getBreadcrumbs(pathname, dynamicLabels),
|
||||
[pathname, dynamicLabels],
|
||||
)
|
||||
|
||||
return (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
@@ -117,10 +50,10 @@ export function SiteHeader() {
|
||||
{crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1
|
||||
return (
|
||||
<span key={crumb.href} className="contents">
|
||||
{index > 0 && (
|
||||
<Fragment key={`${index}-${crumb.href}`}>
|
||||
{index > 0 ? (
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
)}
|
||||
) : null}
|
||||
<BreadcrumbItem
|
||||
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
|
||||
>
|
||||
@@ -132,7 +65,7 @@ export function SiteHeader() {
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</span>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
|
||||
@@ -298,45 +298,50 @@ export function HealthSourceFilterBar({
|
||||
const active = selected.length > 0 ? selected : enabled
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
multiple
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full min-w-0"
|
||||
value={active}
|
||||
aria-label="Тип пробы"
|
||||
onValueChange={(next) => {
|
||||
const values = next.filter((value): value is HealthProvider =>
|
||||
visible.some((item) => item.id === value),
|
||||
)
|
||||
if (values.length === 0) return
|
||||
onChange(values)
|
||||
}}
|
||||
>
|
||||
{visible.map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
aria-label={item.title}
|
||||
className="min-w-0 flex-1 gap-1.5"
|
||||
>
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className={item.iconClassName}
|
||||
aria-hidden="true"
|
||||
<div className="@container min-w-0 w-full">
|
||||
<ToggleGroup
|
||||
multiple
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex w-full min-w-0 flex-wrap justify-start"
|
||||
value={active}
|
||||
aria-label="Тип пробы"
|
||||
onValueChange={(next) => {
|
||||
const values = next.filter((value): value is HealthProvider =>
|
||||
visible.some((item) => item.id === value),
|
||||
)
|
||||
if (values.length === 0) return
|
||||
onChange(values)
|
||||
}}
|
||||
>
|
||||
{visible.map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
aria-label={item.title}
|
||||
title={item.title}
|
||||
className="max-w-full min-w-0 flex-none justify-start gap-1.5 @[16rem]:min-w-[8.5rem]"
|
||||
>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
<span className="truncate">{item.title}</span>
|
||||
<HealthCheckBadge
|
||||
status={statuses[item.id] ?? 'unknown'}
|
||||
provider={item.id}
|
||||
size="xs"
|
||||
/>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className={item.iconClassName}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
<span className="hidden min-w-0 truncate @[16rem]:inline">
|
||||
{item.title}
|
||||
</span>
|
||||
<HealthCheckBadge
|
||||
status={statuses[item.id] ?? 'unknown'}
|
||||
provider={item.id}
|
||||
size="xs"
|
||||
/>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
|
||||
export { ServiceHealthMonitor } from './service-health-monitor'
|
||||
export { ServiceFailoverPanel } from './service-failover-panel'
|
||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||
@@ -28,3 +29,4 @@ export {
|
||||
type HealthProvider,
|
||||
type HealthAggregate,
|
||||
} from './health-source-tiles'
|
||||
export { ServiceAddressBlock } from './service-address-block'
|
||||
|
||||
@@ -67,7 +67,7 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
if (item.footer) return item.footer
|
||||
if (typeof item.hint === 'string') {
|
||||
return (
|
||||
<Badge variant="outline" size="sm">
|
||||
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
||||
{item.hint}
|
||||
</Badge>
|
||||
)
|
||||
@@ -81,30 +81,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
const valueVariant = item.variant ?? 'default'
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
|
||||
{item.icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||
{footer ? <div className="shrink-0">{footer}</div> : null}
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||
{item.label}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-2xl leading-none font-bold tabular-nums',
|
||||
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
|
||||
VALUE_VARIANT_CLASS[valueVariant],
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -116,7 +125,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
|
||||
const selected = isSelected(item)
|
||||
|
||||
return cn(
|
||||
'relative isolate flex h-full flex-col',
|
||||
'relative isolate flex h-full min-w-0 flex-col',
|
||||
clickable &&
|
||||
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { ServerIcon, Trash2Icon } from 'lucide-react'
|
||||
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { isValidIpv4 } from '@/components/tagged-input'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { parseFqdn } from '@/lib/parse-fqdn'
|
||||
import {
|
||||
addAddressNode,
|
||||
addCommonFqdn,
|
||||
addressHasFqdn,
|
||||
removeAddressNode,
|
||||
removeCommonFqdn,
|
||||
updateCommonFqdn,
|
||||
type AddressBlockState,
|
||||
} from '@/lib/service-address'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Field, FieldLabel } from '@cfdm/ui/components/field'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@cfdm/ui/components/input-group'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
|
||||
function ZoneAddon({
|
||||
fqdn,
|
||||
zoneHints,
|
||||
trailing,
|
||||
}: {
|
||||
fqdn: string
|
||||
zoneHints: string[]
|
||||
trailing?: ReactNode
|
||||
}) {
|
||||
const parsed = parseFqdn(fqdn, zoneHints)
|
||||
if (!parsed && !fqdn.trim() && !trailing) return null
|
||||
return (
|
||||
<InputGroupAddon align="inline-end">
|
||||
{parsed ? (
|
||||
<Badge variant="outline" size="xs" className="font-mono">
|
||||
{parsed.zoneName}
|
||||
</Badge>
|
||||
) : fqdn.trim() ? (
|
||||
<Badge variant="warning-light" size="xs">
|
||||
зона не найдена
|
||||
</Badge>
|
||||
) : null}
|
||||
{trailing}
|
||||
</InputGroupAddon>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый блок адресов сервиса: список общих FQDN на весь пул + IP с доп. доменом.
|
||||
* Preview: https://reui.io/preview/base/settings-3
|
||||
* Preview: https://reui.io/preview/base/list-9
|
||||
* Preview: https://reui.io/preview/base/form-7
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
* Docs: https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function ServiceAddressBlock({
|
||||
value,
|
||||
onChange,
|
||||
zoneHints,
|
||||
}: {
|
||||
value: AddressBlockState
|
||||
onChange: (next: AddressBlockState) => void
|
||||
zoneHints: string[]
|
||||
}) {
|
||||
const [pendingIp, setPendingIp] = useState('')
|
||||
const [ipInvalid, setIpInvalid] = useState(false)
|
||||
const [pendingFqdn, setPendingFqdn] = useState('')
|
||||
const [fqdnInvalid, setFqdnInvalid] = useState(false)
|
||||
|
||||
const pool = value.nodes.map((node) => node.ip)
|
||||
const pendingIpTrimmed = pendingIp.trim()
|
||||
const pendingFqdnTrimmed = pendingFqdn.trim()
|
||||
const pendingIpInvalid =
|
||||
ipInvalid && pendingIpTrimmed.length > 0 && !isValidIpv4(pendingIpTrimmed)
|
||||
const pendingFqdnInvalid =
|
||||
fqdnInvalid && pendingFqdnTrimmed.length > 0
|
||||
|
||||
function tryAddFqdn(raw: string) {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
setFqdnInvalid(false)
|
||||
return
|
||||
}
|
||||
if (addressHasFqdn(value, trimmed)) {
|
||||
setFqdnInvalid(true)
|
||||
return
|
||||
}
|
||||
onChange(addCommonFqdn(value, trimmed))
|
||||
setPendingFqdn('')
|
||||
setFqdnInvalid(false)
|
||||
}
|
||||
|
||||
function tryAddIp(raw: string) {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
setIpInvalid(false)
|
||||
return
|
||||
}
|
||||
if (!isValidIpv4(trimmed) || pool.includes(trimmed)) {
|
||||
setIpInvalid(true)
|
||||
return
|
||||
}
|
||||
onChange(addAddressNode(value, trimmed))
|
||||
setPendingIp('')
|
||||
setIpInvalid(false)
|
||||
}
|
||||
|
||||
function handleFqdnKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
tryAddFqdn(pendingFqdn)
|
||||
}
|
||||
}
|
||||
|
||||
function handleIpKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
tryAddIp(pendingIp)
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeFqdn(ip: string, extraFqdn: string) {
|
||||
onChange({
|
||||
...value,
|
||||
nodes: value.nodes.map((node) =>
|
||||
node.ip === ip ? { ...node, extraFqdn } : node,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame stacked dense spacing="sm" className="w-full min-w-0">
|
||||
<FramePanel fit className="flex flex-col gap-3">
|
||||
<FrameHeader className="px-0 pt-0">
|
||||
<FrameTitle>Адреса</FrameTitle>
|
||||
<FrameDescription>
|
||||
Общие FQDN — на весь пул · у IP свой доп. домен
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="service-common-fqdn-add">Общие домены (FQDN)</FieldLabel>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{value.commonFqdns.map((fqdn, index) => (
|
||||
<InputGroup key={`common-fqdn-${index}`}>
|
||||
<InputGroupInput
|
||||
id={`service-common-fqdn-${index}`}
|
||||
className="font-mono"
|
||||
value={fqdn}
|
||||
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
|
||||
onChange={(event) =>
|
||||
onChange(updateCommonFqdn(value, index, event.target.value))
|
||||
}
|
||||
/>
|
||||
<ZoneAddon
|
||||
fqdn={fqdn}
|
||||
zoneHints={zoneHints}
|
||||
trailing={
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={`Удалить ${fqdn || 'FQDN'}`}
|
||||
onClick={() => onChange(removeCommonFqdn(value, index))}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</InputGroupButton>
|
||||
}
|
||||
/>
|
||||
</InputGroup>
|
||||
))}
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="service-common-fqdn-add"
|
||||
className="font-mono"
|
||||
value={pendingFqdn}
|
||||
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
|
||||
aria-invalid={pendingFqdnInvalid || undefined}
|
||||
onChange={(event) => {
|
||||
setPendingFqdn(event.target.value)
|
||||
setFqdnInvalid(false)
|
||||
}}
|
||||
onKeyDown={handleFqdnKeyDown}
|
||||
onBlur={() => tryAddFqdn(pendingFqdn)}
|
||||
/>
|
||||
<ZoneAddon
|
||||
fqdn={pendingFqdn}
|
||||
zoneHints={zoneHints}
|
||||
trailing={
|
||||
<InputGroupButton size="sm" onClick={() => tryAddFqdn(pendingFqdn)}>
|
||||
Добавить
|
||||
</InputGroupButton>
|
||||
}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</Field>
|
||||
</FramePanel>
|
||||
|
||||
<FramePanel fit className="flex flex-col gap-3">
|
||||
<FrameHeader className="px-0 pt-0">
|
||||
<FrameTitle>IP-адреса</FrameTitle>
|
||||
</FrameHeader>
|
||||
{value.nodes.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ServerIcon}
|
||||
title="Добавьте IP пула"
|
||||
description="IPv4 сервиса. Для каждого адреса можно указать доп. FQDN."
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{value.nodes.map((node) => {
|
||||
return (
|
||||
<Item
|
||||
key={node.ip}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="items-stretch"
|
||||
>
|
||||
<ItemMedia>
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className="text-info"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ServerIcon />
|
||||
</IconTile>
|
||||
</ItemMedia>
|
||||
<ItemContent className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ItemTitle className="font-mono">{node.ip}</ItemTitle>
|
||||
<ItemActions className="ml-auto shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Удалить ${node.ip}`}
|
||||
onClick={() => onChange(removeAddressNode(value, node.ip))}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</div>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel
|
||||
htmlFor={`service-ip-extra-${node.ip}`}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
Доп. FQDN
|
||||
</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id={`service-ip-extra-${node.ip}`}
|
||||
className="font-mono"
|
||||
value={node.extraFqdn}
|
||||
placeholder={
|
||||
zoneHints[0]
|
||||
? `необязательно · spb.${zoneHints[0]}`
|
||||
: 'необязательно · spb.example.com'
|
||||
}
|
||||
onChange={(event) =>
|
||||
handleNodeFqdn(node.ip, event.target.value)
|
||||
}
|
||||
/>
|
||||
<ZoneAddon fqdn={node.extraFqdn} zoneHints={zoneHints} />
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
</ItemGroup>
|
||||
)}
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="service-pool-ip-add"
|
||||
className="font-mono"
|
||||
value={pendingIp}
|
||||
placeholder="192.168.1.1"
|
||||
aria-invalid={pendingIpInvalid || undefined}
|
||||
onChange={(event) => {
|
||||
setPendingIp(event.target.value)
|
||||
setIpInvalid(false)
|
||||
}}
|
||||
onKeyDown={handleIpKeyDown}
|
||||
onBlur={() => tryAddIp(pendingIp)}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="sm" onClick={() => tryAddIp(pendingIp)}>
|
||||
Добавить
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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,
|
||||
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 + журнал 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
|
||||
* 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,
|
||||
}: {
|
||||
ipHealth: readonly FailoverHealthInput[]
|
||||
bindings: readonly FailoverBindingPool[]
|
||||
history: readonly FailoverLogEntry[]
|
||||
}) {
|
||||
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">
|
||||
<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 и история A-записей по FQDN
|
||||
</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={history} />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { toAlignedSeries, type UptimeProbe } from './uptime-chart'
|
||||
|
||||
function probe(
|
||||
overrides: Partial<UptimeProbe> & Pick<UptimeProbe, 'id'>,
|
||||
): UptimeProbe {
|
||||
return {
|
||||
status: 'up',
|
||||
ok: true,
|
||||
latency_ms: 10,
|
||||
checked_at: '2026-01-01T00:00:00.000Z',
|
||||
provider: 'local',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('toAlignedSeries', () => {
|
||||
it('puts mixed-source probes in one 60s bucket instead of a sawtooth series', () => {
|
||||
const { points, keys } = toAlignedSeries([
|
||||
probe({
|
||||
id: 1,
|
||||
provider: 'local',
|
||||
latency_ms: 4,
|
||||
checked_at: '2026-01-01T00:00:10.000Z',
|
||||
}),
|
||||
probe({
|
||||
id: 2,
|
||||
provider: 'cloudflare',
|
||||
latency_ms: 284,
|
||||
checked_at: '2026-01-01T00:00:12.000Z',
|
||||
}),
|
||||
probe({
|
||||
id: 3,
|
||||
provider: 'globalping',
|
||||
latency_ms: 38,
|
||||
checked_at: '2026-01-01T00:00:40.000Z',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(points).toHaveLength(1)
|
||||
expect(points[0]?.local).toBe(4)
|
||||
expect(points[0]?.cloudflare).toBe(284)
|
||||
expect(points[0]?.globalping).toBe(38)
|
||||
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({
|
||||
id: 1,
|
||||
status: 'down',
|
||||
ok: false,
|
||||
latency_ms: 12,
|
||||
provider: 'local',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(points).toHaveLength(1)
|
||||
expect(points[0]?.local).toBeNull()
|
||||
expect(points[0]?.localOk).toBe(false)
|
||||
expect(points[0]?.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('splits probes that fall into adjacent minutes', () => {
|
||||
const { points } = toAlignedSeries([
|
||||
probe({ id: 1, latency_ms: 10, checked_at: '2026-01-01T00:00:50.000Z' }),
|
||||
probe({ id: 2, latency_ms: 20, checked_at: '2026-01-01T00:01:10.000Z' }),
|
||||
])
|
||||
|
||||
expect(points).toHaveLength(2)
|
||||
expect(points[0]?.local).toBe(10)
|
||||
expect(points[1]?.local).toBe(20)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
|
||||
import { Area, AreaChart, XAxis } from 'recharts'
|
||||
import { Area, ComposedChart, Line, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
@@ -22,12 +22,13 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||
|
||||
/**
|
||||
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
|
||||
* Preview: https://reui.io/preview/base/chart-17
|
||||
* Frame: https://reui.io/docs/components/base/frame
|
||||
* Chart: shadcn Chart + Recharts AreaChart
|
||||
* Chart: shadcn Chart + Recharts ComposedChart
|
||||
*/
|
||||
|
||||
export interface UptimeProbe {
|
||||
@@ -36,6 +37,7 @@ export interface UptimeProbe {
|
||||
ok: boolean
|
||||
latency_ms: number | null
|
||||
checked_at: string
|
||||
provider?: HealthCheckProvider
|
||||
}
|
||||
|
||||
export type UptimePeriodKey = '5D' | '2W' | '1M'
|
||||
@@ -46,40 +48,112 @@ export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number
|
||||
{ key: '1M', label: '1M', days: 30 },
|
||||
]
|
||||
|
||||
export const UPTIME_BUCKET_MS = 60_000
|
||||
|
||||
export const UPTIME_PROVIDER_KEYS = ['local', 'cloudflare', 'globalping'] as const
|
||||
|
||||
export type UptimeProviderKey = (typeof UPTIME_PROVIDER_KEYS)[number]
|
||||
|
||||
const chartConfig = {
|
||||
latency: {
|
||||
label: 'Задержка',
|
||||
color: 'var(--chart-1)',
|
||||
local: {
|
||||
label: 'Local',
|
||||
color: 'var(--info)',
|
||||
},
|
||||
cloudflare: {
|
||||
label: 'Cloudflare',
|
||||
color: 'var(--warning)',
|
||||
},
|
||||
globalping: {
|
||||
label: 'Globalping',
|
||||
color: 'var(--success)',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
interface ChartPoint {
|
||||
export interface AlignedChartPoint {
|
||||
period: string
|
||||
latency: number
|
||||
ok: boolean
|
||||
at: string
|
||||
status: UptimeProbe['status']
|
||||
ok: boolean
|
||||
local?: number | null
|
||||
cloudflare?: number | null
|
||||
globalping?: number | null
|
||||
localOk?: boolean
|
||||
cloudflareOk?: boolean
|
||||
globalpingOk?: boolean
|
||||
}
|
||||
|
||||
function toSeries(items: UptimeProbe[]): ChartPoint[] {
|
||||
return [...items]
|
||||
.sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at))
|
||||
.map((item) => ({
|
||||
period: formatDate(item.checked_at),
|
||||
latency: item.latency_ms ?? 0,
|
||||
ok: item.ok && item.status !== 'down',
|
||||
at: item.checked_at,
|
||||
status: item.status,
|
||||
}))
|
||||
function isProviderKey(value: string | undefined): value is UptimeProviderKey {
|
||||
return value === 'local' || value === 'cloudflare' || value === 'globalping'
|
||||
}
|
||||
|
||||
function uptimePercent(points: ChartPoint[]): number | null {
|
||||
function bucketStart(time: number): number {
|
||||
return Math.floor(time / UPTIME_BUCKET_MS) * UPTIME_BUCKET_MS
|
||||
}
|
||||
|
||||
function providerOf(item: UptimeProbe): UptimeProviderKey {
|
||||
return isProviderKey(item.provider) ? item.provider : 'local'
|
||||
}
|
||||
|
||||
function probeOk(item: UptimeProbe): boolean {
|
||||
return item.ok && item.status !== 'down'
|
||||
}
|
||||
|
||||
/** Align mixed-source probes onto a 60s time axis so Local/CF/GP do not zigzag. */
|
||||
export function toAlignedSeries(items: UptimeProbe[]): {
|
||||
points: AlignedChartPoint[]
|
||||
keys: UptimeProviderKey[]
|
||||
} {
|
||||
const buckets = new Map<number, AlignedChartPoint>()
|
||||
const used = new Set<UptimeProviderKey>()
|
||||
|
||||
const sorted = [...items].sort(
|
||||
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
|
||||
)
|
||||
|
||||
for (const item of sorted) {
|
||||
const key = providerOf(item)
|
||||
used.add(key)
|
||||
const start = bucketStart(probeTime(item.checked_at))
|
||||
let row = buckets.get(start)
|
||||
if (!row) {
|
||||
row = {
|
||||
period: formatDate(item.checked_at),
|
||||
at: item.checked_at,
|
||||
ok: true,
|
||||
}
|
||||
buckets.set(start, row)
|
||||
}
|
||||
|
||||
const ok = probeOk(item)
|
||||
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()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, row]) => {
|
||||
const present = UPTIME_PROVIDER_KEYS.filter((key) => row[`${key}Ok`] !== undefined)
|
||||
return {
|
||||
...row,
|
||||
ok: present.length === 0 ? row.ok : present.every((key) => row[`${key}Ok`] !== false),
|
||||
}
|
||||
})
|
||||
|
||||
const keys = UPTIME_PROVIDER_KEYS.filter((key) => used.has(key))
|
||||
return { points, keys }
|
||||
}
|
||||
|
||||
function uptimePercent(points: AlignedChartPoint[]): number | null {
|
||||
if (points.length === 0) return null
|
||||
const okCount = points.filter((point) => point.ok).length
|
||||
return (okCount / points.length) * 100
|
||||
}
|
||||
|
||||
function deltaPercent(points: ChartPoint[]): number | null {
|
||||
function deltaPercent(points: AlignedChartPoint[]): number | null {
|
||||
if (points.length < 4) return null
|
||||
const mid = Math.floor(points.length / 2)
|
||||
const prev = uptimePercent(points.slice(0, mid))
|
||||
@@ -113,7 +187,7 @@ function UptimeDelta({ delta }: { delta: number }) {
|
||||
}
|
||||
|
||||
export function probeUptimePercent(items: UptimeProbe[]): number | null {
|
||||
return uptimePercent(toSeries(items))
|
||||
return uptimePercent(toAlignedSeries(items).points)
|
||||
}
|
||||
|
||||
export function lastProbeLatency(items: UptimeProbe[]): number | null {
|
||||
@@ -129,6 +203,12 @@ function formatUptime(value: number | null): string {
|
||||
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
|
||||
}
|
||||
|
||||
function formatPing(value: unknown, ok: boolean | undefined): string {
|
||||
if (ok === false) return '—'
|
||||
const ping = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(ping) ? `${ping} мс` : '—'
|
||||
}
|
||||
|
||||
interface UptimeChartProps {
|
||||
items: UptimeProbe[]
|
||||
isLoading?: boolean
|
||||
@@ -151,30 +231,45 @@ export function UptimeChart({
|
||||
}: UptimeChartProps) {
|
||||
const gradientId = useId().replace(/:/g, '')
|
||||
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
||||
const [tooltipPortal, setTooltipPortal] = useState<HTMLElement | null>(null)
|
||||
const [hovered, setHovered] = useState<AlignedChartPoint | null>(null)
|
||||
const period = periodProp ?? internalPeriod
|
||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||
|
||||
useEffect(() => {
|
||||
setTooltipPortal(document.body)
|
||||
}, [])
|
||||
|
||||
function handlePeriodChange(next: UptimePeriodKey) {
|
||||
onPeriodChange?.(next)
|
||||
if (periodProp == null) setInternalPeriod(next)
|
||||
setHovered(null)
|
||||
}
|
||||
|
||||
const points = useMemo(
|
||||
() => toSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
|
||||
const { points, keys } = useMemo(
|
||||
() => toAlignedSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
|
||||
[items, days, skipPeriodFilter],
|
||||
)
|
||||
const uptime = uptimePercent(points)
|
||||
const delta = deltaPercent(points)
|
||||
const lastOk = points.at(-1)?.ok ?? true
|
||||
const tileClass = lastOk ? 'text-success' : 'text-destructive'
|
||||
const single = keys.length <= 1
|
||||
const areaKey = keys[0] ?? 'local'
|
||||
const hoverPings = hovered
|
||||
? keys.map((key) => {
|
||||
const ok = hovered[`${key}Ok`]
|
||||
const label = chartConfig[key].label
|
||||
return `${label} ${formatPing(hovered[key], ok)}`
|
||||
})
|
||||
: []
|
||||
|
||||
function syncHover(state: {
|
||||
activeTooltipIndex?: unknown
|
||||
activeIndex?: unknown
|
||||
}) {
|
||||
const index = Number(state.activeTooltipIndex ?? state.activeIndex)
|
||||
if (!Number.isFinite(index)) return
|
||||
setHovered(points[index] ?? null)
|
||||
}
|
||||
|
||||
const panel = (
|
||||
<FramePanel className="flex flex-col gap-6">
|
||||
<FramePanel className="flex flex-col gap-6 overflow-visible">
|
||||
{hideHeader ? null : (
|
||||
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -242,48 +337,78 @@ export function UptimeChart({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hoverPings.length > 0 ? (
|
||||
<p className="text-muted-foreground min-h-4 min-w-0 text-xs tabular-nums">
|
||||
<span className="text-foreground font-medium">Пинг</span>
|
||||
{' · '}
|
||||
{formatDate(hovered?.at)}
|
||||
{' · '}
|
||||
{hoverPings.join(' · ')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground min-h-4 text-xs">
|
||||
Наведите на точку графика
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="h-40 w-full overflow-visible">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-full w-full overflow-visible rounded-b-xl"
|
||||
className="[&_.recharts-tooltip-wrapper]:z-50 [&_.recharts-wrapper]:overflow-visible h-full w-full overflow-visible rounded-b-xl"
|
||||
initialDimension={{ width: 320, height: 160 }}
|
||||
>
|
||||
<AreaChart
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 16, left: 8, right: 8, bottom: 4 }}
|
||||
margin={{ top: 24, left: 8, right: 8, bottom: 8 }}
|
||||
accessibilityLayer
|
||||
onMouseMove={syncHover}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onClick={syncHover}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-latency)"
|
||||
stopColor={`var(--color-${areaKey})`}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-latency)"
|
||||
stopColor={`var(--color-${areaKey})`}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="period" hide />
|
||||
<XAxis dataKey="at" hide />
|
||||
<YAxis hide domain={['auto', 'auto']} />
|
||||
<ChartTooltip
|
||||
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
|
||||
filterNull={false}
|
||||
shared
|
||||
isAnimationActive={false}
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
portal={tooltipPortal ?? undefined}
|
||||
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, _name, item) => {
|
||||
const point = item.payload as ChartPoint | undefined
|
||||
const ping = Number(value)
|
||||
labelFormatter={(_label, payload) => {
|
||||
const at = (payload?.[0]?.payload as AlignedChartPoint | undefined)?.at
|
||||
return at ? formatDate(at) : String(_label ?? '')
|
||||
}}
|
||||
formatter={(value, name, item) => {
|
||||
const key = String(name)
|
||||
const row = item.payload as AlignedChartPoint | undefined
|
||||
const ok =
|
||||
key === 'local' || key === 'cloudflare' || key === 'globalping'
|
||||
? row?.[`${key}Ok`]
|
||||
: row?.ok
|
||||
const label = chartConfig[key as UptimeProviderKey]?.label ?? key
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">
|
||||
{point?.ok === false ? 'Down' : 'Пинг'}
|
||||
{ok === false ? `${label} · Down` : `Пинг · ${label}`}
|
||||
</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{Number.isFinite(ping) ? `${ping} мс` : '—'}
|
||||
{formatPing(value, ok)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@@ -291,42 +416,44 @@ export function UptimeChart({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="latency"
|
||||
name="latency"
|
||||
type="natural"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke="var(--color-latency)"
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={(dotProps) => {
|
||||
const { cx, cy, payload, index } = dotProps
|
||||
if (cx == null || cy == null) return <g key={index} />
|
||||
const point = payload as ChartPoint | undefined
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={4}
|
||||
fill={
|
||||
point?.ok
|
||||
? 'var(--color-latency)'
|
||||
: 'var(--destructive)'
|
||||
}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={2}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
stroke: 'var(--background)',
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
{single ? (
|
||||
<Area
|
||||
dataKey={areaKey}
|
||||
name={areaKey}
|
||||
type="monotone"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke={`var(--color-${areaKey})`}
|
||||
strokeWidth={2}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
stroke: 'var(--background)',
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
keys.map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
dataKey={key}
|
||||
name={key}
|
||||
type="monotone"
|
||||
stroke={`var(--color-${key})`}
|
||||
strokeWidth={2}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
stroke: 'var(--background)',
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { Trash2Icon } from 'lucide-react'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
||||
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
||||
import { ServiceAddressBlock } from '@/components/reui-kit/service-address-block'
|
||||
import {
|
||||
HealthCheckConfigFields,
|
||||
type LbAndHealthConfig,
|
||||
type LbMode,
|
||||
type HealthCheckType,
|
||||
type HealthProvider,
|
||||
type HealthAggregate,
|
||||
} from '@/components/health-check-config-fields'
|
||||
import type {
|
||||
CreateServiceWithConfigInput,
|
||||
@@ -18,9 +13,16 @@ import type {
|
||||
ServiceView,
|
||||
UpdateServiceConfigInput,
|
||||
} from '@/lib/schemas'
|
||||
import { parseHealthProviders } from '@cfdm/shared'
|
||||
import { bindingToFqdn, parseFqdn } from '@/lib/parse-fqdn'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
DEFAULT_BINDING_HEALTH,
|
||||
emptyAddressBlock,
|
||||
hydrateAddressBlock,
|
||||
toBindingDrafts,
|
||||
toDomainsPayload,
|
||||
type AddressBlockState,
|
||||
type BindingHealthConfig,
|
||||
type ServiceBindingDraft,
|
||||
} from '@/lib/service-address'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Sheet,
|
||||
@@ -30,14 +32,8 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
Select,
|
||||
@@ -47,44 +43,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
|
||||
interface BindingHealthConfig {
|
||||
enabled: boolean
|
||||
type: HealthCheckType
|
||||
port: number | null
|
||||
path: string | null
|
||||
expected_status: number | null
|
||||
interval_sec: number
|
||||
timeout_ms: number
|
||||
verify_tls: boolean
|
||||
provider: HealthProvider
|
||||
providers: HealthProvider[]
|
||||
aggregate: HealthAggregate
|
||||
}
|
||||
|
||||
export interface ServiceBindingDraft {
|
||||
fqdn: string
|
||||
record_type: 'A' | 'CNAME'
|
||||
target_ips: string[]
|
||||
target_cname: string
|
||||
lb_mode: LbMode
|
||||
health: BindingHealthConfig
|
||||
target_ip_weights: Record<string, number>
|
||||
target_ip_priorities: Record<string, number>
|
||||
}
|
||||
|
||||
const defaultHealth: BindingHealthConfig = {
|
||||
enabled: false,
|
||||
type: 'tcp',
|
||||
port: null,
|
||||
path: null,
|
||||
expected_status: null,
|
||||
interval_sec: 30,
|
||||
timeout_ms: 3000,
|
||||
verify_tls: false,
|
||||
provider: 'local',
|
||||
providers: ['local'],
|
||||
aggregate: 'majority',
|
||||
}
|
||||
export type { ServiceBindingDraft }
|
||||
|
||||
interface ServiceEditSheetProps {
|
||||
mode: 'create' | 'edit'
|
||||
@@ -101,104 +60,19 @@ interface ServiceEditSheetProps {
|
||||
onDelete?: (id: number) => void
|
||||
}
|
||||
|
||||
function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
return (service.domains ?? []).map((binding) => ({
|
||||
fqdn: bindingToFqdn(binding),
|
||||
record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'),
|
||||
target_ips: binding.target_ips ?? [],
|
||||
target_cname: binding.target_cname ?? '',
|
||||
lb_mode: binding.lb_mode,
|
||||
health: {
|
||||
enabled: Boolean(binding.health_check_enabled),
|
||||
type: binding.health_check_type === 'http' ? 'http' : 'tcp',
|
||||
port: binding.health_check_port,
|
||||
path: binding.health_check_path,
|
||||
expected_status: binding.health_check_expected_status,
|
||||
interval_sec: binding.health_check_interval_sec,
|
||||
timeout_ms: binding.health_check_timeout_ms,
|
||||
verify_tls: Boolean(binding.health_check_verify_tls),
|
||||
provider: binding.health_check_provider ?? 'local',
|
||||
providers: parseHealthProviders(
|
||||
binding.health_check_providers,
|
||||
binding.health_check_provider ?? 'local',
|
||||
),
|
||||
aggregate: binding.health_check_aggregate ?? 'majority',
|
||||
},
|
||||
target_ip_weights: binding.target_ip_weights ?? {},
|
||||
target_ip_priorities: binding.target_ip_priorities ?? {},
|
||||
}))
|
||||
}
|
||||
|
||||
function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
return bindings
|
||||
.filter((binding) => {
|
||||
if (!binding.fqdn.trim()) return false
|
||||
if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim())
|
||||
return binding.target_ips.length > 0
|
||||
})
|
||||
.map((binding) =>
|
||||
binding.record_type === 'CNAME'
|
||||
? {
|
||||
fqdn: binding.fqdn.trim(),
|
||||
target_cname: binding.target_cname.trim(),
|
||||
lb_mode: binding.lb_mode,
|
||||
health_check_enabled: binding.health.enabled,
|
||||
health_check_type: binding.health.type,
|
||||
health_check_port: binding.health.port,
|
||||
health_check_path: binding.health.path,
|
||||
health_check_expected_status: binding.health.expected_status,
|
||||
health_check_interval_sec: binding.health.interval_sec,
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
health_check_providers: binding.health.providers,
|
||||
health_check_aggregate: binding.health.aggregate,
|
||||
}
|
||||
: {
|
||||
fqdn: binding.fqdn.trim(),
|
||||
target_ips: binding.target_ips,
|
||||
target_ip_weights: binding.target_ip_weights,
|
||||
target_ip_priorities: binding.target_ip_priorities,
|
||||
lb_mode: binding.lb_mode,
|
||||
health_check_enabled: binding.health.enabled,
|
||||
health_check_type: binding.health.type,
|
||||
health_check_port: binding.health.port,
|
||||
health_check_path: binding.health.path,
|
||||
health_check_expected_status: binding.health.expected_status,
|
||||
health_check_interval_sec: binding.health.interval_sec,
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
health_check_providers: binding.health.providers,
|
||||
health_check_aggregate: binding.health.aggregate,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
|
||||
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
|
||||
return {
|
||||
fqdn,
|
||||
record_type: 'A',
|
||||
target_ips: [],
|
||||
target_cname: '',
|
||||
lb_mode: 'round_robin',
|
||||
health: { ...defaultHealth },
|
||||
target_ip_weights: {},
|
||||
target_ip_priorities: {},
|
||||
}
|
||||
}
|
||||
|
||||
function withPoolIps(draft: ServiceBindingDraft, pool: string[]): ServiceBindingDraft {
|
||||
if (draft.record_type !== 'A' || draft.target_ips.length > 0 || pool.length === 0) {
|
||||
return draft
|
||||
}
|
||||
return {
|
||||
...draft,
|
||||
target_ips: pool,
|
||||
target_ip_weights: Object.fromEntries(pool.map((ip) => [ip, draft.target_ip_weights[ip] ?? 1])),
|
||||
target_ip_priorities: Object.fromEntries(
|
||||
pool.map((ip) => [ip, draft.target_ip_priorities[ip] ?? 1]),
|
||||
),
|
||||
enabled: next.enabled,
|
||||
type: next.type,
|
||||
port: next.port,
|
||||
path: next.path,
|
||||
expected_status: next.expected_status,
|
||||
interval_sec: next.interval_sec,
|
||||
timeout_ms: next.timeout_ms,
|
||||
verify_tls: next.verify_tls,
|
||||
provider: next.provider,
|
||||
providers: next.providers,
|
||||
aggregate: next.aggregate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,9 +93,11 @@ export function ServiceEditSheet({
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [serviceGroupId, setServiceGroupId] = useState('none')
|
||||
const [ips, setIps] = useState<string[]>([])
|
||||
const [commonFqdn, setCommonFqdn] = useState('')
|
||||
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
||||
const [address, setAddress] = useState<AddressBlockState>(() => emptyAddressBlock())
|
||||
const [health, setHealth] = useState<BindingHealthConfig>(() => ({
|
||||
...DEFAULT_BINDING_HEALTH,
|
||||
}))
|
||||
const [lbMode, setLbMode] = useState<LbAndHealthConfig['lb_mode']>('round_robin')
|
||||
const [lbWeight, setLbWeight] = useState(1)
|
||||
const [lbPriority, setLbPriority] = useState(1)
|
||||
|
||||
@@ -243,10 +119,10 @@ export function ServiceEditSheet({
|
||||
setServiceGroupId(
|
||||
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
||||
)
|
||||
setIps(service.ips ?? [])
|
||||
const drafts = toBindingDrafts(service)
|
||||
setBindings(drafts)
|
||||
setCommonFqdn(drafts[0]?.fqdn ?? '')
|
||||
setAddress(hydrateAddressBlock(drafts, service.ips ?? []))
|
||||
setHealth(drafts[0]?.health ?? { ...DEFAULT_BINDING_HEALTH })
|
||||
setLbMode(drafts[0]?.lb_mode ?? service.lb_mode ?? 'round_robin')
|
||||
setLbWeight(service.lb_weight ?? 1)
|
||||
setLbPriority(service.lb_priority ?? 1)
|
||||
return
|
||||
@@ -257,9 +133,9 @@ export function ServiceEditSheet({
|
||||
setServiceGroupId(
|
||||
defaultGroupId != null ? String(defaultGroupId) : 'none',
|
||||
)
|
||||
setIps([])
|
||||
setCommonFqdn('')
|
||||
setBindings([])
|
||||
setAddress(emptyAddressBlock())
|
||||
setHealth({ ...DEFAULT_BINDING_HEALTH })
|
||||
setLbMode('round_robin')
|
||||
setLbWeight(1)
|
||||
setLbPriority(1)
|
||||
}
|
||||
@@ -270,136 +146,27 @@ export function ServiceEditSheet({
|
||||
[knownDomains],
|
||||
)
|
||||
|
||||
const extraBindings = bindings.slice(1)
|
||||
|
||||
function handleCommonFqdnChange(value: string) {
|
||||
setCommonFqdn(value)
|
||||
setBindings((current) => {
|
||||
if (current.length === 0) return current
|
||||
return current.map((item, i) => (i === 0 ? { ...item, fqdn: value } : item))
|
||||
})
|
||||
}
|
||||
|
||||
function handleAddExtraBinding() {
|
||||
setBindings((current) => {
|
||||
const extra = withPoolIps(emptyBindingDraft(), ips)
|
||||
if (current.length === 0) {
|
||||
return [emptyBindingDraft(commonFqdn), extra]
|
||||
}
|
||||
return [...current, extra]
|
||||
})
|
||||
}
|
||||
|
||||
function handleRemoveExtraBinding(extraIndex: number) {
|
||||
const index = extraIndex + 1
|
||||
setBindings((current) => current.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function handleFqdnChange(index: number, fqdn: string) {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
|
||||
)
|
||||
}
|
||||
|
||||
function handleRecordTypeChange(index: number, recordType: 'A' | 'CNAME') {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) =>
|
||||
i === index
|
||||
? {
|
||||
...item,
|
||||
record_type: recordType,
|
||||
target_ips: recordType === 'A' ? item.target_ips : [],
|
||||
target_cname: recordType === 'CNAME' ? item.target_cname : '',
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function handleCnameChange(index: number, value: string) {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) => (i === index ? { ...item, target_cname: value } : item)),
|
||||
)
|
||||
}
|
||||
|
||||
function handleIpsChange(index: number, targetIps: string[]) {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) =>
|
||||
i === index
|
||||
? {
|
||||
...item,
|
||||
target_ips: targetIps,
|
||||
target_ip_weights: Object.fromEntries(
|
||||
targetIps.map((ip) => [ip, item.target_ip_weights[ip] ?? 1]),
|
||||
),
|
||||
target_ip_priorities: Object.fromEntries(
|
||||
targetIps.map((ip) => [ip, item.target_ip_priorities[ip] ?? 1]),
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
|
||||
return {
|
||||
enabled: next.enabled,
|
||||
type: next.type,
|
||||
port: next.port,
|
||||
path: next.path,
|
||||
expected_status: next.expected_status,
|
||||
interval_sec: next.interval_sec,
|
||||
timeout_ms: next.timeout_ms,
|
||||
verify_tls: next.verify_tls,
|
||||
provider: next.provider,
|
||||
providers: next.providers,
|
||||
aggregate: next.aggregate,
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrimaryHealthChange(next: LbAndHealthConfig) {
|
||||
const health = healthFromConfig(next)
|
||||
setBindings((current) => {
|
||||
if (current.length === 0) {
|
||||
return [
|
||||
{
|
||||
...withPoolIps(emptyBindingDraft(commonFqdn), ips),
|
||||
lb_mode: next.lb_mode,
|
||||
health,
|
||||
},
|
||||
]
|
||||
}
|
||||
return current.map((item, index) =>
|
||||
index === 0 ? { ...item, lb_mode: next.lb_mode, health } : item,
|
||||
)
|
||||
})
|
||||
setLbMode(next.lb_mode)
|
||||
setHealth(healthFromConfig(next))
|
||||
}
|
||||
|
||||
const primaryHealthValue: LbAndHealthConfig = {
|
||||
lb_mode: bindings[0]?.lb_mode ?? 'round_robin',
|
||||
...(bindings[0]?.health ?? defaultHealth),
|
||||
lb_mode: lbMode,
|
||||
...health,
|
||||
}
|
||||
|
||||
function resolveServiceGroupId(): number | null {
|
||||
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
||||
}
|
||||
|
||||
function syncCommonDomain(current: ServiceBindingDraft[]): ServiceBindingDraft[] {
|
||||
const trimmed = commonFqdn.trim()
|
||||
if (!trimmed) return current
|
||||
if (current.length === 0) {
|
||||
return [withPoolIps(emptyBindingDraft(trimmed), ips)]
|
||||
}
|
||||
return current.map((item, index) => {
|
||||
if (index !== 0) return item
|
||||
return withPoolIps({ ...item, fqdn: trimmed }, ips)
|
||||
})
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const syncedBindings = syncCommonDomain(bindings)
|
||||
const domains = buildDomainsPayload(syncedBindings)
|
||||
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
|
||||
const ips = address.nodes.map((node) => node.ip)
|
||||
const domains = toDomainsPayload(address, {
|
||||
lb_mode: lbMode,
|
||||
health,
|
||||
})
|
||||
const normalizedFqdns = domains.map((item) => item.fqdn.trim().toLowerCase())
|
||||
const hasDuplicateFqdn =
|
||||
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
||||
if (hasDuplicateFqdn) {
|
||||
@@ -443,6 +210,7 @@ export function ServiceEditSheet({
|
||||
const canSubmit = isCreate
|
||||
? name.trim().length > 0 && slug.trim().length > 0
|
||||
: Boolean(service)
|
||||
const addressResetKey = `${mode}-${service?.id ?? 'new'}-${open ? 'open' : 'closed'}`
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
@@ -450,8 +218,8 @@ export function ServiceEditSheet({
|
||||
<SheetHeader className="shrink-0 border-b pb-4">
|
||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Общий домен и IP задаются у сервиса. Дополнительные FQDN — ниже, зона
|
||||
определяется автоматически.
|
||||
Общие FQDN на весь пул IP. У каждого адреса можно указать свой доп.
|
||||
FQDN.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -498,33 +266,16 @@ export function ServiceEditSheet({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-common-domain">
|
||||
Общий домен (FQDN)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="edit-service-common-domain"
|
||||
className="font-mono"
|
||||
value={commonFqdn}
|
||||
placeholder={
|
||||
zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'
|
||||
}
|
||||
onChange={(e) => handleCommonFqdnChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
||||
<TaggedInput
|
||||
id="edit-service-ips"
|
||||
value={ips}
|
||||
onChange={setIps}
|
||||
placeholder="192.168.1.1"
|
||||
validate={isValidIpv4}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</section>
|
||||
|
||||
<ServiceAddressBlock
|
||||
key={addressResetKey}
|
||||
value={address}
|
||||
onChange={setAddress}
|
||||
zoneHints={zoneHints}
|
||||
/>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">Health check</h3>
|
||||
<HealthCheckConfigFields
|
||||
@@ -533,127 +284,6 @@ export function ServiceEditSheet({
|
||||
onChange={handlePrimaryHealthChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Доп. FQDN</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddExtraBinding}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
{extraBindings.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет дополнительных FQDN
|
||||
</p>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{extraBindings.map((binding, extraIndex) => {
|
||||
const index = extraIndex + 1
|
||||
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
|
||||
return (
|
||||
<Item
|
||||
key={`extra-binding-${index}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="items-stretch"
|
||||
>
|
||||
<ItemContent className="flex w-full min-w-0 flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{parsedZone ? (
|
||||
<Badge variant="outline" size="xs" className="font-mono">
|
||||
{parsedZone.zoneName}
|
||||
</Badge>
|
||||
) : binding.fqdn.trim() ? (
|
||||
<Badge variant="warning-light" size="xs">
|
||||
зона не найдена
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
FQDN
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="ml-auto shrink-0"
|
||||
aria-label="Удалить FQDN"
|
||||
onClick={() => handleRemoveExtraBinding(extraIndex)}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
|
||||
<Input
|
||||
id={`extra-fqdn-${index}`}
|
||||
className="font-mono"
|
||||
value={binding.fqdn}
|
||||
onChange={(event) =>
|
||||
handleFqdnChange(index, event.target.value)
|
||||
}
|
||||
placeholder={
|
||||
zoneHints[0]
|
||||
? `api.${zoneHints[0]}`
|
||||
: 'api.ivx.su'
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
items={[
|
||||
{ label: 'A (IP)', value: 'A' },
|
||||
{ label: 'CNAME', value: 'CNAME' },
|
||||
]}
|
||||
value={binding.record_type}
|
||||
onValueChange={(value) =>
|
||||
handleRecordTypeChange(
|
||||
index,
|
||||
(value ?? 'A') as 'A' | 'CNAME',
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`extra-type-${index}`}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="A">A (IP)</SelectItem>
|
||||
<SelectItem value="CNAME">CNAME</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{binding.record_type === 'CNAME' ? (
|
||||
<Input
|
||||
id={`extra-cname-${index}`}
|
||||
value={binding.target_cname}
|
||||
placeholder="mmsk.rkns.top"
|
||||
onChange={(event) =>
|
||||
handleCnameChange(index, event.target.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ServiceBindingIpInput
|
||||
id={`extra-ip-${index}`}
|
||||
value={binding.target_ips}
|
||||
pool={ips}
|
||||
onChange={(targetIps) =>
|
||||
handleIpsChange(index, targetIps)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { dedupeBreadcrumbs, getBreadcrumbs } from './breadcrumbs'
|
||||
|
||||
describe('getBreadcrumbs', () => {
|
||||
it('keeps a single Настройки parent plus the active section', () => {
|
||||
expect(getBreadcrumbs('/settings/appearance')).toEqual([
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||
])
|
||||
expect(getBreadcrumbs('/settings/health')).toEqual([
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
{ label: 'Health-check', href: '/settings/health' },
|
||||
])
|
||||
expect(getBreadcrumbs('/settings/integrations')).toEqual([
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
{ label: 'Интеграции', href: '/settings/integrations' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not reuse the section href for the parent crumb', () => {
|
||||
const crumbs = getBreadcrumbs('/settings/appearance')
|
||||
const hrefs = crumbs.map((crumb) => crumb.href)
|
||||
expect(new Set(hrefs).size).toBe(hrefs.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dedupeBreadcrumbs', () => {
|
||||
it('collapses stacked identical labels from repeated navigations', () => {
|
||||
expect(
|
||||
dedupeBreadcrumbs([
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
{ label: 'Настройки', href: '/settings/appearance' },
|
||||
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||
]),
|
||||
).toEqual([
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
export interface BreadcrumbCrumb {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
const routeTitles: Record<string, string> = {
|
||||
'/': 'Панель управления',
|
||||
'/domains': 'Домены',
|
||||
'/groups': 'Группы доменов',
|
||||
'/services': 'Сервисы',
|
||||
'/certificates': 'Сертификаты',
|
||||
}
|
||||
|
||||
const SETTINGS_SECTIONS: Record<string, string> = {
|
||||
'/settings/appearance': 'Внешний вид',
|
||||
'/settings/health': 'Health-check',
|
||||
'/settings/integrations': 'Интеграции',
|
||||
}
|
||||
|
||||
/** Drop consecutive repeats so «Настройки» does not stack after tab switches. */
|
||||
export function dedupeBreadcrumbs(crumbs: BreadcrumbCrumb[]): BreadcrumbCrumb[] {
|
||||
const out: BreadcrumbCrumb[] = []
|
||||
for (const crumb of crumbs) {
|
||||
const prev = out.at(-1)
|
||||
if (prev && prev.label === crumb.label) continue
|
||||
out.push(crumb)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function getBreadcrumbs(
|
||||
pathname: string,
|
||||
dynamicLabels: Record<string, string> = {},
|
||||
): BreadcrumbCrumb[] {
|
||||
const path = pathname.replace(/\/+$/, '') || '/'
|
||||
|
||||
if (path === '/') {
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
if (path.match(/^\/services\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Сервисы', href: '/services' },
|
||||
{ label: dynamicLabels[path] ?? 'Сервис', href: path },
|
||||
]
|
||||
}
|
||||
|
||||
if (path.match(/^\/groups\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Группы доменов', href: '/groups' },
|
||||
{ label: dynamicLabels[path] ?? 'Группа', href: path },
|
||||
]
|
||||
}
|
||||
|
||||
if (path.match(/^\/domains\/\d+\/dns$/)) {
|
||||
const domainId = path.split('/')[2]
|
||||
const domainPath = `/domains/${domainId}`
|
||||
return [
|
||||
{ label: 'Домены', href: '/domains' },
|
||||
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
|
||||
{ label: 'DNS', href: path },
|
||||
]
|
||||
}
|
||||
|
||||
if (path.match(/^\/domains\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Домены', href: '/domains' },
|
||||
{ label: dynamicLabels[path] ?? 'Обзор домена', href: path },
|
||||
]
|
||||
}
|
||||
|
||||
if (path === '/settings' || path.startsWith('/settings/')) {
|
||||
const section = SETTINGS_SECTIONS[path]
|
||||
return dedupeBreadcrumbs([
|
||||
{ label: 'Настройки', href: '/settings' },
|
||||
...(section ? [{ label: section, href: path }] : []),
|
||||
])
|
||||
}
|
||||
|
||||
const title = routeTitles[path]
|
||||
if (title) {
|
||||
return [{ label: title, href: path }]
|
||||
}
|
||||
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
failoverEventCopy,
|
||||
isFailoverEventStatus,
|
||||
toFailoverEvents,
|
||||
type FailoverBindingPool,
|
||||
type FailoverHealthInput,
|
||||
} from '@/lib/failover-events'
|
||||
|
||||
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',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +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
|
||||
}
|
||||
|
||||
/** 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_BINDING_HEALTH,
|
||||
addAddressNode,
|
||||
addCommonFqdn,
|
||||
emptyAddressBlock,
|
||||
emptyBindingDraft,
|
||||
hydrateAddressBlock,
|
||||
removeAddressNode,
|
||||
toAddressBindings,
|
||||
toDomainsPayload,
|
||||
type ServiceBindingDraft,
|
||||
} from '@/lib/service-address'
|
||||
|
||||
const primaryMeta = {
|
||||
lb_mode: 'round_robin' as const,
|
||||
health: { ...DEFAULT_BINDING_HEALTH, enabled: true },
|
||||
}
|
||||
|
||||
function aRecord(
|
||||
fqdn: string,
|
||||
target_ips: string[],
|
||||
overrides: Partial<ServiceBindingDraft> = {},
|
||||
): ServiceBindingDraft {
|
||||
return {
|
||||
...emptyBindingDraft(fqdn),
|
||||
record_type: 'A',
|
||||
target_ips,
|
||||
target_ip_weights: Object.fromEntries(target_ips.map((ip) => [ip, 1])),
|
||||
target_ip_priorities: Object.fromEntries(target_ips.map((ip) => [ip, 1])),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('hydrateAddressBlock', () => {
|
||||
it('схлопывает extra A с одним IP пула в extraFqdn узла (MSK Macloud)', () => {
|
||||
const drafts = [
|
||||
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
|
||||
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
|
||||
]
|
||||
|
||||
const state = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
|
||||
|
||||
expect(state.commonFqdns).toEqual(['rutg.rkns.top'])
|
||||
expect(state.nodes).toEqual([
|
||||
{ ip: '93.115.203.183', extraFqdn: 'msk.rutg.rkns.top' },
|
||||
{ ip: '185.244.181.61', extraFqdn: '' },
|
||||
])
|
||||
expect(state.preservedBindings).toEqual([])
|
||||
})
|
||||
|
||||
it('кладёт A на весь пул в commonFqdns, CNAME — в preserved', () => {
|
||||
const cname: ServiceBindingDraft = {
|
||||
...emptyBindingDraft('alias.rkns.top'),
|
||||
record_type: 'CNAME',
|
||||
target_cname: 'rutg.rkns.top',
|
||||
}
|
||||
const drafts = [
|
||||
aRecord('rutg.rkns.top', ['1.1.1.1', '2.2.2.2']),
|
||||
aRecord('both.rkns.top', ['1.1.1.1', '2.2.2.2']),
|
||||
cname,
|
||||
]
|
||||
|
||||
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
|
||||
|
||||
expect(state.commonFqdns).toEqual(['rutg.rkns.top', 'both.rkns.top'])
|
||||
expect(state.nodes.every((node) => node.extraFqdn === '')).toBe(true)
|
||||
expect(state.preservedBindings.map((item) => item.fqdn)).toEqual(['alias.rkns.top'])
|
||||
})
|
||||
|
||||
it('кладёт extra A с IP вне пула в preservedBindings', () => {
|
||||
const drafts = [
|
||||
aRecord('gw.example.com', ['10.0.0.1']),
|
||||
aRecord('edge.example.com', ['8.8.8.8']),
|
||||
]
|
||||
|
||||
const state = hydrateAddressBlock(drafts, ['10.0.0.1'])
|
||||
|
||||
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdn: '' }])
|
||||
expect(state.commonFqdns).toEqual(['gw.example.com'])
|
||||
expect(state.preservedBindings).toHaveLength(1)
|
||||
expect(state.preservedBindings[0]?.fqdn).toBe('edge.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toDomainsPayload', () => {
|
||||
it('собирает каждый common на весь пул и extra binding на один IP', () => {
|
||||
const drafts = [
|
||||
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
|
||||
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
|
||||
]
|
||||
const state = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
|
||||
const payload = toDomainsPayload(state, primaryMeta)
|
||||
|
||||
expect(payload).toEqual([
|
||||
expect.objectContaining({
|
||||
fqdn: 'rutg.rkns.top',
|
||||
target_ips: ['93.115.203.183', '185.244.181.61'],
|
||||
health_check_enabled: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
fqdn: 'msk.rutg.rkns.top',
|
||||
target_ips: ['93.115.203.183'],
|
||||
health_check_enabled: true,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('круг hydrate → payload → hydrate сохраняет два common и extra FQDN', () => {
|
||||
const drafts = [
|
||||
aRecord('gt.rkns.top', ['93.115.203.183', '185.244.181.61']),
|
||||
aRecord('msk.rkns.top', ['93.115.203.183', '185.244.181.61']),
|
||||
aRecord('nsgt.rkns.top', ['93.115.203.183']),
|
||||
]
|
||||
const first = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
|
||||
expect(first.commonFqdns).toEqual(['gt.rkns.top', 'msk.rkns.top'])
|
||||
const rebound = toAddressBindings(first, primaryMeta)
|
||||
const second = hydrateAddressBlock(rebound, rebound[0]?.target_ips ?? [])
|
||||
|
||||
expect(second.commonFqdns).toEqual(first.commonFqdns)
|
||||
expect(second.nodes).toEqual(first.nodes)
|
||||
expect(second.preservedBindings).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeAddressNode', () => {
|
||||
it('удаляет extra FQDN узла и IP из preserved A-bindings', () => {
|
||||
const state = hydrateAddressBlock(
|
||||
[
|
||||
aRecord('gw.example.com', ['10.0.0.1', '10.0.0.2']),
|
||||
aRecord('msk.example.com', ['10.0.0.1']),
|
||||
aRecord('edge.example.com', ['9.9.9.9', '10.0.0.1']),
|
||||
],
|
||||
['10.0.0.1', '10.0.0.2'],
|
||||
)
|
||||
|
||||
const next = removeAddressNode(state, '10.0.0.1')
|
||||
|
||||
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdn: '' }])
|
||||
expect(next.preservedBindings).toHaveLength(1)
|
||||
expect(next.preservedBindings[0]?.target_ips).toEqual(['9.9.9.9'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('addAddressNode / addCommonFqdn', () => {
|
||||
it('не добавляет дубликат IP', () => {
|
||||
const withIp = addAddressNode(
|
||||
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdn: '' }] },
|
||||
'1.1.1.1',
|
||||
)
|
||||
expect(withIp.nodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('не добавляет дубликат common FQDN', () => {
|
||||
const state = addCommonFqdn(
|
||||
{ ...emptyAddressBlock(), commonFqdns: ['gt.rkns.top'] },
|
||||
'GT.rkns.top',
|
||||
)
|
||||
expect(state.commonFqdns).toEqual(['gt.rkns.top'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('CNAME / preservedBindings', () => {
|
||||
it('сохраняет CNAME в preserved при круге hydrate → payload', () => {
|
||||
const cname: ServiceBindingDraft = {
|
||||
...emptyBindingDraft('alias.rkns.top'),
|
||||
record_type: 'CNAME',
|
||||
target_cname: 'rutg.rkns.top',
|
||||
}
|
||||
const drafts = [
|
||||
aRecord('rutg.rkns.top', ['1.1.1.1', '2.2.2.2']),
|
||||
aRecord('msk.rkns.top', ['1.1.1.1']),
|
||||
cname,
|
||||
]
|
||||
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
|
||||
expect(state.nodes[0]?.extraFqdn).toBe('msk.rkns.top')
|
||||
expect(state.preservedBindings).toHaveLength(1)
|
||||
|
||||
const payload = toDomainsPayload(state, primaryMeta)
|
||||
expect(payload.map((item) => item.fqdn)).toEqual([
|
||||
'rutg.rkns.top',
|
||||
'msk.rkns.top',
|
||||
'alias.rkns.top',
|
||||
])
|
||||
expect(payload[2]).toEqual(
|
||||
expect.objectContaining({
|
||||
fqdn: 'alias.rkns.top',
|
||||
target_cname: 'rutg.rkns.top',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,380 @@
|
||||
import { parseHealthProviders } from '@cfdm/shared'
|
||||
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
||||
import { bindingToFqdn } from '@/lib/parse-fqdn'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
|
||||
export type AddressLbMode = 'round_robin' | 'failover' | 'weighted'
|
||||
export type AddressHealthCheckType = 'tcp' | 'http'
|
||||
|
||||
export interface BindingHealthConfig {
|
||||
enabled: boolean
|
||||
type: AddressHealthCheckType
|
||||
port: number | null
|
||||
path: string | null
|
||||
expected_status: number | null
|
||||
interval_sec: number
|
||||
timeout_ms: number
|
||||
verify_tls: boolean
|
||||
provider: HealthCheckProvider
|
||||
providers: HealthCheckProvider[]
|
||||
aggregate: HealthCheckAggregate
|
||||
}
|
||||
|
||||
export interface ServiceBindingDraft {
|
||||
fqdn: string
|
||||
record_type: 'A' | 'CNAME'
|
||||
target_ips: string[]
|
||||
target_cname: string
|
||||
lb_mode: AddressLbMode
|
||||
health: BindingHealthConfig
|
||||
target_ip_weights: Record<string, number>
|
||||
target_ip_priorities: Record<string, number>
|
||||
}
|
||||
|
||||
export interface AddressNode {
|
||||
ip: string
|
||||
extraFqdn: string
|
||||
}
|
||||
|
||||
export interface AddressBlockState {
|
||||
commonFqdns: string[]
|
||||
nodes: AddressNode[]
|
||||
preservedBindings: ServiceBindingDraft[]
|
||||
target_ip_weights: Record<string, number>
|
||||
target_ip_priorities: Record<string, number>
|
||||
}
|
||||
|
||||
export interface AddressPrimaryMeta {
|
||||
lb_mode: AddressLbMode
|
||||
health: BindingHealthConfig
|
||||
}
|
||||
|
||||
export const DEFAULT_BINDING_HEALTH: BindingHealthConfig = {
|
||||
enabled: false,
|
||||
type: 'tcp',
|
||||
port: null,
|
||||
path: null,
|
||||
expected_status: null,
|
||||
interval_sec: 30,
|
||||
timeout_ms: 3000,
|
||||
verify_tls: false,
|
||||
provider: 'local',
|
||||
providers: ['local'],
|
||||
aggregate: 'majority',
|
||||
}
|
||||
|
||||
export function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
|
||||
return {
|
||||
fqdn,
|
||||
record_type: 'A',
|
||||
target_ips: [],
|
||||
target_cname: '',
|
||||
lb_mode: 'round_robin',
|
||||
health: { ...DEFAULT_BINDING_HEALTH },
|
||||
target_ip_weights: {},
|
||||
target_ip_priorities: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyAddressBlock(): AddressBlockState {
|
||||
return {
|
||||
commonFqdns: [],
|
||||
nodes: [],
|
||||
preservedBindings: [],
|
||||
target_ip_weights: {},
|
||||
target_ip_priorities: {},
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueIps(...lists: string[][]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const list of lists) {
|
||||
for (const ip of list) {
|
||||
const trimmed = ip.trim()
|
||||
if (!trimmed || seen.has(trimmed)) continue
|
||||
seen.add(trimmed)
|
||||
out.push(trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function omitKey(record: Record<string, number>, key: string): Record<string, number> {
|
||||
const next = { ...record }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
|
||||
function sameIpSet(left: string[], right: string[]): boolean {
|
||||
if (left.length === 0 || left.length !== right.length) return false
|
||||
const set = new Set(left.map((ip) => ip.trim()).filter(Boolean))
|
||||
if (set.size !== left.length) return false
|
||||
return right.every((ip) => set.has(ip.trim()))
|
||||
}
|
||||
|
||||
export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
return (service.domains ?? []).map((binding) => ({
|
||||
fqdn: bindingToFqdn(binding),
|
||||
record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'),
|
||||
target_ips: binding.target_ips ?? [],
|
||||
target_cname: binding.target_cname ?? '',
|
||||
lb_mode: binding.lb_mode,
|
||||
health: {
|
||||
enabled: Boolean(binding.health_check_enabled),
|
||||
type: binding.health_check_type === 'http' ? 'http' : 'tcp',
|
||||
port: binding.health_check_port,
|
||||
path: binding.health_check_path,
|
||||
expected_status: binding.health_check_expected_status,
|
||||
interval_sec: binding.health_check_interval_sec,
|
||||
timeout_ms: binding.health_check_timeout_ms,
|
||||
verify_tls: Boolean(binding.health_check_verify_tls),
|
||||
provider: binding.health_check_provider ?? 'local',
|
||||
providers: parseHealthProviders(
|
||||
binding.health_check_providers,
|
||||
binding.health_check_provider ?? 'local',
|
||||
),
|
||||
aggregate: binding.health_check_aggregate ?? 'majority',
|
||||
},
|
||||
target_ip_weights: binding.target_ip_weights ?? {},
|
||||
target_ip_priorities: binding.target_ip_priorities ?? {},
|
||||
}))
|
||||
}
|
||||
|
||||
function isFullPoolA(draft: ServiceBindingDraft, pool: string[]): boolean {
|
||||
return draft.record_type === 'A' && sameIpSet(draft.target_ips, pool)
|
||||
}
|
||||
|
||||
export function hydrateAddressBlock(
|
||||
drafts: ServiceBindingDraft[],
|
||||
pool: string[] = [],
|
||||
): AddressBlockState {
|
||||
const multiIpTargets = drafts
|
||||
.filter((draft) => draft.record_type === 'A' && draft.target_ips.length > 1)
|
||||
.map((draft) => draft.target_ips)
|
||||
const allAIps = drafts
|
||||
.filter((draft) => draft.record_type === 'A')
|
||||
.map((draft) => draft.target_ips)
|
||||
const ips =
|
||||
pool.length > 0
|
||||
? uniqueIps(pool)
|
||||
: uniqueIps(...(multiIpTargets.length > 0 ? multiIpTargets : allAIps))
|
||||
const poolSet = new Set(ips)
|
||||
const commonFqdns: string[] = []
|
||||
const claimed = new Set<string>()
|
||||
const extraByIp = new Map<string, string>()
|
||||
const preservedBindings: ServiceBindingDraft[] = []
|
||||
let weights: Record<string, number> = {}
|
||||
let priorities: Record<string, number> = {}
|
||||
|
||||
for (const draft of drafts) {
|
||||
const fqdn = draft.fqdn.trim()
|
||||
if (isFullPoolA(draft, ips)) {
|
||||
if (fqdn) commonFqdns.push(draft.fqdn)
|
||||
if (Object.keys(weights).length === 0) {
|
||||
weights = { ...draft.target_ip_weights }
|
||||
priorities = { ...draft.target_ip_priorities }
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (draft.record_type === 'A' && draft.target_ips.length === 1) {
|
||||
const ip = draft.target_ips[0]?.trim() ?? ''
|
||||
if (ip && poolSet.has(ip) && fqdn && !claimed.has(ip)) {
|
||||
claimed.add(ip)
|
||||
extraByIp.set(ip, draft.fqdn)
|
||||
continue
|
||||
}
|
||||
}
|
||||
preservedBindings.push(draft)
|
||||
}
|
||||
|
||||
return {
|
||||
commonFqdns,
|
||||
nodes: ips.map((ip) => ({
|
||||
ip,
|
||||
extraFqdn: extraByIp.get(ip) ?? '',
|
||||
})),
|
||||
preservedBindings,
|
||||
target_ip_weights: weights,
|
||||
target_ip_priorities: priorities,
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneIpFromBindings(
|
||||
bindings: ServiceBindingDraft[],
|
||||
ip: string,
|
||||
): ServiceBindingDraft[] {
|
||||
return bindings.flatMap((binding) => {
|
||||
if (binding.record_type !== 'A') return [binding]
|
||||
if (!binding.target_ips.includes(ip)) return [binding]
|
||||
const target_ips = binding.target_ips.filter((item) => item !== ip)
|
||||
if (target_ips.length === 0) return []
|
||||
return [
|
||||
{
|
||||
...binding,
|
||||
target_ips,
|
||||
target_ip_weights: omitKey(binding.target_ip_weights, ip),
|
||||
target_ip_priorities: omitKey(binding.target_ip_priorities, ip),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export function removeAddressNode(state: AddressBlockState, ip: string): AddressBlockState {
|
||||
return {
|
||||
...state,
|
||||
nodes: state.nodes.filter((node) => node.ip !== ip),
|
||||
preservedBindings: pruneIpFromBindings(state.preservedBindings, ip),
|
||||
target_ip_weights: omitKey(state.target_ip_weights, ip),
|
||||
target_ip_priorities: omitKey(state.target_ip_priorities, ip),
|
||||
}
|
||||
}
|
||||
|
||||
export function addAddressNode(state: AddressBlockState, ip: string): AddressBlockState {
|
||||
const trimmed = ip.trim()
|
||||
if (!trimmed || state.nodes.some((node) => node.ip === trimmed)) {
|
||||
return state
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
nodes: [...state.nodes, { ip: trimmed, extraFqdn: '' }],
|
||||
target_ip_weights: { ...state.target_ip_weights, [trimmed]: 1 },
|
||||
target_ip_priorities: { ...state.target_ip_priorities, [trimmed]: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function fqdnKey(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function addressHasFqdn(state: AddressBlockState, fqdn: string): boolean {
|
||||
const key = fqdnKey(fqdn)
|
||||
if (!key) return false
|
||||
if (state.commonFqdns.some((item) => fqdnKey(item) === key)) return true
|
||||
if (state.nodes.some((node) => fqdnKey(node.extraFqdn) === key)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function addCommonFqdn(state: AddressBlockState, fqdn: string): AddressBlockState {
|
||||
const trimmed = fqdn.trim()
|
||||
if (!trimmed || addressHasFqdn(state, trimmed)) return state
|
||||
return { ...state, commonFqdns: [...state.commonFqdns, trimmed] }
|
||||
}
|
||||
|
||||
export function removeCommonFqdn(state: AddressBlockState, index: number): AddressBlockState {
|
||||
return {
|
||||
...state,
|
||||
commonFqdns: state.commonFqdns.filter((_, i) => i !== index),
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCommonFqdn(
|
||||
state: AddressBlockState,
|
||||
index: number,
|
||||
fqdn: string,
|
||||
): AddressBlockState {
|
||||
return {
|
||||
...state,
|
||||
commonFqdns: state.commonFqdns.map((item, i) => (i === index ? fqdn : item)),
|
||||
}
|
||||
}
|
||||
|
||||
export function toAddressBindings(
|
||||
state: AddressBlockState,
|
||||
primary: AddressPrimaryMeta,
|
||||
): ServiceBindingDraft[] {
|
||||
const ips = state.nodes.map((node) => node.ip)
|
||||
const weights = Object.fromEntries(
|
||||
ips.map((ip) => [ip, state.target_ip_weights[ip] ?? 1]),
|
||||
)
|
||||
const priorities = Object.fromEntries(
|
||||
ips.map((ip) => [ip, state.target_ip_priorities[ip] ?? 1]),
|
||||
)
|
||||
|
||||
const drafts: ServiceBindingDraft[] = []
|
||||
for (const raw of state.commonFqdns) {
|
||||
const fqdn = raw.trim()
|
||||
if (!fqdn || ips.length === 0) continue
|
||||
drafts.push({
|
||||
fqdn,
|
||||
record_type: 'A',
|
||||
target_ips: ips,
|
||||
target_cname: '',
|
||||
lb_mode: primary.lb_mode,
|
||||
health: { ...primary.health },
|
||||
target_ip_weights: weights,
|
||||
target_ip_priorities: priorities,
|
||||
})
|
||||
}
|
||||
|
||||
for (const node of state.nodes) {
|
||||
const extraFqdn = node.extraFqdn.trim()
|
||||
if (!extraFqdn) continue
|
||||
drafts.push({
|
||||
fqdn: extraFqdn,
|
||||
record_type: 'A',
|
||||
target_ips: [node.ip],
|
||||
target_cname: '',
|
||||
lb_mode: primary.lb_mode,
|
||||
health: { ...primary.health },
|
||||
target_ip_weights: { [node.ip]: weights[node.ip] ?? 1 },
|
||||
target_ip_priorities: { [node.ip]: priorities[node.ip] ?? 1 },
|
||||
})
|
||||
}
|
||||
|
||||
drafts.push(...state.preservedBindings)
|
||||
return drafts
|
||||
}
|
||||
|
||||
export function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
return bindings
|
||||
.filter((binding) => {
|
||||
if (!binding.fqdn.trim()) return false
|
||||
if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim())
|
||||
return binding.target_ips.length > 0
|
||||
})
|
||||
.map((binding) =>
|
||||
binding.record_type === 'CNAME'
|
||||
? {
|
||||
fqdn: binding.fqdn.trim(),
|
||||
target_cname: binding.target_cname.trim(),
|
||||
lb_mode: binding.lb_mode,
|
||||
health_check_enabled: binding.health.enabled,
|
||||
health_check_type: binding.health.type,
|
||||
health_check_port: binding.health.port,
|
||||
health_check_path: binding.health.path,
|
||||
health_check_expected_status: binding.health.expected_status,
|
||||
health_check_interval_sec: binding.health.interval_sec,
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
health_check_providers: binding.health.providers,
|
||||
health_check_aggregate: binding.health.aggregate,
|
||||
}
|
||||
: {
|
||||
fqdn: binding.fqdn.trim(),
|
||||
target_ips: binding.target_ips,
|
||||
target_ip_weights: binding.target_ip_weights,
|
||||
target_ip_priorities: binding.target_ip_priorities,
|
||||
lb_mode: binding.lb_mode,
|
||||
health_check_enabled: binding.health.enabled,
|
||||
health_check_type: binding.health.type,
|
||||
health_check_port: binding.health.port,
|
||||
health_check_path: binding.health.path,
|
||||
health_check_expected_status: binding.health.expected_status,
|
||||
health_check_interval_sec: binding.health.interval_sec,
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
health_check_providers: binding.health.providers,
|
||||
health_check_aggregate: binding.health.aggregate,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function toDomainsPayload(
|
||||
state: AddressBlockState,
|
||||
primary: AddressPrimaryMeta,
|
||||
) {
|
||||
return buildDomainsPayload(toAddressBindings(state, primary))
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
||||
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { FailoverTimeline } from '@/components/failover-timeline'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
@@ -29,15 +28,9 @@ import {
|
||||
import { LbModeTile } from '@/components/services/service-unit-card'
|
||||
import {
|
||||
KpiStatGrid,
|
||||
ServiceFailoverPanel,
|
||||
ServiceHealthMonitor,
|
||||
} from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { api } from '@/lib/api-client'
|
||||
import {
|
||||
enabledHealthProviders,
|
||||
@@ -51,6 +44,7 @@ import {
|
||||
domainsListQueryOptions,
|
||||
serviceBindingKeys,
|
||||
serviceDetailKeys,
|
||||
serviceFailoverLogQueryOptions,
|
||||
serviceGroupKeys,
|
||||
serviceGroupsQueryOptions,
|
||||
serviceHealthLogQueryOptions,
|
||||
@@ -84,6 +78,7 @@ interface OverviewPayload {
|
||||
priority: number
|
||||
consecutive_failures: number
|
||||
last_failure_reason: string | null
|
||||
last_check_at?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -96,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())
|
||||
@@ -106,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)
|
||||
@@ -135,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) }),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -209,22 +219,6 @@ function ServiceDetailPage() {
|
||||
const isError = viewQuery.isError || overviewQuery.isError
|
||||
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(
|
||||
() => enabledHealthProviders(service?.domains ?? []),
|
||||
[service],
|
||||
@@ -333,17 +327,11 @@ function ServiceDetailPage() {
|
||||
statuses={providerStatuses}
|
||||
isLoading={logQuery.isLoading}
|
||||
/>
|
||||
<Frame dense spacing="sm" className="min-w-0 w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Failover</FrameTitle>
|
||||
<FrameDescription>
|
||||
Нездоровые ноды и причины последней ошибки
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<FailoverTimeline events={failoverEvents} />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<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 }
|
||||
|
||||
Vendored
+295
-3
File diff suppressed because one or more lines are too long
Vendored
+40
-1
@@ -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);
|
||||
@@ -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}
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Vendored
+30
-1
@@ -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 };
|
||||
|
||||
Vendored
+12
-1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user