Skip to content

Commit 57bdf1d

Browse files
voidstackloopclaude
andcommitted
frontend: Settings UI toggle for the experimental SQLite audit backend
Makes the opt-in auditLogBackend flag (previous commit) actually reachable by a human instead of only by editing settings.json directly. New audit:sqliteCapability IPC handler surfaces getSqliteStoreCapabilityReport() so the UI can explain *why* the toggle is disabled when the native addon isn't built, rather than it silently doing nothing. Audit & Privacy gets a new "Audit log storage backend" card: current backend badge, a disabled state with reason text when SQLite isn't available, and a switch button that migrates existing events in automatically (JSON is never deleted, so switching back is always possible). New e2e spec (audit-sqlite-backend-toggle.spec.ts) drives the real Electron app: create a case on JSON, switch to SQLite, confirm the migrated event survived, switch back, confirm it's still there. Screenshotted the resulting Audit & Privacy page to confirm visual placement. Full e2e suite (10 specs) and unit suites (581 app + 95 frontend) all still pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 79baf71 commit 57bdf1d

5 files changed

Lines changed: 117 additions & 0 deletions

File tree

app/src/ipc/audit-handlers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { ipcMain, IpcMainInvokeEvent } from "electron";
22
import * as auditLogStore from "../audit-log-store";
3+
import { getSqliteStoreCapabilityReport } from "../native-sqlite-store";
34

