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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +12 -0
- data/app/admin/features.rb +164 -49
- data/app/models/onni/history_form.rb +13 -0
- data/app/models/onni/rollout_form.rb +13 -0
- data/app/views/admin/features/_stats.html.arb +71 -0
- data/app/views/admin/features/show.html.arb +110 -0
- data/app/views/admin/features/stats.html.arb +49 -0
- data/lib/onni/active_admin.rb +1 -0
- data/lib/onni/active_admin_routes.rb +33 -0
- data/lib/onni/admin/flags_query.rb +120 -0
- data/lib/onni/engine.rb +5 -0
- data/lib/onni/version.rb +1 -1
- metadata +8 -3
- data/app/views/admin/features/_stats.html.erb +0 -54
- data/app/views/admin/features/show.html.erb +0 -88
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fe9cc871e203addc591fead045947fd0049cff52deb2b24929fcf1d36f1f67c6
|
|
4
|
+
data.tar.gz: 3627534cc1ce27a21f64dd8f683930105949d9a354e880cc37d57de1d0f271ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`:
|
data/app/admin/features.rb
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
216
|
+
Onni.enable_group(key, group_name) if group_name.present?
|
|
102
217
|
when "disable"
|
|
103
|
-
Onni.disable_group(key,
|
|
218
|
+
Onni.disable_group(key, group_name) if group_name.present?
|
|
104
219
|
end
|
|
105
|
-
redirect_to
|
|
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 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
|
data/lib/onni/active_admin.rb
CHANGED
|
@@ -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
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.
|
|
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.
|
|
310
|
-
- app/views/admin/features/show.html.
|
|
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>
|