decision_agent 1.0.1 → 1.2.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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +0 -0
  3. data/README.md +64 -108
  4. data/lib/decision_agent/ab_testing/ab_test.rb +5 -1
  5. data/lib/decision_agent/ab_testing/ab_test_assignment.rb +2 -0
  6. data/lib/decision_agent/ab_testing/ab_test_manager.rb +2 -0
  7. data/lib/decision_agent/ab_testing/ab_testing_agent.rb +2 -0
  8. data/lib/decision_agent/ab_testing/storage/activerecord_adapter.rb +2 -16
  9. data/lib/decision_agent/ab_testing/storage/adapter.rb +2 -0
  10. data/lib/decision_agent/ab_testing/storage/memory_adapter.rb +2 -0
  11. data/lib/decision_agent/agent.rb +49 -51
  12. data/lib/decision_agent/audit/adapter.rb +2 -0
  13. data/lib/decision_agent/audit/logger_adapter.rb +2 -0
  14. data/lib/decision_agent/audit/null_adapter.rb +2 -0
  15. data/lib/decision_agent/auth/access_audit_logger.rb +2 -0
  16. data/lib/decision_agent/auth/authenticator.rb +2 -0
  17. data/lib/decision_agent/auth/password_reset_manager.rb +2 -0
  18. data/lib/decision_agent/auth/password_reset_token.rb +2 -0
  19. data/lib/decision_agent/auth/permission.rb +2 -0
  20. data/lib/decision_agent/auth/permission_checker.rb +2 -0
  21. data/lib/decision_agent/auth/rbac_adapter.rb +2 -0
  22. data/lib/decision_agent/auth/rbac_config.rb +2 -0
  23. data/lib/decision_agent/auth/role.rb +2 -0
  24. data/lib/decision_agent/auth/session.rb +2 -0
  25. data/lib/decision_agent/auth/session_manager.rb +2 -0
  26. data/lib/decision_agent/auth/user.rb +2 -0
  27. data/lib/decision_agent/context.rb +13 -0
  28. data/lib/decision_agent/decision.rb +11 -2
  29. data/lib/decision_agent/dmn/adapter.rb +2 -0
  30. data/lib/decision_agent/dmn/cache.rb +2 -2
  31. data/lib/decision_agent/dmn/decision_graph.rb +7 -7
  32. data/lib/decision_agent/dmn/decision_tree.rb +16 -8
  33. data/lib/decision_agent/dmn/errors.rb +2 -0
  34. data/lib/decision_agent/dmn/exporter.rb +43 -2
  35. data/lib/decision_agent/dmn/feel/evaluator.rb +102 -112
  36. data/lib/decision_agent/dmn/feel/functions.rb +2 -0
  37. data/lib/decision_agent/dmn/feel/parser.rb +2 -0
  38. data/lib/decision_agent/dmn/feel/simple_parser.rb +98 -77
  39. data/lib/decision_agent/dmn/feel/transformer.rb +56 -102
  40. data/lib/decision_agent/dmn/feel/types.rb +2 -0
  41. data/lib/decision_agent/dmn/importer.rb +2 -0
  42. data/lib/decision_agent/dmn/model.rb +2 -4
  43. data/lib/decision_agent/dmn/parser.rb +2 -0
  44. data/lib/decision_agent/dmn/testing.rb +3 -6
  45. data/lib/decision_agent/dmn/validator.rb +8 -10
  46. data/lib/decision_agent/dmn/versioning.rb +41 -15
  47. data/lib/decision_agent/dmn/visualizer.rb +7 -6
  48. data/lib/decision_agent/dsl/condition_evaluator.rb +197 -1473
  49. data/lib/decision_agent/dsl/helpers/cache_helpers.rb +82 -0
  50. data/lib/decision_agent/dsl/helpers/comparison_helpers.rb +98 -0
  51. data/lib/decision_agent/dsl/helpers/date_helpers.rb +91 -0
  52. data/lib/decision_agent/dsl/helpers/geospatial_helpers.rb +85 -0
  53. data/lib/decision_agent/dsl/helpers/operator_evaluation_helpers.rb +160 -0
  54. data/lib/decision_agent/dsl/helpers/parameter_parsing_helpers.rb +206 -0
  55. data/lib/decision_agent/dsl/helpers/template_helpers.rb +39 -0
  56. data/lib/decision_agent/dsl/helpers/utility_helpers.rb +45 -0
  57. data/lib/decision_agent/dsl/operators/base.rb +70 -0
  58. data/lib/decision_agent/dsl/operators/basic_comparison_operators.rb +80 -0
  59. data/lib/decision_agent/dsl/operators/collection_operators.rb +60 -0
  60. data/lib/decision_agent/dsl/operators/date_arithmetic_operators.rb +206 -0
  61. data/lib/decision_agent/dsl/operators/date_time_operators.rb +47 -0
  62. data/lib/decision_agent/dsl/operators/duration_operators.rb +149 -0
  63. data/lib/decision_agent/dsl/operators/financial_operators.rb +237 -0
  64. data/lib/decision_agent/dsl/operators/geospatial_operators.rb +106 -0
  65. data/lib/decision_agent/dsl/operators/mathematical_operators.rb +234 -0
  66. data/lib/decision_agent/dsl/operators/moving_window_operators.rb +135 -0
  67. data/lib/decision_agent/dsl/operators/numeric_operators.rb +120 -0
  68. data/lib/decision_agent/dsl/operators/rate_operators.rb +65 -0
  69. data/lib/decision_agent/dsl/operators/statistical_aggregations.rb +187 -0
  70. data/lib/decision_agent/dsl/operators/string_aggregations.rb +84 -0
  71. data/lib/decision_agent/dsl/operators/string_operators.rb +72 -0
  72. data/lib/decision_agent/dsl/operators/time_component_operators.rb +72 -0
  73. data/lib/decision_agent/dsl/rule_parser.rb +2 -0
  74. data/lib/decision_agent/dsl/schema_validator.rb +9 -24
  75. data/lib/decision_agent/errors.rb +2 -0
  76. data/lib/decision_agent/evaluation.rb +14 -2
  77. data/lib/decision_agent/evaluation_validator.rb +0 -0
  78. data/lib/decision_agent/evaluators/base.rb +2 -0
  79. data/lib/decision_agent/evaluators/dmn_evaluator.rb +2 -0
  80. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +28 -41
  81. data/lib/decision_agent/evaluators/static_evaluator.rb +2 -0
  82. data/lib/decision_agent/explainability/condition_trace.rb +2 -0
  83. data/lib/decision_agent/explainability/explainability_result.rb +2 -4
  84. data/lib/decision_agent/explainability/rule_trace.rb +2 -0
  85. data/lib/decision_agent/explainability/trace_collector.rb +2 -0
  86. data/lib/decision_agent/monitoring/alert_manager.rb +2 -15
  87. data/lib/decision_agent/monitoring/dashboard/public/dashboard.css +0 -0
  88. data/lib/decision_agent/monitoring/dashboard/public/dashboard.js +0 -0
  89. data/lib/decision_agent/monitoring/dashboard/public/index.html +0 -0
  90. data/lib/decision_agent/monitoring/dashboard_server.rb +383 -250
  91. data/lib/decision_agent/monitoring/metrics_collector.rb +2 -0
  92. data/lib/decision_agent/monitoring/monitored_agent.rb +2 -0
  93. data/lib/decision_agent/monitoring/prometheus_exporter.rb +3 -1
  94. data/lib/decision_agent/monitoring/storage/activerecord_adapter.rb +0 -0
  95. data/lib/decision_agent/monitoring/storage/base_adapter.rb +0 -0
  96. data/lib/decision_agent/monitoring/storage/memory_adapter.rb +1 -1
  97. data/lib/decision_agent/replay/replay.rb +4 -1
  98. data/lib/decision_agent/scoring/base.rb +2 -0
  99. data/lib/decision_agent/scoring/consensus.rb +2 -0
  100. data/lib/decision_agent/scoring/max_weight.rb +2 -0
  101. data/lib/decision_agent/scoring/threshold.rb +2 -0
  102. data/lib/decision_agent/scoring/weighted_average.rb +2 -0
  103. data/lib/decision_agent/simulation/errors.rb +2 -0
  104. data/lib/decision_agent/simulation/impact_analyzer.rb +3 -3
  105. data/lib/decision_agent/simulation/monte_carlo_simulator.rb +11 -10
  106. data/lib/decision_agent/simulation/replay_engine.rb +3 -3
  107. data/lib/decision_agent/simulation/scenario_engine.rb +3 -1
  108. data/lib/decision_agent/simulation/scenario_library.rb +2 -0
  109. data/lib/decision_agent/simulation/shadow_test_engine.rb +3 -16
  110. data/lib/decision_agent/simulation/what_if_analyzer.rb +17 -13
  111. data/lib/decision_agent/simulation.rb +2 -0
  112. data/lib/decision_agent/testing/batch_test_importer.rb +6 -6
  113. data/lib/decision_agent/testing/batch_test_runner.rb +5 -4
  114. data/lib/decision_agent/testing/test_coverage_analyzer.rb +2 -0
  115. data/lib/decision_agent/testing/test_result_comparator.rb +56 -63
  116. data/lib/decision_agent/testing/test_scenario.rb +2 -0
  117. data/lib/decision_agent/version.rb +3 -1
  118. data/lib/decision_agent/versioning/activerecord_adapter.rb +159 -47
  119. data/lib/decision_agent/versioning/adapter.rb +42 -0
  120. data/lib/decision_agent/versioning/file_storage_adapter.rb +96 -13
  121. data/lib/decision_agent/versioning/version_manager.rb +49 -2
  122. data/lib/decision_agent/web/dmn_editor/serialization.rb +74 -0
  123. data/lib/decision_agent/web/dmn_editor/xml_builder.rb +107 -0
  124. data/lib/decision_agent/web/dmn_editor.rb +8 -73
  125. data/lib/decision_agent/web/middleware/auth_middleware.rb +2 -0
  126. data/lib/decision_agent/web/middleware/permission_middleware.rb +3 -1
  127. data/lib/decision_agent/web/public/app.js +67 -26
  128. data/lib/decision_agent/web/public/batch_testing.html +80 -6
  129. data/lib/decision_agent/web/public/dmn-editor.css +0 -0
  130. data/lib/decision_agent/web/public/dmn-editor.html +2 -2
  131. data/lib/decision_agent/web/public/dmn-editor.js +79 -8
  132. data/lib/decision_agent/web/public/index.html +20 -3
  133. data/lib/decision_agent/web/public/login.html +1 -1
  134. data/lib/decision_agent/web/public/sample_batch.csv +11 -0
  135. data/lib/decision_agent/web/public/sample_impact.csv +11 -0
  136. data/lib/decision_agent/web/public/sample_replay.csv +11 -0
  137. data/lib/decision_agent/web/public/sample_rules.json +118 -0
  138. data/lib/decision_agent/web/public/sample_shadow.csv +11 -0
  139. data/lib/decision_agent/web/public/sample_whatif.csv +11 -0
  140. data/lib/decision_agent/web/public/simulation.html +23 -7
  141. data/lib/decision_agent/web/public/simulation_impact.html +37 -20
  142. data/lib/decision_agent/web/public/simulation_replay.html +19 -23
  143. data/lib/decision_agent/web/public/simulation_shadow.html +36 -21
  144. data/lib/decision_agent/web/public/simulation_whatif.html +38 -21
  145. data/lib/decision_agent/web/public/styles.css +0 -0
  146. data/lib/decision_agent/web/public/users.html +1 -1
  147. data/lib/decision_agent/web/rack_helpers.rb +106 -0
  148. data/lib/decision_agent/web/rack_request_helpers.rb +196 -0
  149. data/lib/decision_agent/web/server.rb +2038 -1851
  150. data/lib/decision_agent.rb +3 -43
  151. data/lib/generators/decision_agent/install/install_generator.rb +2 -0
  152. data/lib/generators/decision_agent/install/templates/README +0 -0
  153. data/lib/generators/decision_agent/install/templates/ab_test_assignment_model.rb +2 -0
  154. data/lib/generators/decision_agent/install/templates/ab_test_model.rb +2 -0
  155. data/lib/generators/decision_agent/install/templates/ab_testing_migration.rb +2 -0
  156. data/lib/generators/decision_agent/install/templates/ab_testing_tasks.rake +0 -0
  157. data/lib/generators/decision_agent/install/templates/decision_agent_tasks.rake +0 -0
  158. data/lib/generators/decision_agent/install/templates/decision_log.rb +0 -0
  159. data/lib/generators/decision_agent/install/templates/error_metric.rb +0 -0
  160. data/lib/generators/decision_agent/install/templates/evaluation_metric.rb +0 -0
  161. data/lib/generators/decision_agent/install/templates/migration.rb +16 -0
  162. data/lib/generators/decision_agent/install/templates/monitoring_migration.rb +0 -2
  163. data/lib/generators/decision_agent/install/templates/performance_metric.rb +0 -0
  164. data/lib/generators/decision_agent/install/templates/rule.rb +2 -0
  165. data/lib/generators/decision_agent/install/templates/rule_version.rb +2 -0
  166. data/lib/generators/decision_agent/install/templates/rule_version_tag.rb +23 -0
  167. data/lib/generators/decision_agent/install/templates/versioning_migration.rb +44 -0
  168. data/lib/generators/decision_agent/monitoring_migration/monitoring_migration_generator.rb +67 -0
  169. data/lib/generators/decision_agent/versioning_migration/versioning_migration_generator.rb +57 -0
  170. metadata +66 -25
  171. data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +0 -86
  172. data/lib/decision_agent/data_enrichment/cache_adapter.rb +0 -49
  173. data/lib/decision_agent/data_enrichment/circuit_breaker.rb +0 -135
  174. data/lib/decision_agent/data_enrichment/client.rb +0 -220
  175. data/lib/decision_agent/data_enrichment/config.rb +0 -78
  176. data/lib/decision_agent/data_enrichment/errors.rb +0 -36
