findbug 0.3.5 → 0.5.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.
@@ -17,11 +17,11 @@ module Findbug
17
17
  # t.float :db_time_ms, default: 0
18
18
  # t.float :view_time_ms, default: 0
19
19
  # t.integer :query_count, default: 0
20
- # t.jsonb :slow_queries, default: []
21
- # t.jsonb :n_plus_one_queries, default: []
20
+ # t.column :slow_queries, <json_type>, default: []
21
+ # t.column :n_plus_one_queries, <json_type>, default: []
22
22
  # t.boolean :has_n_plus_one, default: false
23
23
  # t.integer :view_count, default: 0
24
- # t.jsonb :context, default: {}
24
+ # t.column :context, <json_type>, default: {}
25
25
  # t.string :environment
26
26
  # t.string :release_version
27
27
  # t.datetime :captured_at
@@ -48,6 +48,33 @@ module Findbug
48
48
  TYPE_CUSTOM = "custom"
49
49
  TYPE_JOB = "job"
50
50
 
51
+ # JSON field accessors — normalise across adapters (jsonb/json/text).
52
+ # Reader returns the native Array/Hash; writer serialises to JSON string
53
+ # for text columns and passes through to AR's type system otherwise.
54
+ { slow_queries: [], n_plus_one_queries: [], context: {} }.each do |field, empty|
55
+ define_method(field) do
56
+ val = read_attribute(field)
57
+ return empty.dup if val.nil?
58
+ val.is_a?(String) ? JSON.parse(val) : val
59
+ rescue JSON::ParserError
60
+ empty.dup
61
+ end
62
+
63
+ define_method(:"#{field}=") do |val|
64
+ col_type = self.class.columns_hash[field.to_s]&.type
65
+ if col_type == :text
66
+ serialized = case val
67
+ when nil then nil
68
+ when String then val
69
+ else val.to_json
70
+ end
71
+ write_attribute(field, serialized)
72
+ else
73
+ write_attribute(field, val)
74
+ end
75
+ end
76
+ end
77
+
51
78
  # Validations
52
79
  validates :transaction_name, presence: true
53
80
  validates :duration_ms, presence: true, numericality: { greater_than_or_equal_to: 0 }
@@ -181,28 +208,23 @@ module Findbug
181
208
  # @return [Array<Hash>] time series data
182
209
  #
183
210
  def self.throughput_over_time(since: 24.hours.ago, interval: "hour")
184
- # This uses database-specific date truncation
185
- # Works with PostgreSQL; adjust for other databases
186
- time_column = case interval
187
- when "minute" then "date_trunc('minute', captured_at)"
188
- when "hour" then "date_trunc('hour', captured_at)"
189
- when "day" then "date_trunc('day', captured_at)"
190
- else "date_trunc('hour', captured_at)"
191
- end
211
+ time_sql = Findbug::AdapterHelper.date_trunc_sql(interval, "captured_at")
192
212
 
193
213
  where("captured_at >= ?", since)
194
- .group(Arel.sql(time_column))
214
+ .group(Arel.sql(time_sql))
195
215
  .select(
196
- Arel.sql("#{time_column} as time_bucket"),
216
+ Arel.sql("#{time_sql} as time_bucket"),
197
217
  "COUNT(*) as request_count",
198
218
  "AVG(duration_ms) as avg_duration"
199
219
  )
200
- .order(Arel.sql(time_column))
220
+ .order(Arel.sql(time_sql))
201
221
  .map do |row|
222
+ time = row.time_bucket
223
+ time = Time.parse(time.to_s) unless time.respond_to?(:strftime)
202
224
  {
203
- time: row.time_bucket,
225
+ time: time,
204
226
  count: row.request_count,
205
- avg_duration_ms: row.avg_duration.round(2)
227
+ avg_duration_ms: row.avg_duration&.round(2) || 0
206
228
  }
207
229
  end
208
230
  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>
