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,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,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 %>
|