Implementing more advanced feature: audit and more

This commit is contained in:
Martino Ferrari
2026-06-19 14:17:46 +02:00
parent 8f6dbcba49
commit 901b87d407
31 changed files with 1669 additions and 569 deletions
+169
View File
@@ -0,0 +1,169 @@
import { h } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
// AuditEvent mirrors the JSON returned by GET /api/v1/audit (internal/audit.Event).
interface AuditEvent {
time: string;
actor: string;
actorType: string; // "user" | "system"
action: string;
ds?: string;
signal?: string;
value?: string;
detail?: string;
ip?: string;
outcome: string; // "ok" | "error"
error?: string;
}
interface Props { onClose: () => void; }
/** Convert an RFC3339/ISO timestamp to a compact local datetime string. */
function fmtTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** Convert a datetime-local input value (local time) to an RFC3339 string. */
function localToRFC3339(v: string): string {
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? '' : d.toISOString();
}
export default function AuditViewer({ onClose }: Props) {
const [events, setEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filters
const [user, setUser] = useState('');
const [action, setAction] = useState('');
const [signal, setSignal] = useState('');
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const q = new URLSearchParams();
if (user.trim()) q.set('user', user.trim());
if (action) q.set('action', action);
if (signal.trim()) q.set('signal', signal.trim());
const s = localToRFC3339(start);
const e = localToRFC3339(end);
if (s) q.set('start', s);
if (e) q.set('end', e);
const res = await fetch(`/api/v1/audit?${q.toString()}`);
if (res.status === 403) throw new Error('You are not permitted to view the audit log.');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setEvents(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setEvents([]);
} finally {
setLoading(false);
}
}, [user, action, signal, start, end]);
// Initial load.
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
function resetFilters() {
setUser(''); setAction(''); setSignal(''); setStart(''); setEnd('');
}
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal audit-modal">
<header class="cl-header">
<span class="cl-title">Audit log</span>
<span class="hint cl-subtitle">Every system-affecting action (signal writes, control-logic and interface changes).</span>
<div class="cl-header-actions">
<button class="panel-btn" disabled={loading} onClick={load}>{loading ? 'Loading…' : 'Refresh'}</button>
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
</header>
<div class="audit-filters">
<input class="audit-filter" type="text" placeholder="User" value={user}
onInput={(e) => setUser((e.target as HTMLInputElement).value)} />
<select class="audit-filter" value={action}
onChange={(e) => setAction((e.target as HTMLSelectElement).value)}>
<option value="">All actions</option>
<option value="signal.write">signal.write</option>
<option value="interface.create">interface.create</option>
<option value="interface.update">interface.update</option>
<option value="interface.delete">interface.delete</option>
<option value="controllogic.create">controllogic.create</option>
<option value="controllogic.update">controllogic.update</option>
<option value="controllogic.delete">controllogic.delete</option>
</select>
<input class="audit-filter" type="text" placeholder="Signal contains…" value={signal}
onInput={(e) => setSignal((e.target as HTMLInputElement).value)} />
<label class="audit-filter-label">From
<input class="audit-filter" type="datetime-local" value={start}
onInput={(e) => setStart((e.target as HTMLInputElement).value)} />
</label>
<label class="audit-filter-label">To
<input class="audit-filter" type="datetime-local" value={end}
onInput={(e) => setEnd((e.target as HTMLInputElement).value)} />
</label>
<button class="panel-btn" disabled={loading} onClick={load}>Apply</button>
<button class="panel-btn" onClick={resetFilters}>Reset</button>
</div>
{error && <div class="cl-error">{error}</div>}
<div class="audit-body">
{!error && events.length === 0 && !loading && (
<div class="hint audit-empty">No matching audit entries.</div>
)}
{events.length > 0 && (
<table class="audit-table">
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Action</th>
<th>Source</th>
<th>Signal / target</th>
<th>Value</th>
<th>Detail</th>
<th>Client</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{events.map((ev, i) => (
<tr key={i} class={ev.outcome === 'error' ? 'audit-row-error' : ''}>
<td class="audit-time">{fmtTime(ev.time)}</td>
<td>
<span class={`audit-actor-tag audit-actor-${ev.actorType}`}>{ev.actorType}</span>
{' '}{ev.actor || '—'}
</td>
<td>{ev.action}</td>
<td>{ev.ds || '—'}</td>
<td class="audit-signal" title={ev.signal}>{ev.signal || '—'}</td>
<td class="audit-value" title={ev.value}>{ev.value ?? '—'}</td>
<td class="audit-detail" title={ev.detail}>{ev.detail || '—'}</td>
<td>{ev.ip || '—'}</td>
<td>
{ev.outcome === 'error'
? <span class="audit-result-err" title={ev.error}>error</span>
: <span class="audit-result-ok">ok</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}