feat(db): перевести хранилище с SQLite на PostgreSQL
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m9s
Docker images / frontend-image (push) Successful in 4m28s
Docker images / updater-image (push) Successful in 58s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s

При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-08 01:36:48 +07:00
co-authored by Cursor
parent 0e5fb065e2
commit ec43591a99
128 changed files with 4304 additions and 3639 deletions
+61 -77
View File
@@ -1,79 +1,7 @@
import assert from "node:assert/strict"
import Database from "better-sqlite3"
import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js"
const sqlite = new Database(":memory:")
sqlite.pragma("foreign_keys = ON")
sqlite.exec(`
CREATE TABLE servers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
host TEXT NOT NULL DEFAULT '127.0.0.1'
);
CREATE TABLE app_users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
login TEXT NOT NULL UNIQUE,
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'viewer',
active INTEGER NOT NULL DEFAULT 1,
avatar TEXT NOT NULL DEFAULT '',
last_seen TEXT,
sections_json TEXT NOT NULL DEFAULT '[]',
servers_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE user_interface_bindings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
server_id INTEGER NOT NULL,
interface_name TEXT NOT NULL,
interface_type TEXT NOT NULL DEFAULT 'other',
peer_public_key TEXT NOT NULL DEFAULT '',
peer_name TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES app_users(id) ON DELETE CASCADE,
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE,
UNIQUE (server_id, interface_name, peer_public_key)
);
`)
sqlite.prepare("INSERT INTO servers (id, name, host) VALUES (1, 'jh', '10.0.0.1')").run()
sqlite.prepare("INSERT INTO app_users (id, name, login) VALUES ('u1', 'A', 'a.user')").run()
sqlite.prepare("INSERT INTO app_users (id, name, login) VALUES ('u2', 'B', 'b.user')").run()
sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b1', 'u1', 1, 'gre-office', 'gre')
`).run()
assert.throws(
() => sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b2', 'u2', 1, 'gre-office', 'gre')
`).run(),
/UNIQUE/i,
"один интерфейс на сервере — один пользователь",
)
sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
VALUES ('wg1', 'u1', 1, 'wg-server', 'wg', 'peer-key-aaa', 'phone')
`).run()
sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
VALUES ('wg2', 'u2', 1, 'wg-server', 'wg', 'peer-key-bbb', 'laptop')
`).run()
assert.throws(
() => sqlite.prepare(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key)
VALUES ('wg3', 'u2', 1, 'wg-server', 'wg', 'peer-key-aaa')
`).run(),
/UNIQUE/i,
"один пир — один пользователь",
)
import { withPgOrSkip } from "../../test/pg.js"
import { dbQuery } from "../../db/index.js"
assert.throws(
() => normalizeBindingPeer("wg", ""),
@@ -83,8 +11,64 @@ assert.throws(
assert.equal(normalizeBindingPeer("ether", "ignored"), "")
assert.equal(normalizeBindingPeer("wg", " abc "), "abc")
sqlite.prepare("DELETE FROM app_users WHERE id = 'u1'").run()
const leftover = sqlite.prepare("SELECT COUNT(*) AS n FROM user_interface_bindings").get() as { n: number }
assert.equal(leftover.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
if (!(await withPgOrSkip())) {
console.log("users bindings unique+cascade tests skip")
process.exit(0)
}
await dbQuery(`
INSERT INTO servers (id, name, host) VALUES (91001, 'jh-bind-test', '10.0.0.1')
ON CONFLICT (id) DO NOTHING
`)
await dbQuery(`
INSERT INTO app_users (id, name, login)
VALUES ('u-bind-1', 'A', 'a.bind.test'), ('u-bind-2', 'B', 'b.bind.test')
ON CONFLICT (id) DO NOTHING
`)
await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id = 91001`)
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b-bind-1', 'u-bind-1', 91001, 'gre-office', 'gre')
`)
let uniqueIface = false
try {
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
VALUES ('b-bind-2', 'u-bind-2', 91001, 'gre-office', 'gre')
`)
} catch {
uniqueIface = true
}
assert.equal(uniqueIface, true, "один интерфейс на сервере — один пользователь")
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
VALUES ('wg-bind-1', 'u-bind-1', 91001, 'wg-server', 'wg', 'peer-key-aaa', 'phone')
`)
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
VALUES ('wg-bind-2', 'u-bind-2', 91001, 'wg-server', 'wg', 'peer-key-bbb', 'laptop')
`)
let uniquePeer = false
try {
await dbQuery(`
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key)
VALUES ('wg-bind-3', 'u-bind-2', 91001, 'wg-server', 'wg', 'peer-key-aaa')
`)
} catch {
uniquePeer = true
}
assert.equal(uniquePeer, true, "один пир — один пользователь")
await dbQuery(`DELETE FROM app_users WHERE id = 'u-bind-1'`)
const leftover = await dbQuery<{ n: number }>(
`SELECT COUNT(*)::int AS n FROM user_interface_bindings WHERE server_id = 91001`,
)
assert.equal(leftover.rows[0]?.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
await dbQuery(`DELETE FROM app_users WHERE id IN ('u-bind-1', 'u-bind-2')`)
await dbQuery(`DELETE FROM servers WHERE id = 91001`)
console.log("users bindings unique+cascade tests ok")
+4 -4
View File
@@ -27,10 +27,10 @@ function asBool(raw: unknown): boolean {
return s === "true" || s === "yes" || s === "1"
}
export function parseRawInterfaces(json: string | null | undefined): ParsedRosIface[] {
if (!json) return []
export function parseRawInterfaces(json: unknown): ParsedRosIface[] {
if (json == null || json === "") return []
try {
const parsed = JSON.parse(json) as unknown
const parsed = typeof json === "string" ? JSON.parse(json) as unknown : json
const arr = Array.isArray(parsed) ? parsed : []
const out: ParsedRosIface[] = []
for (const item of arr) {
@@ -56,5 +56,5 @@ export function isUniqueConstraintError(err: unknown): boolean {
const rec = err as { code?: unknown; message?: unknown }
const code = String(rec.code ?? "")
const msg = String(rec.message ?? "")
return code.includes("SQLITE_CONSTRAINT") || /unique constraint/i.test(msg)
return code === "23505" || code.includes("SQLITE_CONSTRAINT") || /unique constraint/i.test(msg)
}
@@ -6,56 +6,59 @@ import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topol
export type AppUserRow = typeof appUsers.$inferSelect
export type BindingRow = typeof userInterfaceBindings.$inferSelect
export function listUserRows(): AppUserRow[] {
return db.select().from(appUsers).all()
export async function listUserRows(): Promise<AppUserRow[]> {
return await db.select().from(appUsers)
}
export function getUserRowById(id: string): AppUserRow | undefined {
return db.select().from(appUsers).where(eq(appUsers.id, id)).limit(1).all()[0]
export async function getUserRowById(id: string): Promise<AppUserRow | undefined> {
const rows = await db.select().from(appUsers).where(eq(appUsers.id, id)).limit(1)
return rows[0]
}
export function getUserRowByLogin(login: string): AppUserRow | undefined {
return db.select().from(appUsers).where(eq(appUsers.login, login)).limit(1).all()[0]
export async function getUserRowByLogin(login: string): Promise<AppUserRow | undefined> {
const rows = await db.select().from(appUsers).where(eq(appUsers.login, login)).limit(1)
return rows[0]
}
export function createUserRow(values: typeof appUsers.$inferInsert): AppUserRow {
const [inserted] = db.insert(appUsers).values(values).returning().all()
export async function createUserRow(values: typeof appUsers.$inferInsert): Promise<AppUserRow> {
const [inserted] = await db.insert(appUsers).values(values).returning()
invalidateFlowCatalogCache()
return inserted
}
export function updateUserRowById(
export async function updateUserRowById(
id: string,
values: Partial<AppUserRow>,
): AppUserRow {
const [updated] = db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning().all()
): Promise<AppUserRow> {
const [updated] = await db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning()
invalidateFlowCatalogCache()
return updated
}
export function deleteUserRowById(id: string): void {
db.delete(appUsers).where(eq(appUsers.id, id)).run()
export async function deleteUserRowById(id: string): Promise<void> {
await db.delete(appUsers).where(eq(appUsers.id, id))
invalidateFlowCatalogCache()
}
export function listBindingRows(): BindingRow[] {
return db.select().from(userInterfaceBindings).all()
export async function listBindingRows(): Promise<BindingRow[]> {
return await db.select().from(userInterfaceBindings)
}
export function listBindingRowsByUser(userId: string): BindingRow[] {
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)).all()
export async function listBindingRowsByUser(userId: string): Promise<BindingRow[]> {
return await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId))
}
export function getBindingRowById(id: string): BindingRow | undefined {
return db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1).all()[0]
export async function getBindingRowById(id: string): Promise<BindingRow | undefined> {
const rows = await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1)
return rows[0]
}
export function getBindingByServerIfacePeer(
export async function getBindingByServerIfacePeer(
serverId: number,
interfaceName: string,
peerPublicKey = "",
): BindingRow | undefined {
return db
): Promise<BindingRow | undefined> {
const rows = await db
.select()
.from(userInterfaceBindings)
.where(and(
@@ -64,20 +67,21 @@ export function getBindingByServerIfacePeer(
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
))
.limit(1)
.all()[0]
return rows[0]
}
export function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): BindingRow {
const [inserted] = db.insert(userInterfaceBindings).values(values).returning().all()
export async function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): Promise<BindingRow> {
const [inserted] = await db.insert(userInterfaceBindings).values(values).returning()
invalidateFlowCatalogCache()
return inserted
}
export function deleteBindingRowById(id: string): void {
db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).run()
export async function deleteBindingRowById(id: string): Promise<void> {
await db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id))
invalidateFlowCatalogCache()
}
export function countUserRows(): number {
return db.select({ n: count() }).from(appUsers).all()[0]?.n ?? 0
export async function countUserRows(): Promise<number> {
const rows = await db.select({ n: count() }).from(appUsers)
return rows[0]?.n ?? 0
}
@@ -12,6 +12,7 @@ import type {
UserBindingCreate,
} from "@mmapp/contracts/users"
import { db } from "../../../db/index.js"
import { parseJsonArray } from "../../../db/json.js"
import { servers, trafficSamples } from "../../../db/schema.js"
import {
createBindingRow,
@@ -52,13 +53,9 @@ export class UsersServiceError extends Error {
}
}
function parseJsonArray<T>(raw: string, fallback: T[]): T[] {
try {
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? (parsed as T[]) : fallback
} catch {
return fallback
}
function asPermArray<T>(raw: unknown, fallback: T[]): T[] {
const arr = parseJsonArray(raw)
return arr.length ? arr as T[] : fallback
}
function initials(name: string): string {
@@ -66,8 +63,9 @@ function initials(name: string): string {
return parts.map((p) => p[0] ?? "").slice(0, 2).join("").toUpperCase() || "??"
}
function serverMeta(serverId: number): { name: string; site: string; country: string } {
const row = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
async function serverMeta(serverId: number): Promise<{ name: string; site: string; country: string }> {
const rows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
const row = rows[0]
return {
name: row?.name || row?.host || String(serverId),
site: row?.site || "—",
@@ -75,8 +73,8 @@ function serverMeta(serverId: number): { name: string; site: string; country: st
}
}
function toBindingDto(row: BindingRow): UserBinding {
const meta = serverMeta(row.serverId)
async function toBindingDto(row: BindingRow): Promise<UserBinding> {
const meta = await serverMeta(row.serverId)
return {
id: row.id,
userId: row.userId,
@@ -94,7 +92,7 @@ function toBindingDto(row: BindingRow): UserBinding {
}
}
function toUserDto(row: AppUserRow, bindings: BindingRow[]): AppUserRead {
async function toUserDto(row: AppUserRow, bindings: BindingRow[]): Promise<AppUserRead> {
return {
id: row.id,
name: row.name,
@@ -104,39 +102,39 @@ function toUserDto(row: AppUserRow, bindings: BindingRow[]): AppUserRead {
active: Boolean(row.active),
avatar: row.avatar,
lastSeen: row.lastSeen ?? null,
sections: parseJsonArray<SectionPerm>(row.sectionsJson, []),
servers: parseJsonArray<ServerPerm>(row.serversJson, []),
bindings: bindings.map(toBindingDto),
sections: asPermArray<SectionPerm>(row.sectionsJson, []),
servers: asPermArray<ServerPerm>(row.serversJson, []),
bindings: await Promise.all(bindings.map((b) => toBindingDto(b))),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
export function listUsers(): AppUserRead[] {
const users = listUserRows()
const allBindings = listBindingRows()
export async function listUsers(): Promise<AppUserRead[]> {
const users = await listUserRows()
const allBindings = await listBindingRows()
const byUser = new Map<string, BindingRow[]>()
for (const b of allBindings) {
const arr = byUser.get(b.userId) ?? []
arr.push(b)
byUser.set(b.userId, arr)
}
return users.map((u) => toUserDto(u, byUser.get(u.id) ?? []))
return Promise.all(users.map((u) => toUserDto(u, byUser.get(u.id) ?? [])))
}
export function getUserById(id: string): AppUserRead | undefined {
const row = getUserRowById(id)
export async function getUserById(id: string): Promise<AppUserRead | undefined> {
const row = await getUserRowById(id)
if (!row) return undefined
return toUserDto(row, listBindingRowsByUser(id))
return await toUserDto(row, await listBindingRowsByUser(id))
}
export function createUser(input: AppUserCreate): AppUserRead {
export async function createUser(input: AppUserCreate): Promise<AppUserRead> {
const login = input.login.trim()
if (getUserRowByLogin(login)) {
if (await getUserRowByLogin(login)) {
throw new UsersServiceError("Логин уже занят", 409)
}
const now = new Date().toISOString()
const row = createUserRow({
const row = await createUserRow({
id: randomUUID(),
name: input.name.trim(),
login,
@@ -145,19 +143,19 @@ export function createUser(input: AppUserCreate): AppUserRead {
active: input.active ?? true,
avatar: (input.avatar ?? "").trim() || initials(input.name),
lastSeen: input.lastSeen ?? null,
sectionsJson: JSON.stringify(input.sections ?? []),
serversJson: JSON.stringify(input.servers ?? []),
sectionsJson: input.sections ?? [],
serversJson: input.servers ?? [],
createdAt: now,
updatedAt: now,
})
return toUserDto(row, [])
return await toUserDto(row, [])
}
export function updateUser(id: string, input: AppUserUpdate): AppUserRead {
const existing = getUserRowById(id)
export async function updateUser(id: string, input: AppUserUpdate): Promise<AppUserRead> {
const existing = await getUserRowById(id)
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
if (input.login != null) {
const other = getUserRowByLogin(input.login.trim())
const other = await getUserRowByLogin(input.login.trim())
if (other && other.id !== id) throw new UsersServiceError("Логин уже занят", 409)
}
const patch: Partial<AppUserRow> = { updatedAt: new Date().toISOString() }
@@ -168,26 +166,26 @@ export function updateUser(id: string, input: AppUserUpdate): AppUserRead {
if (input.active != null) patch.active = input.active
if (input.avatar != null) patch.avatar = input.avatar.trim() || existing.avatar
if (input.lastSeen !== undefined) patch.lastSeen = input.lastSeen
if (input.sections != null) patch.sectionsJson = JSON.stringify(input.sections)
if (input.servers != null) patch.serversJson = JSON.stringify(input.servers)
const updated = updateUserRowById(id, patch)
return toUserDto(updated, listBindingRowsByUser(id))
if (input.sections != null) patch.sectionsJson = input.sections
if (input.servers != null) patch.serversJson = input.servers
const updated = await updateUserRowById(id, patch)
return await toUserDto(updated, await listBindingRowsByUser(id))
}
export function deleteUser(id: string): void {
const existing = getUserRowById(id)
export async function deleteUser(id: string): Promise<void> {
const existing = await getUserRowById(id)
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
deleteUserRowById(id)
await deleteUserRowById(id)
}
export function addBinding(userId: string, input: UserBindingCreate): UserBinding {
const user = getUserRowById(userId)
export async function addBinding(userId: string, input: UserBindingCreate): Promise<UserBinding> {
const user = await getUserRowById(userId)
if (!user) throw new UsersServiceError("Пользователь не найден", 404)
const server = db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1).all()[0]
if (!server) throw new UsersServiceError("Сервер не найден", 404)
const serverRows = await db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1)
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
const ifaceName = input.interfaceName.trim()
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
const type: InterfaceType = input.interfaceType ?? inferIfaceType(input.serverId, ifaceName)
const type: InterfaceType = input.interfaceType ?? await inferIfaceType(input.serverId, ifaceName)
let peerPublicKey = ""
try {
peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey)
@@ -201,7 +199,7 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
name: input.peerName,
})
: ""
const taken = getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
const taken = await getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
if (taken) {
throw new UsersServiceError(
type === "wg"
@@ -212,7 +210,7 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
}
const now = new Date().toISOString()
try {
const row = createBindingRow({
const row = await createBindingRow({
id: randomUUID(),
userId,
serverId: input.serverId,
@@ -224,7 +222,7 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
createdAt: now,
updatedAt: now,
})
return toBindingDto(row)
return await toBindingDto(row)
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new UsersServiceError(
@@ -238,37 +236,37 @@ export function addBinding(userId: string, input: UserBindingCreate): UserBindin
}
}
export function removeBinding(userId: string, bindingId: string): void {
const row = getBindingRowById(bindingId)
export async function removeBinding(userId: string, bindingId: string): Promise<void> {
const row = await getBindingRowById(bindingId)
if (!row || row.userId !== userId) {
throw new UsersServiceError("Привязка не найдена", 404)
}
deleteBindingRowById(bindingId)
await deleteBindingRowById(bindingId)
}
function inferIfaceType(serverId: number, ifaceName: string): InterfaceType {
const snap = getLatestSnapshot(serverId)
async function inferIfaceType(serverId: number, ifaceName: string): Promise<InterfaceType> {
const snap = await getLatestSnapshot(serverId)
const parsed = parseRawInterfaces(snap?.rawInterfaces)
const found = parsed.find((i) => i.name === ifaceName)
return found?.type ?? "other"
}
export async function listInterfaceCatalog(serverId: number): Promise<CatalogInterface[]> {
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
if (!server) throw new UsersServiceError("Сервер не найден", 404)
const serverRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
const snap = getLatestSnapshot(serverId)
const snap = await getLatestSnapshot(serverId)
let ifaces = parseRawInterfaces(snap?.rawInterfaces)
if (ifaces.length === 0) {
const last = db
const lastRows = await db
.select({ sampledAt: trafficSamples.sampledAt })
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.orderBy(desc(trafficSamples.sampledAt))
.limit(1)
.all()[0]
const last = lastRows[0]
if (last) {
const rows = db
const rows = (await db
.select({
interfaceName: trafficSamples.interfaceName,
peerPublicKey: trafficSamples.peerPublicKey,
@@ -276,8 +274,7 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
disabled: trafficSamples.disabled,
})
.from(trafficSamples)
.where(eq(trafficSamples.serverId, serverId))
.all()
.where(eq(trafficSamples.serverId, serverId)))
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? ""))
const seen = new Set<string>()
ifaces = []
@@ -294,8 +291,8 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
}
}
const bindings = listBindingRows().filter((b) => b.serverId === serverId)
const usersById = new Map(listUserRows().map((u) => [u.id, u]))
const bindings = (await listBindingRows()).filter((b) => b.serverId === serverId)
const usersById = new Map((await listUserRows()).map((u) => [u.id, u]))
const hasWg = ifaces.some((i) => i.type === "wg")
const wgLive = hasWg
? await listWireGuardPeersForCatalog(serverId)