index.twig 11 KB

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