Production readiness hardening and ops tooling
This commit is contained in:
@ -133,28 +133,28 @@
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex-1 space-y-1">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="/ui/admin-reconciliation-management">
|
||||
<span class="material-symbols-outlined">account_balance_wallet</span>
|
||||
<span class="font-label-md">Reconciliation</span>
|
||||
</a>
|
||||
<!-- ACTIVE TAB: Audit Logs -->
|
||||
<a class="flex items-center gap-3 px-4 py-3 bg-secondary-container text-on-secondary-container rounded-xl font-bold transition-all scale-98" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 bg-secondary-container text-on-secondary-container rounded-xl font-bold transition-all scale-98" href="/ui/admin-system-audit-logs">
|
||||
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">security</span>
|
||||
<span class="font-label-md">Audit Logs</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="/ui/admin-dashboard-overview">
|
||||
<span class="material-symbols-outlined">payments</span>
|
||||
<span class="font-label-md">Fee Management</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="/ui/settlement-batch-management">
|
||||
<span class="material-symbols-outlined">receipt_long</span>
|
||||
<span class="font-label-md">Settlements</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="/ui/device-technical-detail">
|
||||
<span class="material-symbols-outlined">router</span>
|
||||
<span class="font-label-md">Device Health</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="/ui/hub">
|
||||
<span class="material-symbols-outlined">contact_support</span>
|
||||
<span class="font-label-md">Support</span>
|
||||
</a>
|
||||
@ -163,7 +163,7 @@
|
||||
Generate Report
|
||||
</button>
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-danger hover:bg-error-container/30 rounded-xl transition-all" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-danger hover:bg-error-container/30 rounded-xl transition-all" href="/ui/admin-login">
|
||||
<span class="material-symbols-outlined">logout</span>
|
||||
<span class="font-label-md">Logout</span>
|
||||
</a>
|
||||
@ -182,7 +182,7 @@
|
||||
<!-- Global Search -->
|
||||
<div class="relative w-80">
|
||||
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">search</span>
|
||||
<input class="w-full bg-slate-50 border-none rounded-lg py-2 pl-10 pr-4 text-body-md focus:ring-2 focus:ring-primary/20 placeholder:text-slate-400" placeholder="Search by Entity ID, User, or IP..." type="text"/>
|
||||
<input id="audit-search" class="w-full bg-slate-50 border-none rounded-lg py-2 pl-10 pr-4 text-body-md focus:ring-2 focus:ring-primary/20 placeholder:text-slate-400" placeholder="Search by Entity ID, User, or IP..." type="text"/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="w-10 h-10 flex items-center justify-center text-slate-500 hover:bg-slate-100 rounded-full transition-colors relative">
|
||||
@ -203,38 +203,60 @@
|
||||
<section class="flex flex-wrap items-center gap-4 bg-white p-4 rounded-xl border border-slate-200">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span class="font-label-md text-slate-500">Action Type:</span>
|
||||
<select class="bg-transparent border-none p-0 text-label-md font-bold text-primary focus:ring-0">
|
||||
<select id="audit-action-filter" class="bg-transparent border-none p-0 text-label-md font-bold text-primary focus:ring-0">
|
||||
<option>All Actions</option>
|
||||
<option>Create</option>
|
||||
<option>Update</option>
|
||||
<option>Delete</option>
|
||||
<option>Login</option>
|
||||
<option value="login">All Login Events</option>
|
||||
<option value="admin.login.failed">Admin Login Failed</option>
|
||||
<option value="admin.login.success">Admin Login Success</option>
|
||||
<option value="merchant.login.failed">Merchant Login Failed</option>
|
||||
<option value="merchant.login.success">Merchant Login Success</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span class="font-label-md text-slate-500">User Role:</span>
|
||||
<select class="bg-transparent border-none p-0 text-label-md font-bold text-primary focus:ring-0">
|
||||
<option>All Roles</option>
|
||||
<option>Super Admin</option>
|
||||
<option>Operator</option>
|
||||
<option>Support</option>
|
||||
<select id="audit-entity-filter" class="bg-transparent border-none p-0 text-label-md font-bold text-primary focus:ring-0">
|
||||
<option value="">All Entities</option>
|
||||
<option value="admin_session">Admin Session</option>
|
||||
<option value="merchant_session">Merchant Session</option>
|
||||
<option value="transaction">Transaction</option>
|
||||
<option value="settlement_batch">Settlement Batch</option>
|
||||
<option value="device">Device</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span class="material-symbols-outlined text-slate-400 text-sm">calendar_today</span>
|
||||
<span class="font-label-md text-slate-500">Date Range:</span>
|
||||
<button class="text-label-md font-bold text-primary">Last 24 Hours</button>
|
||||
<span class="font-label-md text-slate-500">From:</span>
|
||||
<input id="audit-from-filter" class="bg-transparent border-none p-0 text-label-md font-bold text-primary focus:ring-0" type="date"/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span class="material-symbols-outlined text-slate-400 text-sm">calendar_today</span>
|
||||
<span class="font-label-md text-slate-500">To:</span>
|
||||
<input id="audit-to-filter" class="bg-transparent border-none p-0 text-label-md font-bold text-primary focus:ring-0" type="date"/>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
<button class="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 hover:bg-slate-50 rounded-lg text-label-md font-medium transition-colors">
|
||||
<button id="audit-login-preset" class="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 hover:bg-slate-50 rounded-lg text-label-md font-medium transition-colors">
|
||||
<span class="material-symbols-outlined text-sm">filter_list</span>
|
||||
More Filters
|
||||
Login Events
|
||||
</button>
|
||||
<button class="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-label-md font-medium hover:bg-black transition-colors">
|
||||
<button id="audit-refresh" class="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-label-md font-medium hover:bg-black transition-colors">
|
||||
<span class="material-symbols-outlined text-sm">download</span>
|
||||
Export CSV
|
||||
Refresh
|
||||
</button>
|
||||
</section>
|
||||
<section class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<p class="text-label-md text-slate-500 uppercase">Total Events</p>
|
||||
<p id="audit-total-count" class="text-metric-lg font-metric-lg text-slate-900">0</p>
|
||||
</div>
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<p class="text-label-md text-slate-500 uppercase">Login Failed</p>
|
||||
<p id="audit-login-failed-count" class="text-metric-lg font-metric-lg text-danger">0</p>
|
||||
</div>
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<p class="text-label-md text-slate-500 uppercase">Login Success</p>
|
||||
<p id="audit-login-success-count" class="text-metric-lg font-metric-lg text-success">0</p>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Audit Table -->
|
||||
<section class="bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm">
|
||||
<div class="overflow-x-auto data-table-container scrollbar-hide">
|
||||
@ -250,7 +272,7 @@
|
||||
<th class="px-6 py-4 font-label-md text-slate-500 uppercase tracking-wider text-right">Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<tbody id="audit-log-rows" class="divide-y divide-slate-100">
|
||||
<!-- Row 1 -->
|
||||
<tr class="hover:bg-slate-50 transition-colors group">
|
||||
<td class="px-6 py-3.5 whitespace-nowrap">
|
||||
@ -416,8 +438,8 @@
|
||||
<div class="absolute right-0 top-0 h-full w-[500px] bg-white shadow-2xl translate-x-full transition-transform duration-300 flex flex-col" id="drawer-content">
|
||||
<div class="p-6 border-b border-slate-200 flex justify-between items-center bg-slate-50">
|
||||
<div>
|
||||
<h3 class="font-headline-md text-slate-900">Audit Detail Payload</h3>
|
||||
<p class="text-xs text-slate-500 font-mono">ID: 550e8400-e29b-41d4-a716-446655440000</p>
|
||||
<h3 id="audit-drawer-title" class="font-headline-md text-slate-900">Audit Detail Payload</h3>
|
||||
<p id="audit-drawer-id" class="text-xs text-slate-500 font-mono">ID: -</p>
|
||||
</div>
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-slate-200 text-slate-500 transition-colors" onclick="toggleDrawer()">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
@ -429,11 +451,11 @@
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-3 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span class="text-xs text-slate-500">Method</span>
|
||||
<p class="font-bold text-primary">PATCH</p>
|
||||
<p id="audit-drawer-action" class="font-bold text-primary">-</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span class="text-xs text-slate-500">Source Agent</span>
|
||||
<p class="font-bold text-primary">Web-Admin/2.4.1</p>
|
||||
<span class="text-xs text-slate-500">Source IP</span>
|
||||
<p id="audit-drawer-ip" class="font-bold text-primary">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -446,7 +468,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl p-6 overflow-hidden relative group">
|
||||
<pre class="font-mono text-[13px] text-green-400 overflow-x-auto scrollbar-hide">{
|
||||
<pre id="audit-drawer-json" class="font-mono text-[13px] text-green-400 overflow-x-auto scrollbar-hide">{
|
||||
"action": "UPDATE_MERCHANT_FEE",
|
||||
"metadata": {
|
||||
"merchant_id": "MID-88219-X",
|
||||
@ -483,13 +505,157 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/ui/shared/admin-api.js"></script>
|
||||
<script>
|
||||
function toggleDrawer() {
|
||||
const api = window.AdminUIAPI;
|
||||
let auditLogs = [];
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
}
|
||||
|
||||
function isoStart(value) {
|
||||
return value ? `${value}T00:00:00.000Z` : undefined;
|
||||
}
|
||||
|
||||
function isoEnd(value) {
|
||||
return value ? `${value}T23:59:59.999Z` : undefined;
|
||||
}
|
||||
|
||||
function formatDateParts(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return { date: '-', time: '-' };
|
||||
}
|
||||
return {
|
||||
date: new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium' }).format(date),
|
||||
time: new Intl.DateTimeFormat('en-GB', { timeStyle: 'medium' }).format(date)
|
||||
};
|
||||
}
|
||||
|
||||
function statusForAction(action) {
|
||||
if (String(action || '').includes('.failed')) return { label: 'Failed', cls: 'bg-danger/10 text-danger', dot: 'bg-danger' };
|
||||
if (String(action || '').includes('.success')) return { label: 'Success', cls: 'bg-success/10 text-success', dot: 'bg-success' };
|
||||
return { label: 'Recorded', cls: 'bg-info/10 text-info', dot: 'bg-info' };
|
||||
}
|
||||
|
||||
function buildQuery() {
|
||||
const action = document.getElementById('audit-action-filter')?.value;
|
||||
const entityType = document.getElementById('audit-entity-filter')?.value;
|
||||
return {
|
||||
limit: 100,
|
||||
action: action && action !== 'login' ? action : undefined,
|
||||
action_contains: action === 'login' ? '.login.' : undefined,
|
||||
entity_type: entityType || undefined,
|
||||
from: isoStart(document.getElementById('audit-from-filter')?.value),
|
||||
to: isoEnd(document.getElementById('audit-to-filter')?.value)
|
||||
};
|
||||
}
|
||||
|
||||
function filteredRows() {
|
||||
const needle = (document.getElementById('audit-search')?.value || '').trim().toLowerCase();
|
||||
if (!needle) return auditLogs;
|
||||
return auditLogs.filter((entry) => [
|
||||
entry.id,
|
||||
entry.actor_id,
|
||||
entry.action,
|
||||
entry.entity_type,
|
||||
entry.entity_id,
|
||||
entry.source_ip,
|
||||
entry.request_id,
|
||||
entry.trace_id
|
||||
].filter(Boolean).some((value) => String(value).toLowerCase().includes(needle)));
|
||||
}
|
||||
|
||||
function renderSummary(rows) {
|
||||
setText('audit-total-count', rows.length);
|
||||
setText('audit-login-failed-count', rows.filter((row) => String(row.action || '').includes('login.failed')).length);
|
||||
setText('audit-login-success-count', rows.filter((row) => String(row.action || '').includes('login.success')).length);
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
const tbody = document.getElementById('audit-log-rows');
|
||||
const rows = filteredRows();
|
||||
renderSummary(rows);
|
||||
if (!tbody) return;
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="px-6 py-10 text-center text-slate-500">No audit events found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = rows.map((entry) => {
|
||||
const when = formatDateParts(entry.created_at);
|
||||
const status = statusForAction(entry.action);
|
||||
return `
|
||||
<tr class="hover:bg-slate-50 transition-colors group">
|
||||
<td class="px-6 py-3.5 whitespace-nowrap">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-metric-sm text-slate-900">${escapeHtml(when.date)}</span>
|
||||
<span class="text-xs text-slate-500">${escapeHtml(when.time)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3.5 whitespace-nowrap">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-slate-900">${escapeHtml(entry.actor_id || '-')}</span>
|
||||
<span class="text-[10px] bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded-full w-fit font-bold uppercase">${escapeHtml(entry.actor_type || 'system')}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3.5"><span class="text-body-md font-medium text-slate-900">${escapeHtml(entry.action || '-')}</span></td>
|
||||
<td class="px-6 py-3.5"><code class="font-mono text-xs text-primary bg-primary-fixed/30 px-2 py-1 rounded">${escapeHtml(entry.entity_id || '-')}</code></td>
|
||||
<td class="px-6 py-3.5 whitespace-nowrap text-slate-500 text-xs">${escapeHtml(entry.source_ip || '-')}</td>
|
||||
<td class="px-6 py-3.5">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold ${status.cls}">
|
||||
<span class="w-1.5 h-1.5 rounded-full ${status.dot}"></span> ${status.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3.5 text-right">
|
||||
<button data-audit-id="${escapeHtml(entry.id)}" class="text-primary hover:underline font-label-md">View JSON</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openDrawer(entry) {
|
||||
if (!entry) return;
|
||||
setText('audit-drawer-title', entry.action || 'Audit Detail Payload');
|
||||
setText('audit-drawer-id', `ID: ${entry.id}`);
|
||||
setText('audit-drawer-action', entry.action || '-');
|
||||
setText('audit-drawer-ip', entry.source_ip || '-');
|
||||
const json = {
|
||||
id: entry.id,
|
||||
actor_type: entry.actor_type,
|
||||
actor_id: entry.actor_id,
|
||||
action: entry.action,
|
||||
entity_type: entry.entity_type,
|
||||
entity_id: entry.entity_id,
|
||||
before_json: entry.before_json,
|
||||
after_json: entry.after_json,
|
||||
source_ip: entry.source_ip,
|
||||
request_id: entry.request_id,
|
||||
trace_id: entry.trace_id,
|
||||
created_at: entry.created_at
|
||||
};
|
||||
setText('audit-drawer-json', JSON.stringify(json, null, 2));
|
||||
toggleDrawer(true);
|
||||
}
|
||||
|
||||
function toggleDrawer(forceOpen) {
|
||||
const drawer = document.getElementById('payload-drawer');
|
||||
const overlay = document.getElementById('drawer-overlay');
|
||||
const content = document.getElementById('drawer-content');
|
||||
|
||||
if (drawer.classList.contains('invisible')) {
|
||||
const shouldOpen = forceOpen === true || drawer.classList.contains('invisible');
|
||||
if (shouldOpen) {
|
||||
drawer.classList.remove('invisible');
|
||||
setTimeout(() => {
|
||||
overlay.classList.replace('opacity-0', 'opacity-100');
|
||||
@ -498,20 +664,47 @@
|
||||
} else {
|
||||
overlay.classList.replace('opacity-100', 'opacity-0');
|
||||
content.classList.replace('translate-x-0', 'translate-x-full');
|
||||
setTimeout(() => {
|
||||
drawer.classList.add('invisible');
|
||||
}, 300);
|
||||
setTimeout(() => drawer.classList.add('invisible'), 300);
|
||||
}
|
||||
}
|
||||
|
||||
// Close on overlay click
|
||||
document.getElementById('drawer-overlay').addEventListener('click', toggleDrawer);
|
||||
|
||||
// Escape key to close
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !document.getElementById('payload-drawer').classList.contains('invisible')) {
|
||||
toggleDrawer();
|
||||
async function loadAuditLogs() {
|
||||
try {
|
||||
api.requireToken();
|
||||
auditLogs = await api.listAuditLogs(buildQuery());
|
||||
renderRows();
|
||||
} catch (error) {
|
||||
const tbody = document.getElementById('audit-log-rows');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="px-6 py-10 text-center text-danger">${escapeHtml(error.message || 'Failed to load audit logs')}</td></tr>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('drawer-overlay')?.addEventListener('click', () => toggleDrawer(false));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !document.getElementById('payload-drawer').classList.contains('invisible')) {
|
||||
toggleDrawer(false);
|
||||
}
|
||||
});
|
||||
document.getElementById('audit-log-rows')?.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-audit-id]');
|
||||
if (!button) return;
|
||||
openDrawer(auditLogs.find((entry) => entry.id === button.dataset.auditId));
|
||||
});
|
||||
['audit-action-filter', 'audit-entity-filter', 'audit-from-filter', 'audit-to-filter'].forEach((id) => {
|
||||
document.getElementById(id)?.addEventListener('change', loadAuditLogs);
|
||||
});
|
||||
document.getElementById('audit-search')?.addEventListener('input', renderRows);
|
||||
document.getElementById('audit-refresh')?.addEventListener('click', loadAuditLogs);
|
||||
document.getElementById('audit-login-preset')?.addEventListener('click', () => {
|
||||
document.getElementById('audit-action-filter').value = 'login';
|
||||
document.getElementById('audit-entity-filter').value = '';
|
||||
loadAuditLogs();
|
||||
});
|
||||
api.applyPermissions?.();
|
||||
loadAuditLogs();
|
||||
});
|
||||
</script>
|
||||
<!-- ui-nav -->
|
||||
@ -523,4 +716,4 @@
|
||||
<a href="/ui/admin-dashboard-overview" style="margin-right:0;color:#2563eb;text-decoration:none;font-weight:600">Dashboard</a>
|
||||
</div>
|
||||
'
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
Reference in New Issue
Block a user