diff --git a/apps/api/src/routes/service-bindings.ts b/apps/api/src/routes/service-bindings.ts index 4b9a203..3a70c3c 100644 --- a/apps/api/src/routes/service-bindings.ts +++ b/apps/api/src/routes/service-bindings.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { z } from "zod"; -import { changeIpSchema } from "@cfdm/shared"; +import { certMonitoringSchema, changeIpSchema } from "@cfdm/shared"; import * as bindingService from "../services/binding-service.js"; import * as changeIp from "../services/change-ip-service.js"; import { recordAudit } from "../lib/audit.js"; @@ -17,6 +17,7 @@ export async function serviceBindingRoutes(app: FastifyInstance) { service_id: z.number().optional(), hostname: z.string().optional(), target_ip: z.string().optional(), + cert_monitoring: certMonitoringSchema.optional(), }); app.get("/service-bindings", async (request) => { diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index fa1e15c..4107c38 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -12,6 +12,7 @@ import { repos } from "@cfdm/db"; import * as serviceConfig from "../services/service-config-service.js"; import * as nodeService from "../services/node-service.js"; import * as changeDomain from "../services/change-domain-service.js"; +import * as certificateService from "../services/certificate-service.js"; import { recordAudit } from "../lib/audit.js"; export async function serviceRoutes(app: FastifyInstance) { @@ -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) => { const { id } = request.params as { id: string }; return nodeService.getOverview(request.server.db, Number(id)); diff --git a/apps/api/src/services/binding-service.ts b/apps/api/src/services/binding-service.ts index 981aed8..85b4871 100644 --- a/apps/api/src/services/binding-service.ts +++ b/apps/api/src/services/binding-service.ts @@ -15,6 +15,7 @@ export interface UpdateBindingRequest { service_id?: number; hostname?: string; target_ip?: string; + cert_monitoring?: string; } function normalizeHostname(hostname?: string): string { @@ -92,6 +93,20 @@ export async function update( req: UpdateBindingRequest, ): Promise { const existing = repos.getBinding(db, id); + if (req.cert_monitoring !== undefined) { + repos.updateBindingLbConfig(db, id, { + cert_monitoring: req.cert_monitoring, + }); + } + + const hasIdentityPatch = + req.service_id !== undefined || + req.hostname !== undefined || + req.target_ip !== undefined; + if (!hasIdentityPatch) { + return repos.getBindingView(db, id); + } + const serviceId = req.service_id ?? existing.service_id; if (req.service_id) repos.getService(db, req.service_id); const hostname = req.hostname diff --git a/apps/api/src/services/certificate-service.ts b/apps/api/src/services/certificate-service.ts index cf5b93b..62775b4 100644 --- a/apps/api/src/services/certificate-service.ts +++ b/apps/api/src/services/certificate-service.ts @@ -2,7 +2,7 @@ import { connect } from "node:net"; import { connect as tlsConnect } from "node:tls"; import type { Db } from "@cfdm/db"; import { repos } from "@cfdm/db"; -import type { Certificate, Domain, Subdomain } from "@cfdm/shared"; +import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared"; import { CERT_ERROR, CERT_MONITOR_AUTO, @@ -11,13 +11,13 @@ import { CERT_UNKNOWN, certStatusFromExpiry, fqdnToDisplay, - parseFqdn, shouldMonitorService, } from "@cfdm/shared"; export interface CertificateTarget { domainId: number; subdomainId: number | null; + serviceId: number; hostname: string; } @@ -41,6 +41,34 @@ export function getCertificate(db: Db, id: number): Certificate { return repos.getCertificate(db, id); } +export function listServiceCertificates( + db: Db, + serviceId: number, +): ServiceCertificateRow[] { + repos.getService(db, serviceId); + const certsByHost = new Map( + repos.listCertificates(db).map((cert) => [cert.hostname, cert]), + ); + return repos.listBindingsByService(db, serviceId).map((binding) => { + const hostname = fqdnToDisplay(binding.hostname, binding.zone_name); + const cert = certsByHost.get(hostname); + return { + binding_id: binding.id, + domain_id: binding.domain_id, + service_id: binding.service_id, + hostname, + cert_monitoring: + (binding.cert_monitoring as ServiceCertificateRow["cert_monitoring"]) ?? + "auto", + id: cert?.id ?? null, + status: cert?.status ?? "unknown", + expires_at: cert?.expires_at ?? null, + last_checked_at: cert?.last_checked_at ?? null, + last_error: cert?.last_error ?? null, + }; + }); +} + export async function checkHostname( hostname: string, ): Promise<{ expiresAt: Date | null; error: string | null }> { @@ -78,6 +106,7 @@ export async function checkAndStore( domainId: number, subdomainId: number | null, hostname: string, + serviceId: number | null = null, ): Promise { const { expiresAt, error } = await checkHostname(hostname); @@ -90,6 +119,7 @@ export async function checkAndStore( null, CERT_ERROR, error, + serviceId, ); } @@ -105,6 +135,7 @@ export async function checkAndStore( expiresAt.toISOString(), certStatusFromExpiry(days), null, + serviceId, ); } @@ -116,23 +147,10 @@ export async function checkAndStore( null, CERT_UNKNOWN, "unknown expiry", + serviceId, ); } -function resolveMonitoringMode( - domain: Domain, - subdomain: Subdomain | null, - fqdn: string, -): string { - if (subdomain) { - return subdomain.cert_monitoring; - } - if (fqdn === domain.zone_name) { - return domain.cert_monitoring; - } - return CERT_MONITOR_AUTO; -} - function bindingSubdomain( db: Db, domainId: number, @@ -165,10 +183,9 @@ function hasSslHealthGate( return false; } -export function buildServiceCertificateFqdns( - db: Db, -): Map { - const result = new Map(); +export function resolveCertificateTargets(db: Db): CertificateTarget[] { + const targets: CertificateTarget[] = []; + const seen = new Set(); for (const binding of repos.listAllBindings(db)) { const service = repos.getService(db, binding.service_id); @@ -176,98 +193,40 @@ export function buildServiceCertificateFqdns( ? repos.getServiceGroup(db, service.service_group_id) : null; if (!shouldMonitorService(service, group)) continue; - if ( - !hasSslHealthGate( - { - health_check_enabled: binding.health_check_enabled, - health_check_verify_tls: binding.health_check_verify_tls, - }, - group, - ) - ) { - continue; - } const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname); if (subdomain && !subdomain.enabled) continue; + const mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO; + if (mode === CERT_MONITOR_SKIPPED) continue; + if (mode === CERT_MONITOR_AUTO) { + if ( + !hasSslHealthGate( + { + health_check_enabled: binding.health_check_enabled, + health_check_verify_tls: binding.health_check_verify_tls, + }, + group, + ) + ) { + continue; + } + } else if (mode !== CERT_MONITOR_REQUIRED) { + continue; + } + const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name); - result.set(fqdn, { + if (seen.has(fqdn)) continue; + seen.add(fqdn); + targets.push({ domainId: binding.domain_id, subdomainId: subdomain?.id ?? null, + serviceId: binding.service_id, hostname: fqdn, }); } - const knownZones = repos.listAllDomains(db).map((d) => d.zone_name); - for (const group of repos.listServiceGroups(db)) { - if (!group.enabled || !group.domain?.trim()) continue; - if (!group.health_check_enabled || !group.health_check_verify_tls) continue; - - const parsed = parseFqdn(group.domain, knownZones); - if (!parsed) continue; - - const domain = repos.findDomainByZoneName(db, parsed.zoneName); - if (!domain) continue; - - const subdomain = - parsed.hostname === "@" - ? null - : bindingSubdomain(db, domain.id, parsed.hostname); - if (subdomain && !subdomain.enabled) continue; - - result.set(parsed.fqdn, { - domainId: domain.id, - subdomainId: subdomain?.id ?? null, - hostname: parsed.fqdn, - }); - } - - return result; -} - -export function resolveCertificateTargets(db: Db): CertificateTarget[] { - const serviceFqdns = buildServiceCertificateFqdns(db); - const targets = new Map(); - - for (const domain of repos.listAllDomains(db)) { - if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue; - if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) { - targets.set(domain.zone_name, { - domainId: domain.id, - subdomainId: null, - hostname: domain.zone_name, - }); - } - } - - for (const sub of repos.listAllSubdomains(db)) { - if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue; - if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) { - targets.set(sub.fqdn, { - domainId: sub.domain_id, - subdomainId: sub.id, - hostname: sub.fqdn, - }); - } - } - - for (const [fqdn, meta] of serviceFqdns) { - const domain = repos.getDomain(db, meta.domainId); - const subdomain = meta.subdomainId - ? repos.getSubdomain(db, meta.subdomainId) - : null; - const monitoring = resolveMonitoringMode(domain, subdomain, fqdn); - if (monitoring === CERT_MONITOR_SKIPPED) continue; - if ( - monitoring === CERT_MONITOR_AUTO || - monitoring === CERT_MONITOR_REQUIRED - ) { - targets.set(fqdn, meta); - } - } - - return [...targets.values()]; + return targets; } export async function runAllChecks(db: Db): Promise { @@ -278,6 +237,7 @@ export async function runAllChecks(db: Db): Promise { target.domainId, target.subdomainId, target.hostname, + target.serviceId, ); } repos.deleteCertificatesNotIn( @@ -287,6 +247,26 @@ export async function runAllChecks(db: Db): Promise { return targets.length; } +export async function runServiceChecks( + db: Db, + serviceId: number, +): Promise { + repos.getService(db, serviceId); + const targets = resolveCertificateTargets(db).filter( + (target) => target.serviceId === serviceId, + ); + for (const target of targets) { + await checkAndStore( + db, + target.domainId, + target.subdomainId, + target.hostname, + target.serviceId, + ); + } + return targets.length; +} + export function statusSummary(db: Db): Array<[string, number]> { pruneStaleCertificates(db); return repos.countCertificatesByStatus(db); diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index 4f268d9..f47b85f 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -308,6 +308,7 @@ async function buildView(db: Db, serviceId: number): Promise { binding.health_check_provider ?? "local", ], health_check_aggregate: binding.health_check_aggregate ?? "majority", + cert_monitoring: binding.cert_monitoring ?? "auto", sync_status: aggregateSyncStatus(statuses), }; }); diff --git a/apps/api/test/certificates.test.ts b/apps/api/test/certificates.test.ts index 0a6fb43..446898e 100644 --- a/apps/api/test/certificates.test.ts +++ b/apps/api/test/certificates.test.ts @@ -208,7 +208,7 @@ describe("certificates", () => { await testApp.close(); }); - it("required apex is monitored without bindings", async () => { + it("required binding is monitored without TLS health gate", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, @@ -221,10 +221,18 @@ describe("certificates", () => { "required.example.com", "cf-zone-req", ); - repos.updateDomain(testApp.db, domain.id, { - group_id: null, - status: "active", + const service = repos.createService(testApp.db, "Req", "req"); + repos.setServiceEnabled(testApp.db, service.id, true); + const binding = repos.insertBinding( + testApp.db, + domain.id, + service.id, + "@", + null, + ); + repos.updateBindingLbConfig(testApp.db, binding.id, { cert_monitoring: CERT_MONITOR_REQUIRED, + health_check_enabled: false, }); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ @@ -247,7 +255,7 @@ describe("certificates", () => { await testApp.close(); }); - it("skipped apex removes stale certificate on check", async () => { + it("skipped binding removes stale certificate on check", async () => { const testApp = await buildApp({ config: { ...loadConfig(), staticDir: null }, memory: true, @@ -260,6 +268,20 @@ describe("certificates", () => { "skipped.example.com", "cf-zone-skip", ); + const service = repos.createService(testApp.db, "Skip", "skip"); + repos.setServiceEnabled(testApp.db, service.id, true); + const binding = repos.insertBinding( + testApp.db, + domain.id, + service.id, + "@", + null, + ); + repos.updateBindingLbConfig(testApp.db, binding.id, { + cert_monitoring: CERT_MONITOR_SKIPPED, + health_check_enabled: true, + health_check_verify_tls: true, + }); repos.upsertCertificateCheck( testApp.db, domain.id, @@ -268,12 +290,8 @@ describe("certificates", () => { null, CERT_ERROR, "stale", + service.id, ); - repos.updateDomain(testApp.db, domain.id, { - group_id: null, - status: "active", - cert_monitoring: CERT_MONITOR_SKIPPED, - }); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: null, @@ -305,9 +323,16 @@ describe("certificates", () => { "broken.example.com", "cf-zone-broken", ); - repos.updateDomain(testApp.db, domain.id, { - group_id: null, - status: "active", + const service = repos.createService(testApp.db, "Broken", "broken"); + repos.setServiceEnabled(testApp.db, service.id, true); + const binding = repos.insertBinding( + testApp.db, + domain.id, + service.id, + "@", + null, + ); + repos.updateBindingLbConfig(testApp.db, binding.id, { cert_monitoring: CERT_MONITOR_REQUIRED, }); @@ -424,7 +449,7 @@ describe("certificates", () => { }); const certs = repos.listCertificates(testApp.db); - expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(true); + expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(false); expect(certs.some((c) => c.hostname === "edge.ok.example.com")).toBe(true); await testApp.close(); @@ -443,12 +468,7 @@ describe("certificates", () => { "force.example.com", "cf-zone-force", ); - repos.updateDomain(testApp.db, domain.id, { - group_id: null, - status: "active", - cert_monitoring: CERT_MONITOR_REQUIRED, - }); - repos.createServiceGroup( + const group = repos.createServiceGroup( testApp.db, "Proxy", "vpn", @@ -459,6 +479,19 @@ describe("certificates", () => { health_check_verify_tls: false, }, ); + const service = repos.createService(testApp.db, "Force", "force"); + repos.setServiceEnabled(testApp.db, service.id, true); + repos.setServiceGroup(testApp.db, service.id, group.id); + const binding = repos.insertBinding( + testApp.db, + domain.id, + service.id, + "@", + null, + ); + repos.updateBindingLbConfig(testApp.db, binding.id, { + cert_monitoring: CERT_MONITOR_REQUIRED, + }); vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), @@ -540,4 +573,137 @@ describe("certificates", () => { await testApp.close(); }); + + it("GET /services/:id/certificates lists binding FQDNs", async () => { + const testApp = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(testApp); + + const domain = repos.createDomain( + testApp.db, + null, + "svc.example.com", + "cf-zone-svc", + ); + const service = repos.createService(testApp.db, "Api", "api"); + repos.setServiceEnabled(testApp.db, service.id, true); + repos.insertBinding(testApp.db, domain.id, service.id, "www", null); + + const res = await testApp.inject({ + method: "GET", + url: `/api/v1/services/${service.id}/certificates`, + headers, + }); + expect(res.statusCode).toBe(200); + const rows = res.json() as Array<{ + hostname: string; + cert_monitoring: string; + status: string; + }>; + expect(rows).toHaveLength(1); + expect(rows[0]?.hostname).toBe("www.svc.example.com"); + expect(rows[0]?.cert_monitoring).toBe("auto"); + expect(rows[0]?.status).toBe("unknown"); + + await testApp.close(); + }); + + it("PATCH /service-bindings/:id updates cert_monitoring", async () => { + const testApp = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(testApp); + + const domain = repos.createDomain( + testApp.db, + null, + "patch.example.com", + "cf-zone-patch", + ); + const service = repos.createService(testApp.db, "Patch", "patch"); + repos.setServiceEnabled(testApp.db, service.id, true); + const binding = repos.insertBinding( + testApp.db, + domain.id, + service.id, + "api", + null, + ); + + const res = await testApp.inject({ + method: "PATCH", + url: `/api/v1/service-bindings/${binding.id}`, + headers, + payload: { cert_monitoring: CERT_MONITOR_REQUIRED }, + }); + expect(res.statusCode).toBe(200); + expect((res.json() as { cert_monitoring: string }).cert_monitoring).toBe( + CERT_MONITOR_REQUIRED, + ); + expect(repos.getBinding(testApp.db, binding.id).cert_monitoring).toBe( + CERT_MONITOR_REQUIRED, + ); + + await testApp.close(); + }); + + it("POST /services/:id/certificates/check only checks that service", async () => { + const testApp = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(testApp); + + const domain = repos.createDomain( + testApp.db, + null, + "check.example.com", + "cf-zone-check", + ); + const service = repos.createService(testApp.db, "One", "one"); + const other = repos.createService(testApp.db, "Two", "two"); + repos.setServiceEnabled(testApp.db, service.id, true); + repos.setServiceEnabled(testApp.db, other.id, true); + const binding = repos.insertBinding( + testApp.db, + domain.id, + service.id, + "one", + null, + ); + const otherBinding = repos.insertBinding( + testApp.db, + domain.id, + other.id, + "two", + null, + ); + repos.updateBindingLbConfig(testApp.db, binding.id, { + cert_monitoring: CERT_MONITOR_REQUIRED, + }); + repos.updateBindingLbConfig(testApp.db, otherBinding.id, { + cert_monitoring: CERT_MONITOR_REQUIRED, + }); + + vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + error: null, + }); + + const res = await testApp.inject({ + method: "POST", + url: `/api/v1/services/${service.id}/certificates/check`, + headers, + }); + expect(res.statusCode).toBe(200); + expect((res.json() as { checked: number }).checked).toBe(1); + const certs = repos.listCertificates(testApp.db); + expect(certs.some((c) => c.hostname === "one.check.example.com")).toBe(true); + expect(certs.some((c) => c.hostname === "two.check.example.com")).toBe(false); + + await testApp.close(); + }); }); diff --git a/apps/web/src/components/columns/certificates-columns.tsx b/apps/web/src/components/columns/certificates-columns.tsx index c1f1516..feb0aea 100644 --- a/apps/web/src/components/columns/certificates-columns.tsx +++ b/apps/web/src/components/columns/certificates-columns.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import type { ColumnDef } from '@tanstack/react-table' -import { GlobeIcon, SearchIcon } from 'lucide-react' +import { Link } from '@tanstack/react-router' +import { GlobeIcon, SearchIcon, ServerIcon } from 'lucide-react' import { Badge } from '@/components/reui/badge' import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' @@ -9,6 +10,7 @@ import { StatusBadge } from '@/components/status-badge' import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils' import type { Certificate } from '@/lib/schemas' import { formatDate, formatRelative } from '@/lib/format' +import { Button } from '@cfdm/ui/components/button' export const CERT_TABS = [ { id: 'all', label: 'Все' }, @@ -41,6 +43,7 @@ export function certTabFilter(item: Certificate, tabId: string) { export function createDefaultCertFilters() { return [ createFilter('hostname', 'contains', ['']), + createFilter('service', 'contains', ['']), createFilter('status', 'is', ['']), ] } @@ -56,6 +59,14 @@ export function useCertFilterFields() { className: 'w-52', placeholder: 'Поиск по хосту…', }, + { + key: 'service', + label: 'Сервис', + icon: , + type: 'text', + className: 'w-52', + placeholder: 'Поиск по сервису…', + }, { key: 'status', label: 'Статус', @@ -75,6 +86,8 @@ export function certFilterFieldValue(item: Certificate, field: string) { switch (field) { case 'hostname': return `${item.hostname} ${item.status}`.toLowerCase() + case 'service': + return (item.service_name ?? '').toLowerCase() case 'status': return item.status default: @@ -82,7 +95,7 @@ export function certFilterFieldValue(item: Certificate, field: string) { } } -function certRelativeBadge(status: string, expiresAt: string | null) { +export function certRelativeBadge(status: string, expiresAt: string | null) { const relative = formatRelative(expiresAt) if (!expiresAt) { return @@ -112,6 +125,39 @@ export function useCertificateColumns() { {row.original.hostname} ), }, + { + id: 'service', + accessorFn: (row) => row.service_name ?? '', + header: ({ column }) => ( + } + /> + ), + cell: ({ row }) => { + const serviceId = row.original.service_id + const name = row.original.service_name + if (serviceId == null || !name) { + return + } + return ( + + ) + }, + }, { id: 'status', header: 'Статус', diff --git a/apps/web/src/components/services/service-detail-grid.tsx b/apps/web/src/components/services/service-detail-grid.tsx index 0fa1b1b..620d3c4 100644 --- a/apps/web/src/components/services/service-detail-grid.tsx +++ b/apps/web/src/components/services/service-detail-grid.tsx @@ -1,4 +1,5 @@ import { useMemo, useState, type ReactNode } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { ColumnDef } from '@tanstack/react-table' import { GlobeIcon, @@ -6,7 +7,9 @@ import { PlusIcon, SearchIcon, ServerIcon, + ShieldCheckIcon, } from 'lucide-react' +import { toast } from 'sonner' import { HealthCheckBadge } from '@/components/health-check-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 { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters' 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 { 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' @@ -60,6 +82,7 @@ const TABS = [ { id: 'ip', label: 'IP' }, { id: 'fqdn', label: 'FQDN' }, { id: 'nodes', label: 'Ноды' }, + { id: 'ssl', label: 'SSL' }, ] as const const HEALTH_OPTIONS = [ @@ -208,6 +231,57 @@ export function ServiceDetailGrid({ createFilter('address', 'contains', ['']), createFilter('health_status', 'is', ['']), ]) + const [sslFilters, setSslFilters] = useState(() => [ + 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]) @@ -221,7 +295,9 @@ export function ServiceDetailGrid({ ? ipRows.length : entry.id === 'fqdn' ? fqdnRows.length - : nodeRows.length, + : entry.id === 'ssl' + ? sslRows.length + : nodeRows.length, })) const ipFilterFields = useMemo( @@ -282,6 +358,20 @@ export function ServiceDetailGrid({ [], ) + const sslFilterFields = useMemo( + () => [ + { + key: 'hostname', + label: 'FQDN', + icon: , + type: 'text', + className: 'w-52', + placeholder: 'Поиск по FQDN…', + }, + ], + [], + ) + const ipColumns = useMemo[]>( () => [ { @@ -473,6 +563,91 @@ export function ServiceDetailGrid({ [onDeleteNode], ) + const sslColumns = useMemo[]>( + () => [ + { + id: 'hostname', + accessorKey: 'hostname', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + } + label={row.original.hostname} + iconClassName="text-foreground" + /> + ), + }, + { + id: 'status', + header: 'Статус', + cell: ({ row }) => , + }, + { + id: 'expires_at', + accessorKey: 'expires_at', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatDate(row.original.expires_at)} + + ), + }, + { + id: 'relative', + header: 'Срок', + cell: ({ row }) => + certRelativeBadge(row.original.status, row.original.expires_at), + }, + { + id: 'mode', + header: 'Режим', + cell: ({ row }) => ( + { + 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) => ( + + {option.label} + + ))} + + ), + }, + { + id: 'last_checked_at', + accessorKey: 'last_checked_at', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatDate(row.original.last_checked_at)} + + ), + }, + ], + [patchCertMonitoring], + ) + const sharedTabs = { tabs, activeTab: tab, @@ -544,6 +719,52 @@ export function ServiceDetailGrid({ ) } + if (tab === 'ssl') { + return ( + + 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={ + + checkSsl.mutate()} + /> + } + > + + + Проверить + + } + emptyState={{ + title: 'Нет FQDN', + description: 'Привяжите домен к сервису, чтобы мониторить SSL.', + }} + /> + ) + } + return ( @@ -67,7 +64,6 @@ export function SubdomainEditSheet({ defaultValues: { name: '', serviceId: 'none', - certMonitoring: 'auto', }, }) @@ -85,42 +81,27 @@ export function SubdomainEditSheet({ [services, serviceGroupById], ) - const certMonitoringItems = useMemo( - () => - certMonitoringOptions.map((option) => ({ - label: option.label, - value: option.value, - })), - [], - ) - useEffect(() => { if (!open) return if (mode === 'edit' && subdomain) { form.reset({ name: subdomain.name, serviceId: currentServiceId || 'none', - certMonitoring: subdomain.cert_monitoring, }) return } form.reset({ name: '', serviceId: 'none', - certMonitoring: 'auto', }) }, [open, mode, subdomain, currentServiceId, form]) - const certMonitoring = form.watch('certMonitoring') - const certHint = - certMonitoringOptions.find((o) => o.value === certMonitoring)?.description const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1 function handleSubmit(values: SubdomainEditValues) { onSubmit({ name: values.name.trim(), serviceId: values.serviceId, - certMonitoring: values.certMonitoring as CertMonitoring, }) } @@ -218,39 +199,6 @@ export function SubdomainEditSheet({ )} /> - - ( - - )} - /> - ) : null} diff --git a/apps/web/src/hooks/use-domain-page.ts b/apps/web/src/hooks/use-domain-page.ts index ddb8e06..0b32354 100644 --- a/apps/web/src/hooks/use-domain-page.ts +++ b/apps/web/src/hooks/use-domain-page.ts @@ -13,7 +13,6 @@ import { serviceGroupsQueryOptions, servicesQueryOptions, subdomainsListQueryOptions, - updateDomain, updateSubdomain, } from '@/queries' import type { CertMonitoring } from '@cfdm/shared' @@ -172,21 +171,6 @@ export function useDomainPage(domainId: number) { }, }) - const updateDomainCertMonitoringMutation = useMutation({ - mutationFn: (certMonitoring: CertMonitoring) => - updateDomain(domainId, { cert_monitoring: certMonitoring }), - onSuccess: () => { - invalidate() - void queryClient.invalidateQueries({ queryKey: ['domains'] }) - toast.success('Режим мониторинга SSL обновлён') - }, - onError: (err) => { - toast.error( - err instanceof Error ? err.message : 'Не удалось обновить мониторинг SSL', - ) - }, - }) - const deleteSubdomainMutation = useMutation({ mutationFn: (id: number) => deleteSubdomain(id), onSuccess: () => { @@ -249,7 +233,6 @@ export function useDomainPage(domainId: number) { syncMutation, createSubdomainMutation, updateSubdomainMutation, - updateDomainCertMonitoringMutation, deleteSubdomainMutation, linkServiceMutation, } diff --git a/apps/web/src/lib/cert-monitoring.ts b/apps/web/src/lib/cert-monitoring.ts index 9a074ab..74d1811 100644 --- a/apps/web/src/lib/cert-monitoring.ts +++ b/apps/web/src/lib/cert-monitoring.ts @@ -8,17 +8,17 @@ export const certMonitoringOptions: Array<{ { value: 'auto', label: 'Авто', - description: 'Проверять, если хост обслуживается активным сервисом', + description: 'Проверять, если health-check сервиса с verify TLS', }, { value: 'required', label: 'Обязательно', - description: 'Всегда проверять SSL, даже без привязок', + description: 'Всегда проверять SSL для этого FQDN', }, { value: 'skipped', label: 'Не проверять', - description: 'Исключить из мониторинга сертификатов', + description: 'Исключить FQDN из мониторинга сертификатов', }, ] diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts index 85a1959..a5548e9 100644 --- a/apps/web/src/lib/schemas.ts +++ b/apps/web/src/lib/schemas.ts @@ -82,6 +82,7 @@ export const serviceDomainBindingSchema = z health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'), health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']), health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'), + cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'), sync_status: z.string().nullable().default(null), }) .transform((binding) => ({ @@ -197,6 +198,7 @@ export const serviceBindingSchema = z health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'), health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']), health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'), + cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'), sync_status: z.string().nullable().default(null), created_at: z.string(), updated_at: z.string(), @@ -234,6 +236,8 @@ export const certificateSchema = z.object({ id: z.number(), domain_id: z.number(), subdomain_id: z.number().nullable(), + service_id: z.number().nullable().optional().default(null), + service_name: z.string().nullable().optional().default(null), hostname: z.string(), expires_at: z.string().nullable(), last_checked_at: z.string().nullable(), @@ -243,6 +247,19 @@ export const certificateSchema = z.object({ updated_at: z.string(), }) +export const serviceCertificateRowSchema = z.object({ + binding_id: z.number(), + domain_id: z.number(), + service_id: z.number(), + hostname: z.string(), + cert_monitoring: z.enum(['auto', 'required', 'skipped']), + id: z.number().nullable(), + status: z.string(), + expires_at: z.string().nullable(), + last_checked_at: z.string().nullable(), + last_error: z.string().nullable(), +}) + export type Group = z.infer export type GroupWithStats = z.infer export type Service = z.infer @@ -256,6 +273,7 @@ export type DomainListItem = z.infer export type ServiceBinding = z.infer export type DnsRecord = z.infer export type Certificate = z.infer +export type ServiceCertificateRow = z.infer export const createGroupSchema = z.object({ name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'), diff --git a/apps/web/src/queries/certificates.ts b/apps/web/src/queries/certificates.ts index 85ad6df..3957b0e 100644 --- a/apps/web/src/queries/certificates.ts +++ b/apps/web/src/queries/certificates.ts @@ -1,11 +1,14 @@ import { queryOptions } from '@tanstack/react-query' import { api } from '@/lib/api-client' -import { certificateSchema } from '@/lib/schemas' +import { certificateSchema, serviceCertificateRowSchema } from '@/lib/schemas' +import type { CertMonitoring } from '@cfdm/shared' import { z } from 'zod' export const certKeys = { all: ['certificates'] as const, summary: ['certificates', 'summary'] as const, + byService: (serviceId: number) => + [...certKeys.all, 'service', serviceId] as const, } export const certificatesQueryOptions = () => @@ -23,3 +26,29 @@ export const certSummaryQueryOptions = () => queryKey: certKeys.summary, queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'), }) + +export const serviceCertificatesQueryOptions = (serviceId: number) => + queryOptions({ + queryKey: certKeys.byService(serviceId), + queryFn: async () => { + const data = await api.get( + `/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`, + ) +} diff --git a/apps/web/src/routes/_auth/certificates.tsx b/apps/web/src/routes/_auth/certificates.tsx index 5bb4e78..af07f46 100644 --- a/apps/web/src/routes/_auth/certificates.tsx +++ b/apps/web/src/routes/_auth/certificates.tsx @@ -74,7 +74,7 @@ function CertificatesPage() { ({ ...tab }))} activeTab={activeTab} diff --git a/apps/web/src/routes/_auth/domains/$domainId/index.tsx b/apps/web/src/routes/_auth/domains/$domainId/index.tsx index 3beb2ec..4e0fc3b 100644 --- a/apps/web/src/routes/_auth/domains/$domainId/index.tsx +++ b/apps/web/src/routes/_auth/domains/$domainId/index.tsx @@ -6,11 +6,9 @@ import { GlobeIcon, Link2Icon, ServerIcon, - ShieldCheckIcon, } from 'lucide-react' import { toast } from 'sonner' import type { Filter } from '@/components/reui/filters' -import type { CertMonitoring } from '@cfdm/shared' import { domainDetailQueryOptions, domainServiceBindingsQueryOptions, @@ -41,19 +39,11 @@ import { import { DomainBindingsPanel } from '@/components/domain-bindings-panel' import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel' import { StatusBadge } from '@/components/status-badge' -import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring' import { formatDate } from '@/lib/format' import { Badge } from '@/components/reui/badge' import { LoadingButton } from '@/components/loading-button' import { TableSkeleton } from '@/components/skeletons' import { Button } from '@cfdm/ui/components/button' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@cfdm/ui/components/select' import { TabsContent } from '@cfdm/ui/components/tabs' export const Route = createFileRoute('/_auth/domains/$domainId/')({ @@ -108,7 +98,6 @@ function DomainOverviewPage() { syncMutation, createSubdomainMutation, updateSubdomainMutation, - updateDomainCertMonitoringMutation, deleteSubdomainMutation, linkServiceMutation, } = useDomainPage(id) @@ -167,20 +156,15 @@ function DomainOverviewPage() { if (!editTarget) return const nameChanged = values.name !== editTarget.subdomain.name - const certMonitoringChanged = - values.certMonitoring !== editTarget.subdomain.cert_monitoring const currentServiceId = resolveServiceId(editTarget) const serviceChanged = values.serviceId !== currentServiceId const targetServiceId = values.serviceId === 'none' ? null : Number(values.serviceId) - if (nameChanged || certMonitoringChanged) { + if (nameChanged) { await updateSubdomainMutation.mutateAsync({ id: editTarget.subdomain.id, - ...(nameChanged ? { name: values.name } : {}), - ...(certMonitoringChanged - ? { cert_monitoring: values.certMonitoring } - : {}), + name: values.name, }) } @@ -209,11 +193,6 @@ function DomainOverviewPage() { updateSubdomainMutation.isPending || linkServiceMutation.isPending - const certMonitoringItems = certMonitoringOptions.map((option) => ({ - label: option.label, - value: option.value, - })) - const metricCards = useMemo(() => { if (!domain) return [] return [ @@ -344,40 +323,6 @@ function DomainOverviewPage() { > - -
- - - -
-
diff --git a/packages/db/migrations/024_service_cert_monitoring.sql b/packages/db/migrations/024_service_cert_monitoring.sql new file mode 100644 index 0000000..0942194 --- /dev/null +++ b/packages/db/migrations/024_service_cert_monitoring.sql @@ -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 +); diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index b5f84d5..2416dc4 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -851,6 +851,7 @@ function mapServiceBinding( health_check_timeout_ms: row.health_check_timeout_ms, health_check_verify_tls: row.health_check_verify_tls, ...mapHealthFields(row), + cert_monitoring: row.cert_monitoring ?? "auto", routing_strategy: row.routing_strategy as LbMode, operation_version: row.operation_version, created_at: row.created_at, @@ -1530,6 +1531,7 @@ export interface BindingLbPatch { health_check_provider?: HealthCheckProvider; health_check_providers?: HealthCheckProvider[]; health_check_aggregate?: HealthCheckAggregate; + cert_monitoring?: string; } export function updateBindingLbConfig( @@ -1560,6 +1562,8 @@ export function updateBindingLbConfig( update.health_check_timeout_ms = patch.health_check_timeout_ms; if (patch.health_check_verify_tls !== undefined) update.health_check_verify_tls = patch.health_check_verify_tls; + if (patch.cert_monitoring !== undefined) + update.cert_monitoring = patch.cert_monitoring; Object.assign(update, healthProviderColumns(patch)); db.update(serviceBindings) .set(update) @@ -1669,7 +1673,7 @@ const SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.h sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port, sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec, sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider, - sb.health_check_providers, sb.health_check_aggregate, sb.cname_target, + sb.health_check_providers, sb.health_check_aggregate, sb.cert_monitoring, sb.cname_target, d.zone_name, d.group_id, g.name AS group_name, s.name AS service_name, s.slug AS service_slug, dr.content AS target_ip, dr.sync_status, @@ -1733,6 +1737,7 @@ function enrichServiceBindingView( return { ...row, cname_target: row.cname_target ?? null, + cert_monitoring: row.cert_monitoring ?? "auto", ...mapHealthFields(row), target_ips, target_ip: target_ips[0] ?? null, @@ -1914,26 +1919,69 @@ export function deleteBinding(db: Db, id: number): void { // --- Certificates --- +const CERTIFICATE_SELECT = `c.id, c.domain_id, c.subdomain_id, c.service_id, c.hostname, + c.expires_at, c.last_checked_at, c.last_error, c.status, c.created_at, c.updated_at, + s.name AS service_name`; + +type CertificateRow = { + id: number; + domain_id: number; + subdomain_id: number | null; + service_id: number | null; + hostname: string; + expires_at: string | null; + last_checked_at: string | null; + last_error: string | null; + status: string; + created_at: string; + updated_at: string; + service_name: string | null; +}; + +function mapCertificate(row: CertificateRow): Certificate { + return { + id: row.id, + domain_id: row.domain_id, + subdomain_id: row.subdomain_id, + service_id: row.service_id ?? null, + service_name: row.service_name ?? null, + hostname: row.hostname, + expires_at: row.expires_at, + last_checked_at: row.last_checked_at, + last_error: row.last_error, + status: row.status, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + export function listCertificates(db: Db, status?: string): Certificate[] { - if (status) { - return db - .select() - .from(certificates) - .where(eq(certificates.status, status)) - .orderBy(asc(certificates.expires_at)) - .all() as Certificate[]; - } - return db - .select() - .from(certificates) - .orderBy(asc(certificates.expires_at)) - .all() as Certificate[]; + const rows = status + ? db.all(sql` + SELECT ${sql.raw(CERTIFICATE_SELECT)} + FROM certificates c + LEFT JOIN services s ON s.id = c.service_id + WHERE c.status = ${status} + ORDER BY c.expires_at ASC + `) + : db.all(sql` + SELECT ${sql.raw(CERTIFICATE_SELECT)} + FROM certificates c + LEFT JOIN services s ON s.id = c.service_id + ORDER BY c.expires_at ASC + `); + return rows.map(mapCertificate); } export function getCertificate(db: Db, id: number): Certificate { - const row = db.select().from(certificates).where(eq(certificates.id, id)).get(); + const row = db.all(sql` + SELECT ${sql.raw(CERTIFICATE_SELECT)} + FROM certificates c + LEFT JOIN services s ON s.id = c.service_id + WHERE c.id = ${id} + `)[0]; if (!row) throw new NotFoundError(`certificate ${id}`); - return row as Certificate; + return mapCertificate(row); } export function upsertCertificateCheck( @@ -1944,6 +1992,7 @@ export function upsertCertificateCheck( expiresAt: string | null, status: string, lastError: string | null, + serviceId?: number | null, ): Certificate { const existing = db .select() @@ -1956,6 +2005,7 @@ export function upsertCertificateCheck( .set({ domain_id: domainId, subdomain_id: subdomainId, + service_id: serviceId === undefined ? existing.service_id : serviceId, expires_at: expiresAt, last_checked_at: sql`datetime('now')`, last_error: lastError, @@ -1972,6 +2022,7 @@ export function upsertCertificateCheck( .values({ domain_id: domainId, subdomain_id: subdomainId, + service_id: serviceId ?? null, hostname, expires_at: expiresAt, last_checked_at: sql`datetime('now')`, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 8399f1c..68b4b59 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -177,6 +177,7 @@ export const serviceBindings = sqliteTable( health_check_aggregate: text("health_check_aggregate") .notNull() .default("majority"), + cert_monitoring: text("cert_monitoring").notNull().default("auto"), routing_strategy: text("routing_strategy").notNull().default("round_robin"), operation_version: integer("operation_version").notNull().default(0), created_at: text("created_at") @@ -324,6 +325,9 @@ export const certificates = sqliteTable("certificates", { subdomain_id: integer("subdomain_id").references(() => subdomains.id, { onDelete: "set null", }), + service_id: integer("service_id").references(() => services.id, { + onDelete: "set null", + }), hostname: text("hostname").notNull().unique(), expires_at: text("expires_at"), last_checked_at: text("last_checked_at"), diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 6440c32..c73e885 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -178,6 +178,7 @@ export const serviceDomainBindingSchema = z health_check_provider: healthCheckProviderSchema.catch('local'), health_check_providers: healthCheckProvidersSchema.catch(['local']), health_check_aggregate: healthCheckAggregateSchema.catch('majority'), + cert_monitoring: certMonitoringSchema.default('auto'), sync_status: z.string().nullable().default(null), }) .transform((binding) => ({ @@ -266,6 +267,7 @@ export const serviceBindingSchema = z health_check_interval_sec: z.number().default(30), health_check_timeout_ms: z.number().default(3000), health_check_verify_tls: z.coerce.boolean().default(false), + cert_monitoring: certMonitoringSchema.default('auto'), sync_status: z.string().nullable().default(null), created_at: z.string(), updated_at: z.string(), @@ -303,6 +305,8 @@ export const certificateSchema = z.object({ id: z.number(), domain_id: z.number(), subdomain_id: z.number().nullable(), + service_id: z.number().nullable().optional().default(null), + service_name: z.string().nullable().optional().default(null), hostname: z.string(), expires_at: z.string().nullable(), last_checked_at: z.string().nullable(), @@ -312,6 +316,19 @@ export const certificateSchema = z.object({ updated_at: z.string(), }) +export const serviceCertificateRowSchema = z.object({ + binding_id: z.number(), + domain_id: z.number(), + service_id: z.number(), + hostname: z.string(), + cert_monitoring: certMonitoringSchema, + id: z.number().nullable(), + status: z.string(), + expires_at: z.string().nullable(), + last_checked_at: z.string().nullable(), + last_error: z.string().nullable(), +}) + export type Group = z.infer export type GroupWithStats = z.infer export type Service = z.infer @@ -324,6 +341,7 @@ export type Domain = z.infer export type DomainListItem = z.infer export type DnsRecord = z.infer export type Certificate = z.infer +export type ServiceCertificateRow = z.infer export const createGroupSchema = z.object({ name: z.string().min(1, 'Укажите название'), diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 6085eb1..aa33fdb 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -111,6 +111,8 @@ export interface Certificate { id: number; domain_id: number; subdomain_id: number | null; + service_id: number | null; + service_name: string | null; hostname: string; expires_at: string | null; last_checked_at: string | null; @@ -139,6 +141,7 @@ export interface ServiceBinding { health_check_provider: HealthCheckProvider; health_check_providers: HealthCheckProvider[]; health_check_aggregate: HealthCheckAggregate; + cert_monitoring: string; routing_strategy: LbMode; operation_version: number; created_at: string; @@ -173,6 +176,7 @@ export interface ServiceBindingView { health_check_provider: HealthCheckProvider; health_check_providers: HealthCheckProvider[]; health_check_aggregate: HealthCheckAggregate; + cert_monitoring: string; sync_status: string | null; created_at: string; updated_at: string; @@ -201,6 +205,7 @@ export interface ServiceDomainBindingView { health_check_provider: HealthCheckProvider; health_check_providers: HealthCheckProvider[]; health_check_aggregate: HealthCheckAggregate; + cert_monitoring: string; sync_status: string | null; }