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