|
1 |
+ |
//! PoM's projection onto the shared operator status payload.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! Spec + rationale: maintainer wiki.
|
|
4 |
+ |
//! <!-- wiki: release-status-payload -->
|
|
5 |
+ |
//!
|
|
6 |
+ |
//! `GET /status.json` serves this. Sando and Bento report on the thing they
|
|
7 |
+ |
//! *are*; PoM reports on the things it *watches*, so every target it monitors
|
|
8 |
+ |
//! becomes one node and the viewer needs no knowledge of health checks, TLS, or
|
|
9 |
+ |
//! incidents to draw PoM.
|
|
10 |
+ |
//!
|
|
11 |
+ |
//! [`payload`] is a pure function of `(&[TargetView], now)`. Every clock is an
|
|
12 |
+ |
//! argument, so a fixture renders identically forever and the mapping is
|
|
13 |
+ |
//! testable without a database.
|
|
14 |
+ |
//!
|
|
15 |
+ |
//! # What maps to what
|
|
16 |
+ |
//!
|
|
17 |
+ |
//! | PoM | payload |
|
|
18 |
+ |
//! |---|---|
|
|
19 |
+ |
//! | monitored target | node, `kind = "target"` |
|
|
20 |
+ |
//! | latest health check | node status + `health` condition |
|
|
21 |
+ |
//! | open incident | `incident` condition carrying the why |
|
|
22 |
+ |
//! | TLS certificate | `tls` condition (expiry) |
|
|
23 |
+ |
//! | domain registration | `whois` condition (expiry) |
|
|
24 |
+ |
//! | uptime / latency / version | fields |
|
|
25 |
+ |
//!
|
|
26 |
+ |
//! # Every signal colors the target
|
|
27 |
+ |
//!
|
|
28 |
+ |
//! The load-bearing difference from Sando. A Sando gate guards *promotion* and
|
|
29 |
+ |
//! must not color a tier — burn-in blocks for 48 hours as routine. PoM has no
|
|
30 |
+ |
//! such notion: a cert 3 days from expiry, an open incident, a domain about to
|
|
31 |
+ |
//! lapse are each a real problem with the target regardless of what the health
|
|
32 |
+ |
//! endpoint says. So a target's status is the worst of every condition on it,
|
|
33 |
+ |
//! and the why is never lost because it is in the conditions either way.
|
|
34 |
+ |
//!
|
|
35 |
+ |
//! PoM declares no actions. It observes; it does not act, and there is no route
|
|
36 |
+ |
//! to trigger a recheck. An empty actions map is the honest shape.
|
|
37 |
+ |
|
|
38 |
+ |
use chrono::{DateTime, Utc};
|
|
39 |
+ |
use ops_status::{Condition, Field, Node, Payload, Status, Value};
|
|
40 |
+ |
|
|
41 |
+ |
use crate::types::HealthStatus;
|
|
42 |
+ |
|
|
43 |
+ |
/// The `source` name PoM answers to in a viewer's config.
|
|
44 |
+ |
pub const SOURCE: &str = "pom";
|
|
45 |
+ |
|
|
46 |
+ |
/// Days before a certificate expires at which the target goes `degraded`.
|
|
47 |
+ |
const TLS_EXPIRY_WARN_DAYS: i64 = 14;
|
|
48 |
+ |
|
|
49 |
+ |
/// Days before a domain registration lapses at which the target goes `degraded`.
|
|
50 |
+ |
const WHOIS_EXPIRY_WARN_DAYS: i64 = 30;
|
|
51 |
+ |
|
|
52 |
+ |
/// Everything the payload needs about one monitored target, already read out of
|
|
53 |
+ |
/// the database.
|
|
54 |
+ |
///
|
|
55 |
+ |
/// A dedicated view rather than the API's `TargetStatus`: that type is shaped
|
|
56 |
+ |
/// for the existing `/api/status` JSON and carries pre-formatted strings, which
|
|
57 |
+ |
/// is exactly what the shared contract forbids a producer from owning. Building
|
|
58 |
+ |
/// this from typed rows keeps [`payload`] pure and the DB out of the mapping.
|
|
59 |
+ |
pub(crate) struct TargetView {
|
|
60 |
+ |
/// Config key, e.g. "mnw".
|
|
61 |
+ |
pub name: String,
|
|
62 |
+ |
/// Human-readable label, e.g. "MakeNotWork".
|
|
63 |
+ |
pub label: String,
|
|
64 |
+ |
/// The most recent health snapshot. `None` before the first check.
|
|
65 |
+ |
pub health: Option<HealthView>,
|
|
66 |
+ |
/// Uptime percentage over the last 24 hours, if any checks fell in the window.
|
|
67 |
+ |
pub uptime_24h: Option<f64>,
|
|
68 |
+ |
/// Mean response time over the last 24 hours of operational checks, in ms.
|
|
69 |
+ |
pub latency_avg_ms: Option<f64>,
|
|
70 |
+ |
/// Latest TLS check. `None` if TLS is not monitored for this target.
|
|
71 |
+ |
pub tls: Option<TlsView>,
|
|
72 |
+ |
/// Currently open incident, if the target is in one.
|
|
73 |
+ |
pub incident: Option<IncidentView>,
|
|
74 |
+ |
/// Latest WHOIS check. `None` if domain expiry is not monitored.
|
|
75 |
+ |
pub whois: Option<WhoisView>,
|
|
76 |
+ |
/// Latest backup freshness, one per monitored database. Empty if the target
|
|
77 |
+ |
/// has no backup monitoring configured.
|
|
78 |
+ |
pub backups: Vec<BackupView>,
|
|
79 |
+ |
/// Latest scan-pipeline check. `None` if not monitored for this target.
|
|
80 |
+ |
pub scan_pipeline: Option<ScanView>,
|
|
81 |
+ |
}
|
|
82 |
+ |
|
|
83 |
+ |
pub(crate) struct HealthView {
|
|
84 |
+ |
pub status: HealthStatus,
|
|
85 |
+ |
pub checked_at: String,
|
|
86 |
+ |
pub version: Option<String>,
|
|
87 |
+ |
/// The failure reason when the check did not pass.
|
|
88 |
+ |
pub error: Option<String>,
|
|
89 |
+ |
}
|
|
90 |
+ |
|
|
91 |
+ |
pub(crate) struct TlsView {
|
|
92 |
+ |
pub valid: bool,
|
|
93 |
+ |
pub days_remaining: i64,
|
|
94 |
+ |
pub checked_at: String,
|
|
95 |
+ |
pub error: Option<String>,
|
|
96 |
+ |
}
|
|
97 |
+ |
|
|
98 |
+ |
pub(crate) struct IncidentView {
|
|
99 |
+ |
pub from_status: String,
|
|
100 |
+ |
pub to_status: String,
|
|
101 |
+ |
pub started_at: String,
|
|
102 |
+ |
}
|
|
103 |
+ |
|
|
104 |
+ |
pub(crate) struct WhoisView {
|
|
105 |
+ |
pub days_remaining: Option<i64>,
|
|
106 |
+ |
pub checked_at: String,
|
|
107 |
+ |
pub error: Option<String>,
|
|
108 |
+ |
}
|
|
109 |
+ |
|
|
110 |
+ |
pub(crate) struct BackupView {
|
|
111 |
+ |
pub database: String,
|
|
112 |
+ |
/// One of "ok", "stale", "missing", "error".
|
|
113 |
+ |
pub status: String,
|
|
114 |
+ |
pub age_hours: Option<i64>,
|
|
115 |
+ |
pub checked_at: String,
|
|
116 |
+ |
pub error: Option<String>,
|
|
117 |
+ |
}
|
|
118 |
+ |
|
|
119 |
+ |
pub(crate) struct ScanView {
|
|
120 |
+ |
/// One of "operational", "degraded", "unreachable".
|
|
121 |
+ |
pub status: String,
|
|
122 |
+ |
/// Fired-threshold issue lines, the why behind a non-operational status.
|
|
123 |
+ |
pub issues: Vec<String>,
|
|
124 |
+ |
pub checked_at: String,
|
|
125 |
+ |
pub error: Option<String>,
|
|
126 |
+ |
}
|
|
127 |
+ |
|
|
128 |
+ |
/// Restate every monitored target as the shared payload.
|
|
129 |
+ |
///
|
|
130 |
+ |
/// `now` is an argument rather than read from the clock so the mapping stays
|
|
131 |
+ |
/// pure and snapshot-testable.
|
|
132 |
+ |
pub(crate) fn payload(targets: &[TargetView], now: DateTime<Utc>) -> Payload {
|
|
133 |
+ |
let mut payload = Payload::new(SOURCE, now);
|
|
134 |
+ |
for target in targets {
|
|
135 |
+ |
payload.nodes.push(target_node(target));
|
|
136 |
+ |
}
|
|
137 |
+ |
payload
|
|
138 |
+ |
}
|
|
139 |
+ |
|
|
140 |
+ |
fn target_node(target: &TargetView) -> Node {
|
|
141 |
+ |
let mut conditions = vec![health_condition(target.health.as_ref())];
|
|
142 |
+ |
if let Some(incident) = &target.incident {
|
|
143 |
+ |
conditions.push(incident_condition(incident));
|
|
144 |
+ |
}
|
|
145 |
+ |
if let Some(tls) = &target.tls {
|
|
146 |
+ |
conditions.push(tls_condition(tls));
|
|
147 |
+ |
}
|
|
148 |
+ |
if let Some(whois) = &target.whois
|
|
149 |
+ |
&& let Some(condition) = whois_condition(whois)
|
|
150 |
+ |
{
|
|
151 |
+ |
conditions.push(condition);
|
|
152 |
+ |
}
|
|
153 |
+ |
for backup in &target.backups {
|
|
154 |
+ |
conditions.push(backup_condition(backup));
|
|
155 |
+ |
}
|
|
156 |
+ |
if let Some(scan) = &target.scan_pipeline {
|
|
157 |
+ |
conditions.push(scan_condition(scan));
|
|
158 |
+ |
}
|
|
159 |
+ |
|
|
160 |
+ |
// The target's status is the worst thing said about it. There is always at
|
|
161 |
+ |
// least the health condition, so the max is well-defined.
|
|
162 |
+ |
let status = conditions
|
|
163 |
+ |
.iter()
|
|
164 |
+ |
.map(|c| c.status)
|
|
165 |
+ |
.max()
|
|
166 |
+ |
.unwrap_or(Status::Unknown);
|
|
167 |
+ |
|
|
168 |
+ |
Node {
|
|
169 |
+ |
id: format!("target:{}", target.name),
|
|
170 |
+ |
kind: "target".into(),
|
|
171 |
+ |
label: target.label.clone(),
|
|
172 |
+ |
status,
|
|
173 |
+ |
fields: target_fields(target),
|
|
174 |
+ |
conditions,
|
|
175 |
+ |
children: Vec::new(),
|
|
176 |
+ |
actions: Vec::new(),
|
|
177 |
+ |
}
|
|
178 |
+ |
}
|
|
179 |
+ |
|
|
180 |
+ |
/// A health snapshot's status, or `pending` when the target has never been
|
|
181 |
+ |
/// checked — PoM has evidence of nothing rather than evidence of health.
|
|
182 |
+ |
fn health_condition(health: Option<&HealthView>) -> Condition {
|
|
183 |
+ |
let Some(health) = health else {
|
|
184 |
+ |
return Condition {
|
|
185 |
+ |
condition_type: "health".into(),
|
|
186 |
+ |
status: Status::Pending,
|
|
187 |
+ |
since: None,
|
|
188 |
+ |
detail: Some("no health check recorded yet".into()),
|
|
189 |
+ |
};
|
|
190 |
+ |
};
|
|
191 |
+ |
Condition {
|
|
192 |
+ |
condition_type: "health".into(),
|
|
193 |
+ |
status: health_status(health.status),
|
|
194 |
+ |
since: parse_instant(&health.checked_at),
|
|
195 |
+ |
// The error is the why on a failing check; a passing one needs no words.
|
|
196 |
+ |
detail: health.error.clone(),
|
|
197 |
+ |
}
|
|
198 |
+ |
}
|
|
199 |
+ |
|
|
200 |
+ |
/// PoM's four-value health vocabulary onto the shared five.
|
|
201 |
+ |
///
|
|
202 |
+ |
/// `unreachable` and `error` both map to `failed`: from an operator's chair a
|
|
203 |
+ |
/// target that 5xxs and one that will not answer are the same event — it is
|
|
204 |
+ |
/// down. The distinction is preserved in the health snapshot's own error text.
|
|
205 |
+ |
fn health_status(status: HealthStatus) -> Status {
|
|
206 |
+ |
match status {
|
|
207 |
+ |
HealthStatus::Operational => Status::Ok,
|
|
208 |
+ |
HealthStatus::Degraded => Status::Degraded,
|
|
209 |
+ |
HealthStatus::Error | HealthStatus::Unreachable => Status::Failed,
|
|
210 |
+ |
}
|
|
211 |
+ |
}
|
|
212 |
+ |
|
|
213 |
+ |
/// An open incident is reported at the severity it escalated *to*, carrying when
|
|
214 |
+ |
/// it started so the viewer can age it.
|
|
215 |
+ |
fn incident_condition(incident: &IncidentView) -> Condition {
|
|
216 |
+ |
Condition {
|
|
217 |
+ |
condition_type: "incident".into(),
|
|
218 |
+ |
status: health_status_from_str(&incident.to_status),
|
|
219 |
+ |
since: parse_instant(&incident.started_at),
|
|
220 |
+ |
detail: Some(format!(
|
|
221 |
+ |
"{} to {}",
|
|
222 |
+ |
incident.from_status, incident.to_status
|
|
223 |
+ |
)),
|
|
224 |
+ |
}
|
|
225 |
+ |
}
|
|
226 |
+ |
|
|
227 |
+ |
/// Incident rows store the health status as a string. An unrecognized value is
|
|
228 |
+ |
/// `unknown` rather than a parse failure, keeping the node legible against a
|
|
229 |
+ |
/// future status PoM learns before the viewer does.
|
|
230 |
+ |
fn health_status_from_str(raw: &str) -> Status {
|
|
231 |
+ |
raw.parse::<HealthStatus>()
|
|
232 |
+ |
.map(health_status)
|
|
233 |
+ |
.unwrap_or(Status::Unknown)
|
|
234 |
+ |
}
|
|
235 |
+ |
|
|
236 |
+ |
/// TLS state as a condition. A failed probe or an invalid chain is `failed`; a
|
|
237 |
+ |
/// certificate inside the warning window is `degraded`; anything further out is
|
|
238 |
+ |
/// `ok` and still carries its days-remaining, since the number is the point.
|
|
239 |
+ |
fn tls_condition(tls: &TlsView) -> Condition {
|
|
240 |
+ |
let (status, detail) = if let Some(error) = &tls.error {
|
|
241 |
+ |
(Status::Failed, format!("tls check failed: {error}"))
|
|
242 |
+ |
} else if !tls.valid {
|
|
243 |
+ |
(Status::Failed, "certificate chain is not valid".into())
|
|
244 |
+ |
} else if tls.days_remaining < 0 {
|
|
245 |
+ |
(
|
|
246 |
+ |
Status::Failed,
|
|
247 |
+ |
format!("certificate expired {} days ago", -tls.days_remaining),
|
|
248 |
+ |
)
|
|
249 |
+ |
} else if tls.days_remaining <= TLS_EXPIRY_WARN_DAYS {
|
|
250 |
+ |
(
|
|
251 |
+ |
Status::Degraded,
|
|
252 |
+ |
format!("certificate expires in {} days", tls.days_remaining),
|
|
253 |
+ |
)
|
|
254 |
+ |
} else {
|
|
255 |
+ |
(
|
|
256 |
+ |
Status::Ok,
|
|
257 |
+ |
format!("certificate valid, {} days remaining", tls.days_remaining),
|
|
258 |
+ |
)
|
|
259 |
+ |
};
|
|
260 |
+ |
Condition {
|
|
261 |
+ |
condition_type: "tls".into(),
|
|
262 |
+ |
status,
|
|
263 |
+ |
since: parse_instant(&tls.checked_at),
|
|
264 |
+ |
detail: Some(detail),
|
|
265 |
+ |
}
|
|
266 |
+ |
}
|
|
267 |
+ |
|
|
268 |
+ |
/// Domain-expiry state as a condition, or `None` when there is nothing to say.
|
|
269 |
+ |
///
|
|
270 |
+ |
/// A WHOIS lookup that failed is `degraded`, not `failed`: registrar WHOIS is
|
|
271 |
+ |
/// flaky and a lookup error is not evidence the domain lapsed. Absent both an
|
|
272 |
+ |
/// error and a days count there is genuinely no signal, so no condition is
|
|
273 |
+ |
/// emitted rather than a permanently `unknown` one that trains the eye to skip
|
|
274 |
+ |
/// the target.
|
|
275 |
+ |
fn whois_condition(whois: &WhoisView) -> Option<Condition> {
|
|
276 |
+ |
let (status, detail) = if let Some(error) = &whois.error {
|
|
277 |
+ |
(Status::Degraded, format!("whois lookup failed: {error}"))
|
|
278 |
+ |
} else {
|
|
279 |
+ |
match whois.days_remaining {
|
|
280 |
+ |
Some(days) if days < 0 => (
|
|
281 |
+ |
Status::Failed,
|
|
282 |
+ |
format!("domain registration expired {} days ago", -days),
|
|
283 |
+ |
),
|
|
284 |
+ |
Some(days) if days <= WHOIS_EXPIRY_WARN_DAYS => (
|
|
285 |
+ |
Status::Degraded,
|
|
286 |
+ |
format!("domain registration expires in {days} days"),
|
|
287 |
+ |
),
|
|
288 |
+ |
Some(days) => (
|
|
289 |
+ |
Status::Ok,
|
|
290 |
+ |
format!("domain registration valid, {days} days remaining"),
|
|
291 |
+ |
),
|
|
292 |
+ |
None => return None,
|
|
293 |
+ |
}
|
|
294 |
+ |
};
|
|
295 |
+ |
Some(Condition {
|
|
296 |
+ |
condition_type: "whois".into(),
|
|
297 |
+ |
status,
|
|
298 |
+ |
since: parse_instant(&whois.checked_at),
|
|
299 |
+ |
detail: Some(detail),
|
|
300 |
+ |
})
|
|
301 |
+ |
}
|
|
302 |
+ |
|
|
303 |
+ |
/// Backup freshness as a condition, one per database. A stale backup or a check
|
|
304 |
+ |
/// error is `degraded`; a missing backup is `failed`. The 40-day-stale backup
|
|
305 |
+ |
/// that stayed green by every check that existed is exactly the signal this
|
|
306 |
+ |
/// surfaces — the `type` names the database so several read as distinct rows.
|
|
307 |
+ |
fn backup_condition(backup: &BackupView) -> Condition {
|
|
308 |
+ |
let db = &backup.database;
|
|
309 |
+ |
let (status, detail) = match backup.status.as_str() {
|
|
310 |
+ |
"ok" => (
|
|
311 |
+ |
Status::Ok,
|
|
312 |
+ |
match backup.age_hours {
|
|
313 |
+ |
Some(hours) => format!("{db} backup is {hours}h old"),
|
|
314 |
+ |
None => format!("{db} backup present"),
|
|
315 |
+ |
},
|
|
316 |
+ |
),
|
|
317 |
+ |
"stale" => (
|
|
318 |
+ |
Status::Degraded,
|
|
319 |
+ |
match backup.age_hours {
|
|
320 |
+ |
Some(hours) => format!("{db} backup is stale, {hours}h old"),
|
|
321 |
+ |
None => format!("{db} backup is stale"),
|
|
322 |
+ |
},
|
|
323 |
+ |
),
|
|
324 |
+ |
"missing" => (Status::Failed, format!("no backup found for {db}")),
|
|
325 |
+ |
"error" => (
|
|
326 |
+ |
Status::Degraded,
|
|
327 |
+ |
match &backup.error {
|
|
328 |
+ |
Some(error) => format!("{db} backup check failed: {error}"),
|
|
329 |
+ |
None => format!("{db} backup check failed"),
|
|
330 |
+ |
},
|
|
331 |
+ |
),
|
|
332 |
+ |
other => (Status::Unknown, format!("{db} backup status {other:?}")),
|
|
333 |
+ |
};
|
|
334 |
+ |
Condition {
|
|
335 |
+ |
condition_type: format!("backup:{db}"),
|
|
336 |
+ |
status,
|
|
337 |
+ |
since: parse_instant(&backup.checked_at),
|
|
338 |
+ |
detail: Some(detail),
|
|
339 |
+ |
}
|
|
340 |
+ |
}
|
|
341 |
+ |
|
|
342 |
+ |
/// Scan-pipeline state as a condition. `operational` is ok, `degraded` is
|
|
343 |
+ |
/// degraded, `unreachable` is failed; the fired-threshold issues, or the probe
|
|
344 |
+ |
/// error, are the why.
|
|
345 |
+ |
fn scan_condition(scan: &ScanView) -> Condition {
|
|
346 |
+ |
let status = match scan.status.as_str() {
|
|
347 |
+ |
"operational" => Status::Ok,
|
|
348 |
+ |
"degraded" => Status::Degraded,
|
|
349 |
+ |
"unreachable" => Status::Failed,
|
|
350 |
+ |
_ => Status::Unknown,
|
|
351 |
+ |
};
|
|
352 |
+ |
let detail = if let Some(error) = &scan.error {
|
|
353 |
+ |
format!("unreachable: {error}")
|
|
354 |
+ |
} else if !scan.issues.is_empty() {
|
|
355 |
+ |
scan.issues.join("; ")
|
|
356 |
+ |
} else {
|
|
357 |
+ |
"pipeline operational".into()
|
|
358 |
+ |
};
|
|
359 |
+ |
Condition {
|
|
360 |
+ |
condition_type: "scan_pipeline".into(),
|
|
361 |
+ |
status,
|
|
362 |
+ |
since: parse_instant(&scan.checked_at),
|
|
363 |
+ |
detail: Some(detail),
|
|
364 |
+ |
}
|
|
365 |
+ |
}
|
|
366 |
+ |
|
|
367 |
+ |
/// Uptime as a bar, latency as a magnitude, version as a comparable — each a
|
|
368 |
+ |
/// number that *means* something rather than a pre-rendered string.
|
|
369 |
+ |
fn target_fields(target: &TargetView) -> Vec<Field> {
|
|
370 |
+ |
let mut fields = Vec::new();
|
|
371 |
+ |
|
|
372 |
+ |
if let Some(version) = target.health.as_ref().and_then(|h| h.version.clone()) {
|
|
373 |
+ |
fields.push(Field::new("version", Value::Version { value: version }));
|
|
374 |
+ |
}
|
|
375 |
+ |
if let Some(uptime) = target.uptime_24h {
|
|
376 |
+ |
fields.push(Field::new(
|
|
377 |
+ |
"uptime 24h",
|
|
378 |
+ |
Value::Progress {
|
|
379 |
+ |
value: uptime,
|
|
380 |
+ |
max: 100.0,
|
|
381 |
+ |
unit: Some("%".into()),
|
|
382 |
+ |
},
|
|
383 |
+ |
));
|
|
384 |
+ |
}
|
|
385 |
+ |
if let Some(latency) = target.latency_avg_ms {
|
|
386 |
+ |
fields.push(Field::new(
|
|
387 |
+ |
"latency 24h",
|
|
388 |
+ |
Value::Quantity {
|
|
389 |
+ |
value: latency,
|
|
390 |
+ |
unit: Some("ms".into()),
|
|
391 |
+ |
},
|
|
392 |
+ |
));
|
|
393 |
+ |
}
|
|
394 |
+ |
if let Some(checked) = target
|
|
395 |
+ |
.health
|
|
396 |
+ |
.as_ref()
|
|
397 |
+ |
.and_then(|h| parse_instant(&h.checked_at))
|
|
398 |
+ |
{
|
|
399 |
+ |
fields.push(Field::new("checked", Value::Instant { value: checked }));
|
|
400 |
+ |
}
|
|
401 |
+ |
|
|
402 |
+ |
fields
|
|
403 |
+ |
}
|
|
404 |
+ |
|
|
405 |
+ |
/// A timestamp that fails to parse costs only itself. A viewer that blanks on
|
|
406 |
+ |
/// one malformed row is worse than one missing a tooltip.
|
|
407 |
+ |
fn parse_instant(raw: &str) -> Option<DateTime<Utc>> {
|
|
408 |
+ |
DateTime::parse_from_rfc3339(raw)
|
|
409 |
+ |
.ok()
|
|
410 |
+ |
.map(|d| d.with_timezone(&Utc))
|
|
411 |
+ |
}
|
|
412 |
+ |
|
|
413 |
+ |
#[cfg(test)]
|
|
414 |
+ |
mod tests {
|
|
415 |
+ |
use super::*;
|
|
416 |
+ |
|
|
417 |
+ |
fn now() -> DateTime<Utc> {
|
|
418 |
+ |
"2026-07-21T18:24:39Z".parse().unwrap()
|
|
419 |
+ |
}
|
|
420 |
+ |
|
|
421 |
+ |
fn checked_at() -> String {
|
|
422 |
+ |
"2026-07-21T18:24:00Z".into()
|
|
423 |
+ |
}
|
|
424 |
+ |
|
|
425 |
+ |
fn healthy(name: &str) -> TargetView {
|
|
426 |
+ |
TargetView {
|
|
427 |
+ |
name: name.into(),
|
|
428 |
+ |
label: name.to_uppercase(),
|
|
429 |
+ |
health: Some(HealthView {
|
|
430 |
+ |
status: HealthStatus::Operational,
|
|
431 |
+ |
checked_at: checked_at(),
|
|
432 |
+ |
version: Some("1.4.0".into()),
|
|
433 |
+ |
error: None,
|
|
434 |
+ |
}),
|
|
435 |
+ |
uptime_24h: Some(100.0),
|
|
436 |
+ |
latency_avg_ms: Some(42.0),
|
|
437 |
+ |
tls: None,
|
|
438 |
+ |
incident: None,
|
|
439 |
+ |
whois: None,
|
|
440 |
+ |
backups: Vec::new(),
|
|
441 |
+ |
scan_pipeline: None,
|
|
442 |
+ |
}
|
|
443 |
+ |
}
|
|
444 |
+ |
|
|
445 |
+ |
fn node<'a>(p: &'a Payload, id: &str) -> &'a Node {
|
|
446 |
+ |
p.node(id).unwrap_or_else(|| panic!("no node {id}"))
|
|
447 |
+ |
}
|
|
448 |
+ |
|
|
449 |
+ |
#[test]
|
|
450 |
+ |
fn a_healthy_target_is_ok_and_structurally_sound() {
|
|
451 |
+ |
let p = payload(&[healthy("mnw")], now());
|
|
452 |
+ |
|
|
453 |
+ |
assert_eq!(p.source, SOURCE);
|
|
454 |
+ |
assert_eq!(p.schema, ops_status::SCHEMA_VERSION);
|
|
455 |
+ |
assert_eq!(p.validate(), Ok(()));
|
|
456 |
+ |
assert_eq!(node(&p, "target:mnw").status, Status::Ok);
|
|
457 |
+ |
assert_eq!(node(&p, "target:mnw").label, "MNW");
|
|
458 |
+ |
assert_eq!(p.worst_status(), Status::Ok);
|
|
459 |
+ |
}
|
|
460 |
+ |
|
|
461 |
+ |
#[test]
|
|
462 |
+ |
fn uptime_and_latency_are_typed_values_not_strings() {
|
|
463 |
+ |
let p = payload(&[healthy("mnw")], now());
|
|
464 |
+ |
let n = node(&p, "target:mnw");
|
|
465 |
+ |
|
|
466 |
+ |
let uptime = n.fields.iter().find(|f| f.label == "uptime 24h").unwrap();
|
|
467 |
+ |
assert_eq!(
|
|
468 |
+ |
uptime.value,
|
|
469 |
+ |
Value::Progress {
|
|
470 |
+ |
value: 100.0,
|
|
471 |
+ |
max: 100.0,
|
|
472 |
+ |
unit: Some("%".into())
|
|
473 |
+ |
}
|
|
474 |
+ |
);
|
|
475 |
+ |
let latency = n.fields.iter().find(|f| f.label == "latency 24h").unwrap();
|
|
476 |
+ |
assert_eq!(
|
|
477 |
+ |
latency.value,
|
|
478 |
+ |
Value::Quantity {
|
|
479 |
+ |
value: 42.0,
|
|
480 |
+ |
unit: Some("ms".into())
|
|
481 |
+ |
}
|
|
482 |
+ |
);
|
|
483 |
+ |
}
|
|
484 |
+ |
|
|
485 |
+ |
#[test]
|
|
486 |
+ |
fn an_unreachable_target_is_failed_and_says_why() {
|
|
487 |
+ |
let mut t = healthy("mnw");
|
|
488 |
+ |
t.health = Some(HealthView {
|
|
489 |
+ |
status: HealthStatus::Unreachable,
|
|
490 |
+ |
checked_at: checked_at(),
|
|
491 |
+ |
version: None,
|
|
492 |
+ |
error: Some("connection timed out".into()),
|
|
493 |
+ |
});
|
|
494 |
+ |
let p = payload(&[t], now());
|
|
495 |
+ |
|
|
496 |
+ |
let n = node(&p, "target:mnw");
|
|
497 |
+ |
assert_eq!(n.status, Status::Failed);
|
|
498 |
+ |
assert_eq!(n.conditions[0].condition_type, "health");
|
|
499 |
+ |
assert_eq!(
|
|
500 |
+ |
n.conditions[0].detail.as_deref(),
|