athar 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +5 -1
  3. data/README.md +60 -0
  4. data/app/assets/images/athar/logo.png +0 -0
  5. data/app/assets/javascripts/athar/dashboard.js +290 -0
  6. data/app/assets/stylesheets/athar/dashboard.css +841 -0
  7. data/app/controllers/athar/application_controller.rb +10 -0
  8. data/app/controllers/athar/dashboard_controller.rb +57 -0
  9. data/app/controllers/athar/deletions_controller.rb +14 -0
  10. data/app/controllers/athar/table_events_controller.rb +11 -0
  11. data/app/controllers/athar/themes_controller.rb +16 -0
  12. data/app/helpers/athar/asset_helper.rb +28 -0
  13. data/app/helpers/athar/dashboard/cell_helper.rb +88 -0
  14. data/app/helpers/athar/dashboard/detail_helper.rb +50 -0
  15. data/app/helpers/athar/dashboard/filter_link_helper.rb +22 -0
  16. data/app/helpers/athar/dashboard/formatting_helper.rb +47 -0
  17. data/app/helpers/athar/dashboard/icon_helper.rb +40 -0
  18. data/app/helpers/athar/dashboard_helper.rb +11 -0
  19. data/app/views/athar/dashboard/_filter_bar.html.erb +95 -0
  20. data/app/views/athar/dashboard/_kpi_strip.html.erb +46 -0
  21. data/app/views/athar/dashboard/_pager.html.erb +32 -0
  22. data/app/views/athar/dashboard/_row.html.erb +72 -0
  23. data/app/views/athar/dashboard/_sidebar.html.erb +106 -0
  24. data/app/views/athar/dashboard/_table.html.erb +30 -0
  25. data/app/views/athar/dashboard/_topbar.html.erb +30 -0
  26. data/app/views/athar/dashboard/index.html.erb +31 -0
  27. data/app/views/athar/deletions/_detail.html.erb +115 -0
  28. data/app/views/athar/deletions/show.html.erb +3 -0
  29. data/app/views/athar/table_events/_detail.html.erb +80 -0
  30. data/app/views/athar/table_events/show.html.erb +3 -0
  31. data/app/views/layouts/athar/application.html.erb +29 -0
  32. data/config/routes.rb +8 -0
  33. data/lib/athar/dashboard/actor_labels.rb +31 -0
  34. data/lib/athar/dashboard/actor_options.rb +71 -0
  35. data/lib/athar/dashboard/connection_info.rb +25 -0
  36. data/lib/athar/dashboard/feed_query.rb +222 -0
  37. data/lib/athar/dashboard/filter_set.rb +63 -0
  38. data/lib/athar/dashboard/kpi_calculator.rb +102 -0
  39. data/lib/athar/dashboard/model_registry.rb +141 -0
  40. data/lib/athar/dashboard/sparkline.rb +42 -0
  41. data/lib/athar/dashboard/trigger_args_parser.rb +42 -0
  42. data/lib/athar/dashboard.rb +16 -0
  43. data/lib/athar/engine.rb +12 -0
  44. data/lib/athar/middleware/asset_server.rb +78 -0
  45. data/lib/athar/version.rb +1 -1
  46. data/lib/athar.rb +1 -0
  47. metadata +41 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b0ef1986b6cadc08da710ef20abb9526c86f097bc032bf881ea9edb669884cd
4
- data.tar.gz: 0cbadc431bdcc7c390f3e7e4d4e9bb8f2f80c269f770c7b855228fd1988da8b1
3
+ metadata.gz: f0b877c826fef22c35dbefe9df8a1d46704567f6a34041afb76f2be0c03d4ac0
4
+ data.tar.gz: 5d53ed66ce9dfaf3d64453ec9913c14498561cdad2e6db6593ec1899326fbd02
5
5
  SHA512:
