code_sunset 0.1.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +242 -0
- data/Rakefile +6 -0
- data/app/assets/images/code_sunset/.keep +0 -0
- data/app/assets/images/code_sunset/logo-dark.png +0 -0
- data/app/assets/images/code_sunset/logo-light.png +0 -0
- data/app/assets/images/code_sunset/topography.svg +1 -0
- data/app/assets/javascripts/code_sunset/application.js +179 -0
- data/app/assets/javascripts/code_sunset/vendor/chart.umd.min.js +14 -0
- data/app/assets/stylesheets/code_sunset/application.css +1075 -0
- data/app/controllers/code_sunset/application_controller.rb +50 -0
- data/app/controllers/code_sunset/dashboard_controller.rb +12 -0
- data/app/controllers/code_sunset/features_controller.rb +62 -0
- data/app/controllers/code_sunset/removal_candidates_controller.rb +9 -0
- data/app/controllers/concerns/.keep +0 -0
- data/app/helpers/code_sunset/application_helper.rb +244 -0
- data/app/jobs/code_sunset/aggregate_daily_rollups_job.rb +48 -0
- data/app/jobs/code_sunset/application_job.rb +4 -0
- data/app/jobs/code_sunset/cleanup_events_job.rb +10 -0
- data/app/jobs/code_sunset/evaluate_alerts_job.rb +15 -0
- data/app/jobs/code_sunset/flush_buffered_events_job.rb +9 -0
- data/app/jobs/code_sunset/persist_event_job.rb +9 -0
- data/app/mailers/code_sunset/application_mailer.rb +6 -0
- data/app/models/code_sunset/alert_delivery.rb +14 -0
- data/app/models/code_sunset/application_record.rb +5 -0
- data/app/models/code_sunset/daily_rollup.rb +31 -0
- data/app/models/code_sunset/event.rb +190 -0
- data/app/models/code_sunset/feature.rb +33 -0
- data/app/models/concerns/.keep +0 -0
- data/app/views/code_sunset/dashboard/index.html.erb +123 -0
- data/app/views/code_sunset/features/index.html.erb +88 -0
- data/app/views/code_sunset/features/show.html.erb +266 -0
- data/app/views/code_sunset/removal_candidates/index.html.erb +118 -0
- data/app/views/code_sunset/shared/_filter_bar.html.erb +81 -0
- data/app/views/layouts/code_sunset/application.html.erb +57 -0
- data/bin/rails +26 -0
- data/code_sunset.gemspec +35 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20260330000000_create_code_sunset_tables.rb +62 -0
- data/db/migrate/20260331000000_harden_code_sunset_reliability.rb +39 -0
- data/lib/code_sunset/alert_dispatcher.rb +103 -0
- data/lib/code_sunset/analytics_filters.rb +59 -0
- data/lib/code_sunset/configuration.rb +104 -0
- data/lib/code_sunset/context.rb +49 -0
- data/lib/code_sunset/controller_context.rb +23 -0
- data/lib/code_sunset/engine.rb +24 -0
- data/lib/code_sunset/event_buffer.rb +28 -0
- data/lib/code_sunset/event_ingestor.rb +57 -0
- data/lib/code_sunset/event_payload.rb +79 -0
- data/lib/code_sunset/feature_query.rb +69 -0
- data/lib/code_sunset/feature_snapshot.rb +14 -0
- data/lib/code_sunset/feature_usage_series.rb +59 -0
- data/lib/code_sunset/identity_hash.rb +9 -0
- data/lib/code_sunset/instrumentation.rb +17 -0
- data/lib/code_sunset/job_context.rb +17 -0
- data/lib/code_sunset/removal_candidate_query.rb +17 -0
- data/lib/code_sunset/removal_prompt_builder.rb +331 -0
- data/lib/code_sunset/score_calculator.rb +83 -0
- data/lib/code_sunset/status_engine.rb +38 -0
- data/lib/code_sunset/subscriber.rb +24 -0
- data/lib/code_sunset/version.rb +3 -0
- data/lib/code_sunset.rb +109 -0
- data/lib/generators/code_sunset/eject_ui/eject_ui_generator.rb +33 -0
- data/lib/generators/code_sunset/initializer/initializer_generator.rb +13 -0
- data/lib/generators/code_sunset/initializer/templates/code_sunset.rb +34 -0
- data/lib/generators/code_sunset/install/install_generator.rb +23 -0
- data/lib/generators/code_sunset/migration/migration_generator.rb +21 -0
- data/lib/generators/code_sunset/migration/templates/create_code_sunset_tables.rb +75 -0
- data/lib/tasks/code_sunset_tasks.rake +21 -0
- metadata +185 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module CodeSunset
|
|
2
|
+
class ApplicationController < ActionController::Base
|
|
3
|
+
layout "code_sunset/application"
|
|
4
|
+
|
|
5
|
+
before_action :authorize_dashboard!
|
|
6
|
+
helper_method :filters, :analytics_filters
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def authorize_dashboard!
|
|
11
|
+
authorizer = CodeSunset.configuration.dashboard_authorizer
|
|
12
|
+
allowed = if authorizer.respond_to?(:call)
|
|
13
|
+
authorizer.arity.zero? ? instance_exec(&authorizer) : authorizer.call(self)
|
|
14
|
+
else
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
return if allowed
|
|
19
|
+
|
|
20
|
+
head :forbidden
|
|
21
|
+
rescue StandardError => error
|
|
22
|
+
CodeSunset.log_error("dashboard authorization", error)
|
|
23
|
+
head :forbidden
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def filters
|
|
27
|
+
@filters ||= params.permit(*permitted_filter_keys).to_h.symbolize_keys
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def analytics_filters
|
|
31
|
+
@analytics_filters ||= CodeSunset::AnalyticsFilters.new(filters)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def permitted_filter_keys
|
|
35
|
+
[
|
|
36
|
+
:app_env,
|
|
37
|
+
:plan,
|
|
38
|
+
:org_id,
|
|
39
|
+
:user_id,
|
|
40
|
+
:from,
|
|
41
|
+
:to,
|
|
42
|
+
:paid_only,
|
|
43
|
+
:exclude_internal,
|
|
44
|
+
:window,
|
|
45
|
+
:page,
|
|
46
|
+
*CodeSunset.configuration.custom_filters.keys
|
|
47
|
+
]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module CodeSunset
|
|
2
|
+
class DashboardController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@summaries = CodeSunset::FeatureQuery.new(filters).call
|
|
5
|
+
@active_count = @summaries.count { |summary| summary.status == "active" }
|
|
6
|
+
@removable_count = @summaries.count { |summary| %w[candidate_for_removal safe_to_remove].include?(summary.status) }
|
|
7
|
+
@recent_features = @summaries.select { |summary| summary.hits_7d.to_i.positive? }.sort_by { |summary| [-summary.hits_7d.to_i, summary.feature.key] }.first(6)
|
|
8
|
+
@safe_to_remove = @summaries.select { |summary| summary.status == "safe_to_remove" }.sort_by { |summary| [-summary.score.to_f, summary.feature.key] }.first(4)
|
|
9
|
+
@cooling_down = @summaries.select { |summary| summary.status == "candidate_for_removal" }.sort_by { |summary| [-summary.score.to_f, summary.feature.key] }.first(4)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module CodeSunset
|
|
2
|
+
class FeaturesController < ApplicationController
|
|
3
|
+
RECENT_EVENTS_PER_PAGE = 25
|
|
4
|
+
|
|
5
|
+
before_action :load_feature, only: :show
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@summaries = CodeSunset::FeatureQuery.new(filters).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def show
|
|
12
|
+
@summary = CodeSunset::FeatureQuery.new(filters, scope: CodeSunset::Feature.where(id: @feature.id)).call.first
|
|
13
|
+
@usage_series = CodeSunset::FeatureUsageSeries.new(feature: @feature, filters: filters).call
|
|
14
|
+
|
|
15
|
+
scoped_events = @feature.events.apply_filters(filters).where(occurred_at: 30.days.ago..Time.current)
|
|
16
|
+
@top_orgs = CodeSunset::Event.top_orgs(scoped_events, limit: 8)
|
|
17
|
+
@top_users = CodeSunset::Event.top_users(scoped_events, limit: 8)
|
|
18
|
+
@request_paths = CodeSunset::Event.top_request_paths(scoped_events, limit: 8)
|
|
19
|
+
@job_usage = CodeSunset::Event.top_job_classes(scoped_events, limit: 8)
|
|
20
|
+
|
|
21
|
+
recent_events_scope = @feature.events.apply_filters(filters.except(:page)).recent_first
|
|
22
|
+
@recent_events_page = requested_recent_events_page
|
|
23
|
+
@recent_events_per_page = RECENT_EVENTS_PER_PAGE
|
|
24
|
+
@recent_events_total_count = recent_events_scope.count
|
|
25
|
+
@recent_events_total_pages = [(@recent_events_total_count.to_f / @recent_events_per_page).ceil, 1].max
|
|
26
|
+
@recent_events_page = [@recent_events_page, @recent_events_total_pages].min
|
|
27
|
+
@recent_events = recent_events_scope.offset((@recent_events_page - 1) * @recent_events_per_page).limit(@recent_events_per_page)
|
|
28
|
+
|
|
29
|
+
@removal_prompt_artifact = build_removal_prompt_artifact if generate_removal_prompt?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def load_feature
|
|
35
|
+
@feature = CodeSunset::Feature.find_by!(key: params[:id])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def requested_recent_events_page
|
|
39
|
+
page = params[:page].to_i
|
|
40
|
+
page.positive? ? page : 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def generate_removal_prompt?
|
|
44
|
+
ActiveModel::Type::Boolean.new.cast(params[:generate_removal_prompt])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_removal_prompt_artifact
|
|
48
|
+
CodeSunset::RemovalPromptBuilder.new(
|
|
49
|
+
feature: @feature,
|
|
50
|
+
snapshot: @summary,
|
|
51
|
+
filters: filters,
|
|
52
|
+
top_orgs: @top_orgs,
|
|
53
|
+
top_users: @top_users,
|
|
54
|
+
request_paths: @request_paths,
|
|
55
|
+
job_usage: @job_usage,
|
|
56
|
+
recent_events: @recent_events,
|
|
57
|
+
recent_events_total_count: @recent_events_total_count,
|
|
58
|
+
usage_series: @usage_series
|
|
59
|
+
).call
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module CodeSunset
|
|
2
|
+
class RemovalCandidatesController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
@summaries = CodeSunset::RemovalCandidateQuery.new(filters).call
|
|
5
|
+
@safe = @summaries.select { |summary| summary.status == "safe_to_remove" }
|
|
6
|
+
@candidates = @summaries.select { |summary| summary.status == "candidate_for_removal" }
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
module CodeSunset
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
def format_seen_at(timestamp)
|
|
4
|
+
return "Never" unless timestamp
|
|
5
|
+
|
|
6
|
+
time = timestamp.in_time_zone
|
|
7
|
+
relative_time = if time > Time.current
|
|
8
|
+
"just now"
|
|
9
|
+
else
|
|
10
|
+
"#{time_ago_in_words(time)} ago"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
content_tag(
|
|
14
|
+
:time,
|
|
15
|
+
relative_time,
|
|
16
|
+
class: "cs-time",
|
|
17
|
+
datetime: time.iso8601,
|
|
18
|
+
title: time.strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def human_count(value)
|
|
23
|
+
number_with_delimiter(value || 0)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def status_badge(status)
|
|
27
|
+
content_tag(:span, status.to_s.tr("_", " ").titleize, class: "cs-badge cs-badge--#{status}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def current_filter_value(key)
|
|
31
|
+
filters[key]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def analytics_notice
|
|
35
|
+
analytics_filters.raw_filter_notice
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def filters_applied?
|
|
39
|
+
filters.except(:page).values.any?(&:present?)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def sidebar_nav_link(label, path, icon:)
|
|
43
|
+
classes = ["cs-nav__link"]
|
|
44
|
+
classes << "is-active" if current_page?(path)
|
|
45
|
+
|
|
46
|
+
link_to path, class: classes.join(" ") do
|
|
47
|
+
safe_join(
|
|
48
|
+
[
|
|
49
|
+
content_tag(:span, sidebar_icon(icon), class: "cs-nav__icon"),
|
|
50
|
+
content_tag(:span, label, class: "cs-nav__text")
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def removal_score_badge(feature, snapshot, label: nil)
|
|
57
|
+
label ||= format("%.1f", snapshot&.score.to_f)
|
|
58
|
+
|
|
59
|
+
content_tag(
|
|
60
|
+
:span,
|
|
61
|
+
label,
|
|
62
|
+
class: "cs-score cs-score--help",
|
|
63
|
+
title: removal_score_tooltip(feature, snapshot)
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def removal_score_tooltip(feature, snapshot)
|
|
68
|
+
return "Removal score is unavailable until usage has been analyzed." unless feature && snapshot
|
|
69
|
+
|
|
70
|
+
calculator = CodeSunset::ScoreCalculator.new(feature: feature, snapshot: snapshot)
|
|
71
|
+
breakdown = calculator.breakdown
|
|
72
|
+
|
|
73
|
+
[
|
|
74
|
+
"Removal score favors features that look safer to remove.",
|
|
75
|
+
"Recency: #{format('%.1f', breakdown[:recency][:score])}/#{format('%.0f', breakdown[:recency][:max])} from #{breakdown[:recency][:detail]}",
|
|
76
|
+
"Volume: #{format('%.1f', breakdown[:volume][:score])}/#{format('%.0f', breakdown[:volume][:max])} from #{breakdown[:volume][:detail]}",
|
|
77
|
+
"Org breadth: #{format('%.1f', breakdown[:orgs][:score])}/#{format('%.0f', breakdown[:orgs][:max])} from #{breakdown[:orgs][:detail]}",
|
|
78
|
+
"Paid usage: #{format('%.1f', breakdown[:paid_usage][:score])}/#{format('%.0f', breakdown[:paid_usage][:max])} from #{breakdown[:paid_usage][:detail]}",
|
|
79
|
+
"Total: #{format('%.1f', calculator.score)}/100"
|
|
80
|
+
].join("\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def usage_chart(points)
|
|
84
|
+
return content_tag(:p, "No usage data yet.", class: "cs-subtle") if points.blank?
|
|
85
|
+
|
|
86
|
+
labels = points.keys.map { |day| day.strftime("%b %-d") }
|
|
87
|
+
values = points.values.map(&:to_i)
|
|
88
|
+
|
|
89
|
+
content_tag(:div, class: "cs-chart-shell") do
|
|
90
|
+
content_tag(
|
|
91
|
+
:canvas,
|
|
92
|
+
nil,
|
|
93
|
+
class: "cs-chart",
|
|
94
|
+
role: "img",
|
|
95
|
+
aria: { label: "Feature usage trend chart" },
|
|
96
|
+
data: {
|
|
97
|
+
code_sunset_usage_chart: true,
|
|
98
|
+
labels: labels.to_json,
|
|
99
|
+
values: values.to_json
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def pagination_link_for(feature, label, page:, current:, filters: {}, rel: nil)
|
|
106
|
+
classes = ["cs-pagination__link"]
|
|
107
|
+
classes << "is-active" if current
|
|
108
|
+
|
|
109
|
+
link_to label, feature_path(feature.key, filters.merge(page: page)), class: classes.join(" "), rel: rel
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def removal_prompt_path_for(feature)
|
|
113
|
+
feature_path(feature.key, filters.merge(generate_removal_prompt: true))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def removal_prompt_reset_path_for(feature)
|
|
117
|
+
feature_path(feature.key, filters.except(:page))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def filter_query_values(overrides = {})
|
|
121
|
+
filters.except(:page).merge(overrides).reject { |_key, value| value.blank? }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def filter_path_for(path, values = filter_query_values)
|
|
125
|
+
query_values = values.reject { |_key, value| value.blank? }
|
|
126
|
+
return path if query_values.empty?
|
|
127
|
+
|
|
128
|
+
"#{path}?#{query_values.to_query}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def active_filter_chips_for(path)
|
|
132
|
+
filters.except(:page).each_with_object([]) do |(key, value), chips|
|
|
133
|
+
next if value.blank?
|
|
134
|
+
|
|
135
|
+
chips << {
|
|
136
|
+
key: key,
|
|
137
|
+
label: filter_chip_label(key),
|
|
138
|
+
value: filter_chip_value(key, value),
|
|
139
|
+
clear_path: filter_path_for(path, filters.except(:page, key))
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def render_filter_bar(form_url:, reset_url:, title: "Filters", copy: nil)
|
|
145
|
+
render(
|
|
146
|
+
"code_sunset/shared/filter_bar",
|
|
147
|
+
form_url: form_url,
|
|
148
|
+
reset_url: reset_url,
|
|
149
|
+
title: title,
|
|
150
|
+
copy: copy,
|
|
151
|
+
filters_applied: filters_applied?,
|
|
152
|
+
active_chips: active_filter_chips_for(reset_url)
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def advanced_filters_open?
|
|
157
|
+
filters.values_at(:plan, :org_id, :user_id, :paid_only, :exclude_internal, *configured_custom_filter_keys).any?(&:present?)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def configured_custom_filters
|
|
161
|
+
CodeSunset.configuration.custom_filters
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def configured_custom_filter_keys
|
|
165
|
+
configured_custom_filters.keys
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def custom_filter_input_tag(key, definition)
|
|
169
|
+
current_value = current_filter_value(key)
|
|
170
|
+
|
|
171
|
+
case definition[:type].to_sym
|
|
172
|
+
when :boolean
|
|
173
|
+
select_tag key, options_for_select([["Any", nil], ["Yes", true], ["No", false]], current_value), include_blank: false
|
|
174
|
+
when :select
|
|
175
|
+
select_tag key, options_for_select([["Any", nil]] + Array(definition[:options]), current_value), include_blank: false
|
|
176
|
+
else
|
|
177
|
+
text_field_tag key, current_value, placeholder: definition[:placeholder]
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def filter_chip_label(key)
|
|
184
|
+
configured = configured_custom_filters[key.to_sym]
|
|
185
|
+
return configured[:label] if configured
|
|
186
|
+
|
|
187
|
+
{
|
|
188
|
+
app_env: "Environment",
|
|
189
|
+
plan: "Plan",
|
|
190
|
+
org_id: "Org",
|
|
191
|
+
user_id: "User",
|
|
192
|
+
from: "From",
|
|
193
|
+
to: "To",
|
|
194
|
+
paid_only: "Paid",
|
|
195
|
+
exclude_internal: "Internal"
|
|
196
|
+
}.fetch(key.to_sym, key.to_s.humanize)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def filter_chip_value(key, value)
|
|
200
|
+
configured = configured_custom_filters[key.to_sym]
|
|
201
|
+
if configured
|
|
202
|
+
return truthy_filter_label(value) if configured[:type].to_sym == :boolean
|
|
203
|
+
|
|
204
|
+
if configured[:type].to_sym == :select && configured[:options].present?
|
|
205
|
+
matched = configured[:options].find { |label, option_value| option_value.to_s == value.to_s }
|
|
206
|
+
return matched.first if matched
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
return value
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
case key.to_sym
|
|
213
|
+
when :paid_only
|
|
214
|
+
truthy_filter_label(value)
|
|
215
|
+
when :exclude_internal
|
|
216
|
+
truthy_filter_label(value, truthy: "Excluded", falsey: "Included")
|
|
217
|
+
else
|
|
218
|
+
value
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def truthy_filter_label(value, truthy: "Yes", falsey: "No")
|
|
223
|
+
ActiveModel::Type::Boolean.new.cast(value) ? truthy : falsey
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def sidebar_icon(name)
|
|
227
|
+
path =
|
|
228
|
+
case name.to_sym
|
|
229
|
+
when :dashboard
|
|
230
|
+
"M3.75 4.5h6.75v6.75H3.75zm9.75 0h6.75v4.5H13.5zm0 7.5h6.75v8.25H13.5zm-9.75 2.25h6.75V20.25H3.75z"
|
|
231
|
+
when :features
|
|
232
|
+
"M4.5 6.75A2.25 2.25 0 0 1 6.75 4.5h10.5a2.25 2.25 0 0 1 2.25 2.25v10.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 17.25zm3 2.25h9m-9 3.75h9m-9 3.75h5.25"
|
|
233
|
+
when :removal_queue
|
|
234
|
+
"M9 3.75h6m-7.5 4.5h9m-10.5 4.5h12m-10.5 4.5h9"
|
|
235
|
+
else
|
|
236
|
+
"M4.5 12h15"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
content_tag(:svg, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "1.75", "stroke-linecap": "round", "stroke-linejoin": "round", aria: { hidden: true }) do
|
|
240
|
+
content_tag(:path, nil, d: path)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module CodeSunset
|
|
2
|
+
class AggregateDailyRollupsJob < ApplicationJob
|
|
3
|
+
queue_as :default
|
|
4
|
+
|
|
5
|
+
def perform(start_date: nil, end_date: nil)
|
|
6
|
+
range = normalize_range(start_date, end_date)
|
|
7
|
+
relation = CodeSunset::Event.where(occurred_at: range)
|
|
8
|
+
|
|
9
|
+
rows = relation
|
|
10
|
+
.group(:feature_key, :app_env, Arel.sql("DATE(occurred_at)"))
|
|
11
|
+
.pluck(
|
|
12
|
+
:feature_key,
|
|
13
|
+
:app_env,
|
|
14
|
+
Arel.sql("DATE(occurred_at)"),
|
|
15
|
+
Arel.sql("COUNT(*)"),
|
|
16
|
+
Arel.sql("COUNT(DISTINCT #{CodeSunset::Event.user_identifier_expression})"),
|
|
17
|
+
Arel.sql("COUNT(DISTINCT #{CodeSunset::Event.org_identifier_expression})"),
|
|
18
|
+
Arel.sql("MAX(occurred_at)")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
upserts = rows.map do |feature_key, app_env, day, hits_count, unique_users_count, unique_orgs_count, last_seen_at|
|
|
22
|
+
{
|
|
23
|
+
feature_key: feature_key,
|
|
24
|
+
app_env: app_env,
|
|
25
|
+
day: day,
|
|
26
|
+
hits_count: hits_count,
|
|
27
|
+
unique_users_count: unique_users_count,
|
|
28
|
+
unique_orgs_count: unique_orgs_count,
|
|
29
|
+
last_seen_at: last_seen_at,
|
|
30
|
+
created_at: Time.current,
|
|
31
|
+
updated_at: Time.current
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
return if upserts.empty?
|
|
36
|
+
|
|
37
|
+
CodeSunset::DailyRollup.upsert_all(upserts, unique_by: %i[feature_key app_env day])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def normalize_range(start_date, end_date)
|
|
43
|
+
from = start_date.present? ? Time.zone.parse(start_date.to_s).beginning_of_day : 30.days.ago.beginning_of_day
|
|
44
|
+
to = end_date.present? ? Time.zone.parse(end_date.to_s).end_of_day : Time.current.end_of_day
|
|
45
|
+
from..to
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module CodeSunset
|
|
2
|
+
class EvaluateAlertsJob < ApplicationJob
|
|
3
|
+
queue_as :default
|
|
4
|
+
|
|
5
|
+
def perform(feature_key = nil)
|
|
6
|
+
if feature_key.present?
|
|
7
|
+
CodeSunset::AlertDispatcher.new(feature_key).dispatch_if_due
|
|
8
|
+
else
|
|
9
|
+
CodeSunset::Feature.find_each do |feature|
|
|
10
|
+
CodeSunset::AlertDispatcher.new(feature.key).dispatch_if_due
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module CodeSunset
|
|
2
|
+
class AlertDelivery < ApplicationRecord
|
|
3
|
+
self.table_name = "code_sunset_alert_deliveries"
|
|
4
|
+
|
|
5
|
+
validates :feature_key, :status, :alert_kind, :delivered_at, presence: true
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def recent_for(feature_key:, status:, alert_kind:, since:)
|
|
9
|
+
where(feature_key: feature_key, status: status, alert_kind: alert_kind)
|
|
10
|
+
.where("delivered_at >= ?", since)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module CodeSunset
|
|
2
|
+
class DailyRollup < ApplicationRecord
|
|
3
|
+
self.table_name = "code_sunset_daily_rollups"
|
|
4
|
+
|
|
5
|
+
belongs_to :feature, primary_key: :key, foreign_key: :feature_key, inverse_of: :daily_rollups, optional: true
|
|
6
|
+
|
|
7
|
+
validates :feature_key, :day, presence: true
|
|
8
|
+
validates :feature_key, uniqueness: { scope: [:app_env, :day] }
|
|
9
|
+
|
|
10
|
+
scope :ordered, -> { order(day: :asc) }
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def apply_filters(filters = {})
|
|
14
|
+
normalized = filters.to_h.symbolize_keys
|
|
15
|
+
scoped = all
|
|
16
|
+
scoped = scoped.where(app_env: normalized[:app_env]) if normalized[:app_env].present?
|
|
17
|
+
scoped = scoped.where(day: parse_date(normalized[:from])..) if normalized[:from].present? && parse_date(normalized[:from])
|
|
18
|
+
scoped = scoped.where(day: ..parse_date(normalized[:to])) if normalized[:to].present? && parse_date(normalized[:to])
|
|
19
|
+
scoped
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def parse_date(value)
|
|
25
|
+
Date.parse(value.to_s)
|
|
26
|
+
rescue Date::Error
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|