@@ -1,5 +1,9 @@
1
- require "sinatra/base"
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
2
4
  require "json"
5
+ require_relative "../web/rack_helpers"
6
+ require_relative "../web/rack_request_helpers"
3
7
 
4
8
  # Faye/WebSocket is optional for real-time features
5
9
  begin
@@ -14,29 +18,320 @@ end
14
18
  module DecisionAgent
15
19
  module Monitoring
16
20
  # Real-time monitoring dashboard server
17
- class DashboardServer < Sinatra::Base
18
- set :public_folder, File.expand_path("dashboard/public", __dir__)
19
- set :views, File.expand_path("dashboard/views", __dir__)
20
- set :bind, "0.0.0.0"
21
- set :port, 4568
22
- set :server, :puma
23
-
24
- # Enable CORS
25
- before do
26
- headers["Access-Control-Allow-Origin"] = "*"
27
- headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
28
- headers["Access-Control-Allow-Headers"] = "Content-Type"
29
- end
30
-
31
- options "*" do
32
- 200
33
- end
21
+ # Framework-agnostic: Pure Rack application compatible with any Rack server
22
+ class DashboardServer
23
+ PUBLIC_FOLDER = File.expand_path("dashboard/public", __dir__)
24
+ VIEWS_FOLDER = File.expand_path("dashboard/views", __dir__)
34
25
 
