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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ class ApplicationController < ::ApplicationController
5
+ layout "athar/application"
6
+
7
+ helper Athar::DashboardHelper
8
+ helper Athar::AssetHelper
9
+ end
10
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ class DashboardController < ApplicationController
5
+ PER_PAGE = 25
6
+
7
+ def index # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
8
+ @filters = Dashboard::FilterSet.from_params(params)
9
+ @now = Time.current
10
+
11
+ @registry = Dashboard::ModelRegistry.discover
12
+ @registry_by_id = @registry.index_by { |model| [model.schema, model.table] }
13
+ @selected_model = @registry.find { |model| model.record_type == @filters.model } if @filters.model
14
+
15
+ feed_query = Dashboard::FeedQuery.new(filters: @filters, per_page: PER_PAGE, now: @now, registry: @registry)
16
+ @rows = feed_query.call
17
+ @total = feed_query.total
18
+
19
+ @page = {
20
+ current: @filters.page,
21
+ last: [(@total.to_f / PER_PAGE).ceil, 1].max,
22
+ total: @total,
23
+ per_page: PER_PAGE
24
+ }
25
+
26
+ @actor_labels = resolve_actor_labels(@rows)
27
+ @kpis = Dashboard::KpiCalculator.new(model: @filters.model, now: @now, registry: @registry).call
28
+ @actors = Dashboard::ActorOptions.new(cutoff: @filters.time_cutoff(@now) || (@now - 30.days)).call
29
+ @connection_info = Dashboard::ConnectionInfo.fetch
30
+ end
31
+
32
+ private
33
+
34
+ def resolve_actor_labels(rows) # rubocop:disable Metrics/AbcSize
35
+ actor_pairs = rows.filter_map do |row|
36
+ [row[:actor_type], row[:actor_id]] if row[:actor_id].present?
37
+ end.uniq
38
+
39
+ actor_pairs.group_by(&:first).each_with_object({}) do |(actor_type, pairs_for_type), labels|
40
+ actor_ids = pairs_for_type.map(&:last)
41
+ records_by_id = batch_fetch_actor_records(actor_type, actor_ids)
42
+
43
+ actor_ids.each do |actor_id|
44
+ labels[[actor_type, actor_id]] =
45
+ Dashboard::ActorLabels.humanize(records_by_id[actor_id], actor_type, actor_id)
46
+ end
47
+ end
48
+ end
49
+
50
+ def batch_fetch_actor_records(actor_type, actor_ids)
51
+ klass = actor_type.safe_constantize
52
+ return {} unless klass.respond_to?(:where)
53
+
54
+ klass.where(klass.primary_key => actor_ids).index_by { |record| record.id.to_s }
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ class DeletionsController < ApplicationController
5
+ def show
6
+ @deletion = Athar::Deletion.find_by(id: params[:id])
7
+
8
+ head(:not_found) and return unless @deletion
9
+
10
+ @registry = Athar::Dashboard::ModelRegistry.discover
11
+ @registry_by_id = @registry.index_by { |model| [model.schema, model.table] }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ class TableEventsController < ApplicationController
5
+ def show
6
+ @event = Athar::TableEvent.find_by(id: params[:id])
7
+
8
+ head(:not_found) and return unless @event
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ class ThemesController < ApplicationController
5
+ THEMES = %w[dark light].freeze
6
+
7
+ def update
8
+ theme = params[:theme].to_s
9
+
10
+ return head :unprocessable_entity unless THEMES.include?(theme)
11
+
12
+ cookies.permanent[:athar_theme] = { value: theme, same_site: :lax }
13
+ head :no_content
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ # Resolves the URL for a dashboard asset (`dashboard.js`, `dashboard.css`).
5
+ #
6
+ # When the host has Sprockets or Propshaft loaded, the asset pipeline serves
7
+ # the digested file from `app/assets/{javascripts,stylesheets}/athar/`.
8
+ # Otherwise we fall through to `/athar-assets/<version>/<name>`, which the
9
+ # `Athar::Middleware::AssetServer` middleware serves directly out of the
10
+ # same `app/assets/` source files.
11
+ module AssetHelper
12
+ def athar_asset_path(name)
13
+ if defined?(::Sprockets) || defined?(::Propshaft)
14
+ begin
15
+ return ActionController::Base.helpers.asset_path("athar/#{name}")
16
+ rescue StandardError => e
17
+ Rails.logger&.warn("[Athar] asset_path fallback for #{name}: #{e.message}")
18
+ end
19
+ end
20
+
21
+ "/athar-assets/#{Athar::VERSION}/#{name}"
22
+ end
23
+
24
+ def athar_csp_nonce
25
+ respond_to?(:content_security_policy_nonce) ? content_security_policy_nonce : nil
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Dashboard
5
+ # Visual atoms for the deletion-feed table cells: capture-mode/mask pills,
6
+ # the kind glyph, copy buttons, the inline metadata preview, and the
7
+ # actor-cell label/role helpers.
8
+ module CellHelper
9
+ def mode_pill(mode)
10
+ content_tag(:span, mode, class: "pill mode-pill mode-#{mode}")
11
+ end
12
+
13
+ def mask_pill(masks)
14
+ return nil if masks.blank?
15
+
16
+ content_tag(:span, "m#{masks.length}", class: "pill mask-pill", title: "masks: #{masks.join(", ")}")
17
+ end
18
+
19
+ def kind_icon(kind)
20
+ content_tag(:span, raw(kind == "truncate" ? icon_trunc : icon_del), class: "kind-icon kind-#{kind}")
21
+ end
22
+
23
+ def copy_button(value, label:)
24
+ button_tag(type: "button",
25
+ class: "copy-btn",
26
+ aria: { label: "Copy #{label}" },
27
+ data: {
28
+ athar_copy: value,
29
+ athar_copy_label: label
30
+ }) do
31
+ raw(icon_copy)
32
+ end
33
+ end
34
+
35
+ def metadata_preview(metadata) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength
36
+ # Drop "actor" since the actor column already renders it; the expanded
37
+ # detail still shows the full unfiltered metadata.
38
+ metadata = (metadata || {}).to_h.except("actor")
39
+
40
+ return content_tag(:span, "{}", class: "muted") if metadata.empty?
41
+
42
+ keys = metadata.keys
43
+ pairs = keys.first(4).map do |key|
44
+ value = metadata[key]
45
+ value_text = value.is_a?(String) ? value : value.to_json
46
+
47
+ content_tag(:span, class: "meta-kv") do
48
+ safe_join(
49
+ [
50
+ content_tag(:span, ERB::Util.html_escape(key), class: "meta-k"),
51
+ content_tag(:span, ERB::Util.html_escape(value_text), class: "meta-v")
52
+ ],
53
+ " "
54
+ )
55
+ end
56
+ end
57
+
58
+ separator = content_tag(:span, "·", class: "dot-sep")
59
+ html = pairs.flat_map.with_index { |pair, index| index.zero? ? [pair] : [separator, pair] }
60
+ html << content_tag(:span, "+#{keys.length - 4}", class: "dim") if keys.length > 4
61
+ content_tag(:span, safe_join(html, " "), class: "summary-bits")
62
+ end
63
+
64
+ # Fallback used by _row.html.erb only when @actor_labels is missing an
65
+ # entry, which happens for rows with NULL actor_id (system or anonymous).
66
+ # Any row with a present actor_id has already been batch-resolved in the
67
+ # controller.
68
+ def actor_label(deletion)
69
+ return "#{deletion[:actor_type]}##{deletion[:actor_id]}" if deletion[:actor_id].present?
70
+ return deletion.dig(:metadata, "actor") if deletion[:metadata].is_a?(Hash) && deletion[:metadata]["actor"]
71
+
72
+ "—"
73
+ end
74
+
75
+ # Returns one of: "engineer" (AR-backed actor), "job" (system actor via
76
+ # metadata.actor), or "anonymous" (no actor info).
77
+ def actor_role(row)
78
+ if row[:actor_id].present?
79
+ "engineer"
80
+ elsif row[:metadata].is_a?(Hash) && row[:metadata]["actor"]
81
+ "job"
82
+ else
83
+ "anonymous"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Dashboard
5
+ # Rendering helpers for the expanded-row detail: JSON-style key-value
6
+ # tables for record_data and metadata, and the row-hash → AR-record
7
+ # upgrade used when a row is opened inline.
8
+ module DetailHelper
9
+ def render_json_kv(hash)
10
+ if hash.blank? || (hash.is_a?(Hash) && hash.empty?)
11
+ return content_tag(:div, "{}".html_safe, class: "json-empty")
12
+ end
13
+
14
+ rows = hash.map do |key, value|
15
+ content_tag(:tr) do
16
+ content_tag(:td, ERB::Util.html_escape(key), class: "kv-key") +
17
+ content_tag(:td, format_kv_value(value), class: "kv-val")
18
+ end
19
+ end
20
+
21
+ content_tag(:table, content_tag(:tbody, safe_join(rows)), class: "kv-table")
22
+ end
23
+
24
+ def format_kv_value(value) # rubocop:disable Metrics/MethodLength
25
+ if value.is_a?(String) && value.include?("***")
26
+ content_tag(:span, ERB::Util.html_escape(value), class: "masked")
27
+ elsif value.nil?
28
+ content_tag(:span, "null", class: "muted")
29
+ elsif [true, false].include?(value)
30
+ content_tag(:span, value.to_s, class: "bool")
31
+ elsif value.is_a?(Numeric)
32
+ content_tag(:span, value.to_s, class: "num")
33
+ else
34
+ ERB::Util.html_escape(value.to_s)
35
+ end
36
+ end
37
+
38
+ # Upgrade a feed-row hash to its AR record so the detail partials can
39
+ # call `.actor` (via ActorLookup), associations, etc. At most one row is
40
+ # expanded at a time, so the cost is one extra query per render.
41
+ def deletion_for(row)
42
+ Athar::Deletion.find(row[:id])
43
+ end
44
+
45
+ def table_event_for(row)
46
+ Athar::TableEvent.find(row[:id])
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Dashboard
5
+ # URL builders for filter-bar / sidebar / pager navigation. Strips
6
+ # `page` and `expanded` on every filter change and drops any param whose
7
+ # value matches its real default, so URLs stay clean (no "?mode=all")
8
+ # while preserving non-default values like "time=all" (default for `time`
9
+ # is "30d", not "all").
10
+ module FilterLinkHelper
11
+ FILTER_DEFAULTS = { "time" => "30d", "mode" => "all", "kind" => "all", "actor" => "all" }.freeze
12
+
13
+ def filter_link_url(replacements = {})
14
+ preserved = request.query_parameters.except("page", "expanded", *replacements.keys.map(&:to_s))
15
+ preserved.merge!(replacements.transform_keys(&:to_s))
16
+ preserved.delete_if { |key, value| value.blank? || FILTER_DEFAULTS[key.to_s] == value }
17
+ query = preserved.empty? ? "" : "?#{preserved.to_query}"
18
+ "#{root_path}#{query}"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Dashboard
5
+ # Compact, terminal-style formatters for time, duration, and large
6
+ # numbers — used in the table rows, sidebar, KPI strip, and topbar.
7
+ module FormattingHelper
8
+ def relative_time(time, now: Time.current)
9
+ diff = (now - time).to_i
10
+ return "#{diff}s ago" if diff < 60
11
+ return "#{diff / 60}m ago" if diff < 3_600
12
+ return "#{diff / 3_600}h ago" if diff < 86_400
13
+ return "#{diff / 86_400}d ago" if diff < 604_800
14
+
15
+ "#{diff / 604_800}w ago"
16
+ end
17
+
18
+ def absolute_time(time)
19
+ time.utc.strftime("%Y-%m-%d %H:%M:%SZ")
20
+ end
21
+
22
+ # Format a large integer compactly: 1_200_000 → "1.2M", 150_000 → "150K".
23
+ def compact_number(number)
24
+ return number.to_s if number.nil? || number < 1_000
25
+
26
+ if number >= 1_000_000
27
+ formatted = (number / 1_000_000.0).round(1)
28
+ "#{formatted.to_s.sub(/\.0$/, "")}M"
29
+ else
30
+ formatted = (number / 1_000.0).round(1)
31
+ "#{formatted.to_s.sub(/\.0$/, "")}K"
32
+ end
33
+ end
34
+
35
+ # Format a duration in seconds as "Xd", "Xy", etc. Used by the sidebar's
36
+ # retention pill.
37
+ def compact_duration(seconds)
38
+ return "—" if seconds.nil?
39
+
40
+ days = seconds / 86_400
41
+ return "#{days / 365}y" if days >= 730
42
+
43
+ "#{days}d"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Dashboard
5
+ # Inline SVG icon library. Each method returns a raw SVG string; callers
6
+ # are responsible for marking it html_safe (typically via `raw(...)`) when
7
+ # the icon needs to embed in surrounding HTML.
8
+ module IconHelper
9
+ # rubocop:disable Layout/LineLength
10
+ def icon_chev_right
11
+ %(<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4l4 4-4 4"/></svg>)
12
+ end
13
+
14
+ def icon_chev_down
15
+ %(<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>)
16
+ end
17
+
18
+ def icon_search
19
+ %(<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM11 11l3 3"/></svg>)
20
+ end
21
+
22
+ def icon_copy
23
+ %(<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M11 5V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h2"/></svg>)
24
+ end
25
+
26
+ def icon_check
27
+ %(<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>)
28
+ end
29
+
30
+ def icon_trunc
31
+ %(<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="M2 8h12M5 5l-3 3 3 3M11 11l3-3-3-3"/></svg>)
32
+ end
33
+
34
+ def icon_del
35
+ %(<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 4h10M5 4V3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1M4 4l1 9a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l1-9"/></svg>)
36
+ end
37
+ # rubocop:enable Layout/LineLength
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module DashboardHelper
5
+ include Dashboard::FormattingHelper
6
+ include Dashboard::IconHelper
7
+ include Dashboard::CellHelper
8
+ include Dashboard::DetailHelper
9
+ include Dashboard::FilterLinkHelper
10
+ end
11
+ end
@@ -0,0 +1,95 @@
1
+ <%# The filter bar is rendered ONCE per full page load and never participates
2
+ in partial swaps (its element is outside #athar-pre / #athar-post). That's
3
+ what keeps the search input's focus / value / selection alive while the
4
+ user is typing. The bar's own state — active segments, the actor select's
5
+ selection — is reconciled from the URL after each swap by
6
+ updateFilterBarFromUrl in dashboard.js. %>
7
+ <div class="filter-bar">
8
+ <form data-athar-partial-form
9
+ action="<%= root_path %>"
10
+ method="get"
11
+ class="filter-form"
12
+ role="search">
13
+ <%# No hidden_field_tag for other params — buildSubmitUrl in dashboard.js
14
+ reads the current URL's query string and overlays the form's q + actor,
15
+ so the form stays in sync without needing the bar to be re-rendered. %>
16
+
17
+ <div class="filter-search">
18
+ <%= raw icon_search %>
19
+ <label for="athar-search" class="sr-only">Search audit log</label>
20
+ <input type="search"
21
+ id="athar-search"
22
+ name="q"
23
+ value="<%= filters.query %>"
24
+ placeholder="record_id, actor, request_id, table…"
25
+ autocomplete="off">
26
+ </div>
27
+
28
+ <div class="filter-select">
29
+ <%# Visible "actor" text is the label — referenced via aria-labelledby
30
+ so screen readers don't double-announce. %>
31
+ <span class="seg-label" id="athar-actor-label">actor</span>
32
+ <select id="athar-actor"
33
+ name="actor"
34
+ aria-labelledby="athar-actor-label">
35
+ <option value="all" <%= "selected" if filters.actor == "all" %>>any</option>
36
+ <% if actors.users.any? %>
37
+ <optgroup label="users">
38
+ <% actors.users.each do |opt| %>
39
+ <option value="<%= opt[:value] %>" <%= "selected" if filters.actor == opt[:value] %>><%= h opt[:label] %></option>
40
+ <% end %>
41
+ </optgroup>
42
+ <% end %>
43
+ <% if actors.system.any? %>
44
+ <optgroup label="system">
45
+ <% actors.system.each do |opt| %>
46
+ <option value="<%= opt[:value] %>" <%= "selected" if filters.actor == opt[:value] %>><%= h opt[:label] %></option>
47
+ <% end %>
48
+ </optgroup>
49
+ <% end %>
50
+ <option value="anon" <%= "selected" if filters.actor == "anon" %>><%= h actors.anonymous_label %></option>
51
+ </select>
52
+ <%= raw icon_chev_down %>
53
+ </div>
54
+ </form>
55
+
56
+ <div class="seg" role="group" aria-label="Time filter">
57
+ <span class="seg-label">time</span>
58
+ <% [["24h", "24h"], ["7d", "7d"], ["30d", "30d"], ["all", "all"]].each do |opt, label| %>
59
+ <a href="<%= filter_link_url(time: opt) %>"
60
+ class="seg-btn <%= "is-active" if filters.time == opt %>"
61
+ <%= 'aria-current="page"'.html_safe if filters.time == opt %>
62
+ data-athar-seg="time"
63
+ data-athar-seg-value="<%= opt %>"
64
+ data-athar-partial-link><%= label %></a>
65
+ <% end %>
66
+ </div>
67
+
68
+ <div class="seg" role="group" aria-label="Capture mode filter">
69
+ <span class="seg-label">mode</span>
70
+ <% %w[all identity only snapshot].each do |opt| %>
71
+ <a href="<%= filter_link_url(mode: opt) %>"
72
+ class="seg-btn <%= "is-active" if filters.mode == opt %>"
73
+ <%= 'aria-current="page"'.html_safe if filters.mode == opt %>
74
+ data-athar-seg="mode"
75
+ data-athar-seg-value="<%= opt %>"
76
+ data-athar-partial-link><%= opt %></a>
77
+ <% end %>
78
+ </div>
79
+
80
+ <div class="seg" role="group" aria-label="Event kind filter">
81
+ <span class="seg-label">kind</span>
82
+ <% [["all", "all"], ["delete", "row deletes"], ["truncate", "truncate"]].each do |opt, label| %>
83
+ <a href="<%= filter_link_url(kind: opt) %>"
84
+ class="seg-btn <%= "is-active" if filters.kind == opt %>"
85
+ <%= 'aria-current="page"'.html_safe if filters.kind == opt %>
86
+ data-athar-seg="kind"
87
+ data-athar-seg-value="<%= opt %>"
88
+ data-athar-partial-link><%= label %></a>
89
+ <% end %>
90
+ </div>
91
+
92
+ <a class="filter-clear"
93
+ href="<%= root_path %>"
94
+ data-athar-partial-link>clear</a>
95
+ </div>
@@ -0,0 +1,46 @@
1
+ <%
2
+ pct_7d = ((kpis.last_7d - kpis.prior_7d).to_f / [kpis.prior_7d, 1].max * 100).round
3
+ max_bucket = [kpis.sparkline.max, 1].max
4
+ %>
5
+ <div class="kpi-strip">
6
+ <div class="kpi">
7
+ <div class="kpi-label">filtered rows</div>
8
+ <div class="kpi-value"><%= number_with_delimiter(total) %></div>
9
+ <div class="kpi-sub">of <%= number_with_delimiter(kpis.scope_total) %></div>
10
+ </div>
11
+
12
+ <div class="kpi">
13
+ <div class="kpi-label">last 24h</div>
14
+ <div class="kpi-value"><%= number_with_delimiter(kpis.last_24h) %></div>
15
+ <div class="kpi-sub">row deletes</div>
16
+ </div>
17
+
18
+ <div class="kpi">
19
+ <div class="kpi-label">last 7d</div>
20
+ <div class="kpi-value"><%= number_with_delimiter(kpis.last_7d) %></div>
21
+ <div class="kpi-sub">+<%= pct_7d %>% vs prior</div>
22
+ </div>
23
+
24
+ <div class="kpi">
25
+ <div class="kpi-label">truncate events</div>
26
+ <div class="kpi-value"><%= number_with_delimiter(kpis.truncates_30d) %></div>
27
+ <div class="kpi-sub">last 30d</div>
28
+ </div>
29
+
30
+ <div class="kpi">
31
+ <div class="kpi-label">distinct actors</div>
32
+ <div class="kpi-value"><%= number_with_delimiter(kpis.distinct_actors_30d) %></div>
33
+ <div class="kpi-sub">last 30d</div>
34
+ </div>
35
+
36
+ <div class="kpi spark-kpi">
37
+ <div class="kpi-label">deletions · 14d</div>
38
+ <div class="spark">
39
+ <% kpis.sparkline.each_with_index do |bucket, i| %>
40
+ <% bar_pct = (bucket.to_f / max_bucket * 100).round(2) %>
41
+ <% day_offset = kpis.sparkline.length - 1 - i %>
42
+ <span class="spark-bar" style="height:<%= bar_pct %>%" title="<%= bucket %> on day -<%= day_offset %>"></span>
43
+ <% end %>
44
+ </div>
45
+ </div>
46
+ </div>
@@ -0,0 +1,32 @@
1
+ <%
2
+ start_row = page[:total] == 0 ? 0 : (page[:current] - 1) * page[:per_page] + 1
3
+ end_row = [page[:current] * page[:per_page], page[:total]].min
4
+ on_first = page[:current] == 1
5
+ on_last = page[:current] == page[:last]
6
+ %>
7
+
8
+ <div class="pager">
9
+ <span class="pager-info">
10
+ <span class="mono"><%= number_with_delimiter(start_row) %></span>–<span class="mono"><%= number_with_delimiter(end_row) %></span><span class="dim"> of </span><span class="mono"><%= number_with_delimiter(page[:total]) %></span>
11
+ </span>
12
+
13
+ <div class="pager-btns">
14
+ <% if on_first %>
15
+ <button disabled>«</button>
16
+ <button disabled>‹</button>
17
+ <% else %>
18
+ <a href="<%= filter_link_url(page: 1) %>" data-athar-partial-link>«</a>
19
+ <a href="<%= filter_link_url(page: page[:current] - 1) %>" data-athar-partial-link>‹</a>
20
+ <% end %>
21
+
22
+ <span class="pager-page">page <span class="mono"><%= page[:current] %></span> / <span class="mono"><%= page[:last] %></span></span>
23
+
24
+ <% if on_last %>
25
+ <button disabled>›</button>
26
+ <button disabled>»</button>
27
+ <% else %>
28
+ <a href="<%= filter_link_url(page: page[:current] + 1) %>" data-athar-partial-link>›</a>
29
+ <a href="<%= filter_link_url(page: page[:last]) %>" data-athar-partial-link>»</a>
30
+ <% end %>
31
+ </div>
32
+ </div>
@@ -0,0 +1,72 @@
1
+ <%
2
+ is_open = expanded == row[:id].to_s
3
+ is_trunc = row[:kind] == "truncate"
4
+ row_class = ["drow", is_open ? "is-open" : nil, is_trunc ? "is-trunc" : nil].compact.join(" ")
5
+ model_info = registry_by_id[[row[:schema_name], row[:table_name]]]
6
+ expand_url = is_open ? filter_link_url(expanded: nil) : filter_link_url(expanded: row[:id].to_s)
7
+ %>
8
+
9
+ <tr class="<%= row_class %>" data-id="<%= row[:id] %>">
10
+ <td class="td-expand">
11
+ <a href="<%= expand_url %>"
12
+ data-athar-partial-link
13
+ aria-label="<%= is_open ? "Collapse row" : "Expand row" %>"
14
+ aria-expanded="<%= is_open %>"
15
+ <%= "data-collapse-expand" if is_open %>>
16
+ <%= raw(is_open ? icon_chev_down : icon_chev_right) %>
17
+ </a>
18
+ </td>
19
+
20
+ <td class="td-when">
21
+ <div class="when-rel"><%= relative_time(row[:occurred_at]) %></div>
22
+ <div class="when-abs"><%= absolute_time(row[:occurred_at]) %></div>
23
+ </td>
24
+
25
+ <td>
26
+ <%= kind_icon(row[:kind]) %>
27
+ <span class="record-type"><%= row[:record_type] || row[:table_name] %></span>
28
+
29
+ <% if row[:record_id].present? %>
30
+ <span class="record-id-cell">
31
+ <span class="record-id">#<%= row[:record_id] %></span>
32
+ <%= copy_button(row[:record_id], label: "record_id") %>
33
+ </span>
34
+ <% end %>
35
+ </td>
36
+
37
+ <td class="mono dim"><%= row[:schema_name] %>.<%= row[:table_name] %></td>
38
+
39
+ <td>
40
+ <% if model_info %>
41
+ <%= mode_pill(model_info.capture_mode) %>
42
+ <%= mask_pill(model_info.masks) %>
43
+ <% end %>
44
+ </td>
45
+
46
+ <td>
47
+ <span class="actor actor-<%= actor_role(row) %>">
48
+ <span class="actor-dot"></span>
49
+ <span class="actor-name"><%= actor_labels[[row[:actor_type], row[:actor_id]]] || actor_label(row) %></span>
50
+
51
+ <% if row[:actor_id].present? %>
52
+ <%= copy_button("#{row[:actor_type]}##{row[:actor_id]}", label: "actor_id") %>
53
+ <% end %>
54
+ </span>
55
+ </td>
56
+
57
+ <td class="td-summary"><%= metadata_preview(row[:metadata]) %></td>
58
+
59
+ <td class="td-id mono"><%= row[:id] %></td>
60
+ </tr>
61
+
62
+ <% if is_open %>
63
+ <tr class="drow-detail">
64
+ <td colspan="8">
65
+ <% if row[:kind] == "deletion" %>
66
+ <%= render "athar/deletions/detail", deletion: deletion_for(row), registry_by_id: registry_by_id %>
67
+ <% else %>
68
+ <%= render "athar/table_events/detail", event: table_event_for(row) %>
69
+ <% end %>
70
+ </td>
71
+ </tr>
72
+ <% end %>