Compare commits

..
2 Commits
Author SHA1 Message Date
bobbanandClaude Sonnet 5.5 88d9c8e097 Record sign-ins, new accounts, automatic log trimming and what settings changed
A check of every write path found gaps in what the audit log captured:

- Sign-ins and sign-outs are now recorded with the IP they came from
  (sign-out is recorded first and can't block signing out).
- A new account is recorded when it's created on first sign-in, including
  when the very first user becomes admin - so the log shows who gained
  access, not only who changed things.
- The automatic log purge, which deletes audit entries, now records
  itself, attributed to "system". recordAudit() takes an optional actor
  for this. It only records when something was actually deleted.
- Settings updates record what changed (before and after) instead of only
  which sections were touched. The notification channels (Gotify, ntfy,
  SMTP, webhook) record field names only: they hold credentials, and
  webhook URLs and public ntfy topics act as secrets, while the audit log
  is readable by operators and Settings is admin-only.
- Integration edits record renames, enabling/disabling, whether
  credentials were replaced (never the credentials), and which settings
  fields changed (names only).

The Privacy page and README now say sign-ins store an IP in the audit log.

Verified through the real routes against a scratch database: user
creation, the logout route, the automatic purge, settings and
integration edits - including that a secret token and a webhook URL
appear nowhere in the stored entries. The sign-in callback itself needs
a real identity provider and wasn't run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 22:40:46 +02:00
bobbanandClaude Sonnet 5.5 22cfdbede0 Fix how the audit log displays entries; record admin-link and domain checks
Entries were stored in UTC without a zone marker and the Audit and
Diagnostic Log pages read them as local time, so every entry showed
shifted by the viewer's UTC offset (two hours early in Sweden). A shared
parseDbTimestamp() now reads them as UTC, and replaces the inline
workaround the Consistency page had.

The Audit Log never displayed an entry's details at all, so adding an
admin link showed only "server #1". Link entries now carry the server
name and the label/URL, and a new Details column shows them, along with
things like a port scan's address and range. Entries made within the
same second are now ordered by id instead of arbitrarily.

A domain's "Check now" was the one user-triggered action that wasn't
audited; it is now.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 22:40:35 +02:00
16 changed files with 184 additions and 27 deletions

No files matched your search

+2
View File
@@ -18,6 +18,8 @@ All modules from the original plan are built:
- Monorepo scaffold, Tabler-themed app shell with a grouped sidebar (Infrastructure, Network, Automation, Operations, Administration; groups open on demand, the one holding the current page is always open, and what you leave open is remembered) - Monorepo scaffold, Tabler-themed app shell with a grouped sidebar (Infrastructure, Network, Automation, Operations, Administration; groups open on demand, the one holding the current page is always open, and what you leave open is remembered)
- Authentik OIDC login, roles (first user to sign in becomes admin), audit log - Authentik OIDC login, roles (first user to sign in becomes admin), audit log
(changes made in the app, sign-ins and sign-outs with the IP they came from, new accounts, and the log's own
automatic trimming — attributed to "system"; settings changes show what changed, but never credentials)
- **Dashboard** — an overview of every system this app tracks, all sharing - **Dashboard** — an overview of every system this app tracks, all sharing
one widget-card design (label + status badge, a small stat row, then its one widget-card design (label + status badge, a small stat row, then its
own breakdown): DNS (domain/record counts per provider, cached records by own breakdown): DNS (domain/record counts per provider, cached records by
+46 -1
View File
@@ -1,8 +1,12 @@
import { Router } from "express"; import { Router } from "express";
import * as client from "openid-client"; import * as client from "openid-client";
import { eq } from "drizzle-orm";
import { getOidcConfig } from "./oidc.js"; import { getOidcConfig } from "./oidc.js";
import { upsertUserFromLogin } from "./users.js"; import { upsertUserFromLogin } from "./users.js";
import { env } from "../env.js"; import { env } from "../env.js";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
import { recordAudit } from "../services/audit.js";
export const authRouter = Router(); export const authRouter = Router();
@@ -63,7 +67,28 @@ authRouter.get("/callback", async (req, res, next) => {
// fall back to ID token claims already captured above // fall back to ID token claims already captured above
} }
await upsertUserFromLogin({ sub: claims.sub, email, name }); const { user, created } = await upsertUserFromLogin({ sub: claims.sub, email, name });
// Access to the app is itself something worth being able to look back on: who got an account (and the very first
// one becomes admin), and every sign-in with where it came from.
if (created) {
await recordAudit({
actor: user,
category: "user",
action: "create",
targetType: "user",
targetId: user.id,
detail: { name: user.name ?? user.email ?? user.oidcSub, role: user.role, firstUser: user.role === "admin" },
});
}
await recordAudit({
actor: user,
category: "session",
action: "login",
targetType: "user",
targetId: user.id,
detail: { name: user.name ?? user.email ?? user.oidcSub, ip: req.ip },
});
delete req.session.pendingAuth; delete req.session.pendingAuth;
req.session.user = { req.session.user = {
@@ -85,6 +110,26 @@ authRouter.get("/callback", async (req, res, next) => {
authRouter.get("/logout", async (req, res, next) => { authRouter.get("/logout", async (req, res, next) => {
const idToken = req.session.user?.idToken; const idToken = req.session.user?.idToken;
// Recorded first, and never allowed to get in the way of signing out.
if (req.session.user) {
try {
const [user] = await db.select().from(users).where(eq(users.oidcSub, req.session.user.sub)).limit(1);
if (user) {
await recordAudit({
actor: user,
category: "session",
action: "logout",
targetType: "user",
targetId: user.id,
detail: { name: user.name ?? user.email ?? user.oidcSub, ip: req.ip },
});
}
} catch (err) {
console.error("[auth] couldn't record sign-out:", err);
}
}
try { try {
const config = await getOidcConfig(); const config = await getOidcConfig();
let endSessionUrl: URL | undefined; let endSessionUrl: URL | undefined;
+3 -3
View File
@@ -11,7 +11,7 @@ export async function upsertUserFromLogin(params: {
sub: string; sub: string;
email?: string; email?: string;
name?: string; name?: string;
}) { }): Promise<{ user: typeof users.$inferSelect; created: boolean }> {
const [existing] = await db.select().from(users).where(eq(users.oidcSub, params.sub)).limit(1); const [existing] = await db.select().from(users).where(eq(users.oidcSub, params.sub)).limit(1);
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -25,7 +25,7 @@ export async function upsertUserFromLogin(params: {
}) })
.where(eq(users.id, existing.id)) .where(eq(users.id, existing.id))
.returning(); .returning();
return updated; return { user: updated, created: false };
} }
const anyUser = await db.select({ id: users.id }).from(users).limit(1); const anyUser = await db.select({ id: users.id }).from(users).limit(1);
@@ -41,5 +41,5 @@ export async function upsertUserFromLogin(params: {
lastLoginAt: now, lastLoginAt: now,
}) })
.returning(); .returning();
return created; return { user: created, created: true };
} }
+2 -1
View File
@@ -11,6 +11,7 @@ auditLogRouter.use(requireAuth, requireRole("operator"));
auditLogRouter.get("/", asyncHandler(async (req, res) => { auditLogRouter.get("/", asyncHandler(async (req, res) => {
const limit = Math.min(Number(req.query.limit ?? 200), 500); const limit = Math.min(Number(req.query.limit ?? 200), 500);
const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(limit); // createdAt only has one-second resolution, so entries made within the same second are ordered by id.
const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt), desc(auditLog.id)).limit(limit);
res.json({ entries: rows }); res.json({ entries: rows });
})); }));
+8
View File
@@ -60,6 +60,14 @@ domainsRouter.post("/:id/check", requireRole("operator"), asyncHandler(async (re
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const row = await checkDomain(id); const row = await checkDomain(id);
if (!row) return res.status(404).json({ error: "not_found" }); if (!row) return res.status(404).json({ error: "not_found" });
await recordAudit({
actor: req.currentUser!,
category: "domain",
action: "check",
targetType: "domain",
targetId: id,
detail: { name: row.name, error: row.lastCheckError },
});
const { healthChecks } = await getSettings(); const { healthChecks } = await getSettings();
res.json({ domain: present(row, healthChecks.domainWarnDays) }); res.json({ domain: present(row, healthChecks.domainWarnDays) });
})); }));
+15 -1
View File
@@ -139,10 +139,18 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
let credentialId = existing.credentialId; let credentialId = existing.credentialId;
let configJson = existing.config; let configJson = existing.config;
let baseUrl = existing.baseUrl; let baseUrl = existing.baseUrl;
// For the audit entry: what this edit actually changed. Names only for settings (operators can read the audit
// log but not an integration's config), and never anything about the credentials beyond "they were replaced".
let credentialsReplaced = false;
let fieldsChanged: string[] = [];
if (parsed.data.config) { if (parsed.data.config) {
const loaded = await loadIntegrationConfig(id); const loaded = await loadIntegrationConfig(id);
const { secretFields, nonSecretFields } = splitIntegrationConfig(existing.type, parsed.data.config); const { secretFields, nonSecretFields } = splitIntegrationConfig(existing.type, parsed.data.config);
credentialsReplaced = Object.keys(secretFields).length > 0;
fieldsChanged = Object.keys(nonSecretFields).filter(
(key) => String(loaded?.config[key] ?? "") !== String(nonSecretFields[key] ?? ""),
);
const mergedNonSecret = { ...(loaded?.config ?? {}), ...nonSecretFields }; const mergedNonSecret = { ...(loaded?.config ?? {}), ...nonSecretFields };
for (const field of INTEGRATION_FIELDS[existing.type] ?? []) { for (const field of INTEGRATION_FIELDS[existing.type] ?? []) {
if (field.secret) delete (mergedNonSecret as Record<string, unknown>)[field.key]; if (field.secret) delete (mergedNonSecret as Record<string, unknown>)[field.key];
@@ -188,7 +196,13 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
action: "update", action: "update",
targetType: "integration", targetType: "integration",
targetId: id, targetId: id,
detail: { name: updated.name }, detail: {
name: updated.name,
...(existing.name !== updated.name ? { renamedFrom: existing.name } : {}),
...(existing.enabled !== updated.enabled ? { enabled: updated.enabled } : {}),
credentialsReplaced,
fieldsChanged,
},
}); });
res.json({ res.json({
+6 -4
View File
@@ -341,7 +341,7 @@ serversRouter.post("/:id/links", requireRole("operator"), asyncHandler(async (re
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
} }
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, serverId)).limit(1); const [server] = await db.select({ id: servers.id, name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
if (!server) return res.status(404).json({ error: "not_found" }); if (!server) return res.status(404).json({ error: "not_found" });
const [created] = await db.insert(serverLinks).values({ serverId, ...parsed.data }).returning(); const [created] = await db.insert(serverLinks).values({ serverId, ...parsed.data }).returning();
@@ -352,7 +352,7 @@ serversRouter.post("/:id/links", requireRole("operator"), asyncHandler(async (re
action: "add_link", action: "add_link",
targetType: "server", targetType: "server",
targetId: serverId, targetId: serverId,
detail: { label: created.label, url: created.url }, detail: { name: server.name, label: created.label, url: created.url },
}); });
res.status(201).json({ link: { id: created.id, label: created.label, url: created.url } }); res.status(201).json({ link: { id: created.id, label: created.label, url: created.url } });
@@ -375,13 +375,14 @@ serversRouter.patch("/:id/links/:linkId", requireRole("operator"), asyncHandler(
.returning(); .returning();
if (!updated) return res.status(404).json({ error: "not_found" }); if (!updated) return res.status(404).json({ error: "not_found" });
const [owner] = await db.select({ name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
await recordAudit({ await recordAudit({
actor: req.currentUser!, actor: req.currentUser!,
category: "server", category: "server",
action: "update_link", action: "update_link",
targetType: "server", targetType: "server",
targetId: serverId, targetId: serverId,
detail: { label: updated.label, url: updated.url }, detail: { name: owner?.name, label: updated.label, url: updated.url },
}); });
res.json({ link: { id: updated.id, label: updated.label, url: updated.url } }); res.json({ link: { id: updated.id, label: updated.label, url: updated.url } });
@@ -398,13 +399,14 @@ serversRouter.delete("/:id/links/:linkId", requireRole("operator"), asyncHandler
.returning(); .returning();
if (deleted.length === 0) return res.status(404).json({ error: "not_found" }); if (deleted.length === 0) return res.status(404).json({ error: "not_found" });
const [owner] = await db.select({ name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
await recordAudit({ await recordAudit({
actor: req.currentUser!, actor: req.currentUser!,
category: "server", category: "server",
action: "remove_link", action: "remove_link",
targetType: "server", targetType: "server",
targetId: serverId, targetId: serverId,
detail: { label: deleted[0].label }, detail: { name: owner?.name, label: deleted[0].label, url: deleted[0].url },
}); });
res.status(204).end(); res.status(204).end();
+3 -1
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { requireAuth, requireRole } from "../auth/middleware.js"; import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js"; import { recordAudit } from "../services/audit.js";
import { getSettings, updateSettings } from "../services/settingsStore.js"; import { getSettings, updateSettings } from "../services/settingsStore.js";
import { describeSettingsChanges } from "../services/settingsDiff.js";
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js"; import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.js"; import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.js";
import { scheduleDockerUpdateCheck } from "../services/dockerUpdateScheduler.js"; import { scheduleDockerUpdateCheck } from "../services/dockerUpdateScheduler.js";
@@ -106,6 +107,7 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
} }
const before = await getSettings();
const updated = await updateSettings(parsed.data); const updated = await updateSettings(parsed.data);
if (parsed.data.notifications) { if (parsed.data.notifications) {
@@ -127,7 +129,7 @@ settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
category: "settings", category: "settings",
action: "update", action: "update",
targetType: "settings", targetType: "settings",
detail: { sections: Object.keys(parsed.data) }, detail: { sections: Object.keys(parsed.data), changes: describeSettingsChanges(before, parsed.data) },
}); });
res.json({ settings: updated }); res.json({ settings: updated });
+7 -4
View File
@@ -3,9 +3,12 @@ import { auditLog, users } from "../db/schema.js";
type CurrentUser = typeof users.$inferSelect; type CurrentUser = typeof users.$inferSelect;
/** Records one audit-log entry. Call this from any route that mutates state or takes an action. */ /** Who an automatic, no-one-clicked-anything entry is attributed to. */
export const SYSTEM_ACTOR_LABEL = "system";
/** Records one audit-log entry. Call this from any route that mutates state or takes an action. Leave `actor` out for something the app did by itself. */
export async function recordAudit(params: { export async function recordAudit(params: {
actor: CurrentUser; actor?: CurrentUser;
category: string; category: string;
action: string; action: string;
targetType?: string; targetType?: string;
@@ -13,8 +16,8 @@ export async function recordAudit(params: {
detail?: unknown; detail?: unknown;
}) { }) {
await db.insert(auditLog).values({ await db.insert(auditLog).values({
actorUserId: params.actor.id, actorUserId: params.actor?.id,
actorLabel: params.actor.name ?? params.actor.email ?? params.actor.oidcSub, actorLabel: params.actor ? (params.actor.name ?? params.actor.email ?? params.actor.oidcSub) : SYSTEM_ACTOR_LABEL,
category: params.category, category: params.category,
action: params.action, action: params.action,
targetType: params.targetType, targetType: params.targetType,
@@ -1,5 +1,6 @@
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js"; import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
import { purgeOldLogs } from "./logRetention.js"; import { purgeOldLogs } from "./logRetention.js";
import { recordAudit } from "./audit.js";
const LAST_RUN_FLAG = "logRetentionLastRunAt"; const LAST_RUN_FLAG = "logRetentionLastRunAt";
@@ -9,6 +10,12 @@ async function runPurge(): Promise<void> {
const result = await purgeOldLogs(logRetention.retentionDays); const result = await purgeOldLogs(logRetention.retentionDays);
await setInternalFlag(LAST_RUN_FLAG, new Date().toISOString()); await setInternalFlag(LAST_RUN_FLAG, new Date().toISOString());
if (result.diagDeleted || result.auditDeleted) { if (result.diagDeleted || result.auditDeleted) {
// Trimming the audit log is itself something to be able to look back on — attributed to the system, since no one asked for it.
await recordAudit({
category: "settings",
action: "purge_logs",
detail: { automatic: true, retentionDays: logRetention.retentionDays, ...result },
});
console.log( console.log(
`[logRetention] purged ${result.diagDeleted} diagnostic log and ${result.auditDeleted} audit log entries older than ${logRetention.retentionDays} days`, `[logRetention] purged ${result.diagDeleted} diagnostic log and ${result.auditDeleted} audit log entries older than ${logRetention.retentionDays} days`,
); );
+41
View File
@@ -0,0 +1,41 @@
/**
* What a settings update actually changed, in a form fit for the audit log.
*
* The audit log can be read by operators, but Settings can't — so values only go in for sections an operator could
* already see in the app. The notification channels (Gotify, ntfy, SMTP, webhook) hold credentials, and even their
* addresses can act as one (a webhook URL carries its own token; a public ntfy topic is the only thing protecting
* it), so for those only the names of the fields that changed are recorded, never what they changed to or from.
*/
const NAMES_ONLY_SECTIONS = new Set(["gotify", "ntfy", "smtp", "webhook"]);
/** Anything longer than this isn't a useful thing to read in a table cell. */
const MAX_VALUE_JSON = 300;
export type SettingsChange = { from: unknown; to: unknown } | "(changed)";
function same(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function capture(value: unknown): unknown {
return value !== undefined && JSON.stringify(value)?.length > MAX_VALUE_JSON ? "(too long to show)" : value;
}
/**
* Compares each section in `patch` with what was stored before. Only fields that really differ are listed, and a
* section where nothing differed is left out entirely.
*/
export function describeSettingsChanges(before: object, patch: object): Record<string, Record<string, SettingsChange>> {
const out: Record<string, Record<string, SettingsChange>> = {};
for (const [section, incoming] of Object.entries(patch)) {
if (incoming === null || typeof incoming !== "object") continue;
const previous = ((before as Record<string, unknown>)[section] ?? {}) as Record<string, unknown>;
const changes: Record<string, SettingsChange> = {};
for (const [key, value] of Object.entries(incoming as Record<string, unknown>)) {
if (same(previous[key], value)) continue;
changes[key] = NAMES_ONLY_SECTIONS.has(section) ? "(changed)" : { from: capture(previous[key]), to: capture(value) };
}
if (Object.keys(changes).length > 0) out[section] = changes;
}
return out;
}
+25 -5
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, type AuditLogEntry } from "../api/client"; import { api, type AuditLogEntry } from "../api/client";
import { formatDateTime } from "../utils/date"; import { formatDateTime, parseDbTimestamp } from "../utils/date";
import { useSortable } from "../hooks/useSortable"; import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh"; import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination"; import { usePagination } from "../hooks/usePagination";
@@ -23,6 +23,22 @@ function targetLabel(e: AuditLogEntry): string {
return `${typeLabel}${e.targetId ? ` #${e.targetId}` : ""}`; return `${typeLabel}${e.targetId ? ` #${e.targetId}` : ""}`;
} }
/** What was done, beyond the target — the link's label and URL, a scan's address and range, and so on. The name is already in the Target column. */
function detailSummary(e: AuditLogEntry): string {
if (!e.detail) return "";
let parsed: unknown;
try {
parsed = JSON.parse(e.detail);
} catch {
return e.detail;
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return String(parsed);
return Object.entries(parsed as Record<string, unknown>)
.filter(([key, value]) => key !== "name" && value !== null && value !== undefined && value !== "" && !(Array.isArray(value) && value.length === 0))
.map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`)
.join(" · ");
}
export default function AuditLog() { export default function AuditLog() {
const [entries, setEntries] = useState<AuditLogEntry[] | null>(null); const [entries, setEntries] = useState<AuditLogEntry[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -41,8 +57,8 @@ export default function AuditLog() {
if (!sorted) return; if (!sorted) return;
downloadCsv( downloadCsv(
"audit-log.csv", "audit-log.csv",
["When", "Actor", "Category", "Action", "Target"], ["When", "Actor", "Category", "Action", "Target", "Details"],
sorted.map((e) => [formatDateTime(new Date(e.createdAt)), e.actorLabel ?? "", e.category, e.action, targetLabel(e)]), sorted.map((e) => [formatDateTime(parseDbTimestamp(e.createdAt)), e.actorLabel ?? "", e.category, e.action, targetLabel(e), detailSummary(e)]),
); );
} }
@@ -65,23 +81,27 @@ export default function AuditLog() {
<SortableTh<AuditLogEntry> label="Category" sortKeyName="category" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> <SortableTh<AuditLogEntry> label="Category" sortKeyName="category" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<AuditLogEntry> label="Action" sortKeyName="action" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> <SortableTh<AuditLogEntry> label="Action" sortKeyName="action" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<AuditLogEntry> label="Target" sortKeyName="targetType" activeKey={sortKey} direction={sortDir} onSort={requestSort} /> <SortableTh<AuditLogEntry> label="Target" sortKeyName="targetType" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<th>Details</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{pageItems?.map((e) => ( {pageItems?.map((e) => (
<tr key={e.id}> <tr key={e.id}>
<td>{formatDateTime(new Date(e.createdAt))}</td> <td>{formatDateTime(parseDbTimestamp(e.createdAt))}</td>
<td>{e.actorLabel ?? "—"}</td> <td>{e.actorLabel ?? "—"}</td>
<td> <td>
<span className="badge bg-blue-lt">{e.category}</span> <span className="badge bg-blue-lt">{e.category}</span>
</td> </td>
<td>{e.action}</td> <td>{e.action}</td>
<td>{targetLabel(e)}</td> <td>{targetLabel(e)}</td>
<td className="text-secondary small text-break" style={{ maxWidth: 420 }}>
{detailSummary(e) || "—"}
</td>
</tr> </tr>
))} ))}
{sorted?.length === 0 && ( {sorted?.length === 0 && (
<tr> <tr>
<td colSpan={5} className="text-secondary text-center"> <td colSpan={6} className="text-secondary text-center">
No activity recorded yet. No activity recorded yet.
</td> </td>
</tr> </tr>
+2 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api, type ConsistencyFinding, type ConsistencyKind, type ConsistencyReport, type ConsistencySeverity, type CurrentUser } from "../api/client"; import { api, type ConsistencyFinding, type ConsistencyKind, type ConsistencyReport, type ConsistencySeverity, type CurrentUser } from "../api/client";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
import { formatDateTime } from "../utils/date"; import { formatDateTime, parseDbTimestamp } from "../utils/date";
import { formatAgo } from "../utils/duration"; import { formatAgo } from "../utils/duration";
import { readableError } from "../utils/errors"; import { readableError } from "../utils/errors";
@@ -356,7 +356,7 @@ Range (a network like 192.168.16.0/20, or a single address):`,
<div className="text-secondary small"> <div className="text-secondary small">
{i.reason ? `${i.reason} · ` : ""} {i.reason ? `${i.reason} · ` : ""}
{i.createdBy ? `${i.createdBy}, ` : ""} {i.createdBy ? `${i.createdBy}, ` : ""}
{formatDateTime(new Date(i.createdAt.includes("T") ? i.createdAt : `${i.createdAt.replace(" ", "T")}Z`))} {formatDateTime(parseDbTimestamp(i.createdAt))}
{!i.stillPresent && " · no longer occurring"} {!i.stillPresent && " · no longer occurring"}
</div> </div>
</div> </div>
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, type DiagLogEntry } from "../api/client"; import { api, type DiagLogEntry } from "../api/client";
import { formatDateTime } from "../utils/date"; import { formatDateTime, parseDbTimestamp } from "../utils/date";
import { useSortable } from "../hooks/useSortable"; import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh"; import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv"; import { downloadCsv } from "../utils/csv";
@@ -84,7 +84,7 @@ export default function DiagLog() {
downloadCsv( downloadCsv(
"diagnostic-log.csv", "diagnostic-log.csv",
["Time", "Source", "Operation", "OK", "Latency (ms)", "Error"], ["Time", "Source", "Operation", "OK", "Latency (ms)", "Error"],
sorted.map((e) => [formatDateTime(new Date(e.createdAt)), e.source, e.operation, e.ok ? "yes" : "no", e.latencyMs, e.error ?? ""]), sorted.map((e) => [formatDateTime(parseDbTimestamp(e.createdAt)), e.source, e.operation, e.ok ? "yes" : "no", e.latencyMs, e.error ?? ""]),
); );
} }
@@ -167,7 +167,7 @@ export default function DiagLog() {
{sorted?.map((e) => ( {sorted?.map((e) => (
<tr key={e.id}> <tr key={e.id}>
<td className="text-secondary" style={{ whiteSpace: "nowrap" }}> <td className="text-secondary" style={{ whiteSpace: "nowrap" }}>
{formatDateTime(new Date(e.createdAt))} {formatDateTime(parseDbTimestamp(e.createdAt))}
</td> </td>
<td>{SOURCE_LABELS[e.source] ?? e.source}</td> <td>{SOURCE_LABELS[e.source] ?? e.source}</td>
<td className="text-secondary">{e.operation}</td> <td className="text-secondary">{e.operation}</td>
+5 -2
View File
@@ -163,7 +163,10 @@ export default function Privacy() {
</tr> </tr>
<tr> <tr>
<td>Audit log</td> <td>Audit log</td>
<td>Who changed what: your name (or email) as it was at the time, the action, what it was done to, and details of the change.</td> <td>
Who changed what: your name (or email) as it was at the time, the action, what it was done to, and details of the change.
Also each time you sign in or out — with the IP address you came from — and when your account was first created.
</td>
<td> <td>
{retention?.enabled {retention?.enabled
? `Entries older than ${retention.retentionDays} days are deleted (checked every ${retention.intervalHours} h).` ? `Entries older than ${retention.retentionDays} days are deleted (checked every ${retention.intervalHours} h).`
@@ -288,7 +291,7 @@ export default function Privacy() {
<div className="card-body"> <div className="card-body">
<ul className="mb-0"> <ul className="mb-0">
<li className="mb-2">Everyone signed in: the inventory and status pages, including server, IP, domain and secret-name details.</li> <li className="mb-2">Everyone signed in: the inventory and status pages, including server, IP, domain and secret-name details.</li>
<li className="mb-2">Operators and admins: also the audit log — who did what.</li> <li className="mb-2">Operators and admins: also the audit log — who did what, and who signed in from where.</li>
<li>Admins only: the user list, everyone's active sign-ins (with IP and browser), the diagnostic log and settings.</li> <li>Admins only: the user list, everyone's active sign-ins (with IP and browser), the diagnostic log and settings.</li>
</ul> </ul>
</div> </div>
+9
View File
@@ -17,6 +17,15 @@ export function is24HourFormat(): boolean {
return current.timeFormat === "24h"; return current.timeFormat === "24h";
} }
/**
* Parses a timestamp the server stored. SQLite's own `current_timestamp` ("2026-10-02 20:27:55") is UTC but carries no
* zone marker, and `new Date()` would read that as local time — showing every entry shifted by the viewer's UTC offset.
* Timestamps the app wrote itself are ISO strings with a zone and parse as they are.
*/
export function parseDbTimestamp(value: string): Date {
return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(value) ? value : `${value.replace(" ", "T")}Z`);
}
function pad(n: number): string { function pad(n: number): string {
return String(n).padStart(2, "0"); return String(n).padStart(2, "0");
} }