From 6d1d8ca4f3aa638ee052e1009ba923d77d45c5c8 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 22 Jun 2026 13:52:33 +0700 Subject: [PATCH] feat:Update pnpm-lock.yaml to link shared package; modify API routes to utilize new schemas for domain and service management; enhance DNS record handling with CNAME support; refactor service and subdomain routes for improved functionality; implement confirm dialog for domain deletion in the frontend; clean up unused components and improve UI consistency. --- .claude/settings.json | 14 + .codegraph/daemon.pid | 4 +- .cursor/mcp.json | 14 + .gemini/settings.json | 12 + .mcp.json | 12 + apps/api/dist/server.js | 476 ++++++++++++++---- apps/api/src/routes/domains.ts | 9 +- apps/api/src/routes/service-groups.ts | 11 +- apps/api/src/routes/services.ts | 11 + apps/api/src/routes/subdomains.ts | 30 +- apps/api/src/services/certificate-service.ts | 148 +++++- apps/api/src/services/dns-service.ts | 23 +- apps/api/src/services/domain-service.ts | 3 +- .../src/services/service-config-service.ts | 453 +++++++++++++++-- apps/api/src/services/sync-service.ts | 140 +++++- apps/api/test/certificates.test.ts | 257 ++++++++++ apps/api/test/dns-sync.test.ts | 106 ++++ apps/api/test/services-reorder.test.ts | 64 +++ apps/api/test/subdomains.test.ts | 62 +++ apps/web/package.json | 1 + apps/web/src/components/board/column-ids.ts | 11 + .../board/drag-context-provider.tsx | 64 +++ apps/web/src/components/confirm-dialog.tsx | 12 +- .../web/src/components/domain-actions-bar.tsx | 17 + .../src/components/domain-bindings-card.tsx | 32 +- apps/web/src/components/domain-group-card.tsx | 87 ---- .../components/domain-group-edit-sheet.tsx | 111 ++++ apps/web/src/components/domain-header.tsx | 69 +++ .../web/src/components/domains-data-table.tsx | 49 +- .../components/groups-board/board-state.ts | 96 ++++ .../src/components/groups-board/column-ids.ts | 5 + .../groups-board/domain-group-header.tsx | 100 ++++ .../groups-board/domain-group-item.tsx | 105 ++++ .../components/groups-board/domain-row.tsx | 177 +++++++ .../groups-board/groups-board-skeleton.tsx | 19 + .../components/groups-board/groups-board.tsx | 95 ++++ apps/web/src/components/groups-board/types.ts | 14 + apps/web/src/components/kanban-board.tsx | 96 ---- apps/web/src/components/kanban-column.tsx | 81 --- apps/web/src/components/service-card.tsx | 103 ---- .../web/src/components/service-edit-sheet.tsx | 232 ++++++--- .../web/src/components/service-group-card.tsx | 116 ----- .../components/service-group-edit-sheet.tsx | 4 +- apps/web/src/components/service-row.tsx | 77 --- .../components/services-board/board-state.ts | 152 ++++++ .../components/services-board/column-ids.ts | 5 + .../services-board/service-group-header.tsx | 152 ++++++ .../services-board/service-group-item.tsx | 193 +++++++ .../components/services-board/service-row.tsx | 199 ++++++++ .../services-board-skeleton.tsx | 19 + .../services-board/services-board.tsx | 136 +++++ .../services-board/services-bulk-toolbar.tsx | 54 ++ .../src/components/services-board/types.ts | 16 + apps/web/src/components/status-badge.tsx | 6 +- .../src/components/subdomain-edit-sheet.tsx | 201 ++++++++ apps/web/src/components/subdomains-table.tsx | 142 ++++++ apps/web/src/hooks/use-domain-page.ts | 256 ++++++++++ apps/web/src/hooks/use-groups-board.ts | 149 ++++++ apps/web/src/hooks/use-services-board.ts | 225 +++++++++ apps/web/src/hooks/use-services-selection.ts | 97 ++++ apps/web/src/lib/cert-monitoring.ts | 27 + apps/web/src/lib/domain-ips.ts | 17 +- apps/web/src/lib/schemas-ext.ts | 20 +- apps/web/src/lib/schemas.ts | 89 +++- apps/web/src/lib/service-utils.ts | 25 +- apps/web/src/queries/index.ts | 50 +- apps/web/src/routes/_auth/certificates.tsx | 4 +- .../routes/_auth/domains/$domainId/index.tsx | 336 +++++++++---- apps/web/src/routes/_auth/domains/index.tsx | 42 +- apps/web/src/routes/_auth/groups.tsx | 381 +++++--------- apps/web/src/routes/_auth/groups/$groupId.tsx | 2 +- apps/web/src/routes/_auth/services.tsx | 254 +++++++--- apps/web/tsconfig.app.json | 3 +- apps/web/vite.config.ts | 1 + opencode.jsonc | 14 + packages/db/dist/index.d.ts | 167 +++++- packages/db/dist/index.js | 131 ++++- .../db/migrations/008_subdomains_enabled.sql | 1 + .../migrations/009_service_binding_cname.sql | 1 + .../db/migrations/010_services_sort_order.sql | 2 + .../db/migrations/011_cert_monitoring.sql | 5 + packages/db/src/index.ts | 2 +- packages/db/src/repos.ts | 229 ++++++++- packages/db/src/schema.ts | 5 + packages/shared/dist/index.d.ts | 340 ++++++++++--- packages/shared/dist/index.js | 88 +++- packages/shared/package.json | 1 + packages/shared/src/constants.ts | 10 + packages/shared/src/index.ts | 1 + packages/shared/src/schemas.ts | 111 +++- packages/shared/src/subdomain.ts | 23 + packages/shared/src/types.ts | 8 + packages/shared/src/validators.ts | 10 + packages/shared/test/subdomain.test.ts | 43 ++ packages/ui/src/components/accordion.tsx | 72 +++ packages/ui/src/components/badge.tsx | 2 + packages/ui/src/components/checkbox.tsx | 27 + packages/ui/src/styles/globals.css | 4 + pnpm-lock.yaml | 3 + 99 files changed, 6692 insertions(+), 1483 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .cursor/mcp.json create mode 100644 .gemini/settings.json create mode 100644 .mcp.json create mode 100644 apps/api/test/certificates.test.ts create mode 100644 apps/api/test/dns-sync.test.ts create mode 100644 apps/api/test/services-reorder.test.ts create mode 100644 apps/api/test/subdomains.test.ts create mode 100644 apps/web/src/components/board/column-ids.ts create mode 100644 apps/web/src/components/board/drag-context-provider.tsx create mode 100644 apps/web/src/components/domain-actions-bar.tsx delete mode 100644 apps/web/src/components/domain-group-card.tsx create mode 100644 apps/web/src/components/domain-group-edit-sheet.tsx create mode 100644 apps/web/src/components/domain-header.tsx create mode 100644 apps/web/src/components/groups-board/board-state.ts create mode 100644 apps/web/src/components/groups-board/column-ids.ts create mode 100644 apps/web/src/components/groups-board/domain-group-header.tsx create mode 100644 apps/web/src/components/groups-board/domain-group-item.tsx create mode 100644 apps/web/src/components/groups-board/domain-row.tsx create mode 100644 apps/web/src/components/groups-board/groups-board-skeleton.tsx create mode 100644 apps/web/src/components/groups-board/groups-board.tsx create mode 100644 apps/web/src/components/groups-board/types.ts delete mode 100644 apps/web/src/components/kanban-board.tsx delete mode 100644 apps/web/src/components/kanban-column.tsx delete mode 100644 apps/web/src/components/service-card.tsx delete mode 100644 apps/web/src/components/service-group-card.tsx delete mode 100644 apps/web/src/components/service-row.tsx create mode 100644 apps/web/src/components/services-board/board-state.ts create mode 100644 apps/web/src/components/services-board/column-ids.ts create mode 100644 apps/web/src/components/services-board/service-group-header.tsx create mode 100644 apps/web/src/components/services-board/service-group-item.tsx create mode 100644 apps/web/src/components/services-board/service-row.tsx create mode 100644 apps/web/src/components/services-board/services-board-skeleton.tsx create mode 100644 apps/web/src/components/services-board/services-board.tsx create mode 100644 apps/web/src/components/services-board/services-bulk-toolbar.tsx create mode 100644 apps/web/src/components/services-board/types.ts create mode 100644 apps/web/src/components/subdomain-edit-sheet.tsx create mode 100644 apps/web/src/components/subdomains-table.tsx create mode 100644 apps/web/src/hooks/use-domain-page.ts create mode 100644 apps/web/src/hooks/use-groups-board.ts create mode 100644 apps/web/src/hooks/use-services-board.ts create mode 100644 apps/web/src/hooks/use-services-selection.ts create mode 100644 apps/web/src/lib/cert-monitoring.ts create mode 100644 opencode.jsonc create mode 100644 packages/db/migrations/008_subdomains_enabled.sql create mode 100644 packages/db/migrations/009_service_binding_cname.sql create mode 100644 packages/db/migrations/010_services_sort_order.sql create mode 100644 packages/db/migrations/011_cert_monitoring.sql create mode 100644 packages/shared/test/subdomain.test.ts create mode 100644 packages/ui/src/components/accordion.tsx create mode 100644 packages/ui/src/components/checkbox.tsx diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..09c360a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "mcp__codegraph__codegraph_explore", + "mcp__codegraph__codegraph_search", + "mcp__codegraph__codegraph_node", + "mcp__codegraph__codegraph_callers", + "mcp__codegraph__codegraph_callees", + "mcp__codegraph__codegraph_impact", + "mcp__codegraph__codegraph_files", + "mcp__codegraph__codegraph_status" + ] + } +} diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid index 986616d..1a96f11 100644 --- a/.codegraph/daemon.pid +++ b/.codegraph/daemon.pid @@ -1,6 +1,6 @@ { - "pid": 35132, + "pid": 44284, "version": "0.9.9", "socketPath": "\\\\.\\pipe\\codegraph-7bcc7a2b16d00925", - "startedAt": 1781511807694 + "startedAt": 1782094135868 } diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..d9d709b --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "codegraph": { + "type": "stdio", + "command": "codegraph", + "args": [ + "serve", + "--mcp", + "--path", + "C:\\Users\\shats\\Dev\\cloudflare-domain-manager" + ] + } + } +} diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000..87ca7de --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "codegraph": { + "type": "stdio", + "command": "codegraph", + "args": [ + "serve", + "--mcp" + ] + } + } +} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..87ca7de --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "codegraph": { + "type": "stdio", + "command": "codegraph", + "args": [ + "serve", + "--mcp" + ] + } + } +} diff --git a/apps/api/dist/server.js b/apps/api/dist/server.js index f01daa5..ad98cd9 100644 --- a/apps/api/dist/server.js +++ b/apps/api/dist/server.js @@ -424,6 +424,7 @@ async function groupRoutes(app2) { // src/routes/services.ts import { z as z3 } from "zod"; +import { reorderServicesSchema } from "@cfdm/shared"; import { repos as repos7 } from "@cfdm/db"; // src/services/service-config-service.ts @@ -431,7 +432,8 @@ import { repos as repos6 } from "@cfdm/db"; import { SYNC_ERROR as SYNC_ERROR2, SYNC_PENDING_PUSH as SYNC_PENDING_PUSH3, - SYNC_SYNCED as SYNC_SYNCED3 + SYNC_SYNCED as SYNC_SYNCED3, + dnsNameToSubdomainLabel as dnsNameToSubdomainLabel2 } from "@cfdm/shared"; // src/lib/validators.ts @@ -874,9 +876,9 @@ async function createDomain(db, cf, groupId, zoneName) { "\u043D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0445 \u0437\u043E\u043D \u0432 Cloudflare \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 CLOUDFLARE_API_TOKEN \u0438 \u043F\u0440\u0430\u0432\u0430 Zone:Read" ); } - const zone = zones.find((z9) => z9.name.toLowerCase() === trimmed.toLowerCase()); + const zone = zones.find((z8) => z8.name.toLowerCase() === trimmed.toLowerCase()); if (!zone) { - const names = zones.map((z9) => z9.name).join(", "); + const names = zones.map((z8) => z8.name).join(", "); throw AppError.notFound( `\u0437\u043E\u043D\u0430 \xAB${trimmed}\xBB \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430 \u0432 Cloudflare. \u0414\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0435: ${names}` ); @@ -956,13 +958,19 @@ async function buildView(db, serviceId) { const records = repos6.listRecordsForBinding(db, binding.id); const statuses = records.map((r) => r.sync_status); const targetIps = repos6.listBindingIps(db, binding.id); + const linkedCname = records.find( + (record) => record.record_type.toUpperCase() === "CNAME" + ); + const targetCname = binding.cname_target?.trim() || linkedCname?.content?.trim() || null; return { binding_id: binding.id, domain_id: binding.domain_id, zone_name: binding.zone_name, hostname: binding.hostname, fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), - target_ips: targetIps, + record_type: targetCname ? "CNAME" : "A", + target_ips: targetCname ? [] : targetIps, + target_cname: targetCname, sync_status: aggregateSyncStatus(statuses) }; }); @@ -1012,14 +1020,114 @@ function shouldPushDns(db, service) { const group = repos6.getServiceGroup(db, service.service_group_id); return group.enabled; } -async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps) { +async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, cnameTarget) { + const domain = repos6.getDomain(db, domainId); + const zoneName = domain.zone_name; + let effectiveCname = cnameTarget?.trim() || null; + if (!effectiveCname) { + const existingCname = await findOrImportDnsRecord( + db, + cf, + domainId, + zoneName, + hostname, + "CNAME" + ); + if (existingCname) { + effectiveCname = existingCname.content; + repos6.setBindingCnameTarget(db, bindingId, effectiveCname); + repos6.replaceBindingIps(db, bindingId, []); + } + } + if (effectiveCname) { + await syncBindingCnameDns( + db, + cf, + bindingId, + domainId, + hostname, + effectiveCname + ); + return; + } + await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps); +} +async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameTarget) { + const domain = repos6.getDomain(db, domainId); + const zoneName = domain.zone_name; + const normalized = normalizeCnameTarget(cnameTarget, zoneName); const existingRecords = repos6.listRecordsForBinding(db, bindingId); for (const record of existingRecords) { - if (!desiredIps.includes(record.content)) { + if (record.record_type.toUpperCase() === "A") { repos6.unlinkBindingRecord(db, bindingId, record.id); await deleteRecord(db, cf, domainId, record.id); } } + const refreshed = repos6.listRecordsForBinding(db, bindingId); + const existingCname = refreshed.find( + (record) => record.record_type.toUpperCase() === "CNAME" + ); + let recordId; + if (existingCname) { + if (!cnameContentMatches(existingCname.content, normalized, zoneName) || existingCname.name !== hostname) { + await update(db, cf, domainId, existingCname.id, { + record_type: "CNAME", + name: hostname, + content: normalized, + proxied: false + }); + } + recordId = existingCname.id; + } else { + const adopted = await findOrImportDnsRecord( + db, + cf, + domainId, + zoneName, + hostname, + "CNAME", + normalized + ); + if (adopted) { + repos6.linkBindingRecord(db, bindingId, adopted.id); + if (!cnameContentMatches(adopted.content, normalized, zoneName)) { + await update(db, cf, domainId, adopted.id, { + record_type: "CNAME", + name: hostname, + content: normalized, + proxied: false + }); + } + recordId = adopted.id; + } else { + const record = await create(db, cf, domainId, { + record_type: "CNAME", + name: hostname, + content: normalized, + ttl: 1, + proxied: false + }); + repos6.linkBindingRecord(db, bindingId, record.id); + recordId = record.id; + } + } + repos6.setBindingDnsRecordId(db, bindingId, recordId); + repos6.setBindingCnameTarget(db, bindingId, normalized); +} +async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps) { + const domain = repos6.getDomain(db, domainId); + const zoneName = domain.zone_name; + const existingRecords = repos6.listRecordsForBinding(db, bindingId); + for (const record of existingRecords) { + if (record.record_type.toUpperCase() === "CNAME") { + repos6.unlinkBindingRecord(db, bindingId, record.id); + await deleteRecord(db, cf, domainId, record.id); + } else if (!desiredIps.includes(record.content)) { + repos6.unlinkBindingRecord(db, bindingId, record.id); + await deleteRecord(db, cf, domainId, record.id); + } + } + repos6.setBindingCnameTarget(db, bindingId, null); if (desiredIps.length === 0) { repos6.setBindingDnsRecordId(db, bindingId, null); return; @@ -1040,22 +1148,36 @@ async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps) } recordId = existing.id; } else { - const record = await create(db, cf, domainId, { - record_type: "A", - name: hostname, - content: ip, - ttl: 1, - proxied: false - }); - repos6.linkBindingRecord(db, bindingId, record.id); - recordId = record.id; + const adopted = await findOrImportDnsRecord( + db, + cf, + domainId, + zoneName, + hostname, + "A", + ip + ); + if (adopted) { + repos6.linkBindingRecord(db, bindingId, adopted.id); + recordId = adopted.id; + } else { + const record = await create(db, cf, domainId, { + record_type: "A", + name: hostname, + content: ip, + ttl: 1, + proxied: false + }); + repos6.linkBindingRecord(db, bindingId, record.id); + recordId = record.id; + } } if (primaryId == null) primaryId = recordId; } repos6.setBindingDnsRecordId(db, bindingId, primaryId); } async function cleanupBindingDns(db, cf, bindingId, domainId, hostname) { - await syncBindingDns(db, cf, bindingId, domainId, hostname, []); + await syncBindingDns(db, cf, bindingId, domainId, hostname, [], null); } async function cleanupServiceDnsOnly(db, cf, serviceId) { const bindings = repos6.listBindingsByService(db, serviceId); @@ -1080,6 +1202,7 @@ function validateTargetIpsInPool(targetIps, ips) { } } function bindingTargetIps(input) { + if (input.target_cname?.trim()) return []; const raw = input.target_ips ? input.target_ips : input.target_ip?.trim() ? [input.target_ip.trim()] : []; const normalized = normalizeIps(raw); if (raw.length > 0 && normalized.length === 0) { @@ -1087,20 +1210,150 @@ function bindingTargetIps(input) { } return normalized; } -async function syncServiceBindingsToDns(db, cf, serviceId) { - const ips = repos6.listServiceIps(db, serviceId); - if (ips.length === 0) { - throw AppError.validation("\u0434\u043E\u0431\u0430\u0432\u044C\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441\u0430 \u0432 \u043F\u0443\u043B \u0441\u0435\u0440\u0432\u0438\u0441\u0430"); +function bindingTargetCname(input) { + const target = input.target_cname?.trim(); + return target ? target : null; +} +function normalizeCnameTarget(target, zoneName) { + const trimmed = target.trim().toLowerCase(); + if (!trimmed) { + throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 CNAME-\u0446\u0435\u043B\u044C"); } + if (trimmed.includes(".")) return trimmed; + return `${trimmed}.${zoneName.toLowerCase()}`; +} +function cnameContentMatches(left, right, zoneName) { + return normalizeCnameTarget(left, zoneName) === normalizeCnameTarget(right, zoneName); +} +function dnsHostnameMatches(recordName, hostname, zoneName) { + const label = dnsNameToSubdomainLabel2(recordName, zoneName); + if (label != null) return label === hostname; + return recordName === hostname; +} +function findLocalDnsRecord(db, domainId, zoneName, hostname, recordType, content) { + const records = repos6.listDnsByDomain(db, domainId); + return records.find( + (record) => record.record_type.toUpperCase() === recordType && (content == null || record.content === content) && dnsHostnameMatches(record.name, hostname, zoneName) + ) ?? null; +} +async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recordType, content) { + const local = findLocalDnsRecord( + db, + domainId, + zoneName, + hostname, + recordType, + content + ); + if (local) return local; + const domain = repos6.getDomain(db, domainId); + const remote = await cf.listDnsRecords(domain.cf_zone_id); + for (const cfRec of remote) { + if (cfRec.type.toUpperCase() !== recordType) continue; + if (content != null) { + if (recordType === "CNAME") { + if (!cnameContentMatches(cfRec.content, content, zoneName)) continue; + } else if (cfRec.content !== content) { + continue; + } + } + if (!dnsHostnameMatches(cfRec.name, hostname, zoneName)) continue; + if (!cfRec.id) continue; + const existing = repos6.findDnsByCfId(db, domainId, cfRec.id); + if (existing) return existing; + return repos6.insertDnsRecord( + db, + domainId, + cfRec.type, + cfRec.name, + cfRec.content, + cfRec.ttl, + cfRec.proxied ?? false, + cfRec.priority ?? null, + SYNC_SYNCED3, + "cloudflare", + cfRec.id + ); + } + return null; +} +async function findOrImportDnsARecord(db, cf, domainId, zoneName, hostname, content) { + return findOrImportDnsRecord( + db, + cf, + domainId, + zoneName, + hostname, + "A", + content + ); +} +async function serviceBindingsExistInDns(db, cf, serviceId) { + const bindings = repos6.listBindingsByService(db, serviceId); + if (bindings.length === 0) return false; + for (const binding of bindings) { + const cnameTarget = binding.cname_target?.trim() || null; + if (cnameTarget) { + const record = await findOrImportDnsRecord( + db, + cf, + binding.domain_id, + binding.zone_name, + binding.hostname, + "CNAME", + cnameTarget + ); + if (!record) return false; + continue; + } + const targetIps = repos6.listBindingIps(db, binding.id); + if (targetIps.length === 0) return false; + for (const ip of targetIps) { + const record = await findOrImportDnsARecord( + db, + cf, + binding.domain_id, + binding.zone_name, + binding.hostname, + ip + ); + if (!record) return false; + } + } + return true; +} +async function syncServiceBindingsToDns(db, cf, serviceId) { const bindings = repos6.listBindingsByService(db, serviceId); if (bindings.length === 0) { throw AppError.validation("\u043D\u0430\u0441\u0442\u0440\u043E\u0439\u0442\u0435 FQDN \u0432 \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440\u0435 \u0441\u0435\u0440\u0432\u0438\u0441\u0430"); } + const needsIpPool = bindings.some((binding) => { + if (binding.cname_target?.trim()) return false; + const targetIps = repos6.listBindingIps(db, binding.id); + return targetIps.length > 0; + }); + const ips = repos6.listServiceIps(db, serviceId); + if (needsIpPool && ips.length === 0) { + throw AppError.validation("\u0434\u043E\u0431\u0430\u0432\u044C\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441\u0430 \u0432 \u043F\u0443\u043B \u0441\u0435\u0440\u0432\u0438\u0441\u0430"); + } for (const binding of bindings) { + const cnameTarget = binding.cname_target?.trim() || null; + if (cnameTarget) { + await syncBindingDns( + db, + cf, + binding.id, + binding.domain_id, + binding.hostname, + [], + cnameTarget + ); + continue; + } const targetIps = repos6.listBindingIps(db, binding.id); if (targetIps.length === 0) { throw AppError.validation( - `\u0443\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0434\u043B\u044F ${fqdnToDisplay(binding.hostname, binding.zone_name)}` + `\u0443\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 CNAME \u0434\u043B\u044F ${fqdnToDisplay(binding.hostname, binding.zone_name)}` ); } validateTargetIpsInPool(targetIps, ips); @@ -1110,7 +1363,8 @@ async function syncServiceBindingsToDns(db, cf, serviceId) { binding.id, binding.domain_id, binding.hostname, - targetIps + targetIps, + null ); } } @@ -1152,6 +1406,19 @@ async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, de } continue; } + const domain = repos6.getDomain(db, domainId); + const adopted = await findOrImportDnsARecord( + db, + cf, + domainId, + domain.zone_name, + hostname, + ip + ); + if (adopted) { + repos6.linkGroupDnsRecord(db, groupId, adopted.id); + continue; + } const record = await create(db, cf, domainId, { record_type: "A", name: hostname, @@ -1273,13 +1540,21 @@ async function updateConfig(db, cf, id, req) { for (const input of req.domains) { const fqdn = input.fqdn.trim(); if (!fqdn) continue; + const targetCname = bindingTargetCname(input); const targetIps = bindingTargetIps(input); - validateTargetIpsInPool(targetIps, ips); + if (!targetCname) { + validateTargetIpsInPool(targetIps, ips); + } else if (targetIps.length > 0) { + throw AppError.validation( + `\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u043B\u0438\u0431\u043E IP, \u043B\u0438\u0431\u043E CNAME \u0434\u043B\u044F ${fqdn}` + ); + } const { zoneName, hostname } = parseFqdn(fqdn, knownZones); const domainId = await resolveDomainId(db, cf, zoneName); const binding = repos6.findBinding(db, id, domainId, hostname) ?? repos6.insertBinding(db, domainId, id, hostname, null); keptBindingIds.push(binding.id); - repos6.replaceBindingIps(db, binding.id, targetIps); + repos6.replaceBindingIps(db, binding.id, targetCname ? [] : targetIps); + repos6.setBindingCnameTarget(db, binding.id, targetCname); if (pushDns) { await syncBindingDns( db, @@ -1287,7 +1562,8 @@ async function updateConfig(db, cf, id, req) { binding.id, domainId, hostname, - targetIps + targetIps, + targetCname ); } } @@ -1320,6 +1596,10 @@ async function updateConfig(db, cf, id, req) { if (shouldPushDns(db, service)) { await syncServiceBindingsToDns(db, cf, id); await syncGroupDomainForService(db, cf, id); + } else if (req.domains && req.domains.length > 0 && !service.enabled && await serviceBindingsExistInDns(db, cf, id)) { + repos6.setServiceEnabled(db, id, true); + await syncServiceBindingsToDns(db, cf, id); + await syncGroupDomainForService(db, cf, id); } return buildView(db, id); } @@ -1343,7 +1623,7 @@ async function updateGroup2(db, cf, id, body) { await cleanupGroupDomainDns(db, cf, id); } const domain = await normalizeGroupDomain(db, cf, body.domain); - const group = repos6.updateServiceGroup( + let group = repos6.updateServiceGroup( db, id, body.name, @@ -1351,6 +1631,10 @@ async function updateGroup2(db, cf, id, body) { body.icon ?? null, domain ); + if (!domain && group.enabled) { + repos6.setServiceGroupEnabled(db, id, false); + group = repos6.getServiceGroup(db, id); + } await syncEnabledServicesInGroup(db, cf, id); return group; } @@ -1361,12 +1645,9 @@ async function toggleService(db, cf, serviceId, enabled) { const service = repos6.getService(db, serviceId); if (enabled && service.service_group_id) { const group = repos6.getServiceGroup(db, service.service_group_id); - if (!group.enabled) { + if (group.domain?.trim() && !group.enabled) { throw AppError.validation("\u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u0435 \u0433\u0440\u0443\u043F\u043F\u0443 \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u0432"); } - if (!group.domain?.trim()) { - throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0434\u043E\u043C\u0435\u043D \u0443 \u0433\u0440\u0443\u043F\u043F\u044B \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u0432"); - } } repos6.setServiceEnabled(db, serviceId, enabled); if (!enabled) { @@ -1379,6 +1660,10 @@ async function toggleService(db, cf, serviceId, enabled) { return buildView(db, serviceId); } async function toggleGroup(db, cf, groupId, enabled) { + const group = repos6.getServiceGroup(db, groupId); + if (enabled && !group.domain?.trim()) { + throw AppError.validation("\u043D\u0435\u043B\u044C\u0437\u044F \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u044C \u0433\u0440\u0443\u043F\u043F\u0443 \u0431\u0435\u0437 \u0434\u043E\u043C\u0435\u043D\u0430"); + } repos6.setServiceGroupEnabled(db, groupId, enabled); if (!enabled) { const services = repos6.listServicesByGroup(db, groupId); @@ -1394,6 +1679,12 @@ async function toggleGroup(db, cf, groupId, enabled) { } return listGroupViews(db); } +function reorderServices(db, groupId, serviceIds) { + if (groupId !== null) { + repos6.getServiceGroup(db, groupId); + } + repos6.reorderServices(db, groupId, serviceIds); +} // src/routes/services.ts async function serviceRoutes(app2) { @@ -1405,6 +1696,15 @@ async function serviceRoutes(app2) { app2.get("/services", async (request) => { return listViews(request.server.db); }); + app2.patch("/services/reorder", async (request) => { + const body = reorderServicesSchema.parse(request.body); + reorderServices( + request.server.db, + body.group_id, + body.service_ids + ); + return { ok: true }; + }); app2.post("/services", async (request) => { const body = createSchema.parse(request.body); const service = repos7.createService( @@ -1452,14 +1752,9 @@ async function serviceRoutes(app2) { } // src/routes/service-groups.ts -import { z as z4 } from "zod"; +import { createServiceGroupSchema, toggleEnabledSchema } from "@cfdm/shared"; async function serviceGroupRoutes(app2) { - const bodySchema = z4.object({ - name: z4.string(), - type: z4.string().optional(), - icon: z4.string().optional(), - domain: z4.string().optional() - }); + const bodySchema = createServiceGroupSchema; app2.get("/service-groups", async (request) => { return listGroupViews(request.server.db); }); @@ -1488,7 +1783,7 @@ async function serviceGroupRoutes(app2) { }); app2.patch("/service-groups/:id/toggle", async (request) => { const { id } = request.params; - const body = z4.object({ enabled: z4.boolean() }).parse(request.body); + const body = toggleEnabledSchema.parse(request.body); return toggleGroup( request.server.db, request.server.cf, @@ -1499,18 +1794,18 @@ async function serviceGroupRoutes(app2) { } // src/routes/service-bindings.ts -import { z as z5 } from "zod"; +import { z as z4 } from "zod"; async function serviceBindingRoutes(app2) { - const createSchema = z5.object({ - domain_id: z5.number(), - service_id: z5.number(), - hostname: z5.string().optional(), - target_ip: z5.string().optional() + const createSchema = z4.object({ + domain_id: z4.number(), + service_id: z4.number(), + hostname: z4.string().optional(), + target_ip: z4.string().optional() }); - const updateSchema = z5.object({ - service_id: z5.number().optional(), - hostname: z5.string().optional(), - target_ip: z5.string().optional() + const updateSchema = z4.object({ + service_id: z4.number().optional(), + hostname: z4.string().optional(), + target_ip: z4.string().optional() }); app2.get("/service-bindings", async (request) => { return listAll(request.server.db); @@ -1550,15 +1845,15 @@ async function serviceBindingRoutes(app2) { } // src/routes/domains.ts -import { z as z6 } from "zod"; +import { z as z5 } from "zod"; async function domainRoutes(app2) { - const createSchema = z6.object({ - zone_name: z6.string(), - group_id: z6.number().nullable().optional() + const createSchema = z5.object({ + zone_name: z5.string(), + group_id: z5.number().nullable().optional() }); - const updateSchema = z6.object({ - group_id: z6.number().nullable().optional(), - status: z6.string().optional() + const updateSchema = z5.object({ + group_id: z5.number().nullable().optional(), + status: z5.string().optional() }); app2.get("/domains", async (request) => { const query = request.query; @@ -1605,7 +1900,7 @@ async function domainRoutes(app2) { }); app2.put("/domains/:id/services", async (request) => { const { id } = request.params; - const body = z6.object({ service_ids: z6.array(z6.number()) }).parse(request.body); + const body = z5.object({ service_ids: z5.array(z5.number()) }).parse(request.body); const serviceIds = await setDomainServices2( request.server.db, Number(id), @@ -1616,15 +1911,15 @@ async function domainRoutes(app2) { } // src/routes/dns.ts -import { z as z7 } from "zod"; +import { z as z6 } from "zod"; async function dnsRoutes(app2) { - const createSchema = z7.object({ - record_type: z7.string(), - name: z7.string(), - content: z7.string(), - ttl: z7.number().optional(), - proxied: z7.boolean().optional(), - priority: z7.number().optional() + const createSchema = z6.object({ + record_type: z6.string(), + name: z6.string(), + content: z6.string(), + ttl: z6.number().optional(), + proxied: z6.boolean().optional(), + priority: z6.number().optional() }); app2.get("/domains/:id/dns", async (request) => { const { id } = request.params; @@ -1653,7 +1948,7 @@ async function dnsRoutes(app2) { }); app2.post("/domains/:id/dns/bulk", async (request) => { const { id } = request.params; - const body = z7.object({ operations: z7.array(z7.record(z7.unknown())) }).parse(request.body); + const body = z6.object({ operations: z6.array(z6.record(z6.unknown())) }).parse(request.body); return bulk( request.server.db, request.server.cf, @@ -1691,7 +1986,7 @@ async function dnsRoutes(app2) { }); app2.post("/domains/:id/dns/:recordId/resolve", async (request) => { const { id, recordId } = request.params; - const body = z7.object({ source: z7.string() }).parse(request.body); + const body = z6.object({ source: z6.string() }).parse(request.body); return resolveConflict( request.server.db, request.server.cf, @@ -1703,8 +1998,9 @@ async function dnsRoutes(app2) { } // src/routes/subdomains.ts -import { z as z8 } from "zod"; +import { updateSubdomainSchema } from "@cfdm/shared"; import { repos as repos8 } from "@cfdm/db"; +import { z as z7 } from "zod"; async function subdomainRoutes(app2) { app2.get("/domains/:id/subdomains", async (request) => { const { id } = request.params; @@ -1713,7 +2009,7 @@ async function subdomainRoutes(app2) { }); app2.post("/domains/:id/subdomains", async (request) => { const { id } = request.params; - const body = z8.object({ name: z8.string() }).parse(request.body); + const body = z7.object({ name: z7.string() }).parse(request.body); const domain = repos8.getDomain(request.server.db, Number(id)); const fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`; return repos8.createSubdomain( @@ -1729,16 +2025,18 @@ async function subdomainRoutes(app2) { }); app2.patch("/subdomains/:id", async (request) => { const { id } = request.params; - const body = z8.object({ name: z8.string() }).parse(request.body); + const body = updateSubdomainSchema.parse(request.body); const sub = repos8.getSubdomain(request.server.db, Number(id)); const domain = repos8.getDomain(request.server.db, sub.domain_id); - const fqdn = `${body.name}.${domain.zone_name}`; - return repos8.updateSubdomain( - request.server.db, - Number(id), - body.name, - fqdn - ); + const patch = {}; + if (body.name !== void 0) { + patch.name = body.name; + patch.fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`; + } + if (body.enabled !== void 0) { + patch.enabled = body.enabled; + } + return repos8.updateSubdomain(request.server.db, Number(id), patch); }); app2.delete("/subdomains/:id", async (request) => { const { id } = request.params; @@ -1896,6 +2194,7 @@ async function syncRoutes(app2) { } // src/app.ts +import { AsyncTask, CronJob } from "toad-scheduler"; async function buildApp(opts = {}) { const config2 = opts.config ?? loadConfig(); const app2 = Fastify({ @@ -1943,20 +2242,23 @@ async function buildApp(opts = {}) { } if (!opts.memory) { await app2.register(import("@fastify/schedule")); - app2.scheduler.addCronJob( - { - cronExpression: config2.certCheckCron, - name: "certificate-check" - }, + const certTask = new AsyncTask( + "certificate-check", async () => { - try { - const n = await runAllChecks(app2.db); - app2.log.info({ checked: n }, "certificate check completed"); - } catch (err) { - app2.log.warn({ err }, "certificate check failed"); - } + const n = await runAllChecks(app2.db); + app2.log.info({ checked: n }, "certificate check completed"); + }, + (err) => { + app2.log.warn({ err }, "certificate check failed"); } ); + app2.scheduler.addCronJob( + new CronJob( + { cronExpression: config2.certCheckCron }, + certTask, + { preventOverrun: true } + ) + ); } return app2; } diff --git a/apps/api/src/routes/domains.ts b/apps/api/src/routes/domains.ts index fe3cd18..2de1c47 100644 --- a/apps/api/src/routes/domains.ts +++ b/apps/api/src/routes/domains.ts @@ -1,4 +1,5 @@ import type { FastifyInstance } from "fastify"; +import { updateDomainSchema } from "@cfdm/shared"; import { z } from "zod"; import * as domainService from "../services/domain-service.js"; @@ -8,11 +9,6 @@ export async function domainRoutes(app: FastifyInstance) { group_id: z.number().nullable().optional(), }); - const updateSchema = z.object({ - group_id: z.number().nullable().optional(), - status: z.string().optional(), - }); - app.get("/domains", async (request) => { const query = request.query as { group_id?: string }; const groupId = query.group_id ? Number(query.group_id) : undefined; @@ -36,13 +32,14 @@ export async function domainRoutes(app: FastifyInstance) { app.patch("/domains/:id", async (request) => { const { id } = request.params as { id: string }; - const body = updateSchema.parse(request.body); + const body = updateDomainSchema.parse(request.body); const existing = domainService.getDomain(request.server.db, Number(id)); return domainService.updateDomain( request.server.db, Number(id), body.group_id !== undefined ? body.group_id : existing.group_id, body.status ?? existing.status, + body.cert_monitoring, ); }); diff --git a/apps/api/src/routes/service-groups.ts b/apps/api/src/routes/service-groups.ts index 7903c83..91d23e4 100644 --- a/apps/api/src/routes/service-groups.ts +++ b/apps/api/src/routes/service-groups.ts @@ -1,14 +1,9 @@ import type { FastifyInstance } from "fastify"; -import { z } from "zod"; +import { createServiceGroupSchema, toggleEnabledSchema } from "@cfdm/shared"; import * as serviceConfig from "../services/service-config-service.js"; export async function serviceGroupRoutes(app: FastifyInstance) { - const bodySchema = z.object({ - name: z.string(), - type: z.string().optional(), - icon: z.string().optional(), - domain: z.string().optional(), - }); + const bodySchema = createServiceGroupSchema; app.get("/service-groups", async (request) => { return serviceConfig.listGroupViews(request.server.db); @@ -42,7 +37,7 @@ export async function serviceGroupRoutes(app: FastifyInstance) { app.patch("/service-groups/:id/toggle", async (request) => { const { id } = request.params as { id: string }; - const body = z.object({ enabled: z.boolean() }).parse(request.body); + const body = toggleEnabledSchema.parse(request.body); return serviceConfig.toggleGroup( request.server.db, request.server.cf, diff --git a/apps/api/src/routes/services.ts b/apps/api/src/routes/services.ts index 05ecd70..3f30c6a 100644 --- a/apps/api/src/routes/services.ts +++ b/apps/api/src/routes/services.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from "fastify"; import { z } from "zod"; +import { reorderServicesSchema } from "@cfdm/shared"; import { repos } from "@cfdm/db"; import * as serviceConfig from "../services/service-config-service.js"; @@ -14,6 +15,16 @@ export async function serviceRoutes(app: FastifyInstance) { return serviceConfig.listViews(request.server.db); }); + app.patch("/services/reorder", async (request) => { + const body = reorderServicesSchema.parse(request.body); + serviceConfig.reorderServices( + request.server.db, + body.group_id, + body.service_ids, + ); + return { ok: true }; + }); + app.post("/services", async (request) => { const body = createSchema.parse(request.body); const service = repos.createService( diff --git a/apps/api/src/routes/subdomains.ts b/apps/api/src/routes/subdomains.ts index 9214ce1..cdebd9a 100644 --- a/apps/api/src/routes/subdomains.ts +++ b/apps/api/src/routes/subdomains.ts @@ -1,6 +1,8 @@ import type { FastifyInstance } from "fastify"; -import { z } from "zod"; +import { updateSubdomainSchema } from "@cfdm/shared"; import { repos } from "@cfdm/db"; +import { z } from "zod"; +import type { UpdateSubdomainPatch } from "@cfdm/db"; export async function subdomainRoutes(app: FastifyInstance) { app.get("/domains/:id/subdomains", async (request) => { @@ -32,16 +34,26 @@ export async function subdomainRoutes(app: FastifyInstance) { app.patch("/subdomains/:id", async (request) => { const { id } = request.params as { id: string }; - const body = z.object({ name: z.string() }).parse(request.body); + const body = updateSubdomainSchema.parse(request.body); const sub = repos.getSubdomain(request.server.db, Number(id)); const domain = repos.getDomain(request.server.db, sub.domain_id); - const fqdn = `${body.name}.${domain.zone_name}`; - return repos.updateSubdomain( - request.server.db, - Number(id), - body.name, - fqdn, - ); + + const patch: UpdateSubdomainPatch = {}; + if (body.name !== undefined) { + patch.name = body.name; + patch.fqdn = + body.name === "@" + ? domain.zone_name + : `${body.name}.${domain.zone_name}`; + } + if (body.enabled !== undefined) { + patch.enabled = body.enabled; + } + if (body.cert_monitoring !== undefined) { + patch.cert_monitoring = body.cert_monitoring; + } + + return repos.updateSubdomain(request.server.db, Number(id), patch); }); app.delete("/subdomains/:id", async (request) => { diff --git a/apps/api/src/services/certificate-service.ts b/apps/api/src/services/certificate-service.ts index 7ee99db..b0dd669 100644 --- a/apps/api/src/services/certificate-service.ts +++ b/apps/api/src/services/certificate-service.ts @@ -2,13 +2,25 @@ 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 } from "@cfdm/shared"; +import type { Certificate, Domain, Subdomain } from "@cfdm/shared"; import { CERT_ERROR, + CERT_MONITOR_AUTO, + CERT_MONITOR_REQUIRED, + CERT_MONITOR_SKIPPED, CERT_UNKNOWN, certStatusFromExpiry, + fqdnToDisplay, + parseFqdn, + shouldMonitorService, } from "@cfdm/shared"; +export interface CertificateTarget { + domainId: number; + subdomainId: number | null; + hostname: string; +} + export function listCertificates( db: Db, status?: string, @@ -98,17 +110,137 @@ export async function checkAndStore( ); } -export async function runAllChecks(db: Db): Promise { - let count = 0; +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, + hostname: string, +): Subdomain | null { + if (hostname === "@") return null; + return repos.findSubdomainByDomainAndName(db, domainId, hostname); +} + +export function buildServiceCertificateFqdns( + db: Db, +): Map { + const result = new Map(); + + for (const binding of repos.listAllBindings(db)) { + const service = repos.getService(db, binding.service_id); + const group = service.service_group_id + ? repos.getServiceGroup(db, service.service_group_id) + : null; + if (!shouldMonitorService(service, group)) continue; + + const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname); + if (subdomain && !subdomain.enabled) continue; + + const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name); + result.set(fqdn, { + domainId: binding.domain_id, + subdomainId: subdomain?.id ?? null, + 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; + + 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)) { - await checkAndStore(db, domain.id, null, domain.zone_name); - count += 1; + 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)) { - await checkAndStore(db, sub.domain_id, sub.id, sub.fqdn); - count += 1; + 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, + }); + } } - return count; + + 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 { + const targets = resolveCertificateTargets(db); + for (const target of targets) { + await checkAndStore( + db, + target.domainId, + target.subdomainId, + target.hostname, + ); + } + repos.deleteCertificatesNotIn( + db, + targets.map((t) => t.hostname), + ); + return targets.length; } export function statusSummary(db: Db): Array<[string, number]> { diff --git a/apps/api/src/services/dns-service.ts b/apps/api/src/services/dns-service.ts index bd10b86..09ccf3a 100644 --- a/apps/api/src/services/dns-service.ts +++ b/apps/api/src/services/dns-service.ts @@ -6,6 +6,7 @@ import { SYNC_ERROR, SYNC_PENDING_PUSH, SYNC_SYNCED, + normalizeDnsRecordName, } from "@cfdm/shared"; import type { CloudflareClient } from "../lib/cf-client.js"; import { AppError } from "../errors.js"; @@ -87,12 +88,12 @@ async function pushRecord( repos.updateDnsFields( db, record.id, - record.record_type, - record.name, - record.content, - record.ttl, - record.proxied, - record.priority, + cfRec.type ?? record.record_type, + cfRec.name, + cfRec.content, + cfRec.ttl, + cfRec.proxied ?? false, + cfRec.priority ?? null, SYNC_SYNCED, cfRec.id ?? null, null, @@ -119,13 +120,14 @@ export async function create( const domain = repos.getDomain(db, domainId); const ttl = req.ttl ?? 1; const proxied = req.proxied ?? false; - validateDnsRecord(req.record_type, req.name, req.content, ttl, proxied); + const name = normalizeDnsRecordName(req.name, domain.zone_name); + validateDnsRecord(req.record_type, name, req.content, ttl, proxied); const record = repos.insertDnsRecord( db, domainId, req.record_type, - req.name, + name, req.content, ttl, proxied, @@ -149,7 +151,10 @@ export async function update( const existing = repos.getDnsRecord(db, domainId, recordId); const recordType = req.record_type ?? existing.record_type; - const name = req.name ?? existing.name; + const name = normalizeDnsRecordName( + req.name ?? existing.name, + domain.zone_name, + ); const content = req.content ?? existing.content; const ttl = req.ttl ?? existing.ttl; const proxied = req.proxied ?? existing.proxied; diff --git a/apps/api/src/services/domain-service.ts b/apps/api/src/services/domain-service.ts index 1c6b7b9..21edac6 100644 --- a/apps/api/src/services/domain-service.ts +++ b/apps/api/src/services/domain-service.ts @@ -45,8 +45,9 @@ export function updateDomain( id: number, groupId: number | null, status: string, + certMonitoring?: string, ): Domain { - return repos.updateDomain(db, id, groupId, status); + return repos.updateDomain(db, id, groupId, status, certMonitoring); } export function deleteDomain(db: Db, id: number): void { diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index 77dd8e9..3e06d15 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -1,6 +1,7 @@ import type { Db } from "@cfdm/db"; import { repos } from "@cfdm/db"; import type { + DnsRecord, Service, ServiceGroup, ServiceGroupsResponse, @@ -10,6 +11,8 @@ import { SYNC_ERROR, SYNC_PENDING_PUSH, SYNC_SYNCED, + dnsRecordNamesMatch, + normalizeDnsRecordName, } from "@cfdm/shared"; import type { CloudflareClient } from "../lib/cf-client.js"; import { AppError } from "../errors.js"; @@ -21,6 +24,7 @@ export interface ServiceDomainInput { fqdn: string; target_ips?: string[]; target_ip?: string; + target_cname?: string; } export interface ToggleRequest { @@ -30,8 +34,8 @@ export interface ToggleRequest { export interface ServiceGroupBody { name: string; type?: string; - icon?: string; - domain?: string; + icon?: string | null; + domain?: string | null; } export interface UpdateServiceConfigRequest { @@ -114,13 +118,20 @@ async function buildView(db: Db, serviceId: number): Promise { const records = repos.listRecordsForBinding(db, binding.id); const statuses = records.map((r) => r.sync_status); const targetIps = repos.listBindingIps(db, binding.id); + const linkedCname = records.find( + (record) => record.record_type.toUpperCase() === "CNAME", + ); + const targetCname = + binding.cname_target?.trim() || linkedCname?.content?.trim() || null; return { binding_id: binding.id, domain_id: binding.domain_id, zone_name: binding.zone_name, hostname: binding.hostname, fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), - target_ips: targetIps, + record_type: targetCname ? ("CNAME" as const) : ("A" as const), + target_ips: targetCname ? [] : targetIps, + target_cname: targetCname, sync_status: aggregateSyncStatus(statuses), }; }); @@ -185,16 +196,144 @@ async function syncBindingDns( domainId: number, hostname: string, desiredIps: string[], + cnameTarget: string | null, ): Promise { + const domain = repos.getDomain(db, domainId); + const zoneName = domain.zone_name; + let effectiveCname = cnameTarget?.trim() || null; + + if (!effectiveCname) { + const existingCname = await findOrImportDnsRecord( + db, + cf, + domainId, + zoneName, + hostname, + "CNAME", + ); + if (existingCname) { + effectiveCname = existingCname.content; + repos.setBindingCnameTarget(db, bindingId, effectiveCname); + repos.replaceBindingIps(db, bindingId, []); + } + } + + if (effectiveCname) { + await syncBindingCnameDns( + db, + cf, + bindingId, + domainId, + hostname, + effectiveCname, + ); + return; + } + + await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps); +} + +async function syncBindingCnameDns( + db: Db, + cf: CloudflareClient, + bindingId: number, + domainId: number, + hostname: string, + cnameTarget: string, +): Promise { + const domain = repos.getDomain(db, domainId); + const zoneName = domain.zone_name; + const normalized = normalizeCnameTarget(cnameTarget, zoneName); const existingRecords = repos.listRecordsForBinding(db, bindingId); for (const record of existingRecords) { - if (!desiredIps.includes(record.content)) { + if (record.record_type.toUpperCase() === "A") { repos.unlinkBindingRecord(db, bindingId, record.id); await dnsService.deleteRecord(db, cf, domainId, record.id); } } + const refreshed = repos.listRecordsForBinding(db, bindingId); + const existingCname = refreshed.find( + (record) => record.record_type.toUpperCase() === "CNAME", + ); + + let recordId: number; + if (existingCname) { + if ( + !cnameContentMatches(existingCname.content, normalized, zoneName) || + !dnsRecordNamesMatch(existingCname.name, hostname, zoneName) + ) { + await dnsService.update(db, cf, domainId, existingCname.id, { + record_type: "CNAME", + name: dnsNameForBinding(hostname, zoneName), + content: normalized, + proxied: false, + }); + } + recordId = existingCname.id; + } else { + const adopted = await findOrImportDnsRecord( + db, + cf, + domainId, + zoneName, + hostname, + "CNAME", + normalized, + ); + if (adopted) { + repos.linkBindingRecord(db, bindingId, adopted.id); + if (!cnameContentMatches(adopted.content, normalized, zoneName)) { + await dnsService.update(db, cf, domainId, adopted.id, { + record_type: "CNAME", + name: dnsNameForBinding(hostname, zoneName), + content: normalized, + proxied: false, + }); + } + recordId = adopted.id; + } else { + const record = await dnsService.create(db, cf, domainId, { + record_type: "CNAME", + name: dnsNameForBinding(hostname, zoneName), + content: normalized, + ttl: 1, + proxied: false, + }); + repos.linkBindingRecord(db, bindingId, record.id); + recordId = record.id; + } + } + + repos.setBindingDnsRecordId(db, bindingId, recordId); + repos.setBindingCnameTarget(db, bindingId, normalized); +} + +async function syncBindingADns( + db: Db, + cf: CloudflareClient, + bindingId: number, + domainId: number, + hostname: string, + desiredIps: string[], +): Promise { + const domain = repos.getDomain(db, domainId); + const zoneName = domain.zone_name; + const existingRecords = repos.listRecordsForBinding(db, bindingId); + + for (const record of existingRecords) { + if (record.record_type.toUpperCase() === "CNAME") { + repos.unlinkBindingRecord(db, bindingId, record.id); + await dnsService.deleteRecord(db, cf, domainId, record.id); + } else if (!desiredIps.includes(record.content)) { + repos.unlinkBindingRecord(db, bindingId, record.id); + await dnsService.deleteRecord(db, cf, domainId, record.id); + } + } + + repos.setBindingCnameTarget(db, bindingId, null); + if (desiredIps.length === 0) { repos.setBindingDnsRecordId(db, bindingId, null); return; @@ -207,25 +346,39 @@ async function syncBindingDns( const existing = refreshed.find((r) => r.content === ip); let recordId: number; if (existing) { - if (existing.name !== hostname) { + if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) { await dnsService.update(db, cf, domainId, existing.id, { record_type: "A", - name: hostname, + name: dnsNameForBinding(hostname, zoneName), content: ip, proxied: false, }); } recordId = existing.id; } else { - const record = await dnsService.create(db, cf, domainId, { - record_type: "A", - name: hostname, - content: ip, - ttl: 1, - proxied: false, - }); - repos.linkBindingRecord(db, bindingId, record.id); - recordId = record.id; + const adopted = await findOrImportDnsRecord( + db, + cf, + domainId, + zoneName, + hostname, + "A", + ip, + ); + if (adopted) { + repos.linkBindingRecord(db, bindingId, adopted.id); + recordId = adopted.id; + } else { + const record = await dnsService.create(db, cf, domainId, { + record_type: "A", + name: dnsNameForBinding(hostname, zoneName), + content: ip, + ttl: 1, + proxied: false, + }); + repos.linkBindingRecord(db, bindingId, record.id); + recordId = record.id; + } } if (primaryId == null) primaryId = recordId; } @@ -240,7 +393,7 @@ async function cleanupBindingDns( domainId: number, hostname: string, ): Promise { - await syncBindingDns(db, cf, bindingId, domainId, hostname, []); + await syncBindingDns(db, cf, bindingId, domainId, hostname, [], null); } async function cleanupServiceDnsOnly( @@ -272,6 +425,7 @@ function validateTargetIpsInPool(targetIps: string[], ips: string[]): void { } function bindingTargetIps(input: ServiceDomainInput): string[] { + if (input.target_cname?.trim()) return []; const raw = input.target_ips ? input.target_ips : input.target_ip?.trim() @@ -284,26 +438,209 @@ function bindingTargetIps(input: ServiceDomainInput): string[] { return normalized; } +function bindingTargetCname(input: ServiceDomainInput): string | null { + const target = input.target_cname?.trim(); + return target ? target : null; +} + +function normalizeCnameTarget(target: string, zoneName: string): string { + const trimmed = target.trim().toLowerCase(); + if (!trimmed) { + throw AppError.validation("укажите CNAME-цель"); + } + if (trimmed.includes(".")) return trimmed; + return `${trimmed}.${zoneName.toLowerCase()}`; +} + +function dnsNameForBinding(hostname: string, zoneName: string): string { + return normalizeDnsRecordName(hostname, zoneName); +} + +function cnameContentMatches( + left: string, + right: string, + zoneName: string, +): boolean { + return ( + normalizeCnameTarget(left, zoneName) === + normalizeCnameTarget(right, zoneName) + ); +} + +function findLocalDnsRecord( + db: Db, + domainId: number, + zoneName: string, + hostname: string, + recordType: "A" | "CNAME", + content?: string, +): DnsRecord | null { + const records = repos.listDnsByDomain(db, domainId); + return ( + records.find( + (record) => + record.record_type.toUpperCase() === recordType && + (content == null || record.content === content) && + dnsRecordNamesMatch(record.name, hostname, zoneName), + ) ?? null + ); +} + +async function findOrImportDnsRecord( + db: Db, + cf: CloudflareClient, + domainId: number, + zoneName: string, + hostname: string, + recordType: "A" | "CNAME", + content?: string, +): Promise { + const local = findLocalDnsRecord( + db, + domainId, + zoneName, + hostname, + recordType, + content, + ); + if (local) return local; + + const domain = repos.getDomain(db, domainId); + const remote = await cf.listDnsRecords(domain.cf_zone_id); + for (const cfRec of remote) { + if (cfRec.type.toUpperCase() !== recordType) continue; + if (content != null) { + if (recordType === "CNAME") { + if (!cnameContentMatches(cfRec.content, content, zoneName)) continue; + } else if (cfRec.content !== content) { + continue; + } + } + if (!dnsRecordNamesMatch(cfRec.name, hostname, zoneName)) continue; + if (!cfRec.id) continue; + + const existing = repos.findDnsByCfId(db, domainId, cfRec.id); + if (existing) return existing; + + return repos.insertDnsRecord( + db, + domainId, + cfRec.type, + cfRec.name, + cfRec.content, + cfRec.ttl, + cfRec.proxied ?? false, + cfRec.priority ?? null, + SYNC_SYNCED, + "cloudflare", + cfRec.id, + ); + } + + return null; +} + +async function findOrImportDnsARecord( + db: Db, + cf: CloudflareClient, + domainId: number, + zoneName: string, + hostname: string, + content: string, +): Promise { + return findOrImportDnsRecord( + db, + cf, + domainId, + zoneName, + hostname, + "A", + content, + ); +} + +async function serviceBindingsExistInDns( + db: Db, + cf: CloudflareClient, + serviceId: number, +): Promise { + const bindings = repos.listBindingsByService(db, serviceId); + if (bindings.length === 0) return false; + + for (const binding of bindings) { + const cnameTarget = binding.cname_target?.trim() || null; + if (cnameTarget) { + const record = await findOrImportDnsRecord( + db, + cf, + binding.domain_id, + binding.zone_name, + binding.hostname, + "CNAME", + cnameTarget, + ); + if (!record) return false; + continue; + } + + const targetIps = repos.listBindingIps(db, binding.id); + if (targetIps.length === 0) return false; + + for (const ip of targetIps) { + const record = await findOrImportDnsARecord( + db, + cf, + binding.domain_id, + binding.zone_name, + binding.hostname, + ip, + ); + if (!record) return false; + } + } + + return true; +} + async function syncServiceBindingsToDns( db: Db, cf: CloudflareClient, serviceId: number, ): Promise { - const ips = repos.listServiceIps(db, serviceId); - if (ips.length === 0) { - throw AppError.validation("добавьте IP-адреса в пул сервиса"); - } - const bindings = repos.listBindingsByService(db, serviceId); if (bindings.length === 0) { throw AppError.validation("настройте FQDN в редакторе сервиса"); } + const needsIpPool = bindings.some((binding) => { + if (binding.cname_target?.trim()) return false; + const targetIps = repos.listBindingIps(db, binding.id); + return targetIps.length > 0; + }); + const ips = repos.listServiceIps(db, serviceId); + if (needsIpPool && ips.length === 0) { + throw AppError.validation("добавьте IP-адреса в пул сервиса"); + } + for (const binding of bindings) { + const cnameTarget = binding.cname_target?.trim() || null; + if (cnameTarget) { + await syncBindingDns( + db, + cf, + binding.id, + binding.domain_id, + binding.hostname, + [], + cnameTarget, + ); + continue; + } + const targetIps = repos.listBindingIps(db, binding.id); if (targetIps.length === 0) { throw AppError.validation( - `укажите IP для ${fqdnToDisplay(binding.hostname, binding.zone_name)}`, + `укажите IP или CNAME для ${fqdnToDisplay(binding.hostname, binding.zone_name)}`, ); } validateTargetIpsInPool(targetIps, ips); @@ -314,6 +651,7 @@ async function syncServiceBindingsToDns( binding.domain_id, binding.hostname, targetIps, + null, ); } } @@ -345,6 +683,8 @@ async function syncGroupDomainDnsRecords( hostname: string, desiredIps: string[], ): Promise { + const domain = repos.getDomain(db, domainId); + const zoneName = domain.zone_name; const existingRecords = repos.listGroupDnsRecords(db, groupId); for (const record of existingRecords) { @@ -360,19 +700,31 @@ async function syncGroupDomainDnsRecords( for (const ip of desiredIps) { const existing = refreshed.find((r) => r.content === ip); if (existing) { - if (existing.name !== hostname) { + if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) { await dnsService.update(db, cf, domainId, existing.id, { record_type: "A", - name: hostname, + name: dnsNameForBinding(hostname, zoneName), content: ip, proxied: false, }); } continue; } + const adopted = await findOrImportDnsARecord( + db, + cf, + domainId, + zoneName, + hostname, + ip, + ); + if (adopted) { + repos.linkGroupDnsRecord(db, groupId, adopted.id); + continue; + } const record = await dnsService.create(db, cf, domainId, { record_type: "A", - name: hostname, + name: dnsNameForBinding(hostname, zoneName), content: ip, ttl: 1, proxied: false, @@ -466,7 +818,7 @@ async function syncEnabledServicesInGroup( async function normalizeGroupDomain( db: Db, cf: CloudflareClient, - domain?: string, + domain?: string | null, ): Promise { const raw = domain?.trim(); if (!raw) return null; @@ -543,8 +895,15 @@ export async function updateConfig( for (const input of req.domains) { const fqdn = input.fqdn.trim(); if (!fqdn) continue; + const targetCname = bindingTargetCname(input); const targetIps = bindingTargetIps(input); - validateTargetIpsInPool(targetIps, ips); + if (!targetCname) { + validateTargetIpsInPool(targetIps, ips); + } else if (targetIps.length > 0) { + throw AppError.validation( + `укажите либо IP, либо CNAME для ${fqdn}`, + ); + } const { zoneName, hostname } = parseFqdn(fqdn, knownZones); const domainId = await resolveDomainId(db, cf, zoneName); @@ -554,7 +913,8 @@ export async function updateConfig( repos.insertBinding(db, domainId, id, hostname, null); keptBindingIds.push(binding.id); - repos.replaceBindingIps(db, binding.id, targetIps); + repos.replaceBindingIps(db, binding.id, targetCname ? [] : targetIps); + repos.setBindingCnameTarget(db, binding.id, targetCname); if (pushDns) { await syncBindingDns( @@ -564,6 +924,7 @@ export async function updateConfig( domainId, hostname, targetIps, + targetCname, ); } } @@ -598,6 +959,15 @@ export async function updateConfig( if (shouldPushDns(db, service)) { await syncServiceBindingsToDns(db, cf, id); await syncGroupDomainForService(db, cf, id); + } else if ( + req.domains && + req.domains.length > 0 && + !service.enabled && + (await serviceBindingsExistInDns(db, cf, id)) + ) { + repos.setServiceEnabled(db, id, true); + await syncServiceBindingsToDns(db, cf, id); + await syncGroupDomainForService(db, cf, id); } return buildView(db, id); @@ -633,7 +1003,7 @@ export async function updateGroup( await cleanupGroupDomainDns(db, cf, id); } const domain = await normalizeGroupDomain(db, cf, body.domain); - const group = repos.updateServiceGroup( + let group = repos.updateServiceGroup( db, id, body.name, @@ -641,6 +1011,10 @@ export async function updateGroup( body.icon ?? null, domain, ); + if (!domain && group.enabled) { + repos.setServiceGroupEnabled(db, id, false); + group = repos.getServiceGroup(db, id); + } await syncEnabledServicesInGroup(db, cf, id); return group; } @@ -659,12 +1033,9 @@ export async function toggleService( if (enabled && service.service_group_id) { const group = repos.getServiceGroup(db, service.service_group_id); - if (!group.enabled) { + if (group.domain?.trim() && !group.enabled) { throw AppError.validation("сначала включите группу сервисов"); } - if (!group.domain?.trim()) { - throw AppError.validation("укажите домен у группы сервисов"); - } } repos.setServiceEnabled(db, serviceId, enabled); @@ -686,6 +1057,11 @@ export async function toggleGroup( groupId: number, enabled: boolean, ): Promise { + const group = repos.getServiceGroup(db, groupId); + if (enabled && !group.domain?.trim()) { + throw AppError.validation("нельзя включить группу без домена"); + } + repos.setServiceGroupEnabled(db, groupId, enabled); if (!enabled) { @@ -703,3 +1079,14 @@ export async function toggleGroup( return listGroupViews(db); } + +export function reorderServices( + db: Db, + groupId: number | null, + serviceIds: number[], +): void { + if (groupId !== null) { + repos.getServiceGroup(db, groupId); + } + repos.reorderServices(db, groupId, serviceIds); +} diff --git a/apps/api/src/services/sync-service.ts b/apps/api/src/services/sync-service.ts index 16bf9ff..e48035c 100644 --- a/apps/api/src/services/sync-service.ts +++ b/apps/api/src/services/sync-service.ts @@ -1,16 +1,93 @@ import type { Db } from "@cfdm/db"; import { repos } from "@cfdm/db"; -import type { Domain, SyncJob } from "@cfdm/shared"; +import type { CfDnsRecord, Domain, DnsRecord, SyncJob } from "@cfdm/shared"; import { SYNC_CONFLICT, SYNC_PENDING_PUSH, SYNC_SYNCED, dnsNameToSubdomainLabel, + dnsRecordNamesMatch, subdomainLabelToFqdn, } from "@cfdm/shared"; import type { CloudflareClient } from "../lib/cf-client.js"; import { randomUUID } from "node:crypto"; +function findLocalByRemote( + local: DnsRecord[], + cfRec: CfDnsRecord, + zoneName: string, +): DnsRecord | null { + return ( + local.find( + (record) => + record.record_type.toUpperCase() === cfRec.type.toUpperCase() && + dnsRecordNamesMatch(record.name, cfRec.name, zoneName), + ) ?? null + ); +} + +function dnsRecordsEquivalent( + existing: DnsRecord, + cfRec: CfDnsRecord, + zoneName: string, +): boolean { + const proxied = cfRec.proxied ?? false; + return ( + existing.content === cfRec.content && + existing.ttl === cfRec.ttl && + existing.proxied === proxied && + dnsRecordNamesMatch(existing.name, cfRec.name, zoneName) && + existing.record_type.toUpperCase() === cfRec.type.toUpperCase() + ); +} + +function applyRemoteRecord( + db: Db, + domainId: number, + cfRec: CfDnsRecord, + existing: DnsRecord, + zoneName: string, +): boolean { + const cfId = cfRec.id; + if (!cfId) return false; + + const proxied = cfRec.proxied ?? false; + const equivalent = dnsRecordsEquivalent(existing, cfRec, zoneName); + + if (!equivalent && existing.sync_status !== SYNC_PENDING_PUSH) { + repos.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT, cfId, null); + return true; + } + + if (!equivalent) return false; + + if ( + existing.name !== cfRec.name || + existing.sync_status !== SYNC_SYNCED || + existing.cf_record_id !== cfId || + existing.content !== cfRec.content || + existing.ttl !== cfRec.ttl || + existing.proxied !== proxied + ) { + repos.updateDnsFields( + db, + existing.id, + cfRec.type, + cfRec.name, + cfRec.content, + cfRec.ttl, + proxied, + cfRec.priority ?? null, + SYNC_SYNCED, + cfId, + null, + ); + return true; + } + + return false; +} + export async function pullSync( db: Db, cf: CloudflareClient, @@ -27,22 +104,14 @@ export async function pullSync( for (const cfRec of remote) { const cfId = cfRec.id; if (!cfId) continue; - const proxied = cfRec.proxied ?? false; - const existing = repos.findDnsByCfId(db, domain.id, cfId); + let existing = repos.findDnsByCfId(db, domain.id, cfId); + if (!existing) { + existing = findLocalByRemote(local, cfRec, domain.zone_name); + } + if (existing) { - const contentMatch = - existing.content === cfRec.content && - existing.ttl === cfRec.ttl && - existing.proxied === proxied && - existing.name === cfRec.name && - existing.record_type.toUpperCase() === cfRec.type.toUpperCase(); - - if (!contentMatch && existing.sync_status !== SYNC_PENDING_PUSH) { - repos.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT, cfId, null); - changed += 1; - } else if (contentMatch && existing.sync_status === SYNC_CONFLICT) { - repos.setDnsSyncStatus(db, existing.id, SYNC_SYNCED, cfId, null); + if (applyRemoteRecord(db, domain.id, cfRec, existing, domain.zone_name)) { changed += 1; } } else { @@ -53,7 +122,7 @@ export async function pullSync( cfRec.name, cfRec.content, cfRec.ttl, - proxied, + cfRec.proxied ?? false, cfRec.priority ?? null, SYNC_SYNCED, "cloudflare", @@ -63,7 +132,9 @@ export async function pullSync( } } - for (const rec of local) { + const refreshedLocal = repos.listDnsByDomain(db, domain.id); + + for (const rec of refreshedLocal) { if (rec.cf_record_id && !remoteIds.has(rec.cf_record_id)) { if (rec.sync_status !== "pending_delete") { repos.setDnsSyncStatus( @@ -75,6 +146,41 @@ export async function pullSync( ); changed += 1; } + continue; + } + + if (rec.sync_status === SYNC_PENDING_PUSH) continue; + + const remoteSameType = remote.find( + (r) => + r.id && + dnsRecordNamesMatch(r.name, rec.name, domain.zone_name) && + r.type.toUpperCase() === rec.record_type.toUpperCase(), + ); + + if (remoteSameType?.id) { + if (applyRemoteRecord(db, domain.id, remoteSameType, rec, domain.zone_name)) { + changed += 1; + } + continue; + } + + const remoteSameHost = remote.find((r) => + dnsRecordNamesMatch(r.name, rec.name, domain.zone_name), + ); + + if ( + remoteSameHost && + remoteSameHost.type.toUpperCase() !== rec.record_type.toUpperCase() + ) { + repos.setDnsSyncStatus( + db, + rec.id, + SYNC_CONFLICT, + rec.cf_record_id, + "type mismatch with cloudflare", + ); + changed += 1; } } diff --git a/apps/api/test/certificates.test.ts b/apps/api/test/certificates.test.ts new file mode 100644 index 0000000..d19bc12 --- /dev/null +++ b/apps/api/test/certificates.test.ts @@ -0,0 +1,257 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { repos } from "@cfdm/db"; +import { + CERT_ERROR, + CERT_MONITOR_REQUIRED, + CERT_MONITOR_SKIPPED, +} from "@cfdm/shared"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import * as certificateService from "../src/services/certificate-service.js"; + +async function authHeaders(app: Awaited>) { + const config = loadConfig(); + const res = await app.inject({ + method: "POST", + url: "/api/v1/auth/login", + payload: { username: config.adminUsername, password: "admin" }, + }); + expect(res.statusCode).toBe(200); + const { token } = res.json() as { token: string }; + return { authorization: `Bearer ${token}` }; +} + +describe("certificates", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("auto mode does not monitor DNS-only domain", async () => { + const testApp = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(testApp); + + const domain = repos.createDomain( + testApp.db, + null, + "dns-only.example.com", + "cf-zone-dns", + ); + + vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ + expiresAt: null, + error: "connection refused", + }); + + const checkRes = await testApp.inject({ + method: "POST", + url: "/api/v1/certificates/check", + headers, + }); + expect(checkRes.statusCode).toBe(200); + + const certs = repos.listCertificates(testApp.db); + expect(certs.find((c) => c.hostname === domain.zone_name)).toBeUndefined(); + + await testApp.close(); + }); + + it("monitors host with enabled service binding", async () => { + const testApp = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(testApp); + + const domain = repos.createDomain( + testApp.db, + null, + "app.example.com", + "cf-zone-app", + ); + const service = repos.createService(testApp.db, "Web", "web"); + repos.setServiceEnabled(testApp.db, service.id, true); + repos.insertBinding(testApp.db, domain.id, service.id, "api", null); + + const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); + vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ + expiresAt, + error: null, + }); + + await testApp.inject({ + method: "POST", + url: "/api/v1/certificates/check", + headers, + }); + + const certs = repos.listCertificates(testApp.db); + expect(certs.some((c) => c.hostname === "api.app.example.com")).toBe(true); + expect(certs.some((c) => c.hostname === "app.example.com")).toBe(false); + + await testApp.close(); + }); + + it("does not monitor host when service is disabled", async () => { + const testApp = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(testApp); + + const domain = repos.createDomain( + testApp.db, + null, + "off.example.com", + "cf-zone-off", + ); + const service = repos.createService(testApp.db, "Off", "off"); + repos.insertBinding(testApp.db, domain.id, service.id, "@", null); + + vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + error: null, + }); + + await testApp.inject({ + method: "POST", + url: "/api/v1/certificates/check", + headers, + }); + + expect( + repos.listCertificates(testApp.db).some((c) => c.hostname === domain.zone_name), + ).toBe(false); + + await testApp.close(); + }); + + it("required apex is monitored without bindings", async () => { + const testApp = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(testApp); + + const domain = repos.createDomain( + testApp.db, + null, + "required.example.com", + "cf-zone-req", + ); + repos.updateDomain( + testApp.db, + domain.id, + null, + "active", + CERT_MONITOR_REQUIRED, + ); + + vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + error: null, + }); + + await testApp.inject({ + method: "POST", + url: "/api/v1/certificates/check", + headers, + }); + + expect( + repos.listCertificates(testApp.db).some( + (c) => c.hostname === domain.zone_name, + ), + ).toBe(true); + + await testApp.close(); + }); + + it("skipped apex removes stale certificate on check", async () => { + const testApp = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(testApp); + + const domain = repos.createDomain( + testApp.db, + null, + "skipped.example.com", + "cf-zone-skip", + ); + repos.upsertCertificateCheck( + testApp.db, + domain.id, + null, + domain.zone_name, + null, + CERT_ERROR, + "stale", + ); + repos.updateDomain( + testApp.db, + domain.id, + null, + "active", + CERT_MONITOR_SKIPPED, + ); + + vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ + expiresAt: null, + error: "should not be called", + }); + + await testApp.inject({ + method: "POST", + url: "/api/v1/certificates/check", + headers, + }); + + expect(repos.listCertificates(testApp.db)).toHaveLength(0); + expect(certificateService.checkHostname).not.toHaveBeenCalled(); + + await testApp.close(); + }); + + it("TLS failure on monitored host is stored as error", async () => { + const testApp = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(testApp); + + const domain = repos.createDomain( + testApp.db, + null, + "broken.example.com", + "cf-zone-broken", + ); + repos.updateDomain( + testApp.db, + domain.id, + null, + "active", + CERT_MONITOR_REQUIRED, + ); + + vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ + expiresAt: null, + error: "certificate has expired", + }); + + await testApp.inject({ + method: "POST", + url: "/api/v1/certificates/check", + headers, + }); + + const cert = repos.listCertificates(testApp.db)[0]; + expect(cert?.status).toBe(CERT_ERROR); + expect(cert?.last_error).toBeTruthy(); + + await testApp.close(); + }); +}); diff --git a/apps/api/test/dns-sync.test.ts b/apps/api/test/dns-sync.test.ts new file mode 100644 index 0000000..5dadd7c --- /dev/null +++ b/apps/api/test/dns-sync.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { createMemoryDb, repos, runMigrations } from "@cfdm/db"; +import { SYNC_CONFLICT, SYNC_ERROR, SYNC_SYNCED } from "@cfdm/shared"; +import type { CloudflareClient } from "../src/lib/cf-client.js"; +import { pullSync } from "../src/services/sync-service.js"; + +const ZONE = "rkns.top"; +const CF_ZONE_ID = "zone-rkns"; + +function mockCf(remote: Array<{ + id: string; + type: string; + name: string; + content: string; +}>): CloudflareClient { + return { + listDnsRecords: async () => + remote.map((record) => ({ + ...record, + ttl: 1, + proxied: false, + })), + } as unknown as CloudflareClient; +} + +function setupDb() { + const { db, sqlite } = createMemoryDb(); + runMigrations(sqlite); + return db; +} + +describe("pullSync", () => { + it("reconciles short local names with Cloudflare FQDNs", async () => { + const db = setupDb(); + const domain = repos.createDomain(db, null, ZONE, CF_ZONE_ID); + + repos.insertDnsRecord( + db, + domain.id, + "A", + "de", + "193.233.134.103", + 1, + false, + null, + SYNC_CONFLICT, + "local", + "cf-de", + ); + + const cf = mockCf([ + { + id: "cf-de", + type: "A", + name: "de.rkns.top", + content: "193.233.134.103", + }, + ]); + + const changes = await pullSync(db, cf, domain); + expect(changes).toBeGreaterThan(0); + + const records = repos.listDnsByDomain(db, domain.id); + expect(records).toHaveLength(1); + expect(records[0].name).toBe("de.rkns.top"); + expect(records[0].sync_status).toBe(SYNC_SYNCED); + }); + + it("marks local record as conflict when Cloudflare has another type", async () => { + const db = setupDb(); + const domain = repos.createDomain(db, null, ZONE, CF_ZONE_ID); + + repos.insertDnsRecord( + db, + domain.id, + "A", + "mhome", + "185.244.181.61", + 1, + false, + null, + SYNC_ERROR, + "local", + null, + ); + + const cf = mockCf([ + { + id: "cf-mhome", + type: "CNAME", + name: "mhome.rkns.top", + content: "mmsk.rkns.top", + }, + ]); + + await pullSync(db, cf, domain); + + const records = repos.listDnsByDomain(db, domain.id); + const localA = records.find((r) => r.record_type === "A"); + expect(localA?.sync_status).toBe(SYNC_CONFLICT); + expect(localA?.last_error).toBe("type mismatch with cloudflare"); + + const localCname = records.find((r) => r.record_type === "CNAME"); + expect(localCname?.sync_status).toBe(SYNC_SYNCED); + }); +}); diff --git a/apps/api/test/services-reorder.test.ts b/apps/api/test/services-reorder.test.ts new file mode 100644 index 0000000..4522a4d --- /dev/null +++ b/apps/api/test/services-reorder.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { repos } from "@cfdm/db"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; + +async function authHeaders(app: Awaited>) { + const config = loadConfig(); + const res = await app.inject({ + method: "POST", + url: "/api/v1/auth/login", + payload: { username: config.adminUsername, password: "admin" }, + }); + expect(res.statusCode).toBe(200); + const { token } = res.json() as { token: string }; + return { authorization: `Bearer ${token}` }; +} + +describe("services reorder", () => { + it("PATCH /services/reorder persists order within group and ungrouped", async () => { + const app = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(app); + + const group = repos.createServiceGroup(app.db, "VPN", "vpn", null, null); + const alpha = repos.createService(app.db, "Alpha", "alpha"); + const beta = repos.createService(app.db, "Beta", "beta"); + const gamma = repos.createService(app.db, "Gamma", "gamma"); + + repos.setServiceGroup(app.db, alpha.id, group.id); + repos.setServiceGroup(app.db, beta.id, group.id); + + const reorderRes = await app.inject({ + method: "PATCH", + url: "/api/v1/services/reorder", + headers, + payload: { + group_id: group.id, + service_ids: [beta.id, alpha.id], + }, + }); + expect(reorderRes.statusCode).toBe(200); + + const grouped = repos.listServicesByGroup(app.db, group.id); + expect(grouped.map((s) => s.id)).toEqual([beta.id, alpha.id]); + + const ungroupedReorderRes = await app.inject({ + method: "PATCH", + url: "/api/v1/services/reorder", + headers, + payload: { + group_id: null, + service_ids: [gamma.id], + }, + }); + expect(ungroupedReorderRes.statusCode).toBe(200); + + const ungrouped = repos.listUngroupedServices(app.db); + expect(ungrouped[0]?.id).toBe(gamma.id); + + await app.close(); + }); +}); diff --git a/apps/api/test/subdomains.test.ts b/apps/api/test/subdomains.test.ts new file mode 100644 index 0000000..ee358a7 --- /dev/null +++ b/apps/api/test/subdomains.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { repos } from "@cfdm/db"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; + +async function authHeaders(app: Awaited>) { + const config = loadConfig(); + const res = await app.inject({ + method: "POST", + url: "/api/v1/auth/login", + payload: { username: config.adminUsername, password: "admin" }, + }); + expect(res.statusCode).toBe(200); + const { token } = res.json() as { token: string }; + return { authorization: `Bearer ${token}` }; +} + +describe("subdomains", () => { + it("PATCH /subdomains/:id toggles enabled and renames", async () => { + const app = await buildApp({ + config: { ...loadConfig(), staticDir: null }, + memory: true, + }); + const headers = await authHeaders(app); + + const domain = repos.createDomain( + app.db, + null, + "example.com", + "cf-zone-1", + ); + const created = repos.createSubdomain( + app.db, + domain.id, + "www", + "www.example.com", + ); + expect(created.enabled).toBe(true); + + const disableRes = await app.inject({ + method: "PATCH", + url: `/api/v1/subdomains/${created.id}`, + headers, + payload: { enabled: false }, + }); + expect(disableRes.statusCode).toBe(200); + expect(disableRes.json().enabled).toBe(false); + + const renameRes = await app.inject({ + method: "PATCH", + url: `/api/v1/subdomains/${created.id}`, + headers, + payload: { name: "api" }, + }); + expect(renameRes.statusCode).toBe(200); + const renamed = renameRes.json(); + expect(renamed.name).toBe("api"); + expect(renamed.fqdn).toBe("api.example.com"); + + await app.close(); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index a515187..5d21dea 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@base-ui/react": "^1.5.0", + "@cfdm/shared": "workspace:*", "@cfdm/ui": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/apps/web/src/components/board/column-ids.ts b/apps/web/src/components/board/column-ids.ts new file mode 100644 index 0000000..b052719 --- /dev/null +++ b/apps/web/src/components/board/column-ids.ts @@ -0,0 +1,11 @@ +export const UNGROUPED_COLUMN_ID = 'ungrouped' + +export function groupColumnId(groupId: number) { + return `group-${groupId}` +} + +export function parseGroupColumnId(columnId: string): number | null { + if (columnId === UNGROUPED_COLUMN_ID) return null + const match = columnId.match(/^group-(\d+)$/) + return match ? Number(match[1]) : null +} diff --git a/apps/web/src/components/board/drag-context-provider.tsx b/apps/web/src/components/board/drag-context-provider.tsx new file mode 100644 index 0000000..a696df7 --- /dev/null +++ b/apps/web/src/components/board/drag-context-provider.tsx @@ -0,0 +1,64 @@ +import { + DndContext, + DragOverlay, + PointerSensor, + closestCenter, + pointerWithin, + rectIntersection, + useSensor, + useSensors, + type CollisionDetection, + type DragEndEvent, + type DragStartEvent, +} from '@dnd-kit/core' +import type { ReactNode } from 'react' + +interface DragContextProviderProps { + children: ReactNode + overlay?: ReactNode + onDragStart: (event: DragStartEvent) => void + onDragEnd: (event: DragEndEvent) => void + disabled?: boolean +} + +const collisionDetection: CollisionDetection = (args) => { + const pointerCollisions = pointerWithin(args) + if (pointerCollisions.length > 0) { + return pointerCollisions + } + + const intersectionCollisions = rectIntersection(args) + if (intersectionCollisions.length > 0) { + return intersectionCollisions + } + + return closestCenter(args) +} + +export function DragContextProvider({ + children, + overlay, + onDragStart, + onDragEnd, + disabled = false, +}: DragContextProviderProps) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ) + + if (disabled) { + return <>{children} + } + + return ( + + {children} + {overlay} + + ) +} diff --git a/apps/web/src/components/confirm-dialog.tsx b/apps/web/src/components/confirm-dialog.tsx index 1ab6e66..53aceaa 100644 --- a/apps/web/src/components/confirm-dialog.tsx +++ b/apps/web/src/components/confirm-dialog.tsx @@ -12,7 +12,9 @@ import { } from '@cfdm/ui/components/alert-dialog' interface ConfirmDialogProps { - trigger: ReactElement + trigger?: ReactElement + open?: boolean + onOpenChange?: (open: boolean) => void title: string description: string confirmLabel?: string @@ -23,6 +25,8 @@ interface ConfirmDialogProps { export function ConfirmDialog({ trigger, + open, + onOpenChange, title, description, confirmLabel = 'Удалить', @@ -31,8 +35,10 @@ export function ConfirmDialog({ disabled, }: ConfirmDialogProps) { return ( - - + + {trigger ? ( + + ) : null} {title} diff --git a/apps/web/src/components/domain-actions-bar.tsx b/apps/web/src/components/domain-actions-bar.tsx new file mode 100644 index 0000000..8c29f1d --- /dev/null +++ b/apps/web/src/components/domain-actions-bar.tsx @@ -0,0 +1,17 @@ +import { PlusIcon } from 'lucide-react' +import { Button } from '@cfdm/ui/components/button' + +interface DomainActionsBarProps { + onCreateSubdomain: () => void +} + +export function DomainActionsBar({ onCreateSubdomain }: DomainActionsBarProps) { + return ( +
+ +
+ ) +} diff --git a/apps/web/src/components/domain-bindings-card.tsx b/apps/web/src/components/domain-bindings-card.tsx index 3ce4fd7..886622c 100644 --- a/apps/web/src/components/domain-bindings-card.tsx +++ b/apps/web/src/components/domain-bindings-card.tsx @@ -22,7 +22,6 @@ import { Item, ItemActions, ItemContent, - ItemDescription, ItemGroup, ItemSeparator, ItemTitle, @@ -32,12 +31,8 @@ interface DomainBindingsCardProps { bindings: ServiceBinding[] } -function uniqueIps(bindings: ServiceBinding[]): string[] { - return [ - ...new Set( - bindings.map((b) => b.target_ip).filter((ip): ip is string => Boolean(ip)), - ), - ] +function uniqueServices(bindings: ServiceBinding[]): string[] { + return [...new Set(bindings.map((b) => b.service_name))] } export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { @@ -48,7 +43,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { Привязки сервисов - IP-адреса, назначенные сервисам в этой зоне + Сервисы, назначенные hostname в этой зоне {entries.length === 0 ? ( @@ -56,34 +51,20 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { Нет привязок - Создайте привязку на странице сервисов — IP появится здесь после синхронизации DNS + Создайте привязку на странице сервисов или в таблице поддоменов ) : ( {entries.map(([hostname, hostnameBindings], index) => { - const ips = uniqueIps(hostnameBindings) - const services = [...new Set(hostnameBindings.map((b) => b.service_name))] + const services = uniqueServices(hostnameBindings) return (
{hostname} - -
- {ips.length > 0 ? ( - ips.map((ip) => ( - - {ip} - - )) - ) : ( - IP не задан - )} -
-
{services.map((name) => ( {name} @@ -103,6 +84,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { diff --git a/apps/web/src/components/domain-group-card.tsx b/apps/web/src/components/domain-group-card.tsx deleted file mode 100644 index 76a64b1..0000000 --- a/apps/web/src/components/domain-group-card.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { useDraggable } from '@dnd-kit/core' -import { CSS } from '@dnd-kit/utilities' -import { GlobeIcon } from 'lucide-react' -import { Link } from '@tanstack/react-router' -import { StatusBadge } from '@/components/status-badge' -import { Badge } from '@cfdm/ui/components/badge' -import { Button } from '@cfdm/ui/components/button' -import { - Card, - CardAction, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from '@cfdm/ui/components/card' -import { cn } from '@cfdm/ui/lib/utils' -import type { DomainListItem } from '@/lib/schemas' - -interface DomainGroupCardProps { - domain: DomainListItem - serviceLabels?: string[] -} - -export function DomainGroupCard({ domain, serviceLabels = [] }: DomainGroupCardProps) { - const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ - id: String(domain.id), - }) - - const style = transform - ? { transform: CSS.Translate.toString(transform) } - : undefined - - return ( - - - - - {domain.zone_name} - - - - - - {(serviceLabels.length > 0 || domain.service_count > 0) && ( - -
- {serviceLabels.length > 0 - ? serviceLabels.map((label) => ( - - {label} - - )) - : ( - - {domain.service_count} сервис(ов) - - )} -
-
- )} - - - -
- ) -} diff --git a/apps/web/src/components/domain-group-edit-sheet.tsx b/apps/web/src/components/domain-group-edit-sheet.tsx new file mode 100644 index 0000000..ce95253 --- /dev/null +++ b/apps/web/src/components/domain-group-edit-sheet.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react' +import type { CreateGroupInput, Group } from '@/lib/schemas' +import { Button } from '@cfdm/ui/components/button' +import { + Field, + FieldGroup, + FieldLabel, +} from '@cfdm/ui/components/field' +import { Input } from '@cfdm/ui/components/input' +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@cfdm/ui/components/sheet' +import { Spinner } from '@cfdm/ui/components/spinner' + +interface DomainGroupEditSheetProps { + mode: 'create' | 'edit' + group: Group | null + open: boolean + isSaving: boolean + onOpenChange: (open: boolean) => void + onCreate?: (body: CreateGroupInput) => void + onSave?: (id: number, body: CreateGroupInput) => void +} + +export function DomainGroupEditSheet({ + mode, + group, + open, + isSaving, + onOpenChange, + onCreate, + onSave, +}: DomainGroupEditSheetProps) { + const [name, setName] = useState('') + const [slug, setSlug] = useState('') + + useEffect(() => { + if (!open) return + if (mode === 'edit' && group) { + setName(group.name) + setSlug(group.slug) + } else { + setName('') + setSlug('') + } + }, [open, mode, group]) + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + const trimmedName = name.trim() + const trimmedSlug = slug.trim() + if (!trimmedName || !trimmedSlug) return + const body: CreateGroupInput = { + name: trimmedName, + slug: trimmedSlug, + } + if (mode === 'create') { + onCreate?.(body) + } else if (group) { + onSave?.(group.id, body) + } + } + + return ( + + + + + {mode === 'create' ? 'Новая группа доменов' : 'Редактировать группу'} + + + Группы используются для организации доменов на доске. + + +
+ + + Название + setName(event.target.value)} + placeholder="Production" + /> + + + Slug + setSlug(event.target.value)} + placeholder="production" + /> + + + + + +
+
+
+ ) +} diff --git a/apps/web/src/components/domain-header.tsx b/apps/web/src/components/domain-header.tsx new file mode 100644 index 0000000..4fc71bd --- /dev/null +++ b/apps/web/src/components/domain-header.tsx @@ -0,0 +1,69 @@ +import type { Domain } from '@/lib/schemas' +import type { CertMonitoring } from '@cfdm/shared' +import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring' +import { StatusBadge } from '@/components/status-badge' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@cfdm/ui/components/select' + +interface DomainHeaderProps { + domain: Domain + onCertMonitoringChange?: (value: CertMonitoring) => void + isCertMonitoringSaving?: boolean +} + +export function DomainHeader({ + domain, + onCertMonitoringChange, + isCertMonitoringSaving, +}: DomainHeaderProps) { + const certMonitoringItems = certMonitoringOptions.map((option) => ({ + label: option.label, + value: option.value, + })) + + return ( +
+

{domain.zone_name}

+

+ Домен: {domain.zone_name} +

+
+ Статус зоны: + +
+ {onCertMonitoringChange && ( +
+ + Мониторинг SSL (apex): + + +
+ )} +
+ ) +} diff --git a/apps/web/src/components/domains-data-table.tsx b/apps/web/src/components/domains-data-table.tsx index ac969f1..4de2fe3 100644 --- a/apps/web/src/components/domains-data-table.tsx +++ b/apps/web/src/components/domains-data-table.tsx @@ -17,7 +17,7 @@ import { MoreHorizontalIcon, SearchIcon, } from 'lucide-react' -import { DomainIpBadges } from '@/components/domain-ip-badges' +import { ConfirmDialog } from '@/components/confirm-dialog' import { StatusBadge } from '@/components/status-badge' import type { DomainListItem } from '@/lib/schemas' import { Badge } from '@cfdm/ui/components/badge' @@ -55,9 +55,7 @@ import { TableRow, } from '@cfdm/ui/components/table' -export interface DomainTableRow extends DomainListItem { - ips: string[] -} +export type DomainTableRow = DomainListItem interface GroupFilterItem { label: string @@ -69,6 +67,8 @@ interface DomainsDataTableProps { groupFilterItems: GroupFilterItem[] groupFilterValue: string onGroupFilterChange: (value: string | null) => void + onDelete: (domain: DomainTableRow) => void + isDeleting?: boolean } export function DomainsDataTable({ @@ -76,9 +76,12 @@ export function DomainsDataTable({ groupFilterItems, groupFilterValue, onGroupFilterChange, + onDelete, + isDeleting = false, }: DomainsDataTableProps) { const [sorting, setSorting] = useState([]) const [columnFilters, setColumnFilters] = useState([]) + const [deleteTarget, setDeleteTarget] = useState(null) const columns = useMemo[]>( () => [ @@ -98,6 +101,7 @@ export function DomainsDataTable({
), }, ], - [], + [isDeleting], ) const table = useReactTable({ @@ -353,6 +359,25 @@ export function DomainsDataTable({
)} + + { + if (!open) setDeleteTarget(null) + }} + title="Удалить зону?" + description={ + deleteTarget + ? `Зона «${deleteTarget.zone_name}» будет удалена из менеджера вместе с DNS-записями и привязками. Зона в Cloudflare не затрагивается.` + : '' + } + onConfirm={() => { + if (!deleteTarget) return + onDelete(deleteTarget) + setDeleteTarget(null) + }} + disabled={isDeleting} + /> ) } diff --git a/apps/web/src/components/groups-board/board-state.ts b/apps/web/src/components/groups-board/board-state.ts new file mode 100644 index 0000000..163b49c --- /dev/null +++ b/apps/web/src/components/groups-board/board-state.ts @@ -0,0 +1,96 @@ +import { + groupColumnId, + UNGROUPED_COLUMN_ID, +} from '@/components/groups-board/column-ids' +import type { BoardColumn, BoardState } from '@/components/groups-board/types' +import type { DomainListItem, Group } from '@/lib/schemas' + +export function mapGroupsDomainsToBoard( + groups: Group[], + domains: DomainListItem[], +): BoardState { + const columns: BoardColumn[] = groups.map((group) => ({ + id: groupColumnId(group.id), + groupId: group.id, + title: group.name, + slug: group.slug, + items: domains.filter((domain) => domain.group_id === group.id), + group, + })) + + const ungrouped = domains.filter((domain) => domain.group_id === null) + columns.push({ + id: UNGROUPED_COLUMN_ID, + groupId: null, + title: 'Без группы', + slug: null, + items: ungrouped, + }) + + return { columns } +} + +export function findColumnId( + columns: BoardColumn[], + itemId: string, +): string | undefined { + if (columns.some((column) => column.id === itemId)) return itemId + return columns.find((column) => + column.items.some((domain) => String(domain.id) === itemId), + )?.id +} + +export function moveDomainBetweenColumns( + board: BoardState, + domainId: number, + fromColumnId: string, + toColumnId: string, +): BoardState { + if (fromColumnId === toColumnId) return board + + const fromColumn = board.columns.find((column) => column.id === fromColumnId) + const domain = fromColumn?.items.find((item) => item.id === domainId) + if (!domain || !fromColumn) return board + + const targetColumn = board.columns.find((column) => column.id === toColumnId) + if (!targetColumn) return board + + const updatedDomain: DomainListItem = { + ...domain, + group_id: targetColumn.groupId, + group_name: targetColumn.group?.name ?? null, + } + + const columns = board.columns.map((column) => { + if (column.id === fromColumnId) { + return { + ...column, + items: column.items.filter((item) => item.id !== domainId), + } + } + if (column.id === toColumnId) { + return { + ...column, + items: [...column.items, updatedDomain], + } + } + return column + }) + + return { columns } +} + +export function boardToDomainsList( + board: BoardState, + previous: DomainListItem[], +): DomainListItem[] { + const byId = new Map(previous.map((domain) => [domain.id, domain])) + + for (const column of board.columns) { + for (const domain of column.items) { + byId.set(domain.id, domain) + } + } + + return Array.from(byId.values()) +} diff --git a/apps/web/src/components/groups-board/column-ids.ts b/apps/web/src/components/groups-board/column-ids.ts new file mode 100644 index 0000000..713a9c7 --- /dev/null +++ b/apps/web/src/components/groups-board/column-ids.ts @@ -0,0 +1,5 @@ +export { + UNGROUPED_COLUMN_ID, + groupColumnId, + parseGroupColumnId, +} from '@/components/board/column-ids' diff --git a/apps/web/src/components/groups-board/domain-group-header.tsx b/apps/web/src/components/groups-board/domain-group-header.tsx new file mode 100644 index 0000000..dbf0a83 --- /dev/null +++ b/apps/web/src/components/groups-board/domain-group-header.tsx @@ -0,0 +1,100 @@ +import { FolderTreeIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react' +import type { BoardColumn } from '@/components/groups-board/types' +import type { Group } from '@/lib/schemas' +import { AccordionTrigger } from '@cfdm/ui/components/accordion' +import { Badge } from '@cfdm/ui/components/badge' +import { Button } from '@cfdm/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@cfdm/ui/components/dropdown-menu' + +interface DomainGroupHeaderProps { + column: BoardColumn + isOpen?: boolean + isDragging?: boolean + dragDisabled?: boolean + onEditGroup?: (group: Group) => void + onDeleteGroup?: (group: Group) => void +} + +export function DomainGroupHeader({ + column, + isOpen = true, + isDragging = false, + dragDisabled = false, + onEditGroup, + onDeleteGroup, +}: DomainGroupHeaderProps) { + return ( +
+ +
+ {column.groupId !== null ? ( + + + + ) : null} + {column.title} + {column.items.length} + {column.slug ? ( + + {column.slug} + + ) : null} + {!isOpen && isDragging && !dragDisabled ? ( + + Отпустите для переноса + + ) : null} +
+
+ + {column.groupId !== null && column.group ? ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + + + } + > + + + + {onEditGroup ? ( + onEditGroup(column.group!)}> + + Редактировать + + ) : null} + {onDeleteGroup ? ( + <> + + onDeleteGroup(column.group!)} + > + + Удалить группу + + + ) : null} + + +
+ ) : null} +
+ ) +} diff --git a/apps/web/src/components/groups-board/domain-group-item.tsx b/apps/web/src/components/groups-board/domain-group-item.tsx new file mode 100644 index 0000000..56c5924 --- /dev/null +++ b/apps/web/src/components/groups-board/domain-group-item.tsx @@ -0,0 +1,105 @@ +import { useEffect } from 'react' +import { useDroppable } from '@dnd-kit/core' +import { DomainGroupHeader } from '@/components/groups-board/domain-group-header' +import { DomainRow } from '@/components/groups-board/domain-row' +import type { BoardColumn } from '@/components/groups-board/types' +import type { Group } from '@/lib/schemas' +import { + AccordionContent, + AccordionItem, +} from '@cfdm/ui/components/accordion' +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from '@cfdm/ui/components/empty' +import { ItemGroup, ItemSeparator } from '@cfdm/ui/components/item' +import { cn } from '@cfdm/ui/lib/utils' + +interface DomainGroupItemProps { + column: BoardColumn + isOpen?: boolean + isDragging?: boolean + onExpandColumn?: (columnId: string) => void + onEditGroup?: (group: Group) => void + onDeleteGroup?: (group: Group) => void + dragDisabled?: boolean + serviceLabelsByDomain?: Map +} + +export function DomainGroupItem({ + column, + isOpen = true, + isDragging = false, + onExpandColumn, + onEditGroup, + onDeleteGroup, + dragDisabled = false, + serviceLabelsByDomain, +}: DomainGroupItemProps) { + const { setNodeRef, isOver } = useDroppable({ + id: column.id, + disabled: dragDisabled, + }) + + useEffect(() => { + if (isOver && isDragging && !dragDisabled) { + onExpandColumn?.(column.id) + } + }, [isOver, isDragging, dragDisabled, column.id, onExpandColumn]) + + return ( + + + + +
+ {column.items.length > 0 ? ( + + {column.items.map((domain, index) => ( +
+ {index > 0 ? : null} + +
+ ))} +
+ ) : ( + + + Нет доменов в группе + + Перетащите домен сюда + + + + )} +
+
+
+ ) +} diff --git a/apps/web/src/components/groups-board/domain-row.tsx b/apps/web/src/components/groups-board/domain-row.tsx new file mode 100644 index 0000000..2e3df6f --- /dev/null +++ b/apps/web/src/components/groups-board/domain-row.tsx @@ -0,0 +1,177 @@ +import { useDraggable } from '@dnd-kit/core' +import { CSS } from '@dnd-kit/utilities' +import { Link } from '@tanstack/react-router' +import { + ExternalLinkIcon, + GripVerticalIcon, + MoreHorizontalIcon, + ServerIcon, +} from 'lucide-react' +import { StatusBadge } from '@/components/status-badge' +import type { DomainListItem } from '@/lib/schemas' +import { Badge } from '@cfdm/ui/components/badge' +import { Button } from '@cfdm/ui/components/button' +import { Card, CardContent } from '@cfdm/ui/components/card' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@cfdm/ui/components/dropdown-menu' +import { cn } from '@cfdm/ui/lib/utils' + +export interface DomainRowProps { + domain: DomainListItem + serviceLabels?: string[] + dragDisabled?: boolean + overlay?: boolean +} + +function DomainServiceList({ + labels, + serviceCount, +}: { + labels: string[] + serviceCount: number +}) { + if (labels.length === 0) { + return ( + + {serviceCount > 0 ? `${serviceCount} сервис(ов)` : 'Нет сервисов'} + + ) + } + + if (labels.length === 1) { + return ( + + {labels[0]} + + ) + } + + return ( + + + {labels.map((label) => ( +
+ + {label} +
+ ))} +
+
+ ) +} + +export function DomainRow({ + domain, + serviceLabels = [], + dragDisabled = false, + overlay = false, +}: DomainRowProps) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + id: String(domain.id), + disabled: dragDisabled || overlay, + }) + + const style = transform + ? { transform: CSS.Translate.toString(transform) } + : undefined + + const hasServiceList = serviceLabels.length > 1 + + return ( +
+ {!dragDisabled && !overlay ? ( + + ) : null} + +
+ {domain.zone_name} +
+ +
+ +
+ +
+ + + event.stopPropagation()} + /> + } + > + + + + + } + > + + Обзор + + + } + > + + Сервисы + + + +
+
+ ) +} diff --git a/apps/web/src/components/groups-board/groups-board-skeleton.tsx b/apps/web/src/components/groups-board/groups-board-skeleton.tsx new file mode 100644 index 0000000..202b78c --- /dev/null +++ b/apps/web/src/components/groups-board/groups-board-skeleton.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from '@cfdm/ui/components/skeleton' + +export function GroupsBoardSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, groupIndex) => ( +
+ + {Array.from({ length: 3 }).map((__, rowIndex) => ( + + ))} +
+ ))} +
+ ) +} diff --git a/apps/web/src/components/groups-board/groups-board.tsx b/apps/web/src/components/groups-board/groups-board.tsx new file mode 100644 index 0000000..b291d77 --- /dev/null +++ b/apps/web/src/components/groups-board/groups-board.tsx @@ -0,0 +1,95 @@ +import { useEffect, useMemo, useState } from 'react' +import { DragContextProvider } from '@/components/board/drag-context-provider' +import { DomainGroupItem } from '@/components/groups-board/domain-group-item' +import { DomainRow } from '@/components/groups-board/domain-row' +import type { BoardState } from '@/components/groups-board/types' +import type { DomainListItem, Group } from '@/lib/schemas' +import { Accordion } from '@cfdm/ui/components/accordion' + +interface GroupsBoardProps { + board: BoardState + activeDomain?: DomainListItem + dragDisabled?: boolean + onDragStart: Parameters[0]['onDragStart'] + onDragEnd: Parameters[0]['onDragEnd'] + onEditGroup?: (group: Group) => void + onDeleteGroup?: (group: Group) => void + serviceLabelsByDomain?: Map +} + +export function GroupsBoard({ + board, + activeDomain, + dragDisabled = false, + onDragStart, + onDragEnd, + onEditGroup, + onDeleteGroup, + serviceLabelsByDomain, +}: GroupsBoardProps) { + const columnIds = useMemo( + () => board.columns.map((column) => column.id), + [board.columns], + ) + const columnIdsKey = columnIds.join(',') + + const [openColumns, setOpenColumns] = useState([]) + + useEffect(() => { + setOpenColumns((prev) => { + const preserved = prev.filter((id) => columnIds.includes(id)) + const added = columnIds.filter((id) => !preserved.includes(id)) + if (preserved.length === 0 && added.length > 0) { + return columnIds + } + return [...preserved, ...added] + }) + }, [columnIdsKey, columnIds]) + + const isDragging = activeDomain != null + + function handleExpandColumn(columnId: string) { + setOpenColumns((prev) => + prev.includes(columnId) ? prev : [...prev, columnId], + ) + } + + return ( + + ) : null + } + > + + {board.columns.map((column) => ( + + ))} + + + ) +} diff --git a/apps/web/src/components/groups-board/types.ts b/apps/web/src/components/groups-board/types.ts new file mode 100644 index 0000000..11d677f --- /dev/null +++ b/apps/web/src/components/groups-board/types.ts @@ -0,0 +1,14 @@ +import type { DomainListItem, Group } from '@/lib/schemas' + +export interface BoardColumn { + id: string + groupId: number | null + title: string + slug: string | null + items: DomainListItem[] + group?: Group +} + +export interface BoardState { + columns: BoardColumn[] +} diff --git a/apps/web/src/components/kanban-board.tsx b/apps/web/src/components/kanban-board.tsx deleted file mode 100644 index 46bde33..0000000 --- a/apps/web/src/components/kanban-board.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { - DndContext, - DragOverlay, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, - type DragStartEvent, -} from '@dnd-kit/core' -import { useState, type ReactNode } from 'react' -import { KanbanColumn } from '@/components/kanban-column' -import { ScrollArea, ScrollBar } from '@cfdm/ui/components/scroll-area' - -export interface KanbanColumnDef { - id: string - title: string - description?: string - href?: string - items: T[] -} - -interface KanbanBoardProps { - columns: KanbanColumnDef[] - getItemId: (item: T) => string - renderCard: (item: T) => ReactNode - renderOverlay?: (item: T) => ReactNode - onMove: (itemId: string, fromColumnId: string, toColumnId: string) => void -} - -function findColumnForItem( - columns: KanbanColumnDef[], - itemId: string, - getItemId: (item: T) => string, -): string | undefined { - return columns.find((col) => col.items.some((item) => getItemId(item) === itemId))?.id -} - -export function KanbanBoard({ - columns, - getItemId, - renderCard, - renderOverlay, - onMove, -}: KanbanBoardProps) { - const [activeId, setActiveId] = useState(null) - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ) - - const activeItem = activeId - ? columns.flatMap((c) => c.items).find((item) => getItemId(item) === activeId) - : undefined - - function handleDragStart(event: DragStartEvent) { - setActiveId(String(event.active.id)) - } - - function handleDragEnd(event: DragEndEvent) { - const { active, over } = event - setActiveId(null) - if (!over) return - - const itemId = String(active.id) - const fromColumnId = findColumnForItem(columns, itemId, getItemId) - const toColumnId = String(over.id) - if (!fromColumnId || fromColumnId === toColumnId) return - onMove(itemId, fromColumnId, toColumnId) - } - - return ( - - -
- {columns.map((column) => ( - - {column.items.map((item) => ( -
{renderCard(item)}
- ))} -
- ))} -
- -
- - {activeItem && (renderOverlay ? renderOverlay(activeItem) : renderCard(activeItem))} - -
- ) -} diff --git a/apps/web/src/components/kanban-column.tsx b/apps/web/src/components/kanban-column.tsx deleted file mode 100644 index 9b92412..0000000 --- a/apps/web/src/components/kanban-column.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useDroppable } from '@dnd-kit/core' -import { Link } from '@tanstack/react-router' -import { Badge } from '@cfdm/ui/components/badge' -import { Button } from '@cfdm/ui/components/button' -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyTitle, -} from '@cfdm/ui/components/empty' -import { cn } from '@cfdm/ui/lib/utils' -import type { ReactNode } from 'react' - -interface KanbanColumnProps { - id: string - title: string - description?: string - href?: string - count: number - children: ReactNode -} - -export function KanbanColumn({ - id, - title, - description, - href, - count, - children, -}: KanbanColumnProps) { - const { setNodeRef, isOver } = useDroppable({ id }) - const isEmpty = count === 0 - - return ( -
-
-
- {href ? ( - - ) : ( - {title} - )} - {description && ( - {description} - )} -
- {count} -
-
- {children} - {isEmpty && ( - - - Пусто - - Перетащите домен сюда - - - - )} -
-
- ) -} diff --git a/apps/web/src/components/service-card.tsx b/apps/web/src/components/service-card.tsx deleted file mode 100644 index 7efeab6..0000000 --- a/apps/web/src/components/service-card.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { PencilIcon } from 'lucide-react' -import { StatusBadge } from '@/components/status-badge' -import { bindingToFqdn } from '@/lib/parse-fqdn' -import type { ServiceView } from '@/lib/schemas' -import { Badge } from '@cfdm/ui/components/badge' -import { Button } from '@cfdm/ui/components/button' -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@cfdm/ui/components/card' -import { - Item, - ItemContent, - ItemGroup, - ItemTitle, -} from '@cfdm/ui/components/item' - -interface ServiceCardProps { - service: ServiceView - onEdit: (service: ServiceView) => void -} - -function aggregateSyncStatus(service: ServiceView) { - const statuses = (service.domains ?? []) - .map((domain) => domain.sync_status) - .filter((status): status is string => Boolean(status)) - if (statuses.length === 0) return null - if (statuses.includes('error')) return 'error' - if (statuses.includes('pending_push')) return 'pending_push' - if (statuses.every((status) => status === 'synced')) return 'synced' - return statuses[0] -} - -export function ServiceCard({ service, onEdit }: ServiceCardProps) { - const ips = service.ips ?? [] - const domains = service.domains ?? [] - const syncStatus = aggregateSyncStatus(service) - - return ( - - - {service.name} - - {service.slug} - - - -
-

