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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +90 -0
- data/MIT-LICENSE +20 -0
- data/README.md +275 -0
- data/RELEASE.md +32 -0
- data/Rakefile +13 -0
- data/SECURITY.md +24 -0
- data/app/assets/config/anomonitor_manifest.js +1 -0
- data/app/assets/stylesheets/anomonitor/application.css +370 -0
- data/app/controllers/anomonitor/anomalies_controller.rb +44 -0
- data/app/controllers/anomonitor/application_controller.rb +21 -0
- data/app/controllers/anomonitor/dashboard_controller.rb +69 -0
- data/app/controllers/anomonitor/jobs_controller.rb +61 -0
- data/app/controllers/anomonitor/metrics_controller.rb +18 -0
- data/app/controllers/anomonitor/mutes_controller.rb +41 -0
- data/app/helpers/anomonitor/application_helper.rb +71 -0
- data/app/models/anomonitor/anomaly.rb +105 -0
- data/app/models/anomonitor/application_record.rb +5 -0
- data/app/models/anomonitor/metric_sample.rb +25 -0
- data/app/models/anomonitor/mute.rb +56 -0
- data/app/views/anomonitor/anomalies/index.html.erb +54 -0
- data/app/views/anomonitor/anomalies/show.html.erb +54 -0
- data/app/views/anomonitor/dashboard/show.html.erb +124 -0
- data/app/views/anomonitor/jobs/index.html.erb +135 -0
- data/app/views/anomonitor/metrics/index.html.erb +33 -0
- data/app/views/anomonitor/mutes/index.html.erb +56 -0
- data/app/views/layouts/anomonitor/application.html.erb +39 -0
- data/config/routes.rb +15 -0
- data/db/migrate/20260807100000_create_anomonitor_metric_samples.rb +17 -0
- data/db/migrate/20260807100001_create_anomonitor_anomalies.rb +23 -0
- data/db/migrate/20260808100000_add_resolved_at_to_anomonitor_anomalies.rb +6 -0
- data/db/migrate/20260808120000_create_anomonitor_mutes.rb +16 -0
- data/lib/anomonitor/collectors/base.rb +25 -0
- data/lib/anomonitor/collectors/delayed_job.rb +55 -0
- data/lib/anomonitor/collectors/schema_drift.rb +144 -0
- data/lib/anomonitor/collectors/sidekiq.rb +47 -0
- data/lib/anomonitor/collectors/solid_queue.rb +57 -0
- data/lib/anomonitor/collectors/table.rb +116 -0
- data/lib/anomonitor/configuration.rb +184 -0
- data/lib/anomonitor/configuration_validator.rb +40 -0
- data/lib/anomonitor/detector.rb +195 -0
- data/lib/anomonitor/digester.rb +79 -0
- data/lib/anomonitor/engine.rb +35 -0
- data/lib/anomonitor/jobs/browser.rb +89 -0
- data/lib/anomonitor/jobs/browsers/delayed_job.rb +143 -0
- data/lib/anomonitor/jobs/browsers/sidekiq.rb +164 -0
- data/lib/anomonitor/jobs/browsers/solid_queue.rb +149 -0
- data/lib/anomonitor/jobs/browsers/table.rb +206 -0
- data/lib/anomonitor/jobs/row.rb +30 -0
- data/lib/anomonitor/metric_point.rb +42 -0
- data/lib/anomonitor/metrics_export.rb +55 -0
- data/lib/anomonitor/notifiers/callable.rb +44 -0
- data/lib/anomonitor/notifiers/composite.rb +33 -0
- data/lib/anomonitor/notifiers/rate_limited.rb +44 -0
- data/lib/anomonitor/notifiers/webhook.rb +108 -0
- data/lib/anomonitor/notifiers.rb +59 -0
- data/lib/anomonitor/poll_lock.rb +67 -0
- data/lib/anomonitor/poller.rb +164 -0
- data/lib/anomonitor/tenancy.rb +46 -0
- data/lib/anomonitor/version.rb +3 -0
- data/lib/anomonitor.rb +52 -0
- data/lib/generators/anomonitor/install/install_generator.rb +27 -0
- data/lib/generators/anomonitor/install/templates/anomonitor.rb +87 -0
- data/lib/tasks/anomonitor_tasks.rake +27 -0
- 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,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 %>
|