webhukhs 0.6.0 → 0.8.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.standard.yml +2 -0
  3. data/.yard-lint.yml +296 -0
  4. data/AGENTS.md +56 -0
  5. data/Appraisals +6 -2
  6. data/CHANGELOG.md +20 -0
  7. data/README.md +101 -11
  8. data/Rakefile +7 -0
  9. data/config/mutant.rb +37 -0
  10. data/config/mutant.yml +25 -0
  11. data/example/Gemfile +5 -6
  12. data/example/Gemfile.lock +260 -141
  13. data/example/app/webhooks/acme_handler.rb +16 -0
  14. data/example/app/webhooks/flaky_handler.rb +8 -0
  15. data/example/app/webhooks/signed_handler.rb +10 -0
  16. data/example/config/initializers/webhukhs.rb +4 -3
  17. data/example/config/routes.rb +3 -0
  18. data/example/db/migrate/20240601000000_add_headers_to_webhukhs_webhooks.rb +7 -0
  19. data/example/db/schema.rb +2 -1
  20. data/example/db/seeds.rb +71 -7
  21. data/gemfiles/{rails_7.1.gemfile → rails_7.2.gemfile} +1 -4
  22. data/gemfiles/rails_7.2.gemfile.lock +477 -0
  23. data/gemfiles/rails_8.0.gemfile +0 -3
  24. data/gemfiles/rails_8.0.gemfile.lock +74 -4
  25. data/gemfiles/rails_8.1.gemfile +0 -3
  26. data/gemfiles/rails_8.1.gemfile.lock +74 -4
  27. data/gemfiles/rails_master.gemfile +7 -0
  28. data/lib/webhukhs/admin/application_controller.rb +32 -0
  29. data/lib/webhukhs/admin/assets/admin.css +476 -0
  30. data/lib/webhukhs/admin/assets/admin.js +111 -0
  31. data/lib/webhukhs/admin/config/routes.rb +6 -0
  32. data/lib/webhukhs/admin/engine.rb +20 -0
  33. data/lib/webhukhs/admin/received_webhook_presenter.rb +63 -0
  34. data/lib/webhukhs/admin/received_webhooks_controller.rb +60 -0
  35. data/lib/webhukhs/admin/view_helper.rb +50 -0
  36. data/lib/webhukhs/admin/views/layouts/webhukhs/admin/application.html.erb +22 -0
  37. data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/index.html.erb +50 -0
  38. data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/show.html.erb +55 -0
  39. data/lib/webhukhs/admin.rb +17 -0
  40. data/lib/webhukhs/base_handler.rb +9 -6
  41. data/lib/webhukhs/controllers/receive_webhooks_controller.rb +26 -7
  42. data/lib/webhukhs/engine.rb +1 -4
  43. data/lib/webhukhs/install_generator.rb +9 -2
  44. data/lib/webhukhs/jobs/processing_job.rb +13 -12
  45. data/lib/webhukhs/models/received_webhook.rb +21 -9
  46. data/lib/webhukhs/version.rb +1 -1
  47. data/lib/webhukhs.rb +9 -0
  48. data/test/admin_test.rb +334 -0
  49. data/test/base_handler_test.rb +125 -0
  50. data/test/browser/admin_click_through.rb +208 -0
  51. data/test/processing_job_test.rb +152 -12
  52. data/test/received_webhook_test.rb +260 -0
  53. data/test/test_app.rb +39 -15
  54. data/test/test_helper.rb +69 -0
  55. data/test/webhukhs_test.rb +111 -49
  56. metadata +173 -11
  57. data/example/.ruby-version +0 -1
  58. data/gemfiles/rails_7.1.gemfile.lock +0 -412