35
- # Class-level configuration
36
26
  class << self
37
- attr_accessor :metrics_collector, :prometheus_exporter, :alert_manager
27
+ attr_accessor :metrics_collector, :prometheus_exporter, :alert_manager, :public_folder, :views_folder, :bind, :port
38
28
  attr_reader :websocket_clients
39
29
 
30
+ def router
31
+ @router ||= begin
32
+ router = Web::RackHelpers::Router.new
33
+ define_routes(router)
34
+ router
35
+ end
36
+ end
37
+
38
+ # Rack call method - entry point for Rack requests
39
+ def call(env)
40
+ new.call(env)
41
+ end
42
+
43
+ def define_routes(router)
44
+ # Enable CORS
45
+ router.before do |ctx|
46
+ ctx.headers["Access-Control-Allow-Origin"] = "*"
47
+ ctx.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
48
+ ctx.headers["Access-Control-Allow-Headers"] = "Content-Type"
49
+ end
50
+
51
+ # OPTIONS handler for CORS preflight
52
+ router.options "*" do |ctx|
53
+ ctx.status(200)
54
+ ctx.body("")
55
+ end
56
+
57
+ # Class-level configuration
58
+ class << self
59
+ attr_accessor :metrics_collector, :prometheus_exporter, :alert_manager
60
+ attr_reader :websocket_clients
61
+
62
+ def configure_monitoring(metrics_collector:, prometheus_exporter:, alert_manager:)
63
+ @metrics_collector = metrics_collector
64
+ @prometheus_exporter = prometheus_exporter
65
+ @alert_manager = alert_manager
66
+ @websocket_clients = []
67
+
68
+ setup_real_time_updates
69
+ end
70
+
71
+ def setup_real_time_updates
72
+ # Register observer for real-time metric updates
73
+ @metrics_collector.add_observer do |event_type, metric|
74
+ broadcast_to_clients({
75
+ type: "metric_update",
76
+ event: event_type,
77
+ data: metric,
78
+ timestamp: Time.now.utc.iso8601
79
+ })
80
+ end
81
+
82
+ # Register alert handler
83
+ @alert_manager.add_handler do |alert|
84
+ broadcast_to_clients({
85
+ type: "alert",
86
+ data: alert,
87
+ timestamp: Time.now.utc.iso8601
88
+ })
89
+ end
90
+ end
91
+
92
+ def broadcast_to_clients(message)
93
+ return unless WEBSOCKET_AVAILABLE
94
+ return if @websocket_clients.empty? # Skip if no clients connected
95
+
96
+ json_message = message.to_json
97
+ @websocket_clients.each do |client|
98
+ client.send(json_message) if client.ready_state == Faye::WebSocket::API::OPEN
99
+ rescue StandardError => e
100
+ warn "WebSocket send failed: #{e.message}"
101
+ end
102
+ end
103
+
104
+ def add_websocket_client(ws)
105
+ @websocket_clients << ws
106
+ end
107
+
108
+ def remove_websocket_client(ws)
109
+ @websocket_clients.delete(ws)
110
+ end
111
+ end
112
+
113
+ # Main dashboard page
114
+ router.get "/" do |ctx|
115
+ index_file = File.join(DashboardServer.public_folder || PUBLIC_FOLDER, "index.html")
116
+ if File.exist?(index_file)
117
+ ctx.send_file(index_file)
118
+ else
119
+ ctx.status(404)
120
+ ctx.body("Dashboard page not found")
121
+ end
122
+ end
123
+
124
+ # WebSocket endpoint for real-time updates
125
+ router.get "/ws" do |ctx|
126
+ unless WEBSOCKET_AVAILABLE
127
+ ctx.status(503)
128
+ ctx.content_type "application/json"
129
+ ctx.json({ error: "WebSocket support not available. Install faye-websocket gem." })
130
+ next
131
+ end
132
+
133
+ if Faye::WebSocket.websocket?(ctx.env)
134
+ ws = Faye::WebSocket.new(ctx.env)
135
+
136
+ ws.on :open do |_event|
137
+ DashboardServer.add_websocket_client(ws)
138
+
139
+ # Send initial state
140
+ ws.send({
141
+ type: "connected",
142
+ message: "Connected to DecisionAgent monitoring",
143
+ timestamp: Time.now.utc.iso8601
144
+ }.to_json)
145
+ end
146
+
147
+ ws.on :message do |event|
148
+ # Handle client messages
149
+ DashboardServer.handle_websocket_message(ws, event.data)
150
+ end
151
+
152
+ ws.on :close do |_event|
153
+ DashboardServer.remove_websocket_client(ws)
154
+ end
155
+
156
+ ws.rack_response
157
+ else
158
+ ctx.status(426)
159
+ ctx.content_type "application/json"
160
+ ctx.json({ error: "WebSocket connection required" })
161
+ end
162
+ end
163
+
164
+ # API: Get current statistics
165
+ router.get "/api/stats" do |ctx|
166
+ ctx.content_type "application/json"
167
+
168
+ time_range = (ctx.params[:time_range] || ctx.params["time_range"])&.to_i
169
+ stats = DashboardServer.metrics_collector.statistics(time_range: time_range)
170
+
171
+ ctx.json(stats)
172
+ end
173
+
174
+ # API: Get time series data
175
+ router.get "/api/timeseries/:metric_type" do |ctx|
176
+ ctx.content_type "application/json"
177
+
178
+ metric_type = (ctx.params[:metric_type] || ctx.params["metric_type"]).to_sym
179
+ bucket_size = ((ctx.params[:bucket_size] || ctx.params["bucket_size"]) || 60).to_i
180
+ time_range = ((ctx.params[:time_range] || ctx.params["time_range"]) || 3600).to_i
181
+
182
+ data = DashboardServer.metrics_collector.time_series(
183
+ metric_type: metric_type,
184
+ bucket_size: bucket_size,
185
+ time_range: time_range
186
+ )
187
+
188
+ ctx.json(data)
189
+ end
190
+
191
+ # API: Prometheus metrics endpoint
192
+ router.get "/metrics" do |ctx|
193
+ ctx.content_type DashboardServer.prometheus_exporter.class::CONTENT_TYPE
194
+ ctx.body(DashboardServer.prometheus_exporter.export)
195
+ end
196
+
197
+ # API: Get Prometheus metrics in JSON format
198
+ router.get "/api/metrics" do |ctx|
199
+ ctx.content_type "application/json"
200
+ ctx.json(DashboardServer.prometheus_exporter.metrics_hash)
201
+ end
202
+
203
+ # API: Register custom KPI
204
+ router.post "/api/kpi" do |ctx|
205
+ ctx.content_type "application/json"
206
+
207
+ begin
208
+ request_body = Web::RackRequestHelpers.read_body(ctx.env)
209
+ data = JSON.parse(request_body, symbolize_names: true)
210
+
211
+ DashboardServer.prometheus_exporter.register_kpi(
212
+ name: data[:name],
213
+ value: data[:value],
214
+ labels: data[:labels] || {},
215
+ help: data[:help]
216
+ )
217
+
218
+ ctx.json({ success: true, message: "KPI registered" })
219
+ rescue StandardError => e
220
+ ctx.status(400)
221
+ ctx.json({ error: e.message })
222
+ end
223
+ end
224
+
225
+ # API: Get active alerts
226
+ router.get "/api/alerts" do |ctx|
227
+ ctx.content_type "application/json"
228
+ ctx.json(DashboardServer.alert_manager.active_alerts)
229
+ end
230
+
231
+ # API: Get all alerts
232
+ router.get "/api/alerts/all" do |ctx|
233
+ ctx.content_type "application/json"
234
+ limit = ((ctx.params[:limit] || ctx.params["limit"]) || 100).to_i
235
+ ctx.json(DashboardServer.alert_manager.all_alerts(limit: limit))
236
+ end
237
+
238
+ # API: Create alert rule
239
+ router.post "/api/alerts/rules" do |ctx|
240
+ ctx.content_type "application/json"
241
+
242
+ begin
243
+ request_body = Web::RackRequestHelpers.read_body(ctx.env)
244
+ data = JSON.parse(request_body, symbolize_names: true)
245
+
246
+ # Parse condition
247
+ condition = DashboardServer.parse_alert_condition(data[:condition], data[:condition_type])
248
+
249
+ rule = DashboardServer.alert_manager.add_rule(
250
+ name: data[:name],
251
+ condition: condition,
252
+ severity: (data[:severity] || :warning).to_sym,
253
+ threshold: data[:threshold],
254
+ message: data[:message],
255
+ cooldown: data[:cooldown] || 300
256
+ )
257
+
258
+ ctx.status(201)
259
+ ctx.json(rule)
260
+ rescue StandardError => e
261
+ ctx.status(400)
262
+ ctx.json({ error: e.message })
263
+ end
264
+ end
265
+
266
+ # API: Toggle alert rule
267
+ router.put "/api/alerts/rules/:rule_id/toggle" do |ctx|
268
+ ctx.content_type "application/json"
269
+
270
+ begin
271
+ request_body = Web::RackRequestHelpers.read_body(ctx.env)
272
+ data = JSON.parse(request_body, symbolize_names: true)
273
+ enabled = data[:enabled] || false
274
+ rule_id = ctx.params[:rule_id] || ctx.params["rule_id"]
275
+
276
+ DashboardServer.alert_manager.toggle_rule(rule_id, enabled)
277
+
278
+ ctx.json({ success: true, message: "Rule #{enabled ? 'enabled' : 'disabled'}" })
279
+ rescue StandardError => e
280
+ ctx.status(400)
281
+ ctx.json({ error: e.message })
282
+ end
283
+ end
284
+
285
+ # API: Acknowledge alert
286
+ router.post "/api/alerts/:alert_id/acknowledge" do |ctx|
287
+ ctx.content_type "application/json"
288
+
289
+ begin
290
+ request_body = Web::RackRequestHelpers.read_body(ctx.env)
291
+ data = JSON.parse(request_body, symbolize_names: true)
292
+ acknowledged_by = data[:acknowledged_by] || "user"
293
+ alert_id = ctx.params[:alert_id] || ctx.params["alert_id"]
294
+
295
+ DashboardServer.alert_manager.acknowledge_alert(alert_id, acknowledged_by: acknowledged_by)
296
+
297
+ ctx.json({ success: true, message: "Alert acknowledged" })
298
+ rescue StandardError => e
299
+ ctx.status(400)
300
+ ctx.json({ error: e.message })
301
+ end
302
+ end
303
+
304
+ # API: Resolve alert
305
+ router.post "/api/alerts/:alert_id/resolve" do |ctx|
306
+ ctx.content_type "application/json"
307
+
308
+ begin
309
+ request_body = Web::RackRequestHelpers.read_body(ctx.env)
310
+ data = JSON.parse(request_body, symbolize_names: true)
311
+ resolved_by = data[:resolved_by] || "user"
312
+ alert_id = ctx.params[:alert_id] || ctx.params["alert_id"]
313
+
314
+ DashboardServer.alert_manager.resolve_alert(alert_id, resolved_by: resolved_by)
315
+
316
+ ctx.json({ success: true, message: "Alert resolved" })
317
+ rescue StandardError => e
318
+ ctx.status(400)
319
+ ctx.json({ error: e.message })
320
+ end
321
+ end
322
+
323
+ # Health check
324
+ router.get "/health" do |ctx|
325
+ ctx.content_type "application/json"
326
+ ctx.json({
327
+ status: "ok",
328
+ version: DecisionAgent::VERSION,
329
+ websocket_clients: DashboardServer.websocket_clients.size,
330
+ metrics_count: DashboardServer.metrics_collector.metrics_count
331
+ })
332
+ end
333
+ end
334
+
40
335
  def configure_monitoring(metrics_collector:, prometheus_exporter:, alert_manager:)
