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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +302 -0
  4. data/app/assets/javascripts/overule/builder.js +268 -0
  5. data/app/controllers/overule/activities_controller.rb +10 -0
  6. data/app/controllers/overule/application_controller.rb +35 -0
  7. data/app/controllers/overule/rule_versions_controller.rb +24 -0
  8. data/app/controllers/overule/rules_controller.rb +60 -0
  9. data/app/models/concerns/overule/rule_activity_behavior.rb +24 -0
  10. data/app/models/concerns/overule/rule_behavior.rb +106 -0
  11. data/app/models/concerns/overule/rule_version_behavior.rb +15 -0
  12. data/app/models/overule/current.rb +7 -0
  13. data/app/models/overule/rule.rb +48 -0
  14. data/app/models/overule/rule_activity.rb +40 -0
  15. data/app/models/overule/rule_version.rb +42 -0
  16. data/app/views/layouts/overule/application.html.erb +39 -0
  17. data/app/views/overule/activities/_activity.html.erb +56 -0
  18. data/app/views/overule/activities/index.html.erb +30 -0
  19. data/app/views/overule/rule_versions/index.html.erb +44 -0
  20. data/app/views/overule/rule_versions/show.html.erb +55 -0
  21. data/app/views/overule/rules/_form.html.erb +95 -0
  22. data/app/views/overule/rules/_group.html.erb +106 -0
  23. data/app/views/overule/rules/_static_node.html.erb +79 -0
  24. data/app/views/overule/rules/edit.html.erb +2 -0
  25. data/app/views/overule/rules/index.html.erb +45 -0
  26. data/app/views/overule/rules/new.html.erb +2 -0
  27. data/app/views/overule/rules/show.html.erb +54 -0
  28. data/config/routes.rb +8 -0
  29. data/lib/generators/overule/install/USAGE +25 -0
  30. data/lib/generators/overule/install/install_generator.rb +64 -0
  31. data/lib/generators/overule/install/templates/add_rule_version_to_overule_rule_activities.rb.tt +21 -0
  32. data/lib/generators/overule/install/templates/create_overule_rule_activities.rb.tt +15 -0
  33. data/lib/generators/overule/install/templates/create_overule_rule_versions.rb.tt +28 -0
  34. data/lib/generators/overule/install/templates/create_overule_rules.rb.tt +13 -0
  35. data/lib/generators/overule/install/templates/overule.rb.tt +35 -0
  36. data/lib/overule/action.rb +22 -0
  37. data/lib/overule/condition.rb +40 -0
  38. data/lib/overule/configuration.rb +75 -0
  39. data/lib/overule/context.rb +22 -0
  40. data/lib/overule/engine.rb +7 -0
  41. data/lib/overule/inference.rb +38 -0
  42. data/lib/overule/operator.rb +25 -0
  43. data/lib/overule/version.rb +3 -0
  44. data/lib/overule.rb +13 -0
  45. 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,7 @@
1
+ module Overule
2
+ # Host apps set Current.actor in a before_action so activity log entries
3
+ # can attribute changes to a user. Falls back to nil when unset.
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ attribute :actor
6
+ end
7
+ 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>