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,190 @@
|
|
|
1
|
+
module CodeSunset
|
|
2
|
+
class Event < ApplicationRecord
|
|
3
|
+
self.table_name = "code_sunset_events"
|
|
4
|
+
|
|
5
|
+
belongs_to :feature, primary_key: :key, foreign_key: :feature_key, inverse_of: :events, optional: true
|
|
6
|
+
|
|
7
|
+
validates :feature_key, presence: true
|
|
8
|
+
validates :occurred_at, presence: true
|
|
9
|
+
|
|
10
|
+
before_validation :apply_defaults
|
|
11
|
+
|
|
12
|
+
scope :recent_first, -> { order(occurred_at: :desc) }
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def create_from_payload(payload)
|
|
16
|
+
attrs = sanitize_payload(payload)
|
|
17
|
+
|
|
18
|
+
transaction do
|
|
19
|
+
CodeSunset::Feature.find_or_create_by!(key: attrs[:feature_key])
|
|
20
|
+
create!(attrs)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def apply_filters(filters = {})
|
|
25
|
+
scoped = all
|
|
26
|
+
normalized = filters.to_h.symbolize_keys
|
|
27
|
+
|
|
28
|
+
scoped = scoped.where(app_env: normalized[:app_env]) if normalized[:app_env].present?
|
|
29
|
+
scoped = scoped.where(plan: normalized[:plan]) if normalized[:plan].present?
|
|
30
|
+
if normalized[:org_id].present?
|
|
31
|
+
scoped = apply_identity_filter(scoped, :org_id, :hashed_org_id, normalized[:org_id])
|
|
32
|
+
end
|
|
33
|
+
if normalized[:user_id].present?
|
|
34
|
+
scoped = apply_identity_filter(scoped, :user_id, :hashed_user_id, normalized[:user_id])
|
|
35
|
+
end
|
|
36
|
+
scoped = scoped.where(paid_org: true) if truthy?(normalized[:paid_only])
|
|
37
|
+
scoped = scoped.where.not(internal_org: true) if truthy?(normalized[:exclude_internal])
|
|
38
|
+
scoped = apply_custom_metadata_filters(scoped, normalized)
|
|
39
|
+
|
|
40
|
+
from_time = parse_time(normalized[:from])&.beginning_of_day
|
|
41
|
+
to_time = parse_time(normalized[:to])&.end_of_day
|
|
42
|
+
|
|
43
|
+
scoped = scoped.where(occurred_at: from_time..) if from_time
|
|
44
|
+
scoped = scoped.where(occurred_at: ..to_time) if to_time
|
|
45
|
+
scoped
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def grouped_last_seen(scope)
|
|
49
|
+
scope.group(:feature_key).maximum(:occurred_at)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def grouped_counts(scope)
|
|
53
|
+
scope.group(:feature_key).count
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def grouped_distinct_org_counts(scope)
|
|
57
|
+
scope.group(:feature_key).distinct.count(Arel.sql(org_identifier_expression))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def grouped_distinct_user_counts(scope)
|
|
61
|
+
scope.group(:feature_key).distinct.count(Arel.sql(user_identifier_expression))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def top_request_paths(scope, limit: 10)
|
|
65
|
+
grouped_top(scope.where.not(request_path: [nil, ""]), "request_path", limit: limit)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def top_job_classes(scope, limit: 10)
|
|
69
|
+
grouped_top(scope.where.not(job_class: [nil, ""]), "job_class", limit: limit)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def top_orgs(scope, limit: 10)
|
|
73
|
+
grouped_top(scope, org_identifier_expression, limit: limit, alias_name: "org_identifier")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def top_users(scope, limit: 10)
|
|
77
|
+
grouped_top(scope, user_identifier_expression, limit: limit, alias_name: "user_identifier")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def org_identifier_expression
|
|
81
|
+
"COALESCE(code_sunset_events.hashed_org_id, code_sunset_events.org_id::text)"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def user_identifier_expression
|
|
85
|
+
"COALESCE(code_sunset_events.hashed_user_id, code_sunset_events.user_id::text)"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def sanitize_payload(payload)
|
|
91
|
+
attrs = payload.to_h.symbolize_keys.slice(
|
|
92
|
+
:feature_key,
|
|
93
|
+
:occurred_at,
|
|
94
|
+
:user_id,
|
|
95
|
+
:org_id,
|
|
96
|
+
:request_id,
|
|
97
|
+
:source,
|
|
98
|
+
:request_path,
|
|
99
|
+
:controller,
|
|
100
|
+
:action,
|
|
101
|
+
:job_class,
|
|
102
|
+
:app_env,
|
|
103
|
+
:app_version,
|
|
104
|
+
:metadata,
|
|
105
|
+
:plan,
|
|
106
|
+
:internal_org,
|
|
107
|
+
:paid_org
|
|
108
|
+
)
|
|
109
|
+
attrs[:metadata] = attrs.fetch(:metadata, {}).to_h
|
|
110
|
+
|
|
111
|
+
if CodeSunset.configuration.hash_identity
|
|
112
|
+
apply_identity_hash!(attrs, :user_id, :hashed_user_id)
|
|
113
|
+
apply_identity_hash!(attrs, :org_id, :hashed_org_id)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
attrs
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def apply_identity_hash!(attrs, source_key, target_key)
|
|
120
|
+
return unless CodeSunset.configuration.identity_fields.include?(source_key)
|
|
121
|
+
return if attrs[source_key].blank?
|
|
122
|
+
|
|
123
|
+
attrs[target_key] = CodeSunset::IdentityHash.digest(attrs[source_key])
|
|
124
|
+
attrs[source_key] = nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def grouped_top(scope, expression, limit:, alias_name: "identifier")
|
|
128
|
+
scope
|
|
129
|
+
.select("#{expression} AS #{alias_name}", "COUNT(*) AS hits")
|
|
130
|
+
.where(Arel.sql("#{expression} IS NOT NULL"))
|
|
131
|
+
.group(alias_name)
|
|
132
|
+
.order("hits DESC")
|
|
133
|
+
.limit(limit)
|
|
134
|
+
.map { |row| [row.public_send(alias_name), row.hits.to_i] }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def apply_custom_metadata_filters(scope, filters)
|
|
138
|
+
CodeSunset.configuration.custom_filters.each do |param_key, definition|
|
|
139
|
+
next unless filters[param_key].present?
|
|
140
|
+
|
|
141
|
+
metadata_key = definition[:metadata_key]
|
|
142
|
+
case definition[:type].to_sym
|
|
143
|
+
when :boolean
|
|
144
|
+
scope = scope.where("code_sunset_events.metadata ->> ? = ?", metadata_key, truthy?(filters[param_key]).to_s)
|
|
145
|
+
else
|
|
146
|
+
scope = scope.where("code_sunset_events.metadata ->> ? = ?", metadata_key, filters[param_key].to_s)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
scope
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def apply_identity_filter(scope, raw_key, hashed_key, value)
|
|
154
|
+
if CodeSunset.configuration.hash_identity && CodeSunset.configuration.identity_fields.include?(raw_key)
|
|
155
|
+
scope.where(hashed_key => CodeSunset::IdentityHash.digest(value))
|
|
156
|
+
else
|
|
157
|
+
scope.where(raw_key => value)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_time(value)
|
|
162
|
+
return if value.blank?
|
|
163
|
+
|
|
164
|
+
Time.zone.parse(value.to_s)
|
|
165
|
+
rescue ArgumentError
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def truthy?(value)
|
|
170
|
+
ActiveModel::Type::Boolean.new.cast(value)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def display_org_identifier
|
|
175
|
+
hashed_org_id.presence || org_id&.to_s
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def display_user_identifier
|
|
179
|
+
hashed_user_id.presence || user_id&.to_s
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def apply_defaults
|
|
185
|
+
self.app_env = CodeSunset.configuration.environment if app_env.blank?
|
|
186
|
+
self.metadata = metadata.to_h if metadata.present?
|
|
187
|
+
self.metadata ||= {}
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module CodeSunset
|
|
2
|
+
class Feature < ApplicationRecord
|
|
3
|
+
self.table_name = "code_sunset_features"
|
|
4
|
+
|
|
5
|
+
STATUSES = %w[active cooling_down candidate_for_removal safe_to_remove].freeze
|
|
6
|
+
|
|
7
|
+
has_many :events, primary_key: :key, foreign_key: :feature_key, inverse_of: :feature, dependent: :nullify
|
|
8
|
+
has_many :daily_rollups, primary_key: :key, foreign_key: :feature_key, inverse_of: :feature, dependent: :delete_all
|
|
9
|
+
|
|
10
|
+
validates :key, presence: true, uniqueness: true
|
|
11
|
+
validates :status, inclusion: { in: STATUSES }, allow_blank: true
|
|
12
|
+
|
|
13
|
+
before_validation :apply_defaults
|
|
14
|
+
|
|
15
|
+
scope :ordered, -> { order(:key) }
|
|
16
|
+
|
|
17
|
+
def computed_status(last_seen_at: nil, reference_time: Time.current)
|
|
18
|
+
CodeSunset::StatusEngine.new(feature: self, last_seen_at: last_seen_at, reference_time: reference_time).status
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def removal_score(snapshot = nil)
|
|
22
|
+
snapshot ||= CodeSunset::FeatureQuery.new({}, scope: self.class.where(id: id)).call.first
|
|
23
|
+
CodeSunset::ScoreCalculator.new(feature: self, snapshot: snapshot).score
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def apply_defaults
|
|
29
|
+
self.sunset_after_days = 90 if sunset_after_days.blank?
|
|
30
|
+
self.remove_after_days_unused = 60 if remove_after_days_unused.blank?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<section class="cs-page-header">
|
|
2
|
+
<div>
|
|
3
|
+
<h2 class="cs-page-title">Overview</h2>
|
|
4
|
+
<p class="cs-page-copy">Start with what is active this week, then review what looks ready to retire.</p>
|
|
5
|
+
</div>
|
|
6
|
+
<div class="cs-page-actions cs-page-actions--compact">
|
|
7
|
+
<%= link_to "Features", features_path, class: "cs-inline-link" %>
|
|
8
|
+
<%= link_to "Review Removal Queue", removal_candidates_path, class: "cs-button cs-button--secondary" %>
|
|
9
|
+
</div>
|
|
10
|
+
</section>
|
|
11
|
+
|
|
12
|
+
<section class="cs-grid">
|
|
13
|
+
<article class="cs-stat">
|
|
14
|
+
<p class="cs-stat__label">Tracked Features</p>
|
|
15
|
+
<p class="cs-stat__value"><%= human_count(@summaries.count) %></p>
|
|
16
|
+
<p class="cs-stat__hint">Registered or observed.</p>
|
|
17
|
+
</article>
|
|
18
|
+
<article class="cs-stat">
|
|
19
|
+
<p class="cs-stat__label">Active Features</p>
|
|
20
|
+
<p class="cs-stat__value"><%= human_count(@active_count) %></p>
|
|
21
|
+
<p class="cs-stat__hint">Still seeing traffic.</p>
|
|
22
|
+
</article>
|
|
23
|
+
<article class="cs-stat">
|
|
24
|
+
<p class="cs-stat__label">Removable Features</p>
|
|
25
|
+
<p class="cs-stat__value"><%= human_count(@removable_count) %></p>
|
|
26
|
+
<p class="cs-stat__hint">Candidate or safe to remove.</p>
|
|
27
|
+
</article>
|
|
28
|
+
</section>
|
|
29
|
+
|
|
30
|
+
<section class="cs-overview-layout">
|
|
31
|
+
<article class="cs-panel">
|
|
32
|
+
<div class="cs-panel__heading">
|
|
33
|
+
<div>
|
|
34
|
+
<h2>Active This Week</h2>
|
|
35
|
+
</div>
|
|
36
|
+
<span class="cs-panel__count"><%= human_count(@recent_features.count) %> features</span>
|
|
37
|
+
</div>
|
|
38
|
+
<p class="cs-panel__copy">A quick pulse on the busiest legacy paths before you drill into the full feature inventory.</p>
|
|
39
|
+
<div class="cs-table-wrap">
|
|
40
|
+
<table class="cs-table">
|
|
41
|
+
<thead>
|
|
42
|
+
<tr>
|
|
43
|
+
<th>Feature</th>
|
|
44
|
+
<th>Status</th>
|
|
45
|
+
<th>Hits 7d</th>
|
|
46
|
+
<th>Last Seen</th>
|
|
47
|
+
</tr>
|
|
48
|
+
</thead>
|
|
49
|
+
<tbody>
|
|
50
|
+
<% @recent_features.each do |summary| %>
|
|
51
|
+
<tr>
|
|
52
|
+
<td><%= link_to summary.feature.key, feature_path(summary.feature.key), class: "cs-code" %></td>
|
|
53
|
+
<td><%= status_badge(summary.status) %></td>
|
|
54
|
+
<td><%= human_count(summary.hits_7d) %></td>
|
|
55
|
+
<td><%= format_seen_at(summary.last_seen_at) %></td>
|
|
56
|
+
</tr>
|
|
57
|
+
<% end %>
|
|
58
|
+
<% if @recent_features.empty? %>
|
|
59
|
+
<tr>
|
|
60
|
+
<td colspan="4" class="cs-subtle">No recent feature usage has been recorded yet.</td>
|
|
61
|
+
</tr>
|
|
62
|
+
<% end %>
|
|
63
|
+
</tbody>
|
|
64
|
+
</table>
|
|
65
|
+
</div>
|
|
66
|
+
</article>
|
|
67
|
+
|
|
68
|
+
<div class="cs-stack">
|
|
69
|
+
<article class="cs-panel">
|
|
70
|
+
<div class="cs-panel__heading">
|
|
71
|
+
<div>
|
|
72
|
+
<h2>Ready To Review</h2>
|
|
73
|
+
</div>
|
|
74
|
+
<span class="cs-panel__count"><%= human_count(@safe_to_remove.count) %> items</span>
|
|
75
|
+
</div>
|
|
76
|
+
<p class="cs-panel__copy">Highest-confidence removals based on inactivity, low breadth, and low paid usage.</p>
|
|
77
|
+
|
|
78
|
+
<ul class="cs-queue-list">
|
|
79
|
+
<% @safe_to_remove.each do |summary| %>
|
|
80
|
+
<li class="cs-queue-list__item">
|
|
81
|
+
<div class="cs-queue-list__body">
|
|
82
|
+
<%= link_to summary.feature.key, feature_path(summary.feature.key), class: "cs-code cs-queue-list__title" %>
|
|
83
|
+
<p class="cs-queue-list__meta">Last seen <%= format_seen_at(summary.last_seen_at) %></p>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="cs-queue-list__aside">
|
|
86
|
+
<%= removal_score_badge(summary.feature, summary, label: "Score #{summary.score.round(1)}") %>
|
|
87
|
+
</div>
|
|
88
|
+
</li>
|
|
89
|
+
<% end %>
|
|
90
|
+
<% if @safe_to_remove.empty? %>
|
|
91
|
+
<li class="cs-subtle">Nothing is confidently removable yet.</li>
|
|
92
|
+
<% end %>
|
|
93
|
+
</ul>
|
|
94
|
+
</article>
|
|
95
|
+
|
|
96
|
+
<article class="cs-panel">
|
|
97
|
+
<div class="cs-panel__heading">
|
|
98
|
+
<div>
|
|
99
|
+
<h2>Cooling Down</h2>
|
|
100
|
+
</div>
|
|
101
|
+
<span class="cs-panel__count"><%= human_count(@cooling_down.count) %> items</span>
|
|
102
|
+
</div>
|
|
103
|
+
<p class="cs-panel__copy">Features that still have some recent traffic, but are trending toward the removal queue.</p>
|
|
104
|
+
|
|
105
|
+
<ul class="cs-queue-list">
|
|
106
|
+
<% @cooling_down.each do |summary| %>
|
|
107
|
+
<li class="cs-queue-list__item">
|
|
108
|
+
<div class="cs-queue-list__body">
|
|
109
|
+
<%= link_to summary.feature.key, feature_path(summary.feature.key), class: "cs-code cs-queue-list__title" %>
|
|
110
|
+
<p class="cs-queue-list__meta"><%= human_count(summary.hits_30d) %> hits in the last 30 days</p>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="cs-queue-list__aside">
|
|
113
|
+
<%= removal_score_badge(summary.feature, summary, label: "Score #{summary.score.round(1)}") %>
|
|
114
|
+
</div>
|
|
115
|
+
</li>
|
|
116
|
+
<% end %>
|
|
117
|
+
<% if @cooling_down.empty? %>
|
|
118
|
+
<li class="cs-subtle">No features are currently in the cooldown queue.</li>
|
|
119
|
+
<% end %>
|
|
120
|
+
</ul>
|
|
121
|
+
</article>
|
|
122
|
+
</div>
|
|
123
|
+
</section>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<% active_count = @summaries.count { |summary| summary.status == "active" } %>
|
|
2
|
+
<% removable_count = @summaries.count { |summary| %w[candidate_for_removal safe_to_remove].include?(summary.status) } %>
|
|
3
|
+
|
|
4
|
+
<section class="cs-page-header">
|
|
5
|
+
<div>
|
|
6
|
+
<h2 class="cs-page-title">Features</h2>
|
|
7
|
+
<p class="cs-page-copy">Scan the inventory, then narrow it with filters when you need a closer look.</p>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="cs-page-actions cs-page-actions--compact">
|
|
10
|
+
<% if filters_applied? %>
|
|
11
|
+
<%= link_to "Reset Filters", features_path, class: "cs-inline-link" %>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
</section>
|
|
15
|
+
|
|
16
|
+
<section class="cs-grid">
|
|
17
|
+
<article class="cs-stat">
|
|
18
|
+
<p class="cs-stat__label">Features</p>
|
|
19
|
+
<p class="cs-stat__value"><%= human_count(@summaries.count) %></p>
|
|
20
|
+
<p class="cs-stat__hint">Visible in the current view.</p>
|
|
21
|
+
</article>
|
|
22
|
+
<article class="cs-stat">
|
|
23
|
+
<p class="cs-stat__label">Active</p>
|
|
24
|
+
<p class="cs-stat__value"><%= human_count(active_count) %></p>
|
|
25
|
+
<p class="cs-stat__hint">Still seeing traffic.</p>
|
|
26
|
+
</article>
|
|
27
|
+
<article class="cs-stat">
|
|
28
|
+
<p class="cs-stat__label">Review Soon</p>
|
|
29
|
+
<p class="cs-stat__value"><%= human_count(removable_count) %></p>
|
|
30
|
+
<p class="cs-stat__hint">Candidate or safe to remove.</p>
|
|
31
|
+
</article>
|
|
32
|
+
</section>
|
|
33
|
+
|
|
34
|
+
<%= render_filter_bar(
|
|
35
|
+
form_url: features_path,
|
|
36
|
+
reset_url: features_path,
|
|
37
|
+
title: "Filters",
|
|
38
|
+
copy: "Environment and dates use rollups where possible. Org, user, plan, internal, and custom metadata filters use retained raw events."
|
|
39
|
+
) %>
|
|
40
|
+
|
|
41
|
+
<section class="cs-panel">
|
|
42
|
+
<div class="cs-panel__heading">
|
|
43
|
+
<div>
|
|
44
|
+
<h2>Feature Inventory</h2>
|
|
45
|
+
</div>
|
|
46
|
+
<span class="cs-panel__count"><%= human_count(@summaries.count) %></span>
|
|
47
|
+
</div>
|
|
48
|
+
<p class="cs-panel__copy">Use this table to decide what needs inspection now, then open a feature for the full drill-down.</p>
|
|
49
|
+
|
|
50
|
+
<div class="cs-table-wrap">
|
|
51
|
+
<table class="cs-table cs-table--features">
|
|
52
|
+
<thead>
|
|
53
|
+
<tr>
|
|
54
|
+
<th>Feature</th>
|
|
55
|
+
<th>Status</th>
|
|
56
|
+
<th>Last Seen</th>
|
|
57
|
+
<th>Activity</th>
|
|
58
|
+
<th>Orgs</th>
|
|
59
|
+
<th>Owner</th>
|
|
60
|
+
<th>Score</th>
|
|
61
|
+
</tr>
|
|
62
|
+
</thead>
|
|
63
|
+
<tbody>
|
|
64
|
+
<% @summaries.each do |summary| %>
|
|
65
|
+
<tr>
|
|
66
|
+
<td><%= link_to summary.feature.key, filter_path_for(feature_path(summary.feature.key)), class: "cs-code" %></td>
|
|
67
|
+
<td><%= status_badge(summary.status) %></td>
|
|
68
|
+
<td><%= format_seen_at(summary.last_seen_at) %></td>
|
|
69
|
+
<td>
|
|
70
|
+
<div class="cs-metric-pair">
|
|
71
|
+
<span class="cs-metric-pair__item"><strong><%= human_count(summary.hits_7d) %></strong><span>7d</span></span>
|
|
72
|
+
<span class="cs-metric-pair__item"><strong><%= human_count(summary.hits_30d) %></strong><span>30d</span></span>
|
|
73
|
+
</div>
|
|
74
|
+
</td>
|
|
75
|
+
<td><%= human_count(summary.unique_orgs_count) %></td>
|
|
76
|
+
<td><%= summary.feature.owner.presence || "Unassigned" %></td>
|
|
77
|
+
<td><%= removal_score_badge(summary.feature, summary, label: summary.score.round(1).to_s) %></td>
|
|
78
|
+
</tr>
|
|
79
|
+
<% end %>
|
|
80
|
+
<% if @summaries.empty? %>
|
|
81
|
+
<tr>
|
|
82
|
+
<td colspan="7" class="cs-subtle">No features matched the current filters.</td>
|
|
83
|
+
</tr>
|
|
84
|
+
<% end %>
|
|
85
|
+
</tbody>
|
|
86
|
+
</table>
|
|
87
|
+
</div>
|
|
88
|
+
</section>
|