diff --git a/docs/tui-screenshots/01-dashboard.png b/docs/tui-screenshots/01-dashboard.png new file mode 100644 index 0000000..5e5f7f4 Binary files /dev/null and b/docs/tui-screenshots/01-dashboard.png differ diff --git a/docs/tui-screenshots/02-routes.png b/docs/tui-screenshots/02-routes.png new file mode 100644 index 0000000..fa571fc Binary files /dev/null and b/docs/tui-screenshots/02-routes.png differ diff --git a/docs/tui-screenshots/03-apps.png b/docs/tui-screenshots/03-apps.png new file mode 100644 index 0000000..a91d92a Binary files /dev/null and b/docs/tui-screenshots/03-apps.png differ diff --git a/docs/tui-screenshots/04-circuits.png b/docs/tui-screenshots/04-circuits.png new file mode 100644 index 0000000..cd7ac76 Binary files /dev/null and b/docs/tui-screenshots/04-circuits.png differ diff --git a/docs/tui-screenshots/05-errors.png b/docs/tui-screenshots/05-errors.png new file mode 100644 index 0000000..2bc7283 Binary files /dev/null and b/docs/tui-screenshots/05-errors.png differ diff --git a/docs/tui-screenshots/06-config.png b/docs/tui-screenshots/06-config.png new file mode 100644 index 0000000..c20d68a Binary files /dev/null and b/docs/tui-screenshots/06-config.png differ diff --git a/docs/tui-screenshots/07-help.png b/docs/tui-screenshots/07-help.png new file mode 100644 index 0000000..f9126b5 Binary files /dev/null and b/docs/tui-screenshots/07-help.png differ diff --git a/docs/tui-screenshots/README.md b/docs/tui-screenshots/README.md new file mode 100644 index 0000000..4ad8616 --- /dev/null +++ b/docs/tui-screenshots/README.md @@ -0,0 +1,23 @@ +# TUI screenshots + +**These images are mockups, not captures.** They are drawn by +`scripts/tui_screenshots.py` with representative sample data, because the real +TUI needs a live daemon, a terminal and an admin password to render anything. + +The layout, palette and key hints are mirrored from the source by hand: + +| Image element | Source of truth | +| ------------------------------ | -------------------------- | +| palette, sidebar, panel chrome | `src/tui/theme.rs` | +| screen composition | `src/tui/screens/*.rs` | +| footer hints, help overlay | `src/tui/app.rs` | + +If you change any of those, re-run the script and compare the result against the +real TUI: + +```sh +python3 scripts/tui_screenshots.py # needs Pillow +``` + +A mockup that has drifted from the shipped UI is worse than no mockup — if you +cannot keep these current, delete them rather than leaving them stale. diff --git a/scripts/tui_screenshots.py b/scripts/tui_screenshots.py new file mode 100644 index 0000000..a13f494 --- /dev/null +++ b/scripts/tui_screenshots.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +"""Render mockups of the Soli Proxy TUI for the docs. + +These are drawn, not captured: the TUI needs a live daemon, a terminal and a +password prompt, none of which a docs build has. The layout, palette and copy +below are therefore kept in lockstep with the real code by hand: + + palette + sidebar + list_block -> src/tui/theme.rs + screen composition -> src/tui/screens/*.rs + footer and help text -> src/tui/app.rs + +If you change any of those, re-run this script and eyeball the result against +the real thing. A mockup that has drifted from the UI is worse than no mockup. +""" + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +# The TUI is laid out for a wide terminal; 118x34 matches a maximised window. +COLS, ROWS = 118, 34 +CW, CH = 8, 16 +W, H = COLS * CW, ROWS * CH + +# ── palette: mirrors src/tui/theme.rs ────────────────────────────────── +ACCENT = (0, 212, 170) +ACCENT_DIM = (0, 120, 100) +SUCCESS = (80, 250, 123) +WARN = (255, 184, 108) +DANGER = (255, 85, 85) +MUTED = (98, 114, 164) +FG = (248, 248, 242) +SELECT_BG = (15, 55, 52) +SIDEBAR_BG = (18, 22, 28) +INK = (10, 12, 16) +MAGENTA = (189, 147, 249) +CYAN_3XX = (139, 233, 253) + +# ── theme.rs constants ───────────────────────────────────────────────── +SIDEBAR_WIDTH = 16 +NAV_TOP_OFFSET = 3 +SCREEN_SHORT = ["dash", "routes", "apps", "circuits", "errors", "config"] +VERSION = "0.29.2" + +BODY_X = SIDEBAR_WIDTH +BODY_W = COLS - SIDEBAR_WIDTH +BODY_H = ROWS - 1 # last row is the footer +FOOTER_Y = ROWS - 1 + +OUT = Path("docs/tui-screenshots") + +FONT_CANDIDATES = [ + "/System/Library/Fonts/Menlo.ttc", + "/System/Library/Fonts/Monaco.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/TTF/DejaVuSansMono.ttf", + "/Library/Fonts/DejaVuSansMono.ttf", +] + + +def load_font(): + for path in FONT_CANDIDATES: + if Path(path).exists(): + try: + return ImageFont.truetype(path, 12) + except OSError: + continue + raise SystemExit( + "No monospace font found. Add one to FONT_CANDIDATES in this script." + ) + + +FONT = load_font() + + +# ── cell-grid primitives ─────────────────────────────────────────────── + + +def put(img, x, y, text, fg=FG, bg=None): + """Draw `text` starting at character cell (x, y), clipped to the grid.""" + if y < 0 or y >= ROWS or x >= COLS: + return + text = text[: max(0, COLS - x)] + if not text: + return + draw = ImageDraw.Draw(img) + px, py = x * CW, y * CH + if bg: + draw.rectangle([px, py, px + len(text) * CW - 1, py + CH - 1], fill=bg) + draw.text((px, py + 1), text, font=FONT, fill=fg) + + +def fill(img, x, y, w, h, color): + ImageDraw.Draw(img).rectangle( + [x * CW, y * CH, (x + w) * CW - 1, (y + h) * CH - 1], fill=color + ) + + +def list_block(img, x, y, w, h, title): + """theme::list_block — a left rule plus an inverted title chip. + + Returns the content rect (theme::body): one column in, one row down. + """ + for i in range(h): + put(img, x, y + i, "│", ACCENT_DIM) + put(img, x + 1, y, f" {title} ", INK, ACCENT) + return x + 1, y + 1, w - 1, h - 1 + + +def kpi(img, x, y, w, h, value, label, color): + """theme::kpi — left rule, value on the first row, label under it.""" + for i in range(h): + put(img, x, y + i, "│", color) + put(img, x + 2, y, value, color) + put(img, x + 2, y + 1, label, MUTED) + + +def bar(img, x, y, cells, color): + """A solid horizontal bar. Drawn as a rectangle rather than repeated "█" + so it fills the cell the way a terminal does, whatever the font.""" + if cells <= 0: + return + fill(img, x, y, cells, 1, color) + + +def sparkline(img, x, y, w, h, values, color): + """ratatui's Sparkline: one column per sample, bars grown from the bottom.""" + peak = max(values) or 1 + for i, value in enumerate(values[-w:]): + filled = value / peak * h + full = int(filled) + for row in range(full): + fill(img, x + i, y + h - 1 - row, 1, 1, color) + frac = filled - full + if frac > 0.125 and full < h: + # Partial top cell: an eighth-block, approximated by a part-height rect. + top = y + h - 1 - full + px, py = (x + i) * CW, top * CH + height = max(1, round(CH * frac)) + ImageDraw.Draw(img).rectangle( + [px, py + CH - height, px + CW - 1, py + CH - 1], fill=color + ) + + +def pct_split(x, w, *weights): + """Ratatui's Percentage constraints across `w` columns.""" + total = sum(weights) + out, cur = [], x + for i, weight in enumerate(weights): + cw = w - (cur - x) if i == len(weights) - 1 else round(w * weight / total) + out.append((cur, cw)) + cur += cw + return out + + +# ── chrome ───────────────────────────────────────────────────────────── + + +def sidebar(img, active): + fill(img, 0, 0, SIDEBAR_WIDTH, ROWS, SIDEBAR_BG) + put(img, 0, 0, " SOLI", INK, ACCENT) + put(img, 0, 1, " proxy", MUTED, SIDEBAR_BG) + for i, short in enumerate(SCREEN_SHORT): + marker = "▸" if i == active else " " + label = f" {marker} {i + 1} {short:<8}" + if i == active: + put(img, 0, NAV_TOP_OFFSET + i, label, ACCENT, SELECT_BG) + else: + put(img, 0, NAV_TOP_OFFSET + i, label, MUTED, SIDEBAR_BG) + put(img, 0, ROWS - 2, f" v{VERSION}", MUTED, SIDEBAR_BG) + put(img, 0, ROWS - 1, " 1-6 ?", ACCENT_DIM, SIDEBAR_BG) + + +def chrome(active, keys, daemon=("● ", SUCCESS, "daemon")): + img = Image.new("RGB", (W, H), INK) + sidebar(img, active) + put(img, BODY_X, FOOTER_Y, f" {keys} ", MUTED) + glyph, color, name = daemon + badge = f" {name} {glyph}" + put(img, COLS - len(badge) - 1, FOOTER_Y, badge, color) + return img + + +NAV_KEYS = "1-6 nav" + + +# ── screens ──────────────────────────────────────────────────────────── + + +def dashboard(): + img = chrome(0, f"{NAV_KEYS} Tab cycle r ? q") + + # Row 0: four KPI tiles (Length(4)). + tiles = pct_split(BODY_X, BODY_W, 25, 25, 25, 25) + for (x, w), (value, label, color) in zip( + tiles, + [ + ("12.4K", "requests", ACCENT), + ("42", "req / s", SUCCESS), + ("18.2ms", "avg latency", WARN), + ("0.10%", "error rate", DANGER), + ], + ): + kpi(img, x, 0, w, 4, value, label, color) + + # Row 1: live rps sparkline | http status bars (Length(6)). + (sx, sw), (hx, hw) = pct_split(BODY_X, BODY_W, 55, 45) + cx, cy, cw, ch = list_block(img, sx, 4, sw, 6, "live rps 42") + series = [ + 12, 18, 27, 19, 24, 36, 31, 22, 17, 29, 44, 33, 25, 13, 19, 26, 34, 41, + 28, 20, 14, 21, 30, 39, 47, 35, 27, 18, 12, 20, 28, 33, 40, 46, 38, 31, + 24, 17, 11, 19, 26, 32, 39, 34, 27, 21, 15, 42, + ] + sparkline(img, cx, cy, cw, ch, series, ACCENT) + + cx, cy, cw, _ = list_block(img, hx, 4, hw, 6, "http") + bar_w = cw - 16 + for i, (code, count, pct, color) in enumerate( + [ + ("2xx", "12.1K", 0.965, SUCCESS), + ("3xx", "180", 0.014, CYAN_3XX), + ("4xx", "142", 0.011, WARN), + ("5xx", "12", 0.010, DANGER), + ] + ): + put(img, cx, cy + i, code, color) + put(img, cx + 5, cy + i, count, FG) + bar(img, cx + 14, cy + i, round(bar_w * pct), color) + + # Row 2: four meta tiles (Length(6)). + tiles = pct_split(BODY_X, BODY_W, 25, 25, 25, 25) + for (x, w), (value, label, color) in zip( + tiles, + [ + ("2h 14m 8s", "up 0.0.0.0:80 :443", ACCENT), + ("3/4", "apps running", SUCCESS), + ("8", "routes", MAGENTA), + ("5", "circuits", SUCCESS), + ], + ): + kpi(img, x, 10, w, 6, value, label, color) + + # Row 3: apps overview | server detail (Min(6)). + (ax, aw), (vx, vw) = pct_split(BODY_X, BODY_W, 58, 42) + cx, cy, _, _ = list_block(img, ax, 16, aw, BODY_H - 16, "apps") + put(img, cx, cy, "name domain slot status port", MUTED) + apps = [ + ("api", "api.example.com", "blue", "● run", SUCCESS, ":8081"), + ("web", "www.example.com", "green", "● run", SUCCESS, ":8082"), + ("docs", "docs.example.com", "blue", "● run", SUCCESS, ":8083"), + ("legacy", "old.example.com", "blue", "○ stop", MUTED, "-"), + ] + for i, (name, domain, slot, status, color, port) in enumerate(apps): + row = cy + 1 + i + put(img, cx, row, name, FG) + put(img, cx + 12, row, domain, MUTED) + put(img, cx + 34, row, slot, MUTED) + put(img, cx + 43, row, status, color) + put(img, cx + 53, row, port, MUTED) + + cx, cy, _, _ = list_block(img, vx, 16, vw, BODY_H - 16, "server") + server = [ + ("listen", "0.0.0.0:80", FG), + ("https", ":443", FG), + ("tls", "acme", FG), + ("admin", "127.0.0.1:9090", FG), + ("auth", "enabled", FG), + ("scripts", "auth.lua", MAGENTA), + ("in flight", "3", FG), + ("bytes in", "4.10 MB", FG), + ("bytes out", "82.3 MB", FG), + ("tls conns", "1.3K", FG), + ("errors", "12", DANGER), + ] + for i, (key, value, color) in enumerate(server): + put(img, cx, cy + i, key, MUTED) + put(img, cx + 10, cy + i, value, color) + return img + + +def _table(img, x, y, w, h, title, header, rows, selected=0): + cx, cy, cw, _ = list_block(img, x, y, w, h, title) + put(img, cx, cy, header, ACCENT) + for i, cells in enumerate(rows): + row = cy + 1 + i + if i == selected: + fill(img, cx, row, cw, 1, SELECT_BG) + for col, text, color in cells: + put(img, cx + col, row, text, FG if i == selected else color) + return cx, cy, cw + + +def routes(): + img = chrome(1, f"{NAV_KEYS} j/k a add e edit d delete / r ? q") + rows = [ + [(0, "0", MUTED), (4, "api.example.com", FG), (26, "http://127.0.0.1:8081", FG), + (52, "-", MUTED), (60, "auth.lua", MAGENTA), (72, "round_robin", MUTED)], + [(0, "1", MUTED), (4, "www.example.com", FG), (26, "http://127.0.0.1:8082", FG), + (52, "2 users", SUCCESS), (60, "-", MUTED), (72, "least_conn", MUTED)], + [(0, "2", MUTED), (4, "docs.example.com", FG), (26, "http://127.0.0.1:8083", FG), + (52, "-", MUTED), (60, "-", MUTED), (72, "round_robin", MUTED)], + [(0, "3", MUTED), (4, "/api/v2/*", FG), (26, "http://10.0.0.4:9000 +2", FG), + (52, "1 user", SUCCESS), (60, "rate.lua", MAGENTA), (72, "ip_hash", MUTED)], + [(0, "4", MUTED), (4, "^/static/(.*)$", FG), (26, "http://127.0.0.1:8090", FG), + (52, "-", MUTED), (60, "-", MUTED), (72, "round_robin", MUTED)], + ] + _table( + img, BODY_X, 0, BODY_W, BODY_H, "routes", + "# Matcher Targets Auth Scripts LB", + rows, selected=1, + ) + return img + + +def apps(): + img = chrome(2, f"{NAV_KEYS} j/k Enter action / r ? q") + rows = [ + [(0, "api", FG), (12, "api.example.com", MUTED), (34, "Running", SUCCESS), + (45, "2.4%", FG), (53, "128.4 MB", FG), (65, "8.2K", FG), (73, "4", DANGER), (81, "12.1ms", FG)], + [(0, "web", FG), (12, "www.example.com", MUTED), (34, "Running", SUCCESS), + (45, "1.1%", FG), (53, "96.0 MB", FG), (65, "3.4K", FG), (73, "0", FG), (81, "8.4ms", FG)], + [(0, "docs", FG), (12, "docs.example.com", MUTED), (34, "Running", SUCCESS), + (45, "0.3%", FG), (53, "42.1 MB", FG), (65, "612", FG), (73, "0", FG), (81, "6.0ms", FG)], + [(0, "legacy", FG), (12, "old.example.com", MUTED), (34, "Stopped", MUTED), + (45, "-", MUTED), (53, "-", MUTED), (65, "0", FG), (73, "0", FG), (81, "-", MUTED)], + ] + _table( + img, BODY_X, 0, BODY_W, BODY_H, "apps", + "Name Domain Status CPU Memory Reqs Errors Avg RT", + rows, selected=0, + ) + return img + + +def circuits(): + img = chrome(3, f"{NAV_KEYS} j/k r ? q") + rows = [ + [(0, "http://127.0.0.1:8081", FG), (40, "closed", SUCCESS), (52, "0", FG), (66, "8241", FG)], + [(0, "http://127.0.0.1:8082", FG), (40, "closed", SUCCESS), (52, "0", FG), (66, "3402", FG)], + [(0, "http://10.0.0.4:9000", FG), (40, "open", DANGER), (52, "12", FG), (66, "0", FG)], + [(0, "http://10.0.0.5:9000", FG), (40, "half_open", WARN), (52, "5", FG), (66, "2", FG)], + [(0, "http://127.0.0.1:8090", FG), (40, "closed", SUCCESS), (52, "1", FG), (66, "612", FG)], + ] + _table( + img, BODY_X, 0, BODY_W, BODY_H, "circuits", + "Target State Failures Successes", + rows, selected=2, + ) + return img + + +def errors(): + img = chrome(4, f"{NAV_KEYS} j/k Enter detail r ? q") + rows = [ + [(0, "14:02:11", MUTED), (10, "502", DANGER), (16, "GET", FG), (22, "api.example.com", FG), (40, "/v1/orders", FG)], + [(0, "14:02:44", MUTED), (10, "504", DANGER), (16, "POST", FG), (22, "api.example.com", FG), (40, "/v1/checkout", FG)], + [(0, "14:05:02", MUTED), (10, "failed", DANGER), (16, "GET", FG), (22, "old.example.com", FG), (40, "/legacy/report", FG)], + [(0, "14:09:37", MUTED), (10, "500", DANGER), (16, "GET", FG), (22, "www.example.com", FG), (40, "/account/settings", FG)], + [(0, "14:11:20", MUTED), (10, "502", DANGER), (16, "GET", FG), (22, "api.example.com", FG), (40, "/v1/orders", FG)], + ] + _table( + img, BODY_X, 0, BODY_W, BODY_H, "errors 5", + "Time Status Method Host Path", + rows, selected=3, + ) + return img + + +def config(): + img = chrome(5, f"{NAV_KEYS} j/k r ? q") + cx, cy, cw, _ = list_block(img, BODY_X, 0, BODY_W, BODY_H, "proxy.conf") + text = [ + "[server]", + 'bind = "0.0.0.0:80"', + "https_port = 443", + "", + "[tls]", + 'mode = "acme"', + 'acme_email = "ops@example.com"', + "", + "[admin]", + 'bind = "127.0.0.1:9090"', + "enabled = true", + 'username = "admin"', + "", + "[[rules]]", + 'matcher = { domain = "api.example.com" }', + 'targets = [{ url = "http://127.0.0.1:8081" }]', + 'scripts = ["auth.lua"]', + "", + "[[rules]]", + 'matcher = { domain = "www.example.com" }', + 'targets = [{ url = "http://127.0.0.1:8082" }]', + 'load_balancing = "least_conn"', + ] + for i, line in enumerate(text): + put(img, cx, cy + i, line, FG) + hint = " 1-32 of 214 · j/k " + put(img, cx + cw - len(hint), 0, hint, MUTED) + return img + + +def help_overlay(): + img = dashboard() + # theme::centered_modal(body, 64, 22) over the body area. + w, h = 64, 22 + x = BODY_X + (BODY_W - w) // 2 + y = (BODY_H - h) // 2 + fill(img, x, y, w, h, INK) + cx, cy, _, _ = list_block(img, x, y, w, h, "help") + text = [ + " 1-6 Jump to screen Tab / S-Tab Cycle", + " j/k PgUp/Dn Move g / G First / last", + " / Search r Refresh now", + " Enter Select / open Esc Back", + " a/e/d Route add/edit/del", + " Mouse Click nav, click rows, wheel scrolls", + " q Quit", + "", + " Apps: Enter -> Deploy / Restart / Stop / Rollback / Logs", + " Errors: Enter detail · y copy (OSC 52)", + "", + " Any key closes this overlay", + ] + for i, line in enumerate(text): + put(img, cx, cy + i, line, FG) + return img + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + shots = { + "01-dashboard.png": dashboard, + "02-routes.png": routes, + "03-apps.png": apps, + "04-circuits.png": circuits, + "05-errors.png": errors, + "06-config.png": config, + "07-help.png": help_overlay, + } + for name, fn in shots.items(): + path = OUT / name + fn().save(path) + print(path) + + +if __name__ == "__main__": + main() diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 2590f3b..ef2f5f7 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -390,7 +390,7 @@ async fn proxy_to_admin_app( let port = match resolve_admin_port(state).await { Ok(p) => p, - Err(resp) => return resp, + Err(resp) => return *resp, }; let path = req.uri().path(); @@ -454,15 +454,23 @@ async fn proxy_to_admin_app( } /// Resolve the _admin app's backend port, or return an error response. -async fn resolve_admin_port(state: &Arc) -> Result> { +/// +/// The error is boxed because `Response` dwarfs the `u16` success +/// value, and every caller immediately returns it as its own response. +async fn resolve_admin_port(state: &Arc) -> Result>> { let app_manager = match &state.app_manager { Some(m) => m, - None => return Err(error_response(501, "App management not configured")), + None => { + return Err(Box::new(error_response( + 501, + "App management not configured", + ))) + } }; let app = match app_manager.get_app("_admin").await { Some(a) => a, - None => return Err(error_response(502, "_admin app not found")), + None => return Err(Box::new(error_response(502, "_admin app not found"))), }; let port = if app.current_slot == "blue" { @@ -472,7 +480,7 @@ async fn resolve_admin_port(state: &Arc) -> Result Response { let port = match resolve_admin_port(state).await { Ok(p) => p, - Err(resp) => return resp, + Err(resp) => return *resp, }; let path = req.uri().path().to_string(); diff --git a/src/metrics.rs b/src/metrics.rs index a857cc7..08deeea 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -9,6 +9,7 @@ struct CpuSnapshot { timestamp: Instant, } +#[derive(Debug, Clone, Copy)] pub struct MetricsSnapshot { pub requests_total: u64, pub requests_in_flight: usize, diff --git a/src/tui/app.rs b/src/tui/app.rs index dc6c78b..e6a92b2 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -2,15 +2,20 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, prelude::Stylize, style::{Color, Style}, + text::{Line, Span}, widgets::{Block, Borders, Clear, Paragraph}, Frame, }; use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use crate::config::ConfigManager; use crate::metrics::{AppMetricsJson, MetricsSnapshot}; use super::errors::{load_request_errors, ErrorEntry}; +use super::theme; use super::{route_form::RouteForm, screens, TuiContext}; /// Per-app stats combining traffic (from admin API) and system (from /proc). @@ -31,7 +36,187 @@ pub struct AppHistory { pub mem: VecDeque, } -const HISTORY_LEN: usize = 60; // 60 samples × 2s = 2 minutes +const HISTORY_LEN: usize = 60; // 60 samples × 1s = 1 minute + +/// How often the background poller re-reads the daemon's admin API. +const DAEMON_POLL_INTERVAL: Duration = Duration::from_secs(1); + +/// State of the daemon's admin API. "Not reachable" and "not enabled" are +/// different things: a proxy running with `admin.enabled = false` is perfectly +/// healthy, it just has no metrics endpoint to offer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DaemonStatus { + /// No poll has completed yet. + #[default] + Connecting, + Ok, + /// Admin API is enabled but did not answer. + Unreachable, + /// `admin.enabled = false` — nothing to connect to, nothing wrong. + Disabled, +} + +impl DaemonStatus { + /// Short footer badge. + pub fn badge(self) -> &'static str { + match self { + DaemonStatus::Connecting => " daemon … ", + DaemonStatus::Ok => " daemon ● ", + DaemonStatus::Unreachable => " daemon ✕ ", + DaemonStatus::Disabled => " daemon ○ ", + } + } + + pub fn color(self) -> Color { + match self { + DaemonStatus::Connecting => theme::MUTED, + DaemonStatus::Ok => theme::SUCCESS, + DaemonStatus::Unreachable => theme::DANGER, + DaemonStatus::Disabled => theme::MUTED, + } + } + + /// Why the metric panes are empty, when they are. + pub fn explain(self) -> &'static str { + match self { + DaemonStatus::Connecting => "connecting", + DaemonStatus::Ok => "", + DaemonStatus::Unreachable => "daemon unreachable", + DaemonStatus::Disabled => "admin api off", + } + } + + pub fn is_ok(self) -> bool { + self == DaemonStatus::Ok + } +} + +/// One completed poll of the admin API. +#[derive(Default)] +struct DaemonSample { + apps: HashMap, + global: Option, + status: DaemonStatus, + /// Incremented per completed poll so the UI can tell a fresh sample from a + /// repeat read of the same one. + seq: u64, +} + +/// Handle to the background poller. The fetch used to run inline on the render +/// thread via `block_on`, which froze the UI for the whole request timeout on +/// every tick — worst exactly when the daemon was down and you most wanted to +/// look around. Now the UI only ever reads the last completed sample. +struct DaemonFeed { + latest: Arc>, + wake: Arc, +} + +impl DaemonFeed { + fn spawn(runtime: &tokio::runtime::Runtime, config_manager: Arc) -> Self { + let latest = Arc::new(Mutex::new(DaemonSample::default())); + let wake = Arc::new(tokio::sync::Notify::new()); + let cell = latest.clone(); + let signal = wake.clone(); + + runtime.spawn(async move { + // Off the render thread, so the timeout can be generous, and the + // client (with its connection pool) is built once rather than per tick. + let client = match reqwest::Client::builder() + .timeout(Duration::from_millis(1500)) + .build() + { + Ok(client) => client, + Err(_) => { + if let Ok(mut slot) = cell.lock() { + slot.status = DaemonStatus::Unreachable; + slot.seq = slot.seq.wrapping_add(1); + } + return; + } + }; + + loop { + let sample = poll_daemon(&client, &config_manager).await; + if let Ok(mut slot) = cell.lock() { + let seq = slot.seq.wrapping_add(1); + *slot = DaemonSample { seq, ..sample }; + } + tokio::select! { + _ = tokio::time::sleep(DAEMON_POLL_INTERVAL) => {} + _ = signal.notified() => {} + } + } + }); + + Self { latest, wake } + } + + /// Ask the poller to fetch again now instead of waiting out its interval. + fn request_refresh(&self) { + self.wake.notify_one(); + } +} + +/// Fetch per-app and global traffic metrics from the daemon's admin API. +/// Best effort: an unreachable daemon yields an `Unreachable` sample rather +/// than an error, so the UI keeps running with whatever it last knew. +async fn poll_daemon(client: &reqwest::Client, config_manager: &ConfigManager) -> DaemonSample { + let cfg = config_manager.get_config(); + if !cfg.admin.enabled.unwrap_or(true) { + return DaemonSample { + status: DaemonStatus::Disabled, + ..Default::default() + }; + } + + // Convert "0.0.0.0:9090" to "127.0.0.1:9090" for local connections + let admin_addr = cfg + .admin + .bind + .replace("0.0.0.0:", "127.0.0.1:") + .replace("[::]:", "127.0.0.1:"); + let apps_url = format!("http://{}/api/v1/app-metrics", admin_addr); + let global_url = format!("http://{}/api/v1/metrics", admin_addr); + let api_key = cfg.admin.api_key.clone(); + + let with_key = |mut req: reqwest::RequestBuilder| { + if let Some(ref key) = api_key { + req = req.header("X-Api-Key", key); + } + req + }; + + // Admin API wraps JSON responses in {"ok": true, "data": ...} + #[derive(serde::Deserialize)] + struct Envelope { + data: HashMap, + } + + let (apps, global) = tokio::join!( + async { + let resp = with_key(client.get(&apps_url)).send().await.ok()?; + resp.json::().await.ok().map(|e| e.data) + }, + async { + let resp = with_key(client.get(&global_url)).send().await.ok()?; + let text = resp.text().await.ok()?; + Some(parse_prometheus_snapshot(&text)) + } + ); + + let status = if apps.is_some() || global.is_some() { + DaemonStatus::Ok + } else { + DaemonStatus::Unreachable + }; + + DaemonSample { + apps: apps.unwrap_or_default(), + global, + status, + seq: 0, + } +} #[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum Screen { @@ -59,6 +244,17 @@ pub enum Modal { const APP_ACTIONS: &[&str] = &["Deploy", "Restart", "Stop", "Rollback", "View Logs"]; +/// How long a transient status flash stays on screen. +const TOAST_TTL: Duration = Duration::from_secs(2); + +/// Largest scroll offset that still fills the viewport: the top of the last +/// page, not the index of the last row. Every screen renders its rows as +/// `.skip(scroll_offset).take(visible)`, so an offset of `len - 1` would leave +/// exactly one row on screen. +fn max_offset(len: usize, visible: usize) -> usize { + len.saturating_sub(visible.max(1)) +} + pub struct TuiApp { ctx: TuiContext, current_screen: Screen, @@ -85,10 +281,29 @@ pub struct TuiApp { errors: Vec, /// Transient "copied to clipboard" flash for the error detail modal. error_copied: bool, + toast: Option<(String, Instant)>, + /// Background poller for the daemon's admin API. + daemon: DaemonFeed, + daemon_status: DaemonStatus, + /// `seq` of the last sample folded into `rps_history`, so a tick that + /// re-reads an unchanged sample does not fabricate a rate of zero. + last_daemon_seq: u64, + rps_history: VecDeque, + last_requests_total: u64, + last_metrics_at: Option, + frame_ticks: u64, + /// Cached `proxy.conf` text and line count for the Config screen, so it is + /// not re-read from disk on every frame. + config_text: String, + config_line_count: usize, + last_sidebar: Rect, + last_body: Rect, + visible_height: usize, } impl TuiApp { pub fn new(ctx: TuiContext) -> Self { + let daemon = DaemonFeed::spawn(&ctx.runtime, ctx.config_manager.clone()); let mut app = Self { ctx, current_screen: Screen::Dashboard, @@ -110,16 +325,61 @@ impl TuiApp { log_auto_follow: true, errors: Vec::new(), error_copied: false, + toast: None, + daemon, + daemon_status: DaemonStatus::Connecting, + last_daemon_seq: 0, + rps_history: VecDeque::with_capacity(HISTORY_LEN), + last_requests_total: 0, + last_metrics_at: None, + frame_ticks: 0, + config_text: String::new(), + config_line_count: 0, + last_sidebar: Rect::default(), + last_body: Rect::default(), + visible_height: 20, }; app.collect_stats(); app } - /// Collect all per-app stats: traffic from admin API + system from /proc. + fn show_toast(&mut self, msg: impl Into) { + self.toast = Some((msg.into(), Instant::now())); + } + + /// Collect all per-app stats: traffic from the background daemon poller + + /// system stats from /proc. Never blocks on the network. fn collect_stats(&mut self) { - // Traffic counters live in the daemon process — fetch them via the - // admin API (best effort; zeros if the daemon is unreachable). - let (traffic, global) = self.fetch_daemon_metrics(); + let (traffic, global, status, seq) = self.read_daemon_sample(); + self.daemon_status = status; + + // Only fold a sample into the rate history once. Re-reading the same + // sample would show a delta of zero over a growing interval. + let fresh = seq != self.last_daemon_seq; + self.last_daemon_seq = seq; + if fresh { + if let Some(ref snap) = global { + let now = Instant::now(); + if let Some(prev_at) = self.last_metrics_at { + let elapsed = now.duration_since(prev_at).as_secs_f64(); + let rps = theme::rps_from_delta( + self.last_requests_total, + snap.requests_total, + elapsed, + ); + if self.rps_history.len() >= HISTORY_LEN { + self.rps_history.pop_front(); + } + self.rps_history.push_back(rps); + } + self.last_requests_total = snap.requests_total; + self.last_metrics_at = Some(now); + } else { + // Daemon went away: drop the stale anchor so the first sample + // after it returns is not averaged across the whole outage. + self.last_metrics_at = None; + } + } self.remote_snapshot = global; // Re-probe PIDs for apps that lost theirs, then collect /proc stats @@ -192,60 +452,38 @@ impl TuiApp { // Parse individual request failures from proxy.log for the Errors screen. self.errors = load_request_errors(); - } - /// Fetch per-app and global traffic metrics from the daemon's admin API. - /// Best effort: returns empty/None when the admin API is disabled or - /// unreachable (short timeout so the UI never stalls noticeably). - fn fetch_daemon_metrics(&self) -> (HashMap, Option) { - let cfg = self.ctx.config_manager.get_config(); - if !cfg.admin.enabled.unwrap_or(true) { - return (HashMap::new(), None); - } + // Cached for the Config screen, which used to re-read the file on every + // frame and had no way to know how far it could scroll. + self.config_text = std::fs::read_to_string(self.ctx.config_manager.config_path()) + .unwrap_or_else(|e| format!("Failed to read config file: {e}")); + self.config_line_count = self.config_text.lines().count(); - // Convert "0.0.0.0:9090" to "127.0.0.1:9090" for local connections - let admin_addr = cfg - .admin - .bind - .replace("0.0.0.0:", "127.0.0.1:") - .replace("[::]:", "127.0.0.1:"); - let apps_url = format!("http://{}/api/v1/app-metrics", admin_addr); - let global_url = format!("http://{}/api/v1/metrics", admin_addr); - let api_key = cfg.admin.api_key.clone(); - - self.ctx.runtime.block_on(async { - let Ok(client) = reqwest::Client::builder() - .timeout(std::time::Duration::from_millis(500)) - .build() - else { - return (HashMap::new(), None); - }; - let with_key = |mut req: reqwest::RequestBuilder| { - if let Some(ref key) = api_key { - req = req.header("X-Api-Key", key); - } - req - }; + self.clamp_scroll(); + } - // Admin API wraps JSON responses in {"ok": true, "data": ...} - #[derive(serde::Deserialize)] - struct Envelope { - data: HashMap, - } - - let (apps, global) = tokio::join!( - async { - let resp = with_key(client.get(&apps_url)).send().await.ok()?; - resp.json::().await.ok().map(|e| e.data) - }, - async { - let resp = with_key(client.get(&global_url)).send().await.ok()?; - let text = resp.text().await.ok()?; - Some(parse_prometheus_snapshot(&text)) - } - ); - (apps.unwrap_or_default(), global) - }) + /// Copy the newest sample produced by the background poller. Lock + /// contention is a few microseconds and there is no I/O on this path, so + /// this is safe to call from the render thread. + fn read_daemon_sample( + &self, + ) -> ( + HashMap, + Option, + DaemonStatus, + u64, + ) { + match self.daemon.latest.lock() { + Ok(slot) => (slot.apps.clone(), slot.global, slot.status, slot.seq), + // Poisoned only if the poller panicked mid-write; report it rather + // than propagating the panic into the render loop. + Err(_) => ( + HashMap::new(), + None, + DaemonStatus::Unreachable, + self.last_daemon_seq, + ), + } } /// Called on each tick (auto-refresh). @@ -266,6 +504,21 @@ impl TuiApp { self.check_pending_action(); } + /// Per-loop housekeeping. Returns true when the frame needs repainting, so + /// an idle TUI does not redraw ten times a second for an identical image. + pub fn on_frame(&mut self) -> bool { + self.frame_ticks += 1; + let mut dirty = false; + if let Some((_, at)) = self.toast { + if at.elapsed() >= TOAST_TTL { + self.toast = None; + dirty = true; + } + } + // The footer spinner animates only while an action is in flight. + dirty || self.pending_action.is_some() + } + pub fn has_pending_action(&self) -> bool { self.pending_action.is_some() } @@ -280,13 +533,12 @@ impl TuiApp { } let handle = self.pending_action.take().unwrap(); match self.ctx.runtime.block_on(handle) { - Ok(Ok(_msg)) => { - // Re-probe after action to update status + Ok(Ok(msg)) => { if let Some(ref mgr) = self.ctx.app_manager { mgr.probe_running_apps(); } - // Clear the progress modal silently on success self.modal = Modal::None; + self.show_toast(msg); } Ok(Err(e)) => { self.modal = Modal::AppActionResult(format!("Error: {}", e)); @@ -299,25 +551,46 @@ impl TuiApp { pub fn render(&mut self, f: &mut Frame) { let size = f.area(); + let (sidebar, rest) = theme::split_shell(size); + let has_toast = self.toast.is_some(); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), Constraint::Min(0), + Constraint::Length(if has_toast { 1 } else { 0 }), Constraint::Length(1), ]) - .split(size); + .split(rest); - self.render_header(f, chunks[0]); + self.last_sidebar = sidebar; + self.last_body = chunks[0]; + // Panel rows minus the title chip and the column header. + self.visible_height = (chunks[0].height.saturating_sub(2) as usize).max(1); + // The terminal may have been resized since the last input event. + self.clamp_scroll(); - if self.help_show { - self.render_help(f, chunks[1]); - } else { - self.render_main(f, chunks[1]); + let nav_idx = match self.current_screen { + Screen::Dashboard => 0, + Screen::Routes => 1, + Screen::Apps => 2, + Screen::Circuits => 3, + Screen::Errors => 4, + Screen::Config => 5, + Screen::Help => 0, + }; + theme::render_sidebar(f, sidebar, nav_idx, env!("CARGO_PKG_VERSION")); + self.render_main(f, chunks[0]); + if has_toast { + if let Some((ref msg, _)) = self.toast { + theme::render_toast(f, chunks[1], msg); + } } - self.render_footer(f, chunks[2]); + if self.help_show { + self.render_help(f, chunks[0]); + } + match &self.modal { Modal::RouteForm => self.render_route_form_modal(f), Modal::DeleteConfirm(idx) => self.render_delete_confirm(f, *idx), @@ -391,6 +664,20 @@ impl TuiApp { KeyCode::Char('r') => { self.refresh_data(); } + KeyCode::Char('1') => self.jump_screen(Screen::Dashboard), + KeyCode::Char('2') => self.jump_screen(Screen::Routes), + KeyCode::Char('3') => self.jump_screen(Screen::Apps), + KeyCode::Char('4') => self.jump_screen(Screen::Circuits), + KeyCode::Char('5') => self.jump_screen(Screen::Errors), + KeyCode::Char('6') => self.jump_screen(Screen::Config), + KeyCode::PageDown => { + let page = self.get_visible_height().max(1) as i32; + self.move_selection(page); + } + KeyCode::PageUp => { + let page = self.get_visible_height().max(1) as i32; + self.move_selection(-page); + } KeyCode::Char('/') => { self.search_active = true; } @@ -874,6 +1161,10 @@ impl TuiApp { } fn move_selection(&mut self, dir: i32) { + if self.scrolls_text() { + self.scroll_text(dir); + return; + } let max = self.get_max_selection(); if max == 0 { return; @@ -894,7 +1185,13 @@ impl TuiApp { fn get_max_selection(&self) -> usize { match self.current_screen { Screen::Dashboard => 0, - Screen::Routes => self.ctx.config_manager.get_config().rules.len(), + // The filtered count, not the full one: with a search active the + // screen renders fewer rows than the config holds, and a cursor + // past the end scrolls the list into empty space. + Screen::Routes => { + let rules = self.ctx.config_manager.get_config().rules.clone(); + screens::routes::filter_indices(&rules, &self.search_query).len() + } Screen::Apps => self.filtered_apps_count, Screen::Circuits => self.ctx.circuit_breaker.get_states().len(), Screen::Errors => self.errors.len(), @@ -904,14 +1201,119 @@ impl TuiApp { } fn get_visible_height(&self) -> usize { - 20 + self.visible_height.max(1) + } + + /// Screens that scroll a block of text rather than moving a row cursor. + /// They have no selectable rows, so `get_max_selection` is 0 for them and + /// the cursor-based path would refuse to move at all. + fn scrolls_text(&self) -> bool { + matches!(self.current_screen, Screen::Config) + } + + fn text_line_count(&self) -> usize { + match self.current_screen { + Screen::Config => self.config_line_count, + _ => 0, + } + } + + fn max_text_offset(&self) -> usize { + max_offset(self.text_line_count(), self.get_visible_height()) + } + + fn scroll_text(&mut self, dir: i32) { + let max_offset = self.max_text_offset() as i64; + let next = (self.scroll_offset as i64 + dir as i64).clamp(0, max_offset); + self.scroll_offset = next as usize; + } + + /// Keep the viewport in range after the underlying content changes — a + /// search filter shrinking the list, errors rotating out of proxy.log, the + /// config file being edited, or the terminal being resized. + fn clamp_scroll(&mut self) { + if self.scrolls_text() { + self.scroll_offset = self.scroll_offset.min(self.max_text_offset()); + return; + } + let len = self.get_max_selection(); + if len == 0 { + self.selected_index = 0; + self.scroll_offset = 0; + return; + } + self.selected_index = self.selected_index.min(len - 1); + self.scroll_offset = self + .scroll_offset + .min(max_offset(len, self.get_visible_height())); + if self.selected_index < self.scroll_offset { + self.scroll_offset = self.selected_index; + } + } + + fn jump_screen(&mut self, screen: Screen) { + self.current_screen = screen; + self.selected_index = 0; + self.scroll_offset = 0; + } + + /// Returns true when the event changed something worth repainting. + pub fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) -> bool { + use crossterm::event::MouseEventKind; + if self.help_show || self.modal != Modal::None { + return false; + } + match mouse.kind { + MouseEventKind::Down(_) => { + if let Some(idx) = theme::nav_at(self.last_sidebar, mouse.column, mouse.row) { + let screen = match idx { + 0 => Screen::Dashboard, + 1 => Screen::Routes, + 2 => Screen::Apps, + 3 => Screen::Circuits, + 4 => Screen::Errors, + 5 => Screen::Config, + _ => return false, + }; + self.jump_screen(screen); + return true; + } + // Row 0 of the panel is the title chip and row 1 the column + // header; data rows start at +2. Clamping instead of offsetting + // would make a click on the header select the first row. + let first_row = self.last_body.y.saturating_add(2); + let past_end = self.last_body.y.saturating_add(self.last_body.height); + if mouse.row >= first_row && mouse.row < past_end { + let rel = (mouse.row - first_row) as usize; + let idx = self.scroll_offset + rel; + if idx < self.get_max_selection() && idx != self.selected_index { + self.selected_index = idx; + return true; + } + } + false + } + MouseEventKind::ScrollDown => { + self.move_selection(1); + true + } + MouseEventKind::ScrollUp => { + self.move_selection(-1); + true + } + _ => false, + } } fn scroll_to_bottom(&mut self) { + if self.scrolls_text() { + self.scroll_offset = self.max_text_offset(); + return; + } let max = self.get_max_selection(); if max > 0 { - self.scroll_offset = max.saturating_sub(1); self.selected_index = max - 1; + self.scroll_offset = max_offset(max, self.get_visible_height()); } } @@ -954,90 +1356,42 @@ impl TuiApp { /// Map the current selected_index to the actual rule index in config, /// accounting for search filtering. + /// Map the on-screen cursor back to an index into `config.rules`. + /// Uses the same predicate the Routes screen renders with, so `e`/`d` + /// always act on the row the user is looking at. fn resolve_route_index(&self) -> Option { let rules = self.ctx.config_manager.get_config().rules.clone(); - if self.search_query.is_empty() { - if self.selected_index < rules.len() { - Some(self.selected_index) - } else { - None - } - } else { - let search_lower = self.search_query.to_lowercase(); - let filtered: Vec = rules - .iter() - .enumerate() - .filter(|(idx, rule)| { - let matcher_str = format!("{:?}", rule.matcher).to_lowercase(); - let targets_str: String = rule - .targets - .iter() - .map(|t| t.url.to_string()) - .collect::>() - .join(", "); - let auth_str: String = rule - .auth - .iter() - .map(|a| a.username.as_str()) - .collect::>() - .join(", "); - let scripts_str = rule.scripts.join(", "); - matcher_str.contains(&search_lower) - || targets_str.to_lowercase().contains(&search_lower) - || auth_str.to_lowercase().contains(&search_lower) - || scripts_str.to_lowercase().contains(&search_lower) - || idx.to_string() == search_lower - }) - .map(|(idx, _)| idx) - .collect(); - filtered.get(self.selected_index).copied() - } + screens::routes::filter_indices(&rules, &self.search_query) + .get(self.selected_index) + .copied() } - fn refresh_data(&mut self) {} - - fn render_header(&self, f: &mut Frame, area: Rect) { - let screen_names = [ - "Dashboard", - "Routes", - "Apps", - "Circuits", - "Errors", - "Config", - ]; - let current_idx = match self.current_screen { - Screen::Dashboard => 0, - Screen::Routes => 1, - Screen::Apps => 2, - Screen::Circuits => 3, - Screen::Errors => 4, - Screen::Config => 5, - Screen::Help => return, - }; - - let mut header_text = String::new(); - for (i, name) in screen_names.iter().enumerate() { - if i == current_idx { - header_text.push_str(&format!("[ {} ] ", name)); - } else { - header_text.push_str(&format!(" {} ", name)); - } - } - - let paragraph = Paragraph::new(header_text) - .style(Style::default().fg(Color::Yellow)) - .alignment(Alignment::Center); - f.render_widget(paragraph, area); + fn refresh_data(&mut self) { + // Kick the poller; its next sample lands on a later tick. The local + // (/proc, log, config) half refreshes synchronously right here. + self.daemon.request_refresh(); + self.collect_stats(); + self.show_toast("refreshed"); } fn render_main(&mut self, f: &mut Frame, area: Rect) { match self.current_screen { - Screen::Dashboard => { - screens::dashboard::render(f, area, &self.ctx, self.remote_snapshot.as_ref()) - } - Screen::Routes => { - screens::routes::render(f, area, &self.ctx, self.selected_index, &self.search_query) - } + Screen::Dashboard => screens::dashboard::render( + f, + area, + &self.ctx, + self.remote_snapshot.as_ref(), + self.daemon_status, + &self.rps_history, + ), + Screen::Routes => screens::routes::render( + f, + area, + &self.ctx, + self.selected_index, + self.scroll_offset, + &self.search_query, + ), Screen::Apps => { let all_apps = self .ctx @@ -1074,7 +1428,13 @@ impl TuiApp { }, ) } - Screen::Circuits => screens::circuits::render(f, area, &self.ctx, self.selected_index), + Screen::Circuits => screens::circuits::render( + f, + area, + &self.ctx, + self.selected_index, + self.scroll_offset, + ), Screen::Errors => screens::errors::render( f, area, @@ -1082,88 +1442,88 @@ impl TuiApp { self.selected_index, self.scroll_offset, ), - Screen::Config => screens::config_viewer::render(f, area, &self.ctx), + Screen::Config => screens::config_viewer::render( + f, + area, + &self.config_text, + self.scroll_offset, + self.config_line_count, + ), Screen::Help => {} } } fn render_footer(&self, f: &mut Frame, area: Rect) { - let mut footer_text = String::new(); - footer_text.push_str( - " q:quit | ?:help | /:search | r:refresh | j/k:move | Tab:cycle | Enter:select", - ); - if self.current_screen == Screen::Routes { - footer_text.push_str(" | a:add | e:edit | d:delete"); - } - if self.current_screen == Screen::Errors { - footer_text.push_str(" | Enter:detail"); - } - if self.search_active { - footer_text = format!("Search: {}_", self.search_query); + let paragraph = Paragraph::new(format!(" / {}_ Esc:cancel", self.search_query)) + .style(Style::default().fg(theme::WARN)); + f.render_widget(paragraph, area); + return; } - let paragraph = Paragraph::new(footer_text) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Left); - f.render_widget(paragraph, area); - } + let keys = match self.modal { + Modal::None => match self.current_screen { + Screen::Routes => "1-6 nav j/k a add e edit d delete / r ? q", + Screen::Apps => "1-6 nav j/k Enter action / r ? q", + Screen::Errors => "1-6 nav j/k Enter detail r ? q", + Screen::Circuits => "1-6 nav j/k r ? q", + Screen::Config => "1-6 nav j/k r ? q", + _ => "1-6 nav Tab cycle r ? q", + }, + Modal::RouteForm => "Tab fields Enter save Esc cancel", + Modal::DeleteConfirm(_) => "y confirm n/Esc cancel", + Modal::AppActionMenu(_, _) => "j/k Enter Esc", + Modal::AppActionProgress(_, _) => "Esc dismiss (action continues)", + Modal::AppActionResult(_) => "Esc/Enter close", + Modal::LogViewer(_, _) => "j/k scroll G follow Esc close", + Modal::ErrorDetail(_) => "j/k y copy Esc", + }; - fn render_help(&self, f: &mut Frame, area: Rect) { - let help_text = vec![ - "", - " SOLI PROXY TUI - HELP", - " =====================", - "", - " NAVIGATION", - " j / Down Move down", - " k / Up Move up", - " g Go to first item", - " G Go to last item", - " Tab Next screen", - " Shift+Tab Previous screen", - " Enter Select / Open", - " Esc Go back / Clear", - "", - " ACTIONS", - " q Quit", - " ? Toggle this help", - " / Search filter", - " r Refresh data", - "", - " ROUTES SCREEN", - " a Add new route", - " e Edit selected route", - " d Delete selected route", - "", - " ROUTE FORM", - " Tab/Shift+Tab Navigate fields", - " Left/Right Change select options", - " Enter Save route", - " Esc Cancel", - "", - " APPS SCREEN", - " Enter Open action menu", - " (Deploy/Restart/Stop/Rollback/Logs)", - "", - " ERRORS SCREEN", - " Enter Open error detail", - " j/k (in detail) Navigate between errors", - " y / c Copy error block to clipboard", - " (needs log_endpoints = true)", - "", - " Press any key to close this help", - ]; + let daemon = Span::styled( + self.daemon_status.badge(), + Style::default().fg(self.daemon_status.color()), + ); + let spinner = if self.pending_action.is_some() { + Span::styled( + format!(" {} ", theme::spinner_frame(self.frame_ticks)), + Style::default().fg(theme::WARN), + ) + } else { + Span::raw("") + }; - let block = Block::default() - .title(" Help ") - .borders(Borders::ALL) - .style(Style::default().fg(Color::Cyan)); + let line = Line::from(vec![ + Span::styled(format!(" {keys} "), Style::default().fg(theme::MUTED)), + Span::raw(" "), + spinner, + daemon, + ]); + f.render_widget(Paragraph::new(line).alignment(Alignment::Left), area); + } - let paragraph = Paragraph::new(help_text.join("\n")) - .block(block) - .style(Style::default().fg(Color::White)); - f.render_widget(paragraph, area); + fn render_help(&self, f: &mut Frame, area: Rect) { + let modal = theme::centered_modal(area, 64, 22); + f.render_widget(Clear, modal); + let help_text = "\ + 1-6 Jump to screen Tab / S-Tab Cycle + j/k PgUp/Dn Move g / G First / last + / Search r Refresh now + Enter Select / open Esc Back + a/e/d Route add/edit/del + Mouse Click nav, click rows, wheel scrolls + q Quit + + Apps: Enter → Deploy / Restart / Stop / Rollback / Logs + Errors: Enter detail · y copy (OSC 52) + + Any key closes this overlay"; + let block = theme::list_block("help"); + f.render_widget( + Paragraph::new(help_text) + .block(block) + .style(Style::default().fg(theme::FG)), + modal, + ); } fn render_route_form_modal(&self, f: &mut Frame) { @@ -1509,8 +1869,7 @@ impl TuiApp { fn render_log_viewer(&mut self, f: &mut Frame, app_name: &str, slot: &str) { let log_path = format!("./run/logs/{}/{}.log", app_name, slot); - let log_content = - std::fs::read_to_string(&log_path).unwrap_or_else(|_| "No log file found.".to_string()); + let log_content = tail_file(&log_path, 256 * 1024); let lines: Vec<&str> = log_content.lines().collect(); let total_lines = lines.len(); @@ -1559,12 +1918,62 @@ impl TuiApp { .borders(Borders::ALL) .style(Style::default().fg(Color::Cyan)); - let paragraph = Paragraph::new(display_text).block(block); + let paragraph = Paragraph::new(colorize_log(&display_text)).block(block); f.render_widget(paragraph, modal_area); } } +/// Last `max_bytes` of a log file, as text. +/// +/// Reads bytes rather than a `String`: seeking to a byte offset can land +/// mid-codepoint, and a single non-UTF-8 byte anywhere in the file would make +/// `read_to_string` fail — leaving the viewer blank with no explanation. +fn tail_file(path: &str, max_bytes: u64) -> String { + use std::io::{Read, Seek, SeekFrom}; + let mut file = match std::fs::File::open(path) { + Ok(f) => f, + Err(e) => return format!("Cannot read {path}: {e}"), + }; + let len = file.metadata().map(|m| m.len()).unwrap_or(0); + let truncated = len > max_bytes; + if truncated { + let _ = file.seek(SeekFrom::Start(len - max_bytes)); + } + let mut bytes = Vec::new(); + if let Err(e) = file.read_to_end(&mut bytes) { + return format!("Cannot read {path}: {e}"); + } + let mut text = String::from_utf8_lossy(&bytes).into_owned(); + if truncated { + // Drop the partial first line left by the seek. + match text.find('\n') { + Some(i) => text.replace_range(..=i, ""), + None => text.clear(), + } + } + text +} + +fn colorize_log(text: &str) -> ratatui::text::Text<'static> { + use ratatui::text::{Line, Span, Text}; + let lines: Vec = text + .lines() + .map(|line| { + let lower = line.to_ascii_lowercase(); + let style = if lower.contains("error") || lower.contains("fatal") { + Style::default().fg(theme::DANGER) + } else if lower.contains("warn") { + Style::default().fg(theme::WARN) + } else { + Style::default().fg(theme::FG) + }; + Line::from(Span::styled(line.to_string(), style)) + }) + .collect(); + Text::from(lines) +} + /// Copy `text` to the terminal clipboard via the OSC52 escape sequence. Works /// locally and over SSH without any display server or extra dependency. Note: /// tmux requires `set -g set-clipboard on` to forward the sequence. @@ -1693,4 +2102,50 @@ proxy_response_status_codes_total{code=\"502\"} 6 assert_eq!(snap.requests_total, 0); assert_eq!(snap.avg_response_time_ms, 0.0); } + + #[test] + fn max_offset_keeps_the_last_page_full() { + // 100 rows in a 20-row viewport: the last page starts at 80, not 99. + assert_eq!(max_offset(100, 20), 80); + // Everything fits: never scroll. + assert_eq!(max_offset(5, 20), 0); + assert_eq!(max_offset(0, 20), 0); + // A degenerate viewport must not divide by / subtract zero. + assert_eq!(max_offset(10, 0), 9); + } + + #[test] + fn tail_file_reports_missing_and_unreadable_files() { + let text = tail_file("/definitely/not/a/log/file.log", 1024); + assert!(text.starts_with("Cannot read"), "{text}"); + } + + #[test] + fn tail_file_survives_non_utf8_bytes() { + let dir = std::env::temp_dir().join("soli-tail-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("bad.log"); + // An invalid UTF-8 byte used to make read_to_string fail, leaving the + // log viewer blank with no indication why. + std::fs::write(&path, b"first line\n\xffsecond line\n").unwrap(); + + let text = tail_file(path.to_str().unwrap(), 1024); + assert!(text.contains("first line"), "{text}"); + assert!(text.contains("second line"), "{text}"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn tail_file_drops_the_partial_first_line() { + let dir = std::env::temp_dir().join("soli-tail-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.log"); + std::fs::write(&path, "aaaaaaaaaa\nbbbbbbbbbb\ncccccccccc\n").unwrap(); + + // 16 bytes back lands mid-way through the "bbb" line. + let text = tail_file(path.to_str().unwrap(), 16); + assert!(!text.contains("aaaa"), "{text}"); + assert!(text.starts_with("cccccccccc"), "{text}"); + let _ = std::fs::remove_file(&path); + } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 4db5ee2..57d6fc3 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -2,6 +2,7 @@ pub mod app; pub mod errors; pub mod route_form; pub mod screens; +pub mod theme; use anyhow::Result; use std::sync::Arc; @@ -65,63 +66,131 @@ impl TuiContext { } } -pub fn authenticate(ctx: &TuiContext) -> Result { - use std::io::{self, Write}; +pub fn authenticate(ctx: &TuiContext, terminal: &mut ratatui::DefaultTerminal) -> Result { + use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; + use ratatui::{ + layout::Alignment, + style::{Color, Style}, + widgets::{Block, Borders, Paragraph}, + }; + use std::time::Duration; if !ctx.auth_required { return Ok(true); } let cfg = ctx.config_manager.get_config(); - let username = cfg.admin.username.as_deref().unwrap_or("admin"); - - let mut attempts = 0; - let max_attempts = 3; - - while attempts < max_attempts { - print!("\x1b[2J\x1b[H"); - println!("Soli Proxy TUI - Authentication Required"); - println!("========================================"); - println!("Username: {}", username); - print!("Password: "); - io::stdout().flush()?; + let username = cfg.admin.username.as_deref().unwrap_or("admin").to_string(); + let mut password = String::new(); + let mut attempts = 0u32; + let max_attempts: u32 = 3; + let mut error: Option = None; - let password = rpassword::read_password()?; - - if ctx.verify_password(&password) { - println!("\nAuthentication successful!"); - std::thread::sleep(std::time::Duration::from_millis(500)); - return Ok(true); + loop { + terminal.draw(|f| { + let area = theme::centered_modal(f.area(), 52, 11); + let block = Block::default() + .title(" Soli Proxy ") + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::ACCENT)); + f.render_widget(block, area); + let inner = theme::inner(area); + let masked: String = "•".repeat(password.len()); + let remaining = max_attempts.saturating_sub(attempts); + let body = format!( + "\n Sign in as {username}\n\n Password: {masked}_\n\n {}\n {} attempt(s) left · Esc to quit", + error.as_deref().unwrap_or(""), + remaining + ); + f.render_widget( + Paragraph::new(body) + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Left), + inner, + ); + })?; + + if crossterm::event::poll(Duration::from_millis(100))? { + match crossterm::event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + // Raw mode is on, so Ctrl+C is ours to honour. + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')) { + return Ok(false); + } + match key.code { + // Esc quits on an empty field, clears it otherwise. Never + // bind a printable character here: it would make every + // password starting with that character unenterable. + KeyCode::Esc if password.is_empty() => return Ok(false), + KeyCode::Esc => { + password.clear(); + error = None; + } + KeyCode::Enter => { + if ctx.verify_password(&password) { + return Ok(true); + } + attempts += 1; + password.clear(); + if attempts >= max_attempts { + return Ok(false); + } + error = + Some(format!("Wrong password. {} left.", max_attempts - attempts)); + } + KeyCode::Char(c) if !ctrl => { + password.push(c); + error = None; + } + KeyCode::Backspace => { + password.pop(); + } + _ => {} + } + } + _ => {} + } } - - attempts += 1; - println!( - "\nAuthentication failed. {} attempt(s) remaining.", - max_attempts - attempts - ); - std::thread::sleep(std::time::Duration::from_secs(1)); } +} - println!("Maximum attempts reached. Exiting."); - Ok(false) +/// Button-press/release + wheel reporting in SGR encoding. +/// +/// Deliberately *not* crossterm's `EnableMouseCapture`, which also turns on +/// `?1002`/`?1003` (drag and any-motion tracking). Those flood the event loop +/// with a redraw for every pixel of pointer movement, and the wider capture +/// takes over the terminal's own text selection — which matters here because +/// the Errors screen's copy path is OSC 52 with drag-select as the fallback. +const MOUSE_ON: &str = "\x1b[?1000h\x1b[?1006h"; +const MOUSE_OFF: &str = "\x1b[?1006l\x1b[?1000l"; + +fn set_mouse_reporting(on: bool) { + use std::io::Write; + let mut out = std::io::stdout(); + let _ = out.write_all(if on { MOUSE_ON } else { MOUSE_OFF }.as_bytes()); + let _ = out.flush(); } pub fn run_tui(ctx: TuiContext) -> Result<()> { - if !authenticate(&ctx)? { - anyhow::bail!("Authentication failed"); - } - - // Install panic hook to restore terminal on panic let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { + set_mouse_reporting(false); ratatui::restore(); original_hook(panic_info); })); let mut terminal = ratatui::init(); - let result = run_tui_loop(&mut terminal, ctx); + set_mouse_reporting(true); + + let result = (|| { + if !authenticate(&ctx, &mut terminal)? { + anyhow::bail!("Authentication failed"); + } + run_tui_loop(&mut terminal, ctx) + })(); - // Always restore the terminal + set_mouse_reporting(false); ratatui::restore(); result @@ -133,35 +202,48 @@ fn run_tui_loop(terminal: &mut ratatui::DefaultTerminal, ctx: TuiContext) -> Res let mut app = app::TuiApp::new(ctx); terminal.draw(|f| app.render(f))?; - let tick_rate = Duration::from_secs(2); + let metrics_tick = Duration::from_secs(1); + let poll_timeout = Duration::from_millis(100); let mut last_tick = Instant::now(); loop { - // Poll more frequently when a background action is running - let poll_timeout = if app.has_pending_action() { - Duration::from_millis(200) - } else { - tick_rate.saturating_sub(last_tick.elapsed()) - }; + // Only repaint when something actually moved. The loop wakes ten times a + // second to animate the spinner and expire toasts; redrawing on every + // wake (and on every mouse event) burns CPU for an identical frame. + let mut dirty = false; if crossterm::event::poll(poll_timeout)? { - if let crossterm::event::Event::Key(key) = crossterm::event::read()? { - if app.handle_key(key) { - break; + match crossterm::event::read()? { + crossterm::event::Event::Key(key) => { + if app.handle_key(key) { + break; + } + dirty = true; + } + crossterm::event::Event::Mouse(mouse) => { + dirty |= app.handle_mouse(mouse); } + crossterm::event::Event::Resize(_, _) => dirty = true, + _ => {} } } if app.has_pending_action() { app.check_pending_action(); + dirty = true; } - if last_tick.elapsed() >= tick_rate { + dirty |= app.on_frame(); + + if last_tick.elapsed() >= metrics_tick { app.on_tick(); last_tick = Instant::now(); + dirty = true; } - terminal.draw(|f| app.render(f))?; + if dirty { + terminal.draw(|f| app.render(f))?; + } } Ok(()) diff --git a/src/tui/screens/apps.rs b/src/tui/screens/apps.rs index bad7cc4..4861fb5 100644 --- a/src/tui/screens/apps.rs +++ b/src/tui/screens/apps.rs @@ -27,11 +27,9 @@ pub fn render(f: &mut Frame, area: Rect, ctx: &TuiContext, view: &AppsView) { }; if all_apps.is_empty() { - let block = Block::default() - .title(" Applications ") - .borders(Borders::ALL); + let block = crate::tui::theme::list_block("apps"); f.render_widget(block, area); - let inner = Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2); + let inner = crate::tui::theme::body(area); let msg = if ctx.app_manager.is_none() { "App manager not available. Check sites/ directory." } else { @@ -69,7 +67,7 @@ pub fn render(f: &mut Frame, area: Rect, ctx: &TuiContext, view: &AppsView) { )) .borders(Borders::ALL); f.render_widget(block, area); - let inner = Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2); + let inner = crate::tui::theme::body(area); f.render_widget(Paragraph::new("No apps match your search."), inner); return; } @@ -109,17 +107,15 @@ fn render_app_table( scroll_offset: usize, app_stats: &HashMap, ) { - let block = Block::default() - .title(" Applications ") - .borders(Borders::ALL); + let block = crate::tui::theme::list_block("apps"); f.render_widget(block, area); - let inner = Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2); + let inner = crate::tui::theme::body(area); let header = Row::new(vec![ "Name", "Domain", "Status", "CPU", "Memory", "Reqs", "Errors", "Avg RT", ]) - .style(Style::default().fg(Color::Green).bold()); + .style(Style::default().fg(crate::tui::theme::ACCENT).bold()); let max_rows = inner.height.saturating_sub(1) as usize; @@ -145,11 +141,7 @@ fn render_app_table( crate::app::InstanceStatus::Failed => Color::Red, }; - let style = if is_selected { - Style::default().bg(Color::Blue).fg(Color::White) - } else { - Style::default().fg(Color::White) - }; + let style = crate::tui::theme::row_style(is_selected); let s = app_stats.get(&app.config.name); @@ -225,10 +217,7 @@ fn render_app_detail( stats: Option<&AppStats>, history: Option<&AppHistory>, ) { - let block = Block::default() - .title(format!(" {} ", app.config.name)) - .borders(Borders::ALL) - .style(Style::default().fg(Color::Cyan)); + let block = crate::tui::theme::list_block(&app.config.name); f.render_widget(block, area); let inner = Rect::new( diff --git a/src/tui/screens/circuits.rs b/src/tui/screens/circuits.rs index 5624256..45e96e7 100644 --- a/src/tui/screens/circuits.rs +++ b/src/tui/screens/circuits.rs @@ -2,19 +2,23 @@ use ratatui::{ layout::{Constraint, Rect}, prelude::Stylize, style::{Color, Style}, - widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + widgets::{Cell, Paragraph, Row, Table}, Frame, }; use crate::tui::TuiContext; -pub fn render(f: &mut Frame, area: Rect, ctx: &TuiContext, selected_index: usize) { - let block = Block::default() - .title(" Circuit Breakers ") - .borders(Borders::ALL); +pub fn render( + f: &mut Frame, + area: Rect, + ctx: &TuiContext, + selected_index: usize, + scroll_offset: usize, +) { + let block = crate::tui::theme::list_block("circuits"); f.render_widget(block, area); - let inner = Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2); + let inner = crate::tui::theme::body(area); let states = ctx.circuit_breaker.get_states(); @@ -26,16 +30,19 @@ pub fn render(f: &mut Frame, area: Rect, ctx: &TuiContext, selected_index: usize } let header = Row::new(vec!["Target", "State", "Failures", "Successes"]) - .style(Style::default().fg(Color::Green).bold()); + .style(Style::default().fg(crate::tui::theme::ACCENT).bold()); let states_vec: Vec<(String, crate::circuit_breaker::CircuitBreakerInfo)> = states.into_iter().collect(); + let max_rows = inner.height.saturating_sub(1) as usize; let rows: Vec = states_vec .iter() + .skip(scroll_offset) + .take(max_rows) .enumerate() .map(|(idx, (url, info))| { - let is_selected = idx == selected_index; + let is_selected = scroll_offset + idx == selected_index; let state_color = match info.state.as_str() { "open" => Color::Red, @@ -43,11 +50,7 @@ pub fn render(f: &mut Frame, area: Rect, ctx: &TuiContext, selected_index: usize _ => Color::Green, }; - let style = if is_selected { - Style::default().bg(Color::Blue).fg(Color::White) - } else { - Style::default().fg(Color::White) - }; + let style = crate::tui::theme::row_style(is_selected); Row::new(vec![ Cell::from(url.as_str()).style(style), diff --git a/src/tui/screens/config_viewer.rs b/src/tui/screens/config_viewer.rs index 3f4942a..7ac7438 100644 --- a/src/tui/screens/config_viewer.rs +++ b/src/tui/screens/config_viewer.rs @@ -1,32 +1,42 @@ use ratatui::{ - layout::Rect, + layout::{Alignment, Rect}, style::Style, - widgets::{Block, Borders, Paragraph}, + widgets::Paragraph, Frame, }; -use crate::tui::TuiContext; +use crate::tui::theme; -pub fn render(f: &mut Frame, area: Rect, ctx: &TuiContext) { - let block = Block::default().title(" proxy.conf ").borders(Borders::ALL); - f.render_widget(block, area); +/// Renders `proxy.conf` from the text cached by `TuiApp` — reading the file +/// here would mean a disk hit on every frame, and the caller needs the line +/// count anyway to know how far `scroll_offset` may go. +pub fn render(f: &mut Frame, area: Rect, config_text: &str, scroll_offset: usize, lines: usize) { + f.render_widget(theme::list_block("proxy.conf"), area); - let inner = Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2); - - let config_path = ctx.config_manager.config_path(); - let config_content = std::fs::read_to_string(config_path) - .unwrap_or_else(|_| "Failed to read config file.".to_string()); - - let lines: Vec<&str> = config_content.lines().collect(); - let visible_lines: Vec = lines - .iter() + let inner = theme::body(area); + let visible: Vec<&str> = config_text + .lines() + .skip(scroll_offset) .take(inner.height as usize) - .map(|s| s.to_string()) .collect(); - let display_text = visible_lines.join("\n"); - let paragraph = - Paragraph::new(display_text).style(Style::default().fg(ratatui::style::Color::White)); + f.render_widget( + Paragraph::new(visible.join("\n")).style(Style::default().fg(theme::FG)), + inner, + ); - f.render_widget(paragraph, inner); + // Scroll affordance: without a cursor there is nothing else to say whether + // the view is at the top, in the middle, or at the end of the file. + if lines > inner.height as usize && inner.width > 8 { + let end = (scroll_offset + inner.height as usize).min(lines); + let hint = format!(" {}-{end} of {lines} · j/k ", scroll_offset + 1); + let w = (hint.chars().count() as u16).min(inner.width); + let hint_area = Rect::new(inner.x + inner.width.saturating_sub(w), area.y, w, 1); + f.render_widget( + Paragraph::new(hint) + .style(Style::default().fg(theme::MUTED)) + .alignment(Alignment::Right), + hint_area, + ); + } } diff --git a/src/tui/screens/dashboard.rs b/src/tui/screens/dashboard.rs index 0d93c1f..da87372 100644 --- a/src/tui/screens/dashboard.rs +++ b/src/tui/screens/dashboard.rs @@ -1,172 +1,196 @@ +use std::collections::VecDeque; + use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, prelude::Stylize, style::{Color, Style}, - widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + widgets::{Cell, Paragraph, Row, Sparkline, Table}, Frame, }; use crate::metrics::MetricsSnapshot; +use crate::tui::app::DaemonStatus; +use crate::tui::theme; use crate::tui::TuiContext; -/// `remote_snap` carries traffic counters fetched from the daemon's admin -/// API; the TUI's own metrics registry is empty (separate process), so it -/// is only used as a fallback when the daemon is unreachable. -pub fn render(f: &mut Frame, area: Rect, ctx: &TuiContext, remote_snap: Option<&MetricsSnapshot>) { +/// `remote_snap` carries traffic counters fetched from the daemon's admin API. +/// The TUI runs in its own process, so its local metrics registry is always +/// empty — `status` is what decides whether the numbers mean anything. +pub fn render( + f: &mut Frame, + area: Rect, + ctx: &TuiContext, + remote_snap: Option<&MetricsSnapshot>, + status: DaemonStatus, + rps_history: &VecDeque, +) { let local_snap = ctx.metrics.snapshot(); let snap = remote_snap.unwrap_or(&local_snap); + let have_metrics = remote_snap.is_some(); + let rows = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(10), // Server + Traffic - Constraint::Length(8), // Resources + Status codes - Constraint::Min(4), // Apps / Circuits + Constraint::Length(4), + Constraint::Length(6), + Constraint::Length(6), + Constraint::Min(6), ]) .split(area); - // Row 1: Server Info | Traffic - let top_cols = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(rows[0]); + render_kpis(f, rows[0], snap, have_metrics, status, rps_history); + render_rps_and_status(f, rows[1], snap, have_metrics, rps_history); + render_meta(f, rows[2], ctx, status); - render_server_info(f, top_cols[0], ctx); - render_traffic(f, top_cols[1], snap); - - // Row 2: Resources | Status Codes - let mid_cols = Layout::default() + let bottom = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(rows[1]); - - render_resources(f, mid_cols[0], ctx); - render_status_codes(f, mid_cols[1], snap); - - // Row 3: Apps quick view - render_apps_overview(f, rows[2], ctx); + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(rows[3]); + render_apps_overview(f, bottom[0], ctx); + render_server(f, bottom[1], ctx, snap, have_metrics); } -fn render_server_info(f: &mut Frame, area: Rect, ctx: &TuiContext) { - let cfg = ctx.config_manager.get_config(); - let uptime = ctx.uptime(); - - let block = Block::default() - .title(" Server ") - .borders(Borders::ALL) - .style(Style::default().fg(Color::Cyan)); - f.render_widget(block, area); - - let inner = inner_area(area); +fn render_kpis( + f: &mut Frame, + area: Rect, + snap: &MetricsSnapshot, + have_metrics: bool, + status: DaemonStatus, + rps_history: &VecDeque, +) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + ]) + .split(area); - let uptime_str = format_uptime(uptime); - let tls_str = if cfg.tls.mode.is_empty() || cfg.tls.mode == "disabled" { - "disabled".to_string() - } else { - cfg.tls.mode.clone() - }; + // Without a sample the counters are not "zero", they are unknown — say + // which of the two it is rather than showing a confident 0. + if !have_metrics { + // `Ok` with no global sample means the metrics endpoint specifically + // did not answer, which `explain()` has nothing to say about. + let why = match status.explain() { + "" => "no metrics", + other => other, + }; + theme::kpi(f, cols[0], "—", why, status.color()); + theme::kpi(f, cols[1], "—", "req / s", theme::MUTED); + theme::kpi(f, cols[2], "—", "avg latency", theme::MUTED); + theme::kpi(f, cols[3], "—", "error rate", theme::MUTED); + return; + } - let admin_str = if cfg.admin.enabled.unwrap_or(true) { - cfg.admin.bind.clone() + let rps = rps_history.back().copied().unwrap_or(0); + let lat = if snap.avg_response_time_ms > 0.0 { + theme::fmt_ms(snap.avg_response_time_ms) } else { - "disabled".to_string() + "-".into() }; - - let rows = vec![ - kv_row( - "", - format!("Soli Proxy v{}", env!("CARGO_PKG_VERSION")), - Color::Green, - ), - kv_row("Listen", cfg.server.bind.clone(), Color::White), - kv_row("HTTPS", format!(":{}", cfg.server.https_port), Color::White), - kv_row("TLS", tls_str, Color::White), - kv_row("Admin", admin_str, Color::White), - kv_row( - "Auth", - if ctx.auth_required { "enabled" } else { "-" }.to_string(), - Color::White, - ), - kv_row("Uptime", uptime_str, Color::Yellow), - ]; - - let table = Table::new(rows, [Constraint::Length(10), Constraint::Min(10)]); - f.render_widget(table, inner); -} - -fn render_traffic(f: &mut Frame, area: Rect, snap: &MetricsSnapshot) { - let block = Block::default() - .title(" Traffic ") - .borders(Borders::ALL) - .style(Style::default().fg(Color::Cyan)); - f.render_widget(block, area); - - let inner = inner_area(area); - - let error_str = if snap.requests_total > 0 && snap.errors_total > 0 { + let err = if snap.requests_total > 0 && snap.errors_total > 0 { format!( - "{} ({:.2}%)", - format_number(snap.errors_total), + "{:.2}%", (snap.errors_total as f64 / snap.requests_total as f64) * 100.0 ) } else { - format_number(snap.errors_total) + "0%".into() }; - let resp_time_str = if snap.avg_response_time_ms > 0.0 { - if snap.avg_response_time_ms >= 1000.0 { - format!("{:.2} s", snap.avg_response_time_ms / 1000.0) + theme::kpi( + f, + cols[0], + &theme::fmt_num(snap.requests_total), + "requests", + theme::ACCENT, + ); + theme::kpi(f, cols[1], &rps.to_string(), "req / s", theme::SUCCESS); + theme::kpi(f, cols[2], &lat, "avg latency", theme::WARN); + theme::kpi( + f, + cols[3], + &err, + "error rate", + if snap.errors_total > 0 { + theme::DANGER } else { - format!("{:.1} ms", snap.avg_response_time_ms) - } - } else { - "-".to_string() - }; + theme::SUCCESS + }, + ); +} + +fn render_rps_and_status( + f: &mut Frame, + area: Rect, + snap: &MetricsSnapshot, + have_metrics: bool, + rps_history: &VecDeque, +) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(area); - let error_color = if snap.errors_total > 0 { - Color::Red + let rps_now = rps_history.back().copied().unwrap_or(0); + let spark_block = theme::list_block(&format!("live rps {rps_now}")); + if rps_history.is_empty() { + f.render_widget(spark_block, cols[0]); } else { - Color::Green - }; + let data: Vec = rps_history.iter().copied().collect(); + let max = data.iter().copied().max().unwrap_or(1).max(1); + let sparkline = Sparkline::default() + .block(spark_block) + .data(&data) + .max(max) + .style(Style::default().fg(theme::ACCENT)); + f.render_widget(sparkline, cols[0]); + } + let total = snap.status_2xx + snap.status_3xx + snap.status_4xx + snap.status_5xx; + f.render_widget(theme::list_block("http"), cols[1]); + let inner = theme::body(cols[1]); + if !have_metrics || total == 0 { + f.render_widget( + Paragraph::new("no traffic yet").style(Style::default().fg(theme::MUTED)), + inner, + ); + return; + } + let bar_width = inner.width.saturating_sub(16) as usize; let rows = vec![ - kv_row("Requests", format_number(snap.requests_total), Color::Cyan), - kv_row( - "In Flight", - format_number(snap.requests_in_flight as u64), - Color::Cyan, - ), - kv_row("Avg Resp", resp_time_str, Color::Cyan), - kv_row("Bytes In", format_bytes(snap.bytes_received), Color::Cyan), - kv_row("Bytes Out", format_bytes(snap.bytes_sent), Color::Cyan), - kv_row( - "TLS Conns", - format_number(snap.tls_connections), - Color::Cyan, + status_row("2xx", snap.status_2xx, total, bar_width, theme::SUCCESS), + status_row( + "3xx", + snap.status_3xx, + total, + bar_width, + Color::Rgb(139, 233, 253), ), - kv_row("Errors", error_str, error_color), + status_row("4xx", snap.status_4xx, total, bar_width, theme::WARN), + status_row("5xx", snap.status_5xx, total, bar_width, theme::DANGER), ]; - - let table = Table::new(rows, [Constraint::Length(12), Constraint::Min(10)]); - f.render_widget(table, inner); + f.render_widget( + Table::new( + rows, + [ + Constraint::Length(4), + Constraint::Length(8), + Constraint::Min(6), + ], + ), + inner, + ); } -fn render_resources(f: &mut Frame, area: Rect, ctx: &TuiContext) { +fn render_meta(f: &mut Frame, area: Rect, ctx: &TuiContext, status: DaemonStatus) { let cfg = ctx.config_manager.get_config(); - - let block = Block::default() - .title(" Resources ") - .borders(Borders::ALL) - .style(Style::default().fg(Color::Cyan)); - f.render_widget(block, area); - - let inner = inner_area(area); - let apps = ctx .app_manager .as_ref() .map(|m| m.list_apps_sync()) .unwrap_or_default(); - let running = apps .iter() .filter(|a| { @@ -178,203 +202,226 @@ fn render_resources(f: &mut Frame, area: Rect, ctx: &TuiContext) { matches!(inst.status, crate::app::InstanceStatus::Running) }) .count(); - - let apps_str = if apps.is_empty() { - "-".to_string() - } else { - format!("{} ({} running)", apps.len(), running) - }; - let circuits = ctx.circuit_breaker.get_states(); let open = circuits.values().filter(|s| s.state == "open").count(); let half = circuits.values().filter(|s| s.state == "half_open").count(); - let circuits_str = if circuits.is_empty() { - "none".to_string() - } else if open == 0 && half == 0 { - format!("{} (all healthy)", circuits.len()) - } else { - let mut parts = vec![format!("{} total", circuits.len())]; - if open > 0 { - parts.push(format!("{} open", open)); - } - if half > 0 { - parts.push(format!("{} half-open", half)); - } - parts.join(", ") - }; + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + ]) + .split(area); + + theme::kpi( + f, + cols[0], + &theme::fmt_uptime(ctx.uptime()), + &format!("up {} :{}", cfg.server.bind, cfg.server.https_port), + theme::ACCENT, + ); + theme::kpi( + f, + cols[1], + &format!("{running}/{}", apps.len()), + "apps running", + theme::SUCCESS, + ); + theme::kpi( + f, + cols[2], + &cfg.rules.len().to_string(), + "routes", + theme::MAGENTA, + ); - let circuits_color = if open > 0 { - Color::Red + let (cval, ccol) = if open > 0 { + (format!("{open} open"), theme::DANGER) } else if half > 0 { - Color::Yellow + (format!("{half} half-open"), theme::WARN) + } else { + (circuits.len().to_string(), theme::SUCCESS) + }; + // Circuit state is local to this process, so it stays meaningful whatever + // the admin API is doing; only annotate when that distinction matters. + let clabel = if status.is_ok() { + "circuits".to_string() } else { - Color::Green + format!("circuits · {}", status.explain()) }; + theme::kpi(f, cols[3], &cval, &clabel, ccol); +} + +/// Listener/TLS configuration plus the traffic counters that do not fit in the +/// KPI strip (bytes moved, in-flight requests, TLS connections). +fn render_server( + f: &mut Frame, + area: Rect, + ctx: &TuiContext, + snap: &MetricsSnapshot, + have_metrics: bool, +) { + let cfg = ctx.config_manager.get_config(); + f.render_widget(theme::list_block("server"), area); + let inner = theme::body(area); - let scripts_str = if cfg.global_scripts.is_empty() { + let tls = if cfg.tls.mode.is_empty() || cfg.tls.mode == "disabled" { + "disabled".to_string() + } else { + cfg.tls.mode.clone() + }; + let admin = if cfg.admin.enabled.unwrap_or(true) { + cfg.admin.bind.clone() + } else { + "disabled".to_string() + }; + let scripts = if cfg.global_scripts.is_empty() { "-".to_string() } else { cfg.global_scripts.join(", ") }; + let unknown = |s: String| if have_metrics { s } else { "—".to_string() }; let rows = vec![ - kv_row("Routes", cfg.rules.len().to_string(), Color::White), - kv_row("Apps", apps_str, Color::White), - kv_row("Scripts", scripts_str, Color::Magenta), - Row::new(vec![ - Cell::from("Circuits").style(Style::default().fg(Color::DarkGray)), - Cell::from(circuits_str).style(Style::default().fg(circuits_color)), - ]), + kv("listen", cfg.server.bind.clone(), theme::FG), + kv("https", format!(":{}", cfg.server.https_port), theme::FG), + kv("tls", tls, theme::FG), + kv("admin", admin, theme::FG), + kv( + "auth", + if ctx.auth_required { "enabled" } else { "-" }.to_string(), + theme::FG, + ), + kv("scripts", scripts, theme::MAGENTA), + kv( + "in flight", + unknown(theme::fmt_num(snap.requests_in_flight as u64)), + theme::FG, + ), + kv( + "bytes in", + unknown(theme::fmt_bytes(snap.bytes_received)), + theme::FG, + ), + kv( + "bytes out", + unknown(theme::fmt_bytes(snap.bytes_sent)), + theme::FG, + ), + kv( + "tls conns", + unknown(theme::fmt_num(snap.tls_connections)), + theme::FG, + ), + kv( + "errors", + unknown(theme::fmt_num(snap.errors_total)), + if snap.errors_total > 0 && have_metrics { + theme::DANGER + } else { + theme::FG + }, + ), ]; - let table = Table::new(rows, [Constraint::Length(10), Constraint::Min(10)]); - f.render_widget(table, inner); + f.render_widget( + Table::new(rows, [Constraint::Length(10), Constraint::Min(8)]), + inner, + ); } -fn render_status_codes(f: &mut Frame, area: Rect, snap: &MetricsSnapshot) { - let block = Block::default() - .title(" HTTP Status ") - .borders(Borders::ALL) - .style(Style::default().fg(Color::Cyan)); - f.render_widget(block, area); - - let inner = inner_area(area); - - let total = snap.status_2xx + snap.status_3xx + snap.status_4xx + snap.status_5xx; - - if total == 0 { - let text = Paragraph::new("No requests yet.").style(Style::default().fg(Color::DarkGray)); - f.render_widget(text, inner); - return; - } - - let bar_width = inner.width.saturating_sub(18) as usize; - - let rows = vec![ - status_row("2xx", snap.status_2xx, total, bar_width, Color::Green), - status_row("3xx", snap.status_3xx, total, bar_width, Color::Blue), - status_row("4xx", snap.status_4xx, total, bar_width, Color::Yellow), - status_row("5xx", snap.status_5xx, total, bar_width, Color::Red), - ]; - - let table = Table::new( - rows, - [ - Constraint::Length(5), - Constraint::Length(10), - Constraint::Min(8), - ], - ); - f.render_widget(table, inner); +fn kv(label: &str, value: String, color: Color) -> Row<'static> { + Row::new(vec![ + Cell::from(label.to_string()).style(Style::default().fg(theme::MUTED)), + Cell::from(value).style(Style::default().fg(color)), + ]) } fn render_apps_overview(f: &mut Frame, area: Rect, ctx: &TuiContext) { - let block = Block::default() - .title(" Applications ") - .borders(Borders::ALL) - .style(Style::default().fg(Color::Cyan)); - f.render_widget(block, area); - - let inner = inner_area(area); - let apps = ctx .app_manager .as_ref() .map(|m| m.list_apps_sync()) .unwrap_or_default(); + f.render_widget(theme::list_block("apps"), area); + let inner = theme::body(area); + if apps.is_empty() { - let text = - Paragraph::new("No apps discovered.").style(Style::default().fg(Color::DarkGray)); - f.render_widget(text, inner); + f.render_widget( + Paragraph::new("no apps in sites/").style(Style::default().fg(theme::MUTED)), + inner, + ); return; } - let header = Row::new(vec!["Name", "Domain", "Slot", "Status", "Port"]) - .style(Style::default().fg(Color::DarkGray).bold()); + let header = Row::new(vec!["name", "domain", "slot", "status", "port"]) + .style(Style::default().fg(theme::MUTED).bold()); let max_rows = inner.height.saturating_sub(1) as usize; + // Leave room for the overflow hint when the list does not fit. + let overflow = apps.len().saturating_sub(max_rows); + let shown = if overflow > 0 { + max_rows.saturating_sub(1) + } else { + max_rows + }; - let rows: Vec = apps + let mut rows: Vec = apps .iter() - .take(max_rows) + .take(shown) .map(|app| { let inst = if app.current_slot == "blue" { &app.blue } else { &app.green }; - let (status_str, status_color) = match inst.status { - crate::app::InstanceStatus::Running => ("Running", Color::Green), - crate::app::InstanceStatus::Starting => ("Starting", Color::Yellow), - crate::app::InstanceStatus::Stopped => ("Stopped", Color::DarkGray), - crate::app::InstanceStatus::Unhealthy => ("Unhealthy", Color::Red), - crate::app::InstanceStatus::Failed => ("Failed", Color::Red), + crate::app::InstanceStatus::Running => ("● run", theme::SUCCESS), + crate::app::InstanceStatus::Starting => ("● start", theme::WARN), + crate::app::InstanceStatus::Stopped => ("○ stop", theme::MUTED), + crate::app::InstanceStatus::Unhealthy => ("● sick", theme::DANGER), + crate::app::InstanceStatus::Failed => ("● fail", theme::DANGER), }; - let port_str = if inst.port > 0 { format!(":{}", inst.port) } else { - "-".to_string() + "-".into() }; - Row::new(vec![ - Cell::from(app.config.name.clone()).style(Style::default().fg(Color::White)), - Cell::from(app.config.domain.clone()).style(Style::default().fg(Color::White)), - Cell::from(app.current_slot.clone()).style(Style::default().fg(Color::DarkGray)), + Cell::from(app.config.name.clone()).style(Style::default().fg(theme::FG)), + Cell::from(app.config.domain.clone()).style(Style::default().fg(theme::MUTED)), + Cell::from(app.current_slot.clone()).style(Style::default().fg(theme::MUTED)), Cell::from(status_str).style(Style::default().fg(status_color)), - Cell::from(port_str).style(Style::default().fg(Color::DarkGray)), + Cell::from(port_str).style(Style::default().fg(theme::MUTED)), ]) }) .collect(); - let overflow = apps.len().saturating_sub(max_rows); - - let table = Table::new( - std::iter::once(header).chain(rows), - [ - Constraint::Percentage(20), - Constraint::Percentage(30), - Constraint::Length(8), - Constraint::Length(10), - Constraint::Length(8), - ], - ) - .column_spacing(1); - - f.render_widget(table, inner); - if overflow > 0 { - let hint = format!(" +{} more (see Apps tab) ", overflow); - let hint_len = hint.len() as u16; - if area.width > hint_len + 2 { - let hint_area = Rect::new(area.x + area.width - hint_len - 1, area.y, hint_len, 1); - f.render_widget( - Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)), - hint_area, - ); - } + rows.push(Row::new(vec![Cell::from(format!( + "+{overflow} more — press 3" + )) + .style(Style::default().fg(theme::MUTED))])); } -} - -// ── helpers ──────────────────────────────────────────── -fn inner_area(area: Rect) -> Rect { - Rect::new( - area.x + 2, - area.y + 1, - area.width.saturating_sub(4), - area.height.saturating_sub(2), - ) -} - -fn kv_row(label: &str, value: String, value_color: Color) -> Row<'static> { - Row::new(vec![ - Cell::from(label.to_string()).style(Style::default().fg(Color::DarkGray)), - Cell::from(value).style(Style::default().fg(value_color)), - ]) + f.render_widget( + Table::new( + std::iter::once(header).chain(rows), + [ + Constraint::Percentage(20), + Constraint::Percentage(35), + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(8), + ], + ) + .column_spacing(1), + inner, + ); } fn status_row(label: &str, count: u64, total: u64, bar_width: usize, color: Color) -> Row<'static> { @@ -384,55 +431,10 @@ fn status_row(label: &str, count: u64, total: u64, bar_width: usize, color: Colo 0.0 }; let filled = ((pct / 100.0) * bar_width as f64).round() as usize; - let bar: String = "\u{2588}".repeat(filled); - + let bar: String = "█".repeat(filled); Row::new(vec![ Cell::from(label.to_string()).style(Style::default().fg(color).bold()), - Cell::from(format_number(count)).style(Style::default().fg(Color::White)), + Cell::from(theme::fmt_num(count)).style(Style::default().fg(theme::FG)), Cell::from(bar).style(Style::default().fg(color)), ]) } - -fn format_bytes(bytes: u64) -> String { - const KB: u64 = 1024; - const MB: u64 = KB * 1024; - const GB: u64 = MB * 1024; - - if bytes >= GB { - format!("{:.2} GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.2} MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.2} KB", bytes as f64 / KB as f64) - } else { - format!("{} B", bytes) - } -} - -fn format_number(n: u64) -> String { - if n >= 1_000_000 { - format!("{:.1}M", n as f64 / 1_000_000.0) - } else if n >= 10_000 { - format!("{:.1}K", n as f64 / 1_000.0) - } else { - n.to_string() - } -} - -fn format_uptime(uptime: std::time::Duration) -> String { - let secs = uptime.as_secs(); - let days = secs / 86400; - let hours = (secs % 86400) / 3600; - let mins = (secs % 3600) / 60; - let s = secs % 60; - - if days > 0 { - format!("{}d {}h {}m", days, hours, mins) - } else if hours > 0 { - format!("{}h {}m {}s", hours, mins, s) - } else if mins > 0 { - format!("{}m {}s", mins, s) - } else { - format!("{}s", s) - } -} diff --git a/src/tui/screens/errors.rs b/src/tui/screens/errors.rs index 44e1c19..18a5b07 100644 --- a/src/tui/screens/errors.rs +++ b/src/tui/screens/errors.rs @@ -2,7 +2,7 @@ use ratatui::{ layout::{Constraint, Rect}, prelude::Stylize, style::{Color, Style}, - widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + widgets::{Cell, Paragraph, Row, Table}, Frame, }; @@ -15,12 +15,10 @@ pub fn render( selected_index: usize, scroll_offset: usize, ) { - let block = Block::default() - .title(format!(" Request Errors ({}) ", entries.len())) - .borders(Borders::ALL); + let block = crate::tui::theme::list_block(&format!("errors {}", entries.len())); f.render_widget(block, area); - let inner = Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2); + let inner = crate::tui::theme::body(area); if entries.is_empty() { let msg = Paragraph::new( @@ -33,7 +31,7 @@ pub fn render( } let header = Row::new(vec!["Time", "Status", "Method", "Host", "Path"]) - .style(Style::default().fg(Color::Green).bold()); + .style(Style::default().fg(crate::tui::theme::ACCENT).bold()); let max_rows = inner.height.saturating_sub(1) as usize; @@ -46,11 +44,7 @@ pub fn render( let visual_idx = scroll_offset + idx; let is_selected = visual_idx == selected_index; - let style = if is_selected { - Style::default().bg(Color::Blue).fg(Color::White) - } else { - Style::default().fg(Color::White) - }; + let style = crate::tui::theme::row_style(is_selected); // 5xx and connect failures are always red unless the row is selected. let status_style = if is_selected { style diff --git a/src/tui/screens/routes.rs b/src/tui/screens/routes.rs index 23b5663..f8dcde5 100644 --- a/src/tui/screens/routes.rs +++ b/src/tui/screens/routes.rs @@ -13,59 +13,40 @@ pub fn render( area: Rect, ctx: &TuiContext, selected_index: usize, + scroll_offset: usize, search_query: &str, ) { let rules = ctx.config_manager.get_config().rules.clone(); - let block = Block::default().title(" Routes ").borders(Borders::ALL); + let block = crate::tui::theme::list_block("routes"); f.render_widget(block, area); - let inner = Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2); + let inner = crate::tui::theme::body(area); if rules.is_empty() { - let text = Paragraph::new("No routes configured. Press 'a' to add a route."); + let text = Paragraph::new("No routes configured. Press a to add a route.") + .style(Style::default().fg(Color::DarkGray)); f.render_widget(text, inner); return; } let header = Row::new(vec!["#", "Matcher", "Targets", "Auth", "Scripts", "LB"]) - .style(Style::default().fg(Color::Green).bold()); + .style(Style::default().fg(crate::tui::theme::ACCENT).bold()); - let filtered_rules: Vec<(usize, &crate::config::ProxyRule)> = rules - .iter() - .enumerate() - .filter(|(idx, rule)| { - if search_query.is_empty() { - return true; - } - let search_lower = search_query.to_lowercase(); - let matcher_str = format_matcher(&rule.matcher).to_lowercase(); - let targets_str: String = rule - .targets - .iter() - .map(|t| t.url.to_string()) - .collect::>() - .join(", "); - let auth_str: String = rule - .auth - .iter() - .map(|a| a.username.as_str()) - .collect::>() - .join(", "); - let scripts_str = rule.scripts.join(", "); - matcher_str.contains(&search_lower) - || targets_str.to_lowercase().contains(&search_lower) - || auth_str.to_lowercase().contains(&search_lower) - || scripts_str.to_lowercase().contains(&search_lower) - || idx.to_string() == search_lower - }) - .collect(); + let filtered_rules: Vec<(usize, &crate::config::ProxyRule)> = + filter_indices(&rules, search_query) + .into_iter() + .map(|idx| (idx, &rules[idx])) + .collect(); + let max_rows = inner.height.saturating_sub(1) as usize; let rows: Vec = filtered_rules .iter() + .skip(scroll_offset) + .take(max_rows) .enumerate() .map(|(display_idx, (idx, rule))| { - let is_selected = display_idx == selected_index; + let is_selected = scroll_offset + display_idx == selected_index; let matcher_str = format_matcher(&rule.matcher); let targets_str: String = rule .targets @@ -94,11 +75,7 @@ pub fn render( format!("{} +{}", rule.scripts[0], rule.scripts.len() - 1) }; - let style = if is_selected { - Style::default().bg(Color::Blue).fg(Color::White) - } else { - Style::default().fg(Color::White) - }; + let style = crate::tui::theme::row_style(is_selected); let auth_style = if is_selected { style @@ -171,3 +148,44 @@ fn format_lb(lb: &crate::config::LoadBalancingStrategy) -> String { crate::config::LoadBalancingStrategy::Failover => "failover".to_string(), } } + +/// Indices of the rules matching `search_query`, in display order. +/// +/// Shared with `TuiApp::resolve_route_index` and `get_max_selection` so that a +/// row's position on screen always maps back to the same rule. These used to be +/// two separate predicates — this one matched on `format_matcher`, the other on +/// the matcher's `Debug` output — which meant a search could line up rows on +/// screen with a different rule than `d` would delete. +pub fn filter_indices(rules: &[crate::config::ProxyRule], search_query: &str) -> Vec { + if search_query.is_empty() { + return (0..rules.len()).collect(); + } + let needle = search_query.to_lowercase(); + rules + .iter() + .enumerate() + .filter(|(idx, rule)| { + let targets_str: String = rule + .targets + .iter() + .map(|t| t.url.to_string()) + .collect::>() + .join(", "); + let auth_str: String = rule + .auth + .iter() + .map(|a| a.username.as_str()) + .collect::>() + .join(", "); + let scripts_str = rule.scripts.join(", "); + format_matcher(&rule.matcher) + .to_lowercase() + .contains(&needle) + || targets_str.to_lowercase().contains(&needle) + || auth_str.to_lowercase().contains(&needle) + || scripts_str.to_lowercase().contains(&needle) + || idx.to_string() == needle + }) + .map(|(idx, _)| idx) + .collect() +} diff --git a/src/tui/theme.rs b/src/tui/theme.rs new file mode 100644 index 0000000..c2944be --- /dev/null +++ b/src/tui/theme.rs @@ -0,0 +1,300 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +/// Mint-on-ink palette — distinct from the old default-cyan boxes. +pub const ACCENT: Color = Color::Rgb(0, 212, 170); +pub const ACCENT_DIM: Color = Color::Rgb(0, 120, 100); +pub const SUCCESS: Color = Color::Rgb(80, 250, 123); +pub const WARN: Color = Color::Rgb(255, 184, 108); +pub const DANGER: Color = Color::Rgb(255, 85, 85); +pub const MUTED: Color = Color::Rgb(98, 114, 164); +pub const FG: Color = Color::Rgb(248, 248, 242); +pub const SELECT_BG: Color = Color::Rgb(15, 55, 52); +pub const SIDEBAR_BG: Color = Color::Rgb(18, 22, 28); +pub const INK: Color = Color::Rgb(10, 12, 16); +pub const MAGENTA: Color = Color::Rgb(189, 147, 249); + +pub const SIDEBAR_WIDTH: u16 = 16; + +pub const SCREEN_SHORT: [&str; 6] = ["dash", "routes", "apps", "circuits", "errors", "config"]; + +/// Rows between the top of the sidebar and the first nav entry (brand block). +const NAV_TOP_OFFSET: u16 = 3; + +pub fn selected_style() -> Style { + Style::default() + .bg(SELECT_BG) + .fg(FG) + .add_modifier(Modifier::BOLD) +} + +pub fn row_style(selected: bool) -> Style { + if selected { + selected_style() + } else { + Style::default().fg(FG) + } +} + +/// Title chip, no wrapping cyan box — the old UI was "everything in a cyan frame". +pub fn list_block(title: &str) -> Block<'static> { + Block::default() + .title(Span::styled( + format!(" {title} "), + Style::default() + .fg(INK) + .bg(ACCENT) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::LEFT) + .border_style(Style::default().fg(ACCENT_DIM)) +} + +/// Content area of a full `Borders::ALL` box. +pub fn inner(area: Rect) -> Rect { + Rect::new( + area.x.saturating_add(1), + area.y.saturating_add(1), + area.width.saturating_sub(2), + area.height.saturating_sub(2), + ) +} + +/// Content area of a [`list_block`]: one column for the left rule, one row for +/// the title chip. Nothing is drawn on the right or bottom edge, so unlike +/// [`inner`] this keeps those cells. +pub fn body(area: Rect) -> Rect { + Rect::new( + area.x.saturating_add(1), + area.y.saturating_add(1), + area.width.saturating_sub(1), + area.height.saturating_sub(1), + ) +} + +pub fn centered_modal(area: Rect, width: u16, height: u16) -> Rect { + let w = width.min(area.width.saturating_sub(2).max(1)); + let h = height.min(area.height.saturating_sub(2).max(1)); + let x = (area.width.saturating_sub(w)) / 2; + let y = (area.height.saturating_sub(h)) / 2; + Rect::new(area.x + x, area.y + y, w, h) +} + +pub fn split_shell(area: Rect) -> (Rect, Rect) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(SIDEBAR_WIDTH), Constraint::Min(10)]) + .split(area); + (cols[0], cols[1]) +} + +pub fn render_sidebar(f: &mut Frame, area: Rect, current_idx: usize, version: &str) { + f.render_widget( + Block::default().style(Style::default().bg(SIDEBAR_BG)), + area, + ); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(NAV_TOP_OFFSET), + Constraint::Length(SCREEN_SHORT.len() as u16 + 2), + Constraint::Min(0), + Constraint::Length(2), + ]) + .split(area); + + let brand = Paragraph::new(vec![ + Line::from(Span::styled( + " SOLI", + Style::default() + .fg(INK) + .bg(ACCENT) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + " proxy", + Style::default().fg(MUTED).bg(SIDEBAR_BG), + )), + ]); + f.render_widget(brand, chunks[0]); + + let mut lines = Vec::new(); + for (i, short) in SCREEN_SHORT.iter().enumerate() { + let active = i == current_idx; + let marker = if active { "▸" } else { " " }; + let label = format!(" {marker} {} {short:<8}", i + 1); + let style = if active { + Style::default() + .fg(ACCENT) + .bg(SELECT_BG) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(MUTED).bg(SIDEBAR_BG) + }; + lines.push(Line::from(Span::styled(label, style))); + } + f.render_widget(Paragraph::new(lines), chunks[1]); + + let foot = Paragraph::new(vec![ + Line::from(Span::styled( + format!(" v{version}"), + Style::default().fg(MUTED).bg(SIDEBAR_BG), + )), + Line::from(Span::styled( + " 1-6 ?", + Style::default().fg(ACCENT_DIM).bg(SIDEBAR_BG), + )), + ]); + f.render_widget(foot, chunks[3]); +} + +/// Nav item under the brand block, or `None` when the click misses the list. +pub fn nav_at(area: Rect, col: u16, row: u16) -> Option { + if col < area.x || col >= area.x.saturating_add(area.width) { + return None; + } + if row >= area.y.saturating_add(area.height) { + return None; + } + let first = area.y.saturating_add(NAV_TOP_OFFSET); + if row < first { + return None; + } + let idx = (row - first) as usize; + if idx < SCREEN_SHORT.len() { + Some(idx) + } else { + None + } +} + +pub fn spinner_frame(ticks: u64) -> char { + const FRAMES: [char; 4] = ['⠋', '⠙', '⠹', '⠸']; + FRAMES[(ticks as usize) % FRAMES.len()] +} + +pub fn fmt_bytes(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{bytes} B") + } +} + +pub fn fmt_num(n: u64) -> String { + if n >= 1_000_000 { + format!("{:.1}M", n as f64 / 1_000_000.0) + } else if n >= 10_000 { + format!("{:.1}K", n as f64 / 1_000.0) + } else { + n.to_string() + } +} + +pub fn fmt_ms(ms: f64) -> String { + if ms >= 1000.0 { + format!("{:.2}s", ms / 1000.0) + } else if ms >= 1.0 { + format!("{:.1}ms", ms) + } else if ms > 0.0 { + format!("{:.0}us", ms * 1000.0) + } else { + "-".to_string() + } +} + +pub fn fmt_uptime(uptime: std::time::Duration) -> String { + let secs = uptime.as_secs(); + let days = secs / 86400; + let hours = (secs % 86400) / 3600; + let mins = (secs % 3600) / 60; + let s = secs % 60; + if days > 0 { + format!("{days}d {hours}h {mins}m") + } else if hours > 0 { + format!("{hours}h {mins}m {s}s") + } else if mins > 0 { + format!("{mins}m {s}s") + } else { + format!("{s}s") + } +} + +pub fn rps_from_delta(prev: u64, next: u64, elapsed_secs: f64) -> u64 { + if elapsed_secs <= 0.0 || next < prev { + return 0; + } + ((next - prev) as f64 / elapsed_secs).round() as u64 +} + +pub fn render_toast(f: &mut Frame, area: Rect, message: &str) { + if area.height == 0 || message.is_empty() { + return; + } + f.render_widget(Clear, area); + let para = Paragraph::new(format!(" {message} ")) + .style( + Style::default() + .fg(INK) + .bg(ACCENT) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center); + f.render_widget(para, area); +} + +pub fn kpi(f: &mut Frame, area: Rect, value: &str, label: &str, color: Color) { + let block = Block::default() + .borders(Borders::LEFT) + .border_style(Style::default().fg(color)); + let inner = Rect::new( + area.x.saturating_add(2), + area.y, + area.width.saturating_sub(2), + area.height, + ); + f.render_widget(block, area); + let lines = vec![ + Line::from(Span::styled( + value, + Style::default().fg(color).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled(label, Style::default().fg(MUTED))), + ]; + f.render_widget(Paragraph::new(lines), inner); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rps_from_delta_basic() { + assert_eq!(rps_from_delta(100, 200, 1.0), 100); + assert_eq!(rps_from_delta(100, 100, 1.0), 0); + assert_eq!(rps_from_delta(200, 100, 1.0), 0); + assert_eq!(rps_from_delta(0, 50, 2.0), 25); + } + + #[test] + fn nav_at_picks_item() { + let area = Rect::new(0, 0, 16, 24); + assert_eq!(nav_at(area, 1, 3), Some(0)); + assert_eq!(nav_at(area, 1, 5), Some(2)); + assert_eq!(nav_at(area, 1, 2), None); + assert_eq!(nav_at(area, 20, 3), None); + } +}