anomonitor 0.6.1

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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +90 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +275 -0
  5. data/RELEASE.md +32 -0
  6. data/Rakefile +13 -0
  7. data/SECURITY.md +24 -0
  8. data/app/assets/config/anomonitor_manifest.js +1 -0
  9. data/app/assets/stylesheets/anomonitor/application.css +370 -0
  10. data/app/controllers/anomonitor/anomalies_controller.rb +44 -0
  11. data/app/controllers/anomonitor/application_controller.rb +21 -0
  12. data/app/controllers/anomonitor/dashboard_controller.rb +69 -0
  13. data/app/controllers/anomonitor/jobs_controller.rb +61 -0
  14. data/app/controllers/anomonitor/metrics_controller.rb +18 -0
  15. data/app/controllers/anomonitor/mutes_controller.rb +41 -0
  16. data/app/helpers/anomonitor/application_helper.rb +71 -0
  17. data/app/models/anomonitor/anomaly.rb +105 -0
  18. data/app/models/anomonitor/application_record.rb +5 -0
  19. data/app/models/anomonitor/metric_sample.rb +25 -0
  20. data/app/models/anomonitor/mute.rb +56 -0
  21. data/app/views/anomonitor/anomalies/index.html.erb +54 -0
  22. data/app/views/anomonitor/anomalies/show.html.erb +54 -0
  23. data/app/views/anomonitor/dashboard/show.html.erb +124 -0
  24. data/app/views/anomonitor/jobs/index.html.erb +135 -0
  25. data/app/views/anomonitor/metrics/index.html.erb +33 -0
  26. data/app/views/anomonitor/mutes/index.html.erb +56 -0
  27. data/app/views/layouts/anomonitor/application.html.erb +39 -0
  28. data/config/routes.rb +15 -0
  29. data/db/migrate/20260807100000_create_anomonitor_metric_samples.rb +17 -0
  30. data/db/migrate/20260807100001_create_anomonitor_anomalies.rb +23 -0
  31. data/db/migrate/20260808100000_add_resolved_at_to_anomonitor_anomalies.rb +6 -0
  32. data/db/migrate/20260808120000_create_anomonitor_mutes.rb +16 -0
  33. data/lib/anomonitor/collectors/base.rb +25 -0
  34. data/lib/anomonitor/collectors/delayed_job.rb +55 -0
  35. data/lib/anomonitor/collectors/schema_drift.rb +144 -0
  36. data/lib/anomonitor/collectors/sidekiq.rb +47 -0
  37. data/lib/anomonitor/collectors/solid_queue.rb +57 -0
  38. data/lib/anomonitor/collectors/table.rb +116 -0
  39. data/lib/anomonitor/configuration.rb +184 -0
  40. data/lib/anomonitor/configuration_validator.rb +40 -0
  41. data/lib/anomonitor/detector.rb +195 -0
  42. data/lib/anomonitor/digester.rb +79 -0
  43. data/lib/anomonitor/engine.rb +35 -0
  44. data/lib/anomonitor/jobs/browser.rb +89 -0
  45. data/lib/anomonitor/jobs/browsers/delayed_job.rb +143 -0
  46. data/lib/anomonitor/jobs/browsers/sidekiq.rb +164 -0
  47. data/lib/anomonitor/jobs/browsers/solid_queue.rb +149 -0
  48. data/lib/anomonitor/jobs/browsers/table.rb +206 -0
  49. data/lib/anomonitor/jobs/row.rb +30 -0
  50. data/lib/anomonitor/metric_point.rb +42 -0
  51. data/lib/anomonitor/metrics_export.rb +55 -0
  52. data/lib/anomonitor/notifiers/callable.rb +44 -0
  53. data/lib/anomonitor/notifiers/composite.rb +33 -0
  54. data/lib/anomonitor/notifiers/rate_limited.rb +44 -0
  55. data/lib/anomonitor/notifiers/webhook.rb +108 -0
  56. data/lib/anomonitor/notifiers.rb +59 -0
  57. data/lib/anomonitor/poll_lock.rb +67 -0
  58. data/lib/anomonitor/poller.rb +164 -0
  59. data/lib/anomonitor/tenancy.rb +46 -0
  60. data/lib/anomonitor/version.rb +3 -0
  61. data/lib/anomonitor.rb +52 -0
  62. data/lib/generators/anomonitor/install/install_generator.rb +27 -0
  63. data/lib/generators/anomonitor/install/templates/anomonitor.rb +87 -0
  64. data/lib/tasks/anomonitor_tasks.rake +27 -0
  65. metadata +126 -0
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ class Anomaly < ApplicationRecord
5
+ self.table_name = "anomonitor_anomalies"
6
+
7
+ validates :rule, :source, :metric, :value, :severity, :cooldown_key, presence: true
8
+
9
+ scope :recent, -> { order(created_at: :desc) }
10
+ scope :open, -> { where(resolved_at: nil) }
11
+ scope :resolved, -> { where.not(resolved_at: nil) }
12
+ scope :webhook_failed, -> { where(webhook_status: "failed") }
13
+
14
+ def open?
15
+ resolved_at.nil?
16
+ end
17
+
18
+ def resolved?
19
+ !open?
20
+ end
21
+
22
+ def webhook_failed?
23
+ webhook_status.to_s == "failed"
24
+ end
25
+
26
+ def manual_resolve?
27
+ tag_value("resolved_by").to_s == "manual"
28
+ end
29
+
30
+ def cleared_after_ack?
31
+ tag_value("cleared_at").present?
32
+ end
33
+
34
+ def resolve!(by: "auto", notify: false)
35
+ return self if resolved?
36
+
37
+ merge_tags!("resolved_by" => by.to_s)
38
+ update!(resolved_at: Time.current)
39
+ if notify
40
+ event = by.to_s == "manual" ? Notifiers::ACKED : Notifiers::RESOLVED
41
+ Anomonitor.config.build_notifier.deliver(self, event: event)
42
+ end
43
+ self
44
+ end
45
+
46
+ def reopen!
47
+ # Lift manual-ack silence so the next matching drift can notify again.
48
+ # Keeps history as resolved unless it was still open.
49
+ if open?
50
+ merge_tags!("reopened_at" => Time.current.iso8601)
51
+ save!
52
+ return self
53
+ end
54
+
55
+ merge_tags!(
56
+ "cleared_at" => Time.current.iso8601,
57
+ "reopened_at" => Time.current.iso8601
58
+ )
59
+ save!
60
+ self
61
+ end
62
+
63
+ def retry_webhook!
64
+ delivered = Anomonitor.config.build_notifier.deliver(
65
+ self,
66
+ event: resolved? ? Notifiers::RESOLVED : Notifiers::DETECTED
67
+ )
68
+ update!(
69
+ webhook_status: delivered ? "delivered" : "failed",
70
+ webhook_delivered_at: delivered ? Time.current : webhook_delivered_at
71
+ )
72
+ delivered
73
+ end
74
+
75
+ def items_list
76
+ list = tag_value("items_list")
77
+ return Array(list) if list.is_a?(Array)
78
+
79
+ items = tag_value("items")
80
+ return [] if items.nil? || items.to_s.empty?
81
+
82
+ items.to_s.split(",").map(&:strip)
83
+ end
84
+
85
+ def tag_value(key)
86
+ t = tags
87
+ return nil unless t.is_a?(Hash)
88
+
89
+ t[key.to_s] || t[key.to_sym]
90
+ end
91
+
92
+ def merge_tags!(extra)
93
+ current = tags.is_a?(Hash) ? tags.stringify_keys : {}
94
+ extra = extra.stringify_keys
95
+ extra.each do |k, v|
96
+ if v.nil?
97
+ current.delete(k)
98
+ else
99
+ current[k] = v
100
+ end
101
+ end
102
+ self.tags = current
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,5 @@
1
+ module Anomonitor
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ class MetricSample < ApplicationRecord
5
+ self.table_name = "anomonitor_metric_samples"
6
+
7
+ validates :source, :metric, :value, :sampled_at, presence: true
8
+
9
+ scope :recent, ->(hours = 24) { where("sampled_at >= ?", hours.hours.ago) }
10
+ scope :for_metric, ->(metric) { where(metric: metric) }
11
+
12
+ def self.latest_by_source_metric
13
+ recent(6).order(sampled_at: :desc).each_with_object({}) do |sample, hash|
14
+ key = [sample.source, sample.metric, sample.tags]
15
+ hash[key] ||= sample
16
+ end.values
17
+ end
18
+
19
+ def self.series(source:, metric:, hours: 24)
20
+ where(source: source, metric: metric)
21
+ .where("sampled_at >= ?", hours.hours.ago)
22
+ .order(:sampled_at)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ class Mute < ApplicationRecord
5
+ self.table_name = "anomonitor_mutes"
6
+
7
+ validates :muted_until, presence: true
8
+
9
+ scope :active, -> { where("muted_until > ?", Time.current) }
10
+ scope :recent, -> { order(muted_until: :desc) }
11
+
12
+ def active?
13
+ muted_until > Time.current
14
+ end
15
+
16
+ def matches?(anomaly_or_attrs)
17
+ attrs =
18
+ if anomaly_or_attrs.respond_to?(:metric)
19
+ {
20
+ metric: anomaly_or_attrs.metric,
21
+ rule: anomaly_or_attrs.rule,
22
+ source: anomaly_or_attrs.source,
23
+ tenant: tenant_from(anomaly_or_attrs)
24
+ }
25
+ else
26
+ anomaly_or_attrs
27
+ end
28
+
29
+ return false if metric.present? && metric.to_s != attrs[:metric].to_s
30
+ return false if rule.present? && rule.to_s != attrs[:rule].to_s
31
+ return false if source.present? && source.to_s != attrs[:source].to_s
32
+ return false if tenant.present? && tenant.to_s != attrs[:tenant].to_s
33
+
34
+ true
35
+ end
36
+
37
+ def self.muted?(anomaly_or_attrs)
38
+ active.any? { |m| m.matches?(anomaly_or_attrs) }
39
+ rescue StandardError
40
+ false
41
+ end
42
+
43
+ def self.prune_expired!
44
+ where("muted_until <= ?", Time.current).delete_all
45
+ end
46
+
47
+ private
48
+
49
+ def tenant_from(anomaly)
50
+ tags = anomaly.tags
51
+ return nil unless tags.is_a?(Hash)
52
+
53
+ tags["tenant"] || tags[:tenant]
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,54 @@
1
+ <% content_for :title, "Anomalies" %>
2
+
3
+ <h1>Anomalies</h1>
4
+ <p class="am-lead">Threshold breaches, growth spikes, and sticky schema drift with webhook delivery status.</p>
5
+
6
+ <%= form_with url: anomalies_path, method: :get, local: true, class: "am-filters" do %>
7
+ <label>
8
+ <span>Status</span>
9
+ <select name="status">
10
+ <% %w[open resolved all].each do |status| %>
11
+ <option value="<%= status %>" <%= "selected" if @status_filter == status %>><%= status %></option>
12
+ <% end %>
13
+ </select>
14
+ </label>
15
+ <button type="submit" class="am-btn">Filter</button>
16
+ <% end %>
17
+
18
+ <% if @anomalies.any? %>
19
+ <table class="am-table">
20
+ <thead>
21
+ <tr>
22
+ <th>When</th>
23
+ <th>Status</th>
24
+ <th>Rule</th>
25
+ <th>Source</th>
26
+ <th>Metric</th>
27
+ <th>Value</th>
28
+ <th>Tenant</th>
29
+ <th>Webhook</th>
30
+ </tr>
31
+ </thead>
32
+ <tbody>
33
+ <% @anomalies.each do |anomaly| %>
34
+ <% tags = anomaly.tags.is_a?(Hash) ? anomaly.tags : {} %>
35
+ <tr>
36
+ <td><%= link_to l(anomaly.created_at, format: :short), anomaly_path(anomaly) %></td>
37
+ <td>
38
+ <span class="am-badge <%= anomaly.open? ? "warn" : "ok" %>">
39
+ <%= anomaly.open? ? "open" : "resolved" %>
40
+ </span>
41
+ </td>
42
+ <td><%= anomaly.rule %></td>
43
+ <td><%= anomaly.source %></td>
44
+ <td><%= anomaly.metric %></td>
45
+ <td><%= number_with_precision(anomaly.value, precision: 0, delimiter: ",") %></td>
46
+ <td><%= tags["tenant"] || tags[:tenant] || "—" %></td>
47
+ <td><span class="am-badge <%= anomaly.webhook_status == 'delivered' ? 'ok' : 'warn' %>"><%= anomaly.webhook_status %></span></td>
48
+ </tr>
49
+ <% end %>
50
+ </tbody>
51
+ </table>
52
+ <% else %>
53
+ <p class="am-empty">No anomalies match this filter.</p>
54
+ <% end %>
@@ -0,0 +1,54 @@
1
+ <% content_for :title, "Anomaly ##{@anomaly.id}" %>
2
+
3
+ <h1>Anomaly #<%= @anomaly.id %></h1>
4
+ <p class="am-lead"><%= @anomaly.rule %> on <%= @anomaly.source %> / <%= @anomaly.metric %></p>
5
+
6
+ <div class="am-actions">
7
+ <% if @anomaly.open? %>
8
+ <%= button_to "Resolve / ack", resolve_anomaly_path(@anomaly), method: :post, class: "am-btn" %>
9
+ <% else %>
10
+ <%= button_to "Allow alerts again", reopen_anomaly_path(@anomaly), method: :post, class: "am-btn am-btn-muted" %>
11
+ <% end %>
12
+ <% if @anomaly.webhook_failed? %>
13
+ <%= button_to "Retry webhook", retry_webhook_anomaly_path(@anomaly), method: :post, class: "am-btn am-btn-warn" %>
14
+ <% end %>
15
+ </div>
16
+
17
+ <dl class="am-detail">
18
+ <dt>Severity</dt>
19
+ <dd><span class="am-badge <%= @anomaly.severity %>"><%= @anomaly.severity %></span></dd>
20
+
21
+ <dt>Value</dt>
22
+ <dd><%= @anomaly.value %></dd>
23
+
24
+ <dt>Threshold</dt>
25
+ <dd><%= @anomaly.threshold || "—" %></dd>
26
+
27
+ <dt>Sampled at</dt>
28
+ <dd><%= @anomaly.sampled_at %></dd>
29
+
30
+ <dt>Webhook</dt>
31
+ <dd><%= @anomaly.webhook_status %><%= " @ #{@anomaly.webhook_delivered_at}" if @anomaly.webhook_delivered_at %></dd>
32
+
33
+ <dt>Status</dt>
34
+ <dd>
35
+ <%= @anomaly.resolved_at ? "resolved @ #{@anomaly.resolved_at}" : "open" %>
36
+ <% if @anomaly.manual_resolve? %>
37
+ <span class="am-badge warn">manual ack</span>
38
+ <% end %>
39
+ </dd>
40
+
41
+ <dt>Cooldown key</dt>
42
+ <dd><%= @anomaly.cooldown_key %></dd>
43
+
44
+ <% items = @anomaly.items_list %>
45
+ <% if items.any? %>
46
+ <dt>Items (<%= items.size %>)</dt>
47
+ <dd class="am-tags"><%= items.join("\n") %></dd>
48
+ <% end %>
49
+
50
+ <dt>Tags</dt>
51
+ <dd class="am-tags"><%= @anomaly.tags.inspect %></dd>
52
+ </dl>
53
+
54
+ <p style="margin-top: 1.25rem"><%= link_to "← All anomalies", anomalies_path %></p>
@@ -0,0 +1,124 @@
1
+ <% content_for :title, "Overview" %>
2
+
3
+ <h1>Overview</h1>
4
+ <p class="am-lead">Queue depth, growth, and anomaly status across configured collectors.</p>
5
+
6
+ <div class="am-status-row">
7
+ <span>Mode: <span class="am-badge ok"><%= poller_mode_label(@status) %></span></span>
8
+ <span>Poller: <span class="am-badge <%= poller_state_badge_class(@status) %>"><%= poller_state_label(@status) %></span></span>
9
+ <span>Last run: <%= poller_last_run_label(@status) %></span>
10
+ <span><%= poller_cron?(@status) ? "Schedule" : "Interval" %>: <%= poller_schedule_label(@status) %></span>
11
+ <% if @status[:last_error].present? || @status["last_error"].present? %>
12
+ <span class="am-badge danger"><%= @status[:last_error] || @status["last_error"] %></span>
13
+ <% end %>
14
+ </div>
15
+
16
+ <%= form_with url: root_path, method: :get, local: true, class: "am-filters" do %>
17
+ <label>
18
+ <span>Tenant</span>
19
+ <select name="tenant">
20
+ <option value="">All tenants</option>
21
+ <% @tenants.each do |tenant| %>
22
+ <option value="<%= tenant %>" <%= "selected" if @tenant == tenant %>><%= tenant %></option>
23
+ <% end %>
24
+ </select>
25
+ </label>
26
+ <button type="submit" class="am-btn">Filter</button>
27
+ <% end %>
28
+
29
+ <% if @tenant %>
30
+ <p class="am-lead">Showing tenant <code><%= @tenant %></code> — <%= link_to "clear", root_path %>.</p>
31
+ <% end %>
32
+
33
+ <section class="am-section">
34
+ <h2>Current metrics<% if @tenant %> · <%= @tenant %><% end %></h2>
35
+ <% if @latest_metrics.any? %>
36
+ <div class="am-grid">
37
+ <% @latest_metrics.first(12).each do |sample| %>
38
+ <div class="am-stat">
39
+ <div class="label"><%= sample.source %> · <%= sample.metric %></div>
40
+ <div class="value"><%= number_with_precision(sample.value, precision: 0, delimiter: ",") %></div>
41
+ <div class="meta">
42
+ <%= time_ago_in_words(sample.sampled_at) %> ago
43
+ <% tags = sample.tags.is_a?(Hash) ? sample.tags : {} %>
44
+ <% if tags["tenant"] || tags[:tenant] || tags["queue"] || tags[:queue] %>
45
+ · <%= [tags["tenant"] || tags[:tenant], tags["queue"] || tags[:queue]].compact.join(" / ") %>
46
+ <% end %>
47
+ </div>
48
+ </div>
49
+ <% end %>
50
+ </div>
51
+ <% else %>
52
+ <p class="am-empty">No samples yet. <% if poller_cron?(@status) %>Run <code>rails anomonitor:poll</code> (or wait for cron).<% else %>The poller will populate metrics after the first tick.<% end %></p>
53
+ <% end %>
54
+ </section>
55
+
56
+ <% if @by_tenant && @by_tenant.size > 1 && @tenant.nil? %>
57
+ <section class="am-section">
58
+ <h2>By tenant</h2>
59
+ <div class="am-grid">
60
+ <% @by_tenant.first(8).each do |tenant, samples| %>
61
+ <div class="am-stat">
62
+ <div class="label"><%= link_to tenant, root_path(tenant: tenant == "(untagged)" ? nil : tenant) %></div>
63
+ <div class="value"><%= samples.size %></div>
64
+ <div class="meta">metric series</div>
65
+ </div>
66
+ <% end %>
67
+ </div>
68
+ </section>
69
+ <% end %>
70
+
71
+ <section class="am-section">
72
+ <h2>Recent history</h2>
73
+ <% if @series.any? %>
74
+ <div class="am-grid">
75
+ <% @series.first(6).each do |(key, rows)| %>
76
+ <% source, metric = key %>
77
+ <% max = rows.map(&:value).max.to_f %>
78
+ <div class="am-stat">
79
+ <div class="label"><%= source %> · <%= metric %></div>
80
+ <div class="am-bars">
81
+ <% rows.each do |row| %>
82
+ <% height = max.positive? ? [(row.value / max * 100).round, 4].max : 4 %>
83
+ <div class="am-bar" style="height: <%= height %>%" title="<%= row.value %> @ <%= row.sampled_at %>"></div>
84
+ <% end %>
85
+ </div>
86
+ </div>
87
+ <% end %>
88
+ </div>
89
+ <% else %>
90
+ <p class="am-empty">No history yet.</p>
91
+ <% end %>
92
+ </section>
93
+
94
+ <section class="am-section">
95
+ <h2>Open anomalies</h2>
96
+ <% if @recent_anomalies.any? %>
97
+ <table class="am-table">
98
+ <thead>
99
+ <tr>
100
+ <th>When</th>
101
+ <th>Rule</th>
102
+ <th>Source</th>
103
+ <th>Metric</th>
104
+ <th>Value</th>
105
+ <th>Severity</th>
106
+ </tr>
107
+ </thead>
108
+ <tbody>
109
+ <% @recent_anomalies.each do |anomaly| %>
110
+ <tr>
111
+ <td><%= link_to time_ago_in_words(anomaly.created_at) + " ago", anomaly_path(anomaly) %></td>
112
+ <td><%= anomaly.rule %></td>
113
+ <td><%= anomaly.source %></td>
114
+ <td><%= anomaly.metric %></td>
115
+ <td><%= number_with_precision(anomaly.value, precision: 0, delimiter: ",") %></td>
116
+ <td><span class="am-badge <%= anomaly.severity %>"><%= anomaly.severity %></span></td>
117
+ </tr>
118
+ <% end %>
119
+ </tbody>
120
+ </table>
121
+ <% else %>
122
+ <p class="am-empty">No open anomalies. <%= link_to "View all", anomalies_path(status: "all") %>.</p>
123
+ <% end %>
124
+ </section>
@@ -0,0 +1,135 @@
1
+ <% content_for :title, "Jobs" %>
2
+
3
+ <h1>Jobs</h1>
4
+ <p class="am-lead">Queue health and live jobs across configured collectors. Read-only — use your host job UI to retry or delete.</p>
5
+
6
+ <section class="am-section">
7
+ <h2>Collector health</h2>
8
+ <% if @health.any? %>
9
+ <div class="am-grid">
10
+ <% @health.each do |card| %>
11
+ <% collector = card[:collector] || {} %>
12
+ <% ok = collector[:ok].nil? ? collector["ok"] : collector[:ok] %>
13
+ <div class="am-stat">
14
+ <div class="label"><%= card[:source] %></div>
15
+ <div class="am-health-metrics">
16
+ <span>depth <strong><%= format_metric(card[:queue_depth]) %></strong></span>
17
+ <span>failed <strong><%= format_metric(card[:failed]) %></strong></span>
18
+ <span>locked <strong><%= format_metric(card[:locked]) %></strong></span>
19
+ </div>
20
+ <div class="meta">
21
+ <% if ok == true %>
22
+ <span class="am-badge ok">collector ok</span>
23
+ <% elsif ok == false %>
24
+ <span class="am-badge danger">collector error</span>
25
+ <% else %>
26
+ <span class="am-badge warn">no poll yet</span>
27
+ <% end %>
28
+ </div>
29
+ <% if card[:series].any? %>
30
+ <% max = card[:series].map(&:value).max.to_f %>
31
+ <div class="am-bars">
32
+ <% card[:series].each do |row| %>
33
+ <% height = max.positive? ? [(row.value / max * 100).round, 4].max : 4 %>
34
+ <div class="am-bar" style="height: <%= height %>%" title="<%= row.value %> @ <%= row.sampled_at %>"></div>
35
+ <% end %>
36
+ </div>
37
+ <% end %>
38
+ </div>
39
+ <% end %>
40
+ </div>
41
+ <% else %>
42
+ <p class="am-empty">No job collectors enabled.</p>
43
+ <% end %>
44
+ </section>
45
+
46
+ <section class="am-section">
47
+ <h2>Live jobs</h2>
48
+
49
+ <%= form_with url: jobs_path, method: :get, local: true, class: "am-filters" do %>
50
+ <label>
51
+ <span>Source</span>
52
+ <select name="source">
53
+ <option value="">All</option>
54
+ <% @sources.each do |source| %>
55
+ <option value="<%= source %>" <%= "selected" if @filters[:source] == source %>><%= source %></option>
56
+ <% end %>
57
+ </select>
58
+ </label>
59
+ <label>
60
+ <span>Status</span>
61
+ <select name="status">
62
+ <% @status_options.each do |status| %>
63
+ <option value="<%= status %>" <%= "selected" if @filters[:status] == status %>><%= status %></option>
64
+ <% end %>
65
+ </select>
66
+ </label>
67
+ <label>
68
+ <span>Tenant</span>
69
+ <input type="text" name="tenant" value="<%= @filters[:tenant] %>" placeholder="optional">
70
+ </label>
71
+ <label>
72
+ <span>Queue</span>
73
+ <input type="text" name="queue" value="<%= @filters[:queue] %>" placeholder="queue name">
74
+ </label>
75
+ <label>
76
+ <span>Search</span>
77
+ <input type="text" name="q" value="<%= @filters[:q] %>" placeholder="job class / name">
78
+ </label>
79
+ <button type="submit" class="am-btn">Filter</button>
80
+ <% end %>
81
+
82
+ <% if @jobs.any? %>
83
+ <div class="am-table-wrap">
84
+ <table class="am-table am-jobs-table">
85
+ <thead>
86
+ <tr>
87
+ <th>Source</th>
88
+ <th>Id</th>
89
+ <th>Name</th>
90
+ <th>Status</th>
91
+ <th>Queue / Tenant</th>
92
+ <th>When</th>
93
+ <th>Error</th>
94
+ </tr>
95
+ </thead>
96
+ <tbody>
97
+ <% @jobs.each do |job| %>
98
+ <tr>
99
+ <td><%= job.source %></td>
100
+ <td><code><%= job.id %></code></td>
101
+ <td>
102
+ <code><%= job.name %></code>
103
+ <% if job.detail.present? %>
104
+ <details class="am-job-detail">
105
+ <summary>detail</summary>
106
+ <pre class="am-tags"><%= job.detail_text %></pre>
107
+ </details>
108
+ <% end %>
109
+ </td>
110
+ <td>
111
+ <span class="am-badge <%= job_status_badge_class(job.status) %>"><%= job.status %></span>
112
+ </td>
113
+ <td>
114
+ <% bits = [job.queue, job.tenant].compact %>
115
+ <%= bits.any? ? bits.join(" · ") : "—" %>
116
+ </td>
117
+ <td>
118
+ <% when_at = job.failed_at || job.run_at %>
119
+ <% if when_at %>
120
+ <%= time_ago_in_words(when_at) %> ago
121
+ <div class="meta"><%= l(when_at, format: :short) rescue when_at %></div>
122
+ <% else %>
123
+
124
+ <% end %>
125
+ </td>
126
+ <td><code class="am-tags"><%= job.error.presence || "—" %></code></td>
127
+ </tr>
128
+ <% end %>
129
+ </tbody>
130
+ </table>
131
+ </div>
132
+ <% else %>
133
+ <p class="am-empty">No live jobs matched these filters (or backends are unavailable).</p>
134
+ <% end %>
135
+ </section>
@@ -0,0 +1,33 @@
1
+ <% content_for :title, "Metrics" %>
2
+
3
+ <h1>Metrics</h1>
4
+ <p class="am-lead">Recent metric samples from the last 24 hours.</p>
5
+
6
+ <% if @metrics.any? %>
7
+ <div class="am-table-wrap">
8
+ <table class="am-table">
9
+ <thead>
10
+ <tr>
11
+ <th>Sampled</th>
12
+ <th>Source</th>
13
+ <th>Metric</th>
14
+ <th>Value</th>
15
+ <th>Tags</th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <% @metrics.each do |sample| %>
20
+ <tr>
21
+ <td><%= l(sample.sampled_at, format: :short) %></td>
22
+ <td><%= sample.source %></td>
23
+ <td><%= sample.metric %></td>
24
+ <td><%= number_with_precision(sample.value, precision: 2, strip_insignificant_zeros: true) %></td>
25
+ <td><code class="am-tags"><%= sample.tags.presence || {} %></code></td>
26
+ </tr>
27
+ <% end %>
28
+ </tbody>
29
+ </table>
30
+ </div>
31
+ <% else %>
32
+ <p class="am-empty">No metric samples yet.</p>
33
+ <% end %>
@@ -0,0 +1,56 @@
1
+ <% content_for :title, "Mutes" %>
2
+
3
+ <h1>Mutes / snoozes</h1>
4
+ <p class="am-lead">Silence anomaly creation for a metric, rule, source, and/or tenant until a time.</p>
5
+
6
+ <%= form_with url: mutes_path, method: :post, local: true, class: "am-filters" do %>
7
+ <label><span>Metric</span><input type="text" name="metric" placeholder="extra_tables"></label>
8
+ <label><span>Rule</span><input type="text" name="rule" placeholder="threshold"></label>
9
+ <label><span>Source</span><input type="text" name="source" placeholder="schema_drift"></label>
10
+ <label><span>Tenant</span><input type="text" name="tenant" placeholder="optional"></label>
11
+ <label>
12
+ <span>Duration</span>
13
+ <select name="duration">
14
+ <option value="1h">1 hour</option>
15
+ <option value="6h">6 hours</option>
16
+ <option value="24h" selected>24 hours</option>
17
+ <option value="7d">7 days</option>
18
+ </select>
19
+ </label>
20
+ <label><span>Reason</span><input type="text" name="reason" placeholder="optional"></label>
21
+ <button type="submit" class="am-btn">Mute</button>
22
+ <% end %>
23
+
24
+ <% if @mutes.any? %>
25
+ <table class="am-table">
26
+ <thead>
27
+ <tr>
28
+ <th>Until</th>
29
+ <th>Metric</th>
30
+ <th>Rule</th>
31
+ <th>Source</th>
32
+ <th>Tenant</th>
33
+ <th>Reason</th>
34
+ <th></th>
35
+ </tr>
36
+ </thead>
37
+ <tbody>
38
+ <% @mutes.each do |mute| %>
39
+ <tr>
40
+ <td>
41
+ <%= l(mute.muted_until, format: :short) rescue mute.muted_until %>
42
+ <% unless mute.active? %><span class="am-badge warn">expired</span><% end %>
43
+ </td>
44
+ <td><%= mute.metric || "—" %></td>
45
+ <td><%= mute.rule || "—" %></td>
46
+ <td><%= mute.source || "—" %></td>
47
+ <td><%= mute.tenant || "—" %></td>
48
+ <td><%= mute.reason || "—" %></td>
49
+ <td><%= button_to "Remove", mute_path(mute), method: :delete, class: "am-btn am-btn-muted" %></td>
50
+ </tr>
51
+ <% end %>
52
+ </tbody>
53
+ </table>
54
+ <% else %>
55
+ <p class="am-empty">No mutes configured.</p>
56
+ <% end %>