1use horto_os_ui_shared::{McpHostProbe, SurfaceProbeReport, LONG_VERSION};
4use ratatui::{
5 style::{Color, Modifier, Style},
6 text::{Line, Span},
7};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Screen {
12 Setup,
13 Overview,
14 Ssh,
15 Cli,
16 Api,
17 Mcp,
18 Reboot,
19 Logs,
20}
21
22impl Screen {
23 #[must_use]
25 pub fn titles(remote: bool) -> Vec<&'static str> {
26 if remote {
27 vec![
28 "1 Setup",
29 "2 Overview",
30 "3 SSH",
31 "4 CLI",
32 "5 API",
33 "6 MCP",
34 "7 Reboot",
35 "8 Logs",
36 ]
37 } else {
38 vec![
39 "1 Setup",
40 "2 Overview",
41 "3 SSH",
42 "4 CLI",
43 "5 API",
44 "6 MCP",
45 "7 Logs",
46 ]
47 }
48 }
49
50 #[must_use]
52 pub fn all(remote: bool) -> Vec<Self> {
53 if remote {
54 vec![
55 Self::Setup,
56 Self::Overview,
57 Self::Ssh,
58 Self::Cli,
59 Self::Api,
60 Self::Mcp,
61 Self::Reboot,
62 Self::Logs,
63 ]
64 } else {
65 vec![
66 Self::Setup,
67 Self::Overview,
68 Self::Ssh,
69 Self::Cli,
70 Self::Api,
71 Self::Mcp,
72 Self::Logs,
73 ]
74 }
75 }
76
77 #[must_use]
79 pub fn index(self, remote: bool) -> usize {
80 Self::all(remote)
81 .iter()
82 .position(|s| *s == self)
83 .unwrap_or(0)
84 }
85
86 #[must_use]
88 pub fn from_digit(d: char, remote: bool) -> Option<Self> {
89 let n = d.to_digit(10)? as usize;
90 if n == 0 {
91 return None;
92 }
93 Self::all(remote).into_iter().nth(n - 1)
94 }
95
96 #[must_use]
98 pub fn next(self, remote: bool) -> Self {
99 let all = Self::all(remote);
100 let i = self.index(remote);
101 all[(i + 1) % all.len()]
102 }
103
104 #[must_use]
106 pub fn prev(self, remote: bool) -> Self {
107 let all = Self::all(remote);
108 let i = self.index(remote);
109 all[(i + all.len() - 1) % all.len()]
110 }
111}
112
113fn label(s: &str) -> Span<'static> {
114 Span::styled(format!("{s:<14}"), Style::default().fg(Color::White))
115}
116
117fn value(s: impl Into<String>) -> Span<'static> {
118 Span::styled(s.into(), Style::default().fg(Color::Cyan))
119}
120
121fn muted(s: impl Into<String>) -> Span<'static> {
122 Span::styled(s.into(), Style::default().fg(Color::Cyan))
123}
124
125fn key_hint(s: &str) -> Span<'static> {
126 Span::styled(
127 s.to_owned(),
128 Style::default()
129 .fg(Color::Magenta)
130 .add_modifier(Modifier::BOLD),
131 )
132}
133
134fn section(title: &str) -> Line<'static> {
135 Line::from(Span::styled(
136 title.to_owned(),
137 Style::default()
138 .fg(Color::Green)
139 .add_modifier(Modifier::BOLD),
140 ))
141}
142
143fn blank() -> Line<'static> {
144 Line::from("")
145}
146
147fn kv(key: &str, val: Span<'static>) -> Line<'static> {
148 Line::from(vec![label(key), val])
149}
150
151fn action(keys: &str, desc: &str) -> Line<'static> {
152 Line::from(vec![key_hint(keys), Span::raw(format!(" {desc}"))])
153}
154
155fn status_badge(raw: &str) -> Span<'static> {
157 let lower = raw.to_ascii_lowercase();
158 let (dot, color) = if matches!(lower.as_str(), "ok" | "active" | "true" | "n/a" | "process")
159 || lower.contains("horto-os-ui")
160 || (raw.starts_with('/') && !lower.contains("missing"))
161 {
162 ('●', Color::Green)
163 } else if lower.contains("fail")
164 || lower.contains("unreachable")
165 || lower.contains("missing")
166 || lower == "inactive"
167 || lower == "false"
168 || lower.contains("error")
169 || lower.contains("auth")
170 || lower.contains("required")
171 {
172 ('●', Color::Red)
173 } else if lower.contains("probing") || lower.contains("pending") {
174 ('●', Color::Cyan)
175 } else {
176 ('●', Color::White)
177 };
178 Span::styled(
179 format!("{dot} {raw}"),
180 Style::default().fg(color).add_modifier(Modifier::BOLD),
181 )
182}
183
184fn bool_badge(ok: bool, yes: &str, no: &str) -> Span<'static> {
185 if ok {
186 Span::styled(
187 format!("● {yes}"),
188 Style::default()
189 .fg(Color::Green)
190 .add_modifier(Modifier::BOLD),
191 )
192 } else {
193 Span::styled(
194 format!("● {no}"),
195 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
196 )
197 }
198}
199
200#[must_use]
202pub fn panel_ssh(
203 remote: bool,
204 host: &str,
205 report: Option<&SurfaceProbeReport>,
206 pending_status: &str,
207 install_ssh_key: bool,
208) -> Vec<Line<'static>> {
209 if !remote {
210 return vec![
211 kv("Mode", value("embedded (on box)")),
212 kv("SSH", muted("n/a")),
213 ];
214 }
215 let mut lines = vec![section("Connection"), kv("Host", value(host.to_owned()))];
216 match report {
217 None => {
218 lines.push(kv("Status", status_badge(pending_status)));
219 lines.push(kv("Key auth", muted("…")));
220 }
221 Some(r) => {
222 lines.push(kv("Status", status_badge(&r.ssh.status)));
223 lines.push(kv(
224 "Key auth",
225 bool_badge(r.ssh.key_ok, "BatchMode ok", "password may be needed"),
226 ));
227 }
228 }
229 lines.push(blank());
230 lines.push(section("Actions"));
231 lines.push(action("Enter", "Edit OpenSSH Host"));
232 lines.push(action("f", "Fetch SSH status"));
233 if install_ssh_key {
234 lines.push(action("i", "Install this PC key on the box"));
235 } else {
236 lines.push(Line::from(muted(
237 "Key install off · start with --install-ssh-key to enable",
238 )));
239 }
240 lines
241}
242
243#[must_use]
245pub fn panel_cli(
246 remote: bool,
247 report: Option<&SurfaceProbeReport>,
248 pending_box: &str,
249) -> Vec<Line<'static>> {
250 let mut lines = vec![
251 section("Versions"),
252 kv("Local", value(LONG_VERSION.to_owned())),
253 ];
254 if !remote {
255 lines.push(kv("Box", value("local (embedded)")));
256 return lines;
257 }
258 match report {
259 None => {
260 lines.push(kv("Box", status_badge(pending_box)));
261 lines.push(blank());
262 lines.push(section("Actions"));
263 lines.push(action("f", "Fetch CLI status"));
264 }
265 Some(r) => {
266 lines.push(kv("Box", value(r.cli.status.as_label().to_owned())));
267 lines.push(kv(
268 "Match",
269 bool_badge(r.cli.current, "up to date", "out of date"),
270 ));
271 lines.push(blank());
272 lines.push(section("Actions"));
273 lines.push(action("Enter", "Sync CLI to box (s0)"));
274 lines.push(action("f", "Fetch CLI status"));
275 }
276 }
277 lines
278}
279
280#[must_use]
282pub fn panel_api(report: Option<&SurfaceProbeReport>) -> Vec<Line<'static>> {
283 report.map_or_else(
284 || {
285 vec![
286 section("Status API"),
287 Line::from(muted("No data yet")),
288 blank(),
289 section("Actions"),
290 action("f", "Fetch API status"),
291 ]
292 },
293 |r| {
294 let unit = if r.api.unit.is_empty() {
295 "-"
296 } else {
297 r.api.unit.as_str()
298 };
299 vec![
300 section("Status API"),
301 kv("URL", value(r.api.url.clone())),
302 kv("Health", status_badge(&r.api.health)),
303 kv("/v1/status", status_badge(&r.api.status)),
304 kv(
305 "Token file",
306 bool_badge(r.api.local_token, "present", "missing"),
307 ),
308 kv("Unit", status_badge(unit)),
309 blank(),
310 section("Actions"),
311 action("f", "Fetch API status"),
312 ]
313 },
314 )
315}
316
317#[must_use]
319pub fn panel_mcp(report: Option<&SurfaceProbeReport>) -> Vec<Line<'static>> {
320 report.map_or_else(
321 || {
322 vec![
323 Line::from(muted("No data yet")),
324 blank(),
325 section("Actions"),
326 action("f", "Fetch MCP status"),
327 ]
328 },
329 |r| {
330 let mut lines = Vec::new();
331 lines.extend(mcp_host_section("PC", &r.mcp_pc, true));
332 lines.push(blank());
333 lines.extend(mcp_host_section("Box", &r.mcp_box, false));
334 lines.push(blank());
335 lines.push(section("Actions"));
336 lines.push(action("f", "Fetch MCP status"));
337 lines
338 },
339 )
340}
341
342fn mcp_host_section(title: &str, p: &McpHostProbe, show_api: bool) -> Vec<Line<'static>> {
343 let docker = p.docker.as_deref().unwrap_or("missing");
344 let binary = p.binary.as_deref().unwrap_or("missing");
345 let unit = if p.unit.is_empty() {
346 "-"
347 } else {
348 p.unit.as_str()
349 };
350 let mut lines = vec![
351 section(title),
352 kv("Docker", status_badge(docker)),
353 kv("Binary", status_badge(binary)),
354 kv("HTTP", status_badge(&p.http_reach)),
355 Line::from(value(p.http_url.clone())),
356 kv("Unit", status_badge(unit)),
357 ];
358 if show_api {
359 lines.push(kv("API health", status_badge(&p.api_health)));
360 }
361 lines
362}
363
364#[must_use]
366pub fn panel_reboot(host: &str) -> Vec<Line<'static>> {
367 vec![
368 kv("Target", value(host.to_owned())),
369 kv("Method", value("SSH + sudo on the box")),
370 blank(),
371 section("Actions"),
372 action("Enter", "Confirm reboot"),
373 ]
374}
375
376#[must_use]
378pub fn panel_overview_remote(
379 host: &str,
380 report: Option<&SurfaceProbeReport>,
381 extra: &str,
382) -> Vec<Line<'static>> {
383 let mut lines = vec![section("Remote"), kv("Host", value(host.to_owned()))];
384 match report {
385 None => {
386 lines.push(kv("Surfaces", status_badge("probing…")));
387 }
388 Some(r) => {
389 lines.push(kv("Local CLI", value(r.local_version.clone())));
390 lines.push(kv("Box CLI", value(r.cli.status.as_label().to_owned())));
391 lines.push(blank());
392 lines.push(section("Surfaces"));
393 lines.push(kv("SSH", status_badge(&r.ssh.status)));
394 lines.push(kv("API", status_badge(&r.api.health)));
395 lines.push(kv("MCP PC HTTP", status_badge(&r.mcp_pc.http_reach)));
396 lines.push(kv("MCP box HTTP", status_badge(&r.mcp_box.http_reach)));
397 }
398 }
399 if !extra.is_empty() {
400 lines.push(blank());
401 lines.push(section("Doctor"));
402 for line in extra.lines() {
403 if line.is_empty() {
404 lines.push(blank());
405 } else {
406 lines.push(Line::from(muted(line.to_owned())));
407 }
408 }
409 }
410 lines.push(blank());
411 lines.push(section("Actions"));
412 lines.push(action("f", "Fetch overview surfaces"));
413 lines
414}
415
416#[must_use]
418pub fn panel_lines_from_plain(text: &str) -> Vec<Line<'static>> {
419 text.lines()
420 .map(|l| Line::from(Span::raw(l.to_owned())))
421 .collect()
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 fn lines_join(lines: &[Line<'static>]) -> String {
429 lines
430 .iter()
431 .map(std::string::ToString::to_string)
432 .collect::<Vec<_>>()
433 .join("\n")
434 }
435
436 #[test]
437 fn remote_tab_order_ends_with_logs() {
438 let t = Screen::titles(true);
439 assert_eq!(t.last().copied(), Some("8 Logs"));
440 assert!(t.iter().any(|x| x.contains("Reboot")));
441 assert_eq!(Screen::from_digit('8', true), Some(Screen::Logs));
442 assert_eq!(Screen::from_digit('7', false), Some(Screen::Logs));
443 assert_eq!(Screen::Setup.next(true), Screen::Overview);
444 assert_eq!(Screen::Logs.prev(true), Screen::Reboot);
445 }
446
447 #[test]
448 fn panels_pending_use_probing_not_question() {
449 let cli = lines_join(&panel_cli(true, None, "probing..."));
450 assert!(cli.contains("probing..."));
451 assert!(!cli.contains('?'));
452 assert!(!cli.contains("missing"));
453 let ssh = lines_join(&panel_ssh(true, "horto", None, "probing...", false));
454 assert!(ssh.contains("probing..."));
455 assert!(ssh.contains("Key install off"));
456 assert!(!ssh.contains("when started with"));
457 }
458
459 #[test]
460 fn ssh_key_install_hint_when_enabled() {
461 let ssh = lines_join(&panel_ssh(true, "horto", None, "ok", true));
462 assert!(ssh.contains("Install this PC key"));
463 }
464}