feat:Update pnpm-lock.yaml to link shared package; modify API routes to utilize new schemas for domain and service management; enhance DNS record handling with CNAME support; refactor service and subdomain routes for improved functionality; implement confirm dialog for domain deletion in the frontend; clean up unused components and improve UI consistency.
Build, Test, and Push CFDM Docker Image / test (push) Failing after 51s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

This commit is contained in:
Denozordec
2026-06-22 13:52:33 +07:00
parent 4a70cf5854
commit 6d1d8ca4f3
99 changed files with 6692 additions and 1483 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"mcp__codegraph__codegraph_explore",
"mcp__codegraph__codegraph_search",
"mcp__codegraph__codegraph_node",
"mcp__codegraph__codegraph_callers",
"mcp__codegraph__codegraph_callees",
"mcp__codegraph__codegraph_impact",
"mcp__codegraph__codegraph_files",
"mcp__codegraph__codegraph_status"
]
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"pid": 35132, "pid": 44284,
"version": "0.9.9", "version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-7bcc7a2b16d00925", "socketPath": "\\\\.\\pipe\\codegraph-7bcc7a2b16d00925",
"startedAt": 1781511807694 "startedAt": 1782094135868
} }
+14
View File
@@ -0,0 +1,14 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp",
"--path",
"C:\\Users\\shats\\Dev\\cloudflare-domain-manager"
]
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp"
]
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp"
]
}
}
}
+389 -87
View File
@@ -424,6 +424,7 @@ async function groupRoutes(app2) {
// src/routes/services.ts // src/routes/services.ts
import { z as z3 } from "zod"; import { z as z3 } from "zod";
import { reorderServicesSchema } from "@cfdm/shared";
import { repos as repos7 } from "@cfdm/db"; import { repos as repos7 } from "@cfdm/db";
// src/services/service-config-service.ts // src/services/service-config-service.ts
@@ -431,7 +432,8 @@ import { repos as repos6 } from "@cfdm/db";
import { import {
SYNC_ERROR as SYNC_ERROR2, SYNC_ERROR as SYNC_ERROR2,
SYNC_PENDING_PUSH as SYNC_PENDING_PUSH3, SYNC_PENDING_PUSH as SYNC_PENDING_PUSH3,
SYNC_SYNCED as SYNC_SYNCED3 SYNC_SYNCED as SYNC_SYNCED3,
dnsNameToSubdomainLabel as dnsNameToSubdomainLabel2
} from "@cfdm/shared"; } from "@cfdm/shared";
// src/lib/validators.ts // src/lib/validators.ts
@@ -874,9 +876,9 @@ async function createDomain(db, cf, groupId, zoneName) {
"\u043D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0445 \u0437\u043E\u043D \u0432 Cloudflare \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 CLOUDFLARE_API_TOKEN \u0438 \u043F\u0440\u0430\u0432\u0430 Zone:Read" "\u043D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0445 \u0437\u043E\u043D \u0432 Cloudflare \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 CLOUDFLARE_API_TOKEN \u0438 \u043F\u0440\u0430\u0432\u0430 Zone:Read"
); );
} }
const zone = zones.find((z9) => z9.name.toLowerCase() === trimmed.toLowerCase()); const zone = zones.find((z8) => z8.name.toLowerCase() === trimmed.toLowerCase());
if (!zone) { if (!zone) {
const names = zones.map((z9) => z9.name).join(", "); const names = zones.map((z8) => z8.name).join(", ");
throw AppError.notFound( throw AppError.notFound(
`\u0437\u043E\u043D\u0430 \xAB${trimmed}\xBB \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430 \u0432 Cloudflare. \u0414\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0435: ${names}` `\u0437\u043E\u043D\u0430 \xAB${trimmed}\xBB \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430 \u0432 Cloudflare. \u0414\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0435: ${names}`
); );
@@ -956,13 +958,19 @@ async function buildView(db, serviceId) {
const records = repos6.listRecordsForBinding(db, binding.id); const records = repos6.listRecordsForBinding(db, binding.id);
const statuses = records.map((r) => r.sync_status); const statuses = records.map((r) => r.sync_status);
const targetIps = repos6.listBindingIps(db, binding.id); const targetIps = repos6.listBindingIps(db, binding.id);
const linkedCname = records.find(
(record) => record.record_type.toUpperCase() === "CNAME"
);
const targetCname = binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
return { return {
binding_id: binding.id, binding_id: binding.id,
domain_id: binding.domain_id, domain_id: binding.domain_id,
zone_name: binding.zone_name, zone_name: binding.zone_name,
hostname: binding.hostname, hostname: binding.hostname,
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
target_ips: targetIps, record_type: targetCname ? "CNAME" : "A",
target_ips: targetCname ? [] : targetIps,
target_cname: targetCname,
sync_status: aggregateSyncStatus(statuses) sync_status: aggregateSyncStatus(statuses)
}; };
}); });
@@ -1012,14 +1020,114 @@ function shouldPushDns(db, service) {
const group = repos6.getServiceGroup(db, service.service_group_id); const group = repos6.getServiceGroup(db, service.service_group_id);
return group.enabled; return group.enabled;
} }
async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps) { async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, cnameTarget) {
const domain = repos6.getDomain(db, domainId);
const zoneName = domain.zone_name;
let effectiveCname = cnameTarget?.trim() || null;
if (!effectiveCname) {
const existingCname = await findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"CNAME"
);
if (existingCname) {
effectiveCname = existingCname.content;
repos6.setBindingCnameTarget(db, bindingId, effectiveCname);
repos6.replaceBindingIps(db, bindingId, []);
}
}
if (effectiveCname) {
await syncBindingCnameDns(
db,
cf,
bindingId,
domainId,
hostname,
effectiveCname
);
return;
}
await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps);
}
async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameTarget) {
const domain = repos6.getDomain(db, domainId);
const zoneName = domain.zone_name;
const normalized = normalizeCnameTarget(cnameTarget, zoneName);
const existingRecords = repos6.listRecordsForBinding(db, bindingId); const existingRecords = repos6.listRecordsForBinding(db, bindingId);
for (const record of existingRecords) { for (const record of existingRecords) {
if (!desiredIps.includes(record.content)) { if (record.record_type.toUpperCase() === "A") {
repos6.unlinkBindingRecord(db, bindingId, record.id); repos6.unlinkBindingRecord(db, bindingId, record.id);
await deleteRecord(db, cf, domainId, record.id); await deleteRecord(db, cf, domainId, record.id);
} }
} }
const refreshed = repos6.listRecordsForBinding(db, bindingId);
const existingCname = refreshed.find(
(record) => record.record_type.toUpperCase() === "CNAME"
);
let recordId;
if (existingCname) {
if (!cnameContentMatches(existingCname.content, normalized, zoneName) || existingCname.name !== hostname) {
await update(db, cf, domainId, existingCname.id, {
record_type: "CNAME",
name: hostname,
content: normalized,
proxied: false
});
}
recordId = existingCname.id;
} else {
const adopted = await findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"CNAME",
normalized
);
if (adopted) {
repos6.linkBindingRecord(db, bindingId, adopted.id);
if (!cnameContentMatches(adopted.content, normalized, zoneName)) {
await update(db, cf, domainId, adopted.id, {
record_type: "CNAME",
name: hostname,
content: normalized,
proxied: false
});
}
recordId = adopted.id;
} else {
const record = await create(db, cf, domainId, {
record_type: "CNAME",
name: hostname,
content: normalized,
ttl: 1,
proxied: false
});
repos6.linkBindingRecord(db, bindingId, record.id);
recordId = record.id;
}
}
repos6.setBindingDnsRecordId(db, bindingId, recordId);
repos6.setBindingCnameTarget(db, bindingId, normalized);
}
async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps) {
const domain = repos6.getDomain(db, domainId);
const zoneName = domain.zone_name;
const existingRecords = repos6.listRecordsForBinding(db, bindingId);
for (const record of existingRecords) {
if (record.record_type.toUpperCase() === "CNAME") {
repos6.unlinkBindingRecord(db, bindingId, record.id);
await deleteRecord(db, cf, domainId, record.id);
} else if (!desiredIps.includes(record.content)) {
repos6.unlinkBindingRecord(db, bindingId, record.id);
await deleteRecord(db, cf, domainId, record.id);
}
}
repos6.setBindingCnameTarget(db, bindingId, null);
if (desiredIps.length === 0) { if (desiredIps.length === 0) {
repos6.setBindingDnsRecordId(db, bindingId, null); repos6.setBindingDnsRecordId(db, bindingId, null);
return; return;
@@ -1040,22 +1148,36 @@ async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps)
} }
recordId = existing.id; recordId = existing.id;
} else { } else {
const record = await create(db, cf, domainId, { const adopted = await findOrImportDnsRecord(
record_type: "A", db,
name: hostname, cf,
content: ip, domainId,
ttl: 1, zoneName,
proxied: false hostname,
}); "A",
repos6.linkBindingRecord(db, bindingId, record.id); ip
recordId = record.id; );
if (adopted) {
repos6.linkBindingRecord(db, bindingId, adopted.id);
recordId = adopted.id;
} else {
const record = await create(db, cf, domainId, {
record_type: "A",
name: hostname,
content: ip,
ttl: 1,
proxied: false
});
repos6.linkBindingRecord(db, bindingId, record.id);
recordId = record.id;
}
} }
if (primaryId == null) primaryId = recordId; if (primaryId == null) primaryId = recordId;
} }
repos6.setBindingDnsRecordId(db, bindingId, primaryId); repos6.setBindingDnsRecordId(db, bindingId, primaryId);
} }
async function cleanupBindingDns(db, cf, bindingId, domainId, hostname) { async function cleanupBindingDns(db, cf, bindingId, domainId, hostname) {
await syncBindingDns(db, cf, bindingId, domainId, hostname, []); await syncBindingDns(db, cf, bindingId, domainId, hostname, [], null);
} }
async function cleanupServiceDnsOnly(db, cf, serviceId) { async function cleanupServiceDnsOnly(db, cf, serviceId) {
const bindings = repos6.listBindingsByService(db, serviceId); const bindings = repos6.listBindingsByService(db, serviceId);
@@ -1080,6 +1202,7 @@ function validateTargetIpsInPool(targetIps, ips) {
} }
} }
function bindingTargetIps(input) { function bindingTargetIps(input) {
if (input.target_cname?.trim()) return [];
const raw = input.target_ips ? input.target_ips : input.target_ip?.trim() ? [input.target_ip.trim()] : []; const raw = input.target_ips ? input.target_ips : input.target_ip?.trim() ? [input.target_ip.trim()] : [];
const normalized = normalizeIps(raw); const normalized = normalizeIps(raw);
if (raw.length > 0 && normalized.length === 0) { if (raw.length > 0 && normalized.length === 0) {
@@ -1087,20 +1210,150 @@ function bindingTargetIps(input) {
} }
return normalized; return normalized;
} }
async function syncServiceBindingsToDns(db, cf, serviceId) { function bindingTargetCname(input) {
const ips = repos6.listServiceIps(db, serviceId); const target = input.target_cname?.trim();
if (ips.length === 0) { return target ? target : null;
throw AppError.validation("\u0434\u043E\u0431\u0430\u0432\u044C\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441\u0430 \u0432 \u043F\u0443\u043B \u0441\u0435\u0440\u0432\u0438\u0441\u0430"); }
function normalizeCnameTarget(target, zoneName) {
const trimmed = target.trim().toLowerCase();
if (!trimmed) {
throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 CNAME-\u0446\u0435\u043B\u044C");
} }
if (trimmed.includes(".")) return trimmed;
return `${trimmed}.${zoneName.toLowerCase()}`;
}
function cnameContentMatches(left, right, zoneName) {
return normalizeCnameTarget(left, zoneName) === normalizeCnameTarget(right, zoneName);
}
function dnsHostnameMatches(recordName, hostname, zoneName) {
const label = dnsNameToSubdomainLabel2(recordName, zoneName);
if (label != null) return label === hostname;
return recordName === hostname;
}
function findLocalDnsRecord(db, domainId, zoneName, hostname, recordType, content) {
const records = repos6.listDnsByDomain(db, domainId);
return records.find(
(record) => record.record_type.toUpperCase() === recordType && (content == null || record.content === content) && dnsHostnameMatches(record.name, hostname, zoneName)
) ?? null;
}
async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recordType, content) {
const local = findLocalDnsRecord(
db,
domainId,
zoneName,
hostname,
recordType,
content
);
if (local) return local;
const domain = repos6.getDomain(db, domainId);
const remote = await cf.listDnsRecords(domain.cf_zone_id);
for (const cfRec of remote) {
if (cfRec.type.toUpperCase() !== recordType) continue;
if (content != null) {
if (recordType === "CNAME") {
if (!cnameContentMatches(cfRec.content, content, zoneName)) continue;
} else if (cfRec.content !== content) {
continue;
}
}
if (!dnsHostnameMatches(cfRec.name, hostname, zoneName)) continue;
if (!cfRec.id) continue;
const existing = repos6.findDnsByCfId(db, domainId, cfRec.id);
if (existing) return existing;
return repos6.insertDnsRecord(
db,
domainId,
cfRec.type,
cfRec.name,
cfRec.content,
cfRec.ttl,
cfRec.proxied ?? false,
cfRec.priority ?? null,
SYNC_SYNCED3,
"cloudflare",
cfRec.id
);
}
return null;
}
async function findOrImportDnsARecord(db, cf, domainId, zoneName, hostname, content) {
return findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"A",
content
);
}
async function serviceBindingsExistInDns(db, cf, serviceId) {
const bindings = repos6.listBindingsByService(db, serviceId);
if (bindings.length === 0) return false;
for (const binding of bindings) {
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) {
const record = await findOrImportDnsRecord(
db,
cf,
binding.domain_id,
binding.zone_name,
binding.hostname,
"CNAME",
cnameTarget
);
if (!record) return false;
continue;
}
const targetIps = repos6.listBindingIps(db, binding.id);
if (targetIps.length === 0) return false;
for (const ip of targetIps) {
const record = await findOrImportDnsARecord(
db,
cf,
binding.domain_id,
binding.zone_name,
binding.hostname,
ip
);
if (!record) return false;
}
}
return true;
}
async function syncServiceBindingsToDns(db, cf, serviceId) {
const bindings = repos6.listBindingsByService(db, serviceId); const bindings = repos6.listBindingsByService(db, serviceId);
if (bindings.length === 0) { 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"); throw AppError.validation("\u043D\u0430\u0441\u0442\u0440\u043E\u0439\u0442\u0435 FQDN \u0432 \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440\u0435 \u0441\u0435\u0440\u0432\u0438\u0441\u0430");
} }
const needsIpPool = bindings.some((binding) => {
if (binding.cname_target?.trim()) return false;
const targetIps = repos6.listBindingIps(db, binding.id);
return targetIps.length > 0;
});
const ips = repos6.listServiceIps(db, serviceId);
if (needsIpPool && ips.length === 0) {
throw AppError.validation("\u0434\u043E\u0431\u0430\u0432\u044C\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441\u0430 \u0432 \u043F\u0443\u043B \u0441\u0435\u0440\u0432\u0438\u0441\u0430");
}
for (const binding of bindings) { for (const binding of bindings) {
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) {
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
[],
cnameTarget
);
continue;
}
const targetIps = repos6.listBindingIps(db, binding.id); const targetIps = repos6.listBindingIps(db, binding.id);
if (targetIps.length === 0) { if (targetIps.length === 0) {
throw AppError.validation( throw AppError.validation(
`\u0443\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0434\u043B\u044F ${fqdnToDisplay(binding.hostname, binding.zone_name)}` `\u0443\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 CNAME \u0434\u043B\u044F ${fqdnToDisplay(binding.hostname, binding.zone_name)}`
); );
} }
validateTargetIpsInPool(targetIps, ips); validateTargetIpsInPool(targetIps, ips);
@@ -1110,7 +1363,8 @@ async function syncServiceBindingsToDns(db, cf, serviceId) {
binding.id, binding.id,
binding.domain_id, binding.domain_id,
binding.hostname, binding.hostname,
targetIps targetIps,
null
); );
} }
} }
@@ -1152,6 +1406,19 @@ async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, de
} }
continue; continue;
} }
const domain = repos6.getDomain(db, domainId);
const adopted = await findOrImportDnsARecord(
db,
cf,
domainId,
domain.zone_name,
hostname,
ip
);
if (adopted) {
repos6.linkGroupDnsRecord(db, groupId, adopted.id);
continue;
}
const record = await create(db, cf, domainId, { const record = await create(db, cf, domainId, {
record_type: "A", record_type: "A",
name: hostname, name: hostname,
@@ -1273,13 +1540,21 @@ async function updateConfig(db, cf, id, req) {
for (const input of req.domains) { for (const input of req.domains) {
const fqdn = input.fqdn.trim(); const fqdn = input.fqdn.trim();
if (!fqdn) continue; if (!fqdn) continue;
const targetCname = bindingTargetCname(input);
const targetIps = bindingTargetIps(input); const targetIps = bindingTargetIps(input);
validateTargetIpsInPool(targetIps, ips); if (!targetCname) {
validateTargetIpsInPool(targetIps, ips);
} else if (targetIps.length > 0) {
throw AppError.validation(
`\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u043B\u0438\u0431\u043E IP, \u043B\u0438\u0431\u043E CNAME \u0434\u043B\u044F ${fqdn}`
);
}
const { zoneName, hostname } = parseFqdn(fqdn, knownZones); const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName); const domainId = await resolveDomainId(db, cf, zoneName);
const binding = repos6.findBinding(db, id, domainId, hostname) ?? repos6.insertBinding(db, domainId, id, hostname, null); const binding = repos6.findBinding(db, id, domainId, hostname) ?? repos6.insertBinding(db, domainId, id, hostname, null);
keptBindingIds.push(binding.id); keptBindingIds.push(binding.id);
repos6.replaceBindingIps(db, binding.id, targetIps); repos6.replaceBindingIps(db, binding.id, targetCname ? [] : targetIps);
repos6.setBindingCnameTarget(db, binding.id, targetCname);
if (pushDns) { if (pushDns) {
await syncBindingDns( await syncBindingDns(
db, db,
@@ -1287,7 +1562,8 @@ async function updateConfig(db, cf, id, req) {
binding.id, binding.id,
domainId, domainId,
hostname, hostname,
targetIps targetIps,
targetCname
); );
} }
} }
@@ -1320,6 +1596,10 @@ async function updateConfig(db, cf, id, req) {
if (shouldPushDns(db, service)) { if (shouldPushDns(db, service)) {
await syncServiceBindingsToDns(db, cf, id); await syncServiceBindingsToDns(db, cf, id);
await syncGroupDomainForService(db, cf, id); await syncGroupDomainForService(db, cf, id);
} else if (req.domains && req.domains.length > 0 && !service.enabled && await serviceBindingsExistInDns(db, cf, id)) {
repos6.setServiceEnabled(db, id, true);
await syncServiceBindingsToDns(db, cf, id);
await syncGroupDomainForService(db, cf, id);
} }
return buildView(db, id); return buildView(db, id);
} }
@@ -1343,7 +1623,7 @@ async function updateGroup2(db, cf, id, body) {
await cleanupGroupDomainDns(db, cf, id); await cleanupGroupDomainDns(db, cf, id);
} }
const domain = await normalizeGroupDomain(db, cf, body.domain); const domain = await normalizeGroupDomain(db, cf, body.domain);
const group = repos6.updateServiceGroup( let group = repos6.updateServiceGroup(
db, db,
id, id,
body.name, body.name,
@@ -1351,6 +1631,10 @@ async function updateGroup2(db, cf, id, body) {
body.icon ?? null, body.icon ?? null,
domain domain
); );
if (!domain && group.enabled) {
repos6.setServiceGroupEnabled(db, id, false);
group = repos6.getServiceGroup(db, id);
}
await syncEnabledServicesInGroup(db, cf, id); await syncEnabledServicesInGroup(db, cf, id);
return group; return group;
} }
@@ -1361,12 +1645,9 @@ async function toggleService(db, cf, serviceId, enabled) {
const service = repos6.getService(db, serviceId); const service = repos6.getService(db, serviceId);
if (enabled && service.service_group_id) { if (enabled && service.service_group_id) {
const group = repos6.getServiceGroup(db, service.service_group_id); const group = repos6.getServiceGroup(db, service.service_group_id);
if (!group.enabled) { if (group.domain?.trim() && !group.enabled) {
throw AppError.validation("\u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u0435 \u0433\u0440\u0443\u043F\u043F\u0443 \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u0432"); throw AppError.validation("\u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u0435 \u0433\u0440\u0443\u043F\u043F\u0443 \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u0432");
} }
if (!group.domain?.trim()) {
throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0434\u043E\u043C\u0435\u043D \u0443 \u0433\u0440\u0443\u043F\u043F\u044B \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u0432");
}
} }
repos6.setServiceEnabled(db, serviceId, enabled); repos6.setServiceEnabled(db, serviceId, enabled);
if (!enabled) { if (!enabled) {
@@ -1379,6 +1660,10 @@ async function toggleService(db, cf, serviceId, enabled) {
return buildView(db, serviceId); return buildView(db, serviceId);
} }
async function toggleGroup(db, cf, groupId, enabled) { async function toggleGroup(db, cf, groupId, enabled) {
const group = repos6.getServiceGroup(db, groupId);
if (enabled && !group.domain?.trim()) {
throw AppError.validation("\u043D\u0435\u043B\u044C\u0437\u044F \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u044C \u0433\u0440\u0443\u043F\u043F\u0443 \u0431\u0435\u0437 \u0434\u043E\u043C\u0435\u043D\u0430");
}
repos6.setServiceGroupEnabled(db, groupId, enabled); repos6.setServiceGroupEnabled(db, groupId, enabled);
if (!enabled) { if (!enabled) {
const services = repos6.listServicesByGroup(db, groupId); const services = repos6.listServicesByGroup(db, groupId);
@@ -1394,6 +1679,12 @@ async function toggleGroup(db, cf, groupId, enabled) {
} }
return listGroupViews(db); return listGroupViews(db);
} }
function reorderServices(db, groupId, serviceIds) {
if (groupId !== null) {
repos6.getServiceGroup(db, groupId);
}
repos6.reorderServices(db, groupId, serviceIds);
}
// src/routes/services.ts // src/routes/services.ts
async function serviceRoutes(app2) { async function serviceRoutes(app2) {
@@ -1405,6 +1696,15 @@ async function serviceRoutes(app2) {
app2.get("/services", async (request) => { app2.get("/services", async (request) => {
return listViews(request.server.db); return listViews(request.server.db);
}); });
app2.patch("/services/reorder", async (request) => {
const body = reorderServicesSchema.parse(request.body);
reorderServices(
request.server.db,
body.group_id,
body.service_ids
);
return { ok: true };
});
app2.post("/services", async (request) => { app2.post("/services", async (request) => {
const body = createSchema.parse(request.body); const body = createSchema.parse(request.body);
const service = repos7.createService( const service = repos7.createService(
@@ -1452,14 +1752,9 @@ async function serviceRoutes(app2) {
} }
// src/routes/service-groups.ts // src/routes/service-groups.ts
import { z as z4 } from "zod"; import { createServiceGroupSchema, toggleEnabledSchema } from "@cfdm/shared";
async function serviceGroupRoutes(app2) { async function serviceGroupRoutes(app2) {
const bodySchema = z4.object({ const bodySchema = createServiceGroupSchema;
name: z4.string(),
type: z4.string().optional(),
icon: z4.string().optional(),
domain: z4.string().optional()
});
app2.get("/service-groups", async (request) => { app2.get("/service-groups", async (request) => {
return listGroupViews(request.server.db); return listGroupViews(request.server.db);
}); });
@@ -1488,7 +1783,7 @@ async function serviceGroupRoutes(app2) {
}); });
app2.patch("/service-groups/:id/toggle", async (request) => { app2.patch("/service-groups/:id/toggle", async (request) => {
const { id } = request.params; const { id } = request.params;
const body = z4.object({ enabled: z4.boolean() }).parse(request.body); const body = toggleEnabledSchema.parse(request.body);
return toggleGroup( return toggleGroup(
request.server.db, request.server.db,
request.server.cf, request.server.cf,
@@ -1499,18 +1794,18 @@ async function serviceGroupRoutes(app2) {
} }
// src/routes/service-bindings.ts // src/routes/service-bindings.ts
import { z as z5 } from "zod"; import { z as z4 } from "zod";
async function serviceBindingRoutes(app2) { async function serviceBindingRoutes(app2) {
const createSchema = z5.object({ const createSchema = z4.object({
domain_id: z5.number(), domain_id: z4.number(),
service_id: z5.number(), service_id: z4.number(),
hostname: z5.string().optional(), hostname: z4.string().optional(),
target_ip: z5.string().optional() target_ip: z4.string().optional()
}); });
const updateSchema = z5.object({ const updateSchema = z4.object({
service_id: z5.number().optional(), service_id: z4.number().optional(),
hostname: z5.string().optional(), hostname: z4.string().optional(),
target_ip: z5.string().optional() target_ip: z4.string().optional()
}); });
app2.get("/service-bindings", async (request) => { app2.get("/service-bindings", async (request) => {
return listAll(request.server.db); return listAll(request.server.db);
@@ -1550,15 +1845,15 @@ async function serviceBindingRoutes(app2) {
} }
// src/routes/domains.ts // src/routes/domains.ts
import { z as z6 } from "zod"; import { z as z5 } from "zod";
async function domainRoutes(app2) { async function domainRoutes(app2) {
const createSchema = z6.object({ const createSchema = z5.object({
zone_name: z6.string(), zone_name: z5.string(),
group_id: z6.number().nullable().optional() group_id: z5.number().nullable().optional()
}); });
const updateSchema = z6.object({ const updateSchema = z5.object({
group_id: z6.number().nullable().optional(), group_id: z5.number().nullable().optional(),
status: z6.string().optional() status: z5.string().optional()
}); });
app2.get("/domains", async (request) => { app2.get("/domains", async (request) => {
const query = request.query; const query = request.query;
@@ -1605,7 +1900,7 @@ async function domainRoutes(app2) {
}); });
app2.put("/domains/:id/services", async (request) => { app2.put("/domains/:id/services", async (request) => {
const { id } = request.params; const { id } = request.params;
const body = z6.object({ service_ids: z6.array(z6.number()) }).parse(request.body); const body = z5.object({ service_ids: z5.array(z5.number()) }).parse(request.body);
const serviceIds = await setDomainServices2( const serviceIds = await setDomainServices2(
request.server.db, request.server.db,
Number(id), Number(id),
@@ -1616,15 +1911,15 @@ async function domainRoutes(app2) {
} }
// src/routes/dns.ts // src/routes/dns.ts
import { z as z7 } from "zod"; import { z as z6 } from "zod";
async function dnsRoutes(app2) { async function dnsRoutes(app2) {
const createSchema = z7.object({ const createSchema = z6.object({
record_type: z7.string(), record_type: z6.string(),
name: z7.string(), name: z6.string(),
content: z7.string(), content: z6.string(),
ttl: z7.number().optional(), ttl: z6.number().optional(),
proxied: z7.boolean().optional(), proxied: z6.boolean().optional(),
priority: z7.number().optional() priority: z6.number().optional()
}); });
app2.get("/domains/:id/dns", async (request) => { app2.get("/domains/:id/dns", async (request) => {
const { id } = request.params; const { id } = request.params;
@@ -1653,7 +1948,7 @@ async function dnsRoutes(app2) {
}); });
app2.post("/domains/:id/dns/bulk", async (request) => { app2.post("/domains/:id/dns/bulk", async (request) => {
const { id } = request.params; const { id } = request.params;
const body = z7.object({ operations: z7.array(z7.record(z7.unknown())) }).parse(request.body); const body = z6.object({ operations: z6.array(z6.record(z6.unknown())) }).parse(request.body);
return bulk( return bulk(
request.server.db, request.server.db,
request.server.cf, request.server.cf,
@@ -1691,7 +1986,7 @@ async function dnsRoutes(app2) {
}); });
app2.post("/domains/:id/dns/:recordId/resolve", async (request) => { app2.post("/domains/:id/dns/:recordId/resolve", async (request) => {
const { id, recordId } = request.params; const { id, recordId } = request.params;
const body = z7.object({ source: z7.string() }).parse(request.body); const body = z6.object({ source: z6.string() }).parse(request.body);
return resolveConflict( return resolveConflict(
request.server.db, request.server.db,
request.server.cf, request.server.cf,
@@ -1703,8 +1998,9 @@ async function dnsRoutes(app2) {
} }
// src/routes/subdomains.ts // src/routes/subdomains.ts
import { z as z8 } from "zod"; import { updateSubdomainSchema } from "@cfdm/shared";
import { repos as repos8 } from "@cfdm/db"; import { repos as repos8 } from "@cfdm/db";
import { z as z7 } from "zod";
async function subdomainRoutes(app2) { async function subdomainRoutes(app2) {
app2.get("/domains/:id/subdomains", async (request) => { app2.get("/domains/:id/subdomains", async (request) => {
const { id } = request.params; const { id } = request.params;
@@ -1713,7 +2009,7 @@ async function subdomainRoutes(app2) {
}); });
app2.post("/domains/:id/subdomains", async (request) => { app2.post("/domains/:id/subdomains", async (request) => {
const { id } = request.params; const { id } = request.params;
const body = z8.object({ name: z8.string() }).parse(request.body); const body = z7.object({ name: z7.string() }).parse(request.body);
const domain = repos8.getDomain(request.server.db, Number(id)); const domain = repos8.getDomain(request.server.db, Number(id));
const fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`; const fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`;
return repos8.createSubdomain( return repos8.createSubdomain(
@@ -1729,16 +2025,18 @@ async function subdomainRoutes(app2) {
}); });
app2.patch("/subdomains/:id", async (request) => { app2.patch("/subdomains/:id", async (request) => {
const { id } = request.params; const { id } = request.params;
const body = z8.object({ name: z8.string() }).parse(request.body); const body = updateSubdomainSchema.parse(request.body);
const sub = repos8.getSubdomain(request.server.db, Number(id)); const sub = repos8.getSubdomain(request.server.db, Number(id));
const domain = repos8.getDomain(request.server.db, sub.domain_id); const domain = repos8.getDomain(request.server.db, sub.domain_id);
const fqdn = `${body.name}.${domain.zone_name}`; const patch = {};
return repos8.updateSubdomain( if (body.name !== void 0) {
request.server.db, patch.name = body.name;
Number(id), patch.fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`;
body.name, }
fqdn if (body.enabled !== void 0) {
); patch.enabled = body.enabled;
}
return repos8.updateSubdomain(request.server.db, Number(id), patch);
}); });
app2.delete("/subdomains/:id", async (request) => { app2.delete("/subdomains/:id", async (request) => {
const { id } = request.params; const { id } = request.params;
@@ -1896,6 +2194,7 @@ async function syncRoutes(app2) {
} }
// src/app.ts // src/app.ts
import { AsyncTask, CronJob } from "toad-scheduler";
async function buildApp(opts = {}) { async function buildApp(opts = {}) {
const config2 = opts.config ?? loadConfig(); const config2 = opts.config ?? loadConfig();
const app2 = Fastify({ const app2 = Fastify({
@@ -1943,20 +2242,23 @@ async function buildApp(opts = {}) {
} }
if (!opts.memory) { if (!opts.memory) {
await app2.register(import("@fastify/schedule")); await app2.register(import("@fastify/schedule"));
app2.scheduler.addCronJob( const certTask = new AsyncTask(
{ "certificate-check",
cronExpression: config2.certCheckCron,
name: "certificate-check"
},
async () => { async () => {
try { const n = await runAllChecks(app2.db);
const n = await runAllChecks(app2.db); app2.log.info({ checked: n }, "certificate check completed");
app2.log.info({ checked: n }, "certificate check completed"); },
} catch (err) { (err) => {
app2.log.warn({ err }, "certificate check failed"); app2.log.warn({ err }, "certificate check failed");
}
} }
); );
app2.scheduler.addCronJob(
new CronJob(
{ cronExpression: config2.certCheckCron },
certTask,
{ preventOverrun: true }
)
);
} }
return app2; return app2;
} }
+3 -6
View File
@@ -1,4 +1,5 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { updateDomainSchema } from "@cfdm/shared";
import { z } from "zod"; import { z } from "zod";
import * as domainService from "../services/domain-service.js"; import * as domainService from "../services/domain-service.js";
@@ -8,11 +9,6 @@ export async function domainRoutes(app: FastifyInstance) {
group_id: z.number().nullable().optional(), group_id: z.number().nullable().optional(),
}); });
const updateSchema = z.object({
group_id: z.number().nullable().optional(),
status: z.string().optional(),
});
app.get("/domains", async (request) => { app.get("/domains", async (request) => {
const query = request.query as { group_id?: string }; const query = request.query as { group_id?: string };
const groupId = query.group_id ? Number(query.group_id) : undefined; const groupId = query.group_id ? Number(query.group_id) : undefined;
@@ -36,13 +32,14 @@ export async function domainRoutes(app: FastifyInstance) {
app.patch("/domains/:id", async (request) => { app.patch("/domains/:id", async (request) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const body = updateSchema.parse(request.body); const body = updateDomainSchema.parse(request.body);
const existing = domainService.getDomain(request.server.db, Number(id)); const existing = domainService.getDomain(request.server.db, Number(id));
return domainService.updateDomain( return domainService.updateDomain(
request.server.db, request.server.db,
Number(id), Number(id),
body.group_id !== undefined ? body.group_id : existing.group_id, body.group_id !== undefined ? body.group_id : existing.group_id,
body.status ?? existing.status, body.status ?? existing.status,
body.cert_monitoring,
); );
}); });
+3 -8
View File
@@ -1,14 +1,9 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { z } from "zod"; import { createServiceGroupSchema, toggleEnabledSchema } from "@cfdm/shared";
import * as serviceConfig from "../services/service-config-service.js"; import * as serviceConfig from "../services/service-config-service.js";
export async function serviceGroupRoutes(app: FastifyInstance) { export async function serviceGroupRoutes(app: FastifyInstance) {
const bodySchema = z.object({ const bodySchema = createServiceGroupSchema;
name: z.string(),
type: z.string().optional(),
icon: z.string().optional(),
domain: z.string().optional(),
});
app.get("/service-groups", async (request) => { app.get("/service-groups", async (request) => {
return serviceConfig.listGroupViews(request.server.db); return serviceConfig.listGroupViews(request.server.db);
@@ -42,7 +37,7 @@ export async function serviceGroupRoutes(app: FastifyInstance) {
app.patch("/service-groups/:id/toggle", async (request) => { app.patch("/service-groups/:id/toggle", async (request) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const body = z.object({ enabled: z.boolean() }).parse(request.body); const body = toggleEnabledSchema.parse(request.body);
return serviceConfig.toggleGroup( return serviceConfig.toggleGroup(
request.server.db, request.server.db,
request.server.cf, request.server.cf,
+11
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { z } from "zod"; import { z } from "zod";
import { reorderServicesSchema } from "@cfdm/shared";
import { repos } from "@cfdm/db"; import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js"; import * as serviceConfig from "../services/service-config-service.js";
@@ -14,6 +15,16 @@ export async function serviceRoutes(app: FastifyInstance) {
return serviceConfig.listViews(request.server.db); return serviceConfig.listViews(request.server.db);
}); });
app.patch("/services/reorder", async (request) => {
const body = reorderServicesSchema.parse(request.body);
serviceConfig.reorderServices(
request.server.db,
body.group_id,
body.service_ids,
);
return { ok: true };
});
app.post("/services", async (request) => { app.post("/services", async (request) => {
const body = createSchema.parse(request.body); const body = createSchema.parse(request.body);
const service = repos.createService( const service = repos.createService(
+21 -9
View File
@@ -1,6 +1,8 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { z } from "zod"; import { updateSubdomainSchema } from "@cfdm/shared";
import { repos } from "@cfdm/db"; import { repos } from "@cfdm/db";
import { z } from "zod";
import type { UpdateSubdomainPatch } from "@cfdm/db";
export async function subdomainRoutes(app: FastifyInstance) { export async function subdomainRoutes(app: FastifyInstance) {
app.get("/domains/:id/subdomains", async (request) => { app.get("/domains/:id/subdomains", async (request) => {
@@ -32,16 +34,26 @@ export async function subdomainRoutes(app: FastifyInstance) {
app.patch("/subdomains/:id", async (request) => { app.patch("/subdomains/:id", async (request) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const body = z.object({ name: z.string() }).parse(request.body); const body = updateSubdomainSchema.parse(request.body);
const sub = repos.getSubdomain(request.server.db, Number(id)); const sub = repos.getSubdomain(request.server.db, Number(id));
const domain = repos.getDomain(request.server.db, sub.domain_id); const domain = repos.getDomain(request.server.db, sub.domain_id);
const fqdn = `${body.name}.${domain.zone_name}`;
return repos.updateSubdomain( const patch: UpdateSubdomainPatch = {};
request.server.db, if (body.name !== undefined) {
Number(id), patch.name = body.name;
body.name, patch.fqdn =
fqdn, body.name === "@"
); ? domain.zone_name
: `${body.name}.${domain.zone_name}`;
}
if (body.enabled !== undefined) {
patch.enabled = body.enabled;
}
if (body.cert_monitoring !== undefined) {
patch.cert_monitoring = body.cert_monitoring;
}
return repos.updateSubdomain(request.server.db, Number(id), patch);
}); });
app.delete("/subdomains/:id", async (request) => { app.delete("/subdomains/:id", async (request) => {
+140 -8
View File
@@ -2,13 +2,25 @@ import { connect } from "node:net";
import { connect as tlsConnect } from "node:tls"; import { connect as tlsConnect } from "node:tls";
import type { Db } from "@cfdm/db"; import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db"; import { repos } from "@cfdm/db";
import type { Certificate } from "@cfdm/shared"; import type { Certificate, Domain, Subdomain } from "@cfdm/shared";
import { import {
CERT_ERROR, CERT_ERROR,
CERT_MONITOR_AUTO,
CERT_MONITOR_REQUIRED,
CERT_MONITOR_SKIPPED,
CERT_UNKNOWN, CERT_UNKNOWN,
certStatusFromExpiry, certStatusFromExpiry,
fqdnToDisplay,
parseFqdn,
shouldMonitorService,
} from "@cfdm/shared"; } from "@cfdm/shared";
export interface CertificateTarget {
domainId: number;
subdomainId: number | null;
hostname: string;
}
export function listCertificates( export function listCertificates(
db: Db, db: Db,
status?: string, status?: string,
@@ -98,17 +110,137 @@ export async function checkAndStore(
); );
} }
export async function runAllChecks(db: Db): Promise<number> { function resolveMonitoringMode(
let count = 0; domain: Domain,
subdomain: Subdomain | null,
fqdn: string,
): string {
if (subdomain) {
return subdomain.cert_monitoring;
}
if (fqdn === domain.zone_name) {
return domain.cert_monitoring;
}
return CERT_MONITOR_AUTO;
}
function bindingSubdomain(
db: Db,
domainId: number,
hostname: string,
): Subdomain | null {
if (hostname === "@") return null;
return repos.findSubdomainByDomainAndName(db, domainId, hostname);
}
export function buildServiceCertificateFqdns(
db: Db,
): Map<string, CertificateTarget> {
const result = new Map<string, CertificateTarget>();
for (const binding of repos.listAllBindings(db)) {
const service = repos.getService(db, binding.service_id);
const group = service.service_group_id
? repos.getServiceGroup(db, service.service_group_id)
: null;
if (!shouldMonitorService(service, group)) continue;
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
if (subdomain && !subdomain.enabled) continue;
const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name);
result.set(fqdn, {
domainId: binding.domain_id,
subdomainId: subdomain?.id ?? null,
hostname: fqdn,
});
}
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
for (const group of repos.listServiceGroups(db)) {
if (!group.enabled || !group.domain?.trim()) continue;
const parsed = parseFqdn(group.domain, knownZones);
if (!parsed) continue;
const domain = repos.findDomainByZoneName(db, parsed.zoneName);
if (!domain) continue;
const subdomain =
parsed.hostname === "@"
? null
: bindingSubdomain(db, domain.id, parsed.hostname);
if (subdomain && !subdomain.enabled) continue;
result.set(parsed.fqdn, {
domainId: domain.id,
subdomainId: subdomain?.id ?? null,
hostname: parsed.fqdn,
});
}
return result;
}
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
const serviceFqdns = buildServiceCertificateFqdns(db);
const targets = new Map<string, CertificateTarget>();
for (const domain of repos.listAllDomains(db)) { for (const domain of repos.listAllDomains(db)) {
await checkAndStore(db, domain.id, null, domain.zone_name); if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
count += 1; if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) {
targets.set(domain.zone_name, {
domainId: domain.id,
subdomainId: null,
hostname: domain.zone_name,
});
}
} }
for (const sub of repos.listAllSubdomains(db)) { for (const sub of repos.listAllSubdomains(db)) {
await checkAndStore(db, sub.domain_id, sub.id, sub.fqdn); if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
count += 1; if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) {
targets.set(sub.fqdn, {
domainId: sub.domain_id,
subdomainId: sub.id,
hostname: sub.fqdn,
});
}
} }
return count;
for (const [fqdn, meta] of serviceFqdns) {
const domain = repos.getDomain(db, meta.domainId);
const subdomain = meta.subdomainId
? repos.getSubdomain(db, meta.subdomainId)
: null;
const monitoring = resolveMonitoringMode(domain, subdomain, fqdn);
if (monitoring === CERT_MONITOR_SKIPPED) continue;
if (
monitoring === CERT_MONITOR_AUTO ||
monitoring === CERT_MONITOR_REQUIRED
) {
targets.set(fqdn, meta);
}
}
return [...targets.values()];
}
export async function runAllChecks(db: Db): Promise<number> {
const targets = resolveCertificateTargets(db);
for (const target of targets) {
await checkAndStore(
db,
target.domainId,
target.subdomainId,
target.hostname,
);
}
repos.deleteCertificatesNotIn(
db,
targets.map((t) => t.hostname),
);
return targets.length;
} }
export function statusSummary(db: Db): Array<[string, number]> { export function statusSummary(db: Db): Array<[string, number]> {
+14 -9
View File
@@ -6,6 +6,7 @@ import {
SYNC_ERROR, SYNC_ERROR,
SYNC_PENDING_PUSH, SYNC_PENDING_PUSH,
SYNC_SYNCED, SYNC_SYNCED,
normalizeDnsRecordName,
} from "@cfdm/shared"; } from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js"; import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js"; import { AppError } from "../errors.js";
@@ -87,12 +88,12 @@ async function pushRecord(
repos.updateDnsFields( repos.updateDnsFields(
db, db,
record.id, record.id,
record.record_type, cfRec.type ?? record.record_type,
record.name, cfRec.name,
record.content, cfRec.content,
record.ttl, cfRec.ttl,
record.proxied, cfRec.proxied ?? false,
record.priority, cfRec.priority ?? null,
SYNC_SYNCED, SYNC_SYNCED,
cfRec.id ?? null, cfRec.id ?? null,
null, null,
@@ -119,13 +120,14 @@ export async function create(
const domain = repos.getDomain(db, domainId); const domain = repos.getDomain(db, domainId);
const ttl = req.ttl ?? 1; const ttl = req.ttl ?? 1;
const proxied = req.proxied ?? false; const proxied = req.proxied ?? false;
validateDnsRecord(req.record_type, req.name, req.content, ttl, proxied); const name = normalizeDnsRecordName(req.name, domain.zone_name);
validateDnsRecord(req.record_type, name, req.content, ttl, proxied);
const record = repos.insertDnsRecord( const record = repos.insertDnsRecord(
db, db,
domainId, domainId,
req.record_type, req.record_type,
req.name, name,
req.content, req.content,
ttl, ttl,
proxied, proxied,
@@ -149,7 +151,10 @@ export async function update(
const existing = repos.getDnsRecord(db, domainId, recordId); const existing = repos.getDnsRecord(db, domainId, recordId);
const recordType = req.record_type ?? existing.record_type; const recordType = req.record_type ?? existing.record_type;
const name = req.name ?? existing.name; const name = normalizeDnsRecordName(
req.name ?? existing.name,
domain.zone_name,
);
const content = req.content ?? existing.content; const content = req.content ?? existing.content;
const ttl = req.ttl ?? existing.ttl; const ttl = req.ttl ?? existing.ttl;
const proxied = req.proxied ?? existing.proxied; const proxied = req.proxied ?? existing.proxied;
+2 -1
View File
@@ -45,8 +45,9 @@ export function updateDomain(
id: number, id: number,
groupId: number | null, groupId: number | null,
status: string, status: string,
certMonitoring?: string,
): Domain { ): Domain {
return repos.updateDomain(db, id, groupId, status); return repos.updateDomain(db, id, groupId, status, certMonitoring);
} }
export function deleteDomain(db: Db, id: number): void { export function deleteDomain(db: Db, id: number): void {
+420 -33
View File
@@ -1,6 +1,7 @@
import type { Db } from "@cfdm/db"; import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db"; import { repos } from "@cfdm/db";
import type { import type {
DnsRecord,
Service, Service,
ServiceGroup, ServiceGroup,
ServiceGroupsResponse, ServiceGroupsResponse,
@@ -10,6 +11,8 @@ import {
SYNC_ERROR, SYNC_ERROR,
SYNC_PENDING_PUSH, SYNC_PENDING_PUSH,
SYNC_SYNCED, SYNC_SYNCED,
dnsRecordNamesMatch,
normalizeDnsRecordName,
} from "@cfdm/shared"; } from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js"; import type { CloudflareClient } from "../lib/cf-client.js";
import { AppError } from "../errors.js"; import { AppError } from "../errors.js";
@@ -21,6 +24,7 @@ export interface ServiceDomainInput {
fqdn: string; fqdn: string;
target_ips?: string[]; target_ips?: string[];
target_ip?: string; target_ip?: string;
target_cname?: string;
} }
export interface ToggleRequest { export interface ToggleRequest {
@@ -30,8 +34,8 @@ export interface ToggleRequest {
export interface ServiceGroupBody { export interface ServiceGroupBody {
name: string; name: string;
type?: string; type?: string;
icon?: string; icon?: string | null;
domain?: string; domain?: string | null;
} }
export interface UpdateServiceConfigRequest { export interface UpdateServiceConfigRequest {
@@ -114,13 +118,20 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
const records = repos.listRecordsForBinding(db, binding.id); const records = repos.listRecordsForBinding(db, binding.id);
const statuses = records.map((r) => r.sync_status); const statuses = records.map((r) => r.sync_status);
const targetIps = repos.listBindingIps(db, binding.id); const targetIps = repos.listBindingIps(db, binding.id);
const linkedCname = records.find(
(record) => record.record_type.toUpperCase() === "CNAME",
);
const targetCname =
binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
return { return {
binding_id: binding.id, binding_id: binding.id,
domain_id: binding.domain_id, domain_id: binding.domain_id,
zone_name: binding.zone_name, zone_name: binding.zone_name,
hostname: binding.hostname, hostname: binding.hostname,
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name), fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
target_ips: targetIps, record_type: targetCname ? ("CNAME" as const) : ("A" as const),
target_ips: targetCname ? [] : targetIps,
target_cname: targetCname,
sync_status: aggregateSyncStatus(statuses), sync_status: aggregateSyncStatus(statuses),
}; };
}); });
@@ -185,16 +196,144 @@ async function syncBindingDns(
domainId: number, domainId: number,
hostname: string, hostname: string,
desiredIps: string[], desiredIps: string[],
cnameTarget: string | null,
): Promise<void> { ): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
let effectiveCname = cnameTarget?.trim() || null;
if (!effectiveCname) {
const existingCname = await findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"CNAME",
);
if (existingCname) {
effectiveCname = existingCname.content;
repos.setBindingCnameTarget(db, bindingId, effectiveCname);
repos.replaceBindingIps(db, bindingId, []);
}
}
if (effectiveCname) {
await syncBindingCnameDns(
db,
cf,
bindingId,
domainId,
hostname,
effectiveCname,
);
return;
}
await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps);
}
async function syncBindingCnameDns(
db: Db,
cf: CloudflareClient,
bindingId: number,
domainId: number,
hostname: string,
cnameTarget: string,
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
const normalized = normalizeCnameTarget(cnameTarget, zoneName);
const existingRecords = repos.listRecordsForBinding(db, bindingId); const existingRecords = repos.listRecordsForBinding(db, bindingId);
for (const record of existingRecords) { for (const record of existingRecords) {
if (!desiredIps.includes(record.content)) { if (record.record_type.toUpperCase() === "A") {
repos.unlinkBindingRecord(db, bindingId, record.id); repos.unlinkBindingRecord(db, bindingId, record.id);
await dnsService.deleteRecord(db, cf, domainId, record.id); await dnsService.deleteRecord(db, cf, domainId, record.id);
} }
} }
const refreshed = repos.listRecordsForBinding(db, bindingId);
const existingCname = refreshed.find(
(record) => record.record_type.toUpperCase() === "CNAME",
);
let recordId: number;
if (existingCname) {
if (
!cnameContentMatches(existingCname.content, normalized, zoneName) ||
!dnsRecordNamesMatch(existingCname.name, hostname, zoneName)
) {
await dnsService.update(db, cf, domainId, existingCname.id, {
record_type: "CNAME",
name: dnsNameForBinding(hostname, zoneName),
content: normalized,
proxied: false,
});
}
recordId = existingCname.id;
} else {
const adopted = await findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"CNAME",
normalized,
);
if (adopted) {
repos.linkBindingRecord(db, bindingId, adopted.id);
if (!cnameContentMatches(adopted.content, normalized, zoneName)) {
await dnsService.update(db, cf, domainId, adopted.id, {
record_type: "CNAME",
name: dnsNameForBinding(hostname, zoneName),
content: normalized,
proxied: false,
});
}
recordId = adopted.id;
} else {
const record = await dnsService.create(db, cf, domainId, {
record_type: "CNAME",
name: dnsNameForBinding(hostname, zoneName),
content: normalized,
ttl: 1,
proxied: false,
});
repos.linkBindingRecord(db, bindingId, record.id);
recordId = record.id;
}
}
repos.setBindingDnsRecordId(db, bindingId, recordId);
repos.setBindingCnameTarget(db, bindingId, normalized);
}
async function syncBindingADns(
db: Db,
cf: CloudflareClient,
bindingId: number,
domainId: number,
hostname: string,
desiredIps: string[],
): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
const existingRecords = repos.listRecordsForBinding(db, bindingId);
for (const record of existingRecords) {
if (record.record_type.toUpperCase() === "CNAME") {
repos.unlinkBindingRecord(db, bindingId, record.id);
await dnsService.deleteRecord(db, cf, domainId, record.id);
} else if (!desiredIps.includes(record.content)) {
repos.unlinkBindingRecord(db, bindingId, record.id);
await dnsService.deleteRecord(db, cf, domainId, record.id);
}
}
repos.setBindingCnameTarget(db, bindingId, null);
if (desiredIps.length === 0) { if (desiredIps.length === 0) {
repos.setBindingDnsRecordId(db, bindingId, null); repos.setBindingDnsRecordId(db, bindingId, null);
return; return;
@@ -207,25 +346,39 @@ async function syncBindingDns(
const existing = refreshed.find((r) => r.content === ip); const existing = refreshed.find((r) => r.content === ip);
let recordId: number; let recordId: number;
if (existing) { if (existing) {
if (existing.name !== hostname) { if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
await dnsService.update(db, cf, domainId, existing.id, { await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A", record_type: "A",
name: hostname, name: dnsNameForBinding(hostname, zoneName),
content: ip, content: ip,
proxied: false, proxied: false,
}); });
} }
recordId = existing.id; recordId = existing.id;
} else { } else {
const record = await dnsService.create(db, cf, domainId, { const adopted = await findOrImportDnsRecord(
record_type: "A", db,
name: hostname, cf,
content: ip, domainId,
ttl: 1, zoneName,
proxied: false, hostname,
}); "A",
repos.linkBindingRecord(db, bindingId, record.id); ip,
recordId = record.id; );
if (adopted) {
repos.linkBindingRecord(db, bindingId, adopted.id);
recordId = adopted.id;
} else {
const record = await dnsService.create(db, cf, domainId, {
record_type: "A",
name: dnsNameForBinding(hostname, zoneName),
content: ip,
ttl: 1,
proxied: false,
});
repos.linkBindingRecord(db, bindingId, record.id);
recordId = record.id;
}
} }
if (primaryId == null) primaryId = recordId; if (primaryId == null) primaryId = recordId;
} }
@@ -240,7 +393,7 @@ async function cleanupBindingDns(
domainId: number, domainId: number,
hostname: string, hostname: string,
): Promise<void> { ): Promise<void> {
await syncBindingDns(db, cf, bindingId, domainId, hostname, []); await syncBindingDns(db, cf, bindingId, domainId, hostname, [], null);
} }
async function cleanupServiceDnsOnly( async function cleanupServiceDnsOnly(
@@ -272,6 +425,7 @@ function validateTargetIpsInPool(targetIps: string[], ips: string[]): void {
} }
function bindingTargetIps(input: ServiceDomainInput): string[] { function bindingTargetIps(input: ServiceDomainInput): string[] {
if (input.target_cname?.trim()) return [];
const raw = input.target_ips const raw = input.target_ips
? input.target_ips ? input.target_ips
: input.target_ip?.trim() : input.target_ip?.trim()
@@ -284,26 +438,209 @@ function bindingTargetIps(input: ServiceDomainInput): string[] {
return normalized; return normalized;
} }
function bindingTargetCname(input: ServiceDomainInput): string | null {
const target = input.target_cname?.trim();
return target ? target : null;
}
function normalizeCnameTarget(target: string, zoneName: string): string {
const trimmed = target.trim().toLowerCase();
if (!trimmed) {
throw AppError.validation("укажите CNAME-цель");
}
if (trimmed.includes(".")) return trimmed;
return `${trimmed}.${zoneName.toLowerCase()}`;
}
function dnsNameForBinding(hostname: string, zoneName: string): string {
return normalizeDnsRecordName(hostname, zoneName);
}
function cnameContentMatches(
left: string,
right: string,
zoneName: string,
): boolean {
return (
normalizeCnameTarget(left, zoneName) ===
normalizeCnameTarget(right, zoneName)
);
}
function findLocalDnsRecord(
db: Db,
domainId: number,
zoneName: string,
hostname: string,
recordType: "A" | "CNAME",
content?: string,
): DnsRecord | null {
const records = repos.listDnsByDomain(db, domainId);
return (
records.find(
(record) =>
record.record_type.toUpperCase() === recordType &&
(content == null || record.content === content) &&
dnsRecordNamesMatch(record.name, hostname, zoneName),
) ?? null
);
}
async function findOrImportDnsRecord(
db: Db,
cf: CloudflareClient,
domainId: number,
zoneName: string,
hostname: string,
recordType: "A" | "CNAME",
content?: string,
): Promise<DnsRecord | null> {
const local = findLocalDnsRecord(
db,
domainId,
zoneName,
hostname,
recordType,
content,
);
if (local) return local;
const domain = repos.getDomain(db, domainId);
const remote = await cf.listDnsRecords(domain.cf_zone_id);
for (const cfRec of remote) {
if (cfRec.type.toUpperCase() !== recordType) continue;
if (content != null) {
if (recordType === "CNAME") {
if (!cnameContentMatches(cfRec.content, content, zoneName)) continue;
} else if (cfRec.content !== content) {
continue;
}
}
if (!dnsRecordNamesMatch(cfRec.name, hostname, zoneName)) continue;
if (!cfRec.id) continue;
const existing = repos.findDnsByCfId(db, domainId, cfRec.id);
if (existing) return existing;
return repos.insertDnsRecord(
db,
domainId,
cfRec.type,
cfRec.name,
cfRec.content,
cfRec.ttl,
cfRec.proxied ?? false,
cfRec.priority ?? null,
SYNC_SYNCED,
"cloudflare",
cfRec.id,
);
}
return null;
}
async function findOrImportDnsARecord(
db: Db,
cf: CloudflareClient,
domainId: number,
zoneName: string,
hostname: string,
content: string,
): Promise<DnsRecord | null> {
return findOrImportDnsRecord(
db,
cf,
domainId,
zoneName,
hostname,
"A",
content,
);
}
async function serviceBindingsExistInDns(
db: Db,
cf: CloudflareClient,
serviceId: number,
): Promise<boolean> {
const bindings = repos.listBindingsByService(db, serviceId);
if (bindings.length === 0) return false;
for (const binding of bindings) {
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) {
const record = await findOrImportDnsRecord(
db,
cf,
binding.domain_id,
binding.zone_name,
binding.hostname,
"CNAME",
cnameTarget,
);
if (!record) return false;
continue;
}
const targetIps = repos.listBindingIps(db, binding.id);
if (targetIps.length === 0) return false;
for (const ip of targetIps) {
const record = await findOrImportDnsARecord(
db,
cf,
binding.domain_id,
binding.zone_name,
binding.hostname,
ip,
);
if (!record) return false;
}
}
return true;
}
async function syncServiceBindingsToDns( async function syncServiceBindingsToDns(
db: Db, db: Db,
cf: CloudflareClient, cf: CloudflareClient,
serviceId: number, serviceId: number,
): Promise<void> { ): Promise<void> {
const ips = repos.listServiceIps(db, serviceId);
if (ips.length === 0) {
throw AppError.validation("добавьте IP-адреса в пул сервиса");
}
const bindings = repos.listBindingsByService(db, serviceId); const bindings = repos.listBindingsByService(db, serviceId);
if (bindings.length === 0) { if (bindings.length === 0) {
throw AppError.validation("настройте FQDN в редакторе сервиса"); throw AppError.validation("настройте FQDN в редакторе сервиса");
} }
const needsIpPool = bindings.some((binding) => {
if (binding.cname_target?.trim()) return false;
const targetIps = repos.listBindingIps(db, binding.id);
return targetIps.length > 0;
});
const ips = repos.listServiceIps(db, serviceId);
if (needsIpPool && ips.length === 0) {
throw AppError.validation("добавьте IP-адреса в пул сервиса");
}
for (const binding of bindings) { for (const binding of bindings) {
const cnameTarget = binding.cname_target?.trim() || null;
if (cnameTarget) {
await syncBindingDns(
db,
cf,
binding.id,
binding.domain_id,
binding.hostname,
[],
cnameTarget,
);
continue;
}
const targetIps = repos.listBindingIps(db, binding.id); const targetIps = repos.listBindingIps(db, binding.id);
if (targetIps.length === 0) { if (targetIps.length === 0) {
throw AppError.validation( throw AppError.validation(
`укажите IP для ${fqdnToDisplay(binding.hostname, binding.zone_name)}`, `укажите IP или CNAME для ${fqdnToDisplay(binding.hostname, binding.zone_name)}`,
); );
} }
validateTargetIpsInPool(targetIps, ips); validateTargetIpsInPool(targetIps, ips);
@@ -314,6 +651,7 @@ async function syncServiceBindingsToDns(
binding.domain_id, binding.domain_id,
binding.hostname, binding.hostname,
targetIps, targetIps,
null,
); );
} }
} }
@@ -345,6 +683,8 @@ async function syncGroupDomainDnsRecords(
hostname: string, hostname: string,
desiredIps: string[], desiredIps: string[],
): Promise<void> { ): Promise<void> {
const domain = repos.getDomain(db, domainId);
const zoneName = domain.zone_name;
const existingRecords = repos.listGroupDnsRecords(db, groupId); const existingRecords = repos.listGroupDnsRecords(db, groupId);
for (const record of existingRecords) { for (const record of existingRecords) {
@@ -360,19 +700,31 @@ async function syncGroupDomainDnsRecords(
for (const ip of desiredIps) { for (const ip of desiredIps) {
const existing = refreshed.find((r) => r.content === ip); const existing = refreshed.find((r) => r.content === ip);
if (existing) { if (existing) {
if (existing.name !== hostname) { if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
await dnsService.update(db, cf, domainId, existing.id, { await dnsService.update(db, cf, domainId, existing.id, {
record_type: "A", record_type: "A",
name: hostname, name: dnsNameForBinding(hostname, zoneName),
content: ip, content: ip,
proxied: false, proxied: false,
}); });
} }
continue; continue;
} }
const adopted = await findOrImportDnsARecord(
db,
cf,
domainId,
zoneName,
hostname,
ip,
);
if (adopted) {
repos.linkGroupDnsRecord(db, groupId, adopted.id);
continue;
}
const record = await dnsService.create(db, cf, domainId, { const record = await dnsService.create(db, cf, domainId, {
record_type: "A", record_type: "A",
name: hostname, name: dnsNameForBinding(hostname, zoneName),
content: ip, content: ip,
ttl: 1, ttl: 1,
proxied: false, proxied: false,
@@ -466,7 +818,7 @@ async function syncEnabledServicesInGroup(
async function normalizeGroupDomain( async function normalizeGroupDomain(
db: Db, db: Db,
cf: CloudflareClient, cf: CloudflareClient,
domain?: string, domain?: string | null,
): Promise<string | null> { ): Promise<string | null> {
const raw = domain?.trim(); const raw = domain?.trim();
if (!raw) return null; if (!raw) return null;
@@ -543,8 +895,15 @@ export async function updateConfig(
for (const input of req.domains) { for (const input of req.domains) {
const fqdn = input.fqdn.trim(); const fqdn = input.fqdn.trim();
if (!fqdn) continue; if (!fqdn) continue;
const targetCname = bindingTargetCname(input);
const targetIps = bindingTargetIps(input); const targetIps = bindingTargetIps(input);
validateTargetIpsInPool(targetIps, ips); if (!targetCname) {
validateTargetIpsInPool(targetIps, ips);
} else if (targetIps.length > 0) {
throw AppError.validation(
`укажите либо IP, либо CNAME для ${fqdn}`,
);
}
const { zoneName, hostname } = parseFqdn(fqdn, knownZones); const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
const domainId = await resolveDomainId(db, cf, zoneName); const domainId = await resolveDomainId(db, cf, zoneName);
@@ -554,7 +913,8 @@ export async function updateConfig(
repos.insertBinding(db, domainId, id, hostname, null); repos.insertBinding(db, domainId, id, hostname, null);
keptBindingIds.push(binding.id); keptBindingIds.push(binding.id);
repos.replaceBindingIps(db, binding.id, targetIps); repos.replaceBindingIps(db, binding.id, targetCname ? [] : targetIps);
repos.setBindingCnameTarget(db, binding.id, targetCname);
if (pushDns) { if (pushDns) {
await syncBindingDns( await syncBindingDns(
@@ -564,6 +924,7 @@ export async function updateConfig(
domainId, domainId,
hostname, hostname,
targetIps, targetIps,
targetCname,
); );
} }
} }
@@ -598,6 +959,15 @@ export async function updateConfig(
if (shouldPushDns(db, service)) { if (shouldPushDns(db, service)) {
await syncServiceBindingsToDns(db, cf, id); await syncServiceBindingsToDns(db, cf, id);
await syncGroupDomainForService(db, cf, id); await syncGroupDomainForService(db, cf, id);
} else if (
req.domains &&
req.domains.length > 0 &&
!service.enabled &&
(await serviceBindingsExistInDns(db, cf, id))
) {
repos.setServiceEnabled(db, id, true);
await syncServiceBindingsToDns(db, cf, id);
await syncGroupDomainForService(db, cf, id);
} }
return buildView(db, id); return buildView(db, id);
@@ -633,7 +1003,7 @@ export async function updateGroup(
await cleanupGroupDomainDns(db, cf, id); await cleanupGroupDomainDns(db, cf, id);
} }
const domain = await normalizeGroupDomain(db, cf, body.domain); const domain = await normalizeGroupDomain(db, cf, body.domain);
const group = repos.updateServiceGroup( let group = repos.updateServiceGroup(
db, db,
id, id,
body.name, body.name,
@@ -641,6 +1011,10 @@ export async function updateGroup(
body.icon ?? null, body.icon ?? null,
domain, domain,
); );
if (!domain && group.enabled) {
repos.setServiceGroupEnabled(db, id, false);
group = repos.getServiceGroup(db, id);
}
await syncEnabledServicesInGroup(db, cf, id); await syncEnabledServicesInGroup(db, cf, id);
return group; return group;
} }
@@ -659,12 +1033,9 @@ export async function toggleService(
if (enabled && service.service_group_id) { if (enabled && service.service_group_id) {
const group = repos.getServiceGroup(db, service.service_group_id); const group = repos.getServiceGroup(db, service.service_group_id);
if (!group.enabled) { if (group.domain?.trim() && !group.enabled) {
throw AppError.validation("сначала включите группу сервисов"); throw AppError.validation("сначала включите группу сервисов");
} }
if (!group.domain?.trim()) {
throw AppError.validation("укажите домен у группы сервисов");
}
} }
repos.setServiceEnabled(db, serviceId, enabled); repos.setServiceEnabled(db, serviceId, enabled);
@@ -686,6 +1057,11 @@ export async function toggleGroup(
groupId: number, groupId: number,
enabled: boolean, enabled: boolean,
): Promise<ServiceGroupsResponse> { ): Promise<ServiceGroupsResponse> {
const group = repos.getServiceGroup(db, groupId);
if (enabled && !group.domain?.trim()) {
throw AppError.validation("нельзя включить группу без домена");
}
repos.setServiceGroupEnabled(db, groupId, enabled); repos.setServiceGroupEnabled(db, groupId, enabled);
if (!enabled) { if (!enabled) {
@@ -703,3 +1079,14 @@ export async function toggleGroup(
return listGroupViews(db); return listGroupViews(db);
} }
export function reorderServices(
db: Db,
groupId: number | null,
serviceIds: number[],
): void {
if (groupId !== null) {
repos.getServiceGroup(db, groupId);
}
repos.reorderServices(db, groupId, serviceIds);
}
+123 -17
View File
@@ -1,16 +1,93 @@
import type { Db } from "@cfdm/db"; import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db"; import { repos } from "@cfdm/db";
import type { Domain, SyncJob } from "@cfdm/shared"; import type { CfDnsRecord, Domain, DnsRecord, SyncJob } from "@cfdm/shared";
import { import {
SYNC_CONFLICT, SYNC_CONFLICT,
SYNC_PENDING_PUSH, SYNC_PENDING_PUSH,
SYNC_SYNCED, SYNC_SYNCED,
dnsNameToSubdomainLabel, dnsNameToSubdomainLabel,
dnsRecordNamesMatch,
subdomainLabelToFqdn, subdomainLabelToFqdn,
} from "@cfdm/shared"; } from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js"; import type { CloudflareClient } from "../lib/cf-client.js";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
function findLocalByRemote(
local: DnsRecord[],
cfRec: CfDnsRecord,
zoneName: string,
): DnsRecord | null {
return (
local.find(
(record) =>
record.record_type.toUpperCase() === cfRec.type.toUpperCase() &&
dnsRecordNamesMatch(record.name, cfRec.name, zoneName),
) ?? null
);
}
function dnsRecordsEquivalent(
existing: DnsRecord,
cfRec: CfDnsRecord,
zoneName: string,
): boolean {
const proxied = cfRec.proxied ?? false;
return (
existing.content === cfRec.content &&
existing.ttl === cfRec.ttl &&
existing.proxied === proxied &&
dnsRecordNamesMatch(existing.name, cfRec.name, zoneName) &&
existing.record_type.toUpperCase() === cfRec.type.toUpperCase()
);
}
function applyRemoteRecord(
db: Db,
domainId: number,
cfRec: CfDnsRecord,
existing: DnsRecord,
zoneName: string,
): boolean {
const cfId = cfRec.id;
if (!cfId) return false;
const proxied = cfRec.proxied ?? false;
const equivalent = dnsRecordsEquivalent(existing, cfRec, zoneName);
if (!equivalent && existing.sync_status !== SYNC_PENDING_PUSH) {
repos.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT, cfId, null);
return true;
}
if (!equivalent) return false;
if (
existing.name !== cfRec.name ||
existing.sync_status !== SYNC_SYNCED ||
existing.cf_record_id !== cfId ||
existing.content !== cfRec.content ||
existing.ttl !== cfRec.ttl ||
existing.proxied !== proxied
) {
repos.updateDnsFields(
db,
existing.id,
cfRec.type,
cfRec.name,
cfRec.content,
cfRec.ttl,
proxied,
cfRec.priority ?? null,
SYNC_SYNCED,
cfId,
null,
);
return true;
}
return false;
}
export async function pullSync( export async function pullSync(
db: Db, db: Db,
cf: CloudflareClient, cf: CloudflareClient,
@@ -27,22 +104,14 @@ export async function pullSync(
for (const cfRec of remote) { for (const cfRec of remote) {
const cfId = cfRec.id; const cfId = cfRec.id;
if (!cfId) continue; if (!cfId) continue;
const proxied = cfRec.proxied ?? false;
const existing = repos.findDnsByCfId(db, domain.id, cfId); let existing = repos.findDnsByCfId(db, domain.id, cfId);
if (!existing) {
existing = findLocalByRemote(local, cfRec, domain.zone_name);
}
if (existing) { if (existing) {
const contentMatch = if (applyRemoteRecord(db, domain.id, cfRec, existing, domain.zone_name)) {
existing.content === cfRec.content &&
existing.ttl === cfRec.ttl &&
existing.proxied === proxied &&
existing.name === cfRec.name &&
existing.record_type.toUpperCase() === cfRec.type.toUpperCase();
if (!contentMatch && existing.sync_status !== SYNC_PENDING_PUSH) {
repos.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT, cfId, null);
changed += 1;
} else if (contentMatch && existing.sync_status === SYNC_CONFLICT) {
repos.setDnsSyncStatus(db, existing.id, SYNC_SYNCED, cfId, null);
changed += 1; changed += 1;
} }
} else { } else {
@@ -53,7 +122,7 @@ export async function pullSync(
cfRec.name, cfRec.name,
cfRec.content, cfRec.content,
cfRec.ttl, cfRec.ttl,
proxied, cfRec.proxied ?? false,
cfRec.priority ?? null, cfRec.priority ?? null,
SYNC_SYNCED, SYNC_SYNCED,
"cloudflare", "cloudflare",
@@ -63,7 +132,9 @@ export async function pullSync(
} }
} }
for (const rec of local) { const refreshedLocal = repos.listDnsByDomain(db, domain.id);
for (const rec of refreshedLocal) {
if (rec.cf_record_id && !remoteIds.has(rec.cf_record_id)) { if (rec.cf_record_id && !remoteIds.has(rec.cf_record_id)) {
if (rec.sync_status !== "pending_delete") { if (rec.sync_status !== "pending_delete") {
repos.setDnsSyncStatus( repos.setDnsSyncStatus(
@@ -75,6 +146,41 @@ export async function pullSync(
); );
changed += 1; changed += 1;
} }
continue;
}
if (rec.sync_status === SYNC_PENDING_PUSH) continue;
const remoteSameType = remote.find(
(r) =>
r.id &&
dnsRecordNamesMatch(r.name, rec.name, domain.zone_name) &&
r.type.toUpperCase() === rec.record_type.toUpperCase(),
);
if (remoteSameType?.id) {
if (applyRemoteRecord(db, domain.id, remoteSameType, rec, domain.zone_name)) {
changed += 1;
}
continue;
}
const remoteSameHost = remote.find((r) =>
dnsRecordNamesMatch(r.name, rec.name, domain.zone_name),
);
if (
remoteSameHost &&
remoteSameHost.type.toUpperCase() !== rec.record_type.toUpperCase()
) {
repos.setDnsSyncStatus(
db,
rec.id,
SYNC_CONFLICT,
rec.cf_record_id,
"type mismatch with cloudflare",
);
changed += 1;
} }
} }
+257
View File
@@ -0,0 +1,257 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { repos } from "@cfdm/db";
import {
CERT_ERROR,
CERT_MONITOR_REQUIRED,
CERT_MONITOR_SKIPPED,
} from "@cfdm/shared";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import * as certificateService from "../src/services/certificate-service.js";
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
const config = loadConfig();
const res = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: config.adminUsername, password: "admin" },
});
expect(res.statusCode).toBe(200);
const { token } = res.json() as { token: string };
return { authorization: `Bearer ${token}` };
}
describe("certificates", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("auto mode does not monitor DNS-only domain", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"dns-only.example.com",
"cf-zone-dns",
);
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: null,
error: "connection refused",
});
const checkRes = await testApp.inject({
method: "POST",
url: "/api/v1/certificates/check",
headers,
});
expect(checkRes.statusCode).toBe(200);
const certs = repos.listCertificates(testApp.db);
expect(certs.find((c) => c.hostname === domain.zone_name)).toBeUndefined();
await testApp.close();
});
it("monitors host with enabled service binding", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"app.example.com",
"cf-zone-app",
);
const service = repos.createService(testApp.db, "Web", "web");
repos.setServiceEnabled(testApp.db, service.id, true);
repos.insertBinding(testApp.db, domain.id, service.id, "api", null);
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt,
error: null,
});
await testApp.inject({
method: "POST",
url: "/api/v1/certificates/check",
headers,
});
const certs = repos.listCertificates(testApp.db);
expect(certs.some((c) => c.hostname === "api.app.example.com")).toBe(true);
expect(certs.some((c) => c.hostname === "app.example.com")).toBe(false);
await testApp.close();
});
it("does not monitor host when service is disabled", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"off.example.com",
"cf-zone-off",
);
const service = repos.createService(testApp.db, "Off", "off");
repos.insertBinding(testApp.db, domain.id, service.id, "@", null);
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
error: null,
});
await testApp.inject({
method: "POST",
url: "/api/v1/certificates/check",
headers,
});
expect(
repos.listCertificates(testApp.db).some((c) => c.hostname === domain.zone_name),
).toBe(false);
await testApp.close();
});
it("required apex is monitored without bindings", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"required.example.com",
"cf-zone-req",
);
repos.updateDomain(
testApp.db,
domain.id,
null,
"active",
CERT_MONITOR_REQUIRED,
);
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
error: null,
});
await testApp.inject({
method: "POST",
url: "/api/v1/certificates/check",
headers,
});
expect(
repos.listCertificates(testApp.db).some(
(c) => c.hostname === domain.zone_name,
),
).toBe(true);
await testApp.close();
});
it("skipped apex removes stale certificate on check", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"skipped.example.com",
"cf-zone-skip",
);
repos.upsertCertificateCheck(
testApp.db,
domain.id,
null,
domain.zone_name,
null,
CERT_ERROR,
"stale",
);
repos.updateDomain(
testApp.db,
domain.id,
null,
"active",
CERT_MONITOR_SKIPPED,
);
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: null,
error: "should not be called",
});
await testApp.inject({
method: "POST",
url: "/api/v1/certificates/check",
headers,
});
expect(repos.listCertificates(testApp.db)).toHaveLength(0);
expect(certificateService.checkHostname).not.toHaveBeenCalled();
await testApp.close();
});
it("TLS failure on monitored host is stored as error", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"broken.example.com",
"cf-zone-broken",
);
repos.updateDomain(
testApp.db,
domain.id,
null,
"active",
CERT_MONITOR_REQUIRED,
);
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: null,
error: "certificate has expired",
});
await testApp.inject({
method: "POST",
url: "/api/v1/certificates/check",
headers,
});
const cert = repos.listCertificates(testApp.db)[0];
expect(cert?.status).toBe(CERT_ERROR);
expect(cert?.last_error).toBeTruthy();
await testApp.close();
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
import { SYNC_CONFLICT, SYNC_ERROR, SYNC_SYNCED } from "@cfdm/shared";
import type { CloudflareClient } from "../src/lib/cf-client.js";
import { pullSync } from "../src/services/sync-service.js";
const ZONE = "rkns.top";
const CF_ZONE_ID = "zone-rkns";
function mockCf(remote: Array<{
id: string;
type: string;
name: string;
content: string;
}>): CloudflareClient {
return {
listDnsRecords: async () =>
remote.map((record) => ({
...record,
ttl: 1,
proxied: false,
})),
} as unknown as CloudflareClient;
}
function setupDb() {
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
return db;
}
describe("pullSync", () => {
it("reconciles short local names with Cloudflare FQDNs", async () => {
const db = setupDb();
const domain = repos.createDomain(db, null, ZONE, CF_ZONE_ID);
repos.insertDnsRecord(
db,
domain.id,
"A",
"de",
"193.233.134.103",
1,
false,
null,
SYNC_CONFLICT,
"local",
"cf-de",
);
const cf = mockCf([
{
id: "cf-de",
type: "A",
name: "de.rkns.top",
content: "193.233.134.103",
},
]);
const changes = await pullSync(db, cf, domain);
expect(changes).toBeGreaterThan(0);
const records = repos.listDnsByDomain(db, domain.id);
expect(records).toHaveLength(1);
expect(records[0].name).toBe("de.rkns.top");
expect(records[0].sync_status).toBe(SYNC_SYNCED);
});
it("marks local record as conflict when Cloudflare has another type", async () => {
const db = setupDb();
const domain = repos.createDomain(db, null, ZONE, CF_ZONE_ID);
repos.insertDnsRecord(
db,
domain.id,
"A",
"mhome",
"185.244.181.61",
1,
false,
null,
SYNC_ERROR,
"local",
null,
);
const cf = mockCf([
{
id: "cf-mhome",
type: "CNAME",
name: "mhome.rkns.top",
content: "mmsk.rkns.top",
},
]);
await pullSync(db, cf, domain);
const records = repos.listDnsByDomain(db, domain.id);
const localA = records.find((r) => r.record_type === "A");
expect(localA?.sync_status).toBe(SYNC_CONFLICT);
expect(localA?.last_error).toBe("type mismatch with cloudflare");
const localCname = records.find((r) => r.record_type === "CNAME");
expect(localCname?.sync_status).toBe(SYNC_SYNCED);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { repos } from "@cfdm/db";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
const config = loadConfig();
const res = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: config.adminUsername, password: "admin" },
});
expect(res.statusCode).toBe(200);
const { token } = res.json() as { token: string };
return { authorization: `Bearer ${token}` };
}
describe("services reorder", () => {
it("PATCH /services/reorder persists order within group and ungrouped", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const group = repos.createServiceGroup(app.db, "VPN", "vpn", null, null);
const alpha = repos.createService(app.db, "Alpha", "alpha");
const beta = repos.createService(app.db, "Beta", "beta");
const gamma = repos.createService(app.db, "Gamma", "gamma");
repos.setServiceGroup(app.db, alpha.id, group.id);
repos.setServiceGroup(app.db, beta.id, group.id);
const reorderRes = await app.inject({
method: "PATCH",
url: "/api/v1/services/reorder",
headers,
payload: {
group_id: group.id,
service_ids: [beta.id, alpha.id],
},
});
expect(reorderRes.statusCode).toBe(200);
const grouped = repos.listServicesByGroup(app.db, group.id);
expect(grouped.map((s) => s.id)).toEqual([beta.id, alpha.id]);
const ungroupedReorderRes = await app.inject({
method: "PATCH",
url: "/api/v1/services/reorder",
headers,
payload: {
group_id: null,
service_ids: [gamma.id],
},
});
expect(ungroupedReorderRes.statusCode).toBe(200);
const ungrouped = repos.listUngroupedServices(app.db);
expect(ungrouped[0]?.id).toBe(gamma.id);
await app.close();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { repos } from "@cfdm/db";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
const config = loadConfig();
const res = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: config.adminUsername, password: "admin" },
});
expect(res.statusCode).toBe(200);
const { token } = res.json() as { token: string };
return { authorization: `Bearer ${token}` };
}
describe("subdomains", () => {
it("PATCH /subdomains/:id toggles enabled and renames", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const domain = repos.createDomain(
app.db,
null,
"example.com",
"cf-zone-1",
);
const created = repos.createSubdomain(
app.db,
domain.id,
"www",
"www.example.com",
);
expect(created.enabled).toBe(true);
const disableRes = await app.inject({
method: "PATCH",
url: `/api/v1/subdomains/${created.id}`,
headers,
payload: { enabled: false },
});
expect(disableRes.statusCode).toBe(200);
expect(disableRes.json().enabled).toBe(false);
const renameRes = await app.inject({
method: "PATCH",
url: `/api/v1/subdomains/${created.id}`,
headers,
payload: { name: "api" },
});
expect(renameRes.statusCode).toBe(200);
const renamed = renameRes.json();
expect(renamed.name).toBe("api");
expect(renamed.fqdn).toBe("api.example.com");
await app.close();
});
});
+1
View File
@@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.5.0", "@base-ui/react": "^1.5.0",
"@cfdm/shared": "workspace:*",
"@cfdm/ui": "workspace:*", "@cfdm/ui": "workspace:*",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -0,0 +1,11 @@
export const UNGROUPED_COLUMN_ID = 'ungrouped'
export function groupColumnId(groupId: number) {
return `group-${groupId}`
}
export function parseGroupColumnId(columnId: string): number | null {
if (columnId === UNGROUPED_COLUMN_ID) return null
const match = columnId.match(/^group-(\d+)$/)
return match ? Number(match[1]) : null
}
@@ -0,0 +1,64 @@
import {
DndContext,
DragOverlay,
PointerSensor,
closestCenter,
pointerWithin,
rectIntersection,
useSensor,
useSensors,
type CollisionDetection,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core'
import type { ReactNode } from 'react'
interface DragContextProviderProps {
children: ReactNode
overlay?: ReactNode
onDragStart: (event: DragStartEvent) => void
onDragEnd: (event: DragEndEvent) => void
disabled?: boolean
}
const collisionDetection: CollisionDetection = (args) => {
const pointerCollisions = pointerWithin(args)
if (pointerCollisions.length > 0) {
return pointerCollisions
}
const intersectionCollisions = rectIntersection(args)
if (intersectionCollisions.length > 0) {
return intersectionCollisions
}
return closestCenter(args)
}
export function DragContextProvider({
children,
overlay,
onDragStart,
onDragEnd,
disabled = false,
}: DragContextProviderProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
)
if (disabled) {
return <>{children}</>
}
return (
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{children}
<DragOverlay dropAnimation={null}>{overlay}</DragOverlay>
</DndContext>
)
}
+9 -3
View File
@@ -12,7 +12,9 @@ import {
} from '@cfdm/ui/components/alert-dialog' } from '@cfdm/ui/components/alert-dialog'
interface ConfirmDialogProps { interface ConfirmDialogProps {
trigger: ReactElement trigger?: ReactElement
open?: boolean
onOpenChange?: (open: boolean) => void
title: string title: string
description: string description: string
confirmLabel?: string confirmLabel?: string
@@ -23,6 +25,8 @@ interface ConfirmDialogProps {
export function ConfirmDialog({ export function ConfirmDialog({
trigger, trigger,
open,
onOpenChange,
title, title,
description, description,
confirmLabel = 'Удалить', confirmLabel = 'Удалить',
@@ -31,8 +35,10 @@ export function ConfirmDialog({
disabled, disabled,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
return ( return (
<AlertDialog> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogTrigger disabled={disabled} render={trigger} /> {trigger ? (
<AlertDialogTrigger disabled={disabled} render={trigger} />
) : null}
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle> <AlertDialogTitle>{title}</AlertDialogTitle>
@@ -0,0 +1,17 @@
import { PlusIcon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
interface DomainActionsBarProps {
onCreateSubdomain: () => void
}
export function DomainActionsBar({ onCreateSubdomain }: DomainActionsBarProps) {
return (
<div className="flex flex-wrap items-center gap-2">
<Button onClick={onCreateSubdomain}>
<PlusIcon data-icon="inline-start" />
Создать поддомен
</Button>
</div>
)
}
@@ -22,7 +22,6 @@ import {
Item, Item,
ItemActions, ItemActions,
ItemContent, ItemContent,
ItemDescription,
ItemGroup, ItemGroup,
ItemSeparator, ItemSeparator,
ItemTitle, ItemTitle,
@@ -32,12 +31,8 @@ interface DomainBindingsCardProps {
bindings: ServiceBinding[] bindings: ServiceBinding[]
} }
function uniqueIps(bindings: ServiceBinding[]): string[] { function uniqueServices(bindings: ServiceBinding[]): string[] {
return [ return [...new Set(bindings.map((b) => b.service_name))]
...new Set(
bindings.map((b) => b.target_ip).filter((ip): ip is string => Boolean(ip)),
),
]
} }
export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) { export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
@@ -48,7 +43,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Привязки сервисов</CardTitle> <CardTitle>Привязки сервисов</CardTitle>
<CardDescription>IP-адреса, назначенные сервисам в этой зоне</CardDescription> <CardDescription>Сервисы, назначенные hostname в этой зоне</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{entries.length === 0 ? ( {entries.length === 0 ? (
@@ -56,34 +51,20 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
<EmptyHeader> <EmptyHeader>
<EmptyTitle>Нет привязок</EmptyTitle> <EmptyTitle>Нет привязок</EmptyTitle>
<EmptyDescription> <EmptyDescription>
Создайте привязку на странице сервисов IP появится здесь после синхронизации DNS Создайте привязку на странице сервисов или в таблице поддоменов
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
) : ( ) : (
<ItemGroup className="gap-0"> <ItemGroup className="gap-0">
{entries.map(([hostname, hostnameBindings], index) => { {entries.map(([hostname, hostnameBindings], index) => {
const ips = uniqueIps(hostnameBindings) const services = uniqueServices(hostnameBindings)
const services = [...new Set(hostnameBindings.map((b) => b.service_name))]
return ( return (
<div key={hostname}> <div key={hostname}>
<Item variant="outline"> <Item variant="outline">
<ItemContent className="gap-2"> <ItemContent className="gap-2">
<ItemTitle className="font-mono">{hostname}</ItemTitle> <ItemTitle className="font-mono">{hostname}</ItemTitle>
<ItemDescription>
<div className="flex flex-wrap gap-1">
{ips.length > 0 ? (
ips.map((ip) => (
<Badge key={ip} variant="secondary" className="font-mono">
{ip}
</Badge>
))
) : (
<span className="text-muted-foreground">IP не задан</span>
)}
</div>
</ItemDescription>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{services.map((name) => ( {services.map((name) => (
<Badge key={name}>{name}</Badge> <Badge key={name}>{name}</Badge>
@@ -103,6 +84,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
nativeButton={false}
render={<Link to="/services" search={{ domainId: undefined }} />} render={<Link to="/services" search={{ domainId: undefined }} />}
> >
Сервисы Сервисы
@@ -118,7 +100,7 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
</CardContent> </CardContent>
{entries.length > 0 && ( {entries.length > 0 && (
<CardFooter> <CardFooter>
<Button variant="link" className="h-auto p-0" render={<Link to="/services" search={{ domainId: undefined }} />}> <Button variant="link" className="h-auto p-0" nativeButton={false} render={<Link to="/services" search={{ domainId: undefined }} />}>
Управление привязками Управление привязками
</Button> </Button>
</CardFooter> </CardFooter>
@@ -1,87 +0,0 @@
import { useDraggable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { GlobeIcon } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import {
Card,
CardAction,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import { cn } from '@cfdm/ui/lib/utils'
import type { DomainListItem } from '@/lib/schemas'
interface DomainGroupCardProps {
domain: DomainListItem
serviceLabels?: string[]
}
export function DomainGroupCard({ domain, serviceLabels = [] }: DomainGroupCardProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: String(domain.id),
})
const style = transform
? { transform: CSS.Translate.toString(transform) }
: undefined
return (
<Card
ref={setNodeRef}
size="sm"
style={style}
className={cn(
'cursor-grab bg-card active:cursor-grabbing',
isDragging && 'opacity-60 shadow-lg',
)}
{...listeners}
{...attributes}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<GlobeIcon />
{domain.zone_name}
</CardTitle>
<CardAction>
<StatusBadge status={domain.status} />
</CardAction>
</CardHeader>
{(serviceLabels.length > 0 || domain.service_count > 0) && (
<CardContent>
<div className="flex flex-wrap gap-1">
{serviceLabels.length > 0
? serviceLabels.map((label) => (
<Badge key={label} variant="outline">
{label}
</Badge>
))
: (
<Badge variant="outline">
{domain.service_count} сервис(ов)
</Badge>
)}
</div>
</CardContent>
)}
<CardFooter>
<Button
variant="outline"
size="sm"
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
/>
}
>
Обзор
</Button>
</CardFooter>
</Card>
)
}
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react'
import type { CreateGroupInput, Group } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
Field,
FieldGroup,
FieldLabel,
} from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { Spinner } from '@cfdm/ui/components/spinner'
interface DomainGroupEditSheetProps {
mode: 'create' | 'edit'
group: Group | null
open: boolean
isSaving: boolean
onOpenChange: (open: boolean) => void
onCreate?: (body: CreateGroupInput) => void
onSave?: (id: number, body: CreateGroupInput) => void
}
export function DomainGroupEditSheet({
mode,
group,
open,
isSaving,
onOpenChange,
onCreate,
onSave,
}: DomainGroupEditSheetProps) {
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
useEffect(() => {
if (!open) return
if (mode === 'edit' && group) {
setName(group.name)
setSlug(group.slug)
} else {
setName('')
setSlug('')
}
}, [open, mode, group])
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const trimmedName = name.trim()
const trimmedSlug = slug.trim()
if (!trimmedName || !trimmedSlug) return
const body: CreateGroupInput = {
name: trimmedName,
slug: trimmedSlug,
}
if (mode === 'create') {
onCreate?.(body)
} else if (group) {
onSave?.(group.id, body)
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>
{mode === 'create' ? 'Новая группа доменов' : 'Редактировать группу'}
</SheetTitle>
<SheetDescription>
Группы используются для организации доменов на доске.
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="domain-group-name">Название</FieldLabel>
<Input
id="domain-group-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Production"
/>
</Field>
<Field>
<FieldLabel htmlFor="domain-group-slug">Slug</FieldLabel>
<Input
id="domain-group-slug"
value={slug}
onChange={(event) => setSlug(event.target.value)}
placeholder="production"
/>
</Field>
</FieldGroup>
<SheetFooter>
<Button type="submit" disabled={isSaving} className="w-full">
{isSaving && <Spinner data-icon="inline-start" />}
{isSaving ? 'Сохранение…' : mode === 'create' ? 'Создать' : 'Сохранить'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
+69
View File
@@ -0,0 +1,69 @@
import type { Domain } from '@/lib/schemas'
import type { CertMonitoring } from '@cfdm/shared'
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
import { StatusBadge } from '@/components/status-badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
interface DomainHeaderProps {
domain: Domain
onCertMonitoringChange?: (value: CertMonitoring) => void
isCertMonitoringSaving?: boolean
}
export function DomainHeader({
domain,
onCertMonitoringChange,
isCertMonitoringSaving,
}: DomainHeaderProps) {
const certMonitoringItems = certMonitoringOptions.map((option) => ({
label: option.label,
value: option.value,
}))
return (
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-semibold tracking-tight">{domain.zone_name}</h2>
<p className="text-sm text-muted-foreground">
Домен: <span className="font-mono text-foreground">{domain.zone_name}</span>
</p>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Статус зоны:</span>
<StatusBadge status={domain.status} />
</div>
{onCertMonitoringChange && (
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-2">
<span className="text-sm text-muted-foreground">
Мониторинг SSL (apex):
</span>
<Select
items={certMonitoringItems}
value={domain.cert_monitoring}
onValueChange={(value) =>
onCertMonitoringChange((value ?? 'auto') as CertMonitoring)
}
disabled={isCertMonitoringSaving}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue>
{certMonitoringLabel(domain.cert_monitoring)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)
}
+37 -12
View File
@@ -17,7 +17,7 @@ import {
MoreHorizontalIcon, MoreHorizontalIcon,
SearchIcon, SearchIcon,
} from 'lucide-react' } from 'lucide-react'
import { DomainIpBadges } from '@/components/domain-ip-badges' import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import type { DomainListItem } from '@/lib/schemas' import type { DomainListItem } from '@/lib/schemas'
import { Badge } from '@cfdm/ui/components/badge' import { Badge } from '@cfdm/ui/components/badge'
@@ -55,9 +55,7 @@ import {
TableRow, TableRow,
} from '@cfdm/ui/components/table' } from '@cfdm/ui/components/table'
export interface DomainTableRow extends DomainListItem { export type DomainTableRow = DomainListItem
ips: string[]
}
interface GroupFilterItem { interface GroupFilterItem {
label: string label: string
@@ -69,6 +67,8 @@ interface DomainsDataTableProps {
groupFilterItems: GroupFilterItem[] groupFilterItems: GroupFilterItem[]
groupFilterValue: string groupFilterValue: string
onGroupFilterChange: (value: string | null) => void onGroupFilterChange: (value: string | null) => void
onDelete: (domain: DomainTableRow) => void
isDeleting?: boolean
} }
export function DomainsDataTable({ export function DomainsDataTable({
@@ -76,9 +76,12 @@ export function DomainsDataTable({
groupFilterItems, groupFilterItems,
groupFilterValue, groupFilterValue,
onGroupFilterChange, onGroupFilterChange,
onDelete,
isDeleting = false,
}: DomainsDataTableProps) { }: DomainsDataTableProps) {
const [sorting, setSorting] = useState<SortingState>([]) const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [deleteTarget, setDeleteTarget] = useState<DomainTableRow | null>(null)
const columns = useMemo<ColumnDef<DomainTableRow>[]>( const columns = useMemo<ColumnDef<DomainTableRow>[]>(
() => [ () => [
@@ -98,6 +101,7 @@ export function DomainsDataTable({
<Button <Button
variant="link" variant="link"
className="h-auto p-0 font-medium" className="h-auto p-0 font-medium"
nativeButton={false}
render={ render={
<Link <Link
to="/domains/$domainId" to="/domains/$domainId"
@@ -120,6 +124,7 @@ export function DomainsDataTable({
<Button <Button
variant="link" variant="link"
className="h-auto p-0" className="h-auto p-0"
nativeButton={false}
render={ render={
<Link <Link
to="/groups/$groupId" to="/groups/$groupId"
@@ -134,13 +139,6 @@ export function DomainsDataTable({
return <Badge variant="outline">Без группы</Badge> return <Badge variant="outline">Без группы</Badge>
}, },
}, },
{
id: 'ips',
accessorFn: (row) => row.ips.join(' '),
header: 'IP-адреса',
enableSorting: false,
cell: ({ row }) => <DomainIpBadges ips={row.original.ips} />,
},
{ {
accessorKey: 'service_count', accessorKey: 'service_count',
header: 'Сервисы', header: 'Сервисы',
@@ -148,6 +146,7 @@ export function DomainsDataTable({
<Button <Button
variant="link" variant="link"
className="h-auto p-0 tabular-nums" className="h-auto p-0 tabular-nums"
nativeButton={false}
render={ render={
<Link <Link
to="/services" to="/services"
@@ -210,13 +209,20 @@ export function DomainsDataTable({
> >
DNS DNS
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={isDeleting}
onClick={() => setDeleteTarget(row.original)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
), ),
}, },
], ],
[], [isDeleting],
) )
const table = useReactTable({ const table = useReactTable({
@@ -353,6 +359,25 @@ export function DomainsDataTable({
</Button> </Button>
</div> </div>
)} )}
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
title="Удалить зону?"
description={
deleteTarget
? `Зона «${deleteTarget.zone_name}» будет удалена из менеджера вместе с DNS-записями и привязками. Зона в Cloudflare не затрагивается.`
: ''
}
onConfirm={() => {
if (!deleteTarget) return
onDelete(deleteTarget)
setDeleteTarget(null)
}}
disabled={isDeleting}
/>
</div> </div>
) )
} }
@@ -0,0 +1,96 @@
import {
groupColumnId,
UNGROUPED_COLUMN_ID,
} from '@/components/groups-board/column-ids'
import type { BoardColumn, BoardState } from '@/components/groups-board/types'
import type { DomainListItem, Group } from '@/lib/schemas'
export function mapGroupsDomainsToBoard(
groups: Group[],
domains: DomainListItem[],
): BoardState {
const columns: BoardColumn[] = groups.map((group) => ({
id: groupColumnId(group.id),
groupId: group.id,
title: group.name,
slug: group.slug,
items: domains.filter((domain) => domain.group_id === group.id),
group,
}))
const ungrouped = domains.filter((domain) => domain.group_id === null)
columns.push({
id: UNGROUPED_COLUMN_ID,
groupId: null,
title: 'Без группы',
slug: null,
items: ungrouped,
})
return { columns }
}
export function findColumnId(
columns: BoardColumn[],
itemId: string,
): string | undefined {
if (columns.some((column) => column.id === itemId)) return itemId
return columns.find((column) =>
column.items.some((domain) => String(domain.id) === itemId),
)?.id
}
export function moveDomainBetweenColumns(
board: BoardState,
domainId: number,
fromColumnId: string,
toColumnId: string,
): BoardState {
if (fromColumnId === toColumnId) return board
const fromColumn = board.columns.find((column) => column.id === fromColumnId)
const domain = fromColumn?.items.find((item) => item.id === domainId)
if (!domain || !fromColumn) return board
const targetColumn = board.columns.find((column) => column.id === toColumnId)
if (!targetColumn) return board
const updatedDomain: DomainListItem = {
...domain,
group_id: targetColumn.groupId,
group_name: targetColumn.group?.name ?? null,
}
const columns = board.columns.map((column) => {
if (column.id === fromColumnId) {
return {
...column,
items: column.items.filter((item) => item.id !== domainId),
}
}
if (column.id === toColumnId) {
return {
...column,
items: [...column.items, updatedDomain],
}
}
return column
})
return { columns }
}
export function boardToDomainsList(
board: BoardState,
previous: DomainListItem[],
): DomainListItem[] {
const byId = new Map(previous.map((domain) => [domain.id, domain]))
for (const column of board.columns) {
for (const domain of column.items) {
byId.set(domain.id, domain)
}
}
return Array.from(byId.values())
}
@@ -0,0 +1,5 @@
export {
UNGROUPED_COLUMN_ID,
groupColumnId,
parseGroupColumnId,
} from '@/components/board/column-ids'
@@ -0,0 +1,100 @@
import { FolderTreeIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import type { BoardColumn } from '@/components/groups-board/types'
import type { Group } from '@/lib/schemas'
import { AccordionTrigger } from '@cfdm/ui/components/accordion'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
interface DomainGroupHeaderProps {
column: BoardColumn
isOpen?: boolean
isDragging?: boolean
dragDisabled?: boolean
onEditGroup?: (group: Group) => void
onDeleteGroup?: (group: Group) => void
}
export function DomainGroupHeader({
column,
isOpen = true,
isDragging = false,
dragDisabled = false,
onEditGroup,
onDeleteGroup,
}: DomainGroupHeaderProps) {
return (
<div className="flex items-center gap-1 px-1">
<AccordionTrigger className="min-h-10 flex-1 items-center gap-2 rounded-md py-2 hover:bg-muted/40 hover:no-underline">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{column.groupId !== null ? (
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-background/80 text-muted-foreground">
<FolderTreeIcon />
</span>
) : null}
<span className="font-medium">{column.title}</span>
<Badge variant="secondary">{column.items.length}</Badge>
{column.slug ? (
<Badge variant="outline" className="font-mono">
{column.slug}
</Badge>
) : null}
{!isOpen && isDragging && !dragDisabled ? (
<Badge variant="default" className="font-normal">
Отпустите для переноса
</Badge>
) : null}
</div>
</AccordionTrigger>
{column.groupId !== null && column.group ? (
<div
className="flex shrink-0 items-center gap-1 pr-1"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия для группы ${column.title}`}
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onEditGroup ? (
<DropdownMenuItem onClick={() => onEditGroup(column.group!)}>
<PencilIcon data-icon="inline-start" />
Редактировать
</DropdownMenuItem>
) : null}
{onDeleteGroup ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteGroup(column.group!)}
>
<Trash2Icon data-icon="inline-start" />
Удалить группу
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,105 @@
import { useEffect } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { DomainGroupHeader } from '@/components/groups-board/domain-group-header'
import { DomainRow } from '@/components/groups-board/domain-row'
import type { BoardColumn } from '@/components/groups-board/types'
import type { Group } from '@/lib/schemas'
import {
AccordionContent,
AccordionItem,
} from '@cfdm/ui/components/accordion'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@cfdm/ui/components/empty'
import { ItemGroup, ItemSeparator } from '@cfdm/ui/components/item'
import { cn } from '@cfdm/ui/lib/utils'
interface DomainGroupItemProps {
column: BoardColumn
isOpen?: boolean
isDragging?: boolean
onExpandColumn?: (columnId: string) => void
onEditGroup?: (group: Group) => void
onDeleteGroup?: (group: Group) => void
dragDisabled?: boolean
serviceLabelsByDomain?: Map<number, string[]>
}
export function DomainGroupItem({
column,
isOpen = true,
isDragging = false,
onExpandColumn,
onEditGroup,
onDeleteGroup,
dragDisabled = false,
serviceLabelsByDomain,
}: DomainGroupItemProps) {
const { setNodeRef, isOver } = useDroppable({
id: column.id,
disabled: dragDisabled,
})
useEffect(() => {
if (isOver && isDragging && !dragDisabled) {
onExpandColumn?.(column.id)
}
}, [isOver, isDragging, dragDisabled, column.id, onExpandColumn])
return (
<AccordionItem
value={column.id}
className={cn(
'not-last:border-b-0 overflow-hidden rounded-lg border border-border transition-colors',
!isOpen && 'bg-muted/20',
isOpen && 'bg-muted/30',
isOver && !dragDisabled && 'ring-2 ring-primary/30',
)}
>
<DomainGroupHeader
column={column}
isOpen={isOpen}
isDragging={isDragging}
dragDisabled={dragDisabled}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
/>
<AccordionContent className="px-1 pb-2">
<div ref={setNodeRef}>
{column.items.length > 0 ? (
<ItemGroup className="gap-0 py-1">
{column.items.map((domain, index) => (
<div key={domain.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null}
<DomainRow
domain={domain}
serviceLabels={serviceLabelsByDomain?.get(domain.id)}
dragDisabled={dragDisabled}
/>
</div>
))}
</ItemGroup>
) : (
<Empty
className={cn(
'border border-dashed py-2',
isOver && !dragDisabled && 'border-primary bg-primary/5',
)}
>
<EmptyHeader>
<EmptyTitle className="text-sm">Нет доменов в группе</EmptyTitle>
<EmptyDescription>
Перетащите домен сюда
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -0,0 +1,177 @@
import { useDraggable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { Link } from '@tanstack/react-router'
import {
ExternalLinkIcon,
GripVerticalIcon,
MoreHorizontalIcon,
ServerIcon,
} from 'lucide-react'
import { StatusBadge } from '@/components/status-badge'
import type { DomainListItem } from '@/lib/schemas'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import { Card, CardContent } from '@cfdm/ui/components/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { cn } from '@cfdm/ui/lib/utils'
export interface DomainRowProps {
domain: DomainListItem
serviceLabels?: string[]
dragDisabled?: boolean
overlay?: boolean
}
function DomainServiceList({
labels,
serviceCount,
}: {
labels: string[]
serviceCount: number
}) {
if (labels.length === 0) {
return (
<span className="text-sm text-muted-foreground">
{serviceCount > 0 ? `${serviceCount} сервис(ов)` : 'Нет сервисов'}
</span>
)
}
if (labels.length === 1) {
return (
<Badge variant="secondary" className="max-w-full truncate font-normal">
{labels[0]}
</Badge>
)
}
return (
<Card size="sm" className="w-full shadow-none">
<CardContent className="flex flex-col gap-1 py-0">
{labels.map((label) => (
<div
key={label}
className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground"
>
<ServerIcon className="size-3 shrink-0" />
<span className="truncate">{label}</span>
</div>
))}
</CardContent>
</Card>
)
}
export function DomainRow({
domain,
serviceLabels = [],
dragDisabled = false,
overlay = false,
}: DomainRowProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: String(domain.id),
disabled: dragDisabled || overlay,
})
const style = transform
? { transform: CSS.Translate.toString(transform) }
: undefined
const hasServiceList = serviceLabels.length > 1
return (
<div
ref={overlay ? undefined : setNodeRef}
style={overlay ? undefined : style}
className={cn(
'flex gap-3 rounded-md px-3 transition-colors hover:bg-muted/50',
hasServiceList ? 'items-start py-2' : 'h-10 items-center',
(isDragging || overlay) && 'opacity-90 shadow-md',
isDragging && !overlay && 'z-10',
)}
>
{!dragDisabled && !overlay ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
'touch-none shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing',
hasServiceList && 'mt-0.5',
)}
aria-label={`Перетащить ${domain.zone_name}`}
{...listeners}
{...attributes}
>
<GripVerticalIcon />
</Button>
) : null}
<div
className={cn(
'flex min-w-0 shrink-0',
hasServiceList ? 'w-28 pt-0.5' : 'items-center',
)}
>
<span className="truncate text-sm font-medium">{domain.zone_name}</span>
</div>
<div className="min-w-0 flex-1">
<DomainServiceList
labels={serviceLabels}
serviceCount={domain.service_count}
/>
</div>
<div
className={cn(
'flex shrink-0 items-center gap-2',
hasServiceList && 'self-center',
)}
>
<StatusBadge status={domain.status} />
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия для ${domain.zone_name}`}
onPointerDown={(event) => event.stopPropagation()}
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
/>
}
>
<ExternalLinkIcon data-icon="inline-start" />
Обзор
</DropdownMenuItem>
<DropdownMenuItem
render={
<Link to="/services" search={{ domainId: domain.id }} />
}
>
<ServerIcon data-icon="inline-start" />
Сервисы
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
@@ -0,0 +1,19 @@
import { Skeleton } from '@cfdm/ui/components/skeleton'
export function GroupsBoardSkeleton() {
return (
<div className="grid w-full grid-cols-1 gap-2 lg:grid-cols-2">
{Array.from({ length: 4 }).map((_, groupIndex) => (
<div
key={groupIndex}
className="flex flex-col gap-2 rounded-lg border border-border px-2 py-2"
>
<Skeleton className="h-10 w-full" />
{Array.from({ length: 3 }).map((__, rowIndex) => (
<Skeleton key={rowIndex} className="h-10 w-full" />
))}
</div>
))}
</div>
)
}
@@ -0,0 +1,95 @@
import { useEffect, useMemo, useState } from 'react'
import { DragContextProvider } from '@/components/board/drag-context-provider'
import { DomainGroupItem } from '@/components/groups-board/domain-group-item'
import { DomainRow } from '@/components/groups-board/domain-row'
import type { BoardState } from '@/components/groups-board/types'
import type { DomainListItem, Group } from '@/lib/schemas'
import { Accordion } from '@cfdm/ui/components/accordion'
interface GroupsBoardProps {
board: BoardState
activeDomain?: DomainListItem
dragDisabled?: boolean
onDragStart: Parameters<typeof DragContextProvider>[0]['onDragStart']
onDragEnd: Parameters<typeof DragContextProvider>[0]['onDragEnd']
onEditGroup?: (group: Group) => void
onDeleteGroup?: (group: Group) => void
serviceLabelsByDomain?: Map<number, string[]>
}
export function GroupsBoard({
board,
activeDomain,
dragDisabled = false,
onDragStart,
onDragEnd,
onEditGroup,
onDeleteGroup,
serviceLabelsByDomain,
}: GroupsBoardProps) {
const columnIds = useMemo(
() => board.columns.map((column) => column.id),
[board.columns],
)
const columnIdsKey = columnIds.join(',')
const [openColumns, setOpenColumns] = useState<string[]>([])
useEffect(() => {
setOpenColumns((prev) => {
const preserved = prev.filter((id) => columnIds.includes(id))
const added = columnIds.filter((id) => !preserved.includes(id))
if (preserved.length === 0 && added.length > 0) {
return columnIds
}
return [...preserved, ...added]
})
}, [columnIdsKey, columnIds])
const isDragging = activeDomain != null
function handleExpandColumn(columnId: string) {
setOpenColumns((prev) =>
prev.includes(columnId) ? prev : [...prev, columnId],
)
}
return (
<DragContextProvider
disabled={dragDisabled}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
overlay={
activeDomain ? (
<DomainRow
domain={activeDomain}
serviceLabels={serviceLabelsByDomain?.get(activeDomain.id)}
dragDisabled
overlay
/>
) : null
}
>
<Accordion
multiple
value={openColumns}
onValueChange={setOpenColumns}
className="grid w-full grid-cols-1 gap-2 lg:grid-cols-2"
>
{board.columns.map((column) => (
<DomainGroupItem
key={column.id}
column={column}
isOpen={openColumns.includes(column.id)}
isDragging={isDragging}
onExpandColumn={handleExpandColumn}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
dragDisabled={dragDisabled}
serviceLabelsByDomain={serviceLabelsByDomain}
/>
))}
</Accordion>
</DragContextProvider>
)
}
@@ -0,0 +1,14 @@
import type { DomainListItem, Group } from '@/lib/schemas'
export interface BoardColumn {
id: string
groupId: number | null
title: string
slug: string | null
items: DomainListItem[]
group?: Group
}
export interface BoardState {
columns: BoardColumn[]
}
-96
View File
@@ -1,96 +0,0 @@
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core'
import { useState, type ReactNode } from 'react'
import { KanbanColumn } from '@/components/kanban-column'
import { ScrollArea, ScrollBar } from '@cfdm/ui/components/scroll-area'
export interface KanbanColumnDef<T> {
id: string
title: string
description?: string
href?: string
items: T[]
}
interface KanbanBoardProps<T> {
columns: KanbanColumnDef<T>[]
getItemId: (item: T) => string
renderCard: (item: T) => ReactNode
renderOverlay?: (item: T) => ReactNode
onMove: (itemId: string, fromColumnId: string, toColumnId: string) => void
}
function findColumnForItem<T>(
columns: KanbanColumnDef<T>[],
itemId: string,
getItemId: (item: T) => string,
): string | undefined {
return columns.find((col) => col.items.some((item) => getItemId(item) === itemId))?.id
}
export function KanbanBoard<T>({
columns,
getItemId,
renderCard,
renderOverlay,
onMove,
}: KanbanBoardProps<T>) {
const [activeId, setActiveId] = useState<string | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
)
const activeItem = activeId
? columns.flatMap((c) => c.items).find((item) => getItemId(item) === activeId)
: undefined
function handleDragStart(event: DragStartEvent) {
setActiveId(String(event.active.id))
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
setActiveId(null)
if (!over) return
const itemId = String(active.id)
const fromColumnId = findColumnForItem(columns, itemId, getItemId)
const toColumnId = String(over.id)
if (!fromColumnId || fromColumnId === toColumnId) return
onMove(itemId, fromColumnId, toColumnId)
}
return (
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<ScrollArea className="w-full">
<div className="flex w-max gap-4 pb-4">
{columns.map((column) => (
<KanbanColumn
key={column.id}
id={column.id}
title={column.title}
description={column.description}
href={column.href}
count={column.items.length}
>
{column.items.map((item) => (
<div key={getItemId(item)}>{renderCard(item)}</div>
))}
</KanbanColumn>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<DragOverlay dropAnimation={null}>
{activeItem && (renderOverlay ? renderOverlay(activeItem) : renderCard(activeItem))}
</DragOverlay>
</DndContext>
)
}
-81
View File
@@ -1,81 +0,0 @@
import { useDroppable } from '@dnd-kit/core'
import { Link } from '@tanstack/react-router'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@cfdm/ui/components/empty'
import { cn } from '@cfdm/ui/lib/utils'
import type { ReactNode } from 'react'
interface KanbanColumnProps {
id: string
title: string
description?: string
href?: string
count: number
children: ReactNode
}
export function KanbanColumn({
id,
title,
description,
href,
count,
children,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id })
const isEmpty = count === 0
return (
<div
ref={setNodeRef}
className={cn(
'flex w-80 shrink-0 flex-col gap-3 rounded-xl bg-muted/50 p-4 ring-1 ring-foreground/10 transition-shadow',
isOver && 'ring-2 ring-primary',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-col gap-1">
{href ? (
<Button
variant="link"
className="h-auto justify-start p-0 text-base font-medium"
render={
<Link
to="/groups/$groupId"
params={{ groupId: href.replace('/groups/', '') }}
/>
}
>
{title}
</Button>
) : (
<span className="text-base font-medium">{title}</span>
)}
{description && (
<span className="text-sm text-muted-foreground">{description}</span>
)}
</div>
<Badge variant="secondary">{count}</Badge>
</div>
<div className="flex min-h-32 flex-col gap-2">
{children}
{isEmpty && (
<Empty className="min-h-28 border">
<EmptyHeader>
<EmptyTitle className="text-xs">Пусто</EmptyTitle>
<EmptyDescription className="text-xs">
Перетащите домен сюда
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
</div>
)
}
-103
View File
@@ -1,103 +0,0 @@
import { PencilIcon } from 'lucide-react'
import { StatusBadge } from '@/components/status-badge'
import { bindingToFqdn } from '@/lib/parse-fqdn'
import type { ServiceView } from '@/lib/schemas'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Item,
ItemContent,
ItemGroup,
ItemTitle,
} from '@cfdm/ui/components/item'
interface ServiceCardProps {
service: ServiceView
onEdit: (service: ServiceView) => void
}
function aggregateSyncStatus(service: ServiceView) {
const statuses = (service.domains ?? [])
.map((domain) => domain.sync_status)
.filter((status): status is string => Boolean(status))
if (statuses.length === 0) return null
if (statuses.includes('error')) return 'error'
if (statuses.includes('pending_push')) return 'pending_push'
if (statuses.every((status) => status === 'synced')) return 'synced'
return statuses[0]
}
export function ServiceCard({ service, onEdit }: ServiceCardProps) {
const ips = service.ips ?? []
const domains = service.domains ?? []
const syncStatus = aggregateSyncStatus(service)
return (
<Card>
<CardHeader>
<CardTitle>{service.name}</CardTitle>
<CardDescription>
<Badge variant="outline">{service.slug}</Badge>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">IP-адреса</p>
{ips.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="flex flex-wrap gap-1.5">
{ips.map((ip) => (
<Badge key={ip} variant="secondary">
{ip}
</Badge>
))}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Домены</p>
{domains.length === 0 ? (
<p className="text-sm text-muted-foreground">Не привязаны</p>
) : (
<ItemGroup>
{domains.map((binding) => (
<Item key={binding.binding_id} variant="outline" size="sm">
<ItemContent>
<ItemTitle className="flex flex-wrap items-center gap-2">
<span>{bindingToFqdn(binding)}</span>
{binding.target_ips.map((ip) => (
<Badge key={ip} variant="secondary">
{ip}
</Badge>
))}
{binding.sync_status ? (
<StatusBadge status={binding.sync_status} />
) : null}
</ItemTitle>
</ItemContent>
</Item>
))}
</ItemGroup>
)}
</div>
</CardContent>
<CardFooter className="flex flex-wrap items-center justify-between gap-2">
<div>{syncStatus ? <StatusBadge status={syncStatus} /> : null}</div>
<Button type="button" variant="outline" size="sm" onClick={() => onEdit(service)}>
<PencilIcon data-icon="inline-start" />
Редактировать
</Button>
</CardFooter>
</Card>
)
}
+170 -62
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { PlusIcon, Trash2Icon } from 'lucide-react' import { Link2Icon, PlusIcon, Trash2Icon } from 'lucide-react'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { EmptyState } from '@/components/empty-state'
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input' import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input' import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import type { import type {
@@ -24,6 +25,7 @@ import {
ItemContent, ItemContent,
ItemGroup, ItemGroup,
} from '@cfdm/ui/components/item' } from '@cfdm/ui/components/item'
import { Separator } from '@cfdm/ui/components/separator'
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -49,7 +51,9 @@ import {
export interface ServiceBindingDraft { export interface ServiceBindingDraft {
fqdn: string fqdn: string
record_type: 'A' | 'CNAME'
target_ips: string[] target_ips: string[]
target_cname: string
} }
interface ServiceEditSheetProps { interface ServiceEditSheetProps {
@@ -60,6 +64,7 @@ interface ServiceEditSheetProps {
knownDomains: DomainListItem[] knownDomains: DomainListItem[]
isSaving: boolean isSaving: boolean
isDeleting?: boolean isDeleting?: boolean
defaultGroupId?: number | null
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onCreate?: (body: CreateServiceWithConfigInput) => void onCreate?: (body: CreateServiceWithConfigInput) => void
onSave?: (id: number, body: UpdateServiceConfigInput) => void onSave?: (id: number, body: UpdateServiceConfigInput) => void
@@ -69,17 +74,30 @@ interface ServiceEditSheetProps {
function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] { function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
return (service.domains ?? []).map((binding) => ({ return (service.domains ?? []).map((binding) => ({
fqdn: bindingToFqdn(binding), fqdn: bindingToFqdn(binding),
record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'),
target_ips: binding.target_ips ?? [], target_ips: binding.target_ips ?? [],
target_cname: binding.target_cname ?? '',
})) }))
} }
function buildDomainsPayload(bindings: ServiceBindingDraft[]) { function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
return bindings return bindings
.filter((binding) => binding.fqdn.trim() && binding.target_ips.length > 0) .filter((binding) => {
.map((binding) => ({ if (!binding.fqdn.trim()) return false
fqdn: binding.fqdn.trim(), if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim())
target_ips: binding.target_ips, return binding.target_ips.length > 0
})) })
.map((binding) =>
binding.record_type === 'CNAME'
? {
fqdn: binding.fqdn.trim(),
target_cname: binding.target_cname.trim(),
}
: {
fqdn: binding.fqdn.trim(),
target_ips: binding.target_ips,
},
)
} }
export function ServiceEditSheet({ export function ServiceEditSheet({
@@ -90,6 +108,7 @@ export function ServiceEditSheet({
knownDomains, knownDomains,
isSaving, isSaving,
isDeleting = false, isDeleting = false,
defaultGroupId = null,
onOpenChange, onOpenChange,
onCreate, onCreate,
onSave, onSave,
@@ -124,11 +143,13 @@ export function ServiceEditSheet({
if (mode === 'create') { if (mode === 'create') {
setName('') setName('')
setSlug('') setSlug('')
setServiceGroupId('none') setServiceGroupId(
defaultGroupId != null ? String(defaultGroupId) : 'none',
)
setIps([]) setIps([])
setBindings([]) setBindings([])
} }
}, [open, mode, service]) }, [open, mode, service, defaultGroupId])
const zoneHints = useMemo( const zoneHints = useMemo(
() => knownDomains.map((domain) => domain.zone_name), () => knownDomains.map((domain) => domain.zone_name),
@@ -136,7 +157,10 @@ export function ServiceEditSheet({
) )
function handleAddBinding() { function handleAddBinding() {
setBindings((current) => [...current, { fqdn: '', target_ips: [] }]) setBindings((current) => [
...current,
{ fqdn: '', record_type: 'A', target_ips: [], target_cname: '' },
])
} }
function handleRemoveBinding(index: number) { function handleRemoveBinding(index: number) {
@@ -150,6 +174,27 @@ export function ServiceEditSheet({
) )
} }
function handleRecordTypeChange(index: number, recordType: 'A' | 'CNAME') {
setBindings((current) =>
current.map((item, i) =>
i === index
? {
...item,
record_type: recordType,
target_ips: recordType === 'A' ? item.target_ips : [],
target_cname: recordType === 'CNAME' ? item.target_cname : '',
}
: item,
),
)
}
function handleCnameChange(index: number, value: string) {
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, target_cname: value } : item)),
)
}
function handleIpsChange(index: number, targetIps: string[]) { function handleIpsChange(index: number, targetIps: string[]) {
setBindings((current) => setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, target_ips: targetIps } : item)), current.map((item, i) => (i === index ? { ...item, target_ips: targetIps } : item)),
@@ -198,20 +243,34 @@ export function ServiceEditSheet({
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto sm:max-w-lg"> <SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-xl">
<SheetHeader> <SheetHeader className="border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle> <SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription> <SheetDescription>
Настройте IP-пул и привязки FQDN IP. Зона определяется из FQDN автоматически. Настройте параметры сервиса и привязки FQDN IP или CNAME. Зона определяется из FQDN
автоматически.
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex flex-col gap-4 px-4">
<Tabs defaultValue="general"> <div className="flex flex-1 flex-col gap-4 px-4 py-4">
<TabsList className="w-full"> <Tabs
defaultValue="general"
orientation="horizontal"
className="flex w-full flex-col gap-4"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="general">Основное</TabsTrigger> <TabsTrigger value="general">Основное</TabsTrigger>
<TabsTrigger value="bindings">Привязки</TabsTrigger> <TabsTrigger value="bindings">
Привязки
{bindings.length > 0 ? (
<span className="text-muted-foreground tabular-nums">
({bindings.length})
</span>
) : null}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="general" className="flex flex-col gap-4 pt-4">
<TabsContent value="general" className="flex flex-col gap-4">
<FieldGroup className="flex flex-col gap-4"> <FieldGroup className="flex flex-col gap-4">
<Field> <Field>
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel> <FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
@@ -262,35 +321,81 @@ export function ServiceEditSheet({
</Field> </Field>
</FieldGroup> </FieldGroup>
</TabsContent> </TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2"> <TabsContent value="bindings" className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<FieldLabel>Привязки доменов</FieldLabel> <p className="text-sm text-muted-foreground">
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}> FQDN IP или CNAME для DNS-записей Cloudflare
<PlusIcon data-icon="inline-start" /> </p>
Добавить <Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
</Button> <PlusIcon data-icon="inline-start" />
</div> Добавить
{bindings.length === 0 ? ( </Button>
<p className="text-sm text-muted-foreground"> </div>
Необязательно. Введите FQDN, например newdom.ivx.su зона ivx.su определится
автоматически. {bindings.length === 0 ? (
</p> <EmptyState
) : ( icon={Link2Icon}
<ItemGroup> title="Нет привязок"
{bindings.map((binding, index) => ( description="Необязательно. Пример: newdom.ivx.su — зона ivx.su определится автоматически."
<Item key={`binding-${index}`} variant="outline"> action={
<ItemContent className="flex flex-col gap-3"> <Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить привязку
</Button>
}
/>
) : (
<ItemGroup className="gap-2">
{bindings.map((binding, index) => (
<Item key={`binding-${index}`} variant="outline">
<ItemContent className="flex flex-col gap-3">
<Field>
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
<TaggedInput
id={`binding-fqdn-${index}`}
value={binding.fqdn ? [binding.fqdn] : []}
onChange={(tags) => handleFqdnChange(index, tags)}
placeholder={
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
}
maxItems={1}
/>
</Field>
<Field>
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
{ label: 'CNAME', value: 'CNAME' },
]}
value={binding.record_type}
onValueChange={(value) =>
handleRecordTypeChange(index, (value ?? 'A') as 'A' | 'CNAME')
}
>
<SelectTrigger id={`binding-type-${index}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A (IP)</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</Field>
{binding.record_type === 'CNAME' ? (
<Field> <Field>
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel> <FieldLabel htmlFor={`binding-cname-${index}`}>
<TaggedInput CNAME-цель
id={`binding-fqdn-${index}`} </FieldLabel>
value={binding.fqdn ? [binding.fqdn] : []} <Input
onChange={(tags) => handleFqdnChange(index, tags)} id={`binding-cname-${index}`}
placeholder={zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'} value={binding.target_cname}
maxItems={1} placeholder="mmsk.rkns.top"
onChange={(event) => handleCnameChange(index, event.target.value)}
/> />
</Field> </Field>
) : (
<Field> <Field>
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel> <FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
<ServiceBindingIpInput <ServiceBindingIpInput
@@ -300,27 +405,30 @@ export function ServiceEditSheet({
onChange={(targetIps) => handleIpsChange(index, targetIps)} onChange={(targetIps) => handleIpsChange(index, targetIps)}
/> />
</Field> </Field>
</ItemContent> )}
<ItemActions> </ItemContent>
<Button <ItemActions>
type="button" <Button
variant="ghost" type="button"
size="icon-sm" variant="ghost"
aria-label="Удалить привязку" size="icon-sm"
onClick={() => handleRemoveBinding(index)} aria-label="Удалить привязку"
> onClick={() => handleRemoveBinding(index)}
<Trash2Icon /> >
</Button> <Trash2Icon />
</ItemActions> </Button>
</Item> </ItemActions>
))} </Item>
</ItemGroup> ))}
)} </ItemGroup>
</div> )}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
<SheetFooter className="flex flex-row flex-wrap gap-2">
<Separator />
<SheetFooter className="flex flex-row flex-wrap gap-2 border-t-0 pt-4">
{!isCreate ? ( {!isCreate ? (
<ConfirmDialog <ConfirmDialog
trigger={ trigger={
@@ -341,7 +449,7 @@ export function ServiceEditSheet({
) : null} ) : null}
<Button <Button
type="button" type="button"
className={isCreate ? 'ml-auto' : 'ml-auto'} className="ml-auto"
disabled={!canSubmit || isSaving || isDeleting} disabled={!canSubmit || isSaving || isDeleting}
onClick={handleSubmit} onClick={handleSubmit}
> >
@@ -1,116 +0,0 @@
import { ChevronDownIcon, PencilIcon } from 'lucide-react'
import { useState } from 'react'
import { ServiceGroupIcon } from '@/components/service-group-icon'
import { ServiceRow } from '@/components/service-row'
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
import { ItemGroup } from '@cfdm/ui/components/item'
import { Spinner } from '@cfdm/ui/components/spinner'
import { Switch } from '@cfdm/ui/components/switch'
import { cn } from '@cfdm/ui/lib/utils'
interface ServiceGroupCardProps {
group: ServiceGroupView
onGroupToggle: (groupId: number, enabled: boolean) => void
onServiceToggle: (serviceId: number, enabled: boolean) => void
onEditService: (service: ServiceView) => void
onEditGroup: (group: ServiceGroupView) => void
togglingGroupId?: number | null
togglingServiceId?: number | null
}
export function ServiceGroupCard({
group,
onGroupToggle,
onServiceToggle,
onEditService,
onEditGroup,
togglingGroupId = null,
togglingServiceId = null,
}: ServiceGroupCardProps) {
const [open, setOpen] = useState(true)
const isGroupToggling = togglingGroupId === group.id
return (
<Card>
<Collapsible open={open} onOpenChange={setOpen}>
<CardHeader>
<div className="flex items-center gap-2">
<CollapsibleTrigger
className={cn(
'flex flex-1 items-center gap-2 text-left',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<ChevronDownIcon
className={cn(
'size-4 shrink-0 transition-transform',
open && 'rotate-180',
)}
/>
<ServiceGroupIcon type={group.type} />
<CardTitle className="flex-1">{group.name}</CardTitle>
</CollapsibleTrigger>
{group.domain ? (
<Badge variant="outline">{group.domain}</Badge>
) : null}
</div>
<CardAction className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => onEditGroup(group)}
aria-label={`Редактировать группу ${group.name}`}
>
<PencilIcon />
</Button>
{isGroupToggling ? (
<Spinner className="size-4" />
) : (
<Switch
checked={group.enabled}
disabled={isGroupToggling}
onCheckedChange={(checked) => onGroupToggle(group.id, checked)}
aria-label={`${group.enabled ? 'Выключить' : 'Включить'} группу ${group.name}`}
/>
)}
</CardAction>
</CardHeader>
<CollapsibleContent>
<CardContent>
{group.services.length === 0 ? (
<p className="text-sm text-muted-foreground">Нет сервисов в группе</p>
) : (
<ItemGroup>
{group.services.map((service) => (
<ServiceRow
key={service.id}
service={service}
onToggle={onServiceToggle}
onEdit={onEditService}
isToggling={togglingServiceId === service.id}
disabled={!group.enabled}
/>
))}
</ItemGroup>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
)
}
@@ -92,7 +92,7 @@ export function ServiceGroupEditSheet({
{mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'} {mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
</SheetTitle> </SheetTitle>
<SheetDescription> <SheetDescription>
Домен группы любой FQDN (gr.ivx.su, domain.new.ivx.su). Публикуется в Cloudflare отдельно от привязок сервисов. Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов.
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-6 px-4">
@@ -127,7 +127,7 @@ export function ServiceGroupEditSheet({
</Select> </Select>
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor="group-domain">Домен группы (FQDN)</FieldLabel> <FieldLabel htmlFor="group-domain">Домен группы (FQDN, необязательно)</FieldLabel>
<Input <Input
id="group-domain" id="group-domain"
value={domain} value={domain}
-77
View File
@@ -1,77 +0,0 @@
import { PencilIcon } from 'lucide-react'
import { StatusBadge } from '@/components/status-badge'
import {
aggregateServiceSyncStatus,
serviceDisplayFqdn,
} from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from '@cfdm/ui/components/item'
import { Spinner } from '@cfdm/ui/components/spinner'
import { Switch } from '@cfdm/ui/components/switch'
interface ServiceRowProps {
service: ServiceView
onToggle: (serviceId: number, enabled: boolean) => void
onEdit: (service: ServiceView) => void
isToggling?: boolean
disabled?: boolean
}
export function ServiceRow({
service,
onToggle,
onEdit,
isToggling = false,
disabled = false,
}: ServiceRowProps) {
const syncStatus = aggregateServiceSyncStatus(service)
const fqdn = serviceDisplayFqdn(service)
return (
<Item variant="outline" size="sm">
<ItemContent>
<ItemTitle>{service.name}</ItemTitle>
<ItemDescription>{fqdn}</ItemDescription>
</ItemContent>
<ItemActions className="gap-1">
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
{isToggling ? (
<Spinner className="size-4" />
) : (
<Switch
checked={service.enabled}
disabled={disabled || isToggling}
onCheckedChange={(checked) => onToggle(service.id, checked)}
aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`}
/>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Редактировать ${service.name}`}
onClick={() => onEdit(service)}
>
<PencilIcon />
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="hidden md:inline-flex"
onClick={() => onEdit(service)}
>
<PencilIcon data-icon="inline-start" />
Редактировать
</Button>
</ItemActions>
</Item>
)
}
@@ -0,0 +1,152 @@
import { arrayMove } from '@dnd-kit/sortable'
import type { ServiceGroupsResponse, ServiceView } from '@/lib/schemas'
import {
groupColumnId,
UNGROUPED_COLUMN_ID,
} from '@/components/services-board/column-ids'
import type { BoardColumn, BoardState } from '@/components/services-board/types'
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
if (!domainId) return true
return service.domains?.some((d) => d.domain_id === domainId) ?? false
}
export function mapResponseToBoard(
data: ServiceGroupsResponse,
domainId?: number,
): BoardState {
const groups = domainId
? data.groups
.map((group) => ({
...group,
services: group.services.filter((s) => serviceMatchesDomain(s, domainId)),
}))
.filter((group) => group.services.length > 0)
: data.groups
const ungrouped = domainId
? data.ungrouped.filter((s) => serviceMatchesDomain(s, domainId))
: data.ungrouped
const columns: BoardColumn[] = groups.map((group) => ({
id: groupColumnId(group.id),
groupId: group.id,
title: group.name,
domain: group.domain,
enabled: group.enabled,
type: group.type,
items: group.services,
group,
}))
if (!domainId || ungrouped.length > 0) {
columns.push({
id: UNGROUPED_COLUMN_ID,
groupId: null,
title: 'Без группы',
domain: null,
enabled: true,
type: 'custom',
items: ungrouped,
})
}
return { columns }
}
export function findColumnId(columns: BoardColumn[], id: string): string | undefined {
if (columns.some((column) => column.id === id)) return id
return columns.find((column) =>
column.items.some((service) => String(service.id) === id),
)?.id
}
export function findColumn(columns: BoardColumn[], id: string): BoardColumn | undefined {
const columnId = findColumnId(columns, id)
return columnId ? columns.find((column) => column.id === columnId) : undefined
}
export function reorderInColumn(
board: BoardState,
columnId: string,
activeId: string,
overId: string,
): BoardState {
const columnIndex = board.columns.findIndex((column) => column.id === columnId)
if (columnIndex === -1) return board
const column = board.columns[columnIndex]!
const oldIndex = column.items.findIndex((service) => String(service.id) === activeId)
const newIndex = column.items.findIndex((service) => String(service.id) === overId)
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return board
const items = arrayMove(column.items, oldIndex, newIndex)
const columns = [...board.columns]
columns[columnIndex] = { ...column, items }
return { columns }
}
export function moveServiceBetweenColumns(
board: BoardState,
serviceId: number,
fromColumnId: string,
toColumnId: string,
overId: string,
): BoardState {
const fromIndex = board.columns.findIndex((column) => column.id === fromColumnId)
const toIndex = board.columns.findIndex((column) => column.id === toColumnId)
if (fromIndex === -1 || toIndex === -1) return board
const fromColumn = board.columns[fromIndex]!
const toColumn = board.columns[toIndex]!
const fromItems = [...fromColumn.items]
const serviceIndex = fromItems.findIndex((service) => service.id === serviceId)
if (serviceIndex === -1) return board
const [service] = fromItems.splice(serviceIndex, 1)
if (!service) return board
const targetGroupId = toColumn.groupId
const movedService: ServiceView = {
...service,
service_group_id: targetGroupId,
}
let insertIndex = toColumn.items.length
if (overId !== toColumnId) {
const overIndex = toColumn.items.findIndex(
(item) => String(item.id) === overId,
)
if (overIndex !== -1) insertIndex = overIndex
}
const targetItems = [...toColumn.items]
targetItems.splice(insertIndex, 0, movedService)
const columns = [...board.columns]
columns[fromIndex] = {
...fromColumn,
items: fromItems,
}
columns[toIndex] = { ...toColumn, items: targetItems }
return { columns }
}
export function boardToQueryData(
board: BoardState,
previous: ServiceGroupsResponse,
): ServiceGroupsResponse {
const groups = previous.groups.map((group) => {
const column = board.columns.find((col) => col.groupId === group.id)
return column ? { ...group, services: column.items } : group
})
const ungroupedColumn = board.columns.find(
(column) => column.id === UNGROUPED_COLUMN_ID,
)
return {
groups,
ungrouped: ungroupedColumn?.items ?? previous.ungrouped,
}
}
@@ -0,0 +1,5 @@
export {
UNGROUPED_COLUMN_ID,
groupColumnId,
parseGroupColumnId,
} from '@/components/board/column-ids'
@@ -0,0 +1,152 @@
import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { ServiceGroupIcon } from '@/components/service-group-icon'
import type { BoardColumn } from '@/components/services-board/types'
import type { ServiceGroupView } from '@/lib/schemas'
import { AccordionTrigger } from '@cfdm/ui/components/accordion'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Spinner } from '@cfdm/ui/components/spinner'
import { Switch } from '@cfdm/ui/components/switch'
import { cn } from '@cfdm/ui/lib/utils'
interface ServiceGroupHeaderProps {
column: BoardColumn
isOpen?: boolean
isDragging?: boolean
dragDisabled?: boolean
showCheckbox?: boolean
groupAllSelected?: boolean
groupSomeSelected?: boolean
serviceIds: number[]
isGroupToggling?: boolean
onSelectAllInGroup?: (ids: number[]) => void
onDeselectAllInGroup?: (ids: number[]) => void
onGroupToggle?: (groupId: number, enabled: boolean) => void
onEditGroup?: (group: ServiceGroupView) => void
onDeleteGroup?: (group: ServiceGroupView) => void
}
export function ServiceGroupHeader({
column,
isOpen = true,
isDragging = false,
dragDisabled = false,
showCheckbox = false,
groupAllSelected = false,
groupSomeSelected = false,
serviceIds,
isGroupToggling = false,
onSelectAllInGroup,
onDeselectAllInGroup,
onGroupToggle,
onEditGroup,
onDeleteGroup,
}: ServiceGroupHeaderProps) {
return (
<div className="flex items-center gap-1 px-1">
{showCheckbox && !dragDisabled && column.items.length > 0 ? (
<Checkbox
checked={groupAllSelected}
onCheckedChange={() => {
if (groupAllSelected || groupSomeSelected) {
onDeselectAllInGroup?.(serviceIds)
} else {
onSelectAllInGroup?.(serviceIds)
}
}}
onPointerDown={(event) => event.stopPropagation()}
aria-label={`Выбрать все сервисы в группе ${column.title}`}
className={cn(
'ml-1',
groupSomeSelected && !groupAllSelected && 'opacity-60',
)}
/>
) : null}
<AccordionTrigger className="min-h-10 flex-1 items-center gap-2 rounded-md py-2 hover:bg-muted/40 hover:no-underline">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
{column.groupId !== null ? (
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-background/80 text-muted-foreground">
<ServiceGroupIcon type={column.type} />
</span>
) : null}
<span className="font-medium">{column.title}</span>
<Badge variant="secondary">{column.items.length}</Badge>
{column.domain ? (
<Badge variant="outline">{column.domain}</Badge>
) : null}
{!isOpen && isDragging && !dragDisabled ? (
<Badge variant="default" className="font-normal">
Отпустите для переноса
</Badge>
) : null}
</div>
</AccordionTrigger>
{column.groupId !== null ? (
<div
className="flex shrink-0 items-center gap-1 pr-1"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия для группы ${column.title}`}
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onEditGroup && column.group ? (
<DropdownMenuItem onClick={() => onEditGroup(column.group!)}>
<PencilIcon data-icon="inline-start" />
Редактировать
</DropdownMenuItem>
) : null}
{onDeleteGroup && column.group ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteGroup(column.group!)}
>
<Trash2Icon data-icon="inline-start" />
Удалить группу
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{column.domain && onGroupToggle ? (
isGroupToggling ? (
<Spinner className="size-4" />
) : (
<Switch
checked={column.enabled}
disabled={isGroupToggling}
onCheckedChange={(checked) =>
onGroupToggle(column.groupId!, checked)
}
aria-label={`${column.enabled ? 'Выключить' : 'Включить'} группу ${column.title}`}
/>
)
) : null}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,193 @@
import { useEffect } from 'react'
import { useDroppable } from '@dnd-kit/core'
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { PlusIcon } from 'lucide-react'
import { ServiceGroupHeader } from '@/components/services-board/service-group-header'
import { ServiceRow } from '@/components/services-board/service-row'
import type { BoardColumn } from '@/components/services-board/types'
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
import {
AccordionContent,
AccordionItem,
} from '@cfdm/ui/components/accordion'
import { Button } from '@cfdm/ui/components/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@cfdm/ui/components/empty'
import { ItemGroup, ItemSeparator } from '@cfdm/ui/components/item'
import { cn } from '@cfdm/ui/lib/utils'
interface ServiceGroupItemProps {
column: BoardColumn
isOpen?: boolean
isDragging?: boolean
onExpandColumn?: (columnId: string) => void
onGroupToggle?: (groupId: number, enabled: boolean) => void
onEditGroup?: (group: ServiceGroupView) => void
onDeleteGroup?: (group: ServiceGroupView) => void
onAddService?: (groupId: number | null) => void
onServiceToggle: (serviceId: number, enabled: boolean) => void
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
togglingGroupId?: number | null
togglingServiceId?: number | null
dragDisabled?: boolean
showCheckbox?: boolean
isSelected?: (id: number) => boolean
onSelectedChange?: (id: number, selected: boolean) => void
isAllSelected?: (ids: number[]) => boolean
isSomeSelected?: (ids: number[]) => boolean
onSelectAllInGroup?: (ids: number[]) => void
onDeselectAllInGroup?: (ids: number[]) => void
}
export function ServiceGroupItem({
column,
isOpen = true,
isDragging = false,
onExpandColumn,
onGroupToggle,
onEditGroup,
onDeleteGroup,
onAddService,
onServiceToggle,
onEditService,
onDeleteService,
togglingGroupId = null,
togglingServiceId = null,
dragDisabled = false,
showCheckbox = false,
isSelected,
onSelectedChange,
isAllSelected,
isSomeSelected,
onSelectAllInGroup,
onDeselectAllInGroup,
}: ServiceGroupItemProps) {
const { setNodeRef, isOver } = useDroppable({
id: column.id,
disabled: dragDisabled,
})
const isGroupToggling = column.groupId !== null && togglingGroupId === column.groupId
const serviceDisabled = Boolean(column.domain) && !column.enabled
const serviceIds = column.items.map((s) => s.id)
const groupAllSelected = isAllSelected?.(serviceIds) ?? false
const groupSomeSelected = isSomeSelected?.(serviceIds) ?? false
useEffect(() => {
if (isOver && isDragging && !dragDisabled) {
onExpandColumn?.(column.id)
}
}, [isOver, isDragging, dragDisabled, column.id, onExpandColumn])
return (
<AccordionItem
value={column.id}
className={cn(
'not-last:border-b-0 overflow-hidden rounded-lg border border-border transition-colors',
!isOpen && 'bg-muted/20',
isOpen && 'bg-muted/30',
isOver && !dragDisabled && 'ring-2 ring-primary/30',
)}
>
<ServiceGroupHeader
column={column}
isOpen={isOpen}
isDragging={isDragging}
dragDisabled={dragDisabled}
showCheckbox={showCheckbox}
groupAllSelected={groupAllSelected}
groupSomeSelected={groupSomeSelected}
serviceIds={serviceIds}
isGroupToggling={isGroupToggling}
onSelectAllInGroup={onSelectAllInGroup}
onDeselectAllInGroup={onDeselectAllInGroup}
onGroupToggle={onGroupToggle}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
/>
<AccordionContent className="px-1 pb-2">
<div ref={setNodeRef}>
<SortableContext
items={column.items.map((service) => String(service.id))}
strategy={verticalListSortingStrategy}
>
{column.items.length > 0 ? (
<ItemGroup className="gap-0 py-1">
{column.items.map((service, index) => (
<div key={service.id}>
{index > 0 ? <ItemSeparator className="my-0" /> : null}
<ServiceRow
service={service}
onToggle={onServiceToggle}
onEdit={onEditService}
onDelete={onDeleteService}
isToggling={togglingServiceId === service.id}
disabled={serviceDisabled}
dragDisabled={dragDisabled}
showCheckbox={showCheckbox}
selected={isSelected?.(service.id) ?? false}
onSelectedChange={(selected) =>
onSelectedChange?.(service.id, selected)
}
/>
</div>
))}
</ItemGroup>
) : (
<Empty
className={cn(
'border border-dashed py-2',
isOver && !dragDisabled && 'border-primary bg-primary/5',
)}
>
<EmptyHeader>
<EmptyTitle className="text-sm">Нет сервисов в группе</EmptyTitle>
<EmptyDescription>
{dragDisabled
? 'Сервисы не найдены'
: 'Перетащите сервис сюда или добавьте новый'}
</EmptyDescription>
</EmptyHeader>
{onAddService ? (
<EmptyContent>
<Button
type="button"
variant="link"
size="sm"
onClick={() => onAddService(column.groupId)}
>
<PlusIcon data-icon="inline-start" />
Добавить сервис
</Button>
</EmptyContent>
) : null}
</Empty>
)}
</SortableContext>
{column.items.length > 0 && onAddService ? (
<Button
type="button"
variant="ghost"
size="sm"
className="mt-1 w-full justify-start text-muted-foreground"
onClick={() => onAddService(column.groupId)}
>
<PlusIcon data-icon="inline-start" />
Добавить сервис
</Button>
) : null}
</div>
</AccordionContent>
</AccordionItem>
)
}
@@ -0,0 +1,199 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVerticalIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { StatusBadge } from '@/components/status-badge'
import { bindingToFqdn } from '@/lib/parse-fqdn'
import {
aggregateServiceSyncStatus,
serviceDisplayFqdn,
} from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Spinner } from '@cfdm/ui/components/spinner'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
export interface ServiceRowProps {
service: ServiceView
onToggle: (serviceId: number, enabled: boolean) => void
onEdit: (service: ServiceView) => void
onDelete: (service: ServiceView) => void
isToggling?: boolean
disabled?: boolean
dragDisabled?: boolean
overlay?: boolean
selected?: boolean
onSelectedChange?: (selected: boolean) => void
showCheckbox?: boolean
}
export function ServiceRow({
service,
onToggle,
onEdit,
onDelete,
isToggling = false,
disabled = false,
dragDisabled = false,
overlay = false,
selected = false,
onSelectedChange,
showCheckbox = false,
}: ServiceRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: String(service.id),
disabled: dragDisabled || overlay,
})
const syncStatus = aggregateServiceSyncStatus(service)
const fqdn = serviceDisplayFqdn(service)
const allFqdns = (service.domains ?? []).map((d) => bindingToFqdn(d))
const style = transform
? {
transform: CSS.Transform.toString(transform),
transition,
}
: undefined
const fqdnContent =
allFqdns.length > 1 ? (
<Tooltip>
<TooltipTrigger
render={
<span className="cursor-default truncate font-mono">{fqdn}</span>
}
/>
<TooltipContent>
<div className="flex flex-col gap-0.5">
{allFqdns.map((name) => (
<span key={name}>{name}</span>
))}
</div>
</TooltipContent>
</Tooltip>
) : (
<span className="truncate font-mono">{fqdn}</span>
)
return (
<div
ref={overlay ? undefined : setNodeRef}
style={overlay ? undefined : style}
className={cn(
'flex h-10 items-center gap-3 rounded-md px-3 transition-colors',
service.enabled ? 'hover:bg-muted/50' : 'text-muted-foreground hover:bg-muted/40',
selected && 'bg-accent/60',
disabled && 'opacity-70',
(isDragging || overlay) && 'opacity-90 shadow-md',
isDragging && !overlay && 'z-10',
)}
>
{showCheckbox && !dragDisabled && !overlay ? (
<Checkbox
checked={selected}
onCheckedChange={(checked) => onSelectedChange?.(checked === true)}
onPointerDown={(event) => event.stopPropagation()}
aria-label={`Выбрать ${service.name}`}
/>
) : null}
{!dragDisabled && !overlay ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="touch-none shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing"
aria-label={`Перетащить ${service.name}`}
{...listeners}
{...attributes}
>
<GripVerticalIcon />
</Button>
) : null}
<div className="flex min-w-0 shrink-0 items-center gap-2">
<span
className={cn(
'truncate text-sm font-medium',
service.enabled ? 'text-foreground' : 'text-muted-foreground',
)}
>
{service.name}
</span>
<Badge variant="secondary" className="shrink-0 font-mono tabular-nums">
{service.slug}
</Badge>
</div>
<div className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{fqdnContent}
</div>
<div className="flex shrink-0 items-center gap-2">
{syncStatus ? <StatusBadge status={syncStatus} /> : null}
{isToggling ? (
<Spinner className="size-4" />
) : (
<Switch
checked={service.enabled}
disabled={disabled || isToggling}
onPointerDown={(event) => event.stopPropagation()}
onCheckedChange={(checked) => onToggle(service.id, checked)}
aria-label={`${service.enabled ? 'Выключить' : 'Включить'} ${service.name}`}
/>
)}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия для ${service.name}`}
onPointerDown={(event) => event.stopPropagation()}
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(service)}>
<PencilIcon data-icon="inline-start" />
Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDelete(service)}
>
<Trash2Icon data-icon="inline-start" />
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
@@ -0,0 +1,19 @@
import { Skeleton } from '@cfdm/ui/components/skeleton'
export function ServicesBoardSkeleton() {
return (
<div className="grid w-full grid-cols-1 gap-2 lg:grid-cols-2">
{Array.from({ length: 4 }).map((_, groupIndex) => (
<div
key={groupIndex}
className="flex flex-col gap-2 rounded-lg border border-border px-2 py-2"
>
<Skeleton className="h-10 w-full" />
{Array.from({ length: 3 }).map((__, rowIndex) => (
<Skeleton key={rowIndex} className="h-10 w-full" />
))}
</div>
))}
</div>
)
}
@@ -0,0 +1,136 @@
import { useEffect, useMemo, useState } from 'react'
import { DragContextProvider } from '@/components/board/drag-context-provider'
import { ServiceGroupItem } from '@/components/services-board/service-group-item'
import { ServiceRow } from '@/components/services-board/service-row'
import type { BoardState } from '@/components/services-board/types'
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
import { Accordion } from '@cfdm/ui/components/accordion'
interface ServicesBoardProps {
board: BoardState
activeService?: ServiceView
dragDisabled?: boolean
onDragStart: Parameters<typeof DragContextProvider>[0]['onDragStart']
onDragEnd: Parameters<typeof DragContextProvider>[0]['onDragEnd']
onGroupToggle: (groupId: number, enabled: boolean) => void
onServiceToggle: (serviceId: number, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup?: (group: ServiceGroupView) => void
onAddService?: (groupId: number | null) => void
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
togglingGroupId?: number | null
togglingServiceId?: number | null
showCheckbox?: boolean
isSelected?: (id: number) => boolean
onSelectedChange?: (id: number, selected: boolean) => void
isAllSelected?: (ids: number[]) => boolean
isSomeSelected?: (ids: number[]) => boolean
onSelectAllInGroup?: (ids: number[]) => void
onDeselectAllInGroup?: (ids: number[]) => void
}
export function ServicesBoard({
board,
activeService,
dragDisabled = false,
onDragStart,
onDragEnd,
onGroupToggle,
onServiceToggle,
onEditGroup,
onDeleteGroup,
onAddService,
onEditService,
onDeleteService,
togglingGroupId = null,
togglingServiceId = null,
showCheckbox = false,
isSelected,
onSelectedChange,
isAllSelected,
isSomeSelected,
onSelectAllInGroup,
onDeselectAllInGroup,
}: ServicesBoardProps) {
const columnIds = useMemo(
() => board.columns.map((column) => column.id),
[board.columns],
)
const columnIdsKey = columnIds.join(',')
const [openColumns, setOpenColumns] = useState<string[]>([])
useEffect(() => {
setOpenColumns((prev) => {
const preserved = prev.filter((id) => columnIds.includes(id))
const added = columnIds.filter((id) => !preserved.includes(id))
if (preserved.length === 0 && added.length > 0) {
return columnIds
}
return [...preserved, ...added]
})
}, [columnIdsKey, columnIds])
const isDragging = activeService != null
function handleExpandColumn(columnId: string) {
setOpenColumns((prev) =>
prev.includes(columnId) ? prev : [...prev, columnId],
)
}
return (
<DragContextProvider
disabled={dragDisabled}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
overlay={
activeService ? (
<ServiceRow
service={activeService}
onToggle={onServiceToggle}
onEdit={onEditService}
onDelete={onDeleteService}
dragDisabled
overlay
/>
) : null
}
>
<Accordion
multiple
value={openColumns}
onValueChange={setOpenColumns}
className="grid w-full grid-cols-1 gap-2 lg:grid-cols-2"
>
{board.columns.map((column) => (
<ServiceGroupItem
key={column.id}
column={column}
isOpen={openColumns.includes(column.id)}
isDragging={isDragging}
onExpandColumn={handleExpandColumn}
onGroupToggle={onGroupToggle}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
onAddService={onAddService}
onServiceToggle={onServiceToggle}
onEditService={onEditService}
onDeleteService={onDeleteService}
togglingGroupId={togglingGroupId}
togglingServiceId={togglingServiceId}
dragDisabled={dragDisabled}
showCheckbox={showCheckbox}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
isAllSelected={isAllSelected}
isSomeSelected={isSomeSelected}
onSelectAllInGroup={onSelectAllInGroup}
onDeselectAllInGroup={onDeselectAllInGroup}
/>
))}
</Accordion>
</DragContextProvider>
)
}
@@ -0,0 +1,54 @@
import { Button } from '@cfdm/ui/components/button'
interface ServicesBulkToolbarProps {
count: number
isPending?: boolean
onEnable: () => void
onDisable: () => void
onClear: () => void
}
export function ServicesBulkToolbar({
count,
isPending = false,
onEnable,
onDisable,
onClear,
}: ServicesBulkToolbarProps) {
if (count === 0) return null
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2">
<span className="text-sm text-muted-foreground">
Выбрано: {count}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={onEnable}
>
Включить
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={isPending}
onClick={onDisable}
>
Выключить
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending}
onClick={onClear}
>
Снять выделение
</Button>
</div>
)
}
@@ -0,0 +1,16 @@
import type { ServiceGroup, ServiceGroupView, ServiceView } from '@/lib/schemas'
export interface BoardColumn {
id: string
groupId: number | null
title: string
domain: string | null
enabled: boolean
type: ServiceGroup['type']
items: ServiceView[]
group?: ServiceGroupView
}
export interface BoardState {
columns: BoardColumn[]
}
+3 -3
View File
@@ -5,9 +5,9 @@ import { cn } from '@cfdm/ui/lib/utils'
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']> type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
const statusVariants: Record<string, BadgeVariant> = { const statusVariants: Record<string, BadgeVariant> = {
active: 'default', active: 'success',
synced: 'default', synced: 'success',
ok: 'default', ok: 'success',
pending_push: 'secondary', pending_push: 'secondary',
warning: 'secondary', warning: 'secondary',
conflict: 'destructive', conflict: 'destructive',
@@ -0,0 +1,201 @@
import { useEffect, useMemo, useState } from 'react'
import type { CertMonitoring } from '@cfdm/shared'
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatServiceGroupLabel } from '@/lib/service-utils'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from '@cfdm/ui/components/field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { Spinner } from '@cfdm/ui/components/spinner'
export interface SubdomainEditValues {
name: string
serviceId: string
certMonitoring: CertMonitoring
}
interface SubdomainEditSheetProps {
mode: 'create' | 'edit'
subdomain: SubdomainRecord | null
zoneName: string
services: ServiceView[]
serviceGroupById: Map<number, string | null>
currentServiceId: string
open: boolean
isSaving: boolean
onOpenChange: (open: boolean) => void
onSubmit: (values: SubdomainEditValues) => void
}
export function SubdomainEditSheet({
mode,
subdomain,
zoneName,
services,
serviceGroupById,
currentServiceId,
open,
isSaving,
onOpenChange,
onSubmit,
}: SubdomainEditSheetProps) {
const [name, setName] = useState('')
const [serviceId, setServiceId] = useState('none')
const [certMonitoring, setCertMonitoring] = useState<CertMonitoring>('auto')
const serviceItems = useMemo(
() => [
{ label: 'Без сервиса', value: 'none' },
...services.map((service) => ({
label: formatServiceGroupLabel(
serviceGroupById.get(service.id),
service.name,
),
value: String(service.id),
})),
],
[services, serviceGroupById],
)
const certMonitoringItems = useMemo(
() =>
certMonitoringOptions.map((option) => ({
label: option.label,
value: option.value,
})),
[],
)
useEffect(() => {
if (!open) return
if (mode === 'edit' && subdomain) {
setName(subdomain.name)
setServiceId(currentServiceId || 'none')
setCertMonitoring(subdomain.cert_monitoring)
return
}
setName('')
setServiceId('none')
setCertMonitoring('auto')
}, [open, mode, subdomain, currentServiceId])
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
onSubmit({ name: trimmed, serviceId, certMonitoring })
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>
{mode === 'create' ? 'Создать поддомен' : 'Редактировать поддомен'}
</SheetTitle>
<SheetDescription>
{mode === 'create'
? `Имя записи в зоне ${zoneName} (например, www или api)`
: `Изменение поддомена в зоне ${zoneName}`}
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="subdomain_name">Имя</FieldLabel>
<Input
id="subdomain_name"
placeholder="www"
value={name}
onChange={(event) => setName(event.target.value)}
className="font-mono"
/>
</Field>
{mode === 'edit' && (
<>
<Field>
<FieldLabel htmlFor="subdomain_service">Сервис</FieldLabel>
<Select
items={serviceItems}
value={serviceId}
onValueChange={(value) => setServiceId(value ?? 'none')}
>
<SelectTrigger id="subdomain_service" className="w-full">
<SelectValue placeholder="Без сервиса" />
</SelectTrigger>
<SelectContent>
{serviceItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="subdomain_cert_monitoring">
Мониторинг SSL
</FieldLabel>
<Select
items={certMonitoringItems}
value={certMonitoring}
onValueChange={(value) =>
setCertMonitoring((value ?? 'auto') as CertMonitoring)
}
>
<SelectTrigger id="subdomain_cert_monitoring" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldDescription>
{
certMonitoringOptions.find((o) => o.value === certMonitoring)
?.description
}
</FieldDescription>
</Field>
</>
)}
</FieldGroup>
<SheetFooter>
<Button type="submit" disabled={isSaving || !name.trim()} className="w-full">
{isSaving && <Spinner data-icon="inline-start" />}
{isSaving
? 'Сохранение…'
: mode === 'create'
? 'Создать'
: 'Сохранить'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,142 @@
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon } from 'lucide-react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import type { SubdomainTableRow } from '@/hooks/use-domain-page'
import { formatSubdomainServiceLinks } from '@/hooks/use-domain-page'
import { formatDate } from '@/lib/format'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
interface SubdomainsTableProps {
domainId: string
rows: SubdomainTableRow[]
isDeleting?: boolean
isToggling?: boolean
onEdit: (row: SubdomainTableRow) => void
onDelete: (row: SubdomainTableRow) => void
onToggleEnabled: (row: SubdomainTableRow) => void
}
export function SubdomainsTable({
domainId,
rows,
isDeleting = false,
isToggling = false,
onEdit,
onDelete,
onToggleEnabled,
}: SubdomainsTableProps) {
const [deleteTarget, setDeleteTarget] = useState<SubdomainTableRow | null>(null)
return (
<>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Поддомен</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Группа / Сервис</TableHead>
<TableHead>Создан</TableHead>
<TableHead className="w-12">
<span className="sr-only">Действия</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.subdomain.id}>
<TableCell className="font-mono">{row.subdomain.fqdn}</TableCell>
<TableCell>
<Badge variant={row.subdomain.enabled ? 'default' : 'outline'}>
{row.subdomain.enabled ? 'Активен' : 'Неактивен'}
</Badge>
</TableCell>
<TableCell>{formatSubdomainServiceLinks(row.serviceLinks)}</TableCell>
<TableCell>{formatDate(row.subdomain.created_at)}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="icon" className="size-8" />
}
>
<MoreHorizontalIcon />
<span className="sr-only">Действия</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row)}>
Редактировать
</DropdownMenuItem>
<DropdownMenuItem
disabled={isToggling}
onClick={() => onToggleEnabled(row)}
>
{row.subdomain.enabled ? 'Деактивировать' : 'Активировать'}
</DropdownMenuItem>
<DropdownMenuItem
render={
<Link
to="/domains/$domainId/dns"
params={{ domainId }}
search={{ host: row.subdomain.fqdn }}
/>
}
>
DNS-записи
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteTarget(row)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
title="Удалить поддомен?"
description={
deleteTarget
? `Поддомен ${deleteTarget.subdomain.fqdn} будет удалён из менеджера.`
: ''
}
confirmLabel="Удалить"
onConfirm={() => {
if (deleteTarget) {
onDelete(deleteTarget)
setDeleteTarget(null)
}
}}
disabled={isDeleting}
/>
</>
)
}
+256
View File
@@ -0,0 +1,256 @@
import { useMemo } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { api } from '@/lib/api-client'
import {
createServiceBinding,
createSubdomain,
deleteServiceBinding,
deleteSubdomain,
domainDetailQueryOptions,
domainServiceBindingsQueryOptions,
invalidateDomainPage,
serviceGroupsQueryOptions,
servicesQueryOptions,
subdomainsListQueryOptions,
updateDomain,
updateSubdomain,
} from '@/queries'
import type { CertMonitoring } from '@cfdm/shared'
import type { ServiceBinding, SubdomainRecord } from '@/lib/schemas'
import {
buildServiceGroupNameById,
formatServiceGroupLabel,
} from '@/lib/service-utils'
export interface SubdomainServiceLink {
serviceId: number
groupName: string | null
serviceName: string
}
export interface SubdomainTableRow {
subdomain: SubdomainRecord
serviceLinks: SubdomainServiceLink[]
bindingIds: number[]
}
export function formatSubdomainServiceLinks(links: SubdomainServiceLink[]): string {
if (links.length === 0) return '—'
return links
.map((link) => formatServiceGroupLabel(link.groupName, link.serviceName))
.join(', ')
}
function mapSubdomainRows(
subdomains: SubdomainRecord[],
bindings: ServiceBinding[],
serviceGroupById: Map<number, string | null>,
): SubdomainTableRow[] {
const byHostname = new Map<string, ServiceBinding[]>()
for (const binding of bindings) {
const list = byHostname.get(binding.hostname) ?? []
list.push(binding)
byHostname.set(binding.hostname, list)
}
return subdomains.map((subdomain) => {
const hostnameBindings = byHostname.get(subdomain.name) ?? []
const seenServiceIds = new Set<number>()
const serviceLinks: SubdomainServiceLink[] = []
for (const binding of hostnameBindings) {
if (seenServiceIds.has(binding.service_id)) continue
seenServiceIds.add(binding.service_id)
serviceLinks.push({
serviceId: binding.service_id,
groupName: serviceGroupById.get(binding.service_id) ?? null,
serviceName: binding.service_name,
})
}
return {
subdomain,
serviceLinks,
bindingIds: hostnameBindings.map((b) => b.id),
}
})
}
export function useDomainPage(domainId: number) {
const queryClient = useQueryClient()
const domainQuery = useQuery(domainDetailQueryOptions(domainId))
const subdomainsQuery = useQuery(subdomainsListQueryOptions(domainId))
const bindingsQuery = useQuery(domainServiceBindingsQueryOptions(domainId))
const servicesQuery = useQuery(servicesQueryOptions())
const serviceGroupsQuery = useQuery(serviceGroupsQueryOptions())
const isLoading =
domainQuery.isLoading ||
subdomainsQuery.isLoading ||
bindingsQuery.isLoading ||
servicesQuery.isLoading ||
serviceGroupsQuery.isLoading
const isError =
domainQuery.isError ||
subdomainsQuery.isError ||
bindingsQuery.isError ||
servicesQuery.isError ||
serviceGroupsQuery.isError
const error =
domainQuery.error ??
subdomainsQuery.error ??
bindingsQuery.error ??
servicesQuery.error ??
serviceGroupsQuery.error
const serviceGroupById = useMemo(
() =>
serviceGroupsQuery.data
? buildServiceGroupNameById(serviceGroupsQuery.data)
: new Map<number, string | null>(),
[serviceGroupsQuery.data],
)
const subdomainRows = useMemo(
() =>
mapSubdomainRows(
subdomainsQuery.data ?? [],
bindingsQuery.data ?? [],
serviceGroupById,
),
[subdomainsQuery.data, bindingsQuery.data, serviceGroupById],
)
function invalidate() {
invalidateDomainPage(queryClient, domainId)
}
const syncMutation = useMutation({
mutationFn: () => api.post(`/api/v1/domains/${domainId}/sync`),
onSuccess: () => {
invalidate()
void queryClient.invalidateQueries({ queryKey: ['domains'] })
void queryClient.invalidateQueries({ queryKey: ['service-bindings'] })
toast.success('Синхронизация завершена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Ошибка синхронизации')
},
})
const createSubdomainMutation = useMutation({
mutationFn: (name: string) => createSubdomain(domainId, { name }),
onSuccess: () => {
invalidate()
toast.success('Поддомен создан')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось создать поддомен')
},
})
const updateSubdomainMutation = useMutation({
mutationFn: ({
id,
...body
}: {
id: number
name?: string
enabled?: boolean
cert_monitoring?: CertMonitoring
}) => updateSubdomain(id, body),
onSuccess: () => {
invalidate()
toast.success('Поддомен обновлён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось обновить поддомен')
},
})
const updateDomainCertMonitoringMutation = useMutation({
mutationFn: (certMonitoring: CertMonitoring) =>
updateDomain(domainId, { cert_monitoring: certMonitoring }),
onSuccess: () => {
invalidate()
void queryClient.invalidateQueries({ queryKey: ['domains'] })
toast.success('Режим мониторинга SSL обновлён')
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось обновить мониторинг SSL',
)
},
})
const deleteSubdomainMutation = useMutation({
mutationFn: (id: number) => deleteSubdomain(id),
onSuccess: () => {
invalidate()
toast.success('Поддомен удалён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить поддомен')
},
})
const linkServiceMutation = useMutation({
mutationFn: async ({
subdomainName,
serviceId,
bindingIds,
}: {
subdomainName: string
serviceId: number | null
bindingIds: number[]
}) => {
await Promise.all(bindingIds.map((id) => deleteServiceBinding(id)))
if (serviceId != null) {
await createServiceBinding({
domain_id: domainId,
service_id: serviceId,
hostname: subdomainName,
})
}
},
onSuccess: (_data, variables) => {
invalidate()
toast.success(
variables.serviceId != null ? 'Сервис привязан' : 'Сервис отвязан',
)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось привязать сервис')
},
})
function refetch() {
void domainQuery.refetch()
void subdomainsQuery.refetch()
void bindingsQuery.refetch()
void servicesQuery.refetch()
void serviceGroupsQuery.refetch()
}
return {
domain: domainQuery.data,
bindings: bindingsQuery.data ?? [],
services: servicesQuery.data ?? [],
serviceGroupById,
subdomainRows,
isLoading,
isError,
error: error instanceof Error ? error : null,
refetch,
syncMutation,
createSubdomainMutation,
updateSubdomainMutation,
updateDomainCertMonitoringMutation,
deleteSubdomainMutation,
linkServiceMutation,
}
}
+149
View File
@@ -0,0 +1,149 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useReducer, useState } from 'react'
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import { toast } from 'sonner'
import { parseGroupColumnId } from '@/components/board/column-ids'
import {
boardToDomainsList,
findColumnId,
mapGroupsDomainsToBoard,
moveDomainBetweenColumns,
} from '@/components/groups-board/board-state'
import type { BoardState } from '@/components/groups-board/types'
import { UNGROUPED_COLUMN_ID } from '@/components/groups-board/column-ids'
import { api } from '@/lib/api-client'
import type { DomainListItem, Group } from '@/lib/schemas'
import { domainKeys, groupKeys } from '@/queries'
type BoardAction =
| { type: 'set'; board: BoardState }
| { type: 'replace'; board: BoardState }
function boardReducer(state: BoardState, action: BoardAction): BoardState {
switch (action.type) {
case 'set':
case 'replace':
return action.board
default:
return state
}
}
interface UseGroupsBoardOptions {
groups: Group[] | undefined
domains: DomainListItem[] | undefined
dragDisabled?: boolean
}
export function useGroupsBoard({
groups,
domains,
dragDisabled = false,
}: UseGroupsBoardOptions) {
const queryClient = useQueryClient()
const [board, dispatch] = useReducer(boardReducer, { columns: [] })
const [activeId, setActiveId] = useState<string | null>(null)
useEffect(() => {
if (!groups || !domains) return
dispatch({
type: 'set',
board: mapGroupsDomainsToBoard(groups, domains),
})
}, [groups, domains])
function invalidateAll() {
queryClient.invalidateQueries({ queryKey: groupKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
}
function syncDomainsCache(nextBoard: BoardState) {
const previous = queryClient.getQueryData<DomainListItem[]>(
domainKeys.list(),
)
if (!previous) return
queryClient.setQueryData(
domainKeys.list(),
boardToDomainsList(nextBoard, previous),
)
}
const moveDomainMutation = useMutation({
mutationFn: ({
domainId,
groupId,
}: {
domainId: number
groupId: number | null
}) => api.patch(`/api/v1/domains/${domainId}`, { group_id: groupId }),
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось переместить домен')
invalidateAll()
},
})
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
setActiveId(null)
if (dragDisabled || !over) return
const domainId = Number(active.id)
const overId = String(over.id)
const fromColumnId = findColumnId(board.columns, String(domainId))
let toColumnId = findColumnId(board.columns, overId)
if (!toColumnId && board.columns.some((column) => column.id === overId)) {
toColumnId = overId
}
if (!fromColumnId || !toColumnId || fromColumnId === toColumnId) return
const targetGroupId = parseGroupColumnId(toColumnId)
if (toColumnId !== UNGROUPED_COLUMN_ID && targetGroupId === null) return
const previousBoard = board
const nextBoard = moveDomainBetweenColumns(
board,
domainId,
fromColumnId,
toColumnId,
)
if (nextBoard === board) return
dispatch({ type: 'replace', board: nextBoard })
syncDomainsCache(nextBoard)
try {
await moveDomainMutation.mutateAsync({
domainId,
groupId: targetGroupId,
})
toast.success('Домен перемещён')
invalidateAll()
} catch {
dispatch({ type: 'replace', board: previousBoard })
syncDomainsCache(previousBoard)
}
}
function handleDragStart(event: DragStartEvent) {
if (dragDisabled) return
setActiveId(String(event.active.id))
}
const activeDomain = activeId
? board.columns
.flatMap((column) => column.items)
.find((domain) => String(domain.id) === activeId)
: undefined
return {
board,
activeId,
activeDomain,
handleDragStart,
handleDragEnd,
dragDisabled,
}
}
+225
View File
@@ -0,0 +1,225 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useReducer, useState } from 'react'
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import { toast } from 'sonner'
import {
boardToQueryData,
findColumnId,
mapResponseToBoard,
moveServiceBetweenColumns,
reorderInColumn,
} from '@/components/services-board/board-state'
import { parseGroupColumnId } from '@/components/services-board/column-ids'
import type { BoardState } from '@/components/services-board/types'
import { api } from '@/lib/api-client'
import type { ServiceGroupsResponse } from '@/lib/schemas'
import {
domainKeys,
serviceBindingKeys,
serviceGroupKeys,
serviceKeys,
} from '@/queries'
type BoardAction =
| { type: 'set'; board: BoardState }
| { type: 'replace'; board: BoardState }
function boardReducer(state: BoardState, action: BoardAction): BoardState {
switch (action.type) {
case 'set':
case 'replace':
return action.board
default:
return state
}
}
interface UseServicesBoardOptions {
data: ServiceGroupsResponse | undefined
domainId?: number
dragDisabled?: boolean
}
export function useServicesBoard({
data,
domainId,
dragDisabled = false,
}: UseServicesBoardOptions) {
const queryClient = useQueryClient()
const [board, dispatch] = useReducer(boardReducer, { columns: [] })
const [activeId, setActiveId] = useState<string | null>(null)
useEffect(() => {
if (!data) return
dispatch({ type: 'set', board: mapResponseToBoard(data, domainId) })
}, [data, domainId])
function invalidateAll() {
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
}
function syncQueryCache(nextBoard: BoardState) {
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (!previous) return
queryClient.setQueryData(
serviceGroupKeys.all,
boardToQueryData(nextBoard, previous),
)
}
const reorderMutation = useMutation({
mutationFn: ({
groupId,
serviceIds,
}: {
groupId: number | null
serviceIds: number[]
}) =>
api.patch('/api/v1/services/reorder', {
group_id: groupId,
service_ids: serviceIds,
}),
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось изменить порядок')
invalidateAll()
},
})
const moveGroupMutation = useMutation({
mutationFn: ({
serviceId,
groupId,
}: {
serviceId: number
groupId: number | null
}) =>
api.patch(`/api/v1/services/${serviceId}`, {
service_group_id: groupId,
}),
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось переместить сервис')
invalidateAll()
},
})
async function persistColumnOrder(columnId: string, serviceIds: number[]) {
if (serviceIds.length === 0) return
await reorderMutation.mutateAsync({
groupId: parseGroupColumnId(columnId),
serviceIds,
})
}
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
setActiveId(null)
if (dragDisabled || !over) return
const activeServiceId = String(active.id)
const overId = String(over.id)
if (overId === activeServiceId) return
const fromColumnId = findColumnId(board.columns, activeServiceId)
let toColumnId = findColumnId(board.columns, overId)
if (!toColumnId && board.columns.some((column) => column.id === overId)) {
toColumnId = overId
}
if (!fromColumnId || !toColumnId) return
const serviceId = Number(activeServiceId)
const previousBoard = board
if (fromColumnId === toColumnId) {
if (overId === toColumnId) return
const nextBoard = reorderInColumn(board, fromColumnId, activeServiceId, overId)
if (nextBoard === board) return
dispatch({ type: 'replace', board: nextBoard })
syncQueryCache(nextBoard)
const column = nextBoard.columns.find((col) => col.id === fromColumnId)
if (!column) return
try {
await persistColumnOrder(
fromColumnId,
column.items.map((item) => item.id),
)
toast.success('Порядок сервисов обновлён')
} catch {
dispatch({ type: 'replace', board: previousBoard })
syncQueryCache(previousBoard)
}
return
}
const nextBoard = moveServiceBetweenColumns(
board,
serviceId,
fromColumnId,
toColumnId,
overId,
)
if (nextBoard === board) return
dispatch({ type: 'replace', board: nextBoard })
syncQueryCache(nextBoard)
const targetGroupId = parseGroupColumnId(toColumnId)
const targetColumn = nextBoard.columns.find((col) => col.id === toColumnId)
const sourceColumn = nextBoard.columns.find((col) => col.id === fromColumnId)
try {
await moveGroupMutation.mutateAsync({
serviceId,
groupId: targetGroupId,
})
if (targetColumn) {
await persistColumnOrder(
toColumnId,
targetColumn.items.map((item) => item.id),
)
}
if (sourceColumn && sourceColumn.items.length > 0) {
await persistColumnOrder(
fromColumnId,
sourceColumn.items.map((item) => item.id),
)
}
toast.success('Сервис перемещён')
invalidateAll()
} catch {
dispatch({ type: 'replace', board: previousBoard })
syncQueryCache(previousBoard)
}
}
function handleDragStart(event: DragStartEvent) {
if (dragDisabled) return
setActiveId(String(event.active.id))
}
const activeService = activeId
? board.columns
.flatMap((column) => column.items)
.find((service) => String(service.id) === activeId)
: undefined
return {
board,
activeId,
activeService,
handleDragStart,
handleDragEnd,
dragDisabled,
}
}
@@ -0,0 +1,97 @@
import { useCallback, useMemo, useState } from 'react'
export function useServicesSelection() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set())
const toggle = useCallback((id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const setSelected = useCallback((id: number, selected: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (selected) {
next.add(id)
} else {
next.delete(id)
}
return next
})
}, [])
const selectAll = useCallback((ids: number[]) => {
setSelectedIds((prev) => {
const next = new Set(prev)
for (const id of ids) {
next.add(id)
}
return next
})
}, [])
const deselectAll = useCallback((ids: number[]) => {
setSelectedIds((prev) => {
const next = new Set(prev)
for (const id of ids) {
next.delete(id)
}
return next
})
}, [])
const clear = useCallback(() => {
setSelectedIds(new Set())
}, [])
const isSelected = useCallback(
(id: number) => selectedIds.has(id),
[selectedIds],
)
const isAllSelected = useCallback(
(ids: number[]) => ids.length > 0 && ids.every((id) => selectedIds.has(id)),
[selectedIds],
)
const isSomeSelected = useCallback(
(ids: number[]) => ids.some((id) => selectedIds.has(id)),
[selectedIds],
)
const count = selectedIds.size
return useMemo(
() => ({
selectedIds,
count,
toggle,
setSelected,
selectAll,
deselectAll,
clear,
isSelected,
isAllSelected,
isSomeSelected,
}),
[
selectedIds,
count,
toggle,
setSelected,
selectAll,
deselectAll,
clear,
isSelected,
isAllSelected,
isSomeSelected,
],
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { CertMonitoring } from '@cfdm/shared'
export const certMonitoringOptions: Array<{
value: CertMonitoring
label: string
description: string
}> = [
{
value: 'auto',
label: 'Авто',
description: 'Проверять, если хост обслуживается активным сервисом',
},
{
value: 'required',
label: 'Обязательно',
description: 'Всегда проверять SSL, даже без привязок',
},
{
value: 'skipped',
label: 'Не проверять',
description: 'Исключить из мониторинга сертификатов',
},
]
export function certMonitoringLabel(value: string): string {
return certMonitoringOptions.find((o) => o.value === value)?.label ?? value
}
+11 -6
View File
@@ -1,9 +1,14 @@
import type { ServiceBinding } from '@/lib/schemas' import type { ServiceBinding } from '@/lib/schemas'
function bindingIps(binding: ServiceBinding): string[] {
if (binding.target_ips.length > 0) return binding.target_ips
return binding.target_ip ? [binding.target_ip] : []
}
export function getDomainIps(bindings: ServiceBinding[], domainId: number): string[] { export function getDomainIps(bindings: ServiceBinding[], domainId: number): string[] {
const ips = bindings const ips = bindings
.filter((b) => b.domain_id === domainId && b.target_ip) .filter((b) => b.domain_id === domainId)
.map((b) => b.target_ip as string) .flatMap(bindingIps)
return [...new Set(ips)] return [...new Set(ips)]
} }
@@ -22,11 +27,11 @@ export function groupBindingsByHostname(
export function buildIpsByDomainId(bindings: ServiceBinding[]): Map<number, string[]> { export function buildIpsByDomainId(bindings: ServiceBinding[]): Map<number, string[]> {
const map = new Map<number, string[]>() const map = new Map<number, string[]>()
for (const binding of bindings) { for (const binding of bindings) {
if (!binding.target_ip) continue const ips = bindingIps(binding)
if (ips.length === 0) continue
const existing = map.get(binding.domain_id) ?? [] const existing = map.get(binding.domain_id) ?? []
if (!existing.includes(binding.target_ip)) { const merged = [...new Set([...existing, ...ips])]
map.set(binding.domain_id, [...existing, binding.target_ip]) map.set(binding.domain_id, merged)
}
} }
return map return map
} }
+8 -12
View File
@@ -1,12 +1,8 @@
import { z } from 'zod' export {
createSubdomainSchema,
export const subdomainSchema = z.object({ subdomainSchema,
id: z.number(), updateSubdomainSchema,
domain_id: z.number(), type CreateSubdomainInput,
name: z.string(), type SubdomainRecord,
fqdn: z.string(), type UpdateSubdomainInput,
created_at: z.string(), } from '@cfdm/shared'
updated_at: z.string(),
})
export type Subdomain = z.infer<typeof subdomainSchema>
+69 -20
View File
@@ -50,8 +50,10 @@ export const serviceDomainBindingSchema = z
zone_name: z.string(), zone_name: z.string(),
hostname: z.string(), hostname: z.string(),
fqdn: z.string(), fqdn: z.string(),
record_type: z.enum(['A', 'CNAME']).default('A'),
target_ips: z.array(z.string()).optional(), target_ips: z.array(z.string()).optional(),
target_ip: z.string().nullable().optional(), target_ip: z.string().nullable().optional(),
target_cname: z.string().nullable().optional(),
sync_status: z.string().nullable(), sync_status: z.string().nullable(),
}) })
.transform((binding) => ({ .transform((binding) => ({
@@ -62,6 +64,10 @@ export const serviceDomainBindingSchema = z
: binding.target_ip : binding.target_ip
? [binding.target_ip] ? [binding.target_ip]
: [], : [],
target_cname: binding.target_cname?.trim() || null,
record_type: binding.target_cname?.trim()
? ('CNAME' as const)
: (binding.record_type ?? 'A'),
})) }))
export const serviceViewSchema = serviceSchema.extend({ export const serviceViewSchema = serviceSchema.extend({
@@ -86,6 +92,7 @@ export const domainSchema = z.object({
zone_name: z.string(), zone_name: z.string(),
cf_zone_id: z.string(), cf_zone_id: z.string(),
status: z.string(), status: z.string(),
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
last_synced_at: z.string().nullable(), last_synced_at: z.string().nullable(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
@@ -96,22 +103,33 @@ export const domainListItemSchema = domainSchema.extend({
service_count: z.number(), service_count: z.number(),
}) })
export const serviceBindingSchema = z.object({ export const serviceBindingSchema = z
id: z.number(), .object({
domain_id: z.number(), id: z.number(),
service_id: z.number(), domain_id: z.number(),
hostname: z.string(), service_id: z.number(),
dns_record_id: z.number().nullable(), hostname: z.string(),
zone_name: z.string(), dns_record_id: z.number().nullable(),
group_id: z.number().nullable(), zone_name: z.string(),
group_name: z.string().nullable(), group_id: z.number().nullable(),
service_name: z.string(), group_name: z.string().nullable(),
service_slug: z.string(), service_name: z.string(),
target_ip: z.string().nullable(), service_slug: z.string(),
sync_status: z.string().nullable(), target_ip: z.string().nullable(),
created_at: z.string(), target_ips: z.array(z.string()).optional(),
updated_at: z.string(), sync_status: z.string().nullable(),
}) created_at: z.string(),
updated_at: z.string(),
})
.transform((binding) => ({
...binding,
target_ips:
binding.target_ips && binding.target_ips.length > 0
? binding.target_ips
: binding.target_ip
? [binding.target_ip]
: [],
}))
export const dnsRecordSchema = z.object({ export const dnsRecordSchema = z.object({
id: z.number(), id: z.number(),
@@ -169,10 +187,30 @@ const ipv4Schema = z
'╨Э╨╡╨║╨╛╤А╤А╨╡╨║╤В╨╜╤Л╨╣ IPv4', '╨Э╨╡╨║╨╛╤А╤А╨╡╨║╤В╨╜╤Л╨╣ IPv4',
) )
const serviceDomainInputSchema = z.object({ const serviceDomainInputSchema = z
fqdn: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ FQDN'), .object({
target_ips: z.array(ipv4Schema).min(1, '╨Т╤Л╨▒╨╡╤А╨╕╤В╨╡ ╤Е╨╛╤В╤П ╨▒╤Л ╨╛╨┤╨╕╨╜ IP'), fqdn: z.string().min(1, 'Укажите FQDN'),
}) target_ips: z.array(ipv4Schema).optional(),
target_cname: z.string().min(1, 'Укажите CNAME-цель').optional(),
})
.superRefine((data, ctx) => {
const hasIps = (data.target_ips?.length ?? 0) > 0
const hasCname = Boolean(data.target_cname?.trim())
if (!hasIps && !hasCname) {
ctx.addIssue({
code: 'custom',
message: 'Укажите IP или CNAME-цель',
path: ['target_ips'],
})
}
if (hasIps && hasCname) {
ctx.addIssue({
code: 'custom',
message: 'Укажите либо IP, либо CNAME-цель',
path: ['target_cname'],
})
}
})
export const createServiceSchema = z.object({ export const createServiceSchema = z.object({
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'), name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
@@ -248,3 +286,14 @@ export type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchem
export type CreateDomainInput = z.infer<typeof createDomainSchema> export type CreateDomainInput = z.infer<typeof createDomainSchema>
export type LoginInput = z.infer<typeof loginSchema> export type LoginInput = z.infer<typeof loginSchema>
export type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema> export type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>
export {
createSubdomainSchema,
subdomainSchema,
updateDomainSchema,
updateSubdomainSchema,
type CreateSubdomainInput,
type SubdomainRecord,
type UpdateDomainInput,
type UpdateSubdomainInput,
} from '@cfdm/shared'
+24 -1
View File
@@ -1,5 +1,28 @@
import { bindingToFqdn } from '@/lib/parse-fqdn' import { bindingToFqdn } from '@/lib/parse-fqdn'
import type { ServiceView } from '@/lib/schemas' import type { ServiceGroupsResponse, ServiceView } from '@/lib/schemas'
export function formatServiceGroupLabel(
groupName: string | null | undefined,
serviceName: string,
): string {
const group = groupName?.trim() || 'Без группы'
return `${group} / ${serviceName}`
}
export function buildServiceGroupNameById(
data: ServiceGroupsResponse,
): Map<number, string | null> {
const map = new Map<number, string | null>()
for (const group of data.groups) {
for (const service of group.services) {
map.set(service.id, group.name)
}
}
for (const service of data.ungrouped) {
map.set(service.id, null)
}
return map
}
export function serviceDisplayFqdn(service: ServiceView): string { export function serviceDisplayFqdn(service: ServiceView): string {
const first = service.domains?.[0] const first = service.domains?.[0]
+49 -1
View File
@@ -10,8 +10,11 @@ import {
serviceBindingSchema, serviceBindingSchema,
serviceGroupsResponseSchema, serviceGroupsResponseSchema,
serviceViewSchema, serviceViewSchema,
subdomainSchema,
type CreateSubdomainInput,
type UpdateDomainInput,
type UpdateSubdomainInput,
} from '@/lib/schemas' } from '@/lib/schemas'
import { subdomainSchema } from '@/lib/schemas-ext'
import { z } from 'zod' import { z } from 'zod'
export const groupKeys = { export const groupKeys = {
@@ -160,3 +163,48 @@ export const subdomainsListQueryOptions = (domainId: number) =>
return z.array(subdomainSchema).parse(data) return z.array(subdomainSchema).parse(data)
}, },
}) })
export interface CreateServiceBindingBody {
domain_id: number
service_id: number
hostname?: string
target_ip?: string
}
export async function createSubdomain(domainId: number, body: CreateSubdomainInput) {
const data = await api.post<unknown>(`/api/v1/domains/${domainId}/subdomains`, body)
return subdomainSchema.parse(data)
}
export async function updateSubdomain(id: number, body: UpdateSubdomainInput) {
const data = await api.patch<unknown>(`/api/v1/subdomains/${id}`, body)
return subdomainSchema.parse(data)
}
export async function updateDomain(id: number, body: UpdateDomainInput) {
const data = await api.patch<unknown>(`/api/v1/domains/${id}`, body)
return domainSchema.parse(data)
}
export async function deleteSubdomain(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/subdomains/${id}`)
}
export async function createServiceBinding(body: CreateServiceBindingBody) {
const data = await api.post<unknown>('/api/v1/service-bindings', body)
return serviceBindingSchema.parse(data)
}
export async function deleteServiceBinding(id: number) {
return api.delete<{ deleted: boolean }>(`/api/v1/service-bindings/${id}`)
}
export function invalidateDomainPage(
queryClient: import('@tanstack/react-query').QueryClient,
domainId: number,
) {
void queryClient.invalidateQueries({ queryKey: subdomainKeys.list(domainId) })
void queryClient.invalidateQueries({ queryKey: serviceBindingKeys.byDomain(domainId) })
void queryClient.invalidateQueries({ queryKey: domainKeys.detail(domainId) })
void queryClient.invalidateQueries({ queryKey: dnsKeys.list(domainId) })
}
+2 -2
View File
@@ -97,7 +97,7 @@ function CertificatesPage() {
<PageShell> <PageShell>
<PageHeader <PageHeader
title="Сертификаты" title="Сертификаты"
description="Мониторинг SSL-сертификатов" description="Мониторинг SSL: только хосты с активными сервисами или режимом «Обязательно»"
actions={ actions={
<Button <Button
onClick={() => checkMutation.mutate()} onClick={() => checkMutation.mutate()}
@@ -142,7 +142,7 @@ function CertificatesPage() {
</Card> </Card>
<DataTableCard <DataTableCard
title="Сертификаты" title="Сертификаты"
description="Все отслеживаемые хосты" description="Хосты с активными сервисами или ручным мониторингом"
isEmpty={!filteredCerts.length} isEmpty={!filteredCerts.length}
emptyTitle={ emptyTitle={
isFilteredEmpty ? 'Ничего не найдено' : 'Сертификаты не найдены' isFilteredEmpty ? 'Ничего не найдено' : 'Сертификаты не найдены'
@@ -1,21 +1,29 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useState } from 'react'
import { GlobeIcon } from 'lucide-react' import { GlobeIcon } from 'lucide-react'
import { toast } from 'sonner'
import { import {
domainDetailQueryOptions, domainDetailQueryOptions,
domainKeys,
domainServiceBindingsQueryOptions, domainServiceBindingsQueryOptions,
subdomainKeys, serviceGroupsQueryOptions,
servicesQueryOptions,
subdomainsListQueryOptions, subdomainsListQueryOptions,
} from '@/queries' } from '@/queries'
import { api } from '@/lib/api-client' import { useDomainPage } from '@/hooks/use-domain-page'
import { formatDate } from '@/lib/format' import type { SubdomainTableRow } from '@/hooks/use-domain-page'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { EmptyState } from '@/components/empty-state' import { QueryState } from '@/components/query-state'
import { StatusBadge } from '@/components/status-badge' import { DomainHeader } from '@/components/domain-header'
import { DomainActionsBar } from '@/components/domain-actions-bar'
import { DomainBindingsCard } from '@/components/domain-bindings-card' import { DomainBindingsCard } from '@/components/domain-bindings-card'
import { DataTableCard } from '@/components/data-table-card'
import { SubdomainsTable } from '@/components/subdomains-table'
import {
SubdomainEditSheet,
type SubdomainEditValues,
} from '@/components/subdomain-edit-sheet'
import { StatusBadge } from '@/components/status-badge'
import { formatDate } from '@/lib/format'
import { Badge } from '@cfdm/ui/components/badge' import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { import {
@@ -25,14 +33,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@cfdm/ui/components/card' } from '@cfdm/ui/components/card'
import { import { Skeleton } from '@cfdm/ui/components/skeleton'
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemSeparator,
ItemTitle,
} from '@cfdm/ui/components/item'
import { Spinner } from '@cfdm/ui/components/spinner' import { Spinner } from '@cfdm/ui/components/spinner'
export const Route = createFileRoute('/_auth/domains/$domainId/')({ export const Route = createFileRoute('/_auth/domains/$domainId/')({
@@ -42,32 +43,121 @@ export const Route = createFileRoute('/_auth/domains/$domainId/')({
await Promise.all([ await Promise.all([
queryClient.ensureQueryData(subdomainsListQueryOptions(id)), queryClient.ensureQueryData(subdomainsListQueryOptions(id)),
queryClient.ensureQueryData(domainServiceBindingsQueryOptions(id)), queryClient.ensureQueryData(domainServiceBindingsQueryOptions(id)),
queryClient.ensureQueryData(servicesQueryOptions()),
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
]) ])
return { breadcrumb: domain.zone_name } return { breadcrumb: domain.zone_name }
}, },
component: DomainOverviewPage, component: DomainOverviewPage,
}) })
function DomainPageSkeleton() {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-10 w-1/3" />
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-40 w-full" />
</div>
<Skeleton className="h-64 w-full" />
</div>
)
}
function DomainOverviewPage() { function DomainOverviewPage() {
const { domainId } = Route.useParams() const { domainId } = Route.useParams()
const id = Number(domainId) const id = Number(domainId)
const queryClient = useQueryClient()
const { data: domain } = useQuery(domainDetailQueryOptions(id))
const { data: subdomains } = useQuery(subdomainsListQueryOptions(id))
const { data: bindings } = useQuery(domainServiceBindingsQueryOptions(id))
const syncMutation = useMutation({ const {
mutationFn: () => api.post(`/api/v1/domains/${id}/sync`), domain,
onSuccess: () => { bindings,
queryClient.invalidateQueries({ queryKey: subdomainKeys.list(id) }) services,
queryClient.invalidateQueries({ queryKey: domainKeys.all }) serviceGroupById,
queryClient.invalidateQueries({ queryKey: ['service-bindings'] }) subdomainRows,
toast.success('Синхронизация завершена') isLoading,
}, isError,
onError: (err) => { error,
toast.error(err instanceof Error ? err.message : 'Ошибка синхронизации') refetch,
}, syncMutation,
}) createSubdomainMutation,
updateSubdomainMutation,
updateDomainCertMonitoringMutation,
deleteSubdomainMutation,
linkServiceMutation,
} = useDomainPage(id)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<'create' | 'edit'>('create')
const [editTarget, setEditTarget] = useState<SubdomainTableRow | null>(null)
function openCreateSheet() {
setSheetMode('create')
setEditTarget(null)
setSheetOpen(true)
}
function openEditSheet(row: SubdomainTableRow) {
setSheetMode('edit')
setEditTarget(row)
setSheetOpen(true)
}
function resolveServiceId(row: SubdomainTableRow): string {
if (row.serviceLinks.length === 0) return 'none'
return String(row.serviceLinks[0].serviceId)
}
async function handleSheetSubmit(values: SubdomainEditValues) {
if (sheetMode === 'create') {
await createSubdomainMutation.mutateAsync(values.name)
setSheetOpen(false)
return
}
if (!editTarget) return
const nameChanged = values.name !== editTarget.subdomain.name
const certMonitoringChanged =
values.certMonitoring !== editTarget.subdomain.cert_monitoring
const currentServiceId = resolveServiceId(editTarget)
const serviceChanged = values.serviceId !== currentServiceId
const targetServiceId =
values.serviceId === 'none' ? null : Number(values.serviceId)
if (nameChanged || certMonitoringChanged) {
await updateSubdomainMutation.mutateAsync({
id: editTarget.subdomain.id,
...(nameChanged ? { name: values.name } : {}),
...(certMonitoringChanged
? { cert_monitoring: values.certMonitoring }
: {}),
})
}
const bindingsNeedSync =
serviceChanged || (nameChanged && editTarget.bindingIds.length > 0)
if (bindingsNeedSync) {
await linkServiceMutation.mutateAsync({
subdomainName: values.name,
serviceId: targetServiceId,
bindingIds: editTarget.bindingIds,
})
} else if (serviceChanged && targetServiceId != null) {
await linkServiceMutation.mutateAsync({
subdomainName: values.name,
serviceId: targetServiceId,
bindingIds: [],
})
}
setSheetOpen(false)
}
const isSheetSaving =
createSubdomainMutation.isPending ||
updateSubdomainMutation.isPending ||
linkServiceMutation.isPending
return ( return (
<PageShell> <PageShell>
@@ -88,6 +178,7 @@ function DomainOverviewPage() {
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
nativeButton={false}
render={ render={
<Link <Link
to="/domains/$domainId/dns" to="/domains/$domainId/dns"
@@ -101,87 +192,124 @@ function DomainOverviewPage() {
</> </>
} }
/> />
<div className="grid gap-4 md:grid-cols-2">
<Card> <QueryState
<CardHeader> isLoading={isLoading}
<CardTitle>Сводка</CardTitle> isError={isError}
<CardDescription>Основные параметры зоны</CardDescription> error={error}
</CardHeader> onRetry={refetch}
<CardContent className="flex flex-col gap-3 text-sm"> skeleton={<DomainPageSkeleton />}
<div className="flex items-center justify-between gap-2"> >
<span className="text-muted-foreground">Статус</span> {domain && (
{domain ? <StatusBadge status={domain.status} /> : '—'} <>
</div> <DomainHeader
<div className="flex items-center justify-between gap-2"> domain={domain}
<span className="text-muted-foreground">Группа</span> onCertMonitoringChange={(value) =>
{domain?.group_id ? ( updateDomainCertMonitoringMutation.mutate(value)
<Button }
variant="link" isCertMonitoringSaving={updateDomainCertMonitoringMutation.isPending}
className="h-auto p-0" />
render={
<Link <div className="grid gap-4 md:grid-cols-2">
to="/groups/$groupId" <Card>
params={{ groupId: String(domain.group_id) }} <CardHeader>
/> <CardTitle>Сводка</CardTitle>
} <CardDescription>Основные параметры зоны</CardDescription>
> </CardHeader>
Открыть группу <CardContent className="flex flex-col gap-3 text-sm">
</Button> <div className="flex items-center justify-between gap-2">
) : ( <span className="text-muted-foreground">Статус</span>
<Badge variant="outline">Без группы</Badge> <StatusBadge status={domain.status} />
)} </div>
</div> <div className="flex items-center justify-between gap-2">
<div className="flex items-center justify-between gap-2"> <span className="text-muted-foreground">Группа</span>
<span className="text-muted-foreground">Последняя синхронизация</span> {domain.group_id ? (
<span className="font-medium">{formatDate(domain?.last_synced_at)}</span>
</div>
</CardContent>
</Card>
<DomainBindingsCard bindings={bindings ?? []} />
</div>
<Card>
<CardHeader>
<CardTitle>Поддомены</CardTitle>
<CardDescription>Обнаруженные поддомены в зоне</CardDescription>
</CardHeader>
<CardContent>
{subdomains?.length ? (
<ItemGroup className="gap-0">
{subdomains.map((s, index) => (
<div key={s.id}>
<Item variant="outline">
<ItemContent>
<ItemTitle className="font-mono font-normal">{s.fqdn}</ItemTitle>
</ItemContent>
<ItemActions>
<Button <Button
variant="outline" variant="link"
size="sm" className="h-auto p-0"
nativeButton={false}
render={ render={
<Link <Link
to="/domains/$domainId/dns" to="/groups/$groupId"
params={{ domainId }} params={{ groupId: String(domain.group_id) }}
search={{ host: s.fqdn }}
/> />
} }
> >
DNS Открыть группу
</Button> </Button>
</ItemActions> ) : (
</Item> <Badge variant="outline">Без группы</Badge>
{index < subdomains.length - 1 && <ItemSeparator />} )}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">
Последняя синхронизация
</span>
<span className="font-medium">
{formatDate(domain.last_synced_at)}
</span>
</div>
</CardContent>
</Card>
<DomainBindingsCard bindings={bindings} />
</div>
<DataTableCard
title="Поддомены"
description="Управление поддоменами и привязками сервисов"
isEmpty={subdomainRows.length === 0}
emptyTitle="Поддомены не найдены"
emptyDescription="Создайте поддомен вручную или синхронизируйте зону с Cloudflare"
emptyIcon={GlobeIcon}
emptyAction={
<div className="flex flex-wrap justify-center gap-2">
<Button onClick={openCreateSheet}>Создать поддомен</Button>
<Button
variant="outline"
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
>
Синхронизировать
</Button>
</div> </div>
))} }
</ItemGroup> toolbar={<DomainActionsBar onCreateSubdomain={openCreateSheet} />}
) : ( >
<EmptyState <SubdomainsTable
icon={GlobeIcon} domainId={domainId}
title="Поддомены не найдены" rows={subdomainRows}
description="Нажмите «Синхронизировать» — поддомены извлекаются из DNS-записей Cloudflare" isDeleting={deleteSubdomainMutation.isPending}
isToggling={updateSubdomainMutation.isPending}
onEdit={openEditSheet}
onDelete={(row) =>
deleteSubdomainMutation.mutate(row.subdomain.id)
}
onToggleEnabled={(row) =>
updateSubdomainMutation.mutate({
id: row.subdomain.id,
enabled: !row.subdomain.enabled,
})
}
/>
</DataTableCard>
<SubdomainEditSheet
mode={sheetMode}
subdomain={editTarget?.subdomain ?? null}
zoneName={domain.zone_name}
services={services}
serviceGroupById={serviceGroupById}
currentServiceId={
editTarget ? resolveServiceId(editTarget) : 'none'
}
open={sheetOpen}
isSaving={isSheetSaving}
onOpenChange={setSheetOpen}
onSubmit={handleSheetSubmit}
/> />
)} </>
</CardContent> )}
</Card> </QueryState>
</PageShell> </PageShell>
) )
} }
+24 -18
View File
@@ -6,17 +6,16 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner' import { toast } from 'sonner'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas' import { createDomainSchema, type CreateDomainInput } from '@/lib/schemas'
import { buildIpsByDomainId } from '@/lib/domain-ips'
import { import {
domainKeys, domainKeys,
domainsListQueryOptions, domainsListQueryOptions,
groupsQueryOptions, groupsQueryOptions,
serviceBindingsQueryOptions, serviceBindingKeys,
} from '@/queries' } from '@/queries'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { DataTableCard } from '@/components/data-table-card' import { DataTableCard } from '@/components/data-table-card'
import { DomainsDataTable } from '@/components/domains-data-table' import { DomainsDataTable, type DomainTableRow } from '@/components/domains-data-table'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input' import { Input } from '@cfdm/ui/components/input'
import { import {
@@ -46,7 +45,6 @@ export const Route = createFileRoute('/_auth/domains/')({
Promise.all([ Promise.all([
queryClient.ensureQueryData(domainsListQueryOptions()), queryClient.ensureQueryData(domainsListQueryOptions()),
queryClient.ensureQueryData(groupsQueryOptions()), queryClient.ensureQueryData(groupsQueryOptions()),
queryClient.ensureQueryData(serviceBindingsQueryOptions()),
]), ]),
component: DomainsPage, component: DomainsPage,
}) })
@@ -60,12 +58,6 @@ function DomainsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: domains } = useQuery(domainsListQueryOptions(listGroupId)) const { data: domains } = useQuery(domainsListQueryOptions(listGroupId))
const { data: groups } = useQuery(groupsQueryOptions()) const { data: groups } = useQuery(groupsQueryOptions())
const { data: bindings } = useQuery(serviceBindingsQueryOptions())
const ipsByDomainId = useMemo(
() => buildIpsByDomainId(bindings ?? []),
[bindings],
)
const groupItems = useMemo( const groupItems = useMemo(
() => [ () => [
@@ -94,6 +86,22 @@ function DomainsPage() {
}, },
}) })
const deleteMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/domains/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
toast.success('Зона удалена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить зону')
},
})
function handleDeleteDomain(domain: DomainTableRow) {
deleteMutation.mutate(domain.id)
}
const handleCreate = form.handleSubmit((values) => { const handleCreate = form.handleSubmit((values) => {
createMutation.mutate({ createMutation.mutate({
zone_name: values.zone_name.trim(), zone_name: values.zone_name.trim(),
@@ -123,22 +131,18 @@ function DomainsPage() {
}, [domains, filterGroupId]) }, [domains, filterGroupId])
const tableData = useMemo( const tableData = useMemo(
() => () => filteredDomains as DomainTableRow[],
filteredDomains.map((domain) => ({ [filteredDomains],
...domain,
ips: ipsByDomainId.get(domain.id) ?? [],
})),
[filteredDomains, ipsByDomainId],
) )
return ( return (
<PageShell> <PageShell>
<PageHeader <PageHeader
title="Домены" title="Домены"
description={`${tableData.length} зон · ${tableData.filter((d) => d.ips.length > 0).length} с IP · ${tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов`} description={`${tableData.length} зон · ${tableData.reduce((sum, d) => sum + d.service_count, 0)} привязок сервисов`}
actions={ actions={
<> <>
<Button variant="outline" render={<Link to="/groups" />}> <Button variant="outline" nativeButton={false} render={<Link to="/groups" />}>
Канбан групп Канбан групп
</Button> </Button>
<Button onClick={() => setSheetOpen(true)}>Импортировать домен</Button> <Button onClick={() => setSheetOpen(true)}>Импортировать домен</Button>
@@ -155,6 +159,8 @@ function DomainsPage() {
groupFilterItems={groupItems} groupFilterItems={groupItems}
groupFilterValue={filterGroupId || 'all'} groupFilterValue={filterGroupId || 'all'}
onGroupFilterChange={handleFilterGroupChange} onGroupFilterChange={handleFilterGroupChange}
onDelete={handleDeleteDomain}
isDeleting={deleteMutation.isPending}
/> />
</DataTableCard> </DataTableCard>
+129 -252
View File
@@ -1,8 +1,6 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { FolderTreeIcon } from 'lucide-react' import { FolderTreeIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
@@ -10,46 +8,21 @@ import {
domainsListQueryOptions, domainsListQueryOptions,
groupKeys, groupKeys,
groupsQueryOptions, groupsQueryOptions,
serviceBindingKeys,
serviceBindingsQueryOptions, serviceBindingsQueryOptions,
} from '@/queries' } from '@/queries'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
import { createGroupSchema, type CreateGroupInput, type DomainListItem } from '@/lib/schemas' import type { CreateGroupInput, Group } from '@/lib/schemas'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state'
import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { TableToolbar } from '@/components/table-toolbar' import { DomainGroupEditSheet } from '@/components/domain-group-edit-sheet'
import { KanbanBoard } from '@/components/kanban-board' import { GroupsBoard } from '@/components/groups-board/groups-board'
import { DomainGroupCard } from '@/components/domain-group-card' import { GroupsBoardSkeleton } from '@/components/groups-board/groups-board-skeleton'
import { DataTableCard } from '@/components/data-table-card' import { useGroupsBoard } from '@/hooks/use-groups-board'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import {
Field,
FieldGroup,
FieldLabel,
} from '@cfdm/ui/components/field'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@cfdm/ui/components/tabs'
import { Spinner } from '@cfdm/ui/components/spinner'
export const Route = createFileRoute('/_auth/groups')({ export const Route = createFileRoute('/_auth/groups')({
loader: ({ context: { queryClient } }) => loader: ({ context: { queryClient } }) =>
@@ -61,28 +34,30 @@ export const Route = createFileRoute('/_auth/groups')({
component: GroupsPage, component: GroupsPage,
}) })
const UNGROUPED_COLUMN_ID = 'ungrouped'
function groupColumnId(groupId: number) {
return `group-${groupId}`
}
function parseGroupColumnId(columnId: string): number | null {
if (columnId === UNGROUPED_COLUMN_ID) return null
const match = columnId.match(/^group-(\d+)$/)
return match ? Number(match[1]) : null
}
function GroupsPage() { function GroupsPage() {
const [catalogSearch, setCatalogSearch] = useState('') const [createSheetOpen, setCreateSheetOpen] = useState(false)
const [editingGroup, setEditingGroup] = useState<Group | null>(null)
const [deletingGroup, setDeletingGroup] = useState<Group | null>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: groups } = useQuery(groupsQueryOptions())
const {
data: groups,
isLoading,
isError,
error,
refetch,
} = useQuery(groupsQueryOptions())
const { data: domains } = useQuery(domainsListQueryOptions()) const { data: domains } = useQuery(domainsListQueryOptions())
const { data: bindings } = useQuery(serviceBindingsQueryOptions()) const { data: bindings } = useQuery(serviceBindingsQueryOptions())
const form = useForm<CreateGroupInput>({ const {
resolver: zodResolver(createGroupSchema), board,
defaultValues: { name: '', slug: '' }, activeDomain,
handleDragStart,
handleDragEnd,
} = useGroupsBoard({
groups,
domains,
}) })
const serviceLabelsByDomain = useMemo(() => { const serviceLabelsByDomain = useMemo(() => {
@@ -97,36 +72,24 @@ function GroupsPage() {
return map return map
}, [bindings]) }, [bindings])
const columns = useMemo(() => { const isEmpty = useMemo(() => {
const groupColumns = if (!groups || !domains) return true
groups?.map((group) => ({ const hasDomains = domains.length > 0
id: groupColumnId(group.id), if (hasDomains) return false
title: group.name, return groups.length === 0
description: group.slug,
href: `/groups/${group.id}`,
items:
domains?.filter((d) => d.group_id === group.id) ?? [],
})) ?? []
const ungrouped: DomainListItem[] =
domains?.filter((d) => d.group_id === null) ?? []
return [
...groupColumns,
{
id: UNGROUPED_COLUMN_ID,
title: 'Без группы',
description: 'Домены без назначенной группы',
items: ungrouped,
},
]
}, [groups, domains]) }, [groups, domains])
function invalidateAll() {
queryClient.invalidateQueries({ queryKey: groupKeys.all })
queryClient.invalidateQueries({ queryKey: domainKeys.all })
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all })
}
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (body: CreateGroupInput) => api.post('/api/v1/groups', body), mutationFn: (body: CreateGroupInput) => api.post('/api/v1/groups', body),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: groupKeys.all }) invalidateAll()
form.reset() setCreateSheetOpen(false)
toast.success('Группа создана') toast.success('Группа создана')
}, },
onError: (err) => { onError: (err) => {
@@ -134,11 +97,24 @@ function GroupsPage() {
}, },
}) })
const updateMutation = useMutation({
mutationFn: ({ id, body }: { id: number; body: CreateGroupInput }) =>
api.patch(`/api/v1/groups/${id}`, body),
onSuccess: () => {
invalidateAll()
setEditingGroup(null)
toast.success('Группа сохранена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить группу')
},
})
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/groups/${id}`), mutationFn: (id: number) => api.delete(`/api/v1/groups/${id}`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: groupKeys.all }) invalidateAll()
queryClient.invalidateQueries({ queryKey: domainKeys.all }) setDeletingGroup(null)
toast.success('Группа удалена') toast.success('Группа удалена')
}, },
onError: (err) => { onError: (err) => {
@@ -146,184 +122,85 @@ function GroupsPage() {
}, },
}) })
const moveDomainMutation = useMutation({
mutationFn: ({ domainId, groupId }: { domainId: number; groupId: number | null }) =>
api.patch(`/api/v1/domains/${domainId}`, { group_id: groupId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: domainKeys.all })
toast.success('Группа домена обновлена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось переместить домен')
},
})
function handleMove(itemId: string, _fromColumnId: string, toColumnId: string) {
const groupId = parseGroupColumnId(toColumnId)
if (toColumnId !== UNGROUPED_COLUMN_ID && groupId === null) return
moveDomainMutation.mutate({ domainId: Number(itemId), groupId })
}
const handleSubmit = form.handleSubmit((values) => {
createMutation.mutate(values)
})
const filteredGroups = useMemo(() => {
const list = groups ?? []
const query = catalogSearch.trim().toLowerCase()
if (!query) return list
return list.filter(
(g) =>
g.name.toLowerCase().includes(query) ||
g.slug.toLowerCase().includes(query),
)
}, [groups, catalogSearch])
const isCatalogFilteredEmpty =
(groups?.length ?? 0) > 0 && filteredGroups.length === 0
return ( return (
<PageShell> <PageShell>
<PageHeader <PageHeader
title="Группы доменов" title="Группы доменов"
description="Канбан-доска доменов по группам и справочник групп" description="Перетащите домен в группу или создайте новую группу для организации зон"
actions={
<Button onClick={() => setCreateSheetOpen(true)}>
Добавить группу
</Button>
}
/> />
<Tabs defaultValue="kanban" orientation="horizontal" className="flex w-full flex-col gap-4">
<TabsList> <QueryState
<TabsTrigger value="kanban">Канбан</TabsTrigger> isLoading={isLoading}
<TabsTrigger value="catalog">Справочник</TabsTrigger> isError={isError}
</TabsList> error={error}
<TabsContent value="kanban"> onRetry={refetch}
<Card> skeleton={<GroupsBoardSkeleton />}
<CardHeader> >
<CardTitle>Доска групп</CardTitle> {isEmpty ? (
<CardDescription> <EmptyState
Перетащите домен в колонку группы. Нажмите на название колонки, чтобы открыть список доменов. icon={FolderTreeIcon}
</CardDescription> title="Группы не найдены"
</CardHeader> description="Создайте группу и назначьте домены при импорте или перетаскиванием на доске."
<CardContent> action={
<KanbanBoard <Button onClick={() => setCreateSheetOpen(true)}>
columns={columns} Добавить группу
getItemId={(domain) => String(domain.id)} </Button>
renderCard={(domain) => (
<DomainGroupCard
domain={domain}
serviceLabels={serviceLabelsByDomain.get(domain.id)}
/>
)}
onMove={handleMove}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="catalog" className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Создать группу</CardTitle>
<CardDescription>Добавить новую группу доменов</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<FieldGroup className="flex flex-row flex-wrap items-end gap-2">
<Field className="flex-1">
<FieldLabel htmlFor="name">Название</FieldLabel>
<Input
id="name"
placeholder="Название"
{...form.register('name')}
aria-invalid={!!form.formState.errors.name}
/>
</Field>
<Field className="flex-1">
<FieldLabel htmlFor="slug">Slug</FieldLabel>
<Input
id="slug"
placeholder="slug"
{...form.register('slug')}
aria-invalid={!!form.formState.errors.slug}
/>
</Field>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && (
<Spinner data-icon="inline-start" />
)}
{createMutation.isPending ? 'Создание…' : 'Создать'}
</Button>
</FieldGroup>
</form>
</CardContent>
</Card>
<DataTableCard
title="Справочник групп"
description="Все группы доменов"
isEmpty={!filteredGroups.length}
emptyTitle={
isCatalogFilteredEmpty ? 'Ничего не найдено' : 'Группы не найдены'
} }
emptyDescription={ />
isCatalogFilteredEmpty ) : (
? 'Измените поисковый запрос' <GroupsBoard
: 'Создайте первую группу в форме выше' board={board}
} activeDomain={activeDomain}
emptyIcon={FolderTreeIcon} onDragStart={handleDragStart}
toolbar={ onDragEnd={handleDragEnd}
<TableToolbar onEditGroup={setEditingGroup}
value={catalogSearch} onDeleteGroup={setDeletingGroup}
onChange={setCatalogSearch} serviceLabelsByDomain={serviceLabelsByDomain}
placeholder="Поиск по названию или slug…" />
/> )}
} </QueryState>
>
<Table> <DomainGroupEditSheet
<TableHeader> mode="create"
<TableRow> group={null}
<TableHead>Название</TableHead> open={createSheetOpen}
<TableHead>Slug</TableHead> isSaving={createMutation.isPending}
<TableHead className="text-right">Действия</TableHead> onOpenChange={setCreateSheetOpen}
</TableRow> onCreate={(body) => createMutation.mutate(body)}
</TableHeader> />
<TableBody>
{filteredGroups.map((group) => ( <DomainGroupEditSheet
<TableRow key={group.id}> mode="edit"
<TableCell className="font-medium">{group.name}</TableCell> group={editingGroup}
<TableCell className="text-muted-foreground">{group.slug}</TableCell> open={editingGroup !== null}
<TableCell className="text-right"> isSaving={updateMutation.isPending}
<div className="flex justify-end gap-2"> onOpenChange={(open) => {
<Button if (!open) setEditingGroup(null)
variant="outline" }}
size="sm" onSave={(id, body) => updateMutation.mutate({ id, body })}
render={ />
<Link
to="/groups/$groupId" <ConfirmDialog
params={{ groupId: String(group.id) }} open={deletingGroup !== null}
/> onOpenChange={(open) => {
} if (!open) setDeletingGroup(null)
> }}
Открыть title="Удалить группу?"
</Button> description={
<ConfirmDialog deletingGroup
trigger={ ? `Группа «${deletingGroup.name}» будет удалена. Домены останутся без группы.`
<Button : ''
variant="destructive" }
size="sm" onConfirm={() => {
disabled={deleteMutation.isPending} if (deletingGroup) deleteMutation.mutate(deletingGroup.id)
> }}
Удалить disabled={deleteMutation.isPending}
</Button> />
}
title="Удалить группу?"
description={`Группа «${group.name}» будет удалена. Домены останутся без группы.`}
onConfirm={() => deleteMutation.mutate(group.id)}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DataTableCard>
</TabsContent>
</Tabs>
</PageShell> </PageShell>
) )
} }
@@ -61,7 +61,7 @@ function GroupDetailPage() {
variant="outline" variant="outline"
render={<Link to="/groups" />} render={<Link to="/groups" />}
> >
На канбан На группам
</Button> </Button>
} }
/> />
+182 -72
View File
@@ -24,19 +24,15 @@ import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
import { EmptyState } from '@/components/empty-state' import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { ServiceEditSheet } from '@/components/service-edit-sheet' import { ServiceEditSheet } from '@/components/service-edit-sheet'
import { ServiceGroupCard } from '@/components/service-group-card'
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet' import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
import { ServiceRow } from '@/components/service-row' import { ServicesBoard } from '@/components/services-board/services-board'
import { ServicesBoardSkeleton } from '@/components/services-board/services-board-skeleton'
import { ServicesBulkToolbar } from '@/components/services-board/services-bulk-toolbar'
import { useServicesBoard } from '@/hooks/use-services-board'
import { useServicesSelection } from '@/hooks/use-services-selection'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@cfdm/ui/components/card'
import { ItemGroup } from '@cfdm/ui/components/item'
import { Skeleton } from '@cfdm/ui/components/skeleton'
export const Route = createFileRoute('/_auth/services')({ export const Route = createFileRoute('/_auth/services')({
validateSearch: (search: Record<string, unknown>) => ({ validateSearch: (search: Record<string, unknown>) => ({
@@ -91,22 +87,23 @@ function setGroupEnabled(
} }
} }
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
if (!domainId) return true
return service.domains?.some((d) => d.domain_id === domainId) ?? false
}
function ServicesPage() { function ServicesPage() {
const { domainId } = Route.useSearch() const { domainId } = Route.useSearch()
const [createSheetOpen, setCreateSheetOpen] = useState(false) const [createSheetOpen, setCreateSheetOpen] = useState(false)
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false) const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
const [defaultGroupId, setDefaultGroupId] = useState<number | null>(null)
const [editingGroup, setEditingGroup] = useState<ServiceGroupView | null>(null) const [editingGroup, setEditingGroup] = useState<ServiceGroupView | null>(null)
const [editingService, setEditingService] = useState<ServiceView | null>(null) const [editingService, setEditingService] = useState<ServiceView | null>(null)
const [deletingService, setDeletingService] = useState<ServiceView | null>(null)
const [deletingGroup, setDeletingGroup] = useState<ServiceGroupView | null>(null)
const [savingId, setSavingId] = useState<number | null>(null) const [savingId, setSavingId] = useState<number | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null) const [deletingId, setDeletingId] = useState<number | null>(null)
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null) const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
const [togglingGroupId, setTogglingGroupId] = useState<number | null>(null) const [togglingGroupId, setTogglingGroupId] = useState<number | null>(null)
const [bulkToggling, setBulkToggling] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const selection = useServicesSelection()
const { const {
data, data,
isLoading, isLoading,
@@ -116,10 +113,40 @@ function ServicesPage() {
} = useQuery(serviceGroupsQueryOptions()) } = useQuery(serviceGroupsQueryOptions())
const { data: domains } = useQuery(domainsListQueryOptions()) const { data: domains } = useQuery(domainsListQueryOptions())
const dragDisabled = domainId != null
const {
board,
activeService,
handleDragStart,
handleDragEnd,
} = useServicesBoard({
data,
domainId,
dragDisabled,
})
const filteredDomain = domainId const filteredDomain = domainId
? domains?.find((d) => d.id === domainId) ? domains?.find((d) => d.id === domainId)
: undefined : undefined
const groups = useMemo(() => data?.groups ?? [], [data?.groups])
const isEmpty = useMemo(() => {
if (!data) return true
if (domainId) {
return (
board.columns.length === 0 ||
board.columns.every((column) => column.items.length === 0)
)
}
const hasServices =
data.groups.some((group) => group.services.length > 0) ||
data.ungrouped.length > 0
if (hasServices) return false
return data.groups.length === 0
}, [data, domainId, board.columns])
function invalidateAll() { function invalidateAll() {
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }) queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
queryClient.invalidateQueries({ queryKey: serviceKeys.all }) queryClient.invalidateQueries({ queryKey: serviceKeys.all })
@@ -199,6 +226,7 @@ function ServicesPage() {
onSuccess: () => { onSuccess: () => {
invalidateAll() invalidateAll()
setEditingService(null) setEditingService(null)
setDeletingService(null)
toast.success('Сервис удалён') toast.success('Сервис удалён')
}, },
onError: (err) => { onError: (err) => {
@@ -244,6 +272,21 @@ function ServicesPage() {
}, },
}) })
const deleteGroupMutation = useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
onSuccess: () => {
invalidateAll()
setDeletingGroup(null)
toast.success('Группа удалена')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить группу')
},
onSettled: () => {
setDeletingGroupId(null)
},
})
const toggleGroupMutation = useMutation({ const toggleGroupMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
api.patch<ServiceGroupsResponse>(`/api/v1/service-groups/${id}/toggle`, { api.patch<ServiceGroupsResponse>(`/api/v1/service-groups/${id}/toggle`, {
@@ -305,24 +348,62 @@ function ServicesPage() {
toggleGroupMutation.mutate({ id: groupId, enabled }) toggleGroupMutation.mutate({ id: groupId, enabled })
} }
const groups = useMemo(() => { function handleOpenCreateService(groupId: number | null = null) {
const list = data?.groups ?? [] setDefaultGroupId(groupId)
if (!domainId) return list setCreateSheetOpen(true)
return list }
.map((group) => ({
...group,
services: group.services.filter((s) => serviceMatchesDomain(s, domainId)),
}))
.filter((group) => group.services.length > 0)
}, [data?.groups, domainId])
const ungrouped = useMemo(() => { function handleDeleteGroup(id: number) {
const list = data?.ungrouped ?? [] setDeletingGroupId(id)
if (!domainId) return list deleteGroupMutation.mutate(id)
return list.filter((s) => serviceMatchesDomain(s, domainId)) }
}, [data?.ungrouped, domainId])
const isEmpty = groups.length === 0 && ungrouped.length === 0 async function handleBulkToggle(enabled: boolean) {
const ids = Array.from(selection.selectedIds)
if (ids.length === 0) return
setBulkToggling(true)
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
serviceGroupKeys.all,
)
if (previous) {
let next = previous
for (const id of ids) {
next = setServiceEnabled(next, id, enabled)
}
queryClient.setQueryData(serviceGroupKeys.all, next)
}
const results = await Promise.allSettled(
ids.map((id) =>
api.patch<ServiceView>(`/api/v1/services/${id}/toggle`, { enabled }),
),
)
const succeeded = results.filter((r) => r.status === 'fulfilled').length
const failed = results.length - succeeded
if (failed > 0) {
if (previous) {
queryClient.setQueryData(serviceGroupKeys.all, previous)
}
toast.error(`Не удалось переключить ${failed} из ${results.length} сервисов`)
} else {
toast.success(
enabled
? `Включено сервисов: ${succeeded}`
: `Выключено сервисов: ${succeeded}`,
)
selection.clear()
}
setBulkToggling(false)
invalidateAll()
}
const boardHint = dragDisabled
? 'Перетаскивание отключено при фильтре по домену'
: 'Перетащите сервис между группами или измените порядок в списке'
return ( return (
<PageShell> <PageShell>
@@ -330,30 +411,33 @@ function ServicesPage() {
title="Сервисы" title="Сервисы"
description={ description={
filteredDomain filteredDomain
? `Сервисы с привязками к домену ${filteredDomain.zone_name}` ? `Сервисы с привязками к домену ${filteredDomain.zone_name}. ${boardHint}`
: 'Сервисы и группы сервисов — FQDN синхронизируются в Cloudflare при включении' : `Сервисы и группы — FQDN синхронизируются в Cloudflare при включении. ${boardHint}`
} }
actions={ actions={
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}> <Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу Добавить группу
</Button> </Button>
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button> <Button onClick={() => handleOpenCreateService()}>Добавить сервис</Button>
</div> </div>
} }
/> />
<ServicesBulkToolbar
count={selection.count}
isPending={bulkToggling}
onEnable={() => handleBulkToggle(true)}
onDisable={() => handleBulkToggle(false)}
onClear={selection.clear}
/>
<QueryState <QueryState
isLoading={isLoading} isLoading={isLoading}
isError={isError} isError={isError}
error={error} error={error}
onRetry={refetch} onRetry={refetch}
skeleton={ skeleton={<ServicesBoardSkeleton />}
<div className="flex flex-col gap-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
}
> >
{isEmpty ? ( {isEmpty ? (
<EmptyState <EmptyState
@@ -369,46 +453,34 @@ function ServicesPage() {
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}> <Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
Добавить группу Добавить группу
</Button> </Button>
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button> <Button onClick={() => handleOpenCreateService()}>Добавить сервис</Button>
</div> </div>
} }
/> />
) : ( ) : (
<div className="flex flex-col gap-4"> <ServicesBoard
{groups.map((group) => ( board={board}
<ServiceGroupCard activeService={activeService}
key={group.id} dragDisabled={dragDisabled}
group={group} onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onGroupToggle={handleGroupToggle} onGroupToggle={handleGroupToggle}
onServiceToggle={handleServiceToggle} onServiceToggle={handleServiceToggle}
onEditService={setEditingService}
onEditGroup={setEditingGroup} onEditGroup={setEditingGroup}
onDeleteGroup={setDeletingGroup}
onAddService={handleOpenCreateService}
onEditService={setEditingService}
onDeleteService={setDeletingService}
togglingGroupId={togglingGroupId} togglingGroupId={togglingGroupId}
togglingServiceId={togglingServiceId} togglingServiceId={togglingServiceId}
/> showCheckbox={!dragDisabled}
))} isSelected={selection.isSelected}
onSelectedChange={selection.setSelected}
{ungrouped.length > 0 ? ( isAllSelected={selection.isAllSelected}
<Card> isSomeSelected={selection.isSomeSelected}
<CardHeader> onSelectAllInGroup={selection.selectAll}
<CardTitle>Без группы</CardTitle> onDeselectAllInGroup={selection.deselectAll}
</CardHeader> />
<CardContent>
<ItemGroup>
{ungrouped.map((service) => (
<ServiceRow
key={service.id}
service={service}
onToggle={handleServiceToggle}
onEdit={setEditingService}
isToggling={togglingServiceId === service.id}
/>
))}
</ItemGroup>
</CardContent>
</Card>
) : null}
</div>
)} )}
</QueryState> </QueryState>
@@ -434,7 +506,11 @@ function ServicesPage() {
open={createSheetOpen} open={createSheetOpen}
knownDomains={domains ?? []} knownDomains={domains ?? []}
isSaving={createServiceMutation.isPending} isSaving={createServiceMutation.isPending}
onOpenChange={setCreateSheetOpen} defaultGroupId={defaultGroupId}
onOpenChange={(open) => {
setCreateSheetOpen(open)
if (!open) setDefaultGroupId(null)
}}
onCreate={handleCreate} onCreate={handleCreate}
/> />
@@ -457,6 +533,40 @@ function ServicesPage() {
}} }}
onSave={(id, body) => updateGroupMutation.mutate({ id, body })} onSave={(id, body) => updateGroupMutation.mutate({ id, body })}
/> />
<ConfirmDialog
open={deletingService !== null}
onOpenChange={(open) => {
if (!open) setDeletingService(null)
}}
title="Удалить сервис?"
description={
deletingService
? `Сервис «${deletingService.name}» и его привязки будут удалены.`
: ''
}
onConfirm={() => {
if (deletingService) handleDelete(deletingService.id)
}}
disabled={deleteServiceMutation.isPending}
/>
<ConfirmDialog
open={deletingGroup !== null}
onOpenChange={(open) => {
if (!open) setDeletingGroup(null)
}}
title="Удалить группу?"
description={
deletingGroup
? `Группа «${deletingGroup.name}» будет удалена. Сервисы останутся без группы.`
: ''
}
onConfirm={() => {
if (deletingGroup) handleDeleteGroup(deletingGroup.id)
}}
disabled={deleteGroupMutation.isPending || deletingGroupId === deletingGroup?.id}
/>
</PageShell> </PageShell>
) )
} }
+2 -1
View File
@@ -17,7 +17,8 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"],
"@cfdm/shared": ["../../packages/shared/src/index.ts"]
} }
}, },
"include": ["src"] "include": ["src"]
+1
View File
@@ -13,6 +13,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
'@cfdm/shared': path.resolve(__dirname, '../../packages/shared/src/index.ts'),
}, },
}, },
server: { server: {
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"codegraph": {
"type": "local",
"command": [
"codegraph",
"serve",
"--mcp"
],
"enabled": true
}
}
}
+163 -4
View File
@@ -215,6 +215,23 @@ declare const services: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
identity: undefined; identity: undefined;
generated: undefined; generated: undefined;
}, {}, {}>; }, {}, {}>;
sort_order: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "sort_order";
tableName: "services";
dataType: "number";
columnType: "SQLiteInteger";
data: number;
driverParam: number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {}>;
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "created_at"; name: "created_at";
tableName: "services"; tableName: "services";
@@ -506,6 +523,25 @@ declare const domains: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
}, {}, { }, {}, {
length: number | undefined; length: number | undefined;
}>; }>;
cert_monitoring: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "cert_monitoring";
tableName: "domains";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
last_synced_at: drizzle_orm_sqlite_core.SQLiteColumn<{ last_synced_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "last_synced_at"; name: "last_synced_at";
tableName: "domains"; tableName: "domains";
@@ -642,6 +678,23 @@ declare const subdomains: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
}, {}, { }, {}, {
length: number | undefined; length: number | undefined;
}>; }>;
enabled: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "enabled";
tableName: "subdomains";
dataType: "boolean";
columnType: "SQLiteBoolean";
data: boolean;
driverParam: number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {}>;
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "created_at"; name: "created_at";
tableName: "subdomains"; tableName: "subdomains";
@@ -1020,6 +1073,25 @@ declare const serviceBindings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
}, {}, { }, {}, {
length: number | undefined; length: number | undefined;
}>; }>;
cname_target: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "cname_target";
tableName: "service_bindings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
dns_record_id: drizzle_orm_sqlite_core.SQLiteColumn<{ dns_record_id: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "dns_record_id"; name: "dns_record_id";
tableName: "service_bindings"; tableName: "service_bindings";
@@ -1805,6 +1877,23 @@ declare const schema: {
identity: undefined; identity: undefined;
generated: undefined; generated: undefined;
}, {}, {}>; }, {}, {}>;
sort_order: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "sort_order";
tableName: "services";
dataType: "number";
columnType: "SQLiteInteger";
data: number;
driverParam: number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {}>;
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "created_at"; name: "created_at";
tableName: "services"; tableName: "services";
@@ -2096,6 +2185,25 @@ declare const schema: {
}, {}, { }, {}, {
length: number | undefined; length: number | undefined;
}>; }>;
cert_monitoring: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "cert_monitoring";
tableName: "domains";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
last_synced_at: drizzle_orm_sqlite_core.SQLiteColumn<{ last_synced_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "last_synced_at"; name: "last_synced_at";
tableName: "domains"; tableName: "domains";
@@ -2232,6 +2340,23 @@ declare const schema: {
}, {}, { }, {}, {
length: number | undefined; length: number | undefined;
}>; }>;
enabled: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "enabled";
tableName: "subdomains";
dataType: "boolean";
columnType: "SQLiteBoolean";
data: boolean;
driverParam: number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {}>;
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{ created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "created_at"; name: "created_at";
tableName: "subdomains"; tableName: "subdomains";
@@ -2610,6 +2735,25 @@ declare const schema: {
}, {}, { }, {}, {
length: number | undefined; length: number | undefined;
}>; }>;
cname_target: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "cname_target";
tableName: "service_bindings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
dns_record_id: drizzle_orm_sqlite_core.SQLiteColumn<{ dns_record_id: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "dns_record_id"; name: "dns_record_id";
tableName: "service_bindings"; tableName: "service_bindings";
@@ -3227,15 +3371,22 @@ declare function listDomainsEnriched(db: Db, groupId?: number): DomainListItem[]
declare function findDomainByZoneName(db: Db, zoneName: string): Domain | null; declare function findDomainByZoneName(db: Db, zoneName: string): Domain | null;
declare function getDomain(db: Db, id: number): Domain; declare function getDomain(db: Db, id: number): Domain;
declare function createDomain(db: Db, groupId: number | null, zoneName: string, cfZoneId: string): Domain; declare function createDomain(db: Db, groupId: number | null, zoneName: string, cfZoneId: string): Domain;
declare function updateDomain(db: Db, id: number, groupId: number | null, status: string): Domain; declare function updateDomain(db: Db, id: number, groupId: number | null, status: string, certMonitoring?: string): Domain;
declare function deleteDomain(db: Db, id: number): void; declare function deleteDomain(db: Db, id: number): void;
declare function setDomainLastSynced(db: Db, id: number): void; declare function setDomainLastSynced(db: Db, id: number): void;
declare function listAllDomains(db: Db): Domain[]; declare function listAllDomains(db: Db): Domain[];
declare function listSubdomainsByDomain(db: Db, domainId: number): Subdomain[]; declare function listSubdomainsByDomain(db: Db, domainId: number): Subdomain[];
declare function getSubdomain(db: Db, id: number): Subdomain; declare function getSubdomain(db: Db, id: number): Subdomain;
declare function findSubdomainByDomainAndName(db: Db, domainId: number, name: string): Subdomain | null;
declare function upsertSubdomain(db: Db, domainId: number, name: string, fqdn: string): void; declare function upsertSubdomain(db: Db, domainId: number, name: string, fqdn: string): void;
declare function createSubdomain(db: Db, domainId: number, name: string, fqdn: string): Subdomain; declare function createSubdomain(db: Db, domainId: number, name: string, fqdn: string): Subdomain;
declare function updateSubdomain(db: Db, id: number, name: string, fqdn: string): Subdomain; interface UpdateSubdomainPatch {
name?: string;
fqdn?: string;
enabled?: boolean;
cert_monitoring?: string;
}
declare function updateSubdomain(db: Db, id: number, patch: UpdateSubdomainPatch): Subdomain;
declare function deleteSubdomain(db: Db, id: number): void; declare function deleteSubdomain(db: Db, id: number): void;
declare function listAllSubdomains(db: Db): Subdomain[]; declare function listAllSubdomains(db: Db): Subdomain[];
declare function listDnsRecords(db: Db, domainId: number, filter?: DnsListFilter): DnsRecord[]; declare function listDnsRecords(db: Db, domainId: number, filter?: DnsListFilter): DnsRecord[];
@@ -3255,6 +3406,7 @@ declare function createService(db: Db, name: string, slug: string): Service;
declare function updateService(db: Db, id: number, name: string, slug: string): Service; declare function updateService(db: Db, id: number, name: string, slug: string): Service;
declare function setServiceEnabled(db: Db, id: number, enabled: boolean): Service; declare function setServiceEnabled(db: Db, id: number, enabled: boolean): Service;
declare function setServiceGroup(db: Db, id: number, groupId: number | null): void; declare function setServiceGroup(db: Db, id: number, groupId: number | null): void;
declare function reorderServices(db: Db, groupId: number | null, orderedIds: number[]): void;
declare function deleteService(db: Db, id: number): void; declare function deleteService(db: Db, id: number): void;
declare function listServiceGroups(db: Db): ServiceGroup[]; declare function listServiceGroups(db: Db): ServiceGroup[];
declare function getServiceGroup(db: Db, id: number): ServiceGroup; declare function getServiceGroup(db: Db, id: number): ServiceGroup;
@@ -3266,6 +3418,7 @@ declare function listServiceIps(db: Db, serviceId: number): string[];
declare function replaceServiceIps(db: Db, serviceId: number, ips: string[]): void; declare function replaceServiceIps(db: Db, serviceId: number, ips: string[]): void;
declare function listBindingIps(db: Db, bindingId: number): string[]; declare function listBindingIps(db: Db, bindingId: number): string[];
declare function replaceBindingIps(db: Db, bindingId: number, ips: string[]): void; declare function replaceBindingIps(db: Db, bindingId: number, ips: string[]): void;
declare function setBindingCnameTarget(db: Db, bindingId: number, target: string | null): void;
declare function listRecordsForBinding(db: Db, bindingId: number): DnsRecord[]; declare function listRecordsForBinding(db: Db, bindingId: number): DnsRecord[];
declare function linkBindingRecord(db: Db, bindingId: number, dnsRecordId: number): void; declare function linkBindingRecord(db: Db, bindingId: number, dnsRecordId: number): void;
declare function unlinkBindingRecord(db: Db, bindingId: number, dnsRecordId: number): void; declare function unlinkBindingRecord(db: Db, bindingId: number, dnsRecordId: number): void;
@@ -3290,11 +3443,13 @@ declare function listCertificates(db: Db, status?: string): Certificate[];
declare function getCertificate(db: Db, id: number): Certificate; declare function getCertificate(db: Db, id: number): Certificate;
declare function upsertCertificateCheck(db: Db, domainId: number, subdomainId: number | null, hostname: string, expiresAt: string | null, status: string, lastError: string | null): Certificate; declare function upsertCertificateCheck(db: Db, domainId: number, subdomainId: number | null, hostname: string, expiresAt: string | null, status: string, lastError: string | null): Certificate;
declare function countCertificatesByStatus(db: Db): Array<[string, number]>; declare function countCertificatesByStatus(db: Db): Array<[string, number]>;
declare function deleteCertificatesNotIn(db: Db, hostnames: string[]): number;
declare function createSyncJob(db: Db, id: string, domainId: number | null): void; declare function createSyncJob(db: Db, id: string, domainId: number | null): void;
declare function getSyncJob(db: Db, id: string): SyncJob; declare function getSyncJob(db: Db, id: string): SyncJob;
declare function finishSyncJob(db: Db, id: string, status: string, message: string | null): void; declare function finishSyncJob(db: Db, id: string, status: string, message: string | null): void;
type repos_DnsListFilter = DnsListFilter; type repos_DnsListFilter = DnsListFilter;
type repos_UpdateSubdomainPatch = UpdateSubdomainPatch;
declare const repos_bindingsToRemove: typeof bindingsToRemove; declare const repos_bindingsToRemove: typeof bindingsToRemove;
declare const repos_countCertificatesByStatus: typeof countCertificatesByStatus; declare const repos_countCertificatesByStatus: typeof countCertificatesByStatus;
declare const repos_createDomain: typeof createDomain; declare const repos_createDomain: typeof createDomain;
@@ -3305,6 +3460,7 @@ declare const repos_createSubdomain: typeof createSubdomain;
declare const repos_createSyncJob: typeof createSyncJob; declare const repos_createSyncJob: typeof createSyncJob;
declare const repos_deleteBinding: typeof deleteBinding; declare const repos_deleteBinding: typeof deleteBinding;
declare const repos_deleteBindingsExcept: typeof deleteBindingsExcept; declare const repos_deleteBindingsExcept: typeof deleteBindingsExcept;
declare const repos_deleteCertificatesNotIn: typeof deleteCertificatesNotIn;
declare const repos_deleteDnsRecord: typeof deleteDnsRecord; declare const repos_deleteDnsRecord: typeof deleteDnsRecord;
declare const repos_deleteDomain: typeof deleteDomain; declare const repos_deleteDomain: typeof deleteDomain;
declare const repos_deleteGroup: typeof deleteGroup; declare const repos_deleteGroup: typeof deleteGroup;
@@ -3314,6 +3470,7 @@ declare const repos_deleteSubdomain: typeof deleteSubdomain;
declare const repos_findBinding: typeof findBinding; declare const repos_findBinding: typeof findBinding;
declare const repos_findDnsByCfId: typeof findDnsByCfId; declare const repos_findDnsByCfId: typeof findDnsByCfId;
declare const repos_findDomainByZoneName: typeof findDomainByZoneName; declare const repos_findDomainByZoneName: typeof findDomainByZoneName;
declare const repos_findSubdomainByDomainAndName: typeof findSubdomainByDomainAndName;
declare const repos_finishSyncJob: typeof finishSyncJob; declare const repos_finishSyncJob: typeof finishSyncJob;
declare const repos_getBinding: typeof getBinding; declare const repos_getBinding: typeof getBinding;
declare const repos_getBindingView: typeof getBindingView; declare const repos_getBindingView: typeof getBindingView;
@@ -3351,8 +3508,10 @@ declare const repos_listServicesByGroup: typeof listServicesByGroup;
declare const repos_listSubdomainsByDomain: typeof listSubdomainsByDomain; declare const repos_listSubdomainsByDomain: typeof listSubdomainsByDomain;
declare const repos_listUngroupedServices: typeof listUngroupedServices; declare const repos_listUngroupedServices: typeof listUngroupedServices;
declare const repos_markDnsPendingDelete: typeof markDnsPendingDelete; declare const repos_markDnsPendingDelete: typeof markDnsPendingDelete;
declare const repos_reorderServices: typeof reorderServices;
declare const repos_replaceBindingIps: typeof replaceBindingIps; declare const repos_replaceBindingIps: typeof replaceBindingIps;
declare const repos_replaceServiceIps: typeof replaceServiceIps; declare const repos_replaceServiceIps: typeof replaceServiceIps;
declare const repos_setBindingCnameTarget: typeof setBindingCnameTarget;
declare const repos_setBindingDnsRecordId: typeof setBindingDnsRecordId; declare const repos_setBindingDnsRecordId: typeof setBindingDnsRecordId;
declare const repos_setDnsSyncStatus: typeof setDnsSyncStatus; declare const repos_setDnsSyncStatus: typeof setDnsSyncStatus;
declare const repos_setDomainLastSynced: typeof setDomainLastSynced; declare const repos_setDomainLastSynced: typeof setDomainLastSynced;
@@ -3371,7 +3530,7 @@ declare const repos_updateSubdomain: typeof updateSubdomain;
declare const repos_upsertCertificateCheck: typeof upsertCertificateCheck; declare const repos_upsertCertificateCheck: typeof upsertCertificateCheck;
declare const repos_upsertSubdomain: typeof upsertSubdomain; declare const repos_upsertSubdomain: typeof upsertSubdomain;
declare namespace repos { declare namespace repos {
export { type repos_DnsListFilter as DnsListFilter, repos_bindingsToRemove as bindingsToRemove, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createGroup as createGroup, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteGroup as deleteGroup, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDomain, repos_listUngroupedServices as listUngroupedServices, repos_markDnsPendingDelete as markDnsPendingDelete, repos_replaceBindingIps as replaceBindingIps, repos_replaceServiceIps as replaceServiceIps, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingFields as updateBindingFields, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateGroup as updateGroup, repos_updateService as updateService, repos_updateServiceGroup as updateServiceGroup, repos_updateSubdomain as updateSubdomain, repos_upsertCertificateCheck as upsertCertificateCheck, repos_upsertSubdomain as upsertSubdomain }; export { type repos_DnsListFilter as DnsListFilter, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_bindingsToRemove as bindingsToRemove, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createGroup as createGroup, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteGroup as deleteGroup, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDomain, repos_listUngroupedServices as listUngroupedServices, repos_markDnsPendingDelete as markDnsPendingDelete, repos_reorderServices as reorderServices, repos_replaceBindingIps as replaceBindingIps, repos_replaceServiceIps as replaceServiceIps, repos_setBindingCnameTarget as setBindingCnameTarget, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingFields as updateBindingFields, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateGroup as updateGroup, repos_updateService as updateService, repos_updateServiceGroup as updateServiceGroup, repos_updateSubdomain as updateSubdomain, repos_upsertCertificateCheck as upsertCertificateCheck, repos_upsertSubdomain as upsertSubdomain };
} }
export { ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, certificates, createDb, createMemoryDb, dnsRecords, domains, groups, healthCheck, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs }; export { ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, certificates, createDb, createMemoryDb, dnsRecords, domains, groups, healthCheck, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs };
+117 -14
View File
@@ -29,6 +29,7 @@ var services = sqliteTable("services", {
), ),
subdomain: text("subdomain"), subdomain: text("subdomain"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false), enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
sort_order: integer("sort_order").notNull().default(0),
created_at: text("created_at").notNull().default(sql`datetime('now')`), created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`) updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
}); });
@@ -50,6 +51,7 @@ var domains = sqliteTable("domains", {
zone_name: text("zone_name").notNull().unique(), zone_name: text("zone_name").notNull().unique(),
cf_zone_id: text("cf_zone_id").notNull(), cf_zone_id: text("cf_zone_id").notNull(),
status: text("status").notNull().default("active"), status: text("status").notNull().default("active"),
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
last_synced_at: text("last_synced_at"), last_synced_at: text("last_synced_at"),
created_at: text("created_at").notNull().default(sql`datetime('now')`), created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`) updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
@@ -59,6 +61,8 @@ var subdomains = sqliteTable("subdomains", {
domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }), domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }),
name: text("name").notNull(), name: text("name").notNull(),
fqdn: text("fqdn").notNull(), fqdn: text("fqdn").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
created_at: text("created_at").notNull().default(sql`datetime('now')`), created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`) updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
}); });
@@ -83,6 +87,7 @@ var serviceBindings = sqliteTable("service_bindings", {
domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }), domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }),
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }), service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
hostname: text("hostname").notNull().default("@"), hostname: text("hostname").notNull().default("@"),
cname_target: text("cname_target"),
dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, { dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, {
onDelete: "set null" onDelete: "set null"
}), }),
@@ -234,6 +239,7 @@ __export(repos_exports, {
createSyncJob: () => createSyncJob, createSyncJob: () => createSyncJob,
deleteBinding: () => deleteBinding, deleteBinding: () => deleteBinding,
deleteBindingsExcept: () => deleteBindingsExcept, deleteBindingsExcept: () => deleteBindingsExcept,
deleteCertificatesNotIn: () => deleteCertificatesNotIn,
deleteDnsRecord: () => deleteDnsRecord, deleteDnsRecord: () => deleteDnsRecord,
deleteDomain: () => deleteDomain, deleteDomain: () => deleteDomain,
deleteGroup: () => deleteGroup, deleteGroup: () => deleteGroup,
@@ -243,6 +249,7 @@ __export(repos_exports, {
findBinding: () => findBinding, findBinding: () => findBinding,
findDnsByCfId: () => findDnsByCfId, findDnsByCfId: () => findDnsByCfId,
findDomainByZoneName: () => findDomainByZoneName, findDomainByZoneName: () => findDomainByZoneName,
findSubdomainByDomainAndName: () => findSubdomainByDomainAndName,
finishSyncJob: () => finishSyncJob, finishSyncJob: () => finishSyncJob,
getBinding: () => getBinding, getBinding: () => getBinding,
getBindingView: () => getBindingView, getBindingView: () => getBindingView,
@@ -280,8 +287,10 @@ __export(repos_exports, {
listSubdomainsByDomain: () => listSubdomainsByDomain, listSubdomainsByDomain: () => listSubdomainsByDomain,
listUngroupedServices: () => listUngroupedServices, listUngroupedServices: () => listUngroupedServices,
markDnsPendingDelete: () => markDnsPendingDelete, markDnsPendingDelete: () => markDnsPendingDelete,
reorderServices: () => reorderServices,
replaceBindingIps: () => replaceBindingIps, replaceBindingIps: () => replaceBindingIps,
replaceServiceIps: () => replaceServiceIps, replaceServiceIps: () => replaceServiceIps,
setBindingCnameTarget: () => setBindingCnameTarget,
setBindingDnsRecordId: () => setBindingDnsRecordId, setBindingDnsRecordId: () => setBindingDnsRecordId,
setDnsSyncStatus: () => setDnsSyncStatus, setDnsSyncStatus: () => setDnsSyncStatus,
setDomainLastSynced: () => setDomainLastSynced, setDomainLastSynced: () => setDomainLastSynced,
@@ -300,7 +309,8 @@ __export(repos_exports, {
upsertCertificateCheck: () => upsertCertificateCheck, upsertCertificateCheck: () => upsertCertificateCheck,
upsertSubdomain: () => upsertSubdomain upsertSubdomain: () => upsertSubdomain
}); });
import { and, asc, count, eq, isNull, like, or, sql as sql2 } from "drizzle-orm"; import { dnsRecordNamesMatch } from "@cfdm/shared";
import { and, asc, count, eq, isNull, like, notInArray, or, sql as sql2 } from "drizzle-orm";
function listGroups(db) { function listGroups(db) {
return db.select().from(groups).orderBy(asc(groups.name)).all(); return db.select().from(groups).orderBy(asc(groups.name)).all();
} }
@@ -367,12 +377,16 @@ function createDomain(db, groupId, zoneName, cfZoneId) {
}).returning({ id: domains.id }).get().id; }).returning({ id: domains.id }).get().id;
return getDomain(db, id); return getDomain(db, id);
} }
function updateDomain(db, id, groupId, status) { function updateDomain(db, id, groupId, status, certMonitoring) {
const result = db.update(domains).set({ const updates = {
group_id: groupId, group_id: groupId,
status, status,
updated_at: sql2`datetime('now')` updated_at: sql2`datetime('now')`
}).where(eq(domains.id, id)).run(); };
if (certMonitoring !== void 0) {
updates.cert_monitoring = certMonitoring;
}
const result = db.update(domains).set(updates).where(eq(domains.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`domain ${id}`); if (result.changes === 0) throw new NotFoundError(`domain ${id}`);
return getDomain(db, id); return getDomain(db, id);
} }
@@ -397,6 +411,10 @@ function getSubdomain(db, id) {
if (!row) throw new NotFoundError(`subdomain ${id}`); if (!row) throw new NotFoundError(`subdomain ${id}`);
return row; return row;
} }
function findSubdomainByDomainAndName(db, domainId, name) {
const row = db.select().from(subdomains).where(and(eq(subdomains.domain_id, domainId), eq(subdomains.name, name))).get();
return row ?? null;
}
function upsertSubdomain(db, domainId, name, fqdn) { function upsertSubdomain(db, domainId, name, fqdn) {
db.run(sql2` db.run(sql2`
INSERT INTO subdomains (domain_id, name, fqdn) INSERT INTO subdomains (domain_id, name, fqdn)
@@ -410,8 +428,15 @@ function createSubdomain(db, domainId, name, fqdn) {
const id = db.insert(subdomains).values({ domain_id: domainId, name, fqdn }).returning({ id: subdomains.id }).get().id; const id = db.insert(subdomains).values({ domain_id: domainId, name, fqdn }).returning({ id: subdomains.id }).get().id;
return getSubdomain(db, id); return getSubdomain(db, id);
} }
function updateSubdomain(db, id, name, fqdn) { function updateSubdomain(db, id, patch) {
const result = db.update(subdomains).set({ name, fqdn, updated_at: sql2`datetime('now')` }).where(eq(subdomains.id, id)).run(); const updates = { updated_at: sql2`datetime('now')` };
if (patch.name !== void 0) updates.name = patch.name;
if (patch.fqdn !== void 0) updates.fqdn = patch.fqdn;
if (patch.enabled !== void 0) updates.enabled = patch.enabled;
if (patch.cert_monitoring !== void 0) {
updates.cert_monitoring = patch.cert_monitoring;
}
const result = db.update(subdomains).set(updates).where(eq(subdomains.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`); if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`);
return getSubdomain(db, id); return getSubdomain(db, id);
} }
@@ -518,14 +543,19 @@ function findDnsByCfId(db, domainId, cfRecordId) {
function markDnsPendingDelete(db, id) { function markDnsPendingDelete(db, id) {
setDnsSyncStatus(db, id, "pending_delete", null, null); setDnsSyncStatus(db, id, "pending_delete", null, null);
} }
function maxSortOrderInGroup(db, groupId) {
const condition = groupId === null ? isNull(services.service_group_id) : eq(services.service_group_id, groupId);
const row = db.select({ maxOrder: sql2`coalesce(max(${services.sort_order}), -1)` }).from(services).where(condition).get();
return row?.maxOrder ?? -1;
}
function listServices(db) { function listServices(db) {
return db.select().from(services).orderBy(asc(services.name)).all(); return db.select().from(services).orderBy(asc(services.sort_order), asc(services.name)).all();
} }
function listServicesByGroup(db, groupId) { function listServicesByGroup(db, groupId) {
return db.select().from(services).where(eq(services.service_group_id, groupId)).orderBy(asc(services.name)).all(); return db.select().from(services).where(eq(services.service_group_id, groupId)).orderBy(asc(services.sort_order), asc(services.name)).all();
} }
function listUngroupedServices(db) { function listUngroupedServices(db) {
return db.select().from(services).where(isNull(services.service_group_id)).orderBy(asc(services.name)).all(); return db.select().from(services).where(isNull(services.service_group_id)).orderBy(asc(services.sort_order), asc(services.name)).all();
} }
function getService(db, id) { function getService(db, id) {
const row = db.select().from(services).where(eq(services.id, id)).get(); const row = db.select().from(services).where(eq(services.id, id)).get();
@@ -533,7 +563,8 @@ function getService(db, id) {
return row; return row;
} }
function createService(db, name, slug) { function createService(db, name, slug) {
const id = db.insert(services).values({ name, slug, subdomain: slug }).returning({ id: services.id }).get().id; const sortOrder = maxSortOrderInGroup(db, null) + 1;
const id = db.insert(services).values({ name, slug, subdomain: slug, sort_order: sortOrder }).returning({ id: services.id }).get().id;
return getService(db, id); return getService(db, id);
} }
function updateService(db, id, name, slug) { function updateService(db, id, name, slug) {
@@ -550,7 +581,31 @@ function setServiceEnabled(db, id, enabled) {
return getService(db, id); return getService(db, id);
} }
function setServiceGroup(db, id, groupId) { function setServiceGroup(db, id, groupId) {
db.update(services).set({ service_group_id: groupId, updated_at: sql2`datetime('now')` }).where(eq(services.id, id)).run(); const sortOrder = maxSortOrderInGroup(db, groupId) + 1;
db.update(services).set({
service_group_id: groupId,
sort_order: sortOrder,
updated_at: sql2`datetime('now')`
}).where(eq(services.id, id)).run();
}
function reorderServices(db, groupId, orderedIds) {
const uniqueIds = new Set(orderedIds);
if (uniqueIds.size !== orderedIds.length) {
throw new Error("duplicate service ids in reorder request");
}
const condition = groupId === null ? isNull(services.service_group_id) : eq(services.service_group_id, groupId);
const existing = db.select({ id: services.id }).from(services).where(condition).all().map((row) => row.id);
const existingSet = new Set(existing);
for (const serviceId of orderedIds) {
if (!existingSet.has(serviceId)) {
throw new NotFoundError(`service ${serviceId} not in group`);
}
}
db.transaction((tx) => {
for (let index = 0; index < orderedIds.length; index++) {
tx.update(services).set({ sort_order: index, updated_at: sql2`datetime('now')` }).where(eq(services.id, orderedIds[index])).run();
}
});
} }
function deleteService(db, id) { function deleteService(db, id) {
const result = db.delete(services).where(eq(services.id, id)).run(); const result = db.delete(services).where(eq(services.id, id)).run();
@@ -618,6 +673,12 @@ function replaceBindingIps(db, bindingId, ips) {
db.insert(serviceBindingIps).values({ binding_id: bindingId, ip }).run(); db.insert(serviceBindingIps).values({ binding_id: bindingId, ip }).run();
} }
} }
function setBindingCnameTarget(db, bindingId, target) {
db.update(serviceBindings).set({
cname_target: target,
updated_at: sql2`datetime('now')`
}).where(eq(serviceBindings.id, bindingId)).run();
}
function listRecordsForBinding(db, bindingId) { function listRecordsForBinding(db, bindingId) {
return db.all(sql2` return db.all(sql2`
SELECT dr.* FROM dns_records dr SELECT dr.* FROM dns_records dr
@@ -662,6 +723,40 @@ function unlinkGroupDnsRecord(db, groupId, dnsRecordId) {
) )
).run(); ).run();
} }
function dnsRecordMatchesHostname(recordName, hostname, zoneName) {
return dnsRecordNamesMatch(recordName, hostname, zoneName);
}
function enrichServiceBindingView(db, row) {
const configured = listBindingIps(db, row.id);
const linkedRecords = listRecordsForBinding(db, row.id);
const linkedIps = linkedRecords.filter((record) => record.record_type.toUpperCase() === "A").map((record) => record.content);
const target_ips = [
.../* @__PURE__ */ new Set([
...configured,
...linkedIps,
...row.target_ip ? [row.target_ip] : []
])
].sort();
if (target_ips.length === 0) {
for (const record of listDnsByDomain(db, row.domain_id)) {
if (record.record_type.toUpperCase() !== "A") continue;
if (!dnsRecordMatchesHostname(record.name, row.hostname, row.zone_name)) {
continue;
}
if (!target_ips.includes(record.content)) {
target_ips.push(record.content);
}
}
target_ips.sort();
}
const sync_status = row.sync_status ?? linkedRecords.find((record) => record.sync_status)?.sync_status ?? null;
return {
...row,
target_ips,
target_ip: target_ips[0] ?? null,
sync_status
};
}
function listAllBindings(db) { function listAllBindings(db) {
return db.all(sql2` return db.all(sql2`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
@@ -675,7 +770,7 @@ function listAllBindings(db) {
JOIN services s ON s.id = sb.service_id JOIN services s ON s.id = sb.service_id
LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id
ORDER BY d.zone_name, s.name ORDER BY d.zone_name, s.name
`); `).map((row) => enrichServiceBindingView(db, row));
} }
function listBindingsByDomain(db, domainId) { function listBindingsByDomain(db, domainId) {
return db.all(sql2` return db.all(sql2`
@@ -691,7 +786,7 @@ function listBindingsByDomain(db, domainId) {
LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id
WHERE sb.domain_id = ${domainId} WHERE sb.domain_id = ${domainId}
ORDER BY s.name ORDER BY s.name
`); `).map((row) => enrichServiceBindingView(db, row));
} }
function listBindingsByService(db, serviceId) { function listBindingsByService(db, serviceId) {
return db.all(sql2` return db.all(sql2`
@@ -720,7 +815,7 @@ function getBindingView(db, id) {
WHERE sb.id = ${id} WHERE sb.id = ${id}
`); `);
if (!rows[0]) throw new NotFoundError(`service binding ${id}`); if (!rows[0]) throw new NotFoundError(`service binding ${id}`);
return rows[0]; return enrichServiceBindingView(db, rows[0]);
} }
function findBinding(db, serviceId, domainId, hostname) { function findBinding(db, serviceId, domainId, hostname) {
const row = db.select().from(serviceBindings).where( const row = db.select().from(serviceBindings).where(
@@ -814,6 +909,14 @@ function countCertificatesByStatus(db) {
}).from(certificates).groupBy(certificates.status).all(); }).from(certificates).groupBy(certificates.status).all();
return rows.map((r) => [r.status, r.cnt]); return rows.map((r) => [r.status, r.cnt]);
} }
function deleteCertificatesNotIn(db, hostnames) {
if (hostnames.length === 0) {
const result2 = db.delete(certificates).run();
return result2.changes;
}
const result = db.delete(certificates).where(notInArray(certificates.hostname, hostnames)).run();
return result.changes;
}
function createSyncJob(db, id, domainId) { function createSyncJob(db, id, domainId) {
db.insert(syncJobs).values({ id, domain_id: domainId, status: "pending" }).run(); db.insert(syncJobs).values({ id, domain_id: domainId, status: "pending" }).run();
} }
@@ -0,0 +1 @@
ALTER TABLE subdomains ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
@@ -0,0 +1 @@
ALTER TABLE service_bindings ADD COLUMN cname_target TEXT;
@@ -0,0 +1,2 @@
ALTER TABLE services ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
UPDATE services SET sort_order = id;
@@ -0,0 +1,5 @@
ALTER TABLE domains ADD COLUMN cert_monitoring TEXT NOT NULL DEFAULT 'auto'
CHECK (cert_monitoring IN ('auto', 'required', 'skipped'));
ALTER TABLE subdomains ADD COLUMN cert_monitoring TEXT NOT NULL DEFAULT 'auto'
CHECK (cert_monitoring IN ('auto', 'required', 'skipped'));
+1 -1
View File
@@ -2,4 +2,4 @@ export * from "./schema.js";
export * from "./client.js"; export * from "./client.js";
export * from "./errors.js"; export * from "./errors.js";
export * as repos from "./repos.js"; export * as repos from "./repos.js";
export type { DnsListFilter } from "./repos.js"; export type { DnsListFilter, UpdateSubdomainPatch } from "./repos.js";
+209 -20
View File
@@ -12,7 +12,8 @@ import type {
Subdomain, Subdomain,
SyncJob, SyncJob,
} from "@cfdm/shared"; } from "@cfdm/shared";
import { and, asc, count, eq, isNull, like, or, sql } from "drizzle-orm"; import { dnsRecordNamesMatch } from "@cfdm/shared";
import { and, asc, count, eq, isNull, like, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "./client.js"; import type { Db } from "./client.js";
import { NotFoundError } from "./errors.js"; import { NotFoundError } from "./errors.js";
import { import {
@@ -158,14 +159,24 @@ export function updateDomain(
id: number, id: number,
groupId: number | null, groupId: number | null,
status: string, status: string,
certMonitoring?: string,
): Domain { ): Domain {
const updates: {
group_id: number | null;
status: string;
cert_monitoring?: string;
updated_at: ReturnType<typeof sql>;
} = {
group_id: groupId,
status,
updated_at: sql`datetime('now')`,
};
if (certMonitoring !== undefined) {
updates.cert_monitoring = certMonitoring;
}
const result = db const result = db
.update(domains) .update(domains)
.set({ .set(updates)
group_id: groupId,
status,
updated_at: sql`datetime('now')`,
})
.where(eq(domains.id, id)) .where(eq(domains.id, id))
.run(); .run();
if (result.changes === 0) throw new NotFoundError(`domain ${id}`); if (result.changes === 0) throw new NotFoundError(`domain ${id}`);
@@ -208,6 +219,19 @@ export function getSubdomain(db: Db, id: number): Subdomain {
return row as Subdomain; return row as Subdomain;
} }
export function findSubdomainByDomainAndName(
db: Db,
domainId: number,
name: string,
): Subdomain | null {
const row = db
.select()
.from(subdomains)
.where(and(eq(subdomains.domain_id, domainId), eq(subdomains.name, name)))
.get();
return (row as Subdomain) ?? null;
}
export function upsertSubdomain( export function upsertSubdomain(
db: Db, db: Db,
domainId: number, domainId: number,
@@ -237,15 +261,36 @@ export function createSubdomain(
return getSubdomain(db, id); return getSubdomain(db, id);
} }
export interface UpdateSubdomainPatch {
name?: string;
fqdn?: string;
enabled?: boolean;
cert_monitoring?: string;
}
export function updateSubdomain( export function updateSubdomain(
db: Db, db: Db,
id: number, id: number,
name: string, patch: UpdateSubdomainPatch,
fqdn: string,
): Subdomain { ): Subdomain {
const updates: {
name?: string;
fqdn?: string;
enabled?: boolean;
cert_monitoring?: string;
updated_at: ReturnType<typeof sql>;
} = { updated_at: sql`datetime('now')` };
if (patch.name !== undefined) updates.name = patch.name;
if (patch.fqdn !== undefined) updates.fqdn = patch.fqdn;
if (patch.enabled !== undefined) updates.enabled = patch.enabled;
if (patch.cert_monitoring !== undefined) {
updates.cert_monitoring = patch.cert_monitoring;
}
const result = db const result = db
.update(subdomains) .update(subdomains)
.set({ name, fqdn, updated_at: sql`datetime('now')` }) .set(updates)
.where(eq(subdomains.id, id)) .where(eq(subdomains.id, id))
.run(); .run();
if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`); if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`);
@@ -448,8 +493,25 @@ export function markDnsPendingDelete(db: Db, id: number): void {
// --- Services --- // --- Services ---
function maxSortOrderInGroup(db: Db, groupId: number | null): number {
const condition =
groupId === null
? isNull(services.service_group_id)
: eq(services.service_group_id, groupId);
const row = db
.select({ maxOrder: sql<number>`coalesce(max(${services.sort_order}), -1)` })
.from(services)
.where(condition)
.get();
return row?.maxOrder ?? -1;
}
export function listServices(db: Db): Service[] { export function listServices(db: Db): Service[] {
return db.select().from(services).orderBy(asc(services.name)).all() as Service[]; return db
.select()
.from(services)
.orderBy(asc(services.sort_order), asc(services.name))
.all() as Service[];
} }
export function listServicesByGroup(db: Db, groupId: number): Service[] { export function listServicesByGroup(db: Db, groupId: number): Service[] {
@@ -457,7 +519,7 @@ export function listServicesByGroup(db: Db, groupId: number): Service[] {
.select() .select()
.from(services) .from(services)
.where(eq(services.service_group_id, groupId)) .where(eq(services.service_group_id, groupId))
.orderBy(asc(services.name)) .orderBy(asc(services.sort_order), asc(services.name))
.all() as Service[]; .all() as Service[];
} }
@@ -466,7 +528,7 @@ export function listUngroupedServices(db: Db): Service[] {
.select() .select()
.from(services) .from(services)
.where(isNull(services.service_group_id)) .where(isNull(services.service_group_id))
.orderBy(asc(services.name)) .orderBy(asc(services.sort_order), asc(services.name))
.all() as Service[]; .all() as Service[];
} }
@@ -477,9 +539,10 @@ export function getService(db: Db, id: number): Service {
} }
export function createService(db: Db, name: string, slug: string): Service { export function createService(db: Db, name: string, slug: string): Service {
const sortOrder = maxSortOrderInGroup(db, null) + 1;
const id = db const id = db
.insert(services) .insert(services)
.values({ name, slug, subdomain: slug }) .values({ name, slug, subdomain: slug, sort_order: sortOrder })
.returning({ id: services.id }) .returning({ id: services.id })
.get()!.id; .get()!.id;
return getService(db, id); return getService(db, id);
@@ -520,12 +583,56 @@ export function setServiceGroup(
id: number, id: number,
groupId: number | null, groupId: number | null,
): void { ): void {
const sortOrder = maxSortOrderInGroup(db, groupId) + 1;
db.update(services) db.update(services)
.set({ service_group_id: groupId, updated_at: sql`datetime('now')` }) .set({
service_group_id: groupId,
sort_order: sortOrder,
updated_at: sql`datetime('now')`,
})
.where(eq(services.id, id)) .where(eq(services.id, id))
.run(); .run();
} }
export function reorderServices(
db: Db,
groupId: number | null,
orderedIds: number[],
): void {
const uniqueIds = new Set(orderedIds);
if (uniqueIds.size !== orderedIds.length) {
throw new Error("duplicate service ids in reorder request");
}
const condition =
groupId === null
? isNull(services.service_group_id)
: eq(services.service_group_id, groupId);
const existing = db
.select({ id: services.id })
.from(services)
.where(condition)
.all()
.map((row) => row.id);
const existingSet = new Set(existing);
for (const serviceId of orderedIds) {
if (!existingSet.has(serviceId)) {
throw new NotFoundError(`service ${serviceId} not in group`);
}
}
db.transaction((tx) => {
for (let index = 0; index < orderedIds.length; index++) {
tx.update(services)
.set({ sort_order: index, updated_at: sql`datetime('now')` })
.where(eq(services.id, orderedIds[index]!))
.run();
}
});
}
export function deleteService(db: Db, id: number): void { export function deleteService(db: Db, id: number): void {
const result = db.delete(services).where(eq(services.id, id)).run(); const result = db.delete(services).where(eq(services.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service ${id}`); if (result.changes === 0) throw new NotFoundError(`service ${id}`);
@@ -668,6 +775,20 @@ export function replaceBindingIps(
} }
} }
export function setBindingCnameTarget(
db: Db,
bindingId: number,
target: string | null,
): void {
db.update(serviceBindings)
.set({
cname_target: target,
updated_at: sql`datetime('now')`,
})
.where(eq(serviceBindings.id, bindingId))
.run();
}
// --- Service Binding Records --- // --- Service Binding Records ---
export function listRecordsForBinding(db: Db, bindingId: number): DnsRecord[] { export function listRecordsForBinding(db: Db, bindingId: number): DnsRecord[] {
@@ -744,8 +865,61 @@ export function unlinkGroupDnsRecord(
// --- Service Bindings --- // --- Service Bindings ---
function dnsRecordMatchesHostname(
recordName: string,
hostname: string,
zoneName: string,
): boolean {
return dnsRecordNamesMatch(recordName, hostname, zoneName);
}
function enrichServiceBindingView(
db: Db,
row: Omit<ServiceBindingView, "target_ips"> & { target_ips?: string[] },
): ServiceBindingView {
const configured = listBindingIps(db, row.id);
const linkedRecords = listRecordsForBinding(db, row.id);
const linkedIps = linkedRecords
.filter((record) => record.record_type.toUpperCase() === "A")
.map((record) => record.content);
const target_ips = [
...new Set([
...configured,
...linkedIps,
...(row.target_ip ? [row.target_ip] : []),
]),
].sort();
if (target_ips.length === 0) {
for (const record of listDnsByDomain(db, row.domain_id)) {
if (record.record_type.toUpperCase() !== "A") continue;
if (!dnsRecordMatchesHostname(record.name, row.hostname, row.zone_name)) {
continue;
}
if (!target_ips.includes(record.content)) {
target_ips.push(record.content);
}
}
target_ips.sort();
}
const sync_status =
row.sync_status ??
linkedRecords.find((record) => record.sync_status)?.sync_status ??
null;
return {
...row,
target_ips,
target_ip: target_ips[0] ?? null,
sync_status,
};
}
export function listAllBindings(db: Db): ServiceBindingView[] { export function listAllBindings(db: Db): ServiceBindingView[] {
return db.all<ServiceBindingView>(sql` return db
.all<Omit<ServiceBindingView, "target_ips">>(sql`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name, d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug, s.name AS service_name, s.slug AS service_slug,
@@ -757,11 +931,13 @@ export function listAllBindings(db: Db): ServiceBindingView[] {
JOIN services s ON s.id = sb.service_id JOIN services s ON s.id = sb.service_id
LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id
ORDER BY d.zone_name, s.name ORDER BY d.zone_name, s.name
`); `)
.map((row) => enrichServiceBindingView(db, row));
} }
export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingView[] { export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingView[] {
return db.all<ServiceBindingView>(sql` return db
.all<Omit<ServiceBindingView, "target_ips">>(sql`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name, d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug, s.name AS service_name, s.slug AS service_slug,
@@ -774,7 +950,8 @@ export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingVi
LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id
WHERE sb.domain_id = ${domainId} WHERE sb.domain_id = ${domainId}
ORDER BY s.name ORDER BY s.name
`); `)
.map((row) => enrichServiceBindingView(db, row));
} }
export function listBindingsByService(db: Db, serviceId: number): Array<ServiceBinding & { zone_name: string }> { export function listBindingsByService(db: Db, serviceId: number): Array<ServiceBinding & { zone_name: string }> {
@@ -796,7 +973,7 @@ export function getBinding(db: Db, id: number): ServiceBinding {
} }
export function getBindingView(db: Db, id: number): ServiceBindingView { export function getBindingView(db: Db, id: number): ServiceBindingView {
const rows = db.all<ServiceBindingView>(sql` const rows = db.all<Omit<ServiceBindingView, "target_ips">>(sql`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id, SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name, d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug, s.name AS service_name, s.slug AS service_slug,
@@ -810,7 +987,7 @@ export function getBindingView(db: Db, id: number): ServiceBindingView {
WHERE sb.id = ${id} WHERE sb.id = ${id}
`); `);
if (!rows[0]) throw new NotFoundError(`service binding ${id}`); if (!rows[0]) throw new NotFoundError(`service binding ${id}`);
return rows[0]; return enrichServiceBindingView(db, rows[0]);
} }
export function findBinding( export function findBinding(
@@ -1005,6 +1182,18 @@ export function countCertificatesByStatus(
return rows.map((r) => [r.status, r.cnt]); return rows.map((r) => [r.status, r.cnt]);
} }
export function deleteCertificatesNotIn(db: Db, hostnames: string[]): number {
if (hostnames.length === 0) {
const result = db.delete(certificates).run();
return result.changes;
}
const result = db
.delete(certificates)
.where(notInArray(certificates.hostname, hostnames))
.run();
return result.changes;
}
// --- Sync Jobs --- // --- Sync Jobs ---
export function createSyncJob( export function createSyncJob(
+5
View File
@@ -28,6 +28,7 @@ export const services = sqliteTable("services", {
), ),
subdomain: text("subdomain"), subdomain: text("subdomain"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false), enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
sort_order: integer("sort_order").notNull().default(0),
created_at: text("created_at") created_at: text("created_at")
.notNull() .notNull()
.default(sql`datetime('now')`), .default(sql`datetime('now')`),
@@ -59,6 +60,7 @@ export const domains = sqliteTable("domains", {
zone_name: text("zone_name").notNull().unique(), zone_name: text("zone_name").notNull().unique(),
cf_zone_id: text("cf_zone_id").notNull(), cf_zone_id: text("cf_zone_id").notNull(),
status: text("status").notNull().default("active"), status: text("status").notNull().default("active"),
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
last_synced_at: text("last_synced_at"), last_synced_at: text("last_synced_at"),
created_at: text("created_at") created_at: text("created_at")
.notNull() .notNull()
@@ -75,6 +77,8 @@ export const subdomains = sqliteTable("subdomains", {
.references(() => domains.id, { onDelete: "cascade" }), .references(() => domains.id, { onDelete: "cascade" }),
name: text("name").notNull(), name: text("name").notNull(),
fqdn: text("fqdn").notNull(), fqdn: text("fqdn").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
created_at: text("created_at") created_at: text("created_at")
.notNull() .notNull()
.default(sql`datetime('now')`), .default(sql`datetime('now')`),
@@ -115,6 +119,7 @@ export const serviceBindings = sqliteTable("service_bindings", {
.notNull() .notNull()
.references(() => services.id, { onDelete: "cascade" }), .references(() => services.id, { onDelete: "cascade" }),
hostname: text("hostname").notNull().default("@"), hostname: text("hostname").notNull().default("@"),
cname_target: text("cname_target"),
dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, { dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, {
onDelete: "set null", onDelete: "set null",
}), }),
+257 -83
View File
@@ -10,16 +10,136 @@ declare const CERT_WARNING = "warning";
declare const CERT_EXPIRED = "expired"; declare const CERT_EXPIRED = "expired";
declare const CERT_ERROR = "error"; declare const CERT_ERROR = "error";
declare const CERT_UNKNOWN = "unknown"; declare const CERT_UNKNOWN = "unknown";
declare const CERT_MONITOR_AUTO = "auto";
declare const CERT_MONITOR_REQUIRED = "required";
declare const CERT_MONITOR_SKIPPED = "skipped";
declare const CERT_MONITORING_VALUES: readonly ["auto", "required", "skipped"];
interface ServiceGroup$1 {
id: number;
name: string;
type: string;
icon: string | null;
domain: string | null;
enabled: boolean;
created_at: string;
updated_at: string;
}
interface Service$1 {
id: number;
name: string;
slug: string;
service_group_id: number | null;
subdomain: string;
enabled: boolean;
sort_order: number;
created_at: string;
updated_at: string;
}
interface Subdomain {
id: number;
domain_id: number;
name: string;
fqdn: string;
enabled: boolean;
cert_monitoring: string;
created_at: string;
updated_at: string;
}
interface ServiceBinding {
id: number;
domain_id: number;
service_id: number;
hostname: string;
cname_target: string | null;
dns_record_id: number | null;
created_at: string;
updated_at: string;
}
interface ServiceBindingView {
id: number;
domain_id: number;
service_id: number;
hostname: string;
dns_record_id: number | null;
zone_name: string;
group_id: number | null;
group_name: string | null;
service_name: string;
service_slug: string;
target_ip: string | null;
target_ips: string[];
sync_status: string | null;
created_at: string;
updated_at: string;
}
interface ServiceDomainBindingView {
binding_id: number;
domain_id: number;
zone_name: string;
hostname: string;
fqdn: string;
record_type: "A" | "CNAME";
target_ips: string[];
target_cname: string | null;
sync_status: string | null;
}
interface SyncJob {
id: string;
status: string;
domain_id: number | null;
message: string | null;
created_at: string;
finished_at: string | null;
}
interface CfZone {
id: string;
name: string;
status: string;
}
interface CfDnsRecord {
id?: string;
type: string;
name: string;
content: string;
ttl: number;
proxied?: boolean;
priority?: number;
}
interface CreateDnsRecordPayload {
type: string;
name: string;
content: string;
ttl: number;
proxied?: boolean;
priority?: number;
}
interface LoginRequest {
username: string;
password: string;
}
interface LoginResponse {
token: string;
expires_at: string;
}
interface JwtClaims {
sub: string;
exp: number;
}
declare class ValidationError extends Error { declare class ValidationError extends Error {
constructor(message: string); constructor(message: string);
} }
declare function validateDnsRecord(recordType: string, name: string, content: string, ttl: number, proxied: boolean): void; declare function validateDnsRecord(recordType: string, name: string, content: string, ttl: number, proxied: boolean): void;
declare function certStatusFromExpiry(daysLeft: number): string; declare function certStatusFromExpiry(daysLeft: number): string;
declare function shouldMonitorService(service: Pick<Service$1, "enabled" | "service_group_id">, group?: Pick<ServiceGroup$1, "enabled"> | null): boolean;
declare function isValidIpv4(ip: string): boolean; declare function isValidIpv4(ip: string): boolean;
declare function dnsNameToSubdomainLabel(recordName: string, zoneName: string): string | null; declare function dnsNameToSubdomainLabel(recordName: string, zoneName: string): string | null;
declare function subdomainLabelToFqdn(label: string, zoneName: string): string; declare function subdomainLabelToFqdn(label: string, zoneName: string): string;
/** Canonical DNS record name as returned by Cloudflare for a zone. */
declare function normalizeDnsRecordName(recordName: string, zoneName: string): string;
declare function dnsRecordNamesMatch(left: string, right: string, zoneName: string): boolean;
interface ParsedFqdn { interface ParsedFqdn {
zoneName: string; zoneName: string;
@@ -34,6 +154,12 @@ declare function bindingToFqdn(binding: {
fqdn?: string; fqdn?: string;
}): string; }): string;
declare const certMonitoringSchema: z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>;
type CertMonitoring = z.infer<typeof certMonitoringSchema>;
declare const groupSchema: z.ZodObject<{ declare const groupSchema: z.ZodObject<{
id: z.ZodNumber; id: z.ZodNumber;
name: z.ZodString; name: z.ZodString;
@@ -89,11 +215,18 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
zone_name: z.ZodString; zone_name: z.ZodString;
hostname: z.ZodString; hostname: z.ZodString;
fqdn: z.ZodString; fqdn: z.ZodString;
record_type: z.ZodDefault<z.ZodEnum<{
A: "A";
CNAME: "CNAME";
}>>;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>; target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>; target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>;
target_cname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
sync_status: z.ZodNullable<z.ZodString>; sync_status: z.ZodNullable<z.ZodString>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
target_ips: string[]; target_ips: string[];
target_cname: string | null;
record_type: "A" | "CNAME";
binding_id: number; binding_id: number;
domain_id: number; domain_id: number;
zone_name: string; zone_name: string;
@@ -107,9 +240,11 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
zone_name: string; zone_name: string;
hostname: string; hostname: string;
fqdn: string; fqdn: string;
record_type: "A" | "CNAME";
sync_status: string | null; sync_status: string | null;
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
target_cname?: string | null | undefined;
}>>; }>>;
declare const serviceViewSchema: z.ZodObject<{ declare const serviceViewSchema: z.ZodObject<{
id: z.ZodNumber; id: z.ZodNumber;
@@ -128,11 +263,18 @@ declare const serviceViewSchema: z.ZodObject<{
zone_name: z.ZodString; zone_name: z.ZodString;
hostname: z.ZodString; hostname: z.ZodString;
fqdn: z.ZodString; fqdn: z.ZodString;
record_type: z.ZodDefault<z.ZodEnum<{
A: "A";
CNAME: "CNAME";
}>>;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>; target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>; target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>;
target_cname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
sync_status: z.ZodNullable<z.ZodString>; sync_status: z.ZodNullable<z.ZodString>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
target_ips: string[]; target_ips: string[];
target_cname: string | null;
record_type: "A" | "CNAME";
binding_id: number; binding_id: number;
domain_id: number; domain_id: number;
zone_name: string; zone_name: string;
@@ -146,9 +288,11 @@ declare const serviceViewSchema: z.ZodObject<{
zone_name: string; zone_name: string;
hostname: string; hostname: string;
fqdn: string; fqdn: string;
record_type: "A" | "CNAME";
sync_status: string | null; sync_status: string | null;
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
target_cname?: string | null | undefined;
}>>>>; }>>>>;
}, z.core.$strip>; }, z.core.$strip>;
declare const serviceGroupViewSchema: z.ZodObject<{ declare const serviceGroupViewSchema: z.ZodObject<{
@@ -183,11 +327,18 @@ declare const serviceGroupViewSchema: z.ZodObject<{
zone_name: z.ZodString; zone_name: z.ZodString;
hostname: z.ZodString; hostname: z.ZodString;
fqdn: z.ZodString; fqdn: z.ZodString;
record_type: z.ZodDefault<z.ZodEnum<{
A: "A";
CNAME: "CNAME";
}>>;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>; target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>; target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>;
target_cname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
sync_status: z.ZodNullable<z.ZodString>; sync_status: z.ZodNullable<z.ZodString>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
target_ips: string[]; target_ips: string[];
target_cname: string | null;
record_type: "A" | "CNAME";
binding_id: number; binding_id: number;
domain_id: number; domain_id: number;
zone_name: string; zone_name: string;
@@ -201,9 +352,11 @@ declare const serviceGroupViewSchema: z.ZodObject<{
zone_name: string; zone_name: string;
hostname: string; hostname: string;
fqdn: string; fqdn: string;
record_type: "A" | "CNAME";
sync_status: string | null; sync_status: string | null;
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
target_cname?: string | null | undefined;
}>>>>; }>>>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
}, z.core.$strip>; }, z.core.$strip>;
@@ -240,11 +393,18 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
zone_name: z.ZodString; zone_name: z.ZodString;
hostname: z.ZodString; hostname: z.ZodString;
fqdn: z.ZodString; fqdn: z.ZodString;
record_type: z.ZodDefault<z.ZodEnum<{
A: "A";
CNAME: "CNAME";
}>>;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>; target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>; target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>;
target_cname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
sync_status: z.ZodNullable<z.ZodString>; sync_status: z.ZodNullable<z.ZodString>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
target_ips: string[]; target_ips: string[];
target_cname: string | null;
record_type: "A" | "CNAME";
binding_id: number; binding_id: number;
domain_id: number; domain_id: number;
zone_name: string; zone_name: string;
@@ -258,9 +418,11 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
zone_name: string; zone_name: string;
hostname: string; hostname: string;
fqdn: string; fqdn: string;
record_type: "A" | "CNAME";
sync_status: string | null; sync_status: string | null;
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
target_cname?: string | null | undefined;
}>>>>; }>>>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
@@ -281,11 +443,18 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
zone_name: z.ZodString; zone_name: z.ZodString;
hostname: z.ZodString; hostname: z.ZodString;
fqdn: z.ZodString; fqdn: z.ZodString;
record_type: z.ZodDefault<z.ZodEnum<{
A: "A";
CNAME: "CNAME";
}>>;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>; target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>; target_ip: z.ZodOptional<z.ZodNullable<z.ZodString>>;
target_cname: z.ZodOptional<z.ZodNullable<z.ZodString>>;
sync_status: z.ZodNullable<z.ZodString>; sync_status: z.ZodNullable<z.ZodString>;
}, z.core.$strip>, z.ZodTransform<{ }, z.core.$strip>, z.ZodTransform<{
target_ips: string[]; target_ips: string[];
target_cname: string | null;
record_type: "A" | "CNAME";
binding_id: number; binding_id: number;
domain_id: number; domain_id: number;
zone_name: string; zone_name: string;
@@ -299,9 +468,11 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
zone_name: string; zone_name: string;
hostname: string; hostname: string;
fqdn: string; fqdn: string;
record_type: "A" | "CNAME";
sync_status: string | null; sync_status: string | null;
target_ips?: string[] | undefined; target_ips?: string[] | undefined;
target_ip?: string | null | undefined; target_ip?: string | null | undefined;
target_cname?: string | null | undefined;
}>>>>; }>>>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
}, z.core.$strip>; }, z.core.$strip>;
@@ -311,6 +482,11 @@ declare const domainSchema: z.ZodObject<{
zone_name: z.ZodString; zone_name: z.ZodString;
cf_zone_id: z.ZodString; cf_zone_id: z.ZodString;
status: z.ZodString; status: z.ZodString;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
last_synced_at: z.ZodNullable<z.ZodString>; last_synced_at: z.ZodNullable<z.ZodString>;
created_at: z.ZodString; created_at: z.ZodString;
updated_at: z.ZodString; updated_at: z.ZodString;
@@ -321,13 +497,18 @@ declare const domainListItemSchema: z.ZodObject<{
zone_name: z.ZodString; zone_name: z.ZodString;
cf_zone_id: z.ZodString; cf_zone_id: z.ZodString;
status: z.ZodString; status: z.ZodString;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
last_synced_at: z.ZodNullable<z.ZodString>; last_synced_at: z.ZodNullable<z.ZodString>;
created_at: z.ZodString; created_at: z.ZodString;
updated_at: z.ZodString; updated_at: z.ZodString;
group_name: z.ZodNullable<z.ZodString>; group_name: z.ZodNullable<z.ZodString>;
service_count: z.ZodNumber; service_count: z.ZodNumber;
}, z.core.$strip>; }, z.core.$strip>;
declare const serviceBindingSchema: z.ZodObject<{ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
id: z.ZodNumber; id: z.ZodNumber;
domain_id: z.ZodNumber; domain_id: z.ZodNumber;
service_id: z.ZodNumber; service_id: z.ZodNumber;
@@ -339,10 +520,43 @@ declare const serviceBindingSchema: z.ZodObject<{
service_name: z.ZodString; service_name: z.ZodString;
service_slug: z.ZodString; service_slug: z.ZodString;
target_ip: z.ZodNullable<z.ZodString>; target_ip: z.ZodNullable<z.ZodString>;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
sync_status: z.ZodNullable<z.ZodString>; sync_status: z.ZodNullable<z.ZodString>;
created_at: z.ZodString; created_at: z.ZodString;
updated_at: z.ZodString; updated_at: z.ZodString;
}, z.core.$strip>; }, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
id: number;
domain_id: number;
service_id: number;
hostname: string;
dns_record_id: number | null;
zone_name: string;
group_id: number | null;
group_name: string | null;
service_name: string;
service_slug: string;
target_ip: string | null;
sync_status: string | null;
created_at: string;
updated_at: string;
}, {
id: number;
domain_id: number;
service_id: number;
hostname: string;
dns_record_id: number | null;
zone_name: string;
group_id: number | null;
group_name: string | null;
service_name: string;
service_slug: string;
target_ip: string | null;
sync_status: string | null;
created_at: string;
updated_at: string;
target_ips?: string[] | undefined;
}>>;
declare const dnsRecordSchema: z.ZodObject<{ declare const dnsRecordSchema: z.ZodObject<{
id: z.ZodNumber; id: z.ZodNumber;
domain_id: z.ZodNumber; domain_id: z.ZodNumber;
@@ -381,7 +595,6 @@ type ServiceGroupView = z.infer<typeof serviceGroupViewSchema>;
type ServiceGroupsResponse = z.infer<typeof serviceGroupsResponseSchema>; type ServiceGroupsResponse = z.infer<typeof serviceGroupsResponseSchema>;
type Domain = z.infer<typeof domainSchema>; type Domain = z.infer<typeof domainSchema>;
type DomainListItem = z.infer<typeof domainListItemSchema>; type DomainListItem = z.infer<typeof domainListItemSchema>;
type ServiceBinding = z.infer<typeof serviceBindingSchema>;
type DnsRecord = z.infer<typeof dnsRecordSchema>; type DnsRecord = z.infer<typeof dnsRecordSchema>;
type Certificate = z.infer<typeof certificateSchema>; type Certificate = z.infer<typeof certificateSchema>;
declare const createGroupSchema: z.ZodObject<{ declare const createGroupSchema: z.ZodObject<{
@@ -399,7 +612,8 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
ips: z.ZodDefault<z.ZodArray<z.ZodString>>; ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
domains: z.ZodDefault<z.ZodArray<z.ZodObject<{ domains: z.ZodDefault<z.ZodArray<z.ZodObject<{
fqdn: z.ZodString; fqdn: z.ZodString;
target_ips: z.ZodArray<z.ZodString>; target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_cname: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
}, z.core.$strip>; }, z.core.$strip>;
declare const createServiceBindingSchema: z.ZodObject<{ declare const createServiceBindingSchema: z.ZodObject<{
@@ -423,8 +637,8 @@ declare const loginSchema: z.ZodObject<{
declare const createDnsRecordSchema: z.ZodObject<{ declare const createDnsRecordSchema: z.ZodObject<{
record_type: z.ZodEnum<{ record_type: z.ZodEnum<{
A: "A"; A: "A";
AAAA: "AAAA";
CNAME: "CNAME"; CNAME: "CNAME";
AAAA: "AAAA";
TXT: "TXT"; TXT: "TXT";
MX: "MX"; MX: "MX";
}>; }>;
@@ -438,10 +652,40 @@ declare const subdomainSchema: z.ZodObject<{
domain_id: z.ZodNumber; domain_id: z.ZodNumber;
name: z.ZodString; name: z.ZodString;
fqdn: z.ZodString; fqdn: z.ZodString;
enabled: z.ZodBoolean;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
created_at: z.ZodString; created_at: z.ZodString;
updated_at: z.ZodString; updated_at: z.ZodString;
}, z.core.$strip>; }, z.core.$strip>;
type SubdomainRecord = z.infer<typeof subdomainSchema>; type SubdomainRecord = z.infer<typeof subdomainSchema>;
declare const createSubdomainSchema: z.ZodObject<{
name: z.ZodString;
}, z.core.$strip>;
declare const updateSubdomainSchema: z.ZodObject<{
name: z.ZodOptional<z.ZodString>;
enabled: z.ZodOptional<z.ZodBoolean>;
cert_monitoring: z.ZodOptional<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
}, z.core.$strip>;
declare const updateDomainSchema: z.ZodObject<{
group_id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
status: z.ZodOptional<z.ZodString>;
cert_monitoring: z.ZodOptional<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
}, z.core.$strip>;
type CreateSubdomainInput = z.infer<typeof createSubdomainSchema>;
type UpdateSubdomainInput = z.infer<typeof updateSubdomainSchema>;
type UpdateDomainInput = z.infer<typeof updateDomainSchema>;
type CreateGroupInput = z.infer<typeof createGroupSchema>; type CreateGroupInput = z.infer<typeof createGroupSchema>;
type CreateServiceInput = z.infer<typeof createServiceSchema>; type CreateServiceInput = z.infer<typeof createServiceSchema>;
type CreateServiceWithConfigInput = z.infer<typeof createServiceWithConfigSchema>; type CreateServiceWithConfigInput = z.infer<typeof createServiceWithConfigSchema>;
@@ -452,7 +696,8 @@ declare const updateServiceConfigSchema: z.ZodObject<{
ips: z.ZodOptional<z.ZodArray<z.ZodString>>; ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
domains: z.ZodOptional<z.ZodArray<z.ZodObject<{ domains: z.ZodOptional<z.ZodArray<z.ZodObject<{
fqdn: z.ZodString; fqdn: z.ZodString;
target_ips: z.ZodArray<z.ZodString>; target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_cname: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
}, z.core.$strip>; }, z.core.$strip>;
type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>; type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>;
@@ -471,87 +716,16 @@ declare const createServiceGroupSchema: z.ZodObject<{
declare const toggleEnabledSchema: z.ZodObject<{ declare const toggleEnabledSchema: z.ZodObject<{
enabled: z.ZodBoolean; enabled: z.ZodBoolean;
}, z.core.$strip>; }, z.core.$strip>;
declare const reorderServicesSchema: z.ZodObject<{
group_id: z.ZodDefault<z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>>>;
service_ids: z.ZodArray<z.ZodNumber>;
}, z.core.$strip>;
type CreateServiceGroupInput = z.infer<typeof createServiceGroupSchema>; type CreateServiceGroupInput = z.infer<typeof createServiceGroupSchema>;
type ToggleEnabledInput = z.infer<typeof toggleEnabledSchema>; type ToggleEnabledInput = z.infer<typeof toggleEnabledSchema>;
type ReorderServicesInput = z.infer<typeof reorderServicesSchema>;
type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchema>; type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchema>;
type CreateDomainInput = z.infer<typeof createDomainSchema>; type CreateDomainInput = z.infer<typeof createDomainSchema>;
type LoginInput = z.infer<typeof loginSchema>; type LoginInput = z.infer<typeof loginSchema>;
type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>; type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>;
interface Subdomain { export { CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainListItem, type Group, type GroupWithStats, type JwtClaims, type LoginInput, type LoginRequest, type LoginResponse, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateSubdomainInput, ValidationError, bindingToFqdn, certMonitoringSchema, certStatusFromExpiry, certificateSchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, isValidIpv4, loginSchema, normalizeDnsRecordName, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateSubdomainSchema, validateDnsRecord };
id: number;
domain_id: number;
name: string;
fqdn: string;
created_at: string;
updated_at: string;
}
interface ServiceBindingView {
id: number;
domain_id: number;
service_id: number;
hostname: string;
dns_record_id: number | null;
zone_name: string;
group_id: number | null;
group_name: string | null;
service_name: string;
service_slug: string;
target_ip: string | null;
sync_status: string | null;
created_at: string;
updated_at: string;
}
interface ServiceDomainBindingView {
binding_id: number;
domain_id: number;
zone_name: string;
hostname: string;
fqdn: string;
target_ips: string[];
sync_status: string | null;
}
interface SyncJob {
id: string;
status: string;
domain_id: number | null;
message: string | null;
created_at: string;
finished_at: string | null;
}
interface CfZone {
id: string;
name: string;
status: string;
}
interface CfDnsRecord {
id?: string;
type: string;
name: string;
content: string;
ttl: number;
proxied?: boolean;
priority?: number;
}
interface CreateDnsRecordPayload {
type: string;
name: string;
content: string;
ttl: number;
proxied?: boolean;
priority?: number;
}
interface LoginRequest {
username: string;
password: string;
}
interface LoginResponse {
token: string;
expires_at: string;
}
interface JwtClaims {
sub: string;
exp: number;
}
export { CERT_ERROR, CERT_EXPIRED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type Certificate, type CfDnsRecord, type CfZone, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type DnsRecord, type Domain, type DomainListItem, type Group, type GroupWithStats, type JwtClaims, type LoginInput, type LoginRequest, type LoginResponse, type ParsedFqdn, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateServiceConfigInput, ValidationError, bindingToFqdn, certStatusFromExpiry, certificateSchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, dnsNameToSubdomainLabel, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, isValidIpv4, loginSchema, parseFqdn, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateServiceConfigSchema, validateDnsRecord };
+85 -3
View File
@@ -9,6 +9,14 @@ var CERT_WARNING = "warning";
var CERT_EXPIRED = "expired"; var CERT_EXPIRED = "expired";
var CERT_ERROR = "error"; var CERT_ERROR = "error";
var CERT_UNKNOWN = "unknown"; var CERT_UNKNOWN = "unknown";
var CERT_MONITOR_AUTO = "auto";
var CERT_MONITOR_REQUIRED = "required";
var CERT_MONITOR_SKIPPED = "skipped";
var CERT_MONITORING_VALUES = [
CERT_MONITOR_AUTO,
CERT_MONITOR_REQUIRED,
CERT_MONITOR_SKIPPED
];
// src/validators.ts // src/validators.ts
var NAME_RE = /^(@|\*|[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?(\.[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?)*)$/; var NAME_RE = /^(@|\*|[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?(\.[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?)*)$/;
@@ -64,6 +72,11 @@ function certStatusFromExpiry(daysLeft) {
if (daysLeft <= 30) return CERT_WARNING; if (daysLeft <= 30) return CERT_WARNING;
return CERT_OK; return CERT_OK;
} }
function shouldMonitorService(service, group) {
if (!service.enabled) return false;
if (!service.service_group_id) return true;
return group?.enabled ?? false;
}
function isValidIpv4(ip) { function isValidIpv4(ip) {
const parts = ip.split("."); const parts = ip.split(".");
if (parts.length !== 4) return false; if (parts.length !== 4) return false;
@@ -93,6 +106,16 @@ function dnsNameToSubdomainLabel(recordName, zoneName) {
function subdomainLabelToFqdn(label, zoneName) { function subdomainLabelToFqdn(label, zoneName) {
return label === "@" ? zoneName : `${label}.${zoneName}`; return label === "@" ? zoneName : `${label}.${zoneName}`;
} }
function normalizeDnsRecordName(recordName, zoneName) {
const label = dnsNameToSubdomainLabel(recordName, zoneName);
if (label == null) {
return recordName.trim().replace(/\.+$/, "");
}
return subdomainLabelToFqdn(label, zoneName);
}
function dnsRecordNamesMatch(left, right, zoneName) {
return normalizeDnsRecordName(left, zoneName).toLowerCase() === normalizeDnsRecordName(right, zoneName).toLowerCase();
}
// src/parse-fqdn.ts // src/parse-fqdn.ts
function fqdnToDisplay(hostname, zoneName) { function fqdnToDisplay(hostname, zoneName) {
@@ -136,6 +159,7 @@ function bindingToFqdn(binding) {
// src/schemas.ts // src/schemas.ts
import { z } from "zod"; import { z } from "zod";
var certMonitoringSchema = z.enum(["auto", "required", "skipped"]);
var groupSchema = z.object({ var groupSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
@@ -180,12 +204,16 @@ var serviceDomainBindingSchema = z.object({
zone_name: z.string(), zone_name: z.string(),
hostname: z.string(), hostname: z.string(),
fqdn: z.string(), fqdn: z.string(),
record_type: z.enum(["A", "CNAME"]).default("A"),
target_ips: z.array(z.string()).optional(), target_ips: z.array(z.string()).optional(),
target_ip: z.string().nullable().optional(), target_ip: z.string().nullable().optional(),
target_cname: z.string().nullable().optional(),
sync_status: z.string().nullable() sync_status: z.string().nullable()
}).transform((binding) => ({ }).transform((binding) => ({
...binding, ...binding,
target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [] target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : [],
target_cname: binding.target_cname?.trim() || null,
record_type: binding.target_cname?.trim() ? "CNAME" : binding.record_type ?? "A"
})); }));
var serviceViewSchema = serviceSchema.extend({ var serviceViewSchema = serviceSchema.extend({
subdomain: z.string().default(""), subdomain: z.string().default(""),
@@ -206,6 +234,7 @@ var domainSchema = z.object({
zone_name: z.string(), zone_name: z.string(),
cf_zone_id: z.string(), cf_zone_id: z.string(),
status: z.string(), status: z.string(),
cert_monitoring: certMonitoringSchema.default("auto"),
last_synced_at: z.string().nullable(), last_synced_at: z.string().nullable(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string() updated_at: z.string()
@@ -226,10 +255,14 @@ var serviceBindingSchema = z.object({
service_name: z.string(), service_name: z.string(),
service_slug: z.string(), service_slug: z.string(),
target_ip: z.string().nullable(), target_ip: z.string().nullable(),
target_ips: z.array(z.string()).optional(),
sync_status: z.string().nullable(), sync_status: z.string().nullable(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string() updated_at: z.string()
}); }).transform((binding) => ({
...binding,
target_ips: binding.target_ips && binding.target_ips.length > 0 ? binding.target_ips : binding.target_ip ? [binding.target_ip] : []
}));
var dnsRecordSchema = z.object({ var dnsRecordSchema = z.object({
id: z.number(), id: z.number(),
domain_id: z.number(), domain_id: z.number(),
@@ -268,7 +301,25 @@ var ipv4Schema = z.string().regex(
); );
var serviceDomainInputSchema = z.object({ var serviceDomainInputSchema = z.object({
fqdn: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 FQDN"), fqdn: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 FQDN"),
target_ips: z.array(ipv4Schema).min(1, "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0445\u043E\u0442\u044F \u0431\u044B \u043E\u0434\u0438\u043D IP") target_ips: z.array(ipv4Schema).optional(),
target_cname: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 CNAME-\u0446\u0435\u043B\u044C").optional()
}).superRefine((data, ctx) => {
const hasIps = (data.target_ips?.length ?? 0) > 0;
const hasCname = Boolean(data.target_cname?.trim());
if (!hasIps && !hasCname) {
ctx.addIssue({
code: "custom",
message: "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 CNAME-\u0446\u0435\u043B\u044C",
path: ["target_ips"]
});
}
if (hasIps && hasCname) {
ctx.addIssue({
code: "custom",
message: "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043B\u0438\u0431\u043E IP, \u043B\u0438\u0431\u043E CNAME-\u0446\u0435\u043B\u044C",
path: ["target_cname"]
});
}
}); });
var createServiceSchema = z.object({ var createServiceSchema = z.object({
name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435"), name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435"),
@@ -309,9 +360,24 @@ var subdomainSchema = z.object({
domain_id: z.number(), domain_id: z.number(),
name: z.string(), name: z.string(),
fqdn: z.string(), fqdn: z.string(),
enabled: z.boolean(),
cert_monitoring: certMonitoringSchema.default("auto"),
created_at: z.string(), created_at: z.string(),
updated_at: z.string() updated_at: z.string()
}); });
var createSubdomainSchema = z.object({
name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u0438\u043C\u044F \u043F\u043E\u0434\u0434\u043E\u043C\u0435\u043D\u0430")
});
var updateSubdomainSchema = z.object({
name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u0438\u043C\u044F \u043F\u043E\u0434\u0434\u043E\u043C\u0435\u043D\u0430").optional(),
enabled: z.boolean().optional(),
cert_monitoring: certMonitoringSchema.optional()
});
var updateDomainSchema = z.object({
group_id: z.number().nullable().optional(),
status: z.string().optional(),
cert_monitoring: certMonitoringSchema.optional()
});
var updateServiceConfigSchema = z.object({ var updateServiceConfigSchema = z.object({
name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435").optional(), name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435").optional(),
slug: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 slug").optional(), slug: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 slug").optional(),
@@ -328,9 +394,17 @@ var createServiceGroupSchema = z.object({
var toggleEnabledSchema = z.object({ var toggleEnabledSchema = z.object({
enabled: z.boolean() enabled: z.boolean()
}); });
var reorderServicesSchema = z.object({
group_id: z.union([z.number(), z.null()]).optional().default(null),
service_ids: z.array(z.number().int().positive()).min(1)
});
export { export {
CERT_ERROR, CERT_ERROR,
CERT_EXPIRED, CERT_EXPIRED,
CERT_MONITORING_VALUES,
CERT_MONITOR_AUTO,
CERT_MONITOR_REQUIRED,
CERT_MONITOR_SKIPPED,
CERT_OK, CERT_OK,
CERT_UNKNOWN, CERT_UNKNOWN,
CERT_WARNING, CERT_WARNING,
@@ -341,6 +415,7 @@ export {
SYNC_SYNCED, SYNC_SYNCED,
ValidationError, ValidationError,
bindingToFqdn, bindingToFqdn,
certMonitoringSchema,
certStatusFromExpiry, certStatusFromExpiry,
certificateSchema, certificateSchema,
createDnsRecordSchema, createDnsRecordSchema,
@@ -350,7 +425,9 @@ export {
createServiceGroupSchema, createServiceGroupSchema,
createServiceSchema, createServiceSchema,
createServiceWithConfigSchema, createServiceWithConfigSchema,
createSubdomainSchema,
dnsNameToSubdomainLabel, dnsNameToSubdomainLabel,
dnsRecordNamesMatch,
dnsRecordSchema, dnsRecordSchema,
domainListItemSchema, domainListItemSchema,
domainSchema, domainSchema,
@@ -359,7 +436,9 @@ export {
groupWithStatsSchema, groupWithStatsSchema,
isValidIpv4, isValidIpv4,
loginSchema, loginSchema,
normalizeDnsRecordName,
parseFqdn, parseFqdn,
reorderServicesSchema,
serviceBindingSchema, serviceBindingSchema,
serviceDomainBindingSchema, serviceDomainBindingSchema,
serviceGroupSchema, serviceGroupSchema,
@@ -368,10 +447,13 @@ export {
serviceGroupsResponseSchema, serviceGroupsResponseSchema,
serviceSchema, serviceSchema,
serviceViewSchema, serviceViewSchema,
shouldMonitorService,
subdomainLabelToFqdn, subdomainLabelToFqdn,
subdomainSchema, subdomainSchema,
toggleEnabledSchema, toggleEnabledSchema,
updateDomainGroupSchema, updateDomainGroupSchema,
updateDomainSchema,
updateServiceConfigSchema, updateServiceConfigSchema,
updateSubdomainSchema,
validateDnsRecord validateDnsRecord
}; };
+1
View File
@@ -6,6 +6,7 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"development": "./src/index.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },
+10
View File
@@ -9,3 +9,13 @@ export const CERT_WARNING = "warning";
export const CERT_EXPIRED = "expired"; export const CERT_EXPIRED = "expired";
export const CERT_ERROR = "error"; export const CERT_ERROR = "error";
export const CERT_UNKNOWN = "unknown"; export const CERT_UNKNOWN = "unknown";
export const CERT_MONITOR_AUTO = "auto";
export const CERT_MONITOR_REQUIRED = "required";
export const CERT_MONITOR_SKIPPED = "skipped";
export const CERT_MONITORING_VALUES = [
CERT_MONITOR_AUTO,
CERT_MONITOR_REQUIRED,
CERT_MONITOR_SKIPPED,
] as const;
+1
View File
@@ -11,6 +11,7 @@ export type {
LoginResponse, LoginResponse,
JwtClaims, JwtClaims,
SyncJob, SyncJob,
ServiceBinding,
ServiceBindingView, ServiceBindingView,
ServiceDomainBindingView, ServiceDomainBindingView,
Subdomain, Subdomain,
+90 -21
View File
@@ -1,5 +1,9 @@
import { z } from 'zod' import { z } from 'zod'
export const certMonitoringSchema = z.enum(['auto', 'required', 'skipped'])
export type CertMonitoring = z.infer<typeof certMonitoringSchema>
export const groupSchema = z.object({ export const groupSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
@@ -50,8 +54,10 @@ export const serviceDomainBindingSchema = z
zone_name: z.string(), zone_name: z.string(),
hostname: z.string(), hostname: z.string(),
fqdn: z.string(), fqdn: z.string(),
record_type: z.enum(['A', 'CNAME']).default('A'),
target_ips: z.array(z.string()).optional(), target_ips: z.array(z.string()).optional(),
target_ip: z.string().nullable().optional(), target_ip: z.string().nullable().optional(),
target_cname: z.string().nullable().optional(),
sync_status: z.string().nullable(), sync_status: z.string().nullable(),
}) })
.transform((binding) => ({ .transform((binding) => ({
@@ -62,6 +68,10 @@ export const serviceDomainBindingSchema = z
: binding.target_ip : binding.target_ip
? [binding.target_ip] ? [binding.target_ip]
: [], : [],
target_cname: binding.target_cname?.trim() || null,
record_type: binding.target_cname?.trim()
? 'CNAME'
: binding.record_type ?? 'A',
})) }))
export const serviceViewSchema = serviceSchema.extend({ export const serviceViewSchema = serviceSchema.extend({
@@ -86,6 +96,7 @@ export const domainSchema = z.object({
zone_name: z.string(), zone_name: z.string(),
cf_zone_id: z.string(), cf_zone_id: z.string(),
status: z.string(), status: z.string(),
cert_monitoring: certMonitoringSchema.default('auto'),
last_synced_at: z.string().nullable(), last_synced_at: z.string().nullable(),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
@@ -96,22 +107,33 @@ export const domainListItemSchema = domainSchema.extend({
service_count: z.number(), service_count: z.number(),
}) })
export const serviceBindingSchema = z.object({ export const serviceBindingSchema = z
id: z.number(), .object({
domain_id: z.number(), id: z.number(),
service_id: z.number(), domain_id: z.number(),
hostname: z.string(), service_id: z.number(),
dns_record_id: z.number().nullable(), hostname: z.string(),
zone_name: z.string(), dns_record_id: z.number().nullable(),
group_id: z.number().nullable(), zone_name: z.string(),
group_name: z.string().nullable(), group_id: z.number().nullable(),
service_name: z.string(), group_name: z.string().nullable(),
service_slug: z.string(), service_name: z.string(),
target_ip: z.string().nullable(), service_slug: z.string(),
sync_status: z.string().nullable(), target_ip: z.string().nullable(),
created_at: z.string(), target_ips: z.array(z.string()).optional(),
updated_at: z.string(), sync_status: z.string().nullable(),
}) created_at: z.string(),
updated_at: z.string(),
})
.transform((binding) => ({
...binding,
target_ips:
binding.target_ips && binding.target_ips.length > 0
? binding.target_ips
: binding.target_ip
? [binding.target_ip]
: [],
}))
export const dnsRecordSchema = z.object({ export const dnsRecordSchema = z.object({
id: z.number(), id: z.number(),
@@ -153,7 +175,6 @@ export type ServiceGroupView = z.infer<typeof serviceGroupViewSchema>
export type ServiceGroupsResponse = z.infer<typeof serviceGroupsResponseSchema> export type ServiceGroupsResponse = z.infer<typeof serviceGroupsResponseSchema>
export type Domain = z.infer<typeof domainSchema> export type Domain = z.infer<typeof domainSchema>
export type DomainListItem = z.infer<typeof domainListItemSchema> export type DomainListItem = z.infer<typeof domainListItemSchema>
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
export type DnsRecord = z.infer<typeof dnsRecordSchema> export type DnsRecord = z.infer<typeof dnsRecordSchema>
export type Certificate = z.infer<typeof certificateSchema> export type Certificate = z.infer<typeof certificateSchema>
@@ -169,10 +190,30 @@ const ipv4Schema = z
'Некорректный IPv4', 'Некорректный IPv4',
) )
const serviceDomainInputSchema = z.object({ const serviceDomainInputSchema = z
fqdn: z.string().min(1, 'Укажите FQDN'), .object({
target_ips: z.array(ipv4Schema).min(1, 'Выберите хотя бы один IP'), fqdn: z.string().min(1, 'Укажите FQDN'),
}) target_ips: z.array(ipv4Schema).optional(),
target_cname: z.string().min(1, 'Укажите CNAME-цель').optional(),
})
.superRefine((data, ctx) => {
const hasIps = (data.target_ips?.length ?? 0) > 0
const hasCname = Boolean(data.target_cname?.trim())
if (!hasIps && !hasCname) {
ctx.addIssue({
code: 'custom',
message: 'Укажите IP или CNAME-цель',
path: ['target_ips'],
})
}
if (hasIps && hasCname) {
ctx.addIssue({
code: 'custom',
message: 'Укажите либо IP, либо CNAME-цель',
path: ['target_cname'],
})
}
})
export const createServiceSchema = z.object({ export const createServiceSchema = z.object({
name: z.string().min(1, 'Укажите название'), name: z.string().min(1, 'Укажите название'),
@@ -220,12 +261,34 @@ export const subdomainSchema = z.object({
domain_id: z.number(), domain_id: z.number(),
name: z.string(), name: z.string(),
fqdn: z.string(), fqdn: z.string(),
enabled: z.boolean(),
cert_monitoring: certMonitoringSchema.default('auto'),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
}) })
export type SubdomainRecord = z.infer<typeof subdomainSchema> export type SubdomainRecord = z.infer<typeof subdomainSchema>
export const createSubdomainSchema = z.object({
name: z.string().min(1, 'Укажите имя поддомена'),
})
export const updateSubdomainSchema = z.object({
name: z.string().min(1, 'Укажите имя поддомена').optional(),
enabled: z.boolean().optional(),
cert_monitoring: certMonitoringSchema.optional(),
})
export const updateDomainSchema = z.object({
group_id: z.number().nullable().optional(),
status: z.string().optional(),
cert_monitoring: certMonitoringSchema.optional(),
})
export type CreateSubdomainInput = z.infer<typeof createSubdomainSchema>
export type UpdateSubdomainInput = z.infer<typeof updateSubdomainSchema>
export type UpdateDomainInput = z.infer<typeof updateDomainSchema>
export type CreateGroupInput = z.infer<typeof createGroupSchema> export type CreateGroupInput = z.infer<typeof createGroupSchema>
export type CreateServiceInput = z.infer<typeof createServiceSchema> export type CreateServiceInput = z.infer<typeof createServiceSchema>
export type CreateServiceWithConfigInput = z.infer<typeof createServiceWithConfigSchema> export type CreateServiceWithConfigInput = z.infer<typeof createServiceWithConfigSchema>
@@ -253,8 +316,14 @@ export const toggleEnabledSchema = z.object({
enabled: z.boolean(), enabled: z.boolean(),
}) })
export const reorderServicesSchema = z.object({
group_id: z.union([z.number(), z.null()]).optional().default(null),
service_ids: z.array(z.number().int().positive()).min(1),
})
export type CreateServiceGroupInput = z.infer<typeof createServiceGroupSchema> export type CreateServiceGroupInput = z.infer<typeof createServiceGroupSchema>
export type ToggleEnabledInput = z.infer<typeof toggleEnabledSchema> export type ToggleEnabledInput = z.infer<typeof toggleEnabledSchema>
export type ReorderServicesInput = z.infer<typeof reorderServicesSchema>
export type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchema> export type CreateServiceBindingInput = z.infer<typeof createServiceBindingSchema>
export type CreateDomainInput = z.infer<typeof createDomainSchema> export type CreateDomainInput = z.infer<typeof createDomainSchema>
export type LoginInput = z.infer<typeof loginSchema> export type LoginInput = z.infer<typeof loginSchema>
+23
View File
@@ -27,3 +27,26 @@ export function dnsNameToSubdomainLabel(
export function subdomainLabelToFqdn(label: string, zoneName: string): string { export function subdomainLabelToFqdn(label: string, zoneName: string): string {
return label === "@" ? zoneName : `${label}.${zoneName}`; return label === "@" ? zoneName : `${label}.${zoneName}`;
} }
/** Canonical DNS record name as returned by Cloudflare for a zone. */
export function normalizeDnsRecordName(
recordName: string,
zoneName: string,
): string {
const label = dnsNameToSubdomainLabel(recordName, zoneName);
if (label == null) {
return recordName.trim().replace(/\.+$/, "");
}
return subdomainLabelToFqdn(label, zoneName);
}
export function dnsRecordNamesMatch(
left: string,
right: string,
zoneName: string,
): boolean {
return (
normalizeDnsRecordName(left, zoneName).toLowerCase() ===
normalizeDnsRecordName(right, zoneName).toLowerCase()
);
}
+8
View File
@@ -33,6 +33,7 @@ export interface Service {
service_group_id: number | null; service_group_id: number | null;
subdomain: string; subdomain: string;
enabled: boolean; enabled: boolean;
sort_order: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -43,6 +44,7 @@ export interface Domain {
zone_name: string; zone_name: string;
cf_zone_id: string; cf_zone_id: string;
status: string; status: string;
cert_monitoring: string;
last_synced_at: string | null; last_synced_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -53,6 +55,8 @@ export interface Subdomain {
domain_id: number; domain_id: number;
name: string; name: string;
fqdn: string; fqdn: string;
enabled: boolean;
cert_monitoring: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -92,6 +96,7 @@ export interface ServiceBinding {
domain_id: number; domain_id: number;
service_id: number; service_id: number;
hostname: string; hostname: string;
cname_target: string | null;
dns_record_id: number | null; dns_record_id: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -109,6 +114,7 @@ export interface ServiceBindingView {
service_name: string; service_name: string;
service_slug: string; service_slug: string;
target_ip: string | null; target_ip: string | null;
target_ips: string[];
sync_status: string | null; sync_status: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -120,7 +126,9 @@ export interface ServiceDomainBindingView {
zone_name: string; zone_name: string;
hostname: string; hostname: string;
fqdn: string; fqdn: string;
record_type: "A" | "CNAME";
target_ips: string[]; target_ips: string[];
target_cname: string | null;
sync_status: string | null; sync_status: string | null;
} }
+10
View File
@@ -3,6 +3,7 @@ import {
CERT_OK, CERT_OK,
CERT_WARNING, CERT_WARNING,
} from "./constants.js"; } from "./constants.js";
import type { Service, ServiceGroup } from "./types.js";
const NAME_RE = const NAME_RE =
/^(@|\*|[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?(\.[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?)*)$/; /^(@|\*|[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?(\.[a-zA-Z0-9_]([a-zA-Z0-9_-]*[a-zA-Z0-9_])?)*)$/;
@@ -70,6 +71,15 @@ export function certStatusFromExpiry(daysLeft: number): string {
return CERT_OK; return CERT_OK;
} }
export function shouldMonitorService(
service: Pick<Service, "enabled" | "service_group_id">,
group?: Pick<ServiceGroup, "enabled"> | null,
): boolean {
if (!service.enabled) return false;
if (!service.service_group_id) return true;
return group?.enabled ?? false;
}
export function isValidIpv4(ip: string): boolean { export function isValidIpv4(ip: string): boolean {
const parts = ip.split("."); const parts = ip.split(".");
if (parts.length !== 4) return false; if (parts.length !== 4) return false;
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
dnsRecordNamesMatch,
dnsNameToSubdomainLabel,
normalizeDnsRecordName,
} from "../src/subdomain.js";
const ZONE = "rkns.top";
describe("normalizeDnsRecordName", () => {
it("normalizes short labels to FQDN", () => {
expect(normalizeDnsRecordName("de", ZONE)).toBe("de.rkns.top");
expect(normalizeDnsRecordName("mhome", ZONE)).toBe("mhome.rkns.top");
});
it("keeps FQDN unchanged", () => {
expect(normalizeDnsRecordName("ihome.rkns.top", ZONE)).toBe(
"ihome.rkns.top",
);
});
it("maps apex forms to zone name", () => {
expect(normalizeDnsRecordName("@", ZONE)).toBe("rkns.top");
expect(normalizeDnsRecordName("rkns.top", ZONE)).toBe("rkns.top");
});
});
describe("dnsRecordNamesMatch", () => {
it("matches short label and FQDN for the same host", () => {
expect(dnsRecordNamesMatch("de", "de.rkns.top", ZONE)).toBe(true);
expect(dnsRecordNamesMatch("mhome", "mhome.rkns.top", ZONE)).toBe(true);
});
it("does not match different hosts", () => {
expect(dnsRecordNamesMatch("de", "mhome.rkns.top", ZONE)).toBe(false);
});
});
describe("dnsNameToSubdomainLabel", () => {
it("extracts label from FQDN", () => {
expect(dnsNameToSubdomainLabel("de.rkns.top", ZONE)).toBe("de");
});
});
+72
View File
@@ -0,0 +1,72 @@
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { cn } from "@cfdm/ui/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+2
View File
@@ -14,6 +14,8 @@ const badgeVariants = cva(
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
success:
"bg-success/10 text-success focus-visible:ring-success/20 dark:bg-success/15 dark:focus-visible:ring-success/30 [a]:hover:bg-success/15",
outline: outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: ghost:
+27
View File
@@ -0,0 +1,27 @@
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@cfdm/ui/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+4
View File
@@ -22,6 +22,7 @@
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-success: var(--success);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
@@ -64,6 +65,7 @@
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--success: oklch(0.527 0.154 150.069);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
@@ -98,6 +100,7 @@
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--success: oklch(0.765 0.177 163.223);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
@@ -133,6 +136,7 @@
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--success: oklch(0.765 0.177 163.223);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
+3
View File
@@ -87,6 +87,9 @@ importers:
'@base-ui/react': '@base-ui/react':
specifier: ^1.5.0 specifier: ^1.5.0
version: 1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@cfdm/shared':
specifier: workspace:*
version: link:../../packages/shared
'@cfdm/ui': '@cfdm/ui':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/ui version: link:../../packages/ui