45
export function registerAuditIpc(): void {
56
ipcMain.handle("audit:list", () => auditLogStore.listEvents());
67
ipcMain.handle("audit:clearAll", () => auditLogStore.clearAll());
78
ipcMain.handle("audit:verifyIntegrity", () => auditLogStore.verifyChainIntegrity());
9+
// Lets Settings explain *why* the experimental SQLite backend silently
10+
// stayed on JSON, rather than the toggle just appearing to do nothing.
11+
ipcMain.handle("audit:sqliteCapability", () => getSqliteStoreCapabilityReport());
812

913
ipcMain.handle(
1014
"audit:record",

app/src/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,8 @@ export const api = {
466466
): Promise<AuditEvent> => ipcRenderer.invoke("audit:record", { actionCategory, fields }),
467467
verifyIntegrity: (): Promise<{ valid: boolean; checkedCount: number; brokenAtIndex?: number; reason?: string }> =>
468468
ipcRenderer.invoke("audit:verifyIntegrity"),
469+
sqliteCapability: (): Promise<{ available: boolean; reason?: string; detail?: string }> =>
470+
ipcRenderer.invoke("audit:sqliteCapability"),
469471
},
470472

471473
encryption: {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { test, expect } from "@playwright/test";
2+
import { launchApp, makeUserDataDir, type LaunchedApp } from "../fixtures/electron-app";
3+
4+
// Exercises the Settings UI toggle for the experimental SQLite audit-log
5+
// backend (see docs/RUST_MIGRATION_ASSESSMENT.md) end to end: creating a
6+
// case while on JSON, switching to SQLite, confirming the migrated event is
7+
// still visible, then switching back — through the real IPC path, not a
8+
// mock. Assumes the native addon is built (app/native present); if it
9+
// isn't, the toggle is expected to stay disabled, which this test doesn't
10+
// cover — see native-sqlite-store.test.ts / audit-log-store.test.ts for the
11+
// addon-unavailable fallback behavior at the unit level.
12+
13+
let instance: LaunchedApp;
14+
15+
test.afterEach(async () => {
16+
await instance?.close();
17+
});
18+
19+
test("switching the audit log storage backend migrates existing events and back again", async () => {
20+
const userDataDir = makeUserDataDir();
21+
instance = await launchApp({ userDataDir, settings: { onboardingComplete: true } });
22+
23+
await instance.window.getByRole("button", { name: "Patient Cases" }).click();
24+
await instance.window.getByLabel("New case").fill("Backend toggle test case");
25+
await instance.window.getByRole("button", { name: "New case" }).click();
26+
await expect(instance.window.getByText("Backend toggle test case")).toBeVisible();
27+
28+
await instance.window.getByRole("button", { name: "Audit & Privacy" }).click();
29+
await expect(instance.window.getByText("JSON (default)")).toBeVisible();
30+
await expect(instance.window.getByText("Case created")).toBeVisible();
31+
32+
const toggle = instance.window.getByRole("button", { name: /Switch to experimental SQLite backend|Switch back to JSON/ });
33+
await expect(toggle).toBeEnabled({ timeout: 10_000 });
34+
await toggle.click();
35+
36+
await expect(instance.window.getByText("SQLite")).toBeVisible();
37+
// The pre-existing event must have migrated in, not disappeared.
38+
await expect(instance.window.getByText("Case created")).toBeVisible();
39+
40+
await instance.window.getByRole("button", { name: "Switch back to JSON" }).click();
41+
await expect(instance.window.getByText("JSON (default)")).toBeVisible();
42+
await expect(instance.window.getByText("Case created")).toBeVisible();
43+
});

frontend/src/pages/AuditPrivacy.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,70 @@ function ModelRegistrySection() {
332332
);
333333
}
334334

335+
/** Experimental, opt-in: routes the audit log through a Rust/SQLite store
336+
* instead of the JSON file (see docs/RUST_MIGRATION_ASSESSMENT.md). Off by
337+
* default and safe to leave off — this exists to make the backend
338+
* switchable/testable, not because JSON has a known problem serious enough
339+
* to recommend switching. */
340+
function StorageBackendSection({ onBackendChanged }: { onBackendChanged: () => void }) {
341+
const [backend, setBackend] = useState<"json" | "sqlite">("json");
342+
const [capability, setCapability] = useState<{ available: boolean; reason?: string; detail?: string } | null>(null);
343+
const [busy, setBusy] = useState(false);
344+
345+
function refresh() {
346+
window.api.settings.get().then((s) => setBackend(s.auditLogBackend ?? "json"));
347+
window.api.audit.sqliteCapability().then(setCapability);
348+
}
349+
useEffect(refresh, []);
350+
351+
async function toggle() {
352+
setBusy(true);
353+
try {
354+
const next = backend === "sqlite" ? "json" : "sqlite";
355+
await window.api.settings.save({ auditLogBackend: next });
356+
refresh();
357+
onBackendChanged();
358+
} finally {
359+
setBusy(false);
360+
}
361+
}
362+
363+
const capabilityUnavailableReason: Record<string, string> = {
364+
"not-built": "the native module wasn't built into this install",
365+
"abi-or-platform-mismatch": "the native module doesn't match this machine's platform/Node version",
366+
"load-error": "the native module failed to load",
367+
};
368+
369+
return (
370+
<div className="rounded-xl border border-border/70 bg-card p-3.5">
371+
<div className="flex items-center justify-between gap-2">
372+
<div>
373+
<p className="text-xs font-semibold">Audit log storage backend</p>
374+
<p className="mt-1 text-xs text-muted-foreground">
375+
Experimental. Existing events migrate in automatically the first time you switch, and are
376+
never deleted from the JSON file — switching back to JSON afterward is always possible.
377+
</p>
378+
</div>
379+
<Badge variant={backend === "sqlite" ? "warning" : "secondary"}>{backend === "sqlite" ? "SQLite" : "JSON (default)"}</Badge>
380+
</div>
381+
382+
{capability && !capability.available && (
383+
<p className="mt-2 text-xs text-muted-foreground">
384+
SQLite backend unavailable on this install —{" "}
385+
{capabilityUnavailableReason[capability.reason ?? ""] ?? "it couldn't be loaded"}. Staying on JSON
386+
regardless of this setting.
387+
</p>
388+
)}
389+
390+
<div className="mt-2.5">
391+
<Button size="sm" variant="outline" disabled={busy || (backend === "json" && !capability?.available)} onClick={toggle}>
392+
{backend === "sqlite" ? "Switch back to JSON" : "Switch to experimental SQLite backend"}
393+
</Button>
394+
</div>
395+
</div>
396+
);
397+
}
398+
335399
const CATEGORY_LABEL: Record<AuditEvent["actionCategory"], string> = {
336400
"case-created": "Case created",
337401
"case-updated": "Case updated",
@@ -410,6 +474,8 @@ export default function AuditPrivacy() {
410474

411475
<ModelRegistrySection />
412476

477+
<StorageBackendSection onBackendChanged={refresh} />
478+
413479
<div className="grid gap-3 sm:grid-cols-2">
414480
<div className="rounded-xl border border-border/70 bg-card p-3.5">
415481
<p className="text-xs font-semibold">Storage</p>

frontend/src/types/electron.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,7 @@ export interface AppSettings {
608608
caseAutoLockMinutes?: number;
609609
redactBeforeRemoteSend?: boolean;
610610
auditLogRetentionDays?: number;
611+
auditLogBackend?: "json" | "sqlite";
611612
}
612613

613614
export interface ChatOptions {
@@ -1128,6 +1129,7 @@ export interface ElectronApi {
11281129
}
11291130
) => Promise<AuditEvent>;
11301131
verifyIntegrity: () => Promise<AuditChainVerificationResult>;
1132+
sqliteCapability: () => Promise<{ available: boolean; reason?: string; detail?: string }>;
11311133
};
11321134
encryption: {
11331135
status: () => Promise<{ enabled: boolean; unlocked: boolean }>;

0 commit comments

Comments
 (0)