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
21pub 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 if last_refresh.elapsed() >= Duration::from_secs(2) {
57 refresh(client, state).await;
58 last_refresh = tokio::time::Instant::now();
59 }
60
61 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 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
110fn 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 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
152fn 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}