Skip to content

Commit 6f2b2c1

Browse files
authored
Merge pull request #12 from radiantlab/feat/SQLite-audit-logging
Feat/sq lite audit logging, reviewed during team meetings today.
2 parents 9c99846 + db05d4f commit 6f2b2c1

10 files changed

Lines changed: 879 additions & 13 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,5 @@ svc/data/panels_config.json
229229
# Note: panels_config.json is tracked (structural data)
230230
# panels_state.json is ignored (runtime state)
231231

232+
svc/data/audit.db
233+
# audit.db is a SQLite database for the audit log, changes on run time, so ignore it for now

svc/app/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
PANELS_CONFIG_FILE = os.path.join(_SVC_DIR, DATA_DIR, "panels_config.json")
1818
PANELS_STATE_FILE = os.path.join(_SVC_DIR, DATA_DIR, "panels_state.json")
1919
AUDIT_FILE = os.path.join(_SVC_DIR, DATA_DIR, "audit.json")
20+
AUDIT_DB_FILE = os.path.join(_SVC_DIR, DATA_DIR, "audit.db")
2021

2122
# Halio API configuration (for real mode)
2223
# NOTE: The previous default ("http://192.168.2.200:8084/api") was trailer-specific.

svc/app/routes.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from __future__ import annotations
22
from fastapi import APIRouter, HTTPException, Depends
3-
from .models import Panel, Group, CommandRequest, CommandResult, GroupCreate, GroupUpdate
3+
from .models import Panel, Group, CommandRequest, CommandResult, GroupCreate, GroupUpdate, AuditEntry
44
from typing import List
55
from .service import ControlService
66
from .config import MODE
7+
from .state import fetch_audit_entries
8+
79

810

911
router = APIRouter()
@@ -80,3 +82,9 @@ def delete_group(group_id: str, service: ControlService = Depends(get_service))
8082
if not ok:
8183
raise HTTPException(status_code=404, detail="group not found")
8284
return {"ok": True}
85+
86+
87+
@router.get("/logs/audit", response_model=List[AuditEntry])
88+
def get_audit_logs(limit: int = 500, offset: int = 0) -> List[AuditEntry]:
89+
rows = fetch_audit_entries(limit=limit, offset=offset)
90+
return [AuditEntry(**row) for row in rows]

svc/app/state.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@
22
import json
33
import os
44
import time
5-
from typing import Dict, List, Tuple
5+
import sqlite3
6+
from typing import Dict, List, Tuple, Any
67
from .models import Panel, Group, Snapshot, AuditEntry
7-
from .config import PANELS_FILE, PANELS_CONFIG_FILE, PANELS_STATE_FILE, AUDIT_FILE
8+
from .config import PANELS_FILE, PANELS_CONFIG_FILE, PANELS_STATE_FILE, AUDIT_FILE, AUDIT_DB_FILE
89

910

1011
def _ensure_dirs() -> None:
1112
os.makedirs(os.path.dirname(PANELS_CONFIG_FILE), exist_ok=True)
1213
os.makedirs(os.path.dirname(PANELS_STATE_FILE), exist_ok=True)
1314
os.makedirs(os.path.dirname(AUDIT_FILE), exist_ok=True)
15+
# AUDIT_DB_FILE lives in the same directory as AUDIT_FILE so no extra work needed
1416

1517

1618
def _migrate_from_legacy_panels_json() -> None:
@@ -145,13 +147,95 @@ def save_snapshot(s: Snapshot) -> None:
145147
save_state(s.panels)
146148

147149

150+
def _ensure_audit_db() -> None:
151+
"""Create the SQLite database and table for audit logs if they do not exist."""
152+
_ensure_dirs()
153+
conn = sqlite3.connect(AUDIT_DB_FILE)
154+
try:
155+
cur = conn.cursor()
156+
cur.execute(
157+
"""
158+
CREATE TABLE IF NOT EXISTS audit_log (
159+
id INTEGER PRIMARY KEY AUTOINCREMENT,
160+
ts REAL NOT NULL,
161+
actor TEXT NOT NULL,
162+
target_type TEXT NOT NULL,
163+
target_id TEXT NOT NULL,
164+
level INTEGER NOT NULL,
165+
applied_to TEXT NOT NULL,
166+
result TEXT NOT NULL
167+
)
168+
"""
169+
)
170+
conn.commit()
171+
finally:
172+
conn.close()
173+
174+
148175
def append_audit(entry: AuditEntry) -> None:
176+
"""Append audit entry to JSON file and SQLite database."""
149177
_ensure_dirs()
150178
row = entry.model_dump()
151179
# write one JSON per line for easy tailing
152180
with open(AUDIT_FILE, "a", encoding="utf-8") as f:
153181
f.write(json.dumps(row) + "\n")
154182

