Enhance CDN Manager: Add Cloudflare API token support, update documentation, and introduce new routes for managing nodes, aliases, and topology. Improve UI components and status badges for better user experience.
quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 53s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 27s

This commit is contained in:
Denozordec
2026-09-04 12:49:51 +07:00
parent d480325357
commit 8700dc2957
47 changed files with 10550 additions and 69 deletions
+2790 -1
View File
File diff suppressed because it is too large Load Diff
+565 -7
View File
@@ -1,14 +1,126 @@
// src/schema.ts
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
var appSettings = sqliteTable("app_settings", {
id: text("id").primaryKey(),
show_quick_actions: integer("show_quick_actions", { mode: "boolean" }).notNull().default(true),
default_ttl: integer("default_ttl").notNull().default(300),
naming_template: text("naming_template").notNull().default("{loc}-{role}{nn}.{zone}"),
proxied_lock: integer("proxied_lock", { mode: "boolean" }).notNull().default(true),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
var zones = sqliteTable("zones", {
id: text("id").primaryKey(),
cf_zone_id: text("cf_zone_id"),
name: text("name").notNull().unique(),
role: text("role").notNull().default("routing"),
naming_template: text("naming_template").notNull().default("{loc}-{role}{nn}.{zone}"),
default_ttl: integer("default_ttl").notNull().default(300),
last_sync_at: text("last_sync_at"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
var locations = sqliteTable("locations", {
id: text("id").primaryKey(),
code: text("code").notNull().unique(),
name: text("name").notNull(),
country: text("country"),
sort_order: integer("sort_order").notNull().default(0),
created_at: text("created_at").notNull().default(sql`datetime('now')`)
});
var nodes = sqliteTable(
"nodes",
{
id: text("id").primaryKey(),
zone_id: text("zone_id").notNull().references(() => zones.id, { onDelete: "cascade" }),
location_id: text("location_id").notNull().references(() => locations.id),
hostname: text("hostname").notNull(),
role: text("role").notNull(),
index_num: integer("index_num").notNull().default(1),
provider_tag: text("provider_tag"),
notes: text("notes"),
sync_status: text("sync_status").notNull().default("pending"),
cf_a_record_id: text("cf_a_record_id"),
cf_aaaa_record_id: text("cf_aaaa_record_id"),
last_error: text("last_error"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
},
(t) => [uniqueIndex("nodes_zone_hostname").on(t.zone_id, t.hostname)]
);
var nodeAddresses = sqliteTable(
"node_addresses",
{
id: text("id").primaryKey(),
node_id: text("node_id").notNull().references(() => nodes.id, { onDelete: "cascade" }),
family: text("family").notNull(),
ip: text("ip").notNull()
},
(t) => [uniqueIndex("node_addresses_node_family").on(t.node_id, t.family)]
);
var aliases = sqliteTable(
"aliases",
{
id: text("id").primaryKey(),
zone_id: text("zone_id").notNull().references(() => zones.id, { onDelete: "cascade" }),
name: text("name").notNull(),
purpose: text("purpose").notNull().default("geo"),
mode: text("mode").notNull().default("primary"),
target_node_id: text("target_node_id").notNull().references(() => nodes.id, { onDelete: "restrict" }),
sync_status: text("sync_status").notNull().default("pending"),
cf_record_id: text("cf_record_id"),
last_error: text("last_error"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
},
(t) => [uniqueIndex("aliases_zone_name").on(t.zone_id, t.name)]
);
var syncJobs = sqliteTable("sync_jobs", {
id: text("id").primaryKey(),
zone_id: text("zone_id").notNull().references(() => zones.id, { onDelete: "cascade" }),
status: text("status").notNull().default("pending"),
diff_json: text("diff_json"),
error: text("error"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
finished_at: text("finished_at")
});
var syncEvents = sqliteTable("sync_events", {
id: text("id").primaryKey(),
job_id: text("job_id").notNull().references(() => syncJobs.id, { onDelete: "cascade" }),
kind: text("kind").notNull(),
record_name: text("record_name"),
record_type: text("record_type"),
detail: text("detail"),
created_at: text("created_at").notNull().default(sql`datetime('now')`)
});
var ignoredOrphans = sqliteTable(
"ignored_orphans",
{
id: text("id").primaryKey(),
zone_id: text("zone_id").notNull().references(() => zones.id, { onDelete: "cascade" }),
record_name: text("record_name").notNull(),
record_type: text("record_type").notNull(),
created_at: text("created_at").notNull().default(sql`datetime('now')`)
},
(t) => [
uniqueIndex("ignored_orphans_unique").on(
t.zone_id,
t.record_name,
t.record_type
)
]
);
var schema = {
appSettings
appSettings,
zones,
locations,
nodes,
nodeAddresses,
aliases,
syncJobs,
syncEvents,
ignoredOrphans
};
// src/client.ts
@@ -50,8 +162,8 @@ function runMigrations(sqlite) {
for (const file of files) {
const applied = sqlite.prepare("SELECT 1 FROM _migrations WHERE name = ?").get(file);
if (applied) continue;
const sql2 = readFileSync(join(migrationsDir, file), "utf-8");
sqlite.exec(sql2);
const sql3 = readFileSync(join(migrationsDir, file), "utf-8");
sqlite.exec(sql3);
sqlite.prepare("INSERT INTO _migrations (name) VALUES (?)").run(file);
}
}
@@ -79,7 +191,10 @@ var SETTINGS_ID = "settings-main";
function toDto(row) {
return {
id: row.id,
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions)
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
defaultTtl: row.default_ttl ?? 300,
namingTemplate: row.naming_template ?? "{loc}-{role}{nn}.{zone}",
proxiedLock: row.proxied_lock == null ? true : Boolean(row.proxied_lock)
};
}
function ensureRow(db) {
@@ -87,7 +202,10 @@ function ensureRow(db) {
if (existing) return existing;
db.insert(appSettings).values({
id: SETTINGS_ID,
show_quick_actions: true
show_quick_actions: true,
default_ttl: 300,
naming_template: "{loc}-{role}{nn}.{zone}",
proxied_lock: true
}).run();
return db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get();
}
@@ -102,19 +220,459 @@ function updateAppSettings(db, patch) {
if (patch.showQuickActions !== void 0) {
updates.show_quick_actions = patch.showQuickActions;
}
if (patch.defaultTtl !== void 0) {
updates.default_ttl = patch.defaultTtl;
}
if (patch.namingTemplate !== void 0) {
updates.naming_template = patch.namingTemplate;
}
if (patch.proxiedLock !== void 0) {
updates.proxied_lock = patch.proxiedLock;
}
db.update(appSettings).set(updates).where(eq(appSettings.id, SETTINGS_ID)).run();
return getAppSettings(db);
}
// src/fleet-repo.ts
import { randomUUID } from "crypto";
import { and, asc, count, eq as eq2, sql as sql2 } from "drizzle-orm";
function now() {
return (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
}
function id(prefix) {
return `${prefix}-${randomUUID().slice(0, 8)}`;
}
function mapLocation(row) {
return {
id: row.id,
code: row.code,
name: row.name,
country: row.country,
sortOrder: row.sort_order
};
}
function mapZone(row) {
return {
id: row.id,
cfZoneId: row.cf_zone_id,
name: row.name,
role: row.role,
namingTemplate: row.naming_template,
defaultTtl: row.default_ttl,
lastSyncAt: row.last_sync_at,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function listLocations(db) {
return db.select().from(locations).orderBy(asc(locations.sort_order), asc(locations.code)).all().map(mapLocation);
}
function getLocation(db, locationId) {
const row = db.select().from(locations).where(eq2(locations.id, locationId)).get();
if (!row) throw new NotFoundError(`location ${locationId}`);
return mapLocation(row);
}
function listZones(db) {
return db.select().from(zones).orderBy(asc(zones.name)).all().map(mapZone);
}
function getZone(db, zoneId) {
const row = db.select().from(zones).where(eq2(zones.id, zoneId)).get();
if (!row) throw new NotFoundError(`zone ${zoneId}`);
return mapZone(row);
}
function createZone(db, input) {
const existing = db.select().from(zones).where(eq2(zones.name, input.name)).get();
if (existing) throw new ConflictError(`zone ${input.name} already exists`);
const zoneId = id("zone");
const ts = now();
db.insert(zones).values({
id: zoneId,
name: input.name,
cf_zone_id: input.cfZoneId ?? null,
role: input.role ?? "routing",
naming_template: input.namingTemplate ?? "{loc}-{role}{nn}.{zone}",
default_ttl: input.defaultTtl ?? 300,
created_at: ts,
updated_at: ts
}).run();
return getZone(db, zoneId);
}
function updateZone(db, zoneId, patch) {
getZone(db, zoneId);
const updates = {
updated_at: now()
};
if (patch.name !== void 0) updates.name = patch.name;
if (patch.cfZoneId !== void 0) updates.cf_zone_id = patch.cfZoneId;
if (patch.role !== void 0) updates.role = patch.role;
if (patch.namingTemplate !== void 0)
updates.naming_template = patch.namingTemplate;
if (patch.defaultTtl !== void 0) updates.default_ttl = patch.defaultTtl;
if (patch.lastSyncAt !== void 0) updates.last_sync_at = patch.lastSyncAt;
db.update(zones).set(updates).where(eq2(zones.id, zoneId)).run();
return getZone(db, zoneId);
}
function deleteZone(db, zoneId) {
getZone(db, zoneId);
db.delete(zones).where(eq2(zones.id, zoneId)).run();
}
function loadAddresses(db, nodeId) {
return db.select().from(nodeAddresses).where(eq2(nodeAddresses.node_id, nodeId)).all().map((r) => ({
id: r.id,
family: r.family,
ip: r.ip
}));
}
function mapNode(db, row, loc) {
const aliasCount = db.select({ c: count() }).from(aliases).where(eq2(aliases.target_node_id, row.id)).get()?.c;
return {
id: row.id,
zoneId: row.zone_id,
locationId: row.location_id,
locationCode: loc?.code,
locationName: loc?.name,
hostname: row.hostname,
role: row.role,
indexNum: row.index_num,
providerTag: row.provider_tag,
notes: row.notes,
syncStatus: row.sync_status,
cfARecordId: row.cf_a_record_id,
cfAaaaRecordId: row.cf_aaaa_record_id,
lastError: row.last_error,
addresses: loadAddresses(db, row.id),
aliasCount: Number(aliasCount ?? 0),
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function listNodes(db, filter = {}) {
let rows = db.select().from(nodes).all();
if (filter.zoneId) rows = rows.filter((r) => r.zone_id === filter.zoneId);
if (filter.locationId)
rows = rows.filter((r) => r.location_id === filter.locationId);
if (filter.role) rows = rows.filter((r) => r.role === filter.role);
if (filter.syncStatus)
rows = rows.filter((r) => r.sync_status === filter.syncStatus);
if (filter.q) {
const q = filter.q.toLowerCase();
rows = rows.filter(
(r) => r.hostname.toLowerCase().includes(q) || (r.provider_tag ?? "").toLowerCase().includes(q)
);
}
const locMap = new Map(
listLocations(db).map((l) => [l.id, { code: l.code, name: l.name }])
);
return rows.map((r) => mapNode(db, r, locMap.get(r.location_id))).sort((a, b) => a.hostname.localeCompare(b.hostname));
}
function getNode(db, nodeId) {
const row = db.select().from(nodes).where(eq2(nodes.id, nodeId)).get();
if (!row) throw new NotFoundError(`node ${nodeId}`);
const loc = getLocation(db, row.location_id);
return mapNode(db, row, { code: loc.code, name: loc.name });
}
function setAddresses(db, nodeId, ipv4, ipv6) {
db.delete(nodeAddresses).where(eq2(nodeAddresses.node_id, nodeId)).run();
db.insert(nodeAddresses).values({ id: id("addr"), node_id: nodeId, family: "v4", ip: ipv4 }).run();
if (ipv6) {
db.insert(nodeAddresses).values({ id: id("addr"), node_id: nodeId, family: "v6", ip: ipv6 }).run();
}
}
function createNode(db, input) {
getZone(db, input.zoneId);
getLocation(db, input.locationId);
const dup = db.select().from(nodes).where(
and(eq2(nodes.zone_id, input.zoneId), eq2(nodes.hostname, input.hostname))
).get();
if (dup) throw new ConflictError(`node ${input.hostname} already exists`);
const nodeId = id("node");
const ts = now();
db.insert(nodes).values({
id: nodeId,
zone_id: input.zoneId,
location_id: input.locationId,
hostname: input.hostname,
role: input.role,
index_num: input.indexNum,
provider_tag: input.providerTag ?? null,
notes: input.notes ?? null,
sync_status: "pending",
created_at: ts,
updated_at: ts
}).run();
setAddresses(db, nodeId, input.ipv4, input.ipv6);
return getNode(db, nodeId);
}
function updateNode(db, nodeId, patch) {
const current = getNode(db, nodeId);
if (patch.locationId) getLocation(db, patch.locationId);
const updates = {
updated_at: now()
};
if (patch.locationId !== void 0) updates.location_id = patch.locationId;
if (patch.hostname !== void 0) updates.hostname = patch.hostname;
if (patch.role !== void 0) updates.role = patch.role;
if (patch.indexNum !== void 0) updates.index_num = patch.indexNum;
if (patch.providerTag !== void 0) updates.provider_tag = patch.providerTag;
if (patch.notes !== void 0) updates.notes = patch.notes;
if (patch.syncStatus !== void 0) updates.sync_status = patch.syncStatus;
if (patch.cfARecordId !== void 0)
updates.cf_a_record_id = patch.cfARecordId;
if (patch.cfAaaaRecordId !== void 0)
updates.cf_aaaa_record_id = patch.cfAaaaRecordId;
if (patch.lastError !== void 0) updates.last_error = patch.lastError;
db.update(nodes).set(updates).where(eq2(nodes.id, nodeId)).run();
if (patch.ipv4 !== void 0) {
const v6 = patch.ipv6 !== void 0 ? patch.ipv6 : current.addresses.find((a) => a.family === "v6")?.ip ?? null;
setAddresses(db, nodeId, patch.ipv4, v6);
} else if (patch.ipv6 !== void 0) {
const v4 = current.addresses.find((a) => a.family === "v4")?.ip;
if (!v4) throw new ConflictError("node has no IPv4");
setAddresses(db, nodeId, v4, patch.ipv6);
}
return getNode(db, nodeId);
}
function deleteNode(db, nodeId) {
getNode(db, nodeId);
const linked = db.select({ c: count() }).from(aliases).where(eq2(aliases.target_node_id, nodeId)).get()?.c;
if (Number(linked ?? 0) > 0) {
throw new ConflictError("node has aliases; retarget or delete them first");
}
db.delete(nodes).where(eq2(nodes.id, nodeId)).run();
}
function mapAlias(row, targetHostname) {
return {
id: row.id,
zoneId: row.zone_id,
name: row.name,
purpose: row.purpose,
mode: row.mode,
targetNodeId: row.target_node_id,
targetHostname,
syncStatus: row.sync_status,
cfRecordId: row.cf_record_id,
lastError: row.last_error,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function listAliases(db, filter = {}) {
let rows = db.select().from(aliases).all();
if (filter.zoneId) rows = rows.filter((r) => r.zone_id === filter.zoneId);
if (filter.purpose) rows = rows.filter((r) => r.purpose === filter.purpose);
if (filter.syncStatus)
rows = rows.filter((r) => r.sync_status === filter.syncStatus);
if (filter.q) {
const q = filter.q.toLowerCase();
rows = rows.filter((r) => r.name.toLowerCase().includes(q));
}
const nodeHost = new Map(
db.select().from(nodes).all().map((n) => [n.id, n.hostname])
);
return rows.map((r) => mapAlias(r, nodeHost.get(r.target_node_id))).sort((a, b) => a.name.localeCompare(b.name));
}
function getAlias(db, aliasId) {
const row = db.select().from(aliases).where(eq2(aliases.id, aliasId)).get();
if (!row) throw new NotFoundError(`alias ${aliasId}`);
const target = db.select().from(nodes).where(eq2(nodes.id, row.target_node_id)).get();
return mapAlias(row, target?.hostname);
}
function createAlias(db, input) {
getZone(db, input.zoneId);
getNode(db, input.targetNodeId);
const dup = db.select().from(aliases).where(and(eq2(aliases.zone_id, input.zoneId), eq2(aliases.name, input.name))).get();
if (dup) throw new ConflictError(`alias ${input.name} already exists`);
const aliasId = id("alias");
const ts = now();
db.insert(aliases).values({
id: aliasId,
zone_id: input.zoneId,
name: input.name,
purpose: input.purpose,
mode: input.mode,
target_node_id: input.targetNodeId,
sync_status: "pending",
created_at: ts,
updated_at: ts
}).run();
return getAlias(db, aliasId);
}
function updateAlias(db, aliasId, patch) {
getAlias(db, aliasId);
if (patch.targetNodeId) getNode(db, patch.targetNodeId);
const updates = {
updated_at: now()
};
if (patch.name !== void 0) updates.name = patch.name;
if (patch.purpose !== void 0) updates.purpose = patch.purpose;
if (patch.mode !== void 0) updates.mode = patch.mode;
if (patch.targetNodeId !== void 0)
updates.target_node_id = patch.targetNodeId;
if (patch.syncStatus !== void 0) updates.sync_status = patch.syncStatus;
if (patch.cfRecordId !== void 0) updates.cf_record_id = patch.cfRecordId;
if (patch.lastError !== void 0) updates.last_error = patch.lastError;
db.update(aliases).set(updates).where(eq2(aliases.id, aliasId)).run();
return getAlias(db, aliasId);
}
function deleteAlias(db, aliasId) {
getAlias(db, aliasId);
db.delete(aliases).where(eq2(aliases.id, aliasId)).run();
}
function createSyncJob(db, zoneId) {
getZone(db, zoneId);
const jobId = id("sync");
db.insert(syncJobs).values({
id: jobId,
zone_id: zoneId,
status: "pending",
created_at: now()
}).run();
return jobId;
}
function updateSyncJob(db, jobId, patch) {
const updates = {};
if (patch.status !== void 0) updates.status = patch.status;
if (patch.diffJson !== void 0) updates.diff_json = patch.diffJson;
if (patch.error !== void 0) updates.error = patch.error;
if (patch.finishedAt !== void 0) updates.finished_at = patch.finishedAt;
db.update(syncJobs).set(updates).where(eq2(syncJobs.id, jobId)).run();
}
function getSyncJob(db, jobId) {
const row = db.select().from(syncJobs).where(eq2(syncJobs.id, jobId)).get();
if (!row) throw new NotFoundError(`sync job ${jobId}`);
return {
id: row.id,
zoneId: row.zone_id,
status: row.status,
diff: row.diff_json ? JSON.parse(row.diff_json) : [],
error: row.error,
createdAt: row.created_at,
finishedAt: row.finished_at
};
}
function listSyncJobs(db, zoneId) {
return db.select().from(syncJobs).where(eq2(syncJobs.zone_id, zoneId)).orderBy(sql2`${syncJobs.created_at} DESC`).all().slice(0, 20).map((row) => ({
id: row.id,
zoneId: row.zone_id,
status: row.status,
diff: row.diff_json ? JSON.parse(row.diff_json) : [],
error: row.error,
createdAt: row.created_at,
finishedAt: row.finished_at
}));
}
function addSyncEvent(db, jobId, event) {
db.insert(syncEvents).values({
id: id("evt"),
job_id: jobId,
kind: event.kind,
record_name: event.recordName ?? null,
record_type: event.recordType ?? null,
detail: event.detail ?? null,
created_at: now()
}).run();
}
function listIgnoredOrphans(db, zoneId) {
return db.select().from(ignoredOrphans).where(eq2(ignoredOrphans.zone_id, zoneId)).all().map((r) => ({
id: r.id,
recordName: r.record_name,
recordType: r.record_type
}));
}
function ignoreOrphan(db, zoneId, recordName, recordType) {
getZone(db, zoneId);
db.insert(ignoredOrphans).values({
id: id("ign"),
zone_id: zoneId,
record_name: recordName,
record_type: recordType,
created_at: now()
}).onConflictDoNothing().run();
}
function unignoreOrphan(db, zoneId, recordName, recordType) {
db.delete(ignoredOrphans).where(
and(
eq2(ignoredOrphans.zone_id, zoneId),
eq2(ignoredOrphans.record_name, recordName),
eq2(ignoredOrphans.record_type, recordType)
)
).run();
}
function dashboardCounts(db) {
const nodeRows = db.select().from(nodes).all();
const aliasRows = db.select().from(aliases).all();
const zoneRows = db.select().from(zones).all();
let syncOk = 0;
let drift = 0;
let nodesWithoutIp = 0;
for (const n of nodeRows) {
if (n.sync_status === "ok") syncOk += 1;
if (n.sync_status === "drift" || n.sync_status === "missing") drift += 1;
const addrs = loadAddresses(db, n.id);
if (!addrs.some((a) => a.family === "v4")) nodesWithoutIp += 1;
}
let brokenAliases = 0;
for (const a of aliasRows) {
if (a.sync_status === "error" || a.sync_status === "missing")
brokenAliases += 1;
if (a.sync_status === "drift") drift += 1;
if (a.sync_status === "ok") syncOk += 1;
}
const lastSyncAt = zoneRows.map((z) => z.last_sync_at).filter(Boolean).sort().at(-1) ?? null;
return {
nodes: nodeRows.length,
aliases: aliasRows.length,
syncOk,
drift,
nodesWithoutIp,
brokenAliases,
lastSyncAt
};
}
export {
ConflictError,
NotFoundError,
addSyncEvent,
aliases,
appSettings,
createAlias,
createDb,
createMemoryDb,
createNode,
createSyncJob,
createZone,
dashboardCounts,
deleteAlias,
deleteNode,
deleteZone,
getAlias,
getAppSettings,
getLocation,
getNode,
getSyncJob,
getZone,
healthCheck,
ignoreOrphan,
ignoredOrphans,
listAliases,
listIgnoredOrphans,
listLocations,
listNodes,
listSyncJobs,
listZones,
locations,
nodeAddresses,
nodes,
resolveDatabasePath,
runMigrations,
schema,
updateAppSettings
syncEvents,
syncJobs,
unignoreOrphan,
updateAlias,
updateAppSettings,
updateNode,
updateSyncJob,
updateZone,
zones
};
+117
View File
@@ -0,0 +1,117 @@
-- Fleet DNS: zones, locations, nodes, aliases, sync
CREATE TABLE IF NOT EXISTS zones (
id TEXT PRIMARY KEY NOT NULL,
cf_zone_id TEXT,
name TEXT NOT NULL UNIQUE,
role TEXT NOT NULL DEFAULT 'routing',
naming_template TEXT NOT NULL DEFAULT '{loc}-{role}{nn}.{zone}',
default_ttl INTEGER NOT NULL DEFAULT 300,
last_sync_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS locations (
id TEXT PRIMARY KEY NOT NULL,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
country TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY NOT NULL,
zone_id TEXT NOT NULL REFERENCES zones(id) ON DELETE CASCADE,
location_id TEXT NOT NULL REFERENCES locations(id),
hostname TEXT NOT NULL,
role TEXT NOT NULL,
index_num INTEGER NOT NULL DEFAULT 1,
provider_tag TEXT,
notes TEXT,
sync_status TEXT NOT NULL DEFAULT 'pending',
cf_a_record_id TEXT,
cf_aaaa_record_id TEXT,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (zone_id, hostname)
);
CREATE INDEX IF NOT EXISTS idx_nodes_zone ON nodes(zone_id);
CREATE INDEX IF NOT EXISTS idx_nodes_location ON nodes(location_id);
CREATE INDEX IF NOT EXISTS idx_nodes_sync ON nodes(sync_status);
CREATE TABLE IF NOT EXISTS node_addresses (
id TEXT PRIMARY KEY NOT NULL,
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
family TEXT NOT NULL,
ip TEXT NOT NULL,
UNIQUE (node_id, family)
);
CREATE TABLE IF NOT EXISTS aliases (
id TEXT PRIMARY KEY NOT NULL,
zone_id TEXT NOT NULL REFERENCES zones(id) ON DELETE CASCADE,
name TEXT NOT NULL,
purpose TEXT NOT NULL DEFAULT 'geo',
mode TEXT NOT NULL DEFAULT 'primary',
target_node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE RESTRICT,
sync_status TEXT NOT NULL DEFAULT 'pending',
cf_record_id TEXT,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (zone_id, name)
);
CREATE INDEX IF NOT EXISTS idx_aliases_zone ON aliases(zone_id);
CREATE INDEX IF NOT EXISTS idx_aliases_target ON aliases(target_node_id);
CREATE INDEX IF NOT EXISTS idx_aliases_sync ON aliases(sync_status);
CREATE TABLE IF NOT EXISTS sync_jobs (
id TEXT PRIMARY KEY NOT NULL,
zone_id TEXT NOT NULL REFERENCES zones(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
diff_json TEXT,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_sync_jobs_zone ON sync_jobs(zone_id);
CREATE TABLE IF NOT EXISTS sync_events (
id TEXT PRIMARY KEY NOT NULL,
job_id TEXT NOT NULL REFERENCES sync_jobs(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
record_name TEXT,
record_type TEXT,
detail TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_sync_events_job ON sync_events(job_id);
CREATE TABLE IF NOT EXISTS ignored_orphans (
id TEXT PRIMARY KEY NOT NULL,
zone_id TEXT NOT NULL REFERENCES zones(id) ON DELETE CASCADE,
record_name TEXT NOT NULL,
record_type TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (zone_id, record_name, record_type)
);
-- Seed locations (IATA / routing codes from naming doc)
INSERT OR IGNORE INTO locations (id, code, name, country, sort_order) VALUES
('loc-msk', 'msk', 'Москва', 'RU', 10),
('loc-fra', 'fra', 'Франкфурт', 'DE', 20),
('loc-ams', 'ams', 'Амстердам', 'NL', 30),
('loc-hel', 'hel', 'Хельсинки', 'FI', 40),
('loc-par', 'par', 'Париж', 'FR', 50);
-- Extend settings defaults for naming / TTL
ALTER TABLE app_settings ADD COLUMN default_ttl INTEGER NOT NULL DEFAULT 300;
ALTER TABLE app_settings ADD COLUMN naming_template TEXT NOT NULL DEFAULT '{loc}-{role}{nn}.{zone}';
ALTER TABLE app_settings ADD COLUMN proxied_lock INTEGER NOT NULL DEFAULT 1;
+734
View File
@@ -0,0 +1,734 @@
import { randomUUID } from "node:crypto";
import { and, asc, count, eq, sql } from "drizzle-orm";
import type { Db } from "./client.js";
import { NotFoundError, ConflictError } from "./errors.js";
import {
aliases,
ignoredOrphans,
locations,
nodeAddresses,
nodes,
syncEvents,
syncJobs,
zones,
} from "./schema.js";
function now() {
return new Date().toISOString().replace("T", " ").slice(0, 19);
}
function id(prefix: string) {
return `${prefix}-${randomUUID().slice(0, 8)}`;
}
export type LocationRow = {
id: string;
code: string;
name: string;
country: string | null;
sortOrder: number;
};
export type ZoneRow = {
id: string;
cfZoneId: string | null;
name: string;
role: string;
namingTemplate: string;
defaultTtl: number;
lastSyncAt: string | null;
createdAt: string;
updatedAt: string;
};
export type NodeAddressRow = {
id: string;
family: "v4" | "v6";
ip: string;
};
export type NodeRow = {
id: string;
zoneId: string;
locationId: string;
locationCode?: string;
locationName?: string;
hostname: string;
role: string;
indexNum: number;
providerTag: string | null;
notes: string | null;
syncStatus: string;
cfARecordId: string | null;
cfAaaaRecordId: string | null;
lastError: string | null;
addresses: NodeAddressRow[];
aliasCount?: number;
createdAt: string;
updatedAt: string;
};
export type AliasRow = {
id: string;
zoneId: string;
name: string;
purpose: string;
mode: string;
targetNodeId: string;
targetHostname?: string;
syncStatus: string;
cfRecordId: string | null;
lastError: string | null;
createdAt: string;
updatedAt: string;
};
function mapLocation(row: typeof locations.$inferSelect): LocationRow {
return {
id: row.id,
code: row.code,
name: row.name,
country: row.country,
sortOrder: row.sort_order,
};
}
function mapZone(row: typeof zones.$inferSelect): ZoneRow {
return {
id: row.id,
cfZoneId: row.cf_zone_id,
name: row.name,
role: row.role,
namingTemplate: row.naming_template,
defaultTtl: row.default_ttl,
lastSyncAt: row.last_sync_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function listLocations(db: Db): LocationRow[] {
return db
.select()
.from(locations)
.orderBy(asc(locations.sort_order), asc(locations.code))
.all()
.map(mapLocation);
}
export function getLocation(db: Db, locationId: string): LocationRow {
const row = db
.select()
.from(locations)
.where(eq(locations.id, locationId))
.get();
if (!row) throw new NotFoundError(`location ${locationId}`);
return mapLocation(row);
}
export function listZones(db: Db): ZoneRow[] {
return db
.select()
.from(zones)
.orderBy(asc(zones.name))
.all()
.map(mapZone);
}
export function getZone(db: Db, zoneId: string): ZoneRow {
const row = db.select().from(zones).where(eq(zones.id, zoneId)).get();
if (!row) throw new NotFoundError(`zone ${zoneId}`);
return mapZone(row);
}
export function createZone(
db: Db,
input: {
name: string;
cfZoneId?: string | null;
role?: string;
namingTemplate?: string;
defaultTtl?: number;
},
): ZoneRow {
const existing = db
.select()
.from(zones)
.where(eq(zones.name, input.name))
.get();
if (existing) throw new ConflictError(`zone ${input.name} already exists`);
const zoneId = id("zone");
const ts = now();
db.insert(zones)
.values({
id: zoneId,
name: input.name,
cf_zone_id: input.cfZoneId ?? null,
role: input.role ?? "routing",
naming_template: input.namingTemplate ?? "{loc}-{role}{nn}.{zone}",
default_ttl: input.defaultTtl ?? 300,
created_at: ts,
updated_at: ts,
})
.run();
return getZone(db, zoneId);
}
export function updateZone(
db: Db,
zoneId: string,
patch: {
name?: string;
cfZoneId?: string | null;
role?: string;
namingTemplate?: string;
defaultTtl?: number;
lastSyncAt?: string | null;
},
): ZoneRow {
getZone(db, zoneId);
const updates: Partial<typeof zones.$inferInsert> = {
updated_at: now(),
};
if (patch.name !== undefined) updates.name = patch.name;
if (patch.cfZoneId !== undefined) updates.cf_zone_id = patch.cfZoneId;
if (patch.role !== undefined) updates.role = patch.role;
if (patch.namingTemplate !== undefined)
updates.naming_template = patch.namingTemplate;
if (patch.defaultTtl !== undefined) updates.default_ttl = patch.defaultTtl;
if (patch.lastSyncAt !== undefined) updates.last_sync_at = patch.lastSyncAt;
db.update(zones).set(updates).where(eq(zones.id, zoneId)).run();
return getZone(db, zoneId);
}
export function deleteZone(db: Db, zoneId: string): void {
getZone(db, zoneId);
db.delete(zones).where(eq(zones.id, zoneId)).run();
}
function loadAddresses(db: Db, nodeId: string): NodeAddressRow[] {
return db
.select()
.from(nodeAddresses)
.where(eq(nodeAddresses.node_id, nodeId))
.all()
.map((r) => ({
id: r.id,
family: r.family as "v4" | "v6",
ip: r.ip,
}));
}
function mapNode(
db: Db,
row: typeof nodes.$inferSelect,
loc?: { code: string; name: string },
): NodeRow {
const aliasCount = db
.select({ c: count() })
.from(aliases)
.where(eq(aliases.target_node_id, row.id))
.get()?.c;
return {
id: row.id,
zoneId: row.zone_id,
locationId: row.location_id,
locationCode: loc?.code,
locationName: loc?.name,
hostname: row.hostname,
role: row.role,
indexNum: row.index_num,
providerTag: row.provider_tag,
notes: row.notes,
syncStatus: row.sync_status,
cfARecordId: row.cf_a_record_id,
cfAaaaRecordId: row.cf_aaaa_record_id,
lastError: row.last_error,
addresses: loadAddresses(db, row.id),
aliasCount: Number(aliasCount ?? 0),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function listNodes(
db: Db,
filter: {
zoneId?: string;
locationId?: string;
role?: string;
syncStatus?: string;
q?: string;
} = {},
): NodeRow[] {
let rows = db.select().from(nodes).all();
if (filter.zoneId) rows = rows.filter((r) => r.zone_id === filter.zoneId);
if (filter.locationId)
rows = rows.filter((r) => r.location_id === filter.locationId);
if (filter.role) rows = rows.filter((r) => r.role === filter.role);
if (filter.syncStatus)
rows = rows.filter((r) => r.sync_status === filter.syncStatus);
if (filter.q) {
const q = filter.q.toLowerCase();
rows = rows.filter(
(r) =>
r.hostname.toLowerCase().includes(q) ||
(r.provider_tag ?? "").toLowerCase().includes(q),
);
}
const locMap = new Map(
listLocations(db).map((l) => [l.id, { code: l.code, name: l.name }]),
);
return rows
.map((r) => mapNode(db, r, locMap.get(r.location_id)))
.sort((a, b) => a.hostname.localeCompare(b.hostname));
}
export function getNode(db: Db, nodeId: string): NodeRow {
const row = db.select().from(nodes).where(eq(nodes.id, nodeId)).get();
if (!row) throw new NotFoundError(`node ${nodeId}`);
const loc = getLocation(db, row.location_id);
return mapNode(db, row, { code: loc.code, name: loc.name });
}
function setAddresses(
db: Db,
nodeId: string,
ipv4: string,
ipv6?: string | null,
) {
db.delete(nodeAddresses).where(eq(nodeAddresses.node_id, nodeId)).run();
db.insert(nodeAddresses)
.values({ id: id("addr"), node_id: nodeId, family: "v4", ip: ipv4 })
.run();
if (ipv6) {
db.insert(nodeAddresses)
.values({ id: id("addr"), node_id: nodeId, family: "v6", ip: ipv6 })
.run();
}
}
export function createNode(
db: Db,
input: {
zoneId: string;
locationId: string;
hostname: string;
role: string;
indexNum: number;
providerTag?: string | null;
notes?: string | null;
ipv4: string;
ipv6?: string | null;
},
): NodeRow {
getZone(db, input.zoneId);
getLocation(db, input.locationId);
const dup = db
.select()
.from(nodes)
.where(
and(eq(nodes.zone_id, input.zoneId), eq(nodes.hostname, input.hostname)),
)
.get();
if (dup) throw new ConflictError(`node ${input.hostname} already exists`);
const nodeId = id("node");
const ts = now();
db.insert(nodes)
.values({
id: nodeId,
zone_id: input.zoneId,
location_id: input.locationId,
hostname: input.hostname,
role: input.role,
index_num: input.indexNum,
provider_tag: input.providerTag ?? null,
notes: input.notes ?? null,
sync_status: "pending",
created_at: ts,
updated_at: ts,
})
.run();
setAddresses(db, nodeId, input.ipv4, input.ipv6);
return getNode(db, nodeId);
}
export function updateNode(
db: Db,
nodeId: string,
patch: {
locationId?: string;
hostname?: string;
role?: string;
indexNum?: number;
providerTag?: string | null;
notes?: string | null;
ipv4?: string;
ipv6?: string | null;
syncStatus?: string;
cfARecordId?: string | null;
cfAaaaRecordId?: string | null;
lastError?: string | null;
},
): NodeRow {
const current = getNode(db, nodeId);
if (patch.locationId) getLocation(db, patch.locationId);
const updates: Partial<typeof nodes.$inferInsert> = {
updated_at: now(),
};
if (patch.locationId !== undefined) updates.location_id = patch.locationId;
if (patch.hostname !== undefined) updates.hostname = patch.hostname;
if (patch.role !== undefined) updates.role = patch.role;
if (patch.indexNum !== undefined) updates.index_num = patch.indexNum;
if (patch.providerTag !== undefined) updates.provider_tag = patch.providerTag;
if (patch.notes !== undefined) updates.notes = patch.notes;
if (patch.syncStatus !== undefined) updates.sync_status = patch.syncStatus;
if (patch.cfARecordId !== undefined)
updates.cf_a_record_id = patch.cfARecordId;
if (patch.cfAaaaRecordId !== undefined)
updates.cf_aaaa_record_id = patch.cfAaaaRecordId;
if (patch.lastError !== undefined) updates.last_error = patch.lastError;
db.update(nodes).set(updates).where(eq(nodes.id, nodeId)).run();
if (patch.ipv4 !== undefined) {
const v6 =
patch.ipv6 !== undefined
? patch.ipv6
: (current.addresses.find((a) => a.family === "v6")?.ip ?? null);
setAddresses(db, nodeId, patch.ipv4, v6);
} else if (patch.ipv6 !== undefined) {
const v4 = current.addresses.find((a) => a.family === "v4")?.ip;
if (!v4) throw new ConflictError("node has no IPv4");
setAddresses(db, nodeId, v4, patch.ipv6);
}
return getNode(db, nodeId);
}
export function deleteNode(db: Db, nodeId: string): void {
getNode(db, nodeId);
const linked = db
.select({ c: count() })
.from(aliases)
.where(eq(aliases.target_node_id, nodeId))
.get()?.c;
if (Number(linked ?? 0) > 0) {
throw new ConflictError("node has aliases; retarget or delete them first");
}
db.delete(nodes).where(eq(nodes.id, nodeId)).run();
}
function mapAlias(
row: typeof aliases.$inferSelect,
targetHostname?: string,
): AliasRow {
return {
id: row.id,
zoneId: row.zone_id,
name: row.name,
purpose: row.purpose,
mode: row.mode,
targetNodeId: row.target_node_id,
targetHostname,
syncStatus: row.sync_status,
cfRecordId: row.cf_record_id,
lastError: row.last_error,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function listAliases(
db: Db,
filter: {
zoneId?: string;
purpose?: string;
syncStatus?: string;
q?: string;
} = {},
): AliasRow[] {
let rows = db.select().from(aliases).all();
if (filter.zoneId) rows = rows.filter((r) => r.zone_id === filter.zoneId);
if (filter.purpose) rows = rows.filter((r) => r.purpose === filter.purpose);
if (filter.syncStatus)
rows = rows.filter((r) => r.sync_status === filter.syncStatus);
if (filter.q) {
const q = filter.q.toLowerCase();
rows = rows.filter((r) => r.name.toLowerCase().includes(q));
}
const nodeHost = new Map(
db
.select()
.from(nodes)
.all()
.map((n) => [n.id, n.hostname]),
);
return rows
.map((r) => mapAlias(r, nodeHost.get(r.target_node_id)))
.sort((a, b) => a.name.localeCompare(b.name));
}
export function getAlias(db: Db, aliasId: string): AliasRow {
const row = db.select().from(aliases).where(eq(aliases.id, aliasId)).get();
if (!row) throw new NotFoundError(`alias ${aliasId}`);
const target = db
.select()
.from(nodes)
.where(eq(nodes.id, row.target_node_id))
.get();
return mapAlias(row, target?.hostname);
}
export function createAlias(
db: Db,
input: {
zoneId: string;
name: string;
purpose: string;
mode: string;
targetNodeId: string;
},
): AliasRow {
getZone(db, input.zoneId);
getNode(db, input.targetNodeId);
const dup = db
.select()
.from(aliases)
.where(and(eq(aliases.zone_id, input.zoneId), eq(aliases.name, input.name)))
.get();
if (dup) throw new ConflictError(`alias ${input.name} already exists`);
const aliasId = id("alias");
const ts = now();
db.insert(aliases)
.values({
id: aliasId,
zone_id: input.zoneId,
name: input.name,
purpose: input.purpose,
mode: input.mode,
target_node_id: input.targetNodeId,
sync_status: "pending",
created_at: ts,
updated_at: ts,
})
.run();
return getAlias(db, aliasId);
}
export function updateAlias(
db: Db,
aliasId: string,
patch: {
name?: string;
purpose?: string;
mode?: string;
targetNodeId?: string;
syncStatus?: string;
cfRecordId?: string | null;
lastError?: string | null;
},
): AliasRow {
getAlias(db, aliasId);
if (patch.targetNodeId) getNode(db, patch.targetNodeId);
const updates: Partial<typeof aliases.$inferInsert> = {
updated_at: now(),
};
if (patch.name !== undefined) updates.name = patch.name;
if (patch.purpose !== undefined) updates.purpose = patch.purpose;
if (patch.mode !== undefined) updates.mode = patch.mode;
if (patch.targetNodeId !== undefined)
updates.target_node_id = patch.targetNodeId;
if (patch.syncStatus !== undefined) updates.sync_status = patch.syncStatus;
if (patch.cfRecordId !== undefined) updates.cf_record_id = patch.cfRecordId;
if (patch.lastError !== undefined) updates.last_error = patch.lastError;
db.update(aliases).set(updates).where(eq(aliases.id, aliasId)).run();
return getAlias(db, aliasId);
}
export function deleteAlias(db: Db, aliasId: string): void {
getAlias(db, aliasId);
db.delete(aliases).where(eq(aliases.id, aliasId)).run();
}
export function createSyncJob(db: Db, zoneId: string): string {
getZone(db, zoneId);
const jobId = id("sync");
db.insert(syncJobs)
.values({
id: jobId,
zone_id: zoneId,
status: "pending",
created_at: now(),
})
.run();
return jobId;
}
export function updateSyncJob(
db: Db,
jobId: string,
patch: {
status?: string;
diffJson?: string | null;
error?: string | null;
finishedAt?: string | null;
},
) {
const updates: Partial<typeof syncJobs.$inferInsert> = {};
if (patch.status !== undefined) updates.status = patch.status;
if (patch.diffJson !== undefined) updates.diff_json = patch.diffJson;
if (patch.error !== undefined) updates.error = patch.error;
if (patch.finishedAt !== undefined) updates.finished_at = patch.finishedAt;
db.update(syncJobs).set(updates).where(eq(syncJobs.id, jobId)).run();
}
export function getSyncJob(db: Db, jobId: string) {
const row = db.select().from(syncJobs).where(eq(syncJobs.id, jobId)).get();
if (!row) throw new NotFoundError(`sync job ${jobId}`);
return {
id: row.id,
zoneId: row.zone_id,
status: row.status,
diff: row.diff_json ? JSON.parse(row.diff_json) : [],
error: row.error,
createdAt: row.created_at,
finishedAt: row.finished_at,
};
}
export function listSyncJobs(db: Db, zoneId: string) {
return db
.select()
.from(syncJobs)
.where(eq(syncJobs.zone_id, zoneId))
.orderBy(sql`${syncJobs.created_at} DESC`)
.all()
.slice(0, 20)
.map((row) => ({
id: row.id,
zoneId: row.zone_id,
status: row.status,
diff: row.diff_json ? JSON.parse(row.diff_json) : [],
error: row.error,
createdAt: row.created_at,
finishedAt: row.finished_at,
}));
}
export function addSyncEvent(
db: Db,
jobId: string,
event: {
kind: string;
recordName?: string;
recordType?: string;
detail?: string;
},
) {
db.insert(syncEvents)
.values({
id: id("evt"),
job_id: jobId,
kind: event.kind,
record_name: event.recordName ?? null,
record_type: event.recordType ?? null,
detail: event.detail ?? null,
created_at: now(),
})
.run();
}
export function listIgnoredOrphans(db: Db, zoneId: string) {
return db
.select()
.from(ignoredOrphans)
.where(eq(ignoredOrphans.zone_id, zoneId))
.all()
.map((r) => ({
id: r.id,
recordName: r.record_name,
recordType: r.record_type,
}));
}
export function ignoreOrphan(
db: Db,
zoneId: string,
recordName: string,
recordType: string,
) {
getZone(db, zoneId);
db.insert(ignoredOrphans)
.values({
id: id("ign"),
zone_id: zoneId,
record_name: recordName,
record_type: recordType,
created_at: now(),
})
.onConflictDoNothing()
.run();
}
export function unignoreOrphan(
db: Db,
zoneId: string,
recordName: string,
recordType: string,
) {
db.delete(ignoredOrphans)
.where(
and(
eq(ignoredOrphans.zone_id, zoneId),
eq(ignoredOrphans.record_name, recordName),
eq(ignoredOrphans.record_type, recordType),
),
)
.run();
}
export function dashboardCounts(db: Db) {
const nodeRows = db.select().from(nodes).all();
const aliasRows = db.select().from(aliases).all();
const zoneRows = db.select().from(zones).all();
let syncOk = 0;
let drift = 0;
let nodesWithoutIp = 0;
for (const n of nodeRows) {
if (n.sync_status === "ok") syncOk += 1;
if (n.sync_status === "drift" || n.sync_status === "missing") drift += 1;
const addrs = loadAddresses(db, n.id);
if (!addrs.some((a) => a.family === "v4")) nodesWithoutIp += 1;
}
let brokenAliases = 0;
for (const a of aliasRows) {
if (a.sync_status === "error" || a.sync_status === "missing")
brokenAliases += 1;
if (a.sync_status === "drift") drift += 1;
if (a.sync_status === "ok") syncOk += 1;
}
const lastSyncAt =
zoneRows
.map((z) => z.last_sync_at)
.filter(Boolean)
.sort()
.at(-1) ?? null;
return {
nodes: nodeRows.length,
aliases: aliasRows.length,
syncOk,
drift,
nodesWithoutIp,
brokenAliases,
lastSyncAt,
};
}
+1
View File
@@ -2,3 +2,4 @@ export * from "./schema.js";
export * from "./client.js";
export * from "./errors.js";
export * from "./settings-repo.js";
export * from "./fleet-repo.js";
+163 -1
View File
@@ -1,11 +1,18 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const appSettings = sqliteTable("app_settings", {
id: text("id").primaryKey(),
show_quick_actions: integer("show_quick_actions", { mode: "boolean" })
.notNull()
.default(true),
default_ttl: integer("default_ttl").notNull().default(300),
naming_template: text("naming_template")
.notNull()
.default("{loc}-{role}{nn}.{zone}"),
proxied_lock: integer("proxied_lock", { mode: "boolean" })
.notNull()
.default(true),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -14,6 +21,161 @@ export const appSettings = sqliteTable("app_settings", {
.default(sql`datetime('now')`),
});
export const zones = sqliteTable("zones", {
id: text("id").primaryKey(),
cf_zone_id: text("cf_zone_id"),
name: text("name").notNull().unique(),
role: text("role").notNull().default("routing"),
naming_template: text("naming_template")
.notNull()
.default("{loc}-{role}{nn}.{zone}"),
default_ttl: integer("default_ttl").notNull().default(300),
last_sync_at: text("last_sync_at"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
updated_at: text("updated_at")
.notNull()
.default(sql`datetime('now')`),
});
export const locations = sqliteTable("locations", {
id: text("id").primaryKey(),
code: text("code").notNull().unique(),
name: text("name").notNull(),
country: text("country"),
sort_order: integer("sort_order").notNull().default(0),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
});
export const nodes = sqliteTable(
"nodes",
{
id: text("id").primaryKey(),
zone_id: text("zone_id")
.notNull()
.references(() => zones.id, { onDelete: "cascade" }),
location_id: text("location_id")
.notNull()
.references(() => locations.id),
hostname: text("hostname").notNull(),
role: text("role").notNull(),
index_num: integer("index_num").notNull().default(1),
provider_tag: text("provider_tag"),
notes: text("notes"),
sync_status: text("sync_status").notNull().default("pending"),
cf_a_record_id: text("cf_a_record_id"),
cf_aaaa_record_id: text("cf_aaaa_record_id"),
last_error: text("last_error"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
updated_at: text("updated_at")
.notNull()
.default(sql`datetime('now')`),
},
(t) => [uniqueIndex("nodes_zone_hostname").on(t.zone_id, t.hostname)],
);
export const nodeAddresses = sqliteTable(
"node_addresses",
{
id: text("id").primaryKey(),
node_id: text("node_id")
.notNull()
.references(() => nodes.id, { onDelete: "cascade" }),
family: text("family").notNull(),
ip: text("ip").notNull(),
},
(t) => [uniqueIndex("node_addresses_node_family").on(t.node_id, t.family)],
);
export const aliases = sqliteTable(
"aliases",
{
id: text("id").primaryKey(),
zone_id: text("zone_id")
.notNull()
.references(() => zones.id, { onDelete: "cascade" }),
name: text("name").notNull(),
purpose: text("purpose").notNull().default("geo"),
mode: text("mode").notNull().default("primary"),
target_node_id: text("target_node_id")
.notNull()
.references(() => nodes.id, { onDelete: "restrict" }),
sync_status: text("sync_status").notNull().default("pending"),
cf_record_id: text("cf_record_id"),
last_error: text("last_error"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
updated_at: text("updated_at")
.notNull()
.default(sql`datetime('now')`),
},
(t) => [uniqueIndex("aliases_zone_name").on(t.zone_id, t.name)],
);
export const syncJobs = sqliteTable("sync_jobs", {
id: text("id").primaryKey(),
zone_id: text("zone_id")
.notNull()
.references(() => zones.id, { onDelete: "cascade" }),
status: text("status").notNull().default("pending"),
diff_json: text("diff_json"),
error: text("error"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
finished_at: text("finished_at"),
});
export const syncEvents = sqliteTable("sync_events", {
id: text("id").primaryKey(),
job_id: text("job_id")
.notNull()
.references(() => syncJobs.id, { onDelete: "cascade" }),
kind: text("kind").notNull(),
record_name: text("record_name"),
record_type: text("record_type"),
detail: text("detail"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
});
export const ignoredOrphans = sqliteTable(
"ignored_orphans",
{
id: text("id").primaryKey(),
zone_id: text("zone_id")
.notNull()
.references(() => zones.id, { onDelete: "cascade" }),
record_name: text("record_name").notNull(),
record_type: text("record_type").notNull(),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
},
(t) => [
uniqueIndex("ignored_orphans_unique").on(
t.zone_id,
t.record_name,
t.record_type,
),
],
);
export const schema = {
appSettings,
zones,
locations,
nodes,
nodeAddresses,
aliases,
syncJobs,
syncEvents,
ignoredOrphans,
};
+21
View File
@@ -7,10 +7,16 @@ const SETTINGS_ID = "settings-main";
export type AppSettingsDto = {
id: string;
showQuickActions: boolean;
defaultTtl: number;
namingTemplate: string;
proxiedLock: boolean;
};
export type AppSettingsPatch = {
showQuickActions?: boolean;
defaultTtl?: number;
namingTemplate?: string;
proxiedLock?: boolean;
};
function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto {
@@ -18,6 +24,9 @@ function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto {
id: row.id,
showQuickActions:
row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
defaultTtl: row.default_ttl ?? 300,
namingTemplate: row.naming_template ?? "{loc}-{role}{nn}.{zone}",
proxiedLock: row.proxied_lock == null ? true : Boolean(row.proxied_lock),
};
}
@@ -33,6 +42,9 @@ function ensureRow(db: Db): typeof appSettings.$inferSelect {
.values({
id: SETTINGS_ID,
show_quick_actions: true,
default_ttl: 300,
naming_template: "{loc}-{role}{nn}.{zone}",
proxied_lock: true,
})
.run();
@@ -58,6 +70,15 @@ export function updateAppSettings(
if (patch.showQuickActions !== undefined) {
updates.show_quick_actions = patch.showQuickActions;
}
if (patch.defaultTtl !== undefined) {
updates.default_ttl = patch.defaultTtl;
}
if (patch.namingTemplate !== undefined) {
updates.naming_template = patch.namingTemplate;
}
if (patch.proxiedLock !== undefined) {
updates.proxied_lock = patch.proxiedLock;
}
db.update(appSettings)
.set(updates)
.where(eq(appSettings.id, SETTINGS_ID))