upright 0.1.2 → 0.2.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.md +2 -3
  3. data/README.md +23 -10
  4. data/app/assets/stylesheets/upright/dashboard.css +65 -193
  5. data/app/assets/stylesheets/upright/tables.css +60 -0
  6. data/app/assets/stylesheets/upright/uptime-bars.css +3 -49
  7. data/app/controllers/upright/alertmanager_proxy_controller.rb +1 -1
  8. data/app/controllers/upright/dashboards/probe_statuses_controller.rb +7 -0
  9. data/app/helpers/upright/application_helper.rb +10 -0
  10. data/app/helpers/upright/dashboards_helper.rb +12 -0
  11. data/app/javascript/upright/controllers/auto_refresh_controller.js +16 -0
  12. data/app/models/concerns/upright/playwright/lifecycle.rb +3 -2
  13. data/app/models/upright/playwright/storage_state.rb +12 -3
  14. data/app/models/upright/probes/http_probe.rb +6 -2
  15. data/app/models/upright/probes/status/probe.rb +24 -0
  16. data/app/models/upright/probes/status/site_status.rb +71 -0
  17. data/app/models/upright/probes/status.rb +54 -0
  18. data/app/models/upright/traceroute/ip_metadata_lookup.rb +1 -2
  19. data/app/views/layouts/upright/_header.html.erb +2 -1
  20. data/app/views/upright/dashboards/_uptime_bars.html.erb +2 -2
  21. data/app/views/upright/dashboards/_uptime_probe_row.html.erb +3 -3
  22. data/app/views/upright/dashboards/probe_statuses/_matrix.html.erb +48 -0
  23. data/app/views/upright/dashboards/probe_statuses/show.html.erb +17 -0
  24. data/app/views/upright/sites/index.html.erb +1 -1
  25. data/config/credentials/development.key +1 -0
  26. data/config/credentials/test.key +1 -0
  27. data/config/routes.rb +1 -0
  28. data/lib/generators/upright/install/install_generator.rb +9 -0
  29. data/lib/generators/upright/install/templates/traceroute_probes.yml +9 -0
  30. data/lib/generators/upright/install/templates/upright.rb +1 -1
  31. data/lib/upright/geohash.rb +46 -0
  32. data/lib/upright/site.rb +1 -3
  33. data/lib/upright/version.rb +1 -1
  34. data/lib/upright.rb +1 -1
  35. metadata +14 -17
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ec7e02ebd4e740ce3d54364ad34d3d0e65df4ad2f6801ffb2a8ebbbf9af12cf
4
- data.tar.gz: 898bccbce30edeb9ab0f38e92e49b894ea197aace8e824bb3f0164e6c050e91b
3
+ metadata.gz: 1d1bb4a1f14f8e4dc16cf5bebcd5868917b2c0703f6dd94592d6f8c818dfa0b9
4
+ data.tar.gz: 4d3a0593d1f49f256145340f3e64c9ab6f467a759f1c0a60e81e58635528e0d1
5
5
  SHA512:
6
- metadata.gz: ed90551122e4fba67b74cae406d70ab58b4e419cffab41df84e816fbe4b53aeba8a1805a0c6f7949fd5fbc232589ca812b458056cd24098f38884d0d50acf364
7
- data.tar.gz: 68df1174db51f31b0a7e4d466fc9d451ca3f81151fe1c4048e1d5ea0cb2859f1689e875edaa02a7e03b010902efbc9e895b37ac8a3c78d7cb0bdfcd649257346
6
+ metadata.gz: 5d06f946c9874af94e42bca38bb58671e227b3c4f2ac2f9a523d36b3efca04771a3d0a087bb2644ea067e5a8094d35e1370ee85ded8ea140af084afbf4a4e29a
7
+ data.tar.gz: c2ac9d3d2822197887e22308dbdce7be1b47692825dcb25cbb660f0de8e24643788b2cd5a63c72c4863041b619a35e3b31b9e241c69861b59ff66c8398fcd9b2
data/LICENSE.md CHANGED
@@ -1,10 +1,9 @@
1
- # O'Saasy License Agreement
1
+ # MIT License
2
2
 
3
3
  Copyright © 2026, 37signals LLC.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
6
 
7
- 1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
- 2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
8
 
10
9
  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  Upright is a self-hosted synthetic monitoring system. It provides a framework for running health check probes from multiple geographic sites and reporting metrics via Prometheus. Alerts can then be configured with AlertManager.
4
4
 
5
+ <table>
6
+ <tr>
7
+ <td><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="100%"></td>
8
+ <td><img src="docs/screenshots/uptime.png" alt="Uptime" width="100%"></td>
9
+ </tr>
10
+ <tr>
11
+ <td><em>Site overview with world map</em></td>
12
+ <td><em>30-day uptime history</em></td>
13
+ </tr>
14
+ <tr>
15
+ <td colspan="2"><img src="docs/screenshots/probe-status.png" alt="Probe status" width="100%"></td>
16
+ </tr>
17
+ <tr>
18
+ <td colspan="2"><em>Probe status across all sites</em></td>
19
+ </tr>
20
+ </table>
21
+
5
22
  ## Features
6
23
 
7
24
  - **Playwright Probes** - Browser-based probes for user flows with video recording and logs
@@ -14,7 +31,6 @@ Upright is a self-hosted synthetic monitoring system. It provides a framework fo
14
31
 
15
32
  ### Not Included
16
33
 
17
- - **Dashboards** - Instead, Grafana is suggested for monitoring the Prometheus metrics generated here
18
34
  - **Notifications** - Instead, Alertmanager is included for alerting and notifications
19
35
  - **Hosting** - Instead, you can use a VPS from DigitalOcean, Hetzner, etc.
20
36
 
@@ -40,9 +56,9 @@ Create a new Rails application and install Upright:
40
56
  ```bash
41
57
  rails new my-upright --database=sqlite3 --skip-test
42
58
  cd my-upright
43
- bundle add upright --github=basecamp/upright
59
+ bundle add upright
44
60
  bin/rails generate upright:install
45
- bin/rails db:setup
61
+ bin/rails db:migrate
46
62
  ```
