feat: audit log in Files panel sidebar

Adds an "Audit Log" section (collapsed by default) at the bottom of the Files
panel showing tool_audit/YYYY-MM-DD.jsonl files for the current user.

- GET /api/audit/files  — lists available dates (newest first, any auth user)
- GET /api/audit/day    — returns entries for one date as JSON (any auth user)
- tool_audit.read_day() — reads a single day's JSONL file chronologically
- Clicking a date renders a read-only table: time / tool / status / args / result
- Status cells are colour-coded (green ok, red error, amber denied)
- Edit/Raw/Preview/Save buttons are hidden in audit view, restored on file switch
- Audit group starts collapsed; expands on click like other file groups

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-05 20:36:08 -04:00
parent 584ae679a6
commit 02accefe8f
4 changed files with 232 additions and 24 deletions

View File

@@ -1,36 +1,75 @@
""" """
Tool audit log endpoints — admin only. Tool audit log endpoints.
Self-service (any authenticated user, own data):
GET /api/audit/files → list of available date strings (newest first)
GET /api/audit/day?date=YYYY-MM-DD → entries for one day
Admin-only (cross-user aggregation):
GET /api/audit/recent?user=scott&days=7&limit=200 GET /api/audit/recent?user=scott&days=7&limit=200
Returns recent tool call entries for one user (or all users if user omitted).
GET /api/audit/stats?user=scott&days=7 GET /api/audit/stats?user=scott&days=7
Returns aggregate counts by tool and status.
""" """
import jwt import jwt
from collections import Counter from collections import Counter
from datetime import date, timedelta
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from auth_utils import COOKIE_NAME, decode_token, get_user_role from auth_utils import COOKIE_NAME, decode_token, get_user_role
from config import settings
import tool_audit import tool_audit
from persona import list_users from persona import list_users
router = APIRouter(prefix="/api/audit") router = APIRouter(prefix="/api/audit")
def _require_admin(request: Request) -> str: def _session_user(request: Request) -> str:
"""Return the authenticated username or raise 401."""
token = request.cookies.get(COOKIE_NAME) token = request.cookies.get(COOKIE_NAME)
if not token: if not token:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
try: try:
username = decode_token(token) return decode_token(token)
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid session") raise HTTPException(status_code=401, detail="Invalid session")
def _require_admin(request: Request) -> str:
username = _session_user(request)
if get_user_role(username) != "admin": if get_user_role(username) != "admin":
raise HTTPException(status_code=403, detail="Admin access required") raise HTTPException(status_code=403, detail="Admin access required")
return username return username
@router.get("/files")
async def audit_files(request: Request) -> dict:
"""List available audit log dates for the current user (newest first)."""
username = _session_user(request)
audit_dir = settings.home_root() / username / "tool_audit"
if not audit_dir.exists():
return {"dates": []}
dates = sorted(
[p.stem for p in audit_dir.glob("*.jsonl") if p.stem],
reverse=True,
)
return {"dates": dates}
@router.get("/day")
async def audit_day(
request: Request,
date: str = Query(..., description="YYYY-MM-DD"),
) -> dict:
"""Return all entries for a specific day (current user only)."""
username = _session_user(request)
try:
from datetime import date as _date
d = _date.fromisoformat(date)
except ValueError:
raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD")
entries = tool_audit.read_day(username, date)
return {"date": date, "entries": entries}
@router.get("/recent") @router.get("/recent")
async def audit_recent( async def audit_recent(
request: Request, request: Request,

View File

@@ -1375,22 +1375,26 @@
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} }
function renderFileSidebar(files) { function _makeFileGroup(label, collapsed = false) {
const groupEl = document.createElement('div');
groupEl.className = 'file-group';
const header = document.createElement('div');
header.className = 'fg-header' + (collapsed ? ' collapsed' : '');
header.textContent = label;
header.addEventListener('click', () => header.classList.toggle('collapsed'));
groupEl.appendChild(header);
const items = document.createElement('div');
items.className = 'fg-items';
groupEl.appendChild(items);
return { groupEl, items };
}
function renderFileSidebar(files, auditDates = []) {
const byName = Object.fromEntries(files.map(f => [f.name, f])); const byName = Object.fromEntries(files.map(f => [f.name, f]));
fileSidebar.innerHTML = ''; fileSidebar.innerHTML = '';
for (const group of FILE_GROUPS) { for (const group of FILE_GROUPS) {
const groupEl = document.createElement('div'); const { groupEl, items } = _makeFileGroup(group.label);
groupEl.className = 'file-group';
const header = document.createElement('div');
header.className = 'fg-header';
header.textContent = group.label;
header.addEventListener('click', () => header.classList.toggle('collapsed'));
groupEl.appendChild(header);
const items = document.createElement('div');
items.className = 'fg-items';
for (const fname of group.files) { for (const fname of group.files) {
const f = byName[fname]; const f = byName[fname];
@@ -1416,7 +1420,35 @@
items.appendChild(item); items.appendChild(item);
} }
groupEl.appendChild(items); fileSidebar.appendChild(groupEl);
}
// ── Audit Log section (dynamic, date-named files) ──────────
if (auditDates.length > 0) {
const { groupEl, items } = _makeFileGroup('Audit Log', true);
for (const d of auditDates) {
const item = document.createElement('div');
item.className = 'file-item';
item.dataset.name = 'audit:' + d;
if (activeFileName === 'audit:' + d) item.classList.add('active');
const nameEl = document.createElement('div');
nameEl.className = 'fi-name';
nameEl.textContent = d + '.jsonl';
item.appendChild(nameEl);
const metaEl = document.createElement('div');
metaEl.className = 'fi-meta';
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
metaEl.innerHTML = `<span>${d === today ? 'today' : d === yesterday ? 'yesterday' : d}</span>`;
item.appendChild(metaEl);
item.addEventListener('click', () => loadAuditLog(d));
items.appendChild(item);
}
fileSidebar.appendChild(groupEl); fileSidebar.appendChild(groupEl);
} }
} }
@@ -1455,6 +1487,10 @@
async function loadFile(name) { async function loadFile(name) {
setActiveFile(name); setActiveFile(name);
initMdEditor(); initMdEditor();
// Restore editor/preview buttons hidden by audit view
fileRawBtn.style.display = '';
filePreviewBtn.style.display = '';
fileSaveBtn.style.display = '';
const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`); const res = await fetch(`/files/${encodeURIComponent(name)}?${_fileParams}`);
if (!res.ok) { mdEditor.setValue(`Error loading ${name}`); return; } if (!res.ok) { mdEditor.setValue(`Error loading ${name}`); return; }
const data = await res.json(); const data = await res.json();
@@ -1463,13 +1499,90 @@
setFileMode(fileMode); setFileMode(fileMode);
} }
async function openFileModal() { function _auditStatusClass(status) {
const res = await fetch(`/files?${_fileParams}`); if (status === 'ok') return 'at-status ok';
if (status === 'error') return 'at-status error';
if (status === 'denied') return 'at-status denied';
return 'at-status';
}
function _fmtArgs(args) {
if (!args || typeof args !== 'object') return '';
return Object.entries(args)
.map(([k, v]) => {
const s = typeof v === 'string' ? v : JSON.stringify(v);
return `${k}: ${s.length > 60 ? s.slice(0, 60) + '…' : s}`;
})
.join(' · ');
}
async function loadAuditLog(dateStr) {
setActiveFile('audit:' + dateStr);
document.getElementById('file-modal-title').textContent = dateStr + '.jsonl';
// Hide edit controls — audit logs are read-only
fileRawBtn.style.display = 'none';
filePreviewBtn.style.display = 'none';
fileSaveBtn.style.display = 'none';
fileEditorWrap.classList.add('hidden');
filePreview.classList.add('active');
filePreview.style.display = '';
filePreview.innerHTML = '<div class="audit-empty">Loading…</div>';
const res = await fetch(`/api/audit/day?date=${encodeURIComponent(dateStr)}`);
if (!res.ok) {
filePreview.innerHTML = '<div class="audit-empty">Failed to load audit log.</div>';
return;
}
const data = await res.json(); const data = await res.json();
renderFileSidebar(data.files); const entries = data.entries || [];
if (entries.length === 0) {
filePreview.innerHTML = '<div class="audit-empty">No entries for this date.</div>';
return;
}
const table = document.createElement('table');
table.className = 'audit-table';
table.innerHTML = `<thead><tr>
<th class="at-time">Time</th>
<th class="at-tool">Tool</th>
<th class="at-status">Status</th>
<th class="at-args">Args</th>
<th class="at-result">Result</th>
</tr></thead>`;
const tbody = document.createElement('tbody');
for (const e of entries) {
const time = (e.ts || '').slice(11, 19); // HH:MM:SS
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="at-time">${time}</td>
<td class="at-tool" title="${e.tool || ''}">${e.tool || '?'}</td>
<td class="${_auditStatusClass(e.status)}">${e.status || '?'}</td>
<td class="at-args" title="${(_fmtArgs(e.args) || '').replace(/"/g, '&quot;')}">${_fmtArgs(e.args)}</td>
<td class="at-result" title="${(e.result_snippet || '').replace(/</g, '&lt;').replace(/"/g, '&quot;')}">${
(e.result_snippet || '').replace(/</g, '&lt;').slice(0, 80)
+ (e.result_chars > 80 ? `… <span style="color:var(--muted)">[${e.result_chars} chars]</span>` : '')
}</td>`;
tbody.appendChild(tr);
}
table.appendChild(tbody);
filePreview.innerHTML = '';
filePreview.appendChild(table);
}
async function openFileModal() {
const [filesRes, auditRes] = await Promise.all([
fetch(`/files?${_fileParams}`),
fetch('/api/audit/files'),
]);
const filesData = await filesRes.json();
const auditData = auditRes.ok ? await auditRes.json() : { dates: [] };
renderFileSidebar(filesData.files, auditData.dates);
fileModal.classList.add('open'); fileModal.classList.add('open');
// Load first existing file // Load first existing regular file
const first = data.files.find(f => f.exists) || data.files[0]; const first = filesData.files.find(f => f.exists) || filesData.files[0];
if (first) await loadFile(first.name); if (first) await loadFile(first.name);
} }

