findbug 0.3.5 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b51dae70712a19753e3c702d669ad052997bdbd92fdbb5a1906dfe7a4739eb3d
4
- data.tar.gz: ece16e26f2e64f87262172768cb577a7d9344c85039992acc7809f372c146173
3
+ metadata.gz: ca9a3e20d87733caff1807d5b89935d349a316cddf15ee2e7565f3b861cafc10
4
+ data.tar.gz: d135aae0a004b454448d2688e04cf22f6121ed3d64b743baf7663466ef5894b0
5
5
  SHA512:
6
- metadata.gz: 8637ab43bfd03606bceee3fc7eb955af65f2db8300b39b07848beed6106677efc82fb8bc3931c12368c525d502d7c708a5e006b31fdcc4f2816ba644a546f8df
7
- data.tar.gz: 8a24d784569793cdbabe425af6ca3c7b1740e7b64a30ec2f59f059adfce927faccc52bdc40e3f9f6c5037136987c1ff9d7f9bba9b585f506da63ede93fa7b63a
6
+ metadata.gz: 00d61fb0d96254b5195951d9c55a0c459f896ad61b3f813ed1fbeb817856b186ee8ceac24d5d659934e3909cfaa9bb901f38cd3fc3bc68e8aa5df4d7e04c3627
7
+ data.tar.gz: 01c241d176885ea96d700623048bf1f7d8af8832057b722b6d23c8a079dedc61410322abb897313a4e7a3d937a0ce6197c248b6438197725ab6656333f1dc311
data/README.md CHANGED
@@ -142,34 +142,11 @@ Findbug.configure do |config|
142
142
  # ===================
143
143
  # Alerts (Optional)
144
144
  # ===================
145
+ # Alert channels (Email, Slack, Discord, Webhook) are configured via the
146
+ # dashboard UI at /findbug/alerts — no code changes needed.
147
+
145
148
  config.alerts do |alerts|
146
149
  alerts.throttle_period = 5.minutes
147
-
148
- # Slack
149
- # alerts.slack(
150
- # enabled: true,
151
- # webhook_url: ENV["SLACK_WEBHOOK_URL"],
152
- # channel: "#errors"
153
- # )
154
-
155
- # Email
156
- # alerts.email(
157
- # enabled: true,
158
- # recipients: ["team@example.com"]
159
- # )
160
-
161
- # Discord
162
- # alerts.discord(
163
- # enabled: true,
164
- # webhook_url: ENV["DISCORD_WEBHOOK_URL"]
165
- # )
166
-
167
- # Custom Webhook
168
- # alerts.webhook(
169
- # enabled: true,
170
- # url: "https://your-service.com/webhook",
171
- # headers: { "Authorization" => "Bearer token" }
172
- # )
173
150
  end
174
151
  end
