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:
@@ -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,
|
||||
|
||||
@@ -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, '"')}">${_fmtArgs(e.args)}</td>
|
||||
<td class="at-result" title="${(e.result_snippet || '').replace(/</g, '<').replace(/"/g, '"')}">${
|
||||
(e.result_snippet || '').replace(/</g, '<').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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '●';
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user