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
Returns recent tool call entries for one user (or all users if user omitted).
GET /api/audit/stats?user=scott&days=7
Returns aggregate counts by tool and status.
"""
import jwt
from collections import Counter
from datetime import date, timedelta
from fastapi import APIRouter, HTTPException, Query, Request
from auth_utils import COOKIE_NAME, decode_token, get_user_role
from config import settings
import tool_audit
from persona import list_users
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)
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
username = decode_token(token)
return decode_token(token)
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid session")
def _require_admin(request: Request) -> str:
username = _session_user(request)
if get_user_role(username) != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
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")
async def audit_recent(
request: Request,

View File

@@ -1375,22 +1375,26 @@
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]));
fileSidebar.innerHTML = '';
for (const group of FILE_GROUPS) {
const groupEl = document.createElement('div');
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';
const { groupEl, items } = _makeFileGroup(group.label);
for (const fname of group.files) {
const f = byName[fname];
@@ -1416,7 +1420,35 @@
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);
}
}
@@ -1455,6 +1487,10 @@
async function loadFile(name) {
setActiveFile(name);
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}`);
if (!res.ok) { mdEditor.setValue(`Error loading ${name}`); return; }
const data = await res.json();
@@ -1463,13 +1499,90 @@
setFileMode(fileMode);
}
async function openFileModal() {
const res = await fetch(`/files?${_fileParams}`);
function _auditStatusClass(status) {
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();
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');
// Load first existing file
const first = data.files.find(f => f.exists) || data.files[0];
// Load first existing regular file
const first = filesData.files.find(f => f.exists) || filesData.files[0];
if (first) await loadFile(first.name);
}

View File

@@ -1232,6 +1232,42 @@
#file-preview.active { display: block; }
#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 */
#sessions-btn.talk-badge::after {
content: '●';

View File

@@ -113,6 +113,26 @@ def read_recent(user: str, days: int = 7, limit: int = 200) -> list[dict]:
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]:
"""Read recent entries across all users, sorted newest-first."""
from persona import list_users