175
152
  ```
@@ -311,6 +288,7 @@ Apartment.configure do |config|
311
288
  # Your existing excluded models...
312
289
  Findbug::ErrorEvent
313
290
  Findbug::PerformanceEvent
291
+ Findbug::AlertChannel
314
292
  ]
315
293
  end
316
294
  ```
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+
5
+ module Findbug
6
+ # AlertsController manages alert channel configuration via the dashboard.
7
+ #
8
+ # Users can create, edit, enable/disable, delete, and test alert channels
9
+ # directly from the UI instead of editing the Rails initializer.
10
+ #
11
+ class AlertsController < ApplicationController
12
+ before_action :set_alert_channel, only: [:edit, :update, :destroy, :toggle, :test]
13
+
14
+ # GET /findbug/alerts
15
+ #
16
+ # List all configured alert channels.
17
+ #
18
+ def index
19
+ @channels = Findbug::AlertChannel.order(created_at: :asc)
20
+ @enabled_count = @channels.count(&:enabled?)
21
+
22
+ render template: "findbug/alerts/index", layout: "findbug/application"
23
+ end
24
+
25
+ # GET /findbug/alerts/new
26
+ #
27
+ # Form to create a new alert channel.
28
+ #
29
+ def new
30
+ @channel = Findbug::AlertChannel.new
31
+ render template: "findbug/alerts/new", layout: "findbug/application"
32
+ end
33
+
34
+ # POST /findbug/alerts
35
+ #
36
+ # Save a new alert channel.
37
+ #
38
+ def create
39
+ @channel = Findbug::AlertChannel.new(channel_params)
40
+ @channel.config = build_config_from_params
41
+
42
+ if @channel.save
43
+ flash_success "#{@channel.display_type} alert channel created"
44
+ redirect_to findbug.alerts_path
45
+ else
46
+ flash_error @channel.errors.full_messages.join(", ")
47
+ render template: "findbug/alerts/new", layout: "findbug/application", status: :unprocessable_entity
48
+ end
49
+ end
50
+
51
+ # GET /findbug/alerts/:id/edit
52
+ #
53
+ # Form to edit an existing alert channel.
54
+ #
55
+ def edit
56
+ render template: "findbug/alerts/edit", layout: "findbug/application"
57
+ end
58
+
59
+ # PATCH /findbug/alerts/:id
60
+ #
61
+ # Update an existing alert channel.
62
+ #
63
+ def update
64
+ @channel.assign_attributes(channel_params)
65
+ @channel.config = build_config_from_params
66
+
67
+ if @channel.save
68
+ flash_success "#{@channel.display_type} alert channel updated"
69
+ redirect_to findbug.alerts_path
70
+ else
71
+ flash_error @channel.errors.full_messages.join(", ")
72
+ render template: "findbug/alerts/edit", layout: "findbug/application", status: :unprocessable_entity
73
+ end
74
+ end
75
+
76
+ # DELETE /findbug/alerts/:id
77
+ #
78
+ # Delete an alert channel.
79
+ #
80
+ def destroy
81
+ name = @channel.name
82
+ @channel.destroy
83
+ flash_success "Alert channel \"#{name}\" deleted"
84
+ redirect_to findbug.alerts_path
85
+ end
86
+
87
+ # POST /findbug/alerts/:id/toggle
88
+ #
89
+ # Toggle enable/disable for an alert channel.
90
+ #
91
+ def toggle
92
+ @channel.enabled = !@channel.enabled?
93
+
94
+ if @channel.save
95
+ status = @channel.enabled? ? "enabled" : "disabled"
96
+ flash_success "#{@channel.name} #{status}"
97
+ else
98
+ flash_error @channel.errors.full_messages.join(", ")
99
+ end
100
+
101
+ redirect_to findbug.alerts_path
102
+ end
103
+
104
+ # POST /findbug/alerts/:id/test
105
+ #
106
+ # Send a test alert to this channel.
107
+ #
108
+ # Creates a synthetic error event (not persisted to DB) and sends it
109
+ # directly to the channel, bypassing throttling.
110
+ #
111
+ def test
112
+ unless @channel.enabled?
113
+ flash_error "Cannot test a disabled channel. Enable it first."
114
+ redirect_to findbug.alerts_path and return
115
+ end
116
+
117
+ error_event = build_test_error_event
118
+ channel_instance = @channel.channel_class.new(@channel.config.symbolize_keys)
119
+
120
+ begin
121
+ channel_instance.send_alert(error_event)
122
+ flash_success "Test alert sent to #{@channel.name} successfully!"
123
+ rescue StandardError => e
124
+ flash_error "Failed to send test alert: #{e.message}"
125
+ end
126
+
127
+ redirect_to findbug.alerts_path
128
+ end
129
+
130
+ private
131
+
132
+ def set_alert_channel
133
+ @channel = Findbug::AlertChannel.find(params[:id])
134
+ end
135
+
136
+ def channel_params
137
+ params.require(:alert_channel).permit(:name, :channel_type, :enabled)
138
+ end
139
+
140
+ # Build the config hash from channel-type-specific form params
141
+ #
142
+ # Each channel type has different fields. We extract them from
143
+ # params[:config] and build a clean hash.
144
+ #
145
+ def build_config_from_params
146
+ config_params = params[:config] || {}
147
+ channel_type = params.dig(:alert_channel, :channel_type) || @channel&.channel_type
148
+
149
+ case channel_type
150
+ when "email"
151
+ recipients = (config_params[:recipients] || "").split(/[\n,]/).map(&:strip).compact_blank
152
+ {
153
+ "recipients" => recipients,
154
+ "from" => config_params[:from].presence
155
+ }.compact
156
+ when "slack"
157
+ {
158
+ "webhook_url" => config_params[:webhook_url],
159
+ "channel" => config_params[:channel].presence,
160
+ "username" => config_params[:username].presence,
161
+ "icon_emoji" => config_params[:icon_emoji].presence
162
+ }.compact
163
+ when "discord"
164
+ {
165
+ "webhook_url" => config_params[:webhook_url],
166
+ "username" => config_params[:username].presence,
167
+ "avatar_url" => config_params[:avatar_url].presence
168
+ }.compact
169
+ when "webhook"
170
+ headers = parse_headers(config_params[:headers])
171
+ {
172
+ "url" => config_params[:url],
173
+ "method" => config_params[:method].presence || "POST",
174
+ "headers" => headers.presence
175
+ }.compact
176
+ else
177
+ {}
178
+ end
179
+ end
180
+
181
+ # Parse headers from textarea format ("Key: Value" per line) into a hash
182
+ def parse_headers(raw)
183
+ return {} if raw.blank?
184
+
185
+ raw.split("\n").each_with_object({}) do |line, hash|
186
+ key, value = line.split(":", 2).map(&:strip)
187
+ hash[key] = value if key.present? && value.present?
188
+ end
189
+ end
190
+
191
+ # Build a synthetic error event for testing alerts
192
+ #
193
+ # Uses OpenStruct to duck-type ErrorEvent without touching the database.
194
+ # Includes all attributes that channel implementations access.
195
+ #
196
+ def build_test_error_event
197
+ now = Time.current
198
+
199
+ OpenStruct.new(
200
+ id: 0,
201
+ fingerprint: "findbug-test-alert-#{now.to_i}",
202
+ exception_class: "Findbug::TestAlert",
203
+ message: "This is a test alert from the Findbug dashboard. If you see this, your alert channel is working correctly!",
204
+ severity: "error",
205
+ status: "unresolved",
206
+ handled: false,
207
+ occurrence_count: 1,
208
+ first_seen_at: now,
209
+ last_seen_at: now,
210
+ environment: Findbug.config.environment || "production",
211
+ release_version: Findbug::VERSION,
212
+ backtrace_lines: [
213
+ "app/controllers/findbug/alerts_controller.rb:42:in `test'",
214
+ "lib/findbug/alerts/dispatcher.rb:57:in `send_alerts'",
215
+ "lib/findbug/alerts/channels/base.rb:34:in `send_alert'"
216
+ ],
217
+ context: {},
218
+ user: nil,
219
+ request: { "method" => "POST", "path" => "/findbug/alerts/test" },
220
+ tags: { "source" => "test_alert" }
221
+ )
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../lib/findbug/alerts/channels/base"
4
+ require_relative "../../../lib/findbug/alerts/channels/email"
5
+ require_relative "../../../lib/findbug/alerts/channels/slack"
6
+ require_relative "../../../lib/findbug/alerts/channels/discord"
7
+ require_relative "../../../lib/findbug/alerts/channels/webhook"
8
+
9
+ module Findbug
10
+ # AlertChannel stores alert channel configurations in the database.
11
+ #
12
+ # DATABASE SCHEMA
13
+ # ===============
14
+ #
15
+ # This model expects a table created by the install generator:
16
+ #
17
+ # create_table :findbug_alert_channels do |t|
18
+ # t.string :channel_type, null: false
19
+ # t.string :name, null: false
20
+ # t.boolean :enabled, default: false
21
+ # t.text :config_data
22
+ # t.timestamps
23
+ # end
24
+ #
25
+ # WHY DB INSTEAD OF INITIALIZER?
26
+ # ==============================
27
+ #
28
+ # Storing alert config in the database lets users create, edit, and
29
+ # manage alert channels from the dashboard UI without code changes
30
+ # or redeployment.
31
+ #
32
+ # WHY TEXT + SERIALIZE INSTEAD OF JSONB?
33
+ # ======================================
34
+ #
35
+ # ActiveRecord::Encryption works on text columns. We serialize the
36
+ # config hash as JSON and encrypt the entire blob at rest, so webhook
37
+ # URLs and tokens are never stored in plain text.
38
+ #
39
+ class AlertChannel < ActiveRecord::Base
40
+ self.table_name = "findbug_alert_channels"
41
+
42
+ # Channel types
43
+ CHANNEL_TYPES = %w[email slack discord webhook].freeze
44
+
45
+ # Check if Rails encryption is configured
46
+ def self.encryption_available?
47
+ return false unless defined?(ActiveRecord::Encryption)
48
+
49
+ ActiveRecord::Encryption.config.primary_key.present?
50
+ rescue StandardError
51
+ false
52
+ end
53
+
54
+ # Serialize config as JSON
55
+ serialize :config_data, coder: JSON
56
+
57
+ # Encrypt config at rest if Rails encryption is configured
58
+ encrypts :config_data if encryption_available?
59
+
60
+ # Validations
61
+ validates :name, presence: true
62
+ validates :channel_type, presence: true, inclusion: { in: CHANNEL_TYPES }
63
+ validate :validate_required_config
64
+
65
+ # Scopes
66
+ scope :enabled, -> { where(enabled: true) }
67
+ scope :by_type, ->(type) { where(channel_type: type) }
68
+
69
+ # Convenience accessor for config
70
+ def config
71
+ config_data || {}
72
+ end
73
+
74
+ def config=(value)
75
+ self.config_data = value
76
+ end
77
+
78
+ # Returns the channel class for sending alerts
79
+ #
80
+ # Maps channel_type to the corresponding Alerts::Channels class.
81
+ #
82
+ def channel_class
83
+ case channel_type
84
+ when "email" then Findbug::Alerts::Channels::Email
85
+ when "slack" then Findbug::Alerts::Channels::Slack
86
+ when "discord" then Findbug::Alerts::Channels::Discord
87
+ when "webhook" then Findbug::Alerts::Channels::Webhook
88
+ end
89
+ end
90
+
91
+ # Human-readable channel type
92
+ def display_type
93
+ channel_type&.titleize
94
+ end
95
+
96
+ # Returns config with sensitive values masked for display
97
+ #
98
+ # Shows scheme + host for URLs, masks everything else.
99
+ # Email recipients are shown in full (not sensitive).
100
+ #
101
+ def masked_config
102
+ masked = {}
103
+
104
+ case channel_type
105
+ when "email"
106
+ masked["Recipients"] = Array(config["recipients"]).join(", ").presence || "None"
107
+ masked["From"] = config["from"] || "findbug@localhost"
108
+ when "slack"
109
+ masked["Webhook URL"] = mask_url(config["webhook_url"])
110
+ masked["Channel"] = config["channel"] || "Default"
111
+ masked["Username"] = config["username"] || "Findbug"
112
+ when "discord"
113
+ masked["Webhook URL"] = mask_url(config["webhook_url"])
114
+ masked["Username"] = config["username"] || "Findbug"
115
+ when "webhook"
116
+ masked["URL"] = mask_url(config["url"])
117
+ masked["Method"] = (config["method"] || "POST").upcase
118
+ headers_count = (config["headers"] || {}).size
119
+ masked["Custom Headers"] = "#{headers_count} configured" if headers_count > 0
120
+ end
121
+
122
+ masked
123
+ end
124
+
125
+ private
126
+
127
+ # Mask a URL for safe display
128
+ #
129
+ # Shows scheme + host but masks the path (which typically contains
130
+ # secret tokens in webhook URLs).
131
+ #
132
+ def mask_url(url)
133
+ return "Not configured" if url.blank?
134
+
135
+ uri = URI.parse(url)
136
+ path = uri.path.to_s
137
+ masked_path = path.length > 8 ? "#{path[0..7]}********" : "********"
138
+ "#{uri.scheme}://#{uri.host}#{masked_path}"
139
+ rescue URI::InvalidURIError
140
+ "#{url[0..15]}********"
141
+ end
142
+
143
+ # Validate that required config fields are present for each channel type
144
+ def validate_required_config
145
+ return if config.blank? && !enabled?
146
+
147
+ case channel_type
148
+ when "email"
149
+ if enabled? && Array(config["recipients"]).compact_blank.empty?
150
+ errors.add(:base, "Email channel requires at least one recipient")
151
+ end
152
+ when "slack"
153
+ if enabled? && config["webhook_url"].blank?
154
+ errors.add(:base, "Slack channel requires a webhook URL")
155
+ end
156
+ when "discord"
157
+ if enabled? && config["webhook_url"].blank?
158
+ errors.add(:base, "Discord channel requires a webhook URL")
159
+ end
160
+ when "webhook"
161
+ if enabled? && config["url"].blank?
162
+ errors.add(:base, "Webhook channel requires a URL")
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,133 @@
1
+ <h1>Edit Alert Channel</h1>
2
+ <p class="page-description">Update configuration for <strong><%= @channel.name %></strong>.</p>
3
+
4
+ <div class="card" style="max-width: 560px;">
5
+ <div class="card-content">
6
+ <%= form_tag findbug.alert_path(@channel), method: :patch do %>
7
+ <div style="display: flex; flex-direction: column; gap: 1.5rem;">
8
+
9
+ <%# Channel Type (locked on edit) %>
10
+ <div class="form-group">
11
+ <label>Channel Type</label>
12
+ <div style="display: flex; align-items: center; gap: 0.5rem; height: 2rem;">
13
+ <span class="badge badge-info"><%= @channel.display_type %></span>
14
+ <span class="form-hint">Cannot be changed after creation</span>
15
+ </div>
16
+ <input type="hidden" name="alert_channel[channel_type]" value="<%= @channel.channel_type %>">
17
+ </div>
18
+
19
+ <%# Name %>
20
+ <div class="form-group">
21
+ <label>Name</label>
22
+ <input type="text" name="alert_channel[name]" value="<%= @channel.name %>"
23
+ placeholder="e.g., Production Slack, Dev Team Email">
24
+ </div>
25
+
26
+ <%# Enabled %>
27
+ <label style="display: flex; align-items: center; gap: 0.75rem; cursor: pointer;">
28
+ <input type="hidden" name="alert_channel[enabled]" value="0">
29
+ <span class="toggle">
30
+ <input type="checkbox" name="alert_channel[enabled]" value="1" <%= "checked" if @channel.enabled? %>>
31
+ <span class="toggle-slider"></span>
32
+ </span>
33
+ <span>Enable this channel</span>
34
+ </label>
35
+
36
+ <div class="separator"></div>
37
+
38
+ <%# Email config fields %>
39
+ <% if @channel.channel_type == "email" %>
40
+ <h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 1.25rem; color: hsl(var(--muted-foreground));">Email Configuration</h3>
41
+ <div style="display: flex; flex-direction: column; gap: 1.25rem;">
42
+ <div class="form-group">
43
+ <label>Recipients</label>
44
+ <textarea name="config[recipients]" rows="3" placeholder="One email per line" class="font-mono"><%= Array(@channel.config["recipients"]).join("\n") %></textarea>
45
+ <span class="form-hint">One email address per line</span>
46
+ </div>
47
+ <div class="form-group">
48
+ <label>From Address</label>
49
+ <input type="text" name="config[from]" value="<%= @channel.config["from"] %>" placeholder="findbug@example.com">
50
+ <span class="form-hint">Optional. Defaults to findbug@localhost</span>
51
+ </div>
52
+ </div>
53
+ <% end %>
54
+
55
+ <%# Slack config fields %>
56
+ <% if @channel.channel_type == "slack" %>
57
+ <h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 1.25rem; color: hsl(var(--muted-foreground));">Slack Configuration</h3>
58
+ <div style="display: flex; flex-direction: column; gap: 1.25rem;">
59
+ <div class="form-group">
60
+ <label>Webhook URL</label>
61
+ <input type="text" name="config[webhook_url]" value="<%= @channel.config["webhook_url"] %>" placeholder="https://hooks.slack.com/services/..." class="font-mono">
62
+ </div>
63
+ <div class="form-group">
64
+ <label>Channel</label>
65
+ <input type="text" name="config[channel]" value="<%= @channel.config["channel"] %>" placeholder="#errors">
66
+ <span class="form-hint">Optional. Overrides the webhook's default channel</span>
67
+ </div>
68
+ <div class="form-group">
69
+ <label>Username</label>
70
+ <input type="text" name="config[username]" value="<%= @channel.config["username"] %>" placeholder="Findbug">
71
+ <span class="form-hint">Optional. Defaults to Findbug</span>
72
+ </div>
73
+ <div class="form-group">
74
+ <label>Icon Emoji</label>
75
+ <input type="text" name="config[icon_emoji]" value="<%= @channel.config["icon_emoji"] %>" placeholder=":bug:">
76
+ <span class="form-hint">Optional. Defaults to :bug:</span>
77
+ </div>
78
+ </div>
79
+ <% end %>
80
+
81
+ <%# Discord config fields %>
82
+ <% if @channel.channel_type == "discord" %>
83
+ <h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 1.25rem; color: hsl(var(--muted-foreground));">Discord Configuration</h3>
84
+ <div style="display: flex; flex-direction: column; gap: 1.25rem;">
85
+ <div class="form-group">
86
+ <label>Webhook URL</label>
87
+ <input type="text" name="config[webhook_url]" value="<%= @channel.config["webhook_url"] %>" placeholder="https://discord.com/api/webhooks/..." class="font-mono">
88
+ </div>
89
+ <div class="form-group">
90
+ <label>Username</label>
91
+ <input type="text" name="config[username]" value="<%= @channel.config["username"] %>" placeholder="Findbug">
92
+ <span class="form-hint">Optional. Defaults to Findbug</span>
93
+ </div>
94
+ <div class="form-group">
95
+ <label>Avatar URL</label>
96
+ <input type="text" name="config[avatar_url]" value="<%= @channel.config["avatar_url"] %>" placeholder="https://example.com/avatar.png">
97
+ <span class="form-hint">Optional. URL to a custom avatar image</span>
98
+ </div>
99
+ </div>
100
+ <% end %>
101
+
102
+ <%# Webhook config fields %>
103
+ <% if @channel.channel_type == "webhook" %>
104
+ <h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 1.25rem; color: hsl(var(--muted-foreground));">Webhook Configuration</h3>
105
+ <div style="display: flex; flex-direction: column; gap: 1.25rem;">
106
+ <div class="form-group">
107
+ <label>URL</label>
108
+ <input type="text" name="config[url]" value="<%= @channel.config["url"] %>" placeholder="https://your-service.com/findbug-webhook" class="font-mono">
109
+ </div>
110
+ <div class="form-group">
111
+ <label>HTTP Method</label>
112
+ <%= select_tag "config[method]",
113
+ options_for_select([["POST", "POST"], ["PUT", "PUT"]], @channel.config["method"] || "POST") %>
114
+ </div>
115
+ <div class="form-group">
116
+ <label>Custom Headers</label>
117
+ <textarea name="config[headers]" rows="3" placeholder="Authorization: Bearer your-token&#10;X-Custom-Header: value" class="font-mono"><%= (@channel.config["headers"] || {}).map { |k, v| "#{k}: #{v}" }.join("\n") %></textarea>
118
+ <span class="form-hint">One header per line in "Key: Value" format</span>
119
+ </div>
120
+ </div>
121
+ <% end %>
122
+
123
+ <div class="separator"></div>
124
+
125
+ <%# Actions %>
126
+ <div style="display: flex; gap: 0.75rem;">
127
+ <button type="submit" class="btn btn-primary">Update Channel</button>
128
+ <a href="<%= findbug.alerts_path %>" class="btn btn-ghost">Cancel</a>
129
+ </div>
130
+ </div>
131
+ <% end %>
132
+ </div>
133
+ </div>