Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a458465153 | ||
|
|
69119a08a4 | ||
|
|
ba03d2be9d |
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
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 bindingService from "../services/binding-service.js";
|
||||||
import * as changeIp from "../services/change-ip-service.js";
|
import * as changeIp from "../services/change-ip-service.js";
|
||||||
import { recordAudit } from "../lib/audit.js";
|
import { recordAudit } from "../lib/audit.js";
|
||||||
@@ -17,6 +17,7 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
|
|||||||
service_id: z.number().optional(),
|
service_id: z.number().optional(),
|
||||||
hostname: z.string().optional(),
|
hostname: z.string().optional(),
|
||||||
target_ip: z.string().optional(),
|
target_ip: z.string().optional(),
|
||||||
|
cert_monitoring: certMonitoringSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/service-bindings", async (request) => {
|
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 serviceConfig from "../services/service-config-service.js";
|
||||||
import * as nodeService from "../services/node-service.js";
|
import * as nodeService from "../services/node-service.js";
|
||||||
import * as changeDomain from "../services/change-domain-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";
|
import { recordAudit } from "../lib/audit.js";
|
||||||
|
|
||||||
export async function serviceRoutes(app: FastifyInstance) {
|
export async function serviceRoutes(app: FastifyInstance) {
|
||||||
@@ -69,10 +70,31 @@ export async function serviceRoutes(app: FastifyInstance) {
|
|||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
repos.getService(request.server.db, Number(id));
|
repos.getService(request.server.db, Number(id));
|
||||||
return {
|
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) => {
|
app.get("/services/:id/overview", async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
return nodeService.getOverview(request.server.db, Number(id));
|
return nodeService.getOverview(request.server.db, Number(id));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface UpdateBindingRequest {
|
|||||||
service_id?: number;
|
service_id?: number;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
target_ip?: string;
|
target_ip?: string;
|
||||||
|
cert_monitoring?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHostname(hostname?: string): string {
|
function normalizeHostname(hostname?: string): string {
|
||||||
@@ -92,6 +93,20 @@ export async function update(
|
|||||||
req: UpdateBindingRequest,
|
req: UpdateBindingRequest,
|
||||||
): Promise<ServiceBindingView> {
|
): Promise<ServiceBindingView> {
|
||||||
const existing = repos.getBinding(db, id);
|
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;
|
const serviceId = req.service_id ?? existing.service_id;
|
||||||
if (req.service_id) repos.getService(db, req.service_id);
|
if (req.service_id) repos.getService(db, req.service_id);
|
||||||
const hostname = req.hostname
|
const hostname = req.hostname
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { connect } from "node:net";
|
|||||||
import { connect as tlsConnect } from "node:tls";
|
import { connect as tlsConnect } from "node:tls";
|
||||||
import type { Db } from "@cfdm/db";
|
import type { Db } from "@cfdm/db";
|
||||||
import { repos } 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 {
|
import {
|
||||||
CERT_ERROR,
|
CERT_ERROR,
|
||||||
CERT_MONITOR_AUTO,
|
CERT_MONITOR_AUTO,
|
||||||
@@ -11,13 +11,13 @@ import {
|
|||||||
CERT_UNKNOWN,
|
CERT_UNKNOWN,
|
||||||
certStatusFromExpiry,
|
certStatusFromExpiry,
|
||||||
fqdnToDisplay,
|
fqdnToDisplay,
|
||||||
parseFqdn,
|
|
||||||
shouldMonitorService,
|
shouldMonitorService,
|
||||||
} from "@cfdm/shared";
|
} from "@cfdm/shared";
|
||||||
|
|
||||||
export interface CertificateTarget {
|
export interface CertificateTarget {
|
||||||
domainId: number;
|
domainId: number;
|
||||||
subdomainId: number | null;
|
subdomainId: number | null;
|
||||||
|
serviceId: number;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +41,34 @@ export function getCertificate(db: Db, id: number): Certificate {
|
|||||||
return repos.getCertificate(db, id);
|
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(
|
export async function checkHostname(
|
||||||
hostname: string,
|
hostname: string,
|
||||||
): Promise<{ expiresAt: Date | null; error: string | null }> {
|
): Promise<{ expiresAt: Date | null; error: string | null }> {
|
||||||
@@ -78,6 +106,7 @@ export async function checkAndStore(
|
|||||||
domainId: number,
|
domainId: number,
|
||||||
subdomainId: number | null,
|
subdomainId: number | null,
|
||||||
hostname: string,
|
hostname: string,
|
||||||
|
serviceId: number | null = null,
|
||||||
): Promise<Certificate> {
|
): Promise<Certificate> {
|
||||||
const { expiresAt, error } = await checkHostname(hostname);
|
const { expiresAt, error } = await checkHostname(hostname);
|
||||||
|
|
||||||
@@ -90,6 +119,7 @@ export async function checkAndStore(
|
|||||||
null,
|
null,
|
||||||
CERT_ERROR,
|
CERT_ERROR,
|
||||||
error,
|
error,
|
||||||
|
serviceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +135,7 @@ export async function checkAndStore(
|
|||||||
expiresAt.toISOString(),
|
expiresAt.toISOString(),
|
||||||
certStatusFromExpiry(days),
|
certStatusFromExpiry(days),
|
||||||
null,
|
null,
|
||||||
|
serviceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,23 +147,10 @@ export async function checkAndStore(
|
|||||||
null,
|
null,
|
||||||
CERT_UNKNOWN,
|
CERT_UNKNOWN,
|
||||||
"unknown expiry",
|
"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(
|
function bindingSubdomain(
|
||||||
db: Db,
|
db: Db,
|
||||||
domainId: number,
|
domainId: number,
|
||||||
@@ -165,10 +183,9 @@ function hasSslHealthGate(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildServiceCertificateFqdns(
|
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
|
||||||
db: Db,
|
const targets: CertificateTarget[] = [];
|
||||||
): Map<string, CertificateTarget> {
|
const seen = new Set<string>();
|
||||||
const result = new Map<string, CertificateTarget>();
|
|
||||||
|
|
||||||
for (const binding of repos.listAllBindings(db)) {
|
for (const binding of repos.listAllBindings(db)) {
|
||||||
const service = repos.getService(db, binding.service_id);
|
const service = repos.getService(db, binding.service_id);
|
||||||
@@ -176,98 +193,40 @@ export function buildServiceCertificateFqdns(
|
|||||||
? repos.getServiceGroup(db, service.service_group_id)
|
? repos.getServiceGroup(db, service.service_group_id)
|
||||||
: null;
|
: null;
|
||||||
if (!shouldMonitorService(service, group)) continue;
|
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);
|
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
|
||||||
if (subdomain && !subdomain.enabled) continue;
|
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);
|
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,
|
domainId: binding.domain_id,
|
||||||
subdomainId: subdomain?.id ?? null,
|
subdomainId: subdomain?.id ?? null,
|
||||||
|
serviceId: binding.service_id,
|
||||||
hostname: fqdn,
|
hostname: fqdn,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
|
return targets;
|
||||||
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()];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runAllChecks(db: Db): Promise<number> {
|
export async function runAllChecks(db: Db): Promise<number> {
|
||||||
@@ -278,6 +237,7 @@ export async function runAllChecks(db: Db): Promise<number> {
|
|||||||
target.domainId,
|
target.domainId,
|
||||||
target.subdomainId,
|
target.subdomainId,
|
||||||
target.hostname,
|
target.hostname,
|
||||||
|
target.serviceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
repos.deleteCertificatesNotIn(
|
repos.deleteCertificatesNotIn(
|
||||||
@@ -287,6 +247,26 @@ export async function runAllChecks(db: Db): Promise<number> {
|
|||||||
return targets.length;
|
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]> {
|
export function statusSummary(db: Db): Array<[string, number]> {
|
||||||
pruneStaleCertificates(db);
|
pruneStaleCertificates(db);
|
||||||
return repos.countCertificatesByStatus(db);
|
return repos.countCertificatesByStatus(db);
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
binding.health_check_provider ?? "local",
|
binding.health_check_provider ?? "local",
|
||||||
],
|
],
|
||||||
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
||||||
|
cert_monitoring: binding.cert_monitoring ?? "auto",
|
||||||
sync_status: aggregateSyncStatus(statuses),
|
sync_status: aggregateSyncStatus(statuses),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ describe("certificates", () => {
|
|||||||
await testApp.close();
|
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({
|
const testApp = await buildApp({
|
||||||
config: { ...loadConfig(), staticDir: null },
|
config: { ...loadConfig(), staticDir: null },
|
||||||
memory: true,
|
memory: true,
|
||||||
@@ -221,10 +221,18 @@ describe("certificates", () => {
|
|||||||
"required.example.com",
|
"required.example.com",
|
||||||
"cf-zone-req",
|
"cf-zone-req",
|
||||||
);
|
);
|
||||||
repos.updateDomain(testApp.db, domain.id, {
|
const service = repos.createService(testApp.db, "Req", "req");
|
||||||
group_id: null,
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
status: "active",
|
const binding = repos.insertBinding(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
service.id,
|
||||||
|
"@",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||||
|
health_check_enabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||||
@@ -247,7 +255,7 @@ describe("certificates", () => {
|
|||||||
await testApp.close();
|
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({
|
const testApp = await buildApp({
|
||||||
config: { ...loadConfig(), staticDir: null },
|
config: { ...loadConfig(), staticDir: null },
|
||||||
memory: true,
|
memory: true,
|
||||||
@@ -260,6 +268,20 @@ describe("certificates", () => {
|
|||||||
"skipped.example.com",
|
"skipped.example.com",
|
||||||
"cf-zone-skip",
|
"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(
|
repos.upsertCertificateCheck(
|
||||||
testApp.db,
|
testApp.db,
|
||||||
domain.id,
|
domain.id,
|
||||||
@@ -268,12 +290,8 @@ describe("certificates", () => {
|
|||||||
null,
|
null,
|
||||||
CERT_ERROR,
|
CERT_ERROR,
|
||||||
"stale",
|
"stale",
|
||||||
|
service.id,
|
||||||
);
|
);
|
||||||
repos.updateDomain(testApp.db, domain.id, {
|
|
||||||
group_id: null,
|
|
||||||
status: "active",
|
|
||||||
cert_monitoring: CERT_MONITOR_SKIPPED,
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
@@ -305,9 +323,16 @@ describe("certificates", () => {
|
|||||||
"broken.example.com",
|
"broken.example.com",
|
||||||
"cf-zone-broken",
|
"cf-zone-broken",
|
||||||
);
|
);
|
||||||
repos.updateDomain(testApp.db, domain.id, {
|
const service = repos.createService(testApp.db, "Broken", "broken");
|
||||||
group_id: null,
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
status: "active",
|
const binding = repos.insertBinding(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
service.id,
|
||||||
|
"@",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,7 +449,7 @@ describe("certificates", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const certs = repos.listCertificates(testApp.db);
|
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);
|
expect(certs.some((c) => c.hostname === "edge.ok.example.com")).toBe(true);
|
||||||
|
|
||||||
await testApp.close();
|
await testApp.close();
|
||||||
@@ -443,12 +468,7 @@ describe("certificates", () => {
|
|||||||
"force.example.com",
|
"force.example.com",
|
||||||
"cf-zone-force",
|
"cf-zone-force",
|
||||||
);
|
);
|
||||||
repos.updateDomain(testApp.db, domain.id, {
|
const group = repos.createServiceGroup(
|
||||||
group_id: null,
|
|
||||||
status: "active",
|
|
||||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
|
||||||
});
|
|
||||||
repos.createServiceGroup(
|
|
||||||
testApp.db,
|
testApp.db,
|
||||||
"Proxy",
|
"Proxy",
|
||||||
"vpn",
|
"vpn",
|
||||||
@@ -459,6 +479,19 @@ describe("certificates", () => {
|
|||||||
health_check_verify_tls: false,
|
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({
|
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||||
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||||
@@ -540,4 +573,137 @@ describe("certificates", () => {
|
|||||||
|
|
||||||
await testApp.close();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
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 { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
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 { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
|
||||||
import type { Certificate } from '@/lib/schemas'
|
import type { Certificate } from '@/lib/schemas'
|
||||||
import { formatDate, formatRelative } from '@/lib/format'
|
import { formatDate, formatRelative } from '@/lib/format'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
|
||||||
export const CERT_TABS = [
|
export const CERT_TABS = [
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
@@ -41,6 +43,7 @@ export function certTabFilter(item: Certificate, tabId: string) {
|
|||||||
export function createDefaultCertFilters() {
|
export function createDefaultCertFilters() {
|
||||||
return [
|
return [
|
||||||
createFilter('hostname', 'contains', ['']),
|
createFilter('hostname', 'contains', ['']),
|
||||||
|
createFilter('service', 'contains', ['']),
|
||||||
createFilter('status', 'is', ['']),
|
createFilter('status', 'is', ['']),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -56,6 +59,14 @@ export function useCertFilterFields() {
|
|||||||
className: 'w-52',
|
className: 'w-52',
|
||||||
placeholder: 'Поиск по хосту…',
|
placeholder: 'Поиск по хосту…',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'service',
|
||||||
|
label: 'Сервис',
|
||||||
|
icon: <ServerIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'w-52',
|
||||||
|
placeholder: 'Поиск по сервису…',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
label: 'Статус',
|
label: 'Статус',
|
||||||
@@ -75,6 +86,8 @@ export function certFilterFieldValue(item: Certificate, field: string) {
|
|||||||
switch (field) {
|
switch (field) {
|
||||||
case 'hostname':
|
case 'hostname':
|
||||||
return `${item.hostname} ${item.status}`.toLowerCase()
|
return `${item.hostname} ${item.status}`.toLowerCase()
|
||||||
|
case 'service':
|
||||||
|
return (item.service_name ?? '').toLowerCase()
|
||||||
case 'status':
|
case 'status':
|
||||||
return item.status
|
return item.status
|
||||||
default:
|
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)
|
const relative = formatRelative(expiresAt)
|
||||||
if (!expiresAt) {
|
if (!expiresAt) {
|
||||||
return <span className="text-muted-foreground tabular-nums">—</span>
|
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>
|
<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',
|
id: 'status',
|
||||||
header: 'Статус',
|
header: 'Статус',
|
||||||
|
|||||||
@@ -29,15 +29,21 @@ export interface HealthTimelineEvent {
|
|||||||
|
|
||||||
interface HealthTimelineProps {
|
interface HealthTimelineProps {
|
||||||
events: HealthTimelineEvent[]
|
events: HealthTimelineEvent[]
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HealthTimeline({ events }: HealthTimelineProps) {
|
export function HealthTimeline({
|
||||||
|
events,
|
||||||
|
emptyTitle = 'Нет событий',
|
||||||
|
emptyDescription = 'Результаты проверок появятся после первого прогона',
|
||||||
|
}: HealthTimelineProps) {
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Link2Icon}
|
icon={Link2Icon}
|
||||||
title="Нет событий"
|
title={emptyTitle}
|
||||||
description="Результаты проверок появятся после первого прогона"
|
description={emptyDescription}
|
||||||
centered={false}
|
centered={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ function getBreadcrumbs(
|
|||||||
return [{ label: 'Панель управления', href: '/' }]
|
return [{ label: 'Панель управления', href: '/' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.match(/^\/services\/\d+$/)) {
|
||||||
|
return [
|
||||||
|
{ label: 'Сервисы', href: '/services' },
|
||||||
|
{ label: dynamicLabels[pathname] ?? 'Сервис', href: pathname },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.match(/^\/groups\/\d+$/)) {
|
if (pathname.match(/^\/groups\/\d+$/)) {
|
||||||
return [
|
return [
|
||||||
{ label: 'Группы доменов', href: '/groups' },
|
{ label: 'Группы доменов', href: '/groups' },
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
ItemMedia,
|
ItemMedia,
|
||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from '@cfdm/ui/components/item'
|
} from '@cfdm/ui/components/item'
|
||||||
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
import type { HealthLogStatus } from '@/lib/health-log'
|
||||||
|
|
||||||
export type HealthProvider = HealthCheckProvider
|
export type HealthProvider = HealthCheckProvider
|
||||||
export type HealthAggregate = HealthCheckAggregate
|
export type HealthAggregate = HealthCheckAggregate
|
||||||
@@ -48,7 +50,7 @@ function GlobalpingMark() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDER_ITEMS: Array<{
|
export const HEALTH_PROVIDER_ITEMS: Array<{
|
||||||
id: HealthProvider
|
id: HealthProvider
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@@ -118,6 +120,7 @@ function ChoicePanel({
|
|||||||
icon,
|
icon,
|
||||||
iconClassName,
|
iconClassName,
|
||||||
role,
|
role,
|
||||||
|
trailing,
|
||||||
onActivate,
|
onActivate,
|
||||||
}: {
|
}: {
|
||||||
selected: boolean
|
selected: boolean
|
||||||
@@ -126,6 +129,7 @@ function ChoicePanel({
|
|||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
role: 'checkbox' | 'radio'
|
role: 'checkbox' | 'radio'
|
||||||
|
trailing?: ReactNode
|
||||||
onActivate: () => void
|
onActivate: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -157,12 +161,8 @@ function ChoicePanel({
|
|||||||
<ItemTitle className="w-full min-w-0">{title}</ItemTitle>
|
<ItemTitle className="w-full min-w-0">{title}</ItemTitle>
|
||||||
<ItemDescription>{description}</ItemDescription>
|
<ItemDescription>{description}</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
{selected ? (
|
{trailing ? (
|
||||||
<ItemActions className="shrink-0">
|
<ItemActions className="shrink-0">{trailing}</ItemActions>
|
||||||
<Badge variant="outline" size="sm">
|
|
||||||
Выбрано
|
|
||||||
</Badge>
|
|
||||||
</ItemActions>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Item>
|
</Item>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
@@ -202,7 +202,7 @@ export function HealthSourceTiles({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChoiceFrame>
|
<ChoiceFrame>
|
||||||
{PROVIDER_ITEMS.map((item) => (
|
{HEALTH_PROVIDER_ITEMS.map((item) => (
|
||||||
<ChoicePanel
|
<ChoicePanel
|
||||||
key={item.id}
|
key={item.id}
|
||||||
selected={selected.includes(item.id)}
|
selected={selected.includes(item.id)}
|
||||||
@@ -211,6 +211,13 @@ export function HealthSourceTiles({
|
|||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
iconClassName={item.iconClassName}
|
iconClassName={item.iconClassName}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
|
trailing={
|
||||||
|
selected.includes(item.id) ? (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
Выбрано
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
onActivate={() => toggle(item.id)}
|
onActivate={() => toggle(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -240,9 +247,71 @@ export function HealthAggregateTiles({
|
|||||||
description={item.description}
|
description={item.description}
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
role="radio"
|
role="radio"
|
||||||
|
trailing={
|
||||||
|
selected === item.id ? (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
Выбрано
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
onActivate={() => onChange(item.id)}
|
onActivate={() => onChange(item.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ChoiceFrame>
|
</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 { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||||
@@ -21,6 +23,7 @@ export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
|||||||
export {
|
export {
|
||||||
HealthSourceTiles,
|
HealthSourceTiles,
|
||||||
HealthAggregateTiles,
|
HealthAggregateTiles,
|
||||||
|
HealthProviderStatusTiles,
|
||||||
type HealthProvider,
|
type HealthProvider,
|
||||||
type HealthAggregate,
|
type HealthAggregate,
|
||||||
} from './health-source-tiles'
|
} 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ const LB_MODE_META: Record<
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function LbModeTile({ mode }: { mode: LbMode }) {
|
export function LbModeTile({ mode }: { mode: LbMode }) {
|
||||||
const meta = LB_MODE_META[mode]
|
const meta = LB_MODE_META[mode]
|
||||||
const Icon = meta.icon
|
const Icon = meta.icon
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { useEffect, useMemo } from 'react'
|
|||||||
import { useForm, Controller } from 'react-hook-form'
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { CertMonitoring } from '@cfdm/shared'
|
|
||||||
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
|
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
|
||||||
import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
|
import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
|
||||||
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
|
||||||
import { formatServiceGroupLabel } from '@/lib/service-utils'
|
import { formatServiceGroupLabel } from '@/lib/service-utils'
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
import { FormFieldSimple } from '@/components/form-field'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
@@ -30,7 +28,6 @@ import {
|
|||||||
const subdomainEditSchema = z.object({
|
const subdomainEditSchema = z.object({
|
||||||
name: z.string().min(1, 'Укажите имя'),
|
name: z.string().min(1, 'Укажите имя'),
|
||||||
serviceId: z.string(),
|
serviceId: z.string(),
|
||||||
certMonitoring: z.enum(['auto', 'required', 'skipped']),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export type SubdomainEditValues = z.infer<typeof subdomainEditSchema>
|
export type SubdomainEditValues = z.infer<typeof subdomainEditSchema>
|
||||||
@@ -67,7 +64,6 @@ export function SubdomainEditSheet({
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
serviceId: 'none',
|
serviceId: 'none',
|
||||||
certMonitoring: 'auto',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -85,42 +81,27 @@ export function SubdomainEditSheet({
|
|||||||
[services, serviceGroupById],
|
[services, serviceGroupById],
|
||||||
)
|
)
|
||||||
|
|
||||||
const certMonitoringItems = useMemo(
|
|
||||||
() =>
|
|
||||||
certMonitoringOptions.map((option) => ({
|
|
||||||
label: option.label,
|
|
||||||
value: option.value,
|
|
||||||
})),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
if (mode === 'edit' && subdomain) {
|
if (mode === 'edit' && subdomain) {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: subdomain.name,
|
name: subdomain.name,
|
||||||
serviceId: currentServiceId || 'none',
|
serviceId: currentServiceId || 'none',
|
||||||
certMonitoring: subdomain.cert_monitoring,
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
form.reset({
|
form.reset({
|
||||||
name: '',
|
name: '',
|
||||||
serviceId: 'none',
|
serviceId: 'none',
|
||||||
certMonitoring: 'auto',
|
|
||||||
})
|
})
|
||||||
}, [open, mode, subdomain, currentServiceId, form])
|
}, [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
|
const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1
|
||||||
|
|
||||||
function handleSubmit(values: SubdomainEditValues) {
|
function handleSubmit(values: SubdomainEditValues) {
|
||||||
onSubmit({
|
onSubmit({
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
serviceId: values.serviceId,
|
serviceId: values.serviceId,
|
||||||
certMonitoring: values.certMonitoring as CertMonitoring,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,39 +199,6 @@ export function SubdomainEditSheet({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormFieldSimple>
|
</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}
|
) : null}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
serviceGroupsQueryOptions,
|
serviceGroupsQueryOptions,
|
||||||
servicesQueryOptions,
|
servicesQueryOptions,
|
||||||
subdomainsListQueryOptions,
|
subdomainsListQueryOptions,
|
||||||
updateDomain,
|
|
||||||
updateSubdomain,
|
updateSubdomain,
|
||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import type { CertMonitoring } from '@cfdm/shared'
|
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({
|
const deleteSubdomainMutation = useMutation({
|
||||||
mutationFn: (id: number) => deleteSubdomain(id),
|
mutationFn: (id: number) => deleteSubdomain(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -249,7 +233,6 @@ export function useDomainPage(domainId: number) {
|
|||||||
syncMutation,
|
syncMutation,
|
||||||
createSubdomainMutation,
|
createSubdomainMutation,
|
||||||
updateSubdomainMutation,
|
updateSubdomainMutation,
|
||||||
updateDomainCertMonitoringMutation,
|
|
||||||
deleteSubdomainMutation,
|
deleteSubdomainMutation,
|
||||||
linkServiceMutation,
|
linkServiceMutation,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ export const certMonitoringOptions: Array<{
|
|||||||
{
|
{
|
||||||
value: 'auto',
|
value: 'auto',
|
||||||
label: 'Авто',
|
label: 'Авто',
|
||||||
description: 'Проверять, если хост обслуживается активным сервисом',
|
description: 'Проверять, если health-check сервиса с verify TLS',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'required',
|
value: 'required',
|
||||||
label: 'Обязательно',
|
label: 'Обязательно',
|
||||||
description: 'Всегда проверять SSL, даже без привязок',
|
description: 'Всегда проверять SSL для этого FQDN',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'skipped',
|
value: 'skipped',
|
||||||
label: 'Не проверять',
|
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_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).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'),
|
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),
|
sync_status: z.string().nullable().default(null),
|
||||||
})
|
})
|
||||||
.transform((binding) => ({
|
.transform((binding) => ({
|
||||||
@@ -197,6 +198,7 @@ export const serviceBindingSchema = z
|
|||||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
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_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
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),
|
sync_status: z.string().nullable().default(null),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
@@ -234,6 +236,8 @@ export const certificateSchema = z.object({
|
|||||||
id: z.number(),
|
id: z.number(),
|
||||||
domain_id: z.number(),
|
domain_id: z.number(),
|
||||||
subdomain_id: z.number().nullable(),
|
subdomain_id: z.number().nullable(),
|
||||||
|
service_id: z.number().nullable().optional().default(null),
|
||||||
|
service_name: z.string().nullable().optional().default(null),
|
||||||
hostname: z.string(),
|
hostname: z.string(),
|
||||||
expires_at: z.string().nullable(),
|
expires_at: z.string().nullable(),
|
||||||
last_checked_at: z.string().nullable(),
|
last_checked_at: z.string().nullable(),
|
||||||
@@ -243,6 +247,19 @@ export const certificateSchema = z.object({
|
|||||||
updated_at: z.string(),
|
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 Group = z.infer<typeof groupSchema>
|
||||||
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
||||||
export type Service = z.infer<typeof serviceSchema>
|
export type Service = z.infer<typeof serviceSchema>
|
||||||
@@ -256,6 +273,7 @@ export type DomainListItem = z.infer<typeof domainListItemSchema>
|
|||||||
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
|
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
|
||||||
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
||||||
export type Certificate = z.infer<typeof certificateSchema>
|
export type Certificate = z.infer<typeof certificateSchema>
|
||||||
|
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
|
||||||
|
|
||||||
export const createGroupSchema = z.object({
|
export const createGroupSchema = z.object({
|
||||||
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
|
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query'
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
import { api } from '@/lib/api-client'
|
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'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const certKeys = {
|
export const certKeys = {
|
||||||
all: ['certificates'] as const,
|
all: ['certificates'] as const,
|
||||||
summary: ['certificates', 'summary'] as const,
|
summary: ['certificates', 'summary'] as const,
|
||||||
|
byService: (serviceId: number) =>
|
||||||
|
[...certKeys.all, 'service', serviceId] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const certificatesQueryOptions = () =>
|
export const certificatesQueryOptions = () =>
|
||||||
@@ -23,3 +26,29 @@ export const certSummaryQueryOptions = () =>
|
|||||||
queryKey: certKeys.summary,
|
queryKey: certKeys.summary,
|
||||||
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/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>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Сертификаты"
|
title="Сертификаты"
|
||||||
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
|
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
|
||||||
actions={primaryAction}
|
actions={primaryAction}
|
||||||
/>
|
/>
|
||||||
<CertKpiStats
|
<CertKpiStats
|
||||||
@@ -86,7 +86,7 @@ function CertificatesPage() {
|
|||||||
/>
|
/>
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="Сертификаты"
|
title="Сертификаты"
|
||||||
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
|
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
|
||||||
hideHeader
|
hideHeader
|
||||||
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
|
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import {
|
|||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
Link2Icon,
|
Link2Icon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
ShieldCheckIcon,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { Filter } from '@/components/reui/filters'
|
import type { Filter } from '@/components/reui/filters'
|
||||||
import type { CertMonitoring } from '@cfdm/shared'
|
|
||||||
import {
|
import {
|
||||||
domainDetailQueryOptions,
|
domainDetailQueryOptions,
|
||||||
domainServiceBindingsQueryOptions,
|
domainServiceBindingsQueryOptions,
|
||||||
@@ -41,19 +39,11 @@ import {
|
|||||||
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
|
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
|
||||||
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
|
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
|
|
||||||
import { formatDate } from '@/lib/format'
|
import { formatDate } from '@/lib/format'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
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'
|
import { TabsContent } from '@cfdm/ui/components/tabs'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||||
@@ -108,7 +98,6 @@ function DomainOverviewPage() {
|
|||||||
syncMutation,
|
syncMutation,
|
||||||
createSubdomainMutation,
|
createSubdomainMutation,
|
||||||
updateSubdomainMutation,
|
updateSubdomainMutation,
|
||||||
updateDomainCertMonitoringMutation,
|
|
||||||
deleteSubdomainMutation,
|
deleteSubdomainMutation,
|
||||||
linkServiceMutation,
|
linkServiceMutation,
|
||||||
} = useDomainPage(id)
|
} = useDomainPage(id)
|
||||||
@@ -167,20 +156,15 @@ function DomainOverviewPage() {
|
|||||||
if (!editTarget) return
|
if (!editTarget) return
|
||||||
|
|
||||||
const nameChanged = values.name !== editTarget.subdomain.name
|
const nameChanged = values.name !== editTarget.subdomain.name
|
||||||
const certMonitoringChanged =
|
|
||||||
values.certMonitoring !== editTarget.subdomain.cert_monitoring
|
|
||||||
const currentServiceId = resolveServiceId(editTarget)
|
const currentServiceId = resolveServiceId(editTarget)
|
||||||
const serviceChanged = values.serviceId !== currentServiceId
|
const serviceChanged = values.serviceId !== currentServiceId
|
||||||
const targetServiceId =
|
const targetServiceId =
|
||||||
values.serviceId === 'none' ? null : Number(values.serviceId)
|
values.serviceId === 'none' ? null : Number(values.serviceId)
|
||||||
|
|
||||||
if (nameChanged || certMonitoringChanged) {
|
if (nameChanged) {
|
||||||
await updateSubdomainMutation.mutateAsync({
|
await updateSubdomainMutation.mutateAsync({
|
||||||
id: editTarget.subdomain.id,
|
id: editTarget.subdomain.id,
|
||||||
...(nameChanged ? { name: values.name } : {}),
|
name: values.name,
|
||||||
...(certMonitoringChanged
|
|
||||||
? { cert_monitoring: values.certMonitoring }
|
|
||||||
: {}),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +193,6 @@ function DomainOverviewPage() {
|
|||||||
updateSubdomainMutation.isPending ||
|
updateSubdomainMutation.isPending ||
|
||||||
linkServiceMutation.isPending
|
linkServiceMutation.isPending
|
||||||
|
|
||||||
const certMonitoringItems = certMonitoringOptions.map((option) => ({
|
|
||||||
label: option.label,
|
|
||||||
value: option.value,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const metricCards = useMemo(() => {
|
const metricCards = useMemo(() => {
|
||||||
if (!domain) return []
|
if (!domain) return []
|
||||||
return [
|
return [
|
||||||
@@ -344,40 +323,6 @@ function DomainOverviewPage() {
|
|||||||
>
|
>
|
||||||
<TabsContent value="overview" className="flex flex-col gap-4">
|
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||||
<DetailPanel.Metrics cards={metricCards} />
|
<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>
|
||||||
|
|
||||||
<TabsContent value="availability" className="flex flex-col gap-4">
|
<TabsContent value="availability" className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -1,104 +1,11 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, redirect } 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'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
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 { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useMemo, useState } from 'react'
|
||||||
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
|
import { useForm } from 'react-hook-form'
|
||||||
import { DetailPanel } from '@/components/reui-kit'
|
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 { 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 { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { serviceOverviewQueryOptions } from '@/queries'
|
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/')({
|
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
||||||
component: ServiceOverviewPage,
|
component: ServiceDetailPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function ServiceOverviewPage() {
|
interface OverviewPayload {
|
||||||
const { serviceId } = Route.useParams()
|
routing_strategy?: string
|
||||||
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
|
active_addresses?: string[]
|
||||||
const overview = data as {
|
nodes?: Array<{
|
||||||
service: {
|
id: number
|
||||||
name: string
|
address: string
|
||||||
enabled: boolean
|
protocol: string
|
||||||
health_status: 'up' | 'down' | 'degraded' | 'unknown'
|
port: number | null
|
||||||
domains: Array<{ fqdn: string; zone_name: string }>
|
health_status: string
|
||||||
}
|
weight: number
|
||||||
nodes: Array<{ id: number; address: string; health_status: string }>
|
priority: number
|
||||||
routing_strategy: string
|
consecutive_failures: number
|
||||||
active_addresses: string[]
|
last_failure_reason: string | null
|
||||||
} | undefined
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
if (!overview) {
|
function ServiceDetailPage() {
|
||||||
return (
|
const { serviceId } = Route.useParams()
|
||||||
<EmptyState
|
const id = Number(serviceId)
|
||||||
title="Сервис не найден"
|
const navigate = useNavigate()
|
||||||
description="Вернитесь в каталог и выберите сервис."
|
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 updateMutation = useMutation({
|
||||||
const domains = overview.service.domains ?? []
|
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 (
|
return (
|
||||||
<DetailPanel>
|
<QueryState
|
||||||
<DetailPanel.Header
|
isLoading={isLoading}
|
||||||
title={overview.service.name}
|
isError={isError}
|
||||||
description={`Маршрутизация: ${overview.routing_strategy}. Активные IP: ${
|
error={error}
|
||||||
overview.active_addresses.join(', ') || '—'
|
onRetry={() => {
|
||||||
}`}
|
void viewQuery.refetch()
|
||||||
actions={
|
void overviewQuery.refetch()
|
||||||
<HealthCheckBadge status={overview.service.health_status} />
|
}}
|
||||||
}
|
>
|
||||||
/>
|
{!service ? (
|
||||||
<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 ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Пустой сервис"
|
title="Сервис не найден"
|
||||||
description="Добавьте поддомен и ноду, затем настройте health-check."
|
description="Вернитесь в каталог и выберите сервис."
|
||||||
stackedIcon
|
|
||||||
/>
|
/>
|
||||||
) : 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 { createFileRoute, redirect } 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'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
|
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 { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { ArrowLeftIcon } from 'lucide-react'
|
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import {
|
||||||
import { QueryState } from '@/components/query-state'
|
serviceHealthLogQueryOptions,
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
serviceNodesQueryOptions,
|
||||||
import { serviceOverviewQueryOptions } from '@/queries'
|
serviceOverviewQueryOptions,
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
serviceViewQueryOptions,
|
||||||
|
} from '@/queries'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
||||||
loader: ({ context: { queryClient }, params }) =>
|
loader: async ({ context: { queryClient }, params }) => {
|
||||||
queryClient.ensureQueryData(serviceOverviewQueryOptions(Number(params.serviceId))),
|
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,
|
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() {
|
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 (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<Outlet />
|
||||||
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>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,11 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, redirect } 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'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
|
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 { createFileRoute, redirect } 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'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
|
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_timeout_ms: row.health_check_timeout_ms,
|
||||||
health_check_verify_tls: row.health_check_verify_tls,
|
health_check_verify_tls: row.health_check_verify_tls,
|
||||||
...mapHealthFields(row),
|
...mapHealthFields(row),
|
||||||
|
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||||
routing_strategy: row.routing_strategy as LbMode,
|
routing_strategy: row.routing_strategy as LbMode,
|
||||||
operation_version: row.operation_version,
|
operation_version: row.operation_version,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
@@ -1530,6 +1531,7 @@ export interface BindingLbPatch {
|
|||||||
health_check_provider?: HealthCheckProvider;
|
health_check_provider?: HealthCheckProvider;
|
||||||
health_check_providers?: HealthCheckProvider[];
|
health_check_providers?: HealthCheckProvider[];
|
||||||
health_check_aggregate?: HealthCheckAggregate;
|
health_check_aggregate?: HealthCheckAggregate;
|
||||||
|
cert_monitoring?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateBindingLbConfig(
|
export function updateBindingLbConfig(
|
||||||
@@ -1560,6 +1562,8 @@ export function updateBindingLbConfig(
|
|||||||
update.health_check_timeout_ms = patch.health_check_timeout_ms;
|
update.health_check_timeout_ms = patch.health_check_timeout_ms;
|
||||||
if (patch.health_check_verify_tls !== undefined)
|
if (patch.health_check_verify_tls !== undefined)
|
||||||
update.health_check_verify_tls = patch.health_check_verify_tls;
|
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));
|
Object.assign(update, healthProviderColumns(patch));
|
||||||
db.update(serviceBindings)
|
db.update(serviceBindings)
|
||||||
.set(update)
|
.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.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_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_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,
|
d.zone_name, d.group_id, g.name AS group_name,
|
||||||
s.name AS service_name, s.slug AS service_slug,
|
s.name AS service_name, s.slug AS service_slug,
|
||||||
dr.content AS target_ip, dr.sync_status,
|
dr.content AS target_ip, dr.sync_status,
|
||||||
@@ -1733,6 +1737,7 @@ function enrichServiceBindingView(
|
|||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
cname_target: row.cname_target ?? null,
|
cname_target: row.cname_target ?? null,
|
||||||
|
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||||
...mapHealthFields(row),
|
...mapHealthFields(row),
|
||||||
target_ips,
|
target_ips,
|
||||||
target_ip: target_ips[0] ?? null,
|
target_ip: target_ips[0] ?? null,
|
||||||
@@ -1914,26 +1919,69 @@ export function deleteBinding(db: Db, id: number): void {
|
|||||||
|
|
||||||
// --- Certificates ---
|
// --- 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[] {
|
export function listCertificates(db: Db, status?: string): Certificate[] {
|
||||||
if (status) {
|
const rows = status
|
||||||
return db
|
? db.all<CertificateRow>(sql`
|
||||||
.select()
|
SELECT ${sql.raw(CERTIFICATE_SELECT)}
|
||||||
.from(certificates)
|
FROM certificates c
|
||||||
.where(eq(certificates.status, status))
|
LEFT JOIN services s ON s.id = c.service_id
|
||||||
.orderBy(asc(certificates.expires_at))
|
WHERE c.status = ${status}
|
||||||
.all() as Certificate[];
|
ORDER BY c.expires_at ASC
|
||||||
}
|
`)
|
||||||
return db
|
: db.all<CertificateRow>(sql`
|
||||||
.select()
|
SELECT ${sql.raw(CERTIFICATE_SELECT)}
|
||||||
.from(certificates)
|
FROM certificates c
|
||||||
.orderBy(asc(certificates.expires_at))
|
LEFT JOIN services s ON s.id = c.service_id
|
||||||
.all() as Certificate[];
|
ORDER BY c.expires_at ASC
|
||||||
|
`);
|
||||||
|
return rows.map(mapCertificate);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCertificate(db: Db, id: number): Certificate {
|
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}`);
|
if (!row) throw new NotFoundError(`certificate ${id}`);
|
||||||
return row as Certificate;
|
return mapCertificate(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertCertificateCheck(
|
export function upsertCertificateCheck(
|
||||||
@@ -1944,6 +1992,7 @@ export function upsertCertificateCheck(
|
|||||||
expiresAt: string | null,
|
expiresAt: string | null,
|
||||||
status: string,
|
status: string,
|
||||||
lastError: string | null,
|
lastError: string | null,
|
||||||
|
serviceId?: number | null,
|
||||||
): Certificate {
|
): Certificate {
|
||||||
const existing = db
|
const existing = db
|
||||||
.select()
|
.select()
|
||||||
@@ -1956,6 +2005,7 @@ export function upsertCertificateCheck(
|
|||||||
.set({
|
.set({
|
||||||
domain_id: domainId,
|
domain_id: domainId,
|
||||||
subdomain_id: subdomainId,
|
subdomain_id: subdomainId,
|
||||||
|
service_id: serviceId === undefined ? existing.service_id : serviceId,
|
||||||
expires_at: expiresAt,
|
expires_at: expiresAt,
|
||||||
last_checked_at: sql`datetime('now')`,
|
last_checked_at: sql`datetime('now')`,
|
||||||
last_error: lastError,
|
last_error: lastError,
|
||||||
@@ -1972,6 +2022,7 @@ export function upsertCertificateCheck(
|
|||||||
.values({
|
.values({
|
||||||
domain_id: domainId,
|
domain_id: domainId,
|
||||||
subdomain_id: subdomainId,
|
subdomain_id: subdomainId,
|
||||||
|
service_id: serviceId ?? null,
|
||||||
hostname,
|
hostname,
|
||||||
expires_at: expiresAt,
|
expires_at: expiresAt,
|
||||||
last_checked_at: sql`datetime('now')`,
|
last_checked_at: sql`datetime('now')`,
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ export const serviceBindings = sqliteTable(
|
|||||||
health_check_aggregate: text("health_check_aggregate")
|
health_check_aggregate: text("health_check_aggregate")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("majority"),
|
.default("majority"),
|
||||||
|
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
|
||||||
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
|
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
|
||||||
operation_version: integer("operation_version").notNull().default(0),
|
operation_version: integer("operation_version").notNull().default(0),
|
||||||
created_at: text("created_at")
|
created_at: text("created_at")
|
||||||
@@ -324,6 +325,9 @@ export const certificates = sqliteTable("certificates", {
|
|||||||
subdomain_id: integer("subdomain_id").references(() => subdomains.id, {
|
subdomain_id: integer("subdomain_id").references(() => subdomains.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
service_id: integer("service_id").references(() => services.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
hostname: text("hostname").notNull().unique(),
|
hostname: text("hostname").notNull().unique(),
|
||||||
expires_at: text("expires_at"),
|
expires_at: text("expires_at"),
|
||||||
last_checked_at: text("last_checked_at"),
|
last_checked_at: text("last_checked_at"),
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export const serviceDomainBindingSchema = z
|
|||||||
health_check_provider: healthCheckProviderSchema.catch('local'),
|
health_check_provider: healthCheckProviderSchema.catch('local'),
|
||||||
health_check_providers: healthCheckProvidersSchema.catch(['local']),
|
health_check_providers: healthCheckProvidersSchema.catch(['local']),
|
||||||
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
|
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
|
||||||
|
cert_monitoring: certMonitoringSchema.default('auto'),
|
||||||
sync_status: z.string().nullable().default(null),
|
sync_status: z.string().nullable().default(null),
|
||||||
})
|
})
|
||||||
.transform((binding) => ({
|
.transform((binding) => ({
|
||||||
@@ -266,6 +267,7 @@ export const serviceBindingSchema = z
|
|||||||
health_check_interval_sec: z.number().default(30),
|
health_check_interval_sec: z.number().default(30),
|
||||||
health_check_timeout_ms: z.number().default(3000),
|
health_check_timeout_ms: z.number().default(3000),
|
||||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||||
|
cert_monitoring: certMonitoringSchema.default('auto'),
|
||||||
sync_status: z.string().nullable().default(null),
|
sync_status: z.string().nullable().default(null),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
@@ -303,6 +305,8 @@ export const certificateSchema = z.object({
|
|||||||
id: z.number(),
|
id: z.number(),
|
||||||
domain_id: z.number(),
|
domain_id: z.number(),
|
||||||
subdomain_id: z.number().nullable(),
|
subdomain_id: z.number().nullable(),
|
||||||
|
service_id: z.number().nullable().optional().default(null),
|
||||||
|
service_name: z.string().nullable().optional().default(null),
|
||||||
hostname: z.string(),
|
hostname: z.string(),
|
||||||
expires_at: z.string().nullable(),
|
expires_at: z.string().nullable(),
|
||||||
last_checked_at: z.string().nullable(),
|
last_checked_at: z.string().nullable(),
|
||||||
@@ -312,6 +316,19 @@ export const certificateSchema = z.object({
|
|||||||
updated_at: z.string(),
|
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 Group = z.infer<typeof groupSchema>
|
||||||
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
||||||
export type Service = z.infer<typeof serviceSchema>
|
export type Service = z.infer<typeof serviceSchema>
|
||||||
@@ -324,6 +341,7 @@ export type Domain = z.infer<typeof domainSchema>
|
|||||||
export type DomainListItem = z.infer<typeof domainListItemSchema>
|
export type DomainListItem = z.infer<typeof domainListItemSchema>
|
||||||
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
||||||
export type Certificate = z.infer<typeof certificateSchema>
|
export type Certificate = z.infer<typeof certificateSchema>
|
||||||
|
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
|
||||||
|
|
||||||
export const createGroupSchema = z.object({
|
export const createGroupSchema = z.object({
|
||||||
name: z.string().min(1, 'Укажите название'),
|
name: z.string().min(1, 'Укажите название'),
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export interface Certificate {
|
|||||||
id: number;
|
id: number;
|
||||||
domain_id: number;
|
domain_id: number;
|
||||||
subdomain_id: number | null;
|
subdomain_id: number | null;
|
||||||
|
service_id: number | null;
|
||||||
|
service_name: string | null;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
last_checked_at: string | null;
|
last_checked_at: string | null;
|
||||||
@@ -139,6 +141,7 @@ export interface ServiceBinding {
|
|||||||
health_check_provider: HealthCheckProvider;
|
health_check_provider: HealthCheckProvider;
|
||||||
health_check_providers: HealthCheckProvider[];
|
health_check_providers: HealthCheckProvider[];
|
||||||
health_check_aggregate: HealthCheckAggregate;
|
health_check_aggregate: HealthCheckAggregate;
|
||||||
|
cert_monitoring: string;
|
||||||
routing_strategy: LbMode;
|
routing_strategy: LbMode;
|
||||||
operation_version: number;
|
operation_version: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -173,6 +176,7 @@ export interface ServiceBindingView {
|
|||||||
health_check_provider: HealthCheckProvider;
|
health_check_provider: HealthCheckProvider;
|
||||||
health_check_providers: HealthCheckProvider[];
|
health_check_providers: HealthCheckProvider[];
|
||||||
health_check_aggregate: HealthCheckAggregate;
|
health_check_aggregate: HealthCheckAggregate;
|
||||||
|
cert_monitoring: string;
|
||||||
sync_status: string | null;
|
sync_status: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -201,6 +205,7 @@ export interface ServiceDomainBindingView {
|
|||||||
health_check_provider: HealthCheckProvider;
|
health_check_provider: HealthCheckProvider;
|
||||||
health_check_providers: HealthCheckProvider[];
|
health_check_providers: HealthCheckProvider[];
|
||||||
health_check_aggregate: HealthCheckAggregate;
|
health_check_aggregate: HealthCheckAggregate;
|
||||||
|
cert_monitoring: string;
|
||||||
sync_status: string | null;
|
sync_status: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user