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,370 @@
1
+ :root {
2
+ --am-bg: #0f1419;
3
+ --am-surface: #1a222c;
4
+ --am-surface-2: #243040;
5
+ --am-border: #2e3d4f;
6
+ --am-text: #e8eef4;
7
+ --am-muted: #8b9cb0;
8
+ --am-accent: #3dd6c6;
9
+ --am-warn: #f0b429;
10
+ --am-danger: #f07178;
11
+ --am-ok: #7fd962;
12
+ --am-font: "IBM Plex Sans", "Segoe UI", sans-serif;
13
+ --am-mono: "IBM Plex Mono", ui-monospace, monospace;
14
+ }
15
+
16
+ * { box-sizing: border-box; }
17
+
18
+ .am-body {
19
+ margin: 0;
20
+ min-height: 100vh;
21
+ font-family: var(--am-font);
22
+ color: var(--am-text);
23
+ background:
24
+ radial-gradient(1200px 600px at 10% -10%, #1c3a42 0%, transparent 55%),
25
+ radial-gradient(900px 500px at 100% 0%, #2a2438 0%, transparent 50%),
26
+ var(--am-bg);
27
+ }
28
+
29
+ .am-shell {
30
+ max-width: 1100px;
31
+ margin: 0 auto;
32
+ padding: 1.25rem 1.5rem 3rem;
33
+ overflow-x: hidden;
34
+ }
35
+
36
+ .am-main {
37
+ min-width: 0;
38
+ }
39
+
40
+ .am-header {
41
+ display: flex;
42
+ align-items: baseline;
43
+ justify-content: space-between;
44
+ gap: 1rem;
45
+ padding-bottom: 1.25rem;
46
+ border-bottom: 1px solid var(--am-border);
47
+ margin-bottom: 1.75rem;
48
+ }
49
+
50
+ .am-brand {
51
+ font-family: var(--am-mono);
52
+ font-weight: 500;
53
+ font-size: 1.15rem;
54
+ letter-spacing: 0.02em;
55
+ color: var(--am-accent);
56
+ text-decoration: none;
57
+ }
58
+
59
+ .am-nav {
60
+ display: flex;
61
+ gap: 1rem;
62
+ }
63
+
64
+ .am-nav a {
65
+ color: var(--am-muted);
66
+ text-decoration: none;
67
+ font-size: 0.9rem;
68
+ }
69
+
70
+ .am-nav a:hover { color: var(--am-text); }
71
+
72
+ .am-main h1 {
73
+ font-size: 1.5rem;
74
+ font-weight: 600;
75
+ margin: 0 0 0.35rem;
76
+ }
77
+
78
+ .am-lead {
79
+ color: var(--am-muted);
80
+ margin: 0 0 1.5rem;
81
+ font-size: 0.95rem;
82
+ }
83
+
84
+ .am-grid {
85
+ display: grid;
86
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
87
+ gap: 0.85rem;
88
+ margin-bottom: 1.75rem;
89
+ }
90
+
91
+ .am-stat {
92
+ background: var(--am-surface);
93
+ border: 1px solid var(--am-border);
94
+ border-radius: 6px;
95
+ padding: 0.9rem 1rem;
96
+ min-width: 0;
97
+ }
98
+
99
+ .am-stat .label {
100
+ font-family: var(--am-mono);
101
+ font-size: 0.72rem;
102
+ text-transform: uppercase;
103
+ letter-spacing: 0.06em;
104
+ color: var(--am-muted);
105
+ overflow-wrap: anywhere;
106
+ word-break: break-word;
107
+ }
108
+
109
+ .am-stat .value {
110
+ font-family: var(--am-mono);
111
+ font-size: 1.55rem;
112
+ margin-top: 0.35rem;
113
+ }
114
+
115
+ .am-stat .meta {
116
+ font-size: 0.8rem;
117
+ color: var(--am-muted);
118
+ margin-top: 0.25rem;
119
+ }
120
+
121
+ .am-section {
122
+ margin-bottom: 2rem;
123
+ }
124
+
125
+ .am-section h2 {
126
+ font-size: 1rem;
127
+ font-weight: 600;
128
+ margin: 0 0 0.75rem;
129
+ color: var(--am-text);
130
+ }
131
+
132
+ .am-table-wrap {
133
+ width: 100%;
134
+ overflow-x: auto;
135
+ }
136
+
137
+ .am-table {
138
+ width: 100%;
139
+ border-collapse: collapse;
140
+ font-size: 0.9rem;
141
+ table-layout: fixed;
142
+ }
143
+
144
+ .am-table th,
145
+ .am-table td {
146
+ text-align: left;
147
+ padding: 0.55rem 0.65rem;
148
+ border-bottom: 1px solid var(--am-border);
149
+ vertical-align: top;
150
+ overflow-wrap: anywhere;
151
+ word-break: break-word;
152
+ }
153
+
154
+ .am-table th {
155
+ font-family: var(--am-mono);
156
+ font-size: 0.72rem;
157
+ text-transform: uppercase;
158
+ letter-spacing: 0.05em;
159
+ color: var(--am-muted);
160
+ font-weight: 500;
161
+ }
162
+
163
+ .am-table a { color: var(--am-accent); text-decoration: none; }
164
+ .am-table a:hover { text-decoration: underline; }
165
+
166
+ .am-table code,
167
+ .am-tags {
168
+ display: block;
169
+ font-family: var(--am-mono);
170
+ font-size: 0.75rem;
171
+ line-height: 1.35;
172
+ white-space: pre-wrap;
173
+ overflow-wrap: anywhere;
174
+ word-break: break-word;
175
+ color: var(--am-muted);
176
+ }
177
+
178
+ .am-badge {
179
+ display: inline-block;
180
+ font-family: var(--am-mono);
181
+ font-size: 0.72rem;
182
+ padding: 0.15rem 0.45rem;
183
+ border-radius: 3px;
184
+ background: var(--am-surface-2);
185
+ color: var(--am-text);
186
+ }
187
+
188
+ .am-badge.high,
189
+ .am-badge.danger { color: var(--am-danger); }
190
+ .am-badge.warn { color: var(--am-warn); }
191
+ .am-badge.ok { color: var(--am-ok); }
192
+
193
+ .am-bars {
194
+ display: flex;
195
+ align-items: flex-end;
196
+ gap: 2px;
197
+ height: 56px;
198
+ margin-top: 0.5rem;
199
+ }
200
+
201
+ .am-bar {
202
+ flex: 1;
203
+ min-width: 3px;
204
+ background: linear-gradient(180deg, var(--am-accent), #1f6f68);
205
+ border-radius: 2px 2px 0 0;
206
+ opacity: 0.85;
207
+ }
208
+
209
+ .am-status-row {
210
+ display: flex;
211
+ flex-wrap: wrap;
212
+ gap: 1rem;
213
+ font-family: var(--am-mono);
214
+ font-size: 0.8rem;
215
+ color: var(--am-muted);
216
+ margin-bottom: 1.25rem;
217
+ }
218
+
219
+ .am-empty {
220
+ color: var(--am-muted);
221
+ font-size: 0.9rem;
222
+ padding: 1rem 0;
223
+ }
224
+
225
+ .am-filters {
226
+ display: flex;
227
+ flex-wrap: wrap;
228
+ gap: 0.75rem 1rem;
229
+ align-items: flex-end;
230
+ margin-bottom: 1.25rem;
231
+ padding: 0.85rem 1rem;
232
+ background: var(--am-surface);
233
+ border: 1px solid var(--am-border);
234
+ border-radius: 6px;
235
+ }
236
+
237
+ .am-filters label {
238
+ display: flex;
239
+ flex-direction: column;
240
+ gap: 0.25rem;
241
+ font-family: var(--am-mono);
242
+ font-size: 0.72rem;
243
+ text-transform: uppercase;
244
+ letter-spacing: 0.05em;
245
+ color: var(--am-muted);
246
+ }
247
+
248
+ .am-filters select,
249
+ .am-filters input {
250
+ font-family: var(--am-font);
251
+ font-size: 0.9rem;
252
+ text-transform: none;
253
+ letter-spacing: normal;
254
+ color: var(--am-text);
255
+ background: var(--am-surface-2);
256
+ border: 1px solid var(--am-border);
257
+ border-radius: 4px;
258
+ padding: 0.4rem 0.55rem;
259
+ min-width: 9rem;
260
+ }
261
+
262
+ .am-btn {
263
+ font-family: var(--am-font);
264
+ font-size: 0.9rem;
265
+ color: var(--am-bg);
266
+ background: var(--am-accent);
267
+ border: none;
268
+ border-radius: 4px;
269
+ padding: 0.45rem 0.9rem;
270
+ cursor: pointer;
271
+ }
272
+
273
+ .am-btn:hover { opacity: 0.9; }
274
+
275
+ .am-btn-muted {
276
+ background: var(--am-surface-2);
277
+ color: var(--am-text);
278
+ border: 1px solid var(--am-border);
279
+ }
280
+
281
+ .am-btn-warn {
282
+ background: var(--am-warn);
283
+ color: var(--am-bg);
284
+ }
285
+
286
+ .am-actions {
287
+ display: flex;
288
+ flex-wrap: wrap;
289
+ gap: 0.5rem;
290
+ margin: 0 0 1rem;
291
+ }
292
+
293
+ .am-flash {
294
+ font-family: var(--am-mono);
295
+ font-size: 0.85rem;
296
+ padding: 0.55rem 0.75rem;
297
+ border-radius: 4px;
298
+ margin-bottom: 1rem;
299
+ background: var(--am-surface);
300
+ border: 1px solid var(--am-border);
301
+ }
302
+
303
+ .am-flash.ok { border-color: var(--am-ok); color: var(--am-ok); }
304
+ .am-flash.warn { border-color: var(--am-warn); color: var(--am-warn); }
305
+
306
+ .am-health-metrics {
307
+ display: flex;
308
+ flex-wrap: wrap;
309
+ gap: 0.65rem;
310
+ margin-top: 0.45rem;
311
+ font-family: var(--am-mono);
312
+ font-size: 0.78rem;
313
+ color: var(--am-muted);
314
+ }
315
+
316
+ .am-health-metrics strong {
317
+ color: var(--am-text);
318
+ font-weight: 500;
319
+ }
320
+
321
+ .am-jobs-table .meta {
322
+ font-size: 0.75rem;
323
+ color: var(--am-muted);
324
+ margin-top: 0.15rem;
325
+ }
326
+
327
+ .am-job-detail {
328
+ margin-top: 0.35rem;
329
+ font-size: 0.75rem;
330
+ }
331
+
332
+ .am-job-detail summary {
333
+ cursor: pointer;
334
+ color: var(--am-accent);
335
+ font-family: var(--am-mono);
336
+ }
337
+
338
+ .am-job-detail pre {
339
+ margin: 0.35rem 0 0;
340
+ max-height: 12rem;
341
+ overflow: auto;
342
+ }
343
+
344
+ .am-detail {
345
+ background: var(--am-surface);
346
+ border: 1px solid var(--am-border);
347
+ border-radius: 6px;
348
+ padding: 1.1rem 1.25rem;
349
+ }
350
+
351
+ .am-detail dt {
352
+ font-family: var(--am-mono);
353
+ font-size: 0.72rem;
354
+ text-transform: uppercase;
355
+ letter-spacing: 0.05em;
356
+ color: var(--am-muted);
357
+ margin-top: 0.75rem;
358
+ }
359
+
360
+ .am-detail dd {
361
+ margin: 0.2rem 0 0;
362
+ font-family: var(--am-mono);
363
+ overflow-wrap: anywhere;
364
+ word-break: break-word;
365
+ white-space: pre-wrap;
366
+ }
367
+
368
+ @media (max-width: 640px) {
369
+ .am-header { flex-direction: column; align-items: flex-start; }
370
+ }
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ class AnomaliesController < ApplicationController
5
+ before_action :set_anomaly, only: %i[show resolve reopen retry_webhook]
6
+
7
+ def index
8
+ @status_filter = params[:status].presence || "open"
9
+ scope = Anomaly.recent
10
+ scope =
11
+ case @status_filter
12
+ when "resolved" then scope.resolved
13
+ when "all" then scope
14
+ else scope.open
15
+ end
16
+ @anomalies = scope.limit(100)
17
+ end
18
+
19
+ def show
20
+ end
21
+
22
+ def resolve
23
+ @anomaly.resolve!(by: "manual", notify: true)
24
+ redirect_to anomaly_path(@anomaly), notice: "Anomaly resolved (silenced until drift clears)."
25
+ end
26
+
27
+ def reopen
28
+ @anomaly.reopen!
29
+ redirect_to anomaly_path(@anomaly), notice: "Alerts allowed again for this fingerprint."
30
+ end
31
+
32
+ def retry_webhook
33
+ ok = @anomaly.retry_webhook!
34
+ redirect_to anomaly_path(@anomaly),
35
+ notice: (ok ? "Webhook delivered." : "Webhook delivery failed again.")
36
+ end
37
+
38
+ private
39
+
40
+ def set_anomaly
41
+ @anomaly = Anomaly.find(params[:id])
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+ layout "anomonitor/application"
7
+
8
+ helper Anomonitor::Engine.helpers
9
+
10
+ before_action :authenticate_anomonitor!
11
+
12
+ private
13
+
14
+ def authenticate_anomonitor!
15
+ auth = Anomonitor.config.authenticate
16
+ return if auth.nil?
17
+
18
+ instance_exec(&auth)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ class DashboardController < ApplicationController
5
+ def show
6
+ @status = Poller.instance.status.merge(poll_mode: Anomonitor.config.poll_mode)
7
+ @tenant = params[:tenant].presence
8
+ @tenants = available_tenants
9
+ @latest_metrics = filter_by_tenant(MetricSample.latest_by_source_metric)
10
+ .sort_by { |m| [m.source, m.metric] }
11
+ @recent_anomalies = filter_anomalies(Anomaly.open.recent.limit(50)).first(10)
12
+ @series = build_series
13
+ @by_tenant = group_metrics_by_tenant(@latest_metrics) if @tenant.nil?
14
+ end
15
+
16
+ private
17
+
18
+ def available_tenants
19
+ MetricSample.recent(24).limit(500).filter_map do |s|
20
+ tags = s.tags
21
+ next unless tags.is_a?(Hash)
22
+
23
+ tags["tenant"] || tags[:tenant]
24
+ end.uniq.sort
25
+ rescue StandardError
26
+ []
27
+ end
28
+
29
+ def filter_by_tenant(samples)
30
+ return samples unless @tenant
31
+
32
+ samples.select do |s|
33
+ tags = s.tags
34
+ tags.is_a?(Hash) && (tags["tenant"] || tags[:tenant]).to_s == @tenant
35
+ end
36
+ end
37
+
38
+ def filter_anomalies(scope)
39
+ return scope.to_a unless @tenant
40
+
41
+ scope.select do |a|
42
+ tags = a.tags
43
+ tags.is_a?(Hash) && (tags["tenant"] || tags[:tenant]).to_s == @tenant
44
+ end
45
+ end
46
+
47
+ def group_metrics_by_tenant(samples)
48
+ grouped = Hash.new { |h, k| h[k] = [] }
49
+ samples.each do |s|
50
+ tags = s.tags.is_a?(Hash) ? s.tags : {}
51
+ key = tags["tenant"] || tags[:tenant] || "(untagged)"
52
+ grouped[key] << s
53
+ end
54
+ grouped.sort_by { |k, _| k.to_s }.to_h
55
+ end
56
+
57
+ def build_series
58
+ rows = MetricSample.recent(24).order(:sampled_at)
59
+ if @tenant
60
+ rows = rows.select do |s|
61
+ tags = s.tags
62
+ tags.is_a?(Hash) && (tags["tenant"] || tags[:tenant]).to_s == @tenant
63
+ end
64
+ end
65
+ rows.group_by { |s| [s.source, s.metric] }
66
+ .transform_values { |list| list.last(48) }
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ class JobsController < ApplicationController
5
+ def index
6
+ @status = Poller.instance.status.merge(poll_mode: Anomonitor.config.poll_mode)
7
+ @filters = {
8
+ source: params[:source].presence,
9
+ status: params[:status].presence || "all",
10
+ tenant: params[:tenant].presence,
11
+ queue: params[:queue].presence,
12
+ q: params[:q].presence
13
+ }
14
+ @sources = Jobs::Browser.enabled_sources
15
+ @status_options = Jobs::Browser.status_options(@filters[:source])
16
+ @filters[:status] = "all" unless @status_options.include?(@filters[:status])
17
+ @health = build_health
18
+ @jobs = Jobs::Browser.fetch(@filters)
19
+ end
20
+
21
+ private
22
+
23
+ def build_health
24
+ latest = MetricSample.latest_by_source_metric
25
+ collectors = @status[:collectors] || @status["collectors"] || {}
26
+
27
+ @sources.map do |source|
28
+ metrics = latest.select { |s| s.source == source }
29
+ by_metric = metrics.group_by(&:metric)
30
+ collector_key = collector_status_key(source)
31
+ collector = collectors[collector_key] || collectors[collector_key.to_s] || collectors[collector_key.to_sym]
32
+
33
+ {
34
+ source: source,
35
+ collector: collector,
36
+ queue_depth: metric_value(by_metric, "queue_depth"),
37
+ failed: metric_value(by_metric, "failed") || metric_value(by_metric, "dead") || metric_value(by_metric, "retries"),
38
+ locked: metric_value(by_metric, "locked") || metric_value(by_metric, "busy_workers") || metric_value(by_metric, "claimed"),
39
+ series: MetricSample.series(source: source, metric: "queue_depth", hours: 24).last(48)
40
+ }
41
+ end
42
+ end
43
+
44
+ def metric_value(by_metric, name)
45
+ rows = by_metric[name]
46
+ return nil unless rows&.any?
47
+
48
+ # Prefer untagged / aggregate sample when multiple tag variants exist
49
+ preferred = rows.find { |s| s.tags.blank? || s.tags == {} } || rows.first
50
+ preferred.value
51
+ end
52
+
53
+ def collector_status_key(source)
54
+ if source.start_with?("table:")
55
+ :"table_#{source.delete_prefix('table:')}"
56
+ else
57
+ source.to_sym
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ class MetricsController < ApplicationController
5
+ def index
6
+ @metrics = MetricSample.recent(24).order(sampled_at: :desc).limit(200)
7
+ end
8
+
9
+ def export_json
10
+ render json: MetricsExport.json_payload
11
+ end
12
+
13
+ def export_prometheus
14
+ render plain: MetricsExport.prometheus_text,
15
+ content_type: "text/plain; version=0.0.4; charset=utf-8"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ class MutesController < ApplicationController
5
+ def index
6
+ @mutes = Mute.recent.limit(100)
7
+ end
8
+
9
+ def create
10
+ seconds = parse_duration(params[:duration].presence || "24h")
11
+ Mute.create!(
12
+ metric: params[:metric].presence,
13
+ rule: params[:rule].presence,
14
+ source: params[:source].presence,
15
+ tenant: params[:tenant].presence,
16
+ muted_until: Time.current + seconds,
17
+ reason: params[:reason].presence
18
+ )
19
+ redirect_to mutes_path, notice: "Mute created until #{seconds / 3600.0}h from now."
20
+ rescue StandardError => e
21
+ redirect_to mutes_path, alert: e.message
22
+ end
23
+
24
+ def destroy
25
+ Mute.find(params[:id]).destroy!
26
+ redirect_to mutes_path, notice: "Mute removed."
27
+ end
28
+
29
+ private
30
+
31
+ def parse_duration(value)
32
+ case value.to_s
33
+ when /\A(\d+)\s*h\z/i then Regexp.last_match(1).to_i * 3600
34
+ when /\A(\d+)\s*m\z/i then Regexp.last_match(1).to_i * 60
35
+ when /\A(\d+)\s*d\z/i then Regexp.last_match(1).to_i * 86_400
36
+ else
37
+ value.to_i.positive? ? value.to_i : 24 * 3600
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anomonitor
4
+ module ApplicationHelper
5
+ include ActionView::Helpers::NumberHelper
6
+ include ActionView::Helpers::DateHelper
7
+
8
+ def poller_mode(status = nil)
9
+ mode = status && (status[:poll_mode] || status["poll_mode"])
10
+ mode || Anomonitor.config.poll_mode
11
+ end
12
+
13
+ def poller_mode_label(status = nil)
14
+ poller_mode(status).to_s == "cron" ? "cron" : "thread"
15
+ end
16
+
17
+ def poller_cron?(status = nil)
18
+ poller_mode(status).to_s == "cron"
19
+ end
20
+
21
+ def poller_state_label(status)
22
+ if poller_cron?(status)
23
+ status[:last_run_at] || status["last_run_at"] ? "scheduled" : "waiting for first poll"
24
+ else
25
+ (status[:running] || status["running"]) ? "running" : "stopped"
26
+ end
27
+ end
28
+
29
+ def poller_state_badge_class(status)
30
+ if poller_cron?(status)
31
+ (status[:last_run_at] || status["last_run_at"]) ? "ok" : "warn"
32
+ else
33
+ (status[:running] || status["running"]) ? "ok" : "warn"
34
+ end
35
+ end
36
+
37
+ def poller_schedule_label(status)
38
+ if poller_cron?(status)
39
+ "rails anomonitor:poll"
40
+ else
41
+ "#{status[:poll_interval] || status["poll_interval"] || Anomonitor.config.poll_interval}s"
42
+ end
43
+ end
44
+
45
+ def poller_last_run_label(status)
46
+ last = status[:last_run_at] || status["last_run_at"]
47
+ return "never" unless last
48
+
49
+ "#{time_ago_in_words(last)} ago"
50
+ end
51
+
52
+ def format_metric(value)
53
+ return "—" if value.nil?
54
+
55
+ number_with_precision(value, precision: 0, delimiter: ",")
56
+ end
57
+
58
+ def job_status_badge_class(status)
59
+ case status.to_s
60
+ when "failed" then "danger"
61
+ when "locked" then "warn"
62
+ when "pending" then "ok"
63
+ else ""
64
+ end
65
+ end
66
+
67
+ def anomaly_status_badge_class(anomaly)
68
+ anomaly.open? ? "warn" : "ok"
69
+ end
70
+ end
71
+ end