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
@@ -0,0 +1,106 @@
1
+ <%
2
+ # ----- precompute -----
3
+ max_count = registry.map(&:count).max || 1
4
+ total_all = registry.sum(&:count)
5
+
6
+ # Group by schema, order: public, billing, reporting, then any others alphabetically
7
+ grouped = registry.group_by(&:schema)
8
+ fixed = %w[public billing reporting]
9
+ extras = (grouped.keys - fixed).sort
10
+ schema_order = (fixed + extras).select { |schema| grouped.key?(schema) }
11
+
12
+ grouped.each_value { |models| models.sort_by!(&:count).reverse! }
13
+
14
+ # Base params for link building — strip model/page/expanded, keep everything else
15
+ base_params = request.query_parameters.except(:model, :page, :expanded).except("model", "page", "expanded")
16
+
17
+ # Retention display
18
+ max_age = Athar.configuration.retention.max_age
19
+ max_count_ret = Athar.configuration.retention.max_count
20
+ age_label = compact_duration(max_age)
21
+ count_label = compact_number(max_count_ret)
22
+ %>
23
+
24
+ <aside class="sidebar">
25
+ <%# ---- sidebar head ---- %>
26
+ <div class="sidebar-head">
27
+ <div class="kbd-row">
28
+ <span class="brand">
29
+ <%= image_tag "athar/logo.png", alt: "", class: "brand-logo" %>
30
+ <span class="brand-name">athar</span>
31
+ <span class="brand-version">v<%= Athar::VERSION %></span>
32
+ </span>
33
+ </div>
34
+ </div>
35
+
36
+ <%# ---- all deletions row ---- %>
37
+ <%# Sidebar links are plain anchors — clicks trigger a full-page reload, which
38
+ re-renders the sidebar's active highlight in sync with the main content.
39
+ In-frame interactions (filter, pager, expand) use partial-fetch instead. %>
40
+ <a class="sidebar-row sidebar-all <%= "is-active" if filters.model.nil? %>"
41
+ href="<%= root_path(base_params) %>"
42
+ <%= 'aria-current="page"'.html_safe if filters.model.nil? %>>
43
+ <span class="sidebar-row-label">
44
+ <span class="dot"></span>All deletions
45
+ </span>
46
+ <span class="sidebar-count"><%= number_with_delimiter(total_all) %></span>
47
+ </a>
48
+
49
+ <%# ---- scrollable grouped list ---- %>
50
+ <div class="sidebar-scroll">
51
+ <% schema_order.each do |schema| %>
52
+ <% models = grouped[schema] %>
53
+ <div class="sidebar-group">
54
+ <div class="sidebar-group-head">
55
+ <span><%= schema %></span>
56
+ <span class="sidebar-group-count"><%= models.length %></span>
57
+ </div>
58
+ <% models.each do |model| %>
59
+ <%
60
+ percent = max_count > 0 ? (model.count.to_f / max_count * 100) : 0
61
+ is_active = filters.model == model.record_type
62
+ row_href = root_path(base_params.merge(model: model.record_type))
63
+ %>
64
+ <a class="sidebar-row <%= "is-active" if is_active %>"
65
+ href="<%= row_href %>"
66
+ <%= 'aria-current="page"'.html_safe if is_active %>>
67
+ <span class="sidebar-bar" style="width:<%= percent.round(2) %>%"></span>
68
+ <span class="sidebar-row-label">
69
+ <span class="mode-dot mode-<%= model.capture_mode %>" title="capture: <%= model.capture_mode %>"></span>
70
+ <span class="sidebar-model"><%= model.record_type %></span>
71
+ <% if model.sti %>
72
+ <span class="sidebar-tag">STI</span>
73
+ <% end %>
74
+ <% if model.truncate %>
75
+ <span class="sidebar-tag" title="TRUNCATE tracked"><%= raw icon_trunc %></span>
76
+ <% end %>
77
+ <% if model.masks.any? %>
78
+ <span class="sidebar-tag mask" title="masks: <%= model.masks.join(", ") %>">m<%= model.masks.length %></span>
79
+ <% end %>
80
+ </span>
81
+ <span class="sidebar-count"><%= number_with_delimiter(model.count) %></span>
82
+ </a>
83
+ <% end %>
84
+ </div>
85
+ <% end %>
86
+ </div>
87
+
88
+ <%# ---- footer ---- %>
89
+ <div class="sidebar-foot">
90
+ <div class="legend">
91
+ <span><i class="mode-dot mode-identity"></i> identity</span>
92
+ <span><i class="mode-dot mode-only"></i> only</span>
93
+ <span><i class="mode-dot mode-snapshot"></i> snapshot</span>
94
+ </div>
95
+ <div class="retention" title="<%= max_age || max_count_ret ? "Audit log pruning configured" : "No retention configured — Athar.configure to set max_age / max_count" %>">
96
+ <span class="retention-label">retention</span>
97
+ <span class="retention-val">
98
+ <% if max_age || max_count_ret %>
99
+ <%= [max_age ? age_label : nil, max_count_ret ? "#{count_label} rows" : nil].compact.join(" · ") %>
100
+ <% else %>
101
+ unconfigured
102
+ <% end %>
103
+ </span>
104
+ </div>
105
+ </div>
106
+ </aside>
@@ -0,0 +1,30 @@
1
+ <div class="table-wrap">
2
+ <table class="dtable">
3
+ <thead>
4
+ <tr>
5
+ <th class="th-expand"></th>
6
+ <th class="th-when">deleted_at</th>
7
+ <th>record</th>
8
+ <th>schema.table</th>
9
+ <th>capture</th>
10
+ <th>actor</th>
11
+ <th>metadata</th>
12
+ <th class="th-id">id</th>
13
+ </tr>
14
+ </thead>
15
+
16
+ <tbody>
17
+ <% rows.each do |row| %>
18
+ <%= render "athar/dashboard/row", row: row, expanded: expanded, registry_by_id: registry_by_id, actor_labels: actor_labels %>
19
+ <% end %>
20
+ </tbody>
21
+ </table>
22
+
23
+ <% if rows.empty? %>
24
+ <div class="table-empty">
25
+ <div class="empty-mark">∅</div>
26
+ <div class="empty-title">no audit rows match these filters</div>
27
+ <div class="empty-sub">athar_deletions returned 0 rows for the active query</div>
28
+ </div>
29
+ <% end %>
30
+ </div>
@@ -0,0 +1,30 @@
1
+ <header class="topbar">
2
+ <div class="crumbs">
3
+ <span class="crumb">athar</span><span class="crumb-sep">/</span><span class="crumb">deletions</span><%
4
+ if model %>
5
+ <span class="crumb-sep">/</span>
6
+ <span class="crumb crumb-active"><%= h model.record_type %></span>
7
+ <span class="crumb-meta mono"><%= model.schema %>.<%= model.table %></span>
8
+ <%= mode_pill(model.capture_mode) %>
9
+ <% if model.masks.present? %>
10
+ <%= mask_pill(model.masks) %>
11
+ <% end %>
12
+ <% if model.truncate %>
13
+ <span class="pill subtle">+ truncate</span>
14
+ <% end %>
15
+ <% else %>
16
+ <span class="crumb-meta">all tracked tables</span>
17
+ <% end %>
18
+ </div>
19
+
20
+ <div class="topbar-right">
21
+ <% theme = cookies[:athar_theme] || "dark" %>
22
+ <button class="theme-btn" type="button" title="Toggle theme" data-athar-theme-toggle><%= theme == "dark" ? "◐" : "◑" %></button>
23
+ <span class="conn-pill">
24
+ <span class="conn-dot"></span>
25
+ <span><%= connection.database %></span>
26
+ <span class="dim mono"><%= connection.version %></span>
27
+ </span>
28
+ <span class="time-pill mono"><%= absolute_time(now) %></span>
29
+ </div>
30
+ </header>
@@ -0,0 +1,31 @@
1
+ <%# Sidebar lives outside #athar-dashboard so filter changes / pagination / row
2
+ expand can swap only the main content via partial-fetch (anchors with
3
+ `data-athar-partial-link` and forms with `data-athar-partial-form`, both
4
+ handled by app/assets/javascripts/athar/dashboard.js).
5
+
6
+ The dashboard body is split into #athar-pre (topbar + KPI strip) and
7
+ #athar-post (table + pager) regions. Partial swaps replace each region
8
+ independently and skip the filter bar entirely — that way the search
9
+ input and actor select are never destroyed mid-keystroke and focus /
10
+ selection / value can never be lost. The filter bar's own visual state
11
+ (active segments, selected actor) is reconciled from the URL after each
12
+ swap; see updateFilterBarFromUrl in dashboard.js. %>
13
+ <div class="app">
14
+ <%= render "athar/dashboard/sidebar", registry: @registry, filters: @filters %>
15
+
16
+ <main class="main">
17
+ <div id="athar-dashboard">
18
+ <div id="athar-pre">
19
+ <%= render "athar/dashboard/topbar", filters: @filters, model: @selected_model, connection: @connection_info, now: @now %>
20
+ <%= render "athar/dashboard/kpi_strip", kpis: @kpis, total: @total %>
21
+ </div>
22
+
23
+ <%= render "athar/dashboard/filter_bar", filters: @filters, actors: @actors %>
24
+
25
+ <div id="athar-post">
26
+ <%= render "athar/dashboard/table", rows: @rows, expanded: @filters.expanded, registry_by_id: @registry_by_id, filters: @filters, actor_labels: @actor_labels %>
27
+ <%= render "athar/dashboard/pager", page: @page %>
28
+ </div>
29
+ </div>
30
+ </main>
31
+ </div>
@@ -0,0 +1,115 @@
1
+ <% model_info = registry_by_id&.dig([deletion.schema_name, deletion.table_name]) %>
2
+
3
+ <div class="detail">
4
+ <div class="detail-grid">
5
+
6
+ <%# Section 1: record_data %>
7
+ <section class="detail-section">
8
+ <header class="detail-h">
9
+ <span>record_data</span>
10
+ <% if model_info %>
11
+ <%= mask_pill(model_info.masks) %>
12
+ <%= mode_pill(model_info.capture_mode) %>
13
+ <% end %>
14
+ </header>
15
+ <% if deletion.record_data.blank? || deletion.record_data.empty? %>
16
+ <div class="json-empty">
17
+ <% if model_info&.capture_mode == "identity" %>
18
+ {} <span class="muted">— identity-only capture, no record_data</span>
19
+ <% else %>
20
+ {}
21
+ <% end %>
22
+ </div>
23
+ <% else %>
24
+ <%= render_json_kv(deletion.record_data) %>
25
+ <% end %>
26
+ </section>
27
+
28
+ <%# Section 2: metadata %>
29
+ <section class="detail-section">
30
+ <header class="detail-h">
31
+ <span>metadata</span>
32
+ </header>
33
+
34
+ <%= render_json_kv(deletion.metadata) %>
35
+ </section>
36
+
37
+ <%# Section 3: identity (wide) %>
38
+ <section class="detail-section detail-wide">
39
+ <header class="detail-h">
40
+ <span>identity</span>
41
+ </header>
42
+ <table class="kv-table">
43
+ <tbody>
44
+ <tr>
45
+ <td class="kv-key">athar_deletions.id</td>
46
+ <td class="kv-val mono">
47
+ <%= deletion.id %>
48
+ <%= copy_button(deletion.id.to_s, label: "audit id") %>
49
+ </td>
50
+ </tr>
51
+ <tr>
52
+ <td class="kv-key">record_type</td>
53
+ <td class="kv-val"><%= deletion.record_type %></td>
54
+ </tr>
55
+ <tr>
56
+ <td class="kv-key">record_id</td>
57
+ <td class="kv-val mono">
58
+ <% if deletion.record_id.present? %>
59
+ <%= deletion.record_id %>
60
+ <%= copy_button(deletion.record_id.to_s, label: "record_id") %>
61
+ <% else %>
62
+ <span class="muted">—</span>
63
+ <% end %>
64
+ </td>
65
+ </tr>
66
+ <tr>
67
+ <td class="kv-key">schema · table</td>
68
+ <td class="kv-val mono"><%= deletion.schema_name %>.<%= deletion.table_name %></td>
69
+ </tr>
70
+ <tr>
71
+ <td class="kv-key">deleted_at</td>
72
+ <td class="kv-val mono"><%= absolute_time(deletion.deleted_at) %></td>
73
+ </tr>
74
+ <tr>
75
+ <td class="kv-key">actor</td>
76
+ <td class="kv-val">
77
+ <% if deletion.actor_id.present? %>
78
+ <span class="mono"><%= deletion.actor_type %>#<%= deletion.actor_id %></span>
79
+ ·
80
+ <% actor_record = deletion.actor %>
81
+ <%= h Athar::Dashboard::ActorLabels.humanize(actor_record, deletion.actor_type, deletion.actor_id) %>
82
+ <%= copy_button("#{deletion.actor_type}##{deletion.actor_id}", label: "actor") %>
83
+ <% elsif deletion.metadata.is_a?(Hash) && deletion.metadata["actor"] %>
84
+ <span class="mono muted">
85
+ <%= ERB::Util.html_escape(deletion.metadata["actor"]) %>
86
+ <span class="pill subtle">metadata</span>
87
+ </span>
88
+ <% else %>
89
+ <span class="muted">(none)</span>
90
+ <% end %>
91
+ </td>
92
+ </tr>
93
+ </tbody>
94
+ </table>
95
+ </section>
96
+
97
+ <%# Section 4: requery (wide) %>
98
+ <% ruby_snippet = "Athar::Deletion.for_record(#{deletion.record_type}, #{deletion.record_id}).last" %>
99
+ <% sql_snippet = "SELECT *\nFROM athar_deletions\nWHERE id = #{deletion.id};" %>
100
+ <section class="detail-section detail-wide">
101
+ <header class="detail-h">
102
+ <span>requery</span>
103
+ </header>
104
+ <div class="code-wrap">
105
+ <%= copy_button(ruby_snippet, label: "Ruby snippet") %>
106
+ <pre class="code"><code><%= h ruby_snippet %></code></pre>
107
+ </div>
108
+ <div class="code-wrap">
109
+ <%= copy_button(sql_snippet, label: "SQL snippet") %>
110
+ <pre class="code"><code><%= h sql_snippet %></code></pre>
111
+ </div>
112
+ </section>
113
+
114
+ </div>
115
+ </div>
@@ -0,0 +1,3 @@
1
+ <div id="detail_<%= @deletion.id %>">
2
+ <%= render "athar/deletions/detail", deletion: @deletion, registry_by_id: @registry_by_id %>
3
+ </div>
@@ -0,0 +1,80 @@
1
+ <div class="detail">
2
+ <div class="detail-grid">
3
+
4
+ <%# Section 1: metadata %>
5
+ <section class="detail-section">
6
+ <header class="detail-h">
7
+ <span>metadata</span>
8
+ </header>
9
+
10
+ <%= render_json_kv(event.metadata) %>
11
+ </section>
12
+
13
+ <%# Section 2: identity (wide) %>
14
+ <section class="detail-section detail-wide">
15
+ <header class="detail-h">
16
+ <span>identity</span>
17
+ </header>
18
+ <table class="kv-table">
19
+ <tbody>
20
+ <tr>
21
+ <td class="kv-key">athar_table_events.id</td>
22
+ <td class="kv-val mono">
23
+ <%= event.id %>
24
+ <%= copy_button(event.id.to_s, label: "audit id") %>
25
+ </td>
26
+ </tr>
27
+ <tr>
28
+ <td class="kv-key">event_type</td>
29
+ <td class="kv-val"><%= event.event_type %></td>
30
+ </tr>
31
+ <tr>
32
+ <td class="kv-key">schema · table</td>
33
+ <td class="kv-val mono"><%= event.schema_name %>.<%= event.table_name %></td>
34
+ </tr>
35
+ <tr>
36
+ <td class="kv-key">occurred_at</td>
37
+ <td class="kv-val mono"><%= absolute_time(event.occurred_at) %></td>
38
+ </tr>
39
+ <tr>
40
+ <td class="kv-key">actor</td>
41
+ <td class="kv-val">
42
+ <% if event.actor_id.present? %>
43
+ <span class="mono"><%= event.actor_type %>#<%= event.actor_id %></span>
44
+ ·
45
+ <% actor_record = event.actor %>
46
+ <%= h Athar::Dashboard::ActorLabels.humanize(actor_record, event.actor_type, event.actor_id) %>
47
+ <%= copy_button("#{event.actor_type}##{event.actor_id}", label: "actor") %>
48
+ <% elsif event.metadata.is_a?(Hash) && event.metadata["actor"] %>
49
+ <span class="mono muted">
50
+ <%= ERB::Util.html_escape(event.metadata["actor"]) %>
51
+ <span class="pill subtle">metadata</span>
52
+ </span>
53
+ <% else %>
54
+ <span class="muted">(none)</span>
55
+ <% end %>
56
+ </td>
57
+ </tr>
58
+ </tbody>
59
+ </table>
60
+ </section>
61
+
62
+ <%# Section 3: requery (wide) %>
63
+ <% ruby_snippet = "Athar::TableEvent.find(#{event.id})" %>
64
+ <% sql_snippet = "SELECT *\nFROM athar_table_events\nWHERE id = #{event.id};" %>
65
+ <section class="detail-section detail-wide">
66
+ <header class="detail-h">
67
+ <span>requery</span>
68
+ </header>
69
+ <div class="code-wrap">
70
+ <%= copy_button(ruby_snippet, label: "Ruby snippet") %>
71
+ <pre class="code"><code><%= h ruby_snippet %></code></pre>
72
+ </div>
73
+ <div class="code-wrap">
74
+ <%= copy_button(sql_snippet, label: "SQL snippet") %>
75
+ <pre class="code"><code><%= h sql_snippet %></code></pre>
76
+ </div>
77
+ </section>
78
+
79
+ </div>
80
+ </div>
@@ -0,0 +1,3 @@
1
+ <div id="detail_<%= @event.id %>">
2
+ <%= render "athar/table_events/detail", event: @event %>
3
+ </div>
@@ -0,0 +1,29 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="<%= cookies[:athar_theme] || "dark" %>">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Athar</title>
6
+
7
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8
+ <meta name="csrf-token" content="<%= form_authenticity_token %>">
9
+ <meta name="athar-theme-url" content="<%= theme_path %>">
10
+
11
+ <%# If the host has Turbo, force a full document load when entering /athar
12
+ so our JS owns the page (no double-Turbo, no leftover host listeners). %>
13
+ <meta name="turbo-visit-control" content="reload">
14
+
15
+ <link rel="icon" type="image/png" href="<%= asset_path("athar/logo.png") %>">
16
+ <link rel="preconnect" href="https://fonts.googleapis.com">
17
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
18
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
19
+
20
+ <%= stylesheet_link_tag athar_asset_path("dashboard.css") %>
21
+ <%= javascript_include_tag athar_asset_path("dashboard.js"), defer: true, nonce: athar_csp_nonce %>
22
+ </head>
23
+
24
+ <body>
25
+ <%= yield %>
26
+ <%# Single ARIA live region for in-page announcements (e.g. "Copied N"). %>
27
+ <div id="athar-aria-live" class="sr-only" aria-live="polite" aria-atomic="true"></div>
28
+ </body>
29
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ Athar::Engine.routes.draw do
4
+ root "dashboard#index"
5
+ resources :deletions, only: [:show]
6
+ resources :table_events, only: [:show]
7
+ resource :theme, only: [:update]
8
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Dashboard
5
+ # Builds a human-readable label for an actor record. Real host apps may or
6
+ # may not override `to_s` on their actor models (Devise's User typically
7
+ # doesn't), so this falls back to common identifying attributes before
8
+ # using `to_s`, and to "Type#id" when nothing readable is available.
9
+ module ActorLabels
10
+ IDENTIFYING_ATTRIBUTES = %i[email name username login handle].freeze
11
+
12
+ class << self
13
+ def humanize(record, type, id)
14
+ return "#{type}##{id}" unless record
15
+
16
+ IDENTIFYING_ATTRIBUTES.each do |attribute|
17
+ next unless record.respond_to?(attribute)
18
+
19
+ value = record.public_send(attribute)
20
+ return value.to_s if value.present?
21
+ end
22
+
23
+ string = record.to_s
24
+ return string if string.is_a?(String) && !string.start_with?("#<")
25
+
26
+ "#{type}##{id}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Dashboard
5
+ class ActorOptions
6
+ Result = Data.define(:users, :system, :anonymous_label)
7
+ LIMIT = 50
8
+
9
+ def initialize(cutoff:)
10
+ @cutoff = cutoff
11
+ end
12
+
13
+ def call(connection: ActiveRecord::Base.connection)
14
+ Result.new(
15
+ users: load_users(connection),
16
+ system: load_system(connection),
17
+ anonymous_label: "(anonymous)"
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def load_users(connection) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
24
+ rows = connection.select_all(<<~SQL).to_a
25
+ SELECT actor_type, actor_id::text AS actor_id, MAX(deleted_at) AS last_seen
26
+ FROM #{Athar::DELETIONS_TABLE_NAME}
27
+ WHERE actor_id IS NOT NULL AND deleted_at >= #{quote(@cutoff)}
28
+ GROUP BY actor_type, actor_id
29
+ ORDER BY last_seen DESC
30
+ LIMIT #{LIMIT}
31
+ SQL
32
+
33
+ rows.group_by { |row| row["actor_type"] }.flat_map do |type, rows_for_type|
34
+ klass = type.safe_constantize
35
+
36
+ if klass.respond_to?(:where)
37
+ ids = rows_for_type.map { |row| row["actor_id"] }
38
+ records_by_id = klass.where(klass.primary_key => ids).index_by { |record| record.id.to_s }
39
+
40
+ rows_for_type.map do |row|
41
+ record = records_by_id[row["actor_id"]]
42
+ { value: "user:#{row["actor_type"]}:#{row["actor_id"]}",
43
+ label: ActorLabels.humanize(record, type, row["actor_id"]) }
44
+ end
45
+ else
46
+ rows_for_type.map do |row|
47
+ { value: "user:#{row["actor_type"]}:#{row["actor_id"]}", label: "#{type}##{row["actor_id"]}" }
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ def load_system(connection)
54
+ rows = connection.select_all(<<~SQL).to_a
55
+ SELECT metadata->>'actor' AS name, MAX(deleted_at) AS last_seen
56
+ FROM #{Athar::DELETIONS_TABLE_NAME}
57
+ WHERE actor_id IS NULL AND metadata ? 'actor' AND deleted_at >= #{quote(@cutoff)}
58
+ GROUP BY 1
59
+ ORDER BY last_seen DESC
60
+ LIMIT #{LIMIT}
61
+ SQL
62
+
63
+ rows.map { |row| { value: "sys:#{row["name"]}", label: row["name"] } }
64
+ end
65
+
66
+ def quote(value)
67
+ ActiveRecord::Base.connection.quote(value)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Dashboard
5
+ # Database name + Postgres major version, rendered in the topbar.
6
+ class ConnectionInfo
7
+ attr_reader :database, :version
8
+
9
+ def self.fetch(
10
+ connection: ActiveRecord::Base.connection,
11
+ db_config: ActiveRecord::Base.connection_db_config
12
+ )
13
+ version_num = connection.select_value("SHOW server_version_num").to_i
14
+ new(database: db_config.database.to_s, version: "pg#{version_num / 10_000}")
15
+ end
16
+
17
+ def initialize(database:, version:)
18
+ @database = database
19
+ @version = version
20
+
21
+ freeze
22
+ end
23
+ end
24
+ end
25
+ end