IP-адреса

- {ips.length === 0 ? ( -

- ) : ( -
- {ips.map((ip) => ( - - {ip} - - ))} -
- )} -
- -
-

Домены

- {domains.length === 0 ? ( -

Не привязаны

- ) : ( - - {domains.map((binding) => ( - - - - {bindingToFqdn(binding)} - {binding.target_ips.map((ip) => ( - - {ip} - - ))} - {binding.sync_status ? ( - - ) : null} - - - - ))} - - )} -
-
- -
{syncStatus ? : null}
- -
-
- ) -} diff --git a/apps/web/src/components/service-edit-sheet.tsx b/apps/web/src/components/service-edit-sheet.tsx index 9ea50ce..fceb392 100644 --- a/apps/web/src/components/service-edit-sheet.tsx +++ b/apps/web/src/components/service-edit-sheet.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' -import { PlusIcon, Trash2Icon } from 'lucide-react' +import { Link2Icon, PlusIcon, Trash2Icon } from 'lucide-react' import { ConfirmDialog } from '@/components/confirm-dialog' +import { EmptyState } from '@/components/empty-state' import { TaggedInput, isValidIpv4 } from '@/components/tagged-input' import { ServiceBindingIpInput } from '@/components/service-binding-ip-input' import type { @@ -24,6 +25,7 @@ import { ItemContent, ItemGroup, } from '@cfdm/ui/components/item' +import { Separator } from '@cfdm/ui/components/separator' import { Sheet, SheetContent, @@ -49,7 +51,9 @@ import { export interface ServiceBindingDraft { fqdn: string + record_type: 'A' | 'CNAME' target_ips: string[] + target_cname: string } interface ServiceEditSheetProps { @@ -60,6 +64,7 @@ interface ServiceEditSheetProps { knownDomains: DomainListItem[] isSaving: boolean isDeleting?: boolean + defaultGroupId?: number | null onOpenChange: (open: boolean) => void onCreate?: (body: CreateServiceWithConfigInput) => void onSave?: (id: number, body: UpdateServiceConfigInput) => void @@ -69,17 +74,30 @@ interface ServiceEditSheetProps { function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] { return (service.domains ?? []).map((binding) => ({ fqdn: bindingToFqdn(binding), + record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'), target_ips: binding.target_ips ?? [], + target_cname: binding.target_cname ?? '', })) } function buildDomainsPayload(bindings: ServiceBindingDraft[]) { return bindings - .filter((binding) => binding.fqdn.trim() && binding.target_ips.length > 0) - .map((binding) => ({ - fqdn: binding.fqdn.trim(), - target_ips: binding.target_ips, - })) + .filter((binding) => { + if (!binding.fqdn.trim()) return false + if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim()) + return binding.target_ips.length > 0 + }) + .map((binding) => + binding.record_type === 'CNAME' + ? { + fqdn: binding.fqdn.trim(), + target_cname: binding.target_cname.trim(), + } + : { + fqdn: binding.fqdn.trim(), + target_ips: binding.target_ips, + }, + ) } export function ServiceEditSheet({ @@ -90,6 +108,7 @@ export function ServiceEditSheet({ knownDomains, isSaving, isDeleting = false, + defaultGroupId = null, onOpenChange, onCreate, onSave, @@ -124,11 +143,13 @@ export function ServiceEditSheet({ if (mode === 'create') { setName('') setSlug('') - setServiceGroupId('none') + setServiceGroupId( + defaultGroupId != null ? String(defaultGroupId) : 'none', + ) setIps([]) setBindings([]) } - }, [open, mode, service]) + }, [open, mode, service, defaultGroupId]) const zoneHints = useMemo( () => knownDomains.map((domain) => domain.zone_name), @@ -136,7 +157,10 @@ export function ServiceEditSheet({ ) function handleAddBinding() { - setBindings((current) => [...current, { fqdn: '', target_ips: [] }]) + setBindings((current) => [ + ...current, + { fqdn: '', record_type: 'A', target_ips: [], target_cname: '' }, + ]) } function handleRemoveBinding(index: number) { @@ -150,6 +174,27 @@ export function ServiceEditSheet({ ) } + function handleRecordTypeChange(index: number, recordType: 'A' | 'CNAME') { + setBindings((current) => + current.map((item, i) => + i === index + ? { + ...item, + record_type: recordType, + target_ips: recordType === 'A' ? item.target_ips : [], + target_cname: recordType === 'CNAME' ? item.target_cname : '', + } + : item, + ), + ) + } + + function handleCnameChange(index: number, value: string) { + setBindings((current) => + current.map((item, i) => (i === index ? { ...item, target_cname: value } : item)), + ) + } + function handleIpsChange(index: number, targetIps: string[]) { setBindings((current) => current.map((item, i) => (i === index ? { ...item, target_ips: targetIps } : item)), @@ -198,20 +243,34 @@ export function ServiceEditSheet({ return ( - - + + {isCreate ? 'Новый сервис' : 'Редактирование сервиса'} - Настройте IP-пул и привязки FQDN → IP. Зона определяется из FQDN автоматически. + Настройте параметры сервиса и привязки FQDN → IP или CNAME. Зона определяется из FQDN + автоматически. -
- - + +
+ + Основное - Привязки + + Привязки + {bindings.length > 0 ? ( + + ({bindings.length}) + + ) : null} + - + + Название @@ -262,35 +321,81 @@ export function ServiceEditSheet({ - -
-
- Привязки доменов - -
- {bindings.length === 0 ? ( -

- Необязательно. Введите FQDN, например newdom.ivx.su — зона ivx.su определится - автоматически. -

- ) : ( - - {bindings.map((binding, index) => ( - - + + +
+

+ FQDN → IP или CNAME для DNS-записей Cloudflare +

+ +
+ + {bindings.length === 0 ? ( + + + Добавить привязку + + } + /> + ) : ( + + {bindings.map((binding, index) => ( + + + + FQDN + handleFqdnChange(index, tags)} + placeholder={ + zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su' + } + maxItems={1} + /> + + + Тип записи + + + {binding.record_type === 'CNAME' ? ( - FQDN - handleFqdnChange(index, tags)} - placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'} - maxItems={1} + + CNAME-цель + + handleCnameChange(index, event.target.value)} /> + ) : ( IP handleIpsChange(index, targetIps)} /> - - - - - - ))} - - )} -
+ )} + + + + + + ))} + + )}
- + + + + {!isCreate ? ( diff --git a/apps/web/src/components/service-group-card.tsx b/apps/web/src/components/service-group-card.tsx deleted file mode 100644 index 292a5d9..0000000 --- a/apps/web/src/components/service-group-card.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { ChevronDownIcon, PencilIcon } from 'lucide-react' -import { useState } from 'react' -import { ServiceGroupIcon } from '@/components/service-group-icon' -import { ServiceRow } from '@/components/service-row' -import type { ServiceGroupView, ServiceView } from '@/lib/schemas' -import { Button } from '@cfdm/ui/components/button' -import { Badge } from '@cfdm/ui/components/badge' -import { - Card, - CardAction, - CardContent, - CardHeader, - CardTitle, -} from '@cfdm/ui/components/card' -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@cfdm/ui/components/collapsible' -import { ItemGroup } from '@cfdm/ui/components/item' -import { Spinner } from '@cfdm/ui/components/spinner' -import { Switch } from '@cfdm/ui/components/switch' -import { cn } from '@cfdm/ui/lib/utils' - -interface ServiceGroupCardProps { - group: ServiceGroupView - onGroupToggle: (groupId: number, enabled: boolean) => void - onServiceToggle: (serviceId: number, enabled: boolean) => void - onEditService: (service: ServiceView) => void - onEditGroup: (group: ServiceGroupView) => void - togglingGroupId?: number | null - togglingServiceId?: number | null -} - -export function ServiceGroupCard({ - group, - onGroupToggle, - onServiceToggle, - onEditService, - onEditGroup, - togglingGroupId = null, - togglingServiceId = null, -}: ServiceGroupCardProps) { - const [open, setOpen] = useState(true) - const isGroupToggling = togglingGroupId === group.id - - return ( - - - -
- - - - {group.name} - - {group.domain ? ( - {group.domain} - ) : null} -
- - - {isGroupToggling ? ( - - ) : ( - onGroupToggle(group.id, checked)} - aria-label={`${group.enabled ? 'Выключить' : 'Включить'} группу ${group.name}`} - /> - )} - -
- - - {group.services.length === 0 ? ( -

Нет сервисов в группе

- ) : ( - - {group.services.map((service) => ( - - ))} - - )} -
-
-
-
- ) -} diff --git a/apps/web/src/components/service-group-edit-sheet.tsx b/apps/web/src/components/service-group-edit-sheet.tsx index 4bb7ea4..0148365 100644 --- a/apps/web/src/components/service-group-edit-sheet.tsx +++ b/apps/web/src/components/service-group-edit-sheet.tsx @@ -92,7 +92,7 @@ export function ServiceGroupEditSheet({ {mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'} - Домен группы — любой FQDN (gr.ivx.su, domain.new.ivx.su). Публикуется в Cloudflare отдельно от привязок сервисов. + Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов.
@@ -127,7 +127,7 @@ export function ServiceGroupEditSheet({ - Домен группы (FQDN) + Домен группы (FQDN, необязательно) void - onEdit: (service: ServiceView) => void - isToggling?: boolean - disabled?: boolean -} - -export function ServiceRow({ - service, - onToggle, - onEdit, - isToggling = false, - disabled = false, -}: ServiceRowProps) { - const syncStatus = aggregateServiceSyncStatus(service) - const fqdn = serviceDisplayFqdn(service) - - return ( - - - {service.name} - {fqdn} - - - {syncStatus ? : null} - {isToggling ? ( - - ) : ( - onToggle(service.id, checked)} - aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`} - /> - )} - - - - - ) -} diff --git a/apps/web/src/components/services-board/board-state.ts b/apps/web/src/components/services-board/board-state.ts new file mode 100644 index 0000000..e24b315 --- /dev/null +++ b/apps/web/src/components/services-board/board-state.ts @@ -0,0 +1,152 @@ +import { arrayMove } from '@dnd-kit/sortable' +import type { ServiceGroupsResponse, ServiceView } from '@/lib/schemas' +import { + groupColumnId, + UNGROUPED_COLUMN_ID, +} from '@/components/services-board/column-ids' +import type { BoardColumn, BoardState } from '@/components/services-board/types' + +function serviceMatchesDomain(service: ServiceView, domainId?: number) { + if (!domainId) return true + return service.domains?.some((d) => d.domain_id === domainId) ?? false +} + +export function mapResponseToBoard( + data: ServiceGroupsResponse, + domainId?: number, +): BoardState { + const groups = domainId + ? data.groups + .map((group) => ({ + ...group, + services: group.services.filter((s) => serviceMatchesDomain(s, domainId)), + })) + .filter((group) => group.services.length > 0) + : data.groups + + const ungrouped = domainId + ? data.ungrouped.filter((s) => serviceMatchesDomain(s, domainId)) + : data.ungrouped + + const columns: BoardColumn[] = groups.map((group) => ({ + id: groupColumnId(group.id), + groupId: group.id, + title: group.name, + domain: group.domain, + enabled: group.enabled, + type: group.type, + items: group.services, + group, + })) + + if (!domainId || ungrouped.length > 0) { + columns.push({ + id: UNGROUPED_COLUMN_ID, + groupId: null, + title: 'Без группы', + domain: null, + enabled: true, + type: 'custom', + items: ungrouped, + }) + } + + return { columns } +} + +export function findColumnId(columns: BoardColumn[], id: string): string | undefined { + if (columns.some((column) => column.id === id)) return id + return columns.find((column) => + column.items.some((service) => String(service.id) === id), + )?.id +} + +export function findColumn(columns: BoardColumn[], id: string): BoardColumn | undefined { + const columnId = findColumnId(columns, id) + return columnId ? columns.find((column) => column.id === columnId) : undefined +} + +export function reorderInColumn( + board: BoardState, + columnId: string, + activeId: string, + overId: string, +): BoardState { + const columnIndex = board.columns.findIndex((column) => column.id === columnId) + if (columnIndex === -1) return board + + const column = board.columns[columnIndex]! + const oldIndex = column.items.findIndex((service) => String(service.id) === activeId) + const newIndex = column.items.findIndex((service) => String(service.id) === overId) + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return board + + const items = arrayMove(column.items, oldIndex, newIndex) + const columns = [...board.columns] + columns[columnIndex] = { ...column, items } + return { columns } +} + +export function moveServiceBetweenColumns( + board: BoardState, + serviceId: number, + fromColumnId: string, + toColumnId: string, + overId: string, +): BoardState { + const fromIndex = board.columns.findIndex((column) => column.id === fromColumnId) + const toIndex = board.columns.findIndex((column) => column.id === toColumnId) + if (fromIndex === -1 || toIndex === -1) return board + + const fromColumn = board.columns[fromIndex]! + const toColumn = board.columns[toIndex]! + const fromItems = [...fromColumn.items] + const serviceIndex = fromItems.findIndex((service) => service.id === serviceId) + if (serviceIndex === -1) return board + + const [service] = fromItems.splice(serviceIndex, 1) + if (!service) return board + + const targetGroupId = toColumn.groupId + const movedService: ServiceView = { + ...service, + service_group_id: targetGroupId, + } + + let insertIndex = toColumn.items.length + if (overId !== toColumnId) { + const overIndex = toColumn.items.findIndex( + (item) => String(item.id) === overId, + ) + if (overIndex !== -1) insertIndex = overIndex + } + + const targetItems = [...toColumn.items] + targetItems.splice(insertIndex, 0, movedService) + + const columns = [...board.columns] + columns[fromIndex] = { + ...fromColumn, + items: fromItems, + } + columns[toIndex] = { ...toColumn, items: targetItems } + return { columns } +} + +export function boardToQueryData( + board: BoardState, + previous: ServiceGroupsResponse, +): ServiceGroupsResponse { + const groups = previous.groups.map((group) => { + const column = board.columns.find((col) => col.groupId === group.id) + return column ? { ...group, services: column.items } : group + }) + + const ungroupedColumn = board.columns.find( + (column) => column.id === UNGROUPED_COLUMN_ID, + ) + + return { + groups, + ungrouped: ungroupedColumn?.items ?? previous.ungrouped, + } +} diff --git a/apps/web/src/components/services-board/column-ids.ts b/apps/web/src/components/services-board/column-ids.ts new file mode 100644 index 0000000..713a9c7 --- /dev/null +++ b/apps/web/src/components/services-board/column-ids.ts @@ -0,0 +1,5 @@ +export { + UNGROUPED_COLUMN_ID, + groupColumnId, + parseGroupColumnId, +} from '@/components/board/column-ids' diff --git a/apps/web/src/components/services-board/service-group-header.tsx b/apps/web/src/components/services-board/service-group-header.tsx new file mode 100644 index 0000000..7099436 --- /dev/null +++ b/apps/web/src/components/services-board/service-group-header.tsx @@ -0,0 +1,152 @@ +import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react' +import { ServiceGroupIcon } from '@/components/service-group-icon' +import type { BoardColumn } from '@/components/services-board/types' +import type { ServiceGroupView } from '@/lib/schemas' +import { AccordionTrigger } from '@cfdm/ui/components/accordion' +import { Badge } from '@cfdm/ui/components/badge' +import { Button } from '@cfdm/ui/components/button' +import { Checkbox } from '@cfdm/ui/components/checkbox' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@cfdm/ui/components/dropdown-menu' +import { Spinner } from '@cfdm/ui/components/spinner' +import { Switch } from '@cfdm/ui/components/switch' +import { cn } from '@cfdm/ui/lib/utils' + +interface ServiceGroupHeaderProps { + column: BoardColumn + isOpen?: boolean + isDragging?: boolean + dragDisabled?: boolean + showCheckbox?: boolean + groupAllSelected?: boolean + groupSomeSelected?: boolean + serviceIds: number[] + isGroupToggling?: boolean + onSelectAllInGroup?: (ids: number[]) => void + onDeselectAllInGroup?: (ids: number[]) => void + onGroupToggle?: (groupId: number, enabled: boolean) => void + onEditGroup?: (group: ServiceGroupView) => void + onDeleteGroup?: (group: ServiceGroupView) => void +} + +export function ServiceGroupHeader({ + column, + isOpen = true, + isDragging = false, + dragDisabled = false, + showCheckbox = false, + groupAllSelected = false, + groupSomeSelected = false, + serviceIds, + isGroupToggling = false, + onSelectAllInGroup, + onDeselectAllInGroup, + onGroupToggle, + onEditGroup, + onDeleteGroup, +}: ServiceGroupHeaderProps) { + return ( +
+ {showCheckbox && !dragDisabled && column.items.length > 0 ? ( + { + if (groupAllSelected || groupSomeSelected) { + onDeselectAllInGroup?.(serviceIds) + } else { + onSelectAllInGroup?.(serviceIds) + } + }} + onPointerDown={(event) => event.stopPropagation()} + aria-label={`Выбрать все сервисы в группе ${column.title}`} + className={cn( + 'ml-1', + groupSomeSelected && !groupAllSelected && 'opacity-60', + )} + /> + ) : null} + + +
+ {column.groupId !== null ? ( + + + + ) : null} + {column.title} + {column.items.length} + {column.domain ? ( + {column.domain} + ) : null} + {!isOpen && isDragging && !dragDisabled ? ( + + Отпустите для переноса + + ) : null} +
+
+ + {column.groupId !== null ? ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + + + } + > + + + + {onEditGroup && column.group ? ( + onEditGroup(column.group!)}> + + Редактировать + + ) : null} + {onDeleteGroup && column.group ? ( + <> + + onDeleteGroup(column.group!)} + > + + Удалить группу + + + ) : null} + + + {column.domain && onGroupToggle ? ( + isGroupToggling ? ( + + ) : ( + + onGroupToggle(column.groupId!, checked) + } + aria-label={`${column.enabled ? 'Выключить' : 'Включить'} группу ${column.title}`} + /> + ) + ) : null} +
+ ) : null} +
+ ) +} diff --git a/apps/web/src/components/services-board/service-group-item.tsx b/apps/web/src/components/services-board/service-group-item.tsx new file mode 100644 index 0000000..15aaff2 --- /dev/null +++ b/apps/web/src/components/services-board/service-group-item.tsx @@ -0,0 +1,193 @@ +import { useEffect } from 'react' +import { useDroppable } from '@dnd-kit/core' +import { + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { PlusIcon } from 'lucide-react' +import { ServiceGroupHeader } from '@/components/services-board/service-group-header' +import { ServiceRow } from '@/components/services-board/service-row' +import type { BoardColumn } from '@/components/services-board/types' +import type { ServiceGroupView, ServiceView } from '@/lib/schemas' +import { + AccordionContent, + AccordionItem, +} from '@cfdm/ui/components/accordion' +import { Button } from '@cfdm/ui/components/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from '@cfdm/ui/components/empty' +import { ItemGroup, ItemSeparator } from '@cfdm/ui/components/item' +import { cn } from '@cfdm/ui/lib/utils' + +interface ServiceGroupItemProps { + column: BoardColumn + isOpen?: boolean + isDragging?: boolean + onExpandColumn?: (columnId: string) => void + onGroupToggle?: (groupId: number, enabled: boolean) => void + onEditGroup?: (group: ServiceGroupView) => void + onDeleteGroup?: (group: ServiceGroupView) => void + onAddService?: (groupId: number | null) => void + onServiceToggle: (serviceId: number, enabled: boolean) => void + onEditService: (service: ServiceView) => void + onDeleteService: (service: ServiceView) => void + togglingGroupId?: number | null + togglingServiceId?: number | null + dragDisabled?: boolean + showCheckbox?: boolean + isSelected?: (id: number) => boolean + onSelectedChange?: (id: number, selected: boolean) => void + isAllSelected?: (ids: number[]) => boolean + isSomeSelected?: (ids: number[]) => boolean + onSelectAllInGroup?: (ids: number[]) => void + onDeselectAllInGroup?: (ids: number[]) => void +} + +export function ServiceGroupItem({ + column, + isOpen = true, + isDragging = false, + onExpandColumn, + onGroupToggle, + onEditGroup, + onDeleteGroup, + onAddService, + onServiceToggle, + onEditService, + onDeleteService, + togglingGroupId = null, + togglingServiceId = null, + dragDisabled = false, + showCheckbox = false, + isSelected, + onSelectedChange, + isAllSelected, + isSomeSelected, + onSelectAllInGroup, + onDeselectAllInGroup, +}: ServiceGroupItemProps) { + const { setNodeRef, isOver } = useDroppable({ + id: column.id, + disabled: dragDisabled, + }) + const isGroupToggling = column.groupId !== null && togglingGroupId === column.groupId + const serviceDisabled = Boolean(column.domain) && !column.enabled + const serviceIds = column.items.map((s) => s.id) + const groupAllSelected = isAllSelected?.(serviceIds) ?? false + const groupSomeSelected = isSomeSelected?.(serviceIds) ?? false + + useEffect(() => { + if (isOver && isDragging && !dragDisabled) { + onExpandColumn?.(column.id) + } + }, [isOver, isDragging, dragDisabled, column.id, onExpandColumn]) + + return ( + + + + +
+ String(service.id))} + strategy={verticalListSortingStrategy} + > + {column.items.length > 0 ? ( + + {column.items.map((service, index) => ( +
+ {index > 0 ? : null} + + onSelectedChange?.(service.id, selected) + } + /> +
+ ))} +
+ ) : ( + + + Нет сервисов в группе + + {dragDisabled + ? 'Сервисы не найдены' + : 'Перетащите сервис сюда или добавьте новый'} + + + {onAddService ? ( + + + + ) : null} + + )} +
+ + {column.items.length > 0 && onAddService ? ( + + ) : null} +
+
+
+ ) +} diff --git a/apps/web/src/components/services-board/service-row.tsx b/apps/web/src/components/services-board/service-row.tsx new file mode 100644 index 0000000..0193b61 --- /dev/null +++ b/apps/web/src/components/services-board/service-row.tsx @@ -0,0 +1,199 @@ +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { GripVerticalIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react' +import { StatusBadge } from '@/components/status-badge' +import { bindingToFqdn } from '@/lib/parse-fqdn' +import { + aggregateServiceSyncStatus, + serviceDisplayFqdn, +} from '@/lib/service-utils' +import type { ServiceView } from '@/lib/schemas' +import { Badge } from '@cfdm/ui/components/badge' +import { Button } from '@cfdm/ui/components/button' +import { Checkbox } from '@cfdm/ui/components/checkbox' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@cfdm/ui/components/dropdown-menu' +import { Spinner } from '@cfdm/ui/components/spinner' +import { Switch } from '@cfdm/ui/components/switch' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@cfdm/ui/components/tooltip' +import { cn } from '@cfdm/ui/lib/utils' + +export interface ServiceRowProps { + service: ServiceView + onToggle: (serviceId: number, enabled: boolean) => void + onEdit: (service: ServiceView) => void + onDelete: (service: ServiceView) => void + isToggling?: boolean + disabled?: boolean + dragDisabled?: boolean + overlay?: boolean + selected?: boolean + onSelectedChange?: (selected: boolean) => void + showCheckbox?: boolean +} + +export function ServiceRow({ + service, + onToggle, + onEdit, + onDelete, + isToggling = false, + disabled = false, + dragDisabled = false, + overlay = false, + selected = false, + onSelectedChange, + showCheckbox = false, +}: ServiceRowProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: String(service.id), + disabled: dragDisabled || overlay, + }) + + const syncStatus = aggregateServiceSyncStatus(service) + const fqdn = serviceDisplayFqdn(service) + const allFqdns = (service.domains ?? []).map((d) => bindingToFqdn(d)) + + const style = transform + ? { + transform: CSS.Transform.toString(transform), + transition, + } + : undefined + + const fqdnContent = + allFqdns.length > 1 ? ( + + {fqdn} + } + /> + +
+ {allFqdns.map((name) => ( + {name} + ))} +
+
+
+ ) : ( + {fqdn} + ) + + return ( +
+ {showCheckbox && !dragDisabled && !overlay ? ( + onSelectedChange?.(checked === true)} + onPointerDown={(event) => event.stopPropagation()} + aria-label={`Выбрать ${service.name}`} + /> + ) : null} + + {!dragDisabled && !overlay ? ( + + ) : null} + +
+ + {service.name} + + + {service.slug} + +
+ +
+ {fqdnContent} +
+ +
+ {syncStatus ? : null} + {isToggling ? ( + + ) : ( + event.stopPropagation()} + onCheckedChange={(checked) => onToggle(service.id, checked)} + aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`} + /> + )} + + event.stopPropagation()} + /> + } + > + + + + onEdit(service)}> + + Редактировать + + + onDelete(service)} + > + + Удалить + + + +
+
+ ) +} diff --git a/apps/web/src/components/services-board/services-board-skeleton.tsx b/apps/web/src/components/services-board/services-board-skeleton.tsx new file mode 100644 index 0000000..04dc1c5 --- /dev/null +++ b/apps/web/src/components/services-board/services-board-skeleton.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from '@cfdm/ui/components/skeleton' + +export function ServicesBoardSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, groupIndex) => ( +
+ + {Array.from({ length: 3 }).map((__, rowIndex) => ( + + ))} +
+ ))} +
+ ) +} diff --git a/apps/web/src/components/services-board/services-board.tsx b/apps/web/src/components/services-board/services-board.tsx new file mode 100644 index 0000000..eed3457 --- /dev/null +++ b/apps/web/src/components/services-board/services-board.tsx @@ -0,0 +1,136 @@ +import { useEffect, useMemo, useState } from 'react' +import { DragContextProvider } from '@/components/board/drag-context-provider' +import { ServiceGroupItem } from '@/components/services-board/service-group-item' +import { ServiceRow } from '@/components/services-board/service-row' +import type { BoardState } from '@/components/services-board/types' +import type { ServiceGroupView, ServiceView } from '@/lib/schemas' +import { Accordion } from '@cfdm/ui/components/accordion' + +interface ServicesBoardProps { + board: BoardState + activeService?: ServiceView + dragDisabled?: boolean + onDragStart: Parameters[0]['onDragStart'] + onDragEnd: Parameters[0]['onDragEnd'] + onGroupToggle: (groupId: number, enabled: boolean) => void + onServiceToggle: (serviceId: number, enabled: boolean) => void + onEditGroup: (group: ServiceGroupView) => void + onDeleteGroup?: (group: ServiceGroupView) => void + onAddService?: (groupId: number | null) => void + onEditService: (service: ServiceView) => void + onDeleteService: (service: ServiceView) => void + togglingGroupId?: number | null + togglingServiceId?: number | null + showCheckbox?: boolean + isSelected?: (id: number) => boolean + onSelectedChange?: (id: number, selected: boolean) => void + isAllSelected?: (ids: number[]) => boolean + isSomeSelected?: (ids: number[]) => boolean + onSelectAllInGroup?: (ids: number[]) => void + onDeselectAllInGroup?: (ids: number[]) => void +} + +export function ServicesBoard({ + board, + activeService, + dragDisabled = false, + onDragStart, + onDragEnd, + onGroupToggle, + onServiceToggle, + onEditGroup, + onDeleteGroup, + onAddService, + onEditService, + onDeleteService, + togglingGroupId = null, + togglingServiceId = null, + showCheckbox = false, + isSelected, + onSelectedChange, + isAllSelected, + isSomeSelected, + onSelectAllInGroup, + onDeselectAllInGroup, +}: ServicesBoardProps) { + const columnIds = useMemo( + () => board.columns.map((column) => column.id), + [board.columns], + ) + const columnIdsKey = columnIds.join(',') + + const [openColumns, setOpenColumns] = useState([]) + + useEffect(() => { + setOpenColumns((prev) => { + const preserved = prev.filter((id) => columnIds.includes(id)) + const added = columnIds.filter((id) => !preserved.includes(id)) + if (preserved.length === 0 && added.length > 0) { + return columnIds + } + return [...preserved, ...added] + }) + }, [columnIdsKey, columnIds]) + + const isDragging = activeService != null + + function handleExpandColumn(columnId: string) { + setOpenColumns((prev) => + prev.includes(columnId) ? prev : [...prev, columnId], + ) + } + + return ( + + ) : null + } + > + + {board.columns.map((column) => ( + + ))} + + + ) +} diff --git a/apps/web/src/components/services-board/services-bulk-toolbar.tsx b/apps/web/src/components/services-board/services-bulk-toolbar.tsx new file mode 100644 index 0000000..816670f --- /dev/null +++ b/apps/web/src/components/services-board/services-bulk-toolbar.tsx @@ -0,0 +1,54 @@ +import { Button } from '@cfdm/ui/components/button' + +interface ServicesBulkToolbarProps { + count: number + isPending?: boolean + onEnable: () => void + onDisable: () => void + onClear: () => void +} + +export function ServicesBulkToolbar({ + count, + isPending = false, + onEnable, + onDisable, + onClear, +}: ServicesBulkToolbarProps) { + if (count === 0) return null + + return ( +
+ + Выбрано: {count} + + + + +
+ ) +} diff --git a/apps/web/src/components/services-board/types.ts b/apps/web/src/components/services-board/types.ts new file mode 100644 index 0000000..6cbaff0 --- /dev/null +++ b/apps/web/src/components/services-board/types.ts @@ -0,0 +1,16 @@ +import type { ServiceGroup, ServiceGroupView, ServiceView } from '@/lib/schemas' + +export interface BoardColumn { + id: string + groupId: number | null + title: string + domain: string | null + enabled: boolean + type: ServiceGroup['type'] + items: ServiceView[] + group?: ServiceGroupView +} + +export interface BoardState { + columns: BoardColumn[] +} diff --git a/apps/web/src/components/status-badge.tsx b/apps/web/src/components/status-badge.tsx index 5982359..93836ce 100644 --- a/apps/web/src/components/status-badge.tsx +++ b/apps/web/src/components/status-badge.tsx @@ -5,9 +5,9 @@ import { cn } from '@cfdm/ui/lib/utils' type BadgeVariant = NonNullable['variant']> const statusVariants: Record = { - active: 'default', - synced: 'default', - ok: 'default', + active: 'success', + synced: 'success', + ok: 'success', pending_push: 'secondary', warning: 'secondary', conflict: 'destructive', diff --git a/apps/web/src/components/subdomain-edit-sheet.tsx b/apps/web/src/components/subdomain-edit-sheet.tsx new file mode 100644 index 0000000..c9b551e --- /dev/null +++ b/apps/web/src/components/subdomain-edit-sheet.tsx @@ -0,0 +1,201 @@ +import { useEffect, useMemo, useState } from 'react' +import type { CertMonitoring } from '@cfdm/shared' +import type { ServiceView, SubdomainRecord } from '@/lib/schemas' +import { certMonitoringOptions } from '@/lib/cert-monitoring' +import { formatServiceGroupLabel } from '@/lib/service-utils' +import { Button } from '@cfdm/ui/components/button' +import { Input } from '@cfdm/ui/components/input' +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from '@cfdm/ui/components/field' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@cfdm/ui/components/select' +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@cfdm/ui/components/sheet' +import { Spinner } from '@cfdm/ui/components/spinner' + +export interface SubdomainEditValues { + name: string + serviceId: string + certMonitoring: CertMonitoring +} + +interface SubdomainEditSheetProps { + mode: 'create' | 'edit' + subdomain: SubdomainRecord | null + zoneName: string + services: ServiceView[] + serviceGroupById: Map + currentServiceId: string + open: boolean + isSaving: boolean + onOpenChange: (open: boolean) => void + onSubmit: (values: SubdomainEditValues) => void +} + +export function SubdomainEditSheet({ + mode, + subdomain, + zoneName, + services, + serviceGroupById, + currentServiceId, + open, + isSaving, + onOpenChange, + onSubmit, +}: SubdomainEditSheetProps) { + const [name, setName] = useState('') + const [serviceId, setServiceId] = useState('none') + const [certMonitoring, setCertMonitoring] = useState('auto') + + const serviceItems = useMemo( + () => [ + { label: 'Без сервиса', value: 'none' }, + ...services.map((service) => ({ + label: formatServiceGroupLabel( + serviceGroupById.get(service.id), + service.name, + ), + value: String(service.id), + })), + ], + [services, serviceGroupById], + ) + + const certMonitoringItems = useMemo( + () => + certMonitoringOptions.map((option) => ({ + label: option.label, + value: option.value, + })), + [], + ) + + useEffect(() => { + if (!open) return + if (mode === 'edit' && subdomain) { + setName(subdomain.name) + setServiceId(currentServiceId || 'none') + setCertMonitoring(subdomain.cert_monitoring) + return + } + setName('') + setServiceId('none') + setCertMonitoring('auto') + }, [open, mode, subdomain, currentServiceId]) + + function handleSubmit(event: React.FormEvent) { + event.preventDefault() + const trimmed = name.trim() + if (!trimmed) return + onSubmit({ name: trimmed, serviceId, certMonitoring }) + } + + return ( + + + + + {mode === 'create' ? 'Создать поддомен' : 'Редактировать поддомен'} + + + {mode === 'create' + ? `Имя записи в зоне ${zoneName} (например, www или api)` + : `Изменение поддомена в зоне ${zoneName}`} + + + + + + Имя + setName(event.target.value)} + className="font-mono" + /> + + {mode === 'edit' && ( + <> + + Сервис + + + + + Мониторинг SSL + + + + { + certMonitoringOptions.find((o) => o.value === certMonitoring) + ?.description + } + + + + )} + + + + + + + + ) +} diff --git a/apps/web/src/components/subdomains-table.tsx b/apps/web/src/components/subdomains-table.tsx new file mode 100644 index 0000000..e084e2c --- /dev/null +++ b/apps/web/src/components/subdomains-table.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react' +import { Link } from '@tanstack/react-router' +import { MoreHorizontalIcon } from 'lucide-react' +import { ConfirmDialog } from '@/components/confirm-dialog' +import type { SubdomainTableRow } from '@/hooks/use-domain-page' +import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page' +import { formatDate } from '@/lib/format' +import { Badge } from '@cfdm/ui/components/badge' +import { Button } from '@cfdm/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@cfdm/ui/components/dropdown-menu' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@cfdm/ui/components/table' + +interface SubdomainsTableProps { + domainId: string + rows: SubdomainTableRow[] + isDeleting?: boolean + isToggling?: boolean + onEdit: (row: SubdomainTableRow) => void + onDelete: (row: SubdomainTableRow) => void + onToggleEnabled: (row: SubdomainTableRow) => void +} + +export function SubdomainsTable({ + domainId, + rows, + isDeleting = false, + isToggling = false, + onEdit, + onDelete, + onToggleEnabled, +}: SubdomainsTableProps) { + const [deleteTarget, setDeleteTarget] = useState(null) + + return ( + <> +
+ + + + Поддомен + Статус + Группа / Сервис + Создан + + Действия + + + + + {rows.map((row) => ( + + {row.subdomain.fqdn} + + + {row.subdomain.enabled ? 'Активен' : 'Неактивен'} + + + {formatSubdomainServiceLinks(row.serviceLinks)} + {formatDate(row.subdomain.created_at)} + + + + } + > + + Действия + + + onEdit(row)}> + Редактировать + + onToggleEnabled(row)} + > + {row.subdomain.enabled ? 'Деактивировать' : 'Активировать'} + + + } + > + DNS-записи + + + setDeleteTarget(row)} + > + Удалить + + + + + + ))} + +
+
+ + { + if (!open) setDeleteTarget(null) + }} + title="Удалить поддомен?" + description={ + deleteTarget + ? `Поддомен ${deleteTarget.subdomain.fqdn} будет удалён из менеджера.` + : '' + } + confirmLabel="Удалить" + onConfirm={() => { + if (deleteTarget) { + onDelete(deleteTarget) + setDeleteTarget(null) + } + }} + disabled={isDeleting} + /> + + ) +} diff --git a/apps/web/src/hooks/use-domain-page.ts b/apps/web/src/hooks/use-domain-page.ts new file mode 100644 index 0000000..ddb8e06 --- /dev/null +++ b/apps/web/src/hooks/use-domain-page.ts @@ -0,0 +1,256 @@ +import { useMemo } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { api } from '@/lib/api-client' +import { + createServiceBinding, + createSubdomain, + deleteServiceBinding, + deleteSubdomain, + domainDetailQueryOptions, + domainServiceBindingsQueryOptions, + invalidateDomainPage, + serviceGroupsQueryOptions, + servicesQueryOptions, + subdomainsListQueryOptions, + updateDomain, + updateSubdomain, +} from '@/queries' +import type { CertMonitoring } from '@cfdm/shared' +import type { ServiceBinding, SubdomainRecord } from '@/lib/schemas' +import { + buildServiceGroupNameById, + formatServiceGroupLabel, +} from '@/lib/service-utils' + +export interface SubdomainServiceLink { + serviceId: number + groupName: string | null + serviceName: string +} + +export interface SubdomainTableRow { + subdomain: SubdomainRecord + serviceLinks: SubdomainServiceLink[] + bindingIds: number[] +} + +export function formatSubdomainServiceLinks(links: SubdomainServiceLink[]): string { + if (links.length === 0) return '—' + return links + .map((link) => formatServiceGroupLabel(link.groupName, link.serviceName)) + .join(', ') +} + +function mapSubdomainRows( + subdomains: SubdomainRecord[], + bindings: ServiceBinding[], + serviceGroupById: Map, +): SubdomainTableRow[] { + const byHostname = new Map() + for (const binding of bindings) { + const list = byHostname.get(binding.hostname) ?? [] + list.push(binding) + byHostname.set(binding.hostname, list) + } + + return subdomains.map((subdomain) => { + const hostnameBindings = byHostname.get(subdomain.name) ?? [] + const seenServiceIds = new Set() + const serviceLinks: SubdomainServiceLink[] = [] + + for (const binding of hostnameBindings) { + if (seenServiceIds.has(binding.service_id)) continue + seenServiceIds.add(binding.service_id) + serviceLinks.push({ + serviceId: binding.service_id, + groupName: serviceGroupById.get(binding.service_id) ?? null, + serviceName: binding.service_name, + }) + } + + return { + subdomain, + serviceLinks, + bindingIds: hostnameBindings.map((b) => b.id), + } + }) +} + +export function useDomainPage(domainId: number) { + const queryClient = useQueryClient() + + const domainQuery = useQuery(domainDetailQueryOptions(domainId)) + const subdomainsQuery = useQuery(subdomainsListQueryOptions(domainId)) + const bindingsQuery = useQuery(domainServiceBindingsQueryOptions(domainId)) + const servicesQuery = useQuery(servicesQueryOptions()) + const serviceGroupsQuery = useQuery(serviceGroupsQueryOptions()) + + const isLoading = + domainQuery.isLoading || + subdomainsQuery.isLoading || + bindingsQuery.isLoading || + servicesQuery.isLoading || + serviceGroupsQuery.isLoading + + const isError = + domainQuery.isError || + subdomainsQuery.isError || + bindingsQuery.isError || + servicesQuery.isError || + serviceGroupsQuery.isError + + const error = + domainQuery.error ?? + subdomainsQuery.error ?? + bindingsQuery.error ?? + servicesQuery.error ?? + serviceGroupsQuery.error + + const serviceGroupById = useMemo( + () => + serviceGroupsQuery.data + ? buildServiceGroupNameById(serviceGroupsQuery.data) + : new Map(), + [serviceGroupsQuery.data], + ) + + const subdomainRows = useMemo( + () => + mapSubdomainRows( + subdomainsQuery.data ?? [], + bindingsQuery.data ?? [], + serviceGroupById, + ), + [subdomainsQuery.data, bindingsQuery.data, serviceGroupById], + ) + + function invalidate() { + invalidateDomainPage(queryClient, domainId) + } + + const syncMutation = useMutation({ + mutationFn: () => api.post(`/api/v1/domains/${domainId}/sync`), + onSuccess: () => { + invalidate() + void queryClient.invalidateQueries({ queryKey: ['domains'] }) + void queryClient.invalidateQueries({ queryKey: ['service-bindings'] }) + toast.success('Синхронизация завершена') + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Ошибка синхронизации') + }, + }) + + const createSubdomainMutation = useMutation({ + mutationFn: (name: string) => createSubdomain(domainId, { name }), + onSuccess: () => { + invalidate() + toast.success('Поддомен создан') + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось создать поддомен') + }, + }) + + const updateSubdomainMutation = useMutation({ + mutationFn: ({ + id, + ...body + }: { + id: number + name?: string + enabled?: boolean + cert_monitoring?: CertMonitoring + }) => updateSubdomain(id, body), + onSuccess: () => { + invalidate() + toast.success('Поддомен обновлён') + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось обновить поддомен') + }, + }) + + 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: () => { + invalidate() + toast.success('Поддомен удалён') + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось удалить поддомен') + }, + }) + + const linkServiceMutation = useMutation({ + mutationFn: async ({ + subdomainName, + serviceId, + bindingIds, + }: { + subdomainName: string + serviceId: number | null + bindingIds: number[] + }) => { + await Promise.all(bindingIds.map((id) => deleteServiceBinding(id))) + if (serviceId != null) { + await createServiceBinding({ + domain_id: domainId, + service_id: serviceId, + hostname: subdomainName, + }) + } + }, + onSuccess: (_data, variables) => { + invalidate() + toast.success( + variables.serviceId != null ? 'Сервис привязан' : 'Сервис отвязан', + ) + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось привязать сервис') + }, + }) + + function refetch() { + void domainQuery.refetch() + void subdomainsQuery.refetch() + void bindingsQuery.refetch() + void servicesQuery.refetch() + void serviceGroupsQuery.refetch() + } + + return { + domain: domainQuery.data, + bindings: bindingsQuery.data ?? [], + services: servicesQuery.data ?? [], + serviceGroupById, + subdomainRows, + isLoading, + isError, + error: error instanceof Error ? error : null, + refetch, + syncMutation, + createSubdomainMutation, + updateSubdomainMutation, + updateDomainCertMonitoringMutation, + deleteSubdomainMutation, + linkServiceMutation, + } +} diff --git a/apps/web/src/hooks/use-groups-board.ts b/apps/web/src/hooks/use-groups-board.ts new file mode 100644 index 0000000..85befd7 --- /dev/null +++ b/apps/web/src/hooks/use-groups-board.ts @@ -0,0 +1,149 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useEffect, useReducer, useState } from 'react' +import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core' +import { toast } from 'sonner' +import { parseGroupColumnId } from '@/components/board/column-ids' +import { + boardToDomainsList, + findColumnId, + mapGroupsDomainsToBoard, + moveDomainBetweenColumns, +} from '@/components/groups-board/board-state' +import type { BoardState } from '@/components/groups-board/types' +import { UNGROUPED_COLUMN_ID } from '@/components/groups-board/column-ids' +import { api } from '@/lib/api-client' +import type { DomainListItem, Group } from '@/lib/schemas' +import { domainKeys, groupKeys } from '@/queries' + +type BoardAction = + | { type: 'set'; board: BoardState } + | { type: 'replace'; board: BoardState } + +function boardReducer(state: BoardState, action: BoardAction): BoardState { + switch (action.type) { + case 'set': + case 'replace': + return action.board + default: + return state + } +} + +interface UseGroupsBoardOptions { + groups: Group[] | undefined + domains: DomainListItem[] | undefined + dragDisabled?: boolean +} + +export function useGroupsBoard({ + groups, + domains, + dragDisabled = false, +}: UseGroupsBoardOptions) { + const queryClient = useQueryClient() + const [board, dispatch] = useReducer(boardReducer, { columns: [] }) + const [activeId, setActiveId] = useState(null) + + useEffect(() => { + if (!groups || !domains) return + dispatch({ + type: 'set', + board: mapGroupsDomainsToBoard(groups, domains), + }) + }, [groups, domains]) + + function invalidateAll() { + queryClient.invalidateQueries({ queryKey: groupKeys.all }) + queryClient.invalidateQueries({ queryKey: domainKeys.all }) + } + + function syncDomainsCache(nextBoard: BoardState) { + const previous = queryClient.getQueryData( + domainKeys.list(), + ) + if (!previous) return + queryClient.setQueryData( + domainKeys.list(), + boardToDomainsList(nextBoard, previous), + ) + } + + const moveDomainMutation = useMutation({ + mutationFn: ({ + domainId, + groupId, + }: { + domainId: number + groupId: number | null + }) => api.patch(`/api/v1/domains/${domainId}`, { group_id: groupId }), + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось переместить домен') + invalidateAll() + }, + }) + + async function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + setActiveId(null) + if (dragDisabled || !over) return + + const domainId = Number(active.id) + const overId = String(over.id) + + const fromColumnId = findColumnId(board.columns, String(domainId)) + let toColumnId = findColumnId(board.columns, overId) + + if (!toColumnId && board.columns.some((column) => column.id === overId)) { + toColumnId = overId + } + + if (!fromColumnId || !toColumnId || fromColumnId === toColumnId) return + + const targetGroupId = parseGroupColumnId(toColumnId) + if (toColumnId !== UNGROUPED_COLUMN_ID && targetGroupId === null) return + + const previousBoard = board + const nextBoard = moveDomainBetweenColumns( + board, + domainId, + fromColumnId, + toColumnId, + ) + if (nextBoard === board) return + + dispatch({ type: 'replace', board: nextBoard }) + syncDomainsCache(nextBoard) + + try { + await moveDomainMutation.mutateAsync({ + domainId, + groupId: targetGroupId, + }) + toast.success('Домен перемещён') + invalidateAll() + } catch { + dispatch({ type: 'replace', board: previousBoard }) + syncDomainsCache(previousBoard) + } + } + + function handleDragStart(event: DragStartEvent) { + if (dragDisabled) return + setActiveId(String(event.active.id)) + } + + const activeDomain = activeId + ? board.columns + .flatMap((column) => column.items) + .find((domain) => String(domain.id) === activeId) + : undefined + + return { + board, + activeId, + activeDomain, + handleDragStart, + handleDragEnd, + dragDisabled, + } +} diff --git a/apps/web/src/hooks/use-services-board.ts b/apps/web/src/hooks/use-services-board.ts new file mode 100644 index 0000000..4ca7fa6 --- /dev/null +++ b/apps/web/src/hooks/use-services-board.ts @@ -0,0 +1,225 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useEffect, useReducer, useState } from 'react' +import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core' +import { toast } from 'sonner' +import { + boardToQueryData, + findColumnId, + mapResponseToBoard, + moveServiceBetweenColumns, + reorderInColumn, +} from '@/components/services-board/board-state' +import { parseGroupColumnId } from '@/components/services-board/column-ids' +import type { BoardState } from '@/components/services-board/types' +import { api } from '@/lib/api-client' +import type { ServiceGroupsResponse } from '@/lib/schemas' +import { + domainKeys, + serviceBindingKeys, + serviceGroupKeys, + serviceKeys, +} from '@/queries' + +type BoardAction = + | { type: 'set'; board: BoardState } + | { type: 'replace'; board: BoardState } + +function boardReducer(state: BoardState, action: BoardAction): BoardState { + switch (action.type) { + case 'set': + case 'replace': + return action.board + default: + return state + } +} + +interface UseServicesBoardOptions { + data: ServiceGroupsResponse | undefined + domainId?: number + dragDisabled?: boolean +} + +export function useServicesBoard({ + data, + domainId, + dragDisabled = false, +}: UseServicesBoardOptions) { + const queryClient = useQueryClient() + const [board, dispatch] = useReducer(boardReducer, { columns: [] }) + const [activeId, setActiveId] = useState(null) + + useEffect(() => { + if (!data) return + dispatch({ type: 'set', board: mapResponseToBoard(data, domainId) }) + }, [data, domainId]) + + function invalidateAll() { + queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }) + queryClient.invalidateQueries({ queryKey: serviceKeys.all }) + queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }) + queryClient.invalidateQueries({ queryKey: domainKeys.all }) + } + + function syncQueryCache(nextBoard: BoardState) { + const previous = queryClient.getQueryData( + serviceGroupKeys.all, + ) + if (!previous) return + queryClient.setQueryData( + serviceGroupKeys.all, + boardToQueryData(nextBoard, previous), + ) + } + + const reorderMutation = useMutation({ + mutationFn: ({ + groupId, + serviceIds, + }: { + groupId: number | null + serviceIds: number[] + }) => + api.patch('/api/v1/services/reorder', { + group_id: groupId, + service_ids: serviceIds, + }), + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось изменить порядок') + invalidateAll() + }, + }) + + const moveGroupMutation = useMutation({ + mutationFn: ({ + serviceId, + groupId, + }: { + serviceId: number + groupId: number | null + }) => + api.patch(`/api/v1/services/${serviceId}`, { + service_group_id: groupId, + }), + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось переместить сервис') + invalidateAll() + }, + }) + + async function persistColumnOrder(columnId: string, serviceIds: number[]) { + if (serviceIds.length === 0) return + await reorderMutation.mutateAsync({ + groupId: parseGroupColumnId(columnId), + serviceIds, + }) + } + + async function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + setActiveId(null) + if (dragDisabled || !over) return + + const activeServiceId = String(active.id) + const overId = String(over.id) + + if (overId === activeServiceId) return + + const fromColumnId = findColumnId(board.columns, activeServiceId) + let toColumnId = findColumnId(board.columns, overId) + + if (!toColumnId && board.columns.some((column) => column.id === overId)) { + toColumnId = overId + } + + if (!fromColumnId || !toColumnId) return + + const serviceId = Number(activeServiceId) + const previousBoard = board + + if (fromColumnId === toColumnId) { + if (overId === toColumnId) return + + const nextBoard = reorderInColumn(board, fromColumnId, activeServiceId, overId) + if (nextBoard === board) return + + dispatch({ type: 'replace', board: nextBoard }) + syncQueryCache(nextBoard) + + const column = nextBoard.columns.find((col) => col.id === fromColumnId) + if (!column) return + + try { + await persistColumnOrder( + fromColumnId, + column.items.map((item) => item.id), + ) + toast.success('Порядок сервисов обновлён') + } catch { + dispatch({ type: 'replace', board: previousBoard }) + syncQueryCache(previousBoard) + } + return + } + + const nextBoard = moveServiceBetweenColumns( + board, + serviceId, + fromColumnId, + toColumnId, + overId, + ) + if (nextBoard === board) return + + dispatch({ type: 'replace', board: nextBoard }) + syncQueryCache(nextBoard) + + const targetGroupId = parseGroupColumnId(toColumnId) + const targetColumn = nextBoard.columns.find((col) => col.id === toColumnId) + const sourceColumn = nextBoard.columns.find((col) => col.id === fromColumnId) + + try { + await moveGroupMutation.mutateAsync({ + serviceId, + groupId: targetGroupId, + }) + if (targetColumn) { + await persistColumnOrder( + toColumnId, + targetColumn.items.map((item) => item.id), + ) + } + if (sourceColumn && sourceColumn.items.length > 0) { + await persistColumnOrder( + fromColumnId, + sourceColumn.items.map((item) => item.id), + ) + } + toast.success('Сервис перемещён') + invalidateAll() + } catch { + dispatch({ type: 'replace', board: previousBoard }) + syncQueryCache(previousBoard) + } + } + + function handleDragStart(event: DragStartEvent) { + if (dragDisabled) return + setActiveId(String(event.active.id)) + } + + const activeService = activeId + ? board.columns + .flatMap((column) => column.items) + .find((service) => String(service.id) === activeId) + : undefined + + return { + board, + activeId, + activeService, + handleDragStart, + handleDragEnd, + dragDisabled, + } +} diff --git a/apps/web/src/hooks/use-services-selection.ts b/apps/web/src/hooks/use-services-selection.ts new file mode 100644 index 0000000..b826886 --- /dev/null +++ b/apps/web/src/hooks/use-services-selection.ts @@ -0,0 +1,97 @@ +import { useCallback, useMemo, useState } from 'react' + +export function useServicesSelection() { + const [selectedIds, setSelectedIds] = useState>(() => new Set()) + + const toggle = useCallback((id: number) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + }, []) + + const setSelected = useCallback((id: number, selected: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (selected) { + next.add(id) + } else { + next.delete(id) + } + return next + }) + }, []) + + const selectAll = useCallback((ids: number[]) => { + setSelectedIds((prev) => { + const next = new Set(prev) + for (const id of ids) { + next.add(id) + } + return next + }) + }, []) + + const deselectAll = useCallback((ids: number[]) => { + setSelectedIds((prev) => { + const next = new Set(prev) + for (const id of ids) { + next.delete(id) + } + return next + }) + }, []) + + const clear = useCallback(() => { + setSelectedIds(new Set()) + }, []) + + const isSelected = useCallback( + (id: number) => selectedIds.has(id), + [selectedIds], + ) + + const isAllSelected = useCallback( + (ids: number[]) => ids.length > 0 && ids.every((id) => selectedIds.has(id)), + [selectedIds], + ) + + const isSomeSelected = useCallback( + (ids: number[]) => ids.some((id) => selectedIds.has(id)), + [selectedIds], + ) + + const count = selectedIds.size + + return useMemo( + () => ({ + selectedIds, + count, + toggle, + setSelected, + selectAll, + deselectAll, + clear, + isSelected, + isAllSelected, + isSomeSelected, + }), + [ + selectedIds, + count, + toggle, + setSelected, + selectAll, + deselectAll, + clear, + isSelected, + isAllSelected, + isSomeSelected, + ], + ) +} diff --git a/apps/web/src/lib/cert-monitoring.ts b/apps/web/src/lib/cert-monitoring.ts new file mode 100644 index 0000000..9a074ab --- /dev/null +++ b/apps/web/src/lib/cert-monitoring.ts @@ -0,0 +1,27 @@ +import type { CertMonitoring } from '@cfdm/shared' + +export const certMonitoringOptions: Array<{ + value: CertMonitoring + label: string + description: string +}> = [ + { + value: 'auto', + label: 'Авто', + description: 'Проверять, если хост обслуживается активным сервисом', + }, + { + value: 'required', + label: 'Обязательно', + description: 'Всегда проверять SSL, даже без привязок', + }, + { + value: 'skipped', + label: 'Не проверять', + description: 'Исключить из мониторинга сертификатов', + }, +] + +export function certMonitoringLabel(value: string): string { + return certMonitoringOptions.find((o) => o.value === value)?.label ?? value +} diff --git a/apps/web/src/lib/domain-ips.ts b/apps/web/src/lib/domain-ips.ts index b373fe8..92a12b8 100644 --- a/apps/web/src/lib/domain-ips.ts +++ b/apps/web/src/lib/domain-ips.ts @@ -1,9 +1,14 @@ import type { ServiceBinding } from '@/lib/schemas' +function bindingIps(binding: ServiceBinding): string[] { + if (binding.target_ips.length > 0) return binding.target_ips + return binding.target_ip ? [binding.target_ip] : [] +} + export function getDomainIps(bindings: ServiceBinding[], domainId: number): string[] { const ips = bindings - .filter((b) => b.domain_id === domainId && b.target_ip) - .map((b) => b.target_ip as string) + .filter((b) => b.domain_id === domainId) + .flatMap(bindingIps) return [...new Set(ips)] } @@ -22,11 +27,11 @@ export function groupBindingsByHostname( export function buildIpsByDomainId(bindings: ServiceBinding[]): Map { const map = new Map() for (const binding of bindings) { - if (!binding.target_ip) continue + const ips = bindingIps(binding) + if (ips.length === 0) continue const existing = map.get(binding.domain_id) ?? [] - if (!existing.includes(binding.target_ip)) { - map.set(binding.domain_id, [...existing, binding.target_ip]) - } + const merged = [...new Set([...existing, ...ips])] + map.set(binding.domain_id, merged) } return map } diff --git a/apps/web/src/lib/schemas-ext.ts b/apps/web/src/lib/schemas-ext.ts index 0991832..c3e32b2 100644 --- a/apps/web/src/lib/schemas-ext.ts +++ b/apps/web/src/lib/schemas-ext.ts @@ -1,12 +1,8 @@ -import { z } from 'zod' - -export const subdomainSchema = z.object({ - id: z.number(), - domain_id: z.number(), - name: z.string(), - fqdn: z.string(), - created_at: z.string(), - updated_at: z.string(), -}) - -export type Subdomain = z.infer +export { + createSubdomainSchema, + subdomainSchema, + updateSubdomainSchema, + type CreateSubdomainInput, + type SubdomainRecord, + type UpdateSubdomainInput, +} from '@cfdm/shared' diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts index b002cf9..468fffe 100644 --- a/apps/web/src/lib/schemas.ts +++ b/apps/web/src/lib/schemas.ts @@ -50,8 +50,10 @@ export const serviceDomainBindingSchema = z zone_name: z.string(), hostname: z.string(), fqdn: z.string(), + record_type: z.enum(['A', 'CNAME']).default('A'), target_ips: z.array(z.string()).optional(), target_ip: z.string().nullable().optional(), + target_cname: z.string().nullable().optional(), sync_status: z.string().nullable(), }) .transform((binding) => ({ @@ -62,6 +64,10 @@ export const serviceDomainBindingSchema = z : binding.target_ip ? [binding.target_ip] : [], + target_cname: binding.target_cname?.trim() || null, + record_type: binding.target_cname?.trim() + ? ('CNAME' as const) + : (binding.record_type ?? 'A'), })) export const serviceViewSchema = serviceSchema.extend({ @@ -86,6 +92,7 @@ export const domainSchema = z.object({ zone_name: z.string(), cf_zone_id: z.string(), status: z.string(), + cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'), last_synced_at: z.string().nullable(), created_at: z.string(), updated_at: z.string(), @@ -96,22 +103,33 @@ export const domainListItemSchema = domainSchema.extend({ service_count: z.number(), }) -export const serviceBindingSchema = z.object({ - id: z.number(), - domain_id: z.number(), - service_id: z.number(), - hostname: z.string(), - dns_record_id: z.number().nullable(), - zone_name: z.string(), - group_id: z.number().nullable(), - group_name: z.string().nullable(), - service_name: z.string(), - service_slug: z.string(), - target_ip: z.string().nullable(), - sync_status: z.string().nullable(), - created_at: z.string(), - updated_at: z.string(), -}) +export const serviceBindingSchema = z + .object({ + id: z.number(), + domain_id: z.number(), + service_id: z.number(), + hostname: z.string(), + dns_record_id: z.number().nullable(), + zone_name: z.string(), + group_id: z.number().nullable(), + group_name: z.string().nullable(), + service_name: z.string(), + service_slug: z.string(), + target_ip: z.string().nullable(), + target_ips: z.array(z.string()).optional(), + sync_status: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), + }) + .transform((binding) => ({ + ...binding, + target_ips: + binding.target_ips && binding.target_ips.length > 0 + ? binding.target_ips + : binding.target_ip + ? [binding.target_ip] + : [], + })) export const dnsRecordSchema = z.object({ id: z.number(), @@ -169,10 +187,30 @@ const ipv4Schema = z '╨Э╨╡╨║╨╛╤А╤А╨╡╨║╤В╨╜╤Л╨╣ IPv4', ) -const serviceDomainInputSchema = z.object({ - fqdn: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ FQDN'), - target_ips: z.array(ipv4Schema).min(1, '╨Т╤Л╨▒╨╡╤А╨╕╤В╨╡ ╤Е╨╛╤В╤П ╨▒╤Л ╨╛╨┤╨╕╨╜ IP'), -}) +const serviceDomainInputSchema = z + .object({ + fqdn: z.string().min(1, 'Укажите FQDN'), + target_ips: z.array(ipv4Schema).optional(), + target_cname: z.string().min(1, 'Укажите CNAME-цель').optional(), + }) + .superRefine((data, ctx) => { + const hasIps = (data.target_ips?.length ?? 0) > 0 + const hasCname = Boolean(data.target_cname?.trim()) + if (!hasIps && !hasCname) { + ctx.addIssue({ + code: 'custom', + message: 'Укажите IP или CNAME-цель', + path: ['target_ips'], + }) + } + if (hasIps && hasCname) { + ctx.addIssue({ + code: 'custom', + message: 'Укажите либо IP, либо CNAME-цель', + path: ['target_cname'], + }) + } + }) export const createServiceSchema = z.object({ name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'), @@ -248,3 +286,14 @@ export type CreateServiceBindingInput = z.infer export type LoginInput = z.infer export type CreateDnsRecordInput = z.infer + +export { + createSubdomainSchema, + subdomainSchema, + updateDomainSchema, + updateSubdomainSchema, + type CreateSubdomainInput, + type SubdomainRecord, + type UpdateDomainInput, + type UpdateSubdomainInput, +} from '@cfdm/shared' diff --git a/apps/web/src/lib/service-utils.ts b/apps/web/src/lib/service-utils.ts index 89c646d..540e6ed 100644 --- a/apps/web/src/lib/service-utils.ts +++ b/apps/web/src/lib/service-utils.ts @@ -1,5 +1,28 @@ import { bindingToFqdn } from '@/lib/parse-fqdn' -import type { ServiceView } from '@/lib/schemas' +import type { ServiceGroupsResponse, ServiceView } from '@/lib/schemas' + +export function formatServiceGroupLabel( + groupName: string | null | undefined, + serviceName: string, +): string { + const group = groupName?.trim() || 'Без группы' + return `${group} / ${serviceName}` +} + +export function buildServiceGroupNameById( + data: ServiceGroupsResponse, +): Map { + const map = new Map() + for (const group of data.groups) { + for (const service of group.services) { + map.set(service.id, group.name) + } + } + for (const service of data.ungrouped) { + map.set(service.id, null) + } + return map +} export function serviceDisplayFqdn(service: ServiceView): string { const first = service.domains?.[0] diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts index 5882c92..fc9d043 100644 --- a/apps/web/src/queries/index.ts +++ b/apps/web/src/queries/index.ts @@ -10,8 +10,11 @@ import { serviceBindingSchema, serviceGroupsResponseSchema, serviceViewSchema, + subdomainSchema, + type CreateSubdomainInput, + type UpdateDomainInput, + type UpdateSubdomainInput, } from '@/lib/schemas' -import { subdomainSchema } from '@/lib/schemas-ext' import { z } from 'zod' export const groupKeys = { @@ -160,3 +163,48 @@ export const subdomainsListQueryOptions = (domainId: number) => return z.array(subdomainSchema).parse(data) }, }) + +export interface CreateServiceBindingBody { + domain_id: number + service_id: number + hostname?: string + target_ip?: string +} + +export async function createSubdomain(domainId: number, body: CreateSubdomainInput) { + const data = await api.post(`/api/v1/domains/${domainId}/subdomains`, body) + return subdomainSchema.parse(data) +} + +export async function updateSubdomain(id: number, body: UpdateSubdomainInput) { + const data = await api.patch(`/api/v1/subdomains/${id}`, body) + return subdomainSchema.parse(data) +} + +export async function updateDomain(id: number, body: UpdateDomainInput) { + const data = await api.patch(`/api/v1/domains/${id}`, body) + return domainSchema.parse(data) +} + +export async function deleteSubdomain(id: number) { + return api.delete<{ deleted: boolean }>(`/api/v1/subdomains/${id}`) +} + +export async function createServiceBinding(body: CreateServiceBindingBody) { + const data = await api.post('/api/v1/service-bindings', body) + return serviceBindingSchema.parse(data) +} + +export async function deleteServiceBinding(id: number) { + return api.delete<{ deleted: boolean }>(`/api/v1/service-bindings/${id}`) +} + +export function invalidateDomainPage( + queryClient: import('@tanstack/react-query').QueryClient, + domainId: number, +) { + void queryClient.invalidateQueries({ queryKey: subdomainKeys.list(domainId) }) + void queryClient.invalidateQueries({ queryKey: serviceBindingKeys.byDomain(domainId) }) + void queryClient.invalidateQueries({ queryKey: domainKeys.detail(domainId) }) + void queryClient.invalidateQueries({ queryKey: dnsKeys.list(domainId) }) +} diff --git a/apps/web/src/routes/_auth/certificates.tsx b/apps/web/src/routes/_auth/certificates.tsx index baed3ee..27fd9b2 100644 --- a/apps/web/src/routes/_auth/certificates.tsx +++ b/apps/web/src/routes/_auth/certificates.tsx @@ -97,7 +97,7 @@ function CertificatesPage() { checkMutation.mutate()} @@ -142,7 +142,7 @@ function CertificatesPage() { + +
+ + +
+ +
+ ) +} + function DomainOverviewPage() { const { domainId } = Route.useParams() const id = Number(domainId) - const queryClient = useQueryClient() - const { data: domain } = useQuery(domainDetailQueryOptions(id)) - const { data: subdomains } = useQuery(subdomainsListQueryOptions(id)) - const { data: bindings } = useQuery(domainServiceBindingsQueryOptions(id)) - const syncMutation = useMutation({ - mutationFn: () => api.post(`/api/v1/domains/${id}/sync`), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: subdomainKeys.list(id) }) - queryClient.invalidateQueries({ queryKey: domainKeys.all }) - queryClient.invalidateQueries({ queryKey: ['service-bindings'] }) - toast.success('Синхронизация завершена') - }, - onError: (err) => { - toast.error(err instanceof Error ? err.message : 'Ошибка синхронизации') - }, - }) + const { + domain, + bindings, + services, + serviceGroupById, + subdomainRows, + isLoading, + isError, + error, + refetch, + syncMutation, + createSubdomainMutation, + updateSubdomainMutation, + updateDomainCertMonitoringMutation, + deleteSubdomainMutation, + linkServiceMutation, + } = useDomainPage(id) + + const [sheetOpen, setSheetOpen] = useState(false) + const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create') + const [editTarget, setEditTarget] = useState(null) + + function openCreateSheet() { + setSheetMode('create') + setEditTarget(null) + setSheetOpen(true) + } + + function openEditSheet(row: SubdomainTableRow) { + setSheetMode('edit') + setEditTarget(row) + setSheetOpen(true) + } + + function resolveServiceId(row: SubdomainTableRow): string { + if (row.serviceLinks.length === 0) return 'none' + return String(row.serviceLinks[0].serviceId) + } + + async function handleSheetSubmit(values: SubdomainEditValues) { + if (sheetMode === 'create') { + await createSubdomainMutation.mutateAsync(values.name) + setSheetOpen(false) + return + } + + 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) { + await updateSubdomainMutation.mutateAsync({ + id: editTarget.subdomain.id, + ...(nameChanged ? { name: values.name } : {}), + ...(certMonitoringChanged + ? { cert_monitoring: values.certMonitoring } + : {}), + }) + } + + const bindingsNeedSync = + serviceChanged || (nameChanged && editTarget.bindingIds.length > 0) + + if (bindingsNeedSync) { + await linkServiceMutation.mutateAsync({ + subdomainName: values.name, + serviceId: targetServiceId, + bindingIds: editTarget.bindingIds, + }) + } else if (serviceChanged && targetServiceId != null) { + await linkServiceMutation.mutateAsync({ + subdomainName: values.name, + serviceId: targetServiceId, + bindingIds: [], + }) + } + + setSheetOpen(false) + } + + const isSheetSaving = + createSubdomainMutation.isPending || + updateSubdomainMutation.isPending || + linkServiceMutation.isPending return ( @@ -88,6 +178,7 @@ function DomainOverviewPage() { - ) : ( - Без группы - )} - -
- Последняя синхронизация - {formatDate(domain?.last_synced_at)} -
-
-
- - - - - Поддомены - Обнаруженные поддомены в зоне - - - {subdomains?.length ? ( - - {subdomains.map((s, index) => ( -
- - - {s.fqdn} - - + + } + > + {domain && ( + <> + + updateDomainCertMonitoringMutation.mutate(value) + } + isCertMonitoringSaving={updateDomainCertMonitoringMutation.isPending} + /> + +
+ + + Сводка + Основные параметры зоны + + +
+ Статус + +
+
+ Группа + {domain.group_id ? ( - - - {index < subdomains.length - 1 && } + ) : ( + Без группы + )} +
+
+ + Последняя синхронизация + + + {formatDate(domain.last_synced_at)} + +
+
+
+ +
+ + + +
- ))} -
- ) : ( - } + > + + deleteSubdomainMutation.mutate(row.subdomain.id) + } + onToggleEnabled={(row) => + updateSubdomainMutation.mutate({ + id: row.subdomain.id, + enabled: !row.subdomain.enabled, + }) + } + /> + + + - )} -
-
+ + )} + ) } diff --git a/apps/web/src/routes/_auth/domains/index.tsx b/apps/web/src/routes/_auth/domains/index.tsx index 05a5bf8..04da852 100644 --- a/apps/web/src/routes/_auth/domains/index.tsx +++ b/apps/web/src/routes/_auth/domains/index.tsx @@ -6,17 +6,16 @@ import { zodResolver } from '@hookform/resolvers/zod' import { toast } from 'sonner' import { api } from '@/lib/api-client' import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas' -import { buildIpsByDomainId } from '@/lib/domain-ips' import { domainKeys, domainsListQueryOptions, groupsQueryOptions, - serviceBindingsQueryOptions, + serviceBindingKeys, } from '@/queries' import { PageHeader } from '@/components/page-header' import { PageShell } from '@/components/page-shell' import { DataTableCard } from '@/components/data-table-card' -import { DomainsDataTable } from '@/components/domains-data-table' +import { DomainsDataTable, type DomainTableRow } from '@/components/domains-data-table' import { Button } from '@cfdm/ui/components/button' import { Input } from '@cfdm/ui/components/input' import { @@ -46,7 +45,6 @@ export const Route = createFileRoute('/_auth/domains/')({ Promise.all([ queryClient.ensureQueryData(domainsListQueryOptions()), queryClient.ensureQueryData(groupsQueryOptions()), - queryClient.ensureQueryData(serviceBindingsQueryOptions()), ]), component: DomainsPage, }) @@ -60,12 +58,6 @@ function DomainsPage() { const queryClient = useQueryClient() const { data: domains } = useQuery(domainsListQueryOptions(listGroupId)) const { data: groups } = useQuery(groupsQueryOptions()) - const { data: bindings } = useQuery(serviceBindingsQueryOptions()) - - const ipsByDomainId = useMemo( - () => buildIpsByDomainId(bindings ?? []), - [bindings], - ) const groupItems = useMemo( () => [ @@ -94,6 +86,22 @@ function DomainsPage() { }, }) + const deleteMutation = useMutation({ + mutationFn: (id: number) => api.delete(`/api/v1/domains/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: domainKeys.all }) + queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }) + toast.success('Зона удалена') + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось удалить зону') + }, + }) + + function handleDeleteDomain(domain: DomainTableRow) { + deleteMutation.mutate(domain.id) + } + const handleCreate = form.handleSubmit((values) => { createMutation.mutate({ zone_name: values.zone_name.trim(), @@ -123,22 +131,18 @@ function DomainsPage() { }, [domains, filterGroupId]) const tableData = useMemo( - () => - filteredDomains.map((domain) => ({ - ...domain, - ips: ipsByDomainId.get(domain.id) ?? [], - })), - [filteredDomains, ipsByDomainId], + () => filteredDomains as DomainTableRow[], + [filteredDomains], ) return ( d.ips.length > 0).length} с IP · ${tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов`} + description={`${tableData.length} зон · ${tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов`} actions={ <> - @@ -155,6 +159,8 @@ function DomainsPage() { groupFilterItems={groupItems} groupFilterValue={filterGroupId || 'all'} onGroupFilterChange={handleFilterGroupChange} + onDelete={handleDeleteDomain} + isDeleting={deleteMutation.isPending} /> diff --git a/apps/web/src/routes/_auth/groups.tsx b/apps/web/src/routes/_auth/groups.tsx index 4184177..6258222 100644 --- a/apps/web/src/routes/_auth/groups.tsx +++ b/apps/web/src/routes/_auth/groups.tsx @@ -1,8 +1,6 @@ -import { createFileRoute, Link } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useMemo, useState } from 'react' -import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' import { FolderTreeIcon } from 'lucide-react' import { toast } from 'sonner' import { @@ -10,46 +8,21 @@ import { domainsListQueryOptions, groupKeys, groupsQueryOptions, + serviceBindingKeys, serviceBindingsQueryOptions, } from '@/queries' import { api } from '@/lib/api-client' -import { createGroupSchema, type CreateGroupInput, type DomainListItem } from '@/lib/schemas' +import type { CreateGroupInput, Group } from '@/lib/schemas' import { PageHeader } from '@/components/page-header' import { PageShell } from '@/components/page-shell' +import { QueryState } from '@/components/query-state' +import { EmptyState } from '@/components/empty-state' import { ConfirmDialog } from '@/components/confirm-dialog' -import { TableToolbar } from '@/components/table-toolbar' -import { KanbanBoard } from '@/components/kanban-board' -import { DomainGroupCard } from '@/components/domain-group-card' -import { DataTableCard } from '@/components/data-table-card' +import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet' +import { GroupsBoard } from '@/components/groups-board/groups-board' +import { GroupsBoardSkeleton } from '@/components/groups-board/groups-board-skeleton' +import { useGroupsBoard } from '@/hooks/use-groups-board' import { Button } from '@cfdm/ui/components/button' -import { Input } from '@cfdm/ui/components/input' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@cfdm/ui/components/card' -import { - Field, - FieldGroup, - FieldLabel, -} from '@cfdm/ui/components/field' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@cfdm/ui/components/table' -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from '@cfdm/ui/components/tabs' -import { Spinner } from '@cfdm/ui/components/spinner' export const Route = createFileRoute('/_auth/groups')({ loader: ({ context: { queryClient } }) => @@ -61,28 +34,30 @@ export const Route = createFileRoute('/_auth/groups')({ component: GroupsPage, }) -const UNGROUPED_COLUMN_ID = 'ungrouped' - -function groupColumnId(groupId: number) { - return `group-${groupId}` -} - -function parseGroupColumnId(columnId: string): number | null { - if (columnId === UNGROUPED_COLUMN_ID) return null - const match = columnId.match(/^group-(\d+)$/) - return match ? Number(match[1]) : null -} - function GroupsPage() { - const [catalogSearch, setCatalogSearch] = useState('') + const [createSheetOpen, setCreateSheetOpen] = useState(false) + const [editingGroup, setEditingGroup] = useState(null) + const [deletingGroup, setDeletingGroup] = useState(null) const queryClient = useQueryClient() - const { data: groups } = useQuery(groupsQueryOptions()) + + const { + data: groups, + isLoading, + isError, + error, + refetch, + } = useQuery(groupsQueryOptions()) const { data: domains } = useQuery(domainsListQueryOptions()) const { data: bindings } = useQuery(serviceBindingsQueryOptions()) - const form = useForm({ - resolver: zodResolver(createGroupSchema), - defaultValues: { name: '', slug: '' }, + const { + board, + activeDomain, + handleDragStart, + handleDragEnd, + } = useGroupsBoard({ + groups, + domains, }) const serviceLabelsByDomain = useMemo(() => { @@ -97,36 +72,24 @@ function GroupsPage() { return map }, [bindings]) - const columns = useMemo(() => { - const groupColumns = - groups?.map((group) => ({ - id: groupColumnId(group.id), - title: group.name, - description: group.slug, - href: `/groups/${group.id}`, - items: - domains?.filter((d) => d.group_id === group.id) ?? [], - })) ?? [] - - const ungrouped: DomainListItem[] = - domains?.filter((d) => d.group_id === null) ?? [] - - return [ - ...groupColumns, - { - id: UNGROUPED_COLUMN_ID, - title: 'Без группы', - description: 'Домены без назначенной группы', - items: ungrouped, - }, - ] + const isEmpty = useMemo(() => { + if (!groups || !domains) return true + const hasDomains = domains.length > 0 + if (hasDomains) return false + return groups.length === 0 }, [groups, domains]) + function invalidateAll() { + queryClient.invalidateQueries({ queryKey: groupKeys.all }) + queryClient.invalidateQueries({ queryKey: domainKeys.all }) + queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }) + } + const createMutation = useMutation({ mutationFn: (body: CreateGroupInput) => api.post('/api/v1/groups', body), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: groupKeys.all }) - form.reset() + invalidateAll() + setCreateSheetOpen(false) toast.success('Группа создана') }, onError: (err) => { @@ -134,11 +97,24 @@ function GroupsPage() { }, }) + const updateMutation = useMutation({ + mutationFn: ({ id, body }: { id: number; body: CreateGroupInput }) => + api.patch(`/api/v1/groups/${id}`, body), + onSuccess: () => { + invalidateAll() + setEditingGroup(null) + toast.success('Группа сохранена') + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось сохранить группу') + }, + }) + const deleteMutation = useMutation({ mutationFn: (id: number) => api.delete(`/api/v1/groups/${id}`), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: groupKeys.all }) - queryClient.invalidateQueries({ queryKey: domainKeys.all }) + invalidateAll() + setDeletingGroup(null) toast.success('Группа удалена') }, onError: (err) => { @@ -146,184 +122,85 @@ function GroupsPage() { }, }) - const moveDomainMutation = useMutation({ - mutationFn: ({ domainId, groupId }: { domainId: number; groupId: number | null }) => - api.patch(`/api/v1/domains/${domainId}`, { group_id: groupId }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: domainKeys.all }) - toast.success('Группа домена обновлена') - }, - onError: (err) => { - toast.error(err instanceof Error ? err.message : 'Не удалось переместить домен') - }, - }) - - function handleMove(itemId: string, _fromColumnId: string, toColumnId: string) { - const groupId = parseGroupColumnId(toColumnId) - if (toColumnId !== UNGROUPED_COLUMN_ID && groupId === null) return - moveDomainMutation.mutate({ domainId: Number(itemId), groupId }) - } - - const handleSubmit = form.handleSubmit((values) => { - createMutation.mutate(values) - }) - - const filteredGroups = useMemo(() => { - const list = groups ?? [] - const query = catalogSearch.trim().toLowerCase() - if (!query) return list - return list.filter( - (g) => - g.name.toLowerCase().includes(query) || - g.slug.toLowerCase().includes(query), - ) - }, [groups, catalogSearch]) - - const isCatalogFilteredEmpty = - (groups?.length ?? 0) > 0 && filteredGroups.length === 0 - return ( setCreateSheetOpen(true)}> + Добавить группу + + } /> - - - Канбан - Справочник - - - - - Доска групп - - Перетащите домен в колонку группы. Нажмите на название колонки, чтобы открыть список доменов. - - - - String(domain.id)} - renderCard={(domain) => ( - - )} - onMove={handleMove} - /> - - - - - - - Создать группу - Добавить новую группу доменов - - -
- - - Название - - - - Slug - - - - -
-
-
- } + > + {isEmpty ? ( + setCreateSheetOpen(true)}> + Добавить группу + } - emptyDescription={ - isCatalogFilteredEmpty - ? 'Измените поисковый запрос' - : 'Создайте первую группу в форме выше' - } - emptyIcon={FolderTreeIcon} - toolbar={ - - } - > - - - - Название - Slug - Действия - - - - {filteredGroups.map((group) => ( - - {group.name} - {group.slug} - -
- - - Удалить - - } - title="Удалить группу?" - description={`Группа «${group.name}» будет удалена. Домены останутся без группы.`} - onConfirm={() => deleteMutation.mutate(group.id)} - /> -
-
-
- ))} -
-
-
-
-
+ /> + ) : ( + + )} + + + createMutation.mutate(body)} + /> + + { + if (!open) setEditingGroup(null) + }} + onSave={(id, body) => updateMutation.mutate({ id, body })} + /> + + { + if (!open) setDeletingGroup(null) + }} + title="Удалить группу?" + description={ + deletingGroup + ? `Группа «${deletingGroup.name}» будет удалена. Домены останутся без группы.` + : '' + } + onConfirm={() => { + if (deletingGroup) deleteMutation.mutate(deletingGroup.id) + }} + disabled={deleteMutation.isPending} + />
) } diff --git a/apps/web/src/routes/_auth/groups/$groupId.tsx b/apps/web/src/routes/_auth/groups/$groupId.tsx index 2a0e792..bdd0f4c 100644 --- a/apps/web/src/routes/_auth/groups/$groupId.tsx +++ b/apps/web/src/routes/_auth/groups/$groupId.tsx @@ -61,7 +61,7 @@ function GroupDetailPage() { variant="outline" render={} > - На канбан + На группам } /> diff --git a/apps/web/src/routes/_auth/services.tsx b/apps/web/src/routes/_auth/services.tsx index 74e8f11..79bdd5f 100644 --- a/apps/web/src/routes/_auth/services.tsx +++ b/apps/web/src/routes/_auth/services.tsx @@ -24,19 +24,15 @@ import { PageHeader } from '@/components/page-header' import { PageShell } from '@/components/page-shell' import { QueryState } from '@/components/query-state' import { EmptyState } from '@/components/empty-state' +import { ConfirmDialog } from '@/components/confirm-dialog' import { ServiceEditSheet } from '@/components/service-edit-sheet' -import { ServiceGroupCard } from '@/components/service-group-card' import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet' -import { ServiceRow } from '@/components/service-row' +import { ServicesBoard } from '@/components/services-board/services-board' +import { ServicesBoardSkeleton } from '@/components/services-board/services-board-skeleton' +import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar' +import { useServicesBoard } from '@/hooks/use-services-board' +import { useServicesSelection } from '@/hooks/use-services-selection' import { Button } from '@cfdm/ui/components/button' -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from '@cfdm/ui/components/card' -import { ItemGroup } from '@cfdm/ui/components/item' -import { Skeleton } from '@cfdm/ui/components/skeleton' export const Route = createFileRoute('/_auth/services')({ validateSearch: (search: Record) => ({ @@ -91,22 +87,23 @@ function setGroupEnabled( } } -function serviceMatchesDomain(service: ServiceView, domainId?: number) { - if (!domainId) return true - return service.domains?.some((d) => d.domain_id === domainId) ?? false -} - function ServicesPage() { const { domainId } = Route.useSearch() const [createSheetOpen, setCreateSheetOpen] = useState(false) const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false) + const [defaultGroupId, setDefaultGroupId] = useState(null) const [editingGroup, setEditingGroup] = useState(null) const [editingService, setEditingService] = useState(null) + const [deletingService, setDeletingService] = useState(null) + const [deletingGroup, setDeletingGroup] = useState(null) const [savingId, setSavingId] = useState(null) const [deletingId, setDeletingId] = useState(null) + const [deletingGroupId, setDeletingGroupId] = useState(null) const [togglingServiceId, setTogglingServiceId] = useState(null) const [togglingGroupId, setTogglingGroupId] = useState(null) + const [bulkToggling, setBulkToggling] = useState(false) const queryClient = useQueryClient() + const selection = useServicesSelection() const { data, isLoading, @@ -116,10 +113,40 @@ function ServicesPage() { } = useQuery(serviceGroupsQueryOptions()) const { data: domains } = useQuery(domainsListQueryOptions()) + const dragDisabled = domainId != null + + const { + board, + activeService, + handleDragStart, + handleDragEnd, + } = useServicesBoard({ + data, + domainId, + dragDisabled, + }) + const filteredDomain = domainId ? domains?.find((d) => d.id === domainId) : undefined + const groups = useMemo(() => data?.groups ?? [], [data?.groups]) + + const isEmpty = useMemo(() => { + if (!data) return true + if (domainId) { + return ( + board.columns.length === 0 || + board.columns.every((column) => column.items.length === 0) + ) + } + const hasServices = + data.groups.some((group) => group.services.length > 0) || + data.ungrouped.length > 0 + if (hasServices) return false + return data.groups.length === 0 + }, [data, domainId, board.columns]) + function invalidateAll() { queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }) queryClient.invalidateQueries({ queryKey: serviceKeys.all }) @@ -199,6 +226,7 @@ function ServicesPage() { onSuccess: () => { invalidateAll() setEditingService(null) + setDeletingService(null) toast.success('Сервис удалён') }, onError: (err) => { @@ -244,6 +272,21 @@ function ServicesPage() { }, }) + const deleteGroupMutation = useMutation({ + mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`), + onSuccess: () => { + invalidateAll() + setDeletingGroup(null) + toast.success('Группа удалена') + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : 'Не удалось удалить группу') + }, + onSettled: () => { + setDeletingGroupId(null) + }, + }) + const toggleGroupMutation = useMutation({ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => api.patch(`/api/v1/service-groups/${id}/toggle`, { @@ -305,24 +348,62 @@ function ServicesPage() { toggleGroupMutation.mutate({ id: groupId, enabled }) } - const groups = useMemo(() => { - const list = data?.groups ?? [] - if (!domainId) return list - return list - .map((group) => ({ - ...group, - services: group.services.filter((s) => serviceMatchesDomain(s, domainId)), - })) - .filter((group) => group.services.length > 0) - }, [data?.groups, domainId]) + function handleOpenCreateService(groupId: number | null = null) { + setDefaultGroupId(groupId) + setCreateSheetOpen(true) + } - const ungrouped = useMemo(() => { - const list = data?.ungrouped ?? [] - if (!domainId) return list - return list.filter((s) => serviceMatchesDomain(s, domainId)) - }, [data?.ungrouped, domainId]) + function handleDeleteGroup(id: number) { + setDeletingGroupId(id) + deleteGroupMutation.mutate(id) + } - const isEmpty = groups.length === 0 && ungrouped.length === 0 + async function handleBulkToggle(enabled: boolean) { + const ids = Array.from(selection.selectedIds) + if (ids.length === 0) return + + setBulkToggling(true) + await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all }) + const previous = queryClient.getQueryData( + serviceGroupKeys.all, + ) + if (previous) { + let next = previous + for (const id of ids) { + next = setServiceEnabled(next, id, enabled) + } + queryClient.setQueryData(serviceGroupKeys.all, next) + } + + const results = await Promise.allSettled( + ids.map((id) => + api.patch(`/api/v1/services/${id}/toggle`, { enabled }), + ), + ) + const succeeded = results.filter((r) => r.status === 'fulfilled').length + const failed = results.length - succeeded + + if (failed > 0) { + if (previous) { + queryClient.setQueryData(serviceGroupKeys.all, previous) + } + toast.error(`Не удалось переключить ${failed} из ${results.length} сервисов`) + } else { + toast.success( + enabled + ? `Включено сервисов: ${succeeded}` + : `Выключено сервисов: ${succeeded}`, + ) + selection.clear() + } + + setBulkToggling(false) + invalidateAll() + } + + const boardHint = dragDisabled + ? 'Перетаскивание отключено при фильтре по домену' + : 'Перетащите сервис между группами или измените порядок в списке' return ( @@ -330,30 +411,33 @@ function ServicesPage() { title="Сервисы" description={ filteredDomain - ? `Сервисы с привязками к домену ${filteredDomain.zone_name}` - : 'Сервисы и группы сервисов — FQDN синхронизируются в Cloudflare при включении' + ? `Сервисы с привязками к домену ${filteredDomain.zone_name}. ${boardHint}` + : `Сервисы и группы — FQDN синхронизируются в Cloudflare при включении. ${boardHint}` } actions={
- +
} /> + handleBulkToggle(true)} + onDisable={() => handleBulkToggle(false)} + onClear={selection.clear} + /> + - - - - } + skeleton={} > {isEmpty ? ( setCreateGroupSheetOpen(true)}> Добавить группу - + } /> ) : ( -
- {groups.map((group) => ( - - ))} - - {ungrouped.length > 0 ? ( - - - Без группы - - - - {ungrouped.map((service) => ( - - ))} - - - - ) : null} -
+ showCheckbox={!dragDisabled} + isSelected={selection.isSelected} + onSelectedChange={selection.setSelected} + isAllSelected={selection.isAllSelected} + isSomeSelected={selection.isSomeSelected} + onSelectAllInGroup={selection.selectAll} + onDeselectAllInGroup={selection.deselectAll} + /> )}
@@ -434,7 +506,11 @@ function ServicesPage() { open={createSheetOpen} knownDomains={domains ?? []} isSaving={createServiceMutation.isPending} - onOpenChange={setCreateSheetOpen} + defaultGroupId={defaultGroupId} + onOpenChange={(open) => { + setCreateSheetOpen(open) + if (!open) setDefaultGroupId(null) + }} onCreate={handleCreate} /> @@ -457,6 +533,40 @@ function ServicesPage() { }} onSave={(id, body) => updateGroupMutation.mutate({ id, body })} /> + + { + if (!open) setDeletingService(null) + }} + title="Удалить сервис?" + description={ + deletingService + ? `Сервис «${deletingService.name}» и его привязки будут удалены.` + : '' + } + onConfirm={() => { + if (deletingService) handleDelete(deletingService.id) + }} + disabled={deleteServiceMutation.isPending} + /> + + { + if (!open) setDeletingGroup(null) + }} + title="Удалить группу?" + description={ + deletingGroup + ? `Группа «${deletingGroup.name}» будет удалена. Сервисы останутся без группы.` + : '' + } + onConfirm={() => { + if (deletingGroup) handleDeleteGroup(deletingGroup.id) + }} + disabled={deleteGroupMutation.isPending || deletingGroupId === deletingGroup?.id} + />
) } diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index 7219072..70b09bc 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -17,7 +17,8 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@cfdm/shared": ["../../packages/shared/src/index.ts"] } }, "include": ["src"] diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 94ea1a6..a1daca2 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), + '@cfdm/shared': path.resolve(__dirname, '../../packages/shared/src/index.ts'), }, }, server: { diff --git a/opencode.jsonc b/opencode.jsonc new file mode 100644 index 0000000..2c2509a --- /dev/null +++ b/opencode.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "codegraph": { + "type": "local", + "command": [ + "codegraph", + "serve", + "--mcp" + ], + "enabled": true + } + } +} diff --git a/packages/db/dist/index.d.ts b/packages/db/dist/index.d.ts index 35398a5..d87d424 100644 --- a/packages/db/dist/index.d.ts +++ b/packages/db/dist/index.d.ts @@ -215,6 +215,23 @@ declare const services: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ identity: undefined; generated: undefined; }, {}, {}>; + sort_order: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "sort_order"; + tableName: "services"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "services"; @@ -506,6 +523,25 @@ declare const domains: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ }, {}, { length: number | undefined; }>; + cert_monitoring: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cert_monitoring"; + tableName: "domains"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; last_synced_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "last_synced_at"; tableName: "domains"; @@ -642,6 +678,23 @@ declare const subdomains: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ }, {}, { length: number | undefined; }>; + enabled: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "enabled"; + tableName: "subdomains"; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "subdomains"; @@ -1020,6 +1073,25 @@ declare const serviceBindings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ }, {}, { length: number | undefined; }>; + cname_target: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cname_target"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; dns_record_id: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "dns_record_id"; tableName: "service_bindings"; @@ -1805,6 +1877,23 @@ declare const schema: { identity: undefined; generated: undefined; }, {}, {}>; + sort_order: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "sort_order"; + tableName: "services"; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "services"; @@ -2096,6 +2185,25 @@ declare const schema: { }, {}, { length: number | undefined; }>; + cert_monitoring: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cert_monitoring"; + tableName: "domains"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; last_synced_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "last_synced_at"; tableName: "domains"; @@ -2232,6 +2340,23 @@ declare const schema: { }, {}, { length: number | undefined; }>; + enabled: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "enabled"; + tableName: "subdomains"; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "created_at"; tableName: "subdomains"; @@ -2610,6 +2735,25 @@ declare const schema: { }, {}, { length: number | undefined; }>; + cname_target: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cname_target"; + tableName: "service_bindings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; dns_record_id: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "dns_record_id"; tableName: "service_bindings"; @@ -3227,15 +3371,22 @@ declare function listDomainsEnriched(db: Db, groupId?: number): DomainListItem[] declare function findDomainByZoneName(db: Db, zoneName: string): Domain | null; declare function getDomain(db: Db, id: number): Domain; declare function createDomain(db: Db, groupId: number | null, zoneName: string, cfZoneId: string): Domain; -declare function updateDomain(db: Db, id: number, groupId: number | null, status: string): Domain; +declare function updateDomain(db: Db, id: number, groupId: number | null, status: string, certMonitoring?: string): Domain; declare function deleteDomain(db: Db, id: number): void; declare function setDomainLastSynced(db: Db, id: number): void; declare function listAllDomains(db: Db): Domain[]; declare function listSubdomainsByDomain(db: Db, domainId: number): Subdomain[]; declare function getSubdomain(db: Db, id: number): Subdomain; +declare function findSubdomainByDomainAndName(db: Db, domainId: number, name: string): Subdomain | null; declare function upsertSubdomain(db: Db, domainId: number, name: string, fqdn: string): void; declare function createSubdomain(db: Db, domainId: number, name: string, fqdn: string): Subdomain; -declare function updateSubdomain(db: Db, id: number, name: string, fqdn: string): Subdomain; +interface UpdateSubdomainPatch { + name?: string; + fqdn?: string; + enabled?: boolean; + cert_monitoring?: string; +} +declare function updateSubdomain(db: Db, id: number, patch: UpdateSubdomainPatch): Subdomain; declare function deleteSubdomain(db: Db, id: number): void; declare function listAllSubdomains(db: Db): Subdomain[]; declare function listDnsRecords(db: Db, domainId: number, filter?: DnsListFilter): DnsRecord[]; @@ -3255,6 +3406,7 @@ declare function createService(db: Db, name: string, slug: string): Service; declare function updateService(db: Db, id: number, name: string, slug: string): Service; declare function setServiceEnabled(db: Db, id: number, enabled: boolean): Service; declare function setServiceGroup(db: Db, id: number, groupId: number | null): void; +declare function reorderServices(db: Db, groupId: number | null, orderedIds: number[]): void; declare function deleteService(db: Db, id: number): void; declare function listServiceGroups(db: Db): ServiceGroup[]; declare function getServiceGroup(db: Db, id: number): ServiceGroup; @@ -3266,6 +3418,7 @@ declare function listServiceIps(db: Db, serviceId: number): string[]; declare function replaceServiceIps(db: Db, serviceId: number, ips: string[]): void; declare function listBindingIps(db: Db, bindingId: number): string[]; declare function replaceBindingIps(db: Db, bindingId: number, ips: string[]): void; +declare function setBindingCnameTarget(db: Db, bindingId: number, target: string | null): void; declare function listRecordsForBinding(db: Db, bindingId: number): DnsRecord[]; declare function linkBindingRecord(db: Db, bindingId: number, dnsRecordId: number): void; declare function unlinkBindingRecord(db: Db, bindingId: number, dnsRecordId: number): void; @@ -3290,11 +3443,13 @@ declare function listCertificates(db: Db, status?: string): Certificate[]; declare function getCertificate(db: Db, id: number): Certificate; declare function upsertCertificateCheck(db: Db, domainId: number, subdomainId: number | null, hostname: string, expiresAt: string | null, status: string, lastError: string | null): Certificate; declare function countCertificatesByStatus(db: Db): Array<[string, number]>; +declare function deleteCertificatesNotIn(db: Db, hostnames: string[]): number; declare function createSyncJob(db: Db, id: string, domainId: number | null): void; declare function getSyncJob(db: Db, id: string): SyncJob; declare function finishSyncJob(db: Db, id: string, status: string, message: string | null): void; type repos_DnsListFilter = DnsListFilter; +type repos_UpdateSubdomainPatch = UpdateSubdomainPatch; declare const repos_bindingsToRemove: typeof bindingsToRemove; declare const repos_countCertificatesByStatus: typeof countCertificatesByStatus; declare const repos_createDomain: typeof createDomain; @@ -3305,6 +3460,7 @@ declare const repos_createSubdomain: typeof createSubdomain; declare const repos_createSyncJob: typeof createSyncJob; declare const repos_deleteBinding: typeof deleteBinding; declare const repos_deleteBindingsExcept: typeof deleteBindingsExcept; +declare const repos_deleteCertificatesNotIn: typeof deleteCertificatesNotIn; declare const repos_deleteDnsRecord: typeof deleteDnsRecord; declare const repos_deleteDomain: typeof deleteDomain; declare const repos_deleteGroup: typeof deleteGroup; @@ -3314,6 +3470,7 @@ declare const repos_deleteSubdomain: typeof deleteSubdomain; declare const repos_findBinding: typeof findBinding; declare const repos_findDnsByCfId: typeof findDnsByCfId; declare const repos_findDomainByZoneName: typeof findDomainByZoneName; +declare const repos_findSubdomainByDomainAndName: typeof findSubdomainByDomainAndName; declare const repos_finishSyncJob: typeof finishSyncJob; declare const repos_getBinding: typeof getBinding; declare const repos_getBindingView: typeof getBindingView; @@ -3351,8 +3508,10 @@ declare const repos_listServicesByGroup: typeof listServicesByGroup; declare const repos_listSubdomainsByDomain: typeof listSubdomainsByDomain; declare const repos_listUngroupedServices: typeof listUngroupedServices; declare const repos_markDnsPendingDelete: typeof markDnsPendingDelete; +declare const repos_reorderServices: typeof reorderServices; declare const repos_replaceBindingIps: typeof replaceBindingIps; declare const repos_replaceServiceIps: typeof replaceServiceIps; +declare const repos_setBindingCnameTarget: typeof setBindingCnameTarget; declare const repos_setBindingDnsRecordId: typeof setBindingDnsRecordId; declare const repos_setDnsSyncStatus: typeof setDnsSyncStatus; declare const repos_setDomainLastSynced: typeof setDomainLastSynced; @@ -3371,7 +3530,7 @@ declare const repos_updateSubdomain: typeof updateSubdomain; declare const repos_upsertCertificateCheck: typeof upsertCertificateCheck; declare const repos_upsertSubdomain: typeof upsertSubdomain; declare namespace repos { - export { type repos_DnsListFilter as DnsListFilter, repos_bindingsToRemove as bindingsToRemove, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createGroup as createGroup, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteGroup as deleteGroup, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDomain, repos_listUngroupedServices as listUngroupedServices, repos_markDnsPendingDelete as markDnsPendingDelete, repos_replaceBindingIps as replaceBindingIps, repos_replaceServiceIps as replaceServiceIps, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingFields as updateBindingFields, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateGroup as updateGroup, repos_updateService as updateService, repos_updateServiceGroup as updateServiceGroup, repos_updateSubdomain as updateSubdomain, repos_upsertCertificateCheck as upsertCertificateCheck, repos_upsertSubdomain as upsertSubdomain }; + export { type repos_DnsListFilter as DnsListFilter, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_bindingsToRemove as bindingsToRemove, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createGroup as createGroup, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteGroup as deleteGroup, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDomain, repos_listUngroupedServices as listUngroupedServices, repos_markDnsPendingDelete as markDnsPendingDelete, repos_reorderServices as reorderServices, repos_replaceBindingIps as replaceBindingIps, repos_replaceServiceIps as replaceServiceIps, repos_setBindingCnameTarget as setBindingCnameTarget, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingFields as updateBindingFields, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateGroup as updateGroup, repos_updateService as updateService, repos_updateServiceGroup as updateServiceGroup, repos_updateSubdomain as updateSubdomain, repos_upsertCertificateCheck as upsertCertificateCheck, repos_upsertSubdomain as upsertSubdomain }; } -export { ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, certificates, createDb, createMemoryDb, dnsRecords, domains, groups, healthCheck, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs }; +export { ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, certificates, createDb, createMemoryDb, dnsRecords, domains, groups, healthCheck, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs }; diff --git a/packages/db/dist/index.js b/packages/db/dist/index.js index aed7f85..ba6e7a8 100644 --- a/packages/db/dist/index.js +++ b/packages/db/dist/index.js @@ -29,6 +29,7 @@ var services = sqliteTable("services", { ), subdomain: text("subdomain"), enabled: integer("enabled", { mode: "boolean" }).notNull().default(false), + sort_order: integer("sort_order").notNull().default(0), created_at: text("created_at").notNull().default(sql`datetime('now')`), updated_at: text("updated_at").notNull().default(sql`datetime('now')`) }); @@ -50,6 +51,7 @@ var domains = sqliteTable("domains", { zone_name: text("zone_name").notNull().unique(), cf_zone_id: text("cf_zone_id").notNull(), status: text("status").notNull().default("active"), + cert_monitoring: text("cert_monitoring").notNull().default("auto"), last_synced_at: text("last_synced_at"), created_at: text("created_at").notNull().default(sql`datetime('now')`), updated_at: text("updated_at").notNull().default(sql`datetime('now')`) @@ -59,6 +61,8 @@ var subdomains = sqliteTable("subdomains", { domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }), name: text("name").notNull(), fqdn: text("fqdn").notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + cert_monitoring: text("cert_monitoring").notNull().default("auto"), created_at: text("created_at").notNull().default(sql`datetime('now')`), updated_at: text("updated_at").notNull().default(sql`datetime('now')`) }); @@ -83,6 +87,7 @@ var serviceBindings = sqliteTable("service_bindings", { domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }), service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }), hostname: text("hostname").notNull().default("@"), + cname_target: text("cname_target"), dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, { onDelete: "set null" }), @@ -234,6 +239,7 @@ __export(repos_exports, { createSyncJob: () => createSyncJob, deleteBinding: () => deleteBinding, deleteBindingsExcept: () => deleteBindingsExcept, + deleteCertificatesNotIn: () => deleteCertificatesNotIn, deleteDnsRecord: () => deleteDnsRecord, deleteDomain: () => deleteDomain, deleteGroup: () => deleteGroup, @@ -243,6 +249,7 @@ __export(repos_exports, { findBinding: () => findBinding, findDnsByCfId: () => findDnsByCfId, findDomainByZoneName: () => findDomainByZoneName, + findSubdomainByDomainAndName: () => findSubdomainByDomainAndName, finishSyncJob: () => finishSyncJob, getBinding: () => getBinding, getBindingView: () => getBindingView, @@ -280,8 +287,10 @@ __export(repos_exports, { listSubdomainsByDomain: () => listSubdomainsByDomain, listUngroupedServices: () => listUngroupedServices, markDnsPendingDelete: () => markDnsPendingDelete, + reorderServices: () => reorderServices, replaceBindingIps: () => replaceBindingIps, replaceServiceIps: () => replaceServiceIps, + setBindingCnameTarget: () => setBindingCnameTarget, setBindingDnsRecordId: () => setBindingDnsRecordId, setDnsSyncStatus: () => setDnsSyncStatus, setDomainLastSynced: () => setDomainLastSynced, @@ -300,7 +309,8 @@ __export(repos_exports, { upsertCertificateCheck: () => upsertCertificateCheck, upsertSubdomain: () => upsertSubdomain }); -import { and, asc, count, eq, isNull, like, or, sql as sql2 } from "drizzle-orm"; +import { dnsRecordNamesMatch } from "@cfdm/shared"; +import { and, asc, count, eq, isNull, like, notInArray, or, sql as sql2 } from "drizzle-orm"; function listGroups(db) { return db.select().from(groups).orderBy(asc(groups.name)).all(); } @@ -367,12 +377,16 @@ function createDomain(db, groupId, zoneName, cfZoneId) { }).returning({ id: domains.id }).get().id; return getDomain(db, id); } -function updateDomain(db, id, groupId, status) { - const result = db.update(domains).set({ +function updateDomain(db, id, groupId, status, certMonitoring) { + const updates = { group_id: groupId, status, updated_at: sql2`datetime('now')` - }).where(eq(domains.id, id)).run(); + }; + if (certMonitoring !== void 0) { + updates.cert_monitoring = certMonitoring; + } + const result = db.update(domains).set(updates).where(eq(domains.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`domain ${id}`); return getDomain(db, id); } @@ -397,6 +411,10 @@ function getSubdomain(db, id) { if (!row) throw new NotFoundError(`subdomain ${id}`); return row; } +function findSubdomainByDomainAndName(db, domainId, name) { + const row = db.select().from(subdomains).where(and(eq(subdomains.domain_id, domainId), eq(subdomains.name, name))).get(); + return row ?? null; +} function upsertSubdomain(db, domainId, name, fqdn) { db.run(sql2` INSERT INTO subdomains (domain_id, name, fqdn) @@ -410,8 +428,15 @@ function createSubdomain(db, domainId, name, fqdn) { const id = db.insert(subdomains).values({ domain_id: domainId, name, fqdn }).returning({ id: subdomains.id }).get().id; return getSubdomain(db, id); } -function updateSubdomain(db, id, name, fqdn) { - const result = db.update(subdomains).set({ name, fqdn, updated_at: sql2`datetime('now')` }).where(eq(subdomains.id, id)).run(); +function updateSubdomain(db, id, patch) { + const updates = { updated_at: sql2`datetime('now')` }; + if (patch.name !== void 0) updates.name = patch.name; + if (patch.fqdn !== void 0) updates.fqdn = patch.fqdn; + if (patch.enabled !== void 0) updates.enabled = patch.enabled; + if (patch.cert_monitoring !== void 0) { + updates.cert_monitoring = patch.cert_monitoring; + } + const result = db.update(subdomains).set(updates).where(eq(subdomains.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`); return getSubdomain(db, id); } @@ -518,14 +543,19 @@ function findDnsByCfId(db, domainId, cfRecordId) { function markDnsPendingDelete(db, id) { setDnsSyncStatus(db, id, "pending_delete", null, null); } +function maxSortOrderInGroup(db, groupId) { + const condition = groupId === null ? isNull(services.service_group_id) : eq(services.service_group_id, groupId); + const row = db.select({ maxOrder: sql2`coalesce(max(${services.sort_order}), -1)` }).from(services).where(condition).get(); + return row?.maxOrder ?? -1; +} function listServices(db) { - return db.select().from(services).orderBy(asc(services.name)).all(); + return db.select().from(services).orderBy(asc(services.sort_order), asc(services.name)).all(); } function listServicesByGroup(db, groupId) { - return db.select().from(services).where(eq(services.service_group_id, groupId)).orderBy(asc(services.name)).all(); + return db.select().from(services).where(eq(services.service_group_id, groupId)).orderBy(asc(services.sort_order), asc(services.name)).all(); } function listUngroupedServices(db) { - return db.select().from(services).where(isNull(services.service_group_id)).orderBy(asc(services.name)).all(); + return db.select().from(services).where(isNull(services.service_group_id)).orderBy(asc(services.sort_order), asc(services.name)).all(); } function getService(db, id) { const row = db.select().from(services).where(eq(services.id, id)).get(); @@ -533,7 +563,8 @@ function getService(db, id) { return row; } function createService(db, name, slug) { - const id = db.insert(services).values({ name, slug, subdomain: slug }).returning({ id: services.id }).get().id; + const sortOrder = maxSortOrderInGroup(db, null) + 1; + const id = db.insert(services).values({ name, slug, subdomain: slug, sort_order: sortOrder }).returning({ id: services.id }).get().id; return getService(db, id); } function updateService(db, id, name, slug) { @@ -550,7 +581,31 @@ function setServiceEnabled(db, id, enabled) { return getService(db, id); } function setServiceGroup(db, id, groupId) { - db.update(services).set({ service_group_id: groupId, updated_at: sql2`datetime('now')` }).where(eq(services.id, id)).run(); + const sortOrder = maxSortOrderInGroup(db, groupId) + 1; + db.update(services).set({ + service_group_id: groupId, + sort_order: sortOrder, + updated_at: sql2`datetime('now')` + }).where(eq(services.id, id)).run(); +} +function reorderServices(db, groupId, orderedIds) { + const uniqueIds = new Set(orderedIds); + if (uniqueIds.size !== orderedIds.length) { + throw new Error("duplicate service ids in reorder request"); + } + const condition = groupId === null ? isNull(services.service_group_id) : eq(services.service_group_id, groupId); + const existing = db.select({ id: services.id }).from(services).where(condition).all().map((row) => row.id); + const existingSet = new Set(existing); + for (const serviceId of orderedIds) { + if (!existingSet.has(serviceId)) { + throw new NotFoundError(`service ${serviceId} not in group`); + } + } + db.transaction((tx) => { + for (let index = 0; index < orderedIds.length; index++) { + tx.update(services).set({ sort_order: index, updated_at: sql2`datetime('now')` }).where(eq(services.id, orderedIds[index])).run(); + } + }); } function deleteService(db, id) { const result = db.delete(services).where(eq(services.id, id)).run(); @@ -618,6 +673,12 @@ function replaceBindingIps(db, bindingId, ips) { db.insert(serviceBindingIps).values({ binding_id: bindingId, ip }).run(); } } +function setBindingCnameTarget(db, bindingId, target) { + db.update(serviceBindings).set({ + cname_target: target, + updated_at: sql2`datetime('now')` + }).where(eq(serviceBindings.id, bindingId)).run(); +} function listRecordsForBinding(db, bindingId) { return db.all(sql2` SELECT dr.* FROM dns_records dr @@ -662,6 +723,40 @@ function unlinkGroupDnsRecord(db, groupId, dnsRecordId) { ) ).run(); } +function dnsRecordMatchesHostname(recordName, hostname, zoneName) { + return dnsRecordNamesMatch(recordName, hostname, zoneName); +} +function enrichServiceBindingView(db, row) { + const configured = listBindingIps(db, row.id); + const linkedRecords = listRecordsForBinding(db, row.id); + const linkedIps = linkedRecords.filter((record) => record.record_type.toUpperCase() === "A").map((record) => record.content); + const target_ips = [ + .../* @__PURE__ */ new Set([ + ...configured, + ...linkedIps, + ...row.target_ip ? [row.target_ip] : [] + ]) + ].sort(); + if (target_ips.length === 0) { + for (const record of listDnsByDomain(db, row.domain_id)) { + if (record.record_type.toUpperCase() !== "A") continue; + if (!dnsRecordMatchesHostname(record.name, row.hostname, row.zone_name)) { + continue; + } + if (!target_ips.includes(record.content)) { + target_ips.push(record.content); + } + } + target_ips.sort(); + } + const sync_status = row.sync_status ?? linkedRecords.find((record) => record.sync_status)?.sync_status ?? null; + return { + ...row, + target_ips, + target_ip: target_ips[0] ?? null, + sync_status + }; +} function listAllBindings(db) { return db.all(sql2` SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, @@ -675,7 +770,7 @@ function listAllBindings(db) { JOIN services s ON s.id = sb.service_id LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id ORDER BY d.zone_name, s.name - `); + `).map((row) => enrichServiceBindingView(db, row)); } function listBindingsByDomain(db, domainId) { return db.all(sql2` @@ -691,7 +786,7 @@ function listBindingsByDomain(db, domainId) { LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id WHERE sb.domain_id = ${domainId} ORDER BY s.name - `); + `).map((row) => enrichServiceBindingView(db, row)); } function listBindingsByService(db, serviceId) { return db.all(sql2` @@ -720,7 +815,7 @@ function getBindingView(db, id) { WHERE sb.id = ${id} `); if (!rows[0]) throw new NotFoundError(`service binding ${id}`); - return rows[0]; + return enrichServiceBindingView(db, rows[0]); } function findBinding(db, serviceId, domainId, hostname) { const row = db.select().from(serviceBindings).where( @@ -814,6 +909,14 @@ function countCertificatesByStatus(db) { }).from(certificates).groupBy(certificates.status).all(); return rows.map((r) => [r.status, r.cnt]); } +function deleteCertificatesNotIn(db, hostnames) { + if (hostnames.length === 0) { + const result2 = db.delete(certificates).run(); + return result2.changes; + } + const result = db.delete(certificates).where(notInArray(certificates.hostname, hostnames)).run(); + return result.changes; +} function createSyncJob(db, id, domainId) { db.insert(syncJobs).values({ id, domain_id: domainId, status: "pending" }).run(); } diff --git a/packages/db/migrations/008_subdomains_enabled.sql b/packages/db/migrations/008_subdomains_enabled.sql new file mode 100644 index 0000000..4a72a0c --- /dev/null +++ b/packages/db/migrations/008_subdomains_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE subdomains ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1; diff --git a/packages/db/migrations/009_service_binding_cname.sql b/packages/db/migrations/009_service_binding_cname.sql new file mode 100644 index 0000000..8dd2db5 --- /dev/null +++ b/packages/db/migrations/009_service_binding_cname.sql @@ -0,0 +1 @@ +ALTER TABLE service_bindings ADD COLUMN cname_target TEXT; diff --git a/packages/db/migrations/010_services_sort_order.sql b/packages/db/migrations/010_services_sort_order.sql new file mode 100644 index 0000000..496c138 --- /dev/null +++ b/packages/db/migrations/010_services_sort_order.sql @@ -0,0 +1,2 @@ +ALTER TABLE services ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0; +UPDATE services SET sort_order = id; diff --git a/packages/db/migrations/011_cert_monitoring.sql b/packages/db/migrations/011_cert_monitoring.sql new file mode 100644 index 0000000..35716d0 --- /dev/null +++ b/packages/db/migrations/011_cert_monitoring.sql @@ -0,0 +1,5 @@ +ALTER TABLE domains ADD COLUMN cert_monitoring TEXT NOT NULL DEFAULT 'auto' + CHECK (cert_monitoring IN ('auto', 'required', 'skipped')); + +ALTER TABLE subdomains ADD COLUMN cert_monitoring TEXT NOT NULL DEFAULT 'auto' + CHECK (cert_monitoring IN ('auto', 'required', 'skipped')); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 798bfd5..acb80ba 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -2,4 +2,4 @@ export * from "./schema.js"; export * from "./client.js"; export * from "./errors.js"; export * as repos from "./repos.js"; -export type { DnsListFilter } from "./repos.js"; +export type { DnsListFilter, UpdateSubdomainPatch } from "./repos.js"; diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index 95b67ab..b1da7b0 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -12,7 +12,8 @@ import type { Subdomain, SyncJob, } from "@cfdm/shared"; -import { and, asc, count, eq, isNull, like, or, sql } from "drizzle-orm"; +import { dnsRecordNamesMatch } from "@cfdm/shared"; +import { and, asc, count, eq, isNull, like, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "./client.js"; import { NotFoundError } from "./errors.js"; import { @@ -158,14 +159,24 @@ export function updateDomain( id: number, groupId: number | null, status: string, + certMonitoring?: string, ): Domain { + const updates: { + group_id: number | null; + status: string; + cert_monitoring?: string; + updated_at: ReturnType; + } = { + group_id: groupId, + status, + updated_at: sql`datetime('now')`, + }; + if (certMonitoring !== undefined) { + updates.cert_monitoring = certMonitoring; + } const result = db .update(domains) - .set({ - group_id: groupId, - status, - updated_at: sql`datetime('now')`, - }) + .set(updates) .where(eq(domains.id, id)) .run(); if (result.changes === 0) throw new NotFoundError(`domain ${id}`); @@ -208,6 +219,19 @@ export function getSubdomain(db: Db, id: number): Subdomain { return row as Subdomain; } +export function findSubdomainByDomainAndName( + db: Db, + domainId: number, + name: string, +): Subdomain | null { + const row = db + .select() + .from(subdomains) + .where(and(eq(subdomains.domain_id, domainId), eq(subdomains.name, name))) + .get(); + return (row as Subdomain) ?? null; +} + export function upsertSubdomain( db: Db, domainId: number, @@ -237,15 +261,36 @@ export function createSubdomain( return getSubdomain(db, id); } +export interface UpdateSubdomainPatch { + name?: string; + fqdn?: string; + enabled?: boolean; + cert_monitoring?: string; +} + export function updateSubdomain( db: Db, id: number, - name: string, - fqdn: string, + patch: UpdateSubdomainPatch, ): Subdomain { + const updates: { + name?: string; + fqdn?: string; + enabled?: boolean; + cert_monitoring?: string; + updated_at: ReturnType; + } = { updated_at: sql`datetime('now')` }; + + if (patch.name !== undefined) updates.name = patch.name; + if (patch.fqdn !== undefined) updates.fqdn = patch.fqdn; + if (patch.enabled !== undefined) updates.enabled = patch.enabled; + if (patch.cert_monitoring !== undefined) { + updates.cert_monitoring = patch.cert_monitoring; + } + const result = db .update(subdomains) - .set({ name, fqdn, updated_at: sql`datetime('now')` }) + .set(updates) .where(eq(subdomains.id, id)) .run(); if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`); @@ -448,8 +493,25 @@ export function markDnsPendingDelete(db: Db, id: number): void { // --- Services --- +function maxSortOrderInGroup(db: Db, groupId: number | null): number { + const condition = + groupId === null + ? isNull(services.service_group_id) + : eq(services.service_group_id, groupId); + const row = db + .select({ maxOrder: sql`coalesce(max(${services.sort_order}), -1)` }) + .from(services) + .where(condition) + .get(); + return row?.maxOrder ?? -1; +} + export function listServices(db: Db): Service[] { - return db.select().from(services).orderBy(asc(services.name)).all() as Service[]; + return db + .select() + .from(services) + .orderBy(asc(services.sort_order), asc(services.name)) + .all() as Service[]; } export function listServicesByGroup(db: Db, groupId: number): Service[] { @@ -457,7 +519,7 @@ export function listServicesByGroup(db: Db, groupId: number): Service[] { .select() .from(services) .where(eq(services.service_group_id, groupId)) - .orderBy(asc(services.name)) + .orderBy(asc(services.sort_order), asc(services.name)) .all() as Service[]; } @@ -466,7 +528,7 @@ export function listUngroupedServices(db: Db): Service[] { .select() .from(services) .where(isNull(services.service_group_id)) - .orderBy(asc(services.name)) + .orderBy(asc(services.sort_order), asc(services.name)) .all() as Service[]; } @@ -477,9 +539,10 @@ export function getService(db: Db, id: number): Service { } export function createService(db: Db, name: string, slug: string): Service { + const sortOrder = maxSortOrderInGroup(db, null) + 1; const id = db .insert(services) - .values({ name, slug, subdomain: slug }) + .values({ name, slug, subdomain: slug, sort_order: sortOrder }) .returning({ id: services.id }) .get()!.id; return getService(db, id); @@ -520,12 +583,56 @@ export function setServiceGroup( id: number, groupId: number | null, ): void { + const sortOrder = maxSortOrderInGroup(db, groupId) + 1; db.update(services) - .set({ service_group_id: groupId, updated_at: sql`datetime('now')` }) + .set({ + service_group_id: groupId, + sort_order: sortOrder, + updated_at: sql`datetime('now')`, + }) .where(eq(services.id, id)) .run(); } +export function reorderServices( + db: Db, + groupId: number | null, + orderedIds: number[], +): void { + const uniqueIds = new Set(orderedIds); + if (uniqueIds.size !== orderedIds.length) { + throw new Error("duplicate service ids in reorder request"); + } + + const condition = + groupId === null + ? isNull(services.service_group_id) + : eq(services.service_group_id, groupId); + + const existing = db + .select({ id: services.id }) + .from(services) + .where(condition) + .all() + .map((row) => row.id); + + const existingSet = new Set(existing); + for (const serviceId of orderedIds) { + if (!existingSet.has(serviceId)) { + throw new NotFoundError(`service ${serviceId} not in group`); + } + } + + db.transaction((tx) => { + for (let index = 0; index < orderedIds.length; index++) { + tx.update(services) + .set({ sort_order: index, updated_at: sql`datetime('now')` }) + .where(eq(services.id, orderedIds[index]!)) + .run(); + } + }); +} + export function deleteService(db: Db, id: number): void { const result = db.delete(services).where(eq(services.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service ${id}`); @@ -668,6 +775,20 @@ export function replaceBindingIps( } } +export function setBindingCnameTarget( + db: Db, + bindingId: number, + target: string | null, +): void { + db.update(serviceBindings) + .set({ + cname_target: target, + updated_at: sql`datetime('now')`, + }) + .where(eq(serviceBindings.id, bindingId)) + .run(); +} + // --- Service Binding Records --- export function listRecordsForBinding(db: Db, bindingId: number): DnsRecord[] { @@ -744,8 +865,61 @@ export function unlinkGroupDnsRecord( // --- Service Bindings --- +function dnsRecordMatchesHostname( + recordName: string, + hostname: string, + zoneName: string, +): boolean { + return dnsRecordNamesMatch(recordName, hostname, zoneName); +} + +function enrichServiceBindingView( + db: Db, + row: Omit & { target_ips?: string[] }, +): ServiceBindingView { + const configured = listBindingIps(db, row.id); + const linkedRecords = listRecordsForBinding(db, row.id); + const linkedIps = linkedRecords + .filter((record) => record.record_type.toUpperCase() === "A") + .map((record) => record.content); + + const target_ips = [ + ...new Set([ + ...configured, + ...linkedIps, + ...(row.target_ip ? [row.target_ip] : []), + ]), + ].sort(); + + if (target_ips.length === 0) { + for (const record of listDnsByDomain(db, row.domain_id)) { + if (record.record_type.toUpperCase() !== "A") continue; + if (!dnsRecordMatchesHostname(record.name, row.hostname, row.zone_name)) { + continue; + } + if (!target_ips.includes(record.content)) { + target_ips.push(record.content); + } + } + target_ips.sort(); + } + + const sync_status = + row.sync_status ?? + linkedRecords.find((record) => record.sync_status)?.sync_status ?? + null; + + return { + ...row, + target_ips, + target_ip: target_ips[0] ?? null, + sync_status, + }; +} + export function listAllBindings(db: Db): ServiceBindingView[] { - return db.all(sql` + return db + .all>(sql` SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, d.zone_name, d.group_id, g.name AS group_name, s.name AS service_name, s.slug AS service_slug, @@ -757,11 +931,13 @@ export function listAllBindings(db: Db): ServiceBindingView[] { JOIN services s ON s.id = sb.service_id LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id ORDER BY d.zone_name, s.name - `); + `) + .map((row) => enrichServiceBindingView(db, row)); } export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingView[] { - return db.all(sql` + return db + .all>(sql` SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, d.zone_name, d.group_id, g.name AS group_name, s.name AS service_name, s.slug AS service_slug, @@ -774,7 +950,8 @@ export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingVi LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id WHERE sb.domain_id = ${domainId} ORDER BY s.name - `); + `) + .map((row) => enrichServiceBindingView(db, row)); } export function listBindingsByService(db: Db, serviceId: number): Array { @@ -796,7 +973,7 @@ export function getBinding(db: Db, id: number): ServiceBinding { } export function getBindingView(db: Db, id: number): ServiceBindingView { - const rows = db.all(sql` + const rows = db.all>(sql` SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, d.zone_name, d.group_id, g.name AS group_name, s.name AS service_name, s.slug AS service_slug, @@ -810,7 +987,7 @@ export function getBindingView(db: Db, id: number): ServiceBindingView { WHERE sb.id = ${id} `); if (!rows[0]) throw new NotFoundError(`service binding ${id}`); - return rows[0]; + return enrichServiceBindingView(db, rows[0]); } export function findBinding( @@ -1005,6 +1182,18 @@ export function countCertificatesByStatus( return rows.map((r) => [r.status, r.cnt]); } +export function deleteCertificatesNotIn(db: Db, hostnames: string[]): number { + if (hostnames.length === 0) { + const result = db.delete(certificates).run(); + return result.changes; + } + const result = db + .delete(certificates) + .where(notInArray(certificates.hostname, hostnames)) + .run(); + return result.changes; +} + // --- Sync Jobs --- export function createSyncJob( diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3e8de1c..28def3b 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -28,6 +28,7 @@ export const services = sqliteTable("services", { ), subdomain: text("subdomain"), enabled: integer("enabled", { mode: "boolean" }).notNull().default(false), + sort_order: integer("sort_order").notNull().default(0), created_at: text("created_at") .notNull() .default(sql`datetime('now')`), @@ -59,6 +60,7 @@ export const domains = sqliteTable("domains", { zone_name: text("zone_name").notNull().unique(), cf_zone_id: text("cf_zone_id").notNull(), status: text("status").notNull().default("active"), + cert_monitoring: text("cert_monitoring").notNull().default("auto"), last_synced_at: text("last_synced_at"), created_at: text("created_at") .notNull() @@ -75,6 +77,8 @@ export const subdomains = sqliteTable("subdomains", { .references(() => domains.id, { onDelete: "cascade" }), name: text("name").notNull(), fqdn: text("fqdn").notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + cert_monitoring: text("cert_monitoring").notNull().default("auto"), created_at: text("created_at") .notNull() .default(sql`datetime('now')`), @@ -115,6 +119,7 @@ export const serviceBindings = sqliteTable("service_bindings", { .notNull() .references(() => services.id, { onDelete: "cascade" }), hostname: text("hostname").notNull().default("@"), + cname_target: text("cname_target"), dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, { onDelete: "set null", }), diff --git a/packages/shared/dist/index.d.ts b/packages/shared/dist/index.d.ts index 1029fc3..0ce051f 100644 --- a/packages/shared/dist/index.d.ts +++ b/packages/shared/dist/index.d.ts @@ -10,16 +10,136 @@ declare const CERT_WARNING = "warning"; declare const CERT_EXPIRED = "expired"; declare const CERT_ERROR = "error"; declare const CERT_UNKNOWN = "unknown"; +declare const CERT_MONITOR_AUTO = "auto"; +declare const CERT_MONITOR_REQUIRED = "required"; +declare const CERT_MONITOR_SKIPPED = "skipped"; +declare const CERT_MONITORING_VALUES: readonly ["auto", "required", "skipped"]; + +interface ServiceGroup$1 { + id: number; + name: string; + type: string; + icon: string | null; + domain: string | null; + enabled: boolean; + created_at: string; + updated_at: string; +} +interface Service$1 { + id: number; + name: string; + slug: string; + service_group_id: number | null; + subdomain: string; + enabled: boolean; + sort_order: number; + created_at: string; + updated_at: string; +} +interface Subdomain { + id: number; + domain_id: number; + name: string; + fqdn: string; + enabled: boolean; + cert_monitoring: string; + created_at: string; + updated_at: string; +} +interface ServiceBinding { + id: number; + domain_id: number; + service_id: number; + hostname: string; + cname_target: string | null; + dns_record_id: number | null; + created_at: string; + updated_at: string; +} +interface ServiceBindingView { + id: number; + domain_id: number; + service_id: number; + hostname: string; + dns_record_id: number | null; + zone_name: string; + group_id: number | null; + group_name: string | null; + service_name: string; + service_slug: string; + target_ip: string | null; + target_ips: string[]; + sync_status: string | null; + created_at: string; + updated_at: string; +} +interface ServiceDomainBindingView { + binding_id: number; + domain_id: number; + zone_name: string; + hostname: string; + fqdn: string; + record_type: "A" | "CNAME"; + target_ips: string[]; + target_cname: string | null; + sync_status: string | null; +} +interface SyncJob { + id: string; + status: string; + domain_id: number | null; + message: string | null; + created_at: string; + finished_at: string | null; +} +interface CfZone { + id: string; + name: string; + status: string; +} +interface CfDnsRecord { + id?: string; + type: string; + name: string; + content: string; + ttl: number; + proxied?: boolean; + priority?: number; +} +interface CreateDnsRecordPayload { + type: string; + name: string; + content: string; + ttl: number; + proxied?: boolean; + priority?: number; +} +interface LoginRequest { + username: string; + password: string; +} +interface LoginResponse { + token: string; + expires_at: string; +} +interface JwtClaims { + sub: string; + exp: number; +} declare class ValidationError extends Error { constructor(message: string); } declare function validateDnsRecord(recordType: string, name: string, content: string, ttl: number, proxied: boolean): void; declare function certStatusFromExpiry(daysLeft: number): string; +declare function shouldMonitorService(service: Pick, group?: Pick | null): boolean; declare function isValidIpv4(ip: string): boolean; declare function dnsNameToSubdomainLabel(recordName: string, zoneName: string): string | null; declare function subdomainLabelToFqdn(label: string, zoneName: string): string; +/** Canonical DNS record name as returned by Cloudflare for a zone. */ +declare function normalizeDnsRecordName(recordName: string, zoneName: string): string; +declare function dnsRecordNamesMatch(left: string, right: string, zoneName: string): boolean; interface ParsedFqdn { zoneName: string; @@ -34,6 +154,12 @@ declare function bindingToFqdn(binding: { fqdn?: string; }): string; +declare const certMonitoringSchema: z.ZodEnum<{ + auto: "auto"; + required: "required"; + skipped: "skipped"; +}>; +type CertMonitoring = z.infer; declare const groupSchema: z.ZodObject<{ id: z.ZodNumber; name: z.ZodString; @@ -89,11 +215,18 @@ declare const serviceDomainBindingSchema: z.ZodPipe>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; + target_cname: z.ZodOptional>; sync_status: z.ZodNullable; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; + target_cname: string | null; + record_type: "A" | "CNAME"; binding_id: number; domain_id: number; zone_name: string; @@ -107,9 +240,11 @@ declare const serviceDomainBindingSchema: z.ZodPipe>; declare const serviceViewSchema: z.ZodObject<{ id: z.ZodNumber; @@ -128,11 +263,18 @@ declare const serviceViewSchema: z.ZodObject<{ zone_name: z.ZodString; hostname: z.ZodString; fqdn: z.ZodString; + record_type: z.ZodDefault>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; + target_cname: z.ZodOptional>; sync_status: z.ZodNullable; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; + target_cname: string | null; + record_type: "A" | "CNAME"; binding_id: number; domain_id: number; zone_name: string; @@ -146,9 +288,11 @@ declare const serviceViewSchema: z.ZodObject<{ zone_name: string; hostname: string; fqdn: string; + record_type: "A" | "CNAME"; sync_status: string | null; target_ips?: string[] | undefined; target_ip?: string | null | undefined; + target_cname?: string | null | undefined; }>>>>; }, z.core.$strip>; declare const serviceGroupViewSchema: z.ZodObject<{ @@ -183,11 +327,18 @@ declare const serviceGroupViewSchema: z.ZodObject<{ zone_name: z.ZodString; hostname: z.ZodString; fqdn: z.ZodString; + record_type: z.ZodDefault>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; + target_cname: z.ZodOptional>; sync_status: z.ZodNullable; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; + target_cname: string | null; + record_type: "A" | "CNAME"; binding_id: number; domain_id: number; zone_name: string; @@ -201,9 +352,11 @@ declare const serviceGroupViewSchema: z.ZodObject<{ zone_name: string; hostname: string; fqdn: string; + record_type: "A" | "CNAME"; sync_status: string | null; target_ips?: string[] | undefined; target_ip?: string | null | undefined; + target_cname?: string | null | undefined; }>>>>; }, z.core.$strip>>>; }, z.core.$strip>; @@ -240,11 +393,18 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ zone_name: z.ZodString; hostname: z.ZodString; fqdn: z.ZodString; + record_type: z.ZodDefault>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; + target_cname: z.ZodOptional>; sync_status: z.ZodNullable; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; + target_cname: string | null; + record_type: "A" | "CNAME"; binding_id: number; domain_id: number; zone_name: string; @@ -258,9 +418,11 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ zone_name: string; hostname: string; fqdn: string; + record_type: "A" | "CNAME"; sync_status: string | null; target_ips?: string[] | undefined; target_ip?: string | null | undefined; + target_cname?: string | null | undefined; }>>>>; }, z.core.$strip>>>; }, z.core.$strip>>>; @@ -281,11 +443,18 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ zone_name: z.ZodString; hostname: z.ZodString; fqdn: z.ZodString; + record_type: z.ZodDefault>; target_ips: z.ZodOptional>; target_ip: z.ZodOptional>; + target_cname: z.ZodOptional>; sync_status: z.ZodNullable; }, z.core.$strip>, z.ZodTransform<{ target_ips: string[]; + target_cname: string | null; + record_type: "A" | "CNAME"; binding_id: number; domain_id: number; zone_name: string; @@ -299,9 +468,11 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{ zone_name: string; hostname: string; fqdn: string; + record_type: "A" | "CNAME"; sync_status: string | null; target_ips?: string[] | undefined; target_ip?: string | null | undefined; + target_cname?: string | null | undefined; }>>>>; }, z.core.$strip>>>; }, z.core.$strip>; @@ -311,6 +482,11 @@ declare const domainSchema: z.ZodObject<{ zone_name: z.ZodString; cf_zone_id: z.ZodString; status: z.ZodString; + cert_monitoring: z.ZodDefault>; last_synced_at: z.ZodNullable; created_at: z.ZodString; updated_at: z.ZodString; @@ -321,13 +497,18 @@ declare const domainListItemSchema: z.ZodObject<{ zone_name: z.ZodString; cf_zone_id: z.ZodString; status: z.ZodString; + cert_monitoring: z.ZodDefault>; last_synced_at: z.ZodNullable; created_at: z.ZodString; updated_at: z.ZodString; group_name: z.ZodNullable; service_count: z.ZodNumber; }, z.core.$strip>; -declare const serviceBindingSchema: z.ZodObject<{ +declare const serviceBindingSchema: z.ZodPipe; + target_ips: z.ZodOptional>; sync_status: z.ZodNullable; created_at: z.ZodString; updated_at: z.ZodString; -}, z.core.$strip>; +}, z.core.$strip>, z.ZodTransform<{ + target_ips: string[]; + id: number; + domain_id: number; + service_id: number; + hostname: string; + dns_record_id: number | null; + zone_name: string; + group_id: number | null; + group_name: string | null; + service_name: string; + service_slug: string; + target_ip: string | null; + sync_status: string | null; + created_at: string; + updated_at: string; +}, { + id: number; + domain_id: number; + service_id: number; + hostname: string; + dns_record_id: number | null; + zone_name: string; + group_id: number | null; + group_name: string | null; + service_name: string; + service_slug: string; + target_ip: string | null; + sync_status: string | null; + created_at: string; + updated_at: string; + target_ips?: string[] | undefined; +}>>; declare const dnsRecordSchema: z.ZodObject<{ id: z.ZodNumber; domain_id: z.ZodNumber; @@ -381,7 +595,6 @@ type ServiceGroupView = z.infer; type ServiceGroupsResponse = z.infer; type Domain = z.infer; type DomainListItem = z.infer; -type ServiceBinding = z.infer; type DnsRecord = z.infer; type Certificate = z.infer; declare const createGroupSchema: z.ZodObject<{ @@ -399,7 +612,8 @@ declare const createServiceWithConfigSchema: z.ZodObject<{ ips: z.ZodDefault>; domains: z.ZodDefault; + target_ips: z.ZodOptional>; + target_cname: z.ZodOptional; }, z.core.$strip>>>; }, z.core.$strip>; declare const createServiceBindingSchema: z.ZodObject<{ @@ -423,8 +637,8 @@ declare const loginSchema: z.ZodObject<{ declare const createDnsRecordSchema: z.ZodObject<{ record_type: z.ZodEnum<{ A: "A"; - AAAA: "AAAA"; CNAME: "CNAME"; + AAAA: "AAAA"; TXT: "TXT"; MX: "MX"; }>; @@ -438,10 +652,40 @@ declare const subdomainSchema: z.ZodObject<{ domain_id: z.ZodNumber; name: z.ZodString; fqdn: z.ZodString; + enabled: z.ZodBoolean; + cert_monitoring: z.ZodDefault>; created_at: z.ZodString; updated_at: z.ZodString; }, z.core.$strip>; type SubdomainRecord = z.infer; +declare const createSubdomainSchema: z.ZodObject<{ + name: z.ZodString; +}, z.core.$strip>; +declare const updateSubdomainSchema: z.ZodObject<{ + name: z.ZodOptional; + enabled: z.ZodOptional; + cert_monitoring: z.ZodOptional>; +}, z.core.$strip>; +declare const updateDomainSchema: z.ZodObject<{ + group_id: z.ZodOptional>; + status: z.ZodOptional; + cert_monitoring: z.ZodOptional>; +}, z.core.$strip>; +type CreateSubdomainInput = z.infer; +type UpdateSubdomainInput = z.infer; +type UpdateDomainInput = z.infer; type CreateGroupInput = z.infer; type CreateServiceInput = z.infer; type CreateServiceWithConfigInput = z.infer; @@ -452,7 +696,8 @@ declare const updateServiceConfigSchema: z.ZodObject<{ ips: z.ZodOptional>; domains: z.ZodOptional; + target_ips: z.ZodOptional>; + target_cname: z.ZodOptional; }, z.core.$strip>>>; }, z.core.$strip>; type UpdateServiceConfigInput = z.infer; @@ -471,87 +716,16 @@ declare const createServiceGroupSchema: z.ZodObject<{ declare const toggleEnabledSchema: z.ZodObject<{ enabled: z.ZodBoolean; }, z.core.$strip>; +declare const reorderServicesSchema: z.ZodObject<{ + group_id: z.ZodDefault>>; + service_ids: z.ZodArray; +}, z.core.$strip>; type CreateServiceGroupInput = z.infer; type ToggleEnabledInput = z.infer; +type ReorderServicesInput = z.infer; type CreateServiceBindingInput = z.infer; type CreateDomainInput = z.infer; type LoginInput = z.infer; type CreateDnsRecordInput = z.infer; -interface Subdomain { - id: number; - domain_id: number; - name: string; - fqdn: string; - created_at: string; - updated_at: string; -} -interface ServiceBindingView { - id: number; - domain_id: number; - service_id: number; - hostname: string; - dns_record_id: number | null; - zone_name: string; - group_id: number | null; - group_name: string | null; - service_name: string; - service_slug: string; - target_ip: string | null; - sync_status: string | null; - created_at: string; - updated_at: string; -} -interface ServiceDomainBindingView { - binding_id: number; - domain_id: number; - zone_name: string; - hostname: string; - fqdn: string; - target_ips: string[]; - sync_status: string | null; -} -interface SyncJob { - id: string; - status: string; - domain_id: number | null; - message: string | null; - created_at: string; - finished_at: string | null; -} -interface CfZone { - id: string; - name: string; - status: string; -} -interface CfDnsRecord { - id?: string; - type: string; - name: string; - content: string; - ttl: number; - proxied?: boolean; - priority?: number; -} -interface CreateDnsRecordPayload { - type: string; - name: string; - content: string; - ttl: number; - proxied?: boolean; - priority?: number; -} -interface LoginRequest { - username: string; - password: string; -} -interface LoginResponse { - token: string; - expires_at: string; -} -interface JwtClaims { - sub: string; - exp: number; -} - -export { CERT_ERROR, CERT_EXPIRED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type Certificate, type CfDnsRecord, type CfZone, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type DnsRecord, type Domain, type DomainListItem, type Group, type GroupWithStats, type JwtClaims, type LoginInput, type LoginRequest, type LoginResponse, type ParsedFqdn, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateServiceConfigInput, ValidationError, bindingToFqdn, certStatusFromExpiry, certificateSchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, dnsNameToSubdomainLabel, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, isValidIpv4, loginSchema, parseFqdn, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateServiceConfigSchema, validateDnsRecord }; +export { CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainListItem, type Group, type GroupWithStats, type JwtClaims, type LoginInput, type LoginRequest, type LoginResponse, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateSubdomainInput, ValidationError, bindingToFqdn, certMonitoringSchema, certStatusFromExpiry, certificateSchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, isValidIpv4, loginSchema, normalizeDnsRecordName, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateSubdomainSchema, validateDnsRecord }; diff --git a/packages/shared/dist/index.js b/packages/shared/dist/index.js index 5a3fc8c..7198811 100644 --- a/packages/shared/dist/index.js +++ b/packages/shared/dist/index.js @@ -9,6 +9,14 @@ var CERT_WARNING = "warning"; var CERT_EXPIRED = "expired"; var CERT_ERROR = "error"; var CERT_UNKNOWN = "unknown"; +var CERT_MONITOR_AUTO = "auto"; +var CERT_MONITOR_REQUIRED = "required"; +var CERT_MONITOR_SKIPPED = "skipped"; +var CERT_MONITORING_VALUES = [ + CERT_MONITOR_AUTO, + CERT_MONITOR_REQUIRED, + CERT_MONITOR_SKIPPED +]; // src/validators.ts var NAME_RE = /^(@|\*|[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?(\.[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?)*)$/; @@ -64,6 +72,11 @@ function certStatusFromExpiry(daysLeft) { if (daysLeft <= 30) return CERT_WARNING; return CERT_OK; } +function shouldMonitorService(service, group) { + if (!service.enabled) return false; + if (!service.service_group_id) return true; + return group?.enabled ?? false; +} function isValidIpv4(ip) { const parts = ip.split("."); if (parts.length !== 4) return false; @@ -93,6 +106,16 @@ function dnsNameToSubdomainLabel(recordName, zoneName) { function subdomainLabelToFqdn(label, zoneName) { return label === "@" ? zoneName : `${label}.${zoneName}`; } +function normalizeDnsRecordName(recordName, zoneName) { + const label = dnsNameToSubdomainLabel(recordName, zoneName); + if (label == null) { + return recordName.trim().replace(/\.+$/, ""); + } + return subdomainLabelToFqdn(label, zoneName); +} +function dnsRecordNamesMatch(left, right, zoneName) { + return normalizeDnsRecordName(left, zoneName).toLowerCase() === normalizeDnsRecordName(right, zoneName).toLowerCase(); +} // src/parse-fqdn.ts function fqdnToDisplay(hostname, zoneName) { @@ -136,6 +159,7 @@ function bindingToFqdn(binding) { // src/schemas.ts import { z } from "zod"; +var certMonitoringSchema = z.enum(["auto", "required", "skipped"]); var groupSchema = z.object({ id: z.number(), name: z.string(), @@ -180,12 +204,16 @@ var serviceDomainBindingSchema = z.object({ zone_name: z.string(), hostname: z.string(), fqdn: z.string(), + record_type: z.enum(["A", "CNAME"]).default("A"), target_ips: z.array(z.string()).optional(), target_ip: z.string().nullable().optional(), + target_cname: z.string().nullable().optional(), sync_status: z.string().nullable() }).transform((binding) => ({ ...binding, - target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [] + target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [], + target_cname: binding.target_cname?.trim() || null, + record_type: binding.target_cname?.trim() ? "CNAME" : binding.record_type ?? "A" })); var serviceViewSchema = serviceSchema.extend({ subdomain: z.string().default(""), @@ -206,6 +234,7 @@ var domainSchema = z.object({ zone_name: z.string(), cf_zone_id: z.string(), status: z.string(), + cert_monitoring: certMonitoringSchema.default("auto"), last_synced_at: z.string().nullable(), created_at: z.string(), updated_at: z.string() @@ -226,10 +255,14 @@ var serviceBindingSchema = z.object({ service_name: z.string(), service_slug: z.string(), target_ip: z.string().nullable(), + target_ips: z.array(z.string()).optional(), sync_status: z.string().nullable(), created_at: z.string(), updated_at: z.string() -}); +}).transform((binding) => ({ + ...binding, + target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [] +})); var dnsRecordSchema = z.object({ id: z.number(), domain_id: z.number(), @@ -268,7 +301,25 @@ var ipv4Schema = z.string().regex( ); var serviceDomainInputSchema = z.object({ fqdn: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 FQDN"), - target_ips: z.array(ipv4Schema).min(1, "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0445\u043E\u0442\u044F \u0431\u044B \u043E\u0434\u0438\u043D IP") + target_ips: z.array(ipv4Schema).optional(), + target_cname: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 CNAME-\u0446\u0435\u043B\u044C").optional() +}).superRefine((data, ctx) => { + const hasIps = (data.target_ips?.length ?? 0) > 0; + const hasCname = Boolean(data.target_cname?.trim()); + if (!hasIps && !hasCname) { + ctx.addIssue({ + code: "custom", + message: "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 CNAME-\u0446\u0435\u043B\u044C", + path: ["target_ips"] + }); + } + if (hasIps && hasCname) { + ctx.addIssue({ + code: "custom", + message: "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043B\u0438\u0431\u043E IP, \u043B\u0438\u0431\u043E CNAME-\u0446\u0435\u043B\u044C", + path: ["target_cname"] + }); + } }); var createServiceSchema = z.object({ name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435"), @@ -309,9 +360,24 @@ var subdomainSchema = z.object({ domain_id: z.number(), name: z.string(), fqdn: z.string(), + enabled: z.boolean(), + cert_monitoring: certMonitoringSchema.default("auto"), created_at: z.string(), updated_at: z.string() }); +var createSubdomainSchema = z.object({ + name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u0438\u043C\u044F \u043F\u043E\u0434\u0434\u043E\u043C\u0435\u043D\u0430") +}); +var updateSubdomainSchema = z.object({ + name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u0438\u043C\u044F \u043F\u043E\u0434\u0434\u043E\u043C\u0435\u043D\u0430").optional(), + enabled: z.boolean().optional(), + cert_monitoring: certMonitoringSchema.optional() +}); +var updateDomainSchema = z.object({ + group_id: z.number().nullable().optional(), + status: z.string().optional(), + cert_monitoring: certMonitoringSchema.optional() +}); var updateServiceConfigSchema = z.object({ name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435").optional(), slug: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 slug").optional(), @@ -328,9 +394,17 @@ var createServiceGroupSchema = z.object({ var toggleEnabledSchema = z.object({ enabled: z.boolean() }); +var reorderServicesSchema = z.object({ + group_id: z.union([z.number(), z.null()]).optional().default(null), + service_ids: z.array(z.number().int().positive()).min(1) +}); export { CERT_ERROR, CERT_EXPIRED, + CERT_MONITORING_VALUES, + CERT_MONITOR_AUTO, + CERT_MONITOR_REQUIRED, + CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, @@ -341,6 +415,7 @@ export { SYNC_SYNCED, ValidationError, bindingToFqdn, + certMonitoringSchema, certStatusFromExpiry, certificateSchema, createDnsRecordSchema, @@ -350,7 +425,9 @@ export { createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, + createSubdomainSchema, dnsNameToSubdomainLabel, + dnsRecordNamesMatch, dnsRecordSchema, domainListItemSchema, domainSchema, @@ -359,7 +436,9 @@ export { groupWithStatsSchema, isValidIpv4, loginSchema, + normalizeDnsRecordName, parseFqdn, + reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, @@ -368,10 +447,13 @@ export { serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, + shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, + updateDomainSchema, updateServiceConfigSchema, + updateSubdomainSchema, validateDnsRecord }; diff --git a/packages/shared/package.json b/packages/shared/package.json index a4ed8f5..d1f1124 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -6,6 +6,7 @@ "exports": { ".": { "types": "./dist/index.d.ts", + "development": "./src/index.ts", "import": "./dist/index.js" } }, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 7fd91c8..696f7a9 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -9,3 +9,13 @@ export const CERT_WARNING = "warning"; export const CERT_EXPIRED = "expired"; export const CERT_ERROR = "error"; export const CERT_UNKNOWN = "unknown"; + +export const CERT_MONITOR_AUTO = "auto"; +export const CERT_MONITOR_REQUIRED = "required"; +export const CERT_MONITOR_SKIPPED = "skipped"; + +export const CERT_MONITORING_VALUES = [ + CERT_MONITOR_AUTO, + CERT_MONITOR_REQUIRED, + CERT_MONITOR_SKIPPED, +] as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e9bf64f..2182b1b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -11,6 +11,7 @@ export type { LoginResponse, JwtClaims, SyncJob, + ServiceBinding, ServiceBindingView, ServiceDomainBindingView, Subdomain, diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 47e9520..a728ada 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -1,5 +1,9 @@ import { z } from 'zod' +export const certMonitoringSchema = z.enum(['auto', 'required', 'skipped']) + +export type CertMonitoring = z.infer + export const groupSchema = z.object({ id: z.number(), name: z.string(), @@ -50,8 +54,10 @@ export const serviceDomainBindingSchema = z zone_name: z.string(), hostname: z.string(), fqdn: z.string(), + record_type: z.enum(['A', 'CNAME']).default('A'), target_ips: z.array(z.string()).optional(), target_ip: z.string().nullable().optional(), + target_cname: z.string().nullable().optional(), sync_status: z.string().nullable(), }) .transform((binding) => ({ @@ -62,6 +68,10 @@ export const serviceDomainBindingSchema = z : binding.target_ip ? [binding.target_ip] : [], + target_cname: binding.target_cname?.trim() || null, + record_type: binding.target_cname?.trim() + ? 'CNAME' + : binding.record_type ?? 'A', })) export const serviceViewSchema = serviceSchema.extend({ @@ -86,6 +96,7 @@ export const domainSchema = z.object({ zone_name: z.string(), cf_zone_id: z.string(), status: z.string(), + cert_monitoring: certMonitoringSchema.default('auto'), last_synced_at: z.string().nullable(), created_at: z.string(), updated_at: z.string(), @@ -96,22 +107,33 @@ export const domainListItemSchema = domainSchema.extend({ service_count: z.number(), }) -export const serviceBindingSchema = z.object({ - id: z.number(), - domain_id: z.number(), - service_id: z.number(), - hostname: z.string(), - dns_record_id: z.number().nullable(), - zone_name: z.string(), - group_id: z.number().nullable(), - group_name: z.string().nullable(), - service_name: z.string(), - service_slug: z.string(), - target_ip: z.string().nullable(), - sync_status: z.string().nullable(), - created_at: z.string(), - updated_at: z.string(), -}) +export const serviceBindingSchema = z + .object({ + id: z.number(), + domain_id: z.number(), + service_id: z.number(), + hostname: z.string(), + dns_record_id: z.number().nullable(), + zone_name: z.string(), + group_id: z.number().nullable(), + group_name: z.string().nullable(), + service_name: z.string(), + service_slug: z.string(), + target_ip: z.string().nullable(), + target_ips: z.array(z.string()).optional(), + sync_status: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), + }) + .transform((binding) => ({ + ...binding, + target_ips: + binding.target_ips && binding.target_ips.length > 0 + ? binding.target_ips + : binding.target_ip + ? [binding.target_ip] + : [], + })) export const dnsRecordSchema = z.object({ id: z.number(), @@ -153,7 +175,6 @@ export type ServiceGroupView = z.infer export type ServiceGroupsResponse = z.infer export type Domain = z.infer export type DomainListItem = z.infer -export type ServiceBinding = z.infer export type DnsRecord = z.infer export type Certificate = z.infer @@ -169,10 +190,30 @@ const ipv4Schema = z 'Некорректный IPv4', ) -const serviceDomainInputSchema = z.object({ - fqdn: z.string().min(1, 'Укажите FQDN'), - target_ips: z.array(ipv4Schema).min(1, 'Выберите хотя бы один IP'), -}) +const serviceDomainInputSchema = z + .object({ + fqdn: z.string().min(1, 'Укажите FQDN'), + target_ips: z.array(ipv4Schema).optional(), + target_cname: z.string().min(1, 'Укажите CNAME-цель').optional(), + }) + .superRefine((data, ctx) => { + const hasIps = (data.target_ips?.length ?? 0) > 0 + const hasCname = Boolean(data.target_cname?.trim()) + if (!hasIps && !hasCname) { + ctx.addIssue({ + code: 'custom', + message: 'Укажите IP или CNAME-цель', + path: ['target_ips'], + }) + } + if (hasIps && hasCname) { + ctx.addIssue({ + code: 'custom', + message: 'Укажите либо IP, либо CNAME-цель', + path: ['target_cname'], + }) + } + }) export const createServiceSchema = z.object({ name: z.string().min(1, 'Укажите название'), @@ -220,12 +261,34 @@ export const subdomainSchema = z.object({ domain_id: z.number(), name: z.string(), fqdn: z.string(), + enabled: z.boolean(), + cert_monitoring: certMonitoringSchema.default('auto'), created_at: z.string(), updated_at: z.string(), }) export type SubdomainRecord = z.infer +export const createSubdomainSchema = z.object({ + name: z.string().min(1, 'Укажите имя поддомена'), +}) + +export const updateSubdomainSchema = z.object({ + name: z.string().min(1, 'Укажите имя поддомена').optional(), + enabled: z.boolean().optional(), + cert_monitoring: certMonitoringSchema.optional(), +}) + +export const updateDomainSchema = z.object({ + group_id: z.number().nullable().optional(), + status: z.string().optional(), + cert_monitoring: certMonitoringSchema.optional(), +}) + +export type CreateSubdomainInput = z.infer +export type UpdateSubdomainInput = z.infer +export type UpdateDomainInput = z.infer + export type CreateGroupInput = z.infer export type CreateServiceInput = z.infer export type CreateServiceWithConfigInput = z.infer @@ -253,8 +316,14 @@ export const toggleEnabledSchema = z.object({ enabled: z.boolean(), }) +export const reorderServicesSchema = z.object({ + group_id: z.union([z.number(), z.null()]).optional().default(null), + service_ids: z.array(z.number().int().positive()).min(1), +}) + export type CreateServiceGroupInput = z.infer export type ToggleEnabledInput = z.infer +export type ReorderServicesInput = z.infer export type CreateServiceBindingInput = z.infer export type CreateDomainInput = z.infer export type LoginInput = z.infer diff --git a/packages/shared/src/subdomain.ts b/packages/shared/src/subdomain.ts index a4d5ea0..e1bfab1 100644 --- a/packages/shared/src/subdomain.ts +++ b/packages/shared/src/subdomain.ts @@ -27,3 +27,26 @@ export function dnsNameToSubdomainLabel( export function subdomainLabelToFqdn(label: string, zoneName: string): string { return label === "@" ? zoneName : `${label}.${zoneName}`; } + +/** Canonical DNS record name as returned by Cloudflare for a zone. */ +export function normalizeDnsRecordName( + recordName: string, + zoneName: string, +): string { + const label = dnsNameToSubdomainLabel(recordName, zoneName); + if (label == null) { + return recordName.trim().replace(/\.+$/, ""); + } + return subdomainLabelToFqdn(label, zoneName); +} + +export function dnsRecordNamesMatch( + left: string, + right: string, + zoneName: string, +): boolean { + return ( + normalizeDnsRecordName(left, zoneName).toLowerCase() === + normalizeDnsRecordName(right, zoneName).toLowerCase() + ); +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 05666e4..95f9021 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -33,6 +33,7 @@ export interface Service { service_group_id: number | null; subdomain: string; enabled: boolean; + sort_order: number; created_at: string; updated_at: string; } @@ -43,6 +44,7 @@ export interface Domain { zone_name: string; cf_zone_id: string; status: string; + cert_monitoring: string; last_synced_at: string | null; created_at: string; updated_at: string; @@ -53,6 +55,8 @@ export interface Subdomain { domain_id: number; name: string; fqdn: string; + enabled: boolean; + cert_monitoring: string; created_at: string; updated_at: string; } @@ -92,6 +96,7 @@ export interface ServiceBinding { domain_id: number; service_id: number; hostname: string; + cname_target: string | null; dns_record_id: number | null; created_at: string; updated_at: string; @@ -109,6 +114,7 @@ export interface ServiceBindingView { service_name: string; service_slug: string; target_ip: string | null; + target_ips: string[]; sync_status: string | null; created_at: string; updated_at: string; @@ -120,7 +126,9 @@ export interface ServiceDomainBindingView { zone_name: string; hostname: string; fqdn: string; + record_type: "A" | "CNAME"; target_ips: string[]; + target_cname: string | null; sync_status: string | null; } diff --git a/packages/shared/src/validators.ts b/packages/shared/src/validators.ts index 6b8fda8..40f177f 100644 --- a/packages/shared/src/validators.ts +++ b/packages/shared/src/validators.ts @@ -3,6 +3,7 @@ import { CERT_OK, CERT_WARNING, } from "./constants.js"; +import type { Service, ServiceGroup } from "./types.js"; const NAME_RE = /^(@|\*|[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?(\.[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?)*)$/; @@ -70,6 +71,15 @@ export function certStatusFromExpiry(daysLeft: number): string { return CERT_OK; } +export function shouldMonitorService( + service: Pick, + group?: Pick | null, +): boolean { + if (!service.enabled) return false; + if (!service.service_group_id) return true; + return group?.enabled ?? false; +} + export function isValidIpv4(ip: string): boolean { const parts = ip.split("."); if (parts.length !== 4) return false; diff --git a/packages/shared/test/subdomain.test.ts b/packages/shared/test/subdomain.test.ts new file mode 100644 index 0000000..aed56a0 --- /dev/null +++ b/packages/shared/test/subdomain.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + dnsRecordNamesMatch, + dnsNameToSubdomainLabel, + normalizeDnsRecordName, +} from "../src/subdomain.js"; + +const ZONE = "rkns.top"; + +describe("normalizeDnsRecordName", () => { + it("normalizes short labels to FQDN", () => { + expect(normalizeDnsRecordName("de", ZONE)).toBe("de.rkns.top"); + expect(normalizeDnsRecordName("mhome", ZONE)).toBe("mhome.rkns.top"); + }); + + it("keeps FQDN unchanged", () => { + expect(normalizeDnsRecordName("ihome.rkns.top", ZONE)).toBe( + "ihome.rkns.top", + ); + }); + + it("maps apex forms to zone name", () => { + expect(normalizeDnsRecordName("@", ZONE)).toBe("rkns.top"); + expect(normalizeDnsRecordName("rkns.top", ZONE)).toBe("rkns.top"); + }); +}); + +describe("dnsRecordNamesMatch", () => { + it("matches short label and FQDN for the same host", () => { + expect(dnsRecordNamesMatch("de", "de.rkns.top", ZONE)).toBe(true); + expect(dnsRecordNamesMatch("mhome", "mhome.rkns.top", ZONE)).toBe(true); + }); + + it("does not match different hosts", () => { + expect(dnsRecordNamesMatch("de", "mhome.rkns.top", ZONE)).toBe(false); + }); +}); + +describe("dnsNameToSubdomainLabel", () => { + it("extracts label from FQDN", () => { + expect(dnsNameToSubdomainLabel("de.rkns.top", ZONE)).toBe("de"); + }); +}); diff --git a/packages/ui/src/components/accordion.tsx b/packages/ui/src/components/accordion.tsx new file mode 100644 index 0000000..976f4fc --- /dev/null +++ b/packages/ui/src/components/accordion.tsx @@ -0,0 +1,72 @@ +import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion" + +import { cn } from "@cfdm/ui/lib/utils" +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react" + +function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { + return ( + + ) +} + +function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: AccordionPrimitive.Trigger.Props) { + return ( + + + {children} + + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: AccordionPrimitive.Panel.Props) { + return ( + +
+ {children} +
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx index be75158..34343b6 100644 --- a/packages/ui/src/components/badge.tsx +++ b/packages/ui/src/components/badge.tsx @@ -14,6 +14,8 @@ const badgeVariants = cva( "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", destructive: "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + success: + "bg-success/10 text-success focus-visible:ring-success/20 dark:bg-success/15 dark:focus-visible:ring-success/30 [a]:hover:bg-success/15", outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", ghost: diff --git a/packages/ui/src/components/checkbox.tsx b/packages/ui/src/components/checkbox.tsx new file mode 100644 index 0000000..404f2e6 --- /dev/null +++ b/packages/ui/src/components/checkbox.tsx @@ -0,0 +1,27 @@ +import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox" + +import { cn } from "@cfdm/ui/lib/utils" +import { CheckIcon } from "lucide-react" + +function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index bdf10ac..3fb33aa 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -22,6 +22,7 @@ --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); + --color-success: var(--success); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); @@ -64,6 +65,7 @@ --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); + --success: oklch(0.527 0.154 150.069); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); @@ -98,6 +100,7 @@ --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); + --success: oklch(0.765 0.177 163.223); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); @@ -133,6 +136,7 @@ --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); + --success: oklch(0.765 0.177 163.223); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59ac0f2..dd9cf42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: '@base-ui/react': specifier: ^1.5.0 version: 1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@cfdm/shared': + specifier: workspace:* + version: link:../../packages/shared '@cfdm/ui': specifier: workspace:* version: link:../../packages/ui