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
|
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,
|
||||||
|
|||||||
@@ -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, '"')}">${_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');
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: '●';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user