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
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:
Vendored
+2790
-1
File diff suppressed because it is too large
Load Diff
Vendored
+565
-7
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user