feat(certificates): enhance service certificate management and monitoring
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 10s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m8s
quality / api (push) Successful in 1m9s
CD / quality (push) Successful in 2m31s
CD / publish (push) Successful in 1m35s
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 10s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m8s
quality / api (push) Successful in 1m9s
CD / quality (push) Successful in 2m31s
CD / publish (push) Successful in 1m35s
- Added new endpoints for listing and checking service certificates, improving visibility into SSL status. - Integrated certificate monitoring options into service binding updates, allowing for flexible SSL management. - Updated the service detail grid to include SSL monitoring controls, enhancing user interaction with certificate settings. - Refactored related components and schemas to support the new certificate features, ensuring consistency across the application. - Improved test coverage for certificate functionalities, validating the new features and ensuring reliability.
This commit is contained in:
@@ -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) {
|
||||||
@@ -77,6 +78,23 @@ export async function serviceRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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: 'Статус',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState, type ReactNode } from 'react'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import {
|
import {
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
@@ -6,7 +7,9 @@ import {
|
|||||||
PlusIcon,
|
PlusIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
@@ -15,9 +18,28 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
|
|||||||
import { IconTile } from '@/components/reui/icon-tile'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
|
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import { ResourcePage } from '@/components/reui-kit'
|
import { ResourcePage } from '@/components/reui-kit'
|
||||||
import type { ServiceView } from '@/lib/schemas'
|
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 { Button } from '@cfdm/ui/components/button'
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
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'
|
type HealthStatus = 'up' | 'down' | 'degraded' | 'unknown'
|
||||||
|
|
||||||
@@ -60,6 +82,7 @@ const TABS = [
|
|||||||
{ id: 'ip', label: 'IP' },
|
{ id: 'ip', label: 'IP' },
|
||||||
{ id: 'fqdn', label: 'FQDN' },
|
{ id: 'fqdn', label: 'FQDN' },
|
||||||
{ id: 'nodes', label: 'Ноды' },
|
{ id: 'nodes', label: 'Ноды' },
|
||||||
|
{ id: 'ssl', label: 'SSL' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const HEALTH_OPTIONS = [
|
const HEALTH_OPTIONS = [
|
||||||
@@ -208,6 +231,57 @@ export function ServiceDetailGrid({
|
|||||||
createFilter('address', 'contains', ['']),
|
createFilter('address', 'contains', ['']),
|
||||||
createFilter('health_status', 'is', ['']),
|
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 ipRows = useMemo(() => buildIpRows(service), [service])
|
||||||
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
|
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
|
||||||
@@ -221,7 +295,9 @@ export function ServiceDetailGrid({
|
|||||||
? ipRows.length
|
? ipRows.length
|
||||||
: entry.id === 'fqdn'
|
: entry.id === 'fqdn'
|
||||||
? fqdnRows.length
|
? fqdnRows.length
|
||||||
: nodeRows.length,
|
: entry.id === 'ssl'
|
||||||
|
? sslRows.length
|
||||||
|
: nodeRows.length,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const ipFilterFields = useMemo<FilterFieldConfig[]>(
|
const ipFilterFields = useMemo<FilterFieldConfig[]>(
|
||||||
@@ -282,6 +358,20 @@ export function ServiceDetailGrid({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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>[]>(
|
const ipColumns = useMemo<ColumnDef<ServiceIpRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -473,6 +563,91 @@ export function ServiceDetailGrid({
|
|||||||
[onDeleteNode],
|
[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 = {
|
const sharedTabs = {
|
||||||
tabs,
|
tabs,
|
||||||
activeTab: tab,
|
activeTab: tab,
|
||||||
@@ -544,6 +719,52 @@ export function ServiceDetailGrid({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="Активы сервиса"
|
title="Активы сервиса"
|
||||||
|
|||||||
@@ -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 из мониторинга сертификатов',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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