View File

@@ -1232,6 +1232,42 @@
#file-preview.active { display: block; } #file-preview.active { display: block; }
#file-editor-wrap.hidden { display: none; } #file-editor-wrap.hidden { display: none; }
/* ── Audit log table ────────────────────────────────────────── */
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
table-layout: fixed;
}
.audit-table th {
text-align: left;
padding: 5px 8px;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-weight: 600;
white-space: nowrap;
}
.audit-table td {
padding: 5px 8px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
vertical-align: top;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audit-table tr:last-child td { border-bottom: none; }
.audit-table tr:hover td { background: var(--surface); }
/* Column widths */
.at-time { width: 7em; color: var(--muted); white-space: nowrap; }
.at-tool { width: 11em; color: var(--accent); font-weight: 500; }
.at-status { width: 4.5em; font-weight: 600; }
.at-args { width: 30%; color: var(--muted); }
.at-result { color: var(--muted); }
.at-status.ok { color: #4ade80; }
.at-status.error { color: #f87171; }
.at-status.denied { color: #fbbf24; }
.audit-empty { padding: 24px; color: var(--muted); text-align: center; font-size: 0.9rem; }
/* Talk activity badge on Sessions button */ /* Talk activity badge on Sessions button */
#sessions-btn.talk-badge::after { #sessions-btn.talk-badge::after {
content: '●'; content: '●';

View File

@@ -113,6 +113,26 @@ def read_recent(user: str, days: int = 7, limit: int = 200) -> list[dict]:
return entries[:limit] return entries[:limit]
def read_day(user: str, day_str: str) -> list[dict]:
"""Read all entries for a specific date string (YYYY-MM-DD), chronological order."""
path = settings.home_root() / user / "tool_audit" / f"{day_str}.jsonl"
if not path.exists():
return []
entries = []
try:
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
pass
except Exception:
pass
return entries
def read_recent_all_users(days: int = 7, limit: int = 500) -> list[dict]: def read_recent_all_users(days: int = 7, limit: int = 500) -> list[dict]:
"""Read recent entries across all users, sorted newest-first.""" """Read recent entries across all users, sorted newest-first."""
from persona import list_users from persona import list_users