69 lines
5.8 KiB
Svelte
69 lines
5.8 KiB
Svelte
<script lang="ts">
|
||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||
let { data, form } = $props();
|
||
let confirming = $state<any>(null);
|
||
let handledResultToken = $state('');
|
||
const categoryOptions = $derived([
|
||
{id:'user',name:'USER OPERATIONS · ALL'},
|
||
{id:'access_policy',name:'ACCESS POLICY · ALL'},
|
||
...data.categories
|
||
.filter((item) => !['user', 'access_policy'].includes(String(item.entity_type)))
|
||
.map((item) => ({id:String(item.entity_type),name:`${String(item.entity_type).replaceAll('_',' ').toUpperCase()} · ${item.count}`}))
|
||
]);
|
||
const actionOptions = $derived(data.actions.map((item) => ({id:String(item.action),name:`${String(item.action).toUpperCase()} · ${item.count}`})));
|
||
const date = (value: unknown) => new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(new Date(String(value)));
|
||
const pretty = (value: unknown) => {
|
||
if (!value) return 'NO SNAPSHOT';
|
||
try { return JSON.stringify(JSON.parse(String(value)), null, 2); } catch { return String(value); }
|
||
};
|
||
$effect(() => {
|
||
if (form?.reverted && form.resultToken && form.resultToken !== handledResultToken) {
|
||
handledResultToken = form.resultToken;
|
||
confirming = null;
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<svelte:head><title>Audit history — ISAC</title></svelte:head>
|
||
<div class="module-toolbar audit-toolbar"><div><p class="eyebrow">SYSTEM OVERSIGHT</p><h2>AUDIT HISTORY</h2><p>Immutable operator activity, target records, and before/after change evidence.</p></div></div>
|
||
<KpiFilters className="audit-kpis" parameter="category" active={data.filters.category} items={[
|
||
{value:'',label:'ALL EVENTS',count:data.summary.total,description:'Complete audit history'},
|
||
{value:'game_data',label:'GAME DATA',count:data.summary.game_data,description:'Builder-source mutations'},
|
||
{value:'data_moderation',label:'DATA MODERATION',count:data.summary.data_moderation,description:'Proposal lifecycle decisions'},
|
||
{value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'},
|
||
{value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'}
|
||
]}/>
|
||
<form class="audit-filter">
|
||
<input name="q" value={data.filters.query} placeholder="ACTOR, TARGET ID, OR CHANGED VALUE"/>
|
||
<SearchableAssignment items={categoryOptions} mode="single" compact fieldName="category" value={data.filters.category} subjectLabel="AUDIT CATEGORY" searchPlaceholder="Search categories…" emptySelectionLabel="ALL CATEGORIES"/>
|
||
<SearchableAssignment items={actionOptions} mode="single" compact fieldName="action" value={data.filters.action} subjectLabel="AUDIT ACTION" searchPlaceholder="Search actions…" emptySelectionLabel="ALL ACTIONS"/>
|
||
<button class="secondary">SEARCH AUDIT</button>
|
||
</form>
|
||
<RegistryPagination pagination={data.pagination}/>
|
||
<div class="dashboard-table">
|
||
<div class="dashboard-row audit-row table-heading"><span>EVENT</span><span>OPERATOR</span><span>TARGET</span><span>TIME</span><span></span></div>
|
||
{#each data.entries as entry}
|
||
<details class="audit-entry">
|
||
<summary class="dashboard-row audit-row"><div><strong>{String(entry.action).toUpperCase()}</strong><small>{String(entry.entity_type).replaceAll('_',' ').toUpperCase()}</small></div><div><strong>{entry.actor_name}</strong><small>{entry.actor_login ?? entry.actor_id}</small></div><div><strong>{entry.entity_id}</strong><small>EVENT {String(entry.id).slice(0,8).toUpperCase()}</small></div><span>{date(entry.created_at)}</span><b>DETAILS +</b></summary>
|
||
<div class="audit-comparison"><section><span>BEFORE</span><pre>{pretty(entry.before_json)}</pre></section><section><span>AFTER</span><pre>{pretty(entry.after_json)}</pre></section></div>
|
||
{#if entry.reversible}<div class="audit-revert-bar"><span>SUPPORTED COMPENSATING ACTION · CURRENT STATE WILL BE VERIFIED FIRST</span><button class="moderation-action danger audit-revert-button" type="button" onclick={() => confirming = entry}><b>↶</b> ATTEMPT REVERT</button></div>
|
||
{:else if entry.reverted}<div class="audit-revert-bar completed"><span>THIS EVENT HAS ALREADY BEEN REVERTED</span></div>{/if}
|
||
</details>
|
||
{:else}<div class="empty-dashboard"><strong>NO AUDIT EVENTS FOUND</strong><span>Adjust the category, action, or search value.</span></div>{/each}
|
||
</div>
|
||
|
||
{#if confirming}
|
||
<div class="isac-modal-backdrop" role="presentation">
|
||
<div class="isac-confirm-modal audit-revert-modal" role="dialog" aria-modal="true" aria-labelledby="revert-title">
|
||
<div class="editor-title"><div><p class="eyebrow">CONFIRM COMPENSATING ACTION</p><h3 id="revert-title">REVERT {String(confirming.action).toUpperCase()}?</h3></div><button aria-label="Close confirmation" onclick={() => confirming = null}>×</button></div>
|
||
<p>This does not erase history. ISAC will first verify that target <code>{confirming.entity_id}</code> still matches this event’s resulting state. If it has newer changes, the revert will be blocked.</p>
|
||
<div class="audit-revert-summary"><div><span>ORIGINAL ACTION</span><b>{confirming.action}</b></div><div><span>ORIGINAL OPERATOR</span><b>{confirming.actor_name}</b></div><div><span>EVENT</span><b>{confirming.id}</b></div></div>
|
||
{#if form?.revertError}<p class="error">{form.revertError}</p>{/if}
|
||
<PendingForm action="?/revert">{#snippet children(pending)}<input type="hidden" name="eventId" value={confirming.id}/><div class="form-actions"><button class="secondary" type="button" onclick={() => confirming = null} disabled={pending}>CANCEL</button><button class="primary" aria-busy={pending} disabled={pending}>{pending ? 'VERIFYING STATE…' : 'CONFIRM REVERT →'}</button></div>{/snippet}</PendingForm>
|
||
</div>
|
||
</div>
|
||
{/if}
|