Skip to main content

orca_tui/
lib.rs

1pub mod api;
2mod commands;
3mod keys;
4pub mod state;
5pub mod ui;
6
7use std::io;
8use std::time::Duration;
9
10use crossterm::event::{self, Event, KeyEventKind};
11use crossterm::execute;
12use crossterm::terminal::{
13    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
14};
15use ratatui::Terminal;
16use ratatui::backend::CrosstermBackend;
17
18use api::ApiClient;
19use state::{AppState, InputMode, View};
20
21/// Run the TUI dashboard against the given API URL.
22pub async fn run_tui(api_url: &str) -> anyhow::Result<()> {
23    if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
24        anyhow::bail!("TUI requires an interactive terminal. Use `ssh -t` for remote access.");
25    }
26
27    let client = ApiClient::new(api_url);
28    let mut state = AppState::new();
29    state.api_url = client.url().to_string();
30
31    enable_raw_mode()?;
32    let mut stdout = io::stdout();
33    execute!(stdout, EnterAlternateScreen)?;
34    let backend = CrosstermBackend::new(stdout);
35    let mut terminal = Terminal::new(backend)?;
36
37    let result = event_loop(&mut terminal, &client, &mut state).await;
38
39    disable_raw_mode()?;
40    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
41    terminal.show_cursor()?;
42
43    result
44}
45
46async fn event_loop(
47    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
48    client: &ApiClient,
49    state: &mut AppState,
50) -> anyhow::Result<()> {
51    let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(2);
52    let mut last_log_refresh = tokio::time::Instant::now() - Duration::from_secs(2);
53
54    loop {
55        // Global data refresh every 2s.
56        if last_refresh.elapsed() >= Duration::from_secs(2) {
57            refresh(client, state).await;
58            last_refresh = tokio::time::Instant::now();
59        }
60
61        // Auto-refresh logs when in Logs view.
62        if matches!(state.view, View::Logs { .. })
63            && state.auto_refresh_logs
64            && last_log_refresh.elapsed() >= Duration::from_secs(2)
65        {
66            refresh_logs_for_view(client, state).await;
67            last_log_refresh = tokio::time::Instant::now();
68        }
69
70        state.tick = state.tick.wrapping_add(1);
71        state.maybe_clear_flash();
72
73        terminal.draw(|f| ui::draw(f, state))?;
74
75        if !event::poll(Duration::from_millis(100))? {
76            continue;
77        }
78        let Event::Key(key) = event::read()? else {
79            continue;
80        };
81        if key.kind != KeyEventKind::Press {
82            continue;
83        }
84
85        match state.input_mode {
86            InputMode::Filter => keys::handle_filter_key(state, key.code),
87            InputMode::Command => keys::handle_command_key(state, client, key.code).await,
88            InputMode::Normal => {
89                keys::handle_normal_key(state, client, key.code, &mut last_refresh).await;
90            }
91        }
92
93        // If a :sh / :exec command left a pending shell request on the
94        // state, suspend the ratatui alternate-screen, run the child
95        // command with inherited stdio, then rebuild the screen.
96        if let Some((service, node, cmd)) = state.pending_shell.take() {
97            if let Err(e) = run_container_shell(terminal, &service, node.as_deref(), &cmd) {
98                state.error = Some(format!("Exec failed: {e}"));
99            } else {
100                state.flash(format!("Shell in {service} exited"));
101            }
102        }
103
104        if state.should_quit {
105            return Ok(());
106        }
107    }
108}
109
110/// Suspend ratatui and run `docker exec -it` (or `ssh <node> docker exec`
111/// for remote services), blocking until the child exits.
112fn run_container_shell(
113    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
114    service: &str,
115    node: Option<&str>,
116    cmd: &[String],
117) -> anyhow::Result<()> {
118    disable_raw_mode()?;
119    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
120    terminal.show_cursor()?;
121
122    let container = format!("orca-{service}");
123    let mut child = if let Some(host) = node {
124        // `-t` forces a pty on the remote side so interactive programs
125        // (vim, less, /bin/sh with a real prompt) behave correctly.
126        let mut c = std::process::Command::new("ssh");
127        c.args(["-t", host, "docker", "exec", "-it", &container]);
128        for a in cmd {
129            c.arg(a);
130        }
131        c
132    } else {
133        let mut c = std::process::Command::new("docker");
134        c.args(["exec", "-it", &container]);
135        for a in cmd {
136            c.arg(a);
137        }
138        c
139    };
140    let status = child.status()?;
141
142    enable_raw_mode()?;
143    execute!(io::stdout(), EnterAlternateScreen)?;
144    terminal.clear()?;
145    terminal.hide_cursor()?;
146    if !status.success() {
147        anyhow::bail!("exit status {status}");
148    }
149    Ok(())
150}
151
152/// Get the service name from the current view context or selection.
153fn current_service_name(state: &AppState) -> Option<String> {
154    match &state.view {
155        View::Detail { service } | View::Logs { service } => Some(service.clone()),
156        View::Services => state.selected_service_name().map(|s| s.to_string()),
157        _ => None,
158    }
159}
160
161async fn refresh(client: &ApiClient, state: &mut AppState) {
162    state.error = None;
163    match client.status().await {
164        Ok(resp) => state.update_status(resp),
165        Err(e) => {
166            state.mark_disconnected();
167            state.error = Some(format!("API error: {e}"));
168        }
169    }
170    if let Ok(info) = client.cluster_info().await {
171        state.update_cluster(info);
172    }
173}
174
175async fn refresh_logs_for_view(client: &ApiClient, state: &mut AppState) {
176    if let View::Logs { service } = &state.view {
177        let name = service.clone();
178        refresh_logs_named(client, state, &name).await;
179    }
180}
181
182async fn refresh_logs_named(client: &ApiClient, state: &mut AppState, name: &str) {
183    match client.logs(name, 50).await {
184        Ok(logs) => state.logs = logs,
185        Err(e) => state.logs = format!("Failed to fetch logs: {e}"),
186    }
187}
188
189async fn handle_stop(client: &ApiClient, state: &mut AppState) {
190    if let Some(name) = current_service_name(state) {
191        match client.stop(&name).await {
192            Ok(()) => state.flash(format!("Stopped {name}")),
193            Err(e) => state.error = Some(format!("Stop failed: {e}")),
194        }
195    }
196}