@@ -0,0 +1,127 @@
1
+ <h1>Alerts</h1>
2
+ <p class="page-description">Manage alert channels and send test notifications.</p>
3
+
4
+ <%# Summary stats %>
5
+ <div class="stats-grid" style="grid-template-columns: repeat(3, 1fr);">
6
+ <div class="stat-card">
7
+ <div class="stat-label">Total Channels</div>
8
+ <div class="stat-value" style="font-size: 1.5rem;"><%= @channels.size %></div>
9
+ <div class="stat-change"><%= @enabled_count %> enabled</div>
10
+ </div>
11
+
12
+ <div class="stat-card">
13
+ <div class="stat-label">Throttle Period</div>
14
+ <div class="stat-value" style="font-size: 1.5rem;"><%= Findbug.config.alerts.throttle_period %>s</div>
15
+ <div class="stat-change"><%= Findbug.config.alerts.throttle_period / 60 %> minutes between alerts per error</div>
16
+ </div>
17
+
18
+ <div class="stat-card">
19
+ <div class="stat-label">Alert Status</div>
20
+ <div style="margin-top: 0.5rem;">
21
+ <% if @enabled_count > 0 %>
22
+ <span class="status-dot success"></span>
23
+ <span class="text-sm">Active</span>
24
+ <% else %>
25
+ <span class="status-dot error"></span>
26
+ <span class="text-sm">No channels enabled</span>
27
+ <% end %>
28
+ </div>
29
+ <div class="stat-change">
30
+ <% if @enabled_count > 0 %>
31
+ Alerts will be sent when errors occur
32
+ <% else %>
33
+ Configure a channel below to start receiving alerts
34
+ <% end %>
35
+ </div>
36
+ </div>
37
+ </div>
38
+
39
+ <%# Add channel button %>
40
+ <div style="display: flex; justify-content: flex-end; margin-bottom: 1rem;">
41
+ <a href="<%= findbug.new_alert_path %>" class="btn btn-primary btn-sm">
42
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 0.375rem;">
43
+ <line x1="12" y1="5" x2="12" y2="19"/>
44
+ <line x1="5" y1="12" x2="19" y2="12"/>
45
+ </svg>
46
+ Add Alert Channel
47
+ </a>
48
+ </div>
49
+
50
+ <%# Channel list %>
51
+ <% if @channels.any? %>
52
+ <div class="card">
53
+ <table class="table">
54
+ <thead>
55
+ <tr>
56
+ <th>Channel</th>
57
+ <th>Config</th>
58
+ <th style="text-align: center;">Status</th>
59
+ <th style="text-align: right;">Actions</th>
60
+ </tr>
61
+ </thead>
62
+ <tbody>
63
+ <% @channels.each do |channel| %>
64
+ <tr>
65
+ <td>
66
+ <strong><%= channel.name %></strong>
67
+ <span class="badge badge-info" style="margin-left: 0.5rem;"><%= channel.display_type %></span>
68
+ </td>
69
+ <td class="text-muted text-sm font-mono">
70
+ <% masked = channel.masked_config %>
71
+ <%= masked.values.first if masked.any? %>
72
+ </td>
73
+ <td style="text-align: center;">
74
+ <%= form_tag findbug.toggle_alert_path(channel), method: :post, style: "display: inline;" do %>
75
+ <label class="toggle" title="<%= channel.enabled? ? 'Disable' : 'Enable' %>">
76
+ <input type="checkbox" onchange="this.form.submit()" <%= "checked" if channel.enabled? %>>
77
+ <span class="toggle-slider"></span>
78
+ </label>
79
+ <% end %>
80
+ </td>
81
+ <td>
82
+ <div style="display: flex; gap: 0.25rem; justify-content: flex-end; align-items: center;">
83
+ <%# Test %>
84
+ <% if channel.enabled? %>
85
+ <%= button_to findbug.test_alert_path(channel), method: :post, class: "btn-icon", title: "Send test alert" do %>
86
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
87
+ <path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/>
88
+ </svg>
89
+ <% end %>
90
+ <% end %>
91
+ <%# Edit %>
92
+ <a href="<%= findbug.edit_alert_path(channel) %>" class="btn-icon" title="Edit">
93
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
94
+ <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
95
+ <path d="m15 5 4 4"/>
96
+ </svg>
97
+ </a>
98
+ <%# Delete %>
99
+ <%= button_to findbug.alert_path(channel), method: :delete, class: "btn-icon destructive", title: "Delete" do %>
100
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
101
+ <path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
102
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
103
+ </svg>
104
+ <% end %>
105
+ </div>
106
+ </td>
107
+ </tr>
108
+ <% end %>
109
+ </tbody>
110
+ </table>
111
+ </div>
112
+ <% else %>
113
+ <div class="card">
114
+ <div class="empty-state">
115
+ <div class="empty-state-icon">
116
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
117
+ <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
118
+ <path d="M13.73 21a2 2 0 0 1-3.46 0"/>
119
+ </svg>
120
+ </div>
121
+ <p>No alert channels configured yet.</p>
122
+ <p style="margin-top: 0.75rem;">
123
+ <a href="<%= findbug.new_alert_path %>" class="btn btn-primary btn-sm">Add your first channel</a>
124
+ </p>
125
+ </div>
126
+ </div>
127
+ <% end %>
@@ -0,0 +1,154 @@
1
+ <h1>New Alert Channel</h1>
2
+ <p class="page-description">Configure a new alert channel to receive error notifications.</p>
3
+
4
+ <div class="card" style="max-width: 560px;">
5
+ <div class="card-content">
6
+ <%= form_tag findbug.alerts_path, method: :post do %>
7
+ <div style="display: flex; flex-direction: column; gap: 1.5rem;">
8
+
9
+ <%# Channel Type %>
10
+ <div class="form-group">
11
+ <label>Channel Type</label>
12
+ <%= select_tag "alert_channel[channel_type]",
13
+ options_for_select([
14
+ ["Select a channel type...", ""],
15
+ ["Email", "email"],
16
+ ["Slack", "slack"],
17
+ ["Discord", "discord"],
18
+ ["Webhook", "webhook"]
19
+ ], @channel.channel_type),
20
+ id: "channel_type_select",
21
+ onchange: "toggleConfigFields(this.value)" %>
22
+ </div>
23
+
24
+ <%# Name %>
25
+ <div class="form-group">
26
+ <label>Name</label>
27
+ <input type="text" name="alert_channel[name]" value="<%= @channel.name %>"
28
+ placeholder="e.g., Production Slack, Dev Team Email">
29
+ </div>
30
+
31
+ <%# Enabled %>
32
+ <label style="display: flex; align-items: center; gap: 0.75rem; cursor: pointer;">
33
+ <input type="hidden" name="alert_channel[enabled]" value="0">
34
+ <span class="toggle">
35
+ <input type="checkbox" name="alert_channel[enabled]" value="1" <%= "checked" if @channel.enabled? %>>
36
+ <span class="toggle-slider"></span>
37
+ </span>
38
+ <span>Enable this channel</span>
39
+ </label>
40
+
41
+ <div class="separator"></div>
42
+
43
+ <%# Email config fields %>
44
+ <div id="config_email" class="channel-config" style="display: none;">
45
+ <h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 1.25rem; color: hsl(var(--muted-foreground));">Email Configuration</h3>
46
+ <div style="display: flex; flex-direction: column; gap: 1.25rem;">
47
+ <div class="form-group">
48
+ <label>Recipients</label>
49
+ <textarea name="config[recipients]" rows="3" placeholder="One email per line&#10;team@example.com&#10;oncall@example.com" class="font-mono"><%= Array(@channel.config["recipients"]).join("\n") %></textarea>
50
+ <span class="form-hint">One email address per line</span>
51
+ </div>
52
+ <div class="form-group">
53
+ <label>From Address</label>
54
+ <input type="text" name="config[from]" value="<%= @channel.config["from"] %>" placeholder="findbug@example.com">
55
+ <span class="form-hint">Optional. Defaults to findbug@localhost</span>
56
+ </div>
57
+ </div>
58
+ </div>
59
+
60
+ <%# Slack config fields %>
61
+ <div id="config_slack" class="channel-config" style="display: none;">
62
+ <h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 1.25rem; color: hsl(var(--muted-foreground));">Slack Configuration</h3>
63
+ <div style="display: flex; flex-direction: column; gap: 1.25rem;">
64
+ <div class="form-group">
65
+ <label>Webhook URL</label>
66
+ <input type="text" name="config[webhook_url]" value="<%= @channel.config["webhook_url"] %>" placeholder="https://hooks.slack.com/services/..." class="font-mono">
67
+ </div>
68
+ <div class="form-group">
69
+ <label>Channel</label>
70
+ <input type="text" name="config[channel]" value="<%= @channel.config["channel"] %>" placeholder="#errors">
71
+ <span class="form-hint">Optional. Overrides the webhook's default channel</span>
72
+ </div>
73
+ <div class="form-group">
74
+ <label>Username</label>
75
+ <input type="text" name="config[username]" value="<%= @channel.config["username"] %>" placeholder="Findbug">
76
+ <span class="form-hint">Optional. Defaults to Findbug</span>
77
+ </div>
78
+ <div class="form-group">
79
+ <label>Icon Emoji</label>
80
+ <input type="text" name="config[icon_emoji]" value="<%= @channel.config["icon_emoji"] %>" placeholder=":bug:">
81
+ <span class="form-hint">Optional. Defaults to :bug:</span>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <%# Discord config fields %>
87
+ <div id="config_discord" class="channel-config" style="display: none;">
88
+ <h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 1.25rem; color: hsl(var(--muted-foreground));">Discord Configuration</h3>
89
+ <div style="display: flex; flex-direction: column; gap: 1.25rem;">
90
+ <div class="form-group">
91
+ <label>Webhook URL</label>
92
+ <input type="text" name="config[webhook_url]" value="<%= @channel.config["webhook_url"] %>" placeholder="https://discord.com/api/webhooks/..." class="font-mono">
93
+ </div>
94
+ <div class="form-group">
95
+ <label>Username</label>
96
+ <input type="text" name="config[username]" value="<%= @channel.config["username"] %>" placeholder="Findbug">
97
+ <span class="form-hint">Optional. Defaults to Findbug</span>
98
+ </div>
99
+ <div class="form-group">
100
+ <label>Avatar URL</label>
101
+ <input type="text" name="config[avatar_url]" value="<%= @channel.config["avatar_url"] %>" placeholder="https://example.com/avatar.png">
102
+ <span class="form-hint">Optional. URL to a custom avatar image</span>
103
+ </div>
104
+ </div>
105
+ </div>
106
+
107
+ <%# Webhook config fields %>
108
+ <div id="config_webhook" class="channel-config" style="display: none;">
109
+ <h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 1.25rem; color: hsl(var(--muted-foreground));">Webhook Configuration</h3>
110
+ <div style="display: flex; flex-direction: column; gap: 1.25rem;">
111
+ <div class="form-group">
112
+ <label>URL</label>
113
+ <input type="text" name="config[url]" value="<%= @channel.config["url"] %>" placeholder="https://your-service.com/findbug-webhook" class="font-mono">
114
+ </div>
115
+ <div class="form-group">
116
+ <label>HTTP Method</label>
117
+ <%= select_tag "config[method]",
118
+ options_for_select([["POST", "POST"], ["PUT", "PUT"]], @channel.config["method"] || "POST") %>
119
+ </div>
120
+ <div class="form-group">
121
+ <label>Custom Headers</label>
122
+ <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>
123
+ <span class="form-hint">One header per line in "Key: Value" format</span>
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+ <div class="separator"></div>
129
+
130
+ <%# Actions %>
131
+ <div style="display: flex; gap: 0.75rem;">
132
+ <button type="submit" class="btn btn-primary">Create Channel</button>
133
+ <a href="<%= findbug.alerts_path %>" class="btn btn-ghost">Cancel</a>
134
+ </div>
135
+ </div>
136
+ <% end %>
137
+ </div>
138
+ </div>
139
+
140
+ <script>
141
+ function toggleConfigFields(channelType) {
142
+ document.querySelectorAll('.channel-config').forEach(function(el) {
143
+ el.style.display = 'none';
144
+ });
145
+ if (channelType) {
146
+ var section = document.getElementById('config_' + channelType);
147
+ if (section) section.style.display = 'block';
148
+ }
149
+ }
150
+ (function() {
151
+ var select = document.getElementById('channel_type_select');
152
+ if (select && select.value) toggleConfigFields(select.value);
153
+ })();
154
+ </script>
@@ -292,7 +292,61 @@
292
292
  transition: border-color 0.15s;
