summoner-engine 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 +205 -0
- data/Rakefile +6 -0
- data/app/assets/images/summoner/favicon.ico +0 -0
- data/app/assets/stylesheets/summoner/application.css +15 -0
- data/app/controllers/summoner/application_controller.rb +7 -0
- data/app/controllers/summoner/features_controller.rb +40 -0
- data/app/controllers/summoner/overrides_controller.rb +64 -0
- data/app/helpers/summoner/application_helper.rb +4 -0
- data/app/jobs/summoner/application_job.rb +4 -0
- data/app/mailers/summoner/application_mailer.rb +6 -0
- data/app/models/summoner/application_record.rb +5 -0
- data/app/models/summoner/feature.rb +61 -0
- data/app/models/summoner/feature_override.rb +22 -0
- data/app/views/layouts/summoner/application.html.erb +139 -0
- data/app/views/summoner/features/edit.html.erb +38 -0
- data/app/views/summoner/features/index.html.erb +55 -0
- data/app/views/summoner/features/show.html.erb +120 -0
- data/app/views/summoner/overrides/edit.html.erb +37 -0
- data/config/routes.rb +7 -0
- data/lib/generators/summoner/install/install_generator.rb +53 -0
- data/lib/generators/summoner/install/templates/create_summoner_tables.rb.erb +28 -0
- data/lib/generators/summoner/install/templates/features.yml +26 -0
- data/lib/generators/summoner/install/templates/summoner.rb +10 -0
- data/lib/summoner/configuration.rb +20 -0
- data/lib/summoner/engine.rb +9 -0
- data/lib/summoner/entity.rb +49 -0
- data/lib/summoner/fetcher.rb +66 -0
- data/lib/summoner/loader.rb +0 -0
- data/lib/summoner/sync.rb +102 -0
- data/lib/summoner/version.rb +3 -0
- data/lib/summoner.rb +11 -0
- data/lib/tasks/summoner_tasks.rake +20 -0
- metadata +108 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0c140e7b6dcffcd46213c17ea06f93723b115aae006686c64de4cda0e66d0824
|
|
4
|
+
data.tar.gz: bf77ad299888e02d1e2e9712249776eb6ae6b17d01157f3b20be024350886306
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 85eba4331932b10f9bf10aaf3eeeb48f8e12083145c4f247ef8f4e92f475a3112d3da3837863146dac9e2ed24f01bbe4a9a813bbfcc1d99d2ab7178740d4fa2f
|
|
7
|
+
data.tar.gz: 84e1a26a354c4e6cba7507d61b22a02567b960a8b934e992f362947c6b4cc372083d3ec3bbc51f83c900075397b1f0ff5f5e18e5b462ebf76fc72d945ea68351
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Felipe Rodrigues
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# Summoner
|
|
2
|
+
An advanced, flexible, and powerful Feature Toggle, Dynamic Configuration, and Attribute-Based Access Control (ABAC) engine for Ruby on Rails.
|
|
3
|
+
|
|
4
|
+
Summoner goes way beyond simple booleans. It allows you to manage global or **Entity-specific** configurations (e.g., `User`, `Account`), perform gradual rollouts based on attributes (like `role` or `plan`), attach editable descriptions to each feature, and create surgical exceptions (_overrides_) directly through an elegant Web UI. All wrapped in a **lightning-fast Caching layer** to protect your database.
|
|
5
|
+
|
|
6
|
+
## Table of Contents
|
|
7
|
+
- [Key Features](#key-features)
|
|
8
|
+
- [Installation](#installation)
|
|
9
|
+
- [Configuration](#configuration)
|
|
10
|
+
- [The Web Dashboard](#the-web-dashboard)
|
|
11
|
+
- [Usage](#usage)
|
|
12
|
+
- [1. Defining your Source of Truth (`features.yml`)](#1-defining-your-source-of-truth-featuresyml)
|
|
13
|
+
- [2. Setting up your Entities](#2-setting-up-your-entities)
|
|
14
|
+
- [3. Checking Features in your Code](#3-checking-features-in-your-code)
|
|
15
|
+
- [Targeted Overrides and Rollouts](#targeted-overrides-and-rollouts)
|
|
16
|
+
- [Contributing](#contributing)
|
|
17
|
+
- [License](#license)
|
|
18
|
+
|
|
19
|
+
## Key Features
|
|
20
|
+
- **Dynamic Entity Injection**: Features become native methods on your models (e.g., `user.new_dashboard?`).
|
|
21
|
+
- **Attribute-Based Access Control (ABAC)**: Automatically evaluate feature access by matching permission arrays against entity attributes (e.g., checking if a user's role is in the allowed list).
|
|
22
|
+
- **Instant Global Rollouts**: Use the wildcard `["*"]` to release a feature to everyone in seconds, without touching the codebase.
|
|
23
|
+
- **Granular Overrides**: The default rule might be X, but for User ID 42, the rule is Y. Summoner manages these exceptions flawlessly.
|
|
24
|
+
- **Strong Typing**: Native support for complex data types (`boolean`, `integer`, `float`, `json`, `string`).
|
|
25
|
+
- **YAML Synchronization**: Keep a `features.yml` file as your single source of truth and sync it to the database on every deploy. YAML changes update the tracked defaults and descriptions, while manual dashboard edits remain in place until the YAML changes again.
|
|
26
|
+
- **Built-in Web Dashboard**: A mountable Rails Engine for you and your team to manage everything visually.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
Add this line to your application's `Gemfile`:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
gem "summoner"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Install the gem and run the installation generator:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
bundle install
|
|
39
|
+
rails generate summoner:install
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The generator will create the database migrations, an initializer, and your `config/features.yml` template. Then, build the tables:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
rails db:migrate
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
You can customize the engine's behavior, especially the built-in Cache, in `config/initializers/summoner.rb`:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
Summoner.configure do |config|
|
|
53
|
+
# Enables or disables the use of Rails.cache (Default: true)
|
|
54
|
+
config.cache_enabled = true
|
|
55
|
+
|
|
56
|
+
# A safe namespace to prevent key collisions in Redis/Memcached
|
|
57
|
+
config.cache_namespace = 'summoner'
|
|
58
|
+
|
|
59
|
+
# Defines expiration time for the cache (Default: 1.hour)
|
|
60
|
+
config.cache_expires_in = 1.hour
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### The Web Dashboard
|
|
65
|
+
To manage your features and overrides visually, mount the UI inside your `config/routes.rb`:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
Rails.application.routes.draw do
|
|
69
|
+
# ... your application routes ...
|
|
70
|
+
mount Summoner::Engine => "/summoner"
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Now visit `http://localhost:3000/summoner` to see the magic happen!
|
|
75
|
+
|
|
76
|
+
<img width="1912" height="573" alt="image" src="https://github.com/user-attachments/assets/2ee2e48b-98c9-4d53-9dcb-f0b2b28e1993" />
|
|
77
|
+
|
|
78
|
+
## Usage
|
|
79
|
+
Summoner's true power lies in its organization by **Namespaces (Entities)** and **Data Types**.
|
|
80
|
+
|
|
81
|
+
### 1. Defining your Source of Truth (`features.yml`)
|
|
82
|
+
Declare your application's base behavior in your `config/features.yml`. The structure uses the Entity name as the root key. Each feature can define a `default` value and an optional `description`:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
# =========================================================================
|
|
86
|
+
# Whenever you update this file, run in your terminal: rails summoner:sync
|
|
87
|
+
# =========================================================================
|
|
88
|
+
|
|
89
|
+
user:
|
|
90
|
+
# Example 1: Simple Boolean injected into the model
|
|
91
|
+
"new_dashboard?":
|
|
92
|
+
default: false
|
|
93
|
+
description: Enables the new dashboard experience for users.
|
|
94
|
+
|
|
95
|
+
# Example 2: ABAC (Attribute-Based Access Control)
|
|
96
|
+
# Checks if the User's `role` method matches the values in the Array
|
|
97
|
+
"can_access_admin?":
|
|
98
|
+
default: ["admin", "manager"]
|
|
99
|
+
match_attribute: "role"
|
|
100
|
+
description: Allow admins and managers to access the admin area.
|
|
101
|
+
|
|
102
|
+
# Example 3: Global Release with Wildcard ("*")
|
|
103
|
+
# Everyone gets access, ignoring the `role` attribute entirely!
|
|
104
|
+
"can_access_beta?":
|
|
105
|
+
default: ["*"]
|
|
106
|
+
match_attribute: "role"
|
|
107
|
+
|
|
108
|
+
# Example 4: Dynamic Values (Integer, String, JSON)
|
|
109
|
+
max_items_per_page:
|
|
110
|
+
default: 20
|
|
111
|
+
description: Maximum number of items shown per page.
|
|
112
|
+
|
|
113
|
+
system:
|
|
114
|
+
# Features that do not belong to a specific entity
|
|
115
|
+
maintenance_mode:
|
|
116
|
+
default: false
|
|
117
|
+
description: Toggle the maintenance page for all users.
|
|
118
|
+
|
|
119
|
+
app:
|
|
120
|
+
# Global application configurations
|
|
121
|
+
api_timeout:
|
|
122
|
+
default: 30
|
|
123
|
+
description: Request timeout in seconds for external API calls.
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Sync these definitions with your database by running:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
rails summoner:sync
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
If you edit a feature through the dashboard, the manual change stays active until the same feature changes in `features.yml` again. At that point, Summoner updates the tracked YAML value and YAML description back to match the file.
|
|
133
|
+
|
|
134
|
+
### 2. Setting up your Entities
|
|
135
|
+
To allow Summoner to inject these configurations directly into your classes (like your `User` model), simply include the core module:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
class User < ApplicationRecord
|
|
139
|
+
include Summoner::Entity
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### 3. Checking Features in your Code
|
|
144
|
+
Summoner exposes an elegant, fluent API with automatic caching out of the box.
|
|
145
|
+
|
|
146
|
+
**A. Checking Boolean Toggles and ABAC:**
|
|
147
|
+
Keys ending with a `?` in your YAML are converted into direct query methods on your entity:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
user = User.first
|
|
151
|
+
|
|
152
|
+
# Respects the YAML default or a database Override
|
|
153
|
+
if user.new_dashboard?
|
|
154
|
+
render "dashboards/v2"
|
|
155
|
+
else
|
|
156
|
+
render "dashboards/v1"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Automatically evaluates the ABAC array against user.role!
|
|
160
|
+
user.can_access_admin? # => true (if the role is "admin")
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**B. Getting Complex Typed Values:**
|
|
164
|
+
To retrieve integers, strings, or JSON, just call the exact key name:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# Returns 20 (or the specific overridden value for this user)
|
|
168
|
+
limit = user.max_items_per_page
|
|
169
|
+
|
|
170
|
+
Product.limit(limit)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**C. Global Features (Without Entities):**
|
|
174
|
+
Not every feature is tied to a user or an account. You can create arbitrary namespaces in your `features.yml` (like `system:`, `app:`, or `global:`) to group application-wide settings.
|
|
175
|
+
|
|
176
|
+
Since these don't belong to an Active Record model, you can query them directly through the `Summoner` module using the `"namespace.feature_key"` format:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
# Checking a global boolean toggle
|
|
180
|
+
if Summoner.active?("system.maintenance_mode")
|
|
181
|
+
redirect_to maintenance_path
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Getting a global typed value
|
|
185
|
+
api_timeout = Summoner.get("app.api_timeout")
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Targeted Overrides and Rollouts
|
|
189
|
+
Your YAML defines the "General Rule," but real-world apps require exceptions. Through the Web Dashboard, you can create Overrides for specific instances.
|
|
190
|
+
|
|
191
|
+
**Common Use Cases:**
|
|
192
|
+
- **Beta Testing**: The `user.new_dashboard?` feature is `false` for everyone, but you create an Override set to `true` strictly for User ID: `1` and User ID: `42`.
|
|
193
|
+
- **Tenant Customization**: The `max_items_per_page` limit is `20`, but you sold a VIP plan to User ID: `99` and created an Override with the value `100` just for them.
|
|
194
|
+
- **ABAC Bypass**: `can_access_admin?` only allows `["admin"]`, but you want to grant temporary access to a specific regular user.
|
|
195
|
+
|
|
196
|
+
Summoner's Evaluation Engine always respects the following priority hierarchy:
|
|
197
|
+
**Entity-specific Override > ABAC / Wildcard Rule > Global Default Value.**
|
|
198
|
+
|
|
199
|
+
<img width="1914" height="990" alt="image" src="https://github.com/user-attachments/assets/70a7452a-01b8-4737-a7e8-82a2f9ccbd41" />
|
|
200
|
+
|
|
201
|
+
## Contributing
|
|
202
|
+
Bug reports and pull requests are warmly welcome on GitHub at https://github.com/feliperodrigs1/summoner.
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Summoner
|
|
2
|
+
class FeaturesController < ApplicationController
|
|
3
|
+
before_action :set_feature, only: %i[show edit update]
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@features = Summoner::Feature.all.order(:namespace, :name)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
@feature = Summoner::Feature.find(params[:id])
|
|
11
|
+
@overrides = Summoner::FeatureOverride.where(feature_key: @feature.key)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def edit; end
|
|
15
|
+
|
|
16
|
+
def update
|
|
17
|
+
raw_value = params[:feature][:default_value]
|
|
18
|
+
description = params[:feature][:description]
|
|
19
|
+
|
|
20
|
+
unless @feature.valid_value?(raw_value)
|
|
21
|
+
flash.now[:alert] = "Invalid value for type #{@feature.value_type}."
|
|
22
|
+
return render :edit
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
parsed_value = @feature.cast_value(params[:feature][:default_value])
|
|
26
|
+
|
|
27
|
+
if @feature.update(default_value: parsed_value, description: description)
|
|
28
|
+
redirect_to feature_path(@feature), notice: 'Feature updated successfully.'
|
|
29
|
+
else
|
|
30
|
+
render :edit
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def set_feature
|
|
37
|
+
@feature = Summoner::Feature.find(params[:id])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Summoner
|
|
2
|
+
class OverridesController < ApplicationController
|
|
3
|
+
before_action :set_feature
|
|
4
|
+
before_action :set_override, only: [:edit, :update, :destroy]
|
|
5
|
+
before_action :validate_type, only: [:create, :update]
|
|
6
|
+
|
|
7
|
+
def create
|
|
8
|
+
@override = Summoner::FeatureOverride.new(override_params)
|
|
9
|
+
|
|
10
|
+
@override.feature_key = @feature.key
|
|
11
|
+
@override.flaggable_type = @feature.namespace.classify
|
|
12
|
+
@override.value = @feature.cast_value(params[:feature_override][:value])
|
|
13
|
+
|
|
14
|
+
if @override.save
|
|
15
|
+
redirect_to feature_path(@feature), notice: 'Override created successfully!'
|
|
16
|
+
else
|
|
17
|
+
redirect_to feature_path(@feature), alert: "Error: #{@override.errors.full_messages.to_sentence}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def edit; end
|
|
22
|
+
|
|
23
|
+
def update
|
|
24
|
+
parsed_value = @feature.cast_value(params[:feature_override][:value])
|
|
25
|
+
|
|
26
|
+
if @override.update(value: parsed_value)
|
|
27
|
+
redirect_to feature_path(@feature), notice: 'Override updated successfully!'
|
|
28
|
+
else
|
|
29
|
+
render :edit
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def destroy
|
|
34
|
+
@override.destroy
|
|
35
|
+
redirect_to feature_path(@feature), notice: 'Override removed!'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def set_feature
|
|
41
|
+
@feature = Summoner::Feature.find(params[:feature_id])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def set_override
|
|
45
|
+
@override = Summoner::FeatureOverride.find(params[:id])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_type
|
|
49
|
+
value = params[:feature_override][:value]
|
|
50
|
+
|
|
51
|
+
unless @feature.valid_value?(value)
|
|
52
|
+
if action_name == 'update'
|
|
53
|
+
redirect_to edit_feature_override_path(@feature, @override), alert: "Invalid value for type #{@feature.value_type}."
|
|
54
|
+
else
|
|
55
|
+
redirect_to feature_path(@feature), alert: "Invalid value for type #{@feature.value_type}."
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def override_params
|
|
61
|
+
params.require(:feature_override).permit(:flaggable_id)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Summoner
|
|
2
|
+
class Feature < ApplicationRecord
|
|
3
|
+
self.table_name = "summoner_features"
|
|
4
|
+
|
|
5
|
+
validates :key, presence: true, uniqueness: true
|
|
6
|
+
validates :namespace, presence: true
|
|
7
|
+
validates :name, presence: true
|
|
8
|
+
validates :value_type, presence: true
|
|
9
|
+
|
|
10
|
+
after_commit :clear_cache
|
|
11
|
+
|
|
12
|
+
def cast_value(raw_value)
|
|
13
|
+
case value_type
|
|
14
|
+
when 'boolean'
|
|
15
|
+
raw_value.to_s.strip.downcase == 'true'
|
|
16
|
+
when 'integer'
|
|
17
|
+
raw_value.to_i
|
|
18
|
+
when 'float'
|
|
19
|
+
raw_value.to_f
|
|
20
|
+
when 'json'
|
|
21
|
+
raw_value.is_a?(String) ? JSON.parse(raw_value) : raw_value
|
|
22
|
+
else
|
|
23
|
+
raw_value.to_s
|
|
24
|
+
end
|
|
25
|
+
rescue
|
|
26
|
+
raw_value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def valid_value?(raw_value)
|
|
30
|
+
str_val = raw_value.to_s.strip
|
|
31
|
+
|
|
32
|
+
case value_type
|
|
33
|
+
when 'boolean'
|
|
34
|
+
str_val.downcase.in?(['true', 'false'])
|
|
35
|
+
when 'integer'
|
|
36
|
+
str_val.match?(/\A-?\d+\z/)
|
|
37
|
+
when 'float'
|
|
38
|
+
str_val.match?(/\A-?\d+(\.\d+)?\z/)
|
|
39
|
+
when 'json'
|
|
40
|
+
begin
|
|
41
|
+
JSON.parse(str_val)
|
|
42
|
+
true
|
|
43
|
+
rescue JSON::ParserError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
else
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def clear_cache
|
|
54
|
+
return unless Summoner.configuration.cache_enabled
|
|
55
|
+
|
|
56
|
+
base_key = "#{Summoner.configuration.cache_namespace}:#{key}"
|
|
57
|
+
|
|
58
|
+
Rails.cache.delete_matched(/#{Regexp.escape(base_key)}/)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Summoner
|
|
2
|
+
class FeatureOverride < ApplicationRecord
|
|
3
|
+
self.table_name = 'summoner_feature_overrides'
|
|
4
|
+
|
|
5
|
+
belongs_to :flaggable, polymorphic: true, optional: true
|
|
6
|
+
|
|
7
|
+
validates :feature_key, presence: true
|
|
8
|
+
validates :feature_key, uniqueness: { scope: [:flaggable_id, :flaggable_type] }
|
|
9
|
+
|
|
10
|
+
after_commit :clear_cache
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def clear_cache
|
|
15
|
+
return unless Summoner.configuration.cache_enabled
|
|
16
|
+
|
|
17
|
+
cache_key = "#{Summoner.configuration.cache_namespace}:#{feature_key}:#{flaggable_type.underscore}:#{flaggable_id}"
|
|
18
|
+
|
|
19
|
+
Rails.cache.delete(cache_key)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Summoner Dashboard</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%= csp_meta_tag %>
|
|
8
|
+
<link rel="icon" type="image/x-icon" href="<%= image_path('summoner/favicon.ico') %>" />
|
|
9
|
+
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--bg-main: #f3f4f6;
|
|
13
|
+
--bg-card: #ffffff;
|
|
14
|
+
--text-main: #111827;
|
|
15
|
+
--text-muted: #6b7280;
|
|
16
|
+
--border-color: #e5e7eb;
|
|
17
|
+
--nav-bg: #aa526b;
|
|
18
|
+
--nav-text: #ffffff;
|
|
19
|
+
--btn-primary: #2596be;
|
|
20
|
+
--btn-primary-hover: #1e7a9b;
|
|
21
|
+
--btn-danger: #ef4444;
|
|
22
|
+
--btn-danger-hover: #dc2626;
|
|
23
|
+
--btn-text: #ffffff;
|
|
24
|
+
--code-bg: #e0e7ff;
|
|
25
|
+
--code-text: #4f46e5;
|
|
26
|
+
--badge-active-bg: #d1fae5;
|
|
27
|
+
--badge-active-text: #065f46;
|
|
28
|
+
--badge-inactive-bg: #fee2e2;
|
|
29
|
+
--badge-inactive-text: #991b1b;
|
|
30
|
+
--input-bg: #ffffff;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@media (prefers-color-scheme: dark) {
|
|
34
|
+
:root {
|
|
35
|
+
--bg-main: #111111;
|
|
36
|
+
--bg-card: #1c1c1e;
|
|
37
|
+
--text-main: #f4f4f5;
|
|
38
|
+
--text-muted: #a1a1aa;
|
|
39
|
+
--border-color: #2c2c2e;
|
|
40
|
+
--nav-bg: #1c1c1e;
|
|
41
|
+
--nav-text: #aa526b;
|
|
42
|
+
--btn-primary: #2596be;
|
|
43
|
+
--btn-primary-hover: #48a5c8;
|
|
44
|
+
--btn-danger: #dc2626;
|
|
45
|
+
--btn-danger-hover: #ef4444;
|
|
46
|
+
--btn-text: #ffffff;
|
|
47
|
+
--code-bg: #282836;
|
|
48
|
+
--code-text: #818cf8;
|
|
49
|
+
--badge-active-bg: #064e3b;
|
|
50
|
+
--badge-active-text: #34d399;
|
|
51
|
+
--badge-inactive-bg: #7f1d1d;
|
|
52
|
+
--badge-inactive-text: #fca5a5;
|
|
53
|
+
--input-bg: #111111;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: var(--bg-main); color: var(--text-main); margin: 0; padding: 0; }
|
|
58
|
+
|
|
59
|
+
.navbar { background: var(--nav-bg); color: var(--nav-text); padding: 1rem 0; border-bottom: 1px solid var(--border-color); }
|
|
60
|
+
.navbar .container { display: flex; align-items: center; margin: 0 auto; padding-top: 0; padding-bottom: 0; }
|
|
61
|
+
.navbar h1 { margin: 0; font-size: 1.5rem; font-weight: 600; letter-spacing: -0.025em; }
|
|
62
|
+
|
|
63
|
+
.container { max-width: 1100px; margin: 2rem auto; padding: 0 1.5rem; }
|
|
64
|
+
|
|
65
|
+
.card { background: var(--bg-card); border-radius: 8px; border: 1px solid var(--border-color); box-shadow: 0 1px 3px rgba(0,0,0,0.05); overflow: hidden; margin-bottom: 2rem; }
|
|
66
|
+
.card-header { padding: 1rem 1.5rem; border-bottom: 1px solid var(--border-color); background: var(--bg-card); }
|
|
67
|
+
.card-header h2, .card-header h3 { margin: 0; font-size: 1.125rem; font-weight: 600; }
|
|
68
|
+
.card-body { padding: 1.25rem 1.5rem; }
|
|
69
|
+
|
|
70
|
+
table { width: 100%; border-collapse: collapse; text-align: left; }
|
|
71
|
+
th { background: var(--bg-main); color: var(--text-muted); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.75rem 1.5rem; border-bottom: 1px solid var(--border-color); border-top: 1px solid var(--border-color); }
|
|
72
|
+
td { padding: 1rem 1.5rem; border-bottom: 1px solid var(--border-color); color: var(--text-main); vertical-align: middle; }
|
|
73
|
+
tr:last-child td { border-bottom: none; }
|
|
74
|
+
|
|
75
|
+
code { background: var(--code-bg); color: var(--code-text); padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.85em; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
|
76
|
+
|
|
77
|
+
.badge { display: inline-flex; align-items: center; padding: 0.25rem 0.625rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; }
|
|
78
|
+
.badge.active { background: var(--badge-active-bg); color: var(--badge-active-text); }
|
|
79
|
+
.badge.inactive { background: var(--badge-inactive-bg); color: var(--badge-inactive-text); }
|
|
80
|
+
|
|
81
|
+
a { color: var(--btn-primary); text-decoration: none; }
|
|
82
|
+
a:hover { text-decoration: underline; }
|
|
83
|
+
|
|
84
|
+
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.4rem 1rem; border-radius: 6px; font-size: 0.875rem; font-weight: 500; border: 1px solid transparent; cursor: pointer; transition: all 0.15s; text-decoration: none; line-height: 1.25rem; }
|
|
85
|
+
.btn:hover { text-decoration: none; }
|
|
86
|
+
.btn-sm { padding: 0.25rem 0.625rem; font-size: 0.75rem; }
|
|
87
|
+
.btn-primary { background: var(--btn-primary); color: var(--btn-text); }
|
|
88
|
+
.btn-primary:hover { background: var(--btn-primary-hover); color: var(--btn-text); }
|
|
89
|
+
.btn-danger { background: var(--btn-danger); color: var(--btn-text); }
|
|
90
|
+
.btn-danger:hover { background: var(--btn-danger-hover); color: var(--btn-text); }
|
|
91
|
+
.btn-outline { background: transparent; border-color: var(--border-color); color: var(--text-main); }
|
|
92
|
+
.btn-outline:hover { background: var(--bg-main); color: var(--text-main); }
|
|
93
|
+
|
|
94
|
+
.form-group { margin-bottom: 1rem; }
|
|
95
|
+
.form-label { display: block; font-size: 0.875rem; font-weight: 500; color: var(--text-main); margin-bottom: 0.35rem; }
|
|
96
|
+
.form-control { display: block; width: 100%; padding: 0.4rem 0.6rem; font-size: 0.875rem; line-height: 1.5rem; color: var(--text-main); background-color: var(--input-bg); border: 1px solid var(--border-color); border-radius: 6px; box-sizing: border-box; transition: border-color 0.15s ease-in-out; }
|
|
97
|
+
.form-control:focus { border-color: var(--btn-primary); outline: 0; box-shadow: 0 0 0 1px var(--btn-primary); }
|
|
98
|
+
.form-control[readonly] { background-color: var(--bg-main); cursor: not-allowed; color: var(--text-muted); }
|
|
99
|
+
|
|
100
|
+
.d-flex { display: flex; }
|
|
101
|
+
.gap-2 { gap: 0.5rem; }
|
|
102
|
+
.gap-3 { gap: 1rem; }
|
|
103
|
+
.align-items-center { align-items: center; }
|
|
104
|
+
.align-items-end { align-items: flex-end; }
|
|
105
|
+
.justify-content-between { justify-content: space-between; }
|
|
106
|
+
.flex-wrap { flex-wrap: wrap; }
|
|
107
|
+
|
|
108
|
+
.text-center { text-align: center; }
|
|
109
|
+
.text-muted { color: var(--text-muted); }
|
|
110
|
+
.mb-0 { margin-bottom: 0; }
|
|
111
|
+
.mb-3 { margin-bottom: 1rem; }
|
|
112
|
+
.mb-4 { margin-bottom: 1.5rem; }
|
|
113
|
+
|
|
114
|
+
.grid-details { display: grid; grid-template-columns: minmax(130px, max-content) 1fr; gap: 0.75rem 1rem; align-items: baseline; }
|
|
115
|
+
.grid-details > span:nth-child(odd) { font-weight: 500; color: var(--text-muted); font-size: 0.875rem; }
|
|
116
|
+
|
|
117
|
+
.flash { padding: 1rem; margin-bottom: 1.5rem; border-radius: 6px; font-weight: 500; font-size: 0.875rem; }
|
|
118
|
+
.flash.notice { background: var(--badge-active-bg); color: var(--badge-active-text); border: 1px solid var(--badge-active-text); }
|
|
119
|
+
.flash.alert { background: var(--badge-inactive-bg); color: var(--badge-inactive-text); border: 1px solid var(--badge-inactive-text); }
|
|
120
|
+
</style>
|
|
121
|
+
</head>
|
|
122
|
+
<body>
|
|
123
|
+
<div class="navbar">
|
|
124
|
+
<div class="container">
|
|
125
|
+
<h1>Summoner</h1>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div class="container">
|
|
130
|
+
<% flash.each do |key, message| %>
|
|
131
|
+
<div class="flash <%= key %>">
|
|
132
|
+
<%= message %>
|
|
133
|
+
</div>
|
|
134
|
+
<% end %>
|
|
135
|
+
|
|
136
|
+
<%= yield %>
|
|
137
|
+
</div>
|
|
138
|
+
</body>
|
|
139
|
+
</html>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<div class="mb-4">
|
|
2
|
+
<%= link_to "← Back to Feature", feature_path(@feature), class: "btn btn-outline btn-sm", style: "text-decoration: none;" %>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<div class="card" style="max-width: 600px;">
|
|
6
|
+
<div class="card-header">
|
|
7
|
+
<h2 class="mb-0">Edit Default Value</h2>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div class="card-body">
|
|
11
|
+
<div class="grid-details mb-4">
|
|
12
|
+
<span>Feature:</span> <code><%= @feature.key %></code>
|
|
13
|
+
<span>Expected Type:</span> <code><%= @feature.value_type %></code>
|
|
14
|
+
<span>Description:</span>
|
|
15
|
+
<span><%= @feature.description.presence || "No description set" %></span>
|
|
16
|
+
|
|
17
|
+
<% if @feature.match_attribute.present? %>
|
|
18
|
+
<span>Matches Attribute:</span> <code style="background: #fef08a; color: #854d0e;"><%= @feature.match_attribute %></code>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<hr style="border: 0; border-top: 1px solid var(--border-color); margin: 1.5rem 0;">
|
|
23
|
+
|
|
24
|
+
<%= form_with model: @feature, local: true do |f| %>
|
|
25
|
+
<div class="form-group mb-4">
|
|
26
|
+
<%= f.label :default_value, "New Default Value", class: "form-label" %>
|
|
27
|
+
<%= f.text_field :default_value, value: @feature.default_value.is_a?(String) ? @feature.default_value : @feature.default_value.to_json, class: "form-control" %>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="form-group mb-4">
|
|
31
|
+
<%= f.label :description, "Description", class: "form-label" %>
|
|
32
|
+
<%= f.text_area :description, rows: 4, class: "form-control" %>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<%= f.submit "Save & Lock Sync", class: "btn btn-primary" %>
|
|
36
|
+
<% end %>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|