upright 0.3.0 → 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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -7
  3. data/Rakefile +22 -0
  4. data/app/assets/images/upright/.keep +0 -0
  5. data/app/assets/stylesheets/upright/_global.css +2 -0
  6. data/app/assets/stylesheets/upright/artifact.css +7 -25
  7. data/app/assets/stylesheets/upright/flash.css +19 -0
  8. data/app/assets/stylesheets/upright/header.css +14 -0
  9. data/app/assets/stylesheets/upright/incidents.css +610 -0
  10. data/app/assets/stylesheets/upright/map.css +6 -0
  11. data/app/assets/stylesheets/upright/public_status.css +534 -0
  12. data/app/controllers/concerns/.keep +0 -0
  13. data/app/controllers/concerns/upright/authentication.rb +1 -0
  14. data/app/controllers/concerns/upright/proxy_authentication.rb +134 -0
  15. data/app/controllers/upright/alertmanager_proxy_controller.rb +12 -4
  16. data/app/controllers/upright/artifacts_controller.rb +1 -1
  17. data/app/controllers/upright/incidents/updates_controller.rb +13 -0
  18. data/app/controllers/upright/incidents_controller.rb +53 -0
  19. data/app/controllers/upright/prometheus_proxy_controller.rb +13 -18
  20. data/app/controllers/upright/public/base_controller.rb +16 -0
  21. data/app/controllers/upright/public/incidents_controller.rb +6 -0
  22. data/app/controllers/upright/public/service_incidents_controller.rb +7 -0
  23. data/app/controllers/upright/public/services_controller.rb +6 -0
  24. data/app/controllers/upright/sessions_controller.rb +36 -1
  25. data/app/controllers/upright/traces_controller.rb +88 -0
  26. data/app/helpers/upright/application_helper.rb +22 -2
  27. data/app/helpers/upright/artifacts_helper.rb +20 -0
  28. data/app/helpers/upright/probe_results_helper.rb +1 -0
  29. data/app/helpers/upright/public/services_helper.rb +70 -0
  30. data/app/javascript/upright/application.js +3 -0
  31. data/app/javascript/upright/controllers/sites_map_controller.js +13 -5
  32. data/app/javascript/upright/controllers/template_controller.js +11 -0
  33. data/app/javascript/upright/public.js +5 -0
  34. data/app/jobs/upright/health_metrics_job.rb +27 -0
  35. data/app/jobs/upright/incident_reporter_job.rb +7 -0
  36. data/app/jobs/upright/maintenance_advance_job.rb +8 -0
  37. data/app/jobs/upright/playwright_video_sweep_job.rb +21 -0
  38. data/app/jobs/upright/rollups/daily_aggregation_job.rb +14 -0
  39. data/app/models/concerns/.keep +0 -0
  40. data/app/models/concerns/upright/incidents/auto_reporting.rb +93 -0
  41. data/app/models/concerns/upright/incidents/lifecycle.rb +47 -0
  42. data/app/models/concerns/upright/playwright/form_authentication.rb +3 -2
  43. data/app/models/concerns/upright/playwright/lifecycle.rb +8 -13
  44. data/app/models/concerns/upright/playwright/trace_recording.rb +53 -0
  45. data/app/models/concerns/upright/playwright/video_recording.rb +55 -14
  46. data/app/models/concerns/upright/probe_result/stale_cleanup.rb +26 -2
  47. data/app/models/concerns/upright/probeable.rb +9 -1
  48. data/app/models/concerns/upright/services/live_status.rb +69 -0
  49. data/app/models/concerns/upright/services/maintenance_status.rb +19 -0
  50. data/app/models/upright/artifact.rb +2 -1
  51. data/app/models/upright/http/request.rb +12 -3
  52. data/app/models/upright/incident/automatic_report.rb +3 -0
  53. data/app/models/upright/incident.rb +87 -0
  54. data/app/models/upright/incident_affected_service.rb +9 -0
  55. data/app/models/upright/incident_update.rb +7 -0
  56. data/app/models/upright/maintenance.rb +36 -0
  57. data/app/models/upright/persistent_record.rb +12 -0
  58. data/app/models/upright/playwright/authenticator/base.rb +50 -70
  59. data/app/models/upright/probes/http_probe.rb +30 -1
  60. data/app/models/upright/probes/playwright/base.rb +2 -0
  61. data/app/models/upright/probes/status/probe.rb +8 -0
  62. data/app/models/upright/probes/status.rb +3 -11
  63. data/app/models/upright/probes/uptime.rb +2 -9
  64. data/app/models/upright/rollups/daily_uptime.rb +28 -0
  65. data/app/models/upright/rollups/probe_rollup.rb +64 -0
  66. data/app/models/upright/rollups/probe_uptime.rb +9 -0
  67. data/app/models/upright/rollups/site_uptime.rb +75 -0
  68. data/app/models/upright/service/daily_status.rb +39 -0
  69. data/app/models/upright/service/incident_history.rb +24 -0
  70. data/app/models/upright/service/outage.rb +15 -0
  71. data/app/models/upright/service/status_page.rb +35 -0
  72. data/app/models/upright/service.rb +101 -0
  73. data/app/models/upright/status.rb +21 -0
  74. data/app/models/upright/traceroute/ip_metadata_lookup.rb +21 -2
  75. data/app/models/upright/traceroute/result.rb +8 -1
  76. data/app/views/layouts/upright/_header.html.erb +12 -2
  77. data/app/views/layouts/upright/application.html.erb +4 -1
  78. data/app/views/layouts/upright/public.html.erb +18 -0
  79. data/app/views/layouts/upright/public.rss.builder +1 -0
  80. data/app/views/upright/active_storage/attachments/_attachment.html.erb +1 -1
  81. data/app/views/upright/artifacts/show.html.erb +8 -0
  82. data/app/views/upright/incidents/_form.html.erb +93 -0
  83. data/app/views/upright/incidents/_row.html.erb +15 -0
  84. data/app/views/upright/incidents/edit.html.erb +64 -0
  85. data/app/views/upright/incidents/index.html.erb +53 -0
  86. data/app/views/upright/incidents/new.html.erb +6 -0
  87. data/app/views/upright/public/_branding.html.erb +3 -0
  88. data/app/views/upright/public/_footer.html.erb +3 -0
  89. data/app/views/upright/public/incidents/show.html.erb +29 -0
  90. data/app/views/upright/public/service_incidents/_incident.html.erb +14 -0
  91. data/app/views/upright/public/service_incidents/index.html.erb +50 -0
  92. data/app/views/upright/public/services/_active_events.html.erb +28 -0
  93. data/app/views/upright/public/services/_degraded_list.html.erb +14 -0
  94. data/app/views/upright/public/services/_overall_banner.html.erb +4 -0
  95. data/app/views/upright/public/services/_service.html.erb +34 -0
  96. data/app/views/upright/public/services/_upcoming_maintenance.html.erb +21 -0
  97. data/app/views/upright/public/services/_uptime_bar.html.erb +9 -0
  98. data/app/views/upright/public/services/index.html.erb +13 -0
  99. data/app/views/upright/public/services/index.rss.builder +29 -0
  100. data/app/views/upright/sessions/new.html.erb +14 -1
  101. data/config/importmap.rb +25 -14
  102. data/config/initializers/date_formats.rb +5 -0
  103. data/config/initializers/mime_types.rb +1 -0
  104. data/config/routes.rb +26 -2
  105. data/db/migrate/20260629000001_fix_probe_result_duration_precision.rb +14 -0
  106. data/db/persistent_migrate/20260512000001_create_upright_rollups.rb +16 -0
  107. data/db/persistent_migrate/20260702000001_create_upright_incidents.rb +34 -0
  108. data/db/persistent_migrate/20260707000001_add_probe_identity_to_upright_rollups.rb +11 -0
  109. data/db/persistent_migrate/20260709000001_add_created_by_to_incidents.rb +7 -0
  110. data/db/persistent_migrate/20260901000001_add_auto_reporting_to_upright_incidents.rb +22 -0
  111. data/lib/generators/upright/install/database_config.rb +46 -0
  112. data/lib/generators/upright/install/install_generator.rb +18 -10
  113. data/lib/generators/upright/install/templates/Dockerfile +80 -0
  114. data/lib/generators/upright/install/templates/content_security_policy.rb +42 -0
  115. data/lib/generators/upright/install/templates/deploy.yml +13 -13
  116. data/lib/generators/upright/install/templates/development_prometheus.yml +1 -1
  117. data/lib/generators/upright/install/templates/docker-compose.yml +12 -11
  118. data/lib/generators/upright/install/templates/omniauth.rb +7 -3
  119. data/lib/generators/upright/install/templates/otel_collector.yml +6 -0
  120. data/lib/generators/upright/install/templates/recurring.yml +20 -0
  121. data/lib/generators/upright/install/templates/sites.yml +4 -0
  122. data/lib/generators/upright/install/templates/upright.rb +21 -2
  123. data/lib/generators/upright/install/templates/upright.rules.yml +11 -4
  124. data/lib/upright/configuration.rb +113 -6
  125. data/lib/upright/engine.rb +25 -2
  126. data/lib/upright/metrics.rb +25 -0
  127. data/lib/upright/site.rb +31 -1
  128. data/lib/upright/version.rb +3 -1
  129. data/lib/upright.rb +29 -2
  130. metadata +107 -7
  131. data/config/credentials/development.key +0 -1
  132. data/config/credentials/test.key +0 -1
  133. data/lib/tasks/upright_tasks.rake +0 -4
