| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194 |
- {% extends "layout.twig" %}
- {% set actionClasses = {
- 'CREATE': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
- 'UPDATE': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
- 'DELETE': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
- 'LOGIN': 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
- 'LOGOUT': 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
- 'LOGIN_FAILED': 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
- 'BOOTSTRAP_ADMIN': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
- } %}
- {% set defaultActionClass = 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200' %}
- {% set anyFilter = false %}
- {% for v in filters %}{% if v != '' and v is not null %}{% set anyFilter = true %}{% endif %}{% endfor %}
- {% block content %}
- <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 dark:text-slate-400">
- {{ total }} matching row{{ total == 1 ? '' : 's' }}
- · page {{ page }} / {{ pages }}
- · {{ pageSize }} per page
- </p>
- </div>
- </header>
- <form method="get" action="/audit"
- hx-boost="true" hx-target="body" hx-push-url="true"
- class="rounded-lg border bg-white p-4 grid grid-cols-1 md:grid-cols-6 gap-3 dark:bg-slate-800 dark:border-slate-700">
- <label class="block">
- <span class="text-xs text-slate-600 dark:text-slate-400">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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
- <option value="">Any</option>
- {% for u in users %}
- <option value="{{ u }}" {{ filters.user_email == u ? 'selected' : '' }}>{{ u }}</option>
- {% endfor %}
- </select>
- </label>
- <label class="block">
- <span class="text-xs text-slate-600 dark:text-slate-400">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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
- <option value="">Any</option>
- {% for a in actions %}
- <option value="{{ a }}" {{ filters.action == a ? 'selected' : '' }}>{{ a }}</option>
- {% endfor %}
- </select>
- </label>
- <label class="block">
- <span class="text-xs text-slate-600 dark:text-slate-400">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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
- <option value="">Any</option>
- {% for t in entityTypes %}
- <option value="{{ t }}" {{ filters.entity_type == t ? 'selected' : '' }}>{{ t }}</option>
- {% endfor %}
- </select>
- </label>
- <label class="block">
- <span class="text-xs text-slate-600 dark:text-slate-400">Entity ID (contains)</span>
- <input name="entity_id" type="text"
- value="{{ 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 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
- </label>
- <label class="block">
- <span class="text-xs text-slate-600 dark:text-slate-400">From date</span>
- <input name="from_date" type="date"
- value="{{ filters.from_date }}"
- class="mt-1 block w-full rounded border {{ dateErrors.from_date is defined ? 'border-amber-400 dark:border-amber-500' : 'border-slate-300 dark:border-slate-600' }} px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:text-slate-100 dark:focus:ring-slate-500">
- {% if dateErrors.from_date is defined %}
- <span class="block mt-1 text-xs text-amber-700 dark:text-amber-300">{{ dateErrors.from_date }}</span>
- {% endif %}
- </label>
- <label class="block">
- <span class="text-xs text-slate-600 dark:text-slate-400">To date</span>
- <input name="to_date" type="date"
- value="{{ filters.to_date }}"
- class="mt-1 block w-full rounded border {{ dateErrors.to_date is defined ? 'border-amber-400 dark:border-amber-500' : 'border-slate-300 dark:border-slate-600' }} px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:text-slate-100 dark:focus:ring-slate-500">
- {% if dateErrors.to_date is defined %}
- <span class="block mt-1 text-xs text-amber-700 dark:text-amber-300">{{ dateErrors.to_date }}</span>
- {% endif %}
- </label>
- {% if dateErrors is not empty %}
- <div class="md:col-span-6 -mt-1 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-600 dark:bg-amber-900/30 dark:text-amber-200">
- Invalid date filter ignored. Use the YYYY-MM-DD format (e.g. 2026-01-31).
- </div>
- {% endif %}
- <div class="md:col-span-6 flex items-center justify-end gap-2">
- {% if anyFilter %}
- <a href="/audit" class="text-sm text-slate-600 hover:underline dark:text-slate-400 dark:hover:text-slate-200">Clear</a>
- {% endif %}
- <button type="submit"
- class="rounded bg-slate-900 text-white px-4 py-1.5 text-sm font-medium hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600">
- Apply
- </button>
- </div>
- </form>
- <div class="rounded-lg border bg-white overflow-hidden dark:bg-slate-800 dark:border-slate-700">
- {% if rows is empty %}
- <div class="p-8 text-center text-slate-500 text-sm dark:text-slate-400">No audit rows match.</div>
- {% else %}
- <table class="min-w-full text-sm">
- <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider dark:bg-slate-700 dark:text-slate-300">
- <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 dark:divide-slate-700">
- {% for r in rows %}
- <tr>
- <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">{{ r.occurred_at }}</td>
- <td class="px-3 py-2">
- {% if r.user_email is not null and r.user_email != '' %}{{ r.user_email }}{% else %}<span class="text-slate-400 dark:text-slate-500">—</span>{% endif %}
- </td>
- <td class="px-3 py-2">
- <span class="inline-block px-1.5 py-0.5 rounded text-xs font-mono {{ actionClasses[r.action]|default(defaultActionClass) }}">
- {{ r.action }}
- </span>
- </td>
- <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">
- {{ r.entity_type }}
- {% if r.entity_id is not null %}
- <span class="text-slate-500 dark:text-slate-400">/</span>
- {{ r.entity_id }}
- {% endif %}
- </td>
- <td class="px-3 py-2">
- {% set b = pretty_json(r.before_json|default(null)) %}
- {% set a = pretty_json(r.after_json|default(null)) %}
- {% if b == '' and a == '' %}
- <span class="text-slate-400 text-xs dark:text-slate-500">—</span>
- {% else %}
- <details class="text-xs">
- <summary class="cursor-pointer text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100">
- {% if b != '' and a != '' %}before / after{% elseif b != '' %}before only{% else %}after only{% endif %}
- </summary>
- {% if b != '' %}
- <div class="mt-1 text-[11px] text-slate-500 dark:text-slate-400">before</div>
- <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto dark:bg-slate-900 dark:text-slate-200">{{ b }}</pre>
- {% endif %}
- {% if a != '' %}
- <div class="mt-1 text-[11px] text-slate-500 dark:text-slate-400">after</div>
- <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto dark:bg-slate-900 dark:text-slate-200">{{ a }}</pre>
- {% endif %}
- </details>
- {% endif %}
- </td>
- <td class="px-3 py-2 text-xs text-slate-500 whitespace-nowrap dark:text-slate-400">
- {{ r.ip_address|default('') }}
- {% if r.user_agent is defined and r.user_agent %}
- <span class="text-slate-300 dark:text-slate-500" title="{{ r.user_agent }}">(UA)</span>
- {% endif %}
- </td>
- </tr>
- {% endfor %}
- </tbody>
- </table>
- {% endif %}
- </div>
- {% if pages > 1 %}
- <nav class="flex items-center justify-between text-sm">
- {% set prevQs = query_string(filters, 'page', { page: max(1, page - 1) }) %}
- {% set nextQs = query_string(filters, 'page', { page: min(pages, page + 1) }) %}
- <a href="/audit{{ prevQs }}"
- class="{{ page <= 1 ? 'pointer-events-none text-slate-300 dark:text-slate-600' : 'text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300' }}">
- ← Previous
- </a>
- <span class="text-slate-600 dark:text-slate-400">Page {{ page }} of {{ pages }}</span>
- <a href="/audit{{ nextQs }}"
- class="{{ page >= pages ? 'pointer-events-none text-slate-300 dark:text-slate-600' : 'text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300' }}">
- Next →
- </a>
- </nav>
- {% endif %}
- </section>
- {% endblock %}
|