293
293
  }
294
294
 
295
- select:focus, input:focus { outline: none; border-color: hsl(var(--ring)); box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2); }
295
+ textarea {
296
+ padding: 0.5rem 0.75rem;
297
+ font-size: 0.875rem;
298
+ background-color: hsl(var(--background));
299
+ border: 1px solid hsl(var(--input));
300
+ border-radius: var(--radius);
301
+ color: hsl(var(--foreground));
302
+ transition: border-color 0.15s;
303
+ resize: vertical;
304
+ line-height: 1.5;
305
+ }
306
+
307
+ input[type="checkbox"] {
308
+ width: 1rem;
309
+ height: 1rem;
310
+ accent-color: hsl(var(--primary));
311
+ cursor: pointer;
312
+ }
313
+
314
+ /* Toggle switch */
315
+ .toggle { position: relative; display: inline-block; width: 2rem; height: 1.125rem; flex-shrink: 0; }
316
+ .toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
317
+ .toggle-slider {
318
+ position: absolute; cursor: pointer; inset: 0;
319
+ background-color: hsl(var(--error) / 0.5);
320
+ border-radius: 1rem;
321
+ transition: background-color 0.2s;
322
+ }
323
+ .toggle-slider::before {
324
+ content: "";
325
+ position: absolute; left: 2px; top: 50%; transform: translateY(-50%);
326
+ width: 0.875rem; height: 0.875rem;
327
+ background-color: hsl(var(--foreground));
328
+ border-radius: 50%;
329
+ transition: transform 0.2s;
330
+ }
331
+ .toggle input:checked + .toggle-slider { background-color: hsl(var(--success)); }
332
+ .toggle input:checked + .toggle-slider::before { transform: translateY(-50%) translateX(0.875rem); }
333
+ .toggle input:focus-visible + .toggle-slider { box-shadow: 0 0 0 2px hsl(var(--ring) / 0.3); }
334
+
335
+ /* Icon button */
336
+ .btn-icon {
337
+ display: inline-flex; align-items: center; justify-content: center;
338
+ width: 1.75rem; height: 1.75rem; padding: 0; border: none; border-radius: var(--radius);
339
+ background: transparent; color: hsl(var(--muted-foreground)); cursor: pointer;
340
+ transition: background-color 0.15s, color 0.15s;
341
+ }
342
+ .btn-icon:hover { background-color: hsl(var(--muted)); color: hsl(var(--foreground)); }
343
+ .btn-icon.destructive:hover { color: hsl(var(--error)); }
344
+
345
+ select:focus, input:focus, textarea:focus { outline: none; border-color: hsl(var(--ring)); box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2); }
346
+
347
+ .form-group { display: flex; flex-direction: column; gap: 0.375rem; }
348
+ .form-group label { font-size: 0.875rem; font-weight: 500; color: hsl(var(--foreground)); }
349
+ .form-hint { font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
296
350
 
297
351
  select {
298
352
  cursor: pointer;
@@ -566,6 +620,7 @@
566
620
  <a href="<%= findbug.root_path %>" class="<%= 'active' if controller_name == 'dashboard' %>">Dashboard</a>
567
621
  <a href="<%= findbug.errors_path %>" class="<%= 'active' if controller_name == 'errors' %>">Errors</a>
568
622
  <a href="<%= findbug.performance_index_path %>" class="<%= 'active' if controller_name == 'performance' %>">Performance</a>
623
+ <a href="<%= findbug.alerts_path %>" class="<%= 'active' if controller_name == 'alerts' %>">Alerts</a>
569
624
  </nav>
570
625
  <div class="header-right">
571
626
  <div class="health-indicator">