@@ -0,0 +1,476 @@
1
+ /* Webhukhs admin - vanilla CSS, system fonts only */
2
+
3
+ :root {
4
+ color-scheme: light dark;
5
+
6
+ --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
7
+ --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
8
+
9
+ --color-text: #404040;
10
+ --color-text-secondary: #737373;
11
+ --color-text-muted: #a3a3a3;
12
+ --color-bg: #ffffff;
13
+ --color-bg-subtle: #fafafa;
14
+ --color-bg-badge: #f5f5f5;
15
+ --color-bg-header: #262626;
16
+ --color-text-header: #ffffff;
17
+ --color-border: #e5e5e5;
18
+ --color-border-strong: #d4d4d4;
19
+ --color-bg-hover: #eef4fc;
20
+ --color-link: #0969da;
21
+
22
+ --color-ok-bg: #dff0d8;
23
+ --color-ok-border: #b6ddab;
24
+ --color-ok-text: #3c763d;
25
+ --color-bad-bg: #f2dede;
26
+ --color-bad-border: #e2c2c2;
27
+ --color-bad-text: #a94442;
28
+ --color-waiting-bg: #fcf8e3;
29
+ --color-waiting-border: #e8dfb4;
30
+ --color-waiting-text: #8a6d3b;
31
+ }
32
+
33
+ @media (prefers-color-scheme: dark) {
34
+ :root {
35
+ --color-text: #e5e5e5;
36
+ --color-text-secondary: #a3a3a3;
37
+ --color-text-muted: #737373;
38
+ --color-bg: #171717;
39
+ --color-bg-subtle: #1f1f1f;
40
+ --color-bg-badge: #262626;
41
+ --color-bg-header: #0a0a0a;
42
+ --color-border: #333333;
43
+ --color-border-strong: #474747;
44
+ --color-bg-hover: #1c2a3d;
45
+ --color-link: #58a6ff;
46
+
47
+ --color-ok-bg: #16301a;
48
+ --color-ok-border: #2c5a33;
49
+ --color-ok-text: #8fd694;
50
+ --color-bad-bg: #3a1a1a;
51
+ --color-bad-border: #6b2e2e;
52
+ --color-bad-text: #f19a9a;
53
+ --color-waiting-bg: #33290f;
54
+ --color-waiting-border: #5f4d1d;
55
+ --color-waiting-text: #e6c66e;
56
+ }
57
+ }
58
+
59
+ *,
60
+ *::before,
61
+ *::after {
62
+ box-sizing: border-box;
63
+ }
64
+
65
+ body {
66
+ margin: 0;
67
+ font-family: var(--font-sans);
68
+ font-size: 14px;
69
+ line-height: 1.5;
70
+ color: var(--color-text);
71
+ background: var(--color-bg);
72
+ }
73
+
74
+ a {
75
+ color: var(--color-link);
76
+ text-decoration: none;
77
+ }
78
+ a:hover {
79
+ text-decoration: underline;
80
+ }
81
+
82
+ code,
83
+ pre {
84
+ font-family: var(--font-mono);
85
+ font-size: 13px;
86
+ }
87
+
88
+ /* Header */
89
+ .admin-header {
90
+ display: flex;
91
+ gap: 20px;
92
+ align-items: baseline;
93
+ background: var(--color-bg-header);
94
+ color: var(--color-text-header);
95
+ padding: 10px 20px;
96
+ }
97
+ .admin-header a {
98
+ color: var(--color-text-header);
99
+ }
100
+
101
+ main {
102
+ max-width: 1200px;
103
+ margin: 0 auto;
104
+ padding: 20px;
105
+ }
106
+
107
+ h1 {
108
+ font-size: 1.4rem;
109
+ margin: 0 0 16px;
110
+ }
111
+ h2 {
112
+ font-size: 1.1rem;
113
+ margin: 24px 0 8px;
114
+ }
115
+
116
+ .muted {
117
+ color: var(--color-text-secondary);
118
+ font-size: 13px;
119
+ font-weight: normal;
120
+ }
121
+ p.muted {
122
+ max-width: 44rem;
123
+ }
124
+
125
+ .flash {
126
+ padding: 8px 12px;
127
+ border-radius: 4px;
128
+ margin-bottom: 16px;
129
+ max-width: 44rem;
130
+ }
131
+ .flash.alert {
132
+ background: var(--color-bad-bg);
133
+ color: var(--color-bad-text);
134
+ }
135
+
136
+ .breadcrumb {
137
+ font-size: 13px;
138
+ color: var(--color-text-secondary);
139
+ margin-bottom: 12px;
140
+ }
141
+
142
+ /* Page title with controls pinned to the right of it */
143
+ .page-heading {
144
+ display: flex;
145
+ align-items: baseline;
146
+ justify-content: space-between;
147
+ gap: 16px;
148
+ }
149
+ .page-heading h1 {
150
+ margin: 0;
151
+ }
152
+
153
+ /* A checkbox drawn as an on/off switch */
154
+ .switch {
155
+ display: inline-flex;
156
+ align-items: center;
157
+ gap: 8px;
158
+ font-size: 13px;
159
+ color: var(--color-text-secondary);
160
+ cursor: pointer;
161
+ user-select: none;
162
+ }
163
+ .switch input {
164
+ appearance: none;
165
+ position: relative;
166
+ width: 30px;
167
+ height: 18px;
168
+ margin: 0;
169
+ border-radius: 9px;
170
+ background: var(--color-border-strong);
171
+ cursor: pointer;
172
+ transition: background-color 0.15s;
173
+ }
174
+ .switch input::before {
175
+ content: "";
176
+ position: absolute;
177
+ top: 2px;
178
+ left: 2px;
179
+ width: 14px;
180
+ height: 14px;
181
+ border-radius: 50%;
182
+ background: var(--color-bg);
183
+ transition: transform 0.15s;
184
+ }
185
+ .switch input:checked {
186
+ background: var(--color-link);
187
+ }
188
+ .switch input:checked::before {
189
+ transform: translateX(12px);
190
+ }
191
+ .switch input:focus-visible {
192
+ outline: 2px solid var(--color-link);
193
+ outline-offset: 2px;
194
+ }
195
+
196
+ /* Filters - one group of uniformly sized controls */
197
+ .filters {
198
+ display: inline-flex;
199
+ margin: 16px 0;
200
+ }
201
+ .filters > select,
202
+ .filters > input[type="submit"],
203
+ .filters > a {
204
+ position: relative;
205
+ height: 30px;
206
+ margin: 0 0 0 -1px;
207
+ padding: 0 14px;
208
+ font: inherit;
209
+ font-size: 13px;
210
+ line-height: 28px;
211
+ color: var(--color-text);
212
+ background-color: var(--color-bg);
213
+ border: 1px solid var(--color-border-strong);
214
+ border-radius: 0;
215
+ cursor: pointer;
216
+ }
217
+ .filters > select {
218
+ appearance: none;
219
+ padding-right: 30px;
220
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23737373' stroke-width='1.5'/%3E%3C/svg%3E");
221
+ background-repeat: no-repeat;
222
+ background-position: right 12px center;
223
+ }
224
+ .filters > select:first-of-type {
225
+ margin-left: 0;
226
+ border-radius: 4px 0 0 4px;
227
+ padding-left: 16px;
228
+ }
229
+ .filters > a {
230
+ border-radius: 0 4px 4px 0;
231
+ padding-right: 16px;
232
+ text-decoration: none;
233
+ }
234
+ .filters > input[type="submit"] {
235
+ font-weight: 500;
236
+ }
237
+ .filters > :hover {
238
+ background-color: var(--color-bg-subtle);
239
+ }
240
+ /* A select narrowing the list is tinted, so an active filter does not read as "any" */
241
+ .filters > select:has(option:checked:not([value=""])) {
242
+ background-color: var(--color-bg-hover);
243
+ }
244
+ .filters > :focus-visible {
245
+ z-index: 1;
246
+ outline: 2px solid var(--color-link);
247
+ outline-offset: -1px;
248
+ }
249
+
250
+ /*
251
+ * The frame every admin_table sits in. Cells are nowrap, so one long column makes the
252
+ * table wider than `main` - the overflow is the container's, so the table scrolls inside
253
+ * the page instead of moving it.
254
+ */
255
+ .table-scroll {
256
+ max-width: 100%;
257
+ overflow-x: auto;
258
+ margin-bottom: 16px;
259
+ border: 1px solid var(--color-border);
260
+ border-radius: 4px;
261
+ }
262
+
263
+ .admin-table {
264
+ width: 100%;
265
+ border-collapse: collapse;
266
+ }
267
+ .admin-table th,
268
+ .admin-table td {
269
+ padding: 6px 10px;
270
+ text-align: left;
271
+ border-bottom: 1px solid var(--color-border);
272
+ white-space: nowrap;
273
+ }
274
+ .admin-table tbody tr:last-child > * {
275
+ border-bottom: 0;
276
+ }
277
+ .admin-table thead th {
278
+ background: var(--color-bg-subtle);
279
+ font-weight: 600;
280
+ font-size: 12px;
281
+ text-transform: uppercase;
282
+ letter-spacing: 0.03em;
283
+ }
284
+ .admin-table tbody tr:nth-child(even) {
285
+ background: var(--color-bg-subtle);
286
+ }
287
+ /* Rows with a .row-link are clickable as a whole: the link's ::after covers the row */
288
+ .admin-table tbody tr:has(> td > .row-link) {
289
+ position: relative;
290
+ cursor: pointer;
291
+ }
292
+ .admin-table tbody tr:has(> td > .row-link):hover {
293
+ background: var(--color-bg-hover);
294
+ }
295
+ .admin-table tbody tr:has(> td > .row-link:focus-visible) {
296
+ outline: 2px solid var(--color-link);
297
+ outline-offset: -2px;
298
+ }
299
+ .row-link::after {
300
+ content: "";
301
+ position: absolute;
302
+ inset: 0;
303
+ }
304
+ .row-link:focus-visible {
305
+ outline: none;
306
+ }
307
+
308
+ .admin-table .empty {
309
+ text-align: center;
310
+ color: var(--color-text-muted);
311
+ height: 165px;
312
+ }
313
+
314
+ .sort-link {
315
+ color: inherit;
316
+ }
317
+ .sort-arrow {
318
+ font-size: 10px;
319
+ }
320
+
321
+ /* Key-value table on the detail page */
322
+ .attr-scroll {
323
+ width: fit-content;
324
+ min-width: min(700px, 100%);
325
+ }
326
+ .attr-table th {
327
+ width: 200px;
328
+ font-weight: 500;
329
+ color: var(--color-text-secondary);
330
+ }
331
+
332
+ /* Values that are long, opaque and only interesting at their head. The full value is in the title */
333
+ .cell-ellipsis {
334
+ display: inline-block;
335
+ max-width: 34ch;
336
+ overflow: hidden;
337
+ text-overflow: ellipsis;
338
+ vertical-align: bottom;
339
+ }
340
+
341
+ .cell-id {
342
+ max-width: 14ch;
343
+ }
344
+
345
+ .header-value {
346
+ white-space: pre-wrap;
347
+ word-break: break-all;
348
+ }
349
+
350
+ /* Status badges */
351
+ .badge {
352
+ display: inline-block;
353
+ padding: 1px 6px;
354
+ border-radius: 4px;
355
+ font-family: var(--font-mono);
356
+ font-size: 12px;
357
+ font-weight: 500;
358
+ background: var(--color-bg-badge);
359
+ border: 1px solid var(--color-border);
360
+ }
361
+ h1 .badge {
362
+ vertical-align: middle;
363
+ }
364
+ .badge.status-ok {
365
+ background: var(--color-ok-bg);
366
+ border-color: var(--color-ok-border);
367
+ color: var(--color-ok-text);
368
+ }
369
+ .badge.status-bad {
370
+ background: var(--color-bad-bg);
371
+ border-color: var(--color-bad-border);
372
+ color: var(--color-bad-text);
373
+ }
374
+ .badge.status-waiting {
375
+ background: var(--color-waiting-bg);
376
+ border-color: var(--color-waiting-border);
377
+ color: var(--color-waiting-text);
378
+ }
379
+
380
+ /* Body viewer */
381
+ .body-detail {
382
+ margin: 0;
383
+ padding: 8px 12px;
384
+ background: var(--color-bg-subtle);
385
+ border: 1px solid var(--color-border);
386
+ border-radius: 4px;
387
+ overflow: auto;
388
+ max-height: 70vh;
389
+ }
390
+ .body-detail code {
391
+ font-size: 12px;
392
+ white-space: pre;
393
+ }
394
+ /* As sent, a body is very often one enormous line */
395
+ .body-raw code {
396
+ white-space: pre-wrap;
397
+ word-break: break-all;
398
+ }
399
+
400
+ .tabs [role="tabpanel"] .body-detail {
401
+ border-top-left-radius: 0;
402
+ }
403
+ .tab-bar {
404
+ display: flex;
405
+ justify-content: space-between;
406
+ align-items: flex-end;
407
+ }
408
+ .tab-bar [role="tab"] {
409
+ font: inherit;
410
+ font-size: 13px;
411
+ color: var(--color-text-secondary);
412
+ background: transparent;
413
+ border: 1px solid transparent;
414
+ border-bottom: 0;
415
+ border-radius: 4px 4px 0 0;
416
+ padding: 4px 12px;
417
+ margin-bottom: -1px;
418
+ cursor: pointer;
419
+ }
420
+ .tab-bar [role="tab"][aria-selected="true"] {
421
+ color: var(--color-text);
422
+ background: var(--color-bg-subtle);
423
+ border-color: var(--color-border);
424
+ position: relative;
425
+ }
426
+ .btn-copy-panel {
427
+ font: inherit;
428
+ font-size: 12px;
429
+ margin-bottom: 4px;
430
+ padding: 1px 8px;
431
+ cursor: pointer;
432
+ }
433
+ .btn-copy-panel.copied {
434
+ color: var(--color-ok-text);
435
+ }
436
+
437
+ /* Copy button */
438
+ .btn-copy {
439
+ display: inline-block;
440
+ vertical-align: middle;
441
+ width: 14px;
442
+ height: 14px;
443
+ margin-left: 4px;
444
+ padding: 0;
445
+ border: none;
446
+ background-color: var(--color-text-muted);
447
+ cursor: pointer;
448
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z'/%3E%3C/svg%3E");
449
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z'/%3E%3C/svg%3E");
450
+ -webkit-mask-size: contain;
451
+ mask-size: contain;
452
+ -webkit-mask-repeat: no-repeat;
453
+ mask-repeat: no-repeat;
454
+ }
455
+ .btn-copy:hover {
456
+ background-color: var(--color-text);
457
+ }
458
+ .btn-copy.copied {
459
+ background-color: var(--color-ok-text);
460
+ }
461
+
462
+ /* Pagination */
463
+ .pagination {
464
+ display: flex;
465
+ gap: 16px;
466
+ margin: 8px 0 24px;
467
+ }
468
+ .pagination a {
469
+ padding: 4px 12px;
470
+ border: 1px solid var(--color-border);
471
+ border-radius: 4px;
472
+ }
473
+ .pagination a:hover {
474
+ background: var(--color-bg-subtle);
475
+ text-decoration: none;
476
+ }
@@ -0,0 +1,111 @@
1
+ // Webhukhs admin - copy buttons, the body viewer tabs and the index auto-refresh. No framework, the CSP only allows this file.
2
+
3
+ const flash = (element) => {
4
+ element.classList.add("copied");
5
+ setTimeout(() => element.classList.remove("copied"), 1200);
6
+ };
7
+
8
+ const tabsOf = (tablist) => Array.from(tablist.querySelectorAll("[role=tab]"));
9
+
10
+ const selectTab = (tab) => {
11
+ for (const other of tabsOf(tab.closest("[role=tablist]"))) {
12
+ const selected = other === tab;
13
+ other.setAttribute("aria-selected", String(selected));
14
+ other.tabIndex = selected ? 0 : -1;
15
+ document.getElementById(other.getAttribute("aria-controls")).hidden = !selected;
16
+ }
17
+ };
18
+
19
+ document.addEventListener("click", (event) => {
20
+ const copy = event.target.closest(".btn-copy");
21
+ if (copy) {
22
+ navigator.clipboard.writeText(copy.dataset.copy).then(() => flash(copy));
23
+ return;
24
+ }
25
+
26
+ const tab = event.target.closest("[role=tab]");
27
+ if (tab) {
28
+ selectTab(tab);
29
+ return;
30
+ }
31
+
32
+ const copyPanel = event.target.closest(".btn-copy-panel");
33
+ if (copyPanel) {
34
+ const panel = copyPanel.closest(".tabs").querySelector("[role=tabpanel]:not([hidden])");
35
+ navigator.clipboard.writeText(panel.querySelector("code").textContent).then(() => flash(copyPanel));
36
+ }
37
+ });
38
+
39
+ // Arrow keys move between tabs, as the ARIA tabs pattern expects
40
+ document.addEventListener("keydown", (event) => {
41
+ const tab = event.target.closest("[role=tab]");
42
+ if (!tab || !["ArrowLeft", "ArrowRight"].includes(event.key)) return;
43
+
44
+ const tabs = tabsOf(tab.closest("[role=tablist]"));
45
+ const step = event.key === "ArrowRight" ? 1 : -1;
46
+ const next = tabs[(tabs.indexOf(tab) + step + tabs.length) % tabs.length];
47
+ selectTab(next);
48
+ next.focus();
49
+ });
50
+
51
+ // Auto-refresh of the index. Polls rather than holding an EventSource open: a forgotten tab with
52
+ // an SSE stream pins a server thread forever, a forgotten tab polling costs one request now and then.
53
+ const POLL_INTERVAL_MS = 5000;
54
+ const POLL_STORAGE_KEY = "webhukhs.autoRefresh";
55
+
56
+ const autoRefresh = document.getElementById("auto-refresh");
57
+ let pollTimer = null;
58
+ let pollAbort = null;
59
+
60
+ const stopPolling = () => {
61
+ clearTimeout(pollTimer);
62
+ pollTimer = null;
63
+ pollAbort?.abort();
64
+ pollAbort = null;
65
+ };
66
+
67
+ const schedulePoll = () => {
68
+ pollTimer = setTimeout(refreshListing, POLL_INTERVAL_MS);
69
+ };
70
+
71
+ const refreshListing = async () => {
72
+ // Nobody is looking, so check again later instead
73
+ if (document.hidden) return schedulePoll();
74
+
75
+ const abort = (pollAbort = new AbortController());
76
+ try {
77
+ const response = await fetch(location.href, {signal: abort.signal, headers: {Accept: "text/html"}});
78
+ const html = await response.text();
79
+ // Swapped synchronously after the last await, so a switch turned off meanwhile has aborted us
80
+ const fresh = new DOMParser().parseFromString(html, "text/html").getElementById("received-webhooks");
81
+ if (response.ok && fresh) document.getElementById("received-webhooks").replaceWith(fresh);
82
+ } catch (error) {
83
+ if (abort.signal.aborted) return;
84
+ }
85
+ if (pollAbort === abort) schedulePoll();
86
+ };
87
+
88
+ const rememberAutoRefresh = (enabled) => {
89
+ try {
90
+ localStorage.setItem(POLL_STORAGE_KEY, enabled ? "1" : "");
91
+ } catch {}
92
+ };
93
+
94
+ const recallAutoRefresh = () => {
95
+ try {
96
+ return localStorage.getItem(POLL_STORAGE_KEY) === "1";
97
+ } catch {
98
+ return false;
99
+ }
100
+ };
101
+
102
+ if (autoRefresh) {
103
+ autoRefresh.checked = recallAutoRefresh();
104
+ if (autoRefresh.checked) schedulePoll();
105
+
106
+ autoRefresh.addEventListener("change", () => {
107
+ stopPolling();
108
+ rememberAutoRefresh(autoRefresh.checked);
109
+ if (autoRefresh.checked) refreshListing();
110
+ });
111
+ }
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Webhukhs::Admin::Engine.routes.draw do
4
+ resources :received_webhooks, only: [:index, :show], path: "received"
5
+ root to: "received_webhooks#index"
6
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webhukhs
4
+ module Admin
5
+ # Rails engine serving the admin pages and their assets.
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace Webhukhs::Admin
8
+
9
+ # Rooted here rather than at the gem root, so that the views of the admin do not end up
10
+ # on the view path of Webhukhs::Engine - which shares the gem root with us
11
+ config.root = __dir__
12
+ paths["app/views"] = "views"
13
+
14
+ # Has to run before :build_middleware_stack, as Rails 8.1+ freezes the stack after it
15
+ initializer "webhukhs_admin.assets", before: :build_middleware_stack do
16
+ middleware.use Assiette::Server, root: Engine.root.join("assets")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webhukhs
4
+ module Admin
5
+ # Wraps a Webhukhs::ReceivedWebhook with what the admin views need to display it.
6
+ class ReceivedWebhookPresenter < SimpleDelegator
7
+ # `received` and `processing` are on their way somewhere, the two failures are the rows
8
+ # worth finding in a list. `skipped` is deliberate and stays uncolored
9
+ STATUS_CLASSES = {
10
+ "processed" => "status-ok",
11
+ "error" => "status-bad",
12
+ "failed_validation" => "status-bad",
13
+ "received" => "status-waiting",
14
+ "processing" => "status-waiting"
15
+ }.freeze
16
+
17
+ # Bodies larger than this are neither parsed nor shown in full - a browser asked to lay out
18
+ # a few megabytes of pretty-printed JSON just hangs
19
+ DISPLAY_LIMIT = 256 * 1024
20
+
21
+ # CSS class coloring the status badge.
22
+ #
23
+ # @return [String]
24
+ def status_class
25
+ STATUS_CLASSES.fetch(status, "status-neutral")
26
+ end
27
+
28
+ # Whether the body is too large to display in full.
29
+ #
30
+ # @return [Boolean]
31
+ def truncated?
32
+ body.bytesize > DISPLAY_LIMIT
33
+ end
34
+
35
+ # The body as text, cut off at DISPLAY_LIMIT bytes. The body is stored as raw bytes and is not
36
+ # necessarily UTF-8, so anything that does not decode (including a character the cut went through)
37
+ # gets replaced with U+FFFD instead of blowing up the view.
38
+ #
39
+ # @return [String]
40
+ def body_text
41
+ String.new(body.byteslice(0, DISPLAY_LIMIT), encoding: Encoding::UTF_8).scrub
42
+ end
43
+
44
+ # Pretty-printed body, if the body is valid JSON.
45
+ #
46
+ # @return [String, nil]
47
+ def pretty_json
48
+ JSON.pretty_generate(JSON.parse(body_text))
49
+ rescue JSON::ParserError
50
+ nil
51
+ end
52
+
53
+ # Stored request headers sorted by name, with structured values (the path parameters) as JSON.
54
+ #
55
+ # @return [Array<Array<String>>] name and value pairs
56
+ def header_rows
57
+ request_headers.to_h.sort.map do |name, value|
58
+ [name, value.instance_of?(String) ? value : value.to_json]
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end