Add a Diagnostic Log, ported and generalized from Sloth Manager
Sloth Manager tracked every API call made to DNS providers for connectivity troubleshooting. Port that here, generalized to cover every outbound integration this app makes, not just DNS -- Tailscale, Proxmox, Synology, Semaphore, Gitea, and Dockhand calls now show up too, since a broken API token or unreachable host on any of them is just as worth diagnosing. New services/diagLog.ts: a generic withDiagLogging(source, adapter) wraps every async method of any adapter object with timing + success/failure recording, without touching a single adapter's request/error-handling internals -- every DNS and integration adapter interface here is already just a flat set of async methods, so this one wrapper works for all twelve of them. Applied it at each adapter factory's own return statement (one line each) rather than at the route layer, so background jobs that construct adapters directly (the Tailscale key-expiry scheduler, IPAM sync, agent-driven Proxmox lookups) get logged too, not just requests through routes/integrations.ts. New diag_log table (ring-buffered to the last 500 rows, mirroring Sloth Manager's approach -- this is for live troubleshooting, not a durable record) and admin-only GET/DELETE /api/diag-log routes, source/ result filters, pagination. New admin-only Diagnostic Log page: filterable, paginated table with a Clear button. Also introduces the shared useSortable hook + SortableTh component used here for the first time -- a follow-up commit applies the same sorting (and CSV export) to the rest of the app's tables, per the same request. Verified end-to-end against a temp SQLite DB with real migrations: a fake wrapped adapter's successful and failing calls both land correctly in the log with the right source/operation/latency/error, and the source/ok filters and clear-log operation all behave correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
42f073df81
commit
e35da87886
26 files changed
+1592
-12
No files matched your search
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE `diag_log` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`source` text NOT NULL,
|
||||
`operation` text NOT NULL,
|
||||
`ok` integer NOT NULL,
|
||||
`latency_ms` integer NOT NULL,
|
||||
`error` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -22,6 +22,13 @@
|
||||
"when": 1789506601790,
|
||||
"tag": "0002_motionless_lord_hawal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1789673523898,
|
||||
"tag": "0003_fantastic_randall",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,6 +34,20 @@ export const auditLog = sqliteTable("audit_log", {
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// ─── Diagnostic log — every outbound call to a DNS provider or integration ──
|
||||
|
||||
export const diagLog = sqliteTable("diag_log", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
source: text("source").notNull(), // e.g. 'cloudflare' | 'tailscale' | 'proxmox' | ...
|
||||
operation: text("operation").notNull(), // adapter method name, e.g. 'listZones' | 'listDevices'
|
||||
ok: integer("ok", { mode: "boolean" }).notNull(),
|
||||
latencyMs: integer("latency_ms").notNull(),
|
||||
error: text("error"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// ─── Settings (key/value) ───────────────────────────────────────────────────
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* resource group to target for record operations.
|
||||
*/
|
||||
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
const ARM_BASE = "https://management.azure.com";
|
||||
const API_VERSION = "2018-05-01";
|
||||
@@ -326,5 +327,5 @@ export function createAzureAdapter(config: AzureConfig): DnsAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
return { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
return withDiagLogging("azure", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* Requires config: apiToken
|
||||
*/
|
||||
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
const BASE = "https://api.cloudflare.com/client/v4";
|
||||
|
||||
@@ -94,5 +95,5 @@ export function createCloudflareAdapter(config: CloudflareConfig): DnsAdapter {
|
||||
await cfFetch(`/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
return { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
return withDiagLogging("cloudflare", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
import * as https from "node:https";
|
||||
import * as http from "node:http";
|
||||
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface CpanelConfig {
|
||||
url: string;
|
||||
@@ -273,5 +274,5 @@ export function createCpanelAdapter(config: CpanelConfig): DnsAdapter {
|
||||
await api2("remove_zone_record", { domain, line: String(lineIndex) });
|
||||
}
|
||||
|
||||
return { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
return withDiagLogging("cpanel", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { parseStringPromise } from "xml2js";
|
||||
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
const ENDPOINT = "https://api.loopia.se/RPCSERV";
|
||||
|
||||
@@ -179,5 +180,5 @@ export function createLoopiaAdapter(config: LoopiaConfig): DnsAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
return { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
return withDiagLogging("loopia", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
* treated as a single zone. Record types are limited to A, AAAA, CNAME.
|
||||
*/
|
||||
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface PiholeConfig {
|
||||
url: string;
|
||||
@@ -138,5 +139,5 @@ export function createPiholeAdapter(config: PiholeConfig): DnsAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
return { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
return withDiagLogging("pihole", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
* Record ID format: "type||name||content||priority"
|
||||
*/
|
||||
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface TechnitiumConfig {
|
||||
url: string;
|
||||
@@ -148,5 +149,5 @@ export function createTechnitiumAdapter(config: TechnitiumConfig): DnsAdapter {
|
||||
await apiPost("/api/zones/records/delete", { domain: name, zone, type, ...typeParams });
|
||||
}
|
||||
|
||||
return { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
return withDiagLogging("technitium", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { authRouter } from "./auth/router.js";
|
||||
import { meRouter } from "./routes/me.js";
|
||||
import { usersRouter } from "./routes/users.js";
|
||||
import { auditLogRouter } from "./routes/auditLog.js";
|
||||
import { diagLogRouter } from "./routes/diagLog.js";
|
||||
import { secretsRouter } from "./routes/secrets.js";
|
||||
import { ipamRouter } from "./routes/ipam.js";
|
||||
import { dnsRouter } from "./routes/dns.js";
|
||||
@@ -66,6 +67,7 @@ app.use("/auth", authRouter);
|
||||
app.use("/api/me", meRouter);
|
||||
app.use("/api/users", usersRouter);
|
||||
app.use("/api/audit-log", auditLogRouter);
|
||||
app.use("/api/diag-log", diagLogRouter);
|
||||
app.use("/api/secrets", secretsRouter);
|
||||
app.use("/api/ipam", ipamRouter);
|
||||
app.use("/api/dns", dnsRouter);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* API reference verified against Dockhand's published OpenAPI spec
|
||||
* (https://github.com/strausmann/mcp-dockhand/blob/main/docs/dockhand-openapi.json).
|
||||
*/
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface DockhandConfig {
|
||||
url: string;
|
||||
@@ -122,5 +123,5 @@ export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
return { ping, listContainers, startContainer, stopContainer, restartContainer };
|
||||
return withDiagLogging("dockhand", { ping, listContainers, startContainer, stopContainer, restartContainer });
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
* API docs: https://gitea.labsconnect.se/api/swagger (or any instance's /api/swagger)
|
||||
* Verified against Gitea 1.27.
|
||||
*/
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface GiteaConfig {
|
||||
url: string;
|
||||
@@ -150,5 +151,5 @@ export function createGiteaAdapter(config: GiteaConfig): GiteaAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
return { ping, listReposWithStatus, rerunFailedJobs };
|
||||
return withDiagLogging("gitea", { ping, listReposWithStatus, rerunFailedJobs });
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
* fetch, to support an "insecure" opt-out of certificate verification.
|
||||
*/
|
||||
import * as https from "node:https";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface ProxmoxConfig {
|
||||
url: string;
|
||||
@@ -398,5 +399,14 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
return { ping, listGuests, getGuestDetail, listNodeStats, startGuest, stopGuest, restartGuest, shutdownGuest };
|
||||
return withDiagLogging("proxmox", {
|
||||
ping,
|
||||
listGuests,
|
||||
getGuestDetail,
|
||||
listNodeStats,
|
||||
startGuest,
|
||||
stopGuest,
|
||||
restartGuest,
|
||||
shutdownGuest,
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
* source (db/Task.go, pkg/task_logger/task_logger.go) for the exact task
|
||||
* status enum, since the swagger doc itself doesn't enumerate it.
|
||||
*/
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface SemaphoreConfig {
|
||||
url: string;
|
||||
@@ -140,5 +141,5 @@ export function createSemaphoreAdapter(config: SemaphoreConfig): SemaphoreAdapte
|
||||
}
|
||||
}
|
||||
|
||||
return { ping, listTemplatesWithStatus, runTemplate };
|
||||
return withDiagLogging("semaphore", { ping, listTemplatesWithStatus, runTemplate });
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
*/
|
||||
import * as https from "node:https";
|
||||
import * as http from "node:http";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface SynologyConfig {
|
||||
url: string;
|
||||
@@ -280,5 +281,5 @@ export function createSynologyAdapter(config: SynologyConfig): SynologyAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
return { ping, getStorageInfo, getSystemInfo };
|
||||
return withDiagLogging("synology", { ping, getStorageInfo, getSystemInfo });
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
* in the same call as the basic fields, so no per-device follow-up is
|
||||
* needed.
|
||||
*/
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
const BASE = "https://api.tailscale.com";
|
||||
|
||||
@@ -129,5 +130,5 @@ export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapte
|
||||
}
|
||||
}
|
||||
|
||||
return { ping, listDevices, setAuthorized, deleteDevice };
|
||||
return withDiagLogging("tailscale", { ping, listDevices, setAuthorized, deleteDevice });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Router } from "express";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
import { getDiagEntries, clearDiagLog } from "../services/diagLog.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
export const diagLogRouter = Router();
|
||||
|
||||
diagLogRouter.use(requireAuth, requireRole("admin"));
|
||||
|
||||
diagLogRouter.get("/", asyncHandler(async (req, res) => {
|
||||
const { source, ok, limit, offset } = req.query;
|
||||
const result = await getDiagEntries({
|
||||
source: typeof source === "string" && source ? source : undefined,
|
||||
ok: ok === "true" ? true : ok === "false" ? false : undefined,
|
||||
limit: Math.min(Number(limit) || 100, 200),
|
||||
offset: Number(offset) || 0,
|
||||
});
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
diagLogRouter.delete("/", asyncHandler(async (req, res) => {
|
||||
await clearDiagLog();
|
||||
await recordAudit({ actor: req.currentUser!, category: "diag_log", action: "clear" });
|
||||
res.status(204).end();
|
||||
}));
|
||||
@@ -0,0 +1,87 @@
|
||||
import { and, desc, eq, lt, sql } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { diagLog } from "../db/schema.js";
|
||||
|
||||
const MAX_ENTRIES = 500;
|
||||
|
||||
/** Records one outbound-call result. Never throws — a logging failure must not break the call it's logging. */
|
||||
async function recordDiagEntry(entry: { source: string; operation: string; ok: boolean; latencyMs: number; error: string | null }) {
|
||||
try {
|
||||
await db.insert(diagLog).values(entry);
|
||||
// Trim to the most recent MAX_ENTRIES rows (a simple ring buffer, mirroring
|
||||
// Sloth Manager's diagnostic log — this table is for live troubleshooting,
|
||||
// not a durable audit trail, so unbounded growth isn't worth guarding here).
|
||||
const [cutoff] = await db.select({ id: diagLog.id }).from(diagLog).orderBy(desc(diagLog.id)).limit(1).offset(MAX_ENTRIES);
|
||||
if (cutoff) {
|
||||
await db.delete(diagLog).where(lt(diagLog.id, cutoff.id));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[diagLog] failed to record entry:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps every method of an adapter (DNS provider or integration) so each call
|
||||
* is timed and recorded to the diagnostic log, success or failure, without
|
||||
* touching the adapter's own request/error-handling logic. Safe for any
|
||||
* adapter whose interface is entirely async methods (true for every DNS and
|
||||
* integration adapter in this codebase).
|
||||
*/
|
||||
export function withDiagLogging<T extends object>(source: string, adapter: T): T {
|
||||
const wrapped = {} as T;
|
||||
for (const key of Object.keys(adapter) as (keyof T)[]) {
|
||||
const value = adapter[key];
|
||||
if (typeof value !== "function") {
|
||||
wrapped[key] = value;
|
||||
continue;
|
||||
}
|
||||
const original = value as (...args: unknown[]) => Promise<unknown>;
|
||||
wrapped[key] = (async (...args: unknown[]) => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await original.apply(adapter, args);
|
||||
recordDiagEntry({ source, operation: String(key), ok: true, latencyMs: Date.now() - start, error: null });
|
||||
return result;
|
||||
} catch (err) {
|
||||
recordDiagEntry({
|
||||
source,
|
||||
operation: String(key),
|
||||
ok: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}) as T[keyof T];
|
||||
}
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
export interface DiagLogQuery {
|
||||
source?: string;
|
||||
ok?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export async function getDiagEntries({ source, ok, limit = 100, offset = 0 }: DiagLogQuery) {
|
||||
const conditions = [];
|
||||
if (source) conditions.push(eq(diagLog.source, source));
|
||||
if (ok !== undefined) conditions.push(eq(diagLog.ok, ok));
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(diagLog).where(where);
|
||||
const entries = await db
|
||||
.select()
|
||||
.from(diagLog)
|
||||
.where(where)
|
||||
.orderBy(desc(diagLog.id))
|
||||
.limit(Math.min(limit, 200))
|
||||
.offset(offset);
|
||||
|
||||
return { total, entries };
|
||||
}
|
||||
|
||||
export async function clearDiagLog(): Promise<void> {
|
||||
await db.delete(diagLog);
|
||||
}
|
||||
Reference in new issue
Block a user