41
336
  @metrics_collector = metrics_collector
42
337
  @prometheus_exporter = prometheus_exporter
@@ -80,260 +375,98 @@ module DecisionAgent
80
375
  end
81
376
 
82
377
  def add_websocket_client(ws)
378
+ @websocket_clients ||= []
83
379
  @websocket_clients << ws
84
380
  end
85
381
 
86
382
  def remove_websocket_client(ws)
383
+ @websocket_clients ||= []
87
384
  @websocket_clients.delete(ws)
88
385
  end
89
- end
90
-
91
- # Main dashboard page
92
- get "/" do
93
- send_file File.join(settings.public_folder, "index.html")
94
- end
95
-
96
- # WebSocket endpoint for real-time updates
97
- get "/ws" do
98
- halt 503, { error: "WebSocket support not available. Install faye-websocket gem." }.to_json unless WEBSOCKET_AVAILABLE
99
-
100
- if Faye::WebSocket.websocket?(request.env)
101
- ws = Faye::WebSocket.new(request.env)
102
386
 
103
- ws.on :open do |_event|
104
- self.class.add_websocket_client(ws)
105
-
106
- # Send initial state
107
- ws.send({
108
- type: "connected",
109
- message: "Connected to DecisionAgent monitoring",
110
- timestamp: Time.now.utc.iso8601
111
- }.to_json)
112
- end
113
-
114
- ws.on :message do |event|
115
- # Handle client messages
116
- handle_websocket_message(ws, event.data)
387
+ def handle_websocket_message(ws, data)
388
+ message = JSON.parse(data, symbolize_names: true)
389
+
390
+ case message[:action]
391
+ when "subscribe"
392
+ # Send current stats
393
+ stats = metrics_collector.statistics
394
+ ws.send({ type: "stats", data: stats }.to_json)
395
+ when "get_alerts"
396
+ alerts = alert_manager.active_alerts
397
+ ws.send({ type: "alerts", data: alerts }.to_json)
117
398
  end
