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:
@@ -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: '●';
|
||||
|
||||
Reference in New Issue
Block a user