183+
# also mirror into SQLite
184+
_ensure_audit_db()
185+
conn = sqlite3.connect(AUDIT_DB_FILE)
186+
try:
187+
cur = conn.cursor()
188+
cur.execute(
189+
"""
190+
INSERT INTO audit_log (ts, actor, target_type, target_id, level, applied_to, result)
191+
VALUES (?, ?, ?, ?, ?, ?, ?)
192+
""",
193+
(
194+
row["ts"],
195+
row["actor"],
196+
row["target_type"],
197+
row["target_id"],
198+
row["level"],
199+
json.dumps(row["applied_to"]),
200+
row["result"],
201+
),
202+
)
203+
conn.commit()
204+
finally:
205+
conn.close()
206+
207+
208+
def fetch_audit_entries(limit: int = 500, offset: int = 0) -> List[Dict[str, Any]]:
209+
"""Fetch audit entries from SQLite ordered newest first."""
210+
_ensure_audit_db()
211+
conn = sqlite3.connect(AUDIT_DB_FILE)
212+
try:
213+
conn.row_factory = sqlite3.Row
214+
cur = conn.cursor()
215+
cur.execute(
216+
"""
217+
SELECT ts, actor, target_type, target_id, level, applied_to, result
218+
FROM audit_log
219+
ORDER BY ts DESC
220+
LIMIT ? OFFSET ?
221+
""",
222+
(limit, offset),
223+
)
224+
rows = cur.fetchall()
225+
result: List[Dict[str, Any]] = []
226+
for r in rows:
227+
row_dict = dict(r)
228+
# applied_to is stored as JSON text
229+
try:
230+
row_dict["applied_to"] = json.loads(row_dict.get("applied_to") or "[]")
231+
except Exception:
232+
row_dict["applied_to"] = []
233+
result.append(row_dict)
234+
return result
235+
finally:
236+
conn.close()
237+
238+
155239

156240
def bootstrap_default_if_empty() -> Snapshot:
157241
"""Bootstrap default panels and groups if config doesn't exist."""

svc/data/audit.db

12 KB
Binary file not shown.

web/src/AppHMI.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import React, { useEffect, useState } from "react";
2-
import { api, type Panel, type Group } from "./api";
1+
import React, { useEffect, useState, useCallback } from "react"
2+
import { api, type Panel, type Group, type AuditLogEntry } from "./api";
33
import { mockApi } from "./mockData";
44
import RoomGrid from "./components/RoomGrid";
55
import RoomGridCompact from "./components/RoomGridCompact";
66
import SidePanel from "./components/SidePanel";
77
import ActiveControllersBar from "./components/ActiveControllersBar";
88
import { controlManager, type ControlSource } from "./utils/controlManager";
99
import { useToast } from "./utils/toast";
10+
import LogsPanel from "./components/LogsPanel";
11+
1012

1113
export default function AppHMI() {
1214
const [health, setHealth] = useState<string>("checking");
@@ -20,6 +22,11 @@ export default function AppHMI() {
2022
const { showToast } = useToast();
2123
const [groupId, setGroupId] = useState<string>("");
2224
const [groupLevel, setGroupLevel] = useState<number>(50);
25+
const [logsPanelOpen, setLogsPanelOpen] = useState<boolean>(false);
26+
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
27+
const [logsLoading, setLogsLoading] = useState<boolean>(false);
28+
const [logsError, setLogsError] = useState<string | null>(null);
29+
2330

2431
async function refresh() {
2532
try {
@@ -281,6 +288,26 @@ export default function AppHMI() {
281288
setBusy(null);
282289
}
283290

291+
async function loadAuditLogs() {
292+
if (usingMock) {
293+
setAuditLogs([]);
294+
setLogsError("Logs are not available in mock mode");
295+
return;
296+
}
297+
298+
try {
299+
setLogsLoading(true);
300+
setLogsError(null);
301+
const rows = await api.auditLogs(500);
302+
setAuditLogs(rows);
303+
} catch (e) {
304+
setLogsError(`Failed to load logs ${String(e)}`);
305+
} finally {
306+
setLogsLoading(false);
307+
}
308+
}
309+
310+
284311
return (
285312
<>
286313
<ActiveControllersBar
@@ -336,6 +363,19 @@ export default function AppHMI() {
336363
{busy === "clear-all" ? "Clearing..." : "Clear All"}
337364
</button>
338365

366+
367+
<button
368+
className="hmi-manage-btn"
369+
onClick={async () => {
370+
setLogsPanelOpen(true);
371+
setSidePanelOpen(false);
372+
await loadAuditLogs();
373+
}}
374+
title="View system logs"
375+
>
376+
Logs
377+
</button>
378+
339379
<button
340380
className="hmi-manage-btn"
341381
onClick={() => setSidePanelOpen(true)}
@@ -436,6 +476,16 @@ export default function AppHMI() {
436476
onGroupDelete={deleteGroup}
437477
/>
438478

479+
<LogsPanel
480+
isOpen={logsPanelOpen}
481+
onClose={() => setLogsPanelOpen(false)}
482+
auditLogs={auditLogs}
483+
loading={logsLoading}
484+
error={logsError}
485+
onRefresh={loadAuditLogs}
486+
isMock={usingMock}
487+
/>
488+
439489
</>
440490
);
441491
}

web/src/api.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ export type Group = {
1212
member_ids: string[];
1313
};
1414

15+
export type AuditLogEntry = {
16+
ts: number
17+
actor: string
18+
target_type: "panel" | "group"
19+
target_id: string
20+
level: number
21+
applied_to: string[]
22+
result: string
23+
};
24+
25+
1526
const API_BASE = (import.meta.env.VITE_API_BASE || "http://127.0.0.1:8000").replace(/\/$/, "");
1627

1728
async function http<T>(path: string, options?: RequestInit): Promise<T> {
@@ -66,5 +77,10 @@ export const api = {
6677
http<{ ok: boolean; applied_to: string[]; message: string }>("/commands/set-level", {
6778
method: "POST",
6879
body: JSON.stringify({ target_type: "group", target_id: groupId, level })
69-
})
80+
}),
81+
82+
auditLogs: (limit = 500) =>
83+
http<AuditLogEntry[]>(`/logs/audit?limit=${encodeURIComponent(limit)}`)
7084
};
85+
86+

0 commit comments

Comments
 (0)