47
63
 
48
64
  Start the server:
@@ -227,18 +243,15 @@ Configure probe scheduling with Solid Queue in `config/recurring.yml`:
227
243
  ```yaml
228
244
  production:
229
245
  http_probes:
230
- class: Upright::ProbeCheckJob
231
- args: [http]
246
+ command: "Upright::Probes::HTTPProbe.check_and_record_all_later"
232
247
  schedule: every 30 seconds
233
248
 
234
249
  smtp_probes:
235
- class: Upright::ProbeCheckJob
236
- args: [smtp]
250
+ command: "Upright::Probes::SMTPProbe.check_and_record_all_later"
237
251
  schedule: every 30 seconds
238
252
 
239
253
  my_service_auth:
240
- class: Upright::ProbeCheckJob
241
- args: [playwright, MyServiceAuth]
254
+ command: "Probes::Playwright::MyServiceAuthProbe.check_and_record_later"
242
255
  schedule: every 15 minutes
243
256
  ```
244
257
 
@@ -452,4 +465,4 @@ bin/rails test
452
465
 
453
466
  ## License
454
467
 
455
- The gem is available under the terms of the [O'Saasy License](LICENSE.md).
468
+ The gem is available under the terms of the [MIT License](LICENSE.md).
@@ -34,227 +34,83 @@
34
34
  width: auto;
35
35
  }
36
36
 
