Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634a9dc362 | ||
|
|
1bf6cfa0d0 | ||
|
|
3da6de9311 | ||
|
|
7a8bacade9 | ||
|
|
5d84c7bf6c | ||
|
|
78811bc9b1 | ||
|
|
a458465153 | ||
|
|
69119a08a4 | ||
|
|
ba03d2be9d | ||
|
|
d8fc4ac949 |
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { changeIpSchema } from "@cfdm/shared";
|
||||
import { certMonitoringSchema, changeIpSchema } from "@cfdm/shared";
|
||||
import * as bindingService from "../services/binding-service.js";
|
||||
import * as changeIp from "../services/change-ip-service.js";
|
||||
import { recordAudit } from "../lib/audit.js";
|
||||
@@ -17,6 +17,7 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
|
||||
service_id: z.number().optional(),
|
||||
hostname: z.string().optional(),
|
||||
target_ip: z.string().optional(),
|
||||
cert_monitoring: certMonitoringSchema.optional(),
|
||||
});
|
||||
|
||||
app.get("/service-bindings", async (request) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { repos } from "@cfdm/db";
|
||||
import * as serviceConfig from "../services/service-config-service.js";
|
||||
import * as nodeService from "../services/node-service.js";
|
||||
import * as changeDomain from "../services/change-domain-service.js";
|
||||
import * as certificateService from "../services/certificate-service.js";
|
||||
import { recordAudit } from "../lib/audit.js";
|
||||
|
||||
export async function serviceRoutes(app: FastifyInstance) {
|
||||
@@ -69,10 +70,31 @@ export async function serviceRoutes(app: FastifyInstance) {
|
||||
const { id } = request.params as { id: string };
|
||||
repos.getService(request.server.db, Number(id));
|
||||
return {
|
||||
items: repos.listHealthProbeLogForService(request.server.db, Number(id)),
|
||||
items: repos.listHealthProbeLogForService(
|
||||
request.server.db,
|
||||
Number(id),
|
||||
200,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/services/:id/certificates", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
return certificateService.listServiceCertificates(
|
||||
request.server.db,
|
||||
Number(id),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/services/:id/certificates/check", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const checked = await certificateService.runServiceChecks(
|
||||
request.server.db,
|
||||
Number(id),
|
||||
);
|
||||
return { checked };
|
||||
});
|
||||
|
||||
app.get("/services/:id/overview", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
return nodeService.getOverview(request.server.db, Number(id));
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface UpdateBindingRequest {
|
||||
service_id?: number;
|
||||
hostname?: string;
|
||||
target_ip?: string;
|
||||
cert_monitoring?: string;
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname?: string): string {
|
||||
@@ -92,6 +93,20 @@ export async function update(
|
||||
req: UpdateBindingRequest,
|
||||
): Promise<ServiceBindingView> {
|
||||
const existing = repos.getBinding(db, id);
|
||||
if (req.cert_monitoring !== undefined) {
|
||||
repos.updateBindingLbConfig(db, id, {
|
||||
cert_monitoring: req.cert_monitoring,
|
||||
});
|
||||
}
|
||||
|
||||
const hasIdentityPatch =
|
||||
req.service_id !== undefined ||
|
||||
req.hostname !== undefined ||
|
||||
req.target_ip !== undefined;
|
||||
if (!hasIdentityPatch) {
|
||||
return repos.getBindingView(db, id);
|
||||
}
|
||||
|
||||
const serviceId = req.service_id ?? existing.service_id;
|
||||
if (req.service_id) repos.getService(db, req.service_id);
|
||||
const hostname = req.hostname
|
||||
|
||||
@@ -2,7 +2,7 @@ import { connect } from "node:net";
|
||||
import { connect as tlsConnect } from "node:tls";
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type { Certificate, Domain, Subdomain } from "@cfdm/shared";
|
||||
import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
|
||||
import {
|
||||
CERT_ERROR,
|
||||
CERT_MONITOR_AUTO,
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
CERT_UNKNOWN,
|
||||
certStatusFromExpiry,
|
||||
fqdnToDisplay,
|
||||
parseFqdn,
|
||||
shouldMonitorService,
|
||||
} from "@cfdm/shared";
|
||||
|
||||
export interface CertificateTarget {
|
||||
domainId: number;
|
||||
subdomainId: number | null;
|
||||
serviceId: number;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,34 @@ export function getCertificate(db: Db, id: number): Certificate {
|
||||
return repos.getCertificate(db, id);
|
||||
}
|
||||
|
||||
export function listServiceCertificates(
|
||||
db: Db,
|
||||
serviceId: number,
|
||||
): ServiceCertificateRow[] {
|
||||
repos.getService(db, serviceId);
|
||||
const certsByHost = new Map(
|
||||
repos.listCertificates(db).map((cert) => [cert.hostname, cert]),
|
||||
);
|
||||
return repos.listBindingsByService(db, serviceId).map((binding) => {
|
||||
const hostname = fqdnToDisplay(binding.hostname, binding.zone_name);
|
||||
const cert = certsByHost.get(hostname);
|
||||
return {
|
||||
binding_id: binding.id,
|
||||
domain_id: binding.domain_id,
|
||||
service_id: binding.service_id,
|
||||
hostname,
|
||||
cert_monitoring:
|
||||
(binding.cert_monitoring as ServiceCertificateRow["cert_monitoring"]) ??
|
||||
"auto",
|
||||
id: cert?.id ?? null,
|
||||
status: cert?.status ?? "unknown",
|
||||
expires_at: cert?.expires_at ?? null,
|
||||
last_checked_at: cert?.last_checked_at ?? null,
|
||||
last_error: cert?.last_error ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkHostname(
|
||||
hostname: string,
|
||||
): Promise<{ expiresAt: Date | null; error: string | null }> {
|
||||
@@ -78,6 +106,7 @@ export async function checkAndStore(
|
||||
domainId: number,
|
||||
subdomainId: number | null,
|
||||
hostname: string,
|
||||
serviceId: number | null = null,
|
||||
): Promise<Certificate> {
|
||||
const { expiresAt, error } = await checkHostname(hostname);
|
||||
|
||||
@@ -90,6 +119,7 @@ export async function checkAndStore(
|
||||
null,
|
||||
CERT_ERROR,
|
||||
error,
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,6 +135,7 @@ export async function checkAndStore(
|
||||
expiresAt.toISOString(),
|
||||
certStatusFromExpiry(days),
|
||||
null,
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,23 +147,10 @@ export async function checkAndStore(
|
||||
null,
|
||||
CERT_UNKNOWN,
|
||||
"unknown expiry",
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMonitoringMode(
|
||||
domain: Domain,
|
||||
subdomain: Subdomain | null,
|
||||
fqdn: string,
|
||||
): string {
|
||||
if (subdomain) {
|
||||
return subdomain.cert_monitoring;
|
||||
}
|
||||
if (fqdn === domain.zone_name) {
|
||||
return domain.cert_monitoring;
|
||||
}
|
||||
return CERT_MONITOR_AUTO;
|
||||
}
|
||||
|
||||
function bindingSubdomain(
|
||||
db: Db,
|
||||
domainId: number,
|
||||
@@ -165,10 +183,9 @@ function hasSslHealthGate(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildServiceCertificateFqdns(
|
||||
db: Db,
|
||||
): Map<string, CertificateTarget> {
|
||||
const result = new Map<string, CertificateTarget>();
|
||||
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
|
||||
const targets: CertificateTarget[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const binding of repos.listAllBindings(db)) {
|
||||
const service = repos.getService(db, binding.service_id);
|
||||
@@ -176,98 +193,40 @@ export function buildServiceCertificateFqdns(
|
||||
? repos.getServiceGroup(db, service.service_group_id)
|
||||
: null;
|
||||
if (!shouldMonitorService(service, group)) continue;
|
||||
if (
|
||||
!hasSslHealthGate(
|
||||
{
|
||||
health_check_enabled: binding.health_check_enabled,
|
||||
health_check_verify_tls: binding.health_check_verify_tls,
|
||||
},
|
||||
group,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
|
||||
if (subdomain && !subdomain.enabled) continue;
|
||||
|
||||
const mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO;
|
||||
if (mode === CERT_MONITOR_SKIPPED) continue;
|
||||
if (mode === CERT_MONITOR_AUTO) {
|
||||
if (
|
||||
!hasSslHealthGate(
|
||||
{
|
||||
health_check_enabled: binding.health_check_enabled,
|
||||
health_check_verify_tls: binding.health_check_verify_tls,
|
||||
},
|
||||
group,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
} else if (mode !== CERT_MONITOR_REQUIRED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name);
|
||||
result.set(fqdn, {
|
||||
if (seen.has(fqdn)) continue;
|
||||
seen.add(fqdn);
|
||||
targets.push({
|
||||
domainId: binding.domain_id,
|
||||
subdomainId: subdomain?.id ?? null,
|
||||
serviceId: binding.service_id,
|
||||
hostname: fqdn,
|
||||
});
|
||||
}
|
||||
|
||||
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
|
||||
for (const group of repos.listServiceGroups(db)) {
|
||||
if (!group.enabled || !group.domain?.trim()) continue;
|
||||
if (!group.health_check_enabled || !group.health_check_verify_tls) continue;
|
||||
|
||||
const parsed = parseFqdn(group.domain, knownZones);
|
||||
if (!parsed) continue;
|
||||
|
||||
const domain = repos.findDomainByZoneName(db, parsed.zoneName);
|
||||
if (!domain) continue;
|
||||
|
||||
const subdomain =
|
||||
parsed.hostname === "@"
|
||||
? null
|
||||
: bindingSubdomain(db, domain.id, parsed.hostname);
|
||||
if (subdomain && !subdomain.enabled) continue;
|
||||
|
||||
result.set(parsed.fqdn, {
|
||||
domainId: domain.id,
|
||||
subdomainId: subdomain?.id ?? null,
|
||||
hostname: parsed.fqdn,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
|
||||
const serviceFqdns = buildServiceCertificateFqdns(db);
|
||||
const targets = new Map<string, CertificateTarget>();
|
||||
|
||||
for (const domain of repos.listAllDomains(db)) {
|
||||
if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
|
||||
if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) {
|
||||
targets.set(domain.zone_name, {
|
||||
domainId: domain.id,
|
||||
subdomainId: null,
|
||||
hostname: domain.zone_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const sub of repos.listAllSubdomains(db)) {
|
||||
if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
|
||||
if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) {
|
||||
targets.set(sub.fqdn, {
|
||||
domainId: sub.domain_id,
|
||||
subdomainId: sub.id,
|
||||
hostname: sub.fqdn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [fqdn, meta] of serviceFqdns) {
|
||||
const domain = repos.getDomain(db, meta.domainId);
|
||||
const subdomain = meta.subdomainId
|
||||
? repos.getSubdomain(db, meta.subdomainId)
|
||||
: null;
|
||||
const monitoring = resolveMonitoringMode(domain, subdomain, fqdn);
|
||||
if (monitoring === CERT_MONITOR_SKIPPED) continue;
|
||||
if (
|
||||
monitoring === CERT_MONITOR_AUTO ||
|
||||
monitoring === CERT_MONITOR_REQUIRED
|
||||
) {
|
||||
targets.set(fqdn, meta);
|
||||
}
|
||||
}
|
||||
|
||||
return [...targets.values()];
|
||||
return targets;
|
||||
}
|
||||
|
||||
export async function runAllChecks(db: Db): Promise<number> {
|
||||
@@ -278,6 +237,7 @@ export async function runAllChecks(db: Db): Promise<number> {
|
||||
target.domainId,
|
||||
target.subdomainId,
|
||||
target.hostname,
|
||||
target.serviceId,
|
||||
);
|
||||
}
|
||||
repos.deleteCertificatesNotIn(
|
||||
@@ -287,6 +247,26 @@ export async function runAllChecks(db: Db): Promise<number> {
|
||||
return targets.length;
|
||||
}
|
||||
|
||||
export async function runServiceChecks(
|
||||
db: Db,
|
||||
serviceId: number,
|
||||
): Promise<number> {
|
||||
repos.getService(db, serviceId);
|
||||
const targets = resolveCertificateTargets(db).filter(
|
||||
(target) => target.serviceId === serviceId,
|
||||
);
|
||||
for (const target of targets) {
|
||||
await checkAndStore(
|
||||
db,
|
||||
target.domainId,
|
||||
target.subdomainId,
|
||||
target.hostname,
|
||||
target.serviceId,
|
||||
);
|
||||
}
|
||||
return targets.length;
|
||||
}
|
||||
|
||||
export function statusSummary(db: Db): Array<[string, number]> {
|
||||
pruneStaleCertificates(db);
|
||||
return repos.countCertificatesByStatus(db);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SYNC_PENDING_PUSH,
|
||||
SYNC_SYNCED,
|
||||
dnsRecordNamesMatch,
|
||||
isIpLiteral,
|
||||
normalizeDnsRecordName,
|
||||
} from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||
@@ -308,6 +309,7 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
binding.health_check_provider ?? "local",
|
||||
],
|
||||
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
||||
cert_monitoring: binding.cert_monitoring ?? "auto",
|
||||
sync_status: aggregateSyncStatus(statuses),
|
||||
};
|
||||
});
|
||||
@@ -343,6 +345,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[],
|
||||
@@ -352,11 +412,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),
|
||||
|
||||
@@ -208,7 +208,7 @@ describe("certificates", () => {
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("required apex is monitored without bindings", async () => {
|
||||
it("required binding is monitored without TLS health gate", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
@@ -221,10 +221,18 @@ describe("certificates", () => {
|
||||
"required.example.com",
|
||||
"cf-zone-req",
|
||||
);
|
||||
repos.updateDomain(testApp.db, domain.id, {
|
||||
group_id: null,
|
||||
status: "active",
|
||||
const service = repos.createService(testApp.db, "Req", "req");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
const binding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
service.id,
|
||||
"@",
|
||||
null,
|
||||
);
|
||||
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||
health_check_enabled: false,
|
||||
});
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
@@ -247,7 +255,7 @@ describe("certificates", () => {
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("skipped apex removes stale certificate on check", async () => {
|
||||
it("skipped binding removes stale certificate on check", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
@@ -260,6 +268,20 @@ describe("certificates", () => {
|
||||
"skipped.example.com",
|
||||
"cf-zone-skip",
|
||||
);
|
||||
const service = repos.createService(testApp.db, "Skip", "skip");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
const binding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
service.id,
|
||||
"@",
|
||||
null,
|
||||
);
|
||||
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||
cert_monitoring: CERT_MONITOR_SKIPPED,
|
||||
health_check_enabled: true,
|
||||
health_check_verify_tls: true,
|
||||
});
|
||||
repos.upsertCertificateCheck(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
@@ -268,12 +290,8 @@ describe("certificates", () => {
|
||||
null,
|
||||
CERT_ERROR,
|
||||
"stale",
|
||||
service.id,
|
||||
);
|
||||
repos.updateDomain(testApp.db, domain.id, {
|
||||
group_id: null,
|
||||
status: "active",
|
||||
cert_monitoring: CERT_MONITOR_SKIPPED,
|
||||
});
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
expiresAt: null,
|
||||
@@ -305,9 +323,16 @@ describe("certificates", () => {
|
||||
"broken.example.com",
|
||||
"cf-zone-broken",
|
||||
);
|
||||
repos.updateDomain(testApp.db, domain.id, {
|
||||
group_id: null,
|
||||
status: "active",
|
||||
const service = repos.createService(testApp.db, "Broken", "broken");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
const binding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
service.id,
|
||||
"@",
|
||||
null,
|
||||
);
|
||||
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||
});
|
||||
|
||||
@@ -424,7 +449,7 @@ describe("certificates", () => {
|
||||
});
|
||||
|
||||
const certs = repos.listCertificates(testApp.db);
|
||||
expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(true);
|
||||
expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(false);
|
||||
expect(certs.some((c) => c.hostname === "edge.ok.example.com")).toBe(true);
|
||||
|
||||
await testApp.close();
|
||||
@@ -443,12 +468,7 @@ describe("certificates", () => {
|
||||
"force.example.com",
|
||||
"cf-zone-force",
|
||||
);
|
||||
repos.updateDomain(testApp.db, domain.id, {
|
||||
group_id: null,
|
||||
status: "active",
|
||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||
});
|
||||
repos.createServiceGroup(
|
||||
const group = repos.createServiceGroup(
|
||||
testApp.db,
|
||||
"Proxy",
|
||||
"vpn",
|
||||
@@ -459,6 +479,19 @@ describe("certificates", () => {
|
||||
health_check_verify_tls: false,
|
||||
},
|
||||
);
|
||||
const service = repos.createService(testApp.db, "Force", "force");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
repos.setServiceGroup(testApp.db, service.id, group.id);
|
||||
const binding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
service.id,
|
||||
"@",
|
||||
null,
|
||||
);
|
||||
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||
});
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||
@@ -540,4 +573,137 @@ describe("certificates", () => {
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("GET /services/:id/certificates lists binding FQDNs", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(testApp);
|
||||
|
||||
const domain = repos.createDomain(
|
||||
testApp.db,
|
||||
null,
|
||||
"svc.example.com",
|
||||
"cf-zone-svc",
|
||||
);
|
||||
const service = repos.createService(testApp.db, "Api", "api");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
repos.insertBinding(testApp.db, domain.id, service.id, "www", null);
|
||||
|
||||
const res = await testApp.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/services/${service.id}/certificates`,
|
||||
headers,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const rows = res.json() as Array<{
|
||||
hostname: string;
|
||||
cert_monitoring: string;
|
||||
status: string;
|
||||
}>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.hostname).toBe("www.svc.example.com");
|
||||
expect(rows[0]?.cert_monitoring).toBe("auto");
|
||||
expect(rows[0]?.status).toBe("unknown");
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("PATCH /service-bindings/:id updates cert_monitoring", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(testApp);
|
||||
|
||||
const domain = repos.createDomain(
|
||||
testApp.db,
|
||||
null,
|
||||
"patch.example.com",
|
||||
"cf-zone-patch",
|
||||
);
|
||||
const service = repos.createService(testApp.db, "Patch", "patch");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
const binding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
service.id,
|
||||
"api",
|
||||
null,
|
||||
);
|
||||
|
||||
const res = await testApp.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/service-bindings/${binding.id}`,
|
||||
headers,
|
||||
payload: { cert_monitoring: CERT_MONITOR_REQUIRED },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect((res.json() as { cert_monitoring: string }).cert_monitoring).toBe(
|
||||
CERT_MONITOR_REQUIRED,
|
||||
);
|
||||
expect(repos.getBinding(testApp.db, binding.id).cert_monitoring).toBe(
|
||||
CERT_MONITOR_REQUIRED,
|
||||
);
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("POST /services/:id/certificates/check only checks that service", async () => {
|
||||
const testApp = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(testApp);
|
||||
|
||||
const domain = repos.createDomain(
|
||||
testApp.db,
|
||||
null,
|
||||
"check.example.com",
|
||||
"cf-zone-check",
|
||||
);
|
||||
const service = repos.createService(testApp.db, "One", "one");
|
||||
const other = repos.createService(testApp.db, "Two", "two");
|
||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||
repos.setServiceEnabled(testApp.db, other.id, true);
|
||||
const binding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
service.id,
|
||||
"one",
|
||||
null,
|
||||
);
|
||||
const otherBinding = repos.insertBinding(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
other.id,
|
||||
"two",
|
||||
null,
|
||||
);
|
||||
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||
});
|
||||
repos.updateBindingLbConfig(testApp.db, otherBinding.id, {
|
||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||
});
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||
error: null,
|
||||
});
|
||||
|
||||
const res = await testApp.inject({
|
||||
method: "POST",
|
||||
url: `/api/v1/services/${service.id}/certificates/check`,
|
||||
headers,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect((res.json() as { checked: number }).checked).toBe(1);
|
||||
const certs = repos.listCertificates(testApp.db);
|
||||
expect(certs.some((c) => c.hostname === "one.check.example.com")).toBe(true);
|
||||
expect(certs.some((c) => c.hostname === "two.check.example.com")).toBe(false);
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -235,4 +235,95 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { GlobeIcon, SearchIcon } from 'lucide-react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { GlobeIcon, SearchIcon, ServerIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
@@ -9,6 +10,7 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
|
||||
import type { Certificate } from '@/lib/schemas'
|
||||
import { formatDate, formatRelative } from '@/lib/format'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
export const CERT_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
@@ -41,6 +43,7 @@ export function certTabFilter(item: Certificate, tabId: string) {
|
||||
export function createDefaultCertFilters() {
|
||||
return [
|
||||
createFilter('hostname', 'contains', ['']),
|
||||
createFilter('service', 'contains', ['']),
|
||||
createFilter('status', 'is', ['']),
|
||||
]
|
||||
}
|
||||
@@ -56,6 +59,14 @@ export function useCertFilterFields() {
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по хосту…',
|
||||
},
|
||||
{
|
||||
key: 'service',
|
||||
label: 'Сервис',
|
||||
icon: <ServerIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по сервису…',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Статус',
|
||||
@@ -75,6 +86,8 @@ export function certFilterFieldValue(item: Certificate, field: string) {
|
||||
switch (field) {
|
||||
case 'hostname':
|
||||
return `${item.hostname} ${item.status}`.toLowerCase()
|
||||
case 'service':
|
||||
return (item.service_name ?? '').toLowerCase()
|
||||
case 'status':
|
||||
return item.status
|
||||
default:
|
||||
@@ -82,7 +95,7 @@ export function certFilterFieldValue(item: Certificate, field: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function certRelativeBadge(status: string, expiresAt: string | null) {
|
||||
export function certRelativeBadge(status: string, expiresAt: string | null) {
|
||||
const relative = formatRelative(expiresAt)
|
||||
if (!expiresAt) {
|
||||
return <span className="text-muted-foreground tabular-nums">—</span>
|
||||
@@ -112,6 +125,39 @@ export function useCertificateColumns() {
|
||||
<span className="truncate font-medium">{row.original.hostname}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'service',
|
||||
accessorFn: (row) => row.service_name ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
column={column}
|
||||
title="Сервис"
|
||||
icon={<ServerIcon className="size-3.5" />}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const serviceId = row.original.service_id
|
||||
const name = row.original.service_name
|
||||
if (serviceId == null || !name) {
|
||||
return <span className="text-muted-foreground">—</span>
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: String(serviceId) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -29,15 +29,21 @@ export interface HealthTimelineEvent {
|
||||
|
||||
interface HealthTimelineProps {
|
||||
events: HealthTimelineEvent[]
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
export function HealthTimeline({ events }: HealthTimelineProps) {
|
||||
export function HealthTimeline({
|
||||
events,
|
||||
emptyTitle = 'Нет событий',
|
||||
emptyDescription = 'Результаты проверок появятся после первого прогона',
|
||||
}: HealthTimelineProps) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Link2Icon}
|
||||
title="Нет событий"
|
||||
description="Результаты проверок появятся после первого прогона"
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
centered={false}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -36,6 +36,13 @@ function getBreadcrumbs(
|
||||
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' },
|
||||
|
||||
@@ -12,8 +12,14 @@ import {
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} 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
|
||||
export type HealthAggregate = HealthCheckAggregate
|
||||
@@ -48,7 +54,7 @@ function GlobalpingMark() {
|
||||
)
|
||||
}
|
||||
|
||||
const PROVIDER_ITEMS: Array<{
|
||||
export const HEALTH_PROVIDER_ITEMS: Array<{
|
||||
id: HealthProvider
|
||||
title: string
|
||||
description: string
|
||||
@@ -118,6 +124,7 @@ function ChoicePanel({
|
||||
icon,
|
||||
iconClassName,
|
||||
role,
|
||||
trailing,
|
||||
onActivate,
|
||||
}: {
|
||||
selected: boolean
|
||||
@@ -126,6 +133,7 @@ function ChoicePanel({
|
||||
icon: ReactNode
|
||||
iconClassName?: string
|
||||
role: 'checkbox' | 'radio'
|
||||
trailing?: ReactNode
|
||||
onActivate: () => void
|
||||
}) {
|
||||
return (
|
||||
@@ -157,12 +165,8 @@ function ChoicePanel({
|
||||
<ItemTitle className="w-full min-w-0">{title}</ItemTitle>
|
||||
<ItemDescription>{description}</ItemDescription>
|
||||
</ItemContent>
|
||||
{selected ? (
|
||||
<ItemActions className="shrink-0">
|
||||
<Badge variant="outline" size="sm">
|
||||
Выбрано
|
||||
</Badge>
|
||||
</ItemActions>
|
||||
{trailing ? (
|
||||
<ItemActions className="shrink-0">{trailing}</ItemActions>
|
||||
) : null}
|
||||
</Item>
|
||||
</FramePanel>
|
||||
@@ -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)) {
|
||||
@@ -202,7 +206,7 @@ export function HealthSourceTiles({
|
||||
|
||||
return (
|
||||
<ChoiceFrame>
|
||||
{PROVIDER_ITEMS.map((item) => (
|
||||
{HEALTH_PROVIDER_ITEMS.map((item) => (
|
||||
<ChoicePanel
|
||||
key={item.id}
|
||||
selected={selected.includes(item.id)}
|
||||
@@ -211,6 +215,13 @@ export function HealthSourceTiles({
|
||||
icon={item.icon}
|
||||
iconClassName={item.iconClassName}
|
||||
role="checkbox"
|
||||
trailing={
|
||||
selected.includes(item.id) ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
Выбрано
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
onActivate={() => toggle(item.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -240,9 +251,137 @@ export function HealthAggregateTiles({
|
||||
description={item.description}
|
||||
icon={item.icon}
|
||||
role="radio"
|
||||
trailing={
|
||||
selected === item.id ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
Выбрано
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
onActivate={() => onChange(item.id)}
|
||||
/>
|
||||
))}
|
||||
</ChoiceFrame>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<ToggleGroup
|
||||
multiple
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full min-w-0"
|
||||
value={active}
|
||||
aria-label="Тип пробы"
|
||||
onValueChange={(next) => {
|
||||
const values = next.filter((value): value is HealthProvider =>
|
||||
visible.some((item) => item.id === value),
|
||||
)
|
||||
if (values.length === 0) return
|
||||
onChange(values)
|
||||
}}
|
||||
>
|
||||
{visible.map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
aria-label={item.title}
|
||||
className="min-w-0 flex-1 gap-1.5"
|
||||
>
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className={item.iconClassName}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
<span className="truncate">{item.title}</span>
|
||||
<HealthCheckBadge
|
||||
status={statuses[item.id] ?? 'unknown'}
|
||||
provider={item.id}
|
||||
size="xs"
|
||||
/>
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export function HealthProviderStatusTiles({
|
||||
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 (
|
||||
<ChoiceFrame>
|
||||
{visible.map((item) => (
|
||||
<ChoicePanel
|
||||
key={item.id}
|
||||
selected={active.includes(item.id)}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
icon={item.icon}
|
||||
iconClassName={item.iconClassName}
|
||||
role="checkbox"
|
||||
trailing={
|
||||
<HealthCheckBadge
|
||||
status={statuses[item.id] ?? 'unknown'}
|
||||
provider={item.id}
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
onActivate={() => onChange(toggleProviders(active, item.id))}
|
||||
/>
|
||||
))}
|
||||
</ChoiceFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
|
||||
export { ServiceHealthMonitor } from './service-health-monitor'
|
||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||
@@ -21,6 +23,8 @@ export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
export {
|
||||
HealthSourceTiles,
|
||||
HealthAggregateTiles,
|
||||
HealthProviderStatusTiles,
|
||||
HealthSourceFilterBar,
|
||||
type HealthProvider,
|
||||
type HealthAggregate,
|
||||
} from './health-source-tiles'
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
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'
|
||||
|
||||
/**
|
||||
* Единый блок мониторинга: компактный мультивыбор типа пробы (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,
|
||||
enabledProviders,
|
||||
statuses,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: HealthLogProbe[]
|
||||
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(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}
|
||||
period={period}
|
||||
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
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<HealthTimeline
|
||||
events={changes.map((row) => ({
|
||||
id: row.id,
|
||||
hostname: row.ip,
|
||||
type: row.provider,
|
||||
status: row.status,
|
||||
latency_ms: row.latency_ms,
|
||||
error: row.error,
|
||||
checked_at: row.checked_at,
|
||||
colo: row.colo,
|
||||
provider: row.provider,
|
||||
}))}
|
||||
emptyTitle="Нет смен статуса"
|
||||
emptyDescription="События появятся при переходе up / degraded / down"
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
|
||||
import { Area, AreaChart, XAxis } from 'recharts'
|
||||
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { filterByPeriod, probeTime } from '@/lib/health-log'
|
||||
import { formatDate } from '@/lib/format'
|
||||
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'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
export interface UptimeProbe {
|
||||
id: number
|
||||
status: 'up' | 'down' | 'degraded' | 'unknown'
|
||||
ok: boolean
|
||||
latency_ms: number | null
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
export type UptimePeriodKey = '5D' | '2W' | '1M'
|
||||
|
||||
export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number }[] = [
|
||||
{ key: '5D', label: '5D', days: 5 },
|
||||
{ key: '2W', label: '2W', days: 14 },
|
||||
{ key: '1M', label: '1M', days: 30 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
latency: {
|
||||
label: 'Задержка',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
interface ChartPoint {
|
||||
period: string
|
||||
latency: number
|
||||
ok: boolean
|
||||
at: string
|
||||
status: UptimeProbe['status']
|
||||
}
|
||||
|
||||
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 uptimePercent(points: ChartPoint[]): 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 {
|
||||
if (points.length < 4) return null
|
||||
const mid = Math.floor(points.length / 2)
|
||||
const prev = uptimePercent(points.slice(0, mid))
|
||||
const next = uptimePercent(points.slice(mid))
|
||||
if (prev == null || next == null) return null
|
||||
return next - prev
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function probeUptimePercent(items: UptimeProbe[]): number | null {
|
||||
return uptimePercent(toSeries(items))
|
||||
}
|
||||
|
||||
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)}%`
|
||||
}
|
||||
|
||||
interface UptimeChartProps {
|
||||
items: UptimeProbe[]
|
||||
isLoading?: boolean
|
||||
period?: UptimePeriodKey
|
||||
onPeriodChange?: (period: UptimePeriodKey) => void
|
||||
skipPeriodFilter?: boolean
|
||||
embedded?: boolean
|
||||
/** dashboard-4: chrome живёт в родительском FrameHeader (переключатель серий). */
|
||||
hideHeader?: boolean
|
||||
}
|
||||
|
||||
export function UptimeChart({
|
||||
items,
|
||||
isLoading = false,
|
||||
period: periodProp,
|
||||
onPeriodChange,
|
||||
skipPeriodFilter = false,
|
||||
embedded = false,
|
||||
hideHeader = false,
|
||||
}: UptimeChartProps) {
|
||||
const gradientId = useId().replace(/:/g, '')
|
||||
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
||||
const [tooltipPortal, setTooltipPortal] = useState<HTMLElement | null>(null)
|
||||
const period = periodProp ?? internalPeriod
|
||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||
|
||||
useEffect(() => {
|
||||
setTooltipPortal(document.body)
|
||||
}, [])
|
||||
|
||||
function handlePeriodChange(next: UptimePeriodKey) {
|
||||
onPeriodChange?.(next)
|
||||
if (periodProp == null) setInternalPeriod(next)
|
||||
}
|
||||
|
||||
const points = useMemo(
|
||||
() => toSeries(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 panel = (
|
||||
<FramePanel className="flex flex-col gap-6">
|
||||
{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"
|
||||
>
|
||||
<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" />
|
||||
) : points.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="Нет проб за период"
|
||||
description="Результаты появятся после health-check"
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-foreground text-3xl font-semibold tabular-nums">
|
||||
{formatUptime(uptime)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{delta == null ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
{points.length} проб
|
||||
</Badge>
|
||||
) : (
|
||||
<UptimeDelta delta={delta} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-40 w-full overflow-visible">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-full w-full overflow-visible rounded-b-xl"
|
||||
initialDimension={{ width: 320, height: 160 }}
|
||||
>
|
||||
<AreaChart
|
||||
data={points}
|
||||
margin={{ top: 16, left: 8, right: 8, bottom: 4 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-latency)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-latency)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="period" hide />
|
||||
<ChartTooltip
|
||||
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
portal={tooltipPortal ?? undefined}
|
||||
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, _name, item) => {
|
||||
const point = item.payload as ChartPoint | undefined
|
||||
const ping = Number(value)
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">
|
||||
{point?.ok === false ? 'Down' : 'Пинг'}
|
||||
</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{Number.isFinite(ping) ? `${ping} мс` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="latency"
|
||||
name="latency"
|
||||
type="natural"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke="var(--color-latency)"
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={(dotProps) => {
|
||||
const { cx, cy, payload, index } = dotProps
|
||||
if (cx == null || cy == null) return <g key={index} />
|
||||
const point = payload as ChartPoint | undefined
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={4}
|
||||
fill={
|
||||
point?.ok
|
||||
? 'var(--color-latency)'
|
||||
: 'var(--destructive)'
|
||||
}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={2}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
stroke: 'var(--background)',
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={period}
|
||||
onValueChange={(value) => handlePeriodChange(value as UptimePeriodKey)}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{UPTIME_PERIODS.map((entry) => (
|
||||
<TabsTrigger key={entry.key} value={entry.key} className="flex-1">
|
||||
{entry.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
)
|
||||
|
||||
if (embedded) return panel
|
||||
|
||||
return (
|
||||
<Frame spacing="sm" className="min-w-0 w-full">
|
||||
{panel}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ServiceView,
|
||||
UpdateServiceConfigInput,
|
||||
} from '@/lib/schemas'
|
||||
import { parseHealthProviders } from '@cfdm/shared'
|
||||
import { bindingToFqdn, parseFqdn } from '@/lib/parse-fqdn'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { toast } from 'sonner'
|
||||
@@ -108,19 +109,19 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
target_cname: binding.target_cname ?? '',
|
||||
lb_mode: binding.lb_mode,
|
||||
health: {
|
||||
enabled: binding.health_check_enabled,
|
||||
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: binding.health_check_verify_tls ?? false,
|
||||
verify_tls: Boolean(binding.health_check_verify_tls),
|
||||
provider: binding.health_check_provider ?? 'local',
|
||||
providers:
|
||||
binding.health_check_providers?.length > 0
|
||||
? binding.health_check_providers
|
||||
: [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 ?? {},
|
||||
@@ -232,6 +233,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) {
|
||||
@@ -260,7 +263,7 @@ export function ServiceEditSheet({
|
||||
setLbWeight(1)
|
||||
setLbPriority(1)
|
||||
}
|
||||
}, [open, mode, service, defaultGroupId])
|
||||
}, [open, mode, service?.id, defaultGroupId])
|
||||
|
||||
const zoneHints = useMemo(
|
||||
() => knownDomains.map((domain) => domain.zone_name),
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import {
|
||||
GlobeIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { certRelativeBadge } from '@/components/columns/certificates-columns'
|
||||
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import type { ServiceCertificateRow, ServiceView } from '@/lib/schemas'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
import {
|
||||
certKeys,
|
||||
checkServiceCertificates,
|
||||
patchBindingCertMonitoring,
|
||||
serviceCertificatesQueryOptions,
|
||||
} from '@/queries'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@cfdm/ui/components/toggle-group'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
|
||||
type HealthStatus = 'up' | 'down' | 'degraded' | 'unknown'
|
||||
|
||||
interface ServiceIpRow {
|
||||
id: string
|
||||
ip: string
|
||||
status: HealthStatus
|
||||
enabled: boolean
|
||||
active: boolean
|
||||
weight: number
|
||||
priority: number
|
||||
latency_ms: number | null
|
||||
last_checked_at: string | null
|
||||
last_error: string | null
|
||||
colo: string | null
|
||||
provider: string | null
|
||||
}
|
||||
|
||||
export interface ServiceFqdnRow {
|
||||
id: string
|
||||
fqdn: string
|
||||
zone_name: string
|
||||
target_ips: string[]
|
||||
binding_id: number
|
||||
domain_id: number
|
||||
}
|
||||
|
||||
interface ServiceNodeRow {
|
||||
id: string
|
||||
nodeId: number
|
||||
address: string
|
||||
protocol: string
|
||||
port: number | null
|
||||
health_status: HealthStatus
|
||||
weight: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 'ip', label: 'IP' },
|
||||
{ id: 'fqdn', label: 'FQDN' },
|
||||
{ id: 'nodes', label: 'Ноды' },
|
||||
{ id: 'ssl', label: 'SSL' },
|
||||
] as const
|
||||
|
||||
const HEALTH_OPTIONS = [
|
||||
{ value: 'up', label: 'OK' },
|
||||
{ value: 'degraded', label: 'Slow' },
|
||||
{ value: 'down', label: 'Down' },
|
||||
{ value: 'unknown', label: '—' },
|
||||
]
|
||||
|
||||
function mapNodeHealth(status: string): HealthStatus {
|
||||
if (status === 'healthy' || status === 'up') return 'up'
|
||||
if (status === 'unhealthy' || status === 'down') return 'down'
|
||||
if (status === 'degraded') return 'degraded'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function buildIpRows(service: ServiceView): ServiceIpRow[] {
|
||||
const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row]))
|
||||
const weights = Object.assign(
|
||||
{},
|
||||
...service.domains.map((domain) => domain.target_ip_weights ?? {}),
|
||||
) as Record<string, number>
|
||||
const priorities = Object.assign(
|
||||
{},
|
||||
...service.domains.map((domain) => domain.target_ip_priorities ?? {}),
|
||||
) as Record<string, number>
|
||||
const activeSet = new Set(service.active_ips)
|
||||
|
||||
return service.ips.map((ip) => {
|
||||
const health = healthByIp.get(ip)
|
||||
return {
|
||||
id: ip,
|
||||
ip,
|
||||
status: health?.status ?? 'unknown',
|
||||
enabled: service.ip_enabled[ip] !== false,
|
||||
active: activeSet.has(ip),
|
||||
weight: weights[ip] ?? 1,
|
||||
priority: priorities[ip] ?? 1,
|
||||
latency_ms: health?.latency_ms ?? null,
|
||||
last_checked_at: health?.last_checked_at ?? null,
|
||||
last_error: health?.last_error ?? null,
|
||||
colo: health?.colo ?? null,
|
||||
provider: health?.provider ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildFqdnRows(service: ServiceView): ServiceFqdnRow[] {
|
||||
return service.domains.map((domain) => ({
|
||||
id: String(domain.binding_id),
|
||||
fqdn: domain.fqdn,
|
||||
zone_name: domain.zone_name,
|
||||
target_ips: domain.target_ips ?? [],
|
||||
binding_id: domain.binding_id,
|
||||
domain_id: domain.domain_id,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildNodeRows(
|
||||
nodes: Array<{
|
||||
id: number
|
||||
address: string
|
||||
protocol: string
|
||||
port: number | null
|
||||
health_status: string
|
||||
weight: number
|
||||
priority: number
|
||||
}>,
|
||||
): ServiceNodeRow[] {
|
||||
return nodes.map((node) => ({
|
||||
id: String(node.id),
|
||||
nodeId: node.id,
|
||||
address: node.address,
|
||||
protocol: node.protocol,
|
||||
port: node.port,
|
||||
health_status: mapNodeHealth(node.health_status),
|
||||
weight: node.weight,
|
||||
priority: node.priority,
|
||||
}))
|
||||
}
|
||||
|
||||
function NameCell({
|
||||
icon,
|
||||
label,
|
||||
iconClassName,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
iconClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className={iconClassName ?? 'text-muted-foreground'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</IconTile>
|
||||
<span className="truncate font-mono text-sm">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServiceDetailGridProps {
|
||||
service: ServiceView
|
||||
nodes: Array<{
|
||||
id: number
|
||||
address: string
|
||||
protocol: string
|
||||
port: number | null
|
||||
health_status: string
|
||||
weight: number
|
||||
priority: number
|
||||
}>
|
||||
togglingIp: string | null
|
||||
onToggleIp: (ip: string, enabled: boolean) => void
|
||||
onChangeIp: (row: ServiceFqdnRow) => void
|
||||
onChangeDomain: () => void
|
||||
onAddNode: () => void
|
||||
onDeleteNode: (nodeId: number) => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function ServiceDetailGrid({
|
||||
service,
|
||||
nodes,
|
||||
togglingIp,
|
||||
onToggleIp,
|
||||
onChangeIp,
|
||||
onChangeDomain,
|
||||
onAddNode,
|
||||
onDeleteNode,
|
||||
isLoading = false,
|
||||
}: ServiceDetailGridProps) {
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]['id']>('ip')
|
||||
const [ipFilters, setIpFilters] = useState<Filter[]>(() => [
|
||||
createFilter('ip', 'contains', ['']),
|
||||
createFilter('status', 'is', ['']),
|
||||
])
|
||||
const [fqdnFilters, setFqdnFilters] = useState<Filter[]>(() => [
|
||||
createFilter('fqdn', 'contains', ['']),
|
||||
])
|
||||
const [nodeFilters, setNodeFilters] = useState<Filter[]>(() => [
|
||||
createFilter('address', 'contains', ['']),
|
||||
createFilter('health_status', 'is', ['']),
|
||||
])
|
||||
const [sslFilters, setSslFilters] = useState<Filter[]>(() => [
|
||||
createFilter('hostname', 'contains', ['']),
|
||||
])
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const certQuery = useQuery(serviceCertificatesQueryOptions(service.id))
|
||||
const sslRows = certQuery.data ?? []
|
||||
|
||||
const patchCertMonitoring = useMutation({
|
||||
mutationFn: ({
|
||||
bindingId,
|
||||
mode,
|
||||
}: {
|
||||
bindingId: number
|
||||
mode: CertMonitoring
|
||||
}) => patchBindingCertMonitoring(bindingId, mode),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
|
||||
])
|
||||
toast.success('Режим проверки SSL обновлён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось обновить режим SSL',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const checkSsl = useMutation({
|
||||
mutationFn: () => checkServiceCertificates(service.id),
|
||||
onSuccess: async (result) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
|
||||
])
|
||||
toast.success(
|
||||
result.checked > 0
|
||||
? `Проверено FQDN: ${result.checked}`
|
||||
: 'Нет FQDN для проверки SSL',
|
||||
)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось проверить SSL',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const ipRows = useMemo(() => buildIpRows(service), [service])
|
||||
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
|
||||
const nodeRows = useMemo(() => buildNodeRows(nodes), [nodes])
|
||||
const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted'
|
||||
|
||||
const tabs = TABS.map((entry) => ({
|
||||
...entry,
|
||||
count:
|
||||
entry.id === 'ip'
|
||||
? ipRows.length
|
||||
: entry.id === 'fqdn'
|
||||
? fqdnRows.length
|
||||
: entry.id === 'ssl'
|
||||
? sslRows.length
|
||||
: nodeRows.length,
|
||||
}))
|
||||
|
||||
const ipFilterFields = useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'ip',
|
||||
label: 'IP',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по IP…',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Статус',
|
||||
type: 'select',
|
||||
searchable: true,
|
||||
className: 'w-[168px]',
|
||||
options: HEALTH_OPTIONS,
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const fqdnFilterFields = useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'fqdn',
|
||||
label: 'FQDN',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по FQDN…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const nodeFilterFields = useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'address',
|
||||
label: 'Адрес',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по адресу…',
|
||||
},
|
||||
{
|
||||
key: 'health_status',
|
||||
label: 'Статус',
|
||||
type: 'select',
|
||||
searchable: true,
|
||||
className: 'w-[168px]',
|
||||
options: HEALTH_OPTIONS,
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const sslFilterFields = useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'hostname',
|
||||
label: 'FQDN',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по FQDN…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const ipColumns = useMemo<ColumnDef<ServiceIpRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'ip',
|
||||
accessorKey: 'ip',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="IP" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<NameCell
|
||||
icon={<NetworkIcon />}
|
||||
label={row.original.ip}
|
||||
iconClassName="text-info"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: 'Health',
|
||||
cell: ({ row }) => (
|
||||
<HealthCheckBadge
|
||||
status={row.original.status}
|
||||
latencyMs={row.original.latency_ms}
|
||||
lastCheckedAt={row.original.last_checked_at}
|
||||
lastError={row.original.last_error}
|
||||
colo={row.original.colo}
|
||||
provider={row.original.provider}
|
||||
size="xs"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
header: 'Пул',
|
||||
cell: ({ row }) =>
|
||||
markActive && row.original.active ? (
|
||||
<StatusBadge status="active" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
accessorKey: 'weight',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Вес" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{service.lb_mode === 'weighted' ? `w${row.original.weight}` : row.original.weight}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
accessorKey: 'priority',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Приоритет" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.priority}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
header: 'Вкл',
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={row.original.enabled}
|
||||
disabled={togglingIp === row.original.ip}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleIp(row.original.ip, Boolean(checked))
|
||||
}
|
||||
aria-label={
|
||||
row.original.enabled
|
||||
? `Выключить IP ${row.original.ip}`
|
||||
: `Включить IP ${row.original.ip}`
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[markActive, onToggleIp, service.lb_mode, togglingIp],
|
||||
)
|
||||
|
||||
const fqdnColumns = useMemo<ColumnDef<ServiceFqdnRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'fqdn',
|
||||
accessorKey: 'fqdn',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="FQDN" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<NameCell
|
||||
icon={<GlobeIcon />}
|
||||
label={row.original.fqdn}
|
||||
iconClassName="text-foreground"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'zone',
|
||||
accessorKey: 'zone_name',
|
||||
header: 'Зона',
|
||||
},
|
||||
{
|
||||
id: 'ips',
|
||||
header: 'Target IP',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{row.original.target_ips.join(', ') || '—'}
|
||||
</span>
|
||||
<Badge variant="outline" size="xs">
|
||||
{row.original.target_ips.length} IP
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChangeIp(row.original)}
|
||||
>
|
||||
Сменить IP
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onChangeIp],
|
||||
)
|
||||
|
||||
const nodeColumns = useMemo<ColumnDef<ServiceNodeRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'address',
|
||||
accessorKey: 'address',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Адрес" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<NameCell
|
||||
icon={<ServerIcon />}
|
||||
label={row.original.address}
|
||||
iconClassName="text-foreground"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
accessorKey: 'health_status',
|
||||
header: 'Health',
|
||||
cell: ({ row }) => (
|
||||
<HealthCheckBadge status={row.original.health_status} size="xs" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'meta',
|
||||
header: 'Вес / приоритет',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{row.original.protocol}
|
||||
{row.original.port ? `:${row.original.port}` : ''} · w
|
||||
{row.original.weight} · p{row.original.priority}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onDeleteNode(row.original.nodeId)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onDeleteNode],
|
||||
)
|
||||
|
||||
const sslColumns = useMemo<ColumnDef<ServiceCertificateRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'hostname',
|
||||
accessorKey: 'hostname',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="FQDN" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<NameCell
|
||||
icon={<ShieldCheckIcon />}
|
||||
label={row.original.hostname}
|
||||
iconClassName="text-foreground"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'expires_at',
|
||||
accessorKey: 'expires_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Истекает" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDate(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'relative',
|
||||
header: 'Срок',
|
||||
cell: ({ row }) =>
|
||||
certRelativeBadge(row.original.status, row.original.expires_at),
|
||||
},
|
||||
{
|
||||
id: 'mode',
|
||||
header: 'Режим',
|
||||
cell: ({ row }) => (
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
size="sm"
|
||||
value={[row.original.cert_monitoring]}
|
||||
onValueChange={(next) => {
|
||||
const value = Array.isArray(next) ? next[0] : next
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value === row.original.cert_monitoring
|
||||
) {
|
||||
return
|
||||
}
|
||||
patchCertMonitoring.mutate({
|
||||
bindingId: row.original.binding_id,
|
||||
mode: value as CertMonitoring,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{certMonitoringOptions.map((option) => (
|
||||
<ToggleGroupItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'last_checked_at',
|
||||
accessorKey: 'last_checked_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Проверка" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDate(row.original.last_checked_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[patchCertMonitoring],
|
||||
)
|
||||
|
||||
const sharedTabs = {
|
||||
tabs,
|
||||
activeTab: tab,
|
||||
onTabChange: (id: string) => setTab(id as typeof tab),
|
||||
}
|
||||
|
||||
if (tab === 'fqdn') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN и ноды этого сервиса"
|
||||
{...sharedTabs}
|
||||
filterFields={fqdnFilterFields}
|
||||
filters={fqdnFilters}
|
||||
onFiltersChange={setFqdnFilters}
|
||||
onClearFilters={() => setFqdnFilters([createFilter('fqdn', 'contains', [''])])}
|
||||
getFilterFieldValue={(item, field) =>
|
||||
field === 'fqdn' ? `${item.fqdn} ${item.zone_name}` : ''
|
||||
}
|
||||
columns={fqdnColumns}
|
||||
data={fqdnRows}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
primaryAction={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onChangeDomain}
|
||||
disabled={fqdnRows.length === 0}
|
||||
>
|
||||
Сменить домен
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab === 'nodes') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN и ноды этого сервиса"
|
||||
{...sharedTabs}
|
||||
filterFields={nodeFilterFields}
|
||||
filters={nodeFilters}
|
||||
onFiltersChange={setNodeFilters}
|
||||
onClearFilters={() =>
|
||||
setNodeFilters([
|
||||
createFilter('address', 'contains', ['']),
|
||||
createFilter('health_status', 'is', ['']),
|
||||
])
|
||||
}
|
||||
getFilterFieldValue={(item, field) => {
|
||||
if (field === 'address') return item.address
|
||||
if (field === 'health_status') return item.health_status
|
||||
return ''
|
||||
}}
|
||||
columns={nodeColumns}
|
||||
data={nodeRows}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
primaryAction={
|
||||
<Button size="sm" onClick={onAddNode}>
|
||||
<PlusIcon className="size-4" aria-hidden />
|
||||
Добавить ноду
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab === 'ssl') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN, ноды и SSL этого сервиса"
|
||||
{...sharedTabs}
|
||||
filterFields={sslFilterFields}
|
||||
filters={sslFilters}
|
||||
onFiltersChange={setSslFilters}
|
||||
onClearFilters={() =>
|
||||
setSslFilters([createFilter('hostname', 'contains', [''])])
|
||||
}
|
||||
getFilterFieldValue={(item, field) =>
|
||||
field === 'hostname' ? item.hostname : ''
|
||||
}
|
||||
columns={sslColumns}
|
||||
data={sslRows}
|
||||
getRowId={(row) => String(row.binding_id)}
|
||||
isLoading={isLoading || certQuery.isLoading}
|
||||
primaryAction={
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Проверить SSL"
|
||||
disabled={checkSsl.isPending || sslRows.length === 0}
|
||||
onClick={() => checkSsl.mutate()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ShieldCheckIcon className="size-4.5" aria-hidden />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Проверить</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
emptyState={{
|
||||
title: 'Нет FQDN',
|
||||
description: 'Привяжите домен к сервису, чтобы мониторить SSL.',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN и ноды этого сервиса"
|
||||
{...sharedTabs}
|
||||
filterFields={ipFilterFields}
|
||||
filters={ipFilters}
|
||||
onFiltersChange={setIpFilters}
|
||||
onClearFilters={() =>
|
||||
setIpFilters([
|
||||
createFilter('ip', 'contains', ['']),
|
||||
createFilter('status', 'is', ['']),
|
||||
])
|
||||
}
|
||||
getFilterFieldValue={(item, field) => {
|
||||
if (field === 'ip') return item.ip
|
||||
if (field === 'status') return item.status
|
||||
return ''
|
||||
}}
|
||||
columns={ipColumns}
|
||||
data={ipRows}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import { cn } from '@cfdm/ui/lib/utils'
|
||||
* Preview: https://reui.io/preview/base/settings-8
|
||||
* Frame: https://reui.io/docs/components/base/frame
|
||||
* IconTile: https://reui.io/docs/components/base/icon-tile
|
||||
* Header fill: FramePanel `bg-muted` (overrides `--frame-panel-bg`; see frame.tsx).
|
||||
*/
|
||||
|
||||
type LbMode = ServiceView['lb_mode']
|
||||
@@ -74,7 +75,7 @@ const LB_MODE_META: Record<
|
||||
},
|
||||
}
|
||||
|
||||
function LbModeTile({ mode }: { mode: LbMode }) {
|
||||
export function LbModeTile({ mode }: { mode: LbMode }) {
|
||||
const meta = LB_MODE_META[mode]
|
||||
const Icon = meta.icon
|
||||
|
||||
@@ -124,7 +125,7 @@ export function ServiceUnitCard({
|
||||
|
||||
return (
|
||||
<Frame stacked spacing="sm" className="h-full min-w-0">
|
||||
<FramePanel fit>
|
||||
<FramePanel fit className="bg-muted">
|
||||
<Item size="sm" className="w-full min-w-0 flex-nowrap border-0 p-0">
|
||||
<ItemMedia>
|
||||
<IconTile
|
||||
|
||||
@@ -3,10 +3,8 @@ import { useEffect, useMemo } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
|
||||
import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
|
||||
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||
import { formatServiceGroupLabel } from '@/lib/service-utils'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
@@ -30,7 +28,6 @@ import {
|
||||
const subdomainEditSchema = z.object({
|
||||
name: z.string().min(1, 'Укажите имя'),
|
||||
serviceId: z.string(),
|
||||
certMonitoring: z.enum(['auto', 'required', 'skipped']),
|
||||
})
|
||||
|
||||
export type SubdomainEditValues = z.infer<typeof subdomainEditSchema>
|
||||
@@ -67,7 +64,6 @@ export function SubdomainEditSheet({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
serviceId: 'none',
|
||||
certMonitoring: 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -85,42 +81,27 @@ export function SubdomainEditSheet({
|
||||
[services, serviceGroupById],
|
||||
)
|
||||
|
||||
const certMonitoringItems = useMemo(
|
||||
() =>
|
||||
certMonitoringOptions.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
})),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (mode === 'edit' && subdomain) {
|
||||
form.reset({
|
||||
name: subdomain.name,
|
||||
serviceId: currentServiceId || 'none',
|
||||
certMonitoring: subdomain.cert_monitoring,
|
||||
})
|
||||
return
|
||||
}
|
||||
form.reset({
|
||||
name: '',
|
||||
serviceId: 'none',
|
||||
certMonitoring: 'auto',
|
||||
})
|
||||
}, [open, mode, subdomain, currentServiceId, form])
|
||||
|
||||
const certMonitoring = form.watch('certMonitoring')
|
||||
const certHint =
|
||||
certMonitoringOptions.find((o) => o.value === certMonitoring)?.description
|
||||
const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1
|
||||
|
||||
function handleSubmit(values: SubdomainEditValues) {
|
||||
onSubmit({
|
||||
name: values.name.trim(),
|
||||
serviceId: values.serviceId,
|
||||
certMonitoring: values.certMonitoring as CertMonitoring,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,39 +199,6 @@ export function SubdomainEditSheet({
|
||||
)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Мониторинг SSL"
|
||||
htmlFor="subdomain_cert_monitoring"
|
||||
hint={certHint}
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="certMonitoring"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={certMonitoringItems}
|
||||
value={field.value}
|
||||
onValueChange={(value) =>
|
||||
field.onChange((value ?? 'auto') as CertMonitoring)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="subdomain_cert_monitoring"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{certMonitoringOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
serviceGroupsQueryOptions,
|
||||
servicesQueryOptions,
|
||||
subdomainsListQueryOptions,
|
||||
updateDomain,
|
||||
updateSubdomain,
|
||||
} from '@/queries'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
@@ -172,21 +171,6 @@ export function useDomainPage(domainId: number) {
|
||||
},
|
||||
})
|
||||
|
||||
const updateDomainCertMonitoringMutation = useMutation({
|
||||
mutationFn: (certMonitoring: CertMonitoring) =>
|
||||
updateDomain(domainId, { cert_monitoring: certMonitoring }),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
void queryClient.invalidateQueries({ queryKey: ['domains'] })
|
||||
toast.success('Режим мониторинга SSL обновлён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось обновить мониторинг SSL',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteSubdomainMutation = useMutation({
|
||||
mutationFn: (id: number) => deleteSubdomain(id),
|
||||
onSuccess: () => {
|
||||
@@ -249,7 +233,6 @@ export function useDomainPage(domainId: number) {
|
||||
syncMutation,
|
||||
createSubdomainMutation,
|
||||
updateSubdomainMutation,
|
||||
updateDomainCertMonitoringMutation,
|
||||
deleteSubdomainMutation,
|
||||
linkServiceMutation,
|
||||
}
|
||||
|
||||
@@ -8,17 +8,17 @@ export const certMonitoringOptions: Array<{
|
||||
{
|
||||
value: 'auto',
|
||||
label: 'Авто',
|
||||
description: 'Проверять, если хост обслуживается активным сервисом',
|
||||
description: 'Проверять, если health-check сервиса с verify TLS',
|
||||
},
|
||||
{
|
||||
value: 'required',
|
||||
label: 'Обязательно',
|
||||
description: 'Всегда проверять SSL, даже без привязок',
|
||||
description: 'Всегда проверять SSL для этого FQDN',
|
||||
},
|
||||
{
|
||||
value: 'skipped',
|
||||
label: 'Не проверять',
|
||||
description: 'Исключить из мониторинга сертификатов',
|
||||
description: 'Исключить FQDN из мониторинга сертификатов',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
collapseStatusChanges,
|
||||
enabledHealthProviders,
|
||||
providerHealthStatuses,
|
||||
worstHealthStatus,
|
||||
type HealthLogProbe,
|
||||
} from '@/lib/health-log'
|
||||
|
||||
function probe(
|
||||
overrides: Partial<HealthLogProbe> & Pick<HealthLogProbe, 'id' | 'status' | 'checked_at'>,
|
||||
): HealthLogProbe {
|
||||
return {
|
||||
ip: '1.1.1.1',
|
||||
provider: 'local',
|
||||
ok: overrides.status === 'up',
|
||||
latency_ms: 12,
|
||||
colo: null,
|
||||
error: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('collapseStatusChanges', () => {
|
||||
it('keeps only status transitions per ip+provider', () => {
|
||||
const items = [
|
||||
probe({ id: 1, status: 'up', checked_at: '2026-01-01T00:00:00Z' }),
|
||||
probe({ id: 2, status: 'up', checked_at: '2026-01-01T00:01:00Z' }),
|
||||
probe({ id: 3, status: 'down', checked_at: '2026-01-01T00:02:00Z' }),
|
||||
probe({ id: 4, status: 'down', checked_at: '2026-01-01T00:03:00Z' }),
|
||||
probe({ id: 5, status: 'up', checked_at: '2026-01-01T00:04:00Z' }),
|
||||
]
|
||||
const changes = collapseStatusChanges(items)
|
||||
expect(changes.map((item) => item.id)).toEqual([5, 3, 1])
|
||||
})
|
||||
|
||||
it('tracks series independently by provider', () => {
|
||||
const items = [
|
||||
probe({ id: 1, provider: 'local', status: 'up', checked_at: '2026-01-01T00:00:00Z' }),
|
||||
probe({
|
||||
id: 2,
|
||||
provider: 'cloudflare',
|
||||
status: 'up',
|
||||
checked_at: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
probe({ id: 3, provider: 'local', status: 'up', checked_at: '2026-01-01T00:01:00Z' }),
|
||||
probe({
|
||||
id: 4,
|
||||
provider: 'cloudflare',
|
||||
status: 'down',
|
||||
checked_at: '2026-01-01T00:01:00Z',
|
||||
}),
|
||||
]
|
||||
const changes = collapseStatusChanges(items)
|
||||
expect(changes.map((item) => item.id).sort()).toEqual([1, 2, 4])
|
||||
})
|
||||
})
|
||||
|
||||
describe('enabledHealthProviders', () => {
|
||||
it('unions bindings in registry order', () => {
|
||||
expect(
|
||||
enabledHealthProviders([
|
||||
{ health_check_providers: ['globalping'] },
|
||||
{ health_check_providers: ['local', 'cloudflare'] },
|
||||
]),
|
||||
).toEqual(['local', 'cloudflare', 'globalping'])
|
||||
})
|
||||
|
||||
it('falls back to local', () => {
|
||||
expect(enabledHealthProviders([])).toEqual(['local'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerHealthStatuses', () => {
|
||||
it('uses worst latest-per-ip status', () => {
|
||||
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' }),
|
||||
probe({
|
||||
id: 3,
|
||||
ip: '2.2.2.2',
|
||||
status: 'up',
|
||||
checked_at: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
]
|
||||
expect(providerHealthStatuses(items, ['local']).local).toBe('down')
|
||||
})
|
||||
})
|
||||
|
||||
describe('worstHealthStatus', () => {
|
||||
it('ranks down over degraded over up', () => {
|
||||
expect(worstHealthStatus(['up', 'degraded'])).toBe('degraded')
|
||||
expect(worstHealthStatus(['degraded', 'down'])).toBe('down')
|
||||
expect(worstHealthStatus([])).toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||
import { HEALTH_CHECK_PROVIDERS, uniqueHealthProviders } from '@cfdm/shared'
|
||||
|
||||
import { sqliteUtcToIso } from '@/lib/format'
|
||||
import type { IpHealthStatus } from '@/lib/schemas'
|
||||
|
||||
export type HealthLogStatus = IpHealthStatus['status']
|
||||
|
||||
export interface HealthLogProbe {
|
||||
id: number
|
||||
ip: string
|
||||
provider: HealthCheckProvider
|
||||
status: HealthLogStatus
|
||||
ok: boolean
|
||||
latency_ms: number | null
|
||||
colo: string | null
|
||||
error: string | null
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<HealthLogStatus, number> = {
|
||||
unknown: 0,
|
||||
up: 1,
|
||||
degraded: 2,
|
||||
down: 3,
|
||||
}
|
||||
|
||||
export function probeTime(checkedAt: string): number {
|
||||
const iso = sqliteUtcToIso(checkedAt) ?? checkedAt
|
||||
const time = new Date(iso).getTime()
|
||||
return Number.isNaN(time) ? 0 : time
|
||||
}
|
||||
|
||||
export function filterByPeriod<T extends { checked_at: string }>(
|
||||
items: T[],
|
||||
days: number,
|
||||
): T[] {
|
||||
const cutoff = Date.now() - days * 86_400_000
|
||||
return items.filter((item) => probeTime(item.checked_at) >= cutoff)
|
||||
}
|
||||
|
||||
export function filterByProviders<T extends { provider: string }>(
|
||||
items: T[],
|
||||
providers: readonly HealthCheckProvider[],
|
||||
): T[] {
|
||||
if (providers.length === 0) return items
|
||||
const allowed = new Set(providers)
|
||||
return items.filter((item) => allowed.has(item.provider as HealthCheckProvider))
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the first probe of each ip+provider series and every later probe
|
||||
* whose status differs from the previous one. Newest first.
|
||||
*/
|
||||
export function collapseStatusChanges<T extends HealthLogProbe>(items: T[]): T[] {
|
||||
const byKey = new Map<string, T[]>()
|
||||
for (const item of items) {
|
||||
const key = `${item.ip}\0${item.provider}`
|
||||
const list = byKey.get(key)
|
||||
if (list) list.push(item)
|
||||
else byKey.set(key, [item])
|
||||
}
|
||||
|
||||
const changes: T[] = []
|
||||
for (const list of byKey.values()) {
|
||||
list.sort(
|
||||
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
|
||||
)
|
||||
let previous: HealthLogStatus | undefined
|
||||
for (const item of list) {
|
||||
if (item.status !== previous) {
|
||||
changes.push(item)
|
||||
previous = item.status
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changes.sort(
|
||||
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
|
||||
)
|
||||
return changes
|
||||
}
|
||||
|
||||
export function enabledHealthProviders(
|
||||
domains: Array<{ health_check_providers?: readonly HealthCheckProvider[] | null }>,
|
||||
): HealthCheckProvider[] {
|
||||
const collected = uniqueHealthProviders(
|
||||
domains.flatMap((domain) => domain.health_check_providers ?? []),
|
||||
)
|
||||
if (collected.length === 0) return ['local']
|
||||
return HEALTH_CHECK_PROVIDERS.filter((provider) => collected.includes(provider))
|
||||
}
|
||||
|
||||
export function worstHealthStatus(statuses: readonly HealthLogStatus[]): HealthLogStatus {
|
||||
if (statuses.length === 0) return 'unknown'
|
||||
return statuses.reduce((worst, status) =>
|
||||
STATUS_RANK[status] > STATUS_RANK[worst] ? status : worst,
|
||||
)
|
||||
}
|
||||
|
||||
/** Latest probe per IP for a provider, then worst among those IPs. */
|
||||
export function providerHealthStatuses(
|
||||
items: readonly HealthLogProbe[],
|
||||
providers: readonly HealthCheckProvider[],
|
||||
): Record<HealthCheckProvider, HealthLogStatus> {
|
||||
const latestByIp = new Map<string, HealthLogProbe>()
|
||||
const sorted = [...items].sort(
|
||||
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
|
||||
)
|
||||
for (const item of sorted) {
|
||||
const key = `${item.provider}\0${item.ip}`
|
||||
if (!latestByIp.has(key)) latestByIp.set(key, item)
|
||||
}
|
||||
|
||||
const result = Object.fromEntries(
|
||||
HEALTH_CHECK_PROVIDERS.map((provider) => [provider, 'unknown' as HealthLogStatus]),
|
||||
) as Record<HealthCheckProvider, HealthLogStatus>
|
||||
|
||||
for (const provider of providers) {
|
||||
const statuses = [...latestByIp.values()]
|
||||
.filter((item) => item.provider === provider)
|
||||
.map((item) => item.status)
|
||||
result[provider] = worstHealthStatus(statuses)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -82,6 +82,7 @@ export const serviceDomainBindingSchema = z
|
||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||
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),
|
||||
})
|
||||
.transform((binding) => ({
|
||||
@@ -197,6 +198,7 @@ export const serviceBindingSchema = z
|
||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||
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),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
@@ -234,6 +236,8 @@ export const 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(),
|
||||
@@ -243,6 +247,19 @@ export const certificateSchema = z.object({
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const serviceCertificateRowSchema = z.object({
|
||||
binding_id: z.number(),
|
||||
domain_id: z.number(),
|
||||
service_id: z.number(),
|
||||
hostname: z.string(),
|
||||
cert_monitoring: z.enum(['auto', 'required', 'skipped']),
|
||||
id: z.number().nullable(),
|
||||
status: z.string(),
|
||||
expires_at: z.string().nullable(),
|
||||
last_checked_at: z.string().nullable(),
|
||||
last_error: z.string().nullable(),
|
||||
})
|
||||
|
||||
export type Group = z.infer<typeof groupSchema>
|
||||
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
||||
export type Service = z.infer<typeof serviceSchema>
|
||||
@@ -256,6 +273,7 @@ export type DomainListItem = z.infer<typeof domainListItemSchema>
|
||||
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
|
||||
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
||||
export type Certificate = z.infer<typeof certificateSchema>
|
||||
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
|
||||
|
||||
export const createGroupSchema = z.object({
|
||||
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
|
||||
@@ -273,14 +291,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(),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { certificateSchema } from '@/lib/schemas'
|
||||
import { certificateSchema, serviceCertificateRowSchema } from '@/lib/schemas'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const certKeys = {
|
||||
all: ['certificates'] as const,
|
||||
summary: ['certificates', 'summary'] as const,
|
||||
byService: (serviceId: number) =>
|
||||
[...certKeys.all, 'service', serviceId] as const,
|
||||
}
|
||||
|
||||
export const certificatesQueryOptions = () =>
|
||||
@@ -23,3 +26,29 @@ export const certSummaryQueryOptions = () =>
|
||||
queryKey: certKeys.summary,
|
||||
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'),
|
||||
})
|
||||
|
||||
export const serviceCertificatesQueryOptions = (serviceId: number) =>
|
||||
queryOptions({
|
||||
queryKey: certKeys.byService(serviceId),
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown[]>(
|
||||
`/api/v1/services/${serviceId}/certificates`,
|
||||
)
|
||||
return z.array(serviceCertificateRowSchema).parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export async function patchBindingCertMonitoring(
|
||||
bindingId: number,
|
||||
certMonitoring: CertMonitoring,
|
||||
) {
|
||||
return api.patch(`/api/v1/service-bindings/${bindingId}`, {
|
||||
cert_monitoring: certMonitoring,
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkServiceCertificates(serviceId: number) {
|
||||
return api.post<{ checked: number }>(
|
||||
`/api/v1/services/${serviceId}/certificates/check`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ function CertificatesPage() {
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Сертификаты"
|
||||
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
|
||||
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
|
||||
actions={primaryAction}
|
||||
/>
|
||||
<CertKpiStats
|
||||
@@ -86,7 +86,7 @@ function CertificatesPage() {
|
||||
/>
|
||||
<ResourcePage
|
||||
title="Сертификаты"
|
||||
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
|
||||
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
|
||||
hideHeader
|
||||
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
|
||||
activeTab={activeTab}
|
||||
|
||||
@@ -6,11 +6,9 @@ import {
|
||||
GlobeIcon,
|
||||
Link2Icon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
import {
|
||||
domainDetailQueryOptions,
|
||||
domainServiceBindingsQueryOptions,
|
||||
@@ -41,19 +39,11 @@ import {
|
||||
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
|
||||
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { TabsContent } from '@cfdm/ui/components/tabs'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
@@ -108,7 +98,6 @@ function DomainOverviewPage() {
|
||||
syncMutation,
|
||||
createSubdomainMutation,
|
||||
updateSubdomainMutation,
|
||||
updateDomainCertMonitoringMutation,
|
||||
deleteSubdomainMutation,
|
||||
linkServiceMutation,
|
||||
} = useDomainPage(id)
|
||||
@@ -167,20 +156,15 @@ function DomainOverviewPage() {
|
||||
if (!editTarget) return
|
||||
|
||||
const nameChanged = values.name !== editTarget.subdomain.name
|
||||
const certMonitoringChanged =
|
||||
values.certMonitoring !== editTarget.subdomain.cert_monitoring
|
||||
const currentServiceId = resolveServiceId(editTarget)
|
||||
const serviceChanged = values.serviceId !== currentServiceId
|
||||
const targetServiceId =
|
||||
values.serviceId === 'none' ? null : Number(values.serviceId)
|
||||
|
||||
if (nameChanged || certMonitoringChanged) {
|
||||
if (nameChanged) {
|
||||
await updateSubdomainMutation.mutateAsync({
|
||||
id: editTarget.subdomain.id,
|
||||
...(nameChanged ? { name: values.name } : {}),
|
||||
...(certMonitoringChanged
|
||||
? { cert_monitoring: values.certMonitoring }
|
||||
: {}),
|
||||
name: values.name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -209,11 +193,6 @@ function DomainOverviewPage() {
|
||||
updateSubdomainMutation.isPending ||
|
||||
linkServiceMutation.isPending
|
||||
|
||||
const certMonitoringItems = certMonitoringOptions.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
}))
|
||||
|
||||
const metricCards = useMemo(() => {
|
||||
if (!domain) return []
|
||||
return [
|
||||
@@ -344,40 +323,6 @@ function DomainOverviewPage() {
|
||||
>
|
||||
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||
<DetailPanel.Metrics cards={metricCards} />
|
||||
<DetailPanel.Section
|
||||
title="Мониторинг SSL"
|
||||
description="Настройка проверки сертификата для apex-зоны"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
|
||||
<span className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<ShieldCheckIcon className="size-4" aria-hidden="true" />
|
||||
Мониторинг SSL (apex):
|
||||
</span>
|
||||
<Select
|
||||
items={certMonitoringItems}
|
||||
value={domain.cert_monitoring}
|
||||
onValueChange={(value) =>
|
||||
updateDomainCertMonitoringMutation.mutate(
|
||||
(value ?? 'auto') as CertMonitoring,
|
||||
)
|
||||
}
|
||||
disabled={updateDomainCertMonitoringMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-56">
|
||||
<SelectValue>
|
||||
{certMonitoringLabel(domain.cert_monitoring)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{certMonitoringOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="availability" className="flex flex-col gap-4">
|
||||
|
||||
@@ -1,104 +1,11 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
|
||||
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import {
|
||||
serviceHealthLogQueryOptions,
|
||||
serviceViewQueryOptions,
|
||||
} from '@/queries'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
||||
component: ServiceHealthPage,
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/services/$serviceId',
|
||||
params,
|
||||
})
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
|
||||
export function ServiceHealthPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const serviceQuery = useQuery(serviceViewQueryOptions(id))
|
||||
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||
const service = serviceQuery.data
|
||||
const items = logQuery.data?.items ?? []
|
||||
const ipHealth = service?.ip_health ?? []
|
||||
|
||||
const kpiCards = ipHealth.map((row) => {
|
||||
const variant =
|
||||
row.status === 'down'
|
||||
? ('destructive' as const)
|
||||
: row.status === 'degraded'
|
||||
? ('warning' as const)
|
||||
: ('default' as const)
|
||||
return {
|
||||
id: row.ip,
|
||||
label: row.ip,
|
||||
value: row.latency_ms != null ? `${row.latency_ms} мс` : '—',
|
||||
hint: row.colo ? `colo ${row.colo}` : row.provider === 'cloudflare' ? 'Worker' : 'Local',
|
||||
icon: row.provider === 'cloudflare' ? <GlobeIcon /> : <ServerIcon />,
|
||||
variant,
|
||||
footer: (
|
||||
<HealthCheckBadge
|
||||
status={row.status}
|
||||
latencyMs={row.latency_ms}
|
||||
lastCheckedAt={row.last_checked_at}
|
||||
lastError={row.last_error}
|
||||
colo={row.colo}
|
||||
provider={row.provider}
|
||||
size="xs"
|
||||
/>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Health"
|
||||
description="Снимок проб этого сервиса. Cloudflare = Worker с edge, не Health Checks API."
|
||||
/>
|
||||
<Alert>
|
||||
<AlertTitle>XOR провайдеров</AlertTitle>
|
||||
<AlertDescription>
|
||||
Local ходит с API CFDM; Cloudflare — через Worker. Cron и пороги Slow/Down общие, в{' '}
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
. Если Worker не задан, цель не пробируется как Local.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{kpiCards.length > 0 ? (
|
||||
<KpiStatGrid cards={kpiCards} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="Нет проб"
|
||||
description="Включите health-check на привязке — статус IP появится после cron."
|
||||
/>
|
||||
)}
|
||||
<DetailPanel.Header
|
||||
title="Журнал проб"
|
||||
description={
|
||||
items[0]?.checked_at
|
||||
? `Последняя: ${formatDate(items[0].checked_at)}`
|
||||
: 'Последние пробы по IP этого сервиса'
|
||||
}
|
||||
/>
|
||||
<HealthTimeline
|
||||
events={items.map((row) => ({
|
||||
id: row.id,
|
||||
hostname: row.ip,
|
||||
type: row.provider,
|
||||
status: row.status,
|
||||
latency_ms: row.latency_ms,
|
||||
error: row.error,
|
||||
checked_at: row.checked_at,
|
||||
colo: row.colo,
|
||||
provider: row.provider,
|
||||
}))}
|
||||
/>
|
||||
</DetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,94 +1,431 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ActivityIcon,
|
||||
GlobeIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
ServerIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
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'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { serviceOverviewQueryOptions } from '@/queries'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||
import {
|
||||
ServiceDetailGrid,
|
||||
type ServiceFqdnRow,
|
||||
} from '@/components/services/service-detail-grid'
|
||||
import { LbModeTile } from '@/components/services/service-unit-card'
|
||||
import {
|
||||
KpiStatGrid,
|
||||
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 {
|
||||
createServiceNode,
|
||||
deleteServiceNode,
|
||||
domainKeys,
|
||||
domainsListQueryOptions,
|
||||
serviceBindingKeys,
|
||||
serviceDetailKeys,
|
||||
serviceGroupKeys,
|
||||
serviceGroupsQueryOptions,
|
||||
serviceHealthLogQueryOptions,
|
||||
serviceKeys,
|
||||
serviceNodesQueryOptions,
|
||||
serviceOverviewQueryOptions,
|
||||
serviceViewQueryOptions,
|
||||
} from '@/queries'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
||||
component: ServiceOverviewPage,
|
||||
component: ServiceDetailPage,
|
||||
})
|
||||
|
||||
function ServiceOverviewPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
|
||||
const overview = data as {
|
||||
service: {
|
||||
name: string
|
||||
enabled: boolean
|
||||
health_status: 'up' | 'down' | 'degraded' | 'unknown'
|
||||
domains: Array<{ fqdn: string; zone_name: string }>
|
||||
}
|
||||
nodes: Array<{ id: number; address: string; health_status: string }>
|
||||
routing_strategy: string
|
||||
active_addresses: string[]
|
||||
} | undefined
|
||||
interface OverviewPayload {
|
||||
routing_strategy?: string
|
||||
active_addresses?: string[]
|
||||
nodes?: Array<{
|
||||
id: number
|
||||
address: string
|
||||
protocol: string
|
||||
port: number | null
|
||||
health_status: string
|
||||
weight: number
|
||||
priority: number
|
||||
consecutive_failures: number
|
||||
last_failure_reason: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
if (!overview) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Сервис не найден"
|
||||
description="Вернитесь в каталог и выберите сервис."
|
||||
/>
|
||||
)
|
||||
function ServiceDetailPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const viewQuery = useQuery(serviceViewQueryOptions(id))
|
||||
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
|
||||
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
|
||||
const groupsQuery = useQuery(serviceGroupsQueryOptions())
|
||||
const domainsQuery = useQuery(domainsListQueryOptions())
|
||||
|
||||
const service = viewQuery.data
|
||||
const overview = overviewQuery.data as OverviewPayload | undefined
|
||||
const logItems = useMemo(
|
||||
() => logQuery.data?.items ?? [],
|
||||
[logQuery.data?.items],
|
||||
)
|
||||
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [togglingIp, setTogglingIp] = useState<string | null>(null)
|
||||
const [changeIp, setChangeIp] = useState<{
|
||||
bindingId: number
|
||||
ip?: string
|
||||
} | null>(null)
|
||||
const [changeDomain, setChangeDomain] = useState(false)
|
||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||
const nodeForm = useForm<{ address: string; port: string }>({
|
||||
defaultValues: { address: '', port: '' },
|
||||
})
|
||||
|
||||
const groups = groupsQuery.data
|
||||
? [...groupsQuery.data.groups]
|
||||
: []
|
||||
|
||||
async function invalidateService() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
|
||||
])
|
||||
}
|
||||
|
||||
const nodes = overview.nodes ?? []
|
||||
const domains = overview.service.domains ?? []
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ body }: { body: UpdateServiceConfigInput }) =>
|
||||
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
|
||||
onSuccess: async () => {
|
||||
await invalidateService()
|
||||
setEditOpen(false)
|
||||
toast.success('Сервис сохранён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
|
||||
},
|
||||
onSettled: () => setSaving(false),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/api/v1/services/${id}`),
|
||||
onSuccess: async () => {
|
||||
await invalidateService()
|
||||
toast.success('Сервис удалён')
|
||||
await navigate({ to: '/services' })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
|
||||
},
|
||||
})
|
||||
|
||||
const toggleIpMutation = useMutation({
|
||||
mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) =>
|
||||
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }),
|
||||
onSuccess: async (_data, { enabled }) => {
|
||||
await invalidateService()
|
||||
toast.success(
|
||||
enabled
|
||||
? 'IP включён и добавлен в DNS-привязки'
|
||||
: 'IP выключен и снят с DNS-привязок',
|
||||
)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP')
|
||||
},
|
||||
onSettled: () => setTogglingIp(null),
|
||||
})
|
||||
|
||||
const createNodeMut = useMutation({
|
||||
mutationFn: (values: { address: string; port: string }) =>
|
||||
createServiceNode(id, {
|
||||
address: values.address.trim(),
|
||||
port: values.port ? Number(values.port) : null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('Нода добавлена, статус CHECKING')
|
||||
await invalidateService()
|
||||
setAddNodeOpen(false)
|
||||
nodeForm.reset()
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
|
||||
})
|
||||
|
||||
const deleteNodeMut = useMutation({
|
||||
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
|
||||
onSuccess: async () => {
|
||||
toast.success('Нода удалена')
|
||||
await invalidateService()
|
||||
},
|
||||
})
|
||||
|
||||
const isLoading = viewQuery.isLoading || overviewQuery.isLoading
|
||||
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 providerStatuses = useMemo(
|
||||
() => providerHealthStatuses(logItems, enabledProviders),
|
||||
[logItems, enabledProviders],
|
||||
)
|
||||
|
||||
return (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={overview.service.name}
|
||||
description={`Маршрутизация: ${overview.routing_strategy}. Активные IP: ${
|
||||
overview.active_addresses.join(', ') || '—'
|
||||
}`}
|
||||
actions={
|
||||
<HealthCheckBadge status={overview.service.health_status} />
|
||||
}
|
||||
/>
|
||||
<DetailPanel.Metrics
|
||||
cards={[
|
||||
{
|
||||
id: 'subdomains',
|
||||
icon: <GlobeIcon />,
|
||||
label: 'Поддомены',
|
||||
description:
|
||||
domains.length > 0
|
||||
? domains.map((d) => d.fqdn).join(', ')
|
||||
: 'Нет привязанных FQDN',
|
||||
footer: <Badge variant="outline">{domains.length}</Badge>,
|
||||
},
|
||||
{
|
||||
id: 'nodes',
|
||||
icon: <ServerIcon />,
|
||||
label: 'Ноды',
|
||||
description:
|
||||
nodes.length > 0
|
||||
? nodes.map((n) => n.address).join(', ')
|
||||
: 'Добавьте ноду, чтобы публиковать DNS',
|
||||
footer: <Badge variant="outline">{nodes.length}</Badge>,
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
icon: <ActivityIcon />,
|
||||
label: 'Пул',
|
||||
description:
|
||||
overview.active_addresses.length > 0
|
||||
? 'Здоровые адреса участвуют в DNS'
|
||||
: 'unknown не попадает в пул, пока не станет healthy',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{domains.length === 0 && nodes.length === 0 ? (
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void viewQuery.refetch()
|
||||
void overviewQuery.refetch()
|
||||
}}
|
||||
>
|
||||
{!service ? (
|
||||
<EmptyState
|
||||
title="Пустой сервис"
|
||||
description="Добавьте поддомен и ноду, затем настройте health-check."
|
||||
stackedIcon
|
||||
title="Сервис не найден"
|
||||
description="Вернитесь в каталог и выберите сервис."
|
||||
/>
|
||||
) : null}
|
||||
</DetailPanel>
|
||||
) : (
|
||||
<div className="@container flex w-full flex-col gap-4 md:gap-6">
|
||||
<PageHeader
|
||||
title={service.name}
|
||||
description="Domain → Service → Node → Health → Failover"
|
||||
actions={
|
||||
<>
|
||||
<LbModeTile mode={service.lb_mode} />
|
||||
<HealthCheckBadge status={service.health_status} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Изменить"
|
||||
onClick={() => setEditOpen(true)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PencilIcon aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Изменить</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStatGrid
|
||||
cards={[
|
||||
{
|
||||
id: 'status',
|
||||
icon: <ActivityIcon />,
|
||||
label: 'Статус',
|
||||
value: service.health_status === 'up' ? 'OK' : service.health_status,
|
||||
variant:
|
||||
service.health_status === 'down'
|
||||
? 'destructive'
|
||||
: service.health_status === 'degraded'
|
||||
? 'warning'
|
||||
: 'default',
|
||||
iconClassName:
|
||||
service.health_status === 'down'
|
||||
? 'text-destructive'
|
||||
: service.health_status === 'degraded'
|
||||
? 'text-warning'
|
||||
: 'text-success',
|
||||
hint: <HealthCheckBadge status={service.health_status} size="xs" />,
|
||||
},
|
||||
{
|
||||
id: 'fqdn',
|
||||
icon: <GlobeIcon />,
|
||||
label: 'FQDN',
|
||||
value: String(service.domains.length),
|
||||
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
|
||||
},
|
||||
{
|
||||
id: 'ip',
|
||||
icon: <NetworkIcon />,
|
||||
label: 'IP',
|
||||
value: String(service.ips.length),
|
||||
hint: `${service.active_ips.length} в пуле`,
|
||||
},
|
||||
{
|
||||
id: 'pool',
|
||||
icon: <ServerIcon />,
|
||||
label: 'Активный пул',
|
||||
value: String((overview?.active_addresses ?? service.active_ips).length),
|
||||
hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<section
|
||||
aria-label="Мониторинг"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
>
|
||||
<ServiceHealthMonitor
|
||||
items={logItems}
|
||||
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>
|
||||
</section>
|
||||
|
||||
{service.ips.length === 0 && service.domains.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Пустой сервис"
|
||||
description="Добавьте поддомен и ноду, затем настройте health-check."
|
||||
stackedIcon
|
||||
/>
|
||||
) : (
|
||||
<ServiceDetailGrid
|
||||
service={service}
|
||||
nodes={nodes}
|
||||
togglingIp={togglingIp}
|
||||
onToggleIp={(ip, enabled) => {
|
||||
setTogglingIp(ip)
|
||||
toggleIpMutation.mutate({ ip, enabled })
|
||||
}}
|
||||
onChangeIp={(row: ServiceFqdnRow) =>
|
||||
setChangeIp({
|
||||
bindingId: row.binding_id,
|
||||
ip: row.target_ips[0],
|
||||
})
|
||||
}
|
||||
onChangeDomain={() => setChangeDomain(true)}
|
||||
onAddNode={() => setAddNodeOpen(true)}
|
||||
onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)}
|
||||
isLoading={nodesQuery.isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ServiceEditSheet
|
||||
mode="edit"
|
||||
service={service}
|
||||
groups={groups}
|
||||
open={editOpen}
|
||||
knownDomains={domainsQuery.data ?? []}
|
||||
isSaving={saving}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
onOpenChange={setEditOpen}
|
||||
onSave={(_serviceId, body) => {
|
||||
setSaving(true)
|
||||
updateMutation.mutate({ body })
|
||||
}}
|
||||
onDelete={() => deleteMutation.mutate()}
|
||||
/>
|
||||
<ChangeIpSheet
|
||||
open={changeIp != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setChangeIp(null)
|
||||
}}
|
||||
bindingId={changeIp?.bindingId ?? null}
|
||||
serviceId={id}
|
||||
currentIp={changeIp?.ip}
|
||||
/>
|
||||
<ChangeDomainSheet
|
||||
open={changeDomain}
|
||||
onOpenChange={setChangeDomain}
|
||||
serviceId={id}
|
||||
fromDomainId={service.domains[0]?.domain_id ?? null}
|
||||
/>
|
||||
<FormSheet
|
||||
open={addNodeOpen}
|
||||
onOpenChange={setAddNodeOpen}
|
||||
title="Добавить ноду"
|
||||
description="IP станет CHECKING до порога успешных проверок."
|
||||
form={nodeForm}
|
||||
onSubmit={(values) => createNodeMut.mutate(values)}
|
||||
footer={
|
||||
<LoadingButton type="submit" isLoading={createNodeMut.isPending}>
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FormFieldSimple label="IP" htmlFor="address">
|
||||
<Input id="address" {...nodeForm.register('address')} placeholder="10.0.0.10" />
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
|
||||
<Input id="port" {...nodeForm.register('port')} placeholder="443" />
|
||||
</FormFieldSimple>
|
||||
</FormSheet>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,143 +1,11 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { createServiceNode, deleteServiceNode, serviceNodesQueryOptions } from '@/queries'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
|
||||
component: ServiceNodesPage,
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/services/$serviceId',
|
||||
params,
|
||||
})
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
|
||||
interface NodeRow {
|
||||
id: number
|
||||
address: string
|
||||
port: number | null
|
||||
protocol: string
|
||||
health_status: 'up' | 'down' | 'degraded' | 'unknown' | 'healthy' | 'unhealthy' | 'checking' | 'disabled'
|
||||
weight: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
function mapHealth(
|
||||
status: NodeRow['health_status'],
|
||||
): 'up' | 'down' | 'degraded' | 'unknown' {
|
||||
if (status === 'healthy' || status === 'up') return 'up'
|
||||
if (status === 'unhealthy' || status === 'down') return 'down'
|
||||
if (status === 'degraded') return 'degraded'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function ServiceNodesPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const queryClient = useQueryClient()
|
||||
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
|
||||
const nodes = (nodesQuery.data ?? []) as NodeRow[]
|
||||
const [open, setOpen] = useState(false)
|
||||
const form = useForm<{ address: string; port: string }>({
|
||||
defaultValues: { address: '', port: '' },
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (values: { address: string; port: string }) =>
|
||||
createServiceNode(id, {
|
||||
address: values.address.trim(),
|
||||
port: values.port ? Number(values.port) : null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('Нода добавлена, статус CHECKING')
|
||||
await queryClient.invalidateQueries({ queryKey: ['services'] })
|
||||
setOpen(false)
|
||||
form.reset()
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
|
||||
onSuccess: async () => {
|
||||
toast.success('Нода удалена')
|
||||
await queryClient.invalidateQueries({ queryKey: ['services'] })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Ноды"
|
||||
description="Адреса происхождения сервиса."
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
<PlusIcon className="size-4" aria-hidden />
|
||||
Добавить ноду
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{nodes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Нет нод"
|
||||
description="Добавьте IP, затем настройте health-check."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{nodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">{node.address}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{node.protocol}
|
||||
{node.port ? `:${node.port}` : ''} · вес {node.weight} · приоритет{' '}
|
||||
{node.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HealthCheckBadge status={mapHealth(node.health_status)} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => deleteMut.mutate(node.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Добавить ноду"
|
||||
description="IP станет CHECKING до порога успешных проверок."
|
||||
form={form}
|
||||
onSubmit={(values) => createMut.mutate(values)}
|
||||
footer={
|
||||
<LoadingButton type="submit" isLoading={createMut.isPending}>
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FormFieldSimple label="IP" htmlFor="address">
|
||||
<Input id="address" {...form.register('address')} placeholder="10.0.0.10" />
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
|
||||
<Input id="port" {...form.register('port')} placeholder="443" />
|
||||
</FormFieldSimple>
|
||||
</FormSheet>
|
||||
</DetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,79 +1,30 @@
|
||||
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { serviceOverviewQueryOptions } from '@/queries'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import {
|
||||
serviceHealthLogQueryOptions,
|
||||
serviceNodesQueryOptions,
|
||||
serviceOverviewQueryOptions,
|
||||
serviceViewQueryOptions,
|
||||
} from '@/queries'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
||||
loader: ({ context: { queryClient }, params }) =>
|
||||
queryClient.ensureQueryData(serviceOverviewQueryOptions(Number(params.serviceId))),
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
const id = Number(params.serviceId)
|
||||
const [view] = await Promise.all([
|
||||
queryClient.ensureQueryData(serviceViewQueryOptions(id)),
|
||||
queryClient.ensureQueryData(serviceOverviewQueryOptions(id)),
|
||||
queryClient.ensureQueryData(serviceHealthLogQueryOptions(id)),
|
||||
queryClient.ensureQueryData(serviceNodesQueryOptions(id)),
|
||||
])
|
||||
return { breadcrumb: view.name }
|
||||
},
|
||||
component: ServiceLayout,
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ to: '/services/$serviceId', label: 'Обзор', exact: true },
|
||||
{ to: '/services/$serviceId/subdomains', label: 'Поддомены', exact: false },
|
||||
{ to: '/services/$serviceId/nodes', label: 'Ноды', exact: false },
|
||||
{ to: '/services/$serviceId/health', label: 'Health', exact: false },
|
||||
{ to: '/services/$serviceId/routing', label: 'Маршрутизация', exact: false },
|
||||
] as const
|
||||
|
||||
function ServiceLayout() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const overview = useQuery(serviceOverviewQueryOptions(id))
|
||||
const name = (overview.data as { service?: { name?: string } } | undefined)?.service?.name
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title={name ?? 'Сервис'}
|
||||
description="Domain → Service → Node → Health → Failover"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={<Link to="/services" />}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" aria-hidden />
|
||||
К каталогу
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<nav className="flex flex-wrap gap-4 border-b">
|
||||
{tabs.map((tab) => {
|
||||
const href = tab.to.replace('$serviceId', serviceId)
|
||||
const active = tab.exact
|
||||
? pathname === `/services/${serviceId}` || pathname === `/services/${serviceId}/`
|
||||
: pathname.startsWith(href)
|
||||
return (
|
||||
<Link
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
params={{ serviceId }}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground pb-3 text-sm font-medium',
|
||||
active && 'text-foreground border-b-2 border-primary',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<QueryState
|
||||
isLoading={overview.isLoading}
|
||||
isError={overview.isError}
|
||||
error={overview.error}
|
||||
onRetry={() => void overview.refetch()}
|
||||
>
|
||||
<Outlet />
|
||||
</QueryState>
|
||||
<Outlet />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,59 +1,11 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import { FailoverTimeline } from '@/components/failover-timeline'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { serviceOverviewQueryOptions } from '@/queries'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
|
||||
component: ServiceRoutingPage,
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/services/$serviceId',
|
||||
params,
|
||||
})
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
|
||||
export function ServiceRoutingPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
|
||||
const overview = data as {
|
||||
routing_strategy: string
|
||||
active_addresses: string[]
|
||||
nodes: Array<{
|
||||
address: string
|
||||
health_status: string
|
||||
consecutive_failures: number
|
||||
last_failure_reason: string | null
|
||||
}>
|
||||
} | undefined
|
||||
|
||||
const events =
|
||||
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}`,
|
||||
})) ?? []
|
||||
|
||||
return (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Маршрутизация"
|
||||
description="Round Robin / Failover. Weighted на DNS = alias Round Robin."
|
||||
actions={<Badge variant="outline">{overview?.routing_strategy ?? 'round_robin'}</Badge>}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
Активные адреса:{' '}
|
||||
{overview?.active_addresses.join(', ') || 'нет (unknown не в пуле)'}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Запись обновляется в Cloudflare. Распространение зависит от TTL.
|
||||
</p>
|
||||
<FailoverTimeline events={events} />
|
||||
</DetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,114 +1,11 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ArrowRightLeftIcon } from 'lucide-react'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
||||
import { serviceOverviewQueryOptions } from '@/queries'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
|
||||
component: ServiceSubdomainsPage,
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/services/$serviceId',
|
||||
params,
|
||||
})
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
|
||||
export function ServiceSubdomainsPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const { data } = useQuery(serviceOverviewQueryOptions(id))
|
||||
const overview = data as {
|
||||
service: {
|
||||
domains: Array<{
|
||||
binding_id: number
|
||||
domain_id: number
|
||||
fqdn: string
|
||||
zone_name: string
|
||||
target_ips: string[]
|
||||
}>
|
||||
}
|
||||
} | undefined
|
||||
const rows = overview?.service.domains ?? []
|
||||
const [changeIp, setChangeIp] = useState<{
|
||||
bindingId: number
|
||||
ip?: string
|
||||
} | null>(null)
|
||||
const [changeDomain, setChangeDomain] = useState(false)
|
||||
const fromDomainId = useMemo(
|
||||
() => rows[0]?.domain_id ?? null,
|
||||
[rows],
|
||||
)
|
||||
|
||||
return (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Поддомены"
|
||||
description="FQDN сервиса в одной или нескольких зонах Cloudflare."
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setChangeDomain(true)}
|
||||
disabled={rows.length === 0}
|
||||
>
|
||||
<ArrowRightLeftIcon className="size-4" aria-hidden />
|
||||
Сменить домен
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Нет поддоменов"
|
||||
description="Привяжите FQDN к сервису из карточки редактирования."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.binding_id}
|
||||
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="font-medium">{row.fqdn}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.zone_name} · {row.target_ips.join(', ') || 'нет IP'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{row.target_ips.length} IP</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setChangeIp({
|
||||
bindingId: row.binding_id,
|
||||
ip: row.target_ips[0],
|
||||
})
|
||||
}
|
||||
>
|
||||
Сменить IP
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChangeIpSheet
|
||||
open={changeIp != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setChangeIp(null)
|
||||
}}
|
||||
bindingId={changeIp?.bindingId ?? null}
|
||||
serviceId={id}
|
||||
currentIp={changeIp?.ip}
|
||||
/>
|
||||
<ChangeDomainSheet
|
||||
open={changeDomain}
|
||||
onOpenChange={setChangeDomain}
|
||||
serviceId={id}
|
||||
fromDomainId={fromDomainId}
|
||||
/>
|
||||
</DetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+74
-1
@@ -1613,6 +1613,25 @@ declare const serviceBindings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
cert_monitoring: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "cert_monitoring";
|
||||
tableName: "service_bindings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
routing_strategy: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "routing_strategy";
|
||||
tableName: "service_bindings";
|
||||
@@ -2682,6 +2701,23 @@ declare const certificates: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {}>;
|
||||
service_id: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "service_id";
|
||||
tableName: "certificates";
|
||||
dataType: "number";
|
||||
columnType: "SQLiteInteger";
|
||||
data: number;
|
||||
driverParam: number;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: undefined;
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {}>;
|
||||
hostname: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "hostname";
|
||||
tableName: "certificates";
|
||||
@@ -6296,6 +6332,25 @@ declare const schema: {
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
cert_monitoring: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "cert_monitoring";
|
||||
tableName: "service_bindings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
routing_strategy: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "routing_strategy";
|
||||
tableName: "service_bindings";
|
||||
@@ -7365,6 +7420,23 @@ declare const schema: {
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {}>;
|
||||
service_id: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "service_id";
|
||||
tableName: "certificates";
|
||||
dataType: "number";
|
||||
columnType: "SQLiteInteger";
|
||||
data: number;
|
||||
driverParam: number;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: undefined;
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {}>;
|
||||
hostname: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "hostname";
|
||||
tableName: "certificates";
|
||||
@@ -9677,6 +9749,7 @@ interface BindingLbPatch {
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
cert_monitoring?: string;
|
||||
}
|
||||
declare function updateBindingLbConfig(db: Db, bindingId: number, patch: BindingLbPatch): void;
|
||||
declare function setBindingCnameTarget(db: Db, bindingId: number, target: string | null): void;
|
||||
@@ -9702,7 +9775,7 @@ declare function deleteBindingsExcept(db: Db, serviceId: number, keepIds: number
|
||||
declare function deleteBinding(db: Db, id: number): void;
|
||||
declare function listCertificates(db: Db, status?: string): Certificate[];
|
||||
declare function getCertificate(db: Db, id: number): Certificate;
|
||||
declare function upsertCertificateCheck(db: Db, domainId: number, subdomainId: number | null, hostname: string, expiresAt: string | null, status: string, lastError: string | null): Certificate;
|
||||
declare function upsertCertificateCheck(db: Db, domainId: number, subdomainId: number | null, hostname: string, expiresAt: string | null, status: string, lastError: string | null, serviceId?: number | null): Certificate;
|
||||
declare function countCertificatesByStatus(db: Db): Array<[string, number]>;
|
||||
declare function deleteCertificatesNotIn(db: Db, hostnames: string[]): number;
|
||||
declare function createSyncJob(db: Db, id: string, domainId: number | null): void;
|
||||
|
||||
Vendored
+111
-20
@@ -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"),
|
||||
@@ -1221,15 +1225,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 +1630,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 +1692,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 +1740,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 +1773,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 +1851,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 +1913,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')`,
|
||||
@@ -2170,6 +2217,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 +2269,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 +2292,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 +2315,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 +2329,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 +2339,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 +2354,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 +2368,8 @@ function listHealthCheckTargets(db) {
|
||||
AND sb.health_check_enabled = 0
|
||||
AND sb.cname_target IS NOT NULL
|
||||
AND sb.cname_target <> ''
|
||||
`);
|
||||
`)
|
||||
);
|
||||
return [
|
||||
...bindingTargets,
|
||||
...groupTargets,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
ALTER TABLE service_bindings ADD COLUMN cert_monitoring TEXT NOT NULL DEFAULT 'auto'
|
||||
CHECK (cert_monitoring IN ('auto', 'required', 'skipped'));
|
||||
|
||||
ALTER TABLE certificates ADD COLUMN service_id INTEGER REFERENCES services(id) ON DELETE SET NULL;
|
||||
|
||||
UPDATE service_bindings
|
||||
SET cert_monitoring = COALESCE(
|
||||
(
|
||||
SELECT d.cert_monitoring FROM domains d
|
||||
WHERE d.id = service_bindings.domain_id
|
||||
),
|
||||
'auto'
|
||||
)
|
||||
WHERE hostname = '@';
|
||||
|
||||
UPDATE service_bindings
|
||||
SET cert_monitoring = COALESCE(
|
||||
(
|
||||
SELECT s.cert_monitoring FROM subdomains s
|
||||
WHERE s.domain_id = service_bindings.domain_id
|
||||
AND s.name = service_bindings.hostname
|
||||
),
|
||||
'auto'
|
||||
)
|
||||
WHERE hostname != '@';
|
||||
|
||||
UPDATE certificates
|
||||
SET service_id = (
|
||||
SELECT sb.service_id
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
WHERE CASE
|
||||
WHEN sb.hostname = '@' THEN d.zone_name
|
||||
ELSE sb.hostname || '.' || d.zone_name
|
||||
END = certificates.hostname
|
||||
LIMIT 1
|
||||
);
|
||||
+161
-32
@@ -842,15 +842,16 @@ 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,
|
||||
operation_version: row.operation_version,
|
||||
created_at: row.created_at,
|
||||
@@ -1530,6 +1531,7 @@ export interface BindingLbPatch {
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
cert_monitoring?: string;
|
||||
}
|
||||
|
||||
export function updateBindingLbConfig(
|
||||
@@ -1560,6 +1562,8 @@ export function updateBindingLbConfig(
|
||||
update.health_check_timeout_ms = patch.health_check_timeout_ms;
|
||||
if (patch.health_check_verify_tls !== undefined)
|
||||
update.health_check_verify_tls = patch.health_check_verify_tls;
|
||||
if (patch.cert_monitoring !== undefined)
|
||||
update.cert_monitoring = patch.cert_monitoring;
|
||||
Object.assign(update, healthProviderColumns(patch));
|
||||
db.update(serviceBindings)
|
||||
.set(update)
|
||||
@@ -1669,7 +1673,7 @@ const SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.h
|
||||
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 +1737,7 @@ function enrichServiceBindingView(
|
||||
return {
|
||||
...row,
|
||||
cname_target: row.cname_target ?? null,
|
||||
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||
...mapHealthFields(row),
|
||||
target_ips,
|
||||
target_ip: target_ips[0] ?? null,
|
||||
@@ -1772,11 +1777,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 {
|
||||
@@ -1914,26 +1923,69 @@ export function deleteBinding(db: Db, id: number): void {
|
||||
|
||||
// --- Certificates ---
|
||||
|
||||
const 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`;
|
||||
|
||||
type CertificateRow = {
|
||||
id: number;
|
||||
domain_id: number;
|
||||
subdomain_id: number | null;
|
||||
service_id: number | null;
|
||||
hostname: string;
|
||||
expires_at: string | null;
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
service_name: string | null;
|
||||
};
|
||||
|
||||
function mapCertificate(row: CertificateRow): Certificate {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function listCertificates(db: Db, status?: string): Certificate[] {
|
||||
if (status) {
|
||||
return db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.status, status))
|
||||
.orderBy(asc(certificates.expires_at))
|
||||
.all() as Certificate[];
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.orderBy(asc(certificates.expires_at))
|
||||
.all() as Certificate[];
|
||||
const rows = status
|
||||
? db.all<CertificateRow>(sql`
|
||||
SELECT ${sql.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<CertificateRow>(sql`
|
||||
SELECT ${sql.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);
|
||||
}
|
||||
|
||||
export function getCertificate(db: Db, id: number): Certificate {
|
||||
const row = db.select().from(certificates).where(eq(certificates.id, id)).get();
|
||||
const row = db.all<CertificateRow>(sql`
|
||||
SELECT ${sql.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 as Certificate;
|
||||
return mapCertificate(row);
|
||||
}
|
||||
|
||||
export function upsertCertificateCheck(
|
||||
@@ -1944,6 +1996,7 @@ export function upsertCertificateCheck(
|
||||
expiresAt: string | null,
|
||||
status: string,
|
||||
lastError: string | null,
|
||||
serviceId?: number | null,
|
||||
): Certificate {
|
||||
const existing = db
|
||||
.select()
|
||||
@@ -1956,6 +2009,7 @@ export function upsertCertificateCheck(
|
||||
.set({
|
||||
domain_id: domainId,
|
||||
subdomain_id: subdomainId,
|
||||
service_id: serviceId === undefined ? existing.service_id : serviceId,
|
||||
expires_at: expiresAt,
|
||||
last_checked_at: sql`datetime('now')`,
|
||||
last_error: lastError,
|
||||
@@ -1972,6 +2026,7 @@ export function upsertCertificateCheck(
|
||||
.values({
|
||||
domain_id: domainId,
|
||||
subdomain_id: subdomainId,
|
||||
service_id: serviceId ?? null,
|
||||
hostname,
|
||||
expires_at: expiresAt,
|
||||
last_checked_at: sql`datetime('now')`,
|
||||
@@ -2438,13 +2493,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,
|
||||
@@ -2460,12 +2575,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,
|
||||
@@ -2487,13 +2604,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,
|
||||
@@ -2515,10 +2633,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,
|
||||
@@ -2529,7 +2650,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
|
||||
@@ -2537,11 +2660,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,
|
||||
@@ -2552,7 +2678,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
|
||||
@@ -2564,7 +2692,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,
|
||||
|
||||
@@ -177,6 +177,7 @@ export const serviceBindings = sqliteTable(
|
||||
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")
|
||||
@@ -324,6 +325,9 @@ export const 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"),
|
||||
|
||||
Vendored
+103
-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;
|
||||
@@ -156,6 +158,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 +427,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";
|
||||
@@ -560,11 +563,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,16 +627,21 @@ 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>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
target_ips: string[];
|
||||
@@ -658,6 +666,7 @@ 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;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -679,6 +688,7 @@ 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;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -737,16 +747,21 @@ 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>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
target_ips: string[];
|
||||
@@ -771,6 +786,7 @@ 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;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -792,6 +808,7 @@ 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;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -869,11 +886,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,16 +949,21 @@ 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>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
target_ips: string[];
|
||||
@@ -966,6 +988,7 @@ 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;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -987,6 +1010,7 @@ 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;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -1073,11 +1097,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,16 +1160,21 @@ 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>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
target_ips: string[];
|
||||
@@ -1170,6 +1199,7 @@ 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;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -1191,6 +1221,7 @@ 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;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -1291,16 +1322,21 @@ 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>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
target_ips: string[];
|
||||
@@ -1325,6 +1361,7 @@ 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;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -1346,6 +1383,7 @@ 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;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -1471,6 +1509,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 +1541,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 +1566,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 +1594,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 +1604,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 +1632,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 +1650,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 +1680,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 +1692,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 +1883,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 +1895,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 +1925,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 +1937,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 +1970,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 +1982,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 +2412,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 Group, type GroupWithStats, HEALTH_CHECK_AGGREGATES, HEALTH_CHECK_PROVIDERS, HEALTH_KV_CURSOR_KEY, HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, HEALTH_PROBE_BATCH, HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, HEALTH_STATUS_PROVIDERS, type HealthCheckAggregate, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthProbeResultItem, type HealthProbeResultsDoc, type HealthProbeTargetItem, type HealthProbeTargetsDoc, type HealthStatusProvider, type HealthStatusQuery, type HealthWorkerStatus, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceCertificateRow, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, aggregateHealthOk, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, clampGlobalpingLimit, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, derivePrimaryProvider, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckAggregateSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckProvidersSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusProviderSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, normalizeProbeProvider, normalizeStatusProvider, notificationLogSchema, originHealthCheckSchema, parseFqdn, parseGlobalpingLocations, parseHealthAggregate, parseHealthProviders, reorderServicesSchema, serializeHealthProviders, serviceBindingSchema, serviceCertificateRowSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, targetHasProvider, targetProviders, toggleEnabledSchema, toggleServiceIpSchema, uniqueHealthProviders, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
|
||||
|
||||
Vendored
+29
-6
@@ -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,
|
||||
@@ -392,6 +398,7 @@ var serviceDomainBindingSchema = z.object({
|
||||
health_check_provider: healthCheckProviderSchema.catch("local"),
|
||||
health_check_providers: healthCheckProvidersSchema.catch(["local"]),
|
||||
health_check_aggregate: healthCheckAggregateSchema.catch("majority"),
|
||||
cert_monitoring: certMonitoringSchema.default("auto"),
|
||||
sync_status: z.string().nullable().default(null)
|
||||
}).transform((binding) => ({
|
||||
...binding,
|
||||
@@ -465,6 +472,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 +502,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 +512,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 +534,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()
|
||||
@@ -1029,6 +1051,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>
|
||||
@@ -178,6 +182,7 @@ export const serviceDomainBindingSchema = z
|
||||
health_check_provider: healthCheckProviderSchema.catch('local'),
|
||||
health_check_providers: healthCheckProvidersSchema.catch(['local']),
|
||||
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
|
||||
cert_monitoring: certMonitoringSchema.default('auto'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
})
|
||||
.transform((binding) => ({
|
||||
@@ -266,6 +271,7 @@ export const serviceBindingSchema = z
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
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(),
|
||||
@@ -303,6 +309,8 @@ export const 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(),
|
||||
@@ -312,6 +320,19 @@ export const certificateSchema = z.object({
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const 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(),
|
||||
})
|
||||
|
||||
export type Group = z.infer<typeof groupSchema>
|
||||
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
||||
export type Service = z.infer<typeof serviceSchema>
|
||||
@@ -324,6 +345,7 @@ export type Domain = z.infer<typeof domainSchema>
|
||||
export type DomainListItem = z.infer<typeof domainListItemSchema>
|
||||
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
||||
export type Certificate = z.infer<typeof certificateSchema>
|
||||
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
|
||||
|
||||
export const createGroupSchema = z.object({
|
||||
name: z.string().min(1, 'Укажите название'),
|
||||
@@ -343,14 +365,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(),
|
||||
|
||||
@@ -111,6 +111,8 @@ export interface Certificate {
|
||||
id: number;
|
||||
domain_id: number;
|
||||
subdomain_id: number | null;
|
||||
service_id: number | null;
|
||||
service_name: string | null;
|
||||
hostname: string;
|
||||
expires_at: string | null;
|
||||
last_checked_at: string | null;
|
||||
@@ -139,6 +141,7 @@ export 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;
|
||||
@@ -173,6 +176,7 @@ export 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;
|
||||
@@ -201,6 +205,7 @@ export interface ServiceDomainBindingView {
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
cert_monitoring: string;
|
||||
sync_status: string | null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user