399
+ rescue StandardError => e
400
+ ws.send({ type: "error", message: e.message }.to_json)
401
+ end
118
402
 
119
- ws.on :close do |_event|
120
- self.class.remove_websocket_client(ws)
403
+ def parse_alert_condition(condition_data, condition_type)
404
+ case condition_type
405
+ when "high_error_rate"
406
+ AlertManager.high_error_rate(threshold: condition_data[:threshold] || 0.1)
407
+ when "low_confidence"
408
+ AlertManager.low_confidence(threshold: condition_data[:threshold] || 0.5)
409
+ when "high_latency"
410
+ AlertManager.high_latency(threshold_ms: condition_data[:threshold_ms] || 1000)
411
+ when "error_spike"
412
+ AlertManager.error_spike(threshold: condition_data[:threshold] || 10)
413
+ when "custom"
414
+ condition_data
415
+ else
416
+ raise "Unknown condition type: #{condition_type}"
121
417
  end
122
-
123
- ws.rack_response
124
- else
125
- status 426
126
- { error: "WebSocket connection required" }.to_json
127
418
  end
128
- end
129
-
130
- # API: Get current statistics
131
- get "/api/stats" do
132
- content_type :json
133
-
134
- time_range = params[:time_range]&.to_i
135
- stats = self.class.metrics_collector.statistics(time_range: time_range)
136
-
137
- stats.to_json
138
- end
139
-
140
- # API: Get time series data
141
- get "/api/timeseries/:metric_type" do
142
- content_type :json
143
-
144
- metric_type = params[:metric_type].to_sym
145
- bucket_size = (params[:bucket_size] || 60).to_i
146
- time_range = (params[:time_range] || 3600).to_i
147
-
148
- data = self.class.metrics_collector.time_series(
149
- metric_type: metric_type,
150
- bucket_size: bucket_size,
151
- time_range: time_range
152
- )
153
-
154
- data.to_json
155
- end
156
419
 
