diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f8caef3716..163297b14d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,7 +26,7 @@ repos: hooks: - id: codespell additional_dependencies: ['tomli'] - args: ['-L', 'cann,thi,makro,wil,rouge,PRIS,hel,te,ans,ser,wit,WIT,implementors,caf'] + args: ['-L', 'cann,thi,makro,wil,rouge,PRIS,hel,te,ans,ser,wit,WIT,implementors,caf,ratatui'] exclude: | (?x)^( src/proto/.*\.proto$| diff --git a/Cargo.toml b/Cargo.toml index bd9c4bc3ec..7645f8ddde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["model_gateway", "crates/protocols", "crates/reasoning_parser", "crates/tool_parser", "crates/workflow", "crates/tokenizer", "crates/auth", "crates/mcp", "crates/kv_index", "crates/data_connector", "crates/multimodal", "crates/wasm", "crates/mesh", "crates/grpc_client", "bindings/python", "bindings/golang", "clients/rust", "clients/openapi-gen"] +members = ["model_gateway", "crates/protocols", "crates/reasoning_parser", "crates/tool_parser", "crates/workflow", "crates/tokenizer", "crates/auth", "crates/mcp", "crates/kv_index", "crates/data_connector", "crates/multimodal", "crates/wasm", "crates/mesh", "crates/grpc_client", "bindings/python", "bindings/golang", "clients/rust", "clients/openapi-gen", "tui"] resolver = "2" [workspace.dependencies] @@ -17,6 +17,7 @@ llm-multimodal = { version = "1.4.0", path = "crates/multimodal" } smg-wasm = { version = "1.1.0", path = "crates/wasm", package = "smg-wasm" } smg-mesh = { version = "1.2.1", path = "crates/mesh", package = "smg-mesh" } smg-grpc-client = { version = "1.4.0", path = "crates/grpc_client" } +smg-tui = { version = "0.1.0", path = "tui" } # Shared dependencies anyhow = "1.0" diff --git a/tui/Cargo.toml b/tui/Cargo.toml new file mode 100644 index 0000000000..4894b8db4b --- /dev/null +++ b/tui/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "smg-tui" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "smg-tui" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +reqwest = { workspace = true, features = ["json", "rustls-tls", "stream"] } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +tokio = { workspace = true, features = ["full"] } +tracing.workspace = true +tracing-subscriber.workspace = true +chrono.workspace = true +futures.workspace = true +openai-protocol.workspace = true + +ratatui = "0.29" +crossterm = { version = "0.28", features = ["event-stream"] } +clap = { version = "4", features = ["derive", "env"] } + +[lints] +workspace = true diff --git a/tui/README.md b/tui/README.md new file mode 100644 index 0000000000..61512793e3 --- /dev/null +++ b/tui/README.md @@ -0,0 +1,274 @@ +# smg-tui + +Terminal dashboard for [Shepherd Model Gateway](../README.md) — monitor workers, route traffic, and chat with models, all from your terminal. + +![TUI Demo](assets/tui-demo.gif) + +## Install + +```bash +cargo build -p smg-tui --release +# Binary at: target/release/smg-tui +``` + +## Quick Start + +```bash +# Auto-start gateway and connect + ./target/release/smg-tui --auto-start + +# Connect to an existing gateway + ./target/release/smg-tui --gateway-url http://localhost:30000 + +# With API key for OpenAI/Anthropic models +OPENAI_API_KEY=sk-... ./target/release/smg-tui --auto-start +``` + +## CLI Options + +| Flag | Default | Env Var | Description | +|--------------------|--------------------------|-------------------------------------------------------|--------------------------------| +| `--gateway-url` | `http://localhost:30000` | | SMG gateway base URL | +| `--metrics-url` | `http://localhost:29000` | | Prometheus metrics endpoint | +| `--poll-interval` | `3` | | Polling interval (seconds) | +| `--api-key` | | `SMG_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` | API key for authenticated endpoints | +| `--auto-start` | `false` | | Start gateway if not reachable | + +### Auto-Start + +When `--auto-start` is passed and the gateway is not reachable: + +1. Launches `smg launch` with `--enable-igw --policy round_robin` +2. Falls back to `cargo run -p smg` if the binary isn't in PATH +3. Polls the health endpoint until the gateway is ready (up to 120s) +4. On `q`: TUI exits but gateway keeps running. On `Ctrl+C ×2`: full shutdown kills the gateway + +## Views + +Switch views with number keys `1`-`7`. + +| Key | View | Description | +|-----|------------|-------------------------------------------------------| +| `1` | Pulse | Real-time dashboard with worker health, throughput sparkline, request stats | +| `2` | Workers | Worker table with running reqs, token usage, detail panel | +| `3` | Chat | Interactive streaming chat with any model | +| `4` | Logs | TUI logs, gateway logs, per-worker logs with sub-tabs | +| `5` | Benchmark | *(coming soon)* | +| `6` | Traffic | *(coming soon)* | +| `7` | Mesh | *(coming soon)* | + +--- + +## Stats Bar + +The top header displays four metric cards updated every poll cycle: + +```text + WORKERS CIRCUIT BREAKERS REQ/S AVG LATENCY + 5 5 250.3 450ms + all healthy all closed 51 in-flight ▓▓▓░░░░░ +``` + +| Card | Source | Description | +|------------------|-------------------------------------------|------------------------------------------| +| WORKERS | `GET /workers` | Total count + health status | +| CIRCUIT BREAKERS | `smg_worker_cb_state` (Prometheus) | Open/closed count + failure tracking | +| REQ/S | `smg_router_requests_total` (Prometheus) | Requests per second + in-flight count | +| AVG LATENCY | `smg_router_request_duration` (Prometheus)| Per-interval avg latency with gauge bar | + +--- + +## Pulse View + +Two-column real-time dashboard: + +**Left column:** +- **Worker Health** — workers grouped by model (local) or provider (external), each with health dot, hostname, runtime, connection mode +- **GPUs** — GPU utilization, memory bar, temperature via `nvidia-smi` (auto-hides when unavailable) + +**Right column:** +- **Throughput** — req/s sparkline over 60s window with tok/s when available +- **Request Stats** — avg latency (with sparkline), active connections, in-flight requests + +--- + +## Workers View + +| Key | Action | +|------------|---------------------------------------| +| `j`/`Down` | Move selection down | +| `k`/`Up` | Move selection up | +| `Enter` | Toggle detail panel | +| `a` | Add worker wizard | +| `d` | Delete worker (kills backend process) | +| `e` | Edit worker (action menu) | +| `/` | Filter workers | + +### Table Columns + +| Column | HTTP sglang/vllm | gRPC sglang | External (OpenAI) | +|-----------|------------------------|-----------------------|-----------------------| +| Running | `num_running_reqs` | `req/s` (Prometheus) | `req/s` (Prometheus) | +| Tok Usage | KV cache `token_usage` | N/A | N/A | + +### Detail Panel + +Press `Enter` to toggle. Shows: + +**Config:** URL, runtime, mode, health, models served + +**Stats (HTTP sglang/vllm):** +- Running requests with gauge bar (current / max) +- Waiting requests +- KV cache usage with gauge bar (tokens used / total) +- Gen throughput (when available) + +**Stats (gRPC / External):** +- Req/s from Prometheus +- Circuit breaker state (open/closed) + +--- + +## Chat View + +Interactive chat with any model through the SMG gateway. + +| Key | Action | +|--------------|------------------------------------| +| `Enter` | Send message | +| `Tab` | Cycle through available models | +| `Shift+Tab` | Cycle endpoint (chat / responses) | +| `Esc` | Cancel streaming / clear input | +| `Up`/`Down` | Scroll conversation | + +**Features:** +- Streaming responses with live cursor +- Markdown rendering — **bold**, *italic*, `code`, headings, bullets, fenced code blocks +- Multi-turn conversation: + - **Chat completions** (`/v1/chat/completions`): sends full conversation history each turn + - **Responses API** (`/v1/responses`): uses `previous_response_id` for efficient multi-turn +- Title bar shows current model, endpoint, and multi-turn mode +- Auto-selects first available model on send +- Model filtering: local models shown as-is, OpenAI filtered to `gpt-5.4*` to keep the list manageable + +--- + +## Logs View + +Sub-tab system for viewing different log sources: + +| Key | Action | +|-----|---------------------------------| +| `a` | Switch to TUI application logs | +| `b` | Switch to SMG gateway logs | +| `w` | Cycle through worker logs | +| `j`/`k` | Scroll up/down | +| `G` | Jump to bottom (auto-scroll) | + +```text + a:TUI b:SMG w:Llama-37595 w:Qwen2-39223 j/k scroll G bottom w worker +``` + +- **TUI logs**: Application events (worker add/delete, status changes) +- **SMG logs**: Gateway logs from `/tmp/smg-gateway.log` (ANSI stripped) +- **Worker logs**: Per-worker logs from `/tmp/smg-worker-{port}.log`, labeled as `model(5chars)-port` + +--- + +## Adding Workers + +![Add Worker](assets/add-worker.gif) + +### External Providers (API-based) + +Press `a` → `1. External` → select provider → enter API key (or press Enter to use env var): + +| Provider | URL | Env Var | +|-----------|--------------------------------------------|-------------------| +| OpenAI | `https://api.openai.com` | `OPENAI_API_KEY` | +| Anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | +| xAI | `https://api.x.ai` | `XAI_API_KEY` | +| Gemini | `https://generativelanguage.googleapis.com` | `GEMINI_API_KEY` | + +The API key is stored on the worker for model discovery (required in IGW mode). + +### Local Workers (sglang / vllm) + +Press `a` → `2. Local` → select runtime → connection mode → model preset: + +| Preset | Model ID | TP | +|----------------|------------------------------------------------|----| +| Llama-3.2-1B | `meta-llama/Llama-3.2-1B-Instruct` | 1 | +| Llama-3.1-8B | `meta-llama/Llama-3.1-8B-Instruct` | 1 | +| Qwen2.5-7B | `Qwen/Qwen2.5-7B-Instruct` | 1 | +| Qwen2.5-14B | `Qwen/Qwen2.5-14B-Instruct` | 2 | +| DeepSeek-R1-7B | `deepseek-ai/DeepSeek-R1-Distill-Qwen-7B` | 1 | +| Mistral-7B | `mistralai/Mistral-7B-Instruct-v0.3` | 1 | + +**GPU management:** +- Auto-selects free GPUs (>2GB free via `nvidia-smi`) +- Sets `CUDA_VISIBLE_DEVICES` to avoid conflicts +- Tracks claimed GPUs to prevent double-allocation before memory shows in `nvidia-smi` +- Releases GPU claims on worker deletion +- Kills backend process on worker deletion + +### Custom URL + +Press `a` → `3. Custom URL` → enter worker URL directly. + +### Command Mode + +Press `:` for command mode: + +```text +:add [--provider

] [--runtime ] +:delete +:priority +:cost +:flush-cache +:toggle-health +:quit +``` + +--- + +## Key Bindings + +| Key | Context | Action | +|-----------------|------------|----------------------------------| +| `1`-`7` | Global | Switch view | +| `q` | Global | Quit TUI (services keep running) | +| `Ctrl+C` ×2 | Global | Full shutdown (stop all services) | +| `?` | Global | Toggle help overlay | +| `/` | Workers | Filter mode | +| `:` | Workers | Command mode | +| `Esc` | Any | Close overlay / clear filter | +| `j` / `Down` | Workers | Move selection down | +| `k` / `Up` | Workers | Move selection up | +| `Enter` | Workers | Toggle detail panel | +| `a` | Workers | Add worker wizard | +| `d` | Workers | Delete worker | +| `e` | Workers | Action menu | +| `Enter` | Chat | Send message | +| `Tab` | Chat | Cycle model | +| `Shift+Tab` | Chat | Cycle endpoint | +| `a`/`b`/`w` | Logs | Switch log sub-tab | +| `G` | Logs | Jump to bottom | + +--- + +## Architecture + +The TUI polls the SMG gateway every `--poll-interval` seconds via: + +| Endpoint | Data | +|-----------------------|---------------------------------------| +| `GET /readiness` | Gateway health | +| `GET /workers` | Worker list, models, health | +| `GET /get_loads` | Per-worker load details (HTTP only) | +| `GET /v1/models` | Available models | +| `GET /ha/status` | Cluster status (mesh mode) | +| `GET /ha/health` | Mesh health | +| `GET /metrics` (Prometheus) | req/s, latency, circuit breakers, tokens, per-worker counts | + +Chat uses `POST /v1/chat/completions` and `POST /v1/responses` with SSE streaming. diff --git a/tui/assets/add-worker.gif b/tui/assets/add-worker.gif new file mode 100644 index 0000000000..5e148d6f4c Binary files /dev/null and b/tui/assets/add-worker.gif differ diff --git a/tui/assets/tui-demo.gif b/tui/assets/tui-demo.gif new file mode 100644 index 0000000000..8ba11ec18e Binary files /dev/null and b/tui/assets/tui-demo.gif differ diff --git a/tui/src/app.rs b/tui/src/app.rs new file mode 100644 index 0000000000..62245d926f --- /dev/null +++ b/tui/src/app.rs @@ -0,0 +1,1565 @@ +use std::collections::VecDeque; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use openai_protocol::worker::{ProviderType, RuntimeType, WorkerSpec}; +use tokio::sync::mpsc; + +use crate::{ + chat::{ChatEndpoint, ChatMessage}, + client::SmgClient, + event::{AppEvent, EventHandler}, + state::SharedState, + types::{ActionMenuItem, AddMenuState, InputMode, ProviderPreset, View}, + ui, +}; + +/// Top-level application state. +pub struct App { + pub view: View, + pub input_mode: InputMode, + pub input_buffer: String, + pub active_filter: Option, + pub should_quit: bool, + /// True when user wants full shutdown (kill workers + gateway). False = quit TUI only. + pub full_shutdown: bool, + /// Tracks first Ctrl+C press for double-Ctrl+C full shutdown. + ctrl_c_at: Option, + pub selected_index: usize, + pub state: SharedState, + pub client: SmgClient, + pub status_message: Option, + pub show_help: bool, + /// (worker_id, worker_url) pending confirmation + pub confirm_delete: Option<(String, String)>, + pub show_detail: bool, + pub show_action_menu: bool, + pub action_menu_index: usize, + pub add_menu_state: Option, + pub confirm_flush: Option<(String, String)>, + + // Playground state + pub chat_messages: Vec, + pub chat_input: String, + pub chat_model: String, + pub chat_streaming: bool, + pub chat_scroll: u16, + pub chat_endpoint: ChatEndpoint, + pub chat_previous_response_id: Option, + chat_stream_rx: Option>, + + // Logs + pub log_entries: VecDeque, + pub log_scroll: u16, + pub log_auto_scroll: bool, + pub log_sub_tab: LogSubTab, + + // Spawned local worker processes + pub worker_children: Vec<(String, tokio::process::Child)>, // (description, child) + // GPUs claimed by spawned workers, keyed by worker URL (to avoid double-allocation) + claimed_gpus: std::collections::HashMap>, + + status_clear_at: Option, +} + +const MAX_LOG_ENTRIES: usize = 1000; + +#[derive(Debug, Clone)] +pub struct LogEntry { + pub timestamp: chrono::DateTime, + pub level: LogLevel, + pub message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + Info, + Warn, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LogSubTab { + Tui, + Gateway, + Worker(String), // port number as string +} + +impl LogSubTab { + pub fn label(&self) -> String { + match self { + Self::Tui => "a:TUI".to_string(), + Self::Gateway => "b:SMG".to_string(), + Self::Worker(port) => format!("w:{port}"), + } + } +} + +impl App { + pub fn new(state: SharedState, client: SmgClient) -> Self { + Self { + view: View::Pulse, + input_mode: InputMode::Normal, + input_buffer: String::new(), + active_filter: None, + should_quit: false, + full_shutdown: false, + ctrl_c_at: None, + selected_index: 0, + state, + client, + status_message: None, + show_help: false, + confirm_delete: None, + show_detail: false, + show_action_menu: false, + action_menu_index: 0, + add_menu_state: None, + confirm_flush: None, + chat_messages: Vec::new(), + chat_input: String::new(), + chat_model: String::new(), + chat_streaming: false, + chat_scroll: 0, + chat_endpoint: ChatEndpoint::default(), + chat_previous_response_id: None, + chat_stream_rx: None, + log_entries: VecDeque::with_capacity(MAX_LOG_ENTRIES), + log_scroll: u16::MAX, + log_auto_scroll: true, + log_sub_tab: LogSubTab::Tui, + worker_children: Vec::new(), + claimed_gpus: std::collections::HashMap::new(), + status_clear_at: None, + } + } + + pub async fn run( + &mut self, + terminal: &mut ratatui::Terminal>, + ) -> Result<()> { + let mut events = EventHandler::new(250); + + loop { + terminal.draw(|f| ui::render(f, self))?; + + match events.next().await { + Some(AppEvent::Key(key)) => self.handle_key(key).await, + Some(AppEvent::Tick) => self.on_tick(), + Some(AppEvent::Resize(_, _)) => {} // ratatui handles resize on next draw + None => break, + } + + if self.should_quit { + break; + } + } + + // Only kill spawned workers on full shutdown (Ctrl+C×2). + // Note: main() also handles this after terminal cleanup, but we start + // killing here so processes begin dying while the terminal restores. + if self.full_shutdown { + for (desc, child) in &mut self.worker_children { + if let Err(e) = child.start_kill() { + tracing::warn!("Failed to kill worker {desc}: {e}"); + } + } + } + + Ok(()) + } + + fn on_tick(&mut self) { + // Auto-clear status message after 5 seconds + if let Some(deadline) = self.status_clear_at { + if std::time::Instant::now() >= deadline { + self.status_message = None; + self.status_clear_at = None; + } + } + + // Log poller errors + { + let err_msg = { + // Safety: RwLock is not poisoned — no panics while holding the lock + #[expect(clippy::unwrap_used)] + let state = self.state.read().unwrap(); + state.last_error.clone() + }; + if let Some(err) = err_msg { + let already_logged = self + .log_entries + .back() + .map(|e| e.message.contains(&err)) + .unwrap_or(false); + if !already_logged { + self.add_log(LogLevel::Warn, &format!("Gateway: {err}")); + } + } + } + + // Drain streaming tokens from playground + if let Some(ref mut rx) = self.chat_stream_rx { + let mut got_data = false; + loop { + match rx.try_recv() { + Ok(token) => { + got_data = true; + if token == "\n[DONE]" { + self.chat_streaming = false; + self.chat_stream_rx = None; + break; + } else if token.starts_with("\n[RESPONSE_ID]") { + let id = token.trim_start_matches("\n[RESPONSE_ID]").to_string(); + self.chat_previous_response_id = Some(id); + continue; + } else if token.starts_with("\n[ERROR]") { + let err = token.trim_start_matches("\n[ERROR]").to_string(); + if let Some(msg) = self.chat_messages.last_mut() { + msg.content.push_str(&format!("\n[Error: {err}]")); + } + self.chat_streaming = false; + self.chat_stream_rx = None; + break; + } else if let Some(msg) = self.chat_messages.last_mut() { + msg.content.push_str(&token); + } + } + Err(mpsc::error::TryRecvError::Empty) => break, + Err(mpsc::error::TryRecvError::Disconnected) => { + self.chat_streaming = false; + self.chat_stream_rx = None; + break; + } + } + } + if got_data { + // Auto-scroll to bottom + self.chat_scroll = u16::MAX; + } + } + } + + async fn handle_key(&mut self, key: KeyEvent) { + // Ctrl+C: double-press for full shutdown, single press warns + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + if let Some(first) = self.ctrl_c_at { + if first.elapsed() < std::time::Duration::from_secs(3) { + // Second Ctrl+C within 3s → full shutdown + self.should_quit = true; + self.full_shutdown = true; + return; + } + } + // First (or expired) Ctrl+C → warn and record time + self.ctrl_c_at = Some(std::time::Instant::now()); + self.set_status( + "Press Ctrl+C again to stop all services, or q to quit TUI only".to_string(), + ); + return; + } + + // Delete confirmation dialog takes priority + if self.confirm_delete.is_some() { + self.handle_delete_confirm(key).await; + return; + } + + if self.show_action_menu { + self.handle_action_menu_key(key).await; + return; + } + if self.add_menu_state.is_some() { + self.handle_add_menu_key(key).await; + return; + } + if let Some((ref id, ref _url)) = self.confirm_flush.clone() { + match key.code { + KeyCode::Char('y') => { + let id = id.clone(); + self.confirm_flush = None; + match self.client.flush_worker_cache(&id).await { + Ok(_) => self.set_status("Cache flushed".to_string()), + Err(e) => self.set_status(format!("Error: {e}")), + } + } + _ => { + self.confirm_flush = None; + } + } + return; + } + + // Playground has its own input handling + if self.view == View::Chat && self.input_mode == InputMode::Normal { + self.handle_chat_key(key); + return; + } + + match self.input_mode { + InputMode::Normal => self.handle_normal(key), + InputMode::Filter => self.handle_filter_input(key), + InputMode::Command => self.handle_command_input(key).await, + } + } + + fn handle_normal(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('q') => self.should_quit = true, + + // View switching + code @ (KeyCode::Char('1') + | KeyCode::Char('2') + | KeyCode::Char('3') + | KeyCode::Char('4') + | KeyCode::Char('5') + | KeyCode::Char('6') + | KeyCode::Char('7') + | KeyCode::Char('8')) => { + if let Some(v) = View::from_key(code) { + self.view = v; + self.selected_index = 0; + } + } + + // Navigation + KeyCode::Char('j') | KeyCode::Down => { + if self.view == View::Logs { + // If at auto-scroll bottom, don't move further down + if self.log_auto_scroll { + return; + } + self.log_scroll = self.log_scroll.saturating_add(1); + } else { + self.selected_index = self.selected_index.saturating_add(1); + self.clamp_selection(); + } + } + KeyCode::Char('k') | KeyCode::Up => { + if self.view == View::Logs { + self.log_scroll = self.log_scroll.saturating_sub(1); + self.log_auto_scroll = false; + } else { + self.selected_index = self.selected_index.saturating_sub(1); + } + } + // Logs: G to jump to bottom + KeyCode::Char('G') if self.view == View::Logs => { + self.log_scroll = u16::MAX; + self.log_auto_scroll = true; + } + // Logs: a/b/c to switch sub-tabs + KeyCode::Char('a') if self.view == View::Logs => { + self.log_sub_tab = LogSubTab::Tui; + self.log_scroll = u16::MAX; + } + KeyCode::Char('b') if self.view == View::Logs => { + self.log_sub_tab = LogSubTab::Gateway; + self.log_scroll = u16::MAX; + } + KeyCode::Char('w') if self.view == View::Logs => { + // Cycle through worker logs + let tabs = self.worker_log_tabs(); + if tabs.is_empty() { + self.set_status("No worker logs available".to_string()); + } else { + let current_port = match &self.log_sub_tab { + LogSubTab::Worker(p) => Some(p.clone()), + _ => None, + }; + let next = if let Some(cur) = current_port { + let idx = tabs.iter().position(|(_, p)| p == &cur).unwrap_or(0); + (idx + 1) % tabs.len() + } else { + 0 + }; + self.log_sub_tab = LogSubTab::Worker(tabs[next].1.clone()); + self.log_scroll = u16::MAX; + } + } + + // Input modes + KeyCode::Char('/') => { + self.input_mode = InputMode::Filter; + self.input_buffer.clear(); + } + KeyCode::Char(':') => { + self.input_mode = InputMode::Command; + self.input_buffer.clear(); + } + + // Help + KeyCode::Char('?') => self.show_help = !self.show_help, + + KeyCode::Enter if self.view == View::Workers => { + self.show_detail = !self.show_detail; + } + + // Esc clears overlays/filters + KeyCode::Esc => { + if self.show_detail { + self.show_detail = false; + } else if self.show_help { + self.show_help = false; + } else { + self.active_filter = None; + } + } + + // Workers-only keys + KeyCode::Char('d') if self.view == View::Workers => { + self.start_delete(); + } + KeyCode::Char('e') if self.view == View::Workers => { + self.show_action_menu = true; + self.action_menu_index = 0; + } + KeyCode::Char('a') if self.view == View::Workers => { + self.add_menu_state = Some(AddMenuState::SelectCategory); + } + + _ => {} + } + } + + fn handle_filter_input(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Enter => { + self.active_filter = if self.input_buffer.is_empty() { + None + } else { + Some(self.input_buffer.clone()) + }; + self.input_mode = InputMode::Normal; + } + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + self.input_buffer.clear(); + } + KeyCode::Backspace => { + self.input_buffer.pop(); + } + KeyCode::Char(c) => { + self.input_buffer.push(c); + } + _ => {} + } + } + + async fn handle_command_input(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Enter => { + let cmd = self.input_buffer.clone(); + self.input_mode = InputMode::Normal; + self.input_buffer.clear(); + self.execute_command(&cmd).await; + } + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + self.input_buffer.clear(); + } + KeyCode::Backspace => { + self.input_buffer.pop(); + } + KeyCode::Char(c) => { + self.input_buffer.push(c); + } + _ => {} + } + } + + async fn execute_command(&mut self, cmd: &str) { + let parts: Vec<&str> = cmd.trim().splitn(2, ' ').collect(); + match parts.first().copied() { + Some("quit" | "q") => self.should_quit = true, + Some("add") => self.cmd_add(parts.get(1).copied()).await, + Some("delete") => { + if let Some(id) = parts.get(1) { + let id = id.trim().to_string(); + // Look up the worker's URL by ID from state before deleting + let worker_url = { + #[expect(clippy::unwrap_used)] + let state = self.state.read().unwrap(); + state.workers.as_ref().and_then(|w| { + w.workers + .iter() + .find(|wi| wi.id == id) + .map(|wi| wi.url.clone()) + }) + }; + match self.client.delete_worker(&id).await { + Ok(_) => { + if let Some(url) = worker_url { + self.claimed_gpus.remove(&url); + } + self.set_status(format!("Worker {id} deleted")); + } + Err(e) => self.set_status(format!("Error: {e}")), + } + } else { + self.set_status("Usage: delete ".into()); + } + } + Some("priority") => { + let args = parts.get(1).copied(); + if let Some(val) = args.and_then(|a| a.parse::().ok()) { + if let Some(id) = self.selected_worker_id() { + let update = openai_protocol::worker::WorkerUpdateRequest { + priority: Some(val), + cost: None, + labels: None, + api_key: None, + health: None, + }; + match self.client.update_worker(&id, &update).await { + Ok(_) => self.set_status(format!("Priority set to {val}")), + Err(e) => self.set_status(format!("Error: {e}")), + } + } else { + self.set_status("No worker selected".to_string()); + } + } else { + self.set_status("Usage: :priority ".to_string()); + } + } + Some("cost") => { + let args = parts.get(1).copied(); + if let Some(val) = args.and_then(|a| a.parse::().ok()) { + if let Some(id) = self.selected_worker_id() { + let update = openai_protocol::worker::WorkerUpdateRequest { + cost: Some(val), + priority: None, + labels: None, + api_key: None, + health: None, + }; + match self.client.update_worker(&id, &update).await { + Ok(_) => self.set_status(format!("Cost set to {val}")), + Err(e) => self.set_status(format!("Error: {e}")), + } + } else { + self.set_status("No worker selected".to_string()); + } + } else { + self.set_status("Usage: :cost ".to_string()); + } + } + Some("api-key") => { + if let Some(key) = parts.get(1) { + if let Some(id) = self.selected_worker_id() { + let update = openai_protocol::worker::WorkerUpdateRequest { + priority: None, + cost: None, + labels: None, + api_key: Some(key.trim().to_string()), + health: None, + }; + match self.client.update_worker(&id, &update).await { + Ok(_) => self.set_status("API key updated".to_string()), + Err(e) => self.set_status(format!("Error: {e}")), + } + } else { + self.set_status("No worker selected".to_string()); + } + } else { + self.set_status("Usage: :api-key ".to_string()); + } + } + Some("flush-cache") => { + if let Some(id) = self.selected_worker_id() { + let url = self.selected_worker_url().unwrap_or_default(); + self.confirm_flush = Some((id, url)); + } else { + self.set_status("No worker selected".to_string()); + } + } + Some("toggle-health") => { + if let Some(worker) = self.selected_worker() { + // Toggle: if healthy, disable health check; if unhealthy, re-enable + let disable = worker.is_healthy; + let update = openai_protocol::worker::WorkerUpdateRequest { + health: Some(openai_protocol::worker::HealthCheckUpdate { + disable_health_check: Some(disable), + timeout_secs: None, + check_interval_secs: None, + success_threshold: None, + failure_threshold: None, + }), + priority: None, + cost: None, + labels: None, + api_key: None, + }; + let action = if disable { "disabled" } else { "enabled" }; + match self.client.update_worker(&worker.id, &update).await { + Ok(_) => self.set_status(format!("Health check {action}")), + Err(e) => self.set_status(format!("Error: {e}")), + } + } else { + self.set_status("No worker selected".to_string()); + } + } + Some("add-openai") => { + self.add_menu_state = Some(AddMenuState::EnterApiKey { + provider: ProviderPreset::OpenAI, + input: String::new(), + }); + } + Some("add-anthropic") => { + self.add_menu_state = Some(AddMenuState::EnterApiKey { + provider: ProviderPreset::Anthropic, + input: String::new(), + }); + } + Some("add-xai") => { + self.add_menu_state = Some(AddMenuState::EnterApiKey { + provider: ProviderPreset::Xai, + input: String::new(), + }); + } + Some("add-gemini") => { + self.add_menu_state = Some(AddMenuState::EnterApiKey { + provider: ProviderPreset::Gemini, + input: String::new(), + }); + } + _ => self.set_status(format!("Unknown command: {cmd}")), + } + } + + /// Parse `:add [--provider

] [--runtime ]` + async fn cmd_add(&mut self, args: Option<&str>) { + let Some(args) = args else { + self.set_status( + "Usage: add [--provider openai|anthropic|gemini|xai] [--runtime external|sglang|vllm|trtllm]".into(), + ); + return; + }; + + let tokens: Vec<&str> = args.split_whitespace().collect(); + if tokens.is_empty() { + self.set_status("Usage: add [--provider

] [--runtime ]".into()); + return; + } + + let url = tokens[0].to_string(); + let mut spec = WorkerSpec::new(url); + + let mut i = 1; + while i < tokens.len() { + match tokens[i] { + "--provider" | "-p" => { + i += 1; + if i < tokens.len() { + spec.provider = Some(parse_provider(tokens[i])); + // Auto-set runtime to external when provider is specified + spec.runtime_type = RuntimeType::External; + } + } + "--runtime" | "-r" => { + i += 1; + if i < tokens.len() { + spec.runtime_type = match tokens[i].parse() { + Ok(rt) => rt, + Err(_) => { + self.set_status(format!("Error: Invalid runtime '{}'", tokens[i])); + return; + } + }; + } + } + _ => {} // skip unknown flags + } + i += 1; + } + + match self.client.add_worker(&spec).await { + Ok(_) => self.set_status("Worker added".into()), + Err(e) => self.set_status(format!("Error: {e}")), + } + } + + fn handle_chat_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('q') if !self.chat_streaming && self.chat_input.is_empty() => { + self.should_quit = true; + } + // Tab to cycle model, Backtab (Shift+Tab) to cycle endpoint + KeyCode::Tab if !self.chat_streaming => { + self.cycle_chat_model(); + } + KeyCode::BackTab if !self.chat_streaming => { + self.chat_endpoint = self.chat_endpoint.cycle(); + self.chat_previous_response_id = None; + self.chat_messages.clear(); // clear conversation on endpoint switch + self.set_status(format!("Endpoint: /v1/{}", self.chat_endpoint.label())); + } + // Number keys for view switching (only when not typing) + code @ (KeyCode::Char('1') + | KeyCode::Char('2') + | KeyCode::Char('4') + | KeyCode::Char('5') + | KeyCode::Char('6') + | KeyCode::Char('7')) + if self.chat_input.is_empty() && !self.chat_streaming => + { + if let Some(v) = View::from_key(code) { + self.view = v; + self.selected_index = 0; + } + } + KeyCode::Char('?') if self.chat_input.is_empty() && !self.chat_streaming => { + self.show_help = !self.show_help; + } + // Enter sends the message + KeyCode::Enter => { + if self.chat_streaming { + return; // Don't send while streaming + } + let text = self.chat_input.trim().to_string(); + if text.is_empty() { + return; + } + // Auto-select first model if none set + if self.chat_model.is_empty() { + self.cycle_chat_model(); + if self.chat_model.is_empty() { + self.set_status("No models available — add a worker first".to_string()); + return; + } + } + self.chat_input.clear(); + self.chat_messages.push(ChatMessage { + role: "user".to_string(), + content: text, + }); + // Add empty assistant message that will be filled by streaming + self.chat_messages.push(ChatMessage { + role: "assistant".to_string(), + content: String::new(), + }); + self.chat_streaming = true; + self.chat_scroll = u16::MAX; + + // Build messages for API + let api_messages: Vec = self + .chat_messages + .iter() + .filter(|m| !m.content.is_empty()) + .map(|m| { + serde_json::json!({ + "role": m.role, + "content": m.content, + }) + }) + .collect(); + + let (tx, rx) = mpsc::unbounded_channel(); + self.chat_stream_rx = Some(rx); + + let client = self.client.clone(); + let model = self.chat_model.clone(); + let endpoint = self.chat_endpoint; + let prev_id = self.chat_previous_response_id.clone(); + // Safety: fire-and-forget streaming task; tokens are sent via channel + #[expect(clippy::disallowed_methods)] + tokio::spawn(async move { + crate::chat::stream_chat(&client, &model, &api_messages, endpoint, prev_id, tx) + .await; + }); + } + // Esc cancels input or stops streaming + KeyCode::Esc => { + if self.chat_streaming { + self.chat_streaming = false; + self.chat_stream_rx = None; + if let Some(msg) = self.chat_messages.last_mut() { + if msg.content.is_empty() { + self.chat_messages.pop(); + } + } + } else if !self.chat_input.is_empty() { + self.chat_input.clear(); + } + } + KeyCode::Backspace => { + self.chat_input.pop(); + } + KeyCode::Char(c) => { + self.chat_input.push(c); + } + KeyCode::Up => { + self.chat_scroll = self.chat_scroll.saturating_sub(1); + } + KeyCode::Down => { + self.chat_scroll = self.chat_scroll.saturating_add(1); + } + _ => {} + } + } + + fn cycle_chat_model(&mut self) { + // Safety: RwLock is not poisoned — no panics while holding the lock + #[expect(clippy::unwrap_used)] + let state = self.state.read().unwrap(); + + // Collect chat-capable models from workers. + // OpenAI has too many models — only show gpt-5.4* to keep the list manageable. + // All other providers/local workers show all chat models. + let mut models: Vec = Vec::new(); + if let Some(ref workers) = state.workers { + for w in &workers.workers { + let is_openai = w.url.contains("openai.com"); + for m in &w.models { + let is_chat = m.model_type.iter().any(|t| t == "chat"); + if !is_chat { + continue; + } + if is_openai && !m.id.starts_with("gpt-5.4") { + continue; + } + if !models.contains(&m.id) { + models.push(m.id.clone()); + } + } + } + } + drop(state); + + if models.is_empty() { + self.set_status("No models available".to_string()); + return; + } + + let current_idx = models.iter().position(|m| m == &self.chat_model); + let next_idx = match current_idx { + Some(i) => (i + 1) % models.len(), + None => 0, + }; + self.chat_model.clone_from(&models[next_idx]); + self.set_status(format!("Model: {}", self.chat_model)); + } + + fn start_delete(&mut self) { + // Safety: RwLock is not poisoned — no panics while holding the lock + #[expect(clippy::unwrap_used)] + let state = self.state.read().unwrap(); + if let Some(ref wl) = state.workers { + let filtered: Vec<_> = wl + .workers + .iter() + .filter(|w| { + self.active_filter.as_ref().is_none_or(|f| { + w.id.to_lowercase().contains(&f.to_lowercase()) + || w.url.to_lowercase().contains(&f.to_lowercase()) + }) + }) + .collect(); + + if let Some(worker) = filtered.get(self.selected_index) { + self.confirm_delete = Some((worker.id.clone(), worker.url.clone())); + } + } + } + + async fn handle_delete_confirm(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + if let Some((ref id, ref url)) = self.confirm_delete { + let id = id.clone(); + let url = url.clone(); + match self.client.delete_worker(&id).await { + Ok(_) => { + // Release claimed GPUs for this worker + if self.claimed_gpus.remove(&url).is_some() { + self.add_log( + LogLevel::Info, + &format!("Released GPU claim for {url}"), + ); + } + // Kill the backend process if it was spawned by the TUI + if let Some(port) = url.rsplit(':').next() { + let port = port.trim_matches('/').to_string(); + let match_str = format!("port {port}"); + let mut kill_result = None; + for (desc, child) in &mut self.worker_children { + if desc.contains(&match_str) { + kill_result = Some((desc.clone(), child.kill().await)); + break; + } + } + if let Some((desc, result)) = kill_result { + match result { + Ok(()) => self.add_log( + LogLevel::Info, + &format!("Killed backend: {desc}"), + ), + Err(e) => self.add_log( + LogLevel::Warn, + &format!("Failed to kill backend: {e}"), + ), + } + self.worker_children + .retain(|(d, _)| !d.contains(&match_str)); + } + } + self.set_status(format!("Worker {id} deleted")); + } + Err(e) => self.set_status(format!("Error: {e}")), + } + } + self.confirm_delete = None; + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + self.confirm_delete = None; + } + _ => {} + } + } + + async fn handle_action_menu_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.show_action_menu = false; + } + KeyCode::Char('j') | KeyCode::Down => { + let max = ActionMenuItem::all().len().saturating_sub(1); + self.action_menu_index = (self.action_menu_index + 1).min(max); + } + KeyCode::Char('k') | KeyCode::Up => { + self.action_menu_index = self.action_menu_index.saturating_sub(1); + } + KeyCode::Enter => { + let item = ActionMenuItem::all()[self.action_menu_index]; + self.show_action_menu = false; + match item { + ActionMenuItem::UpdatePriority => { + self.input_mode = InputMode::Command; + self.input_buffer = "priority ".to_string(); + } + ActionMenuItem::UpdateCost => { + self.input_mode = InputMode::Command; + self.input_buffer = "cost ".to_string(); + } + ActionMenuItem::UpdateApiKey => { + self.input_mode = InputMode::Command; + self.input_buffer = "api-key ".to_string(); + } + ActionMenuItem::FlushCache => { + if let Some(id) = self.selected_worker_id() { + let url = self.selected_worker_url().unwrap_or_default(); + self.confirm_flush = Some((id, url)); + } + } + ActionMenuItem::ToggleHealthCheck => { + if let Some(worker) = self.selected_worker() { + let disable = worker.is_healthy; + let action = if disable { "disabled" } else { "enabled" }; + match self + .client + .update_worker( + &worker.id, + &openai_protocol::worker::WorkerUpdateRequest { + priority: None, + cost: None, + labels: None, + api_key: None, + health: Some(openai_protocol::worker::HealthCheckUpdate { + disable_health_check: Some(disable), + timeout_secs: None, + check_interval_secs: None, + success_threshold: None, + failure_threshold: None, + }), + }, + ) + .await + { + Ok(_) => { + self.set_status(format!("Health check {action}")); + } + Err(e) => self.set_status(format!("Error: {e}")), + } + } + } + } + } + _ => {} + } + } + + async fn handle_add_menu_key(&mut self, key: KeyEvent) { + use crate::types::{LocalModelPreset, LocalRuntime}; + + let state_clone = self.add_menu_state.clone(); + match &state_clone { + Some(AddMenuState::SelectCategory) => match key.code { + KeyCode::Esc => self.add_menu_state = None, + KeyCode::Char('1') => self.add_menu_state = Some(AddMenuState::SelectProvider), + KeyCode::Char('2') => self.add_menu_state = Some(AddMenuState::SelectRuntime), + KeyCode::Char('3') => { + self.add_menu_state = Some(AddMenuState::EnterCustomUrl { + input: String::new(), + }); + } + _ => {} + }, + Some(AddMenuState::SelectProvider) => match key.code { + KeyCode::Esc => self.add_menu_state = Some(AddMenuState::SelectCategory), + KeyCode::Char('1') => { + self.add_menu_state = Some(AddMenuState::EnterApiKey { + provider: ProviderPreset::OpenAI, + input: String::new(), + }); + } + KeyCode::Char('2') => { + self.add_menu_state = Some(AddMenuState::EnterApiKey { + provider: ProviderPreset::Anthropic, + input: String::new(), + }); + } + KeyCode::Char('3') => { + self.add_menu_state = Some(AddMenuState::EnterApiKey { + provider: ProviderPreset::Xai, + input: String::new(), + }); + } + KeyCode::Char('4') => { + self.add_menu_state = Some(AddMenuState::EnterApiKey { + provider: ProviderPreset::Gemini, + input: String::new(), + }); + } + _ => {} + }, + Some(AddMenuState::EnterApiKey { provider, input }) => match key.code { + KeyCode::Esc => self.add_menu_state = Some(AddMenuState::SelectProvider), + KeyCode::Enter => { + let provider = *provider; + // Use entered key, or fall back to env var + let api_key = if input.is_empty() { + std::env::var(provider.env_key()).unwrap_or_default() + } else { + input.clone() + }; + if api_key.is_empty() { + self.set_status("No API key provided".to_string()); + return; + } + self.add_menu_state = None; + let mut spec = WorkerSpec::new(provider.url()); + spec.provider = Some(provider.provider_type()); + spec.runtime_type = provider.runtime_type(); + spec.api_key = Some(api_key); + match self.client.add_worker(&spec).await { + Ok(_) => self.set_status(format!("Added {} worker", provider.label())), + Err(e) => self.set_status(format!("Error: {e}")), + } + } + KeyCode::Backspace => { + if let Some(AddMenuState::EnterApiKey { ref mut input, .. }) = + self.add_menu_state + { + input.pop(); + } + } + KeyCode::Char(c) => { + if let Some(AddMenuState::EnterApiKey { ref mut input, .. }) = + self.add_menu_state + { + input.push(c); + } + } + _ => {} + }, + Some(AddMenuState::SelectRuntime) => match key.code { + KeyCode::Esc => self.add_menu_state = Some(AddMenuState::SelectCategory), + KeyCode::Char('1') => { + self.add_menu_state = Some(AddMenuState::SelectConnection { + runtime: LocalRuntime::Sglang, + }); + } + KeyCode::Char('2') => { + self.add_menu_state = Some(AddMenuState::SelectConnection { + runtime: LocalRuntime::Vllm, + }); + } + _ => {} + }, + Some(AddMenuState::SelectConnection { runtime }) => match key.code { + KeyCode::Esc => self.add_menu_state = Some(AddMenuState::SelectRuntime), + KeyCode::Char('1') => { + self.add_menu_state = Some(AddMenuState::SelectModel { + runtime: *runtime, + grpc: false, + }); + } + KeyCode::Char('2') => { + self.add_menu_state = Some(AddMenuState::SelectModel { + runtime: *runtime, + grpc: true, + }); + } + _ => {} + }, + Some(AddMenuState::SelectModel { runtime, grpc }) => { + let presets = LocalModelPreset::all(); + let custom_idx = presets.len() + 1; + match key.code { + KeyCode::Esc => { + self.add_menu_state = + Some(AddMenuState::SelectConnection { runtime: *runtime }); + } + KeyCode::Char(c) if c.is_ascii_digit() => { + let idx = c.to_digit(10).unwrap_or(0) as usize; + if idx >= 1 && idx <= presets.len() { + let model = presets[idx - 1].clone(); + let runtime = *runtime; + let grpc = *grpc; + self.add_menu_state = None; + self.spawn_local_worker_with_args(runtime, model, grpc, "") + .await; + } else if idx == custom_idx { + self.add_menu_state = Some(AddMenuState::EnterCustomModel { + runtime: *runtime, + grpc: *grpc, + field: 0, + model_id: String::new(), + tp: "1".to_string(), + extra_args: String::new(), + }); + } + } + _ => {} + } + } + Some(AddMenuState::EnterCustomUrl { input }) => match key.code { + KeyCode::Esc => self.add_menu_state = Some(AddMenuState::SelectCategory), + KeyCode::Enter => { + let url = input.clone(); + self.add_menu_state = None; + let spec = WorkerSpec::new(url); + match self.client.add_worker(&spec).await { + Ok(_) => self.set_status("Added custom worker".to_string()), + Err(e) => self.set_status(format!("Error: {e}")), + } + } + KeyCode::Backspace => { + if let Some(AddMenuState::EnterCustomUrl { ref mut input }) = + self.add_menu_state + { + input.pop(); + } + } + KeyCode::Char(c) => { + if let Some(AddMenuState::EnterCustomUrl { ref mut input }) = + self.add_menu_state + { + input.push(c); + } + } + _ => {} + }, + Some(AddMenuState::EnterCustomModel { + runtime, + grpc, + field: _, + model_id, + tp, + extra_args, + }) => { + let runtime = *runtime; + let grpc = *grpc; + match key.code { + KeyCode::Esc => { + self.add_menu_state = Some(AddMenuState::SelectModel { runtime, grpc }); + } + KeyCode::Tab => { + // Cycle to next field + if let Some(AddMenuState::EnterCustomModel { ref mut field, .. }) = + self.add_menu_state + { + *field = (*field + 1) % 3; + } + } + KeyCode::BackTab => { + if let Some(AddMenuState::EnterCustomModel { ref mut field, .. }) = + self.add_menu_state + { + *field = if *field == 0 { 2 } else { *field - 1 }; + } + } + KeyCode::Enter => { + let model_id = model_id.clone(); + let tp_str = tp.clone(); + let extra = extra_args.clone(); + self.add_menu_state = None; + if model_id.is_empty() { + self.set_status("Model ID is required".to_string()); + return; + } + let tp_val: u32 = tp_str.parse().unwrap_or(1); + let model = LocalModelPreset::Custom { + model_id, + tp: tp_val, + }; + self.spawn_local_worker_with_args(runtime, model, grpc, &extra) + .await; + } + KeyCode::Backspace => { + if let Some(AddMenuState::EnterCustomModel { + ref mut model_id, + ref mut tp, + ref mut extra_args, + field, + .. + }) = self.add_menu_state + { + match field { + 0 => { + model_id.pop(); + } + 1 => { + tp.pop(); + } + _ => { + extra_args.pop(); + } + } + } + } + KeyCode::Char(c) => { + if let Some(AddMenuState::EnterCustomModel { + ref mut model_id, + ref mut tp, + ref mut extra_args, + field, + .. + }) = self.add_menu_state + { + match field { + 0 => model_id.push(c), + 1 => { + if c.is_ascii_digit() { + tp.push(c); + } + } + _ => extra_args.push(c), + } + } + } + _ => {} + } + } + None => {} + } + } + + /// Get the selected worker from the filtered list (matching the UI's filter). + fn selected_worker(&self) -> Option { + // Safety: RwLock is not poisoned — no panics while holding the lock + #[expect(clippy::unwrap_used)] + let state = self.state.read().unwrap(); + state.workers.as_ref().and_then(|w| { + let filtered: Vec<_> = w + .workers + .iter() + .filter(|w| { + self.active_filter.as_ref().is_none_or(|f| { + f.is_empty() + || w.id.to_lowercase().contains(&f.to_lowercase()) + || w.url.to_lowercase().contains(&f.to_lowercase()) + || w.runtime_type.to_lowercase().contains(&f.to_lowercase()) + }) + }) + .collect(); + filtered.get(self.selected_index).copied().cloned() + }) + } + + fn selected_worker_id(&self) -> Option { + self.selected_worker().map(|w| w.id) + } + + fn selected_worker_url(&self) -> Option { + self.selected_worker().map(|w| w.url) + } + + fn set_status(&mut self, msg: String) { + let level = if msg.starts_with("Error") { + LogLevel::Error + } else { + LogLevel::Info + }; + self.add_log(level, &msg); + self.status_message = Some(msg); + self.status_clear_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(5)); + } + + async fn spawn_local_worker_with_args( + &mut self, + runtime: crate::types::LocalRuntime, + model: crate::types::LocalModelPreset, + grpc: bool, + extra_args: &str, + ) { + // Find an available port + let port = match std::net::TcpListener::bind("127.0.0.1:0") { + Ok(listener) => match listener.local_addr() { + Ok(addr) => addr.port(), + Err(e) => { + self.set_status(format!("Failed to get local address: {e}")); + return; + } + }, + Err(e) => { + self.set_status(format!("Failed to find available port: {e}")); + return; + } + }; + + // Find GPUs with enough free memory, excluding already-claimed GPUs + let all_claimed: std::collections::HashSet = + self.claimed_gpus.values().flatten().copied().collect(); + let free_gpus: Vec = find_free_gpus() + .await + .into_iter() + .filter(|g| !all_claimed.contains(g)) + .collect(); + let tp = model.tp(); + if free_gpus.is_empty() { + self.set_status( + "No GPUs available (all in use or claimed by pending workers)".to_string(), + ); + return; + } + if (tp as usize) > free_gpus.len() { + self.set_status(format!( + "Not enough free GPUs: model requires TP={tp} but only {} unclaimed GPU(s) available", + free_gpus.len() + )); + return; + } + // Pick the first `tp` free GPUs + let selected_gpus: Vec = free_gpus.into_iter().take(tp as usize).collect(); + let cuda_devices = selected_gpus + .iter() + .map(|g| g.to_string()) + .collect::>() + .join(","); + + let model_id = model.model_id().to_string(); + let conn_label = if grpc { "grpc" } else { "http" }; + let (cmd, mut args) = runtime.launch_args(&model_id, tp, port, grpc); + // Append user-provided extra arguments (e.g. --max-model-len 16384) + for arg in extra_args.split_whitespace() { + args.push(arg.to_string()); + } + let desc = format!( + "{} {} {} (port {port})", + runtime.label(), + conn_label, + model.label() + ); + + self.add_log( + LogLevel::Info, + &format!("Starting {desc} on GPU [{cuda_devices}]..."), + ); + self.add_log( + LogLevel::Info, + &format!( + "Command: CUDA_VISIBLE_DEVICES={cuda_devices} {cmd} {}", + args.join(" ") + ), + ); + + let log_path = format!("/tmp/smg-worker-{port}.log"); + let log_file = match std::fs::File::create(&log_path) { + Ok(f) => f, + Err(e) => { + self.set_status(format!("Failed to create log file: {e}")); + return; + } + }; + let log_file2 = match log_file.try_clone() { + Ok(f) => f, + Err(e) => { + self.set_status(format!("Failed to clone log file handle: {e}")); + return; + } + }; + + match tokio::process::Command::new(&cmd) + .args(&args) + .env("CUDA_VISIBLE_DEVICES", &cuda_devices) + .stdout(std::process::Stdio::from(log_file)) + .stderr(std::process::Stdio::from(log_file2)) + .spawn() + { + Ok(child) => { + self.add_log( + LogLevel::Info, + &format!( + "Worker started (pid {}, log: {log_path})", + child.id().unwrap_or(0) + ), + ); + self.worker_children.push((desc.clone(), child)); + + // Register with gateway immediately — SMG handles health checks itself + let url = if grpc { + format!("grpc://127.0.0.1:{port}") + } else { + format!("http://127.0.0.1:{port}") + }; + // Track claimed GPUs so next worker won't pick the same ones + self.claimed_gpus.insert(url.clone(), selected_gpus); + let runtime_type = runtime.runtime_type(); + let connection_mode = if grpc { + openai_protocol::worker::ConnectionMode::Grpc + } else { + openai_protocol::worker::ConnectionMode::Http + }; + // Register with gateway immediately — SMG handles health checks + let mut spec = WorkerSpec::new(&url); + spec.runtime_type = runtime_type; + spec.connection_mode = connection_mode; + match self.client.add_worker(&spec).await { + Ok(_) => { + self.add_log( + LogLevel::Info, + &format!("Registered worker {url} with gateway"), + ); + self.set_status(format!("Started {desc} — registered with gateway")); + } + Err(e) => { + self.add_log( + LogLevel::Error, + &format!("Failed to register worker {url}: {e}"), + ); + // Roll back: kill the spawned process and release claimed GPUs + if let Some(pos) = self.worker_children.iter().position(|(d, _)| d == &desc) + { + let (_, mut child) = self.worker_children.remove(pos); + let _ = child.kill().await; + self.add_log( + LogLevel::Info, + &format!("Rolled back spawned worker: {desc}"), + ); + } + self.claimed_gpus.remove(&url); + self.set_status(format!("Registration failed, worker rolled back: {e}")); + } + } + } + Err(e) => { + self.set_status(format!("Failed to start worker: {e}")); + } + } + } + + /// Get available worker log files as (label, port) pairs. + pub fn worker_log_tabs(&self) -> Vec<(String, String)> { + let mut tabs = Vec::new(); + for (desc, _child) in &self.worker_children { + // desc is like "sglang http Llama-3.2-1B (TP=1) (port 37595)" + if let Some(port_start) = desc.rfind("port ") { + let port = desc[port_start + 5..].trim_end_matches(')').to_string(); + // Extract short model name (first 5 chars of model label) + let short = desc + .split_whitespace() + .nth(2) + .unwrap_or("work") + .chars() + .take(5) + .collect::(); + tabs.push((format!("{short}-{port}"), port)); + } + } + tabs + } + + fn add_log(&mut self, level: LogLevel, message: &str) { + if self.log_entries.len() >= MAX_LOG_ENTRIES { + self.log_entries.pop_front(); + } + self.log_entries.push_back(LogEntry { + timestamp: chrono::Utc::now(), + level, + message: message.to_string(), + }); + if self.log_auto_scroll { + self.log_scroll = u16::MAX; + } + } + + fn clamp_selection(&mut self) { + // Clamp against filtered list size (matches what the UI renders) + #[expect(clippy::unwrap_used)] + let state = self.state.read().unwrap(); + if let Some(ref wl) = state.workers { + let count = wl + .workers + .iter() + .filter(|w| { + self.active_filter.as_ref().is_none_or(|f| { + f.is_empty() + || w.id.to_lowercase().contains(&f.to_lowercase()) + || w.url.to_lowercase().contains(&f.to_lowercase()) + || w.runtime_type.to_lowercase().contains(&f.to_lowercase()) + }) + }) + .count(); + if count > 0 { + self.selected_index = self.selected_index.min(count - 1); + } + } + } +} + +/// Find GPUs with <10% VRAM utilization via nvidia-smi. Returns empty if unavailable. +async fn find_free_gpus() -> Vec { + let output = tokio::process::Command::new("nvidia-smi") + .args([ + "--query-gpu=index,memory.used,memory.total", + "--format=csv,noheader,nounits", + ]) + .output() + .await; + match output { + Ok(o) if o.status.success() => { + String::from_utf8_lossy(&o.stdout) + .lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|line| { + let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); + if parts.len() >= 3 { + let idx = parts[0].parse::().ok()?; + let used_mb = parts[1].parse::().ok()?; + let total_mb = parts[2].parse::().ok()?; + // Only consider GPUs with <10% VRAM utilization + if total_mb > 0.0 && (used_mb / total_mb) < 0.10 { + Some(idx) + } else { + None + } + } else { + None + } + }) + .collect() + } + _ => Vec::new(), + } +} + +fn parse_provider(s: &str) -> ProviderType { + match s.to_lowercase().as_str() { + "openai" => ProviderType::OpenAI, + "anthropic" | "claude" => ProviderType::Anthropic, + "gemini" | "google" => ProviderType::Gemini, + "xai" | "grok" => ProviderType::XAI, + other => ProviderType::Custom(other.to_string()), + } +} diff --git a/tui/src/chat.rs b/tui/src/chat.rs new file mode 100644 index 0000000000..e30670a0f6 --- /dev/null +++ b/tui/src/chat.rs @@ -0,0 +1,243 @@ +use futures::StreamExt; +use tokio::sync::mpsc; + +use crate::client::SmgClient; + +/// A chat message in the conversation. +#[derive(Debug, Clone)] +pub struct ChatMessage { + pub role: String, + pub content: String, +} + +/// Which API endpoint to use for chat. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ChatEndpoint { + #[default] + Chat, + Responses, +} + +impl ChatEndpoint { + pub fn label(self) -> &'static str { + match self { + Self::Chat => "chat", + Self::Responses => "responses", + } + } + + pub fn cycle(self) -> Self { + match self { + Self::Chat => Self::Responses, + Self::Responses => Self::Chat, + } + } +} + +/// Stream a chat from the SMG gateway, sending tokens via `tx`. +/// Special signals: "\n[DONE]", "\n[ERROR]...", "\n[RESPONSE_ID]..." +pub async fn stream_chat( + client: &SmgClient, + model: &str, + messages: &[serde_json::Value], + endpoint: ChatEndpoint, + previous_response_id: Option, + tx: mpsc::UnboundedSender, +) { + match endpoint { + ChatEndpoint::Chat => stream_chat_completions(client, model, messages, tx).await, + ChatEndpoint::Responses => { + stream_responses(client, model, messages, previous_response_id, tx).await; + } + } +} + +async fn stream_chat_completions( + client: &SmgClient, + model: &str, + messages: &[serde_json::Value], + tx: mpsc::UnboundedSender, +) { + let body = serde_json::json!({ + "model": model, + "messages": messages, + "stream": true, + }); + + let resp = match client.stream_request("/v1/chat/completions", &body).await { + Ok(r) => r, + Err(e) => { + let _ = tx.send(format!("\n[ERROR]{e}")); + return; + } + }; + + process_sse_stream(resp, tx).await; +} + +async fn stream_responses( + client: &SmgClient, + model: &str, + messages: &[serde_json::Value], + previous_response_id: Option, + tx: mpsc::UnboundedSender, +) { + // For multi-turn: use previous_response_id + only the latest user message + let body = if let Some(ref prev_id) = previous_response_id { + // Only send the latest user message with previous_response_id + let latest_input = messages + .last() + .map(|m| m["content"].as_str().unwrap_or("")) + .unwrap_or(""); + serde_json::json!({ + "model": model, + "input": latest_input, + "previous_response_id": prev_id, + "stream": true, + }) + } else { + // First turn: send all messages as input + let input: Vec = messages + .iter() + .map(|m| { + serde_json::json!({ + "role": m["role"], + "content": m["content"], + }) + }) + .collect(); + serde_json::json!({ + "model": model, + "input": input, + "stream": true, + }) + }; + + let resp = match client.stream_request("/v1/responses", &body).await { + Ok(r) => r, + Err(e) => { + let _ = tx.send(format!("\n[ERROR]{e}")); + return; + } + }; + + // Process responses API streaming format + let mut stream = resp.bytes_stream(); + let mut buffer = String::new(); + let mut got_deltas = false; + + while let Some(chunk) = stream.next().await { + let chunk = match chunk { + Ok(c) => c, + Err(e) => { + let _ = tx.send(format!("\n[ERROR]{e}")); + return; + } + }; + + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some(pos) = buffer.find('\n') { + let line = buffer[..pos].to_string(); + buffer.drain(..pos + 1); + + let line = line.trim(); + if line.is_empty() { + continue; + } + + if line == "data: [DONE]" { + let _ = tx.send("\n[DONE]".to_string()); + return; + } + + if let Some(data) = line.strip_prefix("data: ") { + if let Ok(parsed) = serde_json::from_str::(data) { + let event_type = parsed["type"].as_str().unwrap_or(""); + match event_type { + "response.created" => { + // Extract response ID for multi-turn + if let Some(id) = parsed["response"]["id"].as_str() { + let _ = tx.send(format!("\n[RESPONSE_ID]{id}")); + } + } + "response.output_text.delta" => { + if let Some(delta) = parsed["delta"].as_str() { + got_deltas = true; + let _ = tx.send(delta.to_string()); + } + } + "response.completed" | "response.done" => { + // Only extract text if no deltas were received (sglang sends full text here) + if !got_deltas { + if let Some(outputs) = parsed["response"]["output"].as_array() { + for output in outputs { + if let Some(contents) = output["content"].as_array() { + for content in contents { + if let Some(text) = content["text"].as_str() { + let _ = tx.send(text.to_string()); + } + } + } + } + } + } + let _ = tx.send("\n[DONE]".to_string()); + return; + } + _ => {} + } + } + } + } + } + + let _ = tx.send("\n[DONE]".to_string()); +} + +async fn process_sse_stream(resp: reqwest::Response, tx: mpsc::UnboundedSender) { + let mut stream = resp.bytes_stream(); + let mut buffer = String::new(); + + while let Some(chunk) = stream.next().await { + let chunk = match chunk { + Ok(c) => c, + Err(e) => { + let _ = tx.send(format!("\n[ERROR]{e}")); + return; + } + }; + + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some(pos) = buffer.find('\n') { + let line = buffer[..pos].to_string(); + buffer.drain(..pos + 1); + + let line = line.trim(); + if line.is_empty() { + continue; + } + + if line == "data: [DONE]" { + let _ = tx.send("\n[DONE]".to_string()); + return; + } + + if let Some(data) = line.strip_prefix("data: ") { + if let Ok(parsed) = serde_json::from_str::(data) { + // Surface mid-stream errors + if let Some(err_msg) = parsed["error"]["message"].as_str() { + let _ = tx.send(format!("\n[ERROR]{err_msg}")); + return; + } + if let Some(delta) = parsed["choices"][0]["delta"]["content"].as_str() { + let _ = tx.send(delta.to_string()); + } + } + } + } + } + + let _ = tx.send("\n[DONE]".to_string()); +} diff --git a/tui/src/client.rs b/tui/src/client.rs new file mode 100644 index 0000000000..8aa16c96b7 --- /dev/null +++ b/tui/src/client.rs @@ -0,0 +1,302 @@ +use anyhow::Result; +use openai_protocol::{ + messages::ListModelsResponse, + worker::{WorkerLoadResponse, WorkerSpec, WorkerUpdateRequest}, +}; +use serde::Deserialize; + +/// HTTP client for the SMG gateway REST API. +#[derive(Debug, Clone)] +pub struct SmgClient { + http: reqwest::Client, + stream_http: reqwest::Client, + gateway_url: String, + metrics_url: String, + api_key: Option, +} + +// ── Local response types matching the actual wire format ── +// The server uses custom IntoResponse impls that produce JSON different from +// the protocol structs' Serialize output, so we define our own Deserialize types. + +/// Mirrors the JSON produced by `ListWorkersResult::into_response` in model_gateway. +#[derive(Debug, Clone, Deserialize)] +pub struct WorkersResponse { + pub workers: Vec, + pub total: usize, + #[serde(default)] + pub stats: WorkerStatsWire, +} + +/// Worker info as it appears on the wire (WorkerSpec fields are flattened). +#[derive(Debug, Clone, Deserialize)] +pub struct WorkerInfo { + pub id: String, + pub url: String, + #[serde(default)] + pub worker_type: String, + #[serde(default)] + pub connection_mode: String, + #[serde(default)] + pub runtime_type: String, + #[serde(default)] + pub models: Vec, + #[serde(default)] + pub is_healthy: bool, + #[serde(default)] + pub load: usize, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ModelRef { + pub id: String, + #[serde(default)] + pub model_type: Vec, +} + +/// Stats block: `{ prefill_count, decode_count, regular_count }`. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct WorkerStatsWire { + #[serde(default)] + pub prefill_count: usize, + #[serde(default)] + pub decode_count: usize, + #[serde(default)] + pub regular_count: usize, +} + +/// Mirrors the JSON from `WorkerLoadsResult::into_response`: +/// `{ "workers": [{"worker": "...", "load": N}] }` +#[derive(Debug, Clone, Deserialize)] +pub struct LoadsResponse { + pub workers: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct WorkerLoad { + pub worker: String, + #[serde(default)] + pub worker_type: Option, + pub load: isize, + #[serde(default)] + pub details: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ClusterStatusResponse { + pub node_name: Option, + pub cluster_size: Option, + pub stores: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct StoreStatus { + pub name: String, + pub healthy: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MeshHealthResponse { + pub status: String, + pub node_count: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RateLimitStats { + pub limit: Option, + pub current: Option, + pub remaining: Option, +} + +impl SmgClient { + pub fn new(gateway_url: String, metrics_url: String, api_key: Option) -> Self { + // reqwest::Client::builder().build() only fails on TLS backend init failure, + // which is an unrecoverable startup error. + #[expect(clippy::expect_used)] + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .expect("failed to build HTTP client"); + + #[expect(clippy::expect_used)] + let stream_http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .expect("failed to build streaming HTTP client"); + + Self { + http, + stream_http, + gateway_url, + metrics_url, + api_key, + } + } + + fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder { + let url = format!("{}{}", self.gateway_url, path); + let mut req = self.http.request(method, &url); + if let Some(key) = &self.api_key { + req = req.bearer_auth(key); + } + req + } + + pub async fn check_health(&self) -> Result<()> { + self.request(reqwest::Method::GET, "/readiness") + .send() + .await? + .error_for_status()?; + Ok(()) + } + + /// Check if the gateway is alive (accepting connections), regardless of worker readiness. + pub async fn check_alive(&self) -> Result<()> { + // Any response (even 503) means the server is up + self.request(reqwest::Method::GET, "/readiness") + .send() + .await?; + Ok(()) + } + + pub async fn list_workers(&self) -> Result { + Ok(self + .request(reqwest::Method::GET, "/workers") + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn add_worker(&self, spec: &WorkerSpec) -> Result { + // Build JSON manually because WorkerSpec.api_key has skip_serializing + let mut body = serde_json::to_value(spec)?; + if let Some(ref key) = spec.api_key { + body["api_key"] = serde_json::Value::String(key.clone()); + } + Ok(self + .request(reqwest::Method::POST, "/workers") + .json(&body) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn delete_worker(&self, id: &str) -> Result { + Ok(self + .request(reqwest::Method::DELETE, &format!("/workers/{id}")) + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn get_loads(&self) -> Result { + Ok(self + .request(reqwest::Method::GET, "/get_loads") + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn get_cluster_status(&self) -> Result { + Ok(self + .request(reqwest::Method::GET, "/ha/status") + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn get_mesh_health(&self) -> Result { + Ok(self + .request(reqwest::Method::GET, "/ha/health") + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn get_rate_limit_stats(&self) -> Result { + Ok(self + .request(reqwest::Method::GET, "/ha/rate-limit/stats") + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn list_models(&self) -> Result { + Ok(self + .request(reqwest::Method::GET, "/v1/models") + .send() + .await? + .error_for_status()? + .json() + .await?) + } + + pub async fn update_worker( + &self, + id: &str, + update: &WorkerUpdateRequest, + ) -> Result { + let resp = self + .request(reqwest::Method::PATCH, &format!("/workers/{id}")) + .json(update) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } + + /// Send a streaming POST request, returning the raw response for SSE processing. + /// Uses a longer timeout and passes API key as bearer token. + pub async fn stream_request( + &self, + path: &str, + body: &serde_json::Value, + ) -> Result { + let url = format!("{}{}", self.gateway_url, path); + let mut req = self.stream_http.post(&url).json(body); + if let Some(key) = &self.api_key { + req = req.bearer_auth(key); + } + Ok(req.send().await?.error_for_status()?) + } + + /// Fetch raw Prometheus metrics text from the metrics endpoint. + pub async fn fetch_metrics(&self) -> Result { + let url = format!("{}/metrics", self.metrics_url); + Ok(self + .http + .get(&url) + .send() + .await? + .error_for_status()? + .text() + .await?) + } + + pub async fn flush_worker_cache(&self, id: &str) -> Result { + let resp = self + .request(reqwest::Method::POST, &format!("/workers/{id}/flush_cache")) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp) + } +} diff --git a/tui/src/event.rs b/tui/src/event.rs new file mode 100644 index 0000000000..e9c954dc43 --- /dev/null +++ b/tui/src/event.rs @@ -0,0 +1,72 @@ +use crossterm::event::{Event, EventStream, KeyEvent, KeyEventKind}; +use futures::StreamExt; +use tokio::{ + sync::mpsc, + time::{interval, Duration}, +}; + +/// Application-level events fed into the main loop. +#[derive(Debug)] +pub enum AppEvent { + /// A key was pressed. + Key(KeyEvent), + /// Periodic tick for UI refresh. + Tick, + /// Terminal was resized. + Resize(u16, u16), +} + +/// Merges crossterm input events with a periodic tick timer. +pub struct EventHandler { + rx: mpsc::UnboundedReceiver, +} + +impl EventHandler { + /// Create and start the event handler. + /// + /// `tick_ms` controls how often [`AppEvent::Tick`] fires (drives UI refresh). + pub fn new(tick_ms: u64) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + + // Safety: fire-and-forget event reader loop that runs for the app's lifetime + #[expect(clippy::disallowed_methods)] + tokio::spawn(async move { + let mut reader = EventStream::new(); + let mut tick = interval(Duration::from_millis(tick_ms)); + + loop { + tokio::select! { + _ = tick.tick() => { + if tx.send(AppEvent::Tick).is_err() { + break; + } + } + maybe_event = reader.next() => { + match maybe_event { + Some(Ok(Event::Key(key))) + if key.kind == KeyEventKind::Press => + { + if tx.send(AppEvent::Key(key)).is_err() { + break; + } + } + Some(Ok(Event::Resize(w, h))) + if tx.send(AppEvent::Resize(w, h)).is_err() => { + break; + } + Some(Err(_)) | None => break, + _ => {} // ignore mouse, focus, paste events + } + } + } + } + }); + + Self { rx } + } + + /// Wait for the next event. Returns `None` if the channel closed. + pub async fn next(&mut self) -> Option { + self.rx.recv().await + } +} diff --git a/tui/src/lib.rs b/tui/src/lib.rs new file mode 100644 index 0000000000..e24fc8cda5 --- /dev/null +++ b/tui/src/lib.rs @@ -0,0 +1,7 @@ +pub mod app; +pub mod chat; +pub mod client; +pub mod event; +pub mod state; +pub mod types; +pub mod ui; diff --git a/tui/src/main.rs b/tui/src/main.rs new file mode 100644 index 0000000000..8a2c910734 --- /dev/null +++ b/tui/src/main.rs @@ -0,0 +1,244 @@ +use std::{ + io, + sync::{Arc, RwLock}, +}; + +use anyhow::Result; +use clap::Parser; +use crossterm::{ + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use smg_tui::{ + app::App, + client::SmgClient, + state::{spawn_poller, GatewayState}, +}; + +#[derive(Parser)] +#[command( + name = "smg-tui", + about = "Terminal dashboard for Shepherd Model Gateway" +)] +struct Cli { + /// SMG gateway base URL. + #[arg(long, default_value = "http://localhost:30000")] + gateway_url: String, + + /// Prometheus / metrics endpoint URL. + #[arg(long, default_value = "http://localhost:29000")] + metrics_url: String, + + /// Polling interval in seconds. + #[arg(long, default_value_t = 3)] + poll_interval: u64, + + /// API key for authenticated endpoints. + /// Reads from: --api-key flag, SMG_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY env vars. + #[arg(long, env = "SMG_API_KEY")] + api_key: Option, + + /// Automatically start the SMG gateway if not reachable. + #[arg(long, default_value_t = false)] + auto_start: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + // Tracing (file/stderr only — stdout is the TUI) + tracing_subscriber::fmt() + .with_writer(io::stderr) + .with_env_filter("smg_tui=info") + .init(); + + // Resolve API key: --api-key > SMG_API_KEY > OPENAI_API_KEY > ANTHROPIC_API_KEY + let api_key = cli + .api_key + .clone() + .or_else(|| std::env::var("OPENAI_API_KEY").ok()) + .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok()); + + let client = SmgClient::new(cli.gateway_url.clone(), cli.metrics_url.clone(), api_key); + + // Auto-start gateway if requested and not reachable + let _gateway_child = if cli.auto_start { + match client.check_alive().await { + Ok(()) => { + tracing::info!("Gateway already running at {}", cli.gateway_url); + None + } + Err(_) => { + tracing::info!( + "Gateway not reachable at {}, starting automatically...", + cli.gateway_url + ); + let port = extract_port(&cli.gateway_url).unwrap_or(30000); + let metrics_port = extract_port(&cli.metrics_url).unwrap_or(29000); + let launch_args = [ + "launch", + "--port", + &port.to_string(), + "--prometheus-port", + &metrics_port.to_string(), + "--enable-igw", + "--policy", + "round_robin", + ]; + tracing::info!("Running: smg {}", launch_args.join(" ")); + let log_file = std::fs::File::create("/tmp/smg-gateway.log") + .or_else(|_| std::fs::File::create("/dev/null"))?; + let log_file2 = log_file + .try_clone() + .or_else(|_| std::fs::File::create("/dev/null"))?; + tracing::info!("Gateway logs: /tmp/smg-gateway.log"); + let child = tokio::process::Command::new("smg") + .args(launch_args) + .stdout(std::process::Stdio::from(log_file)) + .stderr(std::process::Stdio::from(log_file2)) + .spawn(); + match child { + Ok(mut child) => { + tracing::info!( + "Gateway process started (pid {}), waiting for readiness...", + child.id().unwrap_or(0) + ); + let deadline = + tokio::time::Instant::now() + tokio::time::Duration::from_secs(120); + loop { + if tokio::time::Instant::now() >= deadline { + tracing::warn!( + "Gateway did not become ready within 120s, continuing anyway" + ); + break; + } + // Check if the process exited early (crash/bad args) + match child.try_wait() { + Ok(Some(status)) => { + tracing::error!("Gateway process exited with: {status}"); + tracing::error!("Gateway exited with {status}. Check that 'smg' is the Rust binary (not Python)."); + tracing::error!( + "Install with: cargo install --path model_gateway" + ); + return Err(anyhow::anyhow!("Gateway exited: {status}")); + } + Ok(None) => {} // still running + Err(e) => { + tracing::error!("Failed to check gateway status: {e}"); + } + } + if client.check_alive().await.is_ok() { + tracing::info!("Gateway is up and accepting connections"); + break; + } + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + Some(child) + } + Err(e) => { + tracing::error!( + "Failed to start gateway: {e}. \ + Make sure 'smg' is in your PATH (cargo install --path model_gateway)." + ); + return Err(e.into()); + } + } + } + } + } else { + None + }; + + let state = Arc::new(RwLock::new(GatewayState::default())); + + let _poller = spawn_poller(client.clone(), Arc::clone(&state), cli.poll_interval); + + // Terminal setup — use RAII guard so the terminal is restored on error or panic + enable_raw_mode()?; + + // Install panic hook that restores the terminal + let panic_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + panic_hook(info); + })); + + let mut stdout = io::stdout(); + if let Err(e) = execute!(stdout, EnterAlternateScreen) { + let _ = disable_raw_mode(); + return Err(e.into()); + } + let backend = CrosstermBackend::new(stdout); + let mut terminal = match Terminal::new(backend) { + Ok(t) => t, + Err(e) => { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + return Err(e.into()); + } + }; + + // Run + let mut app = App::new(state, client); + let result = app.run(&mut terminal).await; + + // Cleanup + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + // Full shutdown (Ctrl+C×2): kill all spawned workers and auto-started gateway + // Quit (q): leave everything running in the background + if app.full_shutdown { + tracing::info!("Full shutdown: stopping all services..."); + for (desc, mut child) in app.worker_children.drain(..) { + tracing::info!("Stopping worker: {desc}"); + let _ = child.kill().await; + let _ = child.wait().await; + } + if let Some(mut child) = _gateway_child { + tracing::info!("Stopping auto-started gateway..."); + let _ = child.kill().await; + let _ = child.wait().await; + } else { + // Gateway wasn't auto-started by this session — kill by port + let port = extract_port(&cli.gateway_url).unwrap_or(30000); + tracing::info!("Stopping gateway on port {port}..."); + kill_process_on_port(port).await; + } + tracing::info!("All services stopped."); + } + + result +} + +/// Kill the process listening on the given TCP port using lsof + kill. +async fn kill_process_on_port(port: u16) { + let output = tokio::process::Command::new("lsof") + .args(["-ti", &format!("tcp:{port}")]) + .output() + .await; + match output { + Ok(out) if out.status.success() => { + let pids = String::from_utf8_lossy(&out.stdout); + for pid_str in pids.split_whitespace() { + tracing::info!("Killing process {pid_str} on port {port}"); + let _ = tokio::process::Command::new("kill") + .args(["-TERM", pid_str.trim()]) + .output() + .await; + } + } + _ => { + tracing::warn!("Could not find process on port {port}"); + } + } +} + +/// Extract port from a URL like "http://localhost:30000" or "http://localhost:30000/health". +fn extract_port(url: &str) -> Option { + url.rsplit(':').next()?.split('/').next()?.parse().ok() +} diff --git a/tui/src/state.rs b/tui/src/state.rs new file mode 100644 index 0000000000..7eea11429d --- /dev/null +++ b/tui/src/state.rs @@ -0,0 +1,488 @@ +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, RwLock}, +}; + +use chrono::{DateTime, Utc}; +use openai_protocol::messages::ListModelsResponse; + +use crate::client::{ + ClusterStatusResponse, LoadsResponse, MeshHealthResponse, RateLimitStats, SmgClient, + WorkersResponse, +}; + +/// Cached gateway state from the most recent poll cycle. +#[derive(Debug, Default)] +pub struct GatewayState { + pub connected: bool, + pub healthy: bool, + pub last_updated: Option>, + pub last_error: Option, + + pub workers: Option, + pub loads: Option, + pub cluster: Option, + pub mesh_health: Option, + pub rate_limits: Option, + pub models: Option, + + /// Rolling aggregate throughput history (capacity: 20). + pub throughput_history: VecDeque, + /// Rolling aggregate cache hit rate history (capacity: 20). + pub cache_hit_history: VecDeque, + /// Per-worker throughput history. + pub per_worker_throughput: HashMap>, + /// Per-worker cache hit rate history. + pub per_worker_cache_hit: HashMap>, + + /// Previous total request count from Prometheus (for computing req/s). + pub prev_request_count: Option, + /// Rolling requests-per-second history (for external workers without gen_throughput). + pub requests_per_sec_history: VecDeque, + + /// Previous total token counts from Prometheus (for computing tok/s). + pub prev_input_tokens: Option, + pub prev_output_tokens: Option, + /// Rolling tokens-per-second history. + pub input_tps_history: VecDeque, + pub output_tps_history: VecDeque, + + /// Rolling avg latency history (seconds, from Prometheus duration sum/count). + pub avg_latency_history: VecDeque, + /// Previous duration sum/count for computing per-interval avg latency. + pub prev_duration_sum: Option, + pub prev_duration_count: Option, + /// Active HTTP connections (gauge). + pub active_connections: u64, + /// In-flight requests (gauge). + pub inflight_requests: u64, + /// Per-worker previous request counts (for computing per-worker req/s). + pub prev_worker_requests: HashMap, + /// Per-worker req/s. + pub worker_rps: HashMap, + + /// GPU information from nvidia-smi (None if not available). + pub gpus: Option>, + + /// Circuit breaker status parsed from Prometheus metrics. + pub circuit_breakers: CircuitBreakerSummary, +} + +/// Summary of circuit breaker states across all workers. +#[derive(Debug, Clone, Default)] +pub struct CircuitBreakerSummary { + pub closed: u32, + pub open: u32, + pub total_failures: u64, +} + +/// GPU information parsed from nvidia-smi. +#[derive(Debug, Clone)] +pub struct GpuInfo { + pub index: u32, + pub name: String, + pub memory_used_mb: u64, + pub memory_total_mb: u64, + pub utilization_pct: u32, + pub temperature_c: u32, +} + +/// Thread-safe shared handle to the gateway state. +pub type SharedState = Arc>; + +/// Spawn a background poller that periodically fetches data from all SMG +/// endpoints and updates [`SharedState`]. +pub fn spawn_poller( + client: SmgClient, + state: SharedState, + interval_secs: u64, +) -> tokio::task::JoinHandle<()> { + // Safety: fire-and-forget background poller that runs for the app's lifetime + #[expect(clippy::disallowed_methods)] + tokio::spawn(async move { + let mut ticker = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); + loop { + ticker.tick().await; + poll_once(&client, &state, interval_secs).await; + } + }) +} + +/// Query nvidia-smi for GPU information. Returns None if nvidia-smi is not available. +async fn query_gpus() -> Option> { + let output = tokio::process::Command::new("nvidia-smi") + .args([ + "--query-gpu=index,name,memory.used,memory.total,utilization.gpu,temperature.gpu", + "--format=csv,noheader,nounits", + ]) + .output() + .await + .ok()?; + + if !output.status.success() { + return None; + } + + let text = String::from_utf8_lossy(&output.stdout); + let gpus: Vec = text + .lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| { + let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); + if parts.len() < 6 { + return None; + } + Some(GpuInfo { + index: parts[0].parse().ok()?, + name: parts[1].to_string(), + memory_used_mb: parts[2].parse().ok()?, + memory_total_mb: parts[3].parse().ok()?, + utilization_pct: parts[4].parse().ok()?, + temperature_c: parts[5].parse().ok()?, + }) + }) + .collect(); + + if gpus.is_empty() { + None + } else { + Some(gpus) + } +} + +/// Parse circuit breaker state from Prometheus metrics. +fn parse_circuit_breakers(metrics_text: &str) -> CircuitBreakerSummary { + let mut closed = 0u32; + let mut open = 0u32; + let mut total_failures = 0u64; + + for line in metrics_text.lines() { + if line.starts_with("smg_worker_cb_state{") { + if let Some(val) = line + .rsplit_once(' ') + .and_then(|(_, v)| v.parse::().ok()) + { + match val { + 0 => closed += 1, + 1 => open += 1, + _ => {} // -1 = stale, skip + } + } + } else if line.starts_with("smg_worker_cb_consecutive_failures{") { + if let Some(val) = line + .rsplit_once(' ') + .and_then(|(_, v)| v.parse::().ok()) + { + total_failures += val; + } + } + } + + CircuitBreakerSummary { + closed, + open, + total_failures, + } +} + +/// Parse total request count from Prometheus metrics text. +fn parse_request_count(metrics_text: &str) -> u64 { + metrics_text + .lines() + .filter(|line| line.starts_with("smg_router_requests_total{")) + .filter_map(|line| { + line.rsplit_once(' ') + .and_then(|(_, v)| v.parse::().ok()) + }) + .sum() +} + +/// Parse request duration sum and count from Prometheus (for avg latency). +fn parse_duration_stats(metrics_text: &str) -> (f64, u64) { + let mut sum = 0.0f64; + let mut count = 0u64; + for line in metrics_text.lines() { + if line.starts_with("smg_router_request_duration_seconds_sum{") { + if let Some(v) = line + .rsplit_once(' ') + .and_then(|(_, v)| v.parse::().ok()) + { + sum += v; + } + } else if line.starts_with("smg_router_request_duration_seconds_count{") { + if let Some(v) = line + .rsplit_once(' ') + .and_then(|(_, v)| v.parse::().ok()) + { + count += v; + } + } + } + (sum, count) +} + +/// Parse active connections and in-flight requests from Prometheus. +fn parse_gauges(metrics_text: &str) -> (u64, u64) { + let mut connections = 0u64; + let mut inflight = 0u64; + for line in metrics_text.lines() { + if line.starts_with("smg_http_connections_active ") { + if let Some(v) = line + .rsplit_once(' ') + .and_then(|(_, v)| v.parse::().ok()) + { + connections = v; + } + } else if line.starts_with("smg_http_inflight_request_age_count{") { + if let Some(v) = line + .rsplit_once(' ') + .and_then(|(_, v)| v.parse::().ok()) + { + inflight += v; + } + } + } + (connections, inflight) +} + +/// Parse per-worker request counts from Prometheus (success outcomes). +fn parse_worker_request_counts(metrics_text: &str) -> HashMap { + let mut counts = HashMap::new(); + for line in metrics_text.lines() { + // smg_worker_cb_outcomes_total{worker="grpc://127.0.0.1:46361",outcome="success"} 4024 + if line.starts_with("smg_worker_cb_outcomes_total{") && line.contains("outcome=\"success\"") + { + if let (Some(worker_start), Some(val)) = ( + line.find("worker=\"").map(|i| i + 8), + line.rsplit_once(' ') + .and_then(|(_, v)| v.parse::().ok()), + ) { + if let Some(worker_end) = line[worker_start..].find('"') { + let worker = line[worker_start..worker_start + worker_end].to_string(); + counts.insert(worker, val); + } + } + } + } + counts +} + +/// Parse total token counts from Prometheus metrics (input and output separately). +fn parse_token_counts(metrics_text: &str) -> (u64, u64) { + let mut input = 0u64; + let mut output = 0u64; + for line in metrics_text.lines() { + if line.starts_with("smg_router_tokens_total{") { + if let Some(val) = line + .rsplit_once(' ') + .and_then(|(_, v)| v.parse::().ok()) + { + if line.contains("token_type=\"input\"") { + input += val; + } else if line.contains("token_type=\"output\"") { + output += val; + } + } + } + } + (input, output) +} + +async fn poll_once(client: &SmgClient, state: &SharedState, interval_secs: u64) { + // Fire all requests concurrently. + let (alive, health, workers, loads, cluster, mesh, rates, models, metrics, gpus) = tokio::join!( + client.check_alive(), + client.check_health(), + client.list_workers(), + client.get_loads(), + client.get_cluster_status(), + client.get_mesh_health(), + client.get_rate_limit_stats(), + client.list_models(), + client.fetch_metrics(), + query_gpus(), + ); + + // Safety: RwLock is not poisoned in practice — no panics while holding the lock + #[expect(clippy::unwrap_used)] + let mut s = state.write().unwrap(); + + // Connection (liveness) vs readiness (health) are distinct: + // connected = server is reachable; healthy = readiness check passes + s.connected = alive.is_ok(); + match health { + Ok(()) => { + s.healthy = true; + s.last_error = None; + } + Err(e) => { + s.healthy = false; + if !s.connected { + s.last_error = Some(e.to_string()); + } + } + } + + // Each endpoint: update on success, keep stale data on failure. + if let Ok(w) = workers { + s.workers = Some(w); + } + if let Ok(l) = loads { + s.loads = Some(l); + } + if let Ok(c) = cluster { + s.cluster = Some(c); + } + if let Ok(m) = mesh { + s.mesh_health = Some(m); + } + if let Ok(r) = rates { + s.rate_limits = Some(r); + } + if let Ok(m) = models { + s.models = Some(m); + } + s.gpus = gpus; + + s.last_updated = Some(Utc::now()); + + const SPARKLINE_CAP: usize = 20; + + // Extract per-worker metrics before mutably borrowing sparkline buffers. + let worker_metrics: Vec<(String, f64, f64)> = if let Some(ref loads_resp) = s.loads { + loads_resp + .workers + .iter() + .filter_map(|wl| { + wl.details.as_ref().map(|details| { + let throughput: f64 = details.loads.iter().map(|s| s.gen_throughput).sum(); + let cache_hit: f64 = if details.loads.is_empty() { + 0.0 + } else { + details.loads.iter().map(|s| s.cache_hit_rate).sum::() + / details.loads.len() as f64 + }; + (wl.worker.clone(), throughput, cache_hit) + }) + }) + .collect() + } else { + Vec::new() + }; + + let has_worker_metrics = !worker_metrics.is_empty(); + if has_worker_metrics { + let mut total_cache_hits = 0.0_f64; + let worker_count = worker_metrics.len() as u32; + + for (worker_name, throughput, cache_hit) in worker_metrics { + total_cache_hits += cache_hit; + + let th = s + .per_worker_throughput + .entry(worker_name.clone()) + .or_insert_with(|| VecDeque::with_capacity(SPARKLINE_CAP)); + if th.len() >= SPARKLINE_CAP { + th.pop_front(); + } + th.push_back(throughput); + + let ch = s + .per_worker_cache_hit + .entry(worker_name) + .or_insert_with(|| VecDeque::with_capacity(SPARKLINE_CAP)); + if ch.len() >= SPARKLINE_CAP { + ch.pop_front(); + } + ch.push_back(cache_hit); + } + + let avg_cache = if worker_count > 0 { + total_cache_hits / worker_count as f64 + } else { + 0.0 + }; + if s.cache_hit_history.len() >= SPARKLINE_CAP { + s.cache_hit_history.pop_front(); + } + s.cache_hit_history.push_back(avg_cache); + } + + // Compute requests/sec from Prometheus counter (works for external workers). + if let Ok(metrics_text) = metrics { + s.circuit_breakers = parse_circuit_breakers(&metrics_text); + let current_count = parse_request_count(&metrics_text); + if let Some(prev) = s.prev_request_count { + let delta = current_count.saturating_sub(prev); + let rps = delta as f64 / interval_secs as f64; + if s.requests_per_sec_history.len() >= SPARKLINE_CAP { + s.requests_per_sec_history.pop_front(); + } + s.requests_per_sec_history.push_back(rps); + + // Use req/s as primary throughput sparkline (always works) + if s.throughput_history.len() >= SPARKLINE_CAP { + s.throughput_history.pop_front(); + } + s.throughput_history.push_back(rps); + } + s.prev_request_count = Some(current_count); + + // Compute tok/s from Prometheus token counters + let (cur_input, cur_output) = parse_token_counts(&metrics_text); + if let (Some(prev_in), Some(prev_out)) = (s.prev_input_tokens, s.prev_output_tokens) { + let in_delta = cur_input.saturating_sub(prev_in); + let out_delta = cur_output.saturating_sub(prev_out); + let in_tps = in_delta as f64 / interval_secs as f64; + let out_tps = out_delta as f64 / interval_secs as f64; + if s.input_tps_history.len() >= SPARKLINE_CAP { + s.input_tps_history.pop_front(); + } + if s.output_tps_history.len() >= SPARKLINE_CAP { + s.output_tps_history.pop_front(); + } + s.input_tps_history.push_back(in_tps); + s.output_tps_history.push_back(out_tps); + } + s.prev_input_tokens = Some(cur_input); + s.prev_output_tokens = Some(cur_output); + + // Compute avg latency from duration sum/count delta + let (cur_sum, cur_count) = parse_duration_stats(&metrics_text); + if let (Some(prev_sum), Some(prev_count)) = (s.prev_duration_sum, s.prev_duration_count) { + let delta_sum = if cur_sum >= prev_sum { + cur_sum - prev_sum + } else { + 0.0 + }; + let delta_count = cur_count.saturating_sub(prev_count); + let avg_latency = if delta_count > 0 { + delta_sum / delta_count as f64 + } else { + 0.0 + }; + if s.avg_latency_history.len() >= SPARKLINE_CAP { + s.avg_latency_history.pop_front(); + } + s.avg_latency_history.push_back(avg_latency); + } + s.prev_duration_sum = Some(cur_sum); + s.prev_duration_count = Some(cur_count); + + // Parse gauges + let (connections, inflight) = parse_gauges(&metrics_text); + s.active_connections = connections; + s.inflight_requests = inflight; + + // Compute per-worker req/s + let cur_worker_reqs = parse_worker_request_counts(&metrics_text); + for (worker, cur_count) in &cur_worker_reqs { + if let Some(prev) = s.prev_worker_requests.get(worker) { + let delta = cur_count.saturating_sub(*prev); + let rps = delta as f64 / interval_secs as f64; + s.worker_rps.insert(worker.clone(), rps); + } + } + s.prev_worker_requests = cur_worker_reqs; + } +} diff --git a/tui/src/types.rs b/tui/src/types.rs new file mode 100644 index 0000000000..8c7169c0f8 --- /dev/null +++ b/tui/src/types.rs @@ -0,0 +1,371 @@ +use crossterm::event::KeyCode; + +/// Active view/tab in the TUI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum View { + #[default] + Pulse, + Workers, + Chat, + Logs, + Benchmark, + Traffic, + Mesh, +} + +impl View { + /// Map a number key to a view. + pub fn from_key(code: KeyCode) -> Option { + match code { + KeyCode::Char('1') => Some(Self::Pulse), + KeyCode::Char('2') => Some(Self::Workers), + KeyCode::Char('3') => Some(Self::Chat), + KeyCode::Char('4') => Some(Self::Logs), + KeyCode::Char('5') => Some(Self::Benchmark), + KeyCode::Char('6') => Some(Self::Traffic), + KeyCode::Char('7') => Some(Self::Mesh), + _ => None, + } + } + + /// Human-readable label for the tab bar. + pub fn label(self) -> &'static str { + match self { + Self::Pulse => "Pulse", + Self::Workers => "Workers", + Self::Chat => "Chat", + Self::Logs => "Logs", + Self::Benchmark => "Benchmark", + Self::Traffic => "Traffic", + Self::Mesh => "Mesh", + } + } + + /// All views in order. + pub fn all() -> &'static [View] { + &[ + Self::Pulse, + Self::Workers, + Self::Chat, + Self::Logs, + Self::Benchmark, + Self::Traffic, + Self::Mesh, + ] + } + + /// 1-based index for display. + pub fn index(self) -> usize { + match self { + Self::Pulse => 1, + Self::Workers => 2, + Self::Chat => 3, + Self::Logs => 4, + Self::Benchmark => 5, + Self::Traffic => 6, + Self::Mesh => 7, + } + } +} + +/// Input mode determines how keystrokes are interpreted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InputMode { + /// Normal navigation mode — keys are shortcuts. + #[default] + Normal, + /// Filter mode — typing populates the filter bar (prefix: `/`). + Filter, + /// Command mode — typing populates the command bar (prefix: `:`). + Command, +} + +/// State machine for the Add Worker menu flow. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AddMenuState { + /// Top-level: 1. External 2. Local 3. Custom URL + SelectCategory, + /// External: pick provider + SelectProvider, + /// External: enter API key + EnterApiKey { + provider: ProviderPreset, + input: String, + }, + /// Local: pick runtime (sglang/vllm) + SelectRuntime, + /// Local: pick connection (http/grpc) + SelectConnection { runtime: LocalRuntime }, + /// Local: pick model preset + SelectModel { runtime: LocalRuntime, grpc: bool }, + /// Local: custom model — multi-field form + EnterCustomModel { + runtime: LocalRuntime, + grpc: bool, + /// 0=model_id, 1=tp, 2=extra_args + field: u8, + model_id: String, + tp: String, + extra_args: String, + }, + /// Custom: enter URL + EnterCustomUrl { input: String }, +} + +impl AddMenuState { + pub fn get_input(&self) -> Option { + match self { + Self::EnterApiKey { input, .. } | Self::EnterCustomUrl { input } => Some(input.clone()), + Self::EnterCustomModel { + field, + model_id, + tp, + extra_args, + .. + } => Some( + match field { + 0 => model_id, + 1 => tp, + _ => extra_args, + } + .clone(), + ), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LocalRuntime { + Sglang, + Vllm, +} + +impl LocalRuntime { + pub fn label(self) -> &'static str { + match self { + Self::Sglang => "sglang", + Self::Vllm => "vllm", + } + } + + pub fn runtime_type(self) -> openai_protocol::worker::RuntimeType { + match self { + Self::Sglang => openai_protocol::worker::RuntimeType::Sglang, + Self::Vllm => openai_protocol::worker::RuntimeType::Vllm, + } + } + + /// Build the command and args to launch a worker. + pub fn launch_args( + self, + model_id: &str, + tp: u32, + port: u16, + grpc: bool, + ) -> (String, Vec) { + match self { + Self::Sglang => { + let mut args = vec![ + "-m".to_string(), + "sglang.launch_server".to_string(), + "--model-path".to_string(), + model_id.to_string(), + "--tp-size".to_string(), + tp.to_string(), + "--port".to_string(), + port.to_string(), + "--host".to_string(), + "0.0.0.0".to_string(), + ]; + if grpc { + args.push("--grpc-mode".to_string()); + } + ("python3".to_string(), args) + } + Self::Vllm => { + let entrypoint = if grpc { + "vllm.entrypoints.grpc_server" + } else { + "vllm.entrypoints.openai.api_server" + }; + let args = vec![ + "-m".to_string(), + entrypoint.to_string(), + "--model".to_string(), + model_id.to_string(), + "--tensor-parallel-size".to_string(), + tp.to_string(), + "--port".to_string(), + port.to_string(), + "--host".to_string(), + "0.0.0.0".to_string(), + "--max-model-len".to_string(), + "16384".to_string(), + "--gpu-memory-utilization".to_string(), + "0.9".to_string(), + ]; + ("python3".to_string(), args) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LocalModelPreset { + Preset { + name: &'static str, + model_id: &'static str, + tp: u32, + }, + Custom { + model_id: String, + tp: u32, + }, +} + +impl LocalModelPreset { + pub fn all() -> Vec { + vec![ + Self::Preset { + name: "Llama-3.2-1B", + model_id: "meta-llama/Llama-3.2-1B-Instruct", + tp: 1, + }, + Self::Preset { + name: "Llama-3.1-8B", + model_id: "meta-llama/Llama-3.1-8B-Instruct", + tp: 1, + }, + Self::Preset { + name: "Qwen2.5-7B", + model_id: "Qwen/Qwen2.5-7B-Instruct", + tp: 1, + }, + Self::Preset { + name: "Qwen2.5-14B", + model_id: "Qwen/Qwen2.5-14B-Instruct", + tp: 2, + }, + Self::Preset { + name: "DeepSeek-R1-7B", + model_id: "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", + tp: 1, + }, + Self::Preset { + name: "Mistral-7B", + model_id: "mistralai/Mistral-7B-Instruct-v0.3", + tp: 1, + }, + ] + } + + pub fn label(&self) -> String { + match self { + Self::Preset { name, tp, .. } => format!("{name} (TP={tp})"), + Self::Custom { model_id, tp } => format!("{model_id} (TP={tp})"), + } + } + + pub fn model_id(&self) -> &str { + match self { + Self::Preset { model_id, .. } => model_id, + Self::Custom { model_id, .. } => model_id, + } + } + + pub fn tp(&self) -> u32 { + match self { + Self::Preset { tp, .. } | Self::Custom { tp, .. } => *tp, + } + } +} + +/// Preset provider for quick-add. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderPreset { + OpenAI, + Anthropic, + Xai, + Gemini, +} + +impl ProviderPreset { + pub fn url(self) -> &'static str { + match self { + Self::OpenAI => "https://api.openai.com", + Self::Anthropic => "https://api.anthropic.com", + Self::Xai => "https://api.x.ai", + Self::Gemini => "https://generativelanguage.googleapis.com", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::OpenAI => "OpenAI", + Self::Anthropic => "Anthropic", + Self::Xai => "xAI", + Self::Gemini => "Gemini", + } + } + + pub fn provider_type(self) -> openai_protocol::worker::ProviderType { + match self { + Self::OpenAI => openai_protocol::worker::ProviderType::OpenAI, + Self::Anthropic => openai_protocol::worker::ProviderType::Anthropic, + Self::Xai => openai_protocol::worker::ProviderType::XAI, + Self::Gemini => openai_protocol::worker::ProviderType::Gemini, + } + } + + /// Environment variable name for this provider's API key. + pub fn env_key(self) -> &'static str { + match self { + Self::OpenAI => "OPENAI_API_KEY", + Self::Anthropic => "ANTHROPIC_API_KEY", + Self::Xai => "XAI_API_KEY", + Self::Gemini => "GEMINI_API_KEY", + } + } + + #[expect(clippy::unused_self)] // Part of consistent per-preset API pattern + pub fn runtime_type(self) -> openai_protocol::worker::RuntimeType { + openai_protocol::worker::RuntimeType::External + } + + pub fn all() -> &'static [ProviderPreset] { + &[Self::OpenAI, Self::Anthropic, Self::Xai, Self::Gemini] + } +} + +/// Items in the worker action menu. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionMenuItem { + UpdatePriority, + UpdateCost, + UpdateApiKey, + FlushCache, + ToggleHealthCheck, +} + +impl ActionMenuItem { + pub fn label(self) -> &'static str { + match self { + Self::UpdatePriority => "Update priority", + Self::UpdateCost => "Update cost", + Self::UpdateApiKey => "Update API key", + Self::FlushCache => "Flush cache", + Self::ToggleHealthCheck => "Toggle health check", + } + } + + pub fn all() -> &'static [ActionMenuItem] { + &[ + Self::UpdatePriority, + Self::UpdateCost, + Self::UpdateApiKey, + Self::FlushCache, + Self::ToggleHealthCheck, + ] + } +} diff --git a/tui/src/ui/action_menu.rs b/tui/src/ui/action_menu.rs new file mode 100644 index 0000000000..ff708a5e32 --- /dev/null +++ b/tui/src/ui/action_menu.rs @@ -0,0 +1,396 @@ +use ratatui::{ + layout::{Constraint, Flex, Layout}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}, + Frame, +}; + +use super::theme; +use crate::{app::App, types::ActionMenuItem}; + +/// Render the worker action menu overlay. +pub fn render_action_menu(f: &mut Frame, app: &App) { + if !app.show_action_menu { + return; + } + + let items = ActionMenuItem::all(); + let height = items.len() as u16 + 4; // borders + title + hint + let width = 36u16; + + let area = f.area(); + let [_, vert, _] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(height), + Constraint::Fill(1), + ]) + .areas(area); + + let [popup] = Layout::horizontal([Constraint::Length(width)]) + .flex(Flex::Center) + .areas(vert); + + f.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .title(" Worker Actions ") + .title_style(theme::title()) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::PANEL_BG)); + + let list_items: Vec = items + .iter() + .enumerate() + .map(|(i, item)| { + let style = if i == app.action_menu_index { + Style::default() + .fg(theme::BG) + .bg(theme::ACCENT) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::TEXT) + }; + let label = format!(" {} ", item.label()); + ListItem::new(Line::from(Span::styled(label, style))) + }) + .collect(); + + let mut list_state = ListState::default(); + list_state.select(Some(app.action_menu_index)); + + // We need a hint line at the bottom; render the block first, then list inside + let inner = block.inner(popup); + f.render_widget(block, popup); + + // Split inner: list + hint + let [list_area, hint_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner); + + f.render_stateful_widget(List::new(list_items), list_area, &mut list_state); + + let hint = Paragraph::new(Line::from(vec![ + Span::styled("↑↓/jk", Style::default().fg(theme::TEXT_MUTED)), + Span::styled(" navigate ", Style::default().fg(theme::TEXT_MUTED)), + Span::styled("Enter", Style::default().fg(theme::TEXT_MUTED)), + Span::styled(" select ", Style::default().fg(theme::TEXT_MUTED)), + Span::styled("Esc", Style::default().fg(theme::TEXT_MUTED)), + Span::styled(" close", Style::default().fg(theme::TEXT_MUTED)), + ])); + f.render_widget(hint, hint_area); +} + +/// Render the add worker menu overlay. +pub fn render_add_menu(f: &mut Frame, app: &App) { + use crate::types::{AddMenuState, LocalModelPreset}; + + let Some(ref state) = app.add_menu_state else { + return; + }; + + match state { + AddMenuState::SelectCategory => { + render_menu( + f, + " Add Worker ", + &[ + ("1", "External", "openai, anthropic, etc."), + ("2", "Local", "sglang, vllm"), + ("3", "Custom URL", "manual"), + ], + ); + } + AddMenuState::SelectProvider => { + render_menu( + f, + " External Provider ", + &[ + ("1", "OpenAI", "https://api.openai.com"), + ("2", "Anthropic", "https://api.anthropic.com"), + ("3", "xAI (Grok)", "https://api.x.ai"), + ("4", "Gemini", "https://generativelanguage.googleapis.com"), + ], + ); + } + AddMenuState::EnterApiKey { provider, input } => { + render_text_input( + f, + &format!(" Add {} ", provider.label()), + "API Key:", + input, + true, + ); + } + AddMenuState::SelectRuntime => { + render_menu( + f, + " Local Backend ", + &[ + ("1", "SGLang", "high-performance serving"), + ("2", "vLLM", "versatile serving"), + ], + ); + } + AddMenuState::SelectConnection { runtime } => { + render_menu( + f, + &format!(" {} — Connection ", runtime.label()), + &[ + ("1", "HTTP", "OpenAI-compatible REST"), + ("2", "gRPC", "high-performance binary"), + ], + ); + } + AddMenuState::SelectModel { runtime, .. } => { + let presets = LocalModelPreset::all(); + let mut items: Vec<(String, String, String)> = presets + .iter() + .enumerate() + .map(|(i, p)| (format!("{}", i + 1), p.label(), format!("TP={}", p.tp()))) + .collect(); + items.push(( + format!("{}", presets.len() + 1), + "Custom model...".to_string(), + "enter model ID + TP".to_string(), + )); + + let title = format!(" {} — Model ", runtime.label()); + let refs: Vec<(&str, &str, &str)> = items + .iter() + .map(|(n, l, d)| (n.as_str(), l.as_str(), d.as_str())) + .collect(); + render_menu(f, &title, &refs); + } + AddMenuState::EnterCustomModel { + runtime, + field, + model_id, + tp, + extra_args, + .. + } => { + render_custom_model_form( + f, + &format!(" {} — Custom Model ", runtime.label()), + *field, + model_id, + tp, + extra_args, + ); + } + AddMenuState::EnterCustomUrl { input } => { + render_text_input(f, " Custom Worker ", "URL:", input, false); + } + } +} + +fn render_menu(f: &mut Frame, title: &str, items: &[(&str, &str, &str)]) { + let height = items.len() as u16 * 2 + 5; + let width = 50u16; + + let area = f.area(); + let [_, vert, _] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(height), + Constraint::Fill(1), + ]) + .areas(area); + + let [popup] = Layout::horizontal([Constraint::Length(width)]) + .flex(Flex::Center) + .areas(vert); + + f.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .title_style(theme::title()) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::PANEL_BG)); + + let inner = block.inner(popup); + f.render_widget(block, popup); + + let mut lines = Vec::new(); + for (num, label, desc) in items { + lines.push(Line::from(vec![ + Span::styled( + format!(" [{num}] "), + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ), + Span::styled(*label, Style::default().fg(theme::TEXT)), + Span::styled(format!(" {desc}"), Style::default().fg(theme::TEXT_MUTED)), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Esc to cancel", + Style::default().fg(theme::TEXT_MUTED), + ))); + + f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); +} + +fn render_text_input(f: &mut Frame, title: &str, label: &str, input: &str, masked: bool) { + let width = 55u16; + let height = 8u16; + + let area = f.area(); + let [_, vert, _] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(height), + Constraint::Fill(1), + ]) + .areas(area); + + let [popup] = Layout::horizontal([Constraint::Length(width)]) + .flex(Flex::Center) + .areas(vert); + + f.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .title_style(theme::title()) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::PANEL_BG)); + + let inner = block.inner(popup); + f.render_widget(block, popup); + + let display = if input.is_empty() { + Span::styled("Type here...", Style::default().fg(theme::TEXT_MUTED)) + } else if masked { + Span::styled("*".repeat(input.len()), Style::default().fg(theme::TEXT)) + } else { + Span::styled(input, Style::default().fg(theme::TEXT)) + }; + + let lines = vec![ + Line::from(Span::styled( + label, + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(vec![ + display, + Span::styled("▊", Style::default().fg(theme::ACCENT)), + ]), + Line::from(""), + Line::from(Span::styled( + " Enter to confirm Esc to cancel", + Style::default().fg(theme::TEXT_MUTED), + )), + ]; + + f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); +} + +fn render_custom_model_form( + f: &mut Frame, + title: &str, + active_field: u8, + model_id: &str, + tp: &str, + extra_args: &str, +) { + let width = 60u16; + let height = 14u16; + + let area = f.area(); + let [_, vert, _] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(height), + Constraint::Fill(1), + ]) + .areas(area); + + let [popup] = Layout::horizontal([Constraint::Length(width)]) + .flex(Flex::Center) + .areas(vert); + + f.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .title_style(theme::title()) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::PANEL_BG)); + + let inner = block.inner(popup); + f.render_widget(block, popup); + + let field_style = |idx: u8| { + if idx == active_field { + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::TEXT_MUTED) + } + }; + let cursor = |idx: u8| { + if idx == active_field { + Span::styled("▊", Style::default().fg(theme::ACCENT)) + } else { + Span::raw("") + } + }; + + let lines = vec![ + Line::from(Span::styled("Model ID:", field_style(0))), + Line::from(vec![ + Span::styled( + if model_id.is_empty() && active_field == 0 { + " e.g. meta-llama/Llama-3.1-8B-Instruct".to_string() + } else { + format!(" {model_id}") + }, + if model_id.is_empty() { + Style::default().fg(theme::TEXT_MUTED) + } else { + Style::default().fg(theme::TEXT) + }, + ), + cursor(0), + ]), + Line::from(""), + Line::from(Span::styled("Tensor Parallel (TP):", field_style(1))), + Line::from(vec![ + Span::styled(format!(" {tp}"), Style::default().fg(theme::TEXT)), + cursor(1), + ]), + Line::from(""), + Line::from(Span::styled("Extra Args:", field_style(2))), + Line::from(vec![ + Span::styled( + if extra_args.is_empty() { + " e.g. --max-model-len 16384".to_string() + } else { + format!(" {extra_args}") + }, + if extra_args.is_empty() { + Style::default().fg(theme::TEXT_MUTED) + } else { + Style::default().fg(theme::TEXT) + }, + ), + cursor(2), + ]), + Line::from(""), + Line::from(Span::styled( + " Tab next Shift+Tab prev Enter confirm Esc cancel", + Style::default().fg(theme::TEXT_MUTED), + )), + ]; + + f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); +} diff --git a/tui/src/ui/chat.rs b/tui/src/ui/chat.rs new file mode 100644 index 0000000000..ac15409660 --- /dev/null +++ b/tui/src/ui/chat.rs @@ -0,0 +1,341 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +use super::theme; +use crate::app::App; + +pub fn render_chat(f: &mut Frame, app: &App, area: Rect) { + // Layout: messages (fill) + input (3 lines) + let [messages_area, input_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).areas(area); + + render_messages(f, app, messages_area); + render_input(f, app, input_area); +} + +fn render_messages(f: &mut Frame, app: &App, area: Rect) { + let mode_hint = match app.chat_endpoint { + crate::chat::ChatEndpoint::Chat => "full history", + crate::chat::ChatEndpoint::Responses => { + if app.chat_previous_response_id.is_some() { + "prev_response_id" + } else { + "first turn" + } + } + }; + let title = format!( + " Chat — {} — /v1/{} ({}) ", + app.chat_model, + app.chat_endpoint.label(), + mode_hint, + ); + let block = Block::default() + .title(title) + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::BG)); + + let inner = block.inner(area); + f.render_widget(block, area); + + if app.chat_messages.is_empty() { + let help = vec![ + Line::from(""), + Line::from(Span::styled( + "Type a message and press Enter to chat.", + theme::label(), + )), + Line::from(Span::styled( + "Tab: cycle models Shift+Tab: cycle endpoint Esc: cancel", + theme::label(), + )), + ]; + f.render_widget(Paragraph::new(help), inner); + return; + } + + // Build all lines from messages + let mut lines: Vec = Vec::new(); + + for msg in &app.chat_messages { + let (prefix, prefix_style) = match msg.role.as_str() { + "user" => ( + "You: ", + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ), + "assistant" => ( + "SMG: ", + Style::default() + .fg(theme::GREEN) + .add_modifier(Modifier::BOLD), + ), + _ => ( + "", + Style::default() + .fg(theme::YELLOW) + .add_modifier(Modifier::BOLD), + ), + }; + + // First line gets the role prefix + let is_assistant = msg.role == "assistant"; + let content_lines: Vec<&str> = msg.content.split('\n').collect(); + let mut in_code_block = false; + + for (i, content_line) in content_lines.iter().enumerate() { + // Track fenced code blocks + if content_line.starts_with("```") { + in_code_block = !in_code_block; + let mut spans = Vec::new(); + if i == 0 { + spans.push(Span::styled(prefix, prefix_style)); + } else { + spans.push(Span::raw(" ".repeat(prefix.len()))); + } + spans.push(Span::styled( + *content_line, + Style::default().fg(theme::YELLOW), + )); + lines.push(Line::from(spans)); + continue; + } + + let mut spans = Vec::new(); + if i == 0 { + spans.push(Span::styled(prefix, prefix_style)); + } else { + spans.push(Span::raw(" ".repeat(prefix.len()))); + } + + if in_code_block { + // Inside code block — render as-is with code style + spans.push(Span::styled( + *content_line, + Style::default().fg(theme::YELLOW), + )); + } else if is_assistant { + spans.extend(parse_markdown_spans(content_line)); + } else { + spans.push(Span::styled(*content_line, theme::text())); + } + + lines.push(Line::from(spans)); + } + + // Show streaming cursor + if msg.role == "assistant" + && app.chat_streaming + && app + .chat_messages + .last() + .is_some_and(|last| std::ptr::eq(msg, last)) + { + if let Some(last_line) = lines.last_mut() { + last_line + .spans + .push(Span::styled("▊", Style::default().fg(theme::ACCENT))); + } + } + + lines.push(Line::from("")); // blank line between messages + } + + // Calculate wrapped line count for proper scrolling + let width = inner.width as usize; + let total_lines: u16 = if width > 0 { + lines + .iter() + .map(|line| { + let line_width: usize = line.spans.iter().map(|s| s.content.len()).sum(); + line_width.max(1).div_ceil(width) as u16 // ceil division + }) + .sum() + } else { + lines.len() as u16 + }; + let visible = inner.height; + let max_scroll = total_lines.saturating_sub(visible); + let scroll = if app.chat_scroll >= max_scroll { + max_scroll + } else { + app.chat_scroll + }; + + f.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)), + inner, + ); +} + +fn render_input(f: &mut Frame, app: &App, area: Rect) { + let title = if app.chat_streaming { + " Streaming... (Esc to stop) " + } else { + " Message (Enter to send, Tab: model, Shift+Tab: endpoint) " + }; + + let block = Block::default() + .title(title) + .title_style(if app.chat_streaming { + Style::default().fg(theme::YELLOW) + } else { + theme::title() + }) + .borders(Borders::ALL) + .border_style(Style::default().fg(if app.chat_streaming { + theme::YELLOW + } else { + theme::BORDER + })) + .style(Style::default().bg(theme::BG)); + + let input_text = if app.chat_streaming { + String::new() + } else { + format!("{}▊", app.chat_input) + }; + + let paragraph = Paragraph::new(input_text).style(theme::text()).block(block); + f.render_widget(paragraph, area); +} + +/// Parse inline markdown into styled spans. +/// Supports: **bold**, *italic*, `code`, ### headings +fn parse_markdown_spans(line: &str) -> Vec> { + // Handle heading lines + if let Some(rest) = line.strip_prefix("### ") { + return vec![Span::styled( + rest, + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + )]; + } + if let Some(rest) = line.strip_prefix("## ") { + return vec![Span::styled( + rest, + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + )]; + } + if let Some(rest) = line.strip_prefix("# ") { + return vec![Span::styled( + rest, + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + )]; + } + // Handle bullet points + let (bullet_prefix, rest) = if let Some(rest) = line.strip_prefix("- ") { + ("• ", rest) + } else if let Some(rest) = line.strip_prefix("* ") { + ("• ", rest) + } else { + ("", line) + }; + + let mut spans = Vec::new(); + if !bullet_prefix.is_empty() { + spans.push(Span::styled(bullet_prefix, theme::text())); + } + + let chars: Vec = rest.chars().collect(); + let len = chars.len(); + let mut i = 0; + let mut buf = String::new(); + + while i < len { + // **bold** + if i + 1 < len && chars[i] == '*' && chars[i + 1] == '*' { + if !buf.is_empty() { + spans.push(Span::styled(buf.clone(), theme::text())); + buf.clear(); + } + i += 2; + let start = i; + while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '*') { + i += 1; + } + let bold_text: String = chars[start..i].iter().collect(); + spans.push(Span::styled( + bold_text, + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + )); + if i + 1 < len { + i += 2; // skip closing ** + } + continue; + } + + // `code` + if chars[i] == '`' { + if !buf.is_empty() { + spans.push(Span::styled(buf.clone(), theme::text())); + buf.clear(); + } + i += 1; + let start = i; + while i < len && chars[i] != '`' { + i += 1; + } + let code_text: String = chars[start..i].iter().collect(); + spans.push(Span::styled(code_text, Style::default().fg(theme::YELLOW))); + if i < len { + i += 1; // skip closing ` + } + continue; + } + + // *italic* (single asterisk, not double) + if chars[i] == '*' && (i + 1 >= len || chars[i + 1] != '*') { + if !buf.is_empty() { + spans.push(Span::styled(buf.clone(), theme::text())); + buf.clear(); + } + i += 1; + let start = i; + while i < len && chars[i] != '*' { + i += 1; + } + let italic_text: String = chars[start..i].iter().collect(); + spans.push(Span::styled( + italic_text, + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::ITALIC), + )); + if i < len { + i += 1; // skip closing * + } + continue; + } + + buf.push(chars[i]); + i += 1; + } + + if !buf.is_empty() { + spans.push(Span::styled(buf, theme::text())); + } + + if spans.is_empty() { + spans.push(Span::styled("", theme::text())); + } + + spans +} diff --git a/tui/src/ui/detail.rs b/tui/src/ui/detail.rs new file mode 100644 index 0000000000..3193fc798b --- /dev/null +++ b/tui/src/ui/detail.rs @@ -0,0 +1,232 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::Style, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use super::{sparkline, theme}; +use crate::{app::App, client::WorkerInfo}; + +pub fn render_detail(f: &mut Frame, app: &App, worker: &WorkerInfo, area: Rect) { + let title = format!(" Worker: {} ", truncate_str(&worker.id, 20)); + let block = theme::panel(&title); + f.render_widget(block, area); + + let inner = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + }; + + if inner.width < 3 || inner.height < 1 { + return; + } + + // 2-column layout: config (left) + stats (right) + let [left_area, right_area] = + Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)]).areas(inner); + + // Safety: RwLock is not poisoned — no panics while holding the lock + #[expect(clippy::unwrap_used)] + let state = app.state.read().unwrap(); + + // Left: Config + Models + render_config(f, worker, left_area); + + // Right: Live stats + let worker_load = state + .loads + .as_ref() + .and_then(|l| l.workers.iter().find(|wl| wl.worker == worker.url)); + let worker_rps = state.worker_rps.get(&worker.url).copied().unwrap_or(0.0); + let cb_has_open = state.circuit_breakers.open > 0; + render_stats(f, worker, worker_load, worker_rps, cb_has_open, right_area); +} + +fn render_config(f: &mut Frame, worker: &WorkerInfo, area: Rect) { + if area.height == 0 { + return; + } + + let health_color = if worker.is_healthy { + theme::GREEN + } else { + theme::RED + }; + let health_text = if worker.is_healthy { + "healthy" + } else { + "unhealthy" + }; + + let short_url = worker + .url + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_start_matches("grpc://") + .trim_end_matches('/'); + + let mut lines: Vec = vec![ + Line::from(Span::styled("Config", Style::default().fg(theme::ACCENT))), + line_kv("URL", short_url), + line_kv("Runtime", &worker.runtime_type), + line_kv("Mode", &worker.connection_mode), + Line::from(vec![ + Span::styled("Health: ", Style::default().fg(theme::TEXT_MUTED)), + Span::styled(health_text, Style::default().fg(health_color)), + ]), + ]; + + // Models + if !worker.models.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!("Models ({})", worker.models.len()), + Style::default().fg(theme::ACCENT), + ))); + let max_models = area.height.saturating_sub(lines.len() as u16) as usize; + for (i, model) in worker.models.iter().enumerate() { + if i >= max_models { + lines.push(Line::from(Span::styled( + format!(" +{} more", worker.models.len() - i), + Style::default().fg(theme::TEXT_MUTED), + ))); + break; + } + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(&model.id, Style::default().fg(theme::TEXT)), + ])); + } + } + + f.render_widget(Paragraph::new(lines), area); +} + +fn render_stats( + f: &mut Frame, + worker: &WorkerInfo, + worker_load: Option<&crate::client::WorkerLoad>, + worker_rps: f64, + cb_has_open: bool, + area: Rect, +) { + if area.height == 0 { + return; + } + + let mut lines: Vec = vec![Line::from(Span::styled( + "Stats", + Style::default().fg(theme::ACCENT), + ))]; + + let is_http_local = worker.connection_mode == "http" + && (worker.runtime_type == "sglang" || worker.runtime_type == "vllm"); + + // Req/s from Prometheus (all workers) + lines.push(line_kv("Req/s", &format!("{worker_rps:.1}"))); + + // Circuit breaker — use actual breaker state from Prometheus metrics + // If worker is unhealthy, show open; if there are open breakers globally and worker unhealthy, show open + let cb_is_open = !worker.is_healthy && cb_has_open; + let cb_label = if cb_is_open { "open" } else { "closed" }; + let cb_color = if cb_is_open { theme::RED } else { theme::GREEN }; + lines.push(Line::from(vec![ + Span::styled( + "Circuit: ".to_string(), + Style::default().fg(theme::TEXT_MUTED), + ), + Span::styled(cb_label.to_string(), Style::default().fg(cb_color)), + ])); + + // HTTP sglang/vllm: show detailed load stats + if is_http_local { + if let Some(wl) = worker_load { + if let Some(ref details) = wl.details { + let bar_width = (area.width / 3).max(6) as usize; + for snap in &details.loads { + lines.push(Line::from("")); + + // Running requests + let running_ratio = if snap.max_running_requests > 0 { + snap.num_running_reqs as f64 / snap.max_running_requests as f64 + } else { + 0.0 + } + .clamp(0.0, 1.0); + let run_color = theme::severity(running_ratio); + let (filled, empty, pct) = sparkline::gauge_bar(running_ratio, bar_width); + lines.push(line_kv( + "Running", + &format!( + "{} / {} max", + snap.num_running_reqs, snap.max_running_requests + ), + )); + lines.push(Line::from(vec![ + Span::styled(" ".to_string(), Style::default()), + Span::styled(filled, Style::default().fg(run_color)), + Span::styled(empty, Style::default().fg(theme::TEXT_MUTED)), + Span::styled(format!(" {pct}%"), Style::default().fg(theme::TEXT_MUTED)), + ])); + + // Waiting + lines.push(line_kv("Waiting", &snap.num_waiting_reqs.to_string())); + + // KV Cache usage + let token_ratio = snap.token_usage.clamp(0.0, 1.0); + let tok_color = theme::severity(token_ratio); + let (filled, empty, pct) = sparkline::gauge_bar(token_ratio, bar_width); + lines.push(line_kv( + "KV Cache", + &format!( + "{} / {} ({:.1}%)", + snap.num_used_tokens, + snap.max_total_num_tokens, + snap.token_usage * 100.0 + ), + )); + lines.push(Line::from(vec![ + Span::styled(" ".to_string(), Style::default()), + Span::styled(filled, Style::default().fg(tok_color)), + Span::styled(empty, Style::default().fg(theme::TEXT_MUTED)), + Span::styled(format!(" {pct}%"), Style::default().fg(theme::TEXT_MUTED)), + ])); + + if snap.gen_throughput > 0.0 { + lines.push(line_kv( + "Gen Throughput", + &format!("{:.1} tok/s", snap.gen_throughput), + )); + } + } + } + } + } else if let Some(wl) = worker_load { + // gRPC/external: show load value + if wl.load >= 0 { + lines.push(line_kv("Load", &format!("{}", wl.load))); + } + } + + f.render_widget(Paragraph::new(lines), area); +} + +fn line_kv(key: &str, value: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{key}: "), Style::default().fg(theme::TEXT_MUTED)), + Span::styled(value.to_string(), Style::default().fg(theme::TEXT)), + ]) +} + +fn truncate_str(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let prefix: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{prefix}…") + } +} diff --git a/tui/src/ui/dialog.rs b/tui/src/ui/dialog.rs new file mode 100644 index 0000000000..df1d83472a --- /dev/null +++ b/tui/src/ui/dialog.rs @@ -0,0 +1,91 @@ +use ratatui::{ + layout::{Constraint, Flex, Layout}, + style::Style, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + +use super::theme; +use crate::app::App; + +/// Render the delete-confirmation popup. +pub fn render_delete_dialog(f: &mut Frame, app: &App) { + let Some(ref info) = app.confirm_delete else { + return; + }; + + let area = f.area(); + + // Center a 50×8 popup + let [_, vert, _] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(8), + Constraint::Fill(1), + ]) + .areas(area); + + let [popup] = Layout::horizontal([Constraint::Length(50)]) + .flex(Flex::Center) + .areas(vert); + + f.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .title(" Confirm Delete ") + .title_style(theme::title()) + .border_style(Style::default().fg(theme::RED)) + .style(Style::default().bg(theme::PANEL_BG)); + + let text = format!( + "Delete worker?\n\nID: {}\nURL: {}\n\n[y] confirm [n/Esc] cancel", + info.0, info.1, + ); + + let paragraph = Paragraph::new(text) + .style(Style::default().fg(theme::TEXT)) + .block(block) + .wrap(Wrap { trim: false }); + f.render_widget(paragraph, popup); +} + +/// Render the flush-cache confirmation popup. +pub fn render_flush_dialog(f: &mut Frame, app: &App) { + let Some(ref info) = app.confirm_flush else { + return; + }; + + let area = f.area(); + + // Center a 50×8 popup + let [_, vert, _] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(8), + Constraint::Fill(1), + ]) + .areas(area); + + let [popup] = Layout::horizontal([Constraint::Length(50)]) + .flex(Flex::Center) + .areas(vert); + + f.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .title(" Flush Cache? ") + .title_style(theme::title()) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::PANEL_BG)); + + let text = format!( + "Flush cache for worker?\n\nID: {}\nURL: {}\n\n[y] confirm [n/Esc] cancel", + info.0, info.1, + ); + + let paragraph = Paragraph::new(text) + .style(Style::default().fg(theme::TEXT)) + .block(block) + .wrap(Wrap { trim: false }); + f.render_widget(paragraph, popup); +} diff --git a/tui/src/ui/filter.rs b/tui/src/ui/filter.rs new file mode 100644 index 0000000000..4a9439fa0e --- /dev/null +++ b/tui/src/ui/filter.rs @@ -0,0 +1,23 @@ +use ratatui::{layout::Rect, style::Style, widgets::Paragraph, Frame}; + +use super::theme; +use crate::{app::App, types::InputMode}; + +/// Render the filter/command input bar over the footer area. +pub fn render_filter(f: &mut Frame, app: &App, footer_area: Rect) { + match app.input_mode { + InputMode::Filter => { + let text = format!("/{}", app.input_buffer); + let paragraph = + Paragraph::new(text).style(Style::default().fg(theme::YELLOW).bg(theme::BG)); + f.render_widget(paragraph, footer_area); + } + InputMode::Command => { + let text = format!(":{}", app.input_buffer); + let paragraph = + Paragraph::new(text).style(Style::default().fg(theme::ACCENT).bg(theme::BG)); + f.render_widget(paragraph, footer_area); + } + InputMode::Normal => {} + } +} diff --git a/tui/src/ui/footer.rs b/tui/src/ui/footer.rs new file mode 100644 index 0000000000..69cd702feb --- /dev/null +++ b/tui/src/ui/footer.rs @@ -0,0 +1,78 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::Style, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use super::theme; +use crate::{ + app::App, + types::{InputMode, View}, +}; + +pub fn render_footer(f: &mut Frame, app: &App, area: Rect) { + let [hints_area, status_area] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area); + + // Key hints — context-sensitive + let hints = match app.input_mode { + InputMode::Normal => match app.view { + View::Workers => { + vec![ + hint("q", "quit"), + hint("1-7", "view"), + hint("j/k", "nav"), + hint("/", "filter"), + hint(":", "cmd"), + hint("a", "add"), + hint("d", "delete"), + hint("?", "help"), + ] + } + _ => { + vec![ + hint("q", "quit"), + hint("1-7", "view"), + hint("/", "filter"), + hint(":", "cmd"), + hint("?", "help"), + ] + } + }, + InputMode::Filter | InputMode::Command => { + vec![hint("Enter", "submit"), hint("Esc", "cancel")] + } + }; + + let line = Line::from( + hints + .into_iter() + .flat_map(|(key, desc)| { + vec![ + Span::styled( + format!(" {key} "), + Style::default().fg(theme::ACCENT).bg(theme::PANEL_BG), + ), + Span::styled(format!("{desc} "), Style::default().fg(theme::TEXT_MUTED)), + ] + }) + .collect::>(), + ); + f.render_widget(Paragraph::new(line), hints_area); + + // Status message + if let Some(msg) = &app.status_message { + let style = if msg.starts_with("Error") { + Style::default().fg(theme::RED) + } else { + Style::default().fg(theme::GREEN) + }; + f.render_widget(Paragraph::new(msg.as_str()).style(style), status_area); + } +} + +fn hint(key: &str, desc: &str) -> (String, String) { + (key.to_string(), desc.to_string()) +} diff --git a/tui/src/ui/help.rs b/tui/src/ui/help.rs new file mode 100644 index 0000000000..b85e7e4209 --- /dev/null +++ b/tui/src/ui/help.rs @@ -0,0 +1,108 @@ +use ratatui::{ + layout::{Constraint, Flex, Layout, Rect}, + style::Style, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, +}; + +use super::theme; +use crate::types::View; + +fn help_text(view: View) -> String { + let mut text = String::from( + "\ +Navigation + 1-7 Switch view (Pulse/Workers/Chat/Logs/Bench/Traffic/Mesh) + q Quit TUI (services keep running) + Ctrl+C ×2 Full shutdown (stop all services) + ? Toggle this help + / Filter + : Command mode + Esc Close overlay / clear filter + +", + ); + + match view { + View::Workers => { + text.push_str( + "\ +Workers View + j / Down Move selection down + k / Up Move selection up + Enter Toggle worker detail panel + e Action menu (priority, cost, flush cache, ...) + a Add worker (enters command mode) + d Delete selected worker + +", + ); + } + _ => { + text.push_str( + "\ +General + j / Down Move selection down + k / Up Move selection up + +", + ); + } + } + + text.push_str( + "\ +Commands + :add [--provider

] [--runtime ] + Add a worker + :delete Delete a worker by ID + :priority Set worker priority + :cost Set worker cost + :flush-cache Flush worker cache + :toggle-health Toggle health check + :api-key Update API key + :add-openai Quick-add OpenAI worker + :quit Quit the TUI + +Providers: openai, anthropic, gemini, xai +Runtimes: sglang (default), vllm, trtllm, external +", + ); + + text +} + +pub fn render_help(f: &mut Frame, view: View) { + let popup = centered_rect(60, 70, f.area()); + f.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .title(" Help ") + .title_style(theme::title()) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::PANEL_BG)); + + let paragraph = Paragraph::new(help_text(view)) + .style(Style::default().fg(theme::TEXT)) + .block(block) + .wrap(Wrap { trim: false }); + + f.render_widget(paragraph, popup); +} + +/// Return a centered `Rect` occupying `percent_x`% width and `percent_y`% height. +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let [_, vert, _] = Layout::vertical([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .areas(area); + + let [horiz] = Layout::horizontal([Constraint::Percentage(percent_x)]) + .flex(Flex::Center) + .areas(vert); + + horiz +} diff --git a/tui/src/ui/logs.rs b/tui/src/ui/logs.rs new file mode 100644 index 0000000000..830d252f4e --- /dev/null +++ b/tui/src/ui/logs.rs @@ -0,0 +1,280 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +use super::theme; +use crate::app::{App, LogLevel, LogSubTab}; + +pub fn render_logs(f: &mut Frame, app: &App, area: Rect) { + // Split: content (fill) + footer (1 line) + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + + // Render content based on active sub-tab + match &app.log_sub_tab { + LogSubTab::Tui => render_tui_logs(f, app, content_area), + LogSubTab::Gateway => { + render_file_log(f, "/tmp/smg-gateway.log", "SMG Gateway", app, content_area); + } + LogSubTab::Worker(port) => { + let path = format!("/tmp/smg-worker-{port}.log"); + let label = worker_tab_label(app, port); + render_file_log(f, &path, &label, app, content_area); + } + } + + // Render footer: sub-tabs (left) + key hints (right) + render_footer(f, app, footer_area); +} + +fn render_footer(f: &mut Frame, app: &App, area: Rect) { + let [tabs_area, hints_area] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(30)]).areas(area); + + // Left: sub-tab selector + let mut spans = vec![ + Span::styled(" ", Style::default()), + tab_span("a:TUI", matches!(app.log_sub_tab, LogSubTab::Tui)), + Span::styled(" ", Style::default()), + tab_span("b:SMG", matches!(app.log_sub_tab, LogSubTab::Gateway)), + ]; + + let worker_tabs = app.worker_log_tabs(); + let worker_labels: Vec<(String, bool)> = worker_tabs + .iter() + .map(|(label, port)| { + let active = matches!(&app.log_sub_tab, LogSubTab::Worker(p) if p == port); + (format!("w:{label}"), active) + }) + .collect(); + for (label, active) in &worker_labels { + spans.push(Span::styled(" ", Style::default())); + spans.push(tab_span(label, *active)); + } + + f.render_widget(Paragraph::new(Line::from(spans)), tabs_area); + + // Right: key hints + let hints = Line::from(vec![ + Span::styled("j/k", Style::default().fg(theme::ACCENT)), + Span::styled(" scroll ", theme::label()), + Span::styled("G", Style::default().fg(theme::ACCENT)), + Span::styled(" bottom ", theme::label()), + Span::styled("w", Style::default().fg(theme::ACCENT)), + Span::styled(" worker", theme::label()), + ]); + f.render_widget( + Paragraph::new(hints).alignment(ratatui::layout::Alignment::Right), + hints_area, + ); +} + +fn tab_span(label: &str, active: bool) -> Span<'_> { + if active { + Span::styled( + label, + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ) + } else { + Span::styled(label, theme::label()) + } +} + +fn worker_tab_label(app: &App, port: &str) -> String { + for (label, p) in app.worker_log_tabs() { + if p == port { + return label; + } + } + format!("worker-{port}") +} + +fn render_tui_logs(f: &mut Frame, app: &App, area: Rect) { + let total = app.log_entries.len(); + let title = format!(" TUI Logs ({total}) "); + let block = Block::default() + .title(title) + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::BG)); + + let inner = block.inner(area); + f.render_widget(block, area); + + if app.log_entries.is_empty() { + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "No log entries yet.", + theme::label(), + ))), + inner, + ); + return; + } + + let lines: Vec = app + .log_entries + .iter() + .map(|entry| { + let time = entry.timestamp.format("%H:%M:%S").to_string(); + let (level_str, level_color) = match entry.level { + LogLevel::Info => ("INFO", theme::GREEN), + LogLevel::Warn => ("WARN", theme::YELLOW), + LogLevel::Error => ("ERR ", theme::RED), + }; + + Line::from(vec![ + Span::styled(format!("{time} "), Style::default().fg(theme::TEXT_MUTED)), + Span::styled( + format!("{level_str} "), + Style::default() + .fg(level_color) + .add_modifier(Modifier::BOLD), + ), + Span::styled(&entry.message, theme::text()), + ]) + }) + .collect(); + + let total_lines = lines.len() as u16; + let visible = inner.height; + let max_scroll = total_lines.saturating_sub(visible); + let scroll = if app.log_scroll >= max_scroll { + max_scroll + } else { + app.log_scroll + }; + + f.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)), + inner, + ); +} + +fn render_file_log(f: &mut Frame, path: &str, label: &str, app: &App, area: Rect) { + // Read only the tail of the file to avoid blocking the render loop on large files + use std::io::{Read, Seek, SeekFrom}; + let content = match std::fs::File::open(path) { + Ok(mut file) => { + // Read at most the last 64KB to avoid blocking on large files + const TAIL_BYTES: u64 = 64 * 1024; + let len = file.metadata().map(|m| m.len()).unwrap_or(0); + if len > TAIL_BYTES { + let _ = file.seek(SeekFrom::Start(len - TAIL_BYTES)); + } + let mut raw = Vec::new(); + if file.read_to_end(&mut raw).is_err() { + raw.clear(); + } + let buf = String::from_utf8_lossy(&raw).into_owned(); + // If we seeked into the middle, skip the first partial line + if len > TAIL_BYTES { + buf.split_once('\n') + .map(|(_, rest)| rest.to_string()) + .unwrap_or(buf) + } else { + buf + } + } + Err(_) => { + let block = Block::default() + .title(format!(" {label} ")) + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::BG)); + let inner = block.inner(area); + f.render_widget(block, area); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + format!("Log file not found: {path}"), + theme::label(), + ))), + inner, + ); + return; + } + }; + + // Take last N lines + let all_lines: Vec<&str> = content.lines().collect(); + let max_lines = 500; + let start = all_lines.len().saturating_sub(max_lines); + let lines: Vec = all_lines[start..] + .iter() + .map(|line| { + // Strip ANSI escape codes for cleaner display + let clean = strip_ansi(line); + let color = if clean.contains("ERROR") || clean.contains("Error") { + theme::RED + } else if clean.contains("WARN") || clean.contains("Warning") { + theme::YELLOW + } else if clean.contains("INFO") { + theme::GREEN + } else { + theme::TEXT + }; + Line::from(Span::styled(clean, Style::default().fg(color))) + }) + .collect(); + + let total_count = lines.len(); + let title = format!(" {label} ({total_count} lines) "); + let block = Block::default() + .title(title) + .title_style(theme::title()) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER)) + .style(Style::default().bg(theme::BG)); + + let inner = block.inner(area); + f.render_widget(block, area); + + let total_lines = lines.len() as u16; + let visible = inner.height; + let max_scroll = total_lines.saturating_sub(visible); + let scroll = if app.log_scroll >= max_scroll { + max_scroll + } else { + app.log_scroll + }; + + f.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)), + inner, + ); +} + +/// Strip ANSI escape codes from a string. +fn strip_ansi(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\x1b' { + // Skip until we find the end of the escape sequence + if chars.peek() == Some(&'[') { + chars.next(); + while let Some(&nc) = chars.peek() { + chars.next(); + if nc.is_ascii_alphabetic() { + break; + } + } + } + } else { + result.push(c); + } + } + result +} diff --git a/tui/src/ui/mod.rs b/tui/src/ui/mod.rs new file mode 100644 index 0000000000..4629f21358 --- /dev/null +++ b/tui/src/ui/mod.rs @@ -0,0 +1,93 @@ +pub mod action_menu; +mod chat; +pub mod detail; +mod dialog; +mod filter; +mod footer; +mod help; +mod logs; +pub mod models; +mod pulse; +pub mod sparkline; +pub mod stats_bar; +pub mod tabs; +pub mod theme; +mod workers; + +use ratatui::{ + layout::{Constraint, Layout}, + Frame, +}; + +use crate::{app::App, types::View}; + +/// Root render function — called once per frame. +pub fn render(f: &mut Frame, app: &App) { + // Safety: RwLock is not poisoned — no panics while holding the lock + #[expect(clippy::unwrap_used)] + let state = app.state.read().unwrap(); + + // Layout: stats_bar (5) + tabs (1) + content (fill) + footer (2) + let [stats_area, tabs_area, content_area, footer_area] = Layout::vertical([ + Constraint::Length(5), + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(2), + ]) + .areas(f.area()); + + // Background + f.render_widget( + ratatui::widgets::Block::default().style(ratatui::style::Style::default().bg(theme::BG)), + f.area(), + ); + + stats_bar::render_stats_bar(f, &state, stats_area); + tabs::render_tabs(f, app.view, tabs_area); + + drop(state); + + match app.view { + View::Pulse => pulse::render_pulse(f, app, content_area), + View::Workers => workers::render_workers(f, app, content_area), + View::Chat => chat::render_chat(f, app, content_area), + View::Logs => logs::render_logs(f, app, content_area), + View::Benchmark | View::Traffic | View::Mesh => { + render_placeholder(f, app.view, content_area); + } + } + + footer::render_footer(f, app, footer_area); + + // Overlays (rendered last) + if app.show_help { + help::render_help(f, app.view); + } + if app.confirm_delete.is_some() { + dialog::render_delete_dialog(f, app); + } + if app.show_action_menu { + action_menu::render_action_menu(f, app); + } + if app.add_menu_state.is_some() { + action_menu::render_add_menu(f, app); + } + if app.confirm_flush.is_some() { + dialog::render_flush_dialog(f, app); + } + filter::render_filter(f, app, footer_area); +} + +fn render_placeholder(f: &mut Frame, view: View, area: ratatui::layout::Rect) { + use ratatui::widgets::{Block, Borders, Paragraph}; + + let text = format!("{} — coming soon", view.label()); + let block = Block::default() + .borders(Borders::ALL) + .title(view.label()) + .title_style(theme::title()) + .border_style(ratatui::style::Style::default().fg(theme::BORDER)) + .style(ratatui::style::Style::default().bg(theme::BG)); + let paragraph = Paragraph::new(text).style(theme::label()).block(block); + f.render_widget(paragraph, area); +} diff --git a/tui/src/ui/models.rs b/tui/src/ui/models.rs new file mode 100644 index 0000000000..e423c1b633 --- /dev/null +++ b/tui/src/ui/models.rs @@ -0,0 +1,88 @@ +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + widgets::{Cell, Row, Table, TableState}, + Frame, +}; + +use super::theme; +use crate::app::App; + +pub fn render_models(f: &mut Frame, app: &App, area: Rect) { + // Safety: RwLock is not poisoned — no panics while holding the lock + #[expect(clippy::unwrap_used)] + let state = app.state.read().unwrap(); + + let block = theme::panel(" Models "); + + let header = Row::new(vec!["ID", "NAME", "WORKERS", "CREATED"]).style( + Style::default() + .fg(theme::ACCENT) + .bg(theme::PANEL_BG) + .add_modifier(Modifier::BOLD), + ); + + let rows: Vec = if let Some(ref models) = state.models { + models + .data + .iter() + .map(|m| { + // Count workers serving this model + let worker_count = state + .workers + .as_ref() + .map(|w| { + w.workers + .iter() + .filter(|wi| wi.models.iter().any(|mr| mr.id == m.id)) + .count() + }) + .unwrap_or(0); + + // created_at is already a human-readable string + let created = if m.created_at.is_empty() { + "--".to_string() + } else { + m.created_at.clone() + }; + + Row::new(vec![ + Cell::from(m.id.clone()).style(theme::text()), + Cell::from(m.display_name.clone()).style(theme::label()), + Cell::from(worker_count.to_string()).style(theme::text()), + Cell::from(created).style(theme::label()), + ]) + .style(Style::default().bg(theme::BG)) + }) + .collect() + } else { + vec![] + }; + + let row_count = rows.len(); + + let widths = [ + ratatui::layout::Constraint::Percentage(40), + ratatui::layout::Constraint::Percentage(25), + ratatui::layout::Constraint::Percentage(15), + ratatui::layout::Constraint::Percentage(20), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block(block) + .row_highlight_style( + Style::default() + .fg(theme::TEXT) + .bg(theme::BORDER) + .add_modifier(Modifier::BOLD), + ); + + let mut table_state = TableState::default(); + if row_count > 0 { + table_state.select(Some(app.selected_index.min(row_count.saturating_sub(1)))); + } + + f.render_stateful_widget(table, area, &mut table_state); + drop(state); +} diff --git a/tui/src/ui/pulse.rs b/tui/src/ui/pulse.rs new file mode 100644 index 0000000000..e05549a6ed --- /dev/null +++ b/tui/src/ui/pulse.rs @@ -0,0 +1,475 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use super::{sparkline, theme}; +use crate::app::App; + +pub fn render_pulse(f: &mut Frame, app: &App, area: Rect) { + let width = area.width; + // Safety: RwLock is not poisoned — no panics while holding the lock + #[expect(clippy::unwrap_used)] + let state = app.state.read().unwrap(); + + let has_cluster = state.cluster.is_some(); + let has_gpus = state.gpus.is_some(); + let has_node_panel = has_cluster || has_gpus; + + if width < 80 { + // Narrow: single column + let mut constraints: Vec = vec![Constraint::Fill(1)]; // Worker Health + if has_node_panel { + constraints.push(Constraint::Fill(1)); + } + constraints.push(Constraint::Fill(1)); // Throughput + constraints.push(Constraint::Fill(1)); // Request Stats + + let rows = Layout::vertical(constraints).split(area); + let mut i = 0; + render_worker_health(f, &state, rows[i]); + i += 1; + if has_node_panel { + render_node_status(f, &state, rows[i]); + i += 1; + } + render_throughput_compact(f, &state, rows[i]); + i += 1; + render_request_stats(f, &state, rows[i]); + return; + } + + let columns = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); + + // Left column: Worker Health + Node/GPU (if available) + let left = if has_node_panel { + Layout::vertical([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).split(columns[0]) + } else { + Layout::vertical([Constraint::Fill(1)]).split(columns[0]) + }; + + // Right column: Throughput sparkline + Request Stats + let right = + Layout::vertical([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).split(columns[1]); + + // Left panels + render_worker_health(f, &state, left[0]); + if has_node_panel { + render_node_status(f, &state, left[1]); + } + + // Right panels + if width < 100 { + render_throughput_compact(f, &state, right[0]); + } else { + render_throughput(f, &state, right[0]); + } + render_request_stats(f, &state, right[1]); +} + +fn render_worker_health(f: &mut Frame, state: &crate::state::GatewayState, area: Rect) { + let block = theme::panel(" WORKER HEALTH "); + + let lines = if let Some(ref w) = state.workers { + // Group workers: external by provider name, local by model name + let mut groups: Vec<(String, Vec<&crate::client::WorkerInfo>)> = Vec::new(); + + for worker in &w.workers { + let group_name = if worker.runtime_type == "external" { + // Use provider name from URL (e.g. "OpenAI" from "api.openai.com") + let host = worker + .url + .trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or(""); + if host.contains("openai") { + "OpenAI".to_string() + } else if host.contains("anthropic") { + "Anthropic".to_string() + } else if host.contains("x.ai") { + "xAI".to_string() + } else if host.contains("googleapis") { + "Gemini".to_string() + } else { + format!("External ({host})") + } + } else { + // Local: group by first model name + worker + .models + .first() + .map(|m| m.id.clone()) + .unwrap_or_else(|| "No model".to_string()) + }; + + if let Some(group) = groups.iter_mut().find(|(name, _)| name == &group_name) { + group.1.push(worker); + } else { + groups.push((group_name, vec![worker])); + } + } + + let mut lines: Vec = Vec::new(); + for (group_name, workers) in groups { + // Group header + lines.push(Line::from(Span::styled( + group_name, + ratatui::style::Style::default() + .fg(theme::ACCENT) + .add_modifier(ratatui::style::Modifier::BOLD), + ))); + + // Worker details indented + for worker in workers { + let (dot_color, status) = if worker.is_healthy { + (theme::GREEN, "healthy") + } else { + (theme::RED, "unhealthy") + }; + let status_style = ratatui::style::Style::default() + .fg(dot_color) + .add_modifier(ratatui::style::Modifier::BOLD); + + let host = worker + .url + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_start_matches("grpc://") + .trim_end_matches('/') + .split('/') + .next() + .unwrap_or(&worker.url); + + let rt = if worker.runtime_type.is_empty() { + "unknown" + } else { + &worker.runtime_type + }; + let conn = if worker.connection_mode.is_empty() { + "" + } else { + &worker.connection_mode + }; + + lines.push(Line::from(vec![ + Span::styled(" ● ", ratatui::style::Style::default().fg(dot_color)), + Span::styled(format!("{host} "), theme::text()), + Span::styled(format!("{rt} "), theme::label()), + if conn.is_empty() { + Span::raw("") + } else { + Span::styled(format!("{conn} "), theme::label()) + }, + Span::styled(status, status_style), + ])); + } + } + if lines.is_empty() { + vec![Line::styled("No workers", theme::label())] + } else { + lines + } + } else { + vec![Line::styled("No data", theme::label())] + }; + + f.render_widget(Paragraph::new(lines).block(block), area); +} + +/// Renders either GPU status (single-node) or cluster info (multi-node). +fn render_node_status(f: &mut Frame, state: &crate::state::GatewayState, area: Rect) { + if let Some(ref gpus) = state.gpus { + render_gpu_status(f, gpus, area); + } else if let Some(ref c) = state.cluster { + render_cluster(f, c, area); + } +} + +fn render_gpu_status(f: &mut Frame, gpus: &[crate::state::GpuInfo], area: Rect) { + let block = theme::panel(" GPUs "); + let inner = block.inner(area); + f.render_widget(block, area); + + let lines: Vec = gpus + .iter() + .map(|gpu| { + let mem_ratio = if gpu.memory_total_mb > 0 { + gpu.memory_used_mb as f64 / gpu.memory_total_mb as f64 + } else { + 0.0 + }; + let util_ratio = gpu.utilization_pct as f64 / 100.0; + + let color = theme::severity(util_ratio); + let bar_width = (inner.width / 4).max(6) as usize; + let (filled, empty, _) = sparkline::gauge_bar(mem_ratio, bar_width); + + let mem_gb_used = gpu.memory_used_mb as f64 / 1024.0; + let mem_gb_total = gpu.memory_total_mb as f64 / 1024.0; + + // Shorten GPU name (e.g. "NVIDIA A100-SXM4-80GB" → "A100-80GB") + let short_name = shorten_gpu_name(&gpu.name); + + Line::from(vec![ + Span::styled(format!("GPU{} ", gpu.index), theme::label()), + Span::styled(format!("{short_name:<12} "), theme::text()), + Span::styled(filled, ratatui::style::Style::default().fg(color)), + Span::styled( + empty, + ratatui::style::Style::default().fg(theme::TEXT_MUTED), + ), + Span::styled( + format!(" {mem_gb_used:.1}/{mem_gb_total:.0}G "), + theme::label(), + ), + Span::styled( + format!("{}% ", gpu.utilization_pct), + ratatui::style::Style::default().fg(color), + ), + Span::styled(format!("{}°C", gpu.temperature_c), theme::label()), + ]) + }) + .collect(); + + f.render_widget(Paragraph::new(lines), inner); +} + +fn shorten_gpu_name(name: &str) -> String { + // Strip common prefixes + let name = name + .trim_start_matches("NVIDIA ") + .trim_start_matches("Tesla ") + .trim_start_matches("GeForce "); + // Truncate if too long (char-safe) + if name.chars().count() > 12 { + name.chars().take(12).collect() + } else { + name.to_string() + } +} + +fn render_cluster(f: &mut Frame, c: &crate::client::ClusterStatusResponse, area: Rect) { + let block = theme::panel(" CLUSTER "); + + let mut lines = vec![ + Line::from(vec![ + Span::styled("Node: ", theme::label()), + Span::styled(c.node_name.as_deref().unwrap_or("unknown"), theme::text()), + ]), + Line::from(vec![ + Span::styled("Size: ", theme::label()), + Span::styled( + c.cluster_size + .map(|s| s.to_string()) + .unwrap_or_else(|| "-".into()), + theme::text(), + ), + ]), + ]; + + if let Some(ref stores) = c.stores { + lines.push(Line::from("")); + lines.push(Line::styled("Stores:", theme::label())); + for store in stores { + let color = if store.healthy { + theme::GREEN + } else { + theme::RED + }; + lines.push(Line::from(vec![ + Span::styled(" ● ", ratatui::style::Style::default().fg(color)), + Span::styled(&store.name, theme::text()), + ])); + } + } + + f.render_widget(Paragraph::new(lines).block(block), area); +} + +fn render_throughput(f: &mut Frame, state: &crate::state::GatewayState, area: Rect) { + let block = theme::panel(" THROUGHPUT "); + let inner = block.inner(area); + f.render_widget(block, area); + + if state.throughput_history.is_empty() && state.requests_per_sec_history.is_empty() { + f.render_widget( + Paragraph::new(Line::styled("No data", theme::label())), + inner, + ); + return; + } + + // Header: latest values + let rps = state + .requests_per_sec_history + .back() + .copied() + .unwrap_or(0.0); + let in_tps = state.input_tps_history.back().copied().unwrap_or(0.0); + let out_tps = state.output_tps_history.back().copied().unwrap_or(0.0); + let total_tps = in_tps + out_tps; + + let mut header_spans = vec![ + Span::styled("req/s: ", theme::label()), + Span::styled(format!("{rps:.1}"), theme::text().fg(theme::GREEN)), + ]; + if total_tps > 0.0 { + header_spans.extend([ + Span::styled(" tok/s: ", theme::label()), + Span::styled(format!("{total_tps:.0}"), theme::text().fg(theme::GREEN)), + Span::styled( + format!(" (in:{in_tps:.0} out:{out_tps:.0})"), + theme::label(), + ), + ]); + } + + let header_area = Rect { + x: inner.x, + y: inner.y, + width: inner.width, + height: 1, + }; + f.render_widget(Paragraph::new(Line::from(header_spans)), header_area); + let header_height = 1u16; + + // Sparkline area (total tok/s) + let sparkline_start = inner.y + header_height; + let remaining = inner.height.saturating_sub(header_height + 1); + if remaining > 0 { + let sparkline_area = Rect { + x: inner.x, + y: sparkline_start, + width: inner.width, + height: remaining, + }; + sparkline::render_sparkline(f, &state.throughput_history, theme::GREEN, sparkline_area); + } + + // Time labels + if inner.height >= header_height + 2 { + let label_area = Rect { + x: inner.x, + y: inner.y + inner.height.saturating_sub(1), + width: inner.width, + height: 1, + }; + let padding = " ".repeat(label_area.width.saturating_sub(7) as usize); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("-60s", theme::label()), + Span::raw(padding), + Span::styled("now", theme::label()), + ])), + label_area, + ); + } +} + +fn render_throughput_compact(f: &mut Frame, state: &crate::state::GatewayState, area: Rect) { + let block = theme::panel(" THROUGHPUT "); + let inner = block.inner(area); + f.render_widget(block, area); + + let rps = state + .requests_per_sec_history + .back() + .copied() + .unwrap_or(0.0); + if state.requests_per_sec_history.is_empty() && state.throughput_history.is_empty() { + f.render_widget( + Paragraph::new(Line::styled("No data", theme::label())), + inner, + ); + return; + } + + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Latest: ", theme::label()), + Span::styled(format!("{rps:.1} req/s"), theme::text().fg(theme::GREEN)), + ])), + inner, + ); +} + +fn render_request_stats(f: &mut Frame, state: &crate::state::GatewayState, area: Rect) { + let block = theme::panel(" REQUEST STATS "); + let inner = block.inner(area); + f.render_widget(block, area); + + let avg_latency = state.avg_latency_history.back().copied().unwrap_or(0.0); + let connections = state.active_connections; + let inflight = state.inflight_requests; + + // Format latency nicely + let latency_str = if avg_latency >= 1.0 { + format!("{avg_latency:.2}s") + } else { + format!("{:.0}ms", avg_latency * 1000.0) + }; + let latency_color = if avg_latency > 5.0 { + theme::RED + } else if avg_latency > 1.0 { + theme::YELLOW + } else { + theme::GREEN + }; + + let mut lines = vec![ + Line::from(vec![ + Span::styled("Avg Latency ", theme::label()), + Span::styled( + &latency_str, + ratatui::style::Style::default().fg(latency_color), + ), + ]), + Line::from(vec![ + Span::styled("Connections ", theme::label()), + Span::styled(connections.to_string(), theme::text()), + ]), + Line::from(vec![ + Span::styled("In-flight ", theme::label()), + Span::styled(inflight.to_string(), theme::text()), + ]), + ]; + + // Latency sparkline if we have history + if state.avg_latency_history.len() > 1 && inner.height > 5 { + lines.push(Line::from("")); + let sparkline_area = Rect { + x: inner.x, + y: inner.y + 4, + width: inner.width, + height: inner.height.saturating_sub(5), + }; + f.render_widget(Paragraph::new(lines), inner); + sparkline::render_sparkline(f, &state.avg_latency_history, theme::YELLOW, sparkline_area); + + // Time label + if inner.height > 5 { + let label_area = Rect { + x: inner.x, + y: inner.y + inner.height.saturating_sub(1), + width: inner.width, + height: 1, + }; + let padding = " ".repeat(label_area.width.saturating_sub(7) as usize); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("-60s", theme::label()), + Span::raw(padding), + Span::styled("now", theme::label()), + ])), + label_area, + ); + } + } else { + f.render_widget(Paragraph::new(lines), inner); + } +} diff --git a/tui/src/ui/sparkline.rs b/tui/src/ui/sparkline.rs new file mode 100644 index 0000000000..8dc22ee0ce --- /dev/null +++ b/tui/src/ui/sparkline.rs @@ -0,0 +1,52 @@ +use std::collections::VecDeque; + +use ratatui::{ + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +/// Unicode block characters for 8-level sparkline. +const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + +/// Render a sparkline from a VecDeque of f64 values. +/// Scales values to min/max of the buffer and maps to 8 Unicode block levels. +pub fn render_sparkline(f: &mut Frame, data: &VecDeque, color: Color, area: Rect) { + if data.is_empty() || area.width == 0 { + return; + } + + let min = data.iter().copied().fold(f64::INFINITY, f64::min); + let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let range = max - min; + + let width = area.width as usize; + let start = data.len().saturating_sub(width); + let chars: String = data + .iter() + .skip(start) + .map(|&v| { + let idx = if range <= f64::EPSILON { + 3 + } else { + ((v - min) / range * 7.0).round() as usize + }; + BLOCKS[idx.min(7)] + }) + .collect(); + + let line = Line::from(Span::styled(chars, Style::default().fg(color))); + f.render_widget(Paragraph::new(line), area); +} + +/// Build a gauge bar string: filled portion + empty portion. +/// Returns (filled_str, empty_str, percentage). +pub fn gauge_bar(ratio: f64, width: usize) -> (String, String, u16) { + let ratio = ratio.clamp(0.0, 1.0); + let pct = (ratio * 100.0).round() as u16; + let filled = ((ratio * width as f64).round() as usize).min(width); + let empty = width.saturating_sub(filled); + ("▓".repeat(filled), "░".repeat(empty), pct) +} diff --git a/tui/src/ui/stats_bar.rs b/tui/src/ui/stats_bar.rs new file mode 100644 index 0000000000..4d0cf548e4 --- /dev/null +++ b/tui/src/ui/stats_bar.rs @@ -0,0 +1,299 @@ +use ratatui::{ + layout::{Alignment, Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use super::{sparkline, theme}; +use crate::state::GatewayState; + +pub fn render_stats_bar(f: &mut Frame, state: &GatewayState, area: Rect) { + let bg = Style::default().bg(theme::STATS_BG); + f.render_widget(ratatui::widgets::Block::default().style(bg), area); + + // Row 0: logo (left) + connection status (right) — 1 line + // Row 1–3: stats cards — 3 lines + // Row 4: separator line — 1 line + let rows = Layout::vertical([ + Constraint::Length(1), // logo row + Constraint::Length(3), // stats cards + Constraint::Length(1), // separator + ]) + .split(area); + + render_logo_row(f, state, rows[0], bg); + render_stats_cards(f, state, rows[1], bg); + render_separator(f, rows[2]); +} + +fn render_logo_row(f: &mut Frame, state: &GatewayState, area: Rect, bg: Style) { + let cols = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).split(area); + + // Left: logo + let logo = Line::from(vec![ + Span::styled("⎔ ", theme::label()), + Span::styled("SMG", theme::title()), + ]); + f.render_widget(Paragraph::new(logo).style(bg), cols[0]); + + // Right: connection status + let conn = if state.connected { + Line::from(Span::styled( + "● connected", + Style::default().fg(theme::GREEN), + )) + } else { + Line::from(Span::styled( + "● disconnected", + Style::default().fg(theme::RED), + )) + }; + f.render_widget( + Paragraph::new(conn).alignment(Alignment::Right).style(bg), + cols[1], + ); +} + +fn render_stats_cards(f: &mut Frame, state: &GatewayState, area: Rect, bg: Style) { + let cols = Layout::horizontal([ + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + ]) + .split(area); + + // Card 1: Workers + let (total, healthy) = state + .workers + .as_ref() + .map(|w| { + let h = w.workers.iter().filter(|w| w.is_healthy).count(); + (w.total, h) + }) + .unwrap_or((0, 0)); + + let unhealthy = total.saturating_sub(healthy); + let health_text = if !state.connected { + ("--".to_string(), theme::TEXT_MUTED) + } else if total == 0 { + ("no workers".to_string(), theme::TEXT_MUTED) + } else if unhealthy == 0 { + ("all healthy".to_string(), theme::GREEN) + } else { + (format!("{unhealthy} unhealthy"), theme::RED) + }; + + let workers_value = if state.connected { + total.to_string() + } else { + "--".to_string() + }; + + render_card( + f, + cols[0], + bg, + "WORKERS", + &workers_value, + Some((&health_text.0, health_text.1)), + ); + + // Card 2: Circuit Breakers + let cb = &state.circuit_breakers; + let cb_total = cb.closed + cb.open; + let cb_value = if state.connected { + cb_total.to_string() + } else { + "--".to_string() + }; + + let cb_detail = if !state.connected { + ("--".to_string(), theme::TEXT_MUTED) + } else if cb.open > 0 { + (format!("{} open", cb.open), theme::RED) + } else if cb.closed > 0 { + ("all closed".to_string(), theme::GREEN) + } else { + ("--".to_string(), theme::TEXT_MUTED) + }; + + render_card( + f, + cols[1], + bg, + "CIRCUIT BREAKERS", + &cb_value, + Some((&cb_detail.0, cb_detail.1)), + ); + + // Card 3: REQ/S + let rps_value = if state.connected { + let rps = state + .requests_per_sec_history + .back() + .copied() + .unwrap_or(0.0); + format!("{rps:.1}") + } else { + "--".to_string() + }; + let inflight = if state.connected { + format!("{} in-flight", state.inflight_requests) + } else { + "--".to_string() + }; + render_card( + f, + cols[2], + bg, + "REQ/S", + &rps_value, + Some((&inflight, theme::TEXT_MUTED)), + ); + + // Card 4: AVG LATENCY with gauge bar + let avg_latency = state.avg_latency_history.back().copied().unwrap_or(0.0); + let (latency_value, latency_color) = if state.connected { + let latency_str = if avg_latency >= 1.0 { + format!("{avg_latency:.2}s") + } else { + format!("{:.0}ms", avg_latency * 1000.0) + }; + let color = if avg_latency > 5.0 { + theme::RED + } else if avg_latency > 1.0 { + theme::YELLOW + } else { + theme::GREEN + }; + (latency_str, color) + } else { + ("--".to_string(), theme::TEXT_MUTED) + }; + + render_latency_card( + f, + cols[3], + bg, + &latency_value, + latency_color, + avg_latency, + state.connected, + ); +} + +fn render_card( + f: &mut Frame, + area: Rect, + bg: Style, + label: &str, + value: &str, + detail: Option<(&str, ratatui::style::Color)>, +) { + // 3 rows: label, big number, detail + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + + // Label (centered, muted) + f.render_widget( + Paragraph::new(Line::from(Span::styled(label, theme::label()))) + .alignment(Alignment::Center) + .style(bg), + rows[0], + ); + + // Big number (centered, bold) + f.render_widget( + Paragraph::new(Line::from(Span::styled( + value, + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + ))) + .alignment(Alignment::Center) + .style(bg), + rows[1], + ); + + // Detail line (centered) + if let Some((text, color)) = detail { + f.render_widget( + Paragraph::new(Line::from(Span::styled(text, Style::default().fg(color)))) + .alignment(Alignment::Center) + .style(bg), + rows[2], + ); + } +} + +fn render_latency_card( + f: &mut Frame, + area: Rect, + bg: Style, + latency_value: &str, + latency_color: ratatui::style::Color, + avg_latency_secs: f64, + connected: bool, +) { + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + + // Label + f.render_widget( + Paragraph::new(Line::from(Span::styled("AVG LATENCY", theme::label()))) + .alignment(Alignment::Center) + .style(bg), + rows[0], + ); + + // Big value + f.render_widget( + Paragraph::new(Line::from(Span::styled( + latency_value, + Style::default() + .fg(latency_color) + .add_modifier(Modifier::BOLD), + ))) + .alignment(Alignment::Center) + .style(bg), + rows[1], + ); + + // Gauge bar (0-5s scale) + if connected { + let ratio = (avg_latency_secs / 5.0).clamp(0.0, 1.0); + let bar_width = (area.width / 3).max(6) as usize; + let (filled, empty, _pct) = sparkline::gauge_bar(ratio, bar_width); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(filled, Style::default().fg(latency_color)), + Span::styled(empty, Style::default().fg(theme::TEXT_MUTED)), + ])) + .alignment(Alignment::Center) + .style(bg), + rows[2], + ); + } +} + +fn render_separator(f: &mut Frame, area: Rect) { + let line = "─".repeat(area.width as usize); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + line, + Style::default().fg(theme::BORDER), + ))), + area, + ); +} diff --git a/tui/src/ui/tabs.rs b/tui/src/ui/tabs.rs new file mode 100644 index 0000000000..e4e89fe8f2 --- /dev/null +++ b/tui/src/ui/tabs.rs @@ -0,0 +1,33 @@ +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use super::theme; +use crate::types::View; + +pub fn render_tabs(f: &mut Frame, active: View, area: Rect) { + let bg = Style::default().bg(theme::PANEL_BG); + let tabs: Vec = View::all() + .iter() + .flat_map(|view| { + let num = format!("{}", view.index()); + let label = view.label(); + let style = if *view == active { + Style::default() + .fg(theme::ACCENT) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) + } else { + Style::default().fg(theme::TEXT_MUTED) + }; + vec![ + Span::styled(format!("{num}:{label}"), style), + Span::raw(" "), + ] + }) + .collect(); + f.render_widget(Paragraph::new(Line::from(tabs)).style(bg), area); +} diff --git a/tui/src/ui/theme.rs b/tui/src/ui/theme.rs new file mode 100644 index 0000000000..98a8987329 --- /dev/null +++ b/tui/src/ui/theme.rs @@ -0,0 +1,50 @@ +use ratatui::style::{Color, Modifier, Style}; + +// Terminal-native ANSI 16 colors — adapts to user's terminal theme. +pub const BG: Color = Color::Reset; // terminal default background +pub const PANEL_BG: Color = Color::Reset; // same as terminal +pub const STATS_BG: Color = Color::Reset; // same as terminal +pub const BORDER: Color = Color::DarkGray; +pub const TEXT: Color = Color::Reset; // terminal default foreground +pub const TEXT_MUTED: Color = Color::DarkGray; +pub const ACCENT: Color = Color::Blue; +pub const GREEN: Color = Color::Green; +pub const YELLOW: Color = Color::Yellow; +pub const RED: Color = Color::Red; +pub const PURPLE: Color = Color::Magenta; + +/// Style for panel titles (accent + bold). +pub fn title() -> Style { + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD) +} + +/// Style for section labels (muted). +pub fn label() -> Style { + Style::default().fg(TEXT_MUTED) +} + +/// Style for primary text. +pub fn text() -> Style { + Style::default().fg(TEXT) +} + +/// Severity color for a 0.0–1.0 ratio. +pub fn severity(ratio: f64) -> Color { + if ratio < 0.5 { + GREEN + } else if ratio < 0.8 { + YELLOW + } else { + RED + } +} + +/// Standard panel block with border. +pub fn panel(title: &str) -> ratatui::widgets::Block<'_> { + ratatui::widgets::Block::default() + .title(title) + .title_style(self::title()) + .borders(ratatui::widgets::Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG)) +} diff --git a/tui/src/ui/workers.rs b/tui/src/ui/workers.rs new file mode 100644 index 0000000000..7fe3e4fe10 --- /dev/null +++ b/tui/src/ui/workers.rs @@ -0,0 +1,356 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + widgets::{Cell, Row, Table, TableState}, + Frame, +}; + +use super::{detail, theme}; +use crate::{app::App, client::WorkerInfo}; + +pub fn render_workers(f: &mut Frame, app: &App, area: Rect) { + let block = theme::panel(" Workers "); + + // Split layout when detail panel is visible + let (table_area, detail_area) = if app.show_detail { + let split = + Layout::vertical([Constraint::Percentage(60), Constraint::Percentage(40)]).split(area); + (split[0], Some(split[1])) + } else { + (area, None) + }; + + // Safety: RwLock is not poisoned — no panics while holding the lock + #[expect(clippy::unwrap_used)] + let state = app.state.read().unwrap(); + + let width = table_area.width; + + let filtered: Vec = if let Some(ref wl) = state.workers { + wl.workers + .iter() + .filter(|w| matches_filter(w, app.active_filter.as_ref())) + .cloned() + .collect() + } else { + vec![] + }; + + // Build per-worker load lookup from /get_loads + let worker_loads: std::collections::HashMap = state + .loads + .as_ref() + .map(|l| l.workers.iter().map(|wl| (wl.worker.clone(), wl)).collect()) + .unwrap_or_default(); + let worker_rps = &state.worker_rps; + + // Build rows and table based on terminal width + let (header, rows, widths): (Row, Vec, Vec) = if width < 80 { + // Narrow: ID, Health, Running (3 columns) + let header_cells = ["ID", "Health", "Running"].iter().map(|h| { + Cell::from(*h).style( + Style::default() + .fg(theme::ACCENT) + .bg(theme::PANEL_BG) + .add_modifier(Modifier::BOLD), + ) + }); + let header = Row::new(header_cells).height(1); + + let rows: Vec = filtered + .iter() + .map(|w| { + let health_style = if w.is_healthy { + Style::default().fg(theme::GREEN) + } else { + Style::default().fg(theme::RED) + }; + let health_text = if w.is_healthy { "healthy" } else { "unhealthy" }; + + let (running, _) = get_worker_load_info(&worker_loads, worker_rps, &w.url); + Row::new(vec![ + Cell::from(truncate(&w.id, 12)).style(Style::default().fg(theme::TEXT)), + Cell::from(health_text).style(health_style), + Cell::from(running).style(Style::default().fg(theme::TEXT)), + ]) + .style(Style::default().bg(theme::BG)) + }) + .collect(); + + let widths = vec![ + Constraint::Fill(1), + Constraint::Length(10), + Constraint::Length(8), + ]; + + (header, rows, widths) + } else if width < 100 { + // Compact: ID, URL, Health, Running (4 columns) + let header_cells = ["ID", "URL", "Health", "Running"].iter().map(|h| { + Cell::from(*h).style( + Style::default() + .fg(theme::ACCENT) + .bg(theme::PANEL_BG) + .add_modifier(Modifier::BOLD), + ) + }); + let header = Row::new(header_cells).height(1); + + let rows: Vec = filtered + .iter() + .map(|w| { + let health_style = if w.is_healthy { + Style::default().fg(theme::GREEN) + } else { + Style::default().fg(theme::RED) + }; + let health_text = if w.is_healthy { "healthy" } else { "unhealthy" }; + + let (running, _) = get_worker_load_info(&worker_loads, worker_rps, &w.url); + Row::new(vec![ + Cell::from(truncate(&w.id, 12)).style(Style::default().fg(theme::TEXT)), + Cell::from(shorten_url(&w.url)).style(Style::default().fg(theme::TEXT)), + Cell::from(health_text).style(health_style), + Cell::from(running).style(Style::default().fg(theme::TEXT)), + ]) + .style(Style::default().bg(theme::BG)) + }) + .collect(); + + let widths = vec![ + Constraint::Length(14), + Constraint::Fill(1), + Constraint::Length(10), + Constraint::Length(8), + ]; + + (header, rows, widths) + } else if width < 120 { + // Medium: ID, URL, Runtime, Health, Running, Token Usage (6 columns) + let header_cells = ["ID", "URL", "Runtime", "Health", "Running", "Tok Usage"] + .iter() + .map(|h| { + Cell::from(*h).style( + Style::default() + .fg(theme::ACCENT) + .bg(theme::PANEL_BG) + .add_modifier(Modifier::BOLD), + ) + }); + let header = Row::new(header_cells).height(1); + + let rows: Vec = filtered + .iter() + .map(|w| { + let health_style = if w.is_healthy { + Style::default().fg(theme::GREEN) + } else { + Style::default().fg(theme::RED) + }; + let health_text = if w.is_healthy { "healthy" } else { "unhealthy" }; + + let (running, token_usage) = + get_worker_load_info(&worker_loads, worker_rps, &w.url); + + Row::new(vec![ + Cell::from(truncate(&w.id, 12)).style(Style::default().fg(theme::TEXT)), + Cell::from(shorten_url(&w.url)).style(Style::default().fg(theme::TEXT)), + Cell::from(w.runtime_type.as_str()) + .style(Style::default().fg(theme::TEXT_MUTED)), + Cell::from(health_text).style(health_style), + Cell::from(running).style(Style::default().fg(theme::TEXT)), + Cell::from(token_usage).style(Style::default().fg(theme::TEXT)), + ]) + .style(Style::default().bg(theme::BG)) + }) + .collect(); + + let widths = vec![ + Constraint::Length(14), + Constraint::Fill(1), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + ]; + + (header, rows, widths) + } else { + // Full: all 8 columns + let header_cells = [ + "ID", + "URL", + "Mode", + "Runtime", + "Models", + "Health", + "Running", + "Tok Usage", + ] + .iter() + .map(|h| { + Cell::from(*h).style( + Style::default() + .fg(theme::ACCENT) + .bg(theme::PANEL_BG) + .add_modifier(Modifier::BOLD), + ) + }); + let header = Row::new(header_cells).height(1); + + let rows: Vec = filtered + .iter() + .map(|w| { + let health_style = if w.is_healthy { + Style::default().fg(theme::GREEN) + } else { + Style::default().fg(theme::RED) + }; + let health_text = if w.is_healthy { "healthy" } else { "unhealthy" }; + + let model_names: String = w + .models + .iter() + .map(|m| m.id.as_str()) + .collect::>() + .join(", "); + let models_display = if model_names.is_empty() { + "*".to_string() + } else { + model_names + }; + + let (running, token_usage) = + get_worker_load_info(&worker_loads, worker_rps, &w.url); + + Row::new(vec![ + Cell::from(truncate(&w.id, 12)).style(Style::default().fg(theme::TEXT)), + Cell::from(shorten_url(&w.url)).style(Style::default().fg(theme::TEXT)), + Cell::from(w.connection_mode.as_str()) + .style(Style::default().fg(theme::TEXT_MUTED)), + Cell::from(w.runtime_type.as_str()) + .style(Style::default().fg(theme::TEXT_MUTED)), + Cell::from(truncate(&models_display, 20)) + .style(Style::default().fg(theme::TEXT)), + Cell::from(health_text).style(health_style), + Cell::from(running).style(Style::default().fg(theme::TEXT)), + Cell::from(token_usage).style(Style::default().fg(theme::TEXT)), + ]) + .style(Style::default().bg(theme::BG)) + }) + .collect(); + + let widths = vec![ + Constraint::Length(14), + Constraint::Length(22), + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(22), + Constraint::Length(10), + Constraint::Length(8), + Constraint::Length(10), + ]; + + (header, rows, widths) + }; + + let row_count = rows.len(); + + let table = Table::new(rows, widths) + .header(header) + .block(block) + .row_highlight_style( + Style::default() + .fg(Color::Black) + .bg(theme::ACCENT) + .add_modifier(Modifier::BOLD), + ); + + // Build TableState from app's selected_index + let mut table_state = TableState::default(); + if row_count > 0 { + table_state.select(Some(app.selected_index.min(row_count.saturating_sub(1)))); + } + + f.render_stateful_widget(table, table_area, &mut table_state); + + // Drop state before calling detail render (which re-acquires it) + drop(state); + + if let Some(detail_area) = detail_area { + let clamped = if row_count > 0 { + app.selected_index.min(row_count.saturating_sub(1)) + } else { + 0 + }; + if let Some(worker) = filtered.get(clamped) { + detail::render_detail(f, app, worker, detail_area); + } + } +} + +fn matches_filter(worker: &WorkerInfo, filter: Option<&String>) -> bool { + let Some(f) = filter else { return true }; + if f.is_empty() { + return true; + } + let f_lower = f.to_lowercase(); + worker.id.to_lowercase().contains(&f_lower) + || worker.url.to_lowercase().contains(&f_lower) + || worker.worker_type.to_lowercase().contains(&f_lower) + || worker.runtime_type.to_lowercase().contains(&f_lower) +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let prefix: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{prefix}…") + } +} + +/// Shorten a URL: strip scheme, keep host:port +fn shorten_url(url: &str) -> String { + url.trim_start_matches("https://") + .trim_start_matches("http://") + .trim_start_matches("grpc://") + .trim_end_matches('/') + .split('/') + .next() + .unwrap_or(url) + .to_string() +} + +/// Get running reqs and token usage for a worker from /get_loads data. +/// Returns (running_reqs_str, token_usage_str). +fn get_worker_load_info( + loads: &std::collections::HashMap, + worker_rps: &std::collections::HashMap, + worker_url: &str, +) -> (String, String) { + // HTTP sglang/vllm workers: aggregate across all loads (TP>1 workers have multiple entries) + if let Some(wl) = loads.get(worker_url) { + if let Some(ref details) = wl.details { + if !details.loads.is_empty() { + let total_running: i32 = details.loads.iter().map(|l| l.num_running_reqs).sum(); + let avg_usage: f64 = details.loads.iter().map(|l| l.token_usage).sum::() + / details.loads.len() as f64; + let running = format!("{total_running}"); + let usage = format!("{:.1}%", avg_usage * 100.0); + return (running, usage); + } + } + } + // gRPC/local workers: show req/s from Prometheus per-worker counts + if worker_url.starts_with("grpc://") { + let rps = worker_rps.get(worker_url).copied().unwrap_or(0.0); + return (format!("{rps:.1} r/s"), "N/A".to_string()); + } + // External workers + if worker_url.starts_with("https://") { + let rps = worker_rps.get(worker_url).copied().unwrap_or(0.0); + return (format!("{rps:.1} r/s"), "N/A".to_string()); + } + ("0".to_string(), "0.0%".to_string()) +}