Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a458465153 | ||
|
|
69119a08a4 | ||
|
|
ba03d2be9d | ||
|
|
d8fc4ac949 | ||
|
|
d063323402 |
@@ -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);
|
||||
|
||||
@@ -308,10 +308,19 @@ 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),
|
||||
};
|
||||
});
|
||||
|
||||
const activeIps = new Set<string>();
|
||||
for (const binding of bindings) {
|
||||
const { config, rows } = getBindingLbState(db, binding.id);
|
||||
for (const ip of selectActiveIpsByMode(config, rows)) {
|
||||
activeIps.add(ip);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
@@ -330,6 +339,8 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
health_status: "unknown",
|
||||
health_latency_ms: null,
|
||||
ip_health: [],
|
||||
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
|
||||
active_ips: [...activeIps],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,11 +123,12 @@ describe("create service then list groups", () => {
|
||||
expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
httpParsed.data!.groups
|
||||
.find((g) => g.id === group.id)
|
||||
?.services.some((s) => s.id === created.id),
|
||||
).toBe(true);
|
||||
const listed = httpParsed.data!.groups
|
||||
.find((g) => g.id === group.id)
|
||||
?.services.find((s) => s.id === created.id);
|
||||
expect(listed).toBeDefined();
|
||||
expect(listed?.lb_mode).toBe("round_robin");
|
||||
expect(listed?.active_ips).toEqual(["1.2.3.4"]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -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: 'Статус',
|
||||
|
||||
@@ -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,10 @@ 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 type { HealthLogStatus } from '@/lib/health-log'
|
||||
|
||||
export type HealthProvider = HealthCheckProvider
|
||||
export type HealthAggregate = HealthCheckAggregate
|
||||
@@ -48,7 +50,7 @@ function GlobalpingMark() {
|
||||
)
|
||||
}
|
||||
|
||||
const PROVIDER_ITEMS: Array<{
|
||||
export const HEALTH_PROVIDER_ITEMS: Array<{
|
||||
id: HealthProvider
|
||||
title: string
|
||||
description: string
|
||||
@@ -118,6 +120,7 @@ function ChoicePanel({
|
||||
icon,
|
||||
iconClassName,
|
||||
role,
|
||||
trailing,
|
||||
onActivate,
|
||||
}: {
|
||||
selected: boolean
|
||||
@@ -126,6 +129,7 @@ function ChoicePanel({
|
||||
icon: ReactNode
|
||||
iconClassName?: string
|
||||
role: 'checkbox' | 'radio'
|
||||
trailing?: ReactNode
|
||||
onActivate: () => void
|
||||
}) {
|
||||
return (
|
||||
@@ -157,12 +161,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>
|
||||
@@ -202,7 +202,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 +211,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 +247,71 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
function toggle(id: HealthProvider) {
|
||||
if (active.includes(id)) {
|
||||
if (active.length === 1) return
|
||||
onChange(active.filter((item) => item !== id))
|
||||
return
|
||||
}
|
||||
onChange([...active, id])
|
||||
}
|
||||
|
||||
return (
|
||||
<ChoiceFrame>
|
||||
{visible.map((item) => (
|
||||
<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={() => toggle(item.id)}
|
||||
/>
|
||||
))}
|
||||
</ChoiceFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { UptimeChart, type UptimeProbe, type UptimePeriodKey } 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,7 @@ export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
export {
|
||||
HealthSourceTiles,
|
||||
HealthAggregateTiles,
|
||||
HealthProviderStatusTiles,
|
||||
type HealthProvider,
|
||||
type HealthAggregate,
|
||||
} from './health-source-tiles'
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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 {
|
||||
collapseStatusChanges,
|
||||
filterByPeriod,
|
||||
filterByProviders,
|
||||
type HealthLogProbe,
|
||||
} from '@/lib/health-log'
|
||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||
|
||||
/**
|
||||
* Combined uptime chart + status-change timeline (solution-analytics-8 DNA).
|
||||
* Preview: https://reui.io/preview/base/solution-analytics-8 · https://reui.io/preview/base/chart-17
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/timeline
|
||||
*/
|
||||
export function ServiceHealthMonitor({
|
||||
items,
|
||||
selectedProviders,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: HealthLogProbe[]
|
||||
selectedProviders: readonly HealthCheckProvider[]
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
|
||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterByProviders(filterByPeriod(items, days), selectedProviders),
|
||||
[items, days, selectedProviders],
|
||||
)
|
||||
|
||||
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
|
||||
|
||||
return (
|
||||
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||
<UptimeChart
|
||||
items={filtered}
|
||||
isLoading={isLoading}
|
||||
period={period}
|
||||
onPeriodChange={setPeriod}
|
||||
skipPeriodFilter
|
||||
embedded
|
||||
/>
|
||||
<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,314 @@
|
||||
import { 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,
|
||||
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
|
||||
}
|
||||
|
||||
interface UptimeTooltipProps {
|
||||
active?: boolean
|
||||
payload?: Array<{ payload: ChartPoint }>
|
||||
}
|
||||
|
||||
function UptimeTooltip({ active, payload }: UptimeTooltipProps) {
|
||||
if (!active || !payload?.[0]) return null
|
||||
const point = payload[0].payload
|
||||
return (
|
||||
<div className="bg-popover text-popover-foreground rounded-md px-3 py-2 text-sm shadow-md">
|
||||
<p className="font-medium tabular-nums">
|
||||
{point.latency} мс · {point.ok ? 'OK' : 'Down'}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{point.period}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface UptimeChartProps {
|
||||
items: UptimeProbe[]
|
||||
isLoading?: boolean
|
||||
period?: UptimePeriodKey
|
||||
onPeriodChange?: (period: UptimePeriodKey) => void
|
||||
skipPeriodFilter?: boolean
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
export function UptimeChart({
|
||||
items,
|
||||
isLoading = false,
|
||||
period: periodProp,
|
||||
onPeriodChange,
|
||||
skipPeriodFilter = false,
|
||||
embedded = false,
|
||||
}: UptimeChartProps) {
|
||||
const gradientId = useId().replace(/:/g, '')
|
||||
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
||||
const period = periodProp ?? internalPeriod
|
||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||
|
||||
function handlePeriodChange(next: UptimePeriodKey) {
|
||||
onPeriodChange?.(next)
|
||||
if (periodProp == null) setInternalPeriod(next)
|
||||
}
|
||||
|
||||
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">
|
||||
<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">
|
||||
{uptime == null ? '—' : `${uptime.toFixed(uptime >= 99.95 ? 2 : 1)}%`}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{delta == null ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
{points.length} проб
|
||||
</Badge>
|
||||
) : delta >= 0 ? (
|
||||
<>
|
||||
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
|
||||
<span className="text-success font-medium">
|
||||
+{delta.toFixed(1)} п.п.
|
||||
</span>
|
||||
<span className="text-muted-foreground">к первой половине окна</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
|
||||
<span className="text-destructive font-medium">
|
||||
{delta.toFixed(1)} п.п.
|
||||
</span>
|
||||
<span className="text-muted-foreground">к первой половине окна</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-40 w-full">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-full w-full overflow-hidden rounded-b-xl"
|
||||
initialDimension={{ width: 320, height: 160 }}
|
||||
>
|
||||
<AreaChart
|
||||
data={points}
|
||||
margin={{ top: 10, left: 0, right: 0, bottom: 0 }}
|
||||
>
|
||||
<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 content={<UptimeTooltip />} />
|
||||
<Area
|
||||
dataKey="latency"
|
||||
type="natural"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke="var(--color-latency)"
|
||||
strokeWidth={2}
|
||||
dot={(dotProps) => {
|
||||
const { cx, cy, payload, index } = dotProps as {
|
||||
cx?: number
|
||||
cy?: number
|
||||
index?: number
|
||||
payload?: ChartPoint
|
||||
}
|
||||
if (cx == null || cy == null) return <g key={index} />
|
||||
const fill = payload?.ok
|
||||
? 'var(--color-latency)'
|
||||
: 'var(--destructive)'
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={4}
|
||||
fill={fill}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
stroke: 'var(--background)',
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { CheckIcon, CopyIcon } 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 { TruncatedText } from '@/components/truncated-text'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
@@ -138,6 +139,9 @@ interface ServiceIpListProps {
|
||||
onToggleIp?: (ip: string, enabled: boolean) => void
|
||||
/** Invisible icon-sm slot so IP Switch lines up with the card overflow menu. */
|
||||
alignWithMenu?: boolean
|
||||
lbMode?: ServiceView['lb_mode']
|
||||
activeIps?: string[]
|
||||
ipWeights?: Record<string, number>
|
||||
className?: string
|
||||
emptyLabel?: string
|
||||
copyable?: boolean
|
||||
@@ -152,6 +156,9 @@ export function ServiceIpList({
|
||||
ipToggleDisabled = false,
|
||||
onToggleIp,
|
||||
alignWithMenu = false,
|
||||
lbMode,
|
||||
activeIps = [],
|
||||
ipWeights = {},
|
||||
className,
|
||||
emptyLabel = 'Нет IP',
|
||||
copyable = false,
|
||||
@@ -169,6 +176,8 @@ export function ServiceIpList({
|
||||
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
|
||||
const extraCount = ips.length - visible.length
|
||||
const showMenuSlot = Boolean(onToggleIp && alignWithMenu)
|
||||
const markActive = lbMode === 'failover' || lbMode === 'weighted'
|
||||
const activeSet = new Set(activeIps)
|
||||
|
||||
return (
|
||||
<ItemGroup className={cn('gap-1', className)}>
|
||||
@@ -206,6 +215,18 @@ export function ServiceIpList({
|
||||
{ip}
|
||||
</TruncatedText>
|
||||
{copyable ? <CopyFqdnButton value={ip} /> : null}
|
||||
{markActive && activeSet.has(ip) ? (
|
||||
<StatusBadge status="active" className="shrink-0" />
|
||||
) : null}
|
||||
{lbMode === 'weighted' ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0 tabular-nums"
|
||||
>
|
||||
w{ipWeights[ip] ?? 1}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</ItemContent>
|
||||
{onToggleIp ? (
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
|
||||
import {
|
||||
GitForkIcon,
|
||||
MoreHorizontalIcon,
|
||||
Repeat2Icon,
|
||||
ScaleIcon,
|
||||
ServerIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
@@ -35,13 +42,64 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
/**
|
||||
* Compact service card — settings-8 DNA (Badge + copy + Switch + menu).
|
||||
* 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']
|
||||
|
||||
const LB_MODE_META: Record<
|
||||
LbMode,
|
||||
{ icon: LucideIcon; className: string; label: string }
|
||||
> = {
|
||||
round_robin: {
|
||||
icon: Repeat2Icon,
|
||||
className: 'text-info',
|
||||
label: 'Round Robin',
|
||||
},
|
||||
failover: {
|
||||
icon: GitForkIcon,
|
||||
className: 'text-warning',
|
||||
label: 'Failover (приоритет)',
|
||||
},
|
||||
weighted: {
|
||||
icon: ScaleIcon,
|
||||
className: 'text-info',
|
||||
label: 'Weighted (веса)',
|
||||
},
|
||||
}
|
||||
|
||||
export function LbModeTile({ mode }: { mode: LbMode }) {
|
||||
const meta = LB_MODE_META[mode]
|
||||
const Icon = meta.icon
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className={cn('shrink-0', meta.className)}
|
||||
aria-label={meta.label}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{meta.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServiceUnitCardProps {
|
||||
service: ServiceView
|
||||
togglingId: number | null
|
||||
@@ -67,28 +125,31 @@ 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
|
||||
variant="elevated"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ServerIcon />
|
||||
</IconTile>
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0 gap-px">
|
||||
<FrameTitle className="min-w-0 truncate text-sm">
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: String(service.id) }}
|
||||
className="hover:underline"
|
||||
>
|
||||
{service.name}
|
||||
</Link>
|
||||
</FrameTitle>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<FrameTitle className="min-w-0 truncate text-base font-semibold">
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: String(service.id) }}
|
||||
className="hover:underline"
|
||||
>
|
||||
{service.name}
|
||||
</Link>
|
||||
</FrameTitle>
|
||||
<LbModeTile mode={service.lb_mode} />
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
||||
{primaryDomain}
|
||||
@@ -182,6 +243,9 @@ export function ServiceUnitCard({
|
||||
ipEnabled={service.ip_enabled ?? {}}
|
||||
ipToggleDisabled={togglingId === service.id}
|
||||
togglingIp={togglingIp}
|
||||
lbMode={service.lb_mode}
|
||||
activeIps={service.active_ips}
|
||||
ipWeights={service.domains[0]?.target_ip_weights}
|
||||
onToggleIp={(ip, enabled) =>
|
||||
onToggleServiceIp(service.id, ip, enabled)
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
@@ -133,6 +134,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
||||
health_latency_ms: z.number().nullable().default(null),
|
||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
|
||||
active_ips: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||
@@ -195,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(),
|
||||
@@ -232,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(),
|
||||
@@ -241,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>
|
||||
@@ -254,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, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
|
||||
|
||||
@@ -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,447 @@
|
||||
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 {
|
||||
HealthProviderStatusTiles,
|
||||
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 type { HealthCheckProvider } from '@cfdm/shared'
|
||||
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 [selectedProviders, setSelectedProviders] = useState<
|
||||
HealthCheckProvider[] | null
|
||||
>(null)
|
||||
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 activeProviders =
|
||||
selectedProviders?.filter((provider) => enabledProviders.includes(provider)) ??
|
||||
enabledProviders
|
||||
const effectiveProviders =
|
||||
activeProviders.length > 0 ? activeProviders : enabledProviders
|
||||
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(', ') || 'нет',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<HealthProviderStatusTiles
|
||||
enabled={enabledProviders}
|
||||
selected={effectiveProviders}
|
||||
statuses={providerStatuses}
|
||||
onChange={setSelectedProviders}
|
||||
/>
|
||||
|
||||
<section
|
||||
aria-label="Мониторинг"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
>
|
||||
<ServiceHealthMonitor
|
||||
items={logItems}
|
||||
selectedProviders={effectiveProviders}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
+67
-16
@@ -851,6 +851,7 @@ function mapServiceBinding(
|
||||
health_check_timeout_ms: row.health_check_timeout_ms,
|
||||
health_check_verify_tls: 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,
|
||||
@@ -1914,26 +1919,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 +1992,7 @@ export function upsertCertificateCheck(
|
||||
expiresAt: string | null,
|
||||
status: string,
|
||||
lastError: string | null,
|
||||
serviceId?: number | null,
|
||||
): Certificate {
|
||||
const existing = db
|
||||
.select()
|
||||
@@ -1956,6 +2005,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 +2022,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')`,
|
||||
|
||||
@@ -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
+26
@@ -176,6 +176,8 @@ interface ServiceView$1 {
|
||||
health_latency_ms: number | null;
|
||||
ip_health: ServiceIpHealth$1[];
|
||||
ip_enabled: Record<string, boolean>;
|
||||
lb_mode: LbMode;
|
||||
active_ips: string[];
|
||||
}
|
||||
interface SyncJob {
|
||||
id: string;
|
||||
@@ -824,6 +826,12 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||
round_robin: "round_robin";
|
||||
failover: "failover";
|
||||
weighted: "weighted";
|
||||
}>>;
|
||||
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>;
|
||||
declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
@@ -1013,6 +1021,12 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||
round_robin: "round_robin";
|
||||
failover: "failover";
|
||||
weighted: "weighted";
|
||||
}>>;
|
||||
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
@@ -1211,6 +1225,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||
round_robin: "round_robin";
|
||||
failover: "failover";
|
||||
weighted: "weighted";
|
||||
}>>;
|
||||
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
@@ -1360,6 +1380,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||
round_robin: "round_robin";
|
||||
failover: "failover";
|
||||
weighted: "weighted";
|
||||
}>>;
|
||||
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
declare const domainSchema: z.ZodObject<{
|
||||
|
||||
Vendored
+3
-1
@@ -409,7 +409,9 @@ var serviceViewSchema = serviceSchema.extend({
|
||||
health_status: ipHealthStateSchema.default("unknown"),
|
||||
health_latency_ms: z.number().nullable().default(null),
|
||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||
ip_enabled: z.record(z.string(), z.boolean()).default({})
|
||||
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||
lb_mode: lbModeSchema.catch("round_robin"),
|
||||
active_ips: z.array(z.string()).default([])
|
||||
});
|
||||
var serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||
services: z.array(serviceViewSchema).default([]),
|
||||
|
||||
@@ -178,6 +178,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) => ({
|
||||
@@ -205,6 +206,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
||||
health_latency_ms: z.number().nullable().default(null),
|
||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||
lb_mode: lbModeSchema.catch('round_robin'),
|
||||
active_ips: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||
@@ -264,6 +267,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(),
|
||||
@@ -301,6 +305,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(),
|
||||
@@ -310,6 +316,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>
|
||||
@@ -322,6 +341,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, 'Укажите название'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -222,6 +227,8 @@ export interface ServiceView {
|
||||
health_latency_ms: number | null;
|
||||
ip_health: ServiceIpHealth[];
|
||||
ip_enabled: Record<string, boolean>;
|
||||
lb_mode: LbMode;
|
||||
active_ips: string[];
|
||||
}
|
||||
|
||||
export interface GroupWithStats extends Group {
|
||||
|
||||
Reference in New Issue
Block a user