157
- # API: Prometheus metrics endpoint
158
- get "/metrics" do
159
- content_type PrometheusExporter::CONTENT_TYPE
160
- self.class.prometheus_exporter.export
161
- end
162
-
163
- # API: Get Prometheus metrics in JSON format
164
- get "/api/metrics" do
165
- content_type :json
166
- self.class.prometheus_exporter.metrics_hash.to_json
167
- end
168
-
169
- # API: Register custom KPI
170
- post "/api/kpi" do
171
- content_type :json
172
-
173
- begin
174
- data = JSON.parse(request.body.read, symbolize_names: true)
175
-
176
- self.class.prometheus_exporter.register_kpi(
177
- name: data[:name],
178
- value: data[:value],
179
- labels: data[:labels] || {},
180
- help: data[:help]
420
+ # Class method to start the server
421
+ # Framework-agnostic: uses Rack::Server which supports any Rack-compatible server
422
+ def start!(metrics_collector:, prometheus_exporter:, alert_manager:, port: 4568, host: "0.0.0.0")
423
+ configure_monitoring(
424
+ metrics_collector: metrics_collector,
425
+ prometheus_exporter: prometheus_exporter,
426
+ alert_manager: alert_manager
181
427
  )
182
428
 
183
- { success: true, message: "KPI registered" }.to_json
184
- rescue StandardError => e
185
- status 400
186
- { error: e.message }.to_json
187
- end
188
- end
189
-
190
- # API: Get active alerts
191
- get "/api/alerts" do
192
- content_type :json
193
- self.class.alert_manager.active_alerts.to_json
194
- end
195
-
196
- # API: Get all alerts
197
- get "/api/alerts/all" do
198
- content_type :json
199
- limit = (params[:limit] || 100).to_i
200
- self.class.alert_manager.all_alerts(limit: limit).to_json
201
- end
202
-
203
- # API: Create alert rule
204
- post "/api/alerts/rules" do
205
- content_type :json
206
-
207
- begin
208
- data = JSON.parse(request.body.read, symbolize_names: true)
209
-
210
- # Parse condition
211
- condition = parse_alert_condition(data[:condition], data[:condition_type])
212
-
213
- rule = self.class.alert_manager.add_rule(
214
- name: data[:name],
215
- condition: condition,
216
- severity: (data[:severity] || :warning).to_sym,
217
- threshold: data[:threshold],
218
- message: data[:message],
219
- cooldown: data[:cooldown] || 300
429
+ @port = port
430
+ @bind = host
431
+ @public_folder = PUBLIC_FOLDER
432
+ @views_folder = VIEWS_FOLDER
433
+
434
+ puts "🎯 DecisionAgent Monitoring Dashboard starting..."
435
+ puts "📍 Server: http://#{host == '0.0.0.0' ? 'localhost' : host}:#{port}"
436
+ puts "⚡️ Press Ctrl+C to stop"
437
+ puts ""
438
+
439
+ # Use Rack::Server which automatically selects the best available handler
440
+ # Supports: Puma, WEBrick, Thin, Unicorn, etc. (any Rack-compatible server)
441
+ Rack::Server.start(
442
+ app: self,
443
+ Port: port,
444
+ Host: host,
445
+ server: ENV.fetch("RACK_HANDLER", nil), # Allows override via ENV
446
+ environment: ENV.fetch("RACK_ENV", "development")
220
447
  )
221
-
222
- status 201
223
- rule.to_json
224
- rescue StandardError => e
225
- status 400
226
- { error: e.message }.to_json
227
448
  end
228
449
  end
229
450
 
230
- # API: Toggle alert rule
231
- put "/api/alerts/rules/:rule_id/toggle" do
232
- content_type :json
451
+ def call(env)
452
+ route_match = DashboardServer.router.match(env)
453
+ return [404, { "Content-Type" => "text/plain" }, ["Not Found"]] unless route_match
233
454
 
234
- begin
235
- data = JSON.parse(request.body.read, symbolize_names: true)
236
- enabled = data[:enabled] || false
237
-
238
- self.class.alert_manager.toggle_rule(params[:rule_id], enabled)
455
+ # Create request context with route params
456
+ ctx = Web::RackRequestHelpers::RequestContext.new(env, route_match[:params] || {})
239
457
 
240
- { success: true, message: "Rule #{enabled ? 'enabled' : 'disabled'}" }.to_json
241
- rescue StandardError => e
242
- status 400
243
- { error: e.message }.to_json
458
+ # Run before filters
459
+ route_match[:before_filters].each do |filter|
460
+ filter.call(ctx)
461
+ return ctx.to_rack_response if ctx.halted?
244
462
  end
245
- end
246
-
247
- # API: Acknowledge alert
248
- post "/api/alerts/:alert_id/acknowledge" do
249
- content_type :json
250
463
 
464
+ # Execute route handler
251
465
  begin
252
- data = JSON.parse(request.body.read, symbolize_names: true)
253
- acknowledged_by = data[:acknowledged_by] || "user"
254
-
255
- self.class.alert_manager.acknowledge_alert(params[:alert_id], acknowledged_by: acknowledged_by)
256
-
257
- { success: true, message: "Alert acknowledged" }.to_json
466
+ route_match[:handler].call(ctx)
467
+ ctx.to_rack_response
258
468
  rescue StandardError => e
259
- status 400
260
- { error: e.message }.to_json
261
- end
262
- end
263
-
264
- # API: Resolve alert
265
- post "/api/alerts/:alert_id/resolve" do
266
- content_type :json
267
-
268
- begin
269
- data = JSON.parse(request.body.read, symbolize_names: true)
270
- resolved_by = data[:resolved_by] || "user"
271
-
272
- self.class.alert_manager.resolve_alert(params[:alert_id], resolved_by: resolved_by)
273
-
274
- { success: true, message: "Alert resolved" }.to_json
275
- rescue StandardError => e
276
- status 400
277
- { error: e.message }.to_json
278
- end
279
- end
280
-
281
- # Health check
282
- get "/health" do
283
- content_type :json
284
- {
285
- status: "ok",
286
- version: DecisionAgent::VERSION,
287
- websocket_clients: self.class.websocket_clients.size,
288
- metrics_count: self.class.metrics_collector.metrics_count
289
- }.to_json
290
- end
291
-
292
- # Class method to start the server
293
- def self.start!(metrics_collector:, prometheus_exporter:, alert_manager:, port: 4568, host: "0.0.0.0")
294
- configure_monitoring(
295
- metrics_collector: metrics_collector,
296
- prometheus_exporter: prometheus_exporter,
297
- alert_manager: alert_manager
298
- )
299
-
300
- set :port, port
301
- set :bind, host
302
- run!
303
- end
304
-
305
- private
306
-
307
- def handle_websocket_message(ws, data)
308
- message = JSON.parse(data, symbolize_names: true)
309
-
310
- case message[:action]
311
- when "subscribe"
312
- # Send current stats
313
- stats = self.class.metrics_collector.statistics
314
- ws.send({ type: "stats", data: stats }.to_json)
315
- when "get_alerts"
316
- alerts = self.class.alert_manager.active_alerts
317
- ws.send({ type: "alerts", data: alerts }.to_json)
318
- end
319
- rescue StandardError => e
320
- ws.send({ type: "error", message: e.message }.to_json)
321
- end
322
-
323
- def parse_alert_condition(condition_data, condition_type)
324
- case condition_type
325
- when "high_error_rate"
326
- AlertManager.high_error_rate(threshold: condition_data[:threshold] || 0.1)
327
- when "low_confidence"
328
- AlertManager.low_confidence(threshold: condition_data[:threshold] || 0.5)
329
- when "high_latency"
330
- AlertManager.high_latency(threshold_ms: condition_data[:threshold_ms] || 1000)
331
- when "error_spike"
332
- AlertManager.error_spike(threshold: condition_data[:threshold] || 10)
333
- when "custom"
334
- condition_data
335
- else
336
- raise "Unknown condition type: #{condition_type}"
469
+ [500, { "Content-Type" => "application/json" }, [{ error: e.message }.to_json]]
337
470
  end
338
471
  end
339
472
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "monitor"
2
4
  require "time"
3
5
  require_relative "storage/memory_adapter"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module DecisionAgent
2
4
  module Monitoring
3
5
  # Wrapper around Agent that automatically records metrics