onni 0.1.2 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 771c4d19c150aa9e930bc871dfa35b37f3f6b86098015225bc6ca602dac81762
4
- data.tar.gz: eaad1fa8803d9eefaf51be764fe2d132629f1d02650bedc1b0874045e278fcd0
3
+ metadata.gz: fe9cc871e203addc591fead045947fd0049cff52deb2b24929fcf1d36f1f67c6
4
+ data.tar.gz: 3627534cc1ce27a21f64dd8f683930105949d9a354e880cc37d57de1d0f271ee
5
5
  SHA512:
6
- metadata.gz: c1595142b9455666d5ba1249021f0a01434563d9e2a5f4a444cd7b671bc7bd8e971d281ce498e8889ac3682969372df9a35d116636ff5403438140c027811c2f
7
- data.tar.gz: f06671e2e7f3ccb5bbc7381b9a4d4b022b7f2cbd3e68ff11c0a5d063883c7697ef4d3cd0fd65fdcd2461533c6aafd6965aa900d976eda17c2d1b24c3dc754927
6
+ metadata.gz: dfa4d5919712c0338b2fbbe313f0fa5163a737911cbfb672a16c069eb062c1f865a0c0efc29cc8227e7f0cf9d66e44e28f4c62a95a1fa47fb6647520a698164a
7
+ data.tar.gz: dafe6359be806579e86871b93553ad336609f88309aac7ac2d6cebbc5cbaf075b2ea94c5d044583804242bbca17de860372ed34cbcc263629c52705fd1aeab38
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.1.3 (2026-11-12)
4
+
5
+ - ActiveAdmin Features index is Flags: search, expiry filters, pagination, All/Enabled/Disabled scope tabs with counts
6
+ - Flags filters sit in the ActiveAdmin sidebar (`filters-form`)
7
+ - Stats storage is a separate page (`/admin/features/stats`)
8
+ - Optional `/admin/features/:key` show path via `Onni::ActiveAdminRoutes.draw`
9
+ - Feature show uses ActiveAdmin layout wrappers and paginated table chrome
10
+ - Colored Enable (orange) and Disable (red) actions on the Flags index
11
+
3
12
  ## 0.1.2 (2026-11-12)
4
13
 
5
14
  - ActiveAdmin Features index: one Enable or Disable action per flag, relative expiry with full date hint, compact hit metrics, no explainer panels
data/README.md CHANGED
@@ -57,6 +57,18 @@ ActiveAdmin dashboard loads automatically when ActiveAdmin is present. You can a
57
57
  require "onni/active_admin"
