1use clap::Parser;
4use horto_os_ui_shared::{
5 backup_etc_timestamped, box_status, finish_save_api_token,
6 install_ecosystem_after_embedded_apply, pipeline, probe_api_surface, probe_cli_surface,
7 probe_disk_backup, probe_mcp_surface, probe_ssh_surface, probe_surfaces, remote_run_cli,
8 remote_upload_cli, require_root_for_apply, setup_run, setup_step, ApiSurfaceProbe, ApplyMode,
9 CliSurfaceProbe, DiskBackupOpts, EcosystemInstallChoice, HostContext, McpHostProbe,
10 RemoteBoxCliStatus, RemoteOptions, RemoteOptionsInput, RemoteRunFlags, RemoteRunRequest,
11 SetupKind, SshSurfaceProbe, StdioPrompts, SurfaceProbeReport, SystemProcessRunner,
12 DEFAULT_INSTALL_DIR, LONG_VERSION,
13};
14use ratatui::text::Line;
15use ratatui::widgets::ListState;
16use std::fmt::Write;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::mpsc::{self, Receiver, Sender};
19use std::sync::Arc;
20use std::thread;
21use std::time::{Duration, Instant};
22
23use crate::probe_job::{run_remote_probe, RemoteProbeOk, RemoteProbeOutcome};
24use crate::prompt::{SecretInput, TextInput};
25use crate::tabs::{self, Screen};
26
27#[derive(Debug, Clone)]
29pub enum Modal {
30 Confirm(ConfirmKind),
31 TextHost(TextInput),
32 SudoPassword(SecretInput),
33 Rebooting,
35}
36
37pub enum RebootEvent {
39 Issued,
41 BoxBack,
43 Failed(String),
44}
45
46pub enum FetchEvent {
48 Overview(Box<SurfaceProbeReport>),
49 Ssh(SshSurfaceProbe),
50 Cli(CliSurfaceProbe),
51 Api(ApiSurfaceProbe),
52 Mcp(McpHostProbe, McpHostProbe),
53 Failed(String),
54}
55
56#[derive(Debug, Clone)]
58pub enum ConfirmKind {
59 DestructiveStep(String),
60 Reboot,
61 SaveToken(String),
62 RebootAfterApply,
63 InstallStatusApi {
65 remote: bool,
66 },
67 InstallMcp {
69 remote: bool,
70 status_api: bool,
71 },
72}
73
74impl ConfirmKind {
75 #[must_use]
77 pub const fn title(&self) -> &'static str {
78 match self {
79 Self::DestructiveStep(_) => "Confirm destructive step",
80 Self::Reboot => "Confirm reboot",
81 Self::SaveToken(_) => "Save API token",
82 Self::RebootAfterApply => "Reboot after apply",
83 Self::InstallStatusApi { .. } => "Install status-api?",
84 Self::InstallMcp { .. } => "Install MCP?",
85 }
86 }
87
88 #[must_use]
90 pub fn body(&self) -> String {
91 match self {
92 Self::DestructiveStep(id) => {
93 format!("Step {id} is destructive. Continue?")
94 }
95 Self::Reboot => "Reboot the box now?".into(),
96 Self::SaveToken(_) => {
97 "Save status-api bearer to ~/.config/horto-os-ui/api_token?".into()
98 }
99 Self::RebootAfterApply => "Remote apply succeeded. Reboot the box now?".into(),
100 Self::InstallStatusApi { remote } => {
101 if *remote {
102 "Install status-api (systemd) on the remote box?".into()
103 } else {
104 "Install status-api (systemd) on this host?".into()
105 }
106 }
107 Self::InstallMcp { remote, .. } => {
108 if *remote {
109 "Install MCP (systemd) on the remote box?".into()
110 } else {
111 "Install MCP (systemd) on this host?".into()
112 }
113 }
114 }
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum BoxCliView {
121 Probing,
123 Known(RemoteBoxCliStatus),
125}
126
127impl BoxCliView {
128 #[must_use]
130 pub const fn as_label(&self) -> &str {
131 match self {
132 Self::Probing => "probing...",
133 Self::Known(s) => s.as_label(),
134 }
135 }
136}
137
138#[allow(clippy::struct_excessive_bools)]
139#[derive(Parser, Debug)]
140#[command(
141 name = "horto-os-ui-tui",
142 about = "Horto OS UI terminal",
143 version,
144 long_version = LONG_VERSION
145)]
146pub struct Cli {
148 #[arg(long)]
149 pub apply: bool,
150 #[arg(long)]
151 pub minimal: bool,
152 #[arg(long)]
153 pub skip_piper: bool,
154 #[arg(long)]
156 pub remote: Option<String>,
157 #[arg(long, default_value_t = false)]
159 pub install_ssh_key: bool,
160 #[arg(long, env = "HORTO_BIN_DIR")]
162 pub bin_dir: Option<std::path::PathBuf>,
163 #[arg(long, env = "HORTO_RELEASE_TAG")]
165 pub release_tag: Option<String>,
166}
167
168#[allow(clippy::struct_excessive_bools)]
170pub struct App {
171 pub screen: Screen,
172 pub apply: bool,
173 pub kind: SetupKind,
174 pub skip_piper: bool,
175 pub remote: Option<String>,
176 pub install_ssh_key: bool,
177 pub bin_dir: Option<std::path::PathBuf>,
178 pub release_tag: Option<String>,
179 pub step_state: ListState,
180 pub logs: Vec<String>,
181 pub status_lines: Vec<String>,
182 pub overview_text: String,
183 pub panel_lines: Vec<Line<'static>>,
184 pub modal: Option<Modal>,
185 pub pending_reboot_offer: bool,
187 pub help_open: bool,
188 pub message: String,
189 pub cli_local: String,
191 pub box_cli: BoxCliView,
193 pub cli_current: bool,
195 pub surfaces: Option<SurfaceProbeReport>,
197 pub overview_extra: String,
199 pub probe_tx: Sender<RemoteProbeOutcome>,
201 pub probe_rx: Receiver<RemoteProbeOutcome>,
203 pub probe_inflight: bool,
205 pub reboot_rx: Option<Receiver<RebootEvent>>,
207 pub reboot_inflight: bool,
209 pub reboot_cancel: Arc<AtomicBool>,
211 pub fetch_rx: Option<Receiver<FetchEvent>>,
213 pub fetch_inflight: bool,
214}
215
216impl App {
217 pub fn new(cli: &Cli) -> Self {
219 let kind = if cli.minimal {
220 SetupKind::Minimal
221 } else {
222 SetupKind::Full
223 };
224 let mut step_state = ListState::default();
225 step_state.select(Some(0));
226 let (probe_tx, probe_rx) = mpsc::channel();
227 let mut app = Self {
228 screen: Screen::Setup,
229 apply: cli.apply,
230 kind,
231 skip_piper: cli.skip_piper,
232 remote: cli.remote.clone(),
233 install_ssh_key: cli.install_ssh_key,
234 bin_dir: cli.bin_dir.clone(),
235 release_tag: cli.release_tag.clone(),
236 step_state,
237 logs: Vec::new(),
238 status_lines: Vec::new(),
239 overview_text: String::new(),
240 panel_lines: Vec::new(),
241 modal: None,
242 pending_reboot_offer: false,
243 help_open: false,
244 message: String::new(),
245 cli_local: LONG_VERSION.to_owned(),
246 box_cli: if cli.remote.is_some() {
247 BoxCliView::Probing
248 } else {
249 BoxCliView::Known(RemoteBoxCliStatus::Found(LONG_VERSION.to_owned()))
250 },
251 cli_current: cli.remote.is_none(),
252 surfaces: None,
253 overview_extra: String::new(),
254 probe_tx,
255 probe_rx,
256 probe_inflight: false,
257 reboot_rx: None,
258 reboot_inflight: false,
259 reboot_cancel: Arc::new(AtomicBool::new(false)),
260 fetch_rx: None,
261 fetch_inflight: false,
262 };
263 if app.remote.is_some() {
264 app.rebuild_remote_steps(None);
265 app.message = format!("Refreshing {}", app.remote.as_deref().unwrap_or("box"));
266 app.refresh_panel_text();
267 } else {
268 app.refresh();
269 }
270 app
271 }
272 pub const fn is_remote(&self) -> bool {
274 self.remote.is_some()
275 }
276 pub fn refresh_panel_text(&mut self) {
278 let host = self.remote.as_deref().unwrap_or("local");
279 self.panel_lines = match self.screen {
280 Screen::Ssh => tabs::panel_ssh(
281 self.is_remote(),
282 host,
283 self.surfaces.as_ref(),
284 self.box_cli.as_label(),
285 self.install_ssh_key,
286 ),
287 Screen::Cli => tabs::panel_cli(
288 self.is_remote(),
289 self.surfaces.as_ref(),
290 self.box_cli.as_label(),
291 ),
292 Screen::Api => tabs::panel_api(self.surfaces.as_ref()),
293 Screen::Mcp => tabs::panel_mcp(self.surfaces.as_ref()),
294 Screen::Reboot => tabs::panel_reboot(host),
295 Screen::Overview => {
296 if self.is_remote() {
297 tabs::panel_overview_remote(host, self.surfaces.as_ref(), &self.overview_extra)
298 } else {
299 tabs::panel_lines_from_plain(&self.overview_text)
300 }
301 }
302 Screen::Setup | Screen::Logs => Vec::new(),
303 };
304 }
305 pub fn s0_line(&self) -> String {
307 let status = match &self.box_cli {
308 BoxCliView::Probing => "probing",
309 BoxCliView::Known(RemoteBoxCliStatus::AuthFailed | RemoteBoxCliStatus::Unreachable) => {
310 "blocked"
311 }
312 BoxCliView::Known(_) if self.cli_current => "done",
313 BoxCliView::Known(_) => "pending",
314 };
315 format!("s0 | {status} | Sync CLI to box")
316 }
317 pub fn rebuild_remote_steps(&mut self, setup: Option<&horto_os_ui_shared::SetupStatusReport>) {
319 let mut lines = vec![self.s0_line()];
320 if let Some(report) = setup {
321 lines.extend(report.steps.iter().map(|s| {
322 format!(
323 "{} | {} | {}{}",
324 s.id,
325 s.status,
326 s.title,
327 if s.destructive { " *" } else { "" }
328 )
329 }));
330 } else {
331 lines.extend(pipeline(self.kind).iter().map(|s| {
332 format!(
333 "{} | pending | {}{}",
334 s.id(),
335 s.title(),
336 if s.destructive() { " *" } else { "" }
337 )
338 }));
339 }
340 self.status_lines = lines;
341 self.select_smart_step();
342 }
343 pub fn select_smart_step(&mut self) {
345 if self.remote.is_some() && !self.cli_current {
346 self.step_state.select(Some(0));
347 return;
348 }
349 let start = usize::from(self.remote.is_some());
350 for (i, line) in self.status_lines.iter().enumerate().skip(start) {
351 let status = line.split(" | ").nth(1).unwrap_or("");
352 if status == "pending" {
353 self.step_state.select(Some(i));
354 return;
355 }
356 }
357 if self.status_lines.len() > start {
358 self.step_state.select(Some(start));
359 } else {
360 self.step_state.select(Some(0));
361 }
362 }
363 pub fn set_pipeline_kind_local(&mut self, kind: SetupKind) {
365 self.kind = kind;
366 if self.remote.is_some() {
367 self.rebuild_remote_steps(None);
369 self.message = format!("pipeline={} (press r for status)", kind.as_str());
370 } else {
371 self.refresh_local();
372 self.message = format!("pipeline={}", kind.as_str());
373 }
374 }
375 pub fn remote_opts(&self) -> Option<RemoteOptions> {
377 self.remote.as_ref().map(|host| {
378 RemoteOptions::from_input(RemoteOptionsInput {
379 host: host.clone(),
380 install_ssh_key: self.install_ssh_key,
381 bin_dir: self.bin_dir.clone(),
382 release_tag: self.release_tag.clone(),
383 force_askpass: true,
384 })
385 })
386 }
387 pub fn refresh(&mut self) {
389 if self.remote.is_some() {
390 self.start_remote_probe();
391 } else {
392 self.refresh_local();
393 }
394 }
395
396 pub fn start_remote_probe(&mut self) {
398 let Some(opts) = self.remote_opts() else {
399 return;
400 };
401 if self.probe_inflight {
402 self.message = "Refresh already running".into();
403 return;
404 }
405 let host = opts.host.clone();
406 let full = self.kind != SetupKind::Minimal;
407 self.probe_inflight = true;
408 self.box_cli = BoxCliView::Probing;
409 self.surfaces = None;
410 self.rebuild_remote_steps(None);
411 self.message = format!("Refreshing {host}");
412 let tx = self.probe_tx.clone();
413 thread::spawn(move || {
414 let _ = tx.send(run_remote_probe(&opts, full));
415 });
416 }
417
418 pub fn poll_probe(&mut self) {
420 match self.probe_rx.try_recv() {
421 Ok(outcome) => {
422 self.probe_inflight = false;
423 self.apply_remote_probe(outcome);
424 }
425 Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => {}
426 }
427 }
428 pub fn apply_remote_probe(&mut self, outcome: RemoteProbeOutcome) {
430 let host = outcome.host;
431 let ok = match outcome.result {
432 Ok(ok) => ok,
433 Err(e) => {
434 self.box_cli = BoxCliView::Known(RemoteBoxCliStatus::Unreachable);
435 self.cli_current = false;
436 self.surfaces = None;
437 self.push_log(format!("ERROR probe: {e}"));
438 self.message = format!("Refresh failed: {e}");
439 self.rebuild_remote_steps(None);
440 self.refresh_panel_text();
441 return;
442 }
443 };
444 self.apply_remote_probe_ok(&host, ok);
445 }
446 pub fn apply_remote_probe_ok(&mut self, _host: &str, ok: RemoteProbeOk) {
448 self.box_cli = BoxCliView::Known(ok.report.cli.status.clone());
449 self.cli_current = ok.report.cli.current;
450 self.surfaces = Some(ok.report);
451 self.push_log(format!(
452 "probe ssh={} cli={} api={}",
453 self.surfaces.as_ref().unwrap().ssh.status,
454 self.box_cli.as_label(),
455 self.surfaces.as_ref().unwrap().api.health
456 ));
457
458 self.overview_extra.clear();
459 match ok.snapshot {
460 Some(Ok(snap)) => {
461 self.rebuild_remote_steps(snap.setup.as_ref());
462 if let Some(doc) = &snap.doctor {
463 let _ = writeln!(
464 self.overview_extra,
465 "doctor: root={} sudo={} docker={} full_env={} minimal_env={}",
466 doc.is_root,
467 doc.has_sudo,
468 doc.docker_present,
469 doc.full_env,
470 doc.minimal_env
471 );
472 }
473 self.push_log(format!(
474 "setup steps={}",
475 snap.setup.as_ref().map_or(0, |s| s.steps.len())
476 ));
477 }
478 Some(Err(e)) => {
479 self.rebuild_remote_steps(None);
480 self.push_log(format!("ERROR setup status: {e}"));
481 }
482 None => {
483 self.rebuild_remote_steps(None);
484 }
485 }
486
487 self.refresh_panel_text();
488 if matches!(
489 self.box_cli,
490 BoxCliView::Known(RemoteBoxCliStatus::AuthFailed)
491 ) {
492 self.message = "SSH auth failed. Check key or password; UI stays up.".into();
493 } else if self.message.starts_with("Refreshing") {
494 self.message.clear();
496 }
497 }
498 pub fn open_host_editor(&mut self) {
500 let initial = self.remote.clone().unwrap_or_default();
501 self.modal = Some(Modal::TextHost(TextInput::new("OpenSSH Host", initial)));
502 self.message = "Edit OpenSSH Host".into();
503 }
504 pub fn apply_host_edit(&mut self, host: &str) {
506 let host = host.trim().to_owned();
507 if host.is_empty() {
508 self.message = "Host unchanged (empty)".into();
509 return;
510 }
511 self.remote = Some(host.clone());
512 self.box_cli = BoxCliView::Probing;
513 self.cli_current = false;
514 self.surfaces = None;
515 self.rebuild_remote_steps(None);
516 self.refresh_panel_text();
517 self.push_log(format!("host set to {host}; refreshing..."));
518 self.start_remote_probe();
519 }
520 pub fn install_ssh_key_action(&mut self) {
522 if !self.install_ssh_key {
523 self.message = "Start with --install-ssh-key to enable key install".into();
524 return;
525 }
526 let Some(opts) = self.remote_opts() else {
527 return;
528 };
529 self.push_log("SSH: installing key…");
530 match horto_os_ui_shared::remote_ensure_ssh_key(&SystemProcessRunner, &opts) {
531 Ok(()) => {
532 self.message = "SSH key installed (or already authorized)".into();
533 self.refresh();
534 }
535 Err(e) => {
536 self.push_log(format!("ERROR ssh key: {e}"));
537 self.message = format!("SSH key failed: {e}");
538 }
539 }
540 }
541 pub fn run_surface_enter(&mut self) {
543 match self.screen {
544 Screen::Ssh => self.open_host_editor(),
545 Screen::Cli => self.run_s0_sync(),
546 Screen::Reboot => self.arm_reboot_confirm(),
547 Screen::Overview | Screen::Api | Screen::Mcp | Screen::Setup | Screen::Logs => {}
548 }
549 }
550 pub fn arm_reboot_confirm(&mut self) {
552 self.modal = Some(Modal::Confirm(ConfirmKind::Reboot));
553 }
554 pub fn note(&mut self, msg: impl Into<String>) {
556 self.message = msg.into();
557 }
558 pub fn do_reboot(&mut self, sudo_password: String) {
560 if self.reboot_inflight {
561 self.note("Reboot already running");
562 return;
563 }
564 let Some(opts) = self.remote_opts() else {
565 return;
566 };
567 let host = opts.host.clone();
568 self.push_log("reboot: sudo reboot on box…");
569 self.modal = Some(Modal::Rebooting);
570 self.reboot_inflight = true;
571 self.reboot_cancel.store(false, Ordering::SeqCst);
572 let cancel = Arc::clone(&self.reboot_cancel);
573 let (tx, rx) = mpsc::channel();
574 self.reboot_rx = Some(rx);
575 thread::spawn(move || {
576 let result = horto_os_ui_shared::remote_reboot_with_sudo_password(
577 &SystemProcessRunner,
578 &opts,
579 &sudo_password,
580 );
581 match result {
582 Ok(()) => {
583 let _ = tx.send(RebootEvent::Issued);
584 match wait_until_box_replies(&host, &cancel) {
585 Ok(()) => {
586 let _ = tx.send(RebootEvent::BoxBack);
587 }
588 Err(e) => {
589 let _ = tx.send(RebootEvent::Failed(e));
590 }
591 }
592 }
593 Err(e) => {
594 let _ = tx.send(RebootEvent::Failed(e.to_string()));
595 }
596 }
597 });
598 }
599 pub fn poll_reboot(&mut self) {
601 let Some(rx) = &self.reboot_rx else {
602 return;
603 };
604 match rx.try_recv() {
605 Ok(RebootEvent::Issued) => {
606 self.push_log("Reboot issued");
607 self.note("Reboot issued");
608 }
609 Ok(RebootEvent::BoxBack) => {
610 self.reboot_inflight = false;
611 self.reboot_rx = None;
612 if matches!(self.modal, Some(Modal::Rebooting)) {
613 self.modal = None;
614 }
615 self.push_log("Reboot done");
616 self.note("Reboot done");
617 }
618 Ok(RebootEvent::Failed(e)) => {
619 self.reboot_inflight = false;
620 self.reboot_rx = None;
621 if matches!(self.modal, Some(Modal::Rebooting)) {
622 self.modal = None;
623 }
624 if e == "cancelled" {
625 return;
626 }
627 self.push_log(format!("ERROR reboot: {e}"));
628 self.note(short_reboot_err(&e));
629 }
630 Err(mpsc::TryRecvError::Empty) => {}
631 Err(mpsc::TryRecvError::Disconnected) => {
632 self.reboot_inflight = false;
633 self.reboot_rx = None;
634 if matches!(self.modal, Some(Modal::Rebooting)) {
635 self.modal = None;
636 }
637 self.note("Reboot failed (worker dropped)");
638 }
639 }
640 }
641 pub fn fetch_current_tab(&mut self) {
643 if !matches!(
644 self.screen,
645 Screen::Overview | Screen::Ssh | Screen::Cli | Screen::Api | Screen::Mcp
646 ) {
647 return;
648 }
649 if self.fetch_inflight || self.probe_inflight {
650 self.note("Fetch already running");
651 return;
652 }
653 let Some(opts) = self.remote_opts() else {
654 self.note("Fetch needs --remote");
655 return;
656 };
657 let screen = self.screen;
658 self.fetch_inflight = true;
659 self.note(match screen {
660 Screen::Overview => "Fetching overview…",
661 Screen::Ssh => "Fetching SSH…",
662 Screen::Cli => "Fetching CLI…",
663 Screen::Api => "Fetching API…",
664 Screen::Mcp => "Fetching MCP…",
665 _ => "Fetching…",
666 });
667 let (tx, rx) = mpsc::channel();
668 self.fetch_rx = Some(rx);
669 thread::spawn(move || {
670 let embedded = false;
671 let event = match screen {
672 Screen::Overview => match probe_surfaces(&SystemProcessRunner, &opts, embedded) {
673 Ok(r) => FetchEvent::Overview(Box::new(r)),
674 Err(e) => FetchEvent::Failed(e.to_string()),
675 },
676 Screen::Ssh => match probe_ssh_surface(&SystemProcessRunner, &opts, embedded) {
677 Ok(r) => FetchEvent::Ssh(r),
678 Err(e) => FetchEvent::Failed(e.to_string()),
679 },
680 Screen::Cli => match probe_cli_surface(&SystemProcessRunner, &opts, embedded) {
681 Ok(r) => FetchEvent::Cli(r),
682 Err(e) => FetchEvent::Failed(e.to_string()),
683 },
684 Screen::Api => match probe_api_surface(&SystemProcessRunner, &opts, embedded) {
685 Ok(r) => FetchEvent::Api(r),
686 Err(e) => FetchEvent::Failed(e.to_string()),
687 },
688 Screen::Mcp => match probe_mcp_surface(&SystemProcessRunner, &opts, embedded) {
689 Ok((pc, bx)) => FetchEvent::Mcp(pc, bx),
690 Err(e) => FetchEvent::Failed(e.to_string()),
691 },
692 _ => FetchEvent::Failed("unsupported tab".into()),
693 };
694 let _ = tx.send(event);
695 });
696 }
697 pub fn ensure_surfaces_shell(&mut self) {
699 if self.surfaces.is_some() {
700 return;
701 }
702 let host = self.remote.clone().unwrap_or_else(|| "box".into());
703 self.surfaces = Some(SurfaceProbeReport {
704 local_version: LONG_VERSION.to_owned(),
705 ssh: SshSurfaceProbe {
706 host: host.clone(),
707 status: "…".into(),
708 key_ok: false,
709 },
710 cli: CliSurfaceProbe {
711 status: RemoteBoxCliStatus::Unreachable,
712 version: None,
713 current: false,
714 },
715 api: ApiSurfaceProbe {
716 url: format!("http://{host}:8787"),
717 health: "…".into(),
718 status: "…".into(),
719 local_token: false,
720 unit: String::new(),
721 },
722 mcp_pc: McpHostProbe {
723 docker: None,
724 binary: None,
725 http_url: format!("http://{host}:8790"),
726 http_reach: "…".into(),
727 unit: String::new(),
728 api_health: "…".into(),
729 },
730 mcp_box: McpHostProbe {
731 docker: None,
732 binary: None,
733 http_url: format!("http://{host}:8790"),
734 http_reach: "…".into(),
735 unit: String::new(),
736 api_health: "…".into(),
737 },
738 });
739 }
740 pub fn poll_fetch(&mut self) {
742 let Some(rx) = &self.fetch_rx else {
743 return;
744 };
745 match rx.try_recv() {
746 Ok(event) => {
747 self.fetch_inflight = false;
748 self.fetch_rx = None;
749 match event {
750 FetchEvent::Overview(report) => {
751 self.box_cli = BoxCliView::Known(report.cli.status.clone());
752 self.cli_current = report.cli.current;
753 self.push_log(format!(
754 "fetch overview ssh={} api={} mcp={}",
755 report.ssh.status, report.api.health, report.mcp_box.http_reach
756 ));
757 self.surfaces = Some(*report);
758 self.note("Overview fetched");
759 }
760 FetchEvent::Ssh(ssh) => {
761 self.ensure_surfaces_shell();
762 if let Some(s) = self.surfaces.as_mut() {
763 s.ssh = ssh;
764 }
765 self.push_log(format!(
766 "fetch ssh={}",
767 self.surfaces
768 .as_ref()
769 .map_or("?", |s| s.ssh.status.as_str())
770 ));
771 self.note("SSH fetched");
772 }
773 FetchEvent::Cli(cli) => {
774 self.box_cli = BoxCliView::Known(cli.status.clone());
775 self.cli_current = cli.current;
776 self.ensure_surfaces_shell();
777 if let Some(s) = self.surfaces.as_mut() {
778 s.cli = cli;
779 }
780 self.push_log(format!("fetch cli={}", self.box_cli.as_label()));
781 self.note("CLI fetched");
782 }
783 FetchEvent::Api(api) => {
784 self.ensure_surfaces_shell();
785 self.push_log(format!("fetch api health={}", api.health));
786 if let Some(s) = self.surfaces.as_mut() {
787 s.api = api;
788 }
789 self.note("API fetched");
790 }
791 FetchEvent::Mcp(pc, bx) => {
792 self.ensure_surfaces_shell();
793 self.push_log(format!(
794 "fetch mcp pc={} box={}",
795 pc.http_reach, bx.http_reach
796 ));
797 if let Some(s) = self.surfaces.as_mut() {
798 s.mcp_pc = pc;
799 s.mcp_box = bx;
800 }
801 self.note("MCP fetched");
802 }
803 FetchEvent::Failed(e) => {
804 self.push_log(format!("ERROR fetch: {e}"));
805 self.note(format!("Fetch failed: {e}"));
806 }
807 }
808 self.refresh_panel_text();
809 }
810 Err(mpsc::TryRecvError::Empty) => {}
811 Err(mpsc::TryRecvError::Disconnected) => {
812 self.fetch_inflight = false;
813 self.fetch_rx = None;
814 }
815 }
816 }
817 pub fn resolve_confirm_yes(&mut self) {
819 let Some(Modal::Confirm(kind)) = self.modal.take() else {
820 return;
821 };
822 match kind {
823 ConfirmKind::DestructiveStep(id) => self.execute_step(&id),
824 ConfirmKind::Reboot | ConfirmKind::RebootAfterApply => {
825 if !self.apply {
826 self.note("PLAN: reboot not sent (Tab → APPLY)");
827 self.push_log("PLAN: reboot not sent (press Tab for APPLY)");
828 return;
829 }
830 self.modal = Some(Modal::SudoPassword(SecretInput::new("Sudo password (box)")));
831 self.message = "Enter sudo password".into();
832 }
833 ConfirmKind::SaveToken(token) => {
834 match finish_save_api_token(&token, "y") {
835 Ok(true) => {
836 self.push_log(
837 "Saved status-api bearer to ~/.config/horto-os-ui/api_token".to_owned(),
838 );
839 self.message = "API token saved".into();
840 }
841 Ok(false) => {
842 self.push_log("Skipped saving status-api bearer locally");
843 self.message = "Token save skipped".into();
844 }
845 Err(e) => {
846 self.push_log(format!("Token save failed: {e}"));
847 self.message = format!("Token save failed: {e}");
848 }
849 }
850 self.maybe_offer_reboot_after_token();
851 }
852 ConfirmKind::InstallStatusApi { remote } => {
853 self.modal = Some(Modal::Confirm(ConfirmKind::InstallMcp {
854 remote,
855 status_api: true,
856 }));
857 self.message = "Install MCP? Enter/y confirm, Esc/n cancel.".into();
858 }
859 ConfirmKind::InstallMcp { remote, status_api } => {
860 self.run_full_pipeline_with_ecosystem(
861 remote,
862 EcosystemInstallChoice {
863 status_api,
864 mcp: true,
865 },
866 );
867 }
868 }
869 }
870 pub fn resolve_confirm_no(&mut self) {
872 let Some(Modal::Confirm(kind)) = self.modal.take() else {
873 self.modal = None;
874 self.message = "Cancelled".into();
875 return;
876 };
877 match kind {
878 ConfirmKind::InstallStatusApi { remote } => {
879 self.modal = Some(Modal::Confirm(ConfirmKind::InstallMcp {
880 remote,
881 status_api: false,
882 }));
883 self.message = "Install MCP? Enter/y confirm, Esc/n cancel.".into();
884 }
885 ConfirmKind::InstallMcp { remote, status_api } => {
886 self.run_full_pipeline_with_ecosystem(
887 remote,
888 EcosystemInstallChoice {
889 status_api,
890 mcp: false,
891 },
892 );
893 }
894 ConfirmKind::SaveToken(_) => {
895 self.message = "Cancelled".into();
896 self.push_log("Skipped saving status-api bearer locally");
897 self.maybe_offer_reboot_after_token();
898 }
899 _ => {
900 self.message = "Cancelled".into();
901 }
902 }
903 }
904 pub fn maybe_offer_reboot_after_token(&mut self) {
906 if !self.pending_reboot_offer {
907 return;
908 }
909 self.pending_reboot_offer = false;
910 self.modal = Some(Modal::Confirm(ConfirmKind::RebootAfterApply));
911 self.message = "Reboot after apply? Enter/y confirm, Esc/n cancel.".into();
912 }
913 pub fn run_s0_sync(&mut self) {
915 let Some(opts) = self.remote_opts() else {
916 return;
917 };
918 self.push_log("s0: syncing CLI to box…");
919 match remote_upload_cli(&SystemProcessRunner, &opts) {
920 Ok(probe) => {
921 self.box_cli = BoxCliView::Known(probe.status.clone());
922 self.cli_current = probe.current;
923 self.push_log(format!("s0: box={}", probe.status.as_label()));
924 if probe.current {
925 self.refresh();
926 self.message = "s0 done; refreshing...".into();
927 } else {
928 self.rebuild_remote_steps(None);
929 self.message = format!(
930 "s0 finished but box still {}; check SSH/auth",
931 probe.status.as_label()
932 );
933 }
934 }
935 Err(e) => {
936 self.push_log(format!("ERROR s0: {e}"));
937 self.message = format!("s0 failed: {e}");
938 }
939 }
940 }
941 pub fn refresh_local(&mut self) {
943 let ctx = self.make_ctx();
944 let report = horto_os_ui_shared::setup_status(&ctx, self.kind);
945 self.status_lines = report
946 .steps
947 .iter()
948 .map(|s| {
949 format!(
950 "{} | {} | {}{}",
951 s.id,
952 s.status,
953 s.title,
954 if s.destructive { " *" } else { "" }
955 )
956 })
957 .collect();
958 let box_st = box_status(&ctx, self.kind);
959 let mut overview = String::new();
960 overview.push_str("Mode: embedded\n");
961 let _ = writeln!(overview, "Hostname: {}", box_st.hostname);
962 let _ = writeln!(
963 overview,
964 "Root: {} Docker: {} Full env: {} Minimal env: {}",
965 box_st.doctor.is_root,
966 box_st.doctor.docker_present,
967 box_st.doctor.full_env,
968 box_st.doctor.minimal_env
969 );
970 for n in &box_st.doctor.notes {
971 let _ = writeln!(overview, "- {n}");
972 }
973 overview.push_str("\nContainers:\n");
974 if box_st.containers.is_empty() {
975 overview.push_str(" (none)\n");
976 } else {
977 for c in &box_st.containers {
978 let _ = writeln!(overview, " {} {}", c.names, c.status);
979 }
980 }
981 overview.push_str("\nURLs:\n");
982 for u in &box_st.urls {
983 let mark = if u.up { "up" } else { "down" };
984 let _ = writeln!(overview, " {} [{}]: {}", u.name, mark, u.url);
985 }
986 let _ = writeln!(overview, "\nLeases: {}", box_st.leases.len());
987 for l in box_st.leases.iter().take(12) {
988 let _ = writeln!(overview, " {} {}", l.hostname, l.ip);
989 }
990 overview.push_str("\nBackup:\n");
991 let _ = writeln!(
992 overview,
993 " initial_setup: {}",
994 box_st.backup.initial_setup_present
995 );
996 if box_st.backup.timestamped.is_empty() {
997 overview.push_str(" timestamped: (none)\n");
998 } else {
999 let recent: Vec<_> = box_st.backup.timestamped.iter().rev().take(5).collect();
1000 let _ = writeln!(
1001 overview,
1002 " timestamped ({}): {}",
1003 box_st.backup.timestamped.len(),
1004 recent
1005 .iter()
1006 .map(|s| s.as_str())
1007 .collect::<Vec<_>>()
1008 .join(", ")
1009 );
1010 }
1011 let _ = writeln!(
1012 overview,
1013 " disk root={} safe={} blockers={}",
1014 box_st.backup.disk.root_source,
1015 box_st.backup.disk.safe_to_apply,
1016 box_st.backup.disk.blockers.len()
1017 );
1018 self.overview_text = overview;
1019 let opts = RemoteOptions::default();
1020 match probe_surfaces(&SystemProcessRunner, &opts, true) {
1021 Ok(report) => {
1022 self.surfaces = Some(report);
1023 }
1024 Err(e) => {
1025 self.push_log(format!("ERROR local probe: {e}"));
1026 }
1027 }
1028 self.refresh_panel_text();
1029 }
1030 pub fn run_remote_cli(
1032 &mut self,
1033 rest: &[&str],
1034 use_sudo: bool,
1035 ecosystem: EcosystemInstallChoice,
1036 ) {
1037 let Some(options) = self.remote_opts() else {
1038 return;
1039 };
1040 let mut cli_args = Vec::new();
1041 if self.apply {
1042 cli_args.push("--apply".into());
1043 }
1044 if self.skip_piper {
1045 cli_args.push("--skip-piper".into());
1046 }
1047 for a in rest {
1048 cli_args.push((*a).to_owned());
1049 }
1050 match remote_run_cli(
1051 &SystemProcessRunner,
1052 &RemoteRunRequest {
1053 options,
1054 cli_args,
1055 flags: RemoteRunFlags {
1056 use_sudo,
1057 install_payload_on_success: ecosystem.any(),
1058 offer_reboot_on_success: false,
1059 capture_output: false,
1060 },
1061 ecosystem,
1062 },
1063 ) {
1064 Ok(outcome) => {
1065 for line in outcome.log.lines() {
1066 self.push_log(line.to_owned());
1067 }
1068 let offer_reboot = ecosystem.any() && self.apply;
1069 if let Some(token) = outcome.api_token {
1070 self.pending_reboot_offer = offer_reboot;
1071 self.modal = Some(Modal::Confirm(ConfirmKind::SaveToken(token)));
1072 self.message = "Save API token? Enter/y confirm, Esc/n cancel.".into();
1073 } else if offer_reboot {
1074 self.modal = Some(Modal::Confirm(ConfirmKind::RebootAfterApply));
1075 self.message = "Reboot after apply? Enter/y confirm, Esc/n cancel.".into();
1076 } else {
1077 self.message = "Remote command finished".into();
1078 }
1079 }
1080 Err(e) => {
1081 self.push_log(format!("ERROR: {e}"));
1082 self.message = format!("Remote failed: {e}");
1083 }
1084 }
1085 }
1086 pub fn make_ctx(&self) -> HostContext {
1088 let mode = if self.apply {
1089 ApplyMode::Apply
1090 } else {
1091 ApplyMode::DryRun
1092 };
1093 let mut ctx = HostContext::new(mode, self.kind).with_prompts(Box::new(StdioPrompts));
1094 ctx.skip_piper = self.skip_piper;
1095 ctx
1096 }
1097 pub fn selected_step_id(&self) -> Option<String> {
1099 let idx = self.step_state.selected()?;
1100 self.status_lines
1101 .get(idx)
1102 .and_then(|l| l.split(" | ").next())
1103 .map(str::to_string)
1104 }
1105 pub fn push_log(&mut self, line: impl Into<String>) {
1107 let ts = chrono::Local::now().format("%H:%M:%S");
1108 self.logs.push(format!("{ts} {}", line.into()));
1109 if self.logs.len() > 500 {
1110 self.logs.drain(0..self.logs.len() - 500);
1111 }
1112 }
1113 pub fn clear_logs(&mut self) {
1115 self.logs.clear();
1116 self.message = "Logs cleared".into();
1117 }
1118 pub fn next_screen(&mut self) {
1120 self.screen = self.screen.next(self.is_remote());
1121 self.refresh_panel_text();
1122 }
1123 pub fn prev_screen(&mut self) {
1125 self.screen = self.screen.prev(self.is_remote());
1126 self.refresh_panel_text();
1127 }
1128 pub fn select_screen(&mut self, screen: Screen) {
1130 self.screen = screen;
1131 self.help_open = false;
1132 self.refresh_panel_text();
1133 }
1134 pub fn run_selected(&mut self) {
1136 let Some(id) = self.selected_step_id() else {
1137 return;
1138 };
1139 if id == "s0" {
1140 self.run_s0_sync();
1141 return;
1142 }
1143 if self.remote.is_some() && !self.cli_current {
1144 self.message = match &self.box_cli {
1145 BoxCliView::Probing => "Still probing box CLI...".into(),
1146 BoxCliView::Known(RemoteBoxCliStatus::AuthFailed) => {
1147 "SSH auth failed: install key (--install-ssh-key) or run s0 after login".into()
1148 }
1149 BoxCliView::Known(RemoteBoxCliStatus::Unreachable) => {
1150 "Box unreachable: check host/network, then press r".into()
1151 }
1152 BoxCliView::Known(_) => "Run s0 (Sync CLI) before other steps".into(),
1153 };
1154 return;
1155 }
1156 let ctx_probe = self.make_ctx();
1157 let report = horto_os_ui_shared::setup_status(&ctx_probe, self.kind);
1158 if let Some(row) = report.steps.iter().find(|s| s.id == id) {
1159 if row.destructive && self.apply {
1160 self.modal = Some(Modal::Confirm(ConfirmKind::DestructiveStep(id)));
1161 self.message = "Step is destructive. Enter/y confirm, Esc/n cancel.".into();
1162 return;
1163 }
1164 }
1165 self.execute_step(&id);
1166 }
1167 pub fn execute_step(&mut self, id: &str) {
1169 self.push_log(format!("Running step {id} (apply={})", self.apply));
1170 if self.remote.is_some() {
1171 let kind = if self.kind == SetupKind::Minimal {
1172 "--minimal"
1173 } else {
1174 "--full"
1175 };
1176 self.run_remote_cli(
1177 &["setup", "step", id, kind],
1178 self.apply,
1179 EcosystemInstallChoice::none(),
1180 );
1181 self.refresh();
1182 return;
1183 }
1184 let mut ctx = self.make_ctx();
1185 match setup_step(&mut ctx, self.kind, id) {
1186 Ok(()) => {
1187 for l in &ctx.logs {
1188 self.push_log(l.clone());
1189 }
1190 self.message = format!("Step {id} finished");
1191 }
1192 Err(e) => {
1193 self.push_log(format!("ERROR: {e}"));
1194 self.message = format!("Step {id} failed: {e}");
1195 }
1196 }
1197 self.refresh();
1198 }
1199 pub fn run_all(&mut self) {
1201 if self.remote.is_some() && !self.cli_current {
1202 self.message = match &self.box_cli {
1203 BoxCliView::Probing => "Still probing box CLI...".into(),
1204 BoxCliView::Known(_) => "Run s0 (Sync CLI) before running all steps".into(),
1205 };
1206 return;
1207 }
1208 if self.apply && self.kind == SetupKind::Full {
1209 let remote = self.remote.is_some();
1210 self.modal = Some(Modal::Confirm(ConfirmKind::InstallStatusApi { remote }));
1211 self.message = "Install status-api? Enter/y confirm, Esc/n skip.".into();
1212 return;
1213 }
1214 self.run_full_pipeline_with_ecosystem(
1215 self.remote.is_some(),
1216 EcosystemInstallChoice::none(),
1217 );
1218 }
1219 pub fn run_full_pipeline_with_ecosystem(
1221 &mut self,
1222 remote: bool,
1223 ecosystem: EcosystemInstallChoice,
1224 ) {
1225 self.push_log(format!(
1226 "Running all pipeline steps (apply={}, status_api={}, mcp={})",
1227 self.apply, ecosystem.status_api, ecosystem.mcp
1228 ));
1229 if remote {
1230 let kind = if self.kind == SetupKind::Minimal {
1231 "--minimal"
1232 } else {
1233 "--full"
1234 };
1235 self.run_remote_cli(&["setup", "run", kind], self.apply, ecosystem);
1236 self.refresh();
1237 return;
1238 }
1239 let mut ctx = self.make_ctx();
1240 match setup_run(&mut ctx, self.kind) {
1241 Ok(()) => {
1242 for l in &ctx.logs {
1243 self.push_log(l.clone());
1244 }
1245 self.message = "Pipeline finished".into();
1246 if self.apply && self.kind == SetupKind::Full && ecosystem.any() {
1247 match install_ecosystem_after_embedded_apply(
1248 &SystemProcessRunner,
1249 std::path::Path::new(DEFAULT_INSTALL_DIR),
1250 ecosystem,
1251 ) {
1252 Ok(token) => {
1253 self.push_log(format!(
1254 "Installed selected ecosystem services under {DEFAULT_INSTALL_DIR}"
1255 ));
1256 if let Some(hex) = token {
1257 self.modal = Some(Modal::Confirm(ConfirmKind::SaveToken(hex)));
1258 self.message =
1259 "Save API token? Enter/y confirm, Esc/n cancel.".into();
1260 }
1261 }
1262 Err(e) => {
1263 self.push_log(format!("ERROR ecosystem install: {e}"));
1264 self.message = format!("Ecosystem install failed: {e}");
1265 }
1266 }
1267 } else if self.apply && self.kind == SetupKind::Full && !ecosystem.any() {
1268 self.push_log("Skipped status-api / MCP install");
1269 }
1270 }
1271 Err(e) => {
1272 self.push_log(format!("ERROR: {e}"));
1273 self.message = format!("Pipeline failed: {e}");
1274 }
1275 }
1276 self.refresh();
1277 }
1278 pub fn run_backup_etc(&mut self) {
1280 self.push_log(format!("Timestamped /etc backup (apply={})", self.apply));
1281 let mut ctx = self.make_ctx();
1282 if let Err(e) = require_root_for_apply(ctx.mode) {
1283 self.push_log(format!("ERROR: {e}"));
1284 self.message = format!("Backup etc failed: {e}");
1285 return;
1286 }
1287 match backup_etc_timestamped(&mut ctx) {
1288 Ok(report) => {
1289 for l in &ctx.logs {
1290 self.push_log(l.clone());
1291 }
1292 self.message = format!("Backup etc -> {}", report.dest);
1293 }
1294 Err(e) => {
1295 self.push_log(format!("ERROR: {e}"));
1296 self.message = format!("Backup etc failed: {e}");
1297 }
1298 }
1299 self.refresh();
1300 }
1301 pub fn show_disk_backup_status(&mut self) {
1303 let probe = probe_disk_backup(&DiskBackupOpts::default());
1304 self.push_log(format!(
1305 "disk backup: root={} safe={} partclone={}",
1306 probe.root_source, probe.safe_to_apply, probe.partclone_present
1307 ));
1308 for b in &probe.blockers {
1309 self.push_log(format!("blocker: {b}"));
1310 }
1311 self.message = if probe.safe_to_apply {
1312 "Disk backup looks safe (CLI: horto backup disk)".into()
1313 } else {
1314 format!(
1315 "Disk backup blocked ({}). See Logs. Boot from SD for eMMC image.",
1316 probe.blockers.len()
1317 )
1318 };
1319 self.screen = Screen::Logs;
1320 self.refresh_panel_text();
1321 }
1322}
1323
1324fn short_reboot_err(err: &str) -> String {
1325 let lower = err.to_ascii_lowercase();
1326 if lower.contains("sorry, try again") || lower.contains("incorrect password") {
1327 return "Reboot failed: wrong sudo password".into();
1328 }
1329 if lower.contains("no password was provided") || lower.contains("a terminal is required") {
1330 return "Reboot failed: sudo password not accepted".into();
1331 }
1332 let one_line: String = err
1333 .chars()
1334 .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
1335 .collect();
1336 let trimmed = one_line.trim();
1337 if trimmed.len() > 80 {
1338 format!("Reboot failed: {}…", &trimmed[..77])
1339 } else {
1340 format!("Reboot failed: {trimmed}")
1341 }
1342}
1343
1344fn wait_until_box_replies(host: &str, cancel: &AtomicBool) -> std::result::Result<(), String> {
1347 let deadline = Instant::now() + Duration::from_secs(300);
1348 let mut saw_down = false;
1349 let mut interval = Duration::from_millis(500);
1350 let max_interval = Duration::from_secs(3);
1351
1352 while Instant::now() < deadline {
1353 if cancel.load(Ordering::SeqCst) {
1354 return Err("cancelled".into());
1355 }
1356 let up = ssh_host_up(host);
1357 if !saw_down {
1358 if !up {
1359 saw_down = true;
1360 }
1361 } else if up {
1362 return Ok(());
1363 }
1364 sleep_cancellable(interval, cancel)?;
1365 interval = (interval + Duration::from_millis(250)).min(max_interval);
1366 }
1367 Err("timed out waiting for box to reply".into())
1368}
1369
1370fn ssh_host_up(host: &str) -> bool {
1371 std::process::Command::new("ssh")
1372 .args([
1373 "-o",
1374 "BatchMode=yes",
1375 "-o",
1376 "ConnectTimeout=3",
1377 "-o",
1378 "StrictHostKeyChecking=accept-new",
1379 host,
1380 "true",
1381 ])
1382 .stdout(std::process::Stdio::null())
1383 .stderr(std::process::Stdio::null())
1384 .status()
1385 .is_ok_and(|s| s.success())
1386}
1387
1388fn sleep_cancellable(total: Duration, cancel: &AtomicBool) -> std::result::Result<(), String> {
1389 let mut slept = Duration::ZERO;
1390 let slice = Duration::from_millis(100);
1391 while slept < total {
1392 if cancel.load(Ordering::SeqCst) {
1393 return Err("cancelled".into());
1394 }
1395 thread::sleep(slice.min(total.checked_sub(slept).unwrap()));
1396 slept += slice;
1397 }
1398 Ok(())
1399}