snitch-rails 0.2.4 → 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 +4 -4
- data/README.md +95 -6
- data/app/assets/builds/snitch/application.css +2 -0
- data/app/assets/stylesheets/snitch/application.css +2 -0
- data/app/controllers/snitch/snitches_controller.rb +35 -0
- data/app/controllers/snitch/webhooks_controller.rb +27 -0
- data/app/views/layouts/snitch/application.html.erb +51 -0
- data/app/views/snitch/snitches/index.html.erb +125 -0
- data/app/views/snitch/snitches/show.html.erb +99 -0
- data/config/routes.rb +5 -0
- data/lib/generators/snitch/install/install_generator.rb +4 -0
- data/lib/generators/snitch/install/templates/create_snitch_errors.rb.erb +2 -0
- data/lib/generators/snitch/install/templates/snitch.rb +3 -0
- data/lib/generators/snitch/update/templates/add_status_to_snitch_errors.rb.erb +6 -0
- data/lib/generators/snitch/update/update_generator.rb +17 -0
- data/lib/snitch/configuration.rb +2 -1
- data/lib/snitch/exception_handler.rb +9 -4
- data/lib/snitch/github_client.rb +5 -0
- data/lib/snitch/jobs/resolve_event_job.rb +14 -0
- data/lib/snitch/middleware.rb +1 -1
- data/lib/snitch/models/event.rb +6 -0
- data/lib/snitch/version.rb +1 -1
- data/lib/snitch.rb +1 -0
- data/lib/tasks/snitch_tailwind.rake +22 -0
- metadata +27 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a33dc497c1b82d6d45ebc526e83f27eb51cb683fcf7eb3634d0b94aa020be386
|
|
4
|
+
data.tar.gz: 1302300c77ed110f702cb2f6b5817ce71e87ad1cfe35845a1935776a2f6d830c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d7bbd6b45dc3f1ed501940526360912793e60b7b18eae32c10b32b48f5e90ebb0497cb450e8581fa99e6d14144eabce3c5a03d61069ab37fc3968c5024e573a2
|
|
7
|
+
data.tar.gz: e7996ecc883c3a112211c31a174a3281f1a03898bb8af2c994aef9df21648619dac8e442099becc6223398dcae402fd1e69a07c83552b506cb61562c106f5b7a
|
data/README.md
CHANGED
|
@@ -4,6 +4,15 @@ Snitch catches unhandled exceptions in your Rails application, persists them to
|
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Automatic exception capture via Rack middleware
|
|
10
|
+
- Fingerprinting and deduplication (increments occurrence count for repeat exceptions)
|
|
11
|
+
- GitHub issue creation with full backtrace, request context, and @mentions
|
|
12
|
+
- **Dashboard** at `/snitches` with tabbed views: Open, Closed, and Ignored
|
|
13
|
+
- Ignore exceptions via config or directly from the dashboard
|
|
14
|
+
- Manual exception reporting for rescued exceptions
|
|
15
|
+
|
|
7
16
|
## Installation
|
|
8
17
|
|
|
9
18
|
Add Snitch to your Gemfile:
|
|
@@ -18,18 +27,34 @@ Run bundle install:
|
|
|
18
27
|
bundle install
|
|
19
28
|
```
|
|
20
29
|
|
|
21
|
-
Run the install generator
|
|
30
|
+
Run the install generator:
|
|
22
31
|
|
|
23
32
|
```bash
|
|
24
33
|
rails generate snitch:install
|
|
34
|
+
rails db:migrate
|
|
25
35
|
```
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
The installer mounts the dashboard to `config/routes.rb`:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
mount Snitch::Engine, at: "/snitches"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Upgrading from a previous version
|
|
44
|
+
|
|
45
|
+
If you're upgrading from a version prior to 0.3.0, run the update generator to add the `status` column to the `snitch_errors` table:
|
|
28
46
|
|
|
29
47
|
```bash
|
|
48
|
+
rails generate snitch:update
|
|
30
49
|
rails db:migrate
|
|
31
50
|
```
|
|
32
51
|
|
|
52
|
+
Then mount the engine in your `config/routes.rb`:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
mount Snitch::Engine, at: "/snitches"
|
|
56
|
+
```
|
|
57
|
+
|
|
33
58
|
## Configuration
|
|
34
59
|
|
|
35
60
|
The generator creates an initializer at `config/initializers/snitch.rb`. Update it with your settings:
|
|
@@ -50,12 +75,40 @@ Snitch.configure do |config|
|
|
|
50
75
|
|
|
51
76
|
# Exceptions to ignore (default: ActiveRecord::RecordNotFound, ActionController::RoutingError)
|
|
52
77
|
config.ignored_exceptions += [YourCustomError]
|
|
78
|
+
|
|
79
|
+
# GitHub webhook secret for auto-closing events when issues are closed (optional)
|
|
80
|
+
config.github_webhook_secret = ENV["SNITCH_GITHUB_WEBHOOK_SECRET"]
|
|
53
81
|
end
|
|
54
82
|
```
|
|
55
83
|
|
|
56
84
|
### GitHub Token
|
|
57
85
|
|
|
58
|
-
Create a [personal access token](https://github.com/settings/tokens) with the `repo` scope and set it as an environment variable
|
|
86
|
+
Create a [personal access token](https://github.com/settings/tokens) with the `repo` scope and set it as an environment variable.
|
|
87
|
+
|
|
88
|
+
## Dashboard
|
|
89
|
+
|
|
90
|
+
Visit `/snitches` in your browser to view the Snitch dashboard.
|
|
91
|
+
|
|
92
|
+

|
|
93
|
+
|
|
94
|
+
The dashboard provides three tabs:
|
|
95
|
+
|
|
96
|
+
- **Open** — Exceptions that need attention. Shows the most recently occurred first.
|
|
97
|
+
- **Closed** — Exceptions that have been resolved. You can reopen them if they recur.
|
|
98
|
+
- **Ignored** — Exceptions you've chosen to suppress. Ignored exceptions will not create new GitHub issues even if they occur again.
|
|
99
|
+
|
|
100
|
+
From the dashboard you can change the status of any exception:
|
|
101
|
+
- Open exceptions can be **closed** or **ignored**
|
|
102
|
+
- Closed or ignored exceptions can be **reopened**
|
|
103
|
+
|
|
104
|
+
### Ignoring Exceptions
|
|
105
|
+
|
|
106
|
+
There are two ways to ignore exceptions, and Snitch honors both:
|
|
107
|
+
|
|
108
|
+
1. **Via the initializer** — Add exception classes to `config.ignored_exceptions`. These are filtered at the middleware level and never captured or persisted.
|
|
109
|
+
2. **Via the dashboard** — Mark individual exceptions as "Ignored" from the Open tab. Future occurrences of that exception class will be silently skipped — no new records, no GitHub issues.
|
|
110
|
+
|
|
111
|
+
Both sources are checked every time an exception is caught. If an exception class appears in either the initializer config or has a record with status "ignored" in the database, it will be ignored.
|
|
59
112
|
|
|
60
113
|
## Manual Reporting
|
|
61
114
|
|
|
@@ -74,6 +127,42 @@ This is useful for exceptions you want to recover from gracefully but still want
|
|
|
74
127
|
|
|
75
128
|
The same fingerprinting and deduplication rules apply. If the same exception is reported multiple times, Snitch will increment the occurrence count and comment on the existing GitHub issue rather than creating a new one.
|
|
76
129
|
|
|
130
|
+
## GitHub Webhook (Auto-Close)
|
|
131
|
+
|
|
132
|
+
When a developer fixes a bug and closes the GitHub issue, Snitch can automatically close the matching event record in your database — no manual dashboard cleanup required.
|
|
133
|
+
|
|
134
|
+
### Setup
|
|
135
|
+
|
|
136
|
+
1. Generate a webhook secret:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
ruby -rsecurerandom -e "puts SecureRandom.hex(32)"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
2. Add the secret to your environment and Snitch config:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
export SNITCH_GITHUB_WEBHOOK_SECRET="your-generated-secret"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
# config/initializers/snitch.rb
|
|
150
|
+
config.github_webhook_secret = ENV["SNITCH_GITHUB_WEBHOOK_SECRET"]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
3. Add the same secret to your GitHub repository. Go to **Settings > Webhooks > Add webhook** and configure:
|
|
154
|
+
|
|
155
|
+
| Field | Value |
|
|
156
|
+
|----------------|------------------------------------------------|
|
|
157
|
+
| Payload URL | `https://yourapp.com/snitches/webhooks/github` |
|
|
158
|
+
| Content type | `application/json` |
|
|
159
|
+
| Secret | The secret you generated in step 1 |
|
|
160
|
+
| Events | Select **Issues** only |
|
|
161
|
+
|
|
162
|
+
The secret must match on both sides — your app uses it to verify that incoming webhooks are genuinely from GitHub.
|
|
163
|
+
|
|
164
|
+
Once configured, closing a GitHub issue will automatically close the corresponding Snitch event.
|
|
165
|
+
|
|
77
166
|
## How It Works
|
|
78
167
|
|
|
79
168
|
1. Rack middleware catches any unhandled exception (and re-raises it so normal error handling still applies)
|
|
@@ -84,9 +173,9 @@ The same fingerprinting and deduplication rules apply. If the same exception is
|
|
|
84
173
|
|
|
85
174
|
## Roadmap
|
|
86
175
|
|
|
87
|
-
- [
|
|
88
|
-
- [ ]
|
|
89
|
-
- [
|
|
176
|
+
- [x] Dashboard to view and manage captured exceptions
|
|
177
|
+
- [ ] Multi-db support
|
|
178
|
+
- [x] Webhook to resolve snitch records when GitHub issues close
|
|
90
179
|
|
|
91
180
|
## Requirements
|
|
92
181
|
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
|
|
2
|
+
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-500:oklch(63.7% .237 25.331);--color-red-700:oklch(50.5% .213 27.518);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-green-50:oklch(98.2% .018 155.826);--color-green-200:oklch(92.5% .084 155.995);--color-green-500:oklch(72.3% .219 149.579);--color-green-700:oklch(52.7% .154 150.069);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--radius-lg:.5rem;--radius-xl:.75rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.end{inset-inline-end:var(--spacing)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.block{display:block}.flex{display:flex}.grid{display:grid}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-5{height:calc(var(--spacing) * 5)}.h-7{height:calc(var(--spacing) * 7)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.min-h-screen{min-height:100vh}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-12{width:calc(var(--spacing) * 12)}.w-full{width:100%}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[14rem\]{max-width:14rem}.max-w-xs{max-width:var(--container-xs)}.min-w-\[2rem\]{min-width:2rem}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing) * 1)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-x-1{column-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-amber-300{border-color:var(--color-amber-300)}.border-amber-500{border-color:var(--color-amber-500)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-500{border-color:var(--color-gray-500)}.border-green-200{border-color:var(--color-green-200)}.border-indigo-300{border-color:var(--color-indigo-300)}.border-indigo-500{border-color:var(--color-indigo-500)}.border-red-200{border-color:var(--color-red-200)}.border-transparent{border-color:#0000}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-green-50{background-color:var(--color-green-50)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-white{background-color:var(--color-white)}.p-4{padding:calc(var(--spacing) * 4)}.p-12{padding:calc(var(--spacing) * 12)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-8{padding-block:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-700{color:var(--color-green-700)}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-700{color:var(--color-indigo-700)}.text-red-500{color:var(--color-red-500)}.text-red-700{color:var(--color-red-700)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-indigo-100:hover{background-color:var(--color-indigo-100)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-indigo-800:hover{color:var(--color-indigo-800)}}@media (min-width:40rem){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}}@media (min-width:64rem){.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Snitch
|
|
2
|
+
class SnitchesController < ActionController::Base
|
|
3
|
+
layout "snitch/application"
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@tab = params[:tab] || "open"
|
|
7
|
+
@events = case @tab
|
|
8
|
+
when "open" then Snitch::Event.open.order(last_occurred_at: :desc)
|
|
9
|
+
when "closed" then Snitch::Event.closed.order(updated_at: :desc)
|
|
10
|
+
when "ignored" then Snitch::Event.ignored.order(updated_at: :desc)
|
|
11
|
+
else Snitch::Event.open.order(last_occurred_at: :desc)
|
|
12
|
+
end
|
|
13
|
+
@counts = {
|
|
14
|
+
open: Snitch::Event.open.count,
|
|
15
|
+
closed: Snitch::Event.closed.count,
|
|
16
|
+
ignored: Snitch::Event.ignored.count
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def show
|
|
21
|
+
@event = Snitch::Event.find(params[:id])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def update
|
|
25
|
+
@event = Snitch::Event.find(params[:id])
|
|
26
|
+
@event.update!(status: params[:status])
|
|
27
|
+
|
|
28
|
+
if params[:redirect_to] == "show"
|
|
29
|
+
redirect_to snitch_path(@event), notice: "Snitch updated."
|
|
30
|
+
else
|
|
31
|
+
redirect_to snitches_path(tab: params[:tab] || @event.status), notice: "Snitch updated."
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snitch
|
|
4
|
+
class WebhooksController < ActionController::Base
|
|
5
|
+
skip_forgery_protection
|
|
6
|
+
|
|
7
|
+
def github
|
|
8
|
+
secret = Snitch.configuration.github_webhook_secret
|
|
9
|
+
return head :unauthorized unless secret.present?
|
|
10
|
+
|
|
11
|
+
body = request.body.read
|
|
12
|
+
signature = request.headers["X-Hub-Signature-256"]
|
|
13
|
+
expected = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", secret, body)}"
|
|
14
|
+
return head :unauthorized unless signature.present? && Rack::Utils.secure_compare(expected, signature)
|
|
15
|
+
|
|
16
|
+
return head :ok unless request.headers["X-GitHub-Event"] == "issues"
|
|
17
|
+
|
|
18
|
+
payload = JSON.parse(body)
|
|
19
|
+
return head :ok unless payload["action"] == "closed"
|
|
20
|
+
|
|
21
|
+
issue_number = payload.dig("issue", "number")
|
|
22
|
+
ResolveEventJob.perform_later(issue_number)
|
|
23
|
+
|
|
24
|
+
head :ok
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Snitch</title>
|
|
7
|
+
<style>
|
|
8
|
+
<%= File.read(Snitch::Engine.root.join("app/assets/builds/snitch/application.css")).html_safe %>
|
|
9
|
+
</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body class="bg-gray-50 min-h-screen">
|
|
12
|
+
<header class="bg-white border-b border-gray-200">
|
|
13
|
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
14
|
+
<div class="flex items-center justify-between h-16">
|
|
15
|
+
<div class="flex items-center gap-3">
|
|
16
|
+
<svg class="w-7 h-7 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
17
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
18
|
+
</svg>
|
|
19
|
+
<%= link_to "Snitch", snitches_path, class: "text-xl font-bold text-gray-900 hover:text-indigo-600 transition-colors" %>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</header>
|
|
24
|
+
|
|
25
|
+
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
26
|
+
<% if notice %>
|
|
27
|
+
<div class="mb-6 rounded-lg bg-green-50 border border-green-200 p-4">
|
|
28
|
+
<div class="flex items-center">
|
|
29
|
+
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
30
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
|
31
|
+
</svg>
|
|
32
|
+
<p class="text-sm text-green-700"><%= notice %></p>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
37
|
+
<% if alert %>
|
|
38
|
+
<div class="mb-6 rounded-lg bg-red-50 border border-red-200 p-4">
|
|
39
|
+
<div class="flex items-center">
|
|
40
|
+
<svg class="w-5 h-5 text-red-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
41
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
42
|
+
</svg>
|
|
43
|
+
<p class="text-sm text-red-700"><%= alert %></p>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<% end %>
|
|
47
|
+
|
|
48
|
+
<%= yield %>
|
|
49
|
+
</main>
|
|
50
|
+
</body>
|
|
51
|
+
</html>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<div>
|
|
2
|
+
<div class="border-b border-gray-200 mb-6">
|
|
3
|
+
<nav class="flex gap-x-1" aria-label="Tabs">
|
|
4
|
+
<% [
|
|
5
|
+
["open", "Open", @counts[:open], "indigo"],
|
|
6
|
+
["closed", "Closed", @counts[:closed], "gray"],
|
|
7
|
+
["ignored", "Ignored", @counts[:ignored], "amber"]
|
|
8
|
+
].each do |tab_key, tab_label, count, color| %>
|
|
9
|
+
<% active = @tab == tab_key %>
|
|
10
|
+
<%= link_to snitches_path(tab: tab_key),
|
|
11
|
+
class: "flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors #{
|
|
12
|
+
if active
|
|
13
|
+
case color
|
|
14
|
+
when 'indigo' then 'border-indigo-500 text-indigo-600'
|
|
15
|
+
when 'gray' then 'border-gray-500 text-gray-700'
|
|
16
|
+
when 'amber' then 'border-amber-500 text-amber-600'
|
|
17
|
+
end
|
|
18
|
+
else
|
|
19
|
+
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
20
|
+
end
|
|
21
|
+
}" do %>
|
|
22
|
+
<%= tab_label %>
|
|
23
|
+
<span class="rounded-full px-2.5 py-0.5 text-xs font-medium <%=
|
|
24
|
+
if active
|
|
25
|
+
case color
|
|
26
|
+
when 'indigo' then 'bg-indigo-100 text-indigo-700'
|
|
27
|
+
when 'gray' then 'bg-gray-100 text-gray-700'
|
|
28
|
+
when 'amber' then 'bg-amber-100 text-amber-700'
|
|
29
|
+
end
|
|
30
|
+
else
|
|
31
|
+
'bg-gray-100 text-gray-600'
|
|
32
|
+
end
|
|
33
|
+
%>"><%= count %></span>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% end %>
|
|
36
|
+
</nav>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<% if @events.any? %>
|
|
40
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-x-auto">
|
|
41
|
+
<table class="w-full divide-y divide-gray-200">
|
|
42
|
+
<thead class="bg-gray-50">
|
|
43
|
+
<tr>
|
|
44
|
+
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Exception</th>
|
|
45
|
+
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Message</th>
|
|
46
|
+
<th class="px-6 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider">Count</th>
|
|
47
|
+
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Last Occurred</th>
|
|
48
|
+
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Issue</th>
|
|
49
|
+
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
|
|
50
|
+
</tr>
|
|
51
|
+
</thead>
|
|
52
|
+
<tbody class="divide-y divide-gray-200">
|
|
53
|
+
<% @events.each do |event| %>
|
|
54
|
+
<tr class="hover:bg-gray-50 transition-colors">
|
|
55
|
+
<td class="px-6 py-4 max-w-[14rem]">
|
|
56
|
+
<%= link_to snitch_path(event), class: "font-mono text-sm font-semibold text-indigo-600 hover:text-indigo-800 truncate block" do %>
|
|
57
|
+
<%= truncate(event.exception_class, length: 30) %>
|
|
58
|
+
<% end %>
|
|
59
|
+
</td>
|
|
60
|
+
<td class="px-6 py-4 max-w-xs">
|
|
61
|
+
<p class="text-sm text-gray-600 truncate"><%= truncate(event.message, length: 80) %></p>
|
|
62
|
+
</td>
|
|
63
|
+
<td class="px-6 py-4 text-center">
|
|
64
|
+
<span class="inline-flex items-center justify-center min-w-[2rem] rounded-full px-2.5 py-0.5 text-xs font-semibold <%= event.occurrence_count > 10 ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-700' %>">
|
|
65
|
+
<%= event.occurrence_count %>
|
|
66
|
+
</span>
|
|
67
|
+
</td>
|
|
68
|
+
<td class="px-6 py-4 whitespace-nowrap">
|
|
69
|
+
<span class="text-sm text-gray-500"><%= event.last_occurred_at&.strftime("%b %d, %Y %l:%M %p") %></span>
|
|
70
|
+
</td>
|
|
71
|
+
<td class="px-6 py-4 whitespace-nowrap">
|
|
72
|
+
<% if event.github_issue_url.present? %>
|
|
73
|
+
<a href="<%= event.github_issue_url %>" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1 text-sm text-indigo-600 hover:text-indigo-800 font-medium">
|
|
74
|
+
#<%= event.github_issue_number %>
|
|
75
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
|
76
|
+
</a>
|
|
77
|
+
<% else %>
|
|
78
|
+
<span class="text-sm text-gray-400">—</span>
|
|
79
|
+
<% end %>
|
|
80
|
+
</td>
|
|
81
|
+
<td class="px-6 py-4 whitespace-nowrap text-right">
|
|
82
|
+
<div class="flex items-center justify-end gap-2">
|
|
83
|
+
<% if @tab == "open" %>
|
|
84
|
+
<%= form_with url: snitch_path(event), method: :patch, local: true, class: "inline" do |f| %>
|
|
85
|
+
<%= f.hidden_field :status, value: "closed" %>
|
|
86
|
+
<%= f.hidden_field :tab, value: @tab %>
|
|
87
|
+
<%= f.submit "Close", class: "inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 transition-colors cursor-pointer" %>
|
|
88
|
+
<% end %>
|
|
89
|
+
<%= form_with url: snitch_path(event), method: :patch, local: true, class: "inline" do |f| %>
|
|
90
|
+
<%= f.hidden_field :status, value: "ignored" %>
|
|
91
|
+
<%= f.hidden_field :tab, value: @tab %>
|
|
92
|
+
<%= f.submit "Ignore", class: "inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg border border-amber-300 text-amber-700 bg-amber-50 hover:bg-amber-100 transition-colors cursor-pointer" %>
|
|
93
|
+
<% end %>
|
|
94
|
+
<% else %>
|
|
95
|
+
<%= form_with url: snitch_path(event), method: :patch, local: true, class: "inline" do |f| %>
|
|
96
|
+
<%= f.hidden_field :status, value: "open" %>
|
|
97
|
+
<%= f.hidden_field :tab, value: @tab %>
|
|
98
|
+
<%= f.submit "Reopen", class: "inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg border border-indigo-300 text-indigo-700 bg-indigo-50 hover:bg-indigo-100 transition-colors cursor-pointer" %>
|
|
99
|
+
<% end %>
|
|
100
|
+
<% end %>
|
|
101
|
+
</div>
|
|
102
|
+
</td>
|
|
103
|
+
</tr>
|
|
104
|
+
<% end %>
|
|
105
|
+
</tbody>
|
|
106
|
+
</table>
|
|
107
|
+
</div>
|
|
108
|
+
<% else %>
|
|
109
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
|
|
110
|
+
<svg class="mx-auto w-12 h-12 text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
111
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
112
|
+
</svg>
|
|
113
|
+
<h3 class="text-sm font-semibold text-gray-900 mb-1">No <%= @tab %> exceptions</h3>
|
|
114
|
+
<p class="text-sm text-gray-500">
|
|
115
|
+
<% if @tab == "open" %>
|
|
116
|
+
All clear! No unhandled exceptions to review.
|
|
117
|
+
<% elsif @tab == "closed" %>
|
|
118
|
+
No exceptions have been closed yet.
|
|
119
|
+
<% else %>
|
|
120
|
+
No exceptions are being ignored.
|
|
121
|
+
<% end %>
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
<% end %>
|
|
125
|
+
</div>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<div>
|
|
2
|
+
<div class="mb-6">
|
|
3
|
+
<%= link_to "← Back to list".html_safe, snitches_path(tab: @event.status), class: "text-sm text-gray-500 hover:text-gray-700 transition-colors" %>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
7
|
+
<div class="px-6 py-5 border-b border-gray-200 flex items-center justify-between">
|
|
8
|
+
<div>
|
|
9
|
+
<h1 class="font-mono text-lg font-bold text-gray-900"><%= @event.exception_class %></h1>
|
|
10
|
+
<p class="text-sm text-gray-500 mt-1"><%= @event.message %></p>
|
|
11
|
+
</div>
|
|
12
|
+
<span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold <%=
|
|
13
|
+
case @event.status
|
|
14
|
+
when 'open' then 'bg-indigo-100 text-indigo-700'
|
|
15
|
+
when 'closed' then 'bg-gray-100 text-gray-700'
|
|
16
|
+
when 'ignored' then 'bg-amber-100 text-amber-700'
|
|
17
|
+
end
|
|
18
|
+
%>"><%= @event.status.capitalize %></span>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div class="px-6 py-5 border-b border-gray-200">
|
|
22
|
+
<div class="grid grid-cols-2 gap-6 sm:grid-cols-4">
|
|
23
|
+
<div>
|
|
24
|
+
<dt class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Occurrences</dt>
|
|
25
|
+
<dd class="mt-1 text-sm font-semibold text-gray-900"><%= @event.occurrence_count %></dd>
|
|
26
|
+
</div>
|
|
27
|
+
<div>
|
|
28
|
+
<dt class="text-xs font-semibold text-gray-500 uppercase tracking-wider">First seen</dt>
|
|
29
|
+
<dd class="mt-1 text-sm text-gray-900"><%= @event.first_occurred_at&.strftime("%b %d, %Y %l:%M %p") %></dd>
|
|
30
|
+
</div>
|
|
31
|
+
<div>
|
|
32
|
+
<dt class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Last seen</dt>
|
|
33
|
+
<dd class="mt-1 text-sm text-gray-900"><%= @event.last_occurred_at&.strftime("%b %d, %Y %l:%M %p") %></dd>
|
|
34
|
+
</div>
|
|
35
|
+
<div>
|
|
36
|
+
<dt class="text-xs font-semibold text-gray-500 uppercase tracking-wider">GitHub issue</dt>
|
|
37
|
+
<dd class="mt-1 text-sm">
|
|
38
|
+
<% if @event.github_issue_url.present? %>
|
|
39
|
+
<a href="<%= @event.github_issue_url %>" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1 text-indigo-600 hover:text-indigo-800 font-medium">
|
|
40
|
+
#<%= @event.github_issue_number %>
|
|
41
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
|
42
|
+
</a>
|
|
43
|
+
<% else %>
|
|
44
|
+
<span class="text-gray-400">—</span>
|
|
45
|
+
<% end %>
|
|
46
|
+
</dd>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<% if @event.request_url.present? %>
|
|
52
|
+
<div class="px-6 py-5 border-b border-gray-200">
|
|
53
|
+
<h2 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Request</h2>
|
|
54
|
+
<div class="text-sm text-gray-900">
|
|
55
|
+
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-semibold bg-gray-100 text-gray-700 mr-2"><%= @event.request_method %></span>
|
|
56
|
+
<span class="font-mono text-sm"><%= @event.request_url %></span>
|
|
57
|
+
</div>
|
|
58
|
+
<% if @event.request_params.present? %>
|
|
59
|
+
<pre class="mt-3 bg-gray-50 rounded-lg p-4 text-xs font-mono text-gray-700 overflow-x-auto"><%= JSON.pretty_generate(JSON.parse(@event.request_params)) rescue @event.request_params %></pre>
|
|
60
|
+
<% end %>
|
|
61
|
+
</div>
|
|
62
|
+
<% end %>
|
|
63
|
+
|
|
64
|
+
<% if @event.backtrace.present? %>
|
|
65
|
+
<div class="px-6 py-5 border-b border-gray-200">
|
|
66
|
+
<h2 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Backtrace</h2>
|
|
67
|
+
<div class="bg-gray-50 rounded-lg p-4 overflow-x-auto">
|
|
68
|
+
<% @event.backtrace.each_with_index do |line, i| %>
|
|
69
|
+
<div class="flex gap-3 py-0.5 text-xs font-mono <%= line.include?('/app/') ? 'text-gray-900 font-medium' : 'text-gray-400' %>">
|
|
70
|
+
<span class="text-gray-300 select-none w-6 text-right shrink-0"><%= i + 1 %></span>
|
|
71
|
+
<span class="break-all"><%= line %></span>
|
|
72
|
+
</div>
|
|
73
|
+
<% end %>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
<% end %>
|
|
77
|
+
|
|
78
|
+
<div class="px-6 py-5 flex items-center gap-3">
|
|
79
|
+
<% if @event.status == "open" %>
|
|
80
|
+
<%= form_with url: snitch_path(@event), method: :patch, local: true, class: "inline" do |f| %>
|
|
81
|
+
<%= f.hidden_field :status, value: "closed" %>
|
|
82
|
+
<%= f.hidden_field :redirect_to, value: "show" %>
|
|
83
|
+
<%= f.submit "Close", class: "inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 transition-colors cursor-pointer" %>
|
|
84
|
+
<% end %>
|
|
85
|
+
<%= form_with url: snitch_path(@event), method: :patch, local: true, class: "inline" do |f| %>
|
|
86
|
+
<%= f.hidden_field :status, value: "ignored" %>
|
|
87
|
+
<%= f.hidden_field :redirect_to, value: "show" %>
|
|
88
|
+
<%= f.submit "Ignore", class: "inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg border border-amber-300 text-amber-700 bg-amber-50 hover:bg-amber-100 transition-colors cursor-pointer" %>
|
|
89
|
+
<% end %>
|
|
90
|
+
<% else %>
|
|
91
|
+
<%= form_with url: snitch_path(@event), method: :patch, local: true, class: "inline" do |f| %>
|
|
92
|
+
<%= f.hidden_field :status, value: "open" %>
|
|
93
|
+
<%= f.hidden_field :redirect_to, value: "show" %>
|
|
94
|
+
<%= f.submit "Reopen", class: "inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg border border-indigo-300 text-indigo-700 bg-indigo-50 hover:bg-indigo-100 transition-colors cursor-pointer" %>
|
|
95
|
+
<% end %>
|
|
96
|
+
<% end %>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
data/config/routes.rb
ADDED
|
@@ -11,6 +11,7 @@ class CreateSnitchErrors < ActiveRecord::Migration[<%= ActiveRecord::Migration.c
|
|
|
11
11
|
t.integer :occurrence_count, default: 1
|
|
12
12
|
t.integer :github_issue_number
|
|
13
13
|
t.string :github_issue_url
|
|
14
|
+
t.string :status, default: "open", null: false
|
|
14
15
|
t.datetime :first_occurred_at
|
|
15
16
|
t.datetime :last_occurred_at
|
|
16
17
|
t.timestamps
|
|
@@ -18,5 +19,6 @@ class CreateSnitchErrors < ActiveRecord::Migration[<%= ActiveRecord::Migration.c
|
|
|
18
19
|
|
|
19
20
|
add_index :snitch_errors, :fingerprint, unique: true
|
|
20
21
|
add_index :snitch_errors, :exception_class
|
|
22
|
+
add_index :snitch_errors, :status
|
|
21
23
|
end
|
|
22
24
|
end
|
|
@@ -13,4 +13,7 @@ Snitch.configure do |config|
|
|
|
13
13
|
|
|
14
14
|
# Exceptions to ignore (default: RecordNotFound, RoutingError)
|
|
15
15
|
# config.ignored_exceptions += [CustomError]
|
|
16
|
+
|
|
17
|
+
# GitHub webhook secret for auto-closing events when issues are closed
|
|
18
|
+
# config.github_webhook_secret = ENV["SNITCH_GITHUB_WEBHOOK_SECRET"]
|
|
16
19
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module Snitch
|
|
5
|
+
module Generators
|
|
6
|
+
class UpdateGenerator < Rails::Generators::Base
|
|
7
|
+
include ActiveRecord::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
def create_migration_file
|
|
12
|
+
migration_template "add_status_to_snitch_errors.rb.erb",
|
|
13
|
+
"db/migrate/add_status_to_snitch_errors.rb"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/snitch/configuration.rb
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Snitch
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_accessor :github_token, :github_repo, :mention, :enabled, :ignored_exceptions
|
|
5
|
+
attr_accessor :github_token, :github_repo, :github_webhook_secret, :mention, :enabled, :ignored_exceptions
|
|
6
6
|
|
|
7
7
|
def initialize
|
|
8
8
|
@github_token = nil
|
|
9
9
|
@github_repo = nil
|
|
10
|
+
@github_webhook_secret = nil
|
|
10
11
|
@mention = "@claude"
|
|
11
12
|
@enabled = true
|
|
12
13
|
@ignored_exceptions = [
|
|
@@ -16,10 +16,13 @@ module Snitch
|
|
|
16
16
|
private
|
|
17
17
|
|
|
18
18
|
def ignored?(exception)
|
|
19
|
-
Snitch.configuration.ignored_exceptions.any? do |ignored|
|
|
19
|
+
config_ignored = Snitch.configuration.ignored_exceptions.any? do |ignored|
|
|
20
20
|
ignored_class = ignored.is_a?(String) ? ignored.safe_constantize : ignored
|
|
21
21
|
ignored_class && exception.is_a?(ignored_class)
|
|
22
22
|
end
|
|
23
|
+
return true if config_ignored
|
|
24
|
+
|
|
25
|
+
Snitch::Event.ignored.where(exception_class: exception.class.name).exists?
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def extract_request_data(env)
|
|
@@ -42,12 +45,14 @@ module Snitch
|
|
|
42
45
|
existing = Event.find_by(fingerprint: fingerprint)
|
|
43
46
|
|
|
44
47
|
if existing
|
|
45
|
-
|
|
48
|
+
attrs = {
|
|
46
49
|
occurrence_count: existing.occurrence_count + 1,
|
|
47
50
|
last_occurred_at: Time.current,
|
|
48
51
|
message: exception.message,
|
|
49
52
|
backtrace: exception.backtrace
|
|
50
|
-
|
|
53
|
+
}
|
|
54
|
+
attrs[:status] = "open" if existing.status == "closed"
|
|
55
|
+
existing.update!(**attrs)
|
|
51
56
|
existing
|
|
52
57
|
else
|
|
53
58
|
Event.create!(
|
|
@@ -66,7 +71,7 @@ module Snitch
|
|
|
66
71
|
def enqueue_report(record)
|
|
67
72
|
ReportExceptionJob.perform_later(record.id)
|
|
68
73
|
rescue => e
|
|
69
|
-
Rails.logger.error("[Snitch] Failed to enqueue report job: #{e.message}") if defined?(Rails)
|
|
74
|
+
Rails.logger.error("[Snitch] Failed to enqueue report job: #{e.message}") if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
70
75
|
end
|
|
71
76
|
end
|
|
72
77
|
end
|
data/lib/snitch/github_client.rb
CHANGED
|
@@ -24,6 +24,11 @@ module Snitch
|
|
|
24
24
|
def comment_on_issue(event)
|
|
25
25
|
body = build_comment_body(event)
|
|
26
26
|
@client.add_comment(@repo, event.github_issue_number, body)
|
|
27
|
+
reopen_issue(event) if event.status == "open"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reopen_issue(event)
|
|
31
|
+
@client.update_issue(@repo, event.github_issue_number, state: "open")
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
private
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Snitch
|
|
4
|
+
class ResolveEventJob < ActiveJob::Base
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
retry_on StandardError, wait: :polynomially_later, attempts: 3
|
|
8
|
+
|
|
9
|
+
def perform(github_issue_number)
|
|
10
|
+
Event.where(github_issue_number: github_issue_number, status: "open")
|
|
11
|
+
.update_all(status: "closed")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/snitch/middleware.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Snitch
|
|
|
10
10
|
begin
|
|
11
11
|
ExceptionHandler.handle(e, env)
|
|
12
12
|
rescue => handler_error
|
|
13
|
-
Rails.logger.error("[Snitch] Handler error: #{handler_error.message}") if defined?(Rails)
|
|
13
|
+
Rails.logger.error("[Snitch] Handler error: #{handler_error.message}") if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
14
14
|
end
|
|
15
15
|
raise e
|
|
16
16
|
end
|
data/lib/snitch/models/event.rb
CHANGED
|
@@ -2,12 +2,18 @@ module Snitch
|
|
|
2
2
|
class Event < ActiveRecord::Base
|
|
3
3
|
self.table_name = "snitch_errors"
|
|
4
4
|
|
|
5
|
+
STATUSES = %w[open closed ignored].freeze
|
|
6
|
+
|
|
5
7
|
serialize :backtrace, coder: JSON
|
|
6
8
|
serialize :request_params, coder: JSON
|
|
7
9
|
|
|
8
10
|
validates :exception_class, presence: true
|
|
9
11
|
validates :fingerprint, presence: true
|
|
12
|
+
validates :status, inclusion: { in: STATUSES }
|
|
10
13
|
|
|
11
14
|
scope :by_fingerprint, ->(fp) { where(fingerprint: fp) }
|
|
15
|
+
scope :open, -> { where(status: "open") }
|
|
16
|
+
scope :closed, -> { where(status: "closed") }
|
|
17
|
+
scope :ignored, -> { where(status: "ignored") }
|
|
12
18
|
end
|
|
13
19
|
end
|
data/lib/snitch/version.rb
CHANGED
data/lib/snitch.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "snitch/exception_handler"
|
|
|
8
8
|
require "snitch/github_client"
|
|
9
9
|
require "snitch/models/event" if defined?(ActiveRecord)
|
|
10
10
|
require "snitch/jobs/report_exception_job" if defined?(ActiveJob)
|
|
11
|
+
require "snitch/jobs/resolve_event_job" if defined?(ActiveJob)
|
|
11
12
|
require "snitch/engine" if defined?(Rails)
|
|
12
13
|
|
|
13
14
|
module Snitch
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
namespace :snitch do
|
|
2
|
+
namespace :tailwind do
|
|
3
|
+
desc "Build Snitch Tailwind CSS"
|
|
4
|
+
task :build do
|
|
5
|
+
require "tailwindcss/ruby"
|
|
6
|
+
|
|
7
|
+
gem_root = File.expand_path("../..", __dir__)
|
|
8
|
+
input = File.join(gem_root, "app/assets/stylesheets/snitch/application.css")
|
|
9
|
+
output = File.join(gem_root, "app/assets/builds/snitch/application.css")
|
|
10
|
+
|
|
11
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
12
|
+
|
|
13
|
+
system(Tailwindcss::Ruby.executable,
|
|
14
|
+
"-i", input,
|
|
15
|
+
"-o", output,
|
|
16
|
+
"--minify",
|
|
17
|
+
exception: true)
|
|
18
|
+
|
|
19
|
+
puts "Snitch Tailwind CSS compiled successfully"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: snitch-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- RiseKit
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '9.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: tailwindcss-ruby
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '4.0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '4.0'
|
|
40
54
|
- !ruby/object:Gem::Dependency
|
|
41
55
|
name: rspec-rails
|
|
42
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -115,9 +129,19 @@ extra_rdoc_files: []
|
|
|
115
129
|
files:
|
|
116
130
|
- LICENSE.txt
|
|
117
131
|
- README.md
|
|
132
|
+
- app/assets/builds/snitch/application.css
|
|
133
|
+
- app/assets/stylesheets/snitch/application.css
|
|
134
|
+
- app/controllers/snitch/snitches_controller.rb
|
|
135
|
+
- app/controllers/snitch/webhooks_controller.rb
|
|
136
|
+
- app/views/layouts/snitch/application.html.erb
|
|
137
|
+
- app/views/snitch/snitches/index.html.erb
|
|
138
|
+
- app/views/snitch/snitches/show.html.erb
|
|
139
|
+
- config/routes.rb
|
|
118
140
|
- lib/generators/snitch/install/install_generator.rb
|
|
119
141
|
- lib/generators/snitch/install/templates/create_snitch_errors.rb.erb
|
|
120
142
|
- lib/generators/snitch/install/templates/snitch.rb
|
|
143
|
+
- lib/generators/snitch/update/templates/add_status_to_snitch_errors.rb.erb
|
|
144
|
+
- lib/generators/snitch/update/update_generator.rb
|
|
121
145
|
- lib/snitch.rb
|
|
122
146
|
- lib/snitch/configuration.rb
|
|
123
147
|
- lib/snitch/engine.rb
|
|
@@ -125,9 +149,11 @@ files:
|
|
|
125
149
|
- lib/snitch/fingerprint.rb
|
|
126
150
|
- lib/snitch/github_client.rb
|
|
127
151
|
- lib/snitch/jobs/report_exception_job.rb
|
|
152
|
+
- lib/snitch/jobs/resolve_event_job.rb
|
|
128
153
|
- lib/snitch/middleware.rb
|
|
129
154
|
- lib/snitch/models/event.rb
|
|
130
155
|
- lib/snitch/version.rb
|
|
156
|
+
- lib/tasks/snitch_tailwind.rake
|
|
131
157
|
licenses:
|
|
132
158
|
- MIT
|
|
133
159
|
metadata: {}
|