|
1 |
+ |
//! The status payload contract every monitored service emits and the release
|
|
2 |
+ |
//! viewer renders.
|
|
3 |
+ |
//!
|
|
4 |
+ |
//! Spec + rationale: maintainer wiki.
|
|
5 |
+ |
//! <!-- wiki: release-status-payload -->
|
|
6 |
+ |
//!
|
|
7 |
+ |
//! # Producers describe meaning, the renderer decides appearance
|
|
8 |
+ |
//!
|
|
9 |
+ |
//! A producer says what a value *means*; the renderer decides what it *looks
|
|
10 |
+ |
//! like*. That split is the whole point of the crate, and it is enforced by the
|
|
11 |
+ |
//! type system rather than by discipline: there is nowhere in [`Value`] to put
|
|
12 |
+ |
//! a pre-formatted string, so a producer cannot own presentation even by
|
|
13 |
+ |
//! accident.
|
|
14 |
+ |
//!
|
|
15 |
+ |
//! ```text
|
|
16 |
+ |
//! {"label": "burn-in", "value": "31h of 48h"} // no
|
|
17 |
+ |
//! {"label": "burn-in", "kind": "progress", "value": 31, "max": 48} // yes
|
|
18 |
+ |
//! ```
|
|
19 |
+ |
//!
|
|
20 |
+ |
//! The second form renders as a bar, colored against the same thresholds every
|
|
21 |
+ |
//! other progress value in the UI uses, and it is sortable and filterable,
|
|
22 |
+ |
//! which a pre-formatted string never is.
|
|
23 |
+ |
//!
|
|
24 |
+ |
//! # Version skew is expected
|
|
25 |
+ |
//!
|
|
26 |
+ |
//! Producers and the viewer are separate binaries deployed at different times.
|
|
27 |
+ |
//! Two fallbacks keep a skewed pair working instead of erroring:
|
|
28 |
+ |
//!
|
|
29 |
+ |
//! - an unrecognized [`Value`] kind deserializes to [`Value::Text`]
|
|
30 |
+ |
//! - an unrecognized [`Status`] deserializes to [`Status::Unknown`]
|
|
31 |
+ |
//!
|
|
32 |
+ |
//! Depending on this crate from both ends catches drift when things are built
|
|
33 |
+ |
//! together; [`SCHEMA_VERSION`] plus those fallbacks cover the window when they
|
|
34 |
+ |
//! are not.
|
|
35 |
+ |
//!
|
|
36 |
+ |
//! # Example
|
|
37 |
+ |
//!
|
|
38 |
+ |
//! ```
|
|
39 |
+ |
//! use ops_status::{Payload, Status};
|
|
40 |
+ |
//!
|
|
41 |
+ |
//! let json = r#"{
|
|
42 |
+ |
//! "schema": 1,
|
|
43 |
+ |
//! "source": "sando",
|
|
44 |
+ |
//! "generated_at": "2026-07-21T18:24:39Z",
|
|
45 |
+ |
//! "nodes": [
|
|
46 |
+ |
//! {
|
|
47 |
+ |
//! "id": "tier:b",
|
|
48 |
+ |
//! "kind": "tier",
|
|
49 |
+ |
//! "label": "b (prod-1)",
|
|
50 |
+ |
//! "status": "degraded",
|
|
51 |
+ |
//! "fields": [{"label": "version", "kind": "version", "value": "0.10.14"}],
|
|
52 |
+ |
//! "conditions": [{"type": "burn_in", "status": "pending", "detail": "31h of 48h"}]
|
|
53 |
+ |
//! }
|
|
54 |
+ |
//! ]
|
|
55 |
+ |
//! }"#;
|
|
56 |
+ |
//!
|
|
57 |
+ |
//! let payload: Payload = serde_json::from_str(json).unwrap();
|
|
58 |
+ |
//! assert_eq!(payload.worst_status(), Status::Degraded);
|
|
59 |
+ |
//! ```
|
|
60 |
+ |
|
|
61 |
+ |
use std::collections::BTreeMap;
|
|
62 |
+ |
|
|
63 |
+ |
use chrono::{DateTime, TimeDelta, Utc};
|
|
64 |
+ |
use serde::de::{self, Deserializer};
|
|
65 |
+ |
use serde::{Deserialize, Serialize};
|
|
66 |
+ |
|
|
67 |
+ |
/// The wire version this crate speaks. A producer stamps it into
|
|
68 |
+ |
/// [`Payload::schema`]; a viewer compares it against its own to detect skew.
|
|
69 |
+ |
pub const SCHEMA_VERSION: u32 = 1;
|
|
70 |
+ |
|
|
71 |
+ |
// ---------------------------------------------------------------------------
|
|
72 |
+ |
// Status
|
|
73 |
+ |
// ---------------------------------------------------------------------------
|
|
74 |
+ |
|
|
75 |
+ |
/// The closed status vocabulary, shared by nodes, conditions, and
|
|
76 |
+ |
/// [`Value::State`].
|
|
77 |
+ |
///
|
|
78 |
+ |
/// Consistent color is most of what the viewer is for, so there is exactly one
|
|
79 |
+ |
/// vocabulary and producers cannot extend it. Anything unrecognized becomes
|
|
80 |
+ |
/// [`Status::Unknown`] rather than failing the parse.
|
|
81 |
+ |
///
|
|
82 |
+ |
/// `pass` and `fail` are accepted as aliases for [`Status::Ok`] and
|
|
83 |
+ |
/// [`Status::Failed`], since condition-shaped producers reach for those words.
|
|
84 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)]
|
|
85 |
+ |
#[serde(rename_all = "snake_case")]
|
|
86 |
+ |
pub enum Status {
|
|
87 |
+ |
Ok,
|
|
88 |
+ |
Degraded,
|
|
89 |
+ |
Failed,
|
|
90 |
+ |
Pending,
|
|
91 |
+ |
/// Also the default: nothing heard from is not the same as healthy.
|
|
92 |
+ |
#[default]
|
|
93 |
+ |
Unknown,
|
|
94 |
+ |
}
|
|
95 |
+ |
|
|
96 |
+ |
impl Status {
|
|
97 |
+ |
/// How loudly this status should be shown, ascending.
|
|
98 |
+ |
///
|
|
99 |
+ |
/// [`Status::Unknown`] outranks [`Status::Degraded`] deliberately: a source
|
|
100 |
+ |
/// that cannot be reached is as important as one reporting a failure. A
|
|
101 |
+ |
/// silent gap is the failure mode this whole contract exists to close.
|
|
102 |
+ |
pub fn severity(self) -> u8 {
|
|
103 |
+ |
match self {
|
|
104 |
+ |
Status::Ok => 0,
|
|
105 |
+ |
Status::Pending => 1,
|
|
106 |
+ |
Status::Degraded => 2,
|
|
107 |
+ |
Status::Unknown => 3,
|
|
108 |
+ |
Status::Failed => 4,
|
|
109 |
+ |
}
|
|
110 |
+ |
}
|
|
111 |
+ |
|
|
112 |
+ |
/// The wire spelling.
|
|
113 |
+ |
pub fn as_str(self) -> &'static str {
|
|
114 |
+ |
match self {
|
|
115 |
+ |
Status::Ok => "ok",
|
|
116 |
+ |
Status::Degraded => "degraded",
|
|
117 |
+ |
Status::Failed => "failed",
|
|
118 |
+ |
Status::Pending => "pending",
|
|
119 |
+ |
Status::Unknown => "unknown",
|
|
120 |
+ |
}
|
|
121 |
+ |
}
|
|
122 |
+ |
}
|
|
123 |
+ |
|
|
124 |
+ |
/// Orders by [`Status::severity`], so `iter().max()` is "worst status".
|
|
125 |
+ |
impl Ord for Status {
|
|
126 |
+ |
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
|
127 |
+ |
self.severity().cmp(&other.severity())
|
|
128 |
+ |
}
|
|
129 |
+ |
}
|
|
130 |
+ |
|
|
131 |
+ |
impl PartialOrd for Status {
|
|
132 |
+ |
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
|
133 |
+ |
Some(self.cmp(other))
|
|
134 |
+ |
}
|
|
135 |
+ |
}
|
|
136 |
+ |
|
|
137 |
+ |
impl<'de> Deserialize<'de> for Status {
|
|
138 |
+ |
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
|
139 |
+ |
// Hand-written rather than derived: `#[serde(other)]` is not allowed on
|
|
140 |
+ |
// an externally tagged enum, and the unknown-value fallback is the
|
|
141 |
+ |
// point.
|
|
142 |
+ |
Ok(match String::deserialize(d)?.as_str() {
|
|
143 |
+ |
"ok" | "pass" => Status::Ok,
|
|
144 |
+ |
"degraded" => Status::Degraded,
|
|
145 |
+ |
"failed" | "fail" => Status::Failed,
|
|
146 |
+ |
"pending" => Status::Pending,
|
|
147 |
+ |
_ => Status::Unknown,
|
|
148 |
+ |
})
|
|
149 |
+ |
}
|
|
150 |
+ |
}
|
|
151 |
+ |
|
|
152 |
+ |
// ---------------------------------------------------------------------------
|
|
153 |
+ |
// Values
|
|
154 |
+ |
// ---------------------------------------------------------------------------
|
|
155 |
+ |
|
|
156 |
+ |
/// The ten value kinds. The spec's worth is entirely in staying short.
|
|
157 |
+ |
///
|
|
158 |
+ |
/// Each variant says what a number or string *is*, never how to draw it.
|
|
159 |
+ |
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
160 |
+ |
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
161 |
+ |
pub enum Value {
|
|
162 |
+ |
/// A plain string with no further meaning. Also the landing spot for a kind
|
|
163 |
+ |
/// this build does not recognize.
|
|
164 |
+ |
Text { value: String },
|
|
165 |
+ |
/// An opaque identifier: a digest, a git sha. Rendered monospace and
|
|
166 |
+ |
/// truncated at `abbrev_to` characters.
|
|
167 |
+ |
Ident {
|
|
168 |
+ |
value: String,
|
|
169 |
+ |
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
170 |
+ |
abbrev_to: Option<usize>,
|
|
171 |
+ |
},
|
|
172 |
+ |
/// A semver string, comparable against other versions.
|
|
173 |
+ |
Version { value: String },
|
|
174 |
+ |
/// A point in time. Rendered relative ("3m ago"), absolute on focus.
|
|
175 |
+ |
Instant { value: DateTime<Utc> },
|
|
176 |
+ |
/// An elapsed or remaining span, humanized.
|
|
177 |
+ |
Duration { seconds: i64 },
|
|
178 |
+ |
/// Progress toward a target. Rendered as a bar.
|
|
179 |
+ |
Progress {
|
|
180 |
+ |
value: f64,
|
|
181 |
+ |
max: f64,
|
|
182 |
+ |
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
183 |
+ |
unit: Option<String>,
|
|
184 |
+ |
},
|
|
185 |
+ |
/// A magnitude with a unit, humanized (43.5 MB, 1.2k).
|
|
186 |
+ |
Quantity {
|
|
187 |
+ |
value: f64,
|
|
188 |
+ |
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
189 |
+ |
unit: Option<String>,
|
|
190 |
+ |
},
|
|
191 |
+ |
/// A status in field position. Rendered as the color alone.
|
|
192 |
+ |
State { value: Status },
|
|
193 |
+ |
/// Somewhere to go. Rendered as an actionable control.
|
|
194 |
+ |
///
|
|
195 |
+ |
/// The anchor text is `text`, not `label`: a [`Field`] already owns `label`
|
|
196 |
+ |
/// and these serialize into the same object, so the two would collide and
|
|
197 |
+ |
/// the link's would lose.
|
|
198 |
+ |
Link {
|
|
199 |
+ |
url: String,
|
|
200 |
+ |
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
201 |
+ |
text: Option<String>,
|
|
202 |
+ |
},
|
|
203 |
+ |
/// A filesystem path. Rendered monospace, middle-elided.
|
|
204 |
+ |
Path { value: String },
|
|
205 |
+ |
}
|
|
206 |
+ |
|
|
207 |
+ |
/// Every kind this build understands. Anything else falls back to
|
|
208 |
+ |
/// [`Value::Text`] at parse time.
|
|
209 |
+ |
const KNOWN_KINDS: &[&str] = &[
|
|
210 |
+ |
"text", "ident", "version", "instant", "duration", "progress", "quantity", "state", "link",
|
|
211 |
+ |
"path",
|
|
212 |
+ |
];
|
|
213 |
+ |
|
|
214 |
+ |
impl Value {
|
|
215 |
+ |
/// The wire spelling of this value's kind.
|
|
216 |
+ |
pub fn kind(&self) -> &'static str {
|
|
217 |
+ |
match self {
|
|
218 |
+ |
Value::Text { .. } => "text",
|
|
219 |
+ |
Value::Ident { .. } => "ident",
|
|
220 |
+ |
Value::Version { .. } => "version",
|
|
221 |
+ |
Value::Instant { .. } => "instant",
|
|
222 |
+ |
Value::Duration { .. } => "duration",
|
|
223 |
+ |
Value::Progress { .. } => "progress",
|
|
224 |
+ |
Value::Quantity { .. } => "quantity",
|
|
225 |
+ |
Value::State { .. } => "state",
|
|
226 |
+ |
Value::Link { .. } => "link",
|
|
227 |
+ |
Value::Path { .. } => "path",
|
|
228 |
+ |
}
|
|
229 |
+ |
}
|
|
230 |
+ |
}
|
|
231 |
+ |
|
|
232 |
+ |
/// One labeled value on a node.
|
|
233 |
+ |
///
|
|
234 |
+ |
/// Serializes flat: `{"label": "digest", "kind": "ident", "value": "a3f9..."}`.
|
|
235 |
+ |
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
236 |
+ |
pub struct Field {
|
|
237 |
+ |
pub label: String,
|
|
238 |
+ |
#[serde(flatten)]
|
|
239 |
+ |
pub value: Value,
|
|
240 |
+ |
}
|
|
241 |
+ |
|
|
242 |
+ |
impl Field {
|
|
243 |
+ |
pub fn new(label: impl Into<String>, value: Value) -> Self {
|
|
244 |
+ |
Field {
|
|
245 |
+ |
label: label.into(),
|
|
246 |
+ |
value,
|
|
247 |
+ |
}
|
|
248 |
+ |
}
|
|
249 |
+ |
}
|
|
250 |
+ |
|
|
251 |
+ |
impl<'de> Deserialize<'de> for Field {
|
|
252 |
+ |
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
|
253 |
+ |
// Routed through serde_json rather than derived so that an unrecognized
|
|
254 |
+ |
// `kind` degrades to text instead of failing the whole payload. A
|
|
255 |
+ |
// viewer one release behind a producer must still render everything it
|
|
256 |
+ |
// does understand.
|
|
257 |
+ |
let mut raw = serde_json::Map::<String, serde_json::Value>::deserialize(d)?;
|
|
258 |
+ |
|
|
259 |
+ |
let label = match raw.remove("label") {
|
|
260 |
+ |
Some(serde_json::Value::String(s)) => s,
|
|
261 |
+ |
Some(other) => {
|
|
262 |
+ |
return Err(de::Error::custom(format!(
|
|
263 |
+ |
"label must be a string, got {other}"
|
|
264 |
+ |
)));
|
|
265 |
+ |
}
|
|
266 |
+ |
None => return Err(de::Error::missing_field("label")),
|
|
267 |
+ |
};
|
|
268 |
+ |
|
|
269 |
+ |
let known = raw
|
|
270 |
+ |
.get("kind")
|
|
271 |
+ |
.and_then(serde_json::Value::as_str)
|
|
272 |
+ |
.is_some_and(|k| KNOWN_KINDS.contains(&k));
|
|
273 |
+ |
|
|
274 |
+ |
let value = if known {
|
|
275 |
+ |
serde_json::from_value(serde_json::Value::Object(raw)).map_err(de::Error::custom)?
|
|
276 |
+ |
} else {
|
|
277 |
+ |
Value::Text {
|
|
278 |
+ |
value: unknown_kind_text(&raw),
|
|
279 |
+ |
}
|
|
280 |
+ |
};
|
|
281 |
+ |
|
|
282 |
+ |
Ok(Field { label, value })
|
|
283 |
+ |
}
|
|
284 |
+ |
}
|
|
285 |
+ |
|
|
286 |
+ |
/// Best-effort text for a kind this build does not know: the `value` member if
|
|
287 |
+ |
/// there is one, else the whole object verbatim. Something legible beats a
|
|
288 |
+ |
/// blank cell.
|
|
289 |
+ |
fn unknown_kind_text(raw: &serde_json::Map<String, serde_json::Value>) -> String {
|
|
290 |
+ |
match raw.get("value") {
|
|
291 |
+ |
Some(serde_json::Value::String(s)) => s.clone(),
|
|
292 |
+ |
Some(v) => v.to_string(),
|
|
293 |
+ |
None => serde_json::Value::Object(raw.clone()).to_string(),
|
|
294 |
+ |
}
|
|
295 |
+ |
}
|
|
296 |
+ |
|
|
297 |
+ |
// ---------------------------------------------------------------------------
|
|
298 |
+ |
// Conditions
|
|
299 |
+ |
// ---------------------------------------------------------------------------
|
|
300 |
+ |
|
|
301 |
+ |
/// Why a node is in the status it is in.
|
|
302 |
+ |
///
|
|
303 |
+ |
/// Borrowed from the Kubernetes conditions pattern. This is the part usually
|
|
304 |
+ |
/// omitted and the part that earns its keep: "blocked" is useless, "blocked
|
|
305 |
+ |
/// because burn_in is 31h of 48h" is what saves an SSH.
|
|
306 |
+ |
///
|
|
307 |
+ |
/// `type` is domain vocabulary the viewer never interprets. Sando's gates,
|
|
308 |
+ |
/// Bento's build steps, and PoM's checks all map on without translation.
|
|
309 |
+ |
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
310 |
+ |
pub struct Condition {
|
|
311 |
+ |
#[serde(rename = "type")]
|
|
312 |
+ |
pub condition_type: String,
|
|
313 |
+ |
pub status: Status,
|
|
314 |
+ |
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
315 |
+ |
pub since: Option<DateTime<Utc>>,
|
|
316 |
+ |
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
317 |
+ |
pub detail: Option<String>,
|
|
318 |
+ |
}
|
|
319 |
+ |
|
|
320 |
+ |
// ---------------------------------------------------------------------------
|
|
321 |
+ |
// Nodes
|
|
322 |
+ |
// ---------------------------------------------------------------------------
|
|
323 |
+ |
|
|
324 |
+ |
/// One thing a source reports on: a tier, a host, an app, a build target.
|
|
325 |
+ |
///
|
|
326 |
+ |
/// Children are referenced by id rather than nested. Flat is easier to diff
|
|
327 |
+ |
/// between polls, easier to update incrementally, and keeps the renderer from
|
|
328 |
+ |
/// recursing into something unbounded.
|
|
329 |
+ |
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
330 |
+ |
pub struct Node {
|
|
331 |
+ |
pub id: String,
|
|
332 |
+ |
/// Domain vocabulary ("tier", "node", "app", "target"). Free-form; the
|
|
333 |
+ |
/// viewer may group by it but never branches on it.
|
|
334 |
+ |
pub kind: String,
|
|
335 |
+ |
pub label: String,
|
|
336 |
+ |
pub status: Status,
|
|
337 |
+ |
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
338 |
+ |
pub fields: Vec<Field>,
|
|
339 |
+ |
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
340 |
+ |
pub conditions: Vec<Condition>,
|
|
341 |
+ |
/// Ids of child nodes, which must appear elsewhere in [`Payload::nodes`].
|
|
342 |
+ |
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
343 |
+ |
pub children: Vec<String>,
|
|
344 |
+ |
/// Keys into [`Payload::actions`].
|
|
345 |
+ |
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
346 |
+ |
pub actions: Vec<String>,
|
|
347 |
+ |
}
|
|
348 |
+ |
|
|
349 |
+ |
// ---------------------------------------------------------------------------
|
|
350 |
+ |
// Actions
|
|
351 |
+ |
// ---------------------------------------------------------------------------
|
|
352 |
+ |
|
|
353 |
+ |
/// The HTTP verb an action is invoked with.
|
|
354 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
355 |
+ |
#[serde(rename_all = "UPPERCASE")]
|
|
356 |
+ |
pub enum Method {
|
|
357 |
+ |
Get,
|
|
358 |
+ |
Post,
|
|
359 |
+ |
Put,
|
|
360 |
+ |
Delete,
|
|
361 |
+ |
}
|
|
362 |
+ |
|
|
363 |
+ |
/// Something the operator can do, declared as data.
|
|
364 |
+ |
///
|
|
365 |
+ |
/// This is where a generic viewer usually dies. The moment the shell knows what
|
|
366 |
+ |
/// "promote" means, it is not a shell. So the producer declares label, method,
|
|
367 |
+ |
/// URL, and how dangerous it is; the viewer renders a control and issues the
|
|
368 |
+ |
/// request, never learning domain vocabulary.
|
|
369 |
+ |
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
370 |
+ |
pub struct Action {
|
|
371 |
+ |
pub label: String,
|
|
372 |
+ |
pub method: Method,
|
|
373 |
+ |
/// Resolved against the source's base URL.
|
|
374 |
+ |
pub url: String,
|
|
375 |
+ |
/// Prompt before issuing.
|
|
376 |
+ |
#[serde(default)]
|
|
377 |
+ |
pub confirm: bool,
|
|
378 |
+ |
/// Render as destructive.
|
|
379 |
+ |
#[serde(default)]
|
|
380 |
+ |
pub danger: bool,
|
|
381 |
+ |
/// JSON body to send, verbatim.
|
|
382 |
+ |
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
383 |
+ |
pub body: Option<serde_json::Value>,
|
|
384 |
+ |
}
|
|
385 |
+ |
|
|
386 |
+ |
// ---------------------------------------------------------------------------
|
|
387 |
+ |
// Events
|
|
388 |
+ |
// ---------------------------------------------------------------------------
|
|
389 |
+ |
|
|
390 |
+ |
/// A recent notable moment, newest first. Optional: a source with no event
|
|
391 |
+ |
/// history sends an empty list.
|
|
392 |
+ |
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
393 |
+ |
pub struct Event {
|
|
394 |
+ |
pub at: DateTime<Utc>,
|
|
395 |
+ |
pub label: String,
|
|
396 |
+ |
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
397 |
+ |
pub status: Option<Status>,
|
|
398 |
+ |
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
399 |
+ |
pub detail: Option<String>,
|
|
400 |
+ |
/// The node this concerns, if any.
|
|
401 |
+ |
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
402 |
+ |
pub node_id: Option<String>,
|
|
403 |
+ |
}
|
|
404 |
+ |
|
|
405 |
+ |
// ---------------------------------------------------------------------------
|
|
406 |
+ |
// Payload
|
|
407 |
+ |
// ---------------------------------------------------------------------------
|
|
408 |
+ |
|
|
409 |
+ |
/// What one source serves at `GET /status.json`.
|
|
410 |
+ |
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
411 |
+ |
pub struct Payload {
|
|
412 |
+ |
/// [`SCHEMA_VERSION`] as of the build that produced this.
|
|
413 |
+ |
pub schema: u32,
|
|
414 |
+ |
/// Stable source name ("sando", "bento", "pom").
|
|
415 |
+ |
pub source: String,
|
|
416 |
+ |
/// When this snapshot was taken. A viewer treats a stale value as its own
|
|
417 |
+ |
/// kind of unhealthy: the 40-day-stale backup was green by every check that
|
|
418 |
+ |
/// existed.
|
|
419 |
+ |
pub generated_at: DateTime<Utc>,
|
|
420 |
+ |
#[serde(default)]
|
|
421 |
+ |
pub nodes: Vec<Node>,
|
|
422 |
+ |
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
423 |
+ |
pub events: Vec<Event>,
|
|
424 |
+ |
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
425 |
+ |
pub actions: BTreeMap<String, Action>,
|
|
426 |
+ |
}
|
|
427 |
+ |
|
|
428 |
+ |
impl Payload {
|
|
429 |
+ |
/// An empty payload stamped with the current schema version.
|
|
430 |
+ |
pub fn new(source: impl Into<String>, generated_at: DateTime<Utc>) -> Self {
|
|
431 |
+ |
Payload {
|
|
432 |
+ |
schema: SCHEMA_VERSION,
|
|
433 |
+ |
source: source.into(),
|
|
434 |
+ |
generated_at,
|
|
435 |
+ |
nodes: Vec::new(),
|
|
436 |
+ |
events: Vec::new(),
|
|
437 |
+ |
actions: BTreeMap::new(),
|
|
438 |
+ |
}
|
|
439 |
+ |
}
|
|
440 |
+ |
|
|
441 |
+ |
/// The worst status across every node: this source's line in the rollup.
|
|
442 |
+ |
///
|
|
443 |
+ |
/// A source reporting no nodes at all is [`Status::Unknown`], not healthy.
|
|
444 |
+ |
/// Absence of evidence is the thing that went unnoticed for twenty hours.
|
|
445 |
+ |
pub fn worst_status(&self) -> Status {
|
|
446 |
+ |
self.nodes
|
|
447 |
+ |
.iter()
|
|
448 |
+ |
.map(|n| n.status)
|
|
449 |
+ |
.max()
|
|
450 |
+ |
.unwrap_or(Status::Unknown)
|
|
451 |
+ |
}
|
|
452 |
+ |
|
|
453 |
+ |
/// How old this snapshot is at `now`. Negative if the producer's clock runs
|
|
454 |
+ |
/// ahead.
|
|
455 |
+ |
///
|
|
456 |
+ |
/// The clock is an explicit argument here and everywhere else in the
|
|
457 |
+ |
/// contract: render is a pure function of `(payload, now)`, which is what
|
|
458 |
+ |
/// makes golden-snapshot tests possible.
|
|
459 |
+ |
pub fn age(&self, now: DateTime<Utc>) -> TimeDelta {
|
|
460 |
+ |
now - self.generated_at
|
|
461 |
+ |
}
|
|
462 |
+ |
|
|
463 |
+ |
/// Whether this snapshot is older than `limit` at `now`.
|
|
464 |
+ |
pub fn is_stale(&self, now: DateTime<Utc>, limit: TimeDelta) -> bool {
|
|
465 |
+ |
self.age(now) > limit
|
|
466 |
+ |
}
|
|
467 |
+ |
|
|
468 |
+ |
/// The payload a viewer substitutes for a source it could not reach.
|
|
469 |
+ |
///
|
|
470 |
+ |
/// Unreachability is a status, not a blank tab, and it carries the last
|
|
471 |
+ |
/// time anyone heard from the source.
|
|
472 |
+ |
pub fn unreachable(
|
|
473 |
+ |
source: impl Into<String>,
|
|
474 |
+ |
last_seen: DateTime<Utc>,
|
|
475 |
+ |
why: impl Into<String>,
|
|
476 |
+ |
) -> Self {
|
|
477 |
+ |
let source = source.into();
|
|
478 |
+ |
let mut payload = Payload::new(source.clone(), last_seen);
|
|
479 |
+ |
payload.nodes.push(Node {
|
|
480 |
+ |
id: format!("source:{source}"),
|
|
481 |
+ |
kind: "source".into(),
|
|
482 |
+ |
label: source,
|
|
483 |
+ |
status: Status::Unknown,
|
|
484 |
+ |
fields: Vec::new(),
|
|
485 |
+ |
conditions: vec![Condition {
|
|
486 |
+ |
condition_type: "reachable".into(),
|
|
487 |
+ |
status: Status::Failed,
|
|
488 |
+ |
since: Some(last_seen),
|
|
489 |
+ |
detail: Some(why.into()),
|
|
490 |
+ |
}],
|
|
491 |
+ |
children: Vec::new(),
|
|
492 |
+ |
actions: Vec::new(),
|
|
493 |
+ |
});
|
|
494 |
+ |
payload
|
|
495 |
+ |
}
|
|
496 |
+ |
|
|
497 |
+ |
/// Nodes with no parent, in payload order: where a renderer starts.
|
|
498 |
+ |
pub fn roots(&self) -> impl Iterator<Item = &Node> {
|
|
499 |
+ |
let claimed: std::collections::HashSet<&str> = self
|
|
500 |
+ |
.nodes
|