37
- .dashboard-section {
38
- margin-bottom: calc(var(--block-space) * 2);
39
- }
40
-
41
- .dashboard-section h2 {
42
- font-size: var(--text-large);
43
- margin-bottom: var(--block-space);
44
- }
45
-
46
- .dashboard-section h3 {
47
- font-size: var(--text-normal);
48
- font-weight: 500;
49
- margin-bottom: var(--block-space);
50
- }
51
-
52
- .dashboard-loading {
53
- align-items: center;
54
- background: oklch(var(--lch-ink-lightest) / 85%);
55
- border-radius: 0.25rem;
56
- display: flex;
57
- gap: var(--inline-space);
58
- justify-content: center;
59
- margin-bottom: var(--block-space);
60
- padding: var(--block-space);
61
- }
62
-
63
- .dashboard-loading.hidden {
64
- display: none;
65
- }
66
-
67
- .loading-spinner {
68
- animation: spin 1s linear infinite;
69
- border: 2px solid var(--color-ink-light);
70
- border-radius: 50%;
71
- border-top-color: var(--color-link);
72
- height: 1rem;
73
- width: 1rem;
74
- }
75
-
76
- @keyframes spin {
77
- to { transform: rotate(360deg); }
78
- }
79
-
80
- .uptime-card {
37
+ .no-data-message {
81
38
  background: oklch(var(--lch-ink-lightest) / 85%);
82
39
  border-radius: 0.25rem;
83
40
  box-shadow: var(--shadow);
84
- display: inline-block;
85
- padding: calc(var(--block-space) * 1.5) calc(var(--block-space) * 2);
86
- text-align: center;
87
- }
88
-
89
- .uptime-value {
90
- font-size: 3rem;
91
- font-weight: 600;
92
- letter-spacing: -0.02em;
93
- line-height: 1;
94
- margin-bottom: 0.25rem;
95
- }
96
-
97
- .uptime-label {
98
41
  color: var(--color-ink-dark);
99
- font-size: var(--text-small);
100
- font-weight: 500;
101
- text-transform: uppercase;
102
- }
103
-
104
- .uptime-period {
105
- color: var(--color-ink-medium);
106
- font-size: var(--text-x-small);
107
- margin-top: 0.25rem;
42
+ padding: calc(var(--block-space) * 2);
43
+ text-align: center;
108
44
  }
109
45
 
110
- .dashboard-chart {
111
- background: oklch(var(--lch-ink-lightest) / 85%);
46
+ .error-message {
47
+ background: oklch(var(--lch-red-dark) / 15%);
112
48
  border-radius: 0.25rem;
113
- box-shadow: var(--shadow);
114
- min-height: 250px;
115
- padding: var(--block-space);
116
- }
117
-
118
- .dashboard-chart circle {
119
- stroke: none;
120
- }
121
-
122
- .dashboard-chart text {
123
- font-family: var(--font-mono);
124
- font-size: var(--text-x-small);
125
- }
126
-
127
- .uptime-table {
128
- width: 100%;
129
- }
130
-
131
- .uptime-table tbody tr {
132
- cursor: default;
133
- }
134
-
135
- .status-2xx {
136
- color: var(--color-positive);
137
- font-weight: 500;
138
- }
139
-
140
- .status-3xx {
141
- color: oklch(70% 0.12 250);
142
- font-weight: 500;
143
- }
144
-
145
- .status-4xx {
146
- color: oklch(75% 0.15 85);
147
- font-weight: 500;
148
- }
149
-
150
- .status-5xx {
151
49
  color: var(--color-negative);
152
- font-weight: 600;
153
- }
154
-
155
- .status-unknown {
156
- color: var(--color-ink-medium);
157
- }
158
-
159
- .heatmap-wrapper {
160
- background: oklch(var(--lch-ink-lightest) / 85%);
161
- border-radius: 0.25rem;
162
- box-shadow: var(--shadow);
163
- overflow-x: auto;
50
+ padding: var(--block-space);
51
+ text-align: center;
164
52
  }
165
53
 
166
- .heatmap-table {
167
- border-collapse: collapse;
168
- box-shadow: none;
169
- min-width: 100%;
54
+ /* Probe Status Matrix */
55
+ .probe-status__header,
56
+ .probe-status__row {
57
+ display: grid;
58
+ gap: 0;
59
+ grid-template-columns: minmax(200px, 1fr) repeat(var(--cols), 1fr);
170
60
  }
171
61
 
172
- .heatmap-table th,
173
- .heatmap-table td {
174
- border: 1px solid var(--color-ink-lighter);
175
- font-size: var(--text-x-small);
176
- padding: 0.5em 0.75em;
177
- text-align: center;
62
+ .probe-status__header > *,
63
+ .probe-status__row > * {
64
+ min-width: 8em;
65
+ padding: calc(var(--block-space) * 0.75) var(--block-space);
178
66
  white-space: nowrap;
179
67
  }
180
68
 
181
- .heatmap-probe-header,
182
- .heatmap-probe {
183
- background: var(--color-ink-lighter);
184
- font-weight: 500;
185
- left: 0;
186
- position: sticky;
187
- text-align: left;
69
+ .probe-status-cell-link {
70
+ color: inherit;
71
+ display: block;
72
+ text-decoration: none;
188
73
  }
189
74
 
190
- .heatmap-time {
191
- font-size: calc(var(--text-x-small) * 0.9);
192
- font-weight: 400;
193
- }
194
-
195
- .heatmap-cell {
196
- font-weight: 500;
197
- min-width: 3em;
75
+ .probe-status-cell-link:hover {
76
+ opacity: 0.8;
198
77
  }
199
78
 
200
- .heatmap-cell.status-2xx {
201
- background: oklch(var(--lch-green-dark) / 15%);
79
+ .probe-status-row--down {
80
+ background: oklch(var(--lch-red-dark) / 8%);
202
81
  }
203
82
 
204
- .heatmap-cell.status-3xx {
205
- background: oklch(70% 0.08 250 / 15%);
83
+ .probe-status-cell--up {
84
+ background: oklch(var(--lch-green-dark) / 10%);
206
85
  }
207
86
 
208
- .heatmap-cell.status-4xx {
209
- background: oklch(75% 0.1 85 / 20%);
87
+ .probe-status-cell--down {
88
+ background: oklch(var(--lch-red-dark) / 15%);
210
89
  }
211
90
 
212
- .heatmap-cell.status-5xx {
213
- background: oklch(var(--lch-red-dark) / 20%);
91
+ .probe-status-cell--stale {
92
+ background: oklch(75% 0.1 85 / 15%);
214
93
  }
215
94
 
216
- .error-table {
217
- width: 100%;
95
+ .probe-status-indicator {
96
+ font-weight: 600;
97
+ display: block;
218
98
  }
219
99
 
220
- .error-table tbody tr {
221
- cursor: default;
100
+ .probe-status-indicator--up {
101
+ color: var(--color-positive);
222
102
  }
223
103
 
224
- .error-time {
225
- color: var(--color-ink-dark);
226
- white-space: nowrap;
104
+ .probe-status-indicator--down {
105
+ color: var(--color-negative);
227
106
  }
228
107
 
229
- .error-probe {
230
- font-weight: 500;
108
+ .probe-status-indicator--stale {
109
+ color: oklch(75% 0.15 85);
231
110
  }
232
111
 
233
- .error-target {
112
+ .probe-status-indicator--unknown {
234
113
  color: var(--color-ink-medium);
235
- font-size: var(--text-x-small);
236
- max-width: 300px;
237
- overflow: hidden;
238
- text-overflow: ellipsis;
239
- white-space: nowrap;
240
- }
241
-
242
- .no-data-message,
243
- .no-errors-message {
244
- background: oklch(var(--lch-ink-lightest) / 85%);
245
- border-radius: 0.25rem;
246
- box-shadow: var(--shadow);
247
- color: var(--color-ink-dark);
248
- padding: calc(var(--block-space) * 2);
249
- text-align: center;
250
- }
251
-
252
- .error-message {
253
- background: oklch(var(--lch-red-dark) / 15%);
254
- border-radius: 0.25rem;
255
- color: var(--color-negative);
256
- padding: var(--block-space);
257
- text-align: center;
258
114
  }
259
115
 
260
116
  @media (max-width: 85ch) {
@@ -271,17 +127,33 @@
271
127
  .dashboard-filters {
272
128
  flex-wrap: wrap;
273
129
  }
274
-
275
- .uptime-value {
276
- font-size: 2rem;
277
- }
278
130
  }
279
131
 
280
132
  @media (max-width: 55ch) {
281
- .heatmap-table th,
282
- .heatmap-table td {
283
- padding: 0.25em 0.5em;
284
- font-size: calc(var(--text-x-small) * 0.9);
133
+ .probe-status__header {
134
+ display: none;
135
+ }
136
+
137
+ .probe-status__row {
138
+ grid-template-columns: 1fr;
139
+ }
140
+
141
+ .probe-status__row > .sticky-left {
142
+ position: static;
143
+ }
144
+
145
+ .probe-status__row > [data-label] {
146
+ display: flex;
147
+ align-items: center;
148
+ gap: var(--inline-space);
149
+ text-align: left;
150
+
151
+ &::before {
152
+ content: attr(data-label);
153
+ font-size: var(--text-x-small);
154
+ font-weight: 500;
155
+ min-width: 10em;
156
+ }
285
157
  }
286
158
  }
287
159
  }
@@ -60,4 +60,64 @@
60
60
  padding: calc(var(--block-space) * 0.4) calc(var(--block-space) * 0.4);
61
61
  }
62
62
  }
63
+
64
+ /* Div-based data tables (shared by dashboard views) */
65
+ .data-table {
66
+ background: oklch(var(--lch-ink-lightest) / 85%);
67
+ border-radius: 0.25rem;
68
+ box-shadow: var(--shadow);
69
+ }
70
+
71
+ .data-table__header {
72
+ background: var(--color-ink-lighter);
73
+ font-size: var(--text-x-small);
74
+ font-weight: 500;
75
+ letter-spacing: 0.08em;
76
+ text-transform: uppercase;
77
+ }
78
+
79
+ .data-table__row {
80
+ border-top: 1px solid var(--color-ink-lighter);
81
+ transition: background-color 100ms;
82
+
83
+ &:hover {
84
+ background-color: var(--color-canvas);
85
+ }
86
+ }
87
+
88
+ .data-table__probe {
89
+ align-items: center;
90
+ color: inherit;
91
+ display: flex;
92
+ gap: 0.75em;
93
+ min-width: 0;
94
+ text-decoration: none;
95
+
96
+ &:hover {
97
+ color: var(--color-link);
98
+ text-decoration: none;
99
+ }
100
+ }
101
+
102
+ .data-table__probe-name {
103
+ overflow: hidden;
104
+ text-overflow: ellipsis;
105
+ white-space: nowrap;
106
+ }
107
+
108
+ /* Utilities */
109
+ .text-center {
110
+ text-align: center;
111
+ }
112
+
113
+ .sticky-left {
114
+ background: inherit;
115
+ left: 0;
116
+ position: sticky;
117
+ z-index: 1;
118
+ }
119
+
120
+ .scrollable-x {
121
+ overflow-x: auto;
122
+ }
63
123
  }
@@ -1,56 +1,10 @@
1
1
  @layer components {
2
- .uptime-bars {
3
- display: flex;
4
- flex-direction: column;
5
- }
6
-
7
- .uptime-bars__header {
8
- background: var(--color-ink-lighter);
9
- display: grid;
10
- font-size: var(--text-x-small);
11
- font-weight: 500;
12
- gap: var(--block-space);
13
- grid-template-columns: minmax(200px, 1fr) 1fr auto;
14
- letter-spacing: 0.04em;
15
- padding: calc(var(--block-space) * 0.75) var(--block-space);
16
- text-transform: uppercase;
17
- }
18
-
19
-
2
+ .uptime-bars__header,
20
3
  .uptime-bars__row {
21
- border-top: 1px solid var(--color-ink-lighter);
22
4
  display: grid;
23
5
  gap: var(--block-space);
24
6
  grid-template-columns: minmax(200px, 1fr) 1fr auto;
25
7
  padding: calc(var(--block-space) * 0.75) var(--block-space);
26
- transition: background-color 100ms;
27
- }
28
-
29
- .uptime-bars__row:hover {
30
- background: var(--color-canvas);
31
- }
32
-
33
- .uptime-bars__probe {
34
- align-items: center;
35
- color: inherit;
36
- display: flex;
37
- gap: 0.75em;
38
- min-width: 0;
39
-
40
- &:hover {
41
- color: var(--color-link);
42
- text-decoration: none;
43
- }
44
- }
45
-
46
- .uptime-bars__probe .probe__badge {
47
- flex-shrink: 0;
48
- }
49
-
50
- .uptime-bars__probe .probe__name {
51
- overflow: hidden;
52
- text-overflow: ellipsis;
53
- white-space: nowrap;
54
8
  }
55
9
 
56
10
  .uptime-bars__days {
@@ -83,7 +37,7 @@
83
37
  color: var(--color-negative);
84
38
  }
85
39
 
86
- /* Individual uptime bar (separate block) */
40
+ /* Individual uptime bar */
87
41
  .uptime-bar {
88
42
  border-radius: 2px;
89
43
  cursor: default;
@@ -131,7 +85,7 @@
131
85
  gap: calc(var(--block-space) * 0.5);
132
86
  }
133
87
 
134
- .uptime-bars__probe .probe__badge {
88
+ .data-table__probe .probe__badge {
135
89
  display: none;
136
90
  }
137
91
  }
@@ -5,7 +5,7 @@ class Upright::AlertmanagerProxyController < Upright::ApplicationController
5
5
  end
6
6
 
7
7
  def proxy
8
- proxy_to_alertmanager request.fullpath.delete_prefix("/alertmanager")
8
+ proxy_to_alertmanager request.fullpath.delete_prefix("/alertmanager"), body: request.body&.read
9
9
  end
10
10
 
11
11
  private
@@ -0,0 +1,7 @@
1
+ class Upright::Dashboards::ProbeStatusesController < Upright::ApplicationController
2
+ def show
3
+ @probe_type = params.fetch(:probe_type, :http)
4
+ @probes = Upright::Probes::Status.for_type(@probe_type)
5
+ @sites = Upright.sites
6
+ end
7
+ end
@@ -3,9 +3,19 @@ module Upright::ApplicationHelper
3
3
  Upright::Current.site || Upright.sites.first
4
4
  end
5
5
 
6
+ def site_name(site)
7
+ "#{country_flag(site.country)} #{site.city}"
8
+ end
9
+
6
10
  def upright_stylesheet_link_tag(**options)
7
11
  Upright::Engine.root.join("app/assets/stylesheets/upright").glob("*.css")
8
12
  .map { |f| "upright/#{f.basename('.css')}" }.sort
9
13
  .then { |stylesheets| stylesheet_link_tag(*stylesheets, **options) }
10
14
  end
15
+
16
+ private
17
+
18
+ def country_flag(country_code)
19
+ country_code&.upcase&.gsub(/[A-Z]/) { |c| (c.ord + 0x1F1A5).chr(Encoding::UTF_8) }
20
+ end
11
21
  end
@@ -28,4 +28,16 @@ module Upright::DashboardsHelper
28
28
  mins.zero? ? "#{hours}h" : "#{hours}h #{mins}m"
29
29
  end
30
30
  end
31
+
32
+ def probe_status_css_class(status)
33
+ if status.nil?
34
+ "probe-status-cell--unknown"
35
+ elsif status.stale?
36
+ "probe-status-cell--stale"
37
+ elsif status.up?
38
+ "probe-status-cell--up"
39
+ else
40
+ "probe-status-cell--down"
41
+ end
42
+ end
31
43
  end
@@ -0,0 +1,16 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { interval: { type: Number, default: 60000 } }
5
+
6
+ connect() {
7
+ this.timer = setInterval(() => {
8
+ this.element.src = window.location.href
9
+ this.element.reload()
10
+ }, this.intervalValue)
11
+ }
12
+
13
+ disconnect() {
14
+ clearInterval(this.timer)
15
+ }
16
+ }
@@ -33,8 +33,9 @@ module Upright::Playwright::Lifecycle
33
33
  run_callbacks :page_ready
34
34
  yield
35
35
  ensure
36
- page&.close
37
- context&.close
36
+ # Rescue each step independently so a failed close doesn't prevent video capture
37
+ page&.close rescue Rails.error.report($!)
38
+ context&.close rescue Rails.error.report($!)
38
39
  run_callbacks :page_close
39
40
  end
40
41
 
@@ -8,12 +8,16 @@ class Upright::Playwright::StorageState
8
8
  end
9
9
 
10
10
  def load
11
- JSON.parse(path.read) if exists?
11
+ if exists?
12
+ decrypted_json = encryptor.decrypt_and_verify(path.read)
13
+ JSON.parse(decrypted_json)
14
+ end
12
15
  end
13
16
 
14
17
  def save(state)
15
18
  FileUtils.mkdir_p(storage_dir)
16
- path.write(JSON.pretty_generate(state))
19
+ encrypted_data = encryptor.encrypt_and_sign(JSON.generate(state))
20
+ path.write(encrypted_data)
17
21
  end
18
22
 
19
23
  def clear
@@ -26,6 +30,11 @@ class Upright::Playwright::StorageState
26
30
  end
27
31
 
28
32
  def path
29
- storage_dir.join("#{@service}.json")
33
+ storage_dir.join("#{@service}.enc")
34
+ end
35
+
36
+ def encryptor
37
+ key = Rails.application.key_generator.generate_key("playwright_storage_state", 32)
38
+ ActiveSupport::MessageEncryptor.new(key)
30
39
  end
31
40
  end
@@ -74,11 +74,15 @@ class Upright::Probes::HTTPProbe < FrozenRecord::Base
74
74
  end
75
75
 
76
76
  def proxy_credentials
77
- if try(:proxy)
78
- Rails.application.credentials.dig(:proxies, proxy.to_sym)
77
+ if selected_proxy
78
+ Rails.application.credentials.dig(:proxies, selected_proxy.to_sym)
79
79
  end
80
80
  end
81
81
 
82
+ def selected_proxy
83
+ Array(try(:proxies) || try(:proxy)).sample
84
+ end
85
+
82
86
  def record_response_status
83
87
  if last_response && !last_response.network_error? && defined?(Yabeda)
84
88
  Yabeda.upright_http_response_status.set(
@@ -0,0 +1,24 @@
1
+ class Upright::Probes::Status::Probe
2
+ include Comparable
3
+
4
+ attr_reader :name, :type, :probe_target, :site_statuses
5
+
6
+ def initialize(name:, type:, probe_target:, site_statuses:)
7
+ @name = name
8
+ @type = type
9
+ @probe_target = probe_target
10
+ @site_statuses = site_statuses
11
+ end
12
+
13
+ def status_for_site(code)
14
+ site_statuses.find { |s| s.site_code == code.to_s }
15
+ end
16
+
17
+ def any_down?
18
+ site_statuses.any?(&:down?)
19
+ end
20
+
21
+ def <=>(other)
22
+ [ any_down? ? 0 : 1, type, name ] <=> [ other.any_down? ? 0 : 1, other.type, other.name ]
23
+ end
24
+ end
@@ -0,0 +1,71 @@
1
+ class Upright::Probes::Status::SiteStatus
2
+ STALE_THRESHOLD = 5.minutes
3
+
4
+ attr_reader :site_code, :site_city
5
+
6
+ def initialize(site_code:, site_city:, values:)
7
+ @site_code = site_code
8
+ @site_city = site_city
9
+ @values = values
10
+ end
11
+
12
+ def up?
13
+ latest_value == 1
14
+ end
15
+
16
+ def down?
17
+ !up?
18
+ end
19
+
20
+ def stale?
21
+ if @values.empty?
22
+ true
23
+ else
24
+ Time.at(latest_timestamp) < STALE_THRESHOLD.ago
25
+ end
26
+ end
27
+
28
+ def down_since
29
+ if down? && @values.any?
30
+ Time.at(down_start_timestamp)
31
+ end
32
+ end
33
+
34
+ def down_since_known?
35
+ if down? && @values.any?
36
+ down_start_timestamp != sorted_values.first.first
37
+ else
38
+ false
39
+ end
40
+ end
41
+
42
+ private
43
+ def sorted_values
44
+ @sorted_values ||= @values.sort_by(&:first)
45
+ end
46
+
47
+ def down_start_timestamp
48
+ @down_start_timestamp ||= begin
49
+ result = sorted_values.last.first
50
+
51
+ sorted_values.reverse_each do |timestamp, value|
52
+ break if value.to_f == 1
53
+ result = timestamp
54
+ end
55
+
56
+ result
57
+ end
58
+ end
59
+
60
+ def latest_value
61
+ if @values.any?
62
+ sorted_values.last.last.to_f
63
+ end
64
+ end
65
+
66
+ def latest_timestamp
67
+ if @values.any?
68
+ sorted_values.last.first
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,54 @@
1
+ class Upright::Probes::Status
2
+ class << self
3
+ def for_type(probe_type)
4
+ results = prometheus_client.query_range(
5
+ query: query(probe_type),
6
+ start: 30.minutes.ago.iso8601,
7
+ end: Time.current.iso8601,
8
+ step: "30s"
9
+ ).deep_symbolize_keys
10
+
11
+ build_probes(results[:result])
12
+ end
13
+
14
+ private
15
+ def query(probe_type)
16
+ "upright_probe_up#{label_selector(probe_type)}"
17
+ end
18
+
19
+ def label_selector(probe_type)
20
+ if probe_type.present?
21
+ "{type=\"#{probe_type}\"}"
22
+ end
23
+ end
24
+
25
+ def prometheus_client
26
+ Prometheus::ApiClient.client(
27
+ url: ENV.fetch("PROMETHEUS_URL", "http://localhost:9090"),
28
+ options: { timeout: 30.seconds }
29
+ )
30
+ end
31
+
32
+ def build_probes(results)
33
+ # Group results by probe identity (name + type + probe_target)
34
+ grouped = results.group_by { |r| [ r[:metric][:name], r[:metric][:type], r[:metric][:probe_target] ] }
35
+
36
+ grouped.map do |(_name, _type, _target), series|
37
+ site_statuses = series.map do |s|
38
+ SiteStatus.new(
39
+ site_code: s[:metric][:site_code],
40
+ site_city: s[:metric][:site_city],
41
+ values: s[:values]
42
+ )
43
+ end
44
+
45
+ Probe.new(
46
+ name: _name,
47
+ type: _type,
48
+ probe_target: _target,
49
+ site_statuses: site_statuses
50
+ )
51
+ end.sort
52
+ end
53
+ end
54
+ end
@@ -1,7 +1,6 @@
1
1
  require "net/http"
2
2
  require "json"
3
3
  require "resolv"
4
- require "geohash_ruby"
5
4
 
6
5
  class Upright::Traceroute::IpMetadataLookup
7
6
  API_URL = "http://ip-api.com/batch"
@@ -84,7 +83,7 @@ class Upright::Traceroute::IpMetadataLookup
84
83
 
85
84
  def encode_geohash(latitude, longitude)
86
85
  if latitude && longitude
87
- Geohash.encode(latitude, longitude, GEOHASH_PRECISION)
86
+ Upright::Geohash.encode(latitude, longitude, GEOHASH_PRECISION)
88
87
  end
89
88
  end
90
89
 
@@ -2,13 +2,14 @@
2
2
  <div class="header__left">
3
3
  <%= link_to "Upright", root_url(subdomain: Upright.configuration.global_subdomain), class: "header__logo" %>
4
4
  <% if Upright::Current.site.present? %>
5
- <span class="header__site"><%= Upright::Current.site.city %></span>
5
+ <span class="header__site"><%= site_name(Upright::Current.site) %></span>
6
6
  <% end %>
7
7
  </div>
8
8
 
9
9
  <nav class="header__nav">
10
10
  <%= link_to "Probes", upright.site_root_url(subdomain: current_or_default_site.code), class: "header__link" %>
11
11
  <%= link_to "Uptime", upright.dashboards_uptime_url(subdomain: Upright.configuration.global_subdomain), class: "header__link" %>
12
+ <%= link_to "Status", upright.dashboards_probe_status_url(subdomain: Upright.configuration.global_subdomain), class: "header__link" %>
12
13
  <span class="header__divider"></span>
13
14
  <%= link_to "Jobs", upright.jobs_url(subdomain: current_or_default_site.code), class: "header__link" %>
14
15
  <span class="header__divider"></span>
@@ -3,8 +3,8 @@
3
3
  No probe data available for the selected filters.
4
4
  </div>
5
5
  <% else %>
6
- <div class="uptime-bars">
7
- <div class="uptime-bars__header">
6
+ <div class="data-table">
7
+ <div class="data-table__header uptime-bars__header">
8
8
  <div>Probe</div>
9
9
  <div>Past 30 days</div>
10
10
  <div>Uptime</div>
@@ -1,7 +1,7 @@
1
- <div class="uptime-bars__row">
2
- <%= link_to upright.site_root_url(subdomain: Upright.sites.first.code, probe_type: probe.type, probe_name: probe.name), class: "uptime-bars__probe" do %>
1
+ <div class="data-table__row uptime-bars__row">
2
+ <%= link_to upright.site_root_url(subdomain: Upright.sites.first.code, probe_type: probe.type, probe_name: probe.name), class: "data-table__probe" do %>
3
3
  <%= probe_type_icon(probe.type) %>
4
- <span class="probe__name"><%= probe.name %></span>
4
+ <span class="data-table__probe-name"><%= probe.name %></span>
5
5
  <% end %>
6
6
  <div class="uptime-bars__days">
7
7
  <% dates.each do |date| %>
@@ -0,0 +1,48 @@
1
+ <% if probes.empty? %>
2
+ <div class="no-data-message">
3
+ No probe data available for the selected type.
4
+ </div>
5
+ <% else %>
6
+ <div class="scrollable-x">
7
+ <div class="data-table" style="--cols: <%= sites.size %>">
8
+ <div class="data-table__header probe-status__header">
9
+ <div class="sticky-left">Probe</div>
10
+ <% sites.each do |site| %>
11
+ <div class="text-center"><%= site_name(site) %></div>
12
+ <% end %>
13
+ </div>
14
+ <% probes.each do |probe| %>
15
+ <div class="data-table__row probe-status__row <%= "probe-status-row--down" if probe.any_down? %>">
16
+ <div class="sticky-left">
17
+ <%= link_to upright.site_root_url(subdomain: Upright.sites.first.code, probe_type: probe.type, probe_name: probe.name), class: "data-table__probe" do %>
18
+ <%= probe_type_icon(probe.type) %>
19
+ <span><%= probe.name %></span>
20
+ <% end %>
21
+ </div>
22
+ <% sites.each do |site| %>
23
+ <% status = probe.status_for_site(site.code) %>
24
+ <div class="text-center <%= probe_status_css_class(status) %>" data-label="<%= site_name(site) %>">
25
+ <%= link_to upright.site_root_url(subdomain: site.code, probe_type: probe.type, probe_name: probe.name), class: "probe-status-cell-link" do %>
26
+ <% if status.nil? %>
27
+ <span class="probe-status-indicator probe-status-indicator--unknown">—</span>
28
+ <% elsif status.stale? %>
29
+ <span class="probe-status-indicator probe-status-indicator--stale">STALE</span>
30
+ <% elsif status.up? %>
31
+ <span class="probe-status-indicator probe-status-indicator--up">UP</span>
32
+ <% else %>
33
+ <span class="probe-status-indicator probe-status-indicator--down">
34
+ <% if status.down_since_known? %>
35
+ DOWN for <%= time_ago_in_words(status.down_since) %>
36
+ <% else %>
37
+ DOWN
38
+ <% end %>
39
+ </span>
40
+ <% end %>
41
+ <% end %>
42
+ </div>
43
+ <% end %>
44
+ </div>
45
+ <% end %>
46
+ </div>
47
+ </div>
48
+ <% end %>
@@ -0,0 +1,17 @@
1
+ <div class="dashboard probe-status-dashboard">
2
+ <header class="dashboard-header">
3
+ <h1>Probe Status</h1>
4
+
5
+ <div class="dashboard-filters">
6
+ <%= form_with url: dashboards_probe_status_path, method: :get, data: { controller: "form", turbo_frame: :probe_status_matrix } do %>
7
+ <%= collection_select nil, :probe_type, Upright::Probeable::TYPES, :itself, :titleize,
8
+ { selected: @probe_type },
9
+ { class: "input--select", data: { action: "change->form#submit" } } %>
10
+ <% end %>
11
+ </div>
12
+ </header>
13
+
14
+ <turbo-frame id="probe_status_matrix" data-controller="auto-refresh" data-auto-refresh-interval-value="60000">
15
+ <%= render "matrix", probes: @probes, sites: @sites %>
16
+ </turbo-frame>
17
+ </div>
@@ -14,7 +14,7 @@
14
14
  <% @sites.each do |site| %>
15
15
  <tr>
16
16
  <td><%= link_to site.host, site.url %></td>
17
- <td><%= site.city %></td>
17
+ <td><%= site_name(site) %></td>
18
18
  </tr>
19
19
  <% end %>
20
20
  </tbody>
@@ -0,0 +1 @@
1
+ 40451408e038d0fe36a8f4aed0cef429
@@ -0,0 +1 @@
1
+ 40451408e038d0fe36a8f4aed0cef429
data/config/routes.rb CHANGED
@@ -10,6 +10,7 @@ Upright::Engine.routes.draw do
10
10
 
11
11
  namespace :dashboards do
12
12
  resource :uptime, only: :show
13
+ resource :probe_status, only: :show
13
14
  end
14
15
 
15
16
  scope :framed do
@@ -19,6 +19,7 @@ module Upright
19
19
  empty_directory "probes/authenticators"
20
20
  template "http_probes.yml", "probes/http_probes.yml"
21
21
  template "smtp_probes.yml", "probes/smtp_probes.yml"
22
+ template "traceroute_probes.yml", "probes/traceroute_probes.yml"
22
23
  end
23
24
 
24
25
  def copy_observability_configs
@@ -46,6 +47,14 @@ module Upright
46
47
  route 'mount Upright::Engine => "/", as: :upright'
47
48
  end
48
49
 
50
+ def install_active_storage
51
+ rails_command "active_storage:install"
52
+ end
53
+
54
+ def configure_javascript
55
+ append_to_file "app/javascript/application.js", 'import "upright/application"'
56
+ end
57
+
49
58
  def show_post_install_message
50
59
  say ""
51
60
  say "Upright has been installed!", :green
@@ -0,0 +1,9 @@
1
+ # Traceroute Probe Definitions
2
+ # Add your traceroute probes here
3
+ #
4
+ # Required fields:
5
+ # - name: Unique identifier for the probe
6
+ # - host: Hostname or IP address to trace
7
+
8
+ # - name: Example Service
9
+ # host: example.com
@@ -3,7 +3,7 @@
3
3
  Upright.configure do |config|
4
4
  config.service_name = "<%= Rails.application.class.module_parent_name.underscore %>"
5
5
  config.user_agent = "<%= Rails.application.class.module_parent_name.underscore %>/1.0"
6
- config.hostname = Rails.env.local? ? "<%= Rails.application.class.module_parent_name.downcase %>.localhost" : "<%= Rails.application.class.module_parent_name.downcase %>.com"
6
+ config.hostname = Rails.env.local? ? "<%= Rails.application.class.module_parent_name.underscore.dasherize %>.localhost" : "<%= Rails.application.class.module_parent_name.underscore.dasherize %>.com"
7
7
 
8
8
  # Playwright browser server URL
9
9
  # config.playwright_server_url = ENV["PLAYWRIGHT_SERVER_URL"]
@@ -0,0 +1,46 @@
1
+ module Upright
2
+ module Geohash
3
+ BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
4
+
5
+ module_function
6
+
7
+ def decode(geohash)
8
+ bounds = [ [ -90.0, +90.0 ], [ -180.0, +180.0 ] ]
9
+
10
+ geohash.downcase.each_char.with_index do |c, i|
11
+ d = BASE32.index c
12
+
13
+ 5.times do |j|
14
+ bit = (d & (1 << (4 - j))) >> (4 - j)
15
+ k = (~i & 1) ^ (j & 1)
16
+ bounds[k][bit ^ 1] = (bounds[k][0] + bounds[k][1]) / 2
17
+ end
18
+ end
19
+
20
+ bounds.transpose
21
+ end
22
+
23
+ def encode(latitude, longitude, precision = 12)
24
+ mids = [ latitude, longitude ]
25
+ bounds = [ [ -90.0, +90.0 ], [ -180.0, +180.0 ] ]
26
+
27
+ geohash = +""
28
+
29
+ precision.times do |i|
30
+ d = 0
31
+
32
+ 5.times do |j|
33
+ k = (~i & 1) ^ (j & 1)
34
+ mid = (bounds[k][0] + bounds[k][1]) / 2
35
+ bit = mids[k] > mid ? 1 : 0
36
+ bounds[k][bit ^ 1] = mid
37
+ d |= bit << (4 - j)
38
+ end
39
+
40
+ geohash << BASE32[d]
41
+ end
42
+
43
+ geohash
44
+ end
45
+ end
46
+ end
data/lib/upright/site.rb CHANGED
@@ -1,5 +1,3 @@
1
- require "geohash_ruby"
2
-
3
1
  module Upright
4
2
  class Site
5
3
  attr_reader :code, :city, :country, :geohash, :stagger_index
@@ -43,7 +41,7 @@ module Upright
43
41
 
44
42
  private
45
43
  def coordinates
46
- @coordinates ||= Geohash.decode(geohash).first
44
+ @coordinates ||= Upright::Geohash.decode(geohash).first
47
45
  end
48
46
  end
49
47
  end
@@ -1,3 +1,3 @@
1
1
  module Upright
2
- VERSION = "0.1.2"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/upright.rb CHANGED
@@ -14,12 +14,12 @@ require "importmap-rails"
14
14
  require "turbo-rails"
15
15
  require "stimulus-rails"
16
16
  require "geared_pagination"
17
- require "geohash_ruby"
18
17
  require "yabeda/prometheus"
19
18
  require "yabeda/puma/plugin"
20
19
 
21
20
  require "upright/version"
22
21
  require "upright/configuration"
22
+ require "upright/geohash"
23
23
  require "upright/site"
24
24
  require "upright/metrics"
25
25
  require "upright/tracing"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: upright
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lewis Buckley
@@ -163,20 +163,6 @@ dependencies:
163
163
  - - ">="
164
164
  - !ruby/object:Gem::Version
165
165
  version: '0'
166
- - !ruby/object:Gem::Dependency
167
- name: geohash_ruby
168
- requirement: !ruby/object:Gem::Requirement
169
- requirements:
170
- - - ">="
171
- - !ruby/object:Gem::Version
172
- version: '0'
173
- type: :runtime
174
- prerelease: false
175
- version_requirements: !ruby/object:Gem::Requirement
176
- requirements:
177
- - - ">="
178
- - !ruby/object:Gem::Version
179
- version: '0'
180
166
  - !ruby/object:Gem::Dependency
181
167
  name: playwright-ruby-client
182
168
  requirement: !ruby/object:Gem::Requirement
@@ -390,6 +376,7 @@ files:
390
376
  - app/controllers/upright/alertmanager_proxy_controller.rb
391
377
  - app/controllers/upright/application_controller.rb
392
378
  - app/controllers/upright/artifacts_controller.rb
379
+ - app/controllers/upright/dashboards/probe_statuses_controller.rb
393
380
  - app/controllers/upright/dashboards/uptimes_controller.rb
394
381
  - app/controllers/upright/jobs_controller.rb
395
382
  - app/controllers/upright/probe_results_controller.rb
@@ -401,6 +388,7 @@ files:
401
388
  - app/helpers/upright/probe_results_helper.rb
402
389
  - app/javascript/upright/application.js
403
390
  - app/javascript/upright/controllers/application.js
391
+ - app/javascript/upright/controllers/auto_refresh_controller.js
404
392
  - app/javascript/upright/controllers/form_controller.js
405
393
  - app/javascript/upright/controllers/index.js
406
394
  - app/javascript/upright/controllers/popover_controller.js
@@ -431,6 +419,9 @@ files:
431
419
  - app/models/upright/probes/http_probe.rb
432
420
  - app/models/upright/probes/playwright/base.rb
433
421
  - app/models/upright/probes/smtp_probe.rb
422
+ - app/models/upright/probes/status.rb
423
+ - app/models/upright/probes/status/probe.rb
424
+ - app/models/upright/probes/status/site_status.rb
434
425
  - app/models/upright/probes/traceroute_probe.rb
435
426
  - app/models/upright/probes/uptime.rb
436
427
  - app/models/upright/probes/uptime/summary.rb
@@ -446,6 +437,8 @@ files:
446
437
  - app/views/upright/artifacts/show.html.erb
447
438
  - app/views/upright/dashboards/_uptime_bars.html.erb
448
439
  - app/views/upright/dashboards/_uptime_probe_row.html.erb
440
+ - app/views/upright/dashboards/probe_statuses/_matrix.html.erb
441
+ - app/views/upright/dashboards/probe_statuses/show.html.erb
449
442
  - app/views/upright/dashboards/uptimes/show.html.erb
450
443
  - app/views/upright/jobs/show.html.erb
451
444
  - app/views/upright/probe_results/_pagination.html.erb
@@ -455,6 +448,8 @@ files:
455
448
  - app/views/upright/sites/index.html.erb
456
449
  - config/brakeman.ignore
457
450
  - config/ci.rb
451
+ - config/credentials/development.key
452
+ - config/credentials/test.key
458
453
  - config/importmap.rb
459
454
  - config/routes.rb
460
455
  - db/migrate/20250114000001_create_upright_probe_results.rb
@@ -471,6 +466,7 @@ files:
471
466
  - lib/generators/upright/install/templates/puma.rb
472
467
  - lib/generators/upright/install/templates/sites.yml
473
468
  - lib/generators/upright/install/templates/smtp_probes.yml
469
+ - lib/generators/upright/install/templates/traceroute_probes.yml
474
470
  - lib/generators/upright/install/templates/upright.rb
475
471
  - lib/generators/upright/install/templates/upright.rules.yml
476
472
  - lib/generators/upright/playwright_probe/playwright_probe_generator.rb
@@ -481,6 +477,7 @@ files:
481
477
  - lib/upright.rb
482
478
  - lib/upright/configuration.rb
483
479
  - lib/upright/engine.rb
480
+ - lib/upright/geohash.rb
484
481
  - lib/upright/metrics.rb
485
482
  - lib/upright/playwright/collect_performance_metrics.js
486
483
  - lib/upright/site.rb
@@ -488,7 +485,7 @@ files:
488
485
  - lib/upright/version.rb
489
486
  homepage: https://github.com/basecamp/upright
490
487
  licenses:
491
- - O'Saasy
488
+ - MIT
492
489
  metadata:
493
490
  homepage_uri: https://github.com/basecamp/upright
494
491
  source_code_uri: https://github.com/basecamp/upright
@@ -507,7 +504,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
507
504
  - !ruby/object:Gem::Version
508
505
  version: '0'
509
506
  requirements: []
510
- rubygems_version: 4.0.4
507
+ rubygems_version: 4.0.3
511
508
  specification_version: 4
512
509
  summary: Synthetic monitoring engine with Playwright and Prometheus metrics
513
510
  test_files: []