Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be8f94143f | ||
|
|
fa7d2b6df3 | ||
|
|
7938d2f707 | ||
|
|
994e79e118 | ||
|
|
87b0f1a894 | ||
|
|
ba4e04a224 | ||
|
|
4c59780ff0 | ||
|
|
1457389ae7 | ||
|
|
153be28799 | ||
|
|
39fac7834f | ||
|
|
d267e40157 | ||
|
|
634a9dc362 | ||
|
|
1bf6cfa0d0 | ||
|
|
3da6de9311 | ||
|
|
7a8bacade9 | ||
|
|
5d84c7bf6c | ||
|
|
78811bc9b1 |
@@ -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,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SYNC_PENDING_PUSH,
|
||||
SYNC_SYNCED,
|
||||
dnsRecordNamesMatch,
|
||||
isIpLiteral,
|
||||
normalizeDnsRecordName,
|
||||
} from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||
@@ -28,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,
|
||||
@@ -37,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[];
|
||||
@@ -283,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,
|
||||
@@ -310,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);
|
||||
}
|
||||
}
|
||||
@@ -344,6 +388,64 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
};
|
||||
}
|
||||
|
||||
const HEALTH_RANK: Record<string, number> = {
|
||||
down: 3,
|
||||
degraded: 2,
|
||||
unknown: 1,
|
||||
up: 0,
|
||||
};
|
||||
|
||||
function cnameLookupKeys(value: string, zoneName?: string | null): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
const noDot = trimmed.replace(/\.+$/, "");
|
||||
const lower = noDot.toLowerCase();
|
||||
const keys = new Set([trimmed, noDot, lower]);
|
||||
if (zoneName && !lower.includes(".")) {
|
||||
keys.add(`${lower}.${zoneName.trim().toLowerCase().replace(/\.+$/, "")}`);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
type ServiceHealthRow = {
|
||||
ip: string;
|
||||
status: IpHealthState;
|
||||
latency_ms: number | null;
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
provider: ServiceView["ip_health"][number]["provider"];
|
||||
colo: string | null;
|
||||
};
|
||||
|
||||
/** Health rows keyed by CNAME hostname (legacy probes) applied to service IPs. */
|
||||
function fallbackCnameHealth(
|
||||
rows: ServiceHealthRow[],
|
||||
view: ServiceView,
|
||||
): ServiceHealthRow | undefined {
|
||||
const cnameKeys = new Set<string>();
|
||||
for (const domain of view.domains ?? []) {
|
||||
const cname = domain.target_cname?.trim();
|
||||
if (!cname) continue;
|
||||
for (const key of cnameLookupKeys(cname, domain.zone_name)) {
|
||||
cnameKeys.add(key);
|
||||
}
|
||||
}
|
||||
const hostnameRows = rows.filter((row) => !isIpLiteral(row.ip));
|
||||
if (hostnameRows.length === 0) return undefined;
|
||||
const matched =
|
||||
cnameKeys.size === 0
|
||||
? hostnameRows
|
||||
: hostnameRows.filter((row) =>
|
||||
cnameLookupKeys(row.ip).some((key) => cnameKeys.has(key)),
|
||||
);
|
||||
const candidates = matched.length > 0 ? matched : hostnameRows;
|
||||
return candidates.reduce((worst, row) =>
|
||||
(HEALTH_RANK[row.status] ?? 0) > (HEALTH_RANK[worst.status] ?? 0)
|
||||
? row
|
||||
: worst,
|
||||
);
|
||||
}
|
||||
|
||||
function attachServiceHealth(
|
||||
db: Db,
|
||||
views: ServiceView[],
|
||||
@@ -353,11 +455,16 @@ function attachServiceHealth(
|
||||
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
|
||||
return views.map((view) => {
|
||||
const health = healthByService.get(view.id);
|
||||
const byIp = new Map(
|
||||
(ipHealthByService.get(view.id) ?? []).map((row) => [row.ip, row]),
|
||||
const rows = ipHealthByService.get(view.id) ?? [];
|
||||
const byIp = new Map(rows.map((row) => [row.ip, row]));
|
||||
const cnameFallback = fallbackCnameHealth(rows, view);
|
||||
const aRecordIps = new Set(
|
||||
(view.domains ?? []).flatMap((domain) =>
|
||||
domain.target_cname?.trim() ? [] : (domain.target_ips ?? []),
|
||||
),
|
||||
);
|
||||
const ip_health = (view.ips ?? []).map((ip) => {
|
||||
const row = byIp.get(ip);
|
||||
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
|
||||
return {
|
||||
ip,
|
||||
status: row?.status ?? ("unknown" as const),
|
||||
@@ -612,6 +719,14 @@ async function syncBindingADns(
|
||||
|
||||
if (desiredIps.length === 0) {
|
||||
repos.setBindingDnsRecordId(db, bindingId, null);
|
||||
recordFailoverDnsDiff(
|
||||
db,
|
||||
bindingId,
|
||||
hostname,
|
||||
zoneName,
|
||||
existingRecords,
|
||||
desiredIps,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -660,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"]);
|
||||
});
|
||||
});
|
||||
@@ -235,4 +235,205 @@ describe("health-check state derivation via runAllChecks", () => {
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.verify_tls).toBe(true);
|
||||
});
|
||||
|
||||
it("unwraps CNAME target to origin A record IPs", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||
repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"A",
|
||||
"ihome",
|
||||
"2.59.161.102",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
"synced",
|
||||
"cf",
|
||||
null,
|
||||
);
|
||||
const service = repos.createService(db, "RW Sub", "rw-sub");
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
||||
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
||||
repos.updateBindingLbConfig(db, binding.id, {
|
||||
health_check_enabled: true,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 443,
|
||||
});
|
||||
|
||||
const targets = repos.listHealthCheckTargets(db);
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.ip).toBe("2.59.161.102");
|
||||
expect(targets[0]?.hostname).toBe("s.rkns.top");
|
||||
});
|
||||
|
||||
it("unwraps CNAME target to service IP pool when origin DNS is empty", async () => {
|
||||
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
|
||||
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||
const service = repos.createService(db, "RW Sub", "rw-sub");
|
||||
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
||||
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
||||
repos.updateBindingLbConfig(db, binding.id, {
|
||||
health_check_enabled: true,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 443,
|
||||
});
|
||||
|
||||
const targets = repos.listHealthCheckTargets(db);
|
||||
expect(targets).toHaveLength(1);
|
||||
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", () => {
|
||||
it("getView copies CNAME-keyed health onto the service IP row", 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, "RW Sub", "rw-sub");
|
||||
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
||||
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"ihome.rkns.top",
|
||||
"up",
|
||||
12,
|
||||
0,
|
||||
null,
|
||||
);
|
||||
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("up");
|
||||
expect(view.ip_health).toEqual([
|
||||
expect.objectContaining({
|
||||
ip: "2.59.161.102",
|
||||
status: "up",
|
||||
latency_ms: 12,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
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,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { serviceGroupsResponseSchema } from "@cfdm/shared";
|
||||
import { serviceGroupsResponseSchema, updateServiceConfigSchema } from "@cfdm/shared";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||
import { buildApp } from "../src/app.js";
|
||||
@@ -51,6 +51,26 @@ async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
}
|
||||
|
||||
describe("create service then list groups", () => {
|
||||
it("accepts sqlite-shaped health fields on service config PATCH", () => {
|
||||
const parsed = updateServiceConfigSchema.parse({
|
||||
domains: [
|
||||
{
|
||||
fqdn: "gw.example.com",
|
||||
target_ips: ["1.2.3.4"],
|
||||
health_check_enabled: 1,
|
||||
health_check_verify_tls: 0,
|
||||
health_check_providers: '["local","cloudflare"]',
|
||||
health_check_aggregate: "majority",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parsed.domains?.[0]?.health_check_enabled).toBe(true);
|
||||
expect(parsed.domains?.[0]?.health_check_verify_tls).toBe(false);
|
||||
expect(parsed.domains?.[0]?.health_check_providers).toEqual([
|
||||
"local",
|
||||
"cloudflare",
|
||||
]);
|
||||
});
|
||||
it("create + updateConfig then listGroupViews parses with shared Zod schema", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
@@ -133,6 +153,69 @@ describe("create service then list groups", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("PATCH /services/:id persists health providers and aggregate", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
const cf = mockCf();
|
||||
|
||||
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/services",
|
||||
headers,
|
||||
payload: { name: "GW", slug: "gw" },
|
||||
});
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
const created = createRes.json() as { id: number };
|
||||
|
||||
await updateConfig(app.db, cf, created.id, {
|
||||
ips: ["1.2.3.4"],
|
||||
domains: [
|
||||
{
|
||||
fqdn: "gw.example.com",
|
||||
target_ips: ["1.2.3.4"],
|
||||
health_check_enabled: true,
|
||||
health_check_type: "tcp",
|
||||
health_check_interval_sec: 30,
|
||||
health_check_timeout_ms: 3000,
|
||||
health_check_providers: ["local", "cloudflare"],
|
||||
health_check_aggregate: "majority",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const stored = repos.listBindingsByService(app.db, created.id)[0]!;
|
||||
expect(stored.health_check_enabled).toBe(true);
|
||||
expect(Array.isArray(stored.health_check_providers)).toBe(true);
|
||||
expect(stored.health_check_providers).toEqual(["local", "cloudflare"]);
|
||||
expect(stored.health_check_aggregate).toBe("majority");
|
||||
|
||||
const getRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/services/${created.id}`,
|
||||
headers,
|
||||
});
|
||||
expect(getRes.statusCode).toBe(200);
|
||||
const view = getRes.json() as {
|
||||
domains: Array<{
|
||||
health_check_enabled: boolean;
|
||||
health_check_providers: string[];
|
||||
health_check_aggregate: string;
|
||||
}>;
|
||||
};
|
||||
expect(view.domains[0]?.health_check_enabled).toBe(true);
|
||||
expect(view.domains[0]?.health_check_providers).toEqual([
|
||||
"local",
|
||||
"cloudflare",
|
||||
]);
|
||||
expect(view.domains[0]?.health_check_aggregate).toBe("majority");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("POST /services returns resolved ServiceView with numeric id", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
type HealthAggregate,
|
||||
type HealthProvider,
|
||||
} from '@/components/reui-kit/health-source-tiles'
|
||||
import { uniqueHealthProviders } from '@cfdm/shared'
|
||||
import { parseHealthProviders } from '@cfdm/shared'
|
||||
|
||||
export type LbMode = 'round_robin' | 'failover' | 'weighted'
|
||||
export type HealthCheckType = 'tcp' | 'http'
|
||||
@@ -116,10 +116,10 @@ export function HealthCheckConfigFields({
|
||||
onChange({ ...value, ...next })
|
||||
}
|
||||
|
||||
const providers =
|
||||
value.providers?.length > 0
|
||||
? uniqueHealthProviders(value.providers)
|
||||
: uniqueHealthProviders([value.provider ?? 'local'])
|
||||
const providers = parseHealthProviders(
|
||||
value.providers,
|
||||
value.provider ?? 'local',
|
||||
)
|
||||
const aggregate = value.aggregate ?? 'majority'
|
||||
const isHttp = value.type === 'http'
|
||||
const rowClass = 'gap-3 px-0 py-3'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@cfdm/ui/components/toggle-group'
|
||||
import { parseHealthProviders, type HealthCheckAggregate, type HealthCheckProvider } from '@cfdm/shared'
|
||||
import type { HealthLogStatus } from '@/lib/health-log'
|
||||
|
||||
export type HealthProvider = HealthCheckProvider
|
||||
@@ -189,7 +193,7 @@ export function HealthSourceTiles({
|
||||
value: HealthProvider[]
|
||||
onChange: (next: HealthProvider[]) => void
|
||||
}) {
|
||||
const selected = value.length > 0 ? value : (['local'] as HealthProvider[])
|
||||
const selected = parseHealthProviders(value)
|
||||
|
||||
function toggle(id: HealthProvider) {
|
||||
if (selected.includes(id)) {
|
||||
@@ -261,6 +265,86 @@ export function HealthAggregateTiles({
|
||||
)
|
||||
}
|
||||
|
||||
function toggleProviders(
|
||||
active: HealthProvider[],
|
||||
id: HealthProvider,
|
||||
): HealthProvider[] {
|
||||
if (active.includes(id)) {
|
||||
if (active.length === 1) return active
|
||||
return active.filter((item) => item !== id)
|
||||
}
|
||||
return [...active, id]
|
||||
}
|
||||
|
||||
/**
|
||||
* Компактный мультивыбор типа пробы (toolbar в stacked Frame).
|
||||
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/chart-17
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function HealthSourceFilterBar({
|
||||
enabled,
|
||||
selected,
|
||||
statuses,
|
||||
onChange,
|
||||
}: {
|
||||
enabled: HealthProvider[]
|
||||
selected: HealthProvider[]
|
||||
statuses: Partial<Record<HealthProvider, HealthLogStatus>>
|
||||
onChange: (next: HealthProvider[]) => void
|
||||
}) {
|
||||
const visible = HEALTH_PROVIDER_ITEMS.filter((item) => enabled.includes(item.id))
|
||||
if (visible.length === 0) return null
|
||||
|
||||
const active = selected.length > 0 ? selected : enabled
|
||||
|
||||
return (
|
||||
<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]"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only status tiles for enabled probe sources; click filters the monitor.
|
||||
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
|
||||
@@ -282,15 +366,6 @@ export function HealthProviderStatusTiles({
|
||||
|
||||
const active = selected.length > 0 ? selected : enabled
|
||||
|
||||
function toggle(id: HealthProvider) {
|
||||
if (active.includes(id)) {
|
||||
if (active.length === 1) return
|
||||
onChange(active.filter((item) => item !== id))
|
||||
return
|
||||
}
|
||||
onChange([...active, id])
|
||||
}
|
||||
|
||||
return (
|
||||
<ChoiceFrame>
|
||||
{visible.map((item) => (
|
||||
@@ -309,7 +384,7 @@ export function HealthProviderStatusTiles({
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
onActivate={() => toggle(item.id)}
|
||||
onActivate={() => onChange(toggleProviders(active, item.id))}
|
||||
/>
|
||||
))}
|
||||
</ChoiceFrame>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { UptimeChart, type UptimeProbe, type UptimePeriodKey } from './uptime-chart'
|
||||
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'
|
||||
@@ -24,6 +25,8 @@ export {
|
||||
HealthSourceTiles,
|
||||
HealthAggregateTiles,
|
||||
HealthProviderStatusTiles,
|
||||
HealthSourceFilterBar,
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -9,40 +9,78 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart'
|
||||
import { HealthSourceFilterBar } from '@/components/reui-kit/health-source-tiles'
|
||||
import {
|
||||
collapseStatusChanges,
|
||||
filterByPeriod,
|
||||
filterByProviders,
|
||||
type HealthLogProbe,
|
||||
type HealthLogStatus,
|
||||
} from '@/lib/health-log'
|
||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||
|
||||
/**
|
||||
* Combined uptime chart + status-change timeline (solution-analytics-8 DNA).
|
||||
* Preview: https://reui.io/preview/base/solution-analytics-8 · https://reui.io/preview/base/chart-17
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/timeline
|
||||
* Единый блок мониторинга: компактный мультивыбор типа пробы (list-9 / ToggleGroup)
|
||||
* + график (chart-17) + таймлайн смен статуса.
|
||||
*
|
||||
* Preview: https://reui.io/preview/base/list-9
|
||||
* Preview: https://reui.io/preview/base/chart-17
|
||||
* Preview: https://reui.io/preview/base/solution-ai-ops-1
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
* Docs: https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function ServiceHealthMonitor({
|
||||
items,
|
||||
selectedProviders,
|
||||
enabledProviders,
|
||||
statuses,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: HealthLogProbe[]
|
||||
selectedProviders: readonly HealthCheckProvider[]
|
||||
enabledProviders: readonly HealthCheckProvider[]
|
||||
statuses: Partial<Record<HealthCheckProvider, HealthLogStatus>>
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
|
||||
const [selected, setSelected] = useState<HealthCheckProvider[] | null>(null)
|
||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||
|
||||
const enabled = useMemo(
|
||||
() => [...enabledProviders],
|
||||
[enabledProviders],
|
||||
)
|
||||
|
||||
const activeProviders = useMemo((): HealthCheckProvider[] => {
|
||||
const picked = (selected ?? enabled).filter((provider) =>
|
||||
enabled.includes(provider),
|
||||
)
|
||||
return picked.length > 0 ? picked : enabled
|
||||
}, [enabled, selected])
|
||||
|
||||
const periodItems = useMemo(
|
||||
() => filterByPeriod(items, days),
|
||||
[items, days],
|
||||
)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterByProviders(filterByPeriod(items, days), selectedProviders),
|
||||
[items, days, selectedProviders],
|
||||
() => filterByProviders(periodItems, activeProviders),
|
||||
[periodItems, activeProviders],
|
||||
)
|
||||
|
||||
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
|
||||
|
||||
return (
|
||||
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||
<FramePanel>
|
||||
<HealthSourceFilterBar
|
||||
enabled={enabled}
|
||||
selected={activeProviders}
|
||||
statuses={statuses}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
</FramePanel>
|
||||
|
||||
<UptimeChart
|
||||
items={filtered}
|
||||
isLoading={isLoading}
|
||||
@@ -50,12 +88,15 @@ export function ServiceHealthMonitor({
|
||||
onPeriodChange={setPeriod}
|
||||
skipPeriodFilter
|
||||
embedded
|
||||
hideHeader
|
||||
/>
|
||||
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
<FrameHeader className="px-0 py-0">
|
||||
<FrameTitle>Смены статуса</FrameTitle>
|
||||
<FrameDescription>
|
||||
Только переходы up / degraded / down · Cloudflare = Worker, не Health Checks API
|
||||
Только переходы up / degraded / down · Cloudflare = Worker, не Health
|
||||
Checks API
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<HealthTimeline
|
||||
|
||||
@@ -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 { 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'
|
||||
@@ -12,6 +12,7 @@ import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@cfdm/ui/components/chart'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
@@ -21,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 {
|
||||
@@ -35,6 +37,7 @@ export interface UptimeProbe {
|
||||
ok: boolean
|
||||
latency_ms: number | null
|
||||
checked_at: string
|
||||
provider?: HealthCheckProvider
|
||||
}
|
||||
|
||||
export type UptimePeriodKey = '5D' | '2W' | '1M'
|
||||
@@ -45,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))
|
||||
@@ -87,22 +162,51 @@ function deltaPercent(points: ChartPoint[]): number | null {
|
||||
return next - prev
|
||||
}
|
||||
|
||||
interface UptimeTooltipProps {
|
||||
active?: boolean
|
||||
payload?: Array<{ payload: ChartPoint }>
|
||||
function UptimeDelta({ delta }: { delta: number }) {
|
||||
if (Math.abs(delta) < 0.05) {
|
||||
return <span className="text-muted-foreground">без изменений за период</span>
|
||||
}
|
||||
|
||||
if (delta > 0) {
|
||||
return (
|
||||
<>
|
||||
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
|
||||
<span className="text-success font-medium">+{delta.toFixed(1)} п.п.</span>
|
||||
<span className="text-muted-foreground">с начала периода</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
|
||||
<span className="text-destructive font-medium">{delta.toFixed(1)} п.п.</span>
|
||||
<span className="text-muted-foreground">с начала периода</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function UptimeTooltip({ active, payload }: UptimeTooltipProps) {
|
||||
if (!active || !payload?.[0]) return null
|
||||
const point = payload[0].payload
|
||||
return (
|
||||
<div className="bg-popover text-popover-foreground rounded-md px-3 py-2 text-sm shadow-md">
|
||||
<p className="font-medium tabular-nums">
|
||||
{point.latency} мс · {point.ok ? 'OK' : 'Down'}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{point.period}</p>
|
||||
</div>
|
||||
)
|
||||
export function probeUptimePercent(items: UptimeProbe[]): number | null {
|
||||
return uptimePercent(toAlignedSeries(items).points)
|
||||
}
|
||||
|
||||
export function lastProbeLatency(items: UptimeProbe[]): number | null {
|
||||
if (items.length === 0) return null
|
||||
const latest = [...items].sort(
|
||||
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at),
|
||||
)[0]
|
||||
return latest?.latency_ms ?? null
|
||||
}
|
||||
|
||||
function formatUptime(value: number | null): string {
|
||||
if (value == null) return '—'
|
||||
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 {
|
||||
@@ -112,6 +216,8 @@ interface UptimeChartProps {
|
||||
onPeriodChange?: (period: UptimePeriodKey) => void
|
||||
skipPeriodFilter?: boolean
|
||||
embedded?: boolean
|
||||
/** dashboard-4: chrome живёт в родительском FrameHeader (переключатель серий). */
|
||||
hideHeader?: boolean
|
||||
}
|
||||
|
||||
export function UptimeChart({
|
||||
@@ -121,65 +227,88 @@ export function UptimeChart({
|
||||
onPeriodChange,
|
||||
skipPeriodFilter = false,
|
||||
embedded = false,
|
||||
hideHeader = false,
|
||||
}: UptimeChartProps) {
|
||||
const gradientId = useId().replace(/:/g, '')
|
||||
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
||||
const [hovered, setHovered] = useState<AlignedChartPoint | null>(null)
|
||||
const period = periodProp ?? internalPeriod
|
||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||
|
||||
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">
|
||||
<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">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
className={`size-10.5 ${tileClass}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ActivityIcon />
|
||||
</IconTile>
|
||||
<div className="flex flex-col justify-center">
|
||||
<h3 className="text-base font-semibold">Uptime</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Пробы health-check за период
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider delay={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="О графике uptime"
|
||||
className="text-muted-foreground/70 -mr-1"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
/>
|
||||
}
|
||||
<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">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
className={`size-10.5 ${tileClass}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<InfoIcon data-icon="inline-start" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<ActivityIcon />
|
||||
</IconTile>
|
||||
<div className="flex flex-col justify-center">
|
||||
<h3 className="text-base font-semibold">Uptime</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Пробы health-check за период
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider delay={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="О графике uptime"
|
||||
className="text-muted-foreground/70 -mr-1"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon data-icon="inline-start" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-muted h-40 w-full animate-pulse rounded-xl" />
|
||||
@@ -195,95 +324,136 @@ export function UptimeChart({
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-foreground text-3xl font-semibold tabular-nums">
|
||||
{uptime == null ? '—' : `${uptime.toFixed(uptime >= 99.95 ? 2 : 1)}%`}
|
||||
{formatUptime(uptime)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{delta == null ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
{points.length} проб
|
||||
</Badge>
|
||||
) : delta >= 0 ? (
|
||||
<>
|
||||
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
|
||||
<span className="text-success font-medium">
|
||||
+{delta.toFixed(1)} п.п.
|
||||
</span>
|
||||
<span className="text-muted-foreground">к первой половине окна</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
|
||||
<span className="text-destructive font-medium">
|
||||
{delta.toFixed(1)} п.п.
|
||||
</span>
|
||||
<span className="text-muted-foreground">к первой половине окна</span>
|
||||
</>
|
||||
<UptimeDelta delta={delta} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-40 w-full">
|
||||
{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-hidden 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: 10, left: 0, right: 0, bottom: 0 }}
|
||||
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 />
|
||||
<ChartTooltip content={<UptimeTooltip />} />
|
||||
<Area
|
||||
dataKey="latency"
|
||||
type="natural"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke="var(--color-latency)"
|
||||
strokeWidth={2}
|
||||
dot={(dotProps) => {
|
||||
const { cx, cy, payload, index } = dotProps as {
|
||||
cx?: number
|
||||
cy?: number
|
||||
index?: number
|
||||
payload?: ChartPoint
|
||||
}
|
||||
if (cx == null || cy == null) return <g key={index} />
|
||||
const fill = payload?.ok
|
||||
? 'var(--color-latency)'
|
||||
: 'var(--destructive)'
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={4}
|
||||
fill={fill}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
stroke: 'var(--background)',
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
<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 }}
|
||||
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
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">
|
||||
{ok === false ? `${label} · Down` : `Пинг · ${label}`}
|
||||
</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{formatPing(value, ok)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</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,8 +13,16 @@ import type {
|
||||
ServiceView,
|
||||
UpdateServiceConfigInput,
|
||||
} from '@/lib/schemas'
|
||||
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,
|
||||
@@ -29,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,
|
||||
@@ -46,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'
|
||||
@@ -100,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: 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: binding.health_check_verify_tls ?? false,
|
||||
provider: binding.health_check_provider ?? 'local',
|
||||
providers:
|
||||
binding.health_check_providers?.length > 0
|
||||
? 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,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)
|
||||
|
||||
@@ -232,6 +109,8 @@ export function ServiceEditSheet({
|
||||
[groups],
|
||||
)
|
||||
|
||||
// Reset only when the sheet opens or the service id changes.
|
||||
// Health polling replaces `service` by identity and would wipe unsaved settings.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (mode === 'edit' && service) {
|
||||
@@ -240,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
|
||||
@@ -254,149 +133,40 @@ 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)
|
||||
}
|
||||
}, [open, mode, service, defaultGroupId])
|
||||
}, [open, mode, service?.id, defaultGroupId])
|
||||
|
||||
const zoneHints = useMemo(
|
||||
() => knownDomains.map((domain) => domain.zone_name),
|
||||
[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) {
|
||||
@@ -440,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}>
|
||||
@@ -447,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>
|
||||
|
||||
@@ -495,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
|
||||
@@ -530,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),
|
||||
@@ -291,14 +304,14 @@ const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
|
||||
const healthCheckTypeSchema = z.enum(['tcp', 'http', 'ping', 'dns'])
|
||||
|
||||
const healthCheckConfigFields = {
|
||||
health_check_enabled: z.boolean().optional(),
|
||||
health_check_enabled: z.coerce.boolean().optional(),
|
||||
health_check_type: healthCheckTypeSchema.optional(),
|
||||
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
|
||||
health_check_path: z.string().nullable().optional(),
|
||||
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
|
||||
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
|
||||
health_check_verify_tls: z.boolean().optional(),
|
||||
health_check_verify_tls: z.coerce.boolean().optional(),
|
||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).optional(),
|
||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).optional(),
|
||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).optional(),
|
||||
|
||||
@@ -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'
|
||||
@@ -28,24 +27,16 @@ import {
|
||||
} from '@/components/services/service-detail-grid'
|
||||
import { LbModeTile } from '@/components/services/service-unit-card'
|
||||
import {
|
||||
HealthProviderStatusTiles,
|
||||
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,
|
||||
providerHealthStatuses,
|
||||
} from '@/lib/health-log'
|
||||
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||
import {
|
||||
createServiceNode,
|
||||
deleteServiceNode,
|
||||
@@ -53,6 +44,7 @@ import {
|
||||
domainsListQueryOptions,
|
||||
serviceBindingKeys,
|
||||
serviceDetailKeys,
|
||||
serviceFailoverLogQueryOptions,
|
||||
serviceGroupKeys,
|
||||
serviceGroupsQueryOptions,
|
||||
serviceHealthLogQueryOptions,
|
||||
@@ -86,6 +78,7 @@ interface OverviewPayload {
|
||||
priority: number
|
||||
consecutive_failures: number
|
||||
last_failure_reason: string | null
|
||||
last_check_at?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -98,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())
|
||||
@@ -108,13 +102,23 @@ 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)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [selectedProviders, setSelectedProviders] = useState<
|
||||
HealthCheckProvider[] | null
|
||||
>(null)
|
||||
const [togglingIp, setTogglingIp] = useState<string | null>(null)
|
||||
const [changeIp, setChangeIp] = useState<{
|
||||
bindingId: number
|
||||
@@ -140,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) }),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -214,31 +219,10 @@ 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],
|
||||
)
|
||||
const activeProviders =
|
||||
selectedProviders?.filter((provider) => enabledProviders.includes(provider)) ??
|
||||
enabledProviders
|
||||
const effectiveProviders =
|
||||
activeProviders.length > 0 ? activeProviders : enabledProviders
|
||||
const providerStatuses = useMemo(
|
||||
() => providerHealthStatuses(logItems, enabledProviders),
|
||||
[logItems, enabledProviders],
|
||||
@@ -333,33 +317,21 @@ function ServiceDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<HealthProviderStatusTiles
|
||||
enabled={enabledProviders}
|
||||
selected={effectiveProviders}
|
||||
statuses={providerStatuses}
|
||||
onChange={setSelectedProviders}
|
||||
/>
|
||||
|
||||
<section
|
||||
aria-label="Мониторинг"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
>
|
||||
<ServiceHealthMonitor
|
||||
items={logItems}
|
||||
selectedProviders={effectiveProviders}
|
||||
enabledProviders={enabledProviders}
|
||||
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
+369
-4
File diff suppressed because one or more lines are too long
Vendored
+151
-21
@@ -123,6 +123,7 @@ var serviceBindings = sqliteTable(
|
||||
health_check_provider: text("health_check_provider").notNull().default("local"),
|
||||
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
|
||||
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
|
||||
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
|
||||
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
|
||||
operation_version: integer("operation_version").notNull().default(0),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
@@ -228,6 +229,9 @@ var certificates = sqliteTable("certificates", {
|
||||
subdomain_id: integer("subdomain_id").references(() => subdomains.id, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
service_id: integer("service_id").references(() => services.id, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
hostname: text("hostname").notNull().unique(),
|
||||
expires_at: text("expires_at"),
|
||||
last_checked_at: text("last_checked_at"),
|
||||
@@ -351,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"),
|
||||
@@ -391,6 +404,7 @@ var schema = {
|
||||
domainMonitorResults,
|
||||
healthProbeLog,
|
||||
notificationLog,
|
||||
failoverLog,
|
||||
auditLog
|
||||
};
|
||||
|
||||
@@ -711,6 +725,7 @@ __export(repos_exports, {
|
||||
getSyncJob: () => getSyncJob,
|
||||
insertBinding: () => insertBinding,
|
||||
insertDnsRecord: () => insertDnsRecord,
|
||||
insertFailoverLog: () => insertFailoverLog,
|
||||
insertHealthProbeLog: () => insertHealthProbeLog,
|
||||
insertNotificationLog: () => insertNotificationLog,
|
||||
linkBindingRecord: () => linkBindingRecord,
|
||||
@@ -734,6 +749,7 @@ __export(repos_exports, {
|
||||
listDomains: () => listDomains,
|
||||
listDomainsEnriched: () => listDomainsEnriched,
|
||||
listEnabledDomainMonitors: () => listEnabledDomainMonitors,
|
||||
listFailoverLogForService: () => listFailoverLogForService,
|
||||
listGroupDnsRecords: () => listGroupDnsRecords,
|
||||
listGroups: () => listGroups,
|
||||
listHealthCheckTargets: () => listHealthCheckTargets,
|
||||
@@ -1221,15 +1237,16 @@ function mapServiceBinding(row) {
|
||||
cname_target: row.cname_target,
|
||||
dns_record_id: row.dns_record_id,
|
||||
lb_mode: row.lb_mode,
|
||||
health_check_enabled: row.health_check_enabled,
|
||||
health_check_enabled: Boolean(row.health_check_enabled),
|
||||
health_check_type: row.health_check_type,
|
||||
health_check_port: row.health_check_port,
|
||||
health_check_path: row.health_check_path,
|
||||
health_check_expected_status: row.health_check_expected_status,
|
||||
health_check_interval_sec: row.health_check_interval_sec,
|
||||
health_check_timeout_ms: row.health_check_timeout_ms,
|
||||
health_check_verify_tls: row.health_check_verify_tls,
|
||||
health_check_verify_tls: Boolean(row.health_check_verify_tls),
|
||||
...mapHealthFields(row),
|
||||
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||
routing_strategy: row.routing_strategy,
|
||||
operation_version: row.operation_version,
|
||||
created_at: row.created_at,
|
||||
@@ -1625,6 +1642,8 @@ function updateBindingLbConfig(db, bindingId, patch) {
|
||||
update.health_check_timeout_ms = patch.health_check_timeout_ms;
|
||||
if (patch.health_check_verify_tls !== void 0)
|
||||
update.health_check_verify_tls = patch.health_check_verify_tls;
|
||||
if (patch.cert_monitoring !== void 0)
|
||||
update.cert_monitoring = patch.cert_monitoring;
|
||||
Object.assign(update, healthProviderColumns(patch));
|
||||
db.update(serviceBindings).set(update).where(eq3(serviceBindings.id, bindingId)).run();
|
||||
}
|
||||
@@ -1685,7 +1704,7 @@ var SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hos
|
||||
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
|
||||
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
|
||||
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider,
|
||||
sb.health_check_providers, sb.health_check_aggregate, sb.cname_target,
|
||||
sb.health_check_providers, sb.health_check_aggregate, sb.cert_monitoring, sb.cname_target,
|
||||
d.zone_name, d.group_id, g.name AS group_name,
|
||||
s.name AS service_name, s.slug AS service_slug,
|
||||
dr.content AS target_ip, dr.sync_status,
|
||||
@@ -1733,6 +1752,7 @@ function enrichServiceBindingView(db, row) {
|
||||
return {
|
||||
...row,
|
||||
cname_target: row.cname_target ?? null,
|
||||
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||
...mapHealthFields(row),
|
||||
target_ips,
|
||||
target_ip: target_ips[0] ?? null,
|
||||
@@ -1765,11 +1785,15 @@ function listBindingsByDomain(db, domainId) {
|
||||
`).map((row) => enrichServiceBindingView(db, row));
|
||||
}
|
||||
function listBindingsByService(db, serviceId) {
|
||||
return db.all(sql2`
|
||||
const rows = db.all(sql2`
|
||||
SELECT sb.*, d.zone_name FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
WHERE sb.service_id = ${serviceId}
|
||||
`);
|
||||
return rows.map((row) => ({
|
||||
...mapServiceBinding(row),
|
||||
zone_name: row.zone_name
|
||||
}));
|
||||
}
|
||||
function getBinding(db, id) {
|
||||
const row = db.select().from(serviceBindings).where(eq3(serviceBindings.id, id)).get();
|
||||
@@ -1839,23 +1863,57 @@ function deleteBinding(db, id) {
|
||||
const result = db.delete(serviceBindings).where(eq3(serviceBindings.id, id)).run();
|
||||
if (result.changes === 0) throw new NotFoundError(`service binding ${id}`);
|
||||
}
|
||||
var CERTIFICATE_SELECT = `c.id, c.domain_id, c.subdomain_id, c.service_id, c.hostname,
|
||||
c.expires_at, c.last_checked_at, c.last_error, c.status, c.created_at, c.updated_at,
|
||||
s.name AS service_name`;
|
||||
function mapCertificate(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
domain_id: row.domain_id,
|
||||
subdomain_id: row.subdomain_id,
|
||||
service_id: row.service_id ?? null,
|
||||
service_name: row.service_name ?? null,
|
||||
hostname: row.hostname,
|
||||
expires_at: row.expires_at,
|
||||
last_checked_at: row.last_checked_at,
|
||||
last_error: row.last_error,
|
||||
status: row.status,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
function listCertificates(db, status) {
|
||||
if (status) {
|
||||
return db.select().from(certificates).where(eq3(certificates.status, status)).orderBy(asc(certificates.expires_at)).all();
|
||||
}
|
||||
return db.select().from(certificates).orderBy(asc(certificates.expires_at)).all();
|
||||
const rows = status ? db.all(sql2`
|
||||
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
|
||||
FROM certificates c
|
||||
LEFT JOIN services s ON s.id = c.service_id
|
||||
WHERE c.status = ${status}
|
||||
ORDER BY c.expires_at ASC
|
||||
`) : db.all(sql2`
|
||||
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
|
||||
FROM certificates c
|
||||
LEFT JOIN services s ON s.id = c.service_id
|
||||
ORDER BY c.expires_at ASC
|
||||
`);
|
||||
return rows.map(mapCertificate);
|
||||
}
|
||||
function getCertificate(db, id) {
|
||||
const row = db.select().from(certificates).where(eq3(certificates.id, id)).get();
|
||||
const row = db.all(sql2`
|
||||
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
|
||||
FROM certificates c
|
||||
LEFT JOIN services s ON s.id = c.service_id
|
||||
WHERE c.id = ${id}
|
||||
`)[0];
|
||||
if (!row) throw new NotFoundError(`certificate ${id}`);
|
||||
return row;
|
||||
return mapCertificate(row);
|
||||
}
|
||||
function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError) {
|
||||
function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError, serviceId) {
|
||||
const existing = db.select().from(certificates).where(eq3(certificates.hostname, hostname)).get();
|
||||
if (existing) {
|
||||
db.update(certificates).set({
|
||||
domain_id: domainId,
|
||||
subdomain_id: subdomainId,
|
||||
service_id: serviceId === void 0 ? existing.service_id : serviceId,
|
||||
expires_at: expiresAt,
|
||||
last_checked_at: sql2`datetime('now')`,
|
||||
last_error: lastError,
|
||||
@@ -1867,6 +1925,7 @@ function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt,
|
||||
const id = db.insert(certificates).values({
|
||||
domain_id: domainId,
|
||||
subdomain_id: subdomainId,
|
||||
service_id: serviceId ?? null,
|
||||
hostname,
|
||||
expires_at: expiresAt,
|
||||
last_checked_at: sql2`datetime('now')`,
|
||||
@@ -1948,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;
|
||||
@@ -2008,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
|
||||
@@ -2170,6 +2235,40 @@ function pruneStaleIpHealthStatus(db, activeTargets) {
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
function normalizeCnameHost(target, zoneName) {
|
||||
const trimmed = target.trim().toLowerCase().replace(/\.+$/, "");
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.includes(".")) return trimmed;
|
||||
const zone = zoneName.trim().toLowerCase().replace(/\.+$/, "");
|
||||
return zone ? `${trimmed}.${zone}` : trimmed;
|
||||
}
|
||||
function resolveCnameProbeIps(db, cnameTarget, zoneName, serviceId) {
|
||||
const fqdn = normalizeCnameHost(cnameTarget, zoneName);
|
||||
if (fqdn) {
|
||||
const fromDns = listOriginIpsForFqdn(db, fqdn).filter(isIpLiteral);
|
||||
if (fromDns.length > 0) return [...new Set(fromDns)];
|
||||
}
|
||||
if (serviceId > 0) {
|
||||
return [...new Set(listServiceIps(db, serviceId).filter(isIpLiteral))];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
function expandCnameHealthTargets(db, rows) {
|
||||
const expanded = [];
|
||||
for (const row of rows) {
|
||||
const ips = resolveCnameProbeIps(
|
||||
db,
|
||||
row.ip,
|
||||
row.zone_name ?? "",
|
||||
row.service_id ?? 0
|
||||
);
|
||||
const resolved = ips.length > 0 ? ips : [row.ip];
|
||||
for (const ip of resolved) {
|
||||
expanded.push({ ...row, ip });
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
function listHealthCheckTargets(db) {
|
||||
const fqdnExpr = sql2`CASE WHEN sb.hostname = '@' OR sb.hostname IS NULL THEN d.zone_name ELSE sb.hostname || '.' || d.zone_name END`;
|
||||
const bindingTargets = db.all(sql2`
|
||||
@@ -2188,7 +2287,7 @@ function listHealthCheckTargets(db) {
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
WHERE sb.health_check_enabled = 1
|
||||
`);
|
||||
`).filter((t) => isIpLiteral(t.ip));
|
||||
const groupTargets = db.all(sql2`
|
||||
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
|
||||
sg.domain AS hostname,
|
||||
@@ -2211,7 +2310,7 @@ function listHealthCheckTargets(db) {
|
||||
AND s.enabled = 1
|
||||
AND sg.enabled = 1
|
||||
AND (sb.cname_target IS NULL OR sb.cname_target = '')
|
||||
`);
|
||||
`).filter((t) => isIpLiteral(t.ip));
|
||||
const groupInheritedBindingTargets = db.all(sql2`
|
||||
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
|
||||
${fqdnExpr} AS hostname,
|
||||
@@ -2234,8 +2333,10 @@ function listHealthCheckTargets(db) {
|
||||
AND s.enabled = 1
|
||||
AND sg.enabled = 1
|
||||
AND sb.health_check_enabled = 0
|
||||
`);
|
||||
const cnameBindingTargets = db.all(sql2`
|
||||
`).filter((t) => isIpLiteral(t.ip));
|
||||
const cnameBindingTargets = expandCnameHealthTargets(
|
||||
db,
|
||||
db.all(sql2`
|
||||
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
||||
${fqdnExpr} AS hostname,
|
||||
sb.health_check_type AS type,
|
||||
@@ -2246,7 +2347,9 @@ function listHealthCheckTargets(db) {
|
||||
sb.health_check_verify_tls AS verify_tls,
|
||||
sb.health_check_providers AS providers_json,
|
||||
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sb.health_check_provider, 'local') AS provider
|
||||
COALESCE(sb.health_check_provider, 'local') AS provider,
|
||||
d.zone_name AS zone_name,
|
||||
sb.service_id AS service_id
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
JOIN services s ON s.id = sb.service_id
|
||||
@@ -2254,8 +2357,11 @@ function listHealthCheckTargets(db) {
|
||||
AND sb.cname_target IS NOT NULL
|
||||
AND sb.cname_target <> ''
|
||||
AND s.enabled = 1
|
||||
`);
|
||||
const groupInheritedCnameBindingTargets = db.all(sql2`
|
||||
`)
|
||||
);
|
||||
const groupInheritedCnameBindingTargets = expandCnameHealthTargets(
|
||||
db,
|
||||
db.all(sql2`
|
||||
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
||||
${fqdnExpr} AS hostname,
|
||||
sg.health_check_type AS type,
|
||||
@@ -2266,7 +2372,9 @@ function listHealthCheckTargets(db) {
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider,
|
||||
d.zone_name AS zone_name,
|
||||
sb.service_id AS service_id
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
JOIN services s ON s.id = sb.service_id
|
||||
@@ -2278,7 +2386,8 @@ function listHealthCheckTargets(db) {
|
||||
AND sb.health_check_enabled = 0
|
||||
AND sb.cname_target IS NOT NULL
|
||||
AND sb.cname_target <> ''
|
||||
`);
|
||||
`)
|
||||
);
|
||||
return [
|
||||
...bindingTargets,
|
||||
...groupTargets,
|
||||
@@ -2454,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,
|
||||
@@ -2469,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);
|
||||
+153
-18
@@ -49,6 +49,7 @@ import {
|
||||
nodes,
|
||||
bindingNodes,
|
||||
notificationLog,
|
||||
failoverLog,
|
||||
serviceBindingIps,
|
||||
serviceBindingRecords,
|
||||
serviceBindings,
|
||||
@@ -842,14 +843,14 @@ function mapServiceBinding(
|
||||
cname_target: row.cname_target,
|
||||
dns_record_id: row.dns_record_id,
|
||||
lb_mode: row.lb_mode as LbMode,
|
||||
health_check_enabled: row.health_check_enabled,
|
||||
health_check_enabled: Boolean(row.health_check_enabled),
|
||||
health_check_type: row.health_check_type as HealthCheckType,
|
||||
health_check_port: row.health_check_port,
|
||||
health_check_path: row.health_check_path,
|
||||
health_check_expected_status: row.health_check_expected_status,
|
||||
health_check_interval_sec: row.health_check_interval_sec,
|
||||
health_check_timeout_ms: row.health_check_timeout_ms,
|
||||
health_check_verify_tls: row.health_check_verify_tls,
|
||||
health_check_verify_tls: Boolean(row.health_check_verify_tls),
|
||||
...mapHealthFields(row),
|
||||
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||
routing_strategy: row.routing_strategy as LbMode,
|
||||
@@ -1777,11 +1778,15 @@ export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingVi
|
||||
}
|
||||
|
||||
export function listBindingsByService(db: Db, serviceId: number): Array<ServiceBinding & { zone_name: string }> {
|
||||
return db.all(sql`
|
||||
const rows = db.all<typeof serviceBindings.$inferSelect & { zone_name: string }>(sql`
|
||||
SELECT sb.*, d.zone_name FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
WHERE sb.service_id = ${serviceId}
|
||||
`);
|
||||
return rows.map((row) => ({
|
||||
...mapServiceBinding(row),
|
||||
zone_name: row.zone_name,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getBinding(db: Db, id: number): ServiceBinding {
|
||||
@@ -2159,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,
|
||||
@@ -2232,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[],
|
||||
@@ -2249,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
|
||||
@@ -2489,13 +2502,73 @@ export function pruneStaleIpHealthStatus(
|
||||
|
||||
// --- Health Check Targets ---
|
||||
|
||||
function normalizeCnameHost(target: string, zoneName: string): string {
|
||||
const trimmed = target.trim().toLowerCase().replace(/\.+$/, "");
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.includes(".")) return trimmed;
|
||||
const zone = zoneName.trim().toLowerCase().replace(/\.+$/, "");
|
||||
return zone ? `${trimmed}.${zone}` : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a CNAME health target to origin IPs:
|
||||
* 1) A/AAAA from local dns_records (follows CNAME chain)
|
||||
* 2) this service's IP pool
|
||||
*
|
||||
* Hostname is kept as fallback so probes still run when nothing resolves.
|
||||
*/
|
||||
function resolveCnameProbeIps(
|
||||
db: Db,
|
||||
cnameTarget: string,
|
||||
zoneName: string,
|
||||
serviceId: number,
|
||||
): string[] {
|
||||
const fqdn = normalizeCnameHost(cnameTarget, zoneName);
|
||||
if (fqdn) {
|
||||
const fromDns = listOriginIpsForFqdn(db, fqdn).filter(isIpLiteral);
|
||||
if (fromDns.length > 0) return [...new Set(fromDns)];
|
||||
}
|
||||
if (serviceId > 0) {
|
||||
return [...new Set(listServiceIps(db, serviceId).filter(isIpLiteral))];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
type RawHealthTarget = HealthCheckTarget & {
|
||||
providers_json?: string | null;
|
||||
aggregate?: string | null;
|
||||
zone_name?: string | null;
|
||||
service_id?: number | null;
|
||||
};
|
||||
|
||||
function expandCnameHealthTargets(
|
||||
db: Db,
|
||||
rows: RawHealthTarget[],
|
||||
): RawHealthTarget[] {
|
||||
const expanded: RawHealthTarget[] = [];
|
||||
for (const row of rows) {
|
||||
const ips = resolveCnameProbeIps(
|
||||
db,
|
||||
row.ip,
|
||||
row.zone_name ?? "",
|
||||
row.service_id ?? 0,
|
||||
);
|
||||
const resolved = ips.length > 0 ? ips : [row.ip];
|
||||
for (const ip of resolved) {
|
||||
expanded.push({ ...row, ip });
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
// FQDN for a binding: "@" => zone_name, else "<hostname>.<zone_name>".
|
||||
// Used as SNI / Host header for HTTP(S) probes — the raw `sb.hostname` is just the record name
|
||||
// (e.g. "de" or "@"), which would break TLS SNI (ssl alert 112 "unrecognized name").
|
||||
const fqdnExpr = sql`CASE WHEN sb.hostname = '@' OR sb.hostname IS NULL THEN d.zone_name ELSE sb.hostname || '.' || d.zone_name END`;
|
||||
|
||||
const bindingTargets = db.all<HealthCheckTarget>(sql`
|
||||
const bindingTargets = db
|
||||
.all<RawHealthTarget>(sql`
|
||||
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
|
||||
${fqdnExpr} AS hostname,
|
||||
sb.health_check_type AS type,
|
||||
@@ -2511,12 +2584,14 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
WHERE sb.health_check_enabled = 1
|
||||
`);
|
||||
`)
|
||||
.filter((t) => isIpLiteral(t.ip));
|
||||
|
||||
// Group FQDN probes the same A-record IPs that DNS publishes (binding IPs of
|
||||
// enabled services) — NOT the full service_ips pool. Pool-only dead IPs must
|
||||
// not mark the group Down while the published domain stays healthy.
|
||||
const groupTargets = db.all<HealthCheckTarget>(sql`
|
||||
const groupTargets = db
|
||||
.all<RawHealthTarget>(sql`
|
||||
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
|
||||
sg.domain AS hostname,
|
||||
sg.health_check_type AS type,
|
||||
@@ -2538,13 +2613,14 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
AND s.enabled = 1
|
||||
AND sg.enabled = 1
|
||||
AND (sb.cname_target IS NULL OR sb.cname_target = '')
|
||||
`);
|
||||
`)
|
||||
.filter((t) => isIpLiteral(t.ip));
|
||||
|
||||
// IPs of A-bindings of enabled services in a group whose group has health-check enabled.
|
||||
// These inherit the group's health-check config (scope='binding', ref_id=binding_id),
|
||||
// so per-domain badges reflect group rules. Skipped for bindings that already have
|
||||
// their own health_check_enabled=1 (covered by bindingTargets above).
|
||||
const groupInheritedBindingTargets = db.all<HealthCheckTarget>(sql`
|
||||
const groupInheritedBindingTargets = db.all<RawHealthTarget>(sql`
|
||||
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
|
||||
${fqdnExpr} AS hostname,
|
||||
sg.health_check_type AS type,
|
||||
@@ -2566,10 +2642,13 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
AND s.enabled = 1
|
||||
AND sg.enabled = 1
|
||||
AND sb.health_check_enabled = 0
|
||||
`);
|
||||
`)
|
||||
.filter((t) => isIpLiteral(t.ip));
|
||||
|
||||
// CNAME-bindings with their own health_check_enabled: probe the CNAME target host.
|
||||
const cnameBindingTargets = db.all<HealthCheckTarget>(sql`
|
||||
// CNAME-bindings: unwrap to origin/service IPs so ip_health keys match the IP table.
|
||||
const cnameBindingTargets = expandCnameHealthTargets(
|
||||
db,
|
||||
db.all<RawHealthTarget>(sql`
|
||||
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
||||
${fqdnExpr} AS hostname,
|
||||
sb.health_check_type AS type,
|
||||
@@ -2580,7 +2659,9 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
sb.health_check_verify_tls AS verify_tls,
|
||||
sb.health_check_providers AS providers_json,
|
||||
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sb.health_check_provider, 'local') AS provider
|
||||
COALESCE(sb.health_check_provider, 'local') AS provider,
|
||||
d.zone_name AS zone_name,
|
||||
sb.service_id AS service_id
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
JOIN services s ON s.id = sb.service_id
|
||||
@@ -2588,11 +2669,14 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
AND sb.cname_target IS NOT NULL
|
||||
AND sb.cname_target <> ''
|
||||
AND s.enabled = 1
|
||||
`);
|
||||
`),
|
||||
);
|
||||
|
||||
// CNAME-bindings of services in a group with group health-check enabled
|
||||
// (inherit group config). Only for bindings without their own health_check_enabled.
|
||||
const groupInheritedCnameBindingTargets = db.all<HealthCheckTarget>(sql`
|
||||
const groupInheritedCnameBindingTargets = expandCnameHealthTargets(
|
||||
db,
|
||||
db.all<RawHealthTarget>(sql`
|
||||
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
||||
${fqdnExpr} AS hostname,
|
||||
sg.health_check_type AS type,
|
||||
@@ -2603,7 +2687,9 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider,
|
||||
d.zone_name AS zone_name,
|
||||
sb.service_id AS service_id
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
JOIN services s ON s.id = sb.service_id
|
||||
@@ -2615,7 +2701,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
AND sb.health_check_enabled = 0
|
||||
AND sb.cname_target IS NOT NULL
|
||||
AND sb.cname_target <> ''
|
||||
`);
|
||||
`),
|
||||
);
|
||||
|
||||
return [
|
||||
...bindingTargets,
|
||||
@@ -2977,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
+132
-39
@@ -96,6 +96,7 @@ interface ServiceBinding {
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
cert_monitoring: string;
|
||||
routing_strategy: LbMode;
|
||||
operation_version: number;
|
||||
created_at: string;
|
||||
@@ -129,6 +130,7 @@ interface ServiceBindingView {
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
cert_monitoring: string;
|
||||
sync_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -141,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;
|
||||
@@ -156,6 +159,7 @@ interface ServiceDomainBindingView {
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
cert_monitoring: string;
|
||||
sync_status: string | null;
|
||||
}
|
||||
interface ServiceView$1 {
|
||||
@@ -424,11 +428,11 @@ declare const healthCheckAggregateSchema: z.ZodEnum<{
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>;
|
||||
declare const healthCheckProvidersSchema: z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
declare const healthCheckProvidersSchema: z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
declare const healthCheckScopeSchema: z.ZodEnum<{
|
||||
binding: "binding";
|
||||
group: "group";
|
||||
@@ -502,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;
|
||||
@@ -560,11 +577,11 @@ declare const serviceGroupSchema: z.ZodObject<{
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
@@ -624,17 +641,23 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
cert_monitoring: z.ZodDefault<z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
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>;
|
||||
@@ -658,7 +681,9 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
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;
|
||||
@@ -679,7 +704,9 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
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;
|
||||
@@ -737,17 +764,23 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
cert_monitoring: z.ZodDefault<z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
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>;
|
||||
@@ -771,7 +804,9 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
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;
|
||||
@@ -792,7 +827,9 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
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;
|
||||
@@ -869,11 +906,11 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
@@ -932,17 +969,23 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
cert_monitoring: z.ZodDefault<z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
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>;
|
||||
@@ -966,7 +1009,9 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
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;
|
||||
@@ -987,7 +1032,9 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
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;
|
||||
@@ -1073,11 +1120,11 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
@@ -1136,17 +1183,23 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
cert_monitoring: z.ZodDefault<z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
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>;
|
||||
@@ -1170,7 +1223,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
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;
|
||||
@@ -1191,7 +1246,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
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;
|
||||
@@ -1291,17 +1348,23 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
cert_monitoring: z.ZodDefault<z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
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>;
|
||||
@@ -1325,7 +1388,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
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;
|
||||
@@ -1346,7 +1411,9 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
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;
|
||||
@@ -1471,6 +1538,11 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
|
||||
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
||||
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
|
||||
cert_monitoring: z.ZodDefault<z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
skipped: "skipped";
|
||||
}>>;
|
||||
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
||||
created_at: z.ZodString;
|
||||
updated_at: z.ZodString;
|
||||
@@ -1498,6 +1570,7 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -1522,6 +1595,7 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -1549,6 +1623,8 @@ declare const certificateSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
domain_id: z.ZodNumber;
|
||||
subdomain_id: z.ZodNullable<z.ZodNumber>;
|
||||
service_id: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
|
||||
service_name: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
||||
hostname: z.ZodString;
|
||||
expires_at: z.ZodNullable<z.ZodString>;
|
||||
last_checked_at: z.ZodNullable<z.ZodString>;
|
||||
@@ -1557,6 +1633,22 @@ declare const certificateSchema: z.ZodObject<{
|
||||
created_at: z.ZodString;
|
||||
updated_at: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
declare const serviceCertificateRowSchema: z.ZodObject<{
|
||||
binding_id: z.ZodNumber;
|
||||
domain_id: z.ZodNumber;
|
||||
service_id: z.ZodNumber;
|
||||
hostname: z.ZodString;
|
||||
cert_monitoring: z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
skipped: "skipped";
|
||||
}>;
|
||||
id: z.ZodNullable<z.ZodNumber>;
|
||||
status: z.ZodString;
|
||||
expires_at: z.ZodNullable<z.ZodString>;
|
||||
last_checked_at: z.ZodNullable<z.ZodString>;
|
||||
last_error: z.ZodNullable<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
type Group = z.infer<typeof groupSchema>;
|
||||
type GroupWithStats = z.infer<typeof groupWithStatsSchema>;
|
||||
type Service = z.infer<typeof serviceSchema>;
|
||||
@@ -1569,12 +1661,13 @@ type Domain = z.infer<typeof domainSchema>;
|
||||
type DomainListItem = z.infer<typeof domainListItemSchema>;
|
||||
type DnsRecord = z.infer<typeof dnsRecordSchema>;
|
||||
type Certificate = z.infer<typeof certificateSchema>;
|
||||
type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>;
|
||||
declare const createGroupSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
slug: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
declare const healthCheckConfigSchema: z.ZodObject<{
|
||||
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_type: z.ZodOptional<z.ZodEnum<{
|
||||
tcp: "tcp";
|
||||
http: "http";
|
||||
@@ -1586,17 +1679,17 @@ declare const healthCheckConfigSchema: z.ZodObject<{
|
||||
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
@@ -1616,7 +1709,7 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
|
||||
lb_weight: z.ZodOptional<z.ZodNumber>;
|
||||
lb_priority: z.ZodOptional<z.ZodNumber>;
|
||||
domains: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_type: z.ZodOptional<z.ZodEnum<{
|
||||
tcp: "tcp";
|
||||
http: "http";
|
||||
@@ -1628,17 +1721,17 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
|
||||
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
@@ -1819,7 +1912,7 @@ declare const updateServiceConfigSchema: z.ZodObject<{
|
||||
lb_weight: z.ZodOptional<z.ZodNumber>;
|
||||
lb_priority: z.ZodOptional<z.ZodNumber>;
|
||||
domains: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_type: z.ZodOptional<z.ZodEnum<{
|
||||
tcp: "tcp";
|
||||
http: "http";
|
||||
@@ -1831,17 +1924,17 @@ declare const updateServiceConfigSchema: z.ZodObject<{
|
||||
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
@@ -1861,7 +1954,7 @@ declare const updateServiceConfigSchema: z.ZodObject<{
|
||||
}, z.core.$strip>;
|
||||
type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>;
|
||||
declare const createServiceGroupSchema: z.ZodObject<{
|
||||
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_type: z.ZodOptional<z.ZodEnum<{
|
||||
tcp: "tcp";
|
||||
http: "http";
|
||||
@@ -1873,17 +1966,17 @@ declare const createServiceGroupSchema: z.ZodObject<{
|
||||
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
@@ -1906,7 +1999,7 @@ declare const createServiceGroupSchema: z.ZodObject<{
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
declare const updateServiceGroupSchema: z.ZodObject<{
|
||||
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_type: z.ZodOptional<z.ZodEnum<{
|
||||
tcp: "tcp";
|
||||
http: "http";
|
||||
@@ -1918,17 +2011,17 @@ declare const updateServiceGroupSchema: z.ZodObject<{
|
||||
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodOptional<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
@@ -2348,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 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, 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
+41
-7
@@ -276,10 +276,16 @@ var nodeHealthStateSchema = z.enum([
|
||||
var healthCheckProviderSchema = z.enum(HEALTH_CHECK_PROVIDERS);
|
||||
var healthStatusProviderSchema = z.enum(HEALTH_STATUS_PROVIDERS);
|
||||
var healthCheckAggregateSchema = z.enum(HEALTH_CHECK_AGGREGATES);
|
||||
var healthCheckProvidersSchema = z.array(healthCheckProviderSchema).min(1).transform((arr) => {
|
||||
const unique = uniqueHealthProviders(arr);
|
||||
return unique.length > 0 ? unique : ["local"];
|
||||
});
|
||||
var healthCheckProvidersSchema = z.preprocess(
|
||||
(value) => {
|
||||
if (value === void 0) return void 0;
|
||||
return Array.isArray(value) ? value : parseHealthProviders(value);
|
||||
},
|
||||
z.array(healthCheckProviderSchema).min(1).transform((arr) => {
|
||||
const unique = uniqueHealthProviders(arr);
|
||||
return unique.length > 0 ? unique : ["local"];
|
||||
})
|
||||
);
|
||||
var healthCheckScopeSchema = z.enum(["binding", "group"]);
|
||||
var ipHealthStatusSchema = z.object({
|
||||
scope: healthCheckScopeSchema,
|
||||
@@ -316,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(),
|
||||
@@ -392,7 +407,9 @@ var serviceDomainBindingSchema = z.object({
|
||||
health_check_provider: healthCheckProviderSchema.catch("local"),
|
||||
health_check_providers: healthCheckProvidersSchema.catch(["local"]),
|
||||
health_check_aggregate: healthCheckAggregateSchema.catch("majority"),
|
||||
sync_status: z.string().nullable().default(null)
|
||||
cert_monitoring: certMonitoringSchema.default("auto"),
|
||||
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] : [],
|
||||
@@ -465,6 +482,7 @@ var serviceBindingSchema = z.object({
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3e3),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
cert_monitoring: certMonitoringSchema.default("auto"),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string()
|
||||
@@ -494,6 +512,8 @@ var certificateSchema = z.object({
|
||||
id: z.number(),
|
||||
domain_id: z.number(),
|
||||
subdomain_id: z.number().nullable(),
|
||||
service_id: z.number().nullable().optional().default(null),
|
||||
service_name: z.string().nullable().optional().default(null),
|
||||
hostname: z.string(),
|
||||
expires_at: z.string().nullable(),
|
||||
last_checked_at: z.string().nullable(),
|
||||
@@ -502,6 +522,18 @@ var certificateSchema = z.object({
|
||||
created_at: z.string(),
|
||||
updated_at: z.string()
|
||||
});
|
||||
var serviceCertificateRowSchema = z.object({
|
||||
binding_id: z.number(),
|
||||
domain_id: z.number(),
|
||||
service_id: z.number(),
|
||||
hostname: z.string(),
|
||||
cert_monitoring: certMonitoringSchema,
|
||||
id: z.number().nullable(),
|
||||
status: z.string(),
|
||||
expires_at: z.string().nullable(),
|
||||
last_checked_at: z.string().nullable(),
|
||||
last_error: z.string().nullable()
|
||||
});
|
||||
var createGroupSchema = z.object({
|
||||
name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435"),
|
||||
slug: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 slug")
|
||||
@@ -512,14 +544,14 @@ var ipv4Schema = z.string().regex(
|
||||
);
|
||||
var nodeAddressSchema = z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 hostname").max(255);
|
||||
var healthCheckConfigFields = {
|
||||
health_check_enabled: z.boolean().optional(),
|
||||
health_check_enabled: z.coerce.boolean().optional(),
|
||||
health_check_type: healthCheckTypeSchema.optional(),
|
||||
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
|
||||
health_check_path: z.string().nullable().optional(),
|
||||
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
|
||||
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||
health_check_timeout_ms: z.number().int().min(100).max(3e4).optional(),
|
||||
health_check_verify_tls: z.boolean().optional(),
|
||||
health_check_verify_tls: z.coerce.boolean().optional(),
|
||||
health_check_provider: healthCheckProviderSchema.optional(),
|
||||
health_check_providers: healthCheckProvidersSchema.optional(),
|
||||
health_check_aggregate: healthCheckAggregateSchema.optional()
|
||||
@@ -997,6 +1029,7 @@ export {
|
||||
domainMonitorSchema,
|
||||
domainMonitorTypeSchema,
|
||||
domainSchema,
|
||||
failoverLogSchema,
|
||||
fqdnToDisplay,
|
||||
groupSchema,
|
||||
groupWithStatsSchema,
|
||||
@@ -1029,6 +1062,7 @@ export {
|
||||
reorderServicesSchema,
|
||||
serializeHealthProviders,
|
||||
serviceBindingSchema,
|
||||
serviceCertificateRowSchema,
|
||||
serviceDomainBindingSchema,
|
||||
serviceGroupSchema,
|
||||
serviceGroupTypeSchema,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
HEALTH_CHECK_AGGREGATES,
|
||||
HEALTH_CHECK_PROVIDERS,
|
||||
HEALTH_STATUS_PROVIDERS,
|
||||
parseHealthProviders,
|
||||
uniqueHealthProviders,
|
||||
} from './health-providers.js'
|
||||
|
||||
@@ -41,13 +42,16 @@ export const healthStatusProviderSchema = z.enum(HEALTH_STATUS_PROVIDERS)
|
||||
|
||||
export const healthCheckAggregateSchema = z.enum(HEALTH_CHECK_AGGREGATES)
|
||||
|
||||
export const healthCheckProvidersSchema = z
|
||||
.array(healthCheckProviderSchema)
|
||||
.min(1)
|
||||
.transform((arr) => {
|
||||
export const healthCheckProvidersSchema = z.preprocess(
|
||||
(value) => {
|
||||
if (value === undefined) return undefined
|
||||
return Array.isArray(value) ? value : parseHealthProviders(value)
|
||||
},
|
||||
z.array(healthCheckProviderSchema).min(1).transform((arr) => {
|
||||
const unique = uniqueHealthProviders(arr)
|
||||
return unique.length > 0 ? unique : (['local'] as const)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const healthCheckScopeSchema = z.enum(['binding', 'group'])
|
||||
export type HealthCheckScope = z.infer<typeof healthCheckScopeSchema>
|
||||
@@ -96,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(),
|
||||
@@ -180,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,
|
||||
@@ -361,14 +378,14 @@ const nodeAddressSchema = z
|
||||
.max(255)
|
||||
|
||||
const healthCheckConfigFields = {
|
||||
health_check_enabled: z.boolean().optional(),
|
||||
health_check_enabled: z.coerce.boolean().optional(),
|
||||
health_check_type: healthCheckTypeSchema.optional(),
|
||||
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
|
||||
health_check_path: z.string().nullable().optional(),
|
||||
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
|
||||
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
|
||||
health_check_verify_tls: z.boolean().optional(),
|
||||
health_check_verify_tls: z.coerce.boolean().optional(),
|
||||
health_check_provider: healthCheckProviderSchema.optional(),
|
||||
health_check_providers: healthCheckProvidersSchema.optional(),
|
||||
health_check_aggregate: healthCheckAggregateSchema.optional(),
|
||||
|
||||
@@ -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