6
- metadata.gz: 05d969a0d558f38df5377489cadf8474c224838613714df374759e60a558a42de329d345b9aebe827a5b77bdf5f49aa57d96eaacc48525a56b6e3d85e3d9f596
7
- data.tar.gz: 84b6440c8e7f7b2225c03c3440b469dae05aafb68aeee4ef7cd1a7972e361d10036acdc1f3b076d4824246951dfb9fa4845630ec350b2cd07cfa4b7fda3d2700
6
+ metadata.gz: 0b60bd4ac0cfe563946279bd7ee3e4da31e5add08fad25a5266fe9e14f9faa553cf958923770a4bb38e73981b8e876bfd2fe9dd9a831d24b0ff131d8b248c843
7
+ data.tar.gz: 9185f28b703880a04a6cc5a7259854eb17483d2a16a22526bce5c1ed1696363aa7c62438e3220ee7856c9064f031b643d3a35e2ccb746923ad40711071d33348
data/CHANGELOG.md CHANGED
@@ -4,7 +4,11 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
6
6
 
7
- ## [Unreleased]
7
+ ## [0.3.0] - 2026-05-10
8
+
9
+ ### Added
10
+
11
+ - Read-only deletion-audit dashboard mounted at the engine root. Renders sidebar of tracked models, KPI strip, filter bar, paginated unified feed of `athar_deletions` and `athar_table_events`, and expandable detail. Tracked models are discovered at runtime from `pg_trigger`; no registry table. Self-contained CSS and JS under `app/assets/`, served through the host's asset pipeline (Sprockets or Propshaft) when available or by a built-in `Rack::Static` middleware otherwise; no `turbo-rails`, `stimulus-rails`, or `importmap-rails` required. Document the mount pattern and route-constraint auth in the README.
8
12
 
9
13
  ## [0.2.1] - 2026-05-06
10
14
 
