index.twig 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. {% extends "layout.twig" %}
  2. {% set actionClasses = {
  3. 'CREATE': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
  4. 'UPDATE': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
  5. 'DELETE': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
  6. 'TOMBSTONE': 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
  7. 'RESTORE': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
  8. 'LOGIN': 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
  9. 'LOGOUT': 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
  10. 'LOGIN_FAILED': 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
  11. 'BOOTSTRAP_ADMIN': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
  12. } %}
  13. {% set defaultActionClass = 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200' %}
  14. {% set anyFilter = false %}
  15. {% for v in filters %}{% if v != '' and v is not null %}{% set anyFilter = true %}{% endif %}{% endfor %}
  16. {% block content %}
  17. <section class="space-y-5">
  18. <header class="flex items-end justify-between gap-4">
  19. <div>
  20. <h1 class="text-2xl font-semibold tracking-tight">Audit log</h1>
  21. <p class="text-slate-600 text-sm mt-1 dark:text-slate-400">
  22. {{ total }} matching row{{ total == 1 ? '' : 's' }}
  23. · page {{ page }} / {{ pages }}
  24. · {{ pageSize }} per page
  25. </p>
  26. </div>
  27. </header>
  28. <form method="get" action="/audit"
  29. hx-boost="true" hx-target="body" hx-push-url="true"
  30. 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">
  31. <label class="block">
  32. <span class="text-xs text-slate-600 dark:text-slate-400">User</span>
  33. <select name="user_email"
  34. 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">
  35. <option value="">Any</option>
  36. {% for u in users %}
  37. <option value="{{ u }}" {{ filters.user_email == u ? 'selected' : '' }}>{{ u }}</option>
  38. {% endfor %}
  39. </select>
  40. </label>
  41. <label class="block">
  42. <span class="text-xs text-slate-600 dark:text-slate-400">Action</span>
  43. <select name="action"
  44. 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">
  45. <option value="">Any</option>
  46. {% for a in actions %}
  47. <option value="{{ a }}" {{ filters.action == a ? 'selected' : '' }}>{{ a }}</option>
  48. {% endfor %}
  49. </select>
  50. </label>
  51. <label class="block">
  52. <span class="text-xs text-slate-600 dark:text-slate-400">Entity type</span>
  53. <select name="entity_type"
  54. 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">
  55. <option value="">Any</option>
  56. {% for t in entityTypes %}
  57. <option value="{{ t }}" {{ filters.entity_type == t ? 'selected' : '' }}>{{ t }}</option>
  58. {% endfor %}
  59. </select>
  60. </label>
  61. <label class="block">
  62. <span class="text-xs text-slate-600 dark:text-slate-400">Entity ID (contains)</span>
  63. <input name="entity_id" type="text"
  64. value="{{ filters.entity_id }}"
  65. 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">
  66. </label>
  67. <label class="block">
  68. <span class="text-xs text-slate-600 dark:text-slate-400">From date</span>
  69. <input name="from_date" type="date"
  70. value="{{ filters.from_date }}"
  71. 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">
  72. {% if dateErrors.from_date is defined %}
  73. <span class="block mt-1 text-xs text-amber-700 dark:text-amber-300">{{ dateErrors.from_date }}</span>
  74. {% endif %}
  75. </label>
  76. <label class="block">
  77. <span class="text-xs text-slate-600 dark:text-slate-400">To date</span>
  78. <input name="to_date" type="date"
  79. value="{{ filters.to_date }}"
  80. 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">
  81. {% if dateErrors.to_date is defined %}
  82. <span class="block mt-1 text-xs text-amber-700 dark:text-amber-300">{{ dateErrors.to_date }}</span>
  83. {% endif %}
  84. </label>
  85. {% if dateErrors is not empty %}
  86. <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">
  87. Invalid date filter ignored. Use the YYYY-MM-DD format (e.g. 2026-01-31).
  88. </div>
  89. {% endif %}
  90. <div class="md:col-span-6 flex items-center justify-end gap-2">
  91. {% if anyFilter %}
  92. <a href="/audit" class="text-sm text-slate-600 hover:underline dark:text-slate-400 dark:hover:text-slate-200">Clear</a>
  93. {% endif %}
  94. <button type="submit"
  95. 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">
  96. Apply
  97. </button>
  98. </div>
  99. </form>
  100. <div class="rounded-lg border bg-white overflow-hidden dark:bg-slate-800 dark:border-slate-700">
  101. {% if rows is empty %}
  102. <div class="p-8 text-center text-slate-500 text-sm dark:text-slate-400">No audit rows match.</div>
  103. {% else %}
  104. <table class="min-w-full text-sm">
  105. <thead class="bg-slate-50 text-slate-600 text-xs uppercase tracking-wider dark:bg-slate-700 dark:text-slate-300">
  106. <tr>
  107. <th class="text-left px-3 py-2 font-semibold">When (UTC)</th>
  108. <th class="text-left px-3 py-2 font-semibold">User</th>
  109. <th class="text-left px-3 py-2 font-semibold">Action</th>
  110. <th class="text-left px-3 py-2 font-semibold">Entity</th>
  111. <th class="text-left px-3 py-2 font-semibold">Diff</th>
  112. <th class="text-left px-3 py-2 font-semibold">Origin</th>
  113. </tr>
  114. </thead>
  115. <tbody class="divide-y divide-slate-100 align-top dark:divide-slate-700">
  116. {% for r in rows %}
  117. <tr>
  118. <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">{{ r.occurred_at }}</td>
  119. <td class="px-3 py-2">
  120. {% 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 %}
  121. </td>
  122. <td class="px-3 py-2">
  123. <span class="inline-block px-1.5 py-0.5 rounded text-xs font-mono {{ actionClasses[r.action]|default(defaultActionClass) }}">
  124. {{ r.action }}
  125. </span>
  126. </td>
  127. <td class="px-3 py-2 font-mono text-xs whitespace-nowrap">
  128. {{ r.entity_type }}
  129. {% if r.entity_id is not null %}
  130. <span class="text-slate-500 dark:text-slate-400">/</span>
  131. {{ r.entity_id }}
  132. {% endif %}
  133. </td>
  134. <td class="px-3 py-2">
  135. {% set b = pretty_json(r.before_json|default(null)) %}
  136. {% set a = pretty_json(r.after_json|default(null)) %}
  137. {% if b == '' and a == '' %}
  138. <span class="text-slate-400 text-xs dark:text-slate-500">—</span>
  139. {% else %}
  140. <details class="text-xs">
  141. <summary class="cursor-pointer text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100">
  142. {% if b != '' and a != '' %}before / after{% elseif b != '' %}before only{% else %}after only{% endif %}
  143. </summary>
  144. {% if b != '' %}
  145. <div class="mt-1 text-[11px] text-slate-500 dark:text-slate-400">before</div>
  146. <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto dark:bg-slate-900 dark:text-slate-200">{{ b }}</pre>
  147. {% endif %}
  148. {% if a != '' %}
  149. <div class="mt-1 text-[11px] text-slate-500 dark:text-slate-400">after</div>
  150. <pre class="mt-1 p-2 bg-slate-50 rounded overflow-x-auto dark:bg-slate-900 dark:text-slate-200">{{ a }}</pre>
  151. {% endif %}
  152. </details>
  153. {% endif %}
  154. </td>
  155. <td class="px-3 py-2 text-xs text-slate-500 whitespace-nowrap dark:text-slate-400">
  156. {{ r.ip_address|default('') }}
  157. {% if r.user_agent is defined and r.user_agent %}
  158. <span class="text-slate-300 dark:text-slate-500" title="{{ r.user_agent }}">(UA)</span>
  159. {% endif %}
  160. </td>
  161. </tr>
  162. {% endfor %}
  163. </tbody>
  164. </table>
  165. {% endif %}
  166. </div>
  167. {% if pages > 1 %}
  168. <nav class="flex items-center justify-between text-sm">
  169. {% set prevQs = query_string(filters, 'page', { page: max(1, page - 1) }) %}
  170. {% set nextQs = query_string(filters, 'page', { page: min(pages, page + 1) }) %}
  171. <a href="/audit{{ prevQs }}"
  172. 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' }}">
  173. ← Previous
  174. </a>
  175. <span class="text-slate-600 dark:text-slate-400">Page {{ page }} of {{ pages }}</span>
  176. <a href="/audit{{ nextQs }}"
  177. 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' }}">
  178. Next →
  179. </a>
  180. </nav>
  181. {% endif %}
  182. </section>
  183. {% endblock %}