From 96783386e018778c1e5a3d1fbba0011e051d6369 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 30 Jun 2026 16:45:17 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=20VPS=20Tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Исходящий sync bindings после updateConfig, настройки в app_settings, страница Интеграции, приём событий vps_down для DNS failover. Co-authored-by: Cursor --- .env.example | 3 +- apps/api/dist/server.js | 526 ++++++++++++------ apps/api/src/app.ts | 4 + apps/api/src/routes/health.ts | 5 + .../src/routes/integrations-vps-tracker.ts | 44 ++ apps/api/src/routes/settings.ts | 22 + .../src/services/service-config-service.ts | 5 + apps/api/src/services/vps-tracker-events.ts | 52 ++ apps/api/src/services/vps-tracker-sync.ts | 126 +++++ apps/web/src/components/app-sidebar.tsx | 2 + apps/web/src/components/app-switcher.tsx | 6 +- .../integrations/app-switcher-editor.tsx | 110 ++++ .../vps-tracker-integration-card.tsx | 126 +++++ apps/web/src/components/select-field.tsx | 67 +++ apps/web/src/hooks/use-app-switcher.ts | 22 + apps/web/src/lib/app-switcher-config.ts | 7 + apps/web/src/queries/app-switcher.ts | 15 + apps/web/src/routeTree.gen.ts | 71 +++ apps/web/src/routes/_auth/settings/index.tsx | 7 + .../routes/_auth/settings/integrations.tsx | 74 +++ apps/web/src/routes/_auth/settings/route.tsx | 40 ++ packages/db/dist/index.d.ts | 342 +++++++++++- packages/db/dist/index.js | 253 ++++++--- packages/db/migrations/013_app_settings.sql | 13 + packages/db/src/index.ts | 1 + packages/db/src/schema.ts | 20 + packages/db/src/settings-repo.ts | 155 ++++++ packages/shared/dist/index.d.ts | 105 +++- packages/shared/dist/index.js | 65 ++- packages/shared/src/app-switcher.ts | 26 + packages/shared/src/index.ts | 2 + .../shared/src/integration-vps-tracker.ts | 43 ++ 32 files changed, 2116 insertions(+), 243 deletions(-) create mode 100644 apps/api/src/routes/integrations-vps-tracker.ts create mode 100644 apps/api/src/routes/settings.ts create mode 100644 apps/api/src/services/vps-tracker-events.ts create mode 100644 apps/api/src/services/vps-tracker-sync.ts create mode 100644 apps/web/src/components/integrations/app-switcher-editor.tsx create mode 100644 apps/web/src/components/integrations/vps-tracker-integration-card.tsx create mode 100644 apps/web/src/components/select-field.tsx create mode 100644 apps/web/src/hooks/use-app-switcher.ts create mode 100644 apps/web/src/queries/app-switcher.ts create mode 100644 apps/web/src/routes/_auth/settings/index.tsx create mode 100644 apps/web/src/routes/_auth/settings/integrations.tsx create mode 100644 apps/web/src/routes/_auth/settings/route.tsx create mode 100644 packages/db/migrations/013_app_settings.sql create mode 100644 packages/db/src/settings-repo.ts create mode 100644 packages/shared/src/app-switcher.ts create mode 100644 packages/shared/src/integration-vps-tracker.ts diff --git a/.env.example b/.env.example index 26c95de..11708e3 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,7 @@ ADMIN_USERNAME=admin ADMIN_PASSWORD_HASH= # Frontend (Vite) -# Список приложений для sidebar switcher (JSON, опционально) -# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[{"id":"vps-tracker","name":"VPS Tracker","subtitle":"Учёт VPS","url":"http://192.168.100.67:3001","icon":"server"},{"id":"cfdm","name":"CF Domain Manager","subtitle":"Домены","url":"http://192.168.100.67:6363","icon":"cloud"},{"id":"grafana","name":"Grafana","url":"https://grafana.example.com","icon":"chart"}]} +# Публичные URL приложений и integration token настраиваются в UI: Настройки → Интеграции # Server SERVER_PORT=8080 diff --git a/apps/api/dist/server.js b/apps/api/dist/server.js index 7a34ac8..8e5f061 100644 --- a/apps/api/dist/server.js +++ b/apps/api/dist/server.js @@ -302,6 +302,7 @@ var error_handler_default = fp5(errorHandlerPlugin, { name: "error-handler" }); // src/routes/health.ts import { z } from "zod"; +import { getAppSwitcher } from "@cfdm/db/settings-repo"; // src/services/auth.ts import { verify } from "@node-rs/argon2"; @@ -356,6 +357,9 @@ async function healthRoutes(app2) { }); } async function authRoutes(app2) { + app2.get("/settings/app-switcher", async (request) => { + return getAppSwitcher(request.server.db); + }); const loginSchema = z.object({ username: z.string(), password: z.string() @@ -429,10 +433,10 @@ async function groupRoutes(app2) { // src/routes/services.ts import { z as z3 } from "zod"; import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared"; -import { repos as repos7 } from "@cfdm/db"; +import { repos as repos8 } from "@cfdm/db"; // src/services/service-config-service.ts -import { repos as repos6 } from "@cfdm/db"; +import { repos as repos7 } from "@cfdm/db"; import { SYNC_ERROR as SYNC_ERROR2, SYNC_PENDING_PUSH as SYNC_PENDING_PUSH3, @@ -969,8 +973,108 @@ async function importZoneRecords(db, cf, domainId) { return pullSync(db, cf, domain); } -// src/services/service-config-service.ts +// src/services/vps-tracker-sync.ts +import * as repos6 from "@cfdm/db/repos"; +import { + getAppSettingsSecrets, + touchVpsTrackerSync +} from "@cfdm/db/settings-repo"; function fqdnToDisplay(hostname, zoneName) { + if (hostname === "@" || !hostname.trim()) return zoneName; + return `${hostname}.${zoneName}`; +} +function buildServiceSyncBindings(db, serviceId, deletedBindingIds = []) { + const service = repos6.getService(db, serviceId); + const serviceIps = repos6.listServiceIps(db, serviceId); + const bindings = repos6.listBindingsByService(db, serviceId); + const items = bindings.map((binding) => { + const targetIps = repos6.listBindingIps(db, binding.id); + const ips = targetIps.length > 0 ? targetIps : serviceIps.length > 0 ? serviceIps : []; + return { + bindingId: binding.id, + serviceId: service.id, + serviceName: service.name, + serviceSlug: service.slug, + fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), + zoneName: binding.zone_name, + hostname: binding.hostname, + ips + }; + }); + for (const bindingId of deletedBindingIds) { + items.push({ + bindingId, + serviceId: service.id, + serviceName: service.name, + serviceSlug: service.slug, + fqdn: "", + zoneName: "", + hostname: "", + ips: [], + deleted: true + }); + } + return items; +} +async function syncServiceToVpsTracker(db, serviceId, deletedBindingIds = []) { + const config2 = getAppSettingsSecrets(db); + if (!config2.vpsTrackerSyncEnabled) return; + const baseUrl = config2.vpsTrackerUrl.replace(/\/$/, ""); + const token = config2.vpsTrackerIntegrationToken; + if (!baseUrl || !token) return; + const bindings = buildServiceSyncBindings(db, serviceId, deletedBindingIds); + if (bindings.length === 0) return; + try { + const res = await fetch(`${baseUrl}/api/integrations/cfdm/sync-bindings`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ bindings }) + }); + if (res.ok) { + touchVpsTrackerSync(db); + } else { + console.warn( + `VPS Tracker sync failed (${res.status}): ${await res.text()}` + ); + } + } catch (err) { + console.warn( + "VPS Tracker sync error:", + err instanceof Error ? err.message : err + ); + } +} +async function pingVpsTracker(db) { + const config2 = getAppSettingsSecrets(db); + const baseUrl = config2.vpsTrackerUrl.replace(/\/$/, ""); + const token = config2.vpsTrackerIntegrationToken; + if (!baseUrl) return { ok: false, error: "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 URL VPS Tracker" }; + if (!token) return { ok: false, error: "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 integration token" }; + try { + const res = await fetch(`${baseUrl}/api/integrations/cfdm/ping`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` } + }); + if (!res.ok) { + return { + ok: false, + error: `HTTP ${res.status}: ${await res.text()}` + }; + } + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "\u041E\u0448\u0438\u0431\u043A\u0430 \u0441\u0435\u0442\u0438" + }; + } +} + +// src/services/service-config-service.ts +function fqdnToDisplay2(hostname, zoneName) { return hostname === "@" ? zoneName : `${hostname}.${zoneName}`; } function parseFqdn(fqdn, knownZones) { @@ -1033,10 +1137,10 @@ function selectActiveIpsByMode(config2, rows) { return pool.map((r) => r.ip); } function getBindingLbState(db, bindingId) { - const binding = repos6.getBinding(db, bindingId); - const ipMetas = repos6.listBindingIpsWithMeta(db, bindingId); + const binding = repos7.getBinding(db, bindingId); + const ipMetas = repos7.listBindingIpsWithMeta(db, bindingId); const rows = ipMetas.map((entry) => { - const status = repos6.getIpHealthStatusRow(db, "binding", bindingId, entry.ip); + const status = repos7.getIpHealthStatusRow(db, "binding", bindingId, entry.ip); return { ip: entry.ip, weight: entry.weight, @@ -1053,16 +1157,16 @@ function getBindingLbState(db, bindingId) { }; } function getGroupLbState(db, groupId) { - const group = repos6.getServiceGroup(db, groupId); - const services = repos6.listServicesByGroup(db, groupId); + const group = repos7.getServiceGroup(db, groupId); + const services = repos7.listServicesByGroup(db, groupId); const seen = /* @__PURE__ */ new Map(); for (const service of services) { if (!service.enabled) continue; - const bindings = repos6.listBindingsByService(db, service.id); + const bindings = repos7.listBindingsByService(db, service.id); for (const binding of bindings) { - const ipMetas = repos6.listBindingIpsWithMeta(db, binding.id); + const ipMetas = repos7.listBindingIpsWithMeta(db, binding.id); for (const entry of ipMetas) { - const status = repos6.getIpHealthStatusRow(db, "group", groupId, entry.ip); + const status = repos7.getIpHealthStatusRow(db, "group", groupId, entry.ip); const existing = seen.get(entry.ip); const weight = entry.weight * service.lb_weight; const priority = Math.min(entry.priority, service.lb_priority); @@ -1096,7 +1200,7 @@ function computeActiveIps(db, scope, refId) { return selectActiveIpsByMode(state.config, state.rows); } async function collectKnownZones(db, cf) { - const dbDomains = repos6.listDomains(db); + const dbDomains = repos7.listDomains(db); const zones = dbDomains.map((d) => d.zone_name); const cfZones = await cf.listZones(); for (const zone of cfZones) { @@ -1107,13 +1211,13 @@ async function collectKnownZones(db, cf) { return zones; } async function buildView(db, serviceId) { - const service = repos6.getService(db, serviceId); - const ips = repos6.listServiceIps(db, serviceId); - const bindings = repos6.listBindingsByService(db, serviceId); + const service = repos7.getService(db, serviceId); + const ips = repos7.listServiceIps(db, serviceId); + const bindings = repos7.listBindingsByService(db, serviceId); const domainViews = bindings.map((binding) => { - const records = repos6.listRecordsForBinding(db, binding.id); + const records = repos7.listRecordsForBinding(db, binding.id); const statuses = records.map((r) => r.sync_status); - const targetIpsWithMeta = repos6.listBindingIpsWithMeta(db, binding.id); + const targetIpsWithMeta = repos7.listBindingIpsWithMeta(db, binding.id); const targetIps = targetIpsWithMeta.map((entry) => entry.ip); const linkedCname = records.find( (record) => record.record_type.toUpperCase() === "CNAME" @@ -1134,7 +1238,7 @@ async function buildView(db, serviceId) { domain_id: binding.domain_id, zone_name: binding.zone_name, hostname: binding.hostname, - fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), + fqdn: fqdnToDisplay2(binding.hostname, binding.zone_name), record_type: targetCname ? "CNAME" : "A", target_ips: targetCname ? [] : targetIps, target_ip_weights, @@ -1169,25 +1273,25 @@ async function buildView(db, serviceId) { } async function listViews(db) { return Promise.all( - repos6.listServices(db).map((s) => buildView(db, s.id)) + repos7.listServices(db).map((s) => buildView(db, s.id)) ); } async function getView(db, id) { - repos6.getService(db, id); + repos7.getService(db, id); return buildView(db, id); } async function listGroupViews(db) { - const groups = repos6.listServiceGroups(db); + const groups = repos7.listServiceGroups(db); const groupViews = await Promise.all( groups.map(async (group) => { - const services = repos6.listServicesByGroup(db, group.id); + const services = repos7.listServicesByGroup(db, group.id); const serviceViews = await Promise.all( services.map((s) => buildView(db, s.id)) ); return { ...group, services: serviceViews }; }) ); - const ungroupedServices = repos6.listUngroupedServices(db); + const ungroupedServices = repos7.listUngroupedServices(db); const ungrouped = await Promise.all( ungroupedServices.map((s) => buildView(db, s.id)) ); @@ -1196,11 +1300,11 @@ async function listGroupViews(db) { function shouldPushDns(db, service) { if (!service.enabled) return false; if (!service.service_group_id) return true; - const group = repos6.getServiceGroup(db, service.service_group_id); + const group = repos7.getServiceGroup(db, service.service_group_id); return group.enabled; } async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, cnameTarget) { - const domain = repos6.getDomain(db, domainId); + const domain = repos7.getDomain(db, domainId); const zoneName = domain.zone_name; let effectiveCname = cnameTarget?.trim() || null; if (!effectiveCname) { @@ -1214,8 +1318,8 @@ async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, ); if (existingCname) { effectiveCname = existingCname.content; - repos6.setBindingCnameTarget(db, bindingId, effectiveCname); - repos6.replaceBindingIps(db, bindingId, []); + repos7.setBindingCnameTarget(db, bindingId, effectiveCname); + repos7.replaceBindingIps(db, bindingId, []); } } if (effectiveCname) { @@ -1232,17 +1336,17 @@ async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps); } async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameTarget) { - const domain = repos6.getDomain(db, domainId); + const domain = repos7.getDomain(db, domainId); const zoneName = domain.zone_name; const normalized = normalizeCnameTarget(cnameTarget, zoneName); - const existingRecords = repos6.listRecordsForBinding(db, bindingId); + const existingRecords = repos7.listRecordsForBinding(db, bindingId); for (const record of existingRecords) { if (record.record_type.toUpperCase() === "A") { - repos6.unlinkBindingRecord(db, bindingId, record.id); + repos7.unlinkBindingRecord(db, bindingId, record.id); await deleteRecord(db, cf, domainId, record.id); } } - const refreshed = repos6.listRecordsForBinding(db, bindingId); + const refreshed = repos7.listRecordsForBinding(db, bindingId); const existingCname = refreshed.find( (record) => record.record_type.toUpperCase() === "CNAME" ); @@ -1268,7 +1372,7 @@ async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameT normalized ); if (adopted) { - repos6.linkBindingRecord(db, bindingId, adopted.id); + repos7.linkBindingRecord(db, bindingId, adopted.id); if (!cnameContentMatches(adopted.content, normalized, zoneName)) { await update(db, cf, domainId, adopted.id, { record_type: "CNAME", @@ -1286,32 +1390,32 @@ async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameT ttl: 1, proxied: false }); - repos6.linkBindingRecord(db, bindingId, record.id); + repos7.linkBindingRecord(db, bindingId, record.id); recordId = record.id; } } - repos6.setBindingDnsRecordId(db, bindingId, recordId); - repos6.setBindingCnameTarget(db, bindingId, normalized); + repos7.setBindingDnsRecordId(db, bindingId, recordId); + repos7.setBindingCnameTarget(db, bindingId, normalized); } async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps) { - const domain = repos6.getDomain(db, domainId); + const domain = repos7.getDomain(db, domainId); const zoneName = domain.zone_name; - const existingRecords = repos6.listRecordsForBinding(db, bindingId); + const existingRecords = repos7.listRecordsForBinding(db, bindingId); for (const record of existingRecords) { if (record.record_type.toUpperCase() === "CNAME") { - repos6.unlinkBindingRecord(db, bindingId, record.id); + repos7.unlinkBindingRecord(db, bindingId, record.id); await deleteRecord(db, cf, domainId, record.id); } else if (!desiredIps.includes(record.content)) { - repos6.unlinkBindingRecord(db, bindingId, record.id); + repos7.unlinkBindingRecord(db, bindingId, record.id); await deleteRecord(db, cf, domainId, record.id); } } - repos6.setBindingCnameTarget(db, bindingId, null); + repos7.setBindingCnameTarget(db, bindingId, null); if (desiredIps.length === 0) { - repos6.setBindingDnsRecordId(db, bindingId, null); + repos7.setBindingDnsRecordId(db, bindingId, null); return; } - const refreshed = repos6.listRecordsForBinding(db, bindingId); + const refreshed = repos7.listRecordsForBinding(db, bindingId); let primaryId = null; for (const ip of desiredIps) { const existing = refreshed.find((r) => r.content === ip); @@ -1337,7 +1441,7 @@ async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps ip ); if (adopted) { - repos6.linkBindingRecord(db, bindingId, adopted.id); + repos7.linkBindingRecord(db, bindingId, adopted.id); recordId = adopted.id; } else { const record = await create(db, cf, domainId, { @@ -1347,19 +1451,19 @@ async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps ttl: 1, proxied: false }); - repos6.linkBindingRecord(db, bindingId, record.id); + repos7.linkBindingRecord(db, bindingId, record.id); recordId = record.id; } } if (primaryId == null) primaryId = recordId; } - repos6.setBindingDnsRecordId(db, bindingId, primaryId); + repos7.setBindingDnsRecordId(db, bindingId, primaryId); } async function cleanupBindingDns(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); + const bindings = repos7.listBindingsByService(db, serviceId); for (const binding of bindings) { await cleanupBindingDns( db, @@ -1408,7 +1512,7 @@ function cnameContentMatches(left, right, zoneName) { return normalizeCnameTarget(left, zoneName) === normalizeCnameTarget(right, zoneName); } function findLocalDnsRecord(db, domainId, zoneName, hostname, recordType, content) { - const records = repos6.listDnsByDomain(db, domainId); + const records = repos7.listDnsByDomain(db, domainId); return records.find( (record) => record.record_type.toUpperCase() === recordType && (content == null || record.content === content) && dnsRecordNamesMatch2(record.name, hostname, zoneName) ) ?? null; @@ -1423,7 +1527,7 @@ async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recor content ); if (local) return local; - const domain = repos6.getDomain(db, domainId); + const domain = repos7.getDomain(db, domainId); const remote = await cf.listDnsRecords(domain.cf_zone_id); for (const cfRec of remote) { if (cfRec.type.toUpperCase() !== recordType) continue; @@ -1436,9 +1540,9 @@ async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recor } if (!dnsRecordNamesMatch2(cfRec.name, hostname, zoneName)) continue; if (!cfRec.id) continue; - const existing = repos6.findDnsByCfId(db, domainId, cfRec.id); + const existing = repos7.findDnsByCfId(db, domainId, cfRec.id); if (existing) return existing; - return repos6.insertDnsRecord( + return repos7.insertDnsRecord( db, domainId, cfRec.type, @@ -1466,7 +1570,7 @@ async function findOrImportDnsARecord(db, cf, domainId, zoneName, hostname, cont ); } async function serviceBindingsExistInDns(db, cf, serviceId) { - const bindings = repos6.listBindingsByService(db, serviceId); + const bindings = repos7.listBindingsByService(db, serviceId); if (bindings.length === 0) return false; for (const binding of bindings) { const cnameTarget = binding.cname_target?.trim() || null; @@ -1483,7 +1587,7 @@ async function serviceBindingsExistInDns(db, cf, serviceId) { if (!record) return false; continue; } - const targetIps = repos6.listBindingIps(db, binding.id); + const targetIps = repos7.listBindingIps(db, binding.id); if (targetIps.length === 0) return false; for (const ip of targetIps) { const record = await findOrImportDnsARecord( @@ -1500,16 +1604,16 @@ async function serviceBindingsExistInDns(db, cf, serviceId) { return true; } async function syncServiceBindingsToDns(db, cf, serviceId) { - const bindings = repos6.listBindingsByService(db, serviceId); + const bindings = repos7.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); + const targetIps = repos7.listBindingIps(db, binding.id); return targetIps.length > 0; }); - const ips = repos6.listServiceIps(db, serviceId); + const ips = repos7.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"); } @@ -1527,10 +1631,10 @@ async function syncServiceBindingsToDns(db, cf, serviceId) { ); continue; } - let targetIps = repos6.listBindingIps(db, binding.id); + let targetIps = repos7.listBindingIps(db, binding.id); if (targetIps.length === 0) { throw AppError.validation( - `\u0443\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 CNAME \u0434\u043B\u044F ${fqdnToDisplay(binding.hostname, binding.zone_name)}` + `\u0443\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 CNAME \u0434\u043B\u044F ${fqdnToDisplay2(binding.hostname, binding.zone_name)}` ); } validateTargetIpsInPool(targetIps, ips); @@ -1552,13 +1656,13 @@ async function syncServiceBindingsToDns(db, cf, serviceId) { } } async function collectGroupDnsIps(db, groupId) { - const services = repos6.listServicesByGroup(db, groupId); + const services = repos7.listServicesByGroup(db, groupId); const ips = []; for (const service of services) { if (!service.enabled) continue; - const bindings = repos6.listBindingsByService(db, service.id); + const bindings = repos7.listBindingsByService(db, service.id); for (const binding of bindings) { - for (const ip of repos6.listBindingIps(db, binding.id)) { + for (const ip of repos7.listBindingIps(db, binding.id)) { if (!ips.includes(ip)) ips.push(ip); } } @@ -1567,17 +1671,17 @@ async function collectGroupDnsIps(db, groupId) { return ips; } async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, desiredIps) { - const domain = repos6.getDomain(db, domainId); + const domain = repos7.getDomain(db, domainId); const zoneName = domain.zone_name; - const existingRecords = repos6.listGroupDnsRecords(db, groupId); + const existingRecords = repos7.listGroupDnsRecords(db, groupId); for (const record of existingRecords) { if (!desiredIps.includes(record.content)) { - repos6.unlinkGroupDnsRecord(db, groupId, record.id); + repos7.unlinkGroupDnsRecord(db, groupId, record.id); await deleteRecord(db, cf, domainId, record.id); } } if (desiredIps.length === 0) return; - const refreshed = repos6.listGroupDnsRecords(db, groupId); + const refreshed = repos7.listGroupDnsRecords(db, groupId); for (const ip of desiredIps) { const existing = refreshed.find((r) => r.content === ip); if (existing) { @@ -1600,7 +1704,7 @@ async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, de ip ); if (adopted) { - repos6.linkGroupDnsRecord(db, groupId, adopted.id); + repos7.linkGroupDnsRecord(db, groupId, adopted.id); continue; } const record = await create(db, cf, domainId, { @@ -1610,19 +1714,19 @@ async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, de ttl: 1, proxied: false }); - repos6.linkGroupDnsRecord(db, groupId, record.id); + repos7.linkGroupDnsRecord(db, groupId, record.id); } } async function resolveDomainId(db, cf, zoneName) { const trimmed = zoneName.trim(); if (!trimmed) throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0438\u043C\u044F \u0437\u043E\u043D\u044B"); - const existing = repos6.findDomainByZoneName(db, trimmed); + const existing = repos7.findDomainByZoneName(db, trimmed); if (existing) return existing.id; const created = await createDomain(db, cf, null, trimmed); return created.id; } async function cleanupGroupDomainDns(db, cf, groupId) { - const group = repos6.getServiceGroup(db, groupId); + const group = repos7.getServiceGroup(db, groupId); const domainValue = group.domain?.trim(); if (!domainValue) return; const knownZones = await collectKnownZones(db, cf); @@ -1631,7 +1735,7 @@ async function cleanupGroupDomainDns(db, cf, groupId) { await syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, []); } async function syncGroupDomainDns(db, cf, groupId) { - const group = repos6.getServiceGroup(db, groupId); + const group = repos7.getServiceGroup(db, groupId); if (!group.enabled) { await cleanupGroupDomainDns(db, cf, groupId); return; @@ -1652,14 +1756,14 @@ async function syncGroupDomainDns(db, cf, groupId) { ); } async function syncGroupDomainForService(db, cf, serviceId) { - const service = repos6.getService(db, serviceId); + const service = repos7.getService(db, serviceId); if (!service.service_group_id) return; await syncGroupDomainDns(db, cf, service.service_group_id); } async function syncEnabledServicesInGroup(db, cf, groupId) { - const group = repos6.getServiceGroup(db, groupId); + const group = repos7.getServiceGroup(db, groupId); if (!group.enabled || !group.domain?.trim()) return; - const services = repos6.listServicesByGroup(db, groupId); + const services = repos7.listServicesByGroup(db, groupId); for (const service of services) { if (service.enabled) { await syncServiceBindingsToDns(db, cf, service.id); @@ -1672,17 +1776,17 @@ async function normalizeGroupDomain(db, cf, domain) { if (!raw) return null; const knownZones = await collectKnownZones(db, cf); const { zoneName, hostname } = parseFqdn(raw, knownZones); - return fqdnToDisplay(hostname, zoneName); + return fqdnToDisplay2(hostname, zoneName); } async function cleanupStaleGroupFqdnBindings(db, cf, groupId, fqdn) { const knownZones = await collectKnownZones(db, cf); const { zoneName, hostname } = parseFqdn(fqdn, knownZones); if (hostname === "@") return; - const domain = repos6.findDomainByZoneName(db, zoneName); + const domain = repos7.findDomainByZoneName(db, zoneName); if (!domain) return; - const services = repos6.listServicesByGroup(db, groupId); + const services = repos7.listServicesByGroup(db, groupId); for (const service of services) { - const binding = repos6.findBinding( + const binding = repos7.findBinding( db, service.id, domain.id, @@ -1696,25 +1800,25 @@ async function cleanupStaleGroupFqdnBindings(db, cf, groupId, fqdn) { binding.domain_id, binding.hostname ); - repos6.deleteBinding(db, binding.id); + repos7.deleteBinding(db, binding.id); } } async function updateConfig(db, cf, id, req) { if (req.name && req.slug) { - repos6.updateService(db, id, req.name, req.slug); + repos7.updateService(db, id, req.name, req.slug); } else if (req.name) { - const existing = repos6.getService(db, id); - repos6.updateService(db, id, req.name, existing.slug); + const existing = repos7.getService(db, id); + repos7.updateService(db, id, req.name, existing.slug); } else if (req.slug) { - const existing = repos6.getService(db, id); - repos6.updateService(db, id, existing.name, req.slug); + const existing = repos7.getService(db, id); + repos7.updateService(db, id, existing.name, req.slug); } if (req.service_group_id !== void 0) { - repos6.setServiceGroup(db, id, req.service_group_id); + repos7.setServiceGroup(db, id, req.service_group_id); } if (req.lb_weight !== void 0 || req.lb_priority !== void 0) { - const existing = repos6.getService(db, id); - repos6.setServiceLb( + const existing = repos7.getService(db, id); + repos7.setServiceLb( db, id, req.lb_weight ?? existing.lb_weight, @@ -1723,11 +1827,12 @@ async function updateConfig(db, cf, id, req) { } const ipsUpdated = req.ips !== void 0; const knownZones = await collectKnownZones(db, cf); - const ips = req.ips ? normalizeIps(req.ips) : repos6.listServiceIps(db, id); - if (ipsUpdated) repos6.replaceServiceIps(db, id, ips); + const ips = req.ips ? normalizeIps(req.ips) : repos7.listServiceIps(db, id); + if (ipsUpdated) repos7.replaceServiceIps(db, id, ips); const keptBindingIds = []; - let service = repos6.getService(db, id); + let service = repos7.getService(db, id); const pushDns = shouldPushDns(db, service); + let removedBindingIds = []; if (req.domains) { if (req.domains.length > 0) { for (const input of req.domains) { @@ -1744,7 +1849,7 @@ async function updateConfig(db, cf, id, req) { } 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); + const binding = repos7.findBinding(db, id, domainId, hostname) ?? repos7.insertBinding(db, domainId, id, hostname, null); keptBindingIds.push(binding.id); const targetIpWeights = input.target_ip_weights ?? {}; const targetIpPriorities = input.target_ip_priorities ?? {}; @@ -1753,10 +1858,10 @@ async function updateConfig(db, cf, id, req) { weight: targetIpWeights[ip] ?? 1, priority: targetIpPriorities[ip] ?? 1 })); - repos6.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries); - repos6.setBindingCnameTarget(db, binding.id, targetCname); + repos7.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries); + repos7.setBindingCnameTarget(db, binding.id, targetCname); if (input.lb_mode !== void 0 || input.health_check_enabled !== void 0 || input.health_check_type !== void 0 || input.health_check_port !== void 0 || input.health_check_path !== void 0 || input.health_check_expected_status !== void 0 || input.health_check_interval_sec !== void 0 || input.health_check_timeout_ms !== void 0) { - repos6.updateBindingLbConfig(db, binding.id, { + repos7.updateBindingLbConfig(db, binding.id, { lb_mode: input.lb_mode, health_check_enabled: input.health_check_enabled, health_check_type: input.health_check_type, @@ -1769,7 +1874,7 @@ async function updateConfig(db, cf, id, req) { } if (pushDns) { let effectiveIps = targetIps; - const refreshedBinding = repos6.getBinding(db, binding.id); + const refreshedBinding = repos7.getBinding(db, binding.id); if (refreshedBinding.health_check_enabled) { const activeIps = computeActiveIps(db, "binding", binding.id); if (activeIps.length > 0) { @@ -1787,7 +1892,8 @@ async function updateConfig(db, cf, id, req) { ); } } - const removed = repos6.bindingsToRemove(db, id, keptBindingIds); + const removed = repos7.bindingsToRemove(db, id, keptBindingIds); + removedBindingIds = removed.map((binding) => binding.id); for (const binding of removed) { await cleanupBindingDns( db, @@ -1797,36 +1903,37 @@ async function updateConfig(db, cf, id, req) { binding.hostname ); } - repos6.deleteBindingsExcept(db, id, keptBindingIds); + repos7.deleteBindingsExcept(db, id, keptBindingIds); } } else if (ipsUpdated) { - const bindings = repos6.listBindingsByService(db, id); + const bindings = repos7.listBindingsByService(db, id); for (const binding of bindings) { - const targetIps = repos6.listBindingIps(db, binding.id); + const targetIps = repos7.listBindingIps(db, binding.id); for (const ip of targetIps) { if (!ips.includes(ip)) { throw AppError.validation( - `IP ${ip} \u043F\u0440\u0438\u0432\u044F\u0437\u0430\u043D \u043A ${fqdnToDisplay(binding.hostname, binding.zone_name)}, \u043D\u043E \u043E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0432 \u043D\u043E\u0432\u043E\u043C \u043F\u0443\u043B\u0435 \u0430\u0434\u0440\u0435\u0441\u043E\u0432` + `IP ${ip} \u043F\u0440\u0438\u0432\u044F\u0437\u0430\u043D \u043A ${fqdnToDisplay2(binding.hostname, binding.zone_name)}, \u043D\u043E \u043E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0432 \u043D\u043E\u0432\u043E\u043C \u043F\u0443\u043B\u0435 \u0430\u0434\u0440\u0435\u0441\u043E\u0432` ); } } } } - service = repos6.getService(db, id); + service = repos7.getService(db, id); 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); + repos7.setServiceEnabled(db, id, true); await syncServiceBindingsToDns(db, cf, id); await syncGroupDomainForService(db, cf, id); } + void syncServiceToVpsTracker(db, id, removedBindingIds); return buildView(db, id); } async function createGroup2(db, cf, body) { const groupType = body.type?.trim() || "custom"; const domain = await normalizeGroupDomain(db, cf, body.domain); - return repos6.createServiceGroup( + return repos7.createServiceGroup( db, body.name, groupType, @@ -1846,7 +1953,7 @@ async function createGroup2(db, cf, body) { } async function updateGroup2(db, cf, id, body) { const groupType = body.type?.trim() || "custom"; - const previous = repos6.getServiceGroup(db, id); + const previous = repos7.getServiceGroup(db, id); const name = body.name ?? previous.name; const oldDomain = previous.domain?.trim(); if (oldDomain) { @@ -1854,7 +1961,7 @@ async function updateGroup2(db, cf, id, body) { await cleanupGroupDomainDns(db, cf, id); } const domain = await normalizeGroupDomain(db, cf, body.domain); - let group = repos6.updateServiceGroup( + let group = repos7.updateServiceGroup( db, id, name, @@ -1873,24 +1980,24 @@ async function updateGroup2(db, cf, id, body) { } ); if (!domain && group.enabled) { - repos6.setServiceGroupEnabled(db, id, false); - group = repos6.getServiceGroup(db, id); + repos7.setServiceGroupEnabled(db, id, false); + group = repos7.getServiceGroup(db, id); } await syncEnabledServicesInGroup(db, cf, id); return group; } function deleteGroup2(db, id) { - repos6.deleteServiceGroup(db, id); + repos7.deleteServiceGroup(db, id); } async function toggleService(db, cf, serviceId, enabled) { - const service = repos6.getService(db, serviceId); + const service = repos7.getService(db, serviceId); if (enabled && service.service_group_id) { - const group = repos6.getServiceGroup(db, service.service_group_id); + const group = repos7.getServiceGroup(db, service.service_group_id); 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"); } } - repos6.setServiceEnabled(db, serviceId, enabled); + repos7.setServiceEnabled(db, serviceId, enabled); if (!enabled) { await cleanupServiceDnsOnly(db, cf, serviceId); await syncGroupDomainForService(db, cf, serviceId); @@ -1901,16 +2008,16 @@ async function toggleService(db, cf, serviceId, enabled) { return buildView(db, serviceId); } async function toggleGroup(db, cf, groupId, enabled) { - const group = repos6.getServiceGroup(db, groupId); + const group = repos7.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); + repos7.setServiceGroupEnabled(db, groupId, enabled); if (!enabled) { - const services = repos6.listServicesByGroup(db, groupId); + const services = repos7.listServicesByGroup(db, groupId); for (const service of services) { if (service.enabled) { - repos6.setServiceEnabled(db, service.id, false); + repos7.setServiceEnabled(db, service.id, false); await cleanupServiceDnsOnly(db, cf, service.id); } } @@ -1922,20 +2029,20 @@ async function toggleGroup(db, cf, groupId, enabled) { } function reorderServices(db, groupId, serviceIds) { if (groupId !== null) { - repos6.getServiceGroup(db, groupId); + repos7.getServiceGroup(db, groupId); } - repos6.reorderServices(db, groupId, serviceIds); + repos7.reorderServices(db, groupId, serviceIds); } async function reconcileDnsForTarget(db, cf, scope, refId) { if (scope === "binding") { - const binding = repos6.getBinding(db, refId); + const binding = repos7.getBinding(db, refId); if (!binding.health_check_enabled) return; - const service = repos6.getService(db, binding.service_id); + const service = repos7.getService(db, binding.service_id); if (!shouldPushDns(db, service)) return; const cnameTarget = binding.cname_target?.trim() || null; if (cnameTarget) return; - const ips = repos6.listServiceIps(db, service.id); - const targetIps = repos6.listBindingIps(db, binding.id); + const ips = repos7.listServiceIps(db, service.id); + const targetIps = repos7.listBindingIps(db, binding.id); validateTargetIpsInPool(targetIps, ips); const activeIps = computeActiveIps(db, "binding", refId); const desiredIps = activeIps.length > 0 ? activeIps : targetIps; @@ -1950,7 +2057,7 @@ async function reconcileDnsForTarget(db, cf, scope, refId) { ); return; } - const group = repos6.getServiceGroup(db, refId); + const group = repos7.getServiceGroup(db, refId); if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) { return; } @@ -1978,13 +2085,13 @@ async function serviceRoutes(app2) { }); app2.post("/services", async (request) => { const body = createSchema.parse(request.body); - const service = repos7.createService( + const service = repos8.createService( request.server.db, body.name, body.slug ); if (body.service_group_id != null) { - repos7.setServiceGroup( + repos8.setServiceGroup( request.server.db, service.id, body.service_group_id @@ -2008,7 +2115,7 @@ async function serviceRoutes(app2) { }); app2.delete("/services/:id", async (request) => { const { id } = request.params; - repos7.deleteService(request.server.db, Number(id)); + repos8.deleteService(request.server.db, Number(id)); return { deleted: true }; }); app2.patch("/services/:id/toggle", async (request) => { @@ -2095,8 +2202,8 @@ async function serviceBindingRoutes(app2) { }); app2.get("/service-bindings/:id", async (request) => { const { id } = request.params; - const { repos: repos11 } = await import("@cfdm/db"); - return repos11.getBindingView(request.server.db, Number(id)); + const { repos: repos13 } = await import("@cfdm/db"); + return repos13.getBindingView(request.server.db, Number(id)); }); app2.patch("/service-bindings/:id", async (request) => { const { id } = request.params; @@ -2272,20 +2379,20 @@ async function dnsRoutes(app2) { // src/routes/subdomains.ts import { updateSubdomainSchema } from "@cfdm/shared"; -import { repos as repos8 } from "@cfdm/db"; +import { repos as repos9 } from "@cfdm/db"; import { z as z7 } from "zod"; async function subdomainRoutes(app2) { app2.get("/domains/:id/subdomains", async (request) => { const { id } = request.params; - repos8.getDomain(request.server.db, Number(id)); - return repos8.listSubdomainsByDomain(request.server.db, Number(id)); + repos9.getDomain(request.server.db, Number(id)); + return repos9.listSubdomainsByDomain(request.server.db, Number(id)); }); app2.post("/domains/:id/subdomains", async (request) => { const { id } = request.params; const body = z7.object({ name: z7.string() }).parse(request.body); - const domain = repos8.getDomain(request.server.db, Number(id)); + const domain = repos9.getDomain(request.server.db, Number(id)); const fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`; - return repos8.createSubdomain( + return repos9.createSubdomain( request.server.db, Number(id), body.name, @@ -2294,13 +2401,13 @@ async function subdomainRoutes(app2) { }); app2.get("/subdomains/:id", async (request) => { const { id } = request.params; - return repos8.getSubdomain(request.server.db, Number(id)); + return repos9.getSubdomain(request.server.db, Number(id)); }); app2.patch("/subdomains/:id", async (request) => { const { id } = request.params; 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 sub = repos9.getSubdomain(request.server.db, Number(id)); + const domain = repos9.getDomain(request.server.db, sub.domain_id); const patch = {}; if (body.name !== void 0) { patch.name = body.name; @@ -2312,11 +2419,11 @@ async function subdomainRoutes(app2) { if (body.cert_monitoring !== void 0) { patch.cert_monitoring = body.cert_monitoring; } - return repos8.updateSubdomain(request.server.db, Number(id), patch); + return repos9.updateSubdomain(request.server.db, Number(id), patch); }); app2.delete("/subdomains/:id", async (request) => { const { id } = request.params; - repos8.deleteSubdomain(request.server.db, Number(id)); + repos9.deleteSubdomain(request.server.db, Number(id)); return { deleted: true }; }); } @@ -2324,7 +2431,7 @@ async function subdomainRoutes(app2) { // src/services/certificate-service.ts import { connect } from "net"; import { connect as tlsConnect } from "tls"; -import { repos as repos9 } from "@cfdm/db"; +import { repos as repos10 } from "@cfdm/db"; import { CERT_ERROR, CERT_MONITOR_AUTO, @@ -2332,15 +2439,15 @@ import { CERT_MONITOR_SKIPPED, CERT_UNKNOWN, certStatusFromExpiry as certStatusFromExpiry2, - fqdnToDisplay as fqdnToDisplay2, + fqdnToDisplay as fqdnToDisplay3, parseFqdn as parseFqdn2, shouldMonitorService } from "@cfdm/shared"; function listCertificates(db, status) { - return repos9.listCertificates(db, status); + return repos10.listCertificates(db, status); } function getCertificate(db, id) { - return repos9.getCertificate(db, id); + return repos10.getCertificate(db, id); } async function checkHostname(hostname) { return new Promise((resolve4) => { @@ -2376,7 +2483,7 @@ async function checkHostname(hostname) { async function checkAndStore(db, domainId, subdomainId, hostname) { const { expiresAt, error } = await checkHostname(hostname); if (error) { - return repos9.upsertCertificateCheck( + return repos10.upsertCertificateCheck( db, domainId, subdomainId, @@ -2390,7 +2497,7 @@ async function checkAndStore(db, domainId, subdomainId, hostname) { const days = Math.floor( (expiresAt.getTime() - Date.now()) / (1e3 * 60 * 60 * 24) ); - return repos9.upsertCertificateCheck( + return repos10.upsertCertificateCheck( db, domainId, subdomainId, @@ -2400,7 +2507,7 @@ async function checkAndStore(db, domainId, subdomainId, hostname) { null ); } - return repos9.upsertCertificateCheck( + return repos10.upsertCertificateCheck( db, domainId, subdomainId, @@ -2421,29 +2528,29 @@ function resolveMonitoringMode(domain, subdomain, fqdn) { } function bindingSubdomain(db, domainId, hostname) { if (hostname === "@") return null; - return repos9.findSubdomainByDomainAndName(db, domainId, hostname); + return repos10.findSubdomainByDomainAndName(db, domainId, hostname); } function buildServiceCertificateFqdns(db) { const result = /* @__PURE__ */ new Map(); - for (const binding of repos9.listAllBindings(db)) { - const service = repos9.getService(db, binding.service_id); - const group = service.service_group_id ? repos9.getServiceGroup(db, service.service_group_id) : null; + for (const binding of repos10.listAllBindings(db)) { + const service = repos10.getService(db, binding.service_id); + const group = service.service_group_id ? repos10.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 = fqdnToDisplay2(binding.hostname, binding.zone_name); + const fqdn = fqdnToDisplay3(binding.hostname, binding.zone_name); result.set(fqdn, { domainId: binding.domain_id, subdomainId: subdomain?.id ?? null, hostname: fqdn }); } - const knownZones = repos9.listAllDomains(db).map((d) => d.zone_name); - for (const group of repos9.listServiceGroups(db)) { + const knownZones = repos10.listAllDomains(db).map((d) => d.zone_name); + for (const group of repos10.listServiceGroups(db)) { if (!group.enabled || !group.domain?.trim()) continue; const parsed = parseFqdn2(group.domain, knownZones); if (!parsed) continue; - const domain = repos9.findDomainByZoneName(db, parsed.zoneName); + const domain = repos10.findDomainByZoneName(db, parsed.zoneName); if (!domain) continue; const subdomain = parsed.hostname === "@" ? null : bindingSubdomain(db, domain.id, parsed.hostname); if (subdomain && !subdomain.enabled) continue; @@ -2458,7 +2565,7 @@ function buildServiceCertificateFqdns(db) { function resolveCertificateTargets(db) { const serviceFqdns = buildServiceCertificateFqdns(db); const targets = /* @__PURE__ */ new Map(); - for (const domain of repos9.listAllDomains(db)) { + for (const domain of repos10.listAllDomains(db)) { if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue; if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) { targets.set(domain.zone_name, { @@ -2468,7 +2575,7 @@ function resolveCertificateTargets(db) { }); } } - for (const sub of repos9.listAllSubdomains(db)) { + for (const sub of repos10.listAllSubdomains(db)) { if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue; if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) { targets.set(sub.fqdn, { @@ -2479,8 +2586,8 @@ function resolveCertificateTargets(db) { } } for (const [fqdn, meta] of serviceFqdns) { - const domain = repos9.getDomain(db, meta.domainId); - const subdomain = meta.subdomainId ? repos9.getSubdomain(db, meta.subdomainId) : null; + const domain = repos10.getDomain(db, meta.domainId); + const subdomain = meta.subdomainId ? repos10.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) { @@ -2499,14 +2606,14 @@ async function runAllChecks(db) { target.hostname ); } - repos9.deleteCertificatesNotIn( + repos10.deleteCertificatesNotIn( db, targets.map((t) => t.hostname) ); return targets.length; } function statusSummary(db) { - return repos9.countCertificatesByStatus(db); + return repos10.countCertificatesByStatus(db); } // src/routes/certificates.ts @@ -2564,7 +2671,7 @@ import { healthStatusQuerySchema } from "@cfdm/shared"; // src/services/health-check-service.ts import { connect as connect2 } from "net"; import { Agent, fetch as undiciFetch } from "undici"; -import { repos as repos10 } from "@cfdm/db"; +import { repos as repos11 } from "@cfdm/db"; function tcpProbe(ip, port, timeoutMs) { return new Promise((resolve4) => { const started = Date.now(); @@ -2676,9 +2783,9 @@ function deriveState(ok, latencyMs, prev, thresholds) { return { state: "up", failures: 0 }; } async function runAllChecks2(db, options) { - const targets = repos10.listHealthCheckTargets(db); + const targets = repos11.listHealthCheckTargets(db); for (const target of targets) { - const prev = repos10.getIpHealthStatusRow( + const prev = repos11.getIpHealthStatusRow( db, target.scope, target.ref_id, @@ -2695,7 +2802,7 @@ async function runAllChecks2(db, options) { options.thresholds ); const prevState = prev ? prev.status : null; - repos10.upsertIpHealthStatus( + repos11.upsertIpHealthStatus( db, target.scope, target.ref_id, @@ -2712,7 +2819,7 @@ async function runAllChecks2(db, options) { return targets.length; } function listStatus(db, scope, refId) { - return repos10.listIpHealthStatus(db, scope, refId); + return repos11.listIpHealthStatus(db, scope, refId); } // src/routes/health-check.ts @@ -2749,6 +2856,101 @@ async function healthCheckRoutes(app2) { }); } +// src/routes/settings.ts +import { appSettingsPatchSchema } from "@cfdm/shared"; +import { + getAppSettings, + updateAppSettings +} from "@cfdm/db/settings-repo"; +async function settingsRoutes(app2) { + app2.get("/settings", async (request) => { + return getAppSettings(request.server.db); + }); + app2.patch("/settings", async (request) => { + const body = appSettingsPatchSchema.parse(request.body); + return updateAppSettings(request.server.db, body); + }); + app2.post("/settings/vps-tracker/test", async (request) => { + return pingVpsTracker(request.server.db); + }); +} + +// src/routes/integrations-vps-tracker.ts +import { timingSafeEqual } from "crypto"; +import { vpsTrackerEventSchema } from "@cfdm/shared"; +import { getAppSettingsSecrets as getAppSettingsSecrets2 } from "@cfdm/db/settings-repo"; + +// src/services/vps-tracker-events.ts +import * as repos12 from "@cfdm/db/repos"; +function collectIps(event) { + const ips = /* @__PURE__ */ new Set(); + for (const v of event.vps) { + if (v.ip?.trim()) ips.add(v.ip.trim()); + } + return ips; +} +function bindingUsesIps(db, serviceId, bindingId, ips) { + const targetIps = repos12.listBindingIps(db, bindingId); + const poolIps = repos12.listServiceIps(db, serviceId); + const effective = targetIps.length > 0 ? targetIps : poolIps; + return effective.some((ip) => ips.has(ip)); +} +async function reconcileForVpsDown(db, cf, event) { + const ips = collectIps(event); + if (ips.size === 0) return 0; + const seen = /* @__PURE__ */ new Set(); + let reconciled = 0; + for (const service of repos12.listServices(db)) { + if (!service.enabled) continue; + const bindings = repos12.listBindingsByService(db, service.id); + for (const binding of bindings) { + if (!bindingUsesIps(db, service.id, binding.id, ips)) continue; + const key = `binding:${binding.id}`; + if (seen.has(key)) continue; + seen.add(key); + await reconcileDnsForTarget(db, cf, "binding", binding.id); + reconciled += 1; + } + } + return reconciled; +} + +// src/routes/integrations-vps-tracker.ts +function verifyBearer(authHeader, expected) { + if (!authHeader?.startsWith("Bearer ")) return false; + const token = authHeader.slice(7); + if (!token || !expected) return false; + const a = Buffer.from(token); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} +async function integrationsVpsTrackerRoutes(app2) { + app2.post("/integrations/vps-tracker/events", async (req, reply) => { + const secrets = getAppSettingsSecrets2(app2.db); + const token = secrets.vpsTrackerIntegrationToken; + if (!token) { + return reply.status(503).send({ error: "Integration not configured" }); + } + if (!verifyBearer(req.headers.authorization, token)) { + return reply.status(401).send({ error: "Unauthorized" }); + } + const parsed = vpsTrackerEventSchema.safeParse(req.body); + if (!parsed.success) { + return reply.status(400).send({ error: parsed.error.flatten() }); + } + if (parsed.data.event !== "vps_down") { + return { ok: true, reconciled: 0 }; + } + const reconciled = await reconcileForVpsDown( + app2.db, + app2.cf, + parsed.data + ); + return { ok: true, reconciled }; + }); +} + // src/app.ts import { AsyncTask, CronJob } from "toad-scheduler"; async function buildApp(opts = {}) { @@ -2771,6 +2973,7 @@ async function buildApp(opts = {}) { await app2.register(auth_default, { config: config2 }); await app2.register(healthRoutes); await app2.register(authRoutes, { prefix: "/api/v1" }); + await app2.register(integrationsVpsTrackerRoutes, { prefix: "/api/v1" }); await app2.register( async (protectedApi) => { protectedApi.addHook("onRequest", requireAuth); @@ -2784,6 +2987,7 @@ async function buildApp(opts = {}) { await protectedApi.register(certificateRoutes); await protectedApi.register(syncRoutes); await protectedApi.register(healthCheckRoutes); + await protectedApi.register(settingsRoutes); }, { prefix: "/api/v1" } ); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4e6fed0..9942099 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -24,6 +24,8 @@ import { subdomainRoutes } from "./routes/subdomains.js"; import { certificateRoutes } from "./routes/certificates.js"; import { syncRoutes } from "./routes/sync.js"; import { healthCheckRoutes } from "./routes/health-check.js"; +import { settingsRoutes } from "./routes/settings.js"; +import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js"; import * as certificateService from "./services/certificate-service.js"; import * as healthCheckService from "./services/health-check-service.js"; import * as serviceConfigService from "./services/service-config-service.js"; @@ -58,6 +60,7 @@ export async function buildApp(opts: BuildAppOptions = {}) { await app.register(healthRoutes); await app.register(authRoutes, { prefix: "/api/v1" }); + await app.register(integrationsVpsTrackerRoutes, { prefix: "/api/v1" }); await app.register( async (protectedApi) => { @@ -72,6 +75,7 @@ export async function buildApp(opts: BuildAppOptions = {}) { await protectedApi.register(certificateRoutes); await protectedApi.register(syncRoutes); await protectedApi.register(healthCheckRoutes); + await protectedApi.register(settingsRoutes); }, { prefix: "/api/v1" }, ); diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts index 1a87a0e..4998226 100644 --- a/apps/api/src/routes/health.ts +++ b/apps/api/src/routes/health.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { healthCheck } from "../plugins/db.js"; +import { getAppSwitcher } from "@cfdm/db/settings-repo"; import * as authService from "../services/auth.js"; export async function healthRoutes(app: FastifyInstance) { @@ -31,6 +32,10 @@ export async function healthRoutes(app: FastifyInstance) { } export async function authRoutes(app: FastifyInstance) { + app.get("/settings/app-switcher", async (request) => { + return getAppSwitcher(request.server.db); + }); + const loginSchema = z.object({ username: z.string(), password: z.string(), diff --git a/apps/api/src/routes/integrations-vps-tracker.ts b/apps/api/src/routes/integrations-vps-tracker.ts new file mode 100644 index 0000000..40ff126 --- /dev/null +++ b/apps/api/src/routes/integrations-vps-tracker.ts @@ -0,0 +1,44 @@ +import { timingSafeEqual } from "node:crypto"; +import type { FastifyInstance } from "fastify"; +import { vpsTrackerEventSchema } from "@cfdm/shared"; +import { getAppSettingsSecrets } from "@cfdm/db/settings-repo"; +import * as vpsTrackerEvents from "../services/vps-tracker-events.js"; + +function verifyBearer(authHeader: string | undefined, expected: string): boolean { + if (!authHeader?.startsWith("Bearer ")) return false; + const token = authHeader.slice(7); + if (!token || !expected) return false; + const a = Buffer.from(token); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +export async function integrationsVpsTrackerRoutes(app: FastifyInstance) { + app.post("/integrations/vps-tracker/events", async (req, reply) => { + const secrets = getAppSettingsSecrets(app.db); + const token = secrets.vpsTrackerIntegrationToken; + if (!token) { + return reply.status(503).send({ error: "Integration not configured" }); + } + if (!verifyBearer(req.headers.authorization, token)) { + return reply.status(401).send({ error: "Unauthorized" }); + } + + const parsed = vpsTrackerEventSchema.safeParse(req.body); + if (!parsed.success) { + return reply.status(400).send({ error: parsed.error.flatten() }); + } + + if (parsed.data.event !== "vps_down") { + return { ok: true, reconciled: 0 }; + } + + const reconciled = await vpsTrackerEvents.reconcileForVpsDown( + app.db, + app.cf, + parsed.data, + ); + return { ok: true, reconciled }; + }); +} diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts new file mode 100644 index 0000000..d863223 --- /dev/null +++ b/apps/api/src/routes/settings.ts @@ -0,0 +1,22 @@ +import type { FastifyInstance } from "fastify"; +import { appSettingsPatchSchema } from "@cfdm/shared"; +import { + getAppSettings, + updateAppSettings, +} from "@cfdm/db/settings-repo"; +import { pingVpsTracker } from "../services/vps-tracker-sync.js"; + +export async function settingsRoutes(app: FastifyInstance) { + app.get("/settings", async (request) => { + return getAppSettings(request.server.db); + }); + + app.patch("/settings", async (request) => { + const body = appSettingsPatchSchema.parse(request.body); + return updateAppSettings(request.server.db, body); + }); + + app.post("/settings/vps-tracker/test", async (request) => { + return pingVpsTracker(request.server.db); + }); +} diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index 385bce1..7850876 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -23,6 +23,7 @@ import { AppError } from "../errors.js"; import { isValidIpv4 } from "../lib/validators.js"; import * as dnsService from "./dns-service.js"; import * as domainService from "./domain-service.js"; +import { syncServiceToVpsTracker } from "./vps-tracker-sync.js"; export interface ServiceDomainInput { fqdn: string; @@ -1100,6 +1101,7 @@ export async function updateConfig( const keptBindingIds: number[] = []; let service = repos.getService(db, id); const pushDns = shouldPushDns(db, service); + let removedBindingIds: number[] = []; if (req.domains) { if (req.domains.length > 0) { @@ -1179,6 +1181,7 @@ export async function updateConfig( } const removed = repos.bindingsToRemove(db, id, keptBindingIds); + removedBindingIds = removed.map((binding) => binding.id); for (const binding of removed) { await cleanupBindingDns( db, @@ -1219,6 +1222,8 @@ export async function updateConfig( await syncGroupDomainForService(db, cf, id); } + void syncServiceToVpsTracker(db, id, removedBindingIds); + return buildView(db, id); } diff --git a/apps/api/src/services/vps-tracker-events.ts b/apps/api/src/services/vps-tracker-events.ts new file mode 100644 index 0000000..607a476 --- /dev/null +++ b/apps/api/src/services/vps-tracker-events.ts @@ -0,0 +1,52 @@ +import type { Db } from "@cfdm/db"; +import type { CloudflareClient } from "../lib/cf-client.js"; +import type { VpsTrackerEvent } from "@cfdm/shared"; +import * as repos from "@cfdm/db/repos"; +import { reconcileDnsForTarget } from "./service-config-service.js"; + +function collectIps(event: VpsTrackerEvent): Set { + const ips = new Set(); + for (const v of event.vps) { + if (v.ip?.trim()) ips.add(v.ip.trim()); + } + return ips; +} + +function bindingUsesIps( + db: Db, + serviceId: number, + bindingId: number, + ips: Set, +): boolean { + const targetIps = repos.listBindingIps(db, bindingId); + const poolIps = repos.listServiceIps(db, serviceId); + const effective = targetIps.length > 0 ? targetIps : poolIps; + return effective.some((ip) => ips.has(ip)); +} + +export async function reconcileForVpsDown( + db: Db, + cf: CloudflareClient, + event: VpsTrackerEvent, +): Promise { + const ips = collectIps(event); + if (ips.size === 0) return 0; + + const seen = new Set(); + let reconciled = 0; + + for (const service of repos.listServices(db)) { + if (!service.enabled) continue; + const bindings = repos.listBindingsByService(db, service.id); + for (const binding of bindings) { + if (!bindingUsesIps(db, service.id, binding.id, ips)) continue; + const key = `binding:${binding.id}`; + if (seen.has(key)) continue; + seen.add(key); + await reconcileDnsForTarget(db, cf, "binding", binding.id); + reconciled += 1; + } + } + + return reconciled; +} diff --git a/apps/api/src/services/vps-tracker-sync.ts b/apps/api/src/services/vps-tracker-sync.ts new file mode 100644 index 0000000..b78412b --- /dev/null +++ b/apps/api/src/services/vps-tracker-sync.ts @@ -0,0 +1,126 @@ +import type { CfdmBindingSyncItem } from "@cfdm/shared"; +import type { Db } from "@cfdm/db"; +import * as repos from "@cfdm/db/repos"; +import { + getAppSettingsSecrets, + touchVpsTrackerSync, +} from "@cfdm/db/settings-repo"; + +function fqdnToDisplay(hostname: string, zoneName: string): string { + if (hostname === "@" || !hostname.trim()) return zoneName; + return `${hostname}.${zoneName}`; +} + +export function buildServiceSyncBindings( + db: Db, + serviceId: number, + deletedBindingIds: number[] = [], +): CfdmBindingSyncItem[] { + const service = repos.getService(db, serviceId); + const serviceIps = repos.listServiceIps(db, serviceId); + const bindings = repos.listBindingsByService(db, serviceId); + const items: CfdmBindingSyncItem[] = bindings.map((binding) => { + const targetIps = repos.listBindingIps(db, binding.id); + const ips = + targetIps.length > 0 + ? targetIps + : serviceIps.length > 0 + ? serviceIps + : []; + return { + bindingId: binding.id, + serviceId: service.id, + serviceName: service.name, + serviceSlug: service.slug, + fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), + zoneName: binding.zone_name, + hostname: binding.hostname, + ips, + }; + }); + + for (const bindingId of deletedBindingIds) { + items.push({ + bindingId, + serviceId: service.id, + serviceName: service.name, + serviceSlug: service.slug, + fqdn: "", + zoneName: "", + hostname: "", + ips: [], + deleted: true, + }); + } + + return items; +} + +export async function syncServiceToVpsTracker( + db: Db, + serviceId: number, + deletedBindingIds: number[] = [], +): Promise { + const config = getAppSettingsSecrets(db); + if (!config.vpsTrackerSyncEnabled) return; + + const baseUrl = config.vpsTrackerUrl.replace(/\/$/, ""); + const token = config.vpsTrackerIntegrationToken; + if (!baseUrl || !token) return; + + const bindings = buildServiceSyncBindings(db, serviceId, deletedBindingIds); + if (bindings.length === 0) return; + + try { + const res = await fetch(`${baseUrl}/api/integrations/cfdm/sync-bindings`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ bindings }), + }); + if (res.ok) { + touchVpsTrackerSync(db); + } else { + console.warn( + `VPS Tracker sync failed (${res.status}): ${await res.text()}`, + ); + } + } catch (err) { + console.warn( + "VPS Tracker sync error:", + err instanceof Error ? err.message : err, + ); + } +} + +export async function pingVpsTracker(db: Db): Promise<{ + ok: boolean; + error?: string; +}> { + const config = getAppSettingsSecrets(db); + const baseUrl = config.vpsTrackerUrl.replace(/\/$/, ""); + const token = config.vpsTrackerIntegrationToken; + if (!baseUrl) return { ok: false, error: "Укажите URL VPS Tracker" }; + if (!token) return { ok: false, error: "Укажите integration token" }; + + try { + const res = await fetch(`${baseUrl}/api/integrations/cfdm/ping`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + return { + ok: false, + error: `HTTP ${res.status}: ${await res.text()}`, + }; + } + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Ошибка сети", + }; + } +} diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index d16a1fa..e806cb2 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -5,6 +5,7 @@ import { FolderTreeIcon, ServerIcon, ShieldCheckIcon, + SettingsIcon, } from 'lucide-react' import { AppSwitcher } from '@/components/app-switcher' import { NavUser } from '@/components/nav-user' @@ -30,6 +31,7 @@ const infrastructureNav = [ const operationsNav = [ { to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false }, { to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false }, + { to: '/settings/integrations', label: 'Настройки', icon: SettingsIcon, exact: false }, ] as const function NavSection({ diff --git a/apps/web/src/components/app-switcher.tsx b/apps/web/src/components/app-switcher.tsx index 5667c25..f467c15 100644 --- a/apps/web/src/components/app-switcher.tsx +++ b/apps/web/src/components/app-switcher.tsx @@ -16,13 +16,13 @@ import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react' import { APP_SWITCHER_ICONS, CURRENT_APP_ID, - getAppSwitcherConfig, getCurrentApp, } from '@/lib/app-switcher-config' +import { useAppSwitcherConfig } from '@/hooks/use-app-switcher' export function AppSwitcher() { const { isMobile } = useSidebar() - const config = getAppSwitcherConfig() + const { config, isLoading } = useAppSwitcherConfig() const current = getCurrentApp(config) const CurrentIcon = APP_SWITCHER_ICONS[current.icon] @@ -58,7 +58,7 @@ export function AppSwitcher() { sideOffset={4} >
- {config.menuLabel} + {isLoading ? 'Загрузка…' : config.menuLabel}
{config.apps.map((app) => { const Icon = APP_SWITCHER_ICONS[app.icon] diff --git a/apps/web/src/components/integrations/app-switcher-editor.tsx b/apps/web/src/components/integrations/app-switcher-editor.tsx new file mode 100644 index 0000000..63e4ca3 --- /dev/null +++ b/apps/web/src/components/integrations/app-switcher-editor.tsx @@ -0,0 +1,110 @@ +import { useFieldArray, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { PlusIcon, Trash2Icon } from 'lucide-react' +import { appSwitcherConfigSchema, type AppSwitcherConfig } from '@cfdm/shared' + +import { Button } from '@cfdm/ui/components/button' +import { AppCard, AppCardContent, AppCardDescription, AppCardHeader, AppCardTitle } from '@/components/app-card' +import { FieldGroup } from '@cfdm/ui/components/field' +import { Input } from '@cfdm/ui/components/input' +import { FormFieldSimple } from '@/components/form-field' +import { SelectField } from '@/components/select-field' +import { LoadingButton } from '@/components/loading-button' +import { APP_SWITCHER_ICONS, type AppSwitcherIconName } from '@/lib/app-switcher-config' + +const ICON_OPTIONS = (Object.keys(APP_SWITCHER_ICONS) as AppSwitcherIconName[]).map((icon) => ({ + value: icon, + label: icon, +})) + +export type AppSwitcherFormValues = AppSwitcherConfig + +interface AppSwitcherEditorProps { + defaultValues: AppSwitcherFormValues + onSave: (values: AppSwitcherFormValues) => void + isSaving?: boolean +} + +export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitcherEditorProps) { + const form = useForm({ + resolver: zodResolver(appSwitcherConfigSchema), + defaultValues, + }) + const { fields, append, remove } = useFieldArray({ control: form.control, name: 'apps' }) + + return ( + + + Связанные приложения + URL для переключателя в sidebar + + +
void form.handleSubmit((values) => onSave(values))(e)} + > + + + + + {fields.map((field, index) => ( +
+ + + + + + + + + + + + form.setValue(`apps.${index}.icon`, (v ?? 'server') as AppSwitcherIconName, { + shouldDirty: true, + }) + } + options={ICON_OPTIONS} + /> + +
+ +
+
+ ))} + +
+ + Сохранить приложения + +
+
+
+ ) +} diff --git a/apps/web/src/components/integrations/vps-tracker-integration-card.tsx b/apps/web/src/components/integrations/vps-tracker-integration-card.tsx new file mode 100644 index 0000000..26c39ac --- /dev/null +++ b/apps/web/src/components/integrations/vps-tracker-integration-card.tsx @@ -0,0 +1,126 @@ +import { useForm, Controller } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { toast } from 'sonner' + +import { AppCard, AppCardContent, AppCardDescription, AppCardHeader, AppCardTitle } from '@/components/app-card' +import { FieldGroup } from '@cfdm/ui/components/field' +import { Input } from '@cfdm/ui/components/input' +import { FormFieldSimple } from '@/components/form-field' +import { SelectField } from '@/components/select-field' +import { LoadingButton } from '@/components/loading-button' +import { AppButton } from '@/components/app-button' +import { api } from '@/lib/api-client' + +const formSchema = z.object({ + vpsTrackerUrl: z.string().optional().default(''), + vpsTrackerIntegrationToken: z.string().optional().default(''), + vpsTrackerSyncEnabled: z.boolean().default(false), +}) + +type FormValues = z.infer + +export interface AppSettingsView { + vpsTrackerUrl: string + vpsTrackerIntegrationTokenSet: boolean + vpsTrackerSyncEnabled: boolean + vpsTrackerLastSyncAt: string | null +} + +interface VpsTrackerIntegrationCardProps { + settings?: AppSettingsView + onSave: (values: FormValues) => void + isSaving?: boolean +} + +export function VpsTrackerIntegrationCard({ + settings, + onSave, + isSaving, +}: VpsTrackerIntegrationCardProps) { + const form = useForm({ + resolver: zodResolver(formSchema), + values: { + vpsTrackerUrl: settings?.vpsTrackerUrl ?? '', + vpsTrackerIntegrationToken: '', + vpsTrackerSyncEnabled: settings?.vpsTrackerSyncEnabled ?? false, + }, + }) + + async function handleTest() { + const result = await api.post<{ ok: boolean; error?: string }>( + '/api/v1/settings/vps-tracker/test', + ) + if (result.ok) toast.success('Связь с VPS Tracker установлена') + else toast.error(result.error ?? 'Ошибка проверки') + } + + return ( + + + VPS Tracker + + Исходящая синхронизация доменов и сервисов. URL API VPS Tracker (обычно порт 3001). + + + +
void form.handleSubmit((values) => onSave(values))(e)}> + + + + + + + + ( + + field.onChange((v ?? 'on') === 'on')} + options={[ + { value: 'on', label: 'Вкл' }, + { value: 'off', label: 'Выкл' }, + ]} + /> + + )} + /> + {settings?.vpsTrackerLastSyncAt ? ( +

+ Последний sync:{' '} + {new Date(settings.vpsTrackerLastSyncAt).toLocaleString('ru-RU')} +

+ ) : null} +
+
+ + Сохранить + + void handleTest()}> + Проверить связь + +
+
+
+
+ ) +} diff --git a/apps/web/src/components/select-field.tsx b/apps/web/src/components/select-field.tsx new file mode 100644 index 0000000..25a4ac0 --- /dev/null +++ b/apps/web/src/components/select-field.tsx @@ -0,0 +1,67 @@ +import * as React from 'react' +import type { SelectRootProps } from '@base-ui/react/select' + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@cfdm/ui/components/select' +import { cn } from '@cfdm/ui/lib/utils' + +export interface SelectOption { + value: string + label: React.ReactNode +} + +interface SelectFieldProps extends Omit, 'items' | 'value' | 'onValueChange'> { + options: SelectOption[] + placeholder?: string + triggerClassName?: string + triggerId?: string + size?: 'sm' | 'default' + value?: string | null + onValueChange?: (value: string | null) => void + invalid?: boolean + 'aria-label'?: string +} + +export function SelectField({ + options, + placeholder, + triggerClassName, + triggerId, + size = 'default', + value, + onValueChange, + invalid, + 'aria-label': ariaLabel, + ...props +}: SelectFieldProps) { + const items = React.useMemo( + () => options.map((o) => ({ value: o.value, label: o.label })), + [options], + ) + + return ( + + ) +} diff --git a/apps/web/src/hooks/use-app-switcher.ts b/apps/web/src/hooks/use-app-switcher.ts new file mode 100644 index 0000000..0bae667 --- /dev/null +++ b/apps/web/src/hooks/use-app-switcher.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query' +import type { AppSwitcherConfig } from '@cfdm/shared' +import { appSwitcherQueryOptions } from '@/queries/app-switcher' +import { getAppUrl as getAppUrlFromConfig } from '@/lib/app-switcher-config' + +import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config' + +export function useAppSwitcherConfig(): { + config: AppSwitcherConfig + isLoading: boolean +} { + const { data, isLoading } = useQuery(appSwitcherQueryOptions()) + return { + config: data ?? DEFAULT_APP_SWITCHER_CONFIG, + isLoading, + } +} + +export function useAppUrl(appId: string): string | undefined { + const { config } = useAppSwitcherConfig() + return getAppUrlFromConfig(appId, config) +} diff --git a/apps/web/src/lib/app-switcher-config.ts b/apps/web/src/lib/app-switcher-config.ts index 4c15b19..34e52ff 100644 --- a/apps/web/src/lib/app-switcher-config.ts +++ b/apps/web/src/lib/app-switcher-config.ts @@ -79,6 +79,13 @@ export function getAppSwitcherConfig(): AppSwitcherConfig { return parseAppSwitcherConfig(import.meta.env.VITE_APP_SWITCHER) } +export function getAppUrl( + appId: string, + config: AppSwitcherConfig = getAppSwitcherConfig(), +): string | undefined { + return config.apps.find((app) => app.id === appId)?.url +} + export function getCurrentApp( config: AppSwitcherConfig = getAppSwitcherConfig(), ): AppSwitcherEntry { diff --git a/apps/web/src/queries/app-switcher.ts b/apps/web/src/queries/app-switcher.ts new file mode 100644 index 0000000..ec3b1f0 --- /dev/null +++ b/apps/web/src/queries/app-switcher.ts @@ -0,0 +1,15 @@ +import { queryOptions } from '@tanstack/react-query' +import type { AppSwitcherConfig } from '@cfdm/shared' +import { api } from '@/lib/api-client' +import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config' + +export const appSwitcherQueryKey = ['app-switcher'] as const + +export function appSwitcherQueryOptions() { + return queryOptions({ + queryKey: appSwitcherQueryKey, + queryFn: () => api.get('/api/v1/settings/app-switcher'), + staleTime: 60_000, + placeholderData: DEFAULT_APP_SWITCHER_CONFIG, + }) +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 94edb59..0dd68ba 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -15,7 +15,10 @@ import { Route as AuthIndexRouteImport } from './routes/_auth/index' import { Route as AuthServicesRouteImport } from './routes/_auth/services' import { Route as AuthGroupsRouteImport } from './routes/_auth/groups' import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates' +import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route' +import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index' import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index' +import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations' import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId' import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index' import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns' @@ -49,11 +52,27 @@ const AuthCertificatesRoute = AuthCertificatesRouteImport.update({ path: '/certificates', getParentRoute: () => AuthRoute, } as any) +const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AuthRoute, +} as any) +const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthSettingsRouteRoute, +} as any) const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({ id: '/domains/', path: '/domains/', getParentRoute: () => AuthRoute, } as any) +const AuthSettingsIntegrationsRoute = + AuthSettingsIntegrationsRouteImport.update({ + id: '/integrations', + path: '/integrations', + getParentRoute: () => AuthSettingsRouteRoute, + } as any) const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({ id: '/$groupId', path: '/$groupId', @@ -74,11 +93,14 @@ const AuthDomainsDomainIdDnsRoute = AuthDomainsDomainIdDnsRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof AuthIndexRoute '/login': typeof LoginRoute + '/settings': typeof AuthSettingsRouteRouteWithChildren '/certificates': typeof AuthCertificatesRoute '/groups': typeof AuthGroupsRouteWithChildren '/services': typeof AuthServicesRoute '/groups/$groupId': typeof AuthGroupsGroupIdRoute + '/settings/integrations': typeof AuthSettingsIntegrationsRoute '/domains/': typeof AuthDomainsIndexRoute + '/settings/': typeof AuthSettingsIndexRoute '/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute '/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute } @@ -89,7 +111,9 @@ export interface FileRoutesByTo { '/services': typeof AuthServicesRoute '/': typeof AuthIndexRoute '/groups/$groupId': typeof AuthGroupsGroupIdRoute + '/settings/integrations': typeof AuthSettingsIntegrationsRoute '/domains': typeof AuthDomainsIndexRoute + '/settings': typeof AuthSettingsIndexRoute '/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute '/domains/$domainId': typeof AuthDomainsDomainIdIndexRoute } @@ -97,12 +121,15 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/_auth': typeof AuthRouteWithChildren '/login': typeof LoginRoute + '/_auth/settings': typeof AuthSettingsRouteRouteWithChildren '/_auth/certificates': typeof AuthCertificatesRoute '/_auth/groups': typeof AuthGroupsRouteWithChildren '/_auth/services': typeof AuthServicesRoute '/_auth/': typeof AuthIndexRoute '/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute + '/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute '/_auth/domains/': typeof AuthDomainsIndexRoute + '/_auth/settings/': typeof AuthSettingsIndexRoute '/_auth/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute '/_auth/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute } @@ -111,11 +138,14 @@ export interface FileRouteTypes { fullPaths: | '/' | '/login' + | '/settings' | '/certificates' | '/groups' | '/services' | '/groups/$groupId' + | '/settings/integrations' | '/domains/' + | '/settings/' | '/domains/$domainId/dns' | '/domains/$domainId/' fileRoutesByTo: FileRoutesByTo @@ -126,19 +156,24 @@ export interface FileRouteTypes { | '/services' | '/' | '/groups/$groupId' + | '/settings/integrations' | '/domains' + | '/settings' | '/domains/$domainId/dns' | '/domains/$domainId' id: | '__root__' | '/_auth' | '/login' + | '/_auth/settings' | '/_auth/certificates' | '/_auth/groups' | '/_auth/services' | '/_auth/' | '/_auth/groups/$groupId' + | '/_auth/settings/integrations' | '/_auth/domains/' + | '/_auth/settings/' | '/_auth/domains/$domainId/dns' | '/_auth/domains/$domainId/' fileRoutesById: FileRoutesById @@ -192,6 +227,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthCertificatesRouteImport parentRoute: typeof AuthRoute } + '/_auth/settings': { + id: '/_auth/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof AuthSettingsRouteRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/settings/': { + id: '/_auth/settings/' + path: '/' + fullPath: '/settings/' + preLoaderRoute: typeof AuthSettingsIndexRouteImport + parentRoute: typeof AuthSettingsRouteRoute + } '/_auth/domains/': { id: '/_auth/domains/' path: '/domains' @@ -199,6 +248,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthDomainsIndexRouteImport parentRoute: typeof AuthRoute } + '/_auth/settings/integrations': { + id: '/_auth/settings/integrations' + path: '/integrations' + fullPath: '/settings/integrations' + preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport + parentRoute: typeof AuthSettingsRouteRoute + } '/_auth/groups/$groupId': { id: '/_auth/groups/$groupId' path: '/$groupId' @@ -223,6 +279,19 @@ declare module '@tanstack/react-router' { } } +interface AuthSettingsRouteRouteChildren { + AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute + AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute +} + +const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = { + AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute, + AuthSettingsIndexRoute: AuthSettingsIndexRoute, +} + +const AuthSettingsRouteRouteWithChildren = + AuthSettingsRouteRoute._addFileChildren(AuthSettingsRouteRouteChildren) + interface AuthGroupsRouteChildren { AuthGroupsGroupIdRoute: typeof AuthGroupsGroupIdRoute } @@ -236,6 +305,7 @@ const AuthGroupsRouteWithChildren = AuthGroupsRoute._addFileChildren( ) interface AuthRouteChildren { + AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren AuthCertificatesRoute: typeof AuthCertificatesRoute AuthGroupsRoute: typeof AuthGroupsRouteWithChildren AuthServicesRoute: typeof AuthServicesRoute @@ -246,6 +316,7 @@ interface AuthRouteChildren { } const AuthRouteChildren: AuthRouteChildren = { + AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren, AuthCertificatesRoute: AuthCertificatesRoute, AuthGroupsRoute: AuthGroupsRouteWithChildren, AuthServicesRoute: AuthServicesRoute, diff --git a/apps/web/src/routes/_auth/settings/index.tsx b/apps/web/src/routes/_auth/settings/index.tsx new file mode 100644 index 0000000..b11ca7c --- /dev/null +++ b/apps/web/src/routes/_auth/settings/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/_auth/settings/')({ + beforeLoad: () => { + throw redirect({ to: '/settings/integrations' }) + }, +}) diff --git a/apps/web/src/routes/_auth/settings/integrations.tsx b/apps/web/src/routes/_auth/settings/integrations.tsx new file mode 100644 index 0000000..e30e30b --- /dev/null +++ b/apps/web/src/routes/_auth/settings/integrations.tsx @@ -0,0 +1,74 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import type { AppSettingsPatch } from '@cfdm/shared' + +import { api } from '@/lib/api-client' +import { QueryState } from '@/components/query-state' +import { AppSwitcherEditor } from '@/components/integrations/app-switcher-editor' +import { + VpsTrackerIntegrationCard, + type AppSettingsView, +} from '@/components/integrations/vps-tracker-integration-card' +import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config' +import { appSwitcherQueryKey } from '@/queries/app-switcher' + +type SettingsResponse = AppSettingsView & { + id: string + appSwitcher: typeof DEFAULT_APP_SWITCHER_CONFIG +} + +export const Route = createFileRoute('/_auth/settings/integrations')({ + component: SettingsIntegrationsPage, +}) + +function SettingsIntegrationsPage() { + const queryClient = useQueryClient() + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: ['app-settings'], + queryFn: () => api.get('/api/v1/settings'), + }) + + const saveMut = useMutation({ + mutationFn: (patch: AppSettingsPatch) => + api.patch('/api/v1/settings', patch), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['app-settings'] }) + await queryClient.invalidateQueries({ queryKey: appSwitcherQueryKey }) + toast.success('Настройки сохранены') + }, + onError: () => toast.error('Не удалось сохранить'), + }) + + return ( + refetch()} + > + {data ? ( +
+ saveMut.mutate({ appSwitcher })} + /> + { + const patch: AppSettingsPatch = { + vpsTrackerUrl: values.vpsTrackerUrl, + vpsTrackerSyncEnabled: values.vpsTrackerSyncEnabled, + } + const token = values.vpsTrackerIntegrationToken?.trim() + if (token) patch.vpsTrackerIntegrationToken = token + saveMut.mutate(patch) + }} + /> +
+ ) : null} +
+ ) +} diff --git a/apps/web/src/routes/_auth/settings/route.tsx b/apps/web/src/routes/_auth/settings/route.tsx new file mode 100644 index 0000000..1cc4a40 --- /dev/null +++ b/apps/web/src/routes/_auth/settings/route.tsx @@ -0,0 +1,40 @@ +import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router' +import { cn } from '@cfdm/ui/lib/utils' +import { PageShell } from '@/components/page-shell' +import { PageHeader } from '@/components/page-header' + +export const Route = createFileRoute('/_auth/settings')({ + component: SettingsLayout, +}) + +const TABS = [{ to: '/settings/integrations', label: 'Интеграции' }] as const + +function SettingsLayout() { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + + return ( + + + + + + ) +} diff --git a/packages/db/dist/index.d.ts b/packages/db/dist/index.d.ts index 5510403..d9e712f 100644 --- a/packages/db/dist/index.d.ts +++ b/packages/db/dist/index.d.ts @@ -1,7 +1,7 @@ import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core'; import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; -import { LbMode, HealthCheckType, ServiceBinding, Domain, Group, Service, ServiceGroup, Subdomain, HealthCheckScope, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared'; +import { AppSwitcherConfig, LbMode, HealthCheckType, ServiceBinding, Domain, Group, Service, ServiceGroup, Subdomain, HealthCheckScope, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared'; declare const groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: "groups"; @@ -2226,6 +2226,163 @@ declare const ipHealthStatus: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ }; dialect: "sqlite"; }>; +declare const appSettings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "app_settings"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "app_settings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + app_switcher_json: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "app_switcher_json"; + tableName: "app_settings"; + 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; + }>; + vps_tracker_url: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "vps_tracker_url"; + tableName: "app_settings"; + 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; + }>; + vps_tracker_integration_token: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "vps_tracker_integration_token"; + tableName: "app_settings"; + 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; + }>; + vps_tracker_sync_enabled: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "vps_tracker_sync_enabled"; + tableName: "app_settings"; + 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; + }, {}, {}>; + vps_tracker_last_sync_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "vps_tracker_last_sync_at"; + tableName: "app_settings"; + 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; + }>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "app_settings"; + 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; + }>; + updated_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; + tableName: "app_settings"; + 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; + }>; + }; + dialect: "sqlite"; +}>; declare const schema: { groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: "groups"; @@ -4450,6 +4607,163 @@ declare const schema: { }; dialect: "sqlite"; }>; + appSettings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: "app_settings"; + schema: undefined; + columns: { + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: "app_settings"; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + app_switcher_json: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "app_switcher_json"; + tableName: "app_settings"; + 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; + }>; + vps_tracker_url: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "vps_tracker_url"; + tableName: "app_settings"; + 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; + }>; + vps_tracker_integration_token: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "vps_tracker_integration_token"; + tableName: "app_settings"; + 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; + }>; + vps_tracker_sync_enabled: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "vps_tracker_sync_enabled"; + tableName: "app_settings"; + 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; + }, {}, {}>; + vps_tracker_last_sync_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "vps_tracker_last_sync_at"; + tableName: "app_settings"; + 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; + }>; + created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: "app_settings"; + 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; + }>; + updated_at: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; + tableName: "app_settings"; + 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; + }>; + }; + dialect: "sqlite"; + }>; }; type Sqlite = Database.Database; @@ -4473,6 +4787,30 @@ declare class ConflictError extends Error { constructor(message: string); } +type AppSettingsDto = { + id: string; + appSwitcher: AppSwitcherConfig; + vpsTrackerUrl: string; + vpsTrackerIntegrationTokenSet: boolean; + vpsTrackerSyncEnabled: boolean; + vpsTrackerLastSyncAt: string | null; +}; +type AppSettingsPatch = { + appSwitcher?: AppSwitcherConfig; + vpsTrackerUrl?: string; + vpsTrackerIntegrationToken?: string; + vpsTrackerSyncEnabled?: boolean; +}; +declare function getAppSettings(db: Db): AppSettingsDto; +declare function getAppSettingsSecrets(db: Db): { + vpsTrackerUrl: string; + vpsTrackerIntegrationToken: string; + vpsTrackerSyncEnabled: boolean; +}; +declare function updateAppSettings(db: Db, patch: AppSettingsPatch): AppSettingsDto; +declare function touchVpsTrackerSync(db: Db): void; +declare function getAppSwitcher(db: Db): AppSwitcherConfig; + interface DnsListFilter { record_type?: string; name?: string; @@ -4705,4 +5043,4 @@ declare namespace repos { export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, 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_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, 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_getIpHealthStatusRow as getIpHealthStatusRow, 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_listBindingIpsWithMeta as listBindingIpsWithMeta, 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_listHealthCheckTargets as listHealthCheckTargets, repos_listIpHealthStatus as listIpHealthStatus, 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_replaceBindingIpsWithMeta as replaceBindingIpsWithMeta, 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_setServiceLb as setServiceLb, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingFields as updateBindingFields, repos_updateBindingLbConfig as updateBindingLbConfig, 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_upsertIpHealthStatus as upsertIpHealthStatus, repos_upsertSubdomain as upsertSubdomain }; } -export { ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, certificates, createDb, createMemoryDb, dnsRecords, domains, groups, healthCheck, ipHealthStatus, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs }; +export { type AppSettingsDto, type AppSettingsPatch, ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, certificates, createDb, createMemoryDb, dnsRecords, domains, getAppSettings, getAppSettingsSecrets, getAppSwitcher, groups, healthCheck, ipHealthStatus, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings }; diff --git a/packages/db/dist/index.js b/packages/db/dist/index.js index 8ccfd15..3b1c972 100644 --- a/packages/db/dist/index.js +++ b/packages/db/dist/index.js @@ -184,6 +184,18 @@ var ipHealthStatus = sqliteTable( }, (t) => [primaryKey({ columns: [t.scope, t.ref_id, t.ip] })] ); +var appSettings = sqliteTable("app_settings", { + id: text("id").primaryKey(), + app_switcher_json: text("app_switcher_json"), + vps_tracker_url: text("vps_tracker_url"), + vps_tracker_integration_token: text("vps_tracker_integration_token"), + vps_tracker_sync_enabled: integer("vps_tracker_sync_enabled", { + mode: "boolean" + }).notNull().default(false), + vps_tracker_last_sync_at: text("vps_tracker_last_sync_at"), + created_at: text("created_at").notNull().default(sql`datetime('now')`), + updated_at: text("updated_at").notNull().default(sql`datetime('now')`) +}); var schema = { groups, services, @@ -198,7 +210,8 @@ var schema = { serviceGroupDnsRecords, certificates, syncJobs, - ipHealthStatus + ipHealthStatus, + appSettings }; // src/client.ts @@ -263,6 +276,94 @@ var ConflictError = class extends Error { } }; +// src/settings-repo.ts +import { eq } from "drizzle-orm"; +import { appSwitcherConfigSchema } from "@cfdm/shared"; +var SETTINGS_ID = "settings-main"; +var DEFAULT_APP_SWITCHER = { + menuLabel: "\u041F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044F", + apps: [ + { + id: "vps-tracker", + name: "VPS Tracker", + subtitle: "\u0423\u0447\u0451\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043B\u044C\u043D\u044B\u0445 \u0441\u0435\u0440\u0432\u0435\u0440\u043E\u0432", + url: "http://192.168.100.67:3001", + icon: "server", + shortcut: "\u23181" + }, + { + id: "cfdm", + name: "CF Domain Manager", + subtitle: "\u0423\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u0434\u043E\u043C\u0435\u043D\u0430\u043C\u0438", + url: "http://192.168.100.67:6363", + icon: "cloud", + shortcut: "\u23182" + } + ] +}; +function parseAppSwitcher(raw) { + if (!raw?.trim()) return DEFAULT_APP_SWITCHER; + try { + return appSwitcherConfigSchema.parse(JSON.parse(raw)); + } catch { + return DEFAULT_APP_SWITCHER; + } +} +function toDto(row) { + return { + id: row.id, + appSwitcher: parseAppSwitcher(row.app_switcher_json), + vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "", + vpsTrackerIntegrationTokenSet: Boolean( + row.vps_tracker_integration_token?.trim() + ), + vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled), + vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at + }; +} +function getAppSettings(db) { + const row = db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get(); + if (!row) { + db.insert(appSettings).values({ id: SETTINGS_ID }).run(); + return toDto( + db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get() + ); + } + return toDto(row); +} +function getAppSettingsSecrets(db) { + const row = db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get(); + return { + vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "", + vpsTrackerIntegrationToken: row?.vps_tracker_integration_token?.trim() ?? "", + vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled) + }; +} +function updateAppSettings(db, patch) { + const existing = db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get(); + if (!existing) { + db.insert(appSettings).values({ id: SETTINGS_ID }).run(); + } + const current = db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get(); + db.update(appSettings).set({ + app_switcher_json: patch.appSwitcher !== void 0 ? JSON.stringify(patch.appSwitcher) : current.app_switcher_json, + vps_tracker_url: patch.vpsTrackerUrl !== void 0 ? patch.vpsTrackerUrl : current.vps_tracker_url, + vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token, + vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled, + updated_at: (/* @__PURE__ */ new Date()).toISOString() + }).where(eq(appSettings.id, SETTINGS_ID)).run(); + return getAppSettings(db); +} +function touchVpsTrackerSync(db) { + db.update(appSettings).set({ + vps_tracker_last_sync_at: (/* @__PURE__ */ new Date()).toISOString(), + updated_at: (/* @__PURE__ */ new Date()).toISOString() + }).where(eq(appSettings.id, SETTINGS_ID)).run(); +} +function getAppSwitcher(db) { + return getAppSettings(db).appSwitcher; +} + // src/repos.ts var repos_exports = {}; __export(repos_exports, { @@ -357,12 +458,12 @@ __export(repos_exports, { upsertSubdomain: () => upsertSubdomain }); import { dnsRecordNamesMatch } from "@cfdm/shared"; -import { and, asc, count, eq, isNull, like, notInArray, or, sql as sql2 } from "drizzle-orm"; +import { and, asc, count, eq as eq2, isNull, like, notInArray, or, sql as sql2 } from "drizzle-orm"; function listGroups(db) { return db.select().from(groups).orderBy(asc(groups.name)).all(); } function getGroup(db, id) { - const row = db.select().from(groups).where(eq(groups.id, id)).get(); + const row = db.select().from(groups).where(eq2(groups.id, id)).get(); if (!row) throw new NotFoundError(`group ${id}`); return row; } @@ -380,17 +481,17 @@ function createGroup(db, name, slug) { return getGroup(db, id); } function updateGroup(db, id, name, slug) { - const result = db.update(groups).set({ name, slug, updated_at: sql2`datetime('now')` }).where(eq(groups.id, id)).run(); + const result = db.update(groups).set({ name, slug, updated_at: sql2`datetime('now')` }).where(eq2(groups.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`group ${id}`); return getGroup(db, id); } function deleteGroup(db, id) { - const result = db.delete(groups).where(eq(groups.id, id)).run(); + const result = db.delete(groups).where(eq2(groups.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`group ${id}`); } function listDomains(db, groupId) { if (groupId != null) { - return db.select().from(domains).where(eq(domains.group_id, groupId)).orderBy(asc(domains.zone_name)).all(); + return db.select().from(domains).where(eq2(domains.group_id, groupId)).orderBy(asc(domains.zone_name)).all(); } return db.select().from(domains).orderBy(asc(domains.zone_name)).all(); } @@ -412,7 +513,7 @@ function findDomainByZoneName(db, zoneName) { return rows[0] ?? null; } function getDomain(db, id) { - const row = db.select().from(domains).where(eq(domains.id, id)).get(); + const row = db.select().from(domains).where(eq2(domains.id, id)).get(); if (!row) throw new NotFoundError(`domain ${id}`); return row; } @@ -433,33 +534,33 @@ function updateDomain(db, id, groupId, status, certMonitoring) { if (certMonitoring !== void 0) { updates.cert_monitoring = certMonitoring; } - const result = db.update(domains).set(updates).where(eq(domains.id, id)).run(); + const result = db.update(domains).set(updates).where(eq2(domains.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`domain ${id}`); return getDomain(db, id); } function deleteDomain(db, id) { - const result = db.delete(domains).where(eq(domains.id, id)).run(); + const result = db.delete(domains).where(eq2(domains.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`domain ${id}`); } function setDomainLastSynced(db, id) { db.update(domains).set({ last_synced_at: sql2`datetime('now')`, updated_at: sql2`datetime('now')` - }).where(eq(domains.id, id)).run(); + }).where(eq2(domains.id, id)).run(); } function listAllDomains(db) { return listDomains(db); } function listSubdomainsByDomain(db, domainId) { - return db.select().from(subdomains).where(eq(subdomains.domain_id, domainId)).orderBy(asc(subdomains.name)).all(); + return db.select().from(subdomains).where(eq2(subdomains.domain_id, domainId)).orderBy(asc(subdomains.name)).all(); } function getSubdomain(db, id) { - const row = db.select().from(subdomains).where(eq(subdomains.id, id)).get(); + const row = db.select().from(subdomains).where(eq2(subdomains.id, id)).get(); 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(); + const row = db.select().from(subdomains).where(and(eq2(subdomains.domain_id, domainId), eq2(subdomains.name, name))).get(); return row ?? null; } function upsertSubdomain(db, domainId, name, fqdn) { @@ -483,12 +584,12 @@ function updateSubdomain(db, id, patch) { 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(); + const result = db.update(subdomains).set(updates).where(eq2(subdomains.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`); return getSubdomain(db, id); } function deleteSubdomain(db, id) { - const result = db.delete(subdomains).where(eq(subdomains.id, id)).run(); + const result = db.delete(subdomains).where(eq2(subdomains.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`); } function listAllSubdomains(db) { @@ -498,9 +599,9 @@ function mapDnsRecord(row) { return row; } function listDnsRecords(db, domainId, filter = {}) { - const conditions = [eq(dnsRecords.domain_id, domainId)]; + const conditions = [eq2(dnsRecords.domain_id, domainId)]; if (filter.record_type) { - conditions.push(eq(dnsRecords.record_type, filter.record_type.toUpperCase())); + conditions.push(eq2(dnsRecords.record_type, filter.record_type.toUpperCase())); } if (filter.name) { conditions.push(like(dnsRecords.name, `%${filter.name}%`)); @@ -509,10 +610,10 @@ function listDnsRecords(db, domainId, filter = {}) { conditions.push(like(dnsRecords.content, `%${filter.content}%`)); } if (filter.proxied != null) { - conditions.push(eq(dnsRecords.proxied, filter.proxied)); + conditions.push(eq2(dnsRecords.proxied, filter.proxied)); } if (filter.sync_status) { - conditions.push(eq(dnsRecords.sync_status, filter.sync_status)); + conditions.push(eq2(dnsRecords.sync_status, filter.sync_status)); } if (filter.q) { const pat = `%${filter.q}%`; @@ -531,7 +632,7 @@ function listDnsRecords(db, domainId, filter = {}) { return db.select().from(dnsRecords).where(and(...conditions)).orderBy(asc(sortCol)).limit(limit).offset(offset).all().map(mapDnsRecord); } function getDnsRecord(db, domainId, id) { - const row = db.select().from(dnsRecords).where(and(eq(dnsRecords.id, id), eq(dnsRecords.domain_id, domainId))).get(); + const row = db.select().from(dnsRecords).where(and(eq2(dnsRecords.id, id), eq2(dnsRecords.domain_id, domainId))).get(); if (!row) throw new NotFoundError(`dns record ${id}`); return mapDnsRecord(row); } @@ -562,7 +663,7 @@ function updateDnsFields(db, id, recordType, name, content, ttl, proxied, priori sync_status: syncStatus, last_error: lastError, updated_at: sql2`datetime('now')` - }).where(eq(dnsRecords.id, id)).run(); + }).where(eq2(dnsRecords.id, id)).run(); } function setDnsSyncStatus(db, id, syncStatus, cfRecordId, lastError) { db.update(dnsRecords).set({ @@ -570,19 +671,19 @@ function setDnsSyncStatus(db, id, syncStatus, cfRecordId, lastError) { cf_record_id: cfRecordId, last_error: lastError, updated_at: sql2`datetime('now')` - }).where(eq(dnsRecords.id, id)).run(); + }).where(eq2(dnsRecords.id, id)).run(); } function deleteDnsRecord(db, id) { - db.delete(dnsRecords).where(eq(dnsRecords.id, id)).run(); + db.delete(dnsRecords).where(eq2(dnsRecords.id, id)).run(); } function listDnsByDomain(db, domainId) { - return db.select().from(dnsRecords).where(eq(dnsRecords.domain_id, domainId)).all().map(mapDnsRecord); + return db.select().from(dnsRecords).where(eq2(dnsRecords.domain_id, domainId)).all().map(mapDnsRecord); } function findDnsByCfId(db, domainId, cfRecordId) { const row = db.select().from(dnsRecords).where( and( - eq(dnsRecords.domain_id, domainId), - eq(dnsRecords.cf_record_id, cfRecordId) + eq2(dnsRecords.domain_id, domainId), + eq2(dnsRecords.cf_record_id, cfRecordId) ) ).get(); return row ? mapDnsRecord(row) : null; @@ -591,7 +692,7 @@ 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 condition = groupId === null ? isNull(services.service_group_id) : eq2(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; } @@ -599,13 +700,13 @@ function listServices(db) { 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.sort_order), asc(services.name)).all(); + return db.select().from(services).where(eq2(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.sort_order), asc(services.name)).all(); } function getService(db, id) { - const row = db.select().from(services).where(eq(services.id, id)).get(); + const row = db.select().from(services).where(eq2(services.id, id)).get(); if (!row) throw new NotFoundError(`service ${id}`); return row; } @@ -620,11 +721,11 @@ function updateService(db, id, name, slug) { slug, subdomain: slug, updated_at: sql2`datetime('now')` - }).where(eq(services.id, id)).run(); + }).where(eq2(services.id, id)).run(); return getService(db, id); } function setServiceEnabled(db, id, enabled) { - db.update(services).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq(services.id, id)).run(); + db.update(services).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq2(services.id, id)).run(); return getService(db, id); } function setServiceLb(db, id, weight, priority) { @@ -632,7 +733,7 @@ function setServiceLb(db, id, weight, priority) { lb_weight: weight, lb_priority: priority, updated_at: sql2`datetime('now')` - }).where(eq(services.id, id)).run(); + }).where(eq2(services.id, id)).run(); } function setServiceGroup(db, id, groupId) { const sortOrder = maxSortOrderInGroup(db, groupId) + 1; @@ -640,14 +741,14 @@ function setServiceGroup(db, id, groupId) { service_group_id: groupId, sort_order: sortOrder, updated_at: sql2`datetime('now')` - }).where(eq(services.id, id)).run(); + }).where(eq2(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 condition = groupId === null ? isNull(services.service_group_id) : eq2(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) { @@ -657,12 +758,12 @@ function reorderServices(db, groupId, orderedIds) { } 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(); + tx.update(services).set({ sort_order: index, updated_at: sql2`datetime('now')` }).where(eq2(services.id, orderedIds[index])).run(); } }); } function deleteService(db, id) { - const result = db.delete(services).where(eq(services.id, id)).run(); + const result = db.delete(services).where(eq2(services.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service ${id}`); } function mapServiceGroup(row) { @@ -689,7 +790,7 @@ function listServiceGroups(db) { return db.select().from(serviceGroups).orderBy(asc(serviceGroups.name)).all().map(mapServiceGroup); } function getServiceGroup(db, id) { - const row = db.select().from(serviceGroups).where(eq(serviceGroups.id, id)).get(); + const row = db.select().from(serviceGroups).where(eq2(serviceGroups.id, id)).get(); if (!row) throw new NotFoundError(`service group ${id}`); return mapServiceGroup(row); } @@ -735,37 +836,37 @@ function updateServiceGroup(db, id, name, groupType, icon, domain, lbPatch) { if (lbPatch.health_check_timeout_ms !== void 0) update.health_check_timeout_ms = lbPatch.health_check_timeout_ms; } - const result = db.update(serviceGroups).set(update).where(eq(serviceGroups.id, id)).run(); + const result = db.update(serviceGroups).set(update).where(eq2(serviceGroups.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service group ${id}`); return getServiceGroup(db, id); } function setServiceGroupEnabled(db, id, enabled) { - const result = db.update(serviceGroups).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq(serviceGroups.id, id)).run(); + const result = db.update(serviceGroups).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq2(serviceGroups.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service group ${id}`); return getServiceGroup(db, id); } function deleteServiceGroup(db, id) { - const result = db.delete(serviceGroups).where(eq(serviceGroups.id, id)).run(); + const result = db.delete(serviceGroups).where(eq2(serviceGroups.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service group ${id}`); } function listServiceIps(db, serviceId) { - return db.select({ ip: serviceIps.ip }).from(serviceIps).where(eq(serviceIps.service_id, serviceId)).all().map((r) => r.ip); + return db.select({ ip: serviceIps.ip }).from(serviceIps).where(eq2(serviceIps.service_id, serviceId)).all().map((r) => r.ip); } function replaceServiceIps(db, serviceId, ips) { - db.delete(serviceIps).where(eq(serviceIps.service_id, serviceId)).run(); + db.delete(serviceIps).where(eq2(serviceIps.service_id, serviceId)).run(); for (const ip of ips) { db.insert(serviceIps).values({ service_id: serviceId, ip }).run(); } } function listBindingIps(db, bindingId) { - return db.select({ ip: serviceBindingIps.ip }).from(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).all().map((r) => r.ip); + return db.select({ ip: serviceBindingIps.ip }).from(serviceBindingIps).where(eq2(serviceBindingIps.binding_id, bindingId)).all().map((r) => r.ip); } function listBindingIpsWithMeta(db, bindingId) { return db.select({ ip: serviceBindingIps.ip, weight: serviceBindingIps.weight, priority: serviceBindingIps.priority - }).from(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).all(); + }).from(serviceBindingIps).where(eq2(serviceBindingIps.binding_id, bindingId)).all(); } function replaceBindingIps(db, bindingId, ips) { replaceBindingIpsWithMeta( @@ -775,7 +876,7 @@ function replaceBindingIps(db, bindingId, ips) { ); } function replaceBindingIpsWithMeta(db, bindingId, entries) { - db.delete(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).run(); + db.delete(serviceBindingIps).where(eq2(serviceBindingIps.binding_id, bindingId)).run(); for (const entry of entries) { db.insert(serviceBindingIps).values({ binding_id: bindingId, @@ -804,13 +905,13 @@ function updateBindingLbConfig(db, bindingId, patch) { update.health_check_interval_sec = patch.health_check_interval_sec; if (patch.health_check_timeout_ms !== void 0) update.health_check_timeout_ms = patch.health_check_timeout_ms; - db.update(serviceBindings).set(update).where(eq(serviceBindings.id, bindingId)).run(); + db.update(serviceBindings).set(update).where(eq2(serviceBindings.id, bindingId)).run(); } function setBindingCnameTarget(db, bindingId, target) { db.update(serviceBindings).set({ cname_target: target, updated_at: sql2`datetime('now')` - }).where(eq(serviceBindings.id, bindingId)).run(); + }).where(eq2(serviceBindings.id, bindingId)).run(); } function listRecordsForBinding(db, bindingId) { return db.all(sql2` @@ -829,8 +930,8 @@ function linkBindingRecord(db, bindingId, dnsRecordId) { function unlinkBindingRecord(db, bindingId, dnsRecordId) { db.delete(serviceBindingRecords).where( and( - eq(serviceBindingRecords.binding_id, bindingId), - eq(serviceBindingRecords.dns_record_id, dnsRecordId) + eq2(serviceBindingRecords.binding_id, bindingId), + eq2(serviceBindingRecords.dns_record_id, dnsRecordId) ) ).run(); } @@ -851,8 +952,8 @@ function linkGroupDnsRecord(db, groupId, dnsRecordId) { function unlinkGroupDnsRecord(db, groupId, dnsRecordId) { db.delete(serviceGroupDnsRecords).where( and( - eq(serviceGroupDnsRecords.group_id, groupId), - eq(serviceGroupDnsRecords.dns_record_id, dnsRecordId) + eq2(serviceGroupDnsRecords.group_id, groupId), + eq2(serviceGroupDnsRecords.dns_record_id, dnsRecordId) ) ).run(); } @@ -942,7 +1043,7 @@ function listBindingsByService(db, serviceId) { `); } function getBinding(db, id) { - const row = db.select().from(serviceBindings).where(eq(serviceBindings.id, id)).get(); + const row = db.select().from(serviceBindings).where(eq2(serviceBindings.id, id)).get(); if (!row) throw new NotFoundError(`service binding ${id}`); return row; } @@ -962,9 +1063,9 @@ function getBindingView(db, id) { function findBinding(db, serviceId, domainId, hostname) { const row = db.select().from(serviceBindings).where( and( - eq(serviceBindings.service_id, serviceId), - eq(serviceBindings.domain_id, domainId), - eq(serviceBindings.hostname, hostname) + eq2(serviceBindings.service_id, serviceId), + eq2(serviceBindings.domain_id, domainId), + eq2(serviceBindings.hostname, hostname) ) ).get(); return row ?? null; @@ -984,43 +1085,43 @@ function updateBindingFields(db, id, serviceId, hostname, dnsRecordId) { hostname, dns_record_id: dnsRecordId, updated_at: sql2`datetime('now')` - }).where(eq(serviceBindings.id, id)).run(); + }).where(eq2(serviceBindings.id, id)).run(); } function setBindingDnsRecordId(db, bindingId, dnsRecordId) { db.update(serviceBindings).set({ dns_record_id: dnsRecordId, updated_at: sql2`datetime('now')` - }).where(eq(serviceBindings.id, bindingId)).run(); + }).where(eq2(serviceBindings.id, bindingId)).run(); } function bindingsToRemove(db, serviceId, keepIds) { - const all = db.select().from(serviceBindings).where(eq(serviceBindings.service_id, serviceId)).all(); + const all = db.select().from(serviceBindings).where(eq2(serviceBindings.service_id, serviceId)).all(); return all.filter((b) => !keepIds.includes(b.id)); } function deleteBindingsExcept(db, serviceId, keepIds) { - const all = db.select().from(serviceBindings).where(eq(serviceBindings.service_id, serviceId)).all(); + const all = db.select().from(serviceBindings).where(eq2(serviceBindings.service_id, serviceId)).all(); for (const binding of all) { if (!keepIds.includes(binding.id)) { - db.delete(serviceBindings).where(eq(serviceBindings.id, binding.id)).run(); + db.delete(serviceBindings).where(eq2(serviceBindings.id, binding.id)).run(); } } } function deleteBinding(db, id) { - const result = db.delete(serviceBindings).where(eq(serviceBindings.id, id)).run(); + const result = db.delete(serviceBindings).where(eq2(serviceBindings.id, id)).run(); if (result.changes === 0) throw new NotFoundError(`service binding ${id}`); } function listCertificates(db, status) { if (status) { - return db.select().from(certificates).where(eq(certificates.status, status)).orderBy(asc(certificates.expires_at)).all(); + return db.select().from(certificates).where(eq2(certificates.status, status)).orderBy(asc(certificates.expires_at)).all(); } return db.select().from(certificates).orderBy(asc(certificates.expires_at)).all(); } function getCertificate(db, id) { - const row = db.select().from(certificates).where(eq(certificates.id, id)).get(); + const row = db.select().from(certificates).where(eq2(certificates.id, id)).get(); if (!row) throw new NotFoundError(`certificate ${id}`); return row; } function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError) { - const existing = db.select().from(certificates).where(eq(certificates.hostname, hostname)).get(); + const existing = db.select().from(certificates).where(eq2(certificates.hostname, hostname)).get(); if (existing) { db.update(certificates).set({ domain_id: domainId, @@ -1030,7 +1131,7 @@ function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, last_error: lastError, status, updated_at: sql2`datetime('now')` - }).where(eq(certificates.id, existing.id)).run(); + }).where(eq2(certificates.id, existing.id)).run(); return getCertificate(db, existing.id); } const id = db.insert(certificates).values({ @@ -1063,7 +1164,7 @@ function createSyncJob(db, id, domainId) { db.insert(syncJobs).values({ id, domain_id: domainId, status: "pending" }).run(); } function getSyncJob(db, id) { - const row = db.select().from(syncJobs).where(eq(syncJobs.id, id)).get(); + const row = db.select().from(syncJobs).where(eq2(syncJobs.id, id)).get(); if (!row) throw new NotFoundError(`sync job ${id}`); return row; } @@ -1072,7 +1173,7 @@ function finishSyncJob(db, id, status, message) { status, message, finished_at: sql2`datetime('now')` - }).where(eq(syncJobs.id, id)).run(); + }).where(eq2(syncJobs.id, id)).run(); } function listIpHealthStatus(db, scope, refId) { return db.all(sql2` @@ -1111,17 +1212,17 @@ function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecuti function deleteIpHealthStatusForRef(db, scope, refId) { db.delete(ipHealthStatus).where( and( - eq(ipHealthStatus.scope, scope), - eq(ipHealthStatus.ref_id, refId) + eq2(ipHealthStatus.scope, scope), + eq2(ipHealthStatus.ref_id, refId) ) ).run(); } function deleteIpHealthStatusForIp(db, scope, refId, ip) { db.delete(ipHealthStatus).where( and( - eq(ipHealthStatus.scope, scope), - eq(ipHealthStatus.ref_id, refId), - eq(ipHealthStatus.ip, ip) + eq2(ipHealthStatus.scope, scope), + eq2(ipHealthStatus.ref_id, refId), + eq2(ipHealthStatus.ip, ip) ) ).run(); } @@ -1222,11 +1323,15 @@ function listHealthCheckTargets(db) { export { ConflictError, NotFoundError, + appSettings, certificates, createDb, createMemoryDb, dnsRecords, domains, + getAppSettings, + getAppSettingsSecrets, + getAppSwitcher, groups, healthCheck, ipHealthStatus, @@ -1242,5 +1347,7 @@ export { serviceIps, services, subdomains, - syncJobs + syncJobs, + touchVpsTrackerSync, + updateAppSettings }; diff --git a/packages/db/migrations/013_app_settings.sql b/packages/db/migrations/013_app_settings.sql new file mode 100644 index 0000000..f938f6a --- /dev/null +++ b/packages/db/migrations/013_app_settings.sql @@ -0,0 +1,13 @@ +-- Application settings (single row) +CREATE TABLE IF NOT EXISTS app_settings ( + id TEXT PRIMARY KEY, + app_switcher_json TEXT, + vps_tracker_url TEXT, + vps_tracker_integration_token TEXT, + vps_tracker_sync_enabled INTEGER NOT NULL DEFAULT 0, + vps_tracker_last_sync_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT OR IGNORE INTO app_settings (id) VALUES ('settings-main'); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index acb80ba..83cfb20 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,5 +1,6 @@ export * from "./schema.js"; export * from "./client.js"; export * from "./errors.js"; +export * from "./settings-repo.js"; export * as repos from "./repos.js"; export type { DnsListFilter, UpdateSubdomainPatch } from "./repos.js"; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 81fb92b..15c1398 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -268,6 +268,25 @@ export const ipHealthStatus = sqliteTable( (t) => [primaryKey({ columns: [t.scope, t.ref_id, t.ip] })], ); +export const appSettings = sqliteTable("app_settings", { + id: text("id").primaryKey(), + app_switcher_json: text("app_switcher_json"), + vps_tracker_url: text("vps_tracker_url"), + vps_tracker_integration_token: text("vps_tracker_integration_token"), + vps_tracker_sync_enabled: integer("vps_tracker_sync_enabled", { + mode: "boolean", + }) + .notNull() + .default(false), + vps_tracker_last_sync_at: text("vps_tracker_last_sync_at"), + created_at: text("created_at") + .notNull() + .default(sql`datetime('now')`), + updated_at: text("updated_at") + .notNull() + .default(sql`datetime('now')`), +}); + export const schema = { groups, services, @@ -283,4 +302,5 @@ export const schema = { certificates, syncJobs, ipHealthStatus, + appSettings, }; diff --git a/packages/db/src/settings-repo.ts b/packages/db/src/settings-repo.ts new file mode 100644 index 0000000..4df59b0 --- /dev/null +++ b/packages/db/src/settings-repo.ts @@ -0,0 +1,155 @@ +import { eq } from "drizzle-orm"; +import { appSwitcherConfigSchema, type AppSwitcherConfig } from "@cfdm/shared"; +import type { Db } from "./client.js"; +import { appSettings } from "./schema.js"; + +const SETTINGS_ID = "settings-main"; + +const DEFAULT_APP_SWITCHER: AppSwitcherConfig = { + menuLabel: "Приложения", + apps: [ + { + id: "vps-tracker", + name: "VPS Tracker", + subtitle: "Учёт виртуальных серверов", + url: "http://192.168.100.67:3001", + icon: "server", + shortcut: "⌘1", + }, + { + id: "cfdm", + name: "CF Domain Manager", + subtitle: "Управление доменами", + url: "http://192.168.100.67:6363", + icon: "cloud", + shortcut: "⌘2", + }, + ], +}; + +export type AppSettingsDto = { + id: string; + appSwitcher: AppSwitcherConfig; + vpsTrackerUrl: string; + vpsTrackerIntegrationTokenSet: boolean; + vpsTrackerSyncEnabled: boolean; + vpsTrackerLastSyncAt: string | null; +}; + +export type AppSettingsPatch = { + appSwitcher?: AppSwitcherConfig; + vpsTrackerUrl?: string; + vpsTrackerIntegrationToken?: string; + vpsTrackerSyncEnabled?: boolean; +}; + +function parseAppSwitcher(raw: string | null | undefined): AppSwitcherConfig { + if (!raw?.trim()) return DEFAULT_APP_SWITCHER; + try { + return appSwitcherConfigSchema.parse(JSON.parse(raw)); + } catch { + return DEFAULT_APP_SWITCHER; + } +} + +function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto { + return { + id: row.id, + appSwitcher: parseAppSwitcher(row.app_switcher_json), + vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "", + vpsTrackerIntegrationTokenSet: Boolean( + row.vps_tracker_integration_token?.trim(), + ), + vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled), + vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at, + }; +} + +export function getAppSettings(db: Db): AppSettingsDto { + const row = db + .select() + .from(appSettings) + .where(eq(appSettings.id, SETTINGS_ID)) + .get(); + if (!row) { + db.insert(appSettings).values({ id: SETTINGS_ID }).run(); + return toDto( + db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get()!, + ); + } + return toDto(row); +} + +export function getAppSettingsSecrets(db: Db): { + vpsTrackerUrl: string; + vpsTrackerIntegrationToken: string; + vpsTrackerSyncEnabled: boolean; +} { + const row = db + .select() + .from(appSettings) + .where(eq(appSettings.id, SETTINGS_ID)) + .get(); + return { + vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "", + vpsTrackerIntegrationToken: + row?.vps_tracker_integration_token?.trim() ?? "", + vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled), + }; +} + +export function updateAppSettings(db: Db, patch: AppSettingsPatch): AppSettingsDto { + const existing = db + .select() + .from(appSettings) + .where(eq(appSettings.id, SETTINGS_ID)) + .get(); + if (!existing) { + db.insert(appSettings).values({ id: SETTINGS_ID }).run(); + } + const current = db + .select() + .from(appSettings) + .where(eq(appSettings.id, SETTINGS_ID)) + .get()!; + + db.update(appSettings) + .set({ + app_switcher_json: + patch.appSwitcher !== undefined + ? JSON.stringify(patch.appSwitcher) + : current.app_switcher_json, + vps_tracker_url: + patch.vpsTrackerUrl !== undefined + ? patch.vpsTrackerUrl + : current.vps_tracker_url, + vps_tracker_integration_token: + patch.vpsTrackerIntegrationToken !== undefined && + patch.vpsTrackerIntegrationToken.trim() !== "" + ? patch.vpsTrackerIntegrationToken + : current.vps_tracker_integration_token, + vps_tracker_sync_enabled: + patch.vpsTrackerSyncEnabled !== undefined + ? patch.vpsTrackerSyncEnabled + : current.vps_tracker_sync_enabled, + updated_at: new Date().toISOString(), + }) + .where(eq(appSettings.id, SETTINGS_ID)) + .run(); + + return getAppSettings(db); +} + +export function touchVpsTrackerSync(db: Db): void { + db.update(appSettings) + .set({ + vps_tracker_last_sync_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .where(eq(appSettings.id, SETTINGS_ID)) + .run(); +} + +export function getAppSwitcher(db: Db): AppSwitcherConfig { + return getAppSettings(db).appSwitcher; +} diff --git a/packages/shared/dist/index.d.ts b/packages/shared/dist/index.d.ts index 8a886aa..7c3e7ee 100644 --- a/packages/shared/dist/index.d.ts +++ b/packages/shared/dist/index.d.ts @@ -1197,4 +1197,107 @@ type CreateDomainInput = z.infer; type LoginInput = z.infer; type CreateDnsRecordInput = z.infer; -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 HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, 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 UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, bindingToFqdn, certMonitoringSchema, certStatusFromExpiry, certificateSchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord }; +declare const appSwitcherIconSchema: z.ZodEnum<{ + server: "server"; + cloud: "cloud"; + globe: "globe"; + dashboard: "dashboard"; + chart: "chart"; +}>; +declare const appSwitcherEntrySchema: z.ZodObject<{ + id: z.ZodString; + name: z.ZodString; + subtitle: z.ZodOptional; + url: z.ZodString; + icon: z.ZodDefault>; + shortcut: z.ZodOptional; +}, z.core.$strip>; +declare const appSwitcherConfigSchema: z.ZodObject<{ + menuLabel: z.ZodDefault; + apps: z.ZodArray; + url: z.ZodString; + icon: z.ZodDefault>; + shortcut: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$strip>; +type AppSwitcherEntry = z.infer; +type AppSwitcherConfig = z.infer; + +declare const cfdmBindingSyncItemSchema: z.ZodObject<{ + bindingId: z.ZodNumber; + serviceId: z.ZodNumber; + serviceName: z.ZodString; + serviceSlug: z.ZodString; + fqdn: z.ZodString; + zoneName: z.ZodString; + hostname: z.ZodString; + ips: z.ZodArray; + deleted: z.ZodOptional; +}, z.core.$strip>; +declare const cfdmSyncBindingsBodySchema: z.ZodObject<{ + bindings: z.ZodArray; + deleted: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$strip>; +type CfdmBindingSyncItem = z.infer; +declare const appSettingsPatchSchema: z.ZodObject<{ + appSwitcher: z.ZodOptional; + apps: z.ZodArray; + url: z.ZodString; + icon: z.ZodDefault>; + shortcut: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>>; + vpsTrackerUrl: z.ZodOptional]>>; + vpsTrackerIntegrationToken: z.ZodOptional; + vpsTrackerSyncEnabled: z.ZodOptional; +}, z.core.$strip>; +type AppSettingsPatch = z.infer; +declare const vpsTrackerEventSchema: z.ZodObject<{ + event: z.ZodEnum<{ + vps_down: "vps_down"; + vps_up: "vps_up"; + }>; + vps: z.ZodArray; + label: z.ZodOptional; + }, z.core.$strip>>; + timestamp: z.ZodOptional; +}, z.core.$strip>; +type VpsTrackerEvent = z.infer; + +export { type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, 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 CfdmBindingSyncItem, 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 HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, 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 UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, bindingToFqdn, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema }; diff --git a/packages/shared/dist/index.js b/packages/shared/dist/index.js index 367056b..915b876 100644 --- a/packages/shared/dist/index.js +++ b/packages/shared/dist/index.js @@ -478,6 +478,62 @@ var healthStatusQuerySchema = z.object({ scope: healthCheckScopeSchema, ref_id: z.coerce.number().int().positive() }); + +// src/app-switcher.ts +import { z as z2 } from "zod"; +var appSwitcherIconSchema = z2.enum([ + "server", + "cloud", + "globe", + "dashboard", + "chart" +]); +var appSwitcherEntrySchema = z2.object({ + id: z2.string(), + name: z2.string(), + subtitle: z2.string().optional(), + url: z2.string().url(), + icon: appSwitcherIconSchema.default("server"), + shortcut: z2.string().optional() +}); +var appSwitcherConfigSchema = z2.object({ + menuLabel: z2.string().default("\u041F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044F"), + apps: z2.array(appSwitcherEntrySchema).min(1) +}); + +// src/integration-vps-tracker.ts +import { z as z3 } from "zod"; +var cfdmBindingSyncItemSchema = z3.object({ + bindingId: z3.number().int().positive(), + serviceId: z3.number().int().positive(), + serviceName: z3.string().min(1), + serviceSlug: z3.string().min(1), + fqdn: z3.string().min(1), + zoneName: z3.string().min(1), + hostname: z3.string(), + ips: z3.array(z3.string()), + deleted: z3.boolean().optional() +}); +var cfdmSyncBindingsBodySchema = z3.object({ + bindings: z3.array(cfdmBindingSyncItemSchema).min(1) +}); +var appSettingsPatchSchema = z3.object({ + appSwitcher: appSwitcherConfigSchema.optional(), + vpsTrackerUrl: z3.string().url().or(z3.literal("")).optional(), + vpsTrackerIntegrationToken: z3.string().optional(), + vpsTrackerSyncEnabled: z3.boolean().optional() +}); +var vpsTrackerEventSchema = z3.object({ + event: z3.enum(["vps_down", "vps_up"]), + vps: z3.array( + z3.object({ + id: z3.string().min(1), + ip: z3.string().optional(), + label: z3.string().optional() + }) + ), + timestamp: z3.string().datetime().optional() +}); export { CERT_ERROR, CERT_EXPIRED, @@ -494,10 +550,16 @@ export { SYNC_PENDING_PUSH, SYNC_SYNCED, ValidationError, + appSettingsPatchSchema, + appSwitcherConfigSchema, + appSwitcherEntrySchema, + appSwitcherIconSchema, bindingToFqdn, certMonitoringSchema, certStatusFromExpiry, certificateSchema, + cfdmBindingSyncItemSchema, + cfdmSyncBindingsBodySchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, @@ -543,5 +605,6 @@ export { updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, - validateDnsRecord + validateDnsRecord, + vpsTrackerEventSchema }; diff --git a/packages/shared/src/app-switcher.ts b/packages/shared/src/app-switcher.ts new file mode 100644 index 0000000..80e101b --- /dev/null +++ b/packages/shared/src/app-switcher.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +export const appSwitcherIconSchema = z.enum([ + "server", + "cloud", + "globe", + "dashboard", + "chart", +]); + +export const appSwitcherEntrySchema = z.object({ + id: z.string(), + name: z.string(), + subtitle: z.string().optional(), + url: z.string().url(), + icon: appSwitcherIconSchema.default("server"), + shortcut: z.string().optional(), +}); + +export const appSwitcherConfigSchema = z.object({ + menuLabel: z.string().default("Приложения"), + apps: z.array(appSwitcherEntrySchema).min(1), +}); + +export type AppSwitcherEntry = z.infer; +export type AppSwitcherConfig = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3445be4..4b95a74 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,6 +3,8 @@ export * from "./validators.js"; export * from "./subdomain.js"; export * from "./parse-fqdn.js"; export * from "./schemas.js"; +export * from "./app-switcher.js"; +export * from "./integration-vps-tracker.js"; export type { CfZone, CfDnsRecord, diff --git a/packages/shared/src/integration-vps-tracker.ts b/packages/shared/src/integration-vps-tracker.ts new file mode 100644 index 0000000..0f1169b --- /dev/null +++ b/packages/shared/src/integration-vps-tracker.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import { appSwitcherConfigSchema } from "./app-switcher.js"; + +export const cfdmBindingSyncItemSchema = z.object({ + bindingId: z.number().int().positive(), + serviceId: z.number().int().positive(), + serviceName: z.string().min(1), + serviceSlug: z.string().min(1), + fqdn: z.string().min(1), + zoneName: z.string().min(1), + hostname: z.string(), + ips: z.array(z.string()), + deleted: z.boolean().optional(), +}); + +export const cfdmSyncBindingsBodySchema = z.object({ + bindings: z.array(cfdmBindingSyncItemSchema).min(1), +}); + +export type CfdmBindingSyncItem = z.infer; + +export const appSettingsPatchSchema = z.object({ + appSwitcher: appSwitcherConfigSchema.optional(), + vpsTrackerUrl: z.string().url().or(z.literal("")).optional(), + vpsTrackerIntegrationToken: z.string().optional(), + vpsTrackerSyncEnabled: z.boolean().optional(), +}); + +export type AppSettingsPatch = z.infer; + +export const vpsTrackerEventSchema = z.object({ + event: z.enum(["vps_down", "vps_up"]), + vps: z.array( + z.object({ + id: z.string().min(1), + ip: z.string().optional(), + label: z.string().optional(), + }), + ), + timestamp: z.string().datetime().optional(), +}); + +export type VpsTrackerEvent = z.infer;