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,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Dashboard
5
+ class FeedQuery # rubocop:disable Metrics/ClassLength
6
+ # Typed empty SELECT used for either UNION leg when the kind filter
7
+ # excludes that leg. Column types must match the live SELECTs above
8
+ # so PostgreSQL can compose the UNION without coercion.
9
+ DELETION_SEARCH_COLUMNS = %w[
10
+ record_type record_id::text schema_name table_name
11
+ actor_type actor_id::text record_data::text metadata::text
12
+ ].freeze
13
+
14
+ TABLE_EVENT_SEARCH_COLUMNS = %w[
15
+ schema_name table_name actor_type actor_id::text metadata::text
16
+ ].freeze
17
+
18
+ EMPTY_LEG_SELECT = <<~SQL
19
+ SELECT
20
+ NULL::text AS kind,
21
+ NULL::bigint AS id,
22
+ NULL::text AS record_type,
23
+ NULL::text AS record_id,
24
+ NULL::text AS schema_name,
25
+ NULL::text AS table_name,
26
+ NULL::text AS actor_type,
27
+ NULL::text AS actor_id,
28
+ NULL::timestamptz AS occurred_at,
29
+ NULL::jsonb AS record_data,
30
+ NULL::jsonb AS metadata
31
+ WHERE FALSE
32
+ SQL
33
+
34
+ def initialize(filters:, per_page: 25, now: Time.current, registry: nil)
35
+ @filters = filters
36
+ @per_page = per_page
37
+ @now = now
38
+ @registry = registry
39
+ end
40
+
41
+ def call(connection: ActiveRecord::Base.connection)
42
+ connection.select_all(page_sql, "FeedQuery#call").map { |row| to_row(row) }
43
+ end
44
+
45
+ def total(connection: ActiveRecord::Base.connection)
46
+ connection.select_value(count_sql, "FeedQuery#total").to_i
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :filters, :per_page, :now
52
+
53
+ def page_sql
54
+ <<~SQL
55
+ SELECT * FROM (
56
+ #{deletion_select}
57
+ UNION ALL
58
+ #{table_event_select}
59
+ ) feed
60
+ ORDER BY occurred_at DESC, id DESC
61
+ LIMIT #{per_page} OFFSET #{(filters.page - 1) * per_page}
62
+ SQL
63
+ end
64
+
65
+ def count_sql
66
+ "SELECT COUNT(*) FROM (#{deletion_select} UNION ALL #{table_event_select}) feed"
67
+ end
68
+
69
+ def deletion_select
70
+ return EMPTY_LEG_SELECT if filters.kind == "truncate"
71
+
72
+ <<~SQL
73
+ SELECT
74
+ 'deletion'::text AS kind,
75
+ id,
76
+ record_type,
77
+ record_id::text AS record_id,
78
+ schema_name,
79
+ table_name,
80
+ actor_type,
81
+ actor_id::text AS actor_id,
82
+ deleted_at AS occurred_at,
83
+ record_data,
84
+ metadata
85
+ FROM #{Athar::DELETIONS_TABLE_NAME}
86
+ WHERE #{deletion_where_clause}
87
+ SQL
88
+ end
89
+
90
+ def table_event_select
91
+ return EMPTY_LEG_SELECT if filters.kind == "delete"
92
+
93
+ <<~SQL
94
+ SELECT
95
+ 'truncate'::text AS kind,
96
+ id,
97
+ NULL::text AS record_type,
98
+ NULL::text AS record_id,
99
+ schema_name,
100
+ table_name,
101
+ actor_type,
102
+ actor_id::text AS actor_id,
103
+ occurred_at,
104
+ NULL::jsonb AS record_data,
105
+ metadata
106
+ FROM #{Athar::TABLE_EVENTS_TABLE_NAME}
107
+ WHERE event_type = 'truncate' AND #{table_event_where_clause}
108
+ SQL
109
+ end
110
+
111
+ def deletion_where_clause # rubocop:disable Metrics/AbcSize
112
+ clauses = ["TRUE"]
113
+ clauses << time_clause("deleted_at")
114
+ clauses << "record_type = #{quote(filters.model)}" if filters.model
115
+ # capture_mode is not on the audit row; mode filter operates by table.
116
+ clauses << tables_with_mode_clause(filters.mode) if filters.mode != "all"
117
+ clauses << actor_clause("athar_deletions")
118
+ clauses << search_clause(DELETION_SEARCH_COLUMNS)
119
+ clauses.compact.join(" AND ")
120
+ end
121
+
122
+ def table_event_where_clause # rubocop:disable Metrics/AbcSize
123
+ clauses = ["TRUE"]
124
+ clauses << time_clause("occurred_at")
125
+ clauses << tables_for_model_clause if filters.model
126
+ clauses << tables_with_mode_clause(filters.mode) if filters.mode != "all"
127
+ clauses << actor_clause("athar_table_events")
128
+ clauses << search_clause(TABLE_EVENT_SEARCH_COLUMNS)
129
+ clauses.compact.join(" AND ")
130
+ end
131
+
132
+ def time_clause(column)
133
+ cutoff = filters.time_cutoff(now)
134
+ return nil unless cutoff
135
+
136
+ "#{column} >= #{quote(cutoff)}"
137
+ end
138
+
139
+ def actor_clause(_table)
140
+ actor_filter = filters.actor_filter
141
+ return nil unless actor_filter
142
+
143
+ case actor_filter[:kind]
144
+ when :user
145
+ "actor_id::text = #{quote(actor_filter[:id])} AND actor_type = #{quote(actor_filter[:type])}"
146
+ when :sys
147
+ "actor_id IS NULL AND metadata->>'actor' = #{quote(actor_filter[:name])}"
148
+ when :anon
149
+ "actor_id IS NULL AND NOT (metadata ? 'actor')"
150
+ end
151
+ end
152
+
153
+ def search_clause(columns)
154
+ query = filters.query.strip
155
+ return nil if query.empty?
156
+
157
+ pattern = ActiveRecord::Base.sanitize_sql_like(query)
158
+ like = quote("%#{pattern}%")
159
+ "(#{columns.map { |column| "#{column} ILIKE #{like}" }.join(" OR ")})"
160
+ end
161
+
162
+ def tables_for_model_clause
163
+ tables_in_clause(registry.select { |model| model.record_type == filters.model })
164
+ end
165
+
166
+ def tables_with_mode_clause(mode)
167
+ tables_in_clause(registry.select { |model| model.capture_mode == mode })
168
+ end
169
+
170
+ # Compose `(schema_name, table_name) IN (VALUES …)` from a list of models,
171
+ # or "FALSE" when there are no matching tables — a bare IN over an empty
172
+ # VALUES list is invalid Postgres, so the clause itself collapses.
173
+ def tables_in_clause(models)
174
+ return "FALSE" if models.empty?
175
+
176
+ tuples = models.map { |model| "(#{quote(model.schema)}, #{quote(model.table)})" }.join(",")
177
+ "(schema_name, table_name) IN (VALUES #{tuples})"
178
+ end
179
+
180
+ def registry
181
+ @registry ||= ModelRegistry.discover
182
+ end
183
+
184
+ def quote(value)
185
+ ActiveRecord::Base.connection.quote(value)
186
+ end
187
+
188
+ def to_row(hash)
189
+ {
190
+ kind: hash["kind"],
191
+ id: hash["id"].to_i,
192
+ record_type: hash["record_type"],
193
+ record_id: hash["record_id"],
194
+ schema_name: hash["schema_name"],
195
+ table_name: hash["table_name"],
196
+ actor_type: hash["actor_type"],
197
+ actor_id: hash["actor_id"],
198
+ occurred_at: parse_time(hash["occurred_at"]),
199
+ record_data: parse_jsonb(hash["record_data"]),
200
+ metadata: parse_jsonb(hash["metadata"])
201
+ }
202
+ end
203
+
204
+ def parse_time(value)
205
+ return value if value.is_a?(Time)
206
+ return nil if value.nil?
207
+
208
+ Time.zone.parse(value.to_s)
209
+ end
210
+
211
+ def parse_jsonb(value)
212
+ return value if value.is_a?(Hash) || value.is_a?(Array)
213
+ return nil if value.nil?
214
+ return value unless value.is_a?(String)
215
+
216
+ JSON.parse(value)
217
+ rescue JSON::ParserError
218
+ value
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Dashboard
5
+ # Immutable value object holding the parsed query-param state for a
6
+ # dashboard request. Exposed by DashboardController#index as @filters and
7
+ # consumed by FeedQuery, KpiCalculator, ActorOptions, and the partials.
8
+ class FilterSet
9
+ TIMES = { "24h" => 24.hours, "7d" => 7.days, "30d" => 30.days, "all" => nil }.freeze
10
+ MODES = %w[all identity only snapshot].freeze
11
+ KINDS = %w[all delete truncate].freeze
12
+ DEFAULT_TIME = "30d"
13
+
14
+ attr_reader :model, :time, :mode, :kind, :actor, :query, :page, :expanded
15
+
16
+ def self.from_params(params) # rubocop:disable Metrics/AbcSize
17
+ new(
18
+ model: params[:model].presence,
19
+ time: TIMES.key?(params[:time]) ? params[:time] : DEFAULT_TIME,
20
+ mode: MODES.include?(params[:mode]) ? params[:mode] : "all",
21
+ kind: KINDS.include?(params[:kind]) ? params[:kind] : "all",
22
+ actor: params[:actor].presence || "all",
23
+ query: params[:q].to_s,
24
+ page: [params[:page].to_i, 1].max,
25
+ expanded: params[:expanded].presence
26
+ )
27
+ end
28
+
29
+ def initialize(model:, time:, mode:, kind:, actor:, query:, page:, expanded:) # rubocop:disable Metrics/ParameterLists
30
+ @model = model
31
+ @time = time
32
+ @mode = mode
33
+ @kind = kind
34
+ @actor = actor
35
+ @query = query
36
+ @page = page
37
+ @expanded = expanded
38
+
39
+ freeze
40
+ end
41
+
42
+ def time_cutoff(now = Time.current)
43
+ delta = TIMES[time]
44
+ delta ? now - delta : nil
45
+ end
46
+
47
+ def actor_filter
48
+ return nil if actor == "all"
49
+
50
+ if actor == "anon"
51
+ { kind: :anon }
52
+ elsif actor.start_with?("user:")
53
+ _, type, id = actor.split(":", 3)
54
+ return nil if id.blank?
55
+
56
+ { kind: :user, type:, id: }
57
+ elsif actor.start_with?("sys:")
58
+ { kind: :sys, name: actor.sub("sys:", "") }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Dashboard
5
+ class KpiCalculator
6
+ Result = Data.define(
7
+ :scope_total,
8
+ :last_24h,
9
+ :last_7d,
10
+ :prior_7d,
11
+ :distinct_actors_30d,
12
+ :truncates_30d,
13
+ :sparkline
14
+ )
15
+
16
+ def initialize(model:, now: Time.current, registry: nil)
17
+ @model = model
18
+ @now = now
19
+ @registry = registry
20
+ end
21
+
22
+ def call(connection: ActiveRecord::Base.connection) # rubocop:disable Metrics/AbcSize
23
+ aggregates = aggregate(connection)
24
+ truncates_30d = truncate_count(connection)
25
+
26
+ Result.new(
27
+ # scope_total covers the same universe the feed UNIONs over: row
28
+ # deletions plus all table events for the model's tables (not just
29
+ # the last 30d), so "filtered N of M" never overshoots M.
30
+ scope_total: aggregates["scope_total"].to_i + table_event_total(connection),
31
+ last_24h: aggregates["last_24h"].to_i,
32
+ last_7d: aggregates["last_7d"].to_i,
33
+ prior_7d: aggregates["prior_7d"].to_i,
34
+ distinct_actors_30d: aggregates["distinct_actors_30d"].to_i,
35
+ truncates_30d: truncates_30d,
36
+ sparkline: Sparkline.new(model: @model, now: @now).buckets(connection: connection)
37
+ )
38
+ end
39
+
40
+ private
41
+
42
+ def aggregate(connection) # rubocop:disable Metrics/AbcSize
43
+ sql = <<~SQL
44
+ SELECT
45
+ COUNT(*) AS scope_total,
46
+ COUNT(*) FILTER (WHERE deleted_at > #{quote(@now - 1.day)}) AS last_24h,
47
+ COUNT(*) FILTER (WHERE deleted_at > #{quote(@now - 7.days)}) AS last_7d,
48
+ COUNT(*) FILTER (WHERE deleted_at > #{quote(@now - 14.days)}
49
+ AND deleted_at <= #{quote(@now - 7.days)}) AS prior_7d,
50
+ COUNT(DISTINCT (actor_type, actor_id))
51
+ FILTER (WHERE actor_id IS NOT NULL AND deleted_at > #{quote(@now - 30.days)}) AS distinct_actors_30d
52
+ FROM #{Athar::DELETIONS_TABLE_NAME}
53
+ #{model_scope}
54
+ SQL
55
+
56
+ connection.select_one(sql)
57
+ end
58
+
59
+ def truncate_count(connection)
60
+ sql = <<~SQL
61
+ SELECT COUNT(*) AS n FROM #{Athar::TABLE_EVENTS_TABLE_NAME}
62
+ WHERE event_type = 'truncate' AND occurred_at > #{quote(@now - 30.days)}
63
+ #{table_scope_clause}
64
+ SQL
65
+ connection.select_value(sql).to_i
66
+ end
67
+
68
+ def table_event_total(connection)
69
+ sql = <<~SQL
70
+ SELECT COUNT(*) AS n FROM #{Athar::TABLE_EVENTS_TABLE_NAME}
71
+ WHERE TRUE #{table_scope_clause}
72
+ SQL
73
+
74
+ connection.select_value(sql).to_i
75
+ end
76
+
77
+ def model_scope
78
+ return "" unless @model
79
+
80
+ "WHERE record_type = #{quote(@model)}"
81
+ end
82
+
83
+ def table_scope_clause
84
+ return "" unless @model
85
+
86
+ scoped = registry.select { |model| model.record_type == @model }
87
+ return "AND FALSE" if scoped.empty?
88
+
89
+ pairs = scoped.map { |model| "(#{quote(model.schema)}, #{quote(model.table)})" }.join(",")
90
+ "AND (schema_name, table_name) IN (VALUES #{pairs})"
91
+ end
92
+
93
+ def registry
94
+ @registry ||= ModelRegistry.discover
95
+ end
96
+
97
+ def quote(value)
98
+ ActiveRecord::Base.connection.quote(value)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Athar
6
+ module Dashboard
7
+ module ModelRegistry
8
+ ModelInfo = Data.define(:schema, :table, :record_type, :capture_mode, :columns, :masks, :sti, :truncate, :count)
9
+
10
+ DELETE_TRIGGER_SQL = <<~SQL
11
+ SELECT n.nspname AS schema_name, c.relname AS table_name,
12
+ pg_get_triggerdef(t.oid) AS definition
13
+ FROM pg_trigger t
14
+ JOIN pg_proc p ON p.oid = t.tgfoid
15
+ JOIN pg_class c ON c.oid = t.tgrelid
16
+ JOIN pg_namespace n ON n.oid = c.relnamespace
17
+ WHERE p.proname = 'athar_capture_delete' AND NOT t.tgisinternal
18
+ SQL
19
+
20
+ TRUNCATE_TRIGGER_SQL = <<~SQL
21
+ SELECT n.nspname AS schema_name, c.relname AS table_name
22
+ FROM pg_trigger t
23
+ JOIN pg_proc p ON p.oid = t.tgfoid
24
+ JOIN pg_class c ON c.oid = t.tgrelid
25
+ JOIN pg_namespace n ON n.oid = c.relnamespace
26
+ WHERE p.proname = 'athar_capture_truncate' AND NOT t.tgisinternal
27
+ SQL
28
+
29
+ COUNTS_SQL = <<~SQL.freeze
30
+ SELECT schema_name, table_name, record_type, COUNT(*) AS n
31
+ FROM #{Athar::DELETIONS_TABLE_NAME}
32
+ GROUP BY schema_name, table_name, record_type
33
+ SQL
34
+
35
+ ARGS_RE = /EXECUTE\s+(?:PROCEDURE|FUNCTION)\s+athar_capture_delete\((.*)\)/m
36
+ PG_ARRAY_QUOTED = /"([^"]*)"/
37
+ PG_ARRAY_BARE = /[^,]+/
38
+
39
+ class << self
40
+ def discover(connection = ActiveRecord::Base.connection) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
41
+ triggers = parse_delete_triggers(connection)
42
+ truncate_keys = truncate_trigger_keys(connection)
43
+ counts = load_counts(connection)
44
+
45
+ # One ModelInfo per (schema, table) trigger.
46
+ primary = triggers.map do |trigger|
47
+ key = [trigger[:schema], trigger[:table]]
48
+
49
+ ModelInfo.new(
50
+ schema: trigger[:schema],
51
+ table: trigger[:table],
52
+ record_type: trigger[:record_type] || trigger[:table].classify,
53
+ capture_mode: trigger[:capture_mode],
54
+ columns: trigger[:columns],
55
+ masks: trigger[:masks],
56
+ sti: trigger[:sti],
57
+ truncate: truncate_keys.include?(key),
58
+ count: counts.fetch(
59
+ [trigger[:schema], trigger[:table], trigger[:record_type] || trigger[:table].classify],
60
+ 0
61
+ )
62
+ )
63
+ end
64
+
65
+ # STI children: any (schema, table, record_type) in counts that doesn't
66
+ # match a primary's record_type but whose (schema, table) has STI on.
67
+ children = counts.filter_map do |(schema, table, record_type), n|
68
+ parent = primary.find { |entry| entry.schema == schema && entry.table == table }
69
+ next unless parent&.sti && parent.record_type != record_type
70
+
71
+ ModelInfo.new(
72
+ schema:,
73
+ table:,
74
+ record_type:,
75
+ capture_mode: parent.capture_mode,
76
+ columns: parent.columns,
77
+ masks: parent.masks,
78
+ sti: true,
79
+ truncate: parent.truncate,
80
+ count: n
81
+ )
82
+ end
83
+
84
+ primary + children
85
+ end
86
+
87
+ private
88
+
89
+ def parse_delete_triggers(connection) # rubocop:disable Metrics/AbcSize
90
+ connection.select_all(DELETE_TRIGGER_SQL).map do |row|
91
+ arguments_text = row["definition"][ARGS_RE, 1]
92
+ args = TriggerArgsParser.parse(arguments_text)
93
+
94
+ {
95
+ schema: row["schema_name"],
96
+ table: row["table_name"],
97
+ record_type: args[0],
98
+ capture_mode: args[6],
99
+ columns: parse_pg_array(args[7]),
100
+ masks: parse_pg_array(args[8]).map { |spec| spec.split(":").first },
101
+ sti: !args[5].nil?
102
+ }
103
+ end
104
+ end
105
+
106
+ def truncate_trigger_keys(connection)
107
+ connection.select_all(TRUNCATE_TRIGGER_SQL).each_with_object(Set.new) do |row, set|
108
+ set << [row["schema_name"], row["table_name"]]
109
+ end
110
+ end
111
+
112
+ def load_counts(connection)
113
+ connection.select_all(COUNTS_SQL).to_h do |row|
114
+ [[row["schema_name"], row["table_name"], row["record_type"]], row["n"].to_i]
115
+ end
116
+ end
117
+
118
+ # PG array text: '{a,b,c}' or '{"a:b","c:d"}'. Each token is either a
119
+ # "..."-quoted string (content captured) or a bare token up to the
120
+ # next comma.
121
+ def parse_pg_array(text)
122
+ return [] if text.nil? || text == "null"
123
+
124
+ inner = text.delete_prefix("{").delete_suffix("}")
125
+ return [] if inner.empty?
126
+
127
+ scanner = StringScanner.new(inner)
128
+ parts = []
129
+
130
+ until scanner.eos?
131
+ parts << (scanner.scan(PG_ARRAY_QUOTED) ? scanner[1] : scanner.scan(PG_ARRAY_BARE))
132
+
133
+ scanner.skip(/,/)
134
+ end
135
+
136
+ parts.compact
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Dashboard
5
+ class Sparkline
6
+ DAYS = 14
7
+
8
+ def initialize(model:, now: Time.current)
9
+ @model = model
10
+ @now = now
11
+ end
12
+
13
+ def buckets(connection: ActiveRecord::Base.connection) # rubocop:disable Metrics/AbcSize
14
+ rows = connection.select_all(<<~SQL).to_a
15
+ SELECT date_trunc('day', deleted_at) AS day, COUNT(*) AS n
16
+ FROM #{Athar::DELETIONS_TABLE_NAME}
17
+ WHERE deleted_at > #{quote(@now - DAYS.days)} #{model_scope}
18
+ GROUP BY day ORDER BY day
19
+ SQL
20
+
21
+ by_day = rows.to_h do |row|
22
+ [row["day"].to_date, row["n"].to_i]
23
+ end
24
+
25
+ Array.new(DAYS) do |index|
26
+ day = (@now.to_date - (DAYS - 1 - index))
27
+ by_day.fetch(day, 0)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def model_scope
34
+ @model ? "AND record_type = #{quote(@model)}" : ""
35
+ end
36
+
37
+ def quote(value)
38
+ ActiveRecord::Base.connection.quote(value)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Athar
6
+ module Dashboard
7
+ # Parses the comma-separated argument list captured from a trigger's
8
+ # `EXECUTE PROCEDURE athar_capture_delete(...)` clause. Every arg is a
9
+ # single-quoted string per the trigger templates, so the parser walks
10
+ # quoted tokens (honoring PG's `''` escape for embedded apostrophes) and
11
+ # maps the literal "null" token to Ruby nil.
12
+ module TriggerArgsParser
13
+ QUOTED_TOKEN = /'((?:[^']|'')*)'/
14
+ SEPARATOR = /[\s,]+/
15
+
16
+ class << self
17
+ def parse(input)
18
+ scanner = StringScanner.new(input)
19
+ args = []
20
+
21
+ until scanner.eos?
22
+ scanner.skip(SEPARATOR)
23
+ break if scanner.eos?
24
+ break unless scanner.scan(QUOTED_TOKEN)
25
+
26
+ args << finalize(scanner[1].gsub("''", "'"))
27
+ end
28
+
29
+ args
30
+ end
31
+
32
+ private
33
+
34
+ def finalize(token)
35
+ return nil if token == "null"
36
+
37
+ token
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Athar
4
+ module Dashboard
5
+ end
6
+ end
7
+
8
+ require_relative "dashboard/filter_set"
9
+ require_relative "dashboard/trigger_args_parser"
10
+ require_relative "dashboard/model_registry"
11
+ require_relative "dashboard/feed_query"
12
+ require_relative "dashboard/sparkline"
13
+ require_relative "dashboard/kpi_calculator"
14
+ require_relative "dashboard/actor_labels"
15
+ require_relative "dashboard/actor_options"
16
+ require_relative "dashboard/connection_info"
data/lib/athar/engine.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "middleware/asset_server"
4
+
3
5
  module Athar
4
6
  class Engine < ::Rails::Engine
5
7
  isolate_namespace Athar
@@ -7,5 +9,15 @@ module Athar
7
9
  initializer "athar.set_logger" do
8
10
  Athar.configuration.logger ||= Rails.logger
9
11
  end
12
+
13
+ initializer "athar.assets" do |app|
14
+ if app.config.respond_to?(:assets)
15
+ app.config.assets.paths << root.join("app/assets/stylesheets").to_s
16
+ app.config.assets.paths << root.join("app/assets/javascripts").to_s
17
+ app.config.assets.precompile += %w[athar/dashboard.js athar/dashboard.css] if defined?(::Sprockets)
18
+ end
19
+
20
+ app.middleware.insert_after Rack::Runtime, Athar::Middleware::AssetServer, root
21
+ end
10
22
  end
11
23
  end