From 9a4a5332d8b5a917b90425e6bffc755fb3efa71e Mon Sep 17 00:00:00 2001 From: Qijun Date: Sun, 4 Oct 2026 07:10:33 -0700 Subject: [PATCH] Refactor before container CPU: one Sort, shared fixtures, view formatting No behaviour change: every view render-test screen and every API body the server tests fetch are byte-identical before and after (timestamps normalized), except the order of an application's workloads in AppView, now problems first like everywhere else (the view sorts them anyway). - Sort knows its next order, label, sorted column and comparison; the memory sort is no longer wired into six places. - skym_core::fixtures gains app, workload, host_overview and incident: tests set only what they assert on, so a new field needs no test edits. Server integration tests share down() and app_config() in tests/common. - The view's formatting (times, sizes, rates, CPU, a URL's answer) lives in ui/format.rs. - The server builds a workload summary in one place and reuses an app's workloads for AppView; app links come from links(). - App::apps_iter, app_summary and host_summary replace repeated lookups; the filter's text for an application lives in filter.rs. --- crates/core/src/fixtures.rs | 41 ++++++- crates/core/tests/judge.rs | 19 +--- crates/server/src/api/apps.rs | 20 +--- crates/server/src/api/apps_tests.rs | 21 +--- crates/server/src/api/handlers.rs | 13 +-- crates/server/src/api/snapshot.rs | 16 +-- crates/server/src/api/views.rs | 16 +-- crates/server/src/api/views_tests.rs | 16 +-- crates/server/src/config.rs | 15 ++- crates/server/src/findings.rs | 11 +- crates/server/tests/api.rs | 14 +-- crates/server/tests/common/mod.rs | 24 ++++ crates/server/tests/store.rs | 22 +--- crates/view/Cargo.toml | 3 + crates/view/src/app/keys.rs | 12 +- crates/view/src/app/lists.rs | 28 +++-- crates/view/src/app/tests.rs | 84 +++----------- crates/view/src/apps.rs | 95 ++++++++-------- crates/view/src/filter.rs | 25 ++++- crates/view/src/problems_tests.rs | 14 +-- crates/view/src/ui/apps.rs | 36 +++--- crates/view/src/ui/format.rs | 157 +++++++++++++++++++++++++++ crates/view/src/ui/host.rs | 5 +- crates/view/src/ui/hosts.rs | 17 +-- crates/view/src/ui/mod.rs | 49 ++------- crates/view/src/ui/preview.rs | 87 +-------------- crates/view/src/ui/problems.rs | 3 +- crates/view/src/ui/tests.rs | 101 +++-------------- crates/view/src/ui/timeline.rs | 3 +- crates/view/src/ui/workload.rs | 5 +- 30 files changed, 450 insertions(+), 522 deletions(-) create mode 100644 crates/server/tests/common/mod.rs create mode 100644 crates/view/src/ui/format.rs diff --git a/crates/core/src/fixtures.rs b/crates/core/src/fixtures.rs index f8e25d3..639820d 100644 --- a/crates/core/src/fixtures.rs +++ b/crates/core/src/fixtures.rs @@ -1,7 +1,46 @@ -//! The full sample report, `tests/fixtures/report-full.json`, for other crates' tests. +//! Sample data for other crates' tests: the full sample report, +//! `tests/fixtures/report-full.json`, and views with only what identifies them. A test sets +//! what it asserts on (`AppSummary { status: Warn, ..app("x/shop") }`); a field added later +//! takes its default here, so no test has to change for it. use crate::report::Report; +use crate::rules::{IncidentCode, Severity}; +use crate::view::{AppSummary, HostOverview, IncidentView, WorkloadSummary}; +use serde_json::json; pub fn full_report() -> Report { serde_json::from_str(include_str!("../tests/fixtures/report-full.json")).expect("valid fixture") } + +fn from(value: serde_json::Value) -> T { + serde_json::from_value(value).expect("valid fixture") +} + +/// An ok application named after its key's label (`shop` for `x/shop`). +pub fn app(key: &str) -> AppSummary { + let name = key.rsplit('/').next().unwrap_or(key); + from(json!({ "key": key, "name": name, "status": "ok" })) +} + +/// An ok, running workload: `host/project/service`. +pub fn workload(key: &str) -> WorkloadSummary { + let mut parts = key.splitn(3, '/'); + let (host, project, service) = (parts.next(), parts.next(), parts.next()); + from(json!({ + "key": { "host": host, "project": project, "service": service }, + "status": "ok", "run": "running" + })) +} + +/// An ok host that reported a moment ago. +pub fn host_overview(id: &str) -> HostOverview { + from(json!({ "id": id, "status": "ok", "last_report_ago": "5s" })) +} + +/// An open, unmuted incident with no detail and no application. +pub fn incident(subject: &str, code: IncidentCode, severity: Severity) -> IncidentView { + from(json!({ + "subject": subject, "code": code, "severity": severity, "detail": "", + "muted": false + })) +} diff --git a/crates/core/tests/judge.rs b/crates/core/tests/judge.rs index 3908107..c81cbb3 100644 --- a/crates/core/tests/judge.rs +++ b/crates/core/tests/judge.rs @@ -405,22 +405,9 @@ fn a_healthcheck_that_cannot_run_is_hygiene_not_unhealthy() { } fn incident(subject: Subject, severity: Severity, muted: bool) -> IncidentView { - IncidentView { - subject, - code: IncidentCode::DiskFilling, - severity, - detail: String::new(), - opened_at: None, - since: None, - open_for: None, - resolved_at: None, - muted, - mute_reason: None, - links: BTreeMap::new(), - app: None, - observed_since: None, - workload: None, - } + let i = + skym_core::fixtures::incident(&subject.to_string(), IncidentCode::DiskFilling, severity); + IncidentView { muted, ..i } } #[test] diff --git a/crates/server/src/api/apps.rs b/crates/server/src/api/apps.rs index ee5ba77..6252370 100644 --- a/crates/server/src/api/apps.rs +++ b/crates/server/src/api/apps.rs @@ -1,14 +1,14 @@ //! Applications: workloads grouped by application, described by the configuration. Pure. -use super::views::{links, status}; +use super::views::{self, links, status}; use crate::config::{AppConfig, HostEntry}; use crate::store::hosts::WorkloadRow; use jiff::Timestamp; use skym_core::model::{Event, EventKind, RunState}; use skym_core::rules::IncidentCode; -use skym_core::subject::{AppKey, Subject, WorkloadKey, encode}; +use skym_core::subject::{AppKey, Subject, WorkloadKey}; use skym_core::view::{ - AppSummary, Deploy, EndpointOverview, IncidentView, Status, WorkloadSummary, workload_summary, + AppSummary, Deploy, EndpointOverview, IncidentView, Status, WorkloadSummary, }; use std::collections::BTreeMap; @@ -107,23 +107,15 @@ fn summary( tags, endpoints, incidents, - links: BTreeMap::from([ - ("app".to_string(), key.path()), - ("host".to_string(), format!("/api/hosts/{}", encode(&key.host))), - ]), + links: links(&Subject::App(key.clone())), key, } } /// Its workloads with their own status, those with problems first. fn workload_summaries(rows: &[&WorkloadRow], incidents: &[IncidentView]) -> Vec { - let mut list: Vec = rows - .iter() - .map(|w| { - let links = links(&Subject::Workload(w.key.clone())); - workload_summary(w.key.clone(), w.facts.as_ref(), &w.state, incidents, links) - }) - .collect(); + let mut list: Vec = + rows.iter().map(|w| views::summary(w, incidents)).collect(); list.sort_by(|a, b| b.status.cmp(&a.status).then_with(|| a.key.cmp(&b.key))); list } diff --git a/crates/server/src/api/apps_tests.rs b/crates/server/src/api/apps_tests.rs index 43166d1..79dad73 100644 --- a/crates/server/src/api/apps_tests.rs +++ b/crates/server/src/api/apps_tests.rs @@ -16,31 +16,17 @@ fn row(subject: &str, run: RunState) -> WorkloadRow { fn incident(subject: &str, code: IncidentCode, severity: Severity) -> IncidentView { IncidentView { - subject: subject.parse().unwrap(), - code, - severity, detail: "d".into(), opened_at: Some(at(0)), - since: None, - open_for: None, - resolved_at: None, - muted: false, - mute_reason: None, - links: BTreeMap::new(), - app: None, - observed_since: None, - workload: None, + ..skym_core::fixtures::incident(subject, code, severity) } } fn configured(id: &str, name: Option<&str>) -> AppConfig { AppConfig { - id: id.parse().unwrap(), name: name.map(String::from), - env: Some("prod".into()), note: Some("the shop".into()), - tags: vec![], - probes: vec![], + ..crate::config::app_config(id, Some("prod")) } } @@ -225,9 +211,8 @@ fn an_app_lists_its_workloads_deploys_and_recent_exceptions() { fn apps_carry_their_hosts_tags_and_their_own() { let host = |id: &str, tags: &[&str]| HostEntry { id: id.into(), - customer: None, - token_sha256: String::new(), tags: tags.iter().map(|t| t.to_string()).collect(), + ..HostEntry::default() }; let hosts = [host("x", &["acme", "cn"]), host("y", &["other"])]; let rows = [ diff --git a/crates/server/src/api/handlers.rs b/crates/server/src/api/handlers.rs index 979996a..97daa58 100644 --- a/crates/server/src/api/handlers.rs +++ b/crates/server/src/api/handlers.rs @@ -15,7 +15,7 @@ use skym_core::subject::{AppKey, Subject, WorkloadKey}; use skym_core::time::parse_since; use skym_core::view::{ AppList, AppView, ExceptionList, HostList, HostView, IncidentList, IncidentView, Overview, - Timeline, WorkloadView, workload_summary, + Timeline, WorkloadView, }; type ApiResult = Result, ApiError>; @@ -262,14 +262,7 @@ async fn one_app(s: AppState, key: AppKey) -> ApiResult { .into_iter() .find(|a| a.key == key) .ok_or_else(|| ApiError::not_found(format!("app {key} is unknown")))?; - let workloads = snap - .workloads - .into_iter() - .filter(|w| key.contains(&w.key)) - .map(|w| { - let links = views::links(&Subject::Workload(w.key.clone())); - workload_summary(w.key, w.facts.as_ref(), &w.state, &app.incidents, links) - }) - .collect(); + // The same workloads as the application's own, problems first. + let workloads = app.workloads.clone(); Ok(Json(AppView { app, workloads })) } diff --git a/crates/server/src/api/snapshot.rs b/crates/server/src/api/snapshot.rs index 91a7c99..6585cc1 100644 --- a/crates/server/src/api/snapshot.rs +++ b/crates/server/src/api/snapshot.rs @@ -2,7 +2,7 @@ //! problems the same way. use super::apps::History; -use super::views::{self, Context, incident_view, links}; +use super::views::{self, Context, incident_view}; use super::{ApiError, AppState, apps}; use crate::lifecycle::Incident; use crate::store::hosts::{HostRow, WorkloadRow}; @@ -10,9 +10,7 @@ use crate::store::probes::ProbeRow; use crate::store::{history, hosts, incidents, probes}; use jiff::{SignedDuration, Timestamp}; use skym_core::subject::{HostId, Subject, WorkloadKey}; -use skym_core::view::{ - AppSummary, EndpointOverview, IncidentView, WorkloadSummary, workload_summary, -}; +use skym_core::view::{AppSummary, EndpointOverview, IncidentView, WorkloadSummary}; use std::collections::BTreeMap; /// Generous bound on rows read for one response; lists are cut to `limit` afterwards. @@ -81,14 +79,8 @@ impl Snapshot { }; let open: Vec = self.open.iter().map(|i| incident_view(i, &cx)).collect(); // Each workload once, its status from every open incident (whatever was asked for). - let workloads: BTreeMap<&WorkloadKey, WorkloadSummary> = self - .workloads - .iter() - .map(|w| { - let links = links(&Subject::Workload(w.key.clone())); - (&w.key, workload_summary(w.key.clone(), w.facts.as_ref(), &w.state, &open, links)) - }) - .collect(); + let workloads: BTreeMap<&WorkloadKey, WorkloadSummary> = + self.workloads.iter().map(|w| (&w.key, views::summary(w, &open))).collect(); // A workload's problem carries the workload, for a reader that looks no further. incidents .iter() diff --git a/crates/server/src/api/views.rs b/crates/server/src/api/views.rs index 9607e3f..7311be8 100644 --- a/crates/server/src/api/views.rs +++ b/crates/server/src/api/views.rs @@ -12,7 +12,7 @@ use skym_core::subject::{AppKey, HostId, Subject, WorkloadKey, encode}; use skym_core::time::format_duration; use skym_core::view::{ AppSummary, CustomerOverview, DiskUse, EndpointOverview, HostOverview, HostView, IncidentView, - Overview, Status, Timeline, TimelineEntry, TimelineKind, WorkloadView, rollup, + Overview, Status, Timeline, TimelineEntry, TimelineKind, WorkloadSummary, WorkloadView, rollup, workload_summary, }; use std::collections::BTreeMap; @@ -81,6 +81,12 @@ pub fn app_of(subject: &Subject, probed: &[Endpoint]) -> Option { } } +/// A workload with its links, its status judged by `incidents`. +pub fn summary(w: &WorkloadRow, incidents: &[IncidentView]) -> WorkloadSummary { + let links = links(&Subject::Workload(w.key.clone())); + workload_summary(w.key.clone(), w.facts.as_ref(), &w.state, incidents, links) +} + /// Where to look next, so an agent never builds URLs itself. pub fn links(subject: &Subject) -> BTreeMap { let host_links = |h: &str| { @@ -133,13 +139,7 @@ pub fn host( apps: Vec, now: Timestamp, ) -> HostView { - let workloads = workloads - .into_iter() - .map(|w| { - let links = links(&Subject::Workload(w.key.clone())); - workload_summary(w.key, w.facts.as_ref(), &w.state, &incidents, links) - }) - .collect(); + let workloads = workloads.into_iter().map(|w| summary(&w, &incidents)).collect(); HostView { status: status(&incidents, row.is_some()), last_report_ago: row.as_ref().map(|r| format_duration(now.duration_since(r.last_seen))), diff --git a/crates/server/src/api/views_tests.rs b/crates/server/src/api/views_tests.rs index cc94049..e65c97a 100644 --- a/crates/server/src/api/views_tests.rs +++ b/crates/server/src/api/views_tests.rs @@ -168,22 +168,11 @@ fn row(id: &str, seen: i64) -> HostRow { fn summary(key: &str, status: Status) -> AppSummary { AppSummary { - key: key.parse().unwrap(), name: key.into(), - env: None, - note: None, - configured: false, status, services: 1, running: 1, - last_deployed: None, - endpoints: vec![], - incidents: vec![], - links: BTreeMap::new(), - workloads: vec![], - deploys: vec![], - exceptions_1h: 0, - tags: vec![], + ..skym_core::fixtures::app(key) } } @@ -192,8 +181,7 @@ fn the_overview_lists_problems_flat_and_hosts_by_urgency() { let host = |id: &str, customer: Option<&str>| HostEntry { id: id.into(), customer: customer.map(String::from), - token_sha256: String::new(), - tags: vec![], + ..HostEntry::default() }; let cfg = ServerConfig { customers: vec![Customer { id: "acme".into(), name: "Acme".into() }], diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 60aeff4..6c3c0bb 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -51,7 +51,7 @@ pub struct Customer { pub name: String, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Debug, Clone, Default)] pub struct HostEntry { pub id: HostId, #[serde(default)] @@ -129,6 +129,19 @@ pub struct AppProbe { pub headers: Headers, } +/// A listed application with nothing but its id and environment, for tests. +#[cfg(test)] +pub(crate) fn app_config(id: &str, env: Option<&str>) -> AppConfig { + AppConfig { + id: id.parse().unwrap(), + name: None, + env: env.map(String::from), + note: None, + tags: vec![], + probes: vec![], + } +} + impl ServerConfig { /// Every URL to probe: the applications' probes. pub fn probed(&self) -> Vec { diff --git a/crates/server/src/findings.rs b/crates/server/src/findings.rs index 26d9d0d..aae51ef 100644 --- a/crates/server/src/findings.rs +++ b/crates/server/src/findings.rs @@ -347,16 +347,7 @@ mod tests { assert_eq!(both.len(), 2, "down and expiring are separate incidents"); } - fn app(id: &str, env: Option<&str>) -> AppConfig { - AppConfig { - id: id.parse().unwrap(), - name: None, - env: env.map(String::from), - note: None, - tags: vec![], - probes: vec![], - } - } + use crate::config::app_config as app; #[test] fn container_apps_go_missing_only_from_a_complete_listing() { diff --git a/crates/server/tests/api.rs b/crates/server/tests/api.rs index c266bcb..31c865b 100644 --- a/crates/server/tests/api.rs +++ b/crates/server/tests/api.rs @@ -1,13 +1,15 @@ //! The HTTP API end to end, against an in-memory database. +mod common; + use axum::Router; use axum::body::Body; use axum::http::{Request, StatusCode}; +use common::{app_config, down}; use flate2::{Compression, write::GzEncoder}; use http_body_util::BodyExt; use jiff::{SignedDuration, Timestamp}; use serde_json::Value; -use skym_core::model::RunState; use skym_core::report::Report; use skym_server::api::{AppState, router}; use skym_server::config::{ @@ -256,13 +258,6 @@ async fn a_silent_host_loses_its_heartbeat_and_a_report_restores_it() { assert_eq!(resolved["incidents"][0]["code"], "HEARTBEAT_LOST"); } -/// Workload `i` of the report exited with an error. -fn down(mut r: Report, i: usize) -> Report { - r.workloads[i].state.run = RunState::Exited; - r.workloads[i].state.exit_code = Some(1); - r -} - #[tokio::test] async fn views_scope_incidents_and_ignore_muted_ones() { let mute = Mute { @@ -441,9 +436,7 @@ async fn reports_from_a_clock_slightly_ahead_are_visible_at_once() { #[tokio::test] async fn applications_are_listed_described_and_missed() { let shop = AppConfig { - id: "x/shop".parse().unwrap(), name: Some("Shop".into()), - env: Some("prod".into()), note: Some("the web shop".into()), tags: vec!["billing".into()], probes: vec![AppProbe { @@ -451,6 +444,7 @@ async fn applications_are_listed_described_and_missed() { expect: vec![], headers: Headers([("Authorization".to_string(), "Bearer s3cret".to_string())].into()), }], + ..app_config("x/shop", Some("prod")) }; let (app, state) = app_configured(vec![], vec![shop]); let listed = |mins_ago| Report { containers_listed: true, ..report(mins_ago) }; diff --git a/crates/server/tests/common/mod.rs b/crates/server/tests/common/mod.rs new file mode 100644 index 0000000..a752a82 --- /dev/null +++ b/crates/server/tests/common/mod.rs @@ -0,0 +1,24 @@ +//! Helpers shared by the server's integration tests. + +use skym_core::model::RunState; +use skym_core::report::Report; +use skym_server::config::AppConfig; + +/// Workload `i` of the report exited with an error. +pub fn down(mut r: Report, i: usize) -> Report { + r.workloads[i].state.run = RunState::Exited; + r.workloads[i].state.exit_code = Some(1); + r +} + +/// A listed application with nothing but its id and environment. +pub fn app_config(id: &str, env: Option<&str>) -> AppConfig { + AppConfig { + id: id.parse().unwrap(), + name: None, + env: env.map(String::from), + note: None, + tags: vec![], + probes: vec![], + } +} diff --git a/crates/server/tests/store.rs b/crates/server/tests/store.rs index 6b2fe6e..40269e7 100644 --- a/crates/server/tests/store.rs +++ b/crates/server/tests/store.rs @@ -1,8 +1,11 @@ //! Ingest, heartbeat and maintenance at chosen times, against an in-memory database. +mod common; + +use common::{app_config as app, down}; use jiff::{SignedDuration, Timestamp}; use rusqlite::Connection; -use skym_core::model::{ExceptionClass, ExceptionGroup, LocalEvent, RunState}; +use skym_core::model::{ExceptionClass, ExceptionGroup, LocalEvent}; use skym_core::report::Report; use skym_core::rules::IncidentCode; use skym_server::config::AppConfig; @@ -152,12 +155,6 @@ fn disk_growth_is_projected_from_reported_usage() { assert!(disk.detail.contains("full in"), "{}", disk.detail); } -fn down(mut r: Report, i: usize) -> Report { - r.workloads[i].state.run = RunState::Exited; - r.workloads[i].state.exit_code = Some(1); - r -} - #[test] fn a_stopped_workload_says_when_it_stopped() { let mut c = db::open_in_memory().unwrap(); @@ -333,17 +330,6 @@ fn a_failed_source_does_not_clear_unbounded_logs() { assert!(!open_codes(&c).contains(&host_logs), "every container bounded again"); } -fn app(id: &str, env: Option<&str>) -> AppConfig { - AppConfig { - id: id.parse().unwrap(), - name: None, - env: env.map(String::from), - note: None, - tags: vec![], - probes: vec![], - } -} - fn missing(c: &Connection) -> Vec<(String, State, skym_core::rules::Severity)> { incidents::active_with_code(c, IncidentCode::AppMissing) .unwrap() diff --git a/crates/view/Cargo.toml b/crates/view/Cargo.toml index 4d4b65a..e4a0e75 100644 --- a/crates/view/Cargo.toml +++ b/crates/view/Cargo.toml @@ -24,5 +24,8 @@ serde_json.workspace = true tokio = { version = "1", features = ["rt", "macros", "time", "sync"] } unicode-width = "0.2" +[dev-dependencies] +skym-core = { path = "../core", features = ["fixtures"] } + [lints] workspace = true diff --git a/crates/view/src/app/keys.rs b/crates/view/src/app/keys.rs index db94a24..9757186 100644 --- a/crates/view/src/app/keys.rs +++ b/crates/view/src/app/keys.rs @@ -2,7 +2,6 @@ use super::{App, AppRow, Data, Frame, Key, Screen, TABS, WINDOWS}; use crate::api::Request; -use crate::apps::Sort; use jiff::Timestamp; use skym_core::subject::{AppKey, Subject}; @@ -76,13 +75,10 @@ impl App { } } - /// Sorts the other way, the selection staying on its application (a header stays put). - fn toggle_sort(&mut self) { + /// Sorts by the next order, the selection staying on its application (a header stays put). + fn next_sort(&mut self) { let selected = self.app_keys().get(self.frame().cursor).cloned().flatten(); - self.sort = match self.sort { - Sort::Problems => Sort::Memory, - Sort::Memory => Sort::Problems, - }; + self.sort = self.sort.next(); let at = selected.and_then(|k| self.app_keys().iter().position(|r| r.as_ref() == Some(&k))); if let Some(at) = at { self.frame_mut().cursor = at; @@ -208,7 +204,7 @@ impl App { Key::Char('h') => self.show_info = !self.show_info, Key::Char('p') => self.preview = !self.preview, Key::Char('s') if matches!(self.frame().screen, Screen::Apps | Screen::Host(_)) => { - self.toggle_sort(); + self.next_sort(); } Key::Char('m') => { self.show_muted = !self.show_muted; diff --git a/crates/view/src/app/lists.rs b/crates/view/src/app/lists.rs index 089afa2..43d3218 100644 --- a/crates/view/src/app/lists.rs +++ b/crates/view/src/app/lists.rs @@ -1,14 +1,13 @@ //! What the screens list, derived from the data: problems, applications, hosts, services. use super::{App, Screen}; -use crate::apps::{Group, Sort, groups, used_memory}; +use crate::apps::{Group, groups}; use crate::filter::Filter; use crate::names; use crate::problems::{Problems, Row, problems}; use jiff::Timestamp; use skym_core::subject::{AppKey, Subject}; use skym_core::view::{AppSummary, HostOverview, Status, WorkloadSummary}; -use std::cmp::Reverse; /// A row of the applications tab. #[derive(Debug)] @@ -33,7 +32,7 @@ impl App { let what = names::what(&i.subject); let text = format!("{} {} {what} {}", self.app_name(r), r.host, i.detail); let app = r.app().and_then(|key| self.app_summary(key)); - let host = overview.hosts.iter().find(|h| h.id == r.host); + let host = self.host_summary(r.host); let of_app = r.app().is_some(); filter.problem(r.host, of_app, app, host.map_or(&[], |h| &h.tags), text) }); @@ -59,8 +58,18 @@ impl App { } } - fn app_summary(&self, key: &AppKey) -> Option<&AppSummary> { - self.apps.value.iter().flat_map(|l| &l.apps).find(|a| a.key == *key) + /// Every application read, as the server listed them. + pub fn apps_iter(&self) -> impl Iterator { + self.apps.value.iter().flat_map(|l| &l.apps) + } + + pub fn app_summary(&self, key: &AppKey) -> Option<&AppSummary> { + self.apps_iter().find(|a| a.key == *key) + } + + /// A host as the overview has it. + pub fn host_summary(&self, id: &str) -> Option<&HostOverview> { + self.overview.value.iter().flat_map(|o| &o.hosts).find(|h| h.id == id) } /// The hosts tab: most urgent first, filtered. @@ -84,16 +93,13 @@ impl App { .collect() } - /// A host's applications, filtered: as the server lists them (problems first), or by - /// the memory they use. + /// A host's applications, filtered, in the chosen order; ties keep the server's (problems + /// first), so sorting by problems leaves its order as it is. pub fn host_apps(&self) -> Vec<&AppSummary> { let filter = self.filter(); let apps = self.host.value.iter().flat_map(|h| &h.apps); let mut list: Vec<&AppSummary> = apps.filter(|a| filter.app(a)).collect(); - if self.sort == Sort::Memory { - // Most first, those reporting none last. - list.sort_by_cached_key(|a| Reverse(used_memory(a))); - } + list.sort_by(|a, b| self.sort.order(a, b)); list } diff --git a/crates/view/src/app/tests.rs b/crates/view/src/app/tests.rs index 1d04d7d..e146570 100644 --- a/crates/view/src/app/tests.rs +++ b/crates/view/src/app/tests.rs @@ -1,9 +1,9 @@ use super::*; use crate::apps::{Grouping, Sort}; +use skym_core::fixtures; use skym_core::rules::{IncidentCode, Severity}; use skym_core::subject::{AppKey, Subject}; use skym_core::view::{AppSummary, HostOverview, IncidentView, Status, WorkloadSummary}; -use std::collections::BTreeMap; fn t(sec: i64) -> Timestamp { Timestamp::from_second(1_790_000_000 + sec).unwrap() @@ -11,59 +11,29 @@ fn t(sec: i64) -> Timestamp { /// An open, critical problem, attributed as the server does. fn incident(subject: &str) -> IncidentView { - let subject: Subject = subject.parse().unwrap(); + let i = fixtures::incident(subject, IncidentCode::WorkloadDown, Severity::Critical); IncidentView { - app: match &subject { + app: match &i.subject { Subject::Workload(k) => Some(AppKey::of(k)), Subject::App(a) => Some(a.clone()), Subject::Endpoint(_) => Some("x/shop".parse().unwrap()), _ => None, }, - subject, - code: IncidentCode::WorkloadDown, - severity: Severity::Critical, detail: "exited (1)".into(), opened_at: Some(t(0)), - since: None, - open_for: None, - resolved_at: None, - muted: false, - mute_reason: None, - links: BTreeMap::new(), observed_since: Some(t(0)), - workload: None, + ..i } } fn host(id: &str) -> HostOverview { HostOverview { - id: id.into(), status: Status::Critical, - last_report_ago: Some("5s".into()), observed_since: Some(t(0)), - info_count: 0, - incidents: vec![], - links: BTreeMap::new(), load_1m: Some(0.5), - memory_used_bytes: None, - memory_total_bytes: None, - disks: vec![], apps: 1, apps_in_trouble: 1, - os: None, - kernel: None, - arch: None, - cpu_count: None, - boot_time: None, - docker_version: None, - agent_version: None, - ip: None, - cpu_percent: None, - iowait_percent: None, - steal_percent: None, - net_rx_bytes_per_s: None, - net_tx_bytes_per_s: None, - tags: vec![], + ..fixtures::host_overview(id) } } @@ -87,22 +57,12 @@ fn overview() -> Overview { fn summary(key: &str, env: Option<&str>, status: Status) -> AppSummary { AppSummary { - key: key.parse().unwrap(), - name: key.rsplit('/').next().unwrap().into(), env: env.map(String::from), - note: None, configured: env.is_some(), status, services: 1, running: 1, - last_deployed: None, - endpoints: vec![], - incidents: vec![], - links: BTreeMap::new(), - workloads: vec![], - deploys: vec![], - exceptions_1h: 0, - tags: vec![], + ..fixtures::app(key) } } @@ -136,20 +96,8 @@ fn host_view() -> HostView { fn app_view(key: &str) -> AppView { let service = |name: &str, status| WorkloadSummary { - key: WorkloadKey { host: "x".into(), project: "app".into(), service: name.into() }, - kind: None, status, - run: skym_core::model::RunState::Running, - exit_code: None, - state_since: None, - image: None, - links: BTreeMap::new(), - restart_policy: None, - ports: vec![], - memory_used_bytes: None, - memory_limit_bytes: None, - restarts_last_hour: 0, - health_output: None, + ..fixtures::workload(&format!("x/app/{name}")) }; AppView { app: summary(key, None, Status::Critical), @@ -231,11 +179,8 @@ fn a_host_lists_its_apps_and_an_app_its_services() { press(&mut app, &[Key::Esc]); let apps = |app: &App| app.host_apps().iter().map(|a| a.name.clone()).collect::>(); assert_eq!(apps(&app), ["zz", "app"], "in the server's order: problems first"); - let using: WorkloadSummary = serde_json::from_value(serde_json::json!({ - "key": { "host": "x", "project": "app", "service": "web" }, - "status": "ok", "run": "running", "memory_used_bytes": 1_000_000 - })) - .unwrap(); + let using = + WorkloadSummary { memory_used_bytes: Some(1_000_000), ..fixtures::workload("x/app/web") }; app.host.value.as_mut().unwrap().apps[1].workloads = vec![using]; press(&mut app, &[Key::Down, Key::Char('s')]); assert_eq!(app.frame().cursor, 0, "the selection follows its application"); @@ -387,13 +332,10 @@ fn the_applications_tab_groups_folds_and_leads_to_an_app() { apps: vec![ shop, AppSummary { - workloads: vec![ - serde_json::from_value(serde_json::json!({ - "key": { "host": "y", "project": "shop-test", "service": "web" }, - "status": "ok", "run": "running", "memory_used_bytes": 1_000_000 - })) - .unwrap(), - ], + workloads: vec![WorkloadSummary { + memory_used_bytes: Some(1_000_000), + ..fixtures::workload("y/shop-test/web") + }], ..summary("y/shop-test", Some("test"), Status::Ok) }, summary("x/blog", Some("test"), Status::Critical), diff --git a/crates/view/src/apps.rs b/crates/view/src/apps.rs index 61c06b7..c58ab50 100644 --- a/crates/view/src/apps.rs +++ b/crates/view/src/apps.rs @@ -1,8 +1,8 @@ //! The applications page: grouped by environment, host or tag, each group folded to its //! applications in trouble unless opened. Pure. -use skym_core::view::{AppSummary, Status}; -use std::cmp::Reverse; +use skym_core::view::{AppSummary, Status, WorkloadSummary}; +use std::cmp::{Ordering, Reverse}; use std::collections::{BTreeMap, BTreeSet}; /// The usual environments, in this order; any other comes after them, unset last. @@ -46,7 +46,7 @@ impl Grouping { } } -/// How applications are ordered: worst first, or by the memory they use. +/// How applications are ordered: worst first, or by what they use. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Sort { #[default] @@ -54,15 +54,52 @@ pub enum Sort { Memory, } -/// The memory its workloads report, if any does. +impl Sort { + /// Every order, in the order `s` goes through them. + pub const ALL: [Sort; 2] = [Sort::Problems, Sort::Memory]; + + pub fn next(self) -> Sort { + let at = Sort::ALL.iter().position(|s| *s == self).unwrap_or(0); + Sort::ALL[(at + 1) % Sort::ALL.len()] + } + + pub fn label(self) -> &'static str { + match self { + Sort::Problems => "problems first", + Sort::Memory => "most memory first", + } + } + + /// The column it sorts by, which carries the `▼`. + pub fn column(self) -> Option<&'static str> { + match self { + Sort::Problems => None, + Sort::Memory => Some("MEM"), + } + } + + /// Worst first; or the most used first, those reporting none last. + pub fn order(self, a: &AppSummary, b: &AppSummary) -> Ordering { + match self { + Sort::Problems => b.status.cmp(&a.status), + Sort::Memory => used_memory(b).cmp(&used_memory(a)), + } + } +} + +/// What an application's workloads report of one resource, summed; `None` if none does. +fn used(a: &AppSummary, of: impl Fn(&WorkloadSummary) -> Option) -> Option { + a.workloads.iter().filter_map(of).reduce(|x, y| x + y) +} + pub fn used_memory(a: &AppSummary) -> Option { - a.workloads.iter().filter_map(|w| w.memory_used_bytes).reduce(|x, y| x + y) + used(a, |w| w.memory_used_bytes) } #[derive(Debug, PartialEq)] pub struct Group<'a> { pub name: String, - /// All of its applications, worst first, then by name. + /// All of its applications, in the chosen order, then by name. pub all: Vec<&'a AppSummary>, pub open: bool, } @@ -102,13 +139,7 @@ pub fn groups<'a>( let mut groups: Vec = named .into_iter() .map(|(name, mut all)| { - all.sort_by(|a, b| { - let first = match sort { - Sort::Problems => b.status.cmp(&a.status), - Sort::Memory => used_memory(b).cmp(&used_memory(a)), - }; - first.then_with(|| a.name.cmp(&b.name)) - }); + all.sort_by(|a, b| sort.order(a, b).then_with(|| a.name.cmp(&b.name))); let open = open.contains(&(by, name.clone())); Group { name, all, open } }) @@ -126,36 +157,19 @@ pub fn groups<'a>( groups } -/// What the filter matches: the name, where it runs, its URLs, its note and its tags. -pub fn text(a: &AppSummary) -> String { - let urls: Vec<&str> = a.endpoints.iter().map(|e| e.url.as_str()).collect(); - let note = a.note.as_deref().unwrap_or(""); - format!("{} {} {} {note} {}", a.name, a.key, urls.join(" "), a.tags.join(" ")) -} - #[cfg(test)] mod tests { use super::*; - use std::collections::BTreeMap; + use skym_core::fixtures; fn app(name: &str, env: Option<&str>, status: Status) -> AppSummary { AppSummary { - key: format!("x/{name}").parse().unwrap(), - name: name.into(), env: env.map(String::from), - note: None, configured: env.is_some(), status, services: 1, running: 1, - last_deployed: None, - endpoints: vec![], - incidents: vec![], - links: BTreeMap::new(), - workloads: vec![], - deploys: vec![], - exceptions_1h: 0, - tags: vec![], + ..fixtures::app(&format!("x/{name}")) } } @@ -242,12 +256,9 @@ mod tests { /// An application whose workloads report these amounts of memory (`None`: not reported). fn using(name: &str, status: Status, memory: &[Option]) -> AppSummary { - let workload = |m: &Option| { - serde_json::from_value(serde_json::json!({ - "key": { "host": "x", "project": name, "service": "s" }, - "status": "ok", "run": "running", "memory_used_bytes": m - })) - .unwrap() + let workload = |m: &Option| WorkloadSummary { + memory_used_bytes: *m, + ..fixtures::workload(&format!("x/{name}/s")) }; AppSummary { workloads: memory.iter().map(workload).collect(), @@ -285,12 +296,4 @@ mod tests { let silent = groups(&apps[1..3], Grouping::Env, Sort::Memory, &open, |_| true); assert_eq!(silent[0].memory(), None); } - - #[test] - fn the_filter_reads_names_hosts_urls_notes_and_tags() { - let mut a = app("shop", Some("prod"), Status::Ok); - a.note = Some("the web shop".into()); - a.tags = vec!["acme".into(), "eu".into()]; - assert_eq!(text(&a), "shop x/shop the web shop acme eu"); - } } diff --git a/crates/view/src/filter.rs b/crates/view/src/filter.rs index 34720bc..34ecb14 100644 --- a/crates/view/src/filter.rs +++ b/crates/view/src/filter.rs @@ -50,7 +50,7 @@ impl Filter { env: Some(a.env.as_deref()), tags: Some(&a.tags), status: Some(a.status), - text: crate::apps::text(a), + text: text(a), }) } @@ -104,3 +104,26 @@ impl Filter { }) } } + +/// What the filter matches: the name, where it runs, its URLs, its note and its tags. +fn text(a: &AppSummary) -> String { + let urls: Vec<&str> = a.endpoints.iter().map(|e| e.url.as_str()).collect(); + let note = a.note.as_deref().unwrap_or(""); + format!("{} {} {} {note} {}", a.name, a.key, urls.join(" "), a.tags.join(" ")) +} + +#[cfg(test)] +mod tests { + use super::*; + use skym_core::fixtures; + + #[test] + fn the_filter_reads_names_hosts_urls_notes_and_tags() { + let a = AppSummary { + note: Some("the web shop".into()), + tags: vec!["acme".into(), "eu".into()], + ..fixtures::app("x/shop") + }; + assert_eq!(text(&a), "shop x/shop the web shop acme eu"); + } +} diff --git a/crates/view/src/problems_tests.rs b/crates/view/src/problems_tests.rs index d49dbbf..9e8f615 100644 --- a/crates/view/src/problems_tests.rs +++ b/crates/view/src/problems_tests.rs @@ -1,7 +1,7 @@ use super::*; +use skym_core::fixtures; use skym_core::rules::IncidentCode; use skym_core::view::Status; -use std::collections::BTreeMap; fn t(min: i64) -> Timestamp { Timestamp::from_second(1_790_000_000 + min * 60).unwrap() @@ -15,24 +15,16 @@ fn incident(subject: &str, severity: Severity, opened: i64) -> IncidentView { Some("y") => Some(t(60)), _ => None, }; + let i = fixtures::incident(&subject.to_string(), IncidentCode::WorkloadDown, severity); IncidentView { app: match &subject { skym_core::subject::Subject::Workload(k) => Some(AppKey::of(k)), _ => None, }, - subject, - code: IncidentCode::WorkloadDown, - severity, detail: "d".into(), opened_at: Some(t(opened)), - since: None, - open_for: None, - resolved_at: None, - muted: false, - mute_reason: None, - links: BTreeMap::new(), observed_since: watched, - workload: None, + ..i } } diff --git a/crates/view/src/ui/apps.rs b/crates/view/src/ui/apps.rs index 1451a2e..d25ae0b 100644 --- a/crates/view/src/ui/apps.rs +++ b/crates/view/src/ui/apps.rs @@ -1,7 +1,7 @@ //! Every application, grouped by environment; and one application with its URLs and services. -use super::preview::{size, usage}; -use super::{Theme, ago, block, draw_preview, empty_row, preview, problem_table, with_preview}; +use super::format::{ago, answer, app_count, reason, size, usage}; +use super::{Theme, block, draw_preview, empty_row, preview, problem_table, with_preview}; use crate::app::{App, AppRow}; use crate::apps::{Group, Grouping, Sort, used_memory}; use crate::names; @@ -45,7 +45,11 @@ pub fn list(f: &mut Frame, area: Rect, app: &App, now: Timestamp, theme: Theme) " Applications ({}) · by {}{} ", all.apps.len(), app.grouping.label(), - if app.sort == Sort::Memory { " · most memory first" } else { "" } + if app.sort == Sort::default() { + String::new() + } else { + format!(" · {}", app.sort.label()) + } ); let selected = (!listed.is_empty()).then_some(app.frame().cursor); let mut state = TableState::default().with_selected(selected); @@ -71,7 +75,7 @@ fn group_row(g: &Group, by: Grouping, theme: Theme) -> Row<'static> { format!("{mark} {name}"), Style::new().add_modifier(Modifier::BOLD), )), - Cell::from(Line::styled(preview::app_count(g.all.len()), theme.dim())), + Cell::from(Line::styled(app_count(g.all.len()), theme.dim())), Cell::from(""), Cell::from(Line::from(g.memory().map_or(String::new(), size)).right_aligned()), ]) @@ -102,7 +106,7 @@ fn app_row( ) -> Row<'static> { let url = match (a.endpoints.first(), a.incidents.first()) { (Some(e), _) => endpoint_text(e, a.endpoints.len()), - (None, Some(i)) => super::reason(&i.detail), + (None, Some(i)) => reason(&i.detail), (None, None) => "—".into(), }; let deployed = a.last_deployed.map_or("—".to_string(), |t| ago(now, t)); @@ -126,10 +130,13 @@ fn app_row( .style(if a.status == Status::Ok && a.services == 0 { theme.dim() } else { Style::new() }) } -/// The columns' names: `second` is HOST or ENV; `MEM ▼` while sorted by memory. +/// The columns' names: `second` is HOST or ENV; a `▼` on the one the applications are +/// sorted by. fn app_header(second: &'static str, app: &App, theme: Theme) -> Row<'static> { - let mem = if app.sort == Sort::Memory { "MEM ▼" } else { "MEM" }; - Row::new(["", "APP", second, "SVC", mem, "UP", "URL", "ERR 1H", "DEPLOYED"]).style(theme.dim()) + let names = ["", "APP", second, "SVC", "MEM", "UP", "URL", "ERR 1H", "DEPLOYED"]; + let sorted = app.sort.column(); + let names = names.map(|n| if Some(n) == sorted { format!("{n} ▼") } else { n.to_string() }); + Row::new(names).style(theme.dim()) } /// `2.3G 14%` of its host's memory, `2.3G` without the host's total, `—` without any. @@ -143,8 +150,7 @@ fn mem_cell(a: &AppSummary, host_memory: Option) -> String { /// The total memory of the host an application runs on, as the overview has it. fn host_memory(app: &App, a: &AppSummary) -> Option { - let hosts = app.overview.value.iter().flat_map(|o| &o.hosts); - hosts.filter(|h| h.id == a.key.host).find_map(|h| h.memory_total_bytes) + app.host_summary(&a.key.host)?.memory_total_bytes } /// How long its longest-running workload has been running. @@ -167,7 +173,7 @@ pub(super) fn host_apps(f: &mut Frame, area: Rect, app: &App, now: Timestamp, th rows.push(empty_row(1, "(none)".into(), theme)); } let widths = APP_COLUMNS; - let order = if app.sort == Sort::Memory { "most memory first" } else { "problems first" }; + let order = app.sort.label(); let title = format!(" Apps ({}) · {order} ", apps.len()); let mut state = TableState::default().with_selected(Some(app.frame().cursor)); let table = Table::new(rows, widths) @@ -179,13 +185,9 @@ pub(super) fn host_apps(f: &mut Frame, area: Rect, app: &App, now: Timestamp, th /// `shop.example.com 84ms`, and how many more URLs there are. fn endpoint_text(e: &EndpointOverview, count: usize) -> String { - let answer = match (e.http_status, e.latency_ms) { - (Some(_), Some(ms)) => format!("{ms}ms"), - _ if e.last_probe_ago.is_none() => "not probed yet".into(), - _ => "no answer".into(), - }; let more = if count > 1 { format!(" +{}", count - 1) } else { String::new() }; - format!("{} {answer}{more}", names::short(&Subject::Endpoint(e.url.clone()))) + let url = names::short(&Subject::Endpoint(e.url.clone())); + format!("{url} {}{more}", answer(e, false)) } pub fn one(f: &mut Frame, area: Rect, app: &App, now: Timestamp, theme: Theme) { diff --git a/crates/view/src/ui/format.rs b/crates/view/src/ui/format.rs new file mode 100644 index 0000000..e759fb2 --- /dev/null +++ b/crates/view/src/ui/format.rs @@ -0,0 +1,157 @@ +//! How values read in the view: sizes, rates, CPU, times, ages and short reasons. Pure. + +use crate::problems::Age; +use jiff::Timestamp; +use skym_core::time::format_duration; +use skym_core::view::{EndpointOverview, HostOverview}; + +/// A time as the clock on the wall shows it. +pub fn local(t: Timestamp, format: &str) -> String { + t.to_zoned(jiff::tz::TimeZone::system()).strftime(format).to_string() +} + +/// `5m`, `3d4h`: how long ago. +pub fn ago(now: Timestamp, then: Timestamp) -> String { + format_duration(now.duration_since(then)) +} + +pub fn age(a: Age) -> String { + match a { + Age::Exact(d) => format_duration(d), + Age::AtLeast(d) => format!("≥{}", format_duration(d)), + } +} + +/// An incident's detail without Docker's boilerplate, which would push the cause out of +/// a narrow column. +pub fn reason(detail: &str) -> String { + detail.replace("OCI runtime exec failed: exec failed: unable to start container process: ", "") +} + +pub fn event(kind: &skym_core::model::EventKind) -> String { + use skym_core::model::EventKind; + match kind { + EventKind::Deployed { from, to } => format!("deployed {to} (was {from})"), + EventKind::ConfigChanged => "configuration changed".into(), + EventKind::Restarted => "restarted".into(), + EventKind::OomKilled => "OOM killed".into(), + EventKind::HostRebooted => "host rebooted".into(), + EventKind::KernelChanged => "kernel changed".into(), + EventKind::Unknown => "(an event this version does not know)".into(), + } +} + +/// `80G`, `512M`, `3.2T`. +pub fn size(bytes: u64) -> String { + let units = [("T", 1e12), ("G", 1e9), ("M", 1e6)]; + let b = bytes as f64; + match units.iter().find(|(_, scale)| b >= *scale) { + // One decimal below 10 (as it will be printed), none from there. + Some((unit, scale)) if (b / scale * 10.0).round() >= 100.0 => { + format!("{:.0}{unit}", b / scale) + } + Some((unit, scale)) => format!("{:.1}{unit}", b / scale), + None => format!("{}K", bytes / 1000), + } +} + +/// `1.4 / 3.9 GB`. +pub fn memory(used: Option, total: Option) -> String { + let gb = |b: u64| format!("{:.1}", b as f64 / 1e9); + match (used, total) { + (Some(used), Some(total)) => format!("{} / {} GB", gb(used), gb(total)), + (Some(used), None) => format!("{} GB", gb(used)), + _ => "—".into(), + } +} + +/// `120M / 512M`, or `120M` without a limit. +pub fn usage(used: u64, limit: Option) -> String { + limit.map_or_else(|| size(used), |l| format!("{} / {}", size(used), size(l))) +} + +/// `1 app`, `43 apps`. +pub fn app_count(n: usize) -> String { + if n == 1 { "1 app".into() } else { format!("{n} apps") } +} + +/// `23% busy · 4% iowait · load 0.93`; steal only from 1%, when a neighbour on the +/// hypervisor is taking time. +pub fn cpu_use(h: &HostOverview) -> String { + let parts: Vec = [ + h.cpu_percent.map(|p| format!("{p:.0}% busy")), + h.iowait_percent.map(|p| format!("{p:.0}% iowait")), + h.steal_percent.filter(|p| *p >= 1.0).map(|p| format!("{p:.0}% steal")), + Some(h.load_1m.map_or("load —".into(), |l| format!("load {l:.2}"))), + ] + .into_iter() + .flatten() + .collect(); + parts.join(" · ") +} + +/// `4 · 23%`: CPUs and how busy they were since the previous report. +pub fn cpu_cell(h: &HostOverview) -> String { + let busy = h.cpu_percent.map(|p| format!("{p:.0}%")); + let parts: Vec = h.cpu_count.map(|n| n.to_string()).into_iter().chain(busy).collect(); + parts.join(" · ") +} + +/// How a URL answered: `200 in 84ms` (`with_status`) or `84ms`, else why there is none. +pub fn answer(e: &EndpointOverview, with_status: bool) -> String { + match (e.http_status, e.latency_ms) { + (Some(s), Some(ms)) if with_status => format!("{s} in {ms}ms"), + (Some(_), Some(ms)) => format!("{ms}ms"), + _ if e.last_probe_ago.is_none() => "not probed yet".into(), + _ => "no answer".into(), + } +} + +/// `540B/s`, `1.2MB/s`. +pub fn rate(bytes_per_s: u64) -> String { + match bytes_per_s { + 0..1000 => format!("{bytes_per_s}B/s"), + b => format!("{}B/s", size(b)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sizes_read_at_a_glance() { + assert_eq!(size(80_000_000_000), "80G"); + assert_eq!(size(3_200_000_000_000), "3.2T"); + assert_eq!(size(512_000_000), "512M"); + assert_eq!(size(1_500_000_000), "1.5G"); + assert_eq!(size(9_999_000), "10M", "rounded up to ten, so no decimal"); + assert_eq!(size(9_940_000), "9.9M"); + assert_eq!(size(4_000), "4K"); + assert_eq!(memory(Some(1_400_000_000), Some(3_900_000_000)), "1.4 / 3.9 GB"); + assert_eq!(memory(None, Some(1)), "—"); + assert_eq!( + (usage(120_000_000, Some(512_000_000)), usage(120_000_000, None)), + ("120M / 512M".into(), "120M".into()) + ); + } + + #[test] + fn rates_and_answers() { + assert_eq!((rate(540), rate(1_200_000)), ("540B/s".into(), "1.2MB/s".into())); + let probed = |status, ms| EndpointOverview { + http_status: status, + latency_ms: ms, + last_probe_ago: Some("20s".into()), + ..serde_json::from_value( + serde_json::json!({ "url": "https://a.example/", "status": "ok" }), + ) + .unwrap() + }; + assert_eq!(answer(&probed(Some(200), Some(84)), true), "200 in 84ms"); + assert_eq!(answer(&probed(Some(200), Some(84)), false), "84ms"); + assert_eq!(answer(&probed(None, None), false), "no answer"); + let never = EndpointOverview { last_probe_ago: None, ..probed(None, None) }; + assert_eq!(answer(&never, true), "not probed yet"); + } +} diff --git a/crates/view/src/ui/host.rs b/crates/view/src/ui/host.rs index 0ca680c..ec12b7f 100644 --- a/crates/view/src/ui/host.rs +++ b/crates/view/src/ui/host.rs @@ -1,7 +1,8 @@ //! One host: what it is, how it is doing, its problems and its services. use super::apps::host_apps; -use super::{Theme, hygiene_folded, local, preview, problem_table, shown}; +use super::format::local; +use super::{Theme, hygiene_folded, preview, problem_table, shown}; use crate::app::App; use crate::names::short; use jiff::Timestamp; @@ -66,7 +67,7 @@ fn header(app: &App, h: &HostView, now: Timestamp, theme: Theme) -> Vec = hosts.iter().map(|h| host_row(h, worst(&h.id), now, theme)).collect(); if rows.is_empty() { @@ -68,7 +68,7 @@ fn host_row(h: &HostOverview, worst: Option, now: Timestamp, theme: Them Cell::from(h.ip.clone().unwrap_or_default()), right(h.last_report_ago.clone().unwrap_or("never".into())), right(h.boot_time.map_or(String::new(), |t| ago(now, t))), - right(cpu(h)), + right(cpu_cell(h)), right(h.load_1m.map_or(String::new(), |l| format!("{l:.2}"))), Cell::from( h.memory_used_bytes @@ -79,13 +79,6 @@ fn host_row(h: &HostOverview, worst: Option, now: Timestamp, theme: Them ]) } -/// `4 · 23%`: CPUs and how busy they were since the previous report. -fn cpu(h: &HostOverview) -> String { - let busy = h.cpu_percent.map(|p| format!("{p:.0}%")); - let parts: Vec = h.cpu_count.map(|n| n.to_string()).into_iter().chain(busy).collect(); - parts.join(" · ") -} - /// `/ 61% of 40G /data 44% of 80G ▲`: yellow from 85%, `▲` while one fills up. fn disks(disks: &[DiskUse], theme: Theme) -> Vec> { // Small system partitions (`/boot/efi`) would crowd out the disks that matter, unless diff --git a/crates/view/src/ui/mod.rs b/crates/view/src/ui/mod.rs index 011608c..f6387cd 100644 --- a/crates/view/src/ui/mod.rs +++ b/crates/view/src/ui/mod.rs @@ -1,6 +1,7 @@ //! Drawing. Every frame is drawn from the state alone; nothing here changes it. mod apps; +mod format; mod host; mod hosts; mod preview; @@ -10,7 +11,8 @@ mod workload; use crate::api::FetchError; use crate::app::{App, Screen, TABS}; -use crate::problems::Age; +use crate::apps::Sort; +use format::{age, ago, reason}; use jiff::Timestamp; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; @@ -18,7 +20,6 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table}; use skym_core::rules::Severity; -use skym_core::time::format_duration; use skym_core::view::{IncidentView, Status}; /// Colors, unless `NO_COLOR` asks for none: then the symbols carry the meaning alone. @@ -172,6 +173,12 @@ fn bottom_bar(app: &App, theme: Theme) -> Paragraph<'static> { } fn help(f: &mut Frame, theme: Theme) { + let labels = Sort::ALL.map(Sort::label); + let sorts = match labels.split_last() { + Some((last, rest)) if !rest.is_empty() => format!("{}, or {last}", rest.join(", ")), + _ => labels.join(""), + }; + let sorts = format!("s sort applications: {sorts}"); let lines = [ "↑↓ j k move", "⏎ open / expand (a group of applications: open or fold)", @@ -180,7 +187,7 @@ fn help(f: &mut Frame, theme: Theme) { "/ filter: words, host: env: tag: !ok", "t timeline (host, service)", "e exceptions (host); open or fold every group (applications)", - "s sort applications: problems first, or most memory first", + sorts.as_str(), "g group applications by environment, host or tag", "[ ] timeline window: 6h 24h 7d", "h show hygiene (info) problems", @@ -232,42 +239,6 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect { Rect::new(area.x + (area.width - w) / 2, area.y + (area.height - h) / 2, w, h) } -/// A time as the clock on the wall shows it. -fn local(t: Timestamp, format: &str) -> String { - t.to_zoned(jiff::tz::TimeZone::system()).strftime(format).to_string() -} - -/// `5m`, `3d4h`: how long ago. -fn ago(now: Timestamp, then: Timestamp) -> String { - format_duration(now.duration_since(then)) -} - -fn age(a: Age) -> String { - match a { - Age::Exact(d) => format_duration(d), - Age::AtLeast(d) => format!("≥{}", format_duration(d)), - } -} - -/// An incident's detail without Docker's boilerplate, which would push the cause out of -/// a narrow column. -fn reason(detail: &str) -> String { - detail.replace("OCI runtime exec failed: exec failed: unable to start container process: ", "") -} - -fn event(kind: &skym_core::model::EventKind) -> String { - use skym_core::model::EventKind; - match kind { - EventKind::Deployed { from, to } => format!("deployed {to} (was {from})"), - EventKind::ConfigChanged => "configuration changed".into(), - EventKind::Restarted => "restarted".into(), - EventKind::OomKilled => "OOM killed".into(), - EventKind::HostRebooted => "host rebooted".into(), - EventKind::KernelChanged => "kernel changed".into(), - EventKind::Unknown => "(an event this version does not know)".into(), - } -} - /// Listed in a problems table: muted and hygiene ones only once asked for. fn shown(app: &App, i: &IncidentView) -> bool { (app.show_muted || !i.muted) && (app.show_info || i.severity != Severity::Info) diff --git a/crates/view/src/ui/preview.rs b/crates/view/src/ui/preview.rs index d8c3301..4526670 100644 --- a/crates/view/src/ui/preview.rs +++ b/crates/view/src/ui/preview.rs @@ -1,6 +1,7 @@ //! The preview under (or beside) a list: what the selected row is, without opening it. -use super::{Theme, ago, local, reason}; +use super::Theme; +use super::format::{ago, answer, app_count, cpu_use, local, memory, rate, reason, size, usage}; use crate::app::App; use crate::apps::{self, Group, UNCLASSIFIED}; use crate::problems::{Age, Row}; @@ -10,30 +11,6 @@ use ratatui::text::{Line, Span}; use skym_core::view::{AppSummary, DiskUse, HostOverview, Status, WorkloadSummary}; use unicode_width::UnicodeWidthChar; -/// `80G`, `512M`, `3.2T`. -pub fn size(bytes: u64) -> String { - let units = [("T", 1e12), ("G", 1e9), ("M", 1e6)]; - let b = bytes as f64; - match units.iter().find(|(_, scale)| b >= *scale) { - // One decimal below 10 (as it will be printed), none from there. - Some((unit, scale)) if (b / scale * 10.0).round() >= 100.0 => { - format!("{:.0}{unit}", b / scale) - } - Some((unit, scale)) => format!("{:.1}{unit}", b / scale), - None => format!("{}K", bytes / 1000), - } -} - -/// `1.4 / 3.9 GB`. -pub fn memory(used: Option, total: Option) -> String { - let gb = |b: u64| format!("{:.1}", b as f64 / 1e9); - match (used, total) { - (Some(used), Some(total)) => format!("{} / {} GB", gb(used), gb(total)), - (Some(used), None) => format!("{} GB", gb(used)), - _ => "—".into(), - } -} - /// Where a preview line's text starts, past its label. const INDENT: usize = 10; @@ -91,11 +68,6 @@ fn styled(cells: &[(char, Style)]) -> Vec> { .collect() } -/// `120M / 512M`, or `120M` without a limit. -pub fn usage(used: u64, limit: Option) -> String { - limit.map_or_else(|| size(used), |l| format!("{} / {}", size(used), size(l))) -} - pub fn label(text: &str, theme: Theme) -> Span<'static> { Span::styled(format!(" {text:<9}"), theme.dim()) } @@ -120,26 +92,6 @@ fn top_memory(apps: &[&AppSummary], theme: Theme) -> Option> { .then(|| Line::from(vec![label("top mem", theme), Span::raw(parts.join(" · "))])) } -/// `1 app`, `43 apps`. -pub fn app_count(n: usize) -> String { - if n == 1 { "1 app".into() } else { format!("{n} apps") } -} - -/// `23% busy · 4% iowait · load 0.93`; steal only from 1%, when a neighbour on the -/// hypervisor is taking time. -fn cpu_use(h: &HostOverview) -> String { - let parts: Vec = [ - h.cpu_percent.map(|p| format!("{p:.0}% busy")), - h.iowait_percent.map(|p| format!("{p:.0}% iowait")), - h.steal_percent.filter(|p| *p >= 1.0).map(|p| format!("{p:.0}% steal")), - Some(h.load_1m.map_or("load —".into(), |l| format!("load {l:.2}"))), - ] - .into_iter() - .flatten() - .collect(); - parts.join(" · ") -} - /// `tags acme · billing`, when it has any. fn tags(tags: &[String], theme: Theme) -> Option> { (!tags.is_empty()).then(|| Line::from(vec![label("tags", theme), Span::raw(tags.join(" · "))])) @@ -186,7 +138,7 @@ pub fn problem(app: &App, r: &Row, now: Timestamp, theme: Theme) -> Vec String::new(), }; lines.push(Line::from(vec![label("since", theme), Span::raw(format!("{opened}{watched}"))])); - match r.app().and_then(|k| app.apps.value.iter().flat_map(|l| &l.apps).find(|a| a.key == *k)) { + match r.app().and_then(|k| app.app_summary(k)) { Some(a) => lines.push(Line::from(vec![label("app", theme), Span::raw(app_line(a))])), None if r.app().is_none() => lines.push(Line::from(vec![ label("app", theme), @@ -200,8 +152,7 @@ pub fn problem(app: &App, r: &Row, now: Timestamp, theme: Theme) -> Vec Vec> { } lines.extend(tags(&a.tags, theme)); for e in &a.endpoints { - let answer = match (e.http_status, e.latency_ms) { - (Some(s), Some(ms)) => format!("{s} in {ms}ms"), - _ if e.last_probe_ago.is_none() => "not probed yet".into(), - _ => "no answer".into(), - }; + let answer = answer(e, true); let cert = e .cert_expires_at .map_or(String::new(), |t| format!(" · cert until {}", local(t, "%Y-%m-%d"))); @@ -352,17 +299,12 @@ pub fn host_body(app: &App, h: &HostOverview, now: Timestamp, theme: Theme) -> V ]), ]; if let (Some(rx), Some(tx)) = (h.net_rx_bytes_per_s, h.net_tx_bytes_per_s) { - let rate = |b: u64| match b { - 0..1000 => format!("{b}B/s"), - _ => format!("{}B/s", size(b)), - }; lines.push(Line::from(vec![ label("net", theme), Span::raw(format!("↓ {} ↑ {}", rate(rx), rate(tx))), ])); } - let on_host: Vec<&AppSummary> = - app.apps.value.iter().flat_map(|l| &l.apps).filter(|a| a.key.host == h.id).collect(); + let on_host: Vec<&AppSummary> = app.apps_iter().filter(|a| a.key.host == h.id).collect(); lines.extend(tags(&h.tags, theme)); lines.extend(h.disks.iter().map(|d| disk_line(d, theme))); let troubled: Vec = @@ -432,21 +374,4 @@ mod tests { assert_eq!(wrapped[0].to_string(), " note 学习平台后台"); assert_eq!(wrapped[1].to_string(), " 服务两个 · 每天部署"); } - - #[test] - fn sizes_read_at_a_glance() { - assert_eq!(size(80_000_000_000), "80G"); - assert_eq!(size(3_200_000_000_000), "3.2T"); - assert_eq!(size(512_000_000), "512M"); - assert_eq!(size(1_500_000_000), "1.5G"); - assert_eq!(size(9_999_000), "10M", "rounded up to ten, so no decimal"); - assert_eq!(size(9_940_000), "9.9M"); - assert_eq!(size(4_000), "4K"); - assert_eq!(memory(Some(1_400_000_000), Some(3_900_000_000)), "1.4 / 3.9 GB"); - assert_eq!(memory(None, Some(1)), "—"); - assert_eq!( - (usage(120_000_000, Some(512_000_000)), usage(120_000_000, None)), - ("120M / 512M".into(), "120M".into()) - ); - } } diff --git a/crates/view/src/ui/problems.rs b/crates/view/src/ui/problems.rs index 4aaee0a..c10d2c2 100644 --- a/crates/view/src/ui/problems.rs +++ b/crates/view/src/ui/problems.rs @@ -1,6 +1,7 @@ //! The problems tab: every open problem, new ones first, each named by its application. -use super::{Theme, age, block, draw_preview, empty_row, preview, reason, with_preview}; +use super::format::{age, reason}; +use super::{Theme, block, draw_preview, empty_row, preview, with_preview}; use crate::app::App; use crate::names; use crate::problems::Row; diff --git a/crates/view/src/ui/tests.rs b/crates/view/src/ui/tests.rs index 7a15afd..a9cd3ad 100644 --- a/crates/view/src/ui/tests.rs +++ b/crates/view/src/ui/tests.rs @@ -4,12 +4,13 @@ use crate::app::{Frame, Msg, Screen, update}; use crate::apps::{Grouping, Sort}; use ratatui::Terminal; use ratatui::backend::TestBackend; +use skym_core::fixtures; use skym_core::rules::IncidentCode; use skym_core::subject::{AppKey, Subject}; use skym_core::view::{ AppList, AppSummary, DiskUse, EndpointOverview, HostOverview, HostView, IncidentView, Overview, + WorkloadSummary, }; -use std::collections::BTreeMap; fn t(min: i64) -> Timestamp { Timestamp::from_second(1_790_000_000 + min * 60).unwrap() @@ -17,58 +18,31 @@ fn t(min: i64) -> Timestamp { /// An open problem on a host skym watches since minute 0, attributed as the server does. fn incident(subject: &str, code: IncidentCode, severity: Severity, opened: i64) -> IncidentView { - let subject: Subject = subject.parse().unwrap(); + let i = fixtures::incident(subject, code, severity); IncidentView { - app: match &subject { + app: match &i.subject { Subject::Workload(k) => Some(AppKey::of(k)), Subject::Endpoint(_) => Some("external/partner".parse().unwrap()), _ => None, }, - subject, - code, - severity, detail: format!("{} detail", code.as_str()), opened_at: Some(t(opened)), - since: None, - open_for: None, - resolved_at: None, - muted: false, - mute_reason: None, - links: BTreeMap::new(), observed_since: Some(t(0)), - workload: None, + ..i } } fn host(id: &str, status: Status, disks: Vec) -> HostOverview { HostOverview { - id: id.into(), status, - last_report_ago: Some("5s".into()), observed_since: Some(t(0)), - info_count: 0, - incidents: vec![], - links: BTreeMap::new(), load_1m: Some(1.3), memory_used_bytes: Some(9_800_000_000), memory_total_bytes: Some(31_000_000_000), disks, apps: 23, apps_in_trouble: 3, - os: None, - kernel: None, - arch: None, - cpu_count: None, - boot_time: None, - docker_version: None, - agent_version: None, - ip: None, - cpu_percent: None, - iowait_percent: None, - steal_percent: None, - net_rx_bytes_per_s: None, - net_tx_bytes_per_s: None, - tags: vec![], + ..fixtures::host_overview(id) } } @@ -234,22 +208,11 @@ fn the_hosts_tab_shows_load_disks_and_apps() { t(120), ); let warn = |key: &str| AppSummary { - key: key.parse().unwrap(), name: key.into(), - env: None, - note: None, - configured: false, status: Status::Warn, services: 1, running: 1, - last_deployed: None, - endpoints: vec![], - incidents: vec![], - links: BTreeMap::new(), - workloads: vec![], - deploys: vec![], - exceptions_1h: 0, - tags: vec![], + ..fixtures::app(key) }; let apps = AppList { apps: vec![warn("x/a"), warn("x/b")] }; update(&mut app, Msg::Fetched(Request::Apps, Ok(Box::new(Payload::Apps(apps)))), t(120)); @@ -277,22 +240,12 @@ fn a_host_shows_its_own_problems_and_its_apps() { incident("workload:x/app/fresh", IncidentCode::WorkloadDown, Severity::Critical, 100), ]; let app_summary = |key: &str, env: &str, status| AppSummary { - key: key.parse().unwrap(), - name: key.rsplit('/').next().unwrap().into(), env: Some(env.into()), - note: None, configured: true, status, services: 2, running: 2, - last_deployed: None, - endpoints: vec![], - incidents: vec![], - links: BTreeMap::new(), - workloads: vec![], - deploys: vec![], - exceptions_1h: 0, - tags: vec![], + ..fixtures::app(key) }; view.apps = vec![ app_summary("x/app", "prod", Status::Critical), @@ -404,22 +357,14 @@ fn a_service_hides_its_muted_problems_unless_asked() { #[test] fn the_applications_page_groups_by_environment() { let app_summary = |key: &str, env: Option<&str>, status: Status, endpoints| AppSummary { - key: key.parse().unwrap(), - name: key.rsplit('/').next().unwrap().into(), env: env.map(String::from), - note: None, configured: env.is_some(), status, services: 2, running: 1, last_deployed: Some(t(60)), endpoints, - incidents: vec![], - links: BTreeMap::new(), - workloads: vec![], - deploys: vec![], - exceptions_1h: 0, - tags: vec![], + ..fixtures::app(key) }; let list = AppList { apps: vec![ @@ -478,27 +423,17 @@ fn previewed(width: u16, height: u16) -> Vec { fresh.detail = "exited (1), for 20m, no restart policy, and a reason long enough to be cut in the list" .into(); - fresh.workload = Some(Box::new(skym_core::view::WorkloadSummary { - key: "workload:x/app/fresh" - .parse::() - .map(|s| match s { - Subject::Workload(k) => k, - _ => unreachable!(), - }) - .unwrap(), - kind: None, + fresh.workload = Some(Box::new(WorkloadSummary { status: Status::Critical, run: skym_core::model::RunState::Exited, exit_code: Some(1), - state_since: None, image: Some("registry.example.com/fresh:1.4".into()), - links: BTreeMap::new(), restart_policy: Some("unless-stopped".into()), ports: vec!["0.0.0.0:8080->80/tcp".into()], memory_used_bytes: Some(120_000_000), memory_limit_bytes: Some(512_000_000), restarts_last_hour: 2, - health_output: None, + ..fixtures::workload("x/app/fresh") })); o.hosts[0].os = Some("Ubuntu 22.04".into()); o.hosts[0].ip = Some("203.0.113.7".into()); @@ -540,18 +475,13 @@ fn on_a_wide_terminal_the_preview_sits_beside_the_list() { } /// A workload on x using this much memory. -fn using(bytes: u64) -> skym_core::view::WorkloadSummary { - serde_json::from_value(serde_json::json!({ - "key": { "host": "x", "project": "p", "service": "s" }, - "status": "ok", "run": "running", "memory_used_bytes": bytes - })) - .unwrap() +fn using(bytes: u64) -> WorkloadSummary { + WorkloadSummary { memory_used_bytes: Some(bytes), ..fixtures::workload("x/p/s") } } #[test] fn the_apps_and_hosts_tabs_preview_their_selection() { let mut a = AppSummary { - key: "x/shop".parse().unwrap(), name: "Shop".into(), env: Some("prod".into()), note: Some("the web shop".into()), @@ -559,11 +489,7 @@ fn the_apps_and_hosts_tabs_preview_their_selection() { status: Status::Warn, services: 1, running: 1, - last_deployed: None, endpoints: vec![endpoint("https://shop.example.com/", Some(84), vec![])], - incidents: vec![], - links: BTreeMap::new(), - workloads: vec![], deploys: vec![skym_core::view::Deploy { ts: t(60), service: "web".into(), @@ -572,6 +498,7 @@ fn the_apps_and_hosts_tabs_preview_their_selection() { }], exceptions_1h: 7, tags: vec!["acme".into(), "eu".into()], + ..fixtures::app("x/shop") }; a.endpoints[0].cert_expires_at = Some(t(60 * 24 * 30)); a.workloads = vec![using(3_000_000_000), using(100_000_000)]; diff --git a/crates/view/src/ui/timeline.rs b/crates/view/src/ui/timeline.rs index 5172808..925397a 100644 --- a/crates/view/src/ui/timeline.rs +++ b/crates/view/src/ui/timeline.rs @@ -1,6 +1,7 @@ //! What happened, newest first: incidents and events, deployments stand out. -use super::{Theme, block, empty_row, event, local}; +use super::format::{event, local}; +use super::{Theme, block, empty_row}; use crate::app::{App, Screen, WINDOWS}; use crate::names::short; use ratatui::Frame; diff --git a/crates/view/src/ui/workload.rs b/crates/view/src/ui/workload.rs index 83cf616..c8806ed 100644 --- a/crates/view/src/ui/workload.rs +++ b/crates/view/src/ui/workload.rs @@ -1,7 +1,8 @@ //! One service: its problems, state and facts side by side, exceptions, recent events. //! Also a host's exceptions, which read the same way. -use super::{Theme, ago, block, empty_row, event, problem_table, reason}; +use super::format::{ago, event, local, reason}; +use super::{Theme, block, empty_row, problem_table}; use crate::app::App; use crate::names::short; use jiff::Timestamp; @@ -196,7 +197,7 @@ fn fact_lines(facts: Option<&WorkloadFacts>) -> Vec> { )), Line::from(format!( " created {}", - f.created.map_or("—".into(), |t| super::local(t, "%Y-%m-%d %H:%M")) + f.created.map_or("—".into(), |t| local(t, "%Y-%m-%d %H:%M")) )), ] }