quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 4s
quality / docker-check (push) Skipped
quality / web (push) Successful in 45s
quality / api (push) Successful in 36s
CD / quality (push) Successful in 1m30s
CD / publish (push) Successful in 1m51s
Co-authored-by: Cursor <[email protected]>
741 lines
25 KiB
JavaScript
741 lines
25 KiB
JavaScript
// src/schema.ts
|
|
import { sql } from "drizzle-orm";
|
|
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,
|
|
zones,
|
|
locations,
|
|
nodes,
|
|
nodeAddresses,
|
|
aliases,
|
|
syncJobs,
|
|
syncEvents,
|
|
ignoredOrphans
|
|
};
|
|
|
|
// src/client.ts
|
|
import { dirname, join } from "path";
|
|
import { fileURLToPath } from "url";
|
|
import { readFileSync, readdirSync } from "fs";
|
|
import Database from "better-sqlite3";
|
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
function resolveDatabasePath(databaseUrl) {
|
|
const url = databaseUrl.startsWith("sqlite:") ? databaseUrl.slice("sqlite:".length) : databaseUrl;
|
|
return url;
|
|
}
|
|
function createDb(databaseUrl) {
|
|
const path = resolveDatabasePath(databaseUrl);
|
|
const sqlite = new Database(path);
|
|
sqlite.pragma("journal_mode = WAL");
|
|
sqlite.pragma("synchronous = NORMAL");
|
|
sqlite.pragma("foreign_keys = ON");
|
|
const db = drizzle(sqlite, { schema });
|
|
return { db, sqlite };
|
|
}
|
|
function createMemoryDb() {
|
|
const sqlite = new Database(":memory:");
|
|
sqlite.pragma("foreign_keys = ON");
|
|
const db = drizzle(sqlite, { schema });
|
|
return { db, sqlite };
|
|
}
|
|
function runMigrations(sqlite) {
|
|
const migrationsDir = join(__dirname, "..", "migrations");
|
|
const files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
|
|
sqlite.exec(
|
|
`CREATE TABLE IF NOT EXISTS _migrations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`
|
|
);
|
|
for (const file of files) {
|
|
const applied = sqlite.prepare("SELECT 1 FROM _migrations WHERE name = ?").get(file);
|
|
if (applied) continue;
|
|
const sql3 = readFileSync(join(migrationsDir, file), "utf-8");
|
|
sqlite.exec(sql3);
|
|
sqlite.prepare("INSERT INTO _migrations (name) VALUES (?)").run(file);
|
|
}
|
|
}
|
|
function healthCheck(sqlite) {
|
|
sqlite.prepare("SELECT 1").get();
|
|
}
|
|
|
|
// src/errors.ts
|
|
var NotFoundError = class extends Error {
|
|
constructor(message) {
|
|
super(message);
|
|
this.name = "NotFoundError";
|
|
}
|
|
};
|
|
var ConflictError = class extends Error {
|
|
constructor(message) {
|
|
super(message);
|
|
this.name = "ConflictError";
|
|
}
|
|
};
|
|
|
|
// src/settings-repo.ts
|
|
import { eq } from "drizzle-orm";
|
|
var SETTINGS_ID = "settings-main";
|
|
function toDto(row) {
|
|
return {
|
|
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)
|
|
};
|
|
}
|
|
function ensureRow(db) {
|
|
const existing = db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get();
|
|
if (existing) return existing;
|
|
db.insert(appSettings).values({
|
|
id: SETTINGS_ID,
|
|
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();
|
|
}
|
|
function getAppSettings(db) {
|
|
return toDto(ensureRow(db));
|
|
}
|
|
function updateAppSettings(db, patch) {
|
|
ensureRow(db);
|
|
const updates = {
|
|
updated_at: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19)
|
|
};
|
|
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";
|
|
import { catalogCitiesWithLocCodes } from "@cdnmanager/shared";
|
|
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 ensureCatalogLocations(db) {
|
|
const existing = db.select().from(locations).all();
|
|
const byCode = new Map(existing.map((r) => [r.code.toLowerCase(), r]));
|
|
const byNameCountry = new Map(
|
|
existing.map((r) => [
|
|
`${(r.name || "").trim().toLowerCase()}|${(r.country || "").toUpperCase()}`,
|
|
r
|
|
])
|
|
);
|
|
let inserted = 0;
|
|
let sortOrder = 100;
|
|
for (const city of catalogCitiesWithLocCodes()) {
|
|
const nameKey = `${city.name.trim().toLowerCase()}|${city.countryCode}`;
|
|
if (byNameCountry.has(nameKey)) continue;
|
|
if (byCode.has(city.locCode.toLowerCase())) {
|
|
const alt = `${city.locCode}${city.countryCode.toLowerCase()}`;
|
|
if (byCode.has(alt) || byNameCountry.has(nameKey)) continue;
|
|
db.insert(locations).values({
|
|
id: `loc-${alt}`,
|
|
code: alt,
|
|
name: city.name,
|
|
country: city.countryCode,
|
|
sort_order: sortOrder++
|
|
}).run();
|
|
byCode.set(alt, { id: `loc-${alt}` });
|
|
byNameCountry.set(nameKey, { id: `loc-${alt}` });
|
|
inserted += 1;
|
|
continue;
|
|
}
|
|
db.insert(locations).values({
|
|
id: `loc-${city.locCode}`,
|
|
code: city.locCode,
|
|
name: city.name,
|
|
country: city.countryCode,
|
|
sort_order: sortOrder++
|
|
}).run();
|
|
byCode.set(city.locCode.toLowerCase(), {
|
|
id: `loc-${city.locCode}`
|
|
});
|
|
byNameCountry.set(nameKey, {
|
|
id: `loc-${city.locCode}`
|
|
});
|
|
inserted += 1;
|
|
}
|
|
return inserted;
|
|
}
|
|
function findLocationByCity(db, cityName, countryCode) {
|
|
const q = cityName.trim().toLowerCase();
|
|
if (!q) return null;
|
|
const rows = listLocations(db);
|
|
const match = rows.find((l) => {
|
|
if (l.name.trim().toLowerCase() !== q) return false;
|
|
if (countryCode && l.country && l.country.toUpperCase() !== countryCode.toUpperCase()) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
return match ?? null;
|
|
}
|
|
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,
|
|
ensureCatalogLocations,
|
|
findLocationByCity,
|
|
getAlias,
|
|
getAppSettings,
|
|
getLocation,
|
|
getNode,
|
|
getSyncJob,
|
|
getZone,
|
|
healthCheck,
|
|
ignoreOrphan,
|
|
ignoredOrphans,
|
|
listAliases,
|
|
listIgnoredOrphans,
|
|
listLocations,
|
|
listNodes,
|
|
listSyncJobs,
|
|
listZones,
|
|
locations,
|
|
nodeAddresses,
|
|
nodes,
|
|
resolveDatabasePath,
|
|
runMigrations,
|
|
schema,
|
|
syncEvents,
|
|
syncJobs,
|
|
unignoreOrphan,
|
|
updateAlias,
|
|
updateAppSettings,
|
|
updateNode,
|
|
updateSyncJob,
|
|
updateZone,
|
|
zones
|
|
};
|