Skip to main content

max / makenotwork

viewer: fire declared actions, gated per source and by a typed confirm The viewer was read-only: it rendered a node's declared actions by name but could not issue them. It can now, without learning what any action means. Firing is opt-in per source (allow_actions, default off): pointing the viewer at a daemon can never move it by accident. Enter on a node opens a picker of its actions; a plain action fires, a confirm action asks for 'y', and a danger action (sando's rollback-b) asks the operator to type its key -- muscle memory cannot type rollback-b. The action is re-resolved from the live payload at fire time, so a poll that retracts it cancels rather than firing a stale promote. Execution lives at the edge in a new exec module, mirroring poll: a detached task issues the request (method, url, optional JSON body, bearer token, all declared by the producer) and reports the outcome to the footer. The model stays a pure state machine over the prompt, so the whole path is unit-tested with no daemon running. 81 tests, clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 00:09 UTC
Commit: 6bcef424f6e634be3fdda87382b332c08241ba2e
Parent: d0f1dda
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 + }
M viewer/src/main.rs +288 -20
@@ -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![])]);