overule 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/CHANGELOG.md +80 -0
- data/README.md +302 -0
- data/app/assets/javascripts/overule/builder.js +268 -0
- data/app/controllers/overule/activities_controller.rb +10 -0
- data/app/controllers/overule/application_controller.rb +35 -0
- data/app/controllers/overule/rule_versions_controller.rb +24 -0
- data/app/controllers/overule/rules_controller.rb +60 -0
- data/app/models/concerns/overule/rule_activity_behavior.rb +24 -0
- data/app/models/concerns/overule/rule_behavior.rb +106 -0
- data/app/models/concerns/overule/rule_version_behavior.rb +15 -0
- data/app/models/overule/current.rb +7 -0
- data/app/models/overule/rule.rb +48 -0
- data/app/models/overule/rule_activity.rb +40 -0
- data/app/models/overule/rule_version.rb +42 -0
- data/app/views/layouts/overule/application.html.erb +39 -0
- data/app/views/overule/activities/_activity.html.erb +56 -0
- data/app/views/overule/activities/index.html.erb +30 -0
- data/app/views/overule/rule_versions/index.html.erb +44 -0
- data/app/views/overule/rule_versions/show.html.erb +55 -0
- data/app/views/overule/rules/_form.html.erb +95 -0
- data/app/views/overule/rules/_group.html.erb +106 -0
- data/app/views/overule/rules/_static_node.html.erb +79 -0
- data/app/views/overule/rules/edit.html.erb +2 -0
- data/app/views/overule/rules/index.html.erb +45 -0
- data/app/views/overule/rules/new.html.erb +2 -0
- data/app/views/overule/rules/show.html.erb +54 -0
- data/config/routes.rb +8 -0
- data/lib/generators/overule/install/USAGE +25 -0
- data/lib/generators/overule/install/install_generator.rb +64 -0
- data/lib/generators/overule/install/templates/add_rule_version_to_overule_rule_activities.rb.tt +21 -0
- data/lib/generators/overule/install/templates/create_overule_rule_activities.rb.tt +15 -0
- data/lib/generators/overule/install/templates/create_overule_rule_versions.rb.tt +28 -0
- data/lib/generators/overule/install/templates/create_overule_rules.rb.tt +13 -0
- data/lib/generators/overule/install/templates/overule.rb.tt +35 -0
- data/lib/overule/action.rb +22 -0
- data/lib/overule/condition.rb +40 -0
- data/lib/overule/configuration.rb +75 -0
- data/lib/overule/context.rb +22 -0
- data/lib/overule/engine.rb +7 -0
- data/lib/overule/inference.rb +38 -0
- data/lib/overule/operator.rb +25 -0
- data/lib/overule/version.rb +3 -0
- data/lib/overule.rb +13 -0
- metadata +103 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Overule
|
|
2
|
+
class ApplicationController < ActionController::Base
|
|
3
|
+
protect_from_forgery with: :exception
|
|
4
|
+
|
|
5
|
+
before_action :authenticate_with_overule_http_basic
|
|
6
|
+
before_action :set_overule_actor
|
|
7
|
+
|
|
8
|
+
helper_method :builder_js if respond_to?(:helper_method)
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def builder_js
|
|
13
|
+
@builder_js ||= File.read(Overule::Engine.root.join("app/assets/javascripts/overule/builder.js"))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Populate Overule::Current.actor from the host app's configured
|
|
17
|
+
# actor_proc (set in config/initializers/overule.rb). Host apps that
|
|
18
|
+
# prefer to set Current.actor themselves can leave actor_proc nil.
|
|
19
|
+
def set_overule_actor
|
|
20
|
+
actor = Overule.config.actor_for(self)
|
|
21
|
+
Overule::Current.actor = actor if actor
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Gate every Overule action behind HTTP Basic auth when the host app has
|
|
25
|
+
# configured credentials. When unset (the default), no gate is applied
|
|
26
|
+
# and the engine relies on the host's existing auth (or none).
|
|
27
|
+
def authenticate_with_overule_http_basic
|
|
28
|
+
return unless Overule.config.http_basic_auth_configured?
|
|
29
|
+
|
|
30
|
+
authenticate_or_request_with_http_basic("Overule") do |username, password|
|
|
31
|
+
Overule.config.http_basic_auth_matches?(username, password)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Overule
|
|
2
|
+
class RuleVersionsController < ApplicationController
|
|
3
|
+
before_action :set_rule
|
|
4
|
+
before_action :set_version, only: [:show]
|
|
5
|
+
|
|
6
|
+
def index
|
|
7
|
+
@versions = @rule.versions.ordered.reverse_order
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
# Rendered implicitly: the show view consumes @rule and @version loaded by the before_actions.
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def set_rule
|
|
17
|
+
@rule = Rule.find(params[:rule_id])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def set_version
|
|
21
|
+
@version = @rule.versions.find_by!(version: params[:id])
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Overule
|
|
2
|
+
class RulesController < ApplicationController
|
|
3
|
+
before_action :set_rule, only: %i[show edit update destroy]
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@rules = Rule.order(:name)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
# Rendered implicitly: the show view consumes @rule loaded by set_rule.
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def new
|
|
14
|
+
@rule = Rule.new(definition: Rule::BLANK_DEFINITION.deep_dup)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def edit
|
|
18
|
+
# Rendered implicitly: the edit view consumes @rule loaded by set_rule.
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create
|
|
22
|
+
@rule = Rule.new(rule_params)
|
|
23
|
+
if @rule.save
|
|
24
|
+
redirect_to @rule, notice: "Rule created."
|
|
25
|
+
else
|
|
26
|
+
render :new, status: :unprocessable_entity
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def update
|
|
31
|
+
if @rule.update(rule_params)
|
|
32
|
+
redirect_to @rule, notice: "Rule updated."
|
|
33
|
+
else
|
|
34
|
+
render :edit, status: :unprocessable_entity
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def destroy
|
|
39
|
+
@rule.destroy
|
|
40
|
+
redirect_to rules_path, notice: "Rule deleted."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def set_rule
|
|
46
|
+
@rule = Rule.find(params[:id])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def rule_params
|
|
50
|
+
permitted = params.require(:rule).permit(:name, :description, :enabled, :definition)
|
|
51
|
+
if permitted[:definition].is_a?(String) && !permitted[:definition].empty?
|
|
52
|
+
permitted[:definition] = JSON.parse(permitted[:definition])
|
|
53
|
+
end
|
|
54
|
+
permitted
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
permitted[:definition] = Rule::BLANK_DEFINITION.deep_dup
|
|
57
|
+
permitted
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Overule
|
|
2
|
+
module RuleActivityBehavior
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
ACTIONS = %w[created updated destroyed].freeze
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
validates :action, inclusion: { in: ACTIONS }
|
|
9
|
+
validates :rule_name, presence: true
|
|
10
|
+
|
|
11
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def changed_fields
|
|
15
|
+
return [] unless action == "updated"
|
|
16
|
+
|
|
17
|
+
diff.keys
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def snapshot
|
|
21
|
+
diff["snapshot"] if %w[created destroyed].include?(action)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
module Overule
|
|
2
|
+
module RuleBehavior
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
AUDITED_COLUMNS = %w[name description enabled definition].freeze
|
|
6
|
+
BLANK_DEFINITION = {
|
|
7
|
+
"when" => { "cond" => [], "set" => [], "op" => "and" },
|
|
8
|
+
"then" => { "$static" => {} }
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
included do
|
|
12
|
+
validates :name, presence: true, uniqueness: true
|
|
13
|
+
validate :definition_shape
|
|
14
|
+
|
|
15
|
+
after_create :log_created
|
|
16
|
+
after_update :log_updated
|
|
17
|
+
# prepend so we capture the last version BEFORE dependent: :nullify
|
|
18
|
+
# severs the rule_id pointers on associated versions.
|
|
19
|
+
before_destroy :log_destroyed, prepend: true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def infer(facts)
|
|
23
|
+
Overule::Inference.new(definition, facts).infer
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def definition_shape
|
|
29
|
+
unless definition.is_a?(Hash) && definition["when"].is_a?(Hash)
|
|
30
|
+
errors.add(:definition, "must contain a 'when' object")
|
|
31
|
+
end
|
|
32
|
+
unless definition.is_a?(Hash) && definition["then"].is_a?(Hash)
|
|
33
|
+
errors.add(:definition, "must contain a 'then' object")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def log_created
|
|
38
|
+
version = create_version!
|
|
39
|
+
Overule::RuleActivity.create!(
|
|
40
|
+
rule: self,
|
|
41
|
+
rule_version: version,
|
|
42
|
+
rule_name: name,
|
|
43
|
+
action: "created",
|
|
44
|
+
actor: Overule::Current.actor,
|
|
45
|
+
diff: { "snapshot" => snapshot_attrs }
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def log_updated
|
|
50
|
+
changes = saved_changes.slice(*AUDITED_COLUMNS)
|
|
51
|
+
return if changes.empty?
|
|
52
|
+
|
|
53
|
+
# Only definition (rule-body) changes bump the version. Metadata
|
|
54
|
+
# edits — name, description, enabled — still log an activity, but
|
|
55
|
+
# the activity links back to the current body version.
|
|
56
|
+
version = changes.key?("definition") ? create_version! : versions.ordered.last
|
|
57
|
+
diff = changes.each_with_object({}) do |(col, (before, after)), acc|
|
|
58
|
+
acc[col] = { "before" => before, "after" => after }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Overule::RuleActivity.create!(
|
|
62
|
+
rule: self,
|
|
63
|
+
rule_version: version,
|
|
64
|
+
rule_name: name,
|
|
65
|
+
action: "updated",
|
|
66
|
+
actor: Overule::Current.actor,
|
|
67
|
+
diff: diff
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def log_destroyed
|
|
72
|
+
last_version = versions.ordered.last
|
|
73
|
+
Overule::RuleActivity.create!(
|
|
74
|
+
rule_version: last_version,
|
|
75
|
+
rule_name: name,
|
|
76
|
+
action: "destroyed",
|
|
77
|
+
actor: Overule::Current.actor,
|
|
78
|
+
diff: { "snapshot" => snapshot_attrs }
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def create_version!
|
|
83
|
+
# `pluck(:version).max` works identically on both AR and Mongoid scopes,
|
|
84
|
+
# unlike `maximum(:version)` which has different names across ORMs.
|
|
85
|
+
next_version = (versions.pluck(:version).max || 0) + 1
|
|
86
|
+
versions.create!(
|
|
87
|
+
rule_name: name,
|
|
88
|
+
version: next_version,
|
|
89
|
+
name: name,
|
|
90
|
+
description: description,
|
|
91
|
+
enabled: enabled,
|
|
92
|
+
definition: definition,
|
|
93
|
+
created_at: Time.current
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def snapshot_attrs
|
|
98
|
+
{
|
|
99
|
+
"name" => name,
|
|
100
|
+
"description" => description,
|
|
101
|
+
"enabled" => enabled,
|
|
102
|
+
"definition" => definition
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Overule
|
|
2
|
+
module RuleVersionBehavior
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
validates :version, :rule_name, :name, presence: true
|
|
7
|
+
|
|
8
|
+
scope :ordered, -> { order(:version) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_param
|
|
12
|
+
version.to_s
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Overule
|
|
2
|
+
if Overule.config.orm == :mongoid
|
|
3
|
+
require "mongoid"
|
|
4
|
+
|
|
5
|
+
class Rule
|
|
6
|
+
include ::Mongoid::Document
|
|
7
|
+
include ::Mongoid::Timestamps
|
|
8
|
+
|
|
9
|
+
field :name, type: String
|
|
10
|
+
field :description, type: String
|
|
11
|
+
field :enabled, type: ::Mongoid::Boolean, default: true
|
|
12
|
+
field :definition, type: Hash, default: -> { RuleBehavior::BLANK_DEFINITION.deep_dup }
|
|
13
|
+
|
|
14
|
+
index({ name: 1 }, unique: true)
|
|
15
|
+
|
|
16
|
+
has_many :versions, class_name: "Overule::RuleVersion",
|
|
17
|
+
inverse_of: :rule, dependent: :nullify
|
|
18
|
+
has_many :activities, class_name: "Overule::RuleActivity",
|
|
19
|
+
inverse_of: :rule, dependent: :nullify
|
|
20
|
+
|
|
21
|
+
include RuleBehavior
|
|
22
|
+
|
|
23
|
+
BLANK_DEFINITION = RuleBehavior::BLANK_DEFINITION
|
|
24
|
+
AUDITED_COLUMNS = RuleBehavior::AUDITED_COLUMNS
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
class Rule < ::ActiveRecord::Base
|
|
28
|
+
self.table_name = "overule_rules"
|
|
29
|
+
|
|
30
|
+
has_many :versions, class_name: "Overule::RuleVersion",
|
|
31
|
+
foreign_key: :rule_id, dependent: :nullify, inverse_of: :rule
|
|
32
|
+
has_many :activities, class_name: "Overule::RuleActivity",
|
|
33
|
+
foreign_key: :rule_id, dependent: :nullify, inverse_of: :rule
|
|
34
|
+
|
|
35
|
+
include RuleBehavior
|
|
36
|
+
|
|
37
|
+
BLANK_DEFINITION = RuleBehavior::BLANK_DEFINITION
|
|
38
|
+
AUDITED_COLUMNS = RuleBehavior::AUDITED_COLUMNS
|
|
39
|
+
|
|
40
|
+
# SQLite returns json columns as Strings on some adapter/Rails combos;
|
|
41
|
+
# parse defensively. Postgres/MySQL already deserialize to Hash.
|
|
42
|
+
def definition
|
|
43
|
+
value = super
|
|
44
|
+
value.is_a?(String) ? JSON.parse(value) : value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Overule
|
|
2
|
+
if Overule.config.orm == :mongoid
|
|
3
|
+
require "mongoid"
|
|
4
|
+
|
|
5
|
+
class RuleActivity
|
|
6
|
+
include ::Mongoid::Document
|
|
7
|
+
|
|
8
|
+
# Created-only timestamp — activities are immutable, no updated_at.
|
|
9
|
+
field :created_at, type: DateTime, default: -> { Time.current }
|
|
10
|
+
field :rule_name, type: String
|
|
11
|
+
field :action, type: String
|
|
12
|
+
field :actor, type: String
|
|
13
|
+
field :diff, type: Hash, default: {}
|
|
14
|
+
|
|
15
|
+
belongs_to :rule, class_name: "Overule::Rule",
|
|
16
|
+
optional: true, inverse_of: :activities
|
|
17
|
+
belongs_to :rule_version, class_name: "Overule::RuleVersion",
|
|
18
|
+
optional: true, inverse_of: :activities
|
|
19
|
+
|
|
20
|
+
index({ created_at: -1 })
|
|
21
|
+
index({ action: 1 })
|
|
22
|
+
|
|
23
|
+
include RuleActivityBehavior
|
|
24
|
+
end
|
|
25
|
+
else
|
|
26
|
+
class RuleActivity < ::ActiveRecord::Base
|
|
27
|
+
self.table_name = "overule_rule_activities"
|
|
28
|
+
|
|
29
|
+
belongs_to :rule, class_name: "Overule::Rule", optional: true
|
|
30
|
+
belongs_to :rule_version, class_name: "Overule::RuleVersion", optional: true
|
|
31
|
+
|
|
32
|
+
include RuleActivityBehavior
|
|
33
|
+
|
|
34
|
+
def diff
|
|
35
|
+
value = super
|
|
36
|
+
value.is_a?(String) ? JSON.parse(value) : (value || {})
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Overule
|
|
2
|
+
if Overule.config.orm == :mongoid
|
|
3
|
+
require "mongoid"
|
|
4
|
+
|
|
5
|
+
class RuleVersion
|
|
6
|
+
include ::Mongoid::Document
|
|
7
|
+
|
|
8
|
+
field :created_at, type: DateTime, default: -> { Time.current }
|
|
9
|
+
field :rule_name, type: String
|
|
10
|
+
field :version, type: Integer
|
|
11
|
+
field :name, type: String
|
|
12
|
+
field :description, type: String
|
|
13
|
+
field :enabled, type: ::Mongoid::Boolean, default: true
|
|
14
|
+
field :definition, type: Hash, default: {}
|
|
15
|
+
|
|
16
|
+
belongs_to :rule, class_name: "Overule::Rule",
|
|
17
|
+
optional: true, inverse_of: :versions
|
|
18
|
+
has_many :activities, class_name: "Overule::RuleActivity",
|
|
19
|
+
inverse_of: :rule_version, dependent: :nullify
|
|
20
|
+
|
|
21
|
+
index({ rule_id: 1, version: 1 }, unique: true)
|
|
22
|
+
|
|
23
|
+
include RuleVersionBehavior
|
|
24
|
+
end
|
|
25
|
+
else
|
|
26
|
+
class RuleVersion < ::ActiveRecord::Base
|
|
27
|
+
self.table_name = "overule_rule_versions"
|
|
28
|
+
|
|
29
|
+
belongs_to :rule, class_name: "Overule::Rule", optional: true
|
|
30
|
+
has_many :activities, class_name: "Overule::RuleActivity",
|
|
31
|
+
foreign_key: :rule_version_id,
|
|
32
|
+
dependent: :nullify, inverse_of: :rule_version
|
|
33
|
+
|
|
34
|
+
include RuleVersionBehavior
|
|
35
|
+
|
|
36
|
+
def definition
|
|
37
|
+
value = super
|
|
38
|
+
value.is_a?(String) ? JSON.parse(value) : value
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<title>Overule — Rule Builder</title>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<%= csp_meta_tag if respond_to?(:csp_meta_tag) %>
|
|
9
|
+
|
|
10
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
11
|
+
<style>[x-cloak] { display: none !important; }</style>
|
|
12
|
+
<script>
|
|
13
|
+
<%= raw builder_js %>
|
|
14
|
+
</script>
|
|
15
|
+
<script defer src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
|
16
|
+
</head>
|
|
17
|
+
<body class="bg-slate-50 text-slate-900 min-h-screen">
|
|
18
|
+
<header class="bg-white border-b border-slate-200">
|
|
19
|
+
<div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
20
|
+
<%= link_to "Overule", rules_path, class: "text-xl font-semibold tracking-tight" %>
|
|
21
|
+
<nav class="flex gap-2">
|
|
22
|
+
<%= link_to "Rules", rules_path, class: "px-3 py-1.5 text-sm rounded hover:bg-slate-100" %>
|
|
23
|
+
<%= link_to "Activity", activities_path, class: "px-3 py-1.5 text-sm rounded hover:bg-slate-100" %>
|
|
24
|
+
<%= link_to "New rule", new_rule_path, class: "px-3 py-1.5 text-sm rounded bg-slate-900 text-white hover:bg-slate-700" %>
|
|
25
|
+
</nav>
|
|
26
|
+
</div>
|
|
27
|
+
</header>
|
|
28
|
+
|
|
29
|
+
<% if notice = flash[:notice] %>
|
|
30
|
+
<div class="max-w-5xl mx-auto px-6 mt-4">
|
|
31
|
+
<div class="bg-emerald-100 border border-emerald-200 text-emerald-900 px-4 py-2 rounded text-sm"><%= notice %></div>
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
34
|
+
|
|
35
|
+
<main class="max-w-5xl mx-auto px-6 py-8">
|
|
36
|
+
<%= yield %>
|
|
37
|
+
</main>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<%
|
|
2
|
+
badge_classes = case activity.action
|
|
3
|
+
when "created" then "bg-emerald-100 text-emerald-800 border-emerald-200"
|
|
4
|
+
when "updated" then "bg-sky-100 text-sky-800 border-sky-200"
|
|
5
|
+
when "destroyed" then "bg-rose-100 text-rose-800 border-rose-200"
|
|
6
|
+
end
|
|
7
|
+
%>
|
|
8
|
+
<article class="border border-slate-200 rounded-lg bg-white p-4" x-data="{ open: false }">
|
|
9
|
+
<header class="flex items-start gap-3">
|
|
10
|
+
<span class="<%= badge_classes %> text-xs font-medium uppercase tracking-wide border rounded px-2 py-0.5">
|
|
11
|
+
<%= activity.action %>
|
|
12
|
+
</span>
|
|
13
|
+
|
|
14
|
+
<% if activity.rule_version %>
|
|
15
|
+
<% if activity.rule %>
|
|
16
|
+
<%= link_to "v#{activity.rule_version.version}", rule_version_path(activity.rule, activity.rule_version),
|
|
17
|
+
class: "text-xs font-mono text-sky-700 underline decoration-sky-300 underline-offset-2 hover:decoration-sky-700 hover:bg-sky-50 border border-sky-200 rounded px-2 py-0.5 cursor-pointer",
|
|
18
|
+
title: "View version #{activity.rule_version.version} snapshot" %>
|
|
19
|
+
<% else %>
|
|
20
|
+
<span class="text-xs font-mono text-slate-500 border border-slate-200 rounded px-2 py-0.5">
|
|
21
|
+
v<%= activity.rule_version.version %>
|
|
22
|
+
</span>
|
|
23
|
+
<% end %>
|
|
24
|
+
<% end %>
|
|
25
|
+
|
|
26
|
+
<div class="flex-1 min-w-0">
|
|
27
|
+
<p class="text-sm">
|
|
28
|
+
<% if activity.rule %>
|
|
29
|
+
<%= link_to activity.rule_name, rule_path(activity.rule), class: "font-medium text-slate-900 hover:underline" %>
|
|
30
|
+
<% else %>
|
|
31
|
+
<span class="font-medium text-slate-700"><%= activity.rule_name %></span>
|
|
32
|
+
<span class="text-xs text-slate-400 italic">(deleted)</span>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% if activity.action == "updated" && activity.changed_fields.any? %>
|
|
35
|
+
<span class="text-slate-500 text-xs">
|
|
36
|
+
— changed <%= activity.changed_fields.map { |f| "<code class=\"font-mono\">#{f}</code>".html_safe }.to_sentence.html_safe %>
|
|
37
|
+
</span>
|
|
38
|
+
<% end %>
|
|
39
|
+
</p>
|
|
40
|
+
<p class="text-xs text-slate-500 mt-0.5">
|
|
41
|
+
<%= activity.created_at.strftime("%Y-%m-%d %H:%M:%S") %>
|
|
42
|
+
· <%= activity.actor.presence || "anonymous" %>
|
|
43
|
+
</p>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<button type="button" @click="open = !open"
|
|
47
|
+
class="text-xs text-slate-500 hover:text-slate-900 px-2 py-1 rounded hover:bg-slate-100">
|
|
48
|
+
<span x-show="!open">Details</span>
|
|
49
|
+
<span x-show="open">Hide</span>
|
|
50
|
+
</button>
|
|
51
|
+
</header>
|
|
52
|
+
|
|
53
|
+
<div x-show="open" x-cloak class="mt-3 pt-3 border-t border-slate-100">
|
|
54
|
+
<pre class="text-xs bg-slate-50 border border-slate-200 rounded p-3 overflow-x-auto"><code><%= JSON.pretty_generate(activity.diff) %></code></pre>
|
|
55
|
+
</div>
|
|
56
|
+
</article>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<div class="flex items-center justify-between mb-6">
|
|
2
|
+
<div>
|
|
3
|
+
<h1 class="text-2xl font-semibold">
|
|
4
|
+
<% if @rule_filter %>
|
|
5
|
+
Activity for <span class="font-mono"><%= @rule_filter.name %></span>
|
|
6
|
+
<% else %>
|
|
7
|
+
Activities
|
|
8
|
+
<% end %>
|
|
9
|
+
</h1>
|
|
10
|
+
<% if @rule_filter %>
|
|
11
|
+
<p class="text-slate-500 text-sm mt-1">
|
|
12
|
+
<%= link_to "← All Activity", activities_path, class: "hover:underline" %>
|
|
13
|
+
</p>
|
|
14
|
+
<% else %>
|
|
15
|
+
<p class="text-slate-500 text-sm mt-1">Most recent rule changes across the system. Showing up to 200.</p>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<% if @activities.empty? %>
|
|
21
|
+
<div class="bg-white border border-dashed border-slate-300 rounded-lg p-12 text-center text-slate-500">
|
|
22
|
+
No Activity yet.
|
|
23
|
+
</div>
|
|
24
|
+
<% else %>
|
|
25
|
+
<div class="space-y-2">
|
|
26
|
+
<% @activities.each do |activity| %>
|
|
27
|
+
<%= render "activity", activity: activity %>
|
|
28
|
+
<% end %>
|
|
29
|
+
</div>
|
|
30
|
+
<% end %>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<div class="flex items-center justify-between mb-6">
|
|
2
|
+
<div>
|
|
3
|
+
<h1 class="text-2xl font-semibold">
|
|
4
|
+
Versions of <span class="font-mono"><%= @rule.name %></span>
|
|
5
|
+
</h1>
|
|
6
|
+
<p class="text-slate-500 text-sm mt-1">
|
|
7
|
+
<%= link_to "← Back to rule", rule_path(@rule), class: "hover:underline" %>
|
|
8
|
+
</p>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<% if @versions.empty? %>
|
|
13
|
+
<div class="bg-white border border-dashed border-slate-300 rounded-lg p-12 text-center text-slate-500">
|
|
14
|
+
No versions recorded yet.
|
|
15
|
+
</div>
|
|
16
|
+
<% else %>
|
|
17
|
+
<div class="bg-white border border-slate-200 rounded-lg overflow-hidden">
|
|
18
|
+
<table class="min-w-full text-sm">
|
|
19
|
+
<thead class="bg-slate-50 text-slate-600 text-left">
|
|
20
|
+
<tr>
|
|
21
|
+
<th class="px-4 py-2 font-medium w-20">Version</th>
|
|
22
|
+
<th class="px-4 py-2 font-medium">Name</th>
|
|
23
|
+
<th class="px-4 py-2 font-medium">Enabled</th>
|
|
24
|
+
<th class="px-4 py-2 font-medium">Captured</th>
|
|
25
|
+
<th class="px-4 py-2"></th>
|
|
26
|
+
</tr>
|
|
27
|
+
</thead>
|
|
28
|
+
<tbody class="divide-y divide-slate-200">
|
|
29
|
+
<% @versions.each do |version| %>
|
|
30
|
+
<tr>
|
|
31
|
+
<td class="px-4 py-3 font-mono text-slate-700">v<%= version.version %></td>
|
|
32
|
+
<td class="px-4 py-3"><%= version.name %></td>
|
|
33
|
+
<td class="px-4 py-3"><%= version.enabled? ? "yes" : "no" %></td>
|
|
34
|
+
<td class="px-4 py-3 text-slate-500"><%= version.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
|
|
35
|
+
<td class="px-4 py-3 text-right">
|
|
36
|
+
<%= link_to "View →", rule_version_path(@rule, version),
|
|
37
|
+
class: "text-slate-700 hover:underline" %>
|
|
38
|
+
</td>
|
|
39
|
+
</tr>
|
|
40
|
+
<% end %>
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
</div>
|
|
44
|
+
<% end %>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<%
|
|
2
|
+
prev_version = @rule.versions.where("version < ?", @version.version).ordered.last
|
|
3
|
+
next_version = @rule.versions.where("version > ?", @version.version).ordered.first
|
|
4
|
+
%>
|
|
5
|
+
|
|
6
|
+
<div class="flex items-start justify-between mb-6 gap-4">
|
|
7
|
+
<div>
|
|
8
|
+
<h1 class="text-2xl font-semibold">
|
|
9
|
+
<span class="font-mono text-slate-500">v<%= @version.version %></span>
|
|
10
|
+
<%= @version.name %>
|
|
11
|
+
</h1>
|
|
12
|
+
<p class="text-slate-500 text-sm mt-1">
|
|
13
|
+
Captured <%= @version.created_at.strftime("%Y-%m-%d %H:%M:%S") %>
|
|
14
|
+
· <%= link_to "← Back to rule", rule_path(@rule), class: "hover:underline" %>
|
|
15
|
+
· <%= link_to "All versions", rule_versions_path(@rule), class: "hover:underline" %>
|
|
16
|
+
</p>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="flex gap-2 shrink-0">
|
|
19
|
+
<% if prev_version %>
|
|
20
|
+
<%= link_to "← v#{prev_version.version}", rule_version_path(@rule, prev_version),
|
|
21
|
+
class: "px-3 py-1.5 text-sm rounded border border-slate-300 hover:bg-slate-100" %>
|
|
22
|
+
<% end %>
|
|
23
|
+
<% if next_version %>
|
|
24
|
+
<%= link_to "v#{next_version.version} →", rule_version_path(@rule, next_version),
|
|
25
|
+
class: "px-3 py-1.5 text-sm rounded border border-slate-300 hover:bg-slate-100" %>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<section class="bg-white border border-slate-200 rounded-lg p-4 mb-4" x-data="{ copied: false }">
|
|
31
|
+
<div class="flex items-center justify-between mb-2">
|
|
32
|
+
<h2 class="text-sm font-medium text-slate-600">Definition at v<%= @version.version %></h2>
|
|
33
|
+
<button type="button"
|
|
34
|
+
@click="navigator.clipboard.writeText($refs.def.textContent).then(() => { copied = true; setTimeout(() => copied = false, 1500) })"
|
|
35
|
+
class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">
|
|
36
|
+
<span x-text="copied ? 'Copied!' : 'Copy'"></span>
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
<pre class="text-xs bg-slate-50 border border-slate-200 rounded p-3 overflow-x-auto"><code x-ref="def"><%= JSON.pretty_generate(@version.definition) %></code></pre>
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<dl class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
|
43
|
+
<div class="bg-white border border-slate-200 rounded p-3">
|
|
44
|
+
<dt class="text-xs uppercase text-slate-500 tracking-wide mb-1">Name</dt>
|
|
45
|
+
<dd class="font-mono"><%= @version.name %></dd>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="bg-white border border-slate-200 rounded p-3">
|
|
48
|
+
<dt class="text-xs uppercase text-slate-500 tracking-wide mb-1">Enabled</dt>
|
|
49
|
+
<dd><%= @version.enabled? ? "yes" : "no" %></dd>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="bg-white border border-slate-200 rounded p-3">
|
|
52
|
+
<dt class="text-xs uppercase text-slate-500 tracking-wide mb-1">Description</dt>
|
|
53
|
+
<dd class="text-slate-600"><%= @version.description.presence || "—" %></dd>
|
|
54
|
+
</div>
|
|
55
|
+
</dl>
|