| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218 |
- <?php
- /** @var array{user_email:string,action:string,entity_type:string,entity_id:string,from_date:string,to_date:string} $filters */
- /** @var int $page */
- /** @var int $pages */
- /** @var int $pageSize */
- /** @var int $total */
- /** @var list<array<string,mixed>> $rows */
- /** @var list<string> $actions */
- /** @var list<string> $entityTypes */
- /** @var list<string> $users */
- use function App\Http\e;
- /** Pretty-print JSON for display; tolerate non-JSON values gracefully. */
- $prettyJson = static function (?string $raw): string {
- if ($raw === null || $raw === '') { return ''; }
- try {
- $v = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
- } catch (\JsonException) {
- return $raw;
- }
- return json_encode($v, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $raw;
- };
- /** Build the current query string minus one key (for pagination links). */
- $qsWithout = static function (array $filters, string $drop, array $extra = []): string {
- $params = array_filter(array_merge($filters, $extra), fn($v) => $v !== '' && $v !== null);
- unset($params[$drop]);
- return $params === [] ? '' : '?' . http_build_query($params);
- };
- $anyFilter = array_filter($filters, fn($v) => $v !== '' && $v !== null);
- ?>
- <section class="space-y-5">
- <header class="flex items-end justify-between gap-4">
- <div>
- <h1 class="text-2xl font-semibold tracking-tight">Audit log</h1>
- <p class="text-slate-600 text-sm mt-1">
- <?= (int) $total ?> matching row<?= $total === 1 ? '' : 's' ?>
- · page <?= (int) $page ?> / <?= (int) $pages ?>
- · <?= (int) $pageSize ?> per page
- </p>
- </div>
- </header>
- <!-- Filter form -->
- <form method="get" action="/audit"
- class="rounded-lg border bg-white p-4 grid grid-cols-1 md:grid-cols-6 gap-3">
- <label class="block">
- <span class="text-xs text-slate-600">User</span>
- <select name="user_email"
- class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
- <option value="">Any</option>
- <?php foreach ($users as $u): ?>
- <option value="<?= e($u) ?>" <?= $filters['user_email'] === $u ? 'selected' : '' ?>><?= e($u) ?></option>
- <?php endforeach; ?>
- </select>
- </label>
- <label class="block">
- <span class="text-xs text-slate-600">Action</span>
- <select name="action"
- class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
- <option value="">Any</option>
- <?php foreach ($actions as $a): ?>
- <option value="<?= e($a) ?>" <?= $filters['action'] === $a ? 'selected' : '' ?>><?= e($a) ?></option>
- <?php endforeach; ?>
- </select>
- </label>
- <label class="block">
- <span class="text-xs text-slate-600">Entity type</span>
- <select name="entity_type"
- class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-slate-400">
- <option value="">Any</option>
- <?php foreach ($entityTypes as $t): ?>
- <option value="<?= e($t) ?>" <?= $filters['entity_type'] === $t ? 'selected' : '' ?>><?= e($t) ?></option>
- <?php endforeach; ?>
- </select>
- </label>
- <label class="block">
- <span class="text-xs text-slate-600">Entity ID (contains)</span>
- <input name="entity_id" type="text"
- value="<?= e($filters['entity_id']) ?>"
- class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">
- </label>
- <label class="block">
- <span class="text-xs text-slate-600">From date</span>
- <input name="from_date" type="date"
- value="<?= e($filters['from_date']) ?>"
- class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">
- </label>
- <label class="block">
- <span class="text-xs text-slate-600">To date</span>
- <input name="to_date" type="date"
- value="<?= e($filters['to_date']) ?>"
- class="mt-1 block w-full rounded border border-slate-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400">
- </label>
- <div class="md:col-span-6 flex items-center justify-end gap-2">
- <?php if ($anyFilter): ?>
- <a href="/audit" class="text-sm text-slate-600 hover:underline">Clear</a>
- <?php endif; ?>
- <button type="submit"
- class="rounded bg-slate-900 text-white px-4 py-1.5 text-sm font-medium hover:bg-slate-800">
- Apply
- </button>
- </div>
- </form>
- <!-- Rows -->
- <div class="rounded-lg border bg-white overflow-hidden">
- <?php if ($rows === []): ?>
- <div class="p-8 text-center text-slate-500 text-sm">No audit rows match.</div>
- <?php else: ?>
- <table class="min-w-full text-sm">
- <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider">
- <tr>
- <th class="text-left px-3 py-2 font-semibold">When (UTC)</th>
- <th class="text-left px-3 py-2 font-semibold">User</th>
- <th class="text-left px-3 py-2 font-semibold">Action</th>
- <th class="text-left px-3 py-2 font-semibold">Entity</th>
- <th class="text-left px-3 py-2 font-semibold">Diff</th>
- <th class="text-left px-3 py-2 font-semibold">Origin</th>
- </tr>
- </thead>
- <tbody class="divide-y divide-slate-100 align-top">
- <?php foreach ($rows as $r): ?>
- <tr>
- <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">
- <?= e((string) $r['occurred_at']) ?>
- </td>
- <td class="px-3 py-2">
- <?= $r['user_email'] !== null && $r['user_email'] !== ''
- ? e((string) $r['user_email'])
- : '<span class="text-slate-400">—</span>' ?>
- </td>
- <td class="px-3 py-2">
- <span class="inline-block px-1.5 py-0.5 rounded text-xs font-mono
- <?php
- $action = (string) $r['action'];
- echo match ($action) {
- 'CREATE' => 'bg-green-100 text-green-800',
- 'UPDATE' => 'bg-blue-100 text-blue-800',
- 'DELETE' => 'bg-red-100 text-red-800',
- 'LOGIN' => 'bg-slate-100 text-slate-700',
- 'LOGOUT' => 'bg-slate-100 text-slate-700',
- 'LOGIN_FAILED' => 'bg-amber-100 text-amber-800',
- 'BOOTSTRAP_ADMIN' => 'bg-purple-100 text-purple-800',
- default => 'bg-slate-100 text-slate-700',
- };
- ?>">
- <?= e($action) ?>
- </span>
- </td>
- <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">
- <?= e((string) $r['entity_type']) ?>
- <?php if ($r['entity_id'] !== null): ?>
- <span class="text-slate-500">/</span>
- <?= e((string) $r['entity_id']) ?>
- <?php endif; ?>
- </td>
- <td class="px-3 py-2">
- <?php $b = $prettyJson($r['before_json'] ?? null); $a = $prettyJson($r['after_json'] ?? null); ?>
- <?php if ($b === '' && $a === ''): ?>
- <span class="text-slate-400 text-xs">—</span>
- <?php else: ?>
- <details class="text-xs">
- <summary class="cursor-pointer text-slate-600 hover:text-slate-900">
- <?= $b !== '' && $a !== '' ? 'before / after'
- : ($b !== '' ? 'before only' : 'after only') ?>
- </summary>
- <?php if ($b !== ''): ?>
- <div class="mt-1 text-[11px] text-slate-500">before</div>
- <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto"><?= e($b) ?></pre>
- <?php endif; ?>
- <?php if ($a !== ''): ?>
- <div class="mt-1 text-[11px] text-slate-500">after</div>
- <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto"><?= e($a) ?></pre>
- <?php endif; ?>
- </details>
- <?php endif; ?>
- </td>
- <td class="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">
- <?= e((string) ($r['ip_address'] ?? '')) ?>
- <?php if (!empty($r['user_agent'])): ?>
- <span class="text-slate-300"
- title="<?= e((string) $r['user_agent']) ?>">(UA)</span>
- <?php endif; ?>
- </td>
- </tr>
- <?php endforeach; ?>
- </tbody>
- </table>
- <?php endif; ?>
- </div>
- <!-- Pagination -->
- <?php if ($pages > 1): ?>
- <nav class="flex items-center justify-between text-sm">
- <?php
- $prevQs = $qsWithout($filters, 'page', ['page' => max(1, $page - 1)]);
- $nextQs = $qsWithout($filters, 'page', ['page' => min($pages, $page + 1)]);
- ?>
- <a href="/audit<?= e($prevQs) ?>"
- class="<?= $page <= 1 ? 'pointer-events-none text-slate-300' : 'text-blue-700 hover:underline' ?>">
- ← Previous
- </a>
- <span class="text-slate-600">Page <?= (int) $page ?> of <?= (int) $pages ?></span>
- <a href="/audit<?= e($nextQs) ?>"
- class="<?= $page >= $pages ? 'pointer-events-none text-slate-300' : 'text-blue-700 hover:underline' ?>">
- Next →
- </a>
- </nav>
- <?php endif; ?>
- </section>
|