snitch-rails 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24e7f769e5a0ca5e3af5c85f174944fcb9883f6f70cef92f42dc29eedca8bcf5
4
- data.tar.gz: e286ffc4b8320d31f5e1345005feb186991386a95197df8db9ae3dd7ecab62e2
3
+ metadata.gz: 4c4a5dc4773dd228a4f9478cd08a0b1f3b6a4160500340dd692beca1395f0f49
4
+ data.tar.gz: 95b3c04d9b95d0e15c426c12746bf0fcefaefd47e35aeba898cd21fc26ba47b9
5
5
  SHA512:
6
- metadata.gz: 64eda4f8fd955360c4f0dbf03cfc00ad4fff5ddb0bc99903bb2982d59b36a4cf48218fa934209d813d85d15c4cdb4fc7107ab58c598b9d6139de9b6de70f2678
7
- data.tar.gz: b9e9e351fb45771c3863fc77127b53db784bd87ad49e8d599fab393bffd3b417a59886629ac4377336e5dc62cd89c82b05932ce8b671b9525ba516752a9d4741
6
+ metadata.gz: f1592e5587346cac3b8bd92b6f9536a6f890c7922df77ad3e47423b3139a0692a4367f69b80eee1702abc5eff63cd5bc99ebdce3c59db5e8a54ee91d282e8ed4
7
+ data.tar.gz: d0b95a4e8fa41d368af44a6b0efdfd1c70c1e9dfdf9c072faeb5316a62e19ef2a8c081a317a6e32a2be0fe70da969e8647e720c30a2e46dce88219a7ac35874b
data/README.md CHANGED
@@ -4,6 +4,16 @@ Snitch catches unhandled exceptions in your Rails application, persists them to
4
4
 
5
5
  ![Example GitHub issue created by Snitch](example.png)
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
+ - Upgrade generator for existing installations
16
+
7
17
  ## Installation
8
18
 
9
19
  Add Snitch to your Gemfile:
@@ -18,7 +28,7 @@ Run bundle install:
18
28
  bundle install
19
29
  ```
20
30
 
21
- Run the install generator to create the migration and initializer:
31
+ Run the install generator to create the migration, initializer, and mount the engine routes:
22
32
 
23
33
  ```bash
24
34
  rails generate snitch:install
@@ -30,6 +40,29 @@ Run the migration:
30
40
  rails db:migrate
31
41
  ```
32
42
 
43
+ The installer automatically adds the following route to your application:
44
+
45
+ ```ruby
46
+ mount Snitch::Engine, at: "/snitches"
47
+ ```
48
+
49
+ If you need to mount it manually or at a different path, add the above line to your `config/routes.rb`.
50
+
51
+ ### Upgrading from a previous version
52
+
53
+ 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:
54
+
55
+ ```bash
56
+ rails generate snitch:update
57
+ rails db:migrate
58
+ ```
59
+
60
+ Then mount the engine in your `config/routes.rb`:
61
+
62
+ ```ruby
63
+ mount Snitch::Engine, at: "/snitches"
64
+ ```
65
+
33
66
  ## Configuration
34
67
 
35
68
  The generator creates an initializer at `config/initializers/snitch.rb`. Update it with your settings:
@@ -55,7 +88,26 @@ end
55
88
 
56
89
  ### GitHub Token
57
90
 
58
- Create a [personal access token](https://github.com/settings/tokens) with the `repo` scope and set it as an environment variable:
91
+ Create a [personal access token](https://github.com/settings/tokens) with the `repo` scope and set it as an environment variable.
92
+
93
+ ## Dashboard
94
+
95
+ Visit `/snitches` in your browser to view the Snitch dashboard. The dashboard provides three tabs:
96
+
97
+ - **Open** — Exceptions that need attention. Shows the most recently occurred first.
98
+ - **Closed** — Exceptions that have been resolved. You can reopen them if they recur.
99
+ - **Ignored** — Exceptions you've chosen to suppress. Ignored exceptions will not create new GitHub issues even if they occur again.
100
+
101
+ From the dashboard you can change the status of any exception:
102
+ - Open exceptions can be **closed** or **ignored**
103
+ - Closed or ignored exceptions can be **reopened**
104
+
105
+ ### Ignoring Exceptions
106
+
107
+ There are two ways to ignore exceptions:
108
+
109
+ 1. **Via configuration** — Add exception classes to `config.ignored_exceptions` in the initializer. These are never captured.
110
+ 2. **Via the dashboard** — Mark individual exceptions as "Ignored" from the Open tab. These exceptions are already captured but future occurrences won't create new GitHub issues.
59
111
 
60
112
  ## Manual Reporting
61
113
 
@@ -85,7 +137,6 @@ The same fingerprinting and deduplication rules apply. If the same exception is
85
137
  ## Roadmap
86
138
 
87
139
  - [ ] multi-db support
88
- - [ ] simple dashboard to view captured exceptions (linked to gh issue)
89
140
  - [ ] webhook to resolve snitch record, when issues resolve
90
141
 
91
142
  ## Requirements
@@ -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,2 @@
1
+ @import "tailwindcss";
2
+ @source "../../../views/**/*.erb";
@@ -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,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">&mdash;</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 "&larr; 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">&mdash;</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
@@ -0,0 +1,4 @@
1
+ Snitch::Engine.routes.draw do
2
+ resources :snitches, only: [:index, :show, :update]
3
+ root to: "snitches#index"
4
+ end
@@ -33,6 +33,10 @@ module Snitch
33
33
  def create_initializer
34
34
  template "snitch.rb", "config/initializers/snitch.rb"
35
35
  end
36
+
37
+ def add_route
38
+ route 'mount Snitch::Engine, at: "/snitches"'
39
+ end
36
40
  end
37
41
  end
38
42
  end
@@ -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
@@ -0,0 +1,6 @@
1
+ class AddStatusToSnitchErrors < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ add_column :snitch_errors, :status, :string, default: "open", null: false
4
+ add_index :snitch_errors, :status
5
+ end
6
+ 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
@@ -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)
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Snitch
4
- VERSION = '0.2.4'
4
+ VERSION = '0.3.1'
5
5
  end
@@ -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.2.4
4
+ version: 0.3.1
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,18 @@ 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/views/layouts/snitch/application.html.erb
136
+ - app/views/snitch/snitches/index.html.erb
137
+ - app/views/snitch/snitches/show.html.erb
138
+ - config/routes.rb
118
139
  - lib/generators/snitch/install/install_generator.rb
119
140
  - lib/generators/snitch/install/templates/create_snitch_errors.rb.erb
120
141
  - lib/generators/snitch/install/templates/snitch.rb
142
+ - lib/generators/snitch/update/templates/add_status_to_snitch_errors.rb.erb
143
+ - lib/generators/snitch/update/update_generator.rb
121
144
  - lib/snitch.rb
122
145
  - lib/snitch/configuration.rb
123
146
  - lib/snitch/engine.rb
@@ -128,6 +151,7 @@ files:
128
151
  - lib/snitch/middleware.rb
129
152
  - lib/snitch/models/event.rb
130
153
  - lib/snitch/version.rb
154
+ - lib/tasks/snitch_tailwind.rake
131
155
  licenses:
132
156
  - MIT
133
157
  metadata: {}