diff --git a/.github/workflows/pr-test-rust.yml b/.github/workflows/pr-test-rust.yml index 867ea59def..f4dec02fd8 100644 --- a/.github/workflows/pr-test-rust.yml +++ b/.github/workflows/pr-test-rust.yml @@ -464,7 +464,6 @@ jobs: - 'bindings/golang/src/**' - 'clients/rust/**' - 'clients/openapi-gen/**' - - 'tui/**' - '.cargo/**' - '.github/actions/setup-rust/**' - 'scripts/ci_install_rust.sh' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9813a98081..465548021c 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,ratatui,nin,Nin,doubleclick'] + args: ['-L', 'cann,thi,makro,wil,rouge,PRIS,hel,te,ans,ser,wit,WIT,implementors,caf,nin,Nin,doubleclick'] exclude: | (?x)^( src/proto/.*\.proto$| diff --git a/Cargo.toml b/Cargo.toml index 74162fda54..ca4d2c9e02 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", "tui", "crates/mock_worker"] +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", "crates/mock_worker"] resolver = "2" [workspace.dependencies] @@ -17,7 +17,6 @@ llm-multimodal = { version = "1.6.0", path = "crates/multimodal" } smg-wasm = { version = "1.1.1", path = "crates/wasm", package = "smg-wasm" } smg-mesh = { version = "1.4.0", path = "crates/mesh", package = "smg-mesh" } smg-grpc-client = { version = "1.6.0", path = "crates/grpc_client" } -smg-tui = { version = "0.1.0", path = "tui" } # Shared dependencies anyhow = "1.0" diff --git a/docs/reference/metrics.md b/docs/reference/metrics.md index d317a21bb3..0871ec7f3b 100644 --- a/docs/reference/metrics.md +++ b/docs/reference/metrics.md @@ -17,7 +17,7 @@ curl http://localhost:29000/metrics ``` The same listener also serves a WebSocket stream of real-time metric updates -at `/ws/metrics` (used by the TUI and dashboards that need live state). +at `/ws/metrics` (used by dashboards that need live state). Configure via CLI: diff --git a/tui/Cargo.toml b/tui/Cargo.toml deleted file mode 100644 index fdcba64f47..0000000000 --- a/tui/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[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.30" -crossterm = { version = "0.29", features = ["event-stream"] } -clap = { version = "4", features = ["derive", "env"] } - -[lints] -workspace = true diff --git a/tui/README.md b/tui/README.md deleted file mode 100644 index 61512793e3..0000000000 --- a/tui/README.md +++ /dev/null @@ -1,274 +0,0 @@ -# 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 deleted file mode 100644 index 5e148d6f4c..0000000000 Binary files a/tui/assets/add-worker.gif and /dev/null differ diff --git a/tui/assets/tui-demo.gif b/tui/assets/tui-demo.gif deleted file mode 100644 index 8ba11ec18e..0000000000 Binary files a/tui/assets/tui-demo.gif and /dev/null differ diff --git a/tui/src/app.rs b/tui/src/app.rs deleted file mode 100644 index f88753dfa7..0000000000 --- a/tui/src/app.rs +++ /dev/null @@ -1,1567 +0,0 @@ -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, - drain_settle_secs: 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, - drain_settle_secs: 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 deleted file mode 100644 index e30670a0f6..0000000000 --- a/tui/src/chat.rs +++ /dev/null @@ -1,243 +0,0 @@ -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 deleted file mode 100644 index 8aa16c96b7..0000000000 --- a/tui/src/client.rs +++ /dev/null @@ -1,302 +0,0 @@ -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 deleted file mode 100644 index 7c9be05c1e..0000000000 --- a/tui/src/event.rs +++ /dev/null @@ -1,71 +0,0 @@ -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 - && 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 deleted file mode 100644 index e24fc8cda5..0000000000 --- a/tui/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 8a2c910734..0000000000 --- a/tui/src/main.rs +++ /dev/null @@ -1,244 +0,0 @@ -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 deleted file mode 100644 index 7eea11429d..0000000000 --- a/tui/src/state.rs +++ /dev/null @@ -1,488 +0,0 @@ -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 deleted file mode 100644 index 8c7169c0f8..0000000000 --- a/tui/src/types.rs +++ /dev/null @@ -1,371 +0,0 @@ -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 deleted file mode 100644 index ff708a5e32..0000000000 --- a/tui/src/ui/action_menu.rs +++ /dev/null @@ -1,396 +0,0 @@ -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 deleted file mode 100644 index ac15409660..0000000000 --- a/tui/src/ui/chat.rs +++ /dev/null @@ -1,341 +0,0 @@ -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 deleted file mode 100644 index 3193fc798b..0000000000 --- a/tui/src/ui/detail.rs +++ /dev/null @@ -1,232 +0,0 @@ -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 deleted file mode 100644 index df1d83472a..0000000000 --- a/tui/src/ui/dialog.rs +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index 4a9439fa0e..0000000000 --- a/tui/src/ui/filter.rs +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 69cd702feb..0000000000 --- a/tui/src/ui/footer.rs +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index b85e7e4209..0000000000 --- a/tui/src/ui/help.rs +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index 830d252f4e..0000000000 --- a/tui/src/ui/logs.rs +++ /dev/null @@ -1,280 +0,0 @@ -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 deleted file mode 100644 index 4629f21358..0000000000 --- a/tui/src/ui/mod.rs +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index e423c1b633..0000000000 --- a/tui/src/ui/models.rs +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index e05549a6ed..0000000000 --- a/tui/src/ui/pulse.rs +++ /dev/null @@ -1,475 +0,0 @@ -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 deleted file mode 100644 index 8dc22ee0ce..0000000000 --- a/tui/src/ui/sparkline.rs +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 4d0cf548e4..0000000000 --- a/tui/src/ui/stats_bar.rs +++ /dev/null @@ -1,299 +0,0 @@ -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 deleted file mode 100644 index e4e89fe8f2..0000000000 --- a/tui/src/ui/tabs.rs +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 98a8987329..0000000000 --- a/tui/src/ui/theme.rs +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 7fe3e4fe10..0000000000 --- a/tui/src/ui/workers.rs +++ /dev/null @@ -1,356 +0,0 @@ -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()) -}