webhukhs 0.7.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.
- checksums.yaml +4 -4
- data/.yard-lint.yml +1 -0
- data/AGENTS.md +9 -11
- data/Appraisals +2 -2
- data/CHANGELOG.md +13 -0
- data/README.md +98 -10
- data/Rakefile +7 -0
- data/example/Gemfile +5 -6
- data/example/Gemfile.lock +260 -141
- data/example/app/webhooks/acme_handler.rb +16 -0
- data/example/app/webhooks/flaky_handler.rb +8 -0
- data/example/app/webhooks/signed_handler.rb +10 -0
- data/example/config/initializers/webhukhs.rb +4 -3
- data/example/config/routes.rb +3 -0
- data/example/db/migrate/20240601000000_add_headers_to_webhukhs_webhooks.rb +7 -0
- data/example/db/schema.rb +2 -1
- data/example/db/seeds.rb +71 -7
- data/gemfiles/{rails_7.1.gemfile → rails_7.2.gemfile} +1 -4
- data/gemfiles/rails_7.2.gemfile.lock +477 -0
- data/gemfiles/rails_8.0.gemfile +0 -3
- data/gemfiles/rails_8.0.gemfile.lock +74 -4
- data/gemfiles/rails_8.1.gemfile +0 -3
- data/gemfiles/rails_8.1.gemfile.lock +74 -4
- data/gemfiles/rails_master.gemfile +0 -3
- data/lib/webhukhs/admin/application_controller.rb +32 -0
- data/lib/webhukhs/admin/assets/admin.css +476 -0
- data/lib/webhukhs/admin/assets/admin.js +111 -0
- data/lib/webhukhs/admin/config/routes.rb +6 -0
- data/lib/webhukhs/admin/engine.rb +20 -0
- data/lib/webhukhs/admin/received_webhook_presenter.rb +63 -0
- data/lib/webhukhs/admin/received_webhooks_controller.rb +60 -0
- data/lib/webhukhs/admin/view_helper.rb +50 -0
- data/lib/webhukhs/admin/views/layouts/webhukhs/admin/application.html.erb +22 -0
- data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/index.html.erb +50 -0
- data/lib/webhukhs/admin/views/webhukhs/admin/received_webhooks/show.html.erb +55 -0
- data/lib/webhukhs/admin.rb +17 -0
- data/lib/webhukhs/engine.rb +0 -4
- data/lib/webhukhs/models/received_webhook.rb +3 -0
- data/lib/webhukhs/version.rb +1 -1
- data/test/admin_test.rb +334 -0
- data/test/base_handler_test.rb +3 -3
- data/test/browser/admin_click_through.rb +208 -0
- data/test/processing_job_test.rb +15 -28
- data/test/received_webhook_test.rb +16 -0
- data/test/test_app.rb +2 -0
- data/test/test_helper.rb +9 -6
- data/test/webhukhs_test.rb +20 -62
- metadata +165 -8
- data/example/.ruby-version +0 -1
- 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,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
|