@@ -0,0 +1,534 @@
1
+ @layer components {
2
+ :root {
3
+ --status-operational: oklch(70% 0.18 145);
4
+ --status-degraded: oklch(78% 0.18 95);
5
+ --status-partial: oklch(70% 0.18 50);
6
+ --status-major: oklch(60% 0.21 28);
7
+ --status-maintenance: oklch(64% 0.14 250);
8
+ --status-none: oklch(var(--lch-ink-lightest));
9
+ }
10
+
11
+ .public {
12
+ font-family: var(--font-sans);
13
+ }
14
+
15
+ .status-page {
16
+ margin: 0 auto;
17
+ max-width: 56rem;
18
+ padding: calc(var(--block-space) * 3) var(--main-padding);
19
+ }
20
+
21
+ .status-header,
22
+ .status-footer {
23
+ margin: 0 auto;
24
+ max-width: 56rem;
25
+ padding: 0 var(--main-padding);
26
+ }
27
+
28
+ .status-header {
29
+ align-items: center;
30
+ display: flex;
31
+ gap: 0.75rem;
32
+ padding-top: calc(var(--block-space) * 2);
33
+ }
34
+
35
+ .status-header__label {
36
+ font-size: var(--text-large);
37
+ font-weight: 600;
38
+ letter-spacing: -0.01em;
39
+ }
40
+
41
+ .status-footer {
42
+ border-top: var(--border);
43
+ color: var(--color-ink-medium);
44
+ font-size: var(--text-small);
45
+ margin-top: calc(var(--block-space) * 2);
46
+ padding-bottom: calc(var(--block-space) * 3);
47
+ padding-top: calc(var(--block-space) * 1.5);
48
+ }
49
+
50
+ .status-page__title {
51
+ font-size: var(--text-x-large);
52
+ font-weight: 600;
53
+ letter-spacing: -0.02em;
54
+ margin: 0 0 calc(var(--block-space) * 1.5);
55
+ }
56
+
57
+ .status-banner {
58
+ align-items: center;
59
+ border-radius: 0.5rem;
60
+ color: var(--color-white);
61
+ display: flex;
62
+ font-size: var(--text-large);
63
+ font-weight: 600;
64
+ gap: 0.75rem;
65
+ padding: 1rem 1.25rem;
66
+ margin-bottom: calc(var(--block-space) * 3);
67
+ }
68
+
69
+ .status-banner--operational { background: var(--status-operational); }
70
+ .status-banner--degraded { background: var(--status-degraded); color: oklch(20% 0 0); }
71
+ .status-banner--partial_outage { background: var(--status-partial); }
72
+ .status-banner--major_outage { background: var(--status-major); }
73
+ .status-banner--maintenance { background: var(--status-maintenance); }
74
+
75
+ .status-banner__dot {
76
+ background: currentColor;
77
+ border-radius: 50%;
78
+ height: 0.6rem;
79
+ opacity: 0.8;
80
+ width: 0.6rem;
81
+ }
82
+
83
+ .status-degraded {
84
+ border: var(--border);
85
+ border-radius: 0.5rem;
86
+ list-style: none;
87
+ margin: calc(var(--block-space) * -2) 0 calc(var(--block-space) * 3);
88
+ padding: 0.25rem 0;
89
+ }
90
+
91
+ .status-degraded__item {
92
+ align-items: center;
93
+ display: flex;
94
+ gap: 0.75rem;
95
+ padding: 0.5rem 1rem;
96
+ }
97
+
98
+ .status-degraded__item + .status-degraded__item {
99
+ border-top: var(--border);
100
+ }
101
+
102
+ .status-degraded__dot {
103
+ border-radius: 50%;
104
+ flex-shrink: 0;
105
+ height: 0.6rem;
106
+ width: 0.6rem;
107
+ }
108
+
109
+ .status-degraded__dot--degraded { background: var(--status-degraded); }
110
+ .status-degraded__dot--partial_outage { background: var(--status-partial); }
111
+ .status-degraded__dot--major_outage { background: var(--status-major); }
112
+
113
+ .status-degraded__name { font-weight: 600; }
114
+
115
+ .status-degraded__detail {
116
+ color: var(--color-ink-medium);
117
+ margin-left: auto;
118
+ }
119
+
120
+ .service {
121
+ border-top: var(--border);
122
+ padding: calc(var(--block-space) * 1.25) 0;
123
+ }
124
+
125
+ .service__row {
126
+ align-items: center;
127
+ display: flex;
128
+ gap: 1rem;
129
+ justify-content: space-between;
130
+ margin-bottom: 0.75rem;
131
+ }
132
+
133
+ .service__name {
134
+ color: inherit;
135
+ font-size: var(--text-large);
136
+ font-weight: 500;
137
+ }
138
+
139
+ .service-detail {
140
+ margin: 0 auto;
141
+ max-width: 56rem;
142
+ padding: calc(var(--block-space) * 3) var(--main-padding);
143
+ }
144
+
145
+ .service-detail__back {
146
+ color: var(--color-ink-medium);
147
+ font-size: var(--text-small);
148
+ }
149
+
150
+ .service-detail__head {
151
+ border-bottom: var(--border);
152
+ margin: var(--block-space) 0 calc(var(--block-space) * 2);
153
+ padding-bottom: calc(var(--block-space) * 2);
154
+ }
155
+
156
+ .service-detail__title {
157
+ font-size: var(--text-x-large);
158
+ font-weight: 600;
159
+ letter-spacing: -0.02em;
160
+ margin: 0;
161
+ }
162
+
163
+ .service-history + .service-history {
164
+ margin-top: calc(var(--block-space) * 2);
165
+ }
166
+
167
+ .service-history__title {
168
+ font-size: var(--text-large);
169
+ font-weight: 600;
170
+ margin: 0 0 var(--block-space);
171
+ }
172
+
173
+ .service-history__incident {
174
+ border: var(--border);
175
+ border-left: 3px solid var(--status-none);
176
+ border-radius: 0.5rem;
177
+ color: inherit;
178
+ display: block;
179
+ padding: 0.75rem 1rem;
180
+ text-decoration: none;
181
+ }
182
+
183
+ .service-history__incident + .service-history__incident { margin-top: 0.5rem; }
184
+ .service-history__incident--minor { border-left-color: var(--status-degraded); }
185
+ .service-history__incident--major { border-left-color: var(--status-partial); }
186
+ .service-history__incident--critical { border-left-color: var(--status-major); }
187
+ .service-history__incident--maintenance { border-left-color: var(--status-maintenance); }
188
+
189
+ .service-history__incident:hover { border-color: var(--color-ink-medium); }
190
+
191
+ .service-history__incident-head {
192
+ align-items: baseline;
193
+ display: flex;
194
+ gap: 1rem;
195
+ justify-content: space-between;
196
+ }
197
+
198
+ .service-history__incident-title {
199
+ font-size: inherit;
200
+ font-weight: 600;
201
+ margin: 0;
202
+ }
203
+
204
+ .service-history__incident-impact,
205
+ .service-history__incident-dates,
206
+ .service-history__empty {
207
+ color: var(--color-ink-medium);
208
+ font-size: var(--text-small);
209
+ }
210
+
211
+ .service-history__incident-impact {
212
+ letter-spacing: 0.05em;
213
+ text-transform: uppercase;
214
+ }
215
+
216
+ .service-history__incident-dates { margin: 0.35rem 0 0; }
217
+ .service-history__incident-update { margin: 0.5rem 0 0; }
218
+ .service-history__empty { margin: 0; }
219
+
220
+ .service__status {
221
+ color: var(--color-ink-medium);
222
+ font-size: var(--text-small);
223
+ font-weight: 500;
224
+ text-transform: uppercase;
225
+ letter-spacing: 0.05em;
226
+ }
227
+
228
+ .service__status--operational { color: var(--status-operational); }
229
+ .service__status--degraded { color: var(--status-degraded); }
230
+ .service__status--partial_outage { color: var(--status-partial); }
231
+ .service__status--major_outage { color: var(--status-major); }
232
+ .service__status--maintenance { color: var(--status-maintenance); }
233
+
234
+ .service__description {
235
+ color: var(--color-ink-medium);
236
+ font-size: var(--text-small);
237
+ margin: 0 0 0.75rem;
238
+ }
239
+
240
+ .uptime {
241
+ display: flex;
242
+ flex-direction: column;
243
+ gap: 0.4rem;
244
+ }
245
+
246
+ .uptime__bars {
247
+ display: flex;
248
+ gap: 2px;
249
+ height: 2rem;
250
+ }
251
+
252
+ .uptime__bar {
253
+ background: var(--status-none);
254
+ border-radius: 1px;
255
+ flex: 1 1 0;
256
+ min-width: 2px;
257
+ position: relative;
258
+ transition: filter 100ms ease-out;
259
+ }
260
+
261
+ .uptime__bar:hover {
262
+ filter: brightness(0.9);
263
+ }
264
+
265
+ .uptime__bar--operational { background: var(--status-operational); }
266
+ .uptime__bar--degraded { background: var(--status-degraded); }
267
+ .uptime__bar--partial_outage { background: var(--status-partial); }
268
+ .uptime__bar--major_outage { background: var(--status-major); }
269
+
270
+ .uptime__meta {
271
+ color: var(--color-ink-medium);
272
+ display: flex;
273
+ font-size: var(--text-x-small);
274
+ justify-content: space-between;
275
+ }
276
+
277
+ .uptime__meta-percent {
278
+ color: var(--color-ink-dark);
279
+ font-weight: 500;
280
+ }
281
+
282
+ .uptime__tooltip {
283
+ background: var(--color-canvas);
284
+ border: var(--border);
285
+ border-radius: 0.5rem;
286
+ bottom: calc(100% + 0.5rem);
287
+ box-shadow: var(--shadow);
288
+ display: flex;
289
+ flex-direction: column;
290
+ font-family: var(--font-sans);
291
+ font-size: var(--text-x-small);
292
+ gap: 0.2rem;
293
+ left: 50%;
294
+ line-height: 1.35;
295
+ opacity: 0;
296
+ padding: 0.45rem 0.6rem;
297
+ pointer-events: none;
298
+ position: absolute;
299
+ transform: translate(-50%, 0.25rem);
300
+ transition: opacity 120ms ease-out, transform 120ms ease-out;
301
+ visibility: hidden;
302
+ white-space: nowrap;
303
+ z-index: 10;
304
+ }
305
+
306
+ .uptime__bar:hover .uptime__tooltip {
307
+ opacity: 1;
308
+ transform: translate(-50%, 0);
309
+ visibility: visible;
310
+ }
311
+
312
+ /* Caret, sitting on the tooltip's bottom edge and matching its border. */
313
+ .uptime__tooltip::after {
314
+ background: var(--color-canvas);
315
+ border-bottom: var(--border);
316
+ border-right: var(--border);
317
+ content: "";
318
+ height: 0.5rem;
319
+ left: 50%;
320
+ position: absolute;
321
+ top: calc(100% - 0.25rem);
322
+ transform: translateX(-50%) rotate(45deg);
323
+ width: 0.5rem;
324
+ }
325
+
326
+ .uptime__tooltip-date {
327
+ color: var(--color-ink);
328
+ font-weight: 600;
329
+ }
330
+
331
+ .uptime__tooltip-detail {
332
+ align-items: center;
333
+ color: var(--color-ink-medium);
334
+ display: flex;
335
+ gap: 0.4rem;
336
+ }
337
+
338
+ .uptime__tooltip-dot {
339
+ border-radius: 50%;
340
+ flex-shrink: 0;
341
+ height: 0.5rem;
342
+ width: 0.5rem;
343
+ }
344
+
345
+ .uptime__tooltip-dot--operational { background: var(--status-operational); }
346
+ .uptime__tooltip-dot--degraded { background: var(--status-degraded); }
347
+ .uptime__tooltip-dot--partial_outage { background: var(--status-partial); }
348
+ .uptime__tooltip-dot--major_outage { background: var(--status-major); }
349
+ .uptime__tooltip-dot--none { background: var(--status-none); }
350
+
351
+ .events,
352
+ .upcoming {
353
+ margin: calc(var(--block-space) * -2) 0 calc(var(--block-space) * 3);
354
+ }
355
+
356
+ .event,
357
+ .upcoming__item {
358
+ border: var(--border);
359
+ border-left: 3px solid var(--status-none);
360
+ border-radius: 0.5rem;
361
+ padding: 0.75rem 1rem;
362
+ }
363
+
364
+ .event + .event,
365
+ .upcoming__item + .upcoming__item {
366
+ margin-top: 0.5rem;
367
+ }
368
+
369
+ .event--minor { border-left-color: var(--status-degraded); }
370
+ .event--major { border-left-color: var(--status-partial); }
371
+ .event--critical { border-left-color: var(--status-major); }
372
+ .event--maintenance { border-left-color: var(--status-maintenance); }
373
+
374
+ .upcoming__title {
375
+ font-size: var(--text-large);
376
+ font-weight: 600;
377
+ margin: 0 0 0.75rem;
378
+ }
379
+
380
+ .event__head {
381
+ align-items: baseline;
382
+ display: flex;
383
+ gap: 1rem;
384
+ justify-content: space-between;
385
+ }
386
+
387
+ .event__title { font-weight: 600; }
388
+
389
+ .event__status,
390
+ .upcoming__when {
391
+ color: var(--color-ink-medium);
392
+ flex-shrink: 0;
393
+ font-size: var(--text-small);
394
+ text-transform: uppercase;
395
+ letter-spacing: 0.05em;
396
+ }
397
+
398
+ .event__meta,
399
+ .event__services {
400
+ color: var(--color-ink-medium);
401
+ font-size: var(--text-small);
402
+ margin: 0.35rem 0 0;
403
+ }
404
+
405
+ .event__update {
406
+ margin: 0.5rem 0 0;
407
+ }
408
+
409
+ .event__time {
410
+ color: var(--color-ink-medium);
411
+ display: block;
412
+ font-size: var(--text-x-small);
413
+ margin-top: 0.15rem;
414
+ }
415
+
416
+ .event,
417
+ .upcoming__item {
418
+ color: inherit;
419
+ display: block;
420
+ text-decoration: none;
421
+ transition: border-color 120ms ease-out;
422
+ }
423
+
424
+ .event:hover,
425
+ .upcoming__item:hover {
426
+ border-color: var(--color-ink-medium);
427
+ }
428
+
429
+ .event:focus-visible,
430
+ .upcoming__item:focus-visible {
431
+ outline: 2px solid var(--color-ink);
432
+ outline-offset: 2px;
433
+ }
434
+
435
+ .incident-detail {
436
+ --accent: var(--status-none);
437
+ margin: 0 auto;
438
+ max-width: 44rem;
439
+ padding: calc(var(--block-space) * 3) var(--main-padding);
440
+ }
441
+
442
+ .incident-detail--minor { --accent: var(--status-degraded); }
443
+ .incident-detail--major { --accent: var(--status-partial); }
444
+ .incident-detail--critical { --accent: var(--status-major); }
445
+ .incident-detail--maintenance { --accent: var(--status-maintenance); }
446
+
447
+ .incident-detail__back {
448
+ color: var(--color-ink-medium);
449
+ font-size: var(--text-small);
450
+ }
451
+
452
+ .incident-detail__head {
453
+ border-bottom: var(--border);
454
+ margin: var(--block-space) 0 calc(var(--block-space) * 2);
455
+ padding-bottom: calc(var(--block-space) * 1.5);
456
+ }
457
+
458
+ .incident-detail__eyebrow {
459
+ color: var(--accent);
460
+ font-size: var(--text-small);
461
+ font-weight: 600;
462
+ letter-spacing: 0.04em;
463
+ margin: 0 0 0.4rem;
464
+ text-transform: uppercase;
465
+ }
466
+
467
+ .incident-detail__title {
468
+ font-size: var(--text-x-large);
469
+ font-weight: 600;
470
+ letter-spacing: -0.02em;
471
+ margin: 0 0 0.75rem;
472
+ }
473
+
474
+ .incident-detail__meta {
475
+ align-items: center;
476
+ color: var(--color-ink-medium);
477
+ display: flex;
478
+ flex-wrap: wrap;
479
+ font-size: var(--text-small);
480
+ gap: 0.4rem 0.9rem;
481
+ }
482
+
483
+ .incident-detail__status {
484
+ background: color-mix(in oklab, var(--accent) 16%, var(--color-canvas));
485
+ border-radius: 2rem;
486
+ color: var(--color-ink);
487
+ font-weight: 500;
488
+ padding: 0.15rem 0.7rem;
489
+ }
490
+
491
+ .incident-detail__timeline {
492
+ list-style: none;
493
+ margin: 0;
494
+ padding: 0;
495
+ }
496
+
497
+ .incident-detail__update {
498
+ border-left: 2px solid var(--color-ink-lighter);
499
+ padding: 0 0 calc(var(--block-space) * 1.75) calc(var(--block-space) * 1.5);
500
+ position: relative;
501
+ }
502
+
503
+ .incident-detail__update:last-child {
504
+ border-left-color: transparent;
505
+ padding-bottom: 0;
506
+ }
507
+
508
+ .incident-detail__update::before {
509
+ background: var(--color-canvas);
510
+ border: 2px solid var(--accent);
511
+ border-radius: 50%;
512
+ content: "";
513
+ height: 0.75rem;
514
+ left: calc(-0.375rem - 1px);
515
+ position: absolute;
516
+ top: 0.2rem;
517
+ width: 0.75rem;
518
+ }
519
+
520
+ .incident-detail__update-status {
521
+ font-weight: 600;
522
+ }
523
+
524
+ .incident-detail__update-time {
525
+ color: var(--color-ink-medium);
526
+ font-size: var(--text-x-small);
527
+ margin-left: 0.5rem;
528
+ }
529
+
530
+ .incident-detail__update-body {
531
+ color: var(--color-ink-dark);
532
+ margin: 0.4rem 0 0;
533
+ }
534
+ }
File without changes
@@ -11,6 +11,7 @@ module Upright::Authentication
11
11
  if session[:user_info].present?
