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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -1
- data/README.md +60 -0
- data/app/assets/images/athar/logo.png +0 -0
- data/app/assets/javascripts/athar/dashboard.js +290 -0
- data/app/assets/stylesheets/athar/dashboard.css +841 -0
- data/app/controllers/athar/application_controller.rb +10 -0
- data/app/controllers/athar/dashboard_controller.rb +57 -0
- data/app/controllers/athar/deletions_controller.rb +14 -0
- data/app/controllers/athar/table_events_controller.rb +11 -0
- data/app/controllers/athar/themes_controller.rb +16 -0
- data/app/helpers/athar/asset_helper.rb +28 -0
- data/app/helpers/athar/dashboard/cell_helper.rb +88 -0
- data/app/helpers/athar/dashboard/detail_helper.rb +50 -0
- data/app/helpers/athar/dashboard/filter_link_helper.rb +22 -0
- data/app/helpers/athar/dashboard/formatting_helper.rb +47 -0
- data/app/helpers/athar/dashboard/icon_helper.rb +40 -0
- data/app/helpers/athar/dashboard_helper.rb +11 -0
- data/app/views/athar/dashboard/_filter_bar.html.erb +95 -0
- data/app/views/athar/dashboard/_kpi_strip.html.erb +46 -0
- data/app/views/athar/dashboard/_pager.html.erb +32 -0
- data/app/views/athar/dashboard/_row.html.erb +72 -0
- data/app/views/athar/dashboard/_sidebar.html.erb +106 -0
- data/app/views/athar/dashboard/_table.html.erb +30 -0
- data/app/views/athar/dashboard/_topbar.html.erb +30 -0
- data/app/views/athar/dashboard/index.html.erb +31 -0
- data/app/views/athar/deletions/_detail.html.erb +115 -0
- data/app/views/athar/deletions/show.html.erb +3 -0
- data/app/views/athar/table_events/_detail.html.erb +80 -0
- data/app/views/athar/table_events/show.html.erb +3 -0
- data/app/views/layouts/athar/application.html.erb +29 -0
- data/config/routes.rb +8 -0
- data/lib/athar/dashboard/actor_labels.rb +31 -0
- data/lib/athar/dashboard/actor_options.rb +71 -0
- data/lib/athar/dashboard/connection_info.rb +25 -0
- data/lib/athar/dashboard/feed_query.rb +222 -0
- data/lib/athar/dashboard/filter_set.rb +63 -0
- data/lib/athar/dashboard/kpi_calculator.rb +102 -0
- data/lib/athar/dashboard/model_registry.rb +141 -0
- data/lib/athar/dashboard/sparkline.rb +42 -0
- data/lib/athar/dashboard/trigger_args_parser.rb +42 -0
- data/lib/athar/dashboard.rb +16 -0
- data/lib/athar/engine.rb +12 -0
- data/lib/athar/middleware/asset_server.rb +78 -0
- data/lib/athar/version.rb +1 -1
- data/lib/athar.rb +1 -0
- 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,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,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,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
|