data/README.md CHANGED
@@ -37,6 +37,7 @@ It does not turn deleted rows into queryable models, and it does not provide ful
37
37
  - [Configuration](#configuration)
38
38
  - [How It Works](#how-it-works)
39
39
  - [Operational Notes](#operational-notes)
40
+ - [Dashboard](#dashboard)
40
41
  - [Troubleshooting](#troubleshooting)
41
42
  - [Development](#development)
42
43
  - [Contributing](#contributing)
@@ -462,6 +463,61 @@ The tasks start the local `postgres:18` Docker Compose service when needed, crea
462
463
 
463
464
  Results are machine-dependent. The scripts are intentionally not part of CI; they exist so maintainers can spot large regressions. See [`bench/RESULTS.md`](bench/RESULTS.md) for the last measured baseline.
464
465
 
466
+ ## Dashboard
467
+
468
+ Athar ships a read-only audit dashboard as a Rails engine. It provides a browser interface for the data that Athar's triggers write to `athar_deletions` and `athar_table_events`.
469
+
470
+ ![Athar dashboard](docs/screenshots/dashboard.png)
471
+
472
+ ### Mounting
473
+
474
+ Add the engine to your host app's `config/routes.rb`:
475
+
476
+ ```ruby
477
+ mount Athar::Engine => "/athar"
478
+ ```
479
+
480
+ ### Authentication
481
+
482
+ The engine has no built-in authentication and inherits from the host's `::ApplicationController`, so any `before_action`-based auth (session checks, policy gates) carries through automatically.
483
+
484
+ For Devise-protected apps, wrap the mount in a route constraint:
485
+
486
+ ```ruby
487
+ authenticate :user, ->(u) { u.admin? } do
488
+ mount Athar::Engine => "/athar"
489
+ end
490
+ ```
491
+
492
+ Any controller-level authentication strategy that works for your host app works here.
493
+
494
+ ### Dependencies
495
+
496
+ Athar's only runtime dependencies are `activejob`, `activerecord`, `activesupport`, `railties`, and `fx`. The dashboard ships its own CSS and JS files, so `turbo-rails`, `stimulus-rails`, and `importmap-rails` are not required. The host's asset pipeline (Sprockets or Propshaft) serves those files when one is available; otherwise they're served by a `Rack::Static`-backed middleware bundled with the gem. Hosts that already use Hotwire are unaffected: the engine has its own layout, and dashboard pages set `<meta name="turbo-visit-control" content="reload">` so any Turbo Drive in the host falls back to a full page load when entering `/athar`.
497
+
498
+ ### What the dashboard shows
499
+
500
+ - **Sidebar** — tracked models grouped by schema, with capture mode, masks, STI flag, truncate flag, and per-model audit row count. Discovered at runtime from `pg_trigger`; no registry table.
501
+ - **KPI strip** — filtered row count, last 24 h, last 7 d with vs-prior delta, truncate event count, distinct actor count, and a 14-day sparkline.
502
+ - **Filter bar** — full-text search across record/actor/metadata/data; time, mode, and kind segments; actor dropdown; clear.
503
+ - **Unified feed** — paginated list of `athar_deletions` and truncate events from `athar_table_events`. Each row expands inline to show `record_data`, metadata, identity fields, and a requery snippet in both Ruby and SQL.
504
+ - **Permalinks** — individual deletion at `/athar/deletions/:id`; individual table event at `/athar/table_events/:id`.
505
+
506
+ ### What it does not do
507
+
508
+ The dashboard is read-only. There is no retention or configuration UI, no manual actions, no export, and no live updates (no polling, no Turbo Streams).
509
+
510
+ ### Privacy
511
+
512
+ `record_data` is rendered as stored. Masking happens at trigger time, not at render time. The dashboard inherits whatever masking the host configured when the trigger was installed.
513
+
514
+ > [!IMPORTANT]
515
+ > If a trigger was installed without masking, the dashboard renders the raw values. Mask at the trigger level before sensitive data is written to `athar_deletions`.
516
+
517
+ ### Performance
518
+
519
+ The index renders approximately 8–10 queries. Sidebar model counts and the actor dropdown query scale with audit-table size. Search uses `ILIKE` scans bounded by the active time filter. For very large audit tables, prefer narrower time windows when searching.
520
+
465
521
  ## Troubleshooting
466
522
 
467
523
  ### "Function `athar_capture_delete` does not exist"
@@ -501,6 +557,10 @@ The test task installs missing gems for the pinned Ruby, starts PostgreSQL 18 wh
501
557
 
502
558
  The dummy app under `test/dummy` is a real Rails app. By default it uses `schema.rb` + Fx; `ATHAR_NO_FX=1` flips it to `structure.sql` and the raw-SQL generator path. Tests use real triggers against a real database in both modes.
503
559
 
560
+ ### Dashboard assets
561
+
562
+ The dashboard's CSS and JS are in `app/assets/stylesheets/athar/dashboard.css` and `app/assets/javascripts/athar/dashboard.js`. There's no build step: edit the file and reload the browser. The JS is a single IIFE that handles every dashboard interaction via delegated event listeners on `document`, including the partial-fetch swaps that keep `#athar-dashboard` in sync with the URL. `Athar::Middleware::AssetServer` serves the files at `/athar-assets/<gem-version>/dashboard.{js,css}` for hosts without an asset pipeline; everyone else gets digested URLs from Sprockets or Propshaft.
563
+
504
564
  ## Contributing
505
565
 
506
566
  Bug reports and pull requests are welcome on GitHub at https://github.com/milkstrawai/athar.
Binary file
@@ -0,0 +1,290 @@
1
+ /* Athar dashboard JS — single self-contained IIFE, no build step.
2
+ *
3
+ * Behavior:
4
+ * - Partial-link clicks (anchors with `data-athar-partial-link`) intercept
5
+ * navigation and swap #athar-pre and #athar-post in place via a single
6
+ * fetch, pushing a new history entry. Modifier-clicks fall through to the
7
+ * browser's default open-in-new-tab.
8
+ * - Partial forms (`form[data-athar-partial-form]`) submit on change/input
9
+ * (input is debounced) the same way. Drops the page param on every submit.
10
+ * The form itself lives outside the swap regions, so the search input's
11
+ * focus / value / selection are never disturbed.
12
+ * - The filter bar's visual state (active segments, selected actor) is
13
+ * reconciled from the URL after every swap by updateFilterBarFromUrl —
14
+ * since the bar isn't re-rendered, we keep its highlights in sync from JS.
15
+ * - Back/forward (popstate) re-fetches the current URL into the regions.
16
+ * - Copy buttons (`[data-athar-copy="value"]`) write to the clipboard,
17
+ * flash a check, and announce via the ARIA live region.
18
+ * - Theme button (`[data-athar-theme-toggle]`) flips data-theme on <html>
19
+ * and PATCHes /athar/theme to persist.
20
+ * - Keyboard: `/` focuses the search input; Escape collapses the open row.
21
+ */
22
+ (function () {
23
+ "use strict";
24
+
25
+ var SWAP_REGION_IDS = ["athar-pre", "athar-post"];
26
+ var FILTER_DEFAULTS = { time: "30d", mode: "all", kind: "all", actor: "all" };
27
+
28
+ // ---------- CSRF ----------
29
+ function csrfToken() {
30
+ var meta = document.querySelector('meta[name="csrf-token"]');
31
+ return meta ? meta.content : null;
32
+ }
33
+
34
+ // ---------- Partial-fetch helpers (CSP-safe, DOM-method swap) ----------
35
+ async function fetchPartial(url, options) {
36
+ options = options || {};
37
+ var headers = Object.assign({
38
+ "Accept": "text/html",
39
+ "X-Requested-With": "XMLHttpRequest",
40
+ "X-Athar-Partial": "true"
41
+ }, options.headers || {});
42
+
43
+ var method = (options.method || "GET").toUpperCase();
44
+ if (method !== "GET" && method !== "HEAD") {
45
+ var token = csrfToken();
46
+ if (token) headers["X-CSRF-Token"] = token;
47
+ }
48
+
49
+ var response = await fetch(url, Object.assign({}, options, { method: method, headers: headers }));
50
+ if (!response.ok) throw new Error("fetchPartial: " + response.status + " " + response.statusText);
51
+ var html = await response.text();
52
+ return new DOMParser().parseFromString(html, "text/html");
53
+ }
54
+
55
+ function replaceElement(target, source) {
56
+ while (target.firstChild) target.removeChild(target.firstChild);
57
+ Array.from(source.childNodes).forEach(function (child) {
58
+ target.appendChild(child.cloneNode(true));
59
+ });
60
+ }
61
+
62
+ // ---------- Partial nav: swap #athar-pre + #athar-post (skip filter bar) ----------
63
+ async function partialNav(url, options) {
64
+ options = options || {};
65
+
66
+ var regions = SWAP_REGION_IDS.map(function (id) { return document.getElementById(id); });
67
+ if (regions.some(function (el) { return el == null; })) {
68
+ // Required regions missing — fall back to a full navigation.
69
+ window.location.href = url;
70
+ return;
71
+ }
72
+
73
+ // Suppress the dimming indicator for form-driven submits — the user is
74
+ // actively typing, and any opacity transition near the cursor reads as a
75
+ // distracting flash on every keystroke.
76
+ var showLoading = !options.silent;
77
+ if (showLoading) regions.forEach(function (r) { r.classList.add("is-loading"); });
78
+
79
+ try {
80
+ var doc = await fetchPartial(url, options);
81
+
82
+ regions.forEach(function (region) {
83
+ var fresh = doc.getElementById(region.id);
84
+ if (fresh) replaceElement(region, fresh);
85
+ });
86
+
87
+ // Filter bar isn't re-rendered — reconcile its visual state from the URL.
88
+ updateFilterBarFromUrl();
89
+
90
+ if (options.replaceState) {
91
+ history.replaceState({}, "", url);
92
+ } else {
93
+ history.pushState({}, "", url);
94
+ }
95
+ } catch (error) {
96
+ console.error("[athar] partial-nav failed, falling back to full navigation:", error);
97
+ window.location.href = url;
98
+ } finally {
99
+ if (showLoading) regions.forEach(function (r) { r.classList.remove("is-loading"); });
100
+ }
101
+ }
102
+
103
+ // ---------- Filter bar state reconciliation ----------
104
+ // The filter bar lives outside the swap regions so the search input survives
105
+ // the partial swap. The cost: we have to keep its segment highlights and
106
+ // actor selection in sync with the URL ourselves.
107
+ function updateFilterBarFromUrl() {
108
+ var params = new URL(window.location.href).searchParams;
109
+
110
+ ["time", "mode", "kind"].forEach(function (name) {
111
+ var current = params.get(name) || FILTER_DEFAULTS[name];
112
+ document.querySelectorAll('[data-athar-seg="' + name + '"]').forEach(function (link) {
113
+ var matches = link.dataset.atharSegValue === current;
114
+ link.classList.toggle("is-active", matches);
115
+ if (matches) {
116
+ link.setAttribute("aria-current", "page");
117
+ } else {
118
+ link.removeAttribute("aria-current");
119
+ }
120
+ });
121
+ });
122
+
123
+ var actorSelect = document.getElementById("athar-actor");
124
+ if (actorSelect) {
125
+ var actor = params.get("actor") || FILTER_DEFAULTS.actor;
126
+ if (actorSelect.value !== actor) actorSelect.value = actor;
127
+ }
128
+ }
129
+
130
+ // ---------- Form submit (build URL from current params + form data) ----------
131
+ function buildSubmitUrl(form) {
132
+ var action = form.action || window.location.href;
133
+ var url = new URL(action, window.location.origin);
134
+ // Baseline from the *current* URL, not the form's action — the form has no
135
+ // hidden fields preserving model/time/mode/kind, so without this the only
136
+ // params that would survive a search submit are q + actor.
137
+ var params = new URLSearchParams(window.location.search);
138
+ // Drop params the form is replacing or that should reset.
139
+ params.delete("q"); params.delete("page"); params.delete("expanded");
140
+
141
+ var data = new FormData(form);
142
+ var entries = data.entries ? data.entries() : [];
143
+ for (var entry of entries) {
144
+ var key = entry[0];
145
+ var value = entry[1];
146
+ if (value === "" || value == null) continue;
147
+ params.set(key, value);
148
+ }
149
+
150
+ url.search = params.toString();
151
+ return url.toString();
152
+ }
153
+
154
+ function submitForm(form) {
155
+ partialNav(buildSubmitUrl(form), { silent: true });
156
+ }
157
+
158
+ // ---------- Copy button ----------
159
+ var COPY_CHECK_SVG = '<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8l3 3 7-7"/></svg>';
160
+
161
+ function announce(message) {
162
+ var region = document.getElementById("athar-aria-live");
163
+ if (!region) return;
164
+ region.textContent = "";
165
+ requestAnimationFrame(function () { region.textContent = message; });
166
+ }
167
+
168
+ function flashCopied(button) {
169
+ var label = button.getAttribute("data-athar-copy-label") || "text";
170
+ announce("Copied " + label);
171
+
172
+ var original = button.innerHTML;
173
+ button.innerHTML = COPY_CHECK_SVG;
174
+
175
+ if (button._atharCopyTimeout) clearTimeout(button._atharCopyTimeout);
176
+ button._atharCopyTimeout = setTimeout(function () {
177
+ if (button.isConnected) button.innerHTML = original;
178
+ }, 1100);
179
+ }
180
+
181
+ function doCopy(button) {
182
+ var value = button.getAttribute("data-athar-copy");
183
+ if (!value) return;
184
+ flashCopied(button);
185
+ if (navigator.clipboard) {
186
+ navigator.clipboard.writeText(value).catch(function (error) {
187
+ console.warn("athar: clipboard write failed", error);
188
+ });
189
+ }
190
+ }
191
+
192
+ // ---------- Theme toggle (optimistic, revert on failure) ----------
193
+ function toggleTheme(button) {
194
+ var previous = document.documentElement.dataset.theme === "dark" ? "dark" : "light";
195
+ var next = previous === "dark" ? "light" : "dark";
196
+ var previousLabel = button.textContent;
197
+
198
+ document.documentElement.dataset.theme = next;
199
+ button.textContent = next === "dark" ? "◐" : "◑";
200
+
201
+ var meta = document.querySelector('meta[name="athar-theme-url"]');
202
+ var url = (meta && meta.content) || "/athar/theme";
203
+
204
+ fetch(url, {
205
+ method: "PATCH",
206
+ headers: {
207
+ "Content-Type": "application/json",
208
+ "X-CSRF-Token": csrfToken() || "",
209
+ "Accept": "application/json"
210
+ },
211
+ body: JSON.stringify({ theme: next })
212
+ }).then(function (response) {
213
+ if (!response.ok) throw new Error("HTTP " + response.status);
214
+ }).catch(function (error) {
215
+ // Revert optimistic UI so on-screen state stays in sync with the cookie.
216
+ document.documentElement.dataset.theme = previous;
217
+ button.textContent = previousLabel;
218
+ console.warn("athar: theme persistence failed", error);
219
+ });
220
+ }
221
+
222
+ // ---------- Click delegation ----------
223
+ document.addEventListener("click", function (event) {
224
+ // Partial-link anchors first — they need preventDefault.
225
+ var partialLink = event.target.closest("a[data-athar-partial-link]");
226
+ if (partialLink) {
227
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
228
+ if (event.button !== 0) return;
229
+ event.preventDefault();
230
+ partialNav(partialLink.href);
231
+ return;
232
+ }
233
+
234
+ var copyBtn = event.target.closest("[data-athar-copy]");
235
+ if (copyBtn) {
236
+ event.preventDefault();
237
+ event.stopPropagation();
238
+ doCopy(copyBtn);
239
+ return;
240
+ }
241
+
242
+ var themeBtn = event.target.closest("[data-athar-theme-toggle]");
243
+ if (themeBtn) {
244
+ toggleTheme(themeBtn);
245
+ return;
246
+ }
247
+ });
248
+
249
+ // ---------- Form change/input delegation ----------
250
+ var formInputDebounce;
251
+ document.addEventListener("change", function (event) {
252
+ var form = event.target.closest("form[data-athar-partial-form]");
253
+ if (!form) return;
254
+ if (formInputDebounce) { clearTimeout(formInputDebounce); formInputDebounce = null; }
255
+ submitForm(form);
256
+ });
257
+ document.addEventListener("input", function (event) {
258
+ var form = event.target.closest("form[data-athar-partial-form]");
259
+ if (!form) return;
260
+ if (formInputDebounce) clearTimeout(formInputDebounce);
261
+ formInputDebounce = setTimeout(function () { submitForm(form); }, 300);
262
+ });
263
+
264
+ // ---------- Keyboard shortcuts ----------
265
+ document.addEventListener("keydown", function (event) {
266
+ // Escape always collapses an open row, even when focus is in an input —
267
+ // otherwise the input's default Escape handling eats the first press.
268
+ if (event.key === "Escape") {
269
+ var collapse = document.querySelector("[data-collapse-expand]");
270
+ if (collapse) {
271
+ event.preventDefault();
272
+ collapse.click();
273
+ }
274
+ return;
275
+ }
276
+
277
+ if (event.target.matches("input, textarea, select") || event.target.isContentEditable) return;
278
+
279
+ if (event.key === "/") {
280
+ event.preventDefault();
281
+ var search = document.querySelector(".filter-search input[type=search]");
282
+ if (search) search.focus();
283
+ }
284
+ });
285
+
286
+ // ---------- Browser back/forward ----------
287
+ window.addEventListener("popstate", function () {
288
+ partialNav(window.location.href, { silent: true, replaceState: true });
289
+ });
290
+ })();