58
58
  ```
59
59
 
60
+ After `ActiveAdmin.routes(self)`, draw the feature show path (`/admin/features/:key`):
61
+
62
+ ```ruby
63
+ ActiveAdmin.routes(self)
64
+ Onni::ActiveAdminRoutes.draw(self)
65
+ ```
66
+
67
+ `/admin/features` is the Flags index (search, filters, pagination, All/Enabled/Disabled scopes).
68
+ Stats storage is at `/admin/features/stats`. The older `/admin/features/feature?key=` page action remains available.
69
+
70
+ Host apps that authorize ActiveAdmin page actions by name need a `stats?` rule for the Features page (alongside `enable?`, `disable?`, `feature?`, and `update_feature?` when those are checked).
71
+
60
72
  ## YAML registry
61
73
 
62
74
  Every flag must be listed in `config/features.yml`:
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "onni/admin/flags_query"
4
+
3
5
  ActiveAdmin.register_page "Features" do
4
6
  menu priority: 3, label: proc { I18n.t("onni.menu", default: "Features") },
5
7
  if: proc { instance_exec(&Onni.configuration.admin_authorized) }
@@ -7,6 +9,8 @@ ActiveAdmin.register_page "Features" do
7
9
  controller do
8
10
  before_action :onni_require_admin!
9
11
 
12
+ helper_method :onni_feature_path
13
+
10
14
  private
11
15
 
12
16
  def onni_require_admin!
@@ -14,61 +18,159 @@ ActiveAdmin.register_page "Features" do
14
18
 
15
19
  head :forbidden
16
20
  end
21
+
22
+ def onni_feature_path(key, **options)
23
+ Onni::ActiveAdminRoutes.feature_path_helper(view_context, key, **options)
24
+ end
17
25
  end
18
26
 
19
- content title: "Features" do
20
- flags = Onni::Registry.config.keys.sort.map { |key| Onni::Flag.new(key) }
21
- overview = Onni.stats_overview
22
- retention = overview[:retention]
23
-
24
- h3 "Hit storage"
25
- table class: "onni-hit-storage mb-4" do
26
- tr do
27
- th "Buckets"
28
- th "Oldest hour"
29
- th "Retention"
30
- th "Sample rate"
31
- th "Alerts"
32
- end
33
- tr do
34
- td overview[:bucket_count]
35
- td(overview[:oldest_period_start] || "—")
36
- td(retention ? "#{(retention / 1.day).to_i} days" : "not set")
37
- td overview[:sample_rate]
38
- td do
39
- status_tag("Past retention") if overview[:past_retention]
40
- status_tag("Large table") if overview[:large_table]
41
- text_node "—" unless overview[:past_retention] || overview[:large_table]
27
+ action_item :stats_storage, only: :index do
28
+ link_to "Stats storage", admin_features_stats_path, class: "action-item-button"
29
+ end
30
+
31
+ sidebar "Filters", only: :index do
32
+ query = Onni::Admin::FlagsQuery.new(params)
33
+
34
+ h3 I18n.t("active_admin.sidebars.filters", default: "Filters"), class: "filters-form-title"
35
+
36
+ text_node helpers.form_tag(helpers.admin_features_path, method: :get, class: "filters-form") {
37
+ helpers.safe_join([
38
+ (helpers.hidden_field_tag(:scope, query.scope) unless query.scope == Onni::Admin::FlagsQuery::DEFAULT_SCOPE),
39
+ helpers.tag.div(class: "filters-form-field") {
40
+ helpers.safe_join([
41
+ helpers.label_tag(:q, "Search"),
42
+ helpers.text_field_tag(:q, query.search, id: "q")
43
+ ])
44
+ },
45
+ helpers.tag.div(class: "filters-form-field") {
46
+ helpers.safe_join([
47
+ helpers.label_tag(:expires, "Expires"),
48
+ helpers.select_tag(
49
+ :expires,
50
+ helpers.options_for_select(
51
+ [
52
+ ["Any", ""],
53
+ ["With expiry", "with"],
54
+ ["Without expiry", "without"],
55
+ ["Past expiry", "past"]
56
+ ],
57
+ query.expires_filter
58
+ ),
59
+ id: "expires"
60
+ )
61
+ ])
62
+ },
63
+ helpers.tag.div(class: "filters-form-field") {
64
+ helpers.safe_join([
65
+ helpers.label_tag(:per, "Per page"),
66
+ helpers.select_tag(
67
+ :per,
68
+ helpers.options_for_select(
69
+ Onni::Admin::FlagsQuery::PER_PAGE_OPTIONS,
70
+ query.per_page
71
+ ),
72
+ id: "per"
73
+ )
74
+ ])
75
+ },
76
+ helpers.tag.div(class: "filters-form-buttons") {
77
+ helpers.safe_join([
78
+ helpers.submit_tag(
79
+ I18n.t("active_admin.filters.buttons.filter", default: "Filter"),
80
+ class: "filters-form-submit"
81
+ ),
82
+ helpers.link_to(
83
+ I18n.t("active_admin.filters.buttons.clear", default: "Clear Filters"),
84
+ helpers.admin_features_path,
85
+ class: "filters-form-clear"
86
+ )
87
+ ])
88
+ }
89
+ ].compact)
90
+ }
91
+ end
92
+
93
+ content title: "Flags" do
94
+ query = Onni::Admin::FlagsQuery.new(params)
95
+ collection = query.page
96
+ tab_params = query.filter_params.except(:scope)
97
+ counts = query.scope_counts
98
+
99
+ div class: "scopes mb-4", role: "toolbar" do
100
+ div class: "index-button-group", role: "group" do
101
+ Onni::Admin::FlagsQuery::SCOPES.each do |scope_id|
102
+ label = (scope_id == "all") ? "All" : scope_id.capitalize
103
+ classes = ["index-button"]
104
+ classes << "index-button-selected" if query.scope == scope_id
105
+ a href: helpers.admin_features_path(tab_params.merge(scope: (scope_id == "all") ? nil : scope_id).compact),
106
+ class: classes.join(" ") do
107
+ text_node label
108
+ span counts.fetch(scope_id), class: "scopes-count"
109
+ end
42
110
  end
43
111
  end
44
112
  end
45
113
 
46
- table_for flags do
47
- column("Key") { |flag| link_to flag.name, admin_features_feature_path(key: flag.name) }
48
- column("Description", &:description)
49
- column("Expires") do |flag|
50
- summary = flag.expires_summary
51
- if summary
52
- span summary, title: flag.expires_full, "data-onni-expires": true
53
- else
54
- span "—"
114
+ if collection.respond_to?(:total_pages)
115
+ paginated_collection(
116
+ collection,
117
+ entry_name: "flag",
118
+ download_links: false,
119
+ param_name: :page,
120
+ params: query.filter_params
121
+ ) do
122
+ div class: "index-as-table" do
123
+ table_for collection do
124
+ column("Key") { |flag| link_to flag.name, helpers.onni_feature_path(flag.name) }
125
+ column("Description", &:description)
126
+ column("Expires") do |flag|
127
+ summary = flag.expires_summary
128
+ if summary
129
+ span summary, title: flag.expires_full, "data-onni-expires": true
130
+ else
131
+ span "—"
132
+ end
133
+ end
134
+ column("Enabled globally") { |flag| status_tag(flag.enabled?(record_stats: false) ? "Yes" : "No") }
135
+ column("Gates") do |flag|
136
+ names = flag.gates.map { |gate| gate["key"] }
137
+ names.presence&.join(", ") || "—"
138
+ end
139
+ column("Actions") do |flag|
140
+ if flag.enabled?(record_stats: false)
141
+ button_to "Disable", admin_features_disable_path(key: flag.name),
142
+ method: :post,
143
+ class: "bg-red-500 hover:bg-red-700 text-white hover:text-white action-item-button"
144
+ else
145
+ button_to "Enable", admin_features_enable_path(key: flag.name),
146
+ method: :post,
147
+ class: "bg-orange-500 hover:bg-orange-700 text-white hover:text-white action-item-button"
148
+ end
149
+ end
150
+ end
55
151
  end
56
152
  end
57
- column("Enabled globally") { |flag| status_tag(flag.enabled?(record_stats: false)) }
58
- column("Gates") do |flag|
59
- names = flag.gates.map { |gate| gate["key"] }
60
- names.presence&.join(", ") || "—"
61
- end
62
- column("Actions") do |flag|
63
- if flag.enabled?(record_stats: false)
64
- button_to "Disable", admin_features_disable_path(key: flag.name), method: :post
65
- else
66
- button_to "Enable", admin_features_enable_path(key: flag.name), method: :post
153
+ else
154
+ div class: "paginated-collection" do
155
+ div class: "paginated-collection-contents" do
156
+ div class: "index-as-table" do
157
+ table_for collection do
158
+ column("Key") { |flag| link_to flag.name, helpers.onni_feature_path(flag.name) }
159
+ column("Description", &:description)
160
+ column("Enabled globally") { |flag| status_tag(flag.enabled?(record_stats: false) ? "Yes" : "No") }
161
+ end
162
+ end
67
163
  end
68
164
  end
69
165
  end
70
166
  end
71
167
 
168
+ page_action :stats, method: :get do
169
+ @overview = Onni.stats_overview
170
+ @page_title = "Stats storage"
171
+ render "admin/features/stats", layout: "active_admin"
172
+ end
173
+
72
174
  page_action :enable, method: :post do
73
175
  Onni.enable(params.require(:key))
74
176
  redirect_to admin_features_path, notice: "Enabled #{params[:key]}"
@@ -85,23 +187,36 @@ ActiveAdmin.register_page "Features" do
85
187
 
86
188
  @flag = Onni::Flag.new(params.require(:key))
87
189
  @hit_chart = Onni::HitChart.new(@flag.name, range: range)
190
+ @page_title = @flag.name
88
191
  render "admin/features/show", layout: "active_admin"
89
192
  end
90
193
 
91
194
  page_action :update_feature, method: :post do
92
195
  key = params.require(:key)
93
- percentage = params[:percentage].presence
196
+ feature_params = params[:feature].presence || params
197
+ percentage = feature_params[:percentage].presence
94
198
  Onni.enable_percentage_of_actors(key, percentage.to_i) if percentage
95
- if params[:variants].present?
96
- variants = params[:variants].to_unsafe_h.transform_values(&:to_i).reject { |_name, weight| weight <= 0 }
199
+ if feature_params[:control].present? || feature_params[:treatment].present? || params[:variants].present?
200
+ variants =
201
+ if params[:variants].present?
202
+ params[:variants].to_unsafe_h.transform_values(&:to_i)
203
+ else
204
+ {
205
+ "control" => feature_params[:control].to_i,
206
+ "treatment" => feature_params[:treatment].to_i
207
+ }
208
+ end
209
+ variants = variants.reject { |_name, weight| weight.to_i <= 0 }
97
210
  Onni.set_variants(key, variants) if variants.any?
98
211
  end
99
- case params[:group_action]
212
+ group_action = feature_params[:group_action]
213
+ group_name = feature_params[:group_name]
214
+ case group_action
100
215
  when "enable"
101
- Onni.enable_group(key, params.require(:group_name))
216
+ Onni.enable_group(key, group_name) if group_name.present?
102
217
  when "disable"
103
- Onni.disable_group(key, params.require(:group_name))
218
+ Onni.disable_group(key, group_name) if group_name.present?
104
219
  end
105
- redirect_to admin_features_feature_path(key: key), notice: "Updated #{key}"
220
+ redirect_to onni_feature_path(key), notice: "Updated #{key}"
106
221
  end
107
222
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ class HistoryForm
5
+ include ActiveModel::Model
6
+
7
+ attr_accessor :range
8
+
9
+ def self.model_name
10
+ ActiveModel::Name.new(self, nil, "History")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ class RolloutForm
5
+ include ActiveModel::Model
6
+
7
+ attr_accessor :percentage, :group_name, :group_action, :control, :treatment
8
+
9
+ def self.model_name
10
+ ActiveModel::Name.new(self, nil, "Feature")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,71 @@
1
+ feature_path ||= helpers.onni_feature_path(flag.name)
2
+ range_form = Onni::HistoryForm.new(range: chart.range)
3
+
4
+ div do
5
+ h2 "Hits", class: "text-lg font-bold text-gray-700 dark:text-gray-200 mb-4", id: "onni-hit-history"
6
+
7
+ active_admin_form_for(
8
+ range_form,
9
+ url: feature_path,
10
+ method: :get
11
+ ) do |f|
12
+ f.inputs do
13
+ f.input :range,
14
+ as: :select,
15
+ label: "History",
16
+ collection: [["Last 24 hours", "24h"], ["Last 7 days", "7d"], ["Last 30 days", "30d"]],
17
+ include_blank: false,
18
+ input_html: {name: "range", id: "range"}
19
+ end
20
+ f.actions do
21
+ f.action :submit, label: "Show"
22
+ end
23
+ end
24
+
25
+ totals = [
26
+ {
27
+ "enabled" => helpers.number_with_delimiter(chart.enabled_hits),
28
+ "disabled" => helpers.number_with_delimiter(chart.disabled_hits),
29
+ "share" => chart.enabled_percentage ? helpers.number_to_percentage(chart.enabled_percentage, precision: 1) : "No data"
30
+ }
31
+ ]
32
+ div class: "paginated-collection mb-4" do
33
+ div class: "paginated-collection-contents" do
34
+ div class: "index-as-table" do
35
+ table_for totals do
36
+ column("Enabled") { |row| span row.fetch("enabled"), "data-onni-enabled-hits": true }
37
+ column("Disabled") { |row| span row.fetch("disabled"), "data-onni-disabled-hits": true }
38
+ column("Enabled share") { |row| span row.fetch("share"), "data-onni-enabled-percentage": true }
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ if chart.enabled_percentage.nil?
45
+ para role: "status", class: "my-4" do
46
+ text_node "No recorded evaluations in this window."
47
+ end
48
+ else
49
+ text_node helpers.render(partial: "admin/features/chart", locals: {chart: chart})
50
+ end
51
+
52
+ details class: "mt-4" do
53
+ summary "Counts by #{chart.hourly? ? "hour" : "day"} (UTC)"
54
+ div class: "paginated-collection mt-4" do
55
+ div class: "paginated-collection-contents" do
56
+ div class: "index-as-table" do
57
+ table_for chart.rows do
58
+ column(chart.hourly? ? "Hour" : "Day") do |row|
59
+ helpers.l(
60
+ chart.hourly? ? row.fetch("period_start") : row.fetch("period_start").to_date,
61
+ format: :short
62
+ )
63
+ end
64
+ column("Enabled") { |row| helpers.number_with_delimiter(row.fetch("enabled_hits")) }
65
+ column("Disabled") { |row| helpers.number_with_delimiter(row.fetch("disabled_hits")) }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,110 @@
1
+ flag = assigns.fetch(:flag)
2
+ chart = assigns.fetch(:hit_chart)
3
+ variants = flag.variants
4
+ enabled = flag.enabled?(record_stats: false)
5
+ feature_path = helpers.onni_feature_path(flag.name)
6
+ percentage = flag.gates.find { |gate| gate["key"] == "percentage_of_actors" }&.fetch("value", nil)
7
+ rollout_form = Onni::RolloutForm.new(
8
+ percentage: percentage,
9
+ group_name: nil,
10
+ group_action: nil,
11
+ control: variants["control"],
12
+ treatment: variants["treatment"]
13
+ )
14
+
15
+ div class: "main-content-container" do
16
+ div class: "space-y-10" do
17
+ if flag.description.present?
18
+ para do
19
+ text_node flag.description
20
+ end
21
+ end
22
+
23
+ div do
24
+ h2 "Overview", class: "text-lg font-bold text-gray-700 dark:text-gray-200 mb-4"
25
+ attributes_table_for(flag) do
26
+ row("Status") { status_tag(enabled ? "Enabled" : "Disabled") }
27
+ row("Expires") do
28
+ if flag.expires_summary
29
+ span flag.expires_summary, title: flag.expires_full, "data-onni-expires": true
30
+ else
31
+ span "—"
32
+ end
33
+ end
34
+ row("Sample rate") do
35
+ if !Onni.configuration.stats_enabled
36
+ text_node "off"
37
+ elsif Array(Onni.configuration.stats_ignore).any? { |key| key.to_s == flag.name }
38
+ text_node "ignored"
39
+ else
40
+ text_node Onni.configuration.stats_sample_rate
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ div do
47
+ h2 "Gates", class: "text-lg font-bold text-gray-700 dark:text-gray-200 mb-4"
48
+ if flag.gates.empty?
49
+ para { text_node "None" }
50
+ else
51
+ div class: "paginated-collection" do
52
+ div class: "paginated-collection-contents" do
53
+ div class: "index-as-table" do
54
+ table_for flag.gates do
55
+ column("Key") { |gate| code gate["key"] }
56
+ column("Value") { |gate| code gate["value"] }
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ div do
65
+ h2 "Rollout", class: "text-lg font-bold text-gray-700 dark:text-gray-200 mb-4"
66
+ active_admin_form_for(
67
+ rollout_form,
68
+ url: helpers.admin_features_update_feature_path(key: flag.name),
69
+ method: :post
70
+ ) do |f|
71
+ f.inputs do
72
+ f.input :percentage,
73
+ as: :number,
74
+ label: "Percentage of actors (0-100)",
75
+ input_html: {min: 0, max: 100, name: "percentage", id: "percentage"}
76
+ f.input :group_name,
77
+ as: :select,
78
+ collection: Onni::Group.names,
79
+ include_blank: true,
80
+ input_html: {name: "group_name", id: "group_name"}
81
+ f.input :group_action,
82
+ as: :select,
83
+ collection: [%w[Enable enable], %w[Disable disable]],
84
+ include_blank: "Action",
85
+ input_html: {name: "group_action", id: "group_action"}
86
+ f.input :control,
87
+ as: :number,
88
+ label: "Variant control",
89
+ input_html: {min: 0, max: 100, name: "variants[control]", id: "variant_control"}
90
+ f.input :treatment,
91
+ as: :number,
92
+ label: "Variant treatment",
93
+ input_html: {min: 0, max: 100, name: "variants[treatment]", id: "variant_treatment"}
94
+ end
95
+ f.actions do
96
+ f.action :submit, label: "Save"
97
+ end
98
+ end
99
+ end
100
+
101
+ text_node helpers.render(
102
+ partial: "admin/features/stats",
103
+ locals: {chart: chart, flag: flag, feature_path: feature_path}
104
+ )
105
+
106
+ para do
107
+ text_node helpers.link_to("Back to features", helpers.admin_features_path, class: "action-item-button")
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,49 @@
1
+ overview = assigns.fetch(:overview)
2
+ retention = overview[:retention]
3
+ record = Onni::Flag.new("stats_storage")
4
+
5
+ div class: "main-content-container" do
6
+ div class: "space-y-10" do
7
+ para class: "text-gray-700 dark:text-gray-200" do
8
+ text_node "Storage used for feature evaluation hit stats. Retention and sampling come from Onni configuration."
9
+ end
10
+
11
+ div do
12
+ h2 "Overview", class: "text-lg font-bold text-gray-700 dark:text-gray-200 mb-4"
13
+ attributes_table_for(record) do
14
+ row("Buckets") { overview[:bucket_count] }
15
+ row("Oldest hour") { overview[:oldest_period_start] || "—" }
16
+ row("Retention") { retention ? "#{(retention / 1.day).to_i} days" : "not set" }
17
+ row("Sample rate") { overview[:sample_rate] }
18
+ row("Scale sampled hits") { overview[:scale_sampled_hits] ? "yes" : "no" }
19
+ row("Past retention") { status_tag(overview[:past_retention] ? "Yes" : "No") }
20
+ row("Large table") { status_tag(overview[:large_table] ? "Yes" : "No") }
21
+ end
22
+ end
23
+
24
+ div do
25
+ h2 "Alerts", class: "text-lg font-bold text-gray-700 dark:text-gray-200 mb-4"
26
+ if overview[:past_retention] || overview[:large_table]
27
+ div class: "paginated-collection" do
28
+ div class: "paginated-collection-contents" do
29
+ div class: "index-as-table" do
30
+ table_for [
31
+ ({"alert" => "Past retention", "detail" => "Oldest bucket is older than configured retention"} if overview[:past_retention]),
32
+ ({"alert" => "Large table", "detail" => "Bucket count is at or above #{Onni::Stats::Overview::ROW_WARNING}"} if overview[:large_table])
33
+ ].compact do
34
+ column("Alert") { |row| row.fetch("alert") }
35
+ column("Detail") { |row| row.fetch("detail") }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ else
41
+ para { text_node "No storage alerts." }
42
+ end
43
+ end
44
+
45
+ para do
46
+ text_node helpers.link_to("Back to flags", helpers.admin_features_path, class: "action-item-button")
47
+ end
48
+ end
49
+ end
@@ -7,6 +7,7 @@
7
7
  # require "onni/active_admin"
8
8
  #
9
9
  require "onni"
10
+ require "onni/active_admin_routes"
10
11
 
11
12
  raise LoadError, "ActiveAdmin is required for onni/active_admin" unless defined?(ActiveAdmin)
12
13
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ # Draw after `ActiveAdmin.routes(self)` so `/admin/features/:key` reaches the
5
+ # Features page show action. Reserved page_action names stay on their AA paths.
6
+ module ActiveAdminRoutes
7
+ RESERVED_KEYS = %w[enable disable feature update_feature stats].freeze
8
+ KEY_PATTERN = /[A-Za-z0-9_.:-]+/
9
+
10
+ module_function
11
+
12
+ def draw(router)
13
+ router.instance_eval do
14
+ get "/admin/features/:key",
15
+ to: "admin/features#feature",
16
+ as: :admin_feature,
17
+ constraints: ->(req) {
18
+ key = req.path_parameters[:key].to_s
19
+ Onni::ActiveAdminRoutes::KEY_PATTERN.match?(key) &&
20
+ !Onni::ActiveAdminRoutes::RESERVED_KEYS.include?(key)
21
+ }
22
+ end
23
+ end
24
+
25
+ def feature_path_helper(view, key, **options)
26
+ if view.respond_to?(:admin_feature_path)
27
+ view.admin_feature_path(key, **options)
28
+ else
29
+ view.admin_features_feature_path(**options.merge(key: key))
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Onni
4
+ module Admin
5
+ # Filters and pages registry flags for the ActiveAdmin Features index.
6
+ class FlagsQuery
7
+ DEFAULT_PER_PAGE = 25
8
+ PER_PAGE_OPTIONS = [10, 25, 50, 100].freeze
9
+ SCOPES = %w[all enabled disabled].freeze
10
+ DEFAULT_SCOPE = "all"
11
+
12
+ def initialize(params = {})
13
+ source = params.presence || {}
14
+ @params = {
15
+ q: source[:q],
16
+ scope: source[:scope],
17
+ expires: source[:expires],
18
+ per: source[:per],
19
+ page: source[:page]
20
+ }.with_indifferent_access
21
+ end
22
+
23
+ def flags
24
+ @flags ||= filter_scope(pre_scope_flags)
25
+ end
26
+
27
+ def page
28
+ @page ||= begin
29
+ scope = flags
30
+ if defined?(Kaminari)
31
+ Kaminari.paginate_array(scope).page(@params[:page]).per(per_page)
32
+ else
33
+ scope
34
+ end
35
+ end
36
+ end
37
+
38
+ def search
39
+ @params[:q].to_s.strip
40
+ end
41
+
42
+ def scope
43
+ value = @params[:scope].to_s
44
+ SCOPES.include?(value) ? value : DEFAULT_SCOPE
45
+ end
46
+
47
+ def expires_filter
48
+ @params[:expires].to_s
49
+ end
50
+
51
+ def per_page
52
+ value = @params[:per].to_i
53
+ PER_PAGE_OPTIONS.include?(value) ? value : DEFAULT_PER_PAGE
54
+ end
55
+
56
+ def filter_params
57
+ {
58
+ q: search.presence,
59
+ scope: (scope == DEFAULT_SCOPE) ? nil : scope,
60
+ expires: expires_filter.presence,
61
+ per: per_page
62
+ }.compact
63
+ end
64
+
65
+ def scope_counts
66
+ base = pre_scope_flags
67
+ enabled = base.count { |flag| flag.enabled?(record_stats: false) }
68
+ {
69
+ "all" => base.size,
70
+ "enabled" => enabled,
71
+ "disabled" => base.size - enabled
72
+ }
73
+ end
74
+
75
+ private
76
+
77
+ def all_flags
78
+ @all_flags ||= Onni::Registry.config.keys.sort.map { |key| Onni::Flag.new(key) }
79
+ end
80
+
81
+ def pre_scope_flags
82
+ @pre_scope_flags ||= filter_expires(filter_search(all_flags))
83
+ end
84
+
85
+ def filter_search(list)
86
+ query = search.downcase
87
+ return list if query.blank?
88
+
89
+ list.select do |flag|
90
+ flag.name.downcase.include?(query) || flag.description.to_s.downcase.include?(query)
91
+ end
92
+ end
93
+
94
+ def filter_scope(list)
95
+ case scope
96
+ when "enabled"
97
+ list.select { |flag| flag.enabled?(record_stats: false) }
98
+ when "disabled"
99
+ list.reject { |flag| flag.enabled?(record_stats: false) }
100
+ else
101
+ list
102
+ end
103
+ end
104
+
105
+ def filter_expires(list)
106
+ case expires_filter
107
+ when "with"
108
+ list.select { |flag| flag.expires_on }
109
+ when "without"
110
+ list.reject { |flag| flag.expires_on }
111
+ when "past"
112
+ today = Time.now.utc.to_date
113
+ list.select { |flag| flag.expires_on && flag.expires_on < today }
114
+ else
115
+ list
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
data/lib/onni/engine.rb CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  return unless defined?(Rails::Engine)
4
4
 
5
+ require "onni/active_admin_routes"
6
+ require "onni/admin/flags_query"
7
+
5
8
  module Onni
6
9
  class Engine < ::Rails::Engine
7
10
  engine_name "onni"
@@ -23,6 +26,8 @@ module Onni
23
26
  initializer "onni.load_active_admin" do
24
27
  next unless defined?(ActiveAdmin)
25
28
 
29
+ require "onni/active_admin_routes"
30
+
26
31
  path = root.join("app/admin").to_s
27
32
  ActiveSupport.on_load(:after_initialize) do
28
33
  ActiveAdmin.application.load_paths << path unless ActiveAdmin.application.load_paths.include?(path)
data/lib/onni/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Onni
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: onni
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -303,11 +303,14 @@ files:
303
303
  - app/admin/features.rb
304
304
  - app/models/onni/feature.rb
305
305
  - app/models/onni/gate.rb
306
+ - app/models/onni/history_form.rb
306
307
  - app/models/onni/hit_chart.rb
308
+ - app/models/onni/rollout_form.rb
307
309
  - app/models/onni/stat_bucket.rb
308
310
  - app/views/admin/features/_chart.html.erb
309
- - app/views/admin/features/_stats.html.erb
310
- - app/views/admin/features/show.html.erb
311
+ - app/views/admin/features/_stats.html.arb
312
+ - app/views/admin/features/show.html.arb
313
+ - app/views/admin/features/stats.html.arb
311
314
  - config/locales/en.yml
312
315
  - config/polyrun_coverage.yml
313
316
  - lib/generators/onni/install_generator.rb
@@ -319,9 +322,11 @@ files:
319
322
  - lib/generators/onni/templates/purge_stats_job.rb.tt
320
323
  - lib/onni.rb
321
324
  - lib/onni/active_admin.rb
325
+ - lib/onni/active_admin_routes.rb
322
326
  - lib/onni/actor.rb
323
327
  - lib/onni/adapters/active_record.rb
324
328
  - lib/onni/adapters/memory.rb
329
+ - lib/onni/admin/flags_query.rb
325
330
  - lib/onni/conditions.rb
326
331
  - lib/onni/configuration.rb
327
332
  - lib/onni/engine.rb
@@ -1,54 +0,0 @@
1
- <section aria-labelledby="onni-hit-history" class="mb-6">
2
- <h3 id="onni-hit-history" class="font-semibold mb-2">Hits</h3>
3
- <%= form_with url: admin_features_feature_path, method: :get, local: true, class: "mb-4" do %>
4
- <%= hidden_field_tag :key, flag.name %>
5
- <%= label_tag :range, "History" %>
6
- <%= select_tag :range, options_for_select([["Last 24 hours", "24h"], ["Last 7 days", "7d"], ["Last 30 days", "30d"]], chart.range) %>
7
- <%= submit_tag "Show", name: nil, class: "button" %>
8
- <% end %>
9
-
10
- <table class="mb-4 onni-hit-totals">
11
- <thead>
12
- <tr>
13
- <th scope="col">Enabled</th>
14
- <th scope="col">Disabled</th>
15
- <th scope="col">Enabled share</th>
16
- </tr>
17
- </thead>
18
- <tbody>
19
- <tr>
20
- <td data-onni-enabled-hits><%= number_with_delimiter(chart.enabled_hits) %></td>
21
- <td data-onni-disabled-hits><%= number_with_delimiter(chart.disabled_hits) %></td>
22
- <td data-onni-enabled-percentage><%= chart.enabled_percentage ? number_to_percentage(chart.enabled_percentage, precision: 1) : "No data" %></td>
23
- </tr>
24
- </tbody>
25
- </table>
26
-
27
- <% if chart.enabled_percentage.nil? %>
28
- <p role="status" class="my-4">No recorded evaluations in this window.</p>
29
- <% else %>
30
- <%= render partial: "admin/features/chart", locals: { chart: chart } %>
31
- <% end %>
32
-
33
- <details class="mt-4">
34
- <summary>Counts by <%= chart.hourly? ? "hour" : "day" %> (UTC)</summary>
35
- <table>
36
- <thead>
37
- <tr>
38
- <th scope="col"><%= chart.hourly? ? "Hour" : "Day" %></th>
39
- <th scope="col">Enabled</th>
40
- <th scope="col">Disabled</th>
41
- </tr>
42
- </thead>
43
- <tbody>
44
- <% chart.rows.each do |row| %>
45
- <tr>
46
- <th scope="row"><%= l(chart.hourly? ? row.fetch("period_start") : row.fetch("period_start").to_date, format: :short) %></th>
47
- <td><%= number_with_delimiter(row.fetch("enabled_hits")) %></td>
48
- <td><%= number_with_delimiter(row.fetch("disabled_hits")) %></td>
49
- </tr>
50
- <% end %>
51
- </tbody>
52
- </table>
53
- </details>
54
- </section>
@@ -1,88 +0,0 @@
1
- <% flag = @flag %>
2
- <% variants = flag.variants %>
3
- <% enabled = flag.enabled?(record_stats: false) %>
4
- <div>
5
- <h2 class="text-xl font-semibold mb-2"><%= flag.name %></h2>
6
- <% if flag.description.present? %>
7
- <p class="mb-3"><%= flag.description %></p>
8
- <% end %>
9
-
10
- <table class="mb-4">
11
- <tbody>
12
- <tr>
13
- <th scope="row">Status</th>
14
- <td><%= enabled ? "Enabled" : "Disabled" %></td>
15
- <th scope="row">Expires</th>
16
- <td>
17
- <% if flag.expires_summary %>
18
- <span title="<%= flag.expires_full %>" data-onni-expires><%= flag.expires_summary %></span>
19
- <% else %>
20
- —
21
- <% end %>
22
- </td>
23
- <th scope="row">Sample rate</th>
24
- <td>
25
- <% if !Onni.configuration.stats_enabled %>
26
- off
27
- <% elsif Array(Onni.configuration.stats_ignore).any? { |key| key.to_s == flag.name } %>
28
- ignored
29
- <% else %>
30
- <%= Onni.configuration.stats_sample_rate %>
31
- <% end %>
32
- </td>
33
- </tr>
34
- </tbody>
35
- </table>
36
-
37
- <h3 class="font-semibold mb-2">Gates</h3>
38
- <% if flag.gates.empty? %>
39
- <p class="mb-4">None</p>
40
- <% else %>
41
- <table class="mb-4">
42
- <thead>
43
- <tr>
44
- <th scope="col">Key</th>
45
- <th scope="col">Value</th>
46
- </tr>
47
- </thead>
48
- <tbody>
49
- <% flag.gates.each do |gate| %>
50
- <tr>
51
- <td><code><%= gate["key"] %></code></td>
52
- <td><code><%= gate["value"] %></code></td>
53
- </tr>
54
- <% end %>
55
- </tbody>
56
- </table>
57
- <% end %>
58
-
59
- <%= form_with url: admin_features_update_feature_path(key: flag.name), method: :post, local: true, class: "mb-6 space-y-3" do %>
60
- <div>
61
- <label>Percentage of actors (0-100)</label>
62
- <%= number_field_tag :percentage, flag.gates.find { |gate| gate["key"] == "percentage_of_actors" }&.fetch("value", nil), min: 0, max: 100 %>
63
- </div>
64
- <div>
65
- <label>Group</label>
66
- <%= select_tag :group_name, options_for_select(Onni::Group.names), include_blank: true %>
67
- <%= select_tag :group_action, options_for_select([%w[Enable enable], %w[Disable disable]]), include_blank: "Action" %>
68
- </div>
69
- <div>
70
- <label>A/B variants (name and weight)</label>
71
- <div class="flex gap-2 items-end">
72
- <div>
73
- <label for="variant_control">control</label>
74
- <%= number_field_tag "variants[control]", variants["control"], min: 0, max: 100, id: "variant_control" %>
75
- </div>
76
- <div>
77
- <label for="variant_treatment">treatment</label>
78
- <%= number_field_tag "variants[treatment]", variants["treatment"], min: 0, max: 100, id: "variant_treatment" %>
79
- </div>
80
- </div>
81
- </div>
82
- <%= submit_tag "Save", class: "button" %>
83
- <% end %>
84
-
85
- <%= render partial: "admin/features/stats", locals: { chart: @hit_chart, flag: flag } %>
86
-
87
- <p class="mt-4"><%= link_to "Back to features", admin_features_path %></p>
88
- </div>