197 lines
14 KiB
HTML
197 lines
14 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<script src="/auth-guard.js?v=4"></script>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
||
<title>Audit Trail — SAP B1</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Bebas+Neue&display=swap" rel="stylesheet"/>
|
||
<style>
|
||
:root{--ink:#080e18;--ink2:#0d1626;--purple:#9f5afd;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5)}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
|
||
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
|
||
.nav-logo{background:var(--blue);color:#fff;font-family:var(--display);font-size:15px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
|
||
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
|
||
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap}.nav-link.active{color:var(--blue);background:rgba(26,111,255,.1)}
|
||
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
|
||
main{padding:12px;max-width:1500px;margin:0 auto}
|
||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
|
||
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
|
||
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3)}
|
||
.card-bd{padding:12px 14px}
|
||
.fl{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:3px;display:block}
|
||
.fi{width:100%;padding:7px 9px;border:1.5px solid var(--border2);border-radius:7px;font-size:12.5px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:34px}
|
||
.fi:focus{border-color:var(--blue)}
|
||
select.fi{cursor:pointer}
|
||
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end}
|
||
.filters>div{min-width:120px}
|
||
.btn{padding:8px 15px;border-radius:8px;border:none;font-family:var(--font);font-size:12px;font-weight:700;cursor:pointer;height:34px}
|
||
.b-primary{background:var(--blue);color:#fff}.b-cancel{background:var(--surface2);color:var(--text2);border:1px solid var(--border2)}
|
||
.lst{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
|
||
.lst th{text-align:left;padding:8px 10px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:var(--text3);border-bottom:2px solid var(--border2);white-space:nowrap}
|
||
.lst td{padding:8px 10px;border-bottom:1px solid var(--border);vertical-align:top}
|
||
.lst tr.row{cursor:pointer}
|
||
.lst tr.row:hover td{background:rgba(26,111,255,.05)}
|
||
.mono{font-family:var(--mono)}
|
||
.pill{font-size:9px;font-weight:800;padding:2px 7px;border-radius:20px;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap}
|
||
.p-create{background:rgba(13,187,138,.14);color:#0aa457}
|
||
.p-update{background:rgba(26,111,255,.14);color:#5b9bff}
|
||
.p-delete{background:rgba(229,62,62,.14);color:#e06666}
|
||
.p-approve{background:rgba(159,90,253,.16);color:#b98bff}
|
||
.p-login,.p-logout{background:rgba(245,166,35,.14);color:#c78a1a}
|
||
.p-other{background:var(--surface3);color:var(--text2)}
|
||
.st-ok{color:var(--green);font-weight:700}
|
||
.st-fail{color:var(--red);font-weight:700}
|
||
.det{background:var(--surface3);border:1px solid var(--border2);border-radius:7px;padding:10px 12px;font-size:11.5px;color:var(--text2);max-height:360px;overflow:auto}
|
||
.chg{width:100%;border-collapse:collapse;font-size:11.5px}
|
||
.chg th{text-align:left;padding:4px 8px;font-size:9px;text-transform:uppercase;color:var(--text3);border-bottom:1px solid var(--border2)}
|
||
.chg td{padding:4px 8px;border-bottom:1px solid var(--border);vertical-align:top}
|
||
.chg .fld{font-weight:700;color:var(--text);font-family:var(--mono)}
|
||
.chg .old{color:#e06666;font-family:var(--mono);text-decoration:line-through;opacity:.85}
|
||
.chg .arw{color:var(--text3);padding:0 6px}
|
||
.chg .new{color:#0aa457;font-weight:700;font-family:var(--mono)}
|
||
.kv{display:grid;grid-template-columns:auto 1fr;gap:2px 12px;font-family:var(--mono);font-size:11px}
|
||
.kv .k{color:var(--text3)}.kv .v{color:var(--text2);word-break:break-word}
|
||
.det-meta{margin-top:8px;font-size:10.5px;color:var(--text3)}
|
||
.empty-msg{text-align:center;padding:26px;color:var(--text3);font-size:12px}
|
||
.pager{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px}
|
||
.count{font-size:11px;font-weight:700;color:var(--blue);background:rgba(26,111,255,.1);padding:3px 11px;border-radius:20px}
|
||
.sp{width:14px;height:14px;border:2px solid rgba(26,111,255,.25);border-top-color:var(--blue);border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px}
|
||
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow)}
|
||
.t-err{background:var(--red);color:#fff}
|
||
</style>
|
||
<link rel="stylesheet" href="/theme.css?v=3"/>
|
||
</head>
|
||
<body>
|
||
<div class="toast-c" id="toast-c"></div>
|
||
<nav>
|
||
<span class="nav-logo">SAP</span>
|
||
<div class="nav-links"><a href="/" class="nav-link">Home</a><a href="/audit-logs" class="nav-link active">Audit Trail</a></div>
|
||
<button class="btn-sm" id="btn-logout">Logout</button>
|
||
</nav>
|
||
<main id="app"></main>
|
||
|
||
<script>
|
||
'use strict';
|
||
let _tok=null;const BE='/api';
|
||
try{_tok=sessionStorage.getItem('portal_token');}catch(_e){}
|
||
if(!_tok)location.href='/';
|
||
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();location.href='/';};
|
||
|
||
const _f={from:'',to:'',username:'',entity:'',action:'',status:'',q:''};
|
||
let _page=0,_pageSize=50,_total=0,_rows=[],_facets={entities:[],actions:[],users:[]},_loading=false,_open=new Set();
|
||
|
||
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
function q(s){return document.querySelector(s);}
|
||
function toast(m){const tc=q('#toast-c');const e=document.createElement('div');e.className='toast t-err';e.textContent=m;tc.appendChild(e);setTimeout(()=>e.remove(),4500);}
|
||
function fmt(iso){if(!iso)return '—';const d=new Date(iso);if(isNaN(d))return '—';const p=n=>String(n).padStart(2,'0');return `${p(d.getDate())}-${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()]}-${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;}
|
||
function pcls(a){return {create:'p-create',update:'p-update',delete:'p-delete',approve:'p-approve',reject:'p-delete',cancel:'p-delete',login:'p-login',logout:'p-logout'}[a]||'p-other';}
|
||
|
||
async function api(p){
|
||
const r=await fetch(BE+p,{headers:{Authorization:'Bearer '+_tok}});
|
||
if(r.status===401){location.href='/';throw new Error('Session expired');}
|
||
const d=await r.json();
|
||
if(!r.ok||d.success===false)throw new Error(d.message||('HTTP '+r.status));
|
||
return d;
|
||
}
|
||
|
||
async function loadFacets(){try{const r=await api('/audit/facets');_facets=r.data||_facets;}catch(_e){}}
|
||
async function load(reset){
|
||
if(reset)_page=0;
|
||
_loading=true;render();
|
||
const qs=new URLSearchParams();
|
||
Object.entries(_f).forEach(([k,v])=>{if(v)qs.set(k,v);});
|
||
qs.set('top',_pageSize);qs.set('skip',_page*_pageSize);
|
||
try{const r=await api('/audit?'+qs.toString());_rows=r.data||[];_total=r.total||0;}
|
||
catch(e){toast(e.message);_rows=[];_total=0;}
|
||
_loading=false;render();
|
||
}
|
||
|
||
function opt(list,cur){return '<option value="">All</option>'+list.map(v=>`<option value="${esc(v)}" ${cur===v?'selected':''}>${esc(v)}</option>`).join('');}
|
||
|
||
function render(){
|
||
const app=document.getElementById('app');
|
||
const start=_total?_page*_pageSize+1:0, end=Math.min(_total,(_page+1)*_pageSize);
|
||
app.innerHTML=`
|
||
<div class="card"><div class="card-hd"><span>🛡 Audit Trail</span><span class="right">${_loading?'':`${_total.toLocaleString()} event${_total===1?'':'s'}`}</span></div>
|
||
<div class="card-bd">
|
||
<div class="filters">
|
||
<div><label class="fl">From</label><input class="fi" type="date" id="f-from" value="${_f.from}"/></div>
|
||
<div><label class="fl">To</label><input class="fi" type="date" id="f-to" value="${_f.to}"/></div>
|
||
<div><label class="fl">User</label><select class="fi" id="f-user">${opt(_facets.users,_f.username)}</select></div>
|
||
<div><label class="fl">Module</label><select class="fi" id="f-entity">${opt(_facets.entities,_f.entity)}</select></div>
|
||
<div><label class="fl">Action</label><select class="fi" id="f-action">${opt(_facets.actions,_f.action)}</select></div>
|
||
<div><label class="fl">Result</label><select class="fi" id="f-status"><option value="">All</option><option value="ok" ${_f.status==='ok'?'selected':''}>Success</option><option value="fail" ${_f.status==='fail'?'selected':''}>Failed</option></select></div>
|
||
<div style="min-width:170px;flex:1"><label class="fl">Search</label><input class="fi" id="f-q" placeholder="summary / id / path…" value="${esc(_f.q)}"/></div>
|
||
<button class="btn b-primary" id="f-go">Apply</button>
|
||
<button class="btn b-cancel" id="f-clear">Clear</button>
|
||
</div>
|
||
<div style="overflow-x:auto;margin-top:12px">
|
||
${_loading?`<div class="empty-msg"><span class="sp"></span> Loading…</div>`:
|
||
!_rows.length?`<div class="empty-msg">No audit events match these filters.</div>`:
|
||
`<table class="lst"><thead><tr><th>When</th><th>User</th><th>Action</th><th>Module</th><th>Ref</th><th>Summary</th><th>Result</th></tr></thead>
|
||
<tbody>${_rows.map(r=>rowHtml(r)).join('')}</tbody></table>
|
||
<div class="pager">
|
||
<button class="btn b-cancel" id="p-prev" ${_page===0?'disabled':''}>‹ Prev</button>
|
||
<span class="count">${start.toLocaleString()}–${end.toLocaleString()} of ${_total.toLocaleString()}</span>
|
||
<button class="btn b-cancel" id="p-next" ${end>=_total?'disabled':''}>Next ›</button>
|
||
</div>`}
|
||
</div>
|
||
</div></div>`;
|
||
wire();
|
||
}
|
||
function fval(v){ if(v==null||v==='')return '<span style="opacity:.5">(empty)</span>'; return esc(typeof v==='object'?JSON.stringify(v):v); }
|
||
function renderDetails(r){
|
||
const d=r.details;
|
||
let inner;
|
||
if(d&&Array.isArray(d.changes)&&d.changes.length){
|
||
inner=`<table class="chg"><thead><tr><th>Field</th><th>Old value</th><th></th><th>New value</th></tr></thead><tbody>${
|
||
d.changes.map(c=>`<tr><td class="fld">${esc(c.field)}</td><td class="old">${c.complex?'(changed)':fval(c.from)}</td><td class="arw">→</td><td class="new">${c.complex?'(changed)':fval(c.to)}</td></tr>`).join('')
|
||
}</tbody></table>`;
|
||
} else if(d&&d.deleted&&typeof d.deleted==='object'){
|
||
inner=`<div style="font-weight:700;color:#e06666;margin-bottom:5px">Deleted record</div><div class="kv">${Object.entries(d.deleted).map(([k,v])=>`<div class="k">${esc(k)}</div><div class="v">${fval(v)}</div>`).join('')}</div>`;
|
||
} else if(d&&typeof d==='object'){
|
||
const ents=Object.entries(d);
|
||
inner=ents.length?`<div class="kv">${ents.map(([k,v])=>`<div class="k">${esc(k)}</div><div class="v">${fval(v)}</div>`).join('')}</div>`:'<span style="opacity:.6">(no fields)</span>';
|
||
} else if(d){
|
||
inner=`<span class="mono">${esc(String(d))}</span>`;
|
||
} else {
|
||
inner='<span style="opacity:.6">(no request body captured)</span>';
|
||
}
|
||
const meta=[r.ip?`IP ${esc(r.ip)}`:'',r.company?`Company ${esc(r.company)}`:'',`${esc(r.method)} ${esc(r.path)}`].filter(Boolean).join(' · ');
|
||
return `<div class="det">${inner}<div class="det-meta">${meta}</div></div>`;
|
||
}
|
||
function rowHtml(r){
|
||
const isOpen=_open.has(r.id);
|
||
return `<tr class="row" data-id="${r.id}">
|
||
<td class="mono" style="white-space:nowrap">${esc(fmt(r.at))}</td>
|
||
<td><b>${esc(r.username||'—')}</b>${r.role?`<div style="font-size:9.5px;color:var(--text3)">${esc(r.role)}</div>`:''}</td>
|
||
<td><span class="pill ${pcls(r.action)}">${esc(r.action||'—')}</span></td>
|
||
<td>${esc(r.entity||'—')}</td>
|
||
<td class="mono">${esc(r.entityId||'')}</td>
|
||
<td>${esc(r.summary||'')}<div style="font-size:9.5px;color:var(--text3)" class="mono">${esc(r.method)} ${esc(r.path)}</div></td>
|
||
<td><span class="${r.ok?'st-ok':'st-fail'}">${r.status||''}${r.ok?' OK':' ✗'}</span></td>
|
||
</tr>
|
||
${isOpen?`<tr><td colspan="7">${renderDetails(r)}</td></tr>`:''}`;
|
||
}
|
||
function wire(){
|
||
q('#f-go')&&(q('#f-go').onclick=()=>{
|
||
_f.from=q('#f-from').value;_f.to=q('#f-to').value;_f.username=q('#f-user').value;_f.entity=q('#f-entity').value;_f.action=q('#f-action').value;_f.status=q('#f-status').value;_f.q=q('#f-q').value.trim();
|
||
load(true);
|
||
});
|
||
q('#f-clear')&&(q('#f-clear').onclick=()=>{Object.keys(_f).forEach(k=>_f[k]='');load(true);});
|
||
q('#f-q')&&(q('#f-q').onkeydown=e=>{if(e.key==='Enter')q('#f-go').click();});
|
||
q('#p-prev')&&(q('#p-prev').onclick=()=>{if(_page>0){_page--;load();}});
|
||
q('#p-next')&&(q('#p-next').onclick=()=>{if((_page+1)*_pageSize<_total){_page++;load();}});
|
||
document.querySelectorAll('tr.row').forEach(tr=>tr.onclick=()=>{const id=Number(tr.dataset.id);_open.has(id)?_open.delete(id):_open.add(id);render();});
|
||
}
|
||
|
||
(async function init(){ await loadFacets(); load(); })();
|
||
</script>
|
||
<script src="/sidebar.js?v=31"></script>
|
||
</body>
|
||
</html>
|