max / makenotwork
6 files changed,
+1240 insertions,
-28 deletions
| @@ -6,6 +6,13 @@ | |||
| 6 | 6 | # Adding a service is an edit to this file and nothing else. The viewer knows | |
| 7 | 7 | # nothing about tiers, gates, apps or targets; it renders whatever the source | |
| 8 | 8 | # emits at GET /status.json. A new daemon gets a UI by emitting the payload. | |
| 9 | + | # | |
| 10 | + | # The viewer is read-only until a source sets allow_actions = true. A source | |
| 11 | + | # with it off still shows the actions its nodes declare; it just refuses to | |
| 12 | + | # fire them. With it on, Enter on a node opens a picker of that node's actions; | |
| 13 | + | # a plain action fires, a confirm action asks for 'y', and a danger action | |
| 14 | + | # (sando's rollback-b) asks you to type its key. This keeps a viewer that is | |
| 15 | + | # merely pointed at a daemon from ever moving it by accident. | |
| 9 | 16 | ||
| 10 | 17 | # Age past which a source's answer stops counting as current, for any source | |
| 11 | 18 | # that does not set its own. A source that answers with something hours old is | |
| @@ -22,6 +29,9 @@ url = "http://fw13:8080" | |||
| 22 | 29 | # while sandod already takes the same value from its environment. | |
| 23 | 30 | token_env = "SANDO_API_TOKEN" | |
| 24 | 31 | poll_secs = 5 | |
| 32 | + | # Sando declares promote and rollback actions that move production. Leave this | |
| 33 | + | # off to watch read-only; turn it on deliberately to drive deploys from here. | |
| 34 | + | # allow_actions = true | |
| 25 | 35 | ||
| 26 | 36 | [[source]] | |
| 27 | 37 | name = "bento" |
| @@ -49,6 +49,16 @@ pub struct Source { | |||
| 49 | 49 | pub poll_secs: u64, | |
| 50 | 50 | #[serde(default)] | |
| 51 | 51 | pub stale_after_secs: Option<u64>, | |
| 52 | + | /// Whether this source's declared actions may be fired from the viewer. | |
| 53 | + | /// | |
| 54 | + | /// Defaults off. A viewer is a display first, and some declared actions | |
| 55 | + | /// (sando's `promote-b`, `rollback-b`) move production. Firing is opt-in per | |
| 56 | + | /// source so that pointing the viewer at a daemon can never move it by | |
| 57 | + | /// accident: the operator turns a source's actions on deliberately, the same | |
| 58 | + | /// place they already name its token. A source with this off still renders | |
| 59 | + | /// its actions; it just refuses to issue them. | |
| 60 | + | #[serde(default)] | |
| 61 | + | pub allow_actions: bool, | |
| 52 | 62 | } | |
| 53 | 63 | ||
| 54 | 64 | fn default_poll() -> u64 { | |
| @@ -68,6 +78,22 @@ impl Source { | |||
| 68 | 78 | Duration::from_secs(self.poll_secs.max(1)) | |
| 69 | 79 | } | |
| 70 | 80 | ||
| 81 | + | /// Resolve a declared action's `url` against this source's base. | |
| 82 | + | /// | |
| 83 | + | /// An action carries a path (`/rollback/b`); a producer that instead gives a | |
| 84 | + | /// full URL is honored as-is, so a daemon can point an action at somewhere | |
| 85 | + | /// other than itself without the viewer second-guessing it. | |
| 86 | + | pub fn action_url(&self, path: &str) -> String { | |
| 87 | + | if path.starts_with("http://") || path.starts_with("https://") { | |
| 88 | + | return path.to_string(); | |
| 89 | + | } | |
| 90 | + | format!( | |
| 91 | + | "{}/{}", | |
| 92 | + | self.url.trim_end_matches('/'), | |
| 93 | + | path.trim_start_matches('/') | |
| 94 | + | ) | |
| 95 | + | } | |
| 96 | + | ||
| 71 | 97 | /// Resolve the bearer token from the environment, if one is named. | |
| 72 | 98 | pub fn token(&self) -> Option<String> { | |
| 73 | 99 | self.token_env | |
| @@ -145,6 +171,29 @@ url = "http://fw13:8080" | |||
| 145 | 171 | } | |
| 146 | 172 | ||
| 147 | 173 | #[test] | |
| 174 | + | fn an_action_path_resolves_against_the_base_and_a_full_url_is_left_alone() { | |
| 175 | + | let source: Source = toml::from_str( | |
| 176 | + | r#" | |
| 177 | + | name = "sando" | |
| 178 | + | url = "http://fw13:8080/" | |
| 179 | + | "#, | |
| 180 | + | ) | |
| 181 | + | .unwrap(); | |
| 182 | + | assert_eq!( | |
| 183 | + | source.action_url("/rollback/b"), | |
| 184 | + | "http://fw13:8080/rollback/b" | |
| 185 | + | ); | |
| 186 | + | assert_eq!( | |
| 187 | + | source.action_url("rollback/b"), | |
| 188 | + | "http://fw13:8080/rollback/b" | |
| 189 | + | ); | |
| 190 | + | assert_eq!( | |
| 191 | + | source.action_url("https://elsewhere/x"), | |
| 192 | + | "https://elsewhere/x" | |
| 193 | + | ); | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | #[test] | |
| 148 | 197 | fn a_trailing_slash_does_not_double_up() { | |
| 149 | 198 | let (_dir, path) = write( | |
| 150 | 199 | r#" | |
| @@ -227,6 +276,28 @@ url = "fw13:8080" | |||
| 227 | 276 | } | |
| 228 | 277 | ||
| 229 | 278 | #[test] | |
| 279 | + | fn actions_are_off_unless_a_source_opts_in() { | |
| 280 | + | let (_dir, path) = write( | |
| 281 | + | r#" | |
| 282 | + | [[source]] | |
| 283 | + | name = "pom" | |
| 284 | + | url = "http://pom:9000" | |
| 285 | + | ||
| 286 | + | [[source]] | |
| 287 | + | name = "sando" | |
| 288 | + | url = "http://fw13:8080" | |
| 289 | + | allow_actions = true | |
| 290 | + | "#, | |
| 291 | + | ); | |
| 292 | + | let cfg = Config::load(&path).unwrap(); | |
| 293 | + | assert!( | |
| 294 | + | !cfg.sources[0].allow_actions, | |
| 295 | + | "a source must not be able to move production by default" | |
| 296 | + | ); | |
| 297 | + | assert!(cfg.sources[1].allow_actions); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | #[test] | |
| 230 | 301 | fn a_token_is_read_from_the_environment_never_the_file() { | |
| 231 | 302 | let (_dir, path) = write( | |
| 232 | 303 | r#" |
| @@ -0,0 +1,191 @@ | |||
| 1 | + | //! Issuing a declared action. | |
| 2 | + | //! | |
| 3 | + | //! The mirror of [`crate::poll`]: where polling reads state on a timer, this | |
| 4 | + | //! writes it once, on demand, and reports the outcome back on a channel the UI | |
| 5 | + | //! drains. The split is the same and for the same reason — the network effect | |
| 6 | + | //! lives at the edge, off the render loop, so a slow or hung daemon cannot | |
| 7 | + | //! freeze the surface you are trying to read. | |
| 8 | + | //! | |
| 9 | + | //! The viewer never learns what an action *means*. It has a method, a URL, an | |
| 10 | + | //! optional body, and a bearer token, all declared by the producer, and it | |
| 11 | + | //! issues exactly that. `promote` and `rollback` are opaque strings here. | |
| 12 | + | ||
| 13 | + | use std::time::Duration; | |
| 14 | + | ||
| 15 | + | use ops_status::{Action, Method}; | |
| 16 | + | use tokio::sync::mpsc; | |
| 17 | + | ||
| 18 | + | use crate::config::Source; | |
| 19 | + | ||
| 20 | + | /// The result of one fired action, tagged with the key that fired it so the UI | |
| 21 | + | /// can name it in the footer without tracking the request itself. | |
| 22 | + | #[derive(Debug, Clone)] | |
| 23 | + | pub struct Outcome { | |
| 24 | + | pub key: String, | |
| 25 | + | /// `Ok` carries the 2xx status code; `Err` a short reason. | |
| 26 | + | pub result: Result<u16, String>, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// How long a fired action may take before it counts as failed. | |
| 30 | + | /// | |
| 31 | + | /// Far longer than a poll's 10s: a declared action can be a real operation | |
| 32 | + | /// (sando's `promote` runs synchronously today), and cutting the connection | |
| 33 | + | /// early would abort work the server is mid-way through. Still bounded, so a | |
| 34 | + | /// daemon that never answers does not leak a task forever. A synchronous | |
| 35 | + | /// operation that genuinely outlasts this is a daemon-shape problem, not a | |
| 36 | + | /// viewer one; the next poll still shows the true resulting state. | |
| 37 | + | const ACTION_TIMEOUT: Duration = Duration::from_secs(120); | |
| 38 | + | ||
| 39 | + | /// Body text kept from a failed response, past which it is noise in a one-line | |
| 40 | + | /// footer. | |
| 41 | + | const BODY_KEEP: usize = 120; | |
| 42 | + | ||
| 43 | + | /// Fire `action` at `source`, reporting the outcome on `tx`. | |
| 44 | + | /// | |
| 45 | + | /// Spawns a detached task and returns immediately; the caller must be inside a | |
| 46 | + | /// Tokio runtime (the UI loop is). A send failure means the UI is gone, which | |
| 47 | + | /// is not this task's problem to solve. | |
| 48 | + | pub fn fire(source: &Source, action: Action, key: String, tx: mpsc::Sender<Outcome>) { | |
| 49 | + | let url = source.action_url(&action.url); | |
| 50 | + | let token = source.token(); | |
| 51 | + | tokio::spawn(async move { | |
| 52 | + | let result = issue(url, action, token).await; | |
| 53 | + | let _ = tx.send(Outcome { key, result }).await; | |
| 54 | + | }); | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | async fn issue(url: String, action: Action, token: Option<String>) -> Result<u16, String> { | |
| 58 | + | let client = reqwest::Client::builder() | |
| 59 | + | .timeout(ACTION_TIMEOUT) | |
| 60 | + | .build() | |
| 61 | + | .map_err(|e| format!("client: {e}"))?; | |
| 62 | + | ||
| 63 | + | let mut request = client.request(method(action.method), &url); | |
| 64 | + | if let Some(token) = token { | |
| 65 | + | request = request.bearer_auth(token); | |
| 66 | + | } | |
| 67 | + | if let Some(body) = &action.body { | |
| 68 | + | request = request.json(body); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | let response = request.send().await.map_err(short_error)?; | |
| 72 | + | let status = response.status(); | |
| 73 | + | if status.is_success() { | |
| 74 | + | return Ok(status.as_u16()); | |
| 75 | + | } | |
| 76 | + | // A non-2xx from an action is usually meaningful — a 409 is "a gate said | |
| 77 | + | // no", not a transport failure — so keep a little of the body, unlike a | |
| 78 | + | // poll where the status code alone is the whole story. | |
| 79 | + | let code = status.as_u16(); | |
| 80 | + | if code == 401 || code == 403 { | |
| 81 | + | return Err("unauthorized (check the source's token_env)".into()); | |
| 82 | + | } | |
| 83 | + | let body = response.text().await.unwrap_or_default(); | |
| 84 | + | let trimmed = body.trim(); | |
| 85 | + | if trimmed.is_empty() { | |
| 86 | + | Err(format!("HTTP {code}")) | |
| 87 | + | } else { | |
| 88 | + | Err(format!("HTTP {code}: {}", clip(trimmed))) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | fn method(method: Method) -> reqwest::Method { | |
| 93 | + | match method { | |
| 94 | + | Method::Get => reqwest::Method::GET, | |
| 95 | + | Method::Post => reqwest::Method::POST, | |
| 96 | + | Method::Put => reqwest::Method::PUT, | |
| 97 | + | Method::Delete => reqwest::Method::DELETE, | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// One line's worth of a failure body, newlines flattened. | |
| 102 | + | fn clip(body: &str) -> String { | |
| 103 | + | let flat = body.replace('\n', " "); | |
| 104 | + | if flat.chars().count() <= BODY_KEEP { | |
| 105 | + | return flat; | |
| 106 | + | } | |
| 107 | + | let kept: String = flat.chars().take(BODY_KEEP.saturating_sub(1)).collect(); | |
| 108 | + | format!("{kept}…") | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// The same short-reason treatment `poll` gives transport errors: the footer has | |
| 112 | + | /// one line, so keep the part that says what broke. | |
| 113 | + | fn short_error(e: reqwest::Error) -> String { | |
| 114 | + | if e.is_timeout() { | |
| 115 | + | return "timed out".into(); | |
| 116 | + | } | |
| 117 | + | if e.is_connect() { | |
| 118 | + | return "connection refused".into(); | |
| 119 | + | } | |
| 120 | + | let text = e.to_string(); | |
| 121 | + | text.split(':') | |
| 122 | + | .next_back() | |
| 123 | + | .unwrap_or(&text) | |
| 124 | + | .trim() | |
| 125 | + | .to_string() | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | #[cfg(test)] | |
| 129 | + | mod tests { | |
| 130 | + | use super::*; | |
| 131 | + | ||
| 132 | + | fn source(url: &str) -> Source { | |
| 133 | + | toml::from_str(&format!("name = \"sando\"\nurl = \"{url}\"\n")).unwrap() | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[test] | |
| 137 | + | fn methods_map_across_to_reqwest() { | |
| 138 | + | assert_eq!(method(Method::Get), reqwest::Method::GET); | |
| 139 | + | assert_eq!(method(Method::Post), reqwest::Method::POST); | |
| 140 | + | assert_eq!(method(Method::Put), reqwest::Method::PUT); | |
| 141 | + | assert_eq!(method(Method::Delete), reqwest::Method::DELETE); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | #[test] | |
| 145 | + | fn a_failure_body_is_clipped_to_one_line() { | |
| 146 | + | assert_eq!(clip("gate\nnot\nsatisfied"), "gate not satisfied"); | |
| 147 | + | let long = clip(&"x".repeat(400)); | |
| 148 | + | assert!(long.ends_with('…')); | |
| 149 | + | assert_eq!(long.chars().count(), BODY_KEEP); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | #[tokio::test] | |
| 153 | + | async fn an_unreachable_daemon_reports_a_short_reason_not_a_url_chain() { | |
| 154 | + | // Port 1 on loopback refuses immediately. | |
| 155 | + | let err = issue( | |
| 156 | + | "http://127.0.0.1:1/rollback/b".into(), | |
| 157 | + | Action { | |
| 158 | + | label: "Roll back".into(), | |
| 159 | + | method: Method::Post, | |
| 160 | + | url: "/rollback/b".into(), | |
| 161 | + | confirm: true, | |
| 162 | + | danger: true, | |
| 163 | + | body: None, | |
| 164 | + | }, | |
| 165 | + | None, | |
| 166 | + | ) | |
| 167 | + | .await | |
| 168 | + | .unwrap_err(); | |
| 169 | + | assert!(err.len() < 60, "{err:?}"); | |
| 170 | + | assert!(!err.contains("http://"), "{err:?}"); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | #[test] | |
| 174 | + | fn a_body_carrying_action_serializes_it() { | |
| 175 | + | // Bento's actions take a JSON body; this asserts the wiring compiles and | |
| 176 | + | // the body survives onto the request builder without a per-daemon shape. | |
| 177 | + | let action = Action { | |
| 178 | + | label: "Publish".into(), | |
| 179 | + | method: Method::Post, | |
| 180 | + | url: "/publish".into(), | |
| 181 | + | confirm: true, | |
| 182 | + | danger: false, | |
| 183 | + | body: Some(serde_json::json!({"app": "goingson", "target": "macos"})), | |
| 184 | + | }; | |
| 185 | + | assert_eq!( | |
| 186 | + | source("http://x").action_url(&action.url), | |
| 187 | + | "http://x/publish" | |
| 188 | + | ); | |
| 189 | + | assert!(action.body.is_some()); | |
| 190 | + | } | |
| 191 | + | } |
| @@ -20,6 +20,7 @@ | |||
| 20 | 20 | //! surface that tries to be both becomes one nobody watches. | |
| 21 | 21 | ||
| 22 | 22 | mod config; | |
| 23 | + | mod exec; | |
| 23 | 24 | mod model; | |
| 24 | 25 | mod poll; | |
| 25 | 26 | mod render; | |
| @@ -31,9 +32,10 @@ use std::time::Duration; | |||
| 31 | 32 | use anyhow::{Context, Result}; | |
| 32 | 33 | use chrono::Utc; | |
| 33 | 34 | use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; | |
| 35 | + | use tokio::sync::mpsc; | |
| 34 | 36 | ||
| 35 | - | use crate::config::Config; | |
| 36 | - | use crate::model::{Model, SourceState}; | |
| 37 | + | use crate::config::{Config, Source}; | |
| 38 | + | use crate::model::{FireRequest, Model, Prompt, PromptStep, SourceState}; | |
| 37 | 39 | ||
| 38 | 40 | /// How often the UI redraws when nothing has arrived. | |
| 39 | 41 | /// | |
| @@ -48,18 +50,28 @@ fn main() -> Result<()> { | |||
| 48 | 50 | let runtime = tokio::runtime::Runtime::new().context("starting the async runtime")?; | |
| 49 | 51 | let _guard = runtime.enter(); | |
| 50 | 52 | let updates = poll::spawn_all(&cfg.sources); | |
| 53 | + | // Fired actions report back here. One channel for the whole session; a | |
| 54 | + | // handful of in-flight actions is the realistic ceiling. | |
| 55 | + | let (action_tx, action_rx) = mpsc::channel(cfg.sources.len().max(1) * 4); | |
| 51 | 56 | ||
| 52 | 57 | let sources = cfg | |
| 53 | 58 | .sources | |
| 54 | 59 | .iter() | |
| 55 | - | .map(|s| SourceState::new(&s.name, cfg.stale_after(s))) | |
| 60 | + | .map(|s| SourceState::new(&s.name, cfg.stale_after(s)).with_actions(s.allow_actions)) | |
| 56 | 61 | .collect(); | |
| 57 | 62 | let model = Model::new(sources); | |
| 58 | 63 | ||
| 59 | 64 | // The fallible variants: `init()` panics when there is no terminal, which | |
| 60 | 65 | // turns "you piped this into less" into a backtrace. | |
| 61 | 66 | let mut terminal = ratatui::try_init().context("this needs a terminal (no TTY attached)")?; | |
| 62 | - | let outcome = run(&mut terminal, model, updates); | |
| 67 | + | let outcome = run( | |
| 68 | + | &mut terminal, | |
| 69 | + | model, | |
| 70 | + | updates, | |
| 71 | + | cfg.sources, | |
| 72 | + | action_tx, | |
| 73 | + | action_rx, | |
| 74 | + | ); | |
| 63 | 75 | let restored = ratatui::try_restore(); | |
| 64 | 76 | // Report the run's own failure first; a restore problem is the lesser news | |
| 65 | 77 | // and must not mask why the app actually stopped. | |
| @@ -81,7 +93,10 @@ fn config_path() -> Result<PathBuf> { | |||
| 81 | 93 | fn run( | |
| 82 | 94 | terminal: &mut ratatui::DefaultTerminal, | |
| 83 | 95 | mut model: Model, | |
| 84 | - | mut updates: tokio::sync::mpsc::Receiver<poll::Update>, | |
| 96 | + | mut updates: mpsc::Receiver<poll::Update>, | |
| 97 | + | sources: Vec<Source>, | |
| 98 | + | action_tx: mpsc::Sender<exec::Outcome>, | |
| 99 | + | mut action_rx: mpsc::Receiver<exec::Outcome>, | |
| 85 | 100 | ) -> Result<()> { | |
| 86 | 101 | loop { | |
| 87 | 102 | let now = Utc::now(); | |
| @@ -92,14 +107,54 @@ fn run( | |||
| 92 | 107 | while let Ok(update) = updates.try_recv() { | |
| 93 | 108 | apply(&mut model, update); | |
| 94 | 109 | } | |
| 110 | + | // Fired-action outcomes land in the footer. | |
| 111 | + | while let Ok(outcome) = action_rx.try_recv() { | |
| 112 | + | model.message = Some(match outcome.result { | |
| 113 | + | Ok(code) => format!("{}: ok ({code})", outcome.key), | |
| 114 | + | Err(reason) => format!("{}: {reason}", outcome.key), | |
| 115 | + | }); | |
| 116 | + | } | |
| 95 | 117 | ||
| 96 | 118 | if event::poll(TICK)? | |
| 97 | 119 | && let Event::Key(key) = event::read()? | |
| 98 | 120 | && key.kind == KeyEventKind::Press | |
| 99 | - | && handle_key(&mut model, key) == Flow::Quit | |
| 100 | 121 | { | |
| 101 | - | return Ok(()); | |
| 122 | + | let result = handle_key(&mut model, key); | |
| 123 | + | if let Some(request) = result.fire { | |
| 124 | + | dispatch(&mut model, &sources, request, &action_tx); | |
| 125 | + | } | |
| 126 | + | if result.flow == Flow::Quit { | |
| 127 | + | return Ok(()); | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// Resolve a confirmed request against the live payload and fire it. | |
| 134 | + | /// | |
| 135 | + | /// The action is looked up again here, not trusted from when the prompt opened: | |
| 136 | + | /// a poll in between can retract it, and firing a `promote` the daemon no longer | |
| 137 | + | /// offers is exactly the surprise the whole confirm path exists to prevent. | |
| 138 | + | fn dispatch( | |
| 139 | + | model: &mut Model, | |
| 140 | + | sources: &[Source], | |
| 141 | + | request: FireRequest, | |
| 142 | + | action_tx: &mpsc::Sender<exec::Outcome>, | |
| 143 | + | ) { | |
| 144 | + | let Some(source) = sources.get(request.source) else { | |
| 145 | + | return; | |
| 146 | + | }; | |
| 147 | + | let action = model | |
| 148 | + | .sources | |
| 149 | + | .get(request.source) | |
| 150 | + | .and_then(|s| s.action(&request.key)) | |
| 151 | + | .cloned(); | |
| 152 | + | match action { | |
| 153 | + | Some(action) => { | |
| 154 | + | exec::fire(source, action, request.key.clone(), action_tx.clone()); | |
| 155 | + | model.message = Some(format!("{}: sent", request.key)); | |
| 102 | 156 | } | |
| 157 | + | None => model.message = Some(format!("{}: no longer offered", request.key)), | |
| 103 | 158 | } | |
| 104 | 159 | } | |
| 105 | 160 | ||
| @@ -119,24 +174,115 @@ enum Flow { | |||
| 119 | 174 | Quit, | |
| 120 | 175 | } | |
| 121 | 176 | ||
| 122 | - | fn handle_key(model: &mut Model, key: KeyEvent) -> Flow { | |
| 177 | + | /// What one keypress asked of the loop: whether to keep running, and any | |
| 178 | + | /// confirmed action to fire. | |
| 179 | + | struct KeyResult { | |
| 180 | + | flow: Flow, | |
| 181 | + | fire: Option<FireRequest>, | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | impl KeyResult { | |
| 185 | + | fn cont() -> Self { | |
| 186 | + | KeyResult { | |
| 187 | + | flow: Flow::Continue, | |
| 188 | + | fire: None, | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | fn quit() -> Self { | |
| 192 | + | KeyResult { | |
| 193 | + | flow: Flow::Quit, | |
| 194 | + | fire: None, | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | fn fire(request: FireRequest) -> Self { | |
| 198 | + | KeyResult { | |
| 199 | + | flow: Flow::Continue, | |
| 200 | + | fire: Some(request), | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | fn handle_key(model: &mut Model, key: KeyEvent) -> KeyResult { | |
| 206 | + | // Ctrl-C is the one key that means the same thing in every mode, including | |
| 207 | + | // mid-way through typing an action key to confirm it. | |
| 208 | + | if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { | |
| 209 | + | return KeyResult::quit(); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // A prompt captures the keyboard: while it is open, keys drive it and | |
| 213 | + | // nothing else, so 'q' typed into a confirmation is text, not a quit. | |
| 214 | + | if model.prompt.is_some() { | |
| 215 | + | return handle_prompt_key(model, key); | |
| 216 | + | } | |
| 217 | + | ||
| 123 | 218 | let now = Utc::now(); | |
| 124 | 219 | model.message = None; | |
| 125 | - | ||
| 126 | 220 | match key.code { | |
| 127 | - | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return Flow::Quit, | |
| 128 | - | KeyCode::Char('q') | KeyCode::Esc => return Flow::Quit, | |
| 221 | + | KeyCode::Char('q') | KeyCode::Esc => return KeyResult::quit(), | |
| 129 | 222 | KeyCode::Tab | KeyCode::Right => model.next_tab(), | |
| 130 | 223 | KeyCode::BackTab | KeyCode::Left => model.prev_tab(), | |
| 131 | 224 | KeyCode::Down | KeyCode::Char('j') => model.move_selection(1, now), | |
| 132 | 225 | KeyCode::Up | KeyCode::Char('k') => model.move_selection(-1, now), | |
| 133 | - | KeyCode::Enter => model.open_selected(now), | |
| 226 | + | KeyCode::Enter => match model.tab { | |
| 227 | + | model::Tab::Rollup => model.open_selected(now), | |
| 228 | + | model::Tab::Source(_) => model.open_actions(), | |
| 229 | + | }, | |
| 134 | 230 | KeyCode::Char(c) if c.is_ascii_digit() => { | |
| 135 | 231 | model.select_tab(c.to_digit(10).unwrap_or(0) as usize); | |
| 136 | 232 | } | |
| 137 | 233 | _ => {} | |
| 138 | 234 | } | |
| 139 | - | Flow::Continue | |
| 235 | + | KeyResult::cont() | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | /// Keys while a prompt is open. Each variant answers only the keys that make | |
| 239 | + | /// sense for it; Esc always cancels. | |
| 240 | + | fn handle_prompt_key(model: &mut Model, key: KeyEvent) -> KeyResult { | |
| 241 | + | let step = match &model.prompt { | |
| 242 | + | Some(Prompt::Pick { .. }) => match key.code { | |
| 243 | + | KeyCode::Esc => model.cancel_prompt(), | |
| 244 | + | KeyCode::Down | KeyCode::Char('j') => { | |
| 245 | + | model.prompt_move(1); | |
| 246 | + | PromptStep::Idle | |
| 247 | + | } | |
| 248 | + | KeyCode::Up | KeyCode::Char('k') => { | |
| 249 | + | model.prompt_move(-1); | |
| 250 | + | PromptStep::Idle | |
| 251 | + | } | |
| 252 | + | KeyCode::Char(c) if c.is_ascii_digit() => { | |
| 253 | + | model.prompt_digit(c.to_digit(10).unwrap_or(0) as usize); | |
| 254 | + | PromptStep::Idle | |
| 255 | + | } | |
| 256 | + | KeyCode::Enter => model.prompt_enter(), | |
| 257 | + | _ => PromptStep::Idle, | |
| 258 | + | }, | |
| 259 | + | Some(Prompt::Confirm { .. }) => match key.code { | |
| 260 | + | // 'y' is the only key that fires; 'n'/Esc back out; the rest do | |
| 261 | + | // nothing, so a fat-fingered key neither fires nor loses the prompt. | |
| 262 | + | KeyCode::Char('y') | KeyCode::Char('Y') => model.confirm_yes(), | |
| 263 | + | KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => model.cancel_prompt(), | |
| 264 | + | _ => PromptStep::Idle, | |
| 265 | + | }, | |
| 266 | + | Some(Prompt::Type { .. }) => match key.code { | |
| 267 | + | KeyCode::Esc => model.cancel_prompt(), | |
| 268 | + | KeyCode::Enter => model.prompt_enter(), | |
| 269 | + | KeyCode::Backspace => { | |
| 270 | + | model.prompt_backspace(); | |
| 271 | + | PromptStep::Idle | |
| 272 | + | } | |
| 273 | + | KeyCode::Char(c) => { | |
| 274 | + | model.prompt_push(c); | |
| 275 | + | PromptStep::Idle | |
| 276 | + | } | |
| 277 | + | _ => PromptStep::Idle, | |
| 278 | + | }, | |
| 279 | + | None => PromptStep::Idle, | |
| 280 | + | }; | |
| 281 | + | ||
| 282 | + | match step { | |
| 283 | + | PromptStep::Fire(request) => KeyResult::fire(request), | |
| 284 | + | PromptStep::Idle | PromptStep::Cancelled => KeyResult::cont(), | |
| 285 | + | } | |
| 140 | 286 | } | |
| 141 | 287 | ||
| 142 | 288 | #[cfg(test)] | |
| @@ -158,23 +304,25 @@ mod tests { | |||
| 158 | 304 | ) | |
| 159 | 305 | } | |
| 160 | 306 | ||
| 307 | + | fn flow(model: &mut Model, code: KeyCode) -> Flow { | |
| 308 | + | handle_key(model, key(code)).flow | |
| 309 | + | } | |
| 310 | + | ||
| 161 | 311 | #[test] | |
| 162 | 312 | fn q_and_ctrl_c_quit_and_nothing_else_does() { | |
| 163 | 313 | let mut model = model_with(&["sando"]); | |
| 164 | - | assert_eq!(handle_key(&mut model, key(KeyCode::Char('q'))), Flow::Quit); | |
| 165 | - | assert_eq!(handle_key(&mut model, key(KeyCode::Esc)), Flow::Quit); | |
| 314 | + | assert_eq!(flow(&mut model, KeyCode::Char('q')), Flow::Quit); | |
| 315 | + | assert_eq!(flow(&mut model, KeyCode::Esc), Flow::Quit); | |
| 166 | 316 | assert_eq!( | |
| 167 | 317 | handle_key( | |
| 168 | 318 | &mut model, | |
| 169 | 319 | KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL) | |
| 170 | - | ), | |
| 320 | + | ) | |
| 321 | + | .flow, | |
| 171 | 322 | Flow::Quit | |
| 172 | 323 | ); | |
| 173 | 324 | // A bare 'c' is not a quit. | |
| 174 | - | assert_eq!( | |
| 175 | - | handle_key(&mut model, key(KeyCode::Char('c'))), | |
| 176 | - | Flow::Continue | |
| 177 | - | ); | |
| 325 | + | assert_eq!(flow(&mut model, KeyCode::Char('c')), Flow::Continue); | |
| 178 | 326 | } | |
| 179 | 327 | ||
| 180 | 328 | #[test] | |
| @@ -235,6 +383,126 @@ mod tests { | |||
| 235 | 383 | assert!(model.sources[0].payload.is_some()); | |
| 236 | 384 | } | |
| 237 | 385 | ||
| 386 | + | fn ctrl(c: char) -> KeyEvent { | |
| 387 | + | KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | /// A source with one node declaring a danger action, actions allowed, on | |
| 391 | + | /// its tab. | |
| 392 | + | fn armed(danger: bool) -> Model { | |
| 393 | + | use ops_status::{Action, Method, Node, Payload, Status}; | |
| 394 | + | let mut node = Node { | |
| 395 | + | id: "tier:b".into(), | |
| 396 | + | kind: "tier".into(), | |
| 397 | + | label: "b".into(), | |
| 398 | + | status: Status::Ok, | |
| 399 | + | fields: vec![], | |
| 400 | + | conditions: vec![], | |
| 401 | + | children: vec![], | |
| 402 | + | actions: vec!["rollback-b".into()], | |
| 403 | + | }; | |
| 404 | + | node.actions = vec!["rollback-b".into()]; | |
| 405 | + | let mut p = Payload::new("sando", Utc::now()); | |
| 406 | + | p.nodes = vec![node]; | |
| 407 | + | p.actions.insert( | |
| 408 | + | "rollback-b".into(), | |
| 409 | + | Action { | |
| 410 | + | label: "Roll back".into(), | |
| 411 | + | method: Method::Post, | |
| 412 | + | url: "/rollback/b".into(), | |
| 413 | + | confirm: true, | |
| 414 | + | danger, | |
| 415 | + | body: None, | |
| 416 | + | }, | |
| 417 | + | ); | |
| 418 | + | let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true); | |
| 419 | + | s.observe(p, Utc::now()); | |
| 420 | + | let mut m = Model::new(vec![s]); | |
| 421 | + | m.select_tab(1); | |
| 422 | + | m | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | #[test] | |
| 426 | + | fn enter_on_a_source_node_opens_the_action_picker() { | |
| 427 | + | let mut m = armed(true); | |
| 428 | + | assert!(m.prompt.is_none()); | |
| 429 | + | handle_key(&mut m, key(KeyCode::Enter)); | |
| 430 | + | assert!(matches!(m.prompt, Some(Prompt::Pick { .. }))); | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | #[test] | |
| 434 | + | fn a_full_danger_path_types_the_key_and_yields_a_fire() { | |
| 435 | + | let mut m = armed(true); | |
| 436 | + | handle_key(&mut m, key(KeyCode::Enter)); // open picker | |
| 437 | + | handle_key(&mut m, key(KeyCode::Enter)); // pick -> Type (danger) | |
| 438 | + | assert!(matches!(m.prompt, Some(Prompt::Type { .. }))); | |
| 439 | + | for c in "rollback-b".chars() { | |
| 440 | + | handle_key(&mut m, key(KeyCode::Char(c))); | |
| 441 | + | } | |
| 442 | + | let result = handle_key(&mut m, key(KeyCode::Enter)); | |
| 443 | + | assert_eq!( | |
| 444 | + | result.fire, | |
| 445 | + | Some(FireRequest { | |
| 446 | + | source: 0, | |
| 447 | + | key: "rollback-b".into() | |
| 448 | + | }) | |
| 449 | + | ); | |
| 450 | + | assert_eq!(result.flow, Flow::Continue); | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | #[test] | |
| 454 | + | fn q_typed_into_a_danger_prompt_is_text_not_a_quit() { | |
| 455 | + | // The reason handle_key routes to the prompt before its own keymap: a | |
| 456 | + | // key containing 'q' must be typeable without quitting the app. | |
| 457 | + | let mut m = armed(true); | |
| 458 | + | handle_key(&mut m, key(KeyCode::Enter)); | |
| 459 | + | handle_key(&mut m, key(KeyCode::Enter)); // -> Type | |
| 460 | + | let result = handle_key(&mut m, key(KeyCode::Char('q'))); | |
| 461 | + | assert_eq!( | |
| 462 | + | result.flow, | |
| 463 | + | Flow::Continue, | |
| 464 | + | "'q' must not quit while typing" | |
| 465 | + | ); | |
| 466 | + | if let Some(Prompt::Type { typed, .. }) = &m.prompt { | |
| 467 | + | assert_eq!(typed, "q"); | |
| 468 | + | } else { | |
| 469 | + | panic!("left Type mode on a plain character"); | |
| 470 | + | } | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | #[test] | |
| 474 | + | fn ctrl_c_quits_even_mid_type() { | |
| 475 | + | // The one escape hatch that always works, so a half-typed confirmation | |
| 476 | + | // is never a trap. | |
| 477 | + | let mut m = armed(true); | |
| 478 | + | handle_key(&mut m, key(KeyCode::Enter)); | |
| 479 | + | handle_key(&mut m, key(KeyCode::Enter)); // -> Type | |
| 480 | + | assert_eq!(handle_key(&mut m, ctrl('c')).flow, Flow::Quit); | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | #[test] | |
| 484 | + | fn a_confirm_action_fires_on_y_and_backs_out_on_n() { | |
| 485 | + | let mut m = armed(false); // confirm, not danger | |
| 486 | + | handle_key(&mut m, key(KeyCode::Enter)); | |
| 487 | + | handle_key(&mut m, key(KeyCode::Enter)); // pick -> Confirm | |
| 488 | + | assert!(matches!(m.prompt, Some(Prompt::Confirm { .. }))); | |
| 489 | + | // 'n' cancels. | |
| 490 | + | let result = handle_key(&mut m, key(KeyCode::Char('n'))); | |
| 491 | + | assert_eq!(result.fire, None); | |
| 492 | + | assert!(m.prompt.is_none()); | |
| 493 | + | // Re-open and fire with 'y'. | |
| 494 | + | handle_key(&mut m, key(KeyCode::Enter)); | |
| 495 | + | handle_key(&mut m, key(KeyCode::Enter)); | |
| 496 | + | let result = handle_key(&mut m, key(KeyCode::Char('y'))); | |
| 497 | + | assert_eq!( | |
| 498 | + | result.fire, | |
| 499 | + | Some(FireRequest { | |
| 500 | + | source: 0, | |
| 501 | + | key: "rollback-b".into() | |
| 502 | + | }) | |
| 503 | + | ); | |
| 504 | + | } | |
| 505 | + | ||
| 238 | 506 | #[test] | |
| 239 | 507 | fn an_update_for_a_source_that_does_not_exist_is_ignored() { | |
| 240 | 508 | // Defensive: an index mismatch must not panic the UI thread. |
| @@ -6,11 +6,16 @@ | |||
| 6 | 6 | //! knowledge one convenience at a time. | |
| 7 | 7 | ||
| 8 | 8 | use chrono::{DateTime, TimeDelta, Utc}; | |
| 9 | - | use ops_status::{Node, Payload, Status}; | |
| 9 | + | use ops_status::{Action, Node, Payload, Status}; | |
| 10 | 10 | ||
| 11 | 11 | /// One source, as last heard from. | |
| 12 | 12 | pub struct SourceState { | |
| 13 | 13 | pub name: String, | |
| 14 | + | /// Whether this source's declared actions may be fired. Off by default; set | |
| 15 | + | /// from the config's per-source `allow_actions`. Kept on the state rather | |
| 16 | + | /// than threaded through key handling so the model stays self-contained and | |
| 17 | + | /// testable. | |
| 18 | + | pub allow_actions: bool, | |
| 14 | 19 | /// The last payload that parsed. Kept across a failed poll so the screen | |
| 15 | 20 | /// shows the last known state alongside the fact that it is now stale, | |
| 16 | 21 | /// rather than going blank. | |
| @@ -29,6 +34,7 @@ impl SourceState { | |||
| 29 | 34 | pub fn new(name: impl Into<String>, stale_after: TimeDelta) -> Self { | |
| 30 | 35 | SourceState { | |
| 31 | 36 | name: name.into(), | |
| 37 | + | allow_actions: false, | |
| 32 | 38 | payload: None, | |
| 33 | 39 | error: None, | |
| 34 | 40 | last_ok: None, | |
| @@ -37,6 +43,20 @@ impl SourceState { | |||
| 37 | 43 | } | |
| 38 | 44 | } | |
| 39 | 45 | ||
| 46 | + | /// Let this source's declared actions be fired. Builder-style so tests and | |
| 47 | + | /// `main` set it without a wider constructor. | |
| 48 | + | pub fn with_actions(mut self, allow: bool) -> Self { | |
| 49 | + | self.allow_actions = allow; | |
| 50 | + | self | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// A declared action by key, from the current payload. `None` if the source | |
| 54 | + | /// has not answered or no longer offers it — the latter matters because a | |
| 55 | + | /// poll between opening a prompt and confirming can retract an action. | |
| 56 | + | pub fn action(&self, key: &str) -> Option<&Action> { | |
| 57 | + | self.payload.as_ref().and_then(|p| p.actions.get(key)) | |
| 58 | + | } | |
| 59 | + | ||
| 40 | 60 | /// How old the current payload is, if there is one. | |
| 41 | 61 | pub fn age(&self, now: DateTime<Utc>) -> Option<TimeDelta> { | |
| 42 | 62 | self.payload.as_ref().map(|p| p.age(now)) | |
| @@ -178,6 +198,51 @@ pub enum Tab { | |||
| 178 | 198 | Source(usize), | |
| 179 | 199 | } | |
| 180 | 200 | ||
| 201 | + | /// A modal step between "I want to run this" and the request going out. | |
| 202 | + | /// | |
| 203 | + | /// Firing a declared action can move production, so the path to it is explicit | |
| 204 | + | /// and never a single keystroke: pick which action, then clear its guard. The | |
| 205 | + | /// guard's weight is set by the action itself — a `danger` action is confirmed | |
| 206 | + | /// by typing its key, a `confirm` one by a `y`, a plain one not at all. | |
| 207 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 208 | + | pub enum Prompt { | |
| 209 | + | /// Choosing which of the selected node's actions to run. | |
| 210 | + | Pick { | |
| 211 | + | source: usize, | |
| 212 | + | keys: Vec<String>, | |
| 213 | + | selected: usize, | |
| 214 | + | }, | |
| 215 | + | /// A `y`/`n` guard for a `confirm` action that is not `danger`. | |
| 216 | + | Confirm { source: usize, key: String }, | |
| 217 | + | /// The heaviest guard: type the action key to fire a `danger` action. Muscle | |
| 218 | + | /// memory cannot type `rollback-b`, which is the point. | |
| 219 | + | Type { | |
| 220 | + | source: usize, | |
| 221 | + | key: String, | |
| 222 | + | typed: String, | |
| 223 | + | }, | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | /// A confirmed request the run loop is to issue. The model records it and stops | |
| 227 | + | /// there; resolving the source's URL and token and making the HTTP call is the | |
| 228 | + | /// loop's job, which keeps every network effect out of the model. | |
| 229 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 230 | + | pub struct FireRequest { | |
| 231 | + | pub source: usize, | |
| 232 | + | pub key: String, | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /// What a keypress asked the model to do once the prompt resolved. | |
| 236 | + | #[derive(Debug, PartialEq, Eq)] | |
| 237 | + | pub enum PromptStep { | |
| 238 | + | /// Still in a prompt (or none was open); nothing to dispatch. | |
| 239 | + | Idle, | |
| 240 | + | /// The prompt closed with a confirmed request to fire. | |
| 241 | + | Fire(FireRequest), | |
| 242 | + | /// The prompt closed without firing. | |
| 243 | + | Cancelled, | |
| 244 | + | } | |
| 245 | + | ||
| 181 | 246 | pub struct Model { | |
| 182 | 247 | pub sources: Vec<SourceState>, | |
| 183 | 248 | pub tab: Tab, | |
| @@ -185,6 +250,8 @@ pub struct Model { | |||
| 185 | 250 | pub rollup_selected: usize, | |
| 186 | 251 | /// Transient message shown in the footer. | |
| 187 | 252 | pub message: Option<String>, | |
| 253 | + | /// The open modal, if any. | |
| 254 | + | pub prompt: Option<Prompt>, | |
| 188 | 255 | } | |
| 189 | 256 | ||
| 190 | 257 | impl Model { | |
| @@ -194,9 +261,158 @@ impl Model { | |||
| 194 | 261 | tab: Tab::Rollup, | |
| 195 | 262 | rollup_selected: 0, | |
| 196 | 263 | message: None, | |
| 264 | + | prompt: None, | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // -- Actions ----------------------------------------------------------- | |
| 269 | + | ||
| 270 | + | /// Enter on a source node: open the action picker, or explain why not. | |
| 271 | + | /// | |
| 272 | + | /// A node with no actions does nothing. A source with actions disabled says | |
| 273 | + | /// so rather than silently ignoring the key, because a viewer that looks | |
| 274 | + | /// like it should be able to act and does not is worse than one that says it | |
| 275 | + | /// cannot. | |
| 276 | + | pub fn open_actions(&mut self) { | |
| 277 | + | let Tab::Source(i) = self.tab else { return }; | |
| 278 | + | let Some(source) = self.sources.get(i) else { | |
| 279 | + | return; | |
| 280 | + | }; | |
| 281 | + | let Some(node) = source.selected_node() else { | |
| 282 | + | return; | |
| 283 | + | }; | |
| 284 | + | if node.actions.is_empty() { | |
| 285 | + | return; | |
| 286 | + | } | |
| 287 | + | if !source.allow_actions { | |
| 288 | + | self.message = Some(format!( | |
| 289 | + | "{}: actions are read-only here (set allow_actions to enable)", | |
| 290 | + | source.name | |
| 291 | + | )); | |
| 292 | + | return; | |
| 293 | + | } | |
| 294 | + | self.prompt = Some(Prompt::Pick { | |
| 295 | + | source: i, | |
| 296 | + | keys: node.actions.clone(), | |
| 297 | + | selected: 0, | |
| 298 | + | }); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | /// Move the cursor inside an open picker. No-op for the other prompts. | |
| 302 | + | pub fn prompt_move(&mut self, delta: isize) { | |
| 303 | + | if let Some(Prompt::Pick { keys, selected, .. }) = &mut self.prompt { | |
| 304 | + | if keys.is_empty() { | |
| 305 | + | return; | |
| 306 | + | } | |
| 307 | + | let next = *selected as isize + delta; | |
| 308 | + | *selected = next.clamp(0, keys.len() as isize - 1) as usize; | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | /// A digit inside a picker jumps straight to that action (1-based). | |
| 313 | + | pub fn prompt_digit(&mut self, n: usize) { | |
| 314 | + | if let Some(Prompt::Pick { keys, selected, .. }) = &mut self.prompt | |
| 315 | + | && (1..=keys.len()).contains(&n) | |
| 316 | + | { | |
| 317 | + | *selected = n - 1; | |
| 197 | 318 | } | |
| 198 | 319 | } | |
| 199 | 320 | ||
| 321 | + | /// A printable character while typing a `danger` action's key. | |
| 322 | + | pub fn prompt_push(&mut self, c: char) { | |
| 323 | + | if let Some(Prompt::Type { typed, .. }) = &mut self.prompt { | |
| 324 | + | typed.push(c); | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | /// Backspace while typing. | |
| 329 | + | pub fn prompt_backspace(&mut self) { | |
| 330 | + | if let Some(Prompt::Type { typed, .. }) = &mut self.prompt { | |
| 331 | + | typed.pop(); | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | /// Enter: advance the picker into a guard, or clear a `Type` guard. | |
| 336 | + | /// | |
| 337 | + | /// - On a picker, resolves the chosen action and either opens its guard | |
| 338 | + | /// (`Type` for danger, `Confirm` for confirm) or fires it outright. | |
| 339 | + | /// - On a `Type` guard, fires only when the typed text matches the key. | |
| 340 | + | /// - `Confirm` does not respond to Enter; it wants an explicit `y` | |
| 341 | + | /// ([`confirm_yes`]), so a stray Enter cannot promote through it. | |
| 342 | + | pub fn prompt_enter(&mut self) -> PromptStep { | |
| 343 | + | match self.prompt.take() { | |
| 344 | + | Some(Prompt::Pick { | |
| 345 | + | source, | |
| 346 | + | keys, | |
| 347 | + | selected, | |
| 348 | + | }) => { | |
| 349 | + | let Some(key) = keys.get(selected).cloned() else { | |
| 350 | + | return PromptStep::Cancelled; | |
| 351 | + | }; | |
| 352 | + | let Some(action) = self.sources.get(source).and_then(|s| s.action(&key)) else { | |
| 353 | + | self.message = Some(format!("{key}: no longer offered")); | |
| 354 | + | return PromptStep::Cancelled; | |
| 355 | + | }; | |
| 356 | + | if action.danger { | |
| 357 | + | self.prompt = Some(Prompt::Type { | |
| 358 | + | source, | |
| 359 | + | key, | |
| 360 | + | typed: String::new(), | |
| 361 | + | }); | |
| 362 | + | PromptStep::Idle | |
| 363 | + | } else if action.confirm { | |
| 364 | + | self.prompt = Some(Prompt::Confirm { source, key }); | |
| 365 | + | PromptStep::Idle | |
| 366 | + | } else { | |
| 367 | + | self.fire(source, key) | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | Some(Prompt::Type { source, key, typed }) => { | |
| 371 | + | if typed == key { | |
| 372 | + | self.fire(source, key) | |
| 373 | + | } else { | |
| 374 | + | self.message = Some(format!("type '{key}' exactly to confirm")); | |
| 375 | + | self.prompt = Some(Prompt::Type { | |
| 376 | + | source, | |
| 377 | + | key, | |
| 378 | + | typed: String::new(), | |
| 379 | + | }); | |
| 380 | + | PromptStep::Idle | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | other => { | |
| 384 | + | self.prompt = other; | |
| 385 | + | PromptStep::Idle | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | /// `y` on a `Confirm` guard fires; anywhere else it is nothing. | |
| 391 | + | pub fn confirm_yes(&mut self) -> PromptStep { | |
| 392 | + | if let Some(Prompt::Confirm { source, key }) = self.prompt.take() { | |
| 393 | + | self.fire(source, key) | |
| 394 | + | } else { | |
| 395 | + | PromptStep::Idle | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | /// Close any open prompt without firing. | |
| 400 | + | pub fn cancel_prompt(&mut self) -> PromptStep { | |
| 401 | + | if self.prompt.take().is_some() { | |
| 402 | + | PromptStep::Cancelled | |
| 403 | + | } else { | |
| 404 | + | PromptStep::Idle | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | /// Record a confirmed request and clear the prompt. Guards checked the | |
| 409 | + | /// action still existed, so this only assembles the request; the loop makes | |
| 410 | + | /// the call. | |
| 411 | + | fn fire(&mut self, source: usize, key: String) -> PromptStep { | |
| 412 | + | self.prompt = None; | |
| 413 | + | PromptStep::Fire(FireRequest { source, key }) | |
| 414 | + | } | |
| 415 | + | ||
| 200 | 416 | /// Source indices ordered worst-first, then by name. | |
| 201 | 417 | /// | |
| 202 | 418 | /// Worst-first is the whole argument for the rollup existing. Sorted any | |
| @@ -286,7 +502,8 @@ impl Model { | |||
| 286 | 502 | #[cfg(test)] | |
| 287 | 503 | mod tests { | |
| 288 | 504 | use super::*; | |
| 289 | - | use ops_status::{Condition, Node}; | |
| 505 | + | use ops_status::{Action, Condition, Method, Node}; | |
| 506 | + | use std::collections::BTreeMap; | |
| 290 | 507 | ||
| 291 | 508 | fn now() -> DateTime<Utc> { | |
| 292 | 509 | "2026-07-21T18:00:00Z".parse().unwrap() | |
| @@ -305,6 +522,34 @@ mod tests { | |||
| 305 | 522 | } | |
| 306 | 523 | } | |
| 307 | 524 | ||
| 525 | + | fn act(label: &str, confirm: bool, danger: bool) -> Action { | |
| 526 | + | Action { | |
| 527 | + | label: label.into(), | |
| 528 | + | method: Method::Post, | |
| 529 | + | url: "/x".into(), | |
| 530 | + | confirm, | |
| 531 | + | danger, | |
| 532 | + | body: None, | |
| 533 | + | } | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | /// A source on a single node that declares the given actions, with actions | |
| 537 | + | /// allowed, sitting on its own tab ready to prompt. | |
| 538 | + | fn actionable(node_actions: &[&str], declared: Vec<(&str, Action)>) -> Model { | |
| 539 | + | let mut n = node("tier:b", Status::Ok, vec![]); | |
| 540 | + | n.actions = node_actions.iter().map(|s| s.to_string()).collect(); | |
| 541 | + | let mut p = payload(now(), vec![n]); | |
| 542 | + | p.actions = declared | |
| 543 | + | .into_iter() | |
| 544 | + | .map(|(k, a)| (k.to_string(), a)) | |
| 545 | + | .collect::<BTreeMap<_, _>>(); | |
| 546 | + | let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true); | |
| 547 | + | s.observe(p, now()); | |
| 548 | + | let mut m = Model::new(vec![s]); | |
| 549 | + | m.select_tab(1); | |
| 550 | + | m | |
| 551 | + | } | |
| 552 | + | ||
| 308 | 553 | fn payload(at: DateTime<Utc>, nodes: Vec<Node>) -> Payload { | |
| 309 | 554 | let mut p = Payload::new("sando", at); | |
| 310 | 555 | p.nodes = nodes; | |
| @@ -515,6 +760,197 @@ mod tests { | |||
| 515 | 760 | } | |
| 516 | 761 | ||
| 517 | 762 | #[test] | |
| 763 | + | fn a_disabled_source_refuses_to_open_the_picker_and_says_why() { | |
| 764 | + | let mut n = node("tier:b", Status::Ok, vec![]); | |
| 765 | + | n.actions = vec!["rollback-b".into()]; | |
| 766 | + | let mut p = payload(now(), vec![n]); | |
| 767 | + | p.actions | |
| 768 | + | .insert("rollback-b".into(), act("Roll back", true, true)); | |
| 769 | + | // allow_actions defaults off. | |
| 770 | + | let mut s = SourceState::new("sando", TimeDelta::seconds(60)); | |
| 771 | + | s.observe(p, now()); | |
| 772 | + | let mut m = Model::new(vec![s]); | |
| 773 | + | m.select_tab(1); | |
| 774 | + | ||
| 775 | + | m.open_actions(); | |
| 776 | + | assert!( | |
| 777 | + | m.prompt.is_none(), | |
| 778 | + | "a read-only source must not open a prompt" | |
| 779 | + | ); | |
| 780 | + | assert!(m.message.as_deref().unwrap().contains("read-only")); | |
| 781 | + | } | |
| 782 | + | ||
| 783 | + | #[test] | |
| 784 | + | fn a_node_with_no_actions_does_nothing_on_enter() { | |
| 785 | + | let mut m = actionable(&[], vec![]); | |
| 786 | + | m.open_actions(); | |
| 787 | + | assert!(m.prompt.is_none()); | |
| 788 | + | assert!(m.message.is_none()); | |
| 789 | + | } | |
| 790 | + | ||
| 791 | + | #[test] | |
| 792 | + | fn the_rollup_tab_never_opens_an_action_prompt() { | |
| 793 | + | let mut m = actionable( | |
| 794 | + | &["rollback-b"], | |
| 795 | + | vec![("rollback-b", act("Roll back", true, true))], | |
| 796 | + | ); | |
| 797 | + | m.tab = Tab::Rollup; | |
| 798 | + | m.open_actions(); | |
| 799 | + | assert!( | |
| 800 | + | m.prompt.is_none(), | |
| 801 | + | "actions belong to a source tab, not the rollup" | |
| 802 | + | ); | |
| 803 | + | } | |
| 804 | + | ||
| 805 | + | #[test] | |
| 806 | + | fn a_plain_action_fires_straight_from_the_picker() { | |
| 807 | + | // No confirm, no danger: the picker is the whole ceremony. | |
| 808 | + | let mut m = actionable( | |
| 809 | + | &["recheck"], | |
| 810 | + | vec![("recheck", act("Recheck", false, false))], | |
| 811 | + | ); | |
| 812 | + | m.open_actions(); | |
| 813 | + | assert!(matches!(m.prompt, Some(Prompt::Pick { .. }))); | |
| 814 | + | let step = m.prompt_enter(); | |
| 815 | + | assert_eq!( | |
| 816 | + | step, | |
| 817 | + | PromptStep::Fire(FireRequest { | |
| 818 | + | source: 0, | |
| 819 | + | key: "recheck".into() | |
| 820 | + | }) | |
| 821 | + | ); | |
| 822 | + | assert!(m.prompt.is_none()); | |
| 823 | + | } | |
| 824 | + | ||
| 825 | + | #[test] | |
| 826 | + | fn a_confirm_action_needs_an_explicit_y_and_enter_will_not_do() { | |
| 827 | + | let mut m = actionable( | |
| 828 | + | &["promote-b"], | |
| 829 | + | vec![("promote-b", act("Promote", true, false))], | |
| 830 | + | ); | |
| 831 | + | m.open_actions(); | |
| 832 | + | assert_eq!(m.prompt_enter(), PromptStep::Idle); | |
| 833 | + | assert!( | |
| 834 | + | matches!(m.prompt, Some(Prompt::Confirm { .. })), | |
| 835 | + | "picker enter opens the y/n guard" | |
| 836 | + | ); | |
| 837 | + | // A stray Enter must not promote through a confirm guard. | |
| 838 | + | assert_eq!(m.prompt_enter(), PromptStep::Idle); | |
| 839 | + | assert!(matches!(m.prompt, Some(Prompt::Confirm { .. }))); | |
| 840 | + | // Only 'y' fires. | |
| 841 | + | let step = m.confirm_yes(); | |
| 842 | + | assert_eq!( | |
| 843 | + | step, | |
| 844 | + | PromptStep::Fire(FireRequest { | |
| 845 | + | source: 0, | |
| 846 | + | key: "promote-b".into() | |
| 847 | + | }) | |
| 848 | + | ); | |
| 849 | + | } | |
| 850 | + | ||
| 851 | + | #[test] | |
| 852 | + | fn a_danger_action_must_be_typed_out_to_fire() { | |
| 853 | + | let mut m = actionable( | |
| 854 | + | &["rollback-b"], | |
| 855 | + | vec![("rollback-b", act("Roll back", true, true))], | |
| 856 | + | ); | |
| 857 | + | m.open_actions(); | |
| 858 | + | // Picker -> Type, not a y/n: danger outranks confirm. | |
| 859 | + | assert_eq!(m.prompt_enter(), PromptStep::Idle); | |
| 860 | + | assert!(matches!(m.prompt, Some(Prompt::Type { .. }))); | |
| 861 | + | ||
| 862 | + | // A wrong key does not fire; it resets the buffer with a nudge. | |
| 863 | + | for c in "rollback-a".chars() { | |
| 864 | + | m.prompt_push(c); | |
| 865 | + | } | |
| 866 | + | assert_eq!(m.prompt_enter(), PromptStep::Idle); | |
| 867 | + | assert!(m.message.as_deref().unwrap().contains("exactly")); | |
| 868 | + | if let Some(Prompt::Type { typed, .. }) = &m.prompt { | |
| 869 | + | assert!(typed.is_empty(), "a mismatch clears what was typed"); | |
| 870 | + | } else { | |
| 871 | + | panic!("still in Type after a mismatch"); | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | // The exact key fires. | |
| 875 | + | for c in "rollback-b".chars() { | |
| 876 | + | m.prompt_push(c); | |
| 877 | + | } | |
| 878 | + | m.prompt_backspace(); | |
| 879 | + | m.prompt_push('b'); | |
| 880 | + | let step = m.prompt_enter(); | |
| 881 | + | assert_eq!( | |
| 882 | + | step, | |
| 883 | + | PromptStep::Fire(FireRequest { | |
| 884 | + | source: 0, | |
| 885 | + | key: "rollback-b".into() | |
| 886 | + | }) | |
| 887 | + | ); | |
| 888 | + | assert!(m.prompt.is_none()); | |
| 889 | + | } | |
| 890 | + | ||
| 891 | + | #[test] | |
| 892 | + | fn esc_cancels_without_firing() { | |
| 893 | + | let mut m = actionable( | |
| 894 | + | &["rollback-b"], | |
| 895 | + | vec![("rollback-b", act("Roll back", true, true))], | |
| 896 | + | ); | |
| 897 | + | m.open_actions(); | |
| 898 | + | assert_eq!(m.cancel_prompt(), PromptStep::Cancelled); | |
| 899 | + | assert!(m.prompt.is_none()); | |
| 900 | + | assert_eq!( | |
| 901 | + | m.cancel_prompt(), | |
| 902 | + | PromptStep::Idle, | |
| 903 | + | "nothing to cancel twice" | |
| 904 | + | ); | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | #[test] | |
| 908 | + | fn the_picker_moves_and_digits_jump_within_the_nodes_actions() { | |
| 909 | + | let mut m = actionable( | |
| 910 | + | &["promote-b", "rollback-b"], | |
| 911 | + | vec![ | |
| 912 | + | ("promote-b", act("Promote", true, false)), | |
| 913 | + | ("rollback-b", act("Roll back", true, true)), | |
| 914 | + | ], | |
| 915 | + | ); | |
| 916 | + | m.open_actions(); | |
| 917 | + | m.prompt_move(1); | |
| 918 | + | if let Some(Prompt::Pick { selected, .. }) = &m.prompt { | |
| 919 | + | assert_eq!(*selected, 1); | |
| 920 | + | } | |
| 921 | + | m.prompt_move(5); // clamps | |
| 922 | + | if let Some(Prompt::Pick { selected, .. }) = &m.prompt { | |
| 923 | + | assert_eq!(*selected, 1); | |
| 924 | + | } | |
| 925 | + | m.prompt_digit(1); | |
| 926 | + | if let Some(Prompt::Pick { selected, .. }) = &m.prompt { | |
| 927 | + | assert_eq!(*selected, 0); | |
| 928 | + | } | |
| 929 | + | m.prompt_digit(9); // out of range, ignored | |
| 930 | + | if let Some(Prompt::Pick { selected, .. }) = &m.prompt { | |
| 931 | + | assert_eq!(*selected, 0); | |
| 932 | + | } | |
| 933 | + | } | |
| 934 | + | ||
| 935 | + | #[test] | |
| 936 | + | fn an_action_retracted_between_pick_and_confirm_does_not_fire() { | |
| 937 | + | let mut m = actionable( | |
| 938 | + | &["rollback-b"], | |
| 939 | + | vec![("rollback-b", act("Roll back", true, true))], | |
| 940 | + | ); | |
| 941 | + | m.open_actions(); | |
| 942 | + | // A poll drops the action out from under the open picker. | |
| 943 | + | m.sources[0].observe( | |
| 944 | + | payload(now(), vec![node("tier:b", Status::Ok, vec![])]), | |
| 945 | + | now(), | |
| 946 | + | ); | |
| 947 | + | let step = m.prompt_enter(); | |
| 948 | + | assert_eq!(step, PromptStep::Cancelled); | |
| 949 | + | assert!(m.message.as_deref().unwrap().contains("no longer offered")); | |
| 950 | + | assert!(m.prompt.is_none()); | |
| 951 | + | } | |
| 952 | + | ||
| 953 | + | #[test] | |
| 518 | 954 | fn a_node_needing_attention_is_counted_once() { | |
| 519 | 955 | let mut n = node("tier:b", Status::Failed, vec![]); |
Lines truncated
| @@ -7,14 +7,14 @@ | |||
| 7 | 7 | //! daemon. That is unpleasant to retrofit and trivial to keep. | |
| 8 | 8 | ||
| 9 | 9 | use chrono::{DateTime, Utc}; | |
| 10 | - | use ops_status::Node; | |
| 10 | + | use ops_status::{Method, Node}; | |
| 11 | 11 | use ratatui::Frame; | |
| 12 | 12 | use ratatui::layout::{Constraint, Layout, Rect}; | |
| 13 | - | use ratatui::style::{Modifier, Style, Stylize}; | |
| 13 | + | use ratatui::style::{Color, Modifier, Style, Stylize}; | |
| 14 | 14 | use ratatui::text::{Line, Span}; | |
| 15 | - | use ratatui::widgets::{Block, Cell, List, ListItem, Paragraph, Row, Table, Tabs}; | |
| 15 | + | use ratatui::widgets::{Block, Cell, Clear, List, ListItem, Paragraph, Row, Table, Tabs}; | |
| 16 | 16 | ||
| 17 | - | use crate::model::{Model, SourceState, Tab}; | |
| 17 | + | use crate::model::{Model, Prompt, SourceState, Tab}; | |
| 18 | 18 | use crate::value; | |
| 19 | 19 | ||
| 20 | 20 | pub fn render(model: &Model, now: DateTime<Utc>, frame: &mut Frame) { | |
| @@ -33,6 +33,11 @@ pub fn render(model: &Model, now: DateTime<Utc>, frame: &mut Frame) { | |||
| 33 | 33 | None => frame.render_widget(Paragraph::new("no such source"), body), | |
| 34 | 34 | }, | |
| 35 | 35 | } | |
| 36 | + | // A prompt floats over whatever tab is showing: the state behind it keeps | |
| 37 | + | // updating on every poll, which is the point of not blocking on the modal. | |
| 38 | + | if model.prompt.is_some() { | |
| 39 | + | render_prompt(model, frame, body); | |
| 40 | + | } | |
| 36 | 41 | render_footer(model, frame, footer); | |
| 37 | 42 | } | |
| 38 | 43 | ||
| @@ -186,9 +191,18 @@ fn render_detail(source: &SourceState, now: DateTime<Utc>, frame: &mut Frame, ar | |||
| 186 | 191 | lines.extend(field_lines(node, now, width)); | |
| 187 | 192 | lines.extend(condition_lines(node, width)); | |
| 188 | 193 | if !node.actions.is_empty() { | |
| 189 | - | lines.push(Line::from( | |
| 194 | + | // The hint tells the operator whether Enter does anything here, | |
| 195 | + | // so a read-only source does not look broken when a keypress is | |
| 196 | + | // ignored. | |
| 197 | + | let hint = if source.allow_actions { | |
| 198 | + | " (enter to run)" | |
| 199 | + | } else { | |
| 200 | + | " (read-only)" | |
| 201 | + | }; | |
| 202 | + | lines.push(Line::from(vec![ | |
| 190 | 203 | Span::from(format!("actions: {}", node.actions.join(", "))).dim(), | |
| 191 | - | )); | |
| 204 | + | Span::from(hint).dim(), | |
| 205 | + | ])); | |
| 192 | 206 | } | |
| 193 | 207 | } | |
| 194 | 208 | None => lines.push(Line::from(Span::from(source.summary(now)).dim())), | |
| @@ -253,6 +267,145 @@ fn condition_lines(node: &Node, width: usize) -> Vec<Line<'static>> { | |||
| 253 | 267 | .collect() | |
| 254 | 268 | } | |
| 255 | 269 | ||
| 270 | + | // --------------------------------------------------------------------------- | |
| 271 | + | // Action prompts | |
| 272 | + | // --------------------------------------------------------------------------- | |
| 273 | + | ||
| 274 | + | /// The modal path to firing an action: pick, then clear its guard. | |
| 275 | + | /// | |
| 276 | + | /// A `danger` action shows a red header and asks the operator to type its key; | |
| 277 | + | /// muscle memory cannot type `rollback-b`, which is the whole safeguard. The | |
| 278 | + | /// resolved host is left off — the tab already names the source — so the line | |
| 279 | + | /// stays short and shows the method and path the request will use. | |
| 280 | + | fn render_prompt(model: &Model, frame: &mut Frame, area: Rect) { | |
| 281 | + | let Some(prompt) = &model.prompt else { return }; | |
| 282 | + | let source = model.sources.get(prompt_source(prompt)); | |
| 283 | + | ||
| 284 | + | let (title, title_style, lines) = match prompt { | |
| 285 | + | Prompt::Pick { keys, selected, .. } => { | |
| 286 | + | let mut lines = Vec::new(); | |
| 287 | + | for (i, key) in keys.iter().enumerate() { | |
| 288 | + | let action = source.and_then(|s| s.action(key)); | |
| 289 | + | let label = action.map(|a| a.label.as_str()).unwrap_or(key); | |
| 290 | + | let danger = action.is_some_and(|a| a.danger); | |
| 291 | + | let marker = if i == *selected { "> " } else { " " }; | |
| 292 | + | let mut style = Style::default(); | |
| 293 | + | if danger { | |
| 294 | + | style = style.fg(Color::Red); | |
| 295 | + | } | |
| 296 | + | if i == *selected { | |
| 297 | + | style = style.add_modifier(Modifier::REVERSED); | |
| 298 | + | } | |
| 299 | + | lines.push(Line::from(Span::styled( | |
| 300 | + | format!("{marker}{}. {label} [{key}]", i + 1), | |
| 301 | + | style, | |
| 302 | + | ))); | |
| 303 | + | } | |
| 304 | + | lines.push(Line::from("")); | |
| 305 | + | lines.push(Line::from(Span::from("enter run esc cancel").dim())); | |
| 306 | + | (" run action ", Style::default(), lines) | |
| 307 | + | } | |
| 308 | + | Prompt::Confirm { key, .. } => { | |
| 309 | + | let action = source.and_then(|s| s.action(key)); | |
| 310 | + | let lines = vec![ | |
| 311 | + | action_summary_line(action, key), | |
| 312 | + | Line::from(""), | |
| 313 | + | Line::from(Span::from("press y to confirm esc cancel").dim()), | |
| 314 | + | ]; | |
| 315 | + | (" confirm ", Style::default(), lines) | |
| 316 | + | } | |
| 317 | + | Prompt::Type { key, typed, .. } => { | |
| 318 | + | let action = source.and_then(|s| s.action(key)); | |
| 319 | + | let lines = vec![ | |
| 320 | + | Line::from(Span::styled( | |
| 321 | + | "DANGER", | |
| 322 | + | Style::default().fg(Color::Red).bold(), | |
| 323 | + | )), | |
| 324 | + | action_summary_line(action, key), | |
| 325 | + | Line::from(""), | |
| 326 | + | Line::from(Span::from(format!("type '{key}' to confirm:")).dim()), | |
| 327 | + | Line::from(Span::styled( | |
| 328 | + | format!("> {typed}\u{258f}"), | |
| 329 | + | Style::default().fg(Color::Red), | |
| 330 | + | )), | |
| 331 | + | Line::from(""), | |
| 332 | + | Line::from(Span::from("esc cancel").dim()), | |
| 333 | + | ]; | |
| 334 | + | ( | |
| 335 | + | " DANGER ", | |
| 336 | + | Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), | |
| 337 | + | lines, | |
| 338 | + | ) | |
| 339 | + | } | |
| 340 | + | }; | |
| 341 | + | ||
| 342 | + | let width = lines | |
| 343 | + | .iter() | |
| 344 | + | .map(Line::width) | |
| 345 | + | .chain(std::iter::once(title.len())) | |
| 346 | + | .max() | |
| 347 | + | .unwrap_or(0) as u16 | |
| 348 | + | + 4; | |
| 349 | + | let height = lines.len() as u16 + 2; | |
| 350 | + | let popup = centered(area, width, height); | |
| 351 | + | ||
| 352 | + | // Clear what is under the popup so the tab behind does not show through. | |
| 353 | + | frame.render_widget(Clear, popup); | |
| 354 | + | frame.render_widget( | |
| 355 | + | Paragraph::new(lines).block( | |
| 356 | + | Block::bordered() | |
| 357 | + | .title(Span::styled(title, title_style)) | |
| 358 | + | .border_style(title_style), | |
| 359 | + | ), | |
| 360 | + | popup, | |
| 361 | + | ); | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | /// One line naming what an action will do: `Roll back POST /rollback/b`. | |
| 365 | + | fn action_summary_line(action: Option<&ops_status::Action>, key: &str) -> Line<'static> { | |
| 366 | + | match action { | |
| 367 | + | Some(action) => Line::from(vec![ | |
| 368 | + | Span::styled(action.label.clone(), Style::default().bold()), | |
| 369 | + | Span::from(format!(" {} {}", method_str(action.method), action.url)), | |
| 370 | + | ]), | |
| 371 | + | // The action was retracted by a poll since the prompt opened; say so | |
| 372 | + | // rather than render a blank confirmation. | |
| 373 | + | None => Line::from(Span::styled( | |
| 374 | + | format!("{key}: no longer offered"), | |
| 375 | + | Style::default().fg(Color::Yellow), | |
| 376 | + | )), | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | fn method_str(method: Method) -> &'static str { | |
| 381 | + | match method { | |
| 382 | + | Method::Get => "GET", | |
| 383 | + | Method::Post => "POST", | |
| 384 | + | Method::Put => "PUT", | |
| 385 | + | Method::Delete => "DELETE", | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | fn prompt_source(prompt: &Prompt) -> usize { | |
| 390 | + | match prompt { | |
| 391 | + | Prompt::Pick { source, .. } | |
| 392 | + | | Prompt::Confirm { source, .. } | |
| 393 | + | | Prompt::Type { source, .. } => *source, | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | /// A rectangle of the given size centered in `area`, clamped so it always fits. | |
| 398 | + | fn centered(area: Rect, width: u16, height: u16) -> Rect { | |
| 399 | + | let width = width.min(area.width); | |
| 400 | + | let height = height.min(area.height); | |
| 401 | + | Rect { | |
| 402 | + | x: area.x + (area.width - width) / 2, | |
| 403 | + | y: area.y + (area.height - height) / 2, | |
| 404 | + | width, | |
| 405 | + | height, | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | ||
| 256 | 409 | /// Clip to a character budget, marking that something was cut. | |
| 257 | 410 | fn truncate(text: &str, max: usize) -> String { | |
| 258 | 411 | // A detail string is producer-supplied and may carry newlines; the pane is | |
| @@ -501,6 +654,89 @@ mod tests { | |||
| 501 | 654 | assert_eq!(truncate(&"x".repeat(100), 10).chars().count(), 10); | |
| 502 | 655 | } | |
| 503 | 656 | ||
| 657 | + | fn action(label: &str, danger: bool) -> ops_status::Action { | |
| 658 | + | ops_status::Action { | |
| 659 | + | label: label.into(), | |
| 660 | + | method: ops_status::Method::Post, | |
| 661 | + | url: "/rollback/b".into(), | |
| 662 | + | confirm: true, | |
| 663 | + | danger, | |
| 664 | + | body: None, | |
| 665 | + | } | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | /// A source with one node declaring `keys`, actions allowed, on its tab. | |
| 669 | + | fn actionable(keys: &[(&str, bool)]) -> Model { | |
| 670 | + | let mut n = node("tier:b", "b (prod-1)", Status::Ok); | |
| 671 | + | n.actions = keys.iter().map(|(k, _)| k.to_string()).collect(); | |
| 672 | + | let mut p = Payload::new("sando", now()); | |
| 673 | + | p.nodes = vec![n]; | |
| 674 | + | p.actions = keys | |
| 675 | + | .iter() | |
| 676 | + | .map(|(k, d)| (k.to_string(), action(k, *d))) | |
| 677 | + | .collect(); | |
| 678 | + | let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true); | |
| 679 | + | s.observe(p, now()); | |
| 680 | + | let mut m = Model::new(vec![s]); | |
| 681 | + | m.select_tab(1); | |
| 682 | + | m | |
| 683 | + | } | |
| 684 | + | ||
| 685 | + | #[test] | |
| 686 | + | fn the_detail_hint_says_whether_actions_can_run() { | |
| 687 | + | let mut m = actionable(&[("rollback-b", true)]); | |
| 688 | + | let text = joined(&draw(&m, now(), 80, 20)); | |
| 689 | + | assert!(text.contains("rollback-b"), "{text}"); | |
| 690 | + | assert!(text.contains("enter to run"), "{text}"); | |
| 691 | + | ||
| 692 | + | m.sources[0].allow_actions = false; | |
| 693 | + | let text = joined(&draw(&m, now(), 80, 20)); | |
| 694 | + | assert!( | |
| 695 | + | text.contains("read-only"), | |
| 696 | + | "a disabled source must say so:\n{text}" | |
| 697 | + | ); | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | #[test] | |
| 701 | + | fn the_picker_lists_a_nodes_actions() { | |
| 702 | + | let mut m = actionable(&[("promote-b", false), ("rollback-b", true)]); | |
| 703 | + | m.open_actions(); | |
| 704 | + | let text = joined(&draw(&m, now(), 80, 20)); | |
| 705 | + | assert!(text.contains("run action"), "{text}"); | |
| 706 | + | assert!(text.contains("[promote-b]"), "{text}"); | |
| 707 | + | assert!(text.contains("[rollback-b]"), "{text}"); | |
| 708 | + | assert!(text.contains("enter run"), "{text}"); | |
| 709 | + | } | |
| 710 | + | ||
| 711 | + | #[test] | |
| 712 | + | fn a_danger_prompt_shows_the_key_to_type() { | |
| 713 | + | let mut m = actionable(&[("rollback-b", true)]); | |
| 714 | + | m.open_actions(); | |
| 715 | + | m.prompt_enter(); // Pick -> Type (danger) | |
| 716 | + | let text = joined(&draw(&m, now(), 80, 20)); | |
| 717 | + | assert!( | |
| 718 | + | text.contains("DANGER"), | |
| 719 | + | "a danger action must be loud:\n{text}" | |
| 720 | + | ); | |
| 721 | + | assert!( | |
| 722 | + | text.contains("type 'rollback-b'"), | |
| 723 | + | "the exact key to type must be shown:\n{text}" | |
| 724 | + | ); | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | #[test] | |
| 728 | + | fn a_retracted_action_is_named_in_the_confirmation_not_left_blank() { | |
| 729 | + | let mut m = actionable(&[("promote-b", false)]); | |
| 730 | + | m.open_actions(); | |
| 731 | + | m.prompt_enter(); // Pick -> Confirm (confirm, not danger) | |
| 732 | + | // A poll drops the action while the confirm box is up. | |
| 733 | + | let mut p = Payload::new("sando", now()); | |
| 734 | + | p.nodes = vec![node("tier:b", "b", Status::Ok)]; | |
| 735 | + | m.sources[0].observe(p, now()); | |
| 736 | + | let text = joined(&draw(&m, now(), 80, 20)); | |
| 737 | + | assert!(text.contains("no longer offered"), "{text}"); | |
| 738 | + | } | |
| 739 | + | ||
| 504 | 740 | #[test] | |
| 505 | 741 | fn the_footer_shows_a_message_when_there_is_one() { | |
| 506 | 742 | let mut model = Model::new(vec![source("sando", now(), vec![])]); |