12
12
  Upright::Current.user = Upright::User.new(session[:user_info])
13
13
  else
14
+ session[:return_to] = request.url if request.get?
14
15
  redirect_to new_admin_session_url(default_url_options.merge(subdomain: Upright.configuration.global_subdomain)), allow_other_host: true
15
16
  end
16
17
  end
@@ -0,0 +1,134 @@
1
+ module Upright::ProxyAuthentication
2
+ extend ActiveSupport::Concern
3
+
4
+ # A forwarded path may only be a path: no authority (`//host`), no traversal,
5
+ # nothing outside the RFC 3986 path character set. The upstream host and port
6
+ # never come from the request (see #upstream_url), so this isn't the SSRF
7
+ # control — it keeps a normalizing hop from rewriting the path into a denied
8
+ # one, and keeps junk out of the upstream request.
9
+ SAFE_UPSTREAM_PATH = %r{\A/[A-Za-z0-9\-._~!$&'()*+,;=:@/]*\z}
10
+
11
+ # Cap the query we relay upstream. Rack's query parser already protects *us*
12
+ # from a nested-params bomb (it enforces param count, depth and bytesize limits
13
+ # before the action runs); this is about not handing an unbounded query to
14
+ # Prometheus or Alertmanager.
15
+ MAX_UPSTREAM_QUERY = 4096
16
+
17
+ # Destructive/admin upstream endpoints automation must never reach with a token
18
+ # (Prometheus `/-/reload`, `/-/quit`, Alertmanager admin under `/-/`). The
19
+ # embedded same-origin UI can still use them on the session path.
20
+ TOKEN_DENIED_PREFIXES = %w[ /-/ ]
21
+
22
+ included do
23
+ prepend_before_action :block_cross_site_session_requests
24
+ end
25
+
26
+ private
27
+ def authenticate_user
28
+ if proxy_token_provided?
29
+ authenticate_proxy_token
30
+ else
31
+ super
32
+ end
33
+ end
34
+
35
+ # Specifically "carries a bearer token", not "carries any Authorization
36
+ # header": a `Basic` header belongs on the session path, and shouldn't buy an
37
+ # exemption from the cross-site gate on its way to a 401.
38
+ def proxy_token_provided?
39
+ ActionController::HttpAuthentication::Token.token_and_options(request).present?
40
+ end
41
+
42
+ def authenticate_proxy_token
43
+ authenticate_or_request_with_http_token do |token|
44
+ ActiveSupport::SecurityUtils.secure_compare(token, Upright.configuration.proxy_token.to_s)
45
+ end
46
+ end
47
+
48
+ # The proxies can't demand an authenticity token: the embedded Prometheus and
49
+ # Alertmanager UIs post from JS we don't render, so there's no token to embed.
50
+ # Rather than skipping Rails' verification, widen what counts as verified — a
51
+ # valid token, a bearer token (not an ambient credential, so a cross-site page
52
+ # can't attach one), or same-origin provenance. Cross-site requests are
53
+ # already refused by #block_cross_site_session_requests; leaving verification
54
+ # in the chain means removing that gate can't quietly leave the proxies with
55
+ # no CSRF defence at all.
56
+ def verified_request?
57
+ super || proxy_token_provided? || !cross_site_request?
58
+ end
59
+
60
+ # CSRF gate for the browser (session-cookie) path: nothing in Action Pack
61
+ # reads Fetch-Metadata, so we gate on it here (CVE-2026-67990). Unlike Rails'
62
+ # token verification this covers GET too, because a proxied GET can reach a
63
+ # state-changing upstream endpoint. Automation authenticates with a bearer
64
+ # token and is out of scope; a missing/invalid token is not exempt — it simply
65
+ # falls through to a 401 at authentication.
66
+ def block_cross_site_session_requests
67
+ head :forbidden if !proxy_token_provided? && cross_site_request?
68
+ end
69
+
70
+ def cross_site_request?
71
+ site = request.headers["Sec-Fetch-Site"]
72
+
73
+ if site.present?
74
+ # Only same-origin (the embedded UI) and user-initiated `none` (a typed
75
+ # URL or bookmark) are allowed. same-site is refused too: a sibling on the
76
+ # same registrable domain (evil.example.com vs ams.upright.example.com) is
77
+ # same-site, and a top-level navigation from it would otherwise carry the
78
+ # Lax cookie into a forged proxy request.
79
+ !site.in?(%w[ same-origin none ])
80
+ elsif request.origin.present?
81
+ # The same comparison Rails makes in #valid_request_origin?. A sandboxed
82
+ # iframe or a cross-origin redirect sends `Origin: null`, which never
83
+ # equals base_url and so lands here as cross-site.
84
+ request.origin != request.base_url
85
+ else
86
+ # No Fetch-Metadata and no Origin: treat as cross-site and refuse. Every
87
+ # current browser sends Sec-Fetch-Site (and the embedded same-origin UI
88
+ # does too), so only a pre-2023 client omits it; failing closed keeps a
89
+ # header-less cross-site GET from riding the Lax session cookie.
90
+ true
91
+ end
92
+ end
93
+
94
+ # Build the upstream URL for a forwarded request. Scheme, host and port always
95
+ # come from the configured upstream, so no forwarded path can retarget Faraday
96
+ # at another host — an authority override like
97
+ # `/prometheus//169.254.169.254/latest/meta-data` (SSRF, CVE-2026-25765) can't
98
+ # survive being assigned as a path component. Returns the URI to forward, or
99
+ # nil to reject.
100
+ def upstream_url(base_url, raw)
101
+ path, _separator, query = raw.to_s.partition("?")
102
+ path = "/" if path.empty?
103
+
104
+ if safe_upstream_path?(path) && query.length <= MAX_UPSTREAM_QUERY
105
+ URI.parse(base_url).tap do |url|
106
+ url.path = path
107
+ url.query = query.presence
108
+ end
109
+ end
110
+ rescue URI::Error
111
+ nil
112
+ end
113
+
114
+ # Faraday and the upstream both normalize and percent-decode the path, so
115
+ # `/./-/reload` or a double-encoded `/%252d%252freload` would arrive at a
116
+ # denied `/-/` endpoint if we only checked the literal prefix. Reject any
117
+ # dot-segment, any percent-encoding, and anything outside the path set.
118
+ # Rack::Utils.valid_path? runs first: it screens null bytes and broken
119
+ # encoding, which would otherwise raise out of the regexp match.
120
+ def safe_upstream_path?(path)
121
+ Rack::Utils.valid_path?(path) &&
122
+ !path.start_with?("//") &&
123
+ !path.match?(%r{(?:\A|/)\.\.?(?:/|\z)}) &&
124
+ !path.include?("%") &&
125
+ path.match?(SAFE_UPSTREAM_PATH)
126
+ end
127
+
128
+ # A token may read through the proxy but not drive destructive or
129
+ # state-changing upstream endpoints; those stay on the same-origin session UI.
130
+ def token_forbidden_path?(path)
131
+ proxy_token_provided? &&
132
+ ((!request.get? && !request.head?) || path.start_with?(*TOKEN_DENIED_PREFIXES))
133
+ end
134
+ end
@@ -1,16 +1,24 @@
1
1
  class Upright::AlertmanagerProxyController < Upright::ApplicationController
2
- skip_forgery_protection
2
+ include Upright::ProxyAuthentication
3
3
 
4
4
  def show
5
5
  end
6
6
 
7
7
  def proxy
8
- proxy_to_alertmanager request.fullpath.delete_prefix("/alertmanager"), body: request.body&.read
8
+ url = upstream_url(alertmanager_url, request.fullpath.delete_prefix("/alertmanager"))
9
+
10
+ if url.nil?
11
+ head :bad_request
12
+ elsif token_forbidden_path?(url.path)
13
+ head :forbidden
14
+ else
15
+ proxy_to_alertmanager url, body: request.body&.read
16
+ end
9
17
  end
10
18
 
11
19
  private
12
- def proxy_to_alertmanager(path, method: request.method, body: nil)
13
- response = Faraday.new(url: alertmanager_url).run_request(method.downcase.to_sym, path, body, { "Content-Type" => request.content_type })
20
+ def proxy_to_alertmanager(url, method: request.method, body: nil)
21
+ response = Faraday.new(url: alertmanager_url).run_request(method.downcase.to_sym, url.to_s, body, { "Content-Type" => request.content_type })
14
22
 
15
23
  render body: response.body, status: response.status, content_type: response.headers["content-type"]
16
24
  end
@@ -1,5 +1,5 @@
1
1
  class Upright::ArtifactsController < Upright::ApplicationController
2
2
  def show
3
- @artifact = ActiveStorage::Attachment.find(params[:id])
3
+ @artifact = ActiveStorage::Attachment.where(record_type: "Upright::ProbeResult").find(params[:id])
4
4
  end
5
5
  end