diff --git a/.agent/skills/tui-development/SKILL.md b/.agent/skills/tui-development/SKILL.md new file mode 100644 index 000000000..6a8d8a7c0 --- /dev/null +++ b/.agent/skills/tui-development/SKILL.md @@ -0,0 +1,454 @@ +--- +name: tui-development +description: Guide for developing the "Gator" TUI — a ratatui-based terminal UI for the Navigator platform. Covers architecture, navigation, data fetching, theming, UX conventions, and development workflow. Trigger keywords - gator, TUI, terminal UI, ratatui, navigator-tui, tui development, gator feature, gator bug. +--- + +# Gator TUI Development Guide + +Comprehensive reference for any agent working on the Gator TUI. + +## 1. Overview + +Gator is a ratatui-based terminal UI for the Navigator platform. It provides a keyboard-driven interface for managing clusters, sandboxes, and logs — the same operations available via the `nav` CLI, but with a live, interactive dashboard. + +- **Launched via:** `nav gator` or `mise run gator` +- **Crate:** `crates/navigator-tui/` +- **Key dependencies:** + - `ratatui` (workspace version) — uses `frame.size()` (not `frame.area()`) + - `crossterm` (workspace version) — terminal backend and event polling + - `tonic` with TLS — gRPC client for the Navigator gateway + - `tokio` — async runtime for event loop, spawned tasks, and mpsc channels + - `navigator-core` — proto-generated types (`NavigatorClient`, request/response structs) + - `navigator-bootstrap` — cluster discovery (`list_clusters()`) +- **Theme:** NVIDIA-branded green on dark terminal background + +## 2. Domain Object Hierarchy + +The data model follows a strict hierarchy: **Cluster > Sandboxes > Logs**. + +``` +Cluster (discovered via navigator_bootstrap::list_clusters()) + └── Sandboxes (fetched via gRPC ListSandboxes) + └── Logs (fetched via GetSandboxLogs + streamed via WatchSandbox) +``` + +- **Clusters** are discovered from on-disk config via `navigator_bootstrap::list_clusters()`. Each cluster has a name, gateway endpoint, and local/remote flag. +- **Sandboxes** belong to the active cluster. Fetched via `ListSandboxes` gRPC call with a periodic tick refresh. Each sandbox has: `id`, `name`, `phase`, `created_at_ms`, and `spec.template.image`. +- **Logs** belong to a single sandbox. Initial batch fetched via `GetSandboxLogs` (500 lines), then live-tailed via `WatchSandbox` with `follow_logs: true`. + +The **title bar** always reflects this hierarchy, reading left-to-right from general to specific: + +``` + Gator │ Current Cluster: () │ +``` + +## 3. Navigation & Screen Architecture + +### Screens (`Screen` enum) + +Top-level layouts that own the full content area. Each has its own nav bar hints. + +| Screen | Description | Module | +| --- | --- | --- | +| `Dashboard` | Cluster list (top) + sandbox table (bottom) | `ui/dashboard.rs` | +| `Sandbox` | Single-sandbox view — detail or logs depending on `Focus` | `ui/sandbox_detail.rs`, `ui/sandbox_logs.rs` | + +### Focus (`Focus` enum) + +Tracks which panel currently receives keyboard input. + +| Focus | Screen | Description | +| --- | --- | --- | +| `Clusters` | Dashboard | Cluster list panel has input focus | +| `Sandboxes` | Dashboard | Sandbox table panel has input focus | +| `SandboxDetail` | Sandbox | Sandbox detail view (name, status, image, age) | +| `SandboxLogs` | Sandbox | Log viewer with structured rendering | + +### Screen dispatch + +The top-level `ui::draw()` function (`ui/mod.rs`) handles the chrome (title bar, nav bar, command bar) and dispatches to the correct screen module: + +```rust +match app.screen { + Screen::Dashboard => dashboard::draw(frame, app, chunks[1]), + Screen::Sandbox => draw_sandbox_screen(frame, app, chunks[1]), +} +``` + +Within the `Sandbox` screen, focus determines which sub-view renders: + +```rust +match app.focus { + Focus::SandboxLogs => sandbox_logs::draw(frame, app, area), + _ => sandbox_detail::draw(frame, app, area), +} +``` + +### Layout structure + +Every frame renders four vertical regions: + +``` +┌─────────────────────────────────────────────┐ +│ Title bar (1 row) — brand + cluster + context│ +├─────────────────────────────────────────────┤ +│ │ +│ Main content (flexible) │ +│ │ +├─────────────────────────────────────────────┤ +│ Nav bar (1 row) — context-sensitive key hints│ +├─────────────────────────────────────────────┤ +│ Command bar (1 row) — `:` command input │ +└─────────────────────────────────────────────┘ +``` + +### Title bar examples + +- Dashboard: ` Gator │ Current Cluster: navigator (Healthy) │ Dashboard` +- Sandbox detail: ` Gator │ Current Cluster: navigator (Healthy) │ Sandbox: my-sandbox` + +### Adding a new screen + +1. Add a variant to `Screen` in `app.rs`. +2. Create a new module under `src/ui/` with a `pub fn draw(frame, app, area)`. +3. Add the module declaration in `ui/mod.rs`. +4. Add a match arm in `ui::draw()` to dispatch to the new module. +5. Add relevant `Focus` variants if the screen has multiple panels. +6. Add key handling methods in `App` for the new focus states. +7. Add nav bar hints in `draw_nav_bar()` for the new screen/focus combinations. + +## 4. Data Fetching Pattern + +### Initial fetch first, then stream + +Always grab a batch of initial data so the UI has content immediately, then attach streaming for live updates. + +**Logs example** (`spawn_log_stream` in `lib.rs`): + +``` +Phase 1: GetSandboxLogs → 500 initial lines → send via Event::LogLines +Phase 2: WatchSandbox(follow_logs: true) → live tail → send via Event::LogLines +``` + +**Sandboxes**: Currently fetched via `ListSandboxes` on a 2-second tick. Could be enhanced with a watch mechanism. + +### Never block the event loop + +All network calls must be spawned as async tasks via `tokio::spawn`. The event loop in `lib.rs` must remain responsive to keyboard input and rendering at all times. + +**Pattern:** + +```rust +// Background task sends data back via mpsc channel +let handle = tokio::spawn(async move { + let result = client.some_rpc(request).await; + let _ = tx.send(Event::SomeData(result)); +}); +``` + +### Loading states + +Show `"Loading..."` while async data is in flight (see `sandbox_logs.rs` — renders a loading message when `filtered` is empty and `sandbox_log_lines` is also empty). + +### Event channel + +Background tasks communicate with the event loop via `mpsc::UnboundedSender`. The `EventHandler` provides a `sender()` method to clone the transmit handle: + +```rust +// In lib.rs +spawn_log_stream(&mut app, events.sender()); + +// In the spawned task +let _ = tx.send(Event::LogLines(lines)); +``` + +### gRPC timeouts + +All gRPC calls use a 5-second timeout via `tokio::time::timeout`: + +```rust +tokio::time::timeout(Duration::from_secs(5), client.health(req)).await +``` + +## 5. Style Guide & Colors + +### NVIDIA Green Theme (`theme.rs`) + +All colors and styles are defined in `crates/navigator-tui/src/theme.rs`. + +#### Colors (`theme::colors`) + +| Constant | Value | Usage | +| --- | --- | --- | +| `NVIDIA_GREEN` | `Color::Rgb(118, 185, 0)` | Primary accent — selections, active items, key hints | +| `EVERGLADE` | `Color::Rgb(18, 49, 35)` | Dark green — borders (unfocused), title bar background | +| `BG` | `Color::Black` | Terminal background | +| `FG` | `Color::White` | Default foreground text | + +#### Styles (`theme::styles`) + +| Constant | Definition | Usage | +| --- | --- | --- | +| `TEXT` | White foreground | Default body text | +| `MUTED` | White + DIM modifier | Secondary info, separators (`│`), unfocused items | +| `HEADING` | White + BOLD | Panel titles, sandbox/cluster names when active | +| `ACCENT` | NVIDIA_GREEN foreground | Selected row marker (`▌`), sandbox source labels | +| `ACCENT_BOLD` | NVIDIA_GREEN + BOLD | "Gator" brand text, command prompt `:` | +| `SELECTED` | BOLD modifier only | Selected row text emphasis | +| `BORDER` | EVERGLADE foreground | Unfocused panel borders | +| `BORDER_FOCUSED` | NVIDIA_GREEN foreground | Focused panel borders | +| `STATUS_OK` | NVIDIA_GREEN foreground | Healthy status, INFO log level, Ready phase | +| `STATUS_WARN` | Yellow foreground | Degraded status, WARN log level, Provisioning phase | +| `STATUS_ERR` | Red foreground | Unhealthy status, ERROR log level, Error phase | +| `KEY_HINT` | NVIDIA_GREEN foreground | Keyboard shortcut labels in nav bar (e.g., `[Tab]`) | +| `TITLE_BAR` | White on EVERGLADE + BOLD | Title bar background strip | + +#### Visual conventions + +- **Selected row**: Green `▌` left-border marker on the selected row. Active cluster also gets a green `●` dot. +- **Focused panel**: Border changes from `EVERGLADE` to `NVIDIA_GREEN`. +- **Status indicators**: Green for healthy/ready/info, yellow for degraded/provisioning/warn, red for unhealthy/error. +- **Separators**: Muted `│` characters between title bar segments and nav bar sections. +- **Log source labels**: `"sandbox"` source renders in `ACCENT` (green), `"gateway"` in `MUTED`. + +## 6. UX Conventions + +### Destructive actions require confirmation + +Always show a y/n confirm dialog before delete, stop, or other irreversible operations. + +``` +Delete sandbox 'my-sandbox'? [y] Confirm [Esc] Cancel +``` + +The `confirm_delete` flag in `App` gates destructive key handling — while true, only `y`, `n`, and `Esc` are processed. + +### CLI parity + +Gator actions should parallel `nav` CLI commands so users have familiar mental models: + +| CLI Command | Gator Equivalent | +| --- | --- | +| `nav sandbox list` | Sandbox table on Dashboard | +| `nav sandbox delete ` | `[d]` on sandbox detail, then `[y]` to confirm | +| `nav sandbox logs ` | `[l]` on sandbox detail to open log viewer | +| `nav cluster health` | Status in title bar + cluster list | + +When adding new TUI features, check what the CLI offers and maintain consistency. + +### Scrollable views follow k9s conventions + +Any scrollable content (logs, future long lists) should follow the k9s autoscroll pattern: + +- **Autoscroll on by default** — when entering a scrollable view, it auto-follows new content +- **Scrolling up pauses** — any upward scroll (keyboard or mouse) disables autoscroll +- **`f` or `G` re-enables** — jump to bottom and resume following +- **Visual indicator** — show `● FOLLOWING` (green) or `○ PAUSED` (yellow) in the panel footer +- **Mouse scroll supported** — `ScrollUp`/`ScrollDown` events move by 3 lines and respect autoscroll state +- **Scroll position shown** — `[current/total]` in the panel footer + +State is tracked via `log_autoscroll: bool` on `App`. The `scroll_logs(delta)` method handles both keyboard and mouse input uniformly. + +### Long content: truncate + detail popup + +When content can exceed the viewport width (log lines, field lists, etc.): + +- **Truncate in the list view** — hard-cut at the viewport's inner width and append `…`. This keeps density high and avoids wrapping that breaks the 1-line-per-entry model. +- **Enter opens a detail popup** — a centered overlay showing the full untruncated content with word-wrap. `Esc` or `Enter` closes it. Track the open state via `Option` index. +- **Drop noise in the list view** — omit empty fields, remove developer-internal info (like module paths / tracing targets) that the user doesn't need at a glance. +- **Smart field ordering** — for known message types (e.g. CONNECT, L7_REQUEST), put the most important fields first and trail with process ancestry / noise. Unknown types sort alphabetically. +- **Show everything in the popup** — the detail popup is where target, all fields (including empty ones if useful), and the full message are visible. + +This pattern should be reused for any future view with potentially long entries. + +### Vim-style navigation + +| Key | Action | +| --- | --- | +| `j` / `Down` | Move selection down | +| `k` / `Up` | Move selection up | +| `g` | Jump to top (logs), disables autoscroll | +| `G` | Jump to bottom (logs), re-enables autoscroll | +| `f` | Follow / re-enable autoscroll (logs) | +| `Tab` / `BackTab` | Switch between panels on Dashboard | +| `Enter` | Select / drill into item; open detail popup in logs | +| `Esc` | Go back one level | +| `q` | Quit (from any screen) | +| `Ctrl+C` | Force quit | + +### Keyboard-first, mouse-augmented + +All actions are accessible via keyboard shortcuts displayed in the nav bar. The nav bar is context-sensitive — it shows different hints depending on the current screen and focus state. Mouse scrolling is supported as a convenience but never required — every action must have a keyboard equivalent. + +### Command mode + +`:` enters command mode (like vim). The command bar renders at the bottom with a green `:` prompt and a block cursor. Currently supports: + +- `:q` / `:quit` — exit the application + +`Esc` returns to normal mode. `Enter` executes the command. + +### Screen-specific key hints + +**Dashboard (Clusters focus):** +`[Tab] Switch Panel [Enter] Select [j/k] Navigate │ [:] Command [q] Quit` + +**Dashboard (Sandboxes focus):** +Same as above. + +**Sandbox (Detail focus):** +`[l] Logs [d] Delete │ [Esc] Back to Dashboard [q] Quit` + +**Sandbox (Logs focus):** +`[j/k] Scroll [Enter] Detail [g/G] Top/Bottom [f] Follow [s] Source: │ [Esc] Back [q] Quit` + +## 7. Architecture & Key Files + +| File | Purpose | +| --- | --- | +| `crates/navigator-tui/Cargo.toml` | Crate manifest — dependencies on `navigator-core`, `navigator-bootstrap`, `ratatui`, `crossterm`, `tonic`, `tokio` | +| `crates/navigator-tui/src/lib.rs` | Entry point. Event loop, gRPC calls (`refresh_health`, `refresh_sandboxes`, `spawn_log_stream`, `handle_sandbox_delete`), cluster switching, mTLS channel building | +| `crates/navigator-tui/src/app.rs` | `App` state struct, `Screen`/`Focus`/`InputMode`/`LogSourceFilter` enums, `LogLine` struct, `ClusterEntry`, all key handling logic | +| `crates/navigator-tui/src/event.rs` | `Event` enum (`Key`, `Mouse`, `Tick`, `Resize`, `LogLines`), `EventHandler` with mpsc channels and crossterm polling | +| `crates/navigator-tui/src/theme.rs` | `colors` module (NVIDIA_GREEN, EVERGLADE, BG, FG) and `styles` module (all `Style` constants) | +| `crates/navigator-tui/src/ui/mod.rs` | Top-level `draw()` dispatcher, `draw_title_bar`, `draw_nav_bar`, `draw_command_bar`, screen routing | +| `crates/navigator-tui/src/ui/dashboard.rs` | Dashboard screen — cluster list table (top) + sandbox table (bottom) | +| `crates/navigator-tui/src/ui/sandboxes.rs` | Reusable sandbox table widget with columns: Name, Status, Created, Age, Image | +| `crates/navigator-tui/src/ui/sandbox_detail.rs` | Sandbox detail view — name, status, image, created, age, delete confirmation dialog | +| `crates/navigator-tui/src/ui/sandbox_logs.rs` | Structured log viewer — timestamp, source, level, target, message, key=value fields, scroll position, source filter | + +### Module dependency flow + +``` +lib.rs (event loop, gRPC, async tasks) + ├── app.rs (state + key handling) + ├── event.rs (Event enum + EventHandler) + ├── theme.rs (colors + styles) + └── ui/ + ├── mod.rs (draw dispatcher, chrome) + ├── dashboard.rs (cluster list + sandbox table layout) + ├── sandboxes.rs (sandbox table widget) + ├── sandbox_detail.rs (detail view) + └── sandbox_logs.rs (log viewer) +``` + +## 8. Technical Notes + +### Dependency constraints + +- **`navigator-tui` cannot depend on `navigator-cli`** — this would create a circular dependency. TLS channel building for cluster switching is done directly in `lib.rs` using `tonic::transport` primitives (`Certificate`, `Identity`, `ClientTlsConfig`, `Endpoint`). +- mTLS certs are read from `~/.config/navigator/clusters//mtls/` (ca.crt, tls.crt, tls.key). + +### Proto generated code + +Proto types come from `navigator-core` which generates them from `OUT_DIR` via `include!`. They are **not** checked into the repo. Import paths look like: + +```rust +use navigator_core::proto::navigator_client::NavigatorClient; +use navigator_core::proto::{ListSandboxesRequest, GetSandboxLogsRequest, ...}; +``` + +### Proto field gotchas + +- `DeleteSandboxRequest` uses the `name` field (not `id`): + ```rust + let req = navigator_core::proto::DeleteSandboxRequest { name: sandbox_name }; + ``` +- `WatchSandboxRequest` has extra fields beyond what you might need — always use `..Default::default()`: + ```rust + let req = navigator_core::proto::WatchSandboxRequest { + id: sandbox_id, + follow_status: false, + follow_logs: true, + follow_events: false, + log_tail_lines: 0, + ..Default::default() + }; + ``` +- `SandboxLogLine` proto fields: `sandbox_id`, `timestamp_ms`, `level`, `target`, `message`, `source`, `fields` (HashMap). +- `GetSandboxLogsRequest` fields: `sandbox_id`, `lines` (u32), `since_ms` (i64), `sources` (Vec), `min_level` (String). +- `ListSandboxesRequest` fields: `limit` (i64), `offset` (i64). + +### gRPC timeouts + +All gRPC calls use a 5-second timeout: + +```rust +tokio::time::timeout(Duration::from_secs(5), client.health(req)).await +``` + +The connect timeout for cluster switching is 10 seconds with HTTP/2 keepalive at 10-second intervals. + +### Log streaming lifecycle + +1. User presses `[l]` on sandbox detail → `pending_log_fetch = true` +2. Event loop sees the flag → calls `spawn_log_stream()` +3. Previous stream handle is aborted via `cancel_log_stream()` +4. New `tokio::spawn` task: fetches initial 500 lines, then streams via `WatchSandbox` +5. Lines arrive as `Event::LogLines` and are appended to `app.sandbox_log_lines` +6. Auto-scroll kicks in if the user is near the bottom (within 5 lines) +7. Stream is cancelled when user presses `Esc` or navigates away (handle is `.abort()`ed) + +### Cluster switching lifecycle + +1. User selects a different cluster and presses `Enter` → `pending_cluster_switch = Some(name)` +2. Event loop calls `handle_cluster_switch()` +3. New mTLS channel is built via `connect_to_cluster()` +4. On success: `app.client` is replaced, `reset_sandbox_state()` clears all sandbox data, `refresh_data()` fetches health + sandboxes for the new cluster +5. On failure: `status_text` shows the error + +## 9. Development Workflow + +### Build and run + +```bash +# Build the crate +cargo build -p navigator-tui + +# Run gator against the active cluster +mise run gator + +# Run with cargo-watch for hot-reload during development +mise run gator:dev + +# Format +cargo fmt -p navigator-tui + +# Lint +cargo clippy -p navigator-tui +``` + +### Pre-commit + +Always run before committing: + +```bash +mise run pre-commit +``` + +### Cluster changes + +If you change sandbox or server code that affects the backend, redeploy the cluster: + +```bash +mise run cluster:deploy all +``` + +To pick up new sandbox images after changing sandbox code, delete the pod manually so it gets recreated: + +```bash +kubectl delete pod -n +``` + +### Adding a new gRPC call + +1. Check the proto definitions in `navigator-core` for available RPCs and message types. +2. Add the call in `lib.rs` following the existing pattern (timeout wrapper, error handling, state update). +3. If the call is triggered by a key press, add a `pending_*` flag to `App` and handle it in the event loop. +4. If the call returns streaming data, spawn it as a background task and send results via `Event` variants. + +### Adding a new Event variant + +1. Add the variant to `Event` in `event.rs`. +2. Handle it in the `match events.next().await` block in `lib.rs`. +3. Update `App` state as needed from the event data. diff --git a/Cargo.lock b/Cargo.lock index e518dac9a..716665e0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -555,6 +555,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -702,6 +717,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -820,6 +848,22 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -828,7 +872,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "mio", + "mio 1.1.1", "parking_lot", "rustix 0.38.44", "signal-hook", @@ -2111,6 +2155,24 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2444,6 +2506,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2539,6 +2610,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.1.1" @@ -2593,7 +2676,7 @@ dependencies = [ "bytes", "clap", "clap_complete", - "crossterm", + "crossterm 0.28.1", "dialoguer", "futures", "http-body-util", @@ -2604,7 +2687,9 @@ dependencies = [ "miette", "navigator-bootstrap", "navigator-core", + "navigator-policy", "navigator-providers", + "navigator-tui", "owo-colors", "prost-types", "rcgen", @@ -2641,12 +2726,21 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "navigator-policy" +version = "0.1.0" +dependencies = [ + "miette", + "navigator-core", + "serde", + "serde_yaml", +] + [[package]] name = "navigator-providers" version = "0.1.0" dependencies = [ "navigator-core", - "serde_json", "thiserror 2.0.18", ] @@ -2753,6 +2847,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "navigator-tui" +version = "0.1.0" +dependencies = [ + "crossterm 0.28.1", + "miette", + "navigator-bootstrap", + "navigator-core", + "navigator-policy", + "navigator-providers", + "owo-colors", + "ratatui", + "serde", + "tokio", + "tonic", + "tracing", + "url", +] + [[package]] name = "nix" version = "0.29.0" @@ -3013,6 +3126,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pastey" version = "0.1.1" @@ -3332,7 +3451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -3352,7 +3471,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.114", @@ -3511,6 +3630,26 @@ version = "0.10.0-rc-3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f66ee92bc15280519ef199a274fe0cafff4245d31bc39aaa31c011ad56cb1f05" +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm 0.27.0", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.1.14", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -4227,7 +4366,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio", + "mio 0.8.11", + "mio 1.1.1", "signal-hook", ] @@ -4550,12 +4690,28 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn 2.0.114", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stringprep" version = "0.1.5" @@ -4573,6 +4729,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4820,7 +4998,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.1", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -5145,6 +5323,23 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" diff --git a/architecture/gator.md b/architecture/gator.md new file mode 100644 index 000000000..bd64a8de9 --- /dev/null +++ b/architecture/gator.md @@ -0,0 +1,143 @@ +# Gator: Navigator TUI + +Gator is a terminal user interface for Navigator, inspired by [k9s](https://k9scli.io/). Instead of typing individual CLI commands to check cluster health, list sandboxes, and manage resources, Gator gives you a real-time, keyboard-driven dashboard — everything updates automatically and you navigate with a few keystrokes. + +## Launching Gator + +Gator is a subcommand of the Navigator CLI, so it inherits all your existing configuration — cluster selection, TLS settings, and verbosity flags all work the same way. + +```bash +navigator gator # launch against the active cluster +nav gator # dev alias (builds from source) +nav gator --cluster prod # target a specific cluster +NAVIGATOR_CLUSTER=prod nav gator # same thing, via environment variable +``` + +Cluster resolution follows the same priority as the rest of the CLI: + +1. `--cluster` flag (if provided) +2. `NAVIGATOR_CLUSTER` environment variable +3. Active cluster from `~/.config/navigator/active_cluster` + +No separate configuration files or authentication are needed. + +## Screen Layout + +Gator divides the terminal into four horizontal regions: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ gator ─ my-cluster ─ Dashboard ● Healthy │ ← title bar +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ (view content — Dashboard or Sandboxes) │ ← main area +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ [1] Dashboard [2] Sandboxes │ [?] Help [q] Quit │ ← nav bar +├─────────────────────────────────────────────────────────────────┤ +│ : │ ← command bar +└─────────────────────────────────────────────────────────────────┘ +``` + +- **Title bar** — shows the Gator logo, cluster name, current view, and live cluster health status. +- **Main area** — the active view (Dashboard or Sandboxes). +- **Navigation bar** — lists available views with their shortcut keys, plus Help and Quit. +- **Command bar** — appears when you press `:` to type a command (like vim). + +## Views + +### Dashboard (press `1`) + +The Dashboard is the home screen. It shows your cluster at a glance: + +- **Cluster name** and **gateway endpoint** — which cluster you are connected to and how to reach it. +- **Health status** — a live indicator that polls the cluster every 2 seconds: + - `●` **Healthy** (green) — everything is running normally. + - `◐` **Degraded** (yellow) — the cluster is up but something needs attention. + - `○` **Unhealthy** (red) — the cluster is not operating correctly. + - `…` — still connecting or status unknown. +- **Sandbox count** — how many sandboxes exist in the cluster. + +### Sandboxes (press `2`) + +The Sandboxes view shows a table of all sandboxes in the cluster: + +| Column | Description | +|--------|-------------| +| NAME | Sandbox name | +| STATUS | Current phase, color-coded (see below) | +| AGE | Time since creation (e.g., `45s`, `12m`, `3h 20m`, `2d 5h`) | +| IMAGE | Container image the sandbox is running | + +Status colors tell you the sandbox state at a glance: + +- **Green** — Ready (sandbox is running and accessible) +- **Yellow** — Provisioning (sandbox is starting up) +- **Red** — Error (something went wrong) +- **Dim** — Deleting or Unknown + +Use `j`/`k` or the arrow keys to move through the list. The selected row is highlighted in green. + +When there are no sandboxes, the view displays: *"No sandboxes found."* + +## Keyboard Controls + +Gator has two input modes: **Normal** (default) and **Command** (activated by pressing `:`). + +### Normal Mode + +| Key | Action | +|-----|--------| +| `1` | Switch to Dashboard view | +| `2` | Switch to Sandboxes view | +| `j` or `↓` | Move selection down | +| `k` or `↑` | Move selection up | +| `:` | Enter command mode | +| `q` | Quit Gator | +| `Ctrl+C` | Force quit | + +### Command Mode + +Press `:` to open the command bar at the bottom of the screen. Type a command and press `Enter` to execute it. + +| Command | Action | +|---------|--------| +| `quit` or `q` | Quit Gator | +| `dashboard` or `1` | Switch to Dashboard view | +| `sandboxes` or `2` | Switch to Sandboxes view | + +Press `Esc` to cancel and return to Normal mode. `Backspace` deletes characters as you type. + +## Data Refresh + +Gator automatically polls the cluster every **2 seconds**. Both cluster health and the sandbox list update on each tick, so the display stays current without manual refreshing. This uses the same gRPC calls as the CLI — no additional server-side setup is required. + +## Theme + +Gator uses a dark terminal theme based on the NVIDIA brand palette: + +- **Background**: Black — the standard terminal background. +- **Text**: White for primary content, dimmed white for labels and secondary information. +- **Accent**: NVIDIA Green (`#76b900`) — used for the selected row, active tab indicator, and healthy/ready status. +- **Borders**: Everglade (`#123123`) — subtle dark green for structural separators. +- **Status**: Green for healthy/ready, yellow for pending/provisioning, red for error/unhealthy. + +The title bar uses white text on an Everglade background to visually anchor the top of the screen. + +## What is Not Yet Available + +Gator is in its initial phase. The following features are planned but not yet implemented: + +- **Sandbox operations** — creating, connecting to (SSH), deleting, and viewing logs for sandboxes. +- **Inference and provider views** — browsing inference routes and provider configurations. +- **Help overlay** — the `?` key is shown in the nav bar but does not open a help screen yet. +- **Command bar autocomplete** — the command bar accepts text but does not offer suggestions. +- **Filtering and search** — no `/` search within views yet. + +See the [Gator design plan](plans/gator-tui.md) for the full roadmap, including mockups and future phases. + +## Crate Structure + +The TUI lives in `crates/navigator-tui/`, a separate workspace crate. The CLI crate (`crates/navigator-cli/`) depends on it and launches it via the `Gator` command variant in the `Commands` enum. This keeps TUI-specific dependencies (ratatui, crossterm) out of the CLI when not in use. + +The `navigator-tui` crate depends on `navigator-core` for protobuf types and the gRPC client — it communicates with the gateway over the same gRPC channel the CLI uses. diff --git a/build/gator.toml b/build/gator.toml new file mode 100644 index 000000000..2fa129b6c --- /dev/null +++ b/build/gator.toml @@ -0,0 +1,9 @@ +# Gator TUI tasks + +[gator] +description = "Launch the Gator TUI" +run = "nav gator" + +["gator:dev"] +description = "Launch the Gator TUI with hot-reload on file changes" +run = "cargo watch -s 'nav gator'" diff --git a/crates/navigator-cli/Cargo.toml b/crates/navigator-cli/Cargo.toml index 76561ed61..dd16246d9 100644 --- a/crates/navigator-cli/Cargo.toml +++ b/crates/navigator-cli/Cargo.toml @@ -14,7 +14,9 @@ path = "src/main.rs" [dependencies] navigator-bootstrap = { path = "../navigator-bootstrap" } navigator-core = { path = "../navigator-core" } +navigator-policy = { path = "../navigator-policy" } navigator-providers = { path = "../navigator-providers" } +navigator-tui = { path = "../navigator-tui" } serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } diff --git a/crates/navigator-cli/src/main.rs b/crates/navigator-cli/src/main.rs index ee8b62304..46948895c 100644 --- a/crates/navigator-cli/src/main.rs +++ b/crates/navigator-cli/src/main.rs @@ -131,6 +131,9 @@ enum Commands { command: ProviderCommands, }, + /// Launch the Gator interactive TUI. + Gator, + /// Generate shell completions. #[command(after_long_help = COMPLETIONS_HELP)] Completions { @@ -269,6 +272,7 @@ impl CliProviderType { #[derive(Subcommand, Debug)] enum ProviderCommands { /// Create a provider config. + #[command(group = clap::ArgGroup::new("cred_source").required(true).args(["from_existing", "credentials"]))] Create { /// Provider name. #[arg(long)] @@ -1250,6 +1254,12 @@ async fn main() -> Result<()> { } } } + Some(Commands::Gator) => { + let ctx = resolve_cluster(&cli.cluster)?; + let tls = tls.with_cluster_name(&ctx.name); + let channel = navigator_cli::tls::build_channel(&ctx.endpoint, &tls).await?; + navigator_tui::run(channel, &ctx.name, &ctx.endpoint).await?; + } Some(Commands::Completions { shell }) => { let exe = std::env::current_exe() .map_err(|e| miette::miette!("failed to find current executable: {e}"))?; diff --git a/crates/navigator-cli/src/run.rs b/crates/navigator-cli/src/run.rs index 05967e814..902b7f3b8 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -23,17 +23,16 @@ use navigator_core::proto::{ DeleteInferenceRouteRequest, DeleteProviderRequest, DeleteSandboxRequest, GetProviderRequest, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, InferenceRoute, InferenceRouteSpec, ListInferenceRoutesRequest, ListProvidersRequest, - ListSandboxPoliciesRequest, ListSandboxesRequest, NetworkBinary, NetworkEndpoint, - NetworkPolicyRule, PolicyStatus, Provider, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, - SandboxTemplate, UpdateInferenceRouteRequest, UpdateProviderRequest, - UpdateSandboxPolicyRequest, WatchSandboxRequest, + ListSandboxPoliciesRequest, ListSandboxesRequest, PolicyStatus, Provider, Sandbox, + SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, UpdateInferenceRouteRequest, + UpdateProviderRequest, UpdateSandboxPolicyRequest, WatchSandboxRequest, }; use navigator_providers::{ ProviderRegistry, detect_provider_from_command, normalize_provider_type, }; use owo_colors::OwoColorize; use reqwest::StatusCode as ReqwestStatusCode; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use std::collections::{HashMap, HashSet, VecDeque}; use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; @@ -1258,206 +1257,12 @@ pub async fn sandbox_create( } /// Default sandbox policy YAML, baked in at compile time. -const DEFAULT_SANDBOX_POLICY_YAML: &str = include_str!("../../../dev-sandbox-policy.yaml"); - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DevSandboxPolicyFile { - version: u32, - #[serde(default)] - inference: Option, - #[serde(default)] - filesystem_policy: Option, - #[serde(default)] - landlock: Option, - #[serde(default)] - process: Option, - #[serde(default)] - network_policies: HashMap, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DevFilesystemPolicy { - #[serde(default)] - include_workdir: bool, - #[serde(default)] - read_only: Vec, - #[serde(default)] - read_write: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DevLandlockPolicy { - #[serde(default)] - compatibility: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DevProcessPolicy { - #[serde(default)] - run_as_user: String, - #[serde(default)] - run_as_group: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DevInferencePolicy { - #[serde(default)] - allowed_routes: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DevNetworkPolicyRule { - #[serde(default)] - name: String, - #[serde(default)] - endpoints: Vec, - #[serde(default)] - binaries: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DevNetworkEndpoint { - host: String, - port: u32, - #[serde(default)] - protocol: String, - #[serde(default)] - tls: String, - #[serde(default)] - enforcement: String, - #[serde(default)] - access: String, - #[serde(default)] - rules: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DevL7Rule { - allow: DevL7Allow, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DevL7Allow { - #[serde(default)] - method: String, - #[serde(default)] - path: String, - #[serde(default)] - command: String, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DevNetworkBinary { - path: String, - /// Deprecated: ignored. Kept for backward compat with existing YAML files. - #[serde(default)] - #[allow(dead_code)] - harness: bool, -} - /// Load sandbox policy YAML. /// /// Resolution order: `--policy` flag > `NAVIGATOR_SANDBOX_POLICY` env var > built-in default. +/// Delegates to `navigator_policy::load_sandbox_policy`. fn load_sandbox_policy(cli_path: Option<&str>) -> Result { - let contents = if let Some(p) = cli_path { - let path = Path::new(p); - std::fs::read_to_string(path) - .into_diagnostic() - .wrap_err_with(|| format!("failed to read sandbox policy from {}", path.display()))? - } else if let Ok(policy_path) = std::env::var("NAVIGATOR_SANDBOX_POLICY") { - let path = Path::new(&policy_path); - std::fs::read_to_string(path) - .into_diagnostic() - .wrap_err_with(|| format!("failed to read sandbox policy from {}", path.display()))? - } else { - DEFAULT_SANDBOX_POLICY_YAML.to_string() - }; - let raw: DevSandboxPolicyFile = serde_yaml::from_str(&contents) - .into_diagnostic() - .wrap_err("failed to parse sandbox policy yaml")?; - - let network_policies = raw - .network_policies - .into_iter() - .map(|(key, rule)| { - let proto_rule = NetworkPolicyRule { - name: if rule.name.is_empty() { - key.clone() - } else { - rule.name - }, - endpoints: rule - .endpoints - .into_iter() - .map(|e| NetworkEndpoint { - host: e.host, - port: e.port, - protocol: e.protocol, - tls: e.tls, - enforcement: e.enforcement, - access: e.access, - rules: e - .rules - .into_iter() - .map(|r| navigator_core::proto::L7Rule { - allow: Some(navigator_core::proto::L7Allow { - method: r.allow.method, - path: r.allow.path, - command: r.allow.command, - }), - }) - .collect(), - }) - .collect(), - binaries: rule - .binaries - .into_iter() - .map(|b| NetworkBinary { - path: b.path, - ..Default::default() - }) - .collect(), - }; - (key, proto_rule) - }) - .collect(); - - Ok(SandboxPolicy { - version: raw.version, - filesystem: raw - .filesystem_policy - .map(|fs| navigator_core::proto::FilesystemPolicy { - include_workdir: fs.include_workdir, - read_only: fs.read_only, - read_write: fs.read_write, - }), - landlock: raw - .landlock - .map(|ll| navigator_core::proto::LandlockPolicy { - compatibility: ll.compatibility, - }), - process: raw.process.map(|p| navigator_core::proto::ProcessPolicy { - run_as_user: p.run_as_user, - run_as_group: p.run_as_group, - }), - network_policies, - inference: raw - .inference - .map(|inf| navigator_core::proto::InferencePolicy { - allowed_routes: inf.allowed_routes, - ..Default::default() - }), - }) + navigator_policy::load_sandbox_policy(cli_path) } /// Sync files to or from a sandbox. @@ -2255,6 +2060,13 @@ pub async fn provider_create( } } + if credential_map.is_empty() { + return Err(miette::miette!( + "no credentials resolved for provider type '{provider_type}'. \ + Use --credential KEY[=VALUE] or --from-existing with the appropriate env vars set." + )); + } + let response = client .create_provider(CreateProviderRequest { provider: Some(Provider { diff --git a/crates/navigator-cli/src/tls.rs b/crates/navigator-cli/src/tls.rs index efae2a7ac..e49286b4a 100644 --- a/crates/navigator-cli/src/tls.rs +++ b/crates/navigator-cli/src/tls.rs @@ -199,7 +199,7 @@ pub fn build_tonic_tls_config(materials: &TlsMaterials) -> ClientTlsConfig { .identity(identity) } -pub async fn grpc_client(server: &str, tls: &TlsOptions) -> Result> { +pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result { let mut endpoint = Endpoint::from_shared(server.to_string()) .into_diagnostic()? .connect_timeout(Duration::from_secs(10)) @@ -208,7 +208,11 @@ pub async fn grpc_client(server: &str, tls: &TlsOptions) -> Result Result> { + let channel = build_channel(server, tls).await?; Ok(NavigatorClient::new(channel)) } diff --git a/crates/navigator-policy/Cargo.toml b/crates/navigator-policy/Cargo.toml new file mode 100644 index 000000000..e48d96f31 --- /dev/null +++ b/crates/navigator-policy/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "navigator-policy" +description = "Shared sandbox policy parsing and defaults for Navigator" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +navigator-core = { path = "../navigator-core" } +serde = { workspace = true } +serde_yaml = { workspace = true } +miette = { workspace = true } + +[lints] +workspace = true diff --git a/crates/navigator-policy/src/lib.rs b/crates/navigator-policy/src/lib.rs new file mode 100644 index 000000000..2b169f5a6 --- /dev/null +++ b/crates/navigator-policy/src/lib.rs @@ -0,0 +1,252 @@ +//! Shared sandbox policy parsing and defaults for Navigator. +//! +//! Provides YAML→proto conversion for sandbox policies, with a built-in +//! default policy embedded from `dev-sandbox-policy.yaml`. + +use std::collections::HashMap; + +use miette::{IntoDiagnostic, Result, WrapErr}; +use navigator_core::proto::{ + FilesystemPolicy, L7Allow, L7Rule, LandlockPolicy, NetworkBinary, NetworkEndpoint, + NetworkPolicyRule, ProcessPolicy, SandboxPolicy, +}; +use serde::Deserialize; + +/// Built-in default sandbox policy YAML (embedded at compile time). +const DEFAULT_SANDBOX_POLICY_YAML: &str = include_str!("../../../dev-sandbox-policy.yaml"); + +// --------------------------------------------------------------------------- +// YAML serde types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PolicyFile { + version: u32, + #[serde(default)] + inference: Option, + #[serde(default)] + filesystem_policy: Option, + #[serde(default)] + landlock: Option, + #[serde(default)] + process: Option, + #[serde(default)] + network_policies: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FilesystemDef { + #[serde(default)] + include_workdir: bool, + #[serde(default)] + read_only: Vec, + #[serde(default)] + read_write: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct LandlockDef { + #[serde(default)] + compatibility: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProcessDef { + #[serde(default)] + run_as_user: String, + #[serde(default)] + run_as_group: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct InferenceDef { + #[serde(default)] + allowed_routes: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct NetworkPolicyRuleDef { + #[serde(default)] + name: String, + #[serde(default)] + endpoints: Vec, + #[serde(default)] + binaries: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct NetworkEndpointDef { + host: String, + port: u32, + #[serde(default)] + protocol: String, + #[serde(default)] + tls: String, + #[serde(default)] + enforcement: String, + #[serde(default)] + access: String, + #[serde(default)] + rules: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct L7RuleDef { + allow: L7AllowDef, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct L7AllowDef { + #[serde(default)] + method: String, + #[serde(default)] + path: String, + #[serde(default)] + command: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct NetworkBinaryDef { + path: String, + /// Deprecated: ignored. Kept for backward compat with existing YAML files. + #[serde(default)] + #[allow(dead_code)] + harness: bool, +} + +// --------------------------------------------------------------------------- +// YAML → proto conversion +// --------------------------------------------------------------------------- + +fn convert_policy(raw: PolicyFile) -> SandboxPolicy { + let network_policies = raw + .network_policies + .into_iter() + .map(|(key, rule)| { + let proto_rule = NetworkPolicyRule { + name: if rule.name.is_empty() { + key.clone() + } else { + rule.name + }, + endpoints: rule + .endpoints + .into_iter() + .map(|e| NetworkEndpoint { + host: e.host, + port: e.port, + protocol: e.protocol, + tls: e.tls, + enforcement: e.enforcement, + access: e.access, + rules: e + .rules + .into_iter() + .map(|r| L7Rule { + allow: Some(L7Allow { + method: r.allow.method, + path: r.allow.path, + command: r.allow.command, + }), + }) + .collect(), + }) + .collect(), + binaries: rule + .binaries + .into_iter() + .map(|b| NetworkBinary { + path: b.path, + ..Default::default() + }) + .collect(), + }; + (key, proto_rule) + }) + .collect(); + + SandboxPolicy { + version: raw.version, + filesystem: raw.filesystem_policy.map(|fs| FilesystemPolicy { + include_workdir: fs.include_workdir, + read_only: fs.read_only, + read_write: fs.read_write, + }), + landlock: raw.landlock.map(|ll| LandlockPolicy { + compatibility: ll.compatibility, + }), + process: raw.process.map(|p| ProcessPolicy { + run_as_user: p.run_as_user, + run_as_group: p.run_as_group, + }), + network_policies, + inference: raw + .inference + .map(|inf| navigator_core::proto::InferencePolicy { + allowed_routes: inf.allowed_routes, + ..Default::default() + }), + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Parse a sandbox policy from a YAML string. +pub fn parse_sandbox_policy(yaml: &str) -> Result { + let raw: PolicyFile = serde_yaml::from_str(yaml) + .into_diagnostic() + .wrap_err("failed to parse sandbox policy YAML")?; + Ok(convert_policy(raw)) +} + +/// Load a sandbox policy with the standard resolution order: +/// +/// 1. `cli_path` argument (e.g. from a `--policy` flag) +/// 2. `NAVIGATOR_SANDBOX_POLICY` environment variable +/// 3. Built-in default (`dev-sandbox-policy.yaml`) +pub fn load_sandbox_policy(cli_path: Option<&str>) -> Result { + let contents = if let Some(p) = cli_path { + let path = std::path::Path::new(p); + std::fs::read_to_string(path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read sandbox policy from {}", path.display()))? + } else if let Ok(policy_path) = std::env::var("NAVIGATOR_SANDBOX_POLICY") { + let path = std::path::Path::new(&policy_path); + std::fs::read_to_string(path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read sandbox policy from {}", path.display()))? + } else { + DEFAULT_SANDBOX_POLICY_YAML.to_string() + }; + parse_sandbox_policy(&contents) +} + +/// Return the built-in default sandbox policy. +pub fn default_sandbox_policy() -> SandboxPolicy { + // The embedded YAML is known-good; unwrap is safe. + parse_sandbox_policy(DEFAULT_SANDBOX_POLICY_YAML) + .expect("built-in dev-sandbox-policy.yaml must be valid") +} + +/// Clear `run_as_user` / `run_as_group` from the policy's process section. +/// +/// Call this when a custom image is specified, since the image may lack the +/// default "sandbox" user/group. +pub fn clear_process_identity(policy: &mut SandboxPolicy) { + if let Some(ref mut process) = policy.process { + process.run_as_user = String::new(); + process.run_as_group = String::new(); + } +} diff --git a/crates/navigator-providers/Cargo.toml b/crates/navigator-providers/Cargo.toml index e9a4cf479..9fa5e9766 100644 --- a/crates/navigator-providers/Cargo.toml +++ b/crates/navigator-providers/Cargo.toml @@ -9,7 +9,6 @@ repository.workspace = true [dependencies] navigator-core = { path = "../navigator-core" } -serde_json = { workspace = true } thiserror = { workspace = true } [lints] diff --git a/crates/navigator-providers/src/context.rs b/crates/navigator-providers/src/context.rs index f566aba40..00b69804b 100644 --- a/crates/navigator-providers/src/context.rs +++ b/crates/navigator-providers/src/context.rs @@ -1,10 +1,5 @@ -use std::path::{Component, Path, PathBuf}; - pub trait DiscoveryContext { fn env_var(&self, key: &str) -> Option; - fn expand_home(&self, path: &str) -> Option; - fn path_exists(&self, path: &Path) -> bool; - fn read_to_string(&self, path: &Path) -> Option; } pub struct RealDiscoveryContext; @@ -13,53 +8,4 @@ impl DiscoveryContext for RealDiscoveryContext { fn env_var(&self, key: &str) -> Option { std::env::var(key).ok() } - - fn expand_home(&self, path: &str) -> Option { - if let Some(stripped) = path.strip_prefix("~/") { - let home = std::env::var("HOME").ok()?; - return expand_home_relative(Path::new(&home), stripped); - } - Some(PathBuf::from(path)) - } - - fn path_exists(&self, path: &Path) -> bool { - path.exists() - } - - fn read_to_string(&self, path: &Path) -> Option { - std::fs::read_to_string(path).ok() - } -} - -pub(crate) fn expand_home_relative(home: &Path, stripped: &str) -> Option { - let relative = Path::new(stripped); - for component in relative.components() { - match component { - Component::Normal(_) | Component::CurDir => {} - Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None, - } - } - Some(home.join(relative)) -} - -#[cfg(test)] -mod tests { - use super::expand_home_relative; - use std::path::{Path, PathBuf}; - - #[test] - fn rejects_parent_dir_escape_when_expanding_home() { - assert_eq!( - expand_home_relative(Path::new("/home/tester"), "../etc/passwd"), - None - ); - } - - #[test] - fn expands_safe_relative_home_path() { - assert_eq!( - expand_home_relative(Path::new("/home/tester"), ".config/codex/config.json"), - Some(PathBuf::from("/home/tester/.config/codex/config.json")) - ); - } } diff --git a/crates/navigator-providers/src/discovery.rs b/crates/navigator-providers/src/discovery.rs index d380bee84..2dc80bd00 100644 --- a/crates/navigator-providers/src/discovery.rs +++ b/crates/navigator-providers/src/discovery.rs @@ -1,5 +1,4 @@ use crate::{DiscoveredProvider, DiscoveryContext, ProviderDiscoverySpec, ProviderError}; -use serde_json::Value; pub fn discover_with_spec( spec: &ProviderDiscoverySpec, @@ -18,103 +17,9 @@ pub fn discover_with_spec( } } - for raw_path in spec.config_paths { - let Some(path) = context.expand_home(raw_path) else { - continue; - }; - if !context.path_exists(&path) { - continue; - } - - if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) - && ext.eq_ignore_ascii_case("json") - { - let contents = context.read_to_string(&path).unwrap_or_default(); - if contents.trim().is_empty() { - continue; - } - - let value: Value = - serde_json::from_str(&contents).map_err(|err| ProviderError::ParseJson { - path: path.display().to_string(), - message: err.to_string(), - })?; - - collect_credential_fields(&value, "", &mut discovered.credentials); - } - } - if discovered.is_empty() { Ok(None) } else { Ok(Some(discovered)) } } - -fn looks_like_credential_key(key: &str) -> bool { - let normalized = key.to_ascii_lowercase(); - ["api_key", "apikey", "token", "secret", "password", "auth"] - .iter() - .any(|needle| normalized.contains(needle)) -} - -fn collect_credential_fields( - value: &Value, - prefix: &str, - credentials: &mut std::collections::HashMap, -) { - match value { - Value::Object(map) => { - for (key, nested) in map { - let path = if prefix.is_empty() { - key.clone() - } else { - format!("{prefix}.{key}") - }; - - if let Some(raw) = nested.as_str() - && !raw.trim().is_empty() - && looks_like_credential_key(key) - { - credentials - .entry(path.clone()) - .or_insert_with(|| raw.to_string()); - } - - collect_credential_fields(nested, &path, credentials); - } - } - Value::Array(items) => { - for item in items { - collect_credential_fields(item, prefix, credentials); - } - } - _ => {} - } -} - -#[cfg(test)] -mod tests { - use super::discover_with_spec; - use crate::ProviderDiscoverySpec; - use crate::test_helpers::{MockDiscoveryContext, home_join}; - - #[test] - fn ignores_home_escaping_config_paths() { - let spec = ProviderDiscoverySpec { - id: "test", - credential_env_vars: &[], - config_paths: &["~/../secrets.json"], - }; - - let ctx = MockDiscoveryContext::new() - .with_home("/mock/home") - .with_file( - home_join("/mock/home", "../secrets.json"), - r#"{"token":"secret"}"#, - ); - - let discovered = discover_with_spec(&spec, &ctx).expect("discovery"); - assert!(discovered.is_none()); - } -} diff --git a/crates/navigator-providers/src/lib.rs b/crates/navigator-providers/src/lib.rs index 42bd0c056..8dbfb68e4 100644 --- a/crates/navigator-providers/src/lib.rs +++ b/crates/navigator-providers/src/lib.rs @@ -18,8 +18,6 @@ pub use discovery::discover_with_spec; pub enum ProviderError { #[error("unsupported provider type: {0}")] UnsupportedProvider(String), - #[error("failed to parse JSON in {path}: {message}")] - ParseJson { path: String, message: String }, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -39,7 +37,6 @@ impl DiscoveredProvider { pub struct ProviderDiscoverySpec { pub id: &'static str, pub credential_env_vars: &'static [&'static str], - pub config_paths: &'static [&'static str], } pub trait ProviderPlugin: Send + Sync { @@ -49,6 +46,14 @@ pub trait ProviderPlugin: Send + Sync { /// Discover provider credentials and config from the local machine. fn discover_existing(&self) -> Result, ProviderError>; + /// Return the known credential environment variable names for this provider type. + /// + /// Used by the TUI to label BYO key entry fields and to choose which + /// env var name to store a manually-entered credential under. + fn credential_env_vars(&self) -> &'static [&'static str] { + &[] + } + /// Apply provider data to sandbox runtime context. /// /// Default implementation is a no-op; provider-specific runtime projection @@ -97,6 +102,13 @@ impl ProviderRegistry { plugin.discover_existing() } + /// Return the known credential env var names for a provider type. + #[must_use] + pub fn credential_env_vars(&self, id: &str) -> &'static [&'static str] { + self.get(id) + .map_or(&[], ProviderPlugin::credential_env_vars) + } + #[must_use] pub fn known_types(&self) -> Vec<&'static str> { let mut types = self.plugins.keys().copied().collect::>(); diff --git a/crates/navigator-providers/src/providers/claude.rs b/crates/navigator-providers/src/providers/claude.rs index 493c3050c..4645417e8 100644 --- a/crates/navigator-providers/src/providers/claude.rs +++ b/crates/navigator-providers/src/providers/claude.rs @@ -7,11 +7,6 @@ pub struct ClaudeProvider; pub const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec { id: "claude", credential_env_vars: &["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"], - config_paths: &[ - "~/.claude.json", - "~/.claude/credentials.json", - "~/.config/claude/config.json", - ], }; impl ProviderPlugin for ClaudeProvider { @@ -22,13 +17,17 @@ impl ProviderPlugin for ClaudeProvider { fn discover_existing(&self) -> Result, ProviderError> { discover_with_spec(&SPEC, &RealDiscoveryContext) } + + fn credential_env_vars(&self) -> &'static [&'static str] { + SPEC.credential_env_vars + } } #[cfg(test)] mod tests { use super::SPEC; use crate::discover_with_spec; - use crate::test_helpers::{MockDiscoveryContext, home_join}; + use crate::test_helpers::MockDiscoveryContext; #[test] fn discovers_claude_env_credentials() { @@ -41,23 +40,4 @@ mod tests { Some(&"test-key".to_string()) ); } - - #[test] - fn discovers_claude_json_file_credentials_only() { - let home = "/mock/home"; - let config_path = home_join(home, ".claude/credentials.json"); - let ctx = MockDiscoveryContext::new() - .with_home(home) - .with_file(config_path, r#"{"api_key":"abc","profile":"dev"}"#); - - let discovered = discover_with_spec(&SPEC, &ctx) - .expect("discovery") - .expect("provider"); - - assert_eq!( - discovered.credentials.get("api_key"), - Some(&"abc".to_string()) - ); - assert!(discovered.config.is_empty()); - } } diff --git a/crates/navigator-providers/src/providers/codex.rs b/crates/navigator-providers/src/providers/codex.rs index a40742b11..747c6f90c 100644 --- a/crates/navigator-providers/src/providers/codex.rs +++ b/crates/navigator-providers/src/providers/codex.rs @@ -7,11 +7,6 @@ pub struct CodexProvider; pub const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec { id: "codex", credential_env_vars: &["OPENAI_API_KEY"], - config_paths: &[ - "~/.config/codex/config.json", - "~/.codex/config.json", - "~/.config/openai/config.json", - ], }; impl ProviderPlugin for CodexProvider { @@ -22,13 +17,17 @@ impl ProviderPlugin for CodexProvider { fn discover_existing(&self) -> Result, ProviderError> { discover_with_spec(&SPEC, &RealDiscoveryContext) } + + fn credential_env_vars(&self) -> &'static [&'static str] { + SPEC.credential_env_vars + } } #[cfg(test)] mod tests { use super::SPEC; use crate::discover_with_spec; - use crate::test_helpers::{MockDiscoveryContext, home_join}; + use crate::test_helpers::MockDiscoveryContext; #[test] fn discovers_codex_env_credentials() { @@ -41,23 +40,4 @@ mod tests { Some(&"openai-key".to_string()) ); } - - #[test] - fn discovers_codex_json_file_credentials_only() { - let home = "/mock/home"; - let config_path = home_join(home, ".config/codex/config.json"); - let ctx = MockDiscoveryContext::new().with_home(home).with_file( - config_path, - r#"{"token":"tok","base_url":"https://api.example.com"}"#, - ); - - let discovered = discover_with_spec(&SPEC, &ctx) - .expect("discovery") - .expect("provider"); - assert_eq!( - discovered.credentials.get("token"), - Some(&"tok".to_string()) - ); - assert!(discovered.config.is_empty()); - } } diff --git a/crates/navigator-providers/src/providers/github.rs b/crates/navigator-providers/src/providers/github.rs index 95bd58ca5..3e5677367 100644 --- a/crates/navigator-providers/src/providers/github.rs +++ b/crates/navigator-providers/src/providers/github.rs @@ -7,7 +7,6 @@ pub struct GithubProvider; pub const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec { id: "github", credential_env_vars: &["GITHUB_TOKEN", "GH_TOKEN"], - config_paths: &["~/.config/gh/hosts.yml"], }; impl ProviderPlugin for GithubProvider { @@ -18,13 +17,17 @@ impl ProviderPlugin for GithubProvider { fn discover_existing(&self) -> Result, ProviderError> { discover_with_spec(&SPEC, &RealDiscoveryContext) } + + fn credential_env_vars(&self) -> &'static [&'static str] { + SPEC.credential_env_vars + } } #[cfg(test)] mod tests { use super::SPEC; use crate::discover_with_spec; - use crate::test_helpers::{MockDiscoveryContext, home_join}; + use crate::test_helpers::MockDiscoveryContext; #[test] fn discovers_github_env_credentials() { @@ -37,15 +40,4 @@ mod tests { Some(&"gh-token".to_string()) ); } - - #[test] - fn config_path_existence_alone_does_not_produce_discovery() { - let home = "/mock/home"; - let path = home_join(home, ".config/gh/hosts.yml"); - let ctx = MockDiscoveryContext::new() - .with_home(home) - .with_existing_path(path); - let discovered = discover_with_spec(&SPEC, &ctx).expect("discovery"); - assert!(discovered.is_none()); - } } diff --git a/crates/navigator-providers/src/providers/gitlab.rs b/crates/navigator-providers/src/providers/gitlab.rs index 357e4d3c0..196c76b59 100644 --- a/crates/navigator-providers/src/providers/gitlab.rs +++ b/crates/navigator-providers/src/providers/gitlab.rs @@ -7,7 +7,6 @@ pub struct GitlabProvider; pub const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec { id: "gitlab", credential_env_vars: &["GITLAB_TOKEN", "GLAB_TOKEN", "CI_JOB_TOKEN"], - config_paths: &["~/.config/glab-cli/config.yml"], }; impl ProviderPlugin for GitlabProvider { @@ -18,13 +17,17 @@ impl ProviderPlugin for GitlabProvider { fn discover_existing(&self) -> Result, ProviderError> { discover_with_spec(&SPEC, &RealDiscoveryContext) } + + fn credential_env_vars(&self) -> &'static [&'static str] { + SPEC.credential_env_vars + } } #[cfg(test)] mod tests { use super::SPEC; use crate::discover_with_spec; - use crate::test_helpers::{MockDiscoveryContext, home_join}; + use crate::test_helpers::MockDiscoveryContext; #[test] fn discovers_gitlab_env_credentials() { @@ -37,15 +40,4 @@ mod tests { Some(&"glab-token".to_string()) ); } - - #[test] - fn config_path_existence_alone_does_not_produce_discovery() { - let home = "/mock/home"; - let path = home_join(home, ".config/glab-cli/config.yml"); - let ctx = MockDiscoveryContext::new() - .with_home(home) - .with_existing_path(path); - let discovered = discover_with_spec(&SPEC, &ctx).expect("discovery"); - assert!(discovered.is_none()); - } } diff --git a/crates/navigator-providers/src/providers/nvidia.rs b/crates/navigator-providers/src/providers/nvidia.rs index 3d31f044f..24e1d94c9 100644 --- a/crates/navigator-providers/src/providers/nvidia.rs +++ b/crates/navigator-providers/src/providers/nvidia.rs @@ -7,7 +7,6 @@ pub struct NvidiaProvider; pub const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec { id: "nvidia", credential_env_vars: &["NVIDIA_API_KEY"], - config_paths: &[], }; impl ProviderPlugin for NvidiaProvider { @@ -18,6 +17,10 @@ impl ProviderPlugin for NvidiaProvider { fn discover_existing(&self) -> Result, ProviderError> { discover_with_spec(&SPEC, &RealDiscoveryContext) } + + fn credential_env_vars(&self) -> &'static [&'static str] { + SPEC.credential_env_vars + } } #[cfg(test)] diff --git a/crates/navigator-providers/src/providers/opencode.rs b/crates/navigator-providers/src/providers/opencode.rs index d63ec5ae0..69368920a 100644 --- a/crates/navigator-providers/src/providers/opencode.rs +++ b/crates/navigator-providers/src/providers/opencode.rs @@ -7,7 +7,6 @@ pub struct OpencodeProvider; pub const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec { id: "opencode", credential_env_vars: &["OPENCODE_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY"], - config_paths: &["~/.config/opencode/config.json", "~/.opencode/config.json"], }; impl ProviderPlugin for OpencodeProvider { @@ -18,13 +17,17 @@ impl ProviderPlugin for OpencodeProvider { fn discover_existing(&self) -> Result, ProviderError> { discover_with_spec(&SPEC, &RealDiscoveryContext) } + + fn credential_env_vars(&self) -> &'static [&'static str] { + SPEC.credential_env_vars + } } #[cfg(test)] mod tests { use super::SPEC; use crate::discover_with_spec; - use crate::test_helpers::{MockDiscoveryContext, home_join}; + use crate::test_helpers::MockDiscoveryContext; #[test] fn discovers_opencode_env_credentials() { @@ -37,22 +40,4 @@ mod tests { Some(&"op-key".to_string()) ); } - - #[test] - fn discovers_opencode_json_file_credentials_only() { - let home = "/mock/home"; - let config_path = home_join(home, ".config/opencode/config.json"); - let ctx = MockDiscoveryContext::new() - .with_home(home) - .with_file(config_path, r#"{"openrouter_token":"rtr","model":"x"}"#); - - let discovered = discover_with_spec(&SPEC, &ctx) - .expect("discovery") - .expect("provider"); - assert_eq!( - discovered.credentials.get("openrouter_token"), - Some(&"rtr".to_string()) - ); - assert!(discovered.config.is_empty()); - } } diff --git a/crates/navigator-providers/src/test_helpers.rs b/crates/navigator-providers/src/test_helpers.rs index 58445e5fa..7b2d422b5 100644 --- a/crates/navigator-providers/src/test_helpers.rs +++ b/crates/navigator-providers/src/test_helpers.rs @@ -1,14 +1,9 @@ use crate::DiscoveryContext; -use crate::context::expand_home_relative; -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; +use std::collections::HashMap; #[derive(Default)] pub struct MockDiscoveryContext { env: HashMap, - files: HashMap, - exists: HashSet, - home: Option, } impl MockDiscoveryContext { @@ -16,51 +11,14 @@ impl MockDiscoveryContext { Self::default() } - pub fn with_home(mut self, home: impl Into) -> Self { - self.home = Some(home.into()); - self - } - pub fn with_env(mut self, key: &str, value: &str) -> Self { self.env.insert(key.to_string(), value.to_string()); self } - - pub fn with_file(mut self, path: impl Into, contents: &str) -> Self { - let path = path.into(); - self.exists.insert(path.clone()); - self.files.insert(path, contents.to_string()); - self - } - - pub fn with_existing_path(mut self, path: impl Into) -> Self { - self.exists.insert(path.into()); - self - } } impl DiscoveryContext for MockDiscoveryContext { fn env_var(&self, key: &str) -> Option { self.env.get(key).cloned() } - - fn expand_home(&self, path: &str) -> Option { - if let Some(stripped) = path.strip_prefix("~/") { - let home = self.home.as_deref()?; - return expand_home_relative(home, stripped); - } - Some(PathBuf::from(path)) - } - - fn path_exists(&self, path: &Path) -> bool { - self.exists.contains(path) - } - - fn read_to_string(&self, path: &Path) -> Option { - self.files.get(path).cloned() - } -} - -pub fn home_join(home: &str, rel: &str) -> PathBuf { - PathBuf::from(home).join(rel) } diff --git a/crates/navigator-server/src/grpc.rs b/crates/navigator-server/src/grpc.rs index 9c5f9759e..8ee100897 100644 --- a/crates/navigator-server/src/grpc.rs +++ b/crates/navigator-server/src/grpc.rs @@ -20,7 +20,9 @@ use navigator_core::proto::{ UpdateSandboxPolicyRequest, UpdateSandboxPolicyResponse, WatchSandboxRequest, navigator_server::Navigator, }; -use navigator_core::proto::{Sandbox, SandboxPhase, SandboxPolicy as ProtoSandboxPolicy}; +use navigator_core::proto::{ + Sandbox, SandboxPhase, SandboxPolicy as ProtoSandboxPolicy, SandboxTemplate, +}; use prost::Message; use sha2::{Digest, Sha256}; use std::sync::Arc; @@ -89,6 +91,14 @@ impl Navigator for NavigatorService { })?; } + // Ensure the template always carries the resolved image so clients + // (CLI, TUI, etc.) can read the actual image from the stored sandbox. + let mut spec = spec; + let template = spec.template.get_or_insert_with(SandboxTemplate::default); + if template.image.is_empty() { + template.image = self.state.sandbox_client.default_image().to_string(); + } + let id = uuid::Uuid::new_v4().to_string(); let name = if request.name.is_empty() { format!("sandbox-{id}") @@ -1660,6 +1670,11 @@ async fn create_provider_record( if provider.r#type.trim().is_empty() { return Err(Status::invalid_argument("provider.type is required")); } + if provider.credentials.is_empty() { + return Err(Status::invalid_argument( + "provider.credentials must not be empty", + )); + } let existing = store .get_message_by_name::(&provider.name) diff --git a/crates/navigator-tui/Cargo.toml b/crates/navigator-tui/Cargo.toml new file mode 100644 index 000000000..0cd307e79 --- /dev/null +++ b/crates/navigator-tui/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "navigator-tui" +description = "Gator – Navigator interactive TUI" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +navigator-core = { path = "../navigator-core" } +navigator-bootstrap = { path = "../navigator-bootstrap" } +navigator-policy = { path = "../navigator-policy" } +navigator-providers = { path = "../navigator-providers" } + +ratatui = { workspace = true } +crossterm = { workspace = true } +tokio = { workspace = true } +tonic = { workspace = true, features = ["tls"] } +miette = { workspace = true } +owo-colors = { workspace = true } +serde = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } + +[lints] +workspace = true diff --git a/crates/navigator-tui/src/app.rs b/crates/navigator-tui/src/app.rs new file mode 100644 index 000000000..4238e706d --- /dev/null +++ b/crates/navigator-tui/src/app.rs @@ -0,0 +1,1304 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use navigator_core::proto::navigator_client::NavigatorClient; +use tonic::transport::Channel; + +// --------------------------------------------------------------------------- +// Screens & focus +// --------------------------------------------------------------------------- + +/// Top-level screen (each is a full-screen layout with its own nav bar). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Screen { + /// Cluster list + provider list + sandbox table. + Dashboard, + /// Single-sandbox view (detail + logs). + Sandbox, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputMode { + Normal, + Command, +} + +/// Which panel is focused within the current screen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + // Dashboard screen + Clusters, + Providers, + Sandboxes, + // Sandbox screen — metadata pane is always visible (non-interactive); + // the focused pane is always the bottom one (policy or logs). + SandboxPolicy, + SandboxLogs, +} + +// --------------------------------------------------------------------------- +// Log data model +// --------------------------------------------------------------------------- + +/// Structured log line stored from the server. +#[derive(Debug, Clone)] +pub struct LogLine { + pub timestamp_ms: i64, + pub level: String, + pub source: String, // "gateway" or "sandbox" + pub target: String, + pub message: String, + pub fields: HashMap, +} + +/// Which log sources to display. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogSourceFilter { + All, + Gateway, + Sandbox, +} + +impl LogSourceFilter { + pub fn next(self) -> Self { + match self { + Self::All => Self::Gateway, + Self::Gateway => Self::Sandbox, + Self::Sandbox => Self::All, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::All => "all", + Self::Gateway => "gateway", + Self::Sandbox => "sandbox", + } + } +} + +// --------------------------------------------------------------------------- +// Cluster entry +// --------------------------------------------------------------------------- + +pub struct ClusterEntry { + pub name: String, + pub endpoint: String, + pub is_remote: bool, +} + +// --------------------------------------------------------------------------- +// Create sandbox form (simplified — providers chosen by name) +// --------------------------------------------------------------------------- + +/// Which field is focused in the create sandbox modal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CreateFormField { + Name, + Image, + Command, + Providers, + Submit, +} + +impl CreateFormField { + pub fn next(self) -> Self { + match self { + Self::Name => Self::Image, + Self::Image => Self::Command, + Self::Command => Self::Providers, + Self::Providers => Self::Submit, + Self::Submit => Self::Name, + } + } + + pub fn prev(self) -> Self { + match self { + Self::Name => Self::Submit, + Self::Image => Self::Name, + Self::Command => Self::Image, + Self::Providers => Self::Command, + Self::Submit => Self::Providers, + } + } +} + +/// An existing provider entry for sandbox creation (select by name). +#[derive(Debug, Clone)] +pub struct ProviderEntry { + pub name: String, + pub provider_type: String, + pub selected: bool, +} + +/// Tracks which phase the create sandbox modal is in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreatePhase { + /// Filling out the form. + Form, + /// Creating the sandbox (background task running). + Creating, +} + +/// Minimum time to show the Creating phase before closing. +pub const MIN_CREATING_DISPLAY: Duration = Duration::from_secs(4); + +/// State for the create sandbox modal form. +pub struct CreateSandboxForm { + pub focused_field: CreateFormField, + pub name: String, + pub image: String, + pub command: String, + pub providers: Vec, + pub provider_cursor: usize, + /// Status message shown after submit attempt. + pub status: Option, + /// Current phase of the create flow. + pub phase: CreatePhase, + /// When the create animation started (for pacman timing). + pub anim_start: Option, + /// Buffered create result — held until min display time elapses. + pub create_result: Option>, +} + +// --------------------------------------------------------------------------- +// Create provider form +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreateProviderPhase { + /// Pick provider type from the known list. + SelectType, + /// Choose: autodetect from env or enter key manually. + ChooseMethod, + /// Enter key manually (BYO or autodetect fallback). + EnterKey, + /// Creating provider on gateway (background task). + Creating, +} + +/// Which field is focused in the provider key entry form. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderKeyField { + Name, + /// Focused credential row for known types (index via `cred_cursor`). + Credential, + /// Custom env var name (generic / no-known-env-vars types only). + EnvVarName, + /// Custom env var value (generic / no-known-env-vars types only). + GenericValue, + Submit, +} + +pub struct CreateProviderForm { + pub phase: CreateProviderPhase, + /// Known provider type slugs. + pub types: Vec, + pub type_cursor: usize, + /// 0 = autodetect, 1 = enter manually. + pub method_cursor: usize, + /// Provider name (pre-filled with auto-generated unique name). + pub name: String, + /// For known types: `(env_var_name, value)` pairs — all known env vars listed. + pub credentials: Vec<(String, String)>, + /// Which credential row is focused. + pub cred_cursor: usize, + /// For generic / types with no known env vars: custom env var name. + pub generic_env_name: String, + /// For generic / types with no known env vars: custom value. + pub generic_value: String, + /// Which field is focused in the key entry form. + pub key_field: ProviderKeyField, + /// True when the provider type has no known env vars (generic, outlook). + pub is_generic: bool, + /// Status message (errors, validation). + pub status: Option, + /// Warning shown at top of EnterKey modal (e.g. autodetect failure). + pub warning: Option, + /// Animation start time. + pub anim_start: Option, + /// Buffered create result. + pub create_result: Option>, + /// Credentials to send (filled by autodetect or built from form fields on submit). + pub discovered_credentials: Option>, +} + +// --------------------------------------------------------------------------- +// Provider detail view (Get) +// --------------------------------------------------------------------------- + +pub struct ProviderDetailView { + pub name: String, + pub provider_type: String, + pub credential_key: String, + pub masked_value: String, +} + +// --------------------------------------------------------------------------- +// Update provider form +// --------------------------------------------------------------------------- + +pub struct UpdateProviderForm { + pub provider_name: String, + pub provider_type: String, + pub credential_key: String, + pub new_value: String, + pub status: Option, +} + +// --------------------------------------------------------------------------- +// App state +// --------------------------------------------------------------------------- + +#[allow(clippy::struct_excessive_bools)] +pub struct App { + pub running: bool, + pub screen: Screen, + pub input_mode: InputMode, + pub focus: Focus, + pub command_input: String, + + // Active cluster connection + pub cluster_name: String, + pub endpoint: String, + pub client: NavigatorClient, + pub status_text: String, + + // Cluster list + pub clusters: Vec, + pub cluster_selected: usize, + pub pending_cluster_switch: Option, + + // Provider list + pub provider_names: Vec, + pub provider_types: Vec, + pub provider_cred_keys: Vec, + pub provider_selected: usize, + pub provider_count: usize, + + // Provider CRUD + pub create_provider_form: Option, + pub provider_detail: Option, + pub update_provider_form: Option, + pub confirm_provider_delete: bool, + pub pending_provider_get: bool, + pub pending_provider_delete: bool, + pub pending_provider_create: bool, + pub pending_provider_update: bool, + + // Sandbox list + pub sandbox_ids: Vec, + pub sandbox_names: Vec, + pub sandbox_phases: Vec, + pub sandbox_ages: Vec, + pub sandbox_created: Vec, + pub sandbox_images: Vec, + pub sandbox_selected: usize, + pub sandbox_count: usize, + + // Sandbox detail / actions + pub confirm_delete: bool, + pub pending_log_fetch: bool, + pub pending_sandbox_delete: bool, + pub pending_sandbox_detail: bool, + pub pending_shell_connect: bool, + + // Sandbox policy viewer + pub sandbox_policy: Option, + pub sandbox_providers_list: Vec, + pub policy_lines: Vec>, + pub policy_scroll: usize, + + // Create sandbox modal + pub create_form: Option, + pub pending_create_sandbox: bool, + /// Animation ticker handle — aborted when animation stops. + pub anim_handle: Option>, + + // Sandbox logs + pub sandbox_log_lines: Vec, + pub sandbox_log_scroll: usize, + /// Cursor position relative to `sandbox_log_scroll` (0 = first visible line). + pub log_cursor: usize, + pub log_source_filter: LogSourceFilter, + /// When true, new log lines auto-scroll to the bottom (k9s-style). + pub log_autoscroll: bool, + /// Visible line count in the log viewport (set by the draw pass). + pub log_viewport_height: usize, + /// When `Some(idx)`, a detail popup is shown for the filtered log line at this index. + pub log_detail_index: Option, + /// Handle for the streaming log task. Dropped to cancel. + pub log_stream_handle: Option>, +} + +impl App { + pub fn new(client: NavigatorClient, cluster_name: String, endpoint: String) -> Self { + Self { + running: true, + screen: Screen::Dashboard, + input_mode: InputMode::Normal, + focus: Focus::Clusters, + command_input: String::new(), + cluster_name, + endpoint, + client, + status_text: String::from("connecting..."), + clusters: Vec::new(), + cluster_selected: 0, + pending_cluster_switch: None, + provider_names: Vec::new(), + provider_types: Vec::new(), + provider_cred_keys: Vec::new(), + provider_selected: 0, + provider_count: 0, + create_provider_form: None, + provider_detail: None, + update_provider_form: None, + confirm_provider_delete: false, + pending_provider_get: false, + pending_provider_delete: false, + pending_provider_create: false, + pending_provider_update: false, + sandbox_ids: Vec::new(), + sandbox_names: Vec::new(), + sandbox_phases: Vec::new(), + sandbox_ages: Vec::new(), + sandbox_created: Vec::new(), + sandbox_images: Vec::new(), + sandbox_selected: 0, + sandbox_count: 0, + confirm_delete: false, + pending_log_fetch: false, + pending_sandbox_delete: false, + pending_sandbox_detail: false, + pending_shell_connect: false, + sandbox_policy: None, + sandbox_providers_list: Vec::new(), + policy_lines: Vec::new(), + policy_scroll: 0, + create_form: None, + pending_create_sandbox: false, + anim_handle: None, + sandbox_log_lines: Vec::new(), + sandbox_log_scroll: 0, + log_cursor: 0, + log_source_filter: LogSourceFilter::All, + log_autoscroll: true, + log_viewport_height: 0, + log_detail_index: None, + log_stream_handle: None, + } + } + + // ------------------------------------------------------------------ + // Filtered log helpers + // ------------------------------------------------------------------ + + /// Return log lines matching the current source filter. + pub fn filtered_log_lines(&self) -> Vec<&LogLine> { + self.sandbox_log_lines + .iter() + .filter(|l| match self.log_source_filter { + LogSourceFilter::All => true, + LogSourceFilter::Gateway => l.source == "gateway", + LogSourceFilter::Sandbox => l.source == "sandbox", + }) + .collect() + } + + // ------------------------------------------------------------------ + // Key handling + // ------------------------------------------------------------------ + + pub fn handle_key(&mut self, key: KeyEvent) { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + self.running = false; + return; + } + + // Modals intercept all keys when open. + if self.create_form.is_some() { + self.handle_create_form_key(key); + return; + } + if self.create_provider_form.is_some() { + self.handle_create_provider_key(key); + return; + } + if self.provider_detail.is_some() { + self.handle_provider_detail_key(key); + return; + } + if self.update_provider_form.is_some() { + self.handle_update_provider_key(key); + return; + } + + match self.input_mode { + InputMode::Command => self.handle_command_key(key), + InputMode::Normal => self.handle_normal_key(key), + } + } + + fn handle_normal_key(&mut self, key: KeyEvent) { + match self.focus { + Focus::Clusters => self.handle_clusters_key(key), + Focus::Providers => self.handle_providers_key(key), + Focus::Sandboxes => self.handle_sandboxes_key(key), + Focus::SandboxPolicy => self.handle_policy_key(key), + Focus::SandboxLogs => self.handle_logs_key(key), + } + } + + fn handle_clusters_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('q') => self.running = false, + KeyCode::Tab => self.focus = Focus::Providers, + KeyCode::BackTab => self.focus = Focus::Sandboxes, + KeyCode::Char(':') => { + self.input_mode = InputMode::Command; + self.command_input.clear(); + } + KeyCode::Char('j') | KeyCode::Down => { + if !self.clusters.is_empty() { + self.cluster_selected = + (self.cluster_selected + 1).min(self.clusters.len() - 1); + } + } + KeyCode::Char('k') | KeyCode::Up => { + self.cluster_selected = self.cluster_selected.saturating_sub(1); + } + KeyCode::Enter => { + if let Some(entry) = self.clusters.get(self.cluster_selected) { + if entry.name != self.cluster_name { + self.pending_cluster_switch = Some(entry.name.clone()); + } + self.focus = Focus::Providers; + } + } + _ => {} + } + } + + fn handle_providers_key(&mut self, key: KeyEvent) { + if self.confirm_provider_delete { + match key.code { + KeyCode::Char('y') => { + self.confirm_provider_delete = false; + self.pending_provider_delete = true; + } + KeyCode::Esc | KeyCode::Char('n') => { + self.confirm_provider_delete = false; + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Char('q') => self.running = false, + KeyCode::Tab => self.focus = Focus::Sandboxes, + KeyCode::BackTab => self.focus = Focus::Clusters, + KeyCode::Char(':') => { + self.input_mode = InputMode::Command; + self.command_input.clear(); + } + KeyCode::Char('j') | KeyCode::Down => { + if self.provider_count > 0 { + self.provider_selected = + (self.provider_selected + 1).min(self.provider_count - 1); + } + } + KeyCode::Char('k') | KeyCode::Up => { + self.provider_selected = self.provider_selected.saturating_sub(1); + } + KeyCode::Char('c') => { + self.open_create_provider_form(); + } + KeyCode::Enter => { + // Fetch and show provider detail. + if self.provider_count > 0 { + self.pending_provider_get = true; + } + } + KeyCode::Char('u') => { + // Open update form for the selected provider. + if self.provider_count > 0 { + self.open_update_provider_form(); + } + } + KeyCode::Char('d') => { + if self.provider_count > 0 { + self.confirm_provider_delete = true; + } + } + _ => {} + } + } + + fn handle_sandboxes_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Char('q') => self.running = false, + KeyCode::Tab => self.focus = Focus::Clusters, + KeyCode::BackTab => self.focus = Focus::Providers, + KeyCode::Char(':') => { + self.input_mode = InputMode::Command; + self.command_input.clear(); + } + KeyCode::Char('j') | KeyCode::Down => { + if self.sandbox_count > 0 { + self.sandbox_selected = (self.sandbox_selected + 1).min(self.sandbox_count - 1); + } + } + KeyCode::Char('k') | KeyCode::Up => { + self.sandbox_selected = self.sandbox_selected.saturating_sub(1); + } + KeyCode::Char('c') => { + self.open_create_form(); + } + KeyCode::Enter => { + if self.sandbox_count > 0 { + self.screen = Screen::Sandbox; + self.focus = Focus::SandboxPolicy; + self.confirm_delete = false; + self.pending_sandbox_detail = true; + } + } + KeyCode::Esc => { + self.focus = Focus::Providers; + } + _ => {} + } + } + + fn handle_policy_key(&mut self, key: KeyEvent) { + if self.confirm_delete { + match key.code { + KeyCode::Char('y') => { + self.confirm_delete = false; + self.pending_sandbox_delete = true; + } + KeyCode::Esc | KeyCode::Char('n') => { + self.confirm_delete = false; + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Esc => { + self.cancel_log_stream(); + self.screen = Screen::Dashboard; + self.focus = Focus::Sandboxes; + } + KeyCode::Char('l') => { + self.sandbox_log_lines.clear(); + self.sandbox_log_scroll = 0; + self.log_cursor = 0; + self.log_source_filter = LogSourceFilter::All; + self.log_autoscroll = true; + self.log_detail_index = None; + self.focus = Focus::SandboxLogs; + self.pending_log_fetch = true; + } + KeyCode::Char('s') => { + if self.sandbox_count > 0 { + self.pending_shell_connect = true; + } + } + KeyCode::Char('d') => { + self.confirm_delete = true; + } + KeyCode::Char('j') | KeyCode::Down => { + self.scroll_policy(1); + } + KeyCode::Char('k') | KeyCode::Up => { + self.scroll_policy(-1); + } + KeyCode::Char('G') => { + // Scroll to bottom. + self.policy_scroll = self.policy_lines.len().saturating_sub(1); + } + KeyCode::Char('g') => { + self.policy_scroll = 0; + } + KeyCode::Char('q') => self.running = false, + _ => {} + } + } + + /// Scroll policy pane by a delta (positive = down, negative = up). + pub fn scroll_policy(&mut self, delta: isize) { + let max = self.policy_lines.len().saturating_sub(1); + if delta < 0 { + self.policy_scroll = self.policy_scroll.saturating_sub(delta.unsigned_abs()); + } else { + #[allow(clippy::cast_sign_loss)] + { + self.policy_scroll = (self.policy_scroll + delta as usize).min(max); + } + } + } + + fn handle_logs_key(&mut self, key: KeyEvent) { + if self.log_detail_index.is_some() { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + self.log_detail_index = None; + } + _ => {} + } + return; + } + + let filtered_len = self.filtered_log_lines().len(); + let vh = self.log_viewport_height; + + match key.code { + KeyCode::Esc => { + self.cancel_log_stream(); + self.focus = Focus::SandboxPolicy; + } + KeyCode::Char('q') => self.running = false, + KeyCode::Enter => { + if filtered_len > 0 { + let abs = self.sandbox_log_scroll + self.log_cursor; + if abs < filtered_len { + self.log_detail_index = Some(abs); + } + } + } + KeyCode::Char('j') | KeyCode::Down => { + if filtered_len == 0 { + return; + } + let visible = filtered_len.saturating_sub(self.sandbox_log_scroll).min(vh); + let max_cursor = visible.saturating_sub(1); + if self.log_cursor < max_cursor { + self.log_cursor += 1; + } else { + let max_scroll = filtered_len.saturating_sub(vh.min(filtered_len)); + if self.sandbox_log_scroll < max_scroll { + self.sandbox_log_scroll += 1; + } + } + } + KeyCode::Char('k') | KeyCode::Up => { + if self.log_cursor > 0 { + self.log_cursor -= 1; + } else if self.sandbox_log_scroll > 0 { + self.sandbox_log_scroll -= 1; + } + self.log_autoscroll = false; + } + KeyCode::Char('G' | 'f') => { + self.sandbox_log_scroll = self.log_autoscroll_offset(); + self.log_autoscroll = true; + let visible = filtered_len.saturating_sub(self.sandbox_log_scroll); + self.log_cursor = visible.saturating_sub(1).min(vh.saturating_sub(1)); + } + KeyCode::Char('g') => { + self.sandbox_log_scroll = 0; + self.log_cursor = 0; + self.log_autoscroll = false; + } + KeyCode::Char('s') => { + self.log_source_filter = self.log_source_filter.next(); + self.sandbox_log_scroll = 0; + self.log_cursor = 0; + } + _ => {} + } + } + + /// Scroll logs by a delta (positive = down, negative = up). + pub fn scroll_logs(&mut self, delta: isize) { + let filtered_len = self.filtered_log_lines().len(); + let max_scroll = self.log_autoscroll_offset(); + if delta < 0 { + self.sandbox_log_scroll = self.sandbox_log_scroll.saturating_sub(delta.unsigned_abs()); + self.log_autoscroll = false; + } else { + self.sandbox_log_scroll = (self.sandbox_log_scroll + delta as usize).min(max_scroll); + } + let visible = filtered_len + .saturating_sub(self.sandbox_log_scroll) + .min(self.log_viewport_height); + if visible > 0 { + self.log_cursor = self.log_cursor.min(visible - 1); + } else { + self.log_cursor = 0; + } + } + + // ------------------------------------------------------------------ + // Create sandbox modal (simplified — pick existing providers by name) + // ------------------------------------------------------------------ + + fn open_create_form(&mut self) { + let providers: Vec = self + .provider_names + .iter() + .zip(self.provider_types.iter()) + .map(|(name, ptype)| ProviderEntry { + name: name.clone(), + provider_type: ptype.clone(), + selected: false, + }) + .collect(); + + self.create_form = Some(CreateSandboxForm { + focused_field: CreateFormField::Name, + name: String::new(), + image: String::new(), + command: String::from("/bin/bash"), + providers, + provider_cursor: 0, + status: None, + phase: CreatePhase::Form, + anim_start: None, + create_result: None, + }); + } + + fn handle_create_form_key(&mut self, key: KeyEvent) { + let Some(form) = self.create_form.as_mut() else { + return; + }; + + match form.phase { + CreatePhase::Creating => {} // no input during creation + + CreatePhase::Form => match key.code { + KeyCode::Esc => { + self.create_form = None; + } + KeyCode::Tab => { + form.status = None; + form.focused_field = form.focused_field.next(); + } + KeyCode::BackTab => { + form.status = None; + form.focused_field = form.focused_field.prev(); + } + _ => match form.focused_field { + CreateFormField::Name => Self::handle_text_input(&mut form.name, key), + CreateFormField::Image => Self::handle_text_input(&mut form.image, key), + CreateFormField::Command => Self::handle_text_input(&mut form.command, key), + CreateFormField::Providers => match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if !form.providers.is_empty() { + form.provider_cursor = + (form.provider_cursor + 1).min(form.providers.len() - 1); + } + } + KeyCode::Char('k') | KeyCode::Up => { + form.provider_cursor = form.provider_cursor.saturating_sub(1); + } + KeyCode::Char(' ') | KeyCode::Enter => { + if let Some(p) = form.providers.get_mut(form.provider_cursor) { + p.selected = !p.selected; + } + } + _ => {} + }, + CreateFormField::Submit => { + if key.code == KeyCode::Enter { + form.anim_start = Some(Instant::now()); + form.status = None; + form.phase = CreatePhase::Creating; + self.pending_create_sandbox = true; + } + } + }, + }, + } + } + + /// Build the form data needed for the gRPC `CreateSandbox` request. + /// Returns `(name, image, command, selected_provider_names)`. + pub fn create_form_data(&self) -> Option<(String, String, String, Vec)> { + let form = self.create_form.as_ref()?; + let providers: Vec = form + .providers + .iter() + .filter(|p| p.selected) + .map(|p| p.name.clone()) + .collect(); + Some(( + form.name.clone(), + form.image.clone(), + form.command.clone(), + providers, + )) + } + + // ------------------------------------------------------------------ + // Create provider modal + // ------------------------------------------------------------------ + + fn open_create_provider_form(&mut self) { + let known = navigator_providers::ProviderRegistry::new().known_types(); + let types: Vec = known.into_iter().map(String::from).collect(); + + self.create_provider_form = Some(CreateProviderForm { + phase: CreateProviderPhase::SelectType, + types, + type_cursor: 0, + method_cursor: 0, + name: String::new(), + credentials: Vec::new(), + cred_cursor: 0, + generic_env_name: String::new(), + generic_value: String::new(), + key_field: ProviderKeyField::Name, + is_generic: false, + status: None, + warning: None, + anim_start: None, + create_result: None, + discovered_credentials: None, + }); + } + + fn handle_create_provider_key(&mut self, key: KeyEvent) { + let Some(form) = self.create_provider_form.as_mut() else { + return; + }; + + match form.phase { + CreateProviderPhase::SelectType => match key.code { + KeyCode::Esc => { + self.create_provider_form = None; + } + KeyCode::Char('j') | KeyCode::Down => { + if !form.types.is_empty() { + form.type_cursor = (form.type_cursor + 1).min(form.types.len() - 1); + } + } + KeyCode::Char('k') | KeyCode::Up => { + form.type_cursor = form.type_cursor.saturating_sub(1); + } + KeyCode::Enter => { + let selected = form.types[form.type_cursor].clone(); + let registry = navigator_providers::ProviderRegistry::new(); + let env_vars = registry.credential_env_vars(&selected); + form.is_generic = env_vars.is_empty(); + + // Populate credential rows from all known env vars. + form.credentials = env_vars + .iter() + .map(|s| (s.to_string(), String::new())) + .collect(); + form.cred_cursor = 0; + + // Auto-generate a unique name. + form.name = unique_provider_name(&selected, &self.provider_names); + + if form.is_generic { + // No known env vars — skip straight to manual entry. + form.phase = CreateProviderPhase::EnterKey; + form.key_field = ProviderKeyField::Name; + form.status = None; + form.warning = None; + } else { + form.phase = CreateProviderPhase::ChooseMethod; + form.method_cursor = 0; + } + } + _ => {} + }, + + CreateProviderPhase::ChooseMethod => match key.code { + KeyCode::Esc => { + form.phase = CreateProviderPhase::SelectType; + form.status = None; + form.warning = None; + } + KeyCode::Char('j') | KeyCode::Down | KeyCode::Char('k') | KeyCode::Up => { + form.method_cursor = 1 - form.method_cursor; + } + KeyCode::Enter => { + let ptype = form.types[form.type_cursor].clone(); + if form.method_cursor == 0 { + // Autodetect — synchronous since we only check env vars now. + let registry = navigator_providers::ProviderRegistry::new(); + if let Ok(Some(discovered)) = registry.discover_existing(&ptype) { + form.discovered_credentials = Some(discovered.credentials); + if form.name.is_empty() { + form.name = unique_provider_name(&ptype, &self.provider_names); + } + form.phase = CreateProviderPhase::Creating; + form.anim_start = Some(Instant::now()); + self.pending_provider_create = true; + } else { + // Autodetect failed — fall to manual with warning. + form.phase = CreateProviderPhase::EnterKey; + form.key_field = ProviderKeyField::Name; + form.warning = Some( + "No credentials found in environment. Enter manually.".to_string(), + ); + form.status = None; + } + } else { + // Manual entry. + form.phase = CreateProviderPhase::EnterKey; + form.key_field = ProviderKeyField::Name; + form.warning = None; + form.status = None; + } + } + _ => {} + }, + + CreateProviderPhase::EnterKey => match key.code { + KeyCode::Esc => { + form.phase = CreateProviderPhase::SelectType; + form.status = None; + form.warning = None; + form.name.clear(); + form.credentials.clear(); + form.cred_cursor = 0; + form.generic_env_name.clear(); + form.generic_value.clear(); + } + KeyCode::Tab => { + if form.is_generic { + // Name → EnvVarName → GenericValue → Submit → Name + form.key_field = match form.key_field { + ProviderKeyField::Name => ProviderKeyField::EnvVarName, + ProviderKeyField::EnvVarName => ProviderKeyField::GenericValue, + ProviderKeyField::GenericValue => ProviderKeyField::Submit, + _ => ProviderKeyField::Name, + }; + } else { + // Name → Credential[0..N-1] → Submit → Name + match form.key_field { + ProviderKeyField::Name => { + if form.credentials.is_empty() { + form.key_field = ProviderKeyField::Submit; + } else { + form.key_field = ProviderKeyField::Credential; + form.cred_cursor = 0; + } + } + ProviderKeyField::Credential => { + if form.cred_cursor < form.credentials.len().saturating_sub(1) { + form.cred_cursor += 1; + } else { + form.key_field = ProviderKeyField::Submit; + } + } + _ => { + form.key_field = ProviderKeyField::Name; + } + } + } + } + KeyCode::BackTab => { + if form.is_generic { + form.key_field = match form.key_field { + ProviderKeyField::EnvVarName => ProviderKeyField::Name, + ProviderKeyField::GenericValue => ProviderKeyField::EnvVarName, + ProviderKeyField::Submit => ProviderKeyField::GenericValue, + _ => ProviderKeyField::Submit, + }; + } else { + match form.key_field { + ProviderKeyField::Credential => { + if form.cred_cursor > 0 { + form.cred_cursor -= 1; + } else { + form.key_field = ProviderKeyField::Name; + } + } + ProviderKeyField::Submit => { + if form.credentials.is_empty() { + form.key_field = ProviderKeyField::Name; + } else { + form.key_field = ProviderKeyField::Credential; + form.cred_cursor = form.credentials.len().saturating_sub(1); + } + } + _ => { + form.key_field = ProviderKeyField::Submit; + } + } + } + } + _ => match form.key_field { + ProviderKeyField::Name => Self::handle_text_input(&mut form.name, key), + ProviderKeyField::Credential => { + if let Some((_, value)) = form.credentials.get_mut(form.cred_cursor) { + Self::handle_text_input(value, key); + } + } + ProviderKeyField::EnvVarName => { + Self::handle_text_input(&mut form.generic_env_name, key); + } + ProviderKeyField::GenericValue => { + Self::handle_text_input(&mut form.generic_value, key); + } + ProviderKeyField::Submit => { + if key.code == KeyCode::Enter { + // Validate and build credentials map. + let mut creds = HashMap::new(); + if form.is_generic { + if form.generic_env_name.is_empty() { + form.status = Some("Env var name is required.".to_string()); + return; + } + if form.generic_value.is_empty() { + form.status = Some("Value is required.".to_string()); + return; + } + creds.insert( + form.generic_env_name.clone(), + form.generic_value.clone(), + ); + } else { + for (name, value) in &form.credentials { + if !value.is_empty() { + creds.insert(name.clone(), value.clone()); + } + } + if creds.is_empty() { + form.status = + Some("At least one credential is required.".to_string()); + return; + } + } + form.discovered_credentials = Some(creds); + form.phase = CreateProviderPhase::Creating; + form.anim_start = Some(Instant::now()); + form.status = None; + self.pending_provider_create = true; + } + } + }, + }, + + CreateProviderPhase::Creating => {} // no input during creation + } + } + + // ------------------------------------------------------------------ + // Provider detail (Get) modal + // ------------------------------------------------------------------ + + fn handle_provider_detail_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + self.provider_detail = None; + } + _ => {} + } + } + + // ------------------------------------------------------------------ + // Update provider modal + // ------------------------------------------------------------------ + + fn open_update_provider_form(&mut self) { + let name = match self.provider_names.get(self.provider_selected) { + Some(n) => n.clone(), + None => return, + }; + let ptype = self + .provider_types + .get(self.provider_selected) + .cloned() + .unwrap_or_default(); + let cred_key = self + .provider_cred_keys + .get(self.provider_selected) + .cloned() + .unwrap_or_default(); + + // If we don't know the credential key, derive from registry. + let key = if cred_key.is_empty() { + let registry = navigator_providers::ProviderRegistry::new(); + registry + .credential_env_vars(&ptype) + .first() + .map_or(String::new(), |s| s.to_string()) + } else { + cred_key + }; + + self.update_provider_form = Some(UpdateProviderForm { + provider_name: name, + provider_type: ptype, + credential_key: key, + new_value: String::new(), + status: None, + }); + } + + fn handle_update_provider_key(&mut self, key: KeyEvent) { + let Some(form) = self.update_provider_form.as_mut() else { + return; + }; + + match key.code { + KeyCode::Esc => { + self.update_provider_form = None; + } + KeyCode::Enter => { + if form.new_value.is_empty() { + form.status = Some("Value is required.".to_string()); + return; + } + self.pending_provider_update = true; + } + KeyCode::Char(c) => form.new_value.push(c), + KeyCode::Backspace => { + form.new_value.pop(); + } + _ => {} + } + } + + // ------------------------------------------------------------------ + // Shared helpers + // ------------------------------------------------------------------ + + fn handle_text_input(field: &mut String, key: KeyEvent) { + match key.code { + KeyCode::Char(c) => field.push(c), + KeyCode::Backspace => { + field.pop(); + } + _ => {} + } + } + + fn handle_command_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + self.command_input.clear(); + } + KeyCode::Enter => { + self.execute_command(); + self.input_mode = InputMode::Normal; + self.command_input.clear(); + } + KeyCode::Char(c) => self.command_input.push(c), + KeyCode::Backspace => { + self.command_input.pop(); + } + _ => {} + } + } + + fn execute_command(&mut self) { + let cmd = self.command_input.trim(); + match cmd { + "q" | "quit" => self.running = false, + _ => {} + } + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /// Get the ID of the currently selected sandbox. + pub fn selected_sandbox_id(&self) -> Option<&str> { + self.sandbox_ids + .get(self.sandbox_selected) + .map(String::as_str) + } + + /// Get the name of the currently selected sandbox. + pub fn selected_sandbox_name(&self) -> Option<&str> { + self.sandbox_names + .get(self.sandbox_selected) + .map(String::as_str) + } + + /// Get the name of the currently selected provider. + pub fn selected_provider_name(&self) -> Option<&str> { + self.provider_names + .get(self.provider_selected) + .map(String::as_str) + } + + pub fn log_autoscroll_offset(&self) -> usize { + const BOTTOM_PAD: usize = 3; + let filtered_len = self.filtered_log_lines().len(); + let vh = self.log_viewport_height; + if vh == 0 || filtered_len == 0 { + return 0; + } + let usable = vh.saturating_sub(BOTTOM_PAD); + filtered_len.saturating_sub(usable) + } + + /// Cancel any running log stream task. + pub fn cancel_log_stream(&mut self) { + if let Some(handle) = self.log_stream_handle.take() { + handle.abort(); + } + } + + /// Stop the animation ticker if running. + pub fn stop_anim(&mut self) { + if let Some(h) = self.anim_handle.take() { + h.abort(); + } + } + + /// Reset sandbox and provider state after switching clusters. + pub fn reset_sandbox_state(&mut self) { + self.stop_anim(); + self.cancel_log_stream(); + self.sandbox_ids.clear(); + self.sandbox_names.clear(); + self.sandbox_phases.clear(); + self.sandbox_ages.clear(); + self.sandbox_created.clear(); + self.sandbox_images.clear(); + self.sandbox_selected = 0; + self.sandbox_count = 0; + self.sandbox_log_lines.clear(); + self.sandbox_log_scroll = 0; + self.log_cursor = 0; + self.log_autoscroll = true; + self.log_detail_index = None; + self.confirm_delete = false; + self.sandbox_policy = None; + self.sandbox_providers_list.clear(); + self.policy_lines.clear(); + self.policy_scroll = 0; + // Reset provider state too. + self.provider_names.clear(); + self.provider_types.clear(); + self.provider_cred_keys.clear(); + self.provider_selected = 0; + self.provider_count = 0; + self.confirm_provider_delete = false; + self.status_text = String::from("connecting..."); + if self.screen == Screen::Sandbox { + self.screen = Screen::Dashboard; + self.focus = Focus::Sandboxes; + } + } +} + +/// Generate a unique provider name by appending `-1`, `-2`, etc. if needed. +fn unique_provider_name(base: &str, existing: &[String]) -> String { + if !existing.iter().any(|n| n == base) { + return base.to_string(); + } + for i in 1..100 { + let candidate = format!("{base}-{i}"); + if !existing.iter().any(|n| n == &candidate) { + return candidate; + } + } + base.to_string() +} diff --git a/crates/navigator-tui/src/event.rs b/crates/navigator-tui/src/event.rs new file mode 100644 index 000000000..7fb38c4c7 --- /dev/null +++ b/crates/navigator-tui/src/event.rs @@ -0,0 +1,115 @@ +use crossterm::event::{self, Event as TermEvent, KeyEvent, MouseEvent}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use tokio::sync::mpsc; + +use crate::app::LogLine; + +#[derive(Debug)] +pub enum Event { + Key(KeyEvent), + Mouse(MouseEvent), + Tick, + /// Lightweight redraw trigger (no data refresh). Used for animations. + Redraw, + #[allow(dead_code)] + Resize(u16, u16), + /// A batch of log lines from the streaming log task. + LogLines(Vec), + /// Result of a create sandbox request: `Ok(name)` or `Err(message)`. + CreateResult(Result), + /// Result of creating a provider on the gateway: `Ok(name)` or `Err(message)`. + ProviderCreateResult(Result), + /// Provider detail fetched from gateway. + ProviderDetailFetched(Result, String>), + /// Provider update result: `Ok(name)` or `Err(message)`. + ProviderUpdateResult(Result), + /// Provider delete result: `Ok(deleted)` or `Err(message)`. + ProviderDeleteResult(Result), +} + +pub struct EventHandler { + rx: mpsc::UnboundedReceiver, + // Kept alive so the spawned task's `tx` doesn't see a closed channel. + _keepalive: mpsc::UnboundedSender, + /// When true, the background reader stops polling stdin. + paused: Arc, +} + +impl EventHandler { + pub fn new(tick_rate: Duration) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let keepalive = tx.clone(); + let paused = Arc::new(AtomicBool::new(false)); + let paused_flag = paused.clone(); + + tokio::spawn(async move { + // Use a short poll interval so we check the paused flag frequently. + // The tick event fires when the full tick_rate elapses without input. + let poll_interval = Duration::from_millis(50); + let mut since_tick = std::time::Instant::now(); + + loop { + // When paused, sleep instead of polling stdin so the child + // process (e.g. SSH shell) gets uncontested access to stdin. + if paused_flag.load(Ordering::Relaxed) { + tokio::time::sleep(Duration::from_millis(50)).await; + since_tick = std::time::Instant::now(); + continue; + } + + if event::poll(poll_interval).unwrap_or(false) { + match event::read() { + Ok(TermEvent::Key(key)) => { + if tx.send(Event::Key(key)).is_err() { + return; + } + } + Ok(TermEvent::Mouse(mouse)) => { + if tx.send(Event::Mouse(mouse)).is_err() { + return; + } + } + Ok(TermEvent::Resize(w, h)) => { + if tx.send(Event::Resize(w, h)).is_err() { + return; + } + } + _ => {} + } + } else if since_tick.elapsed() >= tick_rate { + since_tick = std::time::Instant::now(); + if tx.send(Event::Tick).is_err() { + return; + } + } + } + }); + + Self { + rx, + _keepalive: keepalive, + paused, + } + } + + pub async fn next(&mut self) -> Option { + self.rx.recv().await + } + + /// Get a sender handle for dispatching events from background tasks. + pub fn sender(&self) -> mpsc::UnboundedSender { + self._keepalive.clone() + } + + /// Pause stdin polling (call before suspending TUI for a child process). + pub fn pause(&self) { + self.paused.store(true, Ordering::Relaxed); + } + + /// Resume stdin polling (call after child process exits and TUI resumes). + pub fn resume(&self) { + self.paused.store(false, Ordering::Relaxed); + } +} diff --git a/crates/navigator-tui/src/lib.rs b/crates/navigator-tui/src/lib.rs new file mode 100644 index 000000000..b0d7134de --- /dev/null +++ b/crates/navigator-tui/src/lib.rs @@ -0,0 +1,1329 @@ +mod app; +mod event; +pub mod theme; +mod ui; + +use std::io; +use std::path::PathBuf; +use std::time::Duration; + +use crossterm::event::{DisableMouseCapture, EnableMouseCapture, MouseEventKind}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use miette::{IntoDiagnostic, Result}; +use navigator_core::proto::navigator_client::NavigatorClient; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use tokio::sync::mpsc; +use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity}; + +use app::{App, ClusterEntry, Focus, LogLine, Screen}; +use event::{Event, EventHandler}; + +/// Launch the Gator TUI. +/// +/// `channel` must be a connected gRPC channel to the Navigator gateway. +pub async fn run(channel: Channel, cluster_name: &str, endpoint: &str) -> Result<()> { + let client = NavigatorClient::new(channel); + let mut app = App::new(client, cluster_name.to_string(), endpoint.to_string()); + + enable_raw_mode().into_diagnostic()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture).into_diagnostic()?; + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend).into_diagnostic()?; + terminal.clear().into_diagnostic()?; + + let mut events = EventHandler::new(Duration::from_secs(2)); + + refresh_cluster_list(&mut app); + refresh_data(&mut app).await; + + while app.running { + terminal + .draw(|frame| ui::draw(frame, &mut app)) + .into_diagnostic()?; + + match events.next().await { + Some(Event::Key(key)) => { + app.handle_key(key); + // Handle async actions triggered by key presses. + if app.pending_cluster_switch.is_some() { + handle_cluster_switch(&mut app).await; + } + if app.pending_log_fetch { + app.pending_log_fetch = false; + spawn_log_stream(&mut app, events.sender()); + } + if app.pending_sandbox_delete { + app.pending_sandbox_delete = false; + handle_sandbox_delete(&mut app).await; + } + if app.pending_create_sandbox { + app.pending_create_sandbox = false; + spawn_create_sandbox(&app, events.sender()); + start_anim_ticker(&mut app, events.sender()); + } + // --- Provider CRUD --- + if app.pending_provider_create { + app.pending_provider_create = false; + spawn_create_provider(&app, events.sender()); + start_anim_ticker(&mut app, events.sender()); + } + if app.pending_provider_get { + app.pending_provider_get = false; + spawn_get_provider(&app, events.sender()); + } + if app.pending_provider_update { + app.pending_provider_update = false; + spawn_update_provider(&app, events.sender()); + } + if app.pending_provider_delete { + app.pending_provider_delete = false; + spawn_delete_provider(&app, events.sender()); + } + if app.pending_sandbox_detail { + app.pending_sandbox_detail = false; + fetch_sandbox_detail(&mut app).await; + } + if app.pending_shell_connect { + app.pending_shell_connect = false; + handle_shell_connect(&mut app, &mut terminal, &events).await; + refresh_data(&mut app).await; + } + } + Some(Event::LogLines(lines)) => { + app.sandbox_log_lines.extend(lines); + if app.log_autoscroll { + app.sandbox_log_scroll = app.log_autoscroll_offset(); + // Pin cursor to the last visible line during autoscroll. + let filtered_len = app.filtered_log_lines().len(); + let visible = filtered_len + .saturating_sub(app.sandbox_log_scroll) + .min(app.log_viewport_height); + app.log_cursor = visible.saturating_sub(1); + } + } + Some(Event::CreateResult(result)) => { + // Buffer the result — don't close yet. The Redraw handler + // will finalize once MIN_CREATING_DISPLAY has elapsed. + if let Some(form) = app.create_form.as_mut() { + form.create_result = Some(result); + } + } + Some(Event::ProviderCreateResult(result)) => { + // Buffer the result for min-display handling in Redraw. + if let Some(form) = app.create_provider_form.as_mut() { + form.create_result = Some(result); + } + } + Some(Event::ProviderDetailFetched(result)) => match result { + Ok(provider) => { + let cred_key = provider + .credentials + .keys() + .next() + .cloned() + .unwrap_or_default(); + let masked = if let Some(val) = provider.credentials.values().next() { + mask_secret(val) + } else { + "-".to_string() + }; + app.provider_detail = Some(app::ProviderDetailView { + name: provider.name.clone(), + provider_type: provider.r#type.clone(), + credential_key: cred_key, + masked_value: masked, + }); + } + Err(msg) => { + app.status_text = format!("get provider failed: {msg}"); + } + }, + Some(Event::ProviderUpdateResult(result)) => match result { + Ok(name) => { + app.update_provider_form = None; + app.status_text = format!("Updated provider: {name}"); + refresh_providers(&mut app).await; + } + Err(msg) => { + if let Some(form) = app.update_provider_form.as_mut() { + form.status = Some(format!("Failed: {msg}")); + } + } + }, + Some(Event::ProviderDeleteResult(result)) => match result { + Ok(true) => { + app.status_text = "Provider deleted.".to_string(); + refresh_providers(&mut app).await; + } + Ok(false) => { + app.status_text = "Provider not found.".to_string(); + } + Err(msg) => { + app.status_text = format!("delete provider failed: {msg}"); + } + }, + Some(Event::Mouse(mouse)) => match mouse.kind { + MouseEventKind::ScrollUp if app.focus == Focus::SandboxLogs => { + app.scroll_logs(-3); + } + MouseEventKind::ScrollDown if app.focus == Focus::SandboxLogs => { + app.scroll_logs(3); + } + MouseEventKind::ScrollUp if app.focus == Focus::SandboxPolicy => { + app.scroll_policy(-3); + } + MouseEventKind::ScrollDown if app.focus == Focus::SandboxPolicy => { + app.scroll_policy(3); + } + _ => {} + }, + Some(Event::Tick) => { + refresh_cluster_list(&mut app); + refresh_data(&mut app).await; + } + Some(Event::Redraw) => { + // Check if a buffered sandbox CreateResult is ready to finalize. + if let Some(form) = app.create_form.as_ref() { + if form.create_result.is_some() { + let elapsed = form + .anim_start + .map_or(app::MIN_CREATING_DISPLAY, |s| s.elapsed()); + if elapsed >= app::MIN_CREATING_DISPLAY { + let result = app + .create_form + .as_mut() + .and_then(|f| f.create_result.take()); + if let Some(h) = app.anim_handle.take() { + h.abort(); + } + match result { + Some(Ok(name)) => { + app.create_form = None; + app.status_text = format!("Created sandbox: {name}"); + refresh_sandboxes(&mut app).await; + } + Some(Err(msg)) => { + if let Some(form) = app.create_form.as_mut() { + form.phase = app::CreatePhase::Form; + form.anim_start = None; + form.status = Some(format!("Create failed: {msg}")); + } + } + None => {} + } + } + } + } + // Check if a buffered provider CreateResult is ready to finalize. + if let Some(form) = app.create_provider_form.as_ref() { + if form.create_result.is_some() { + let elapsed = form + .anim_start + .map_or(app::MIN_CREATING_DISPLAY, |s| s.elapsed()); + if elapsed >= app::MIN_CREATING_DISPLAY { + let result = app + .create_provider_form + .as_mut() + .and_then(|f| f.create_result.take()); + if let Some(h) = app.anim_handle.take() { + h.abort(); + } + match result { + Some(Ok(name)) => { + app.create_provider_form = None; + app.status_text = format!("Created provider: {name}"); + refresh_providers(&mut app).await; + } + Some(Err(msg)) => { + if let Some(form) = app.create_provider_form.as_mut() { + form.phase = app::CreateProviderPhase::EnterKey; + form.anim_start = None; + form.status = Some(format!("Create failed: {msg}")); + } + } + None => {} + } + } + } + } + } + Some(Event::Resize(_, _)) => {} // ratatui handles resize on next draw + None => break, + } + } + + // Cancel any running background tasks. + app.cancel_log_stream(); + app.stop_anim(); + + disable_raw_mode().into_diagnostic()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ) + .into_diagnostic()?; + terminal.show_cursor().into_diagnostic()?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Cluster discovery and switching +// --------------------------------------------------------------------------- + +/// Refresh the list of known clusters from disk. +fn refresh_cluster_list(app: &mut App) { + if let Ok(clusters) = navigator_bootstrap::list_clusters() { + app.clusters = clusters + .into_iter() + .map(|m| ClusterEntry { + name: m.name, + endpoint: m.gateway_endpoint, + is_remote: m.is_remote, + }) + .collect(); + + // Keep selection in bounds. + if app.cluster_selected >= app.clusters.len() && !app.clusters.is_empty() { + app.cluster_selected = app.clusters.len() - 1; + } + + // If the active cluster appears in the list, move cursor to it on first load. + if let Some(idx) = app.clusters.iter().position(|c| c.name == app.cluster_name) { + // Only snap the cursor when it's still at 0 (initial state). + if app.cluster_selected == 0 { + app.cluster_selected = idx; + } + } + } +} + +/// Handle a pending cluster switch requested by the user. +async fn handle_cluster_switch(app: &mut App) { + let Some(name) = app.pending_cluster_switch.take() else { + return; + }; + + // Look up the endpoint from the cluster list. + let endpoint = match app.clusters.iter().find(|c| c.name == name) { + Some(c) => c.endpoint.clone(), + None => return, + }; + + match connect_to_cluster(&name, &endpoint).await { + Ok(channel) => { + app.client = NavigatorClient::new(channel); + app.cluster_name = name; + app.endpoint = endpoint; + app.reset_sandbox_state(); + // Immediately refresh data for the new cluster. + refresh_data(app).await; + } + Err(e) => { + app.status_text = format!("switch failed: {e}"); + } + } +} + +/// Build a gRPC channel to a cluster using its mTLS certs on disk. +async fn connect_to_cluster(name: &str, endpoint: &str) -> Result { + let mtls_dir = cluster_mtls_dir(name) + .ok_or_else(|| miette::miette!("cannot determine config directory for cluster {name}"))?; + + let ca = std::fs::read(mtls_dir.join("ca.crt")) + .into_diagnostic() + .map_err(|_| miette::miette!("missing CA cert for cluster {name}"))?; + let cert = std::fs::read(mtls_dir.join("tls.crt")) + .into_diagnostic() + .map_err(|_| miette::miette!("missing client cert for cluster {name}"))?; + let key = std::fs::read(mtls_dir.join("tls.key")) + .into_diagnostic() + .map_err(|_| miette::miette!("missing client key for cluster {name}"))?; + + let tls_config = ClientTlsConfig::new() + .ca_certificate(Certificate::from_pem(ca)) + .identity(Identity::from_pem(cert, key)); + + let channel = Endpoint::from_shared(endpoint.to_string()) + .into_diagnostic()? + .connect_timeout(Duration::from_secs(10)) + .http2_keep_alive_interval(Duration::from_secs(10)) + .keep_alive_while_idle(true) + .tls_config(tls_config) + .into_diagnostic()? + .connect() + .await + .into_diagnostic()?; + + Ok(channel) +} + +/// Resolve the mTLS cert directory for a cluster. +fn cluster_mtls_dir(name: &str) -> Option { + let config_dir = std::env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|_| std::env::var("HOME").map(|h| PathBuf::from(h).join(".config"))) + .ok()?; + Some( + config_dir + .join("navigator") + .join("clusters") + .join(name) + .join("mtls"), + ) +} + +// --------------------------------------------------------------------------- +// Sandbox actions +// --------------------------------------------------------------------------- + +/// Spawn a background task that streams logs for the currently selected sandbox. +/// +/// Uses `WatchSandbox` with `follow_logs: true` for live streaming. Initial +/// history is fetched via `GetSandboxLogs`, then live events are appended. +fn spawn_log_stream(app: &mut App, tx: mpsc::UnboundedSender) { + // Cancel any previous stream. + app.cancel_log_stream(); + + let sandbox_id = match app.selected_sandbox_id() { + Some(id) => id.to_string(), + None => return, + }; + + let mut client = app.client.clone(); + + let handle = tokio::spawn(async move { + // Phase 1: Fetch initial history via unary RPC. + let req = navigator_core::proto::GetSandboxLogsRequest { + sandbox_id: sandbox_id.clone(), + lines: 500, + since_ms: 0, + sources: vec![], + min_level: String::new(), + }; + + match tokio::time::timeout(Duration::from_secs(5), client.get_sandbox_logs(req)).await { + Ok(Ok(resp)) => { + let logs = resp.into_inner().logs; + let lines: Vec = logs.into_iter().map(proto_to_log_line).collect(); + if !lines.is_empty() { + let _ = tx.send(Event::LogLines(lines)); + } + } + Ok(Err(e)) => { + let _ = tx.send(Event::LogLines(vec![LogLine { + timestamp_ms: 0, + level: "ERROR".into(), + source: String::new(), + target: String::new(), + message: format!("Failed to fetch logs: {}", e.message()), + fields: Default::default(), + }])); + return; + } + Err(_) => { + let _ = tx.send(Event::LogLines(vec![LogLine { + timestamp_ms: 0, + level: "ERROR".into(), + source: String::new(), + target: String::new(), + message: "Timed out fetching logs.".into(), + fields: Default::default(), + }])); + return; + } + } + + // Phase 2: Stream live logs via WatchSandbox. + let req = navigator_core::proto::WatchSandboxRequest { + id: sandbox_id, + follow_status: false, + follow_logs: true, + follow_events: false, + log_tail_lines: 0, // Don't re-fetch tail, we already have history. + ..Default::default() + }; + + let resp = + match tokio::time::timeout(Duration::from_secs(5), client.watch_sandbox(req)).await { + Ok(Ok(r)) => r, + Ok(Err(_)) | Err(_) => return, // Silently stop — user can re-enter logs. + }; + + let mut stream = resp.into_inner(); + loop { + match stream.message().await { + Ok(Some(event)) => { + if let Some(navigator_core::proto::sandbox_stream_event::Payload::Log(log)) = + event.payload + { + let line = proto_to_log_line(log); + let _ = tx.send(Event::LogLines(vec![line])); + } + } + _ => break, // Stream ended or error. + } + } + }); + + app.log_stream_handle = Some(handle); +} + +/// Convert a proto `SandboxLogLine` to our display `LogLine`. +fn proto_to_log_line(log: navigator_core::proto::SandboxLogLine) -> LogLine { + let source = if log.source.is_empty() { + "gateway".to_string() + } else { + log.source + }; + LogLine { + timestamp_ms: log.timestamp_ms, + level: log.level, + source, + target: log.target, + message: log.message, + fields: log.fields, + } +} + +/// Delete the currently selected sandbox. +async fn handle_sandbox_delete(app: &mut App) { + let sandbox_name = match app.selected_sandbox_name() { + Some(n) => n.to_string(), + None => return, + }; + + let req = navigator_core::proto::DeleteSandboxRequest { name: sandbox_name }; + match app.client.delete_sandbox(req).await { + Ok(_) => { + app.cancel_log_stream(); + app.screen = Screen::Dashboard; + app.focus = Focus::Sandboxes; + refresh_sandboxes(app).await; + } + Err(e) => { + app.status_text = format!("delete failed: {}", e.message()); + app.screen = Screen::Dashboard; + app.focus = Focus::Sandboxes; + } + } +} + +// --------------------------------------------------------------------------- +// Sandbox detail + policy rendering +// --------------------------------------------------------------------------- + +/// Fetch sandbox details (policy + providers) when entering the sandbox screen. +async fn fetch_sandbox_detail(app: &mut App) { + let sandbox_name = match app.selected_sandbox_name() { + Some(n) => n.to_string(), + None => return, + }; + + let req = navigator_core::proto::GetSandboxRequest { name: sandbox_name }; + + match tokio::time::timeout(Duration::from_secs(5), app.client.get_sandbox(req)).await { + Ok(Ok(resp)) => { + if let Some(sandbox) = resp.into_inner().sandbox { + if let Some(spec) = &sandbox.spec { + app.sandbox_providers_list = spec.providers.clone(); + if let Some(policy) = &spec.policy { + app.policy_lines = render_policy_lines(policy); + app.sandbox_policy = Some(policy.clone()); + } + } + } + } + Ok(Err(e)) => { + tracing::warn!("failed to fetch sandbox detail: {}", e.message()); + } + Err(_) => { + tracing::warn!("sandbox detail request timed out"); + } + } + + app.policy_scroll = 0; +} + +// --------------------------------------------------------------------------- +// Shell connect (suspend TUI, launch SSH, resume) +// --------------------------------------------------------------------------- + +/// Suspend the TUI, launch an interactive SSH shell to the sandbox, resume on exit. +/// +/// This replicates the `nav sandbox connect` flow but uses `Command::status()` +/// instead of `exec()` so the TUI process survives. +async fn handle_shell_connect( + app: &mut App, + terminal: &mut Terminal>, + events: &EventHandler, +) { + let sandbox_name = match app.selected_sandbox_name() { + Some(n) => n.to_string(), + None => return, + }; + + // Step 1: Get sandbox ID. + let sandbox_id = { + let req = navigator_core::proto::GetSandboxRequest { + name: sandbox_name.clone(), + }; + match tokio::time::timeout(Duration::from_secs(5), app.client.get_sandbox(req)).await { + Ok(Ok(resp)) => match resp.into_inner().sandbox { + Some(s) => s.id, + None => { + app.status_text = "sandbox not found".to_string(); + return; + } + }, + Ok(Err(e)) => { + app.status_text = format!("failed to get sandbox: {}", e.message()); + return; + } + Err(_) => { + app.status_text = "get sandbox timed out".to_string(); + return; + } + } + }; + + // Step 2: Create SSH session. + let session = { + let req = navigator_core::proto::CreateSshSessionRequest { + sandbox_id: sandbox_id.clone(), + }; + match tokio::time::timeout(Duration::from_secs(5), app.client.create_ssh_session(req)).await + { + Ok(Ok(resp)) => resp.into_inner(), + Ok(Err(e)) => { + app.status_text = format!("SSH session failed: {}", e.message()); + return; + } + Err(_) => { + app.status_text = "SSH session request timed out".to_string(); + return; + } + } + }; + + // Step 3: Resolve gateway address (handle loopback override). + #[allow(clippy::cast_possible_truncation)] + let gateway_port_u16 = session.gateway_port as u16; + let (gateway_host, gateway_port) = + resolve_ssh_gateway(&session.gateway_host, gateway_port_u16, &app.endpoint); + let gateway_url = format!( + "{}://{}:{gateway_port}{}", + session.gateway_scheme, gateway_host, session.connect_path + ); + + // Step 4: Build the ProxyCommand using our own binary. + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + app.status_text = format!("failed to find executable: {e}"); + return; + } + }; + let exe_str = shell_escape(&exe.to_string_lossy()); + let proxy_command = format!( + "{exe_str} ssh-proxy --gateway {gateway_url} --sandbox-id {} --token {}", + session.sandbox_id, session.token, + ); + + // Step 5: Build the SSH command. + let mut command = std::process::Command::new("ssh"); + command + .arg("-o") + .arg(format!("ProxyCommand={proxy_command}")) + .arg("-o") + .arg("StrictHostKeyChecking=no") + .arg("-o") + .arg("UserKnownHostsFile=/dev/null") + .arg("-o") + .arg("GlobalKnownHostsFile=/dev/null") + .arg("-o") + .arg("LogLevel=ERROR") + .arg("-tt") + .arg("-o") + .arg("RequestTTY=force") + .arg("-o") + .arg("SetEnv=TERM=xterm-256color") + .arg("sandbox") + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + + // Step 6: Cancel log stream and pause event handler before suspending. + app.cancel_log_stream(); + events.pause(); + // Wait for the reader task to finish its current poll cycle (tick_rate = 2s max). + tokio::time::sleep(Duration::from_millis(100)).await; + + // Step 7: Suspend TUI — leave alternate screen, disable raw mode. + let _ = execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ); + let _ = disable_raw_mode(); + + // Step 8: Spawn SSH as child process and wait. + let status = tokio::task::spawn_blocking(move || command.status()).await; + match &status { + Ok(Ok(s)) if !s.success() => { + app.status_text = format!("ssh exited with status {s}"); + } + Ok(Err(e)) => { + app.status_text = format!("failed to launch ssh: {e}"); + } + Err(e) => { + app.status_text = format!("shell task failed: {e}"); + } + _ => { + app.status_text = format!("Disconnected from {sandbox_name}"); + } + } + + // Step 9: Resume TUI — re-enter alternate screen, enable raw mode, unpause events. + let _ = enable_raw_mode(); + let _ = execute!( + terminal.backend_mut(), + EnterAlternateScreen, + EnableMouseCapture + ); + let _ = terminal.clear(); + events.resume(); +} + +/// If the gateway host is a loopback address, override it with the host and port +/// from the cluster endpoint URL. Mirrors `resolve_ssh_gateway` in `ssh.rs`. +fn resolve_ssh_gateway(gateway_host: &str, gateway_port: u16, cluster_url: &str) -> (String, u16) { + let is_loopback = gateway_host == "127.0.0.1" + || gateway_host == "0.0.0.0" + || gateway_host == "localhost" + || gateway_host == "::1"; + + if !is_loopback { + return (gateway_host.to_string(), gateway_port); + } + + if let Ok(url) = url::Url::parse(cluster_url) { + if let Some(host) = url.host_str() { + let cluster_port = url.port().unwrap_or(gateway_port); + let cluster_is_loopback = + host == "127.0.0.1" || host == "0.0.0.0" || host == "localhost" || host == "::1"; + if !cluster_is_loopback { + return (host.to_string(), cluster_port); + } + // Both loopback — use cluster URL's port (Docker-mapped). + return (gateway_host.to_string(), cluster_port); + } + } + + (gateway_host.to_string(), gateway_port) +} + +/// Shell-escape a value for use inside a ProxyCommand string. +fn shell_escape(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + let safe = value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'-' | b'_')); + if safe { + return value.to_string(); + } + let escaped = value.replace('\'', "'\"'\"'"); + format!("'{escaped}'") +} + +/// Convert a `SandboxPolicy` proto into styled ratatui lines for the policy viewer. +fn render_policy_lines( + policy: &navigator_core::proto::SandboxPolicy, +) -> Vec> { + use crate::theme::styles; + use ratatui::text::{Line, Span}; + + let mut lines: Vec> = Vec::new(); + + // --- Filesystem Access --- + if let Some(fs) = &policy.filesystem { + lines.push(Line::from(Span::styled( + "Filesystem Access", + styles::HEADING, + ))); + + if !fs.read_only.is_empty() { + let paths = fs.read_only.join(", "); + lines.push(Line::from(vec![ + Span::styled(" Read-only: ", styles::MUTED), + Span::styled(paths, styles::TEXT), + ])); + } + + if !fs.read_write.is_empty() { + let paths = fs.read_write.join(", "); + lines.push(Line::from(vec![ + Span::styled(" Read-write: ", styles::MUTED), + Span::styled(paths, styles::TEXT), + ])); + } + + lines.push(Line::from("")); + } + + // --- Inference --- + if let Some(inference) = &policy.inference { + if !inference.allowed_routes.is_empty() { + lines.push(Line::from(Span::styled("Inference", styles::HEADING))); + let routes = inference.allowed_routes.join(", "); + lines.push(Line::from(vec![ + Span::styled(" Allowed routes: ", styles::MUTED), + Span::styled(routes, styles::TEXT), + ])); + lines.push(Line::from("")); + } + } + + // --- Network Rules --- + if !policy.network_policies.is_empty() { + // Sort keys for deterministic display. + let mut rule_names: Vec<&String> = policy.network_policies.keys().collect(); + rule_names.sort(); + + let header = format!("Network Rules ({})", rule_names.len()); + lines.push(Line::from(Span::styled(header, styles::HEADING))); + lines.push(Line::from("")); + + for name in rule_names { + let Some(rule) = policy.network_policies.get(name) else { + continue; + }; + + // Skip rules with no endpoints (useless policies). + if rule.endpoints.is_empty() { + continue; + } + + // Rule header — include L7/TLS annotation if any endpoint has it. + let has_l7 = rule.endpoints.iter().any(|e| !e.protocol.is_empty()); + let has_tls_term = rule.endpoints.iter().any(|e| e.tls == "terminate"); + let mut annotations = Vec::new(); + if has_l7 { + // Use the first L7 endpoint's protocol for the label. + if let Some(proto) = rule + .endpoints + .iter() + .find(|e| !e.protocol.is_empty()) + .map(|e| e.protocol.to_uppercase()) + { + annotations.push(format!("L7 {proto}")); + } + } + if has_tls_term { + annotations.push("TLS terminate".to_string()); + } + + let title = if annotations.is_empty() { + format!(" {name}") + } else { + format!(" {name} ({})", annotations.join(", ")) + }; + lines.push(Line::from(Span::styled(title, styles::ACCENT))); + + // Endpoints. + for ep in &rule.endpoints { + let addr = if ep.port > 0 { + format!(" {}:{}", ep.host, ep.port) + } else { + format!(" {}", ep.host) + }; + lines.push(Line::from(Span::styled(addr, styles::TEXT))); + + // L7 allow rules. + for l7 in &ep.rules { + if let Some(allow) = &l7.allow { + let method = if allow.method.is_empty() { + "*" + } else { + &allow.method + }; + let target = if !allow.path.is_empty() { + &allow.path + } else if !allow.command.is_empty() { + &allow.command + } else { + "*" + }; + lines.push(Line::from(vec![ + Span::styled(" Allow: ", styles::MUTED), + Span::styled(format!("{:<6} {}", method, target), styles::TEXT), + ])); + } + } + + // Access preset (if set instead of explicit rules). + if !ep.access.is_empty() && ep.rules.is_empty() { + lines.push(Line::from(vec![ + Span::styled(" Access: ", styles::MUTED), + Span::styled(ep.access.clone(), styles::TEXT), + ])); + } + } + + // Binaries. + let binary_paths: Vec<&str> = rule.binaries.iter().map(|b| b.path.as_str()).collect(); + if !binary_paths.is_empty() { + lines.push(Line::from(vec![ + Span::styled(" Binaries: ", styles::MUTED), + Span::styled(binary_paths.join(", "), styles::TEXT), + ])); + } + + lines.push(Line::from("")); + } + } + + // If nothing was rendered, add a placeholder. + if lines.is_empty() { + lines.push(Line::from(Span::styled( + "No policy data available.", + styles::MUTED, + ))); + } + + lines +} + +// --------------------------------------------------------------------------- +// Animation helper +// --------------------------------------------------------------------------- + +/// Spawn a fast animation ticker (~7 fps) and store the handle on the app. +fn start_anim_ticker(app: &mut App, tx: mpsc::UnboundedSender) { + let anim_tx = tx; + app.anim_handle = Some(tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(140)).await; + if anim_tx.send(Event::Redraw).is_err() { + break; + } + } + })); +} + +// --------------------------------------------------------------------------- +// Create sandbox (simplified — uses pre-selected provider names) +// --------------------------------------------------------------------------- + +fn spawn_create_sandbox(app: &App, tx: mpsc::UnboundedSender) { + let mut client = app.client.clone(); + let Some((name, image, _command, selected_providers)) = app.create_form_data() else { + return; + }; + + tokio::spawn(async move { + let has_custom_image = !image.is_empty(); + let template = if has_custom_image { + Some(navigator_core::proto::SandboxTemplate { + image, + ..Default::default() + }) + } else { + None + }; + + let mut policy = navigator_policy::default_sandbox_policy(); + if has_custom_image { + navigator_policy::clear_process_identity(&mut policy); + } + + let req = navigator_core::proto::CreateSandboxRequest { + name, + spec: Some(navigator_core::proto::SandboxSpec { + providers: selected_providers, + template, + policy: Some(policy), + ..Default::default() + }), + }; + + match tokio::time::timeout(Duration::from_secs(30), client.create_sandbox(req)).await { + Ok(Ok(resp)) => { + let sandbox_name = resp + .into_inner() + .sandbox + .map_or_else(|| "unknown".to_string(), |s| s.name); + let _ = tx.send(Event::CreateResult(Ok(sandbox_name))); + } + Ok(Err(e)) => { + let _ = tx.send(Event::CreateResult(Err(e.message().to_string()))); + } + Err(_) => { + let _ = tx.send(Event::CreateResult(Err("request timed out".to_string()))); + } + } + }); +} + +// --------------------------------------------------------------------------- +// Provider CRUD +// --------------------------------------------------------------------------- + +/// Create a provider on the gateway. +fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { + let mut client = app.client.clone(); + let Some(form) = &app.create_provider_form else { + return; + }; + + let ptype = form + .types + .get(form.type_cursor) + .cloned() + .unwrap_or_default(); + let name = if form.name.is_empty() { + ptype.clone() + } else { + form.name.clone() + }; + let credentials = form.discovered_credentials.clone().unwrap_or_default(); + + tokio::spawn(async move { + // Try with the chosen name, retry with suffix on collision. + for attempt in 0..5u32 { + let provider_name = if attempt == 0 { + name.clone() + } else { + format!("{name}-{attempt}") + }; + + let req = navigator_core::proto::CreateProviderRequest { + provider: Some(navigator_core::proto::Provider { + id: String::new(), + name: provider_name.clone(), + r#type: ptype.clone(), + credentials: credentials.clone(), + config: Default::default(), + }), + }; + + match client.create_provider(req).await { + Ok(resp) => { + let final_name = resp.into_inner().provider.map_or(provider_name, |p| p.name); + let _ = tx.send(Event::ProviderCreateResult(Ok(final_name))); + return; + } + Err(status) if status.code() == tonic::Code::AlreadyExists => { + // Retry with a different name. + } + Err(e) => { + let _ = tx.send(Event::ProviderCreateResult(Err(e.message().to_string()))); + return; + } + } + } + let _ = tx.send(Event::ProviderCreateResult(Err( + "name collision after 5 attempts".to_string(), + ))); + }); +} + +/// Fetch a single provider's details. +fn spawn_get_provider(app: &App, tx: mpsc::UnboundedSender) { + let mut client = app.client.clone(); + let name = match app.selected_provider_name() { + Some(n) => n.to_string(), + None => return, + }; + + tokio::spawn(async move { + let req = navigator_core::proto::GetProviderRequest { name }; + match tokio::time::timeout(Duration::from_secs(5), client.get_provider(req)).await { + Ok(Ok(resp)) => { + if let Some(provider) = resp.into_inner().provider { + let _ = tx.send(Event::ProviderDetailFetched(Ok(Box::new(provider)))); + } else { + let _ = tx.send(Event::ProviderDetailFetched(Err( + "provider not found in response".to_string(), + ))); + } + } + Ok(Err(e)) => { + let _ = tx.send(Event::ProviderDetailFetched(Err(e.message().to_string()))); + } + Err(_) => { + let _ = tx.send(Event::ProviderDetailFetched(Err( + "request timed out".to_string() + ))); + } + } + }); +} + +/// Update a provider's credentials. +fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { + let mut client = app.client.clone(); + let Some(form) = &app.update_provider_form else { + return; + }; + + let name = form.provider_name.clone(); + let ptype = form.provider_type.clone(); + let cred_key = form.credential_key.clone(); + let new_value = form.new_value.clone(); + + tokio::spawn(async move { + let mut credentials = std::collections::HashMap::new(); + credentials.insert(cred_key, new_value); + + let req = navigator_core::proto::UpdateProviderRequest { + provider: Some(navigator_core::proto::Provider { + id: String::new(), + name: name.clone(), + r#type: ptype, + credentials, + config: Default::default(), + }), + }; + + match tokio::time::timeout(Duration::from_secs(5), client.update_provider(req)).await { + Ok(Ok(_)) => { + let _ = tx.send(Event::ProviderUpdateResult(Ok(name))); + } + Ok(Err(e)) => { + let _ = tx.send(Event::ProviderUpdateResult(Err(e.message().to_string()))); + } + Err(_) => { + let _ = tx.send(Event::ProviderUpdateResult(Err( + "request timed out".to_string() + ))); + } + } + }); +} + +/// Delete a provider by name. +fn spawn_delete_provider(app: &App, tx: mpsc::UnboundedSender) { + let mut client = app.client.clone(); + let name = match app.selected_provider_name() { + Some(n) => n.to_string(), + None => return, + }; + + tokio::spawn(async move { + let req = navigator_core::proto::DeleteProviderRequest { name }; + match tokio::time::timeout(Duration::from_secs(5), client.delete_provider(req)).await { + Ok(Ok(resp)) => { + let _ = tx.send(Event::ProviderDeleteResult(Ok(resp.into_inner().deleted))); + } + Ok(Err(e)) => { + let _ = tx.send(Event::ProviderDeleteResult(Err(e.message().to_string()))); + } + Err(_) => { + let _ = tx.send(Event::ProviderDeleteResult(Err( + "request timed out".to_string() + ))); + } + } + }); +} + +/// Mask a secret value, showing only the first and last 2 chars. +fn mask_secret(value: &str) -> String { + let len = value.len(); + if len <= 6 { + "*".repeat(len) + } else { + let start: String = value.chars().take(2).collect(); + let end: String = value.chars().skip(len - 2).collect(); + format!("{start}{}…{end}", "*".repeat(len.saturating_sub(4).min(20))) + } +} + +// --------------------------------------------------------------------------- +// Data refresh +// --------------------------------------------------------------------------- + +async fn refresh_data(app: &mut App) { + refresh_health(app).await; + refresh_providers(app).await; + refresh_sandboxes(app).await; +} + +async fn refresh_providers(app: &mut App) { + let req = navigator_core::proto::ListProvidersRequest { + limit: 100, + offset: 0, + }; + let result = tokio::time::timeout(Duration::from_secs(5), app.client.list_providers(req)).await; + match result { + Ok(Err(e)) => { + tracing::warn!("failed to list providers: {}", e.message()); + } + Err(_) => { + tracing::warn!("list providers timed out"); + } + Ok(Ok(resp)) => { + let providers = resp.into_inner().providers; + app.provider_count = providers.len(); + app.provider_names = providers.iter().map(|p| p.name.clone()).collect(); + app.provider_types = providers.iter().map(|p| p.r#type.clone()).collect(); + app.provider_cred_keys = providers + .iter() + .map(|p| { + p.credentials + .keys() + .next() + .cloned() + .unwrap_or_else(|| "-".to_string()) + }) + .collect(); + if app.provider_selected >= app.provider_count && app.provider_count > 0 { + app.provider_selected = app.provider_count - 1; + } + } + } +} + +async fn refresh_health(app: &mut App) { + let req = navigator_core::proto::HealthRequest {}; + let result = tokio::time::timeout(Duration::from_secs(5), app.client.health(req)).await; + match result { + Ok(Ok(resp)) => { + let status = resp.into_inner().status; + app.status_text = match status { + 1 => "Healthy".to_string(), + 2 => "Degraded".to_string(), + 3 => "Unhealthy".to_string(), + _ => format!("Unknown ({status})"), + }; + } + Ok(Err(e)) => { + app.status_text = format!("error: {}", e.message()); + } + Err(_) => { + app.status_text = "timeout".to_string(); + } + } +} + +async fn refresh_sandboxes(app: &mut App) { + let req = navigator_core::proto::ListSandboxesRequest { + limit: 100, + offset: 0, + }; + let result = tokio::time::timeout(Duration::from_secs(5), app.client.list_sandboxes(req)).await; + match result { + Ok(Err(e)) => { + tracing::warn!("failed to list sandboxes: {}", e.message()); + } + Err(_) => { + tracing::warn!("list sandboxes timed out"); + } + Ok(Ok(resp)) => { + let sandboxes = resp.into_inner().sandboxes; + app.sandbox_count = sandboxes.len(); + app.sandbox_ids = sandboxes.iter().map(|s| s.id.clone()).collect(); + app.sandbox_names = sandboxes.iter().map(|s| s.name.clone()).collect(); + app.sandbox_phases = sandboxes.iter().map(|s| phase_label(s.phase)).collect(); + app.sandbox_images = sandboxes + .iter() + .map(|s| { + s.spec + .as_ref() + .and_then(|spec| spec.template.as_ref()) + .map(|t| t.image.as_str()) + .filter(|img| !img.is_empty()) + .unwrap_or("-") + .to_string() + }) + .collect(); + app.sandbox_ages = sandboxes + .iter() + .map(|s| format_age(s.created_at_ms)) + .collect(); + app.sandbox_created = sandboxes + .iter() + .map(|s| format_timestamp(s.created_at_ms)) + .collect(); + if app.sandbox_selected >= app.sandbox_count && app.sandbox_count > 0 { + app.sandbox_selected = app.sandbox_count - 1; + } + } + } +} + +fn phase_label(phase: i32) -> String { + match phase { + 1 => "Provisioning", + 2 => "Ready", + 3 => "Error", + 4 => "Deleting", + _ => "Unknown", + } + .to_string() +} + +fn format_age(epoch_ms: i64) -> String { + if epoch_ms <= 0 { + return String::from("-"); + } + let created_secs = epoch_ms / 1000; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs().cast_signed()); + let diff = now - created_secs; + if diff < 0 { + return String::from("-"); + } + let diff = diff.cast_unsigned(); + if diff < 60 { + format!("{diff}s") + } else if diff < 3600 { + format!("{}m", diff / 60) + } else if diff < 86400 { + format!("{}h {}m", diff / 3600, (diff % 3600) / 60) + } else { + format!("{}d {}h", diff / 86400, (diff % 86400) / 3600) + } +} + +/// Format epoch milliseconds as a human-readable UTC timestamp: `YYYY-MM-DD HH:MM`. +fn format_timestamp(epoch_ms: i64) -> String { + if epoch_ms <= 0 { + return String::from("-"); + } + let secs = epoch_ms / 1000; + let days = secs / 86400; + let time_of_day = secs % 86400; + let hours = time_of_day / 3600; + let minutes = (time_of_day % 3600) / 60; + + let (year, month, day) = days_to_ymd(days); + format!("{year:04}-{month:02}-{day:02} {hours:02}:{minutes:02}") +} + +/// Convert days since Unix epoch (1970-01-01) to (year, month, day). +#[allow(clippy::unreadable_literal)] +fn days_to_ymd(days: i64) -> (i64, i64, i64) { + let z = days + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} diff --git a/crates/navigator-tui/src/theme.rs b/crates/navigator-tui/src/theme.rs new file mode 100644 index 000000000..20113d561 --- /dev/null +++ b/crates/navigator-tui/src/theme.rs @@ -0,0 +1,38 @@ +pub mod colors { + use ratatui::style::Color; + + pub const NVIDIA_GREEN: Color = Color::Rgb(118, 185, 0); + pub const EVERGLADE: Color = Color::Rgb(18, 49, 35); + pub const MAROON: Color = Color::Rgb(128, 0, 0); + pub const BG: Color = Color::Black; + pub const FG: Color = Color::White; +} + +pub mod styles { + use ratatui::style::{Color, Modifier, Style}; + + use super::colors; + + pub const TEXT: Style = Style::new().fg(colors::FG); + pub const MUTED: Style = Style::new().fg(colors::FG).add_modifier(Modifier::DIM); + pub const HEADING: Style = Style::new().fg(colors::FG).add_modifier(Modifier::BOLD); + pub const ACCENT: Style = Style::new().fg(colors::NVIDIA_GREEN); + pub const ACCENT_BOLD: Style = Style::new() + .fg(colors::NVIDIA_GREEN) + .add_modifier(Modifier::BOLD); + pub const SELECTED: Style = Style::new().add_modifier(Modifier::BOLD); + pub const BORDER: Style = Style::new().fg(colors::EVERGLADE); + pub const BORDER_FOCUSED: Style = Style::new().fg(colors::NVIDIA_GREEN); + pub const STATUS_OK: Style = Style::new().fg(colors::NVIDIA_GREEN); + pub const STATUS_WARN: Style = Style::new().fg(Color::Yellow); + pub const STATUS_ERR: Style = Style::new().fg(Color::Red); + pub const KEY_HINT: Style = Style::new().fg(colors::NVIDIA_GREEN); + /// Background highlight for the cursor line in log viewer. + pub const LOG_CURSOR: Style = Style::new().bg(colors::EVERGLADE); + /// Maroon style for the pacman chase claw. + pub const CLAW: Style = Style::new().fg(colors::MAROON).add_modifier(Modifier::BOLD); + pub const TITLE_BAR: Style = Style::new() + .fg(colors::FG) + .bg(colors::EVERGLADE) + .add_modifier(Modifier::BOLD); +} diff --git a/crates/navigator-tui/src/ui/create_provider.rs b/crates/navigator-tui/src/ui/create_provider.rs new file mode 100644 index 000000000..2ee38f929 --- /dev/null +++ b/crates/navigator-tui/src/ui/create_provider.rs @@ -0,0 +1,751 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; + +use crate::app::{App, CreateProviderPhase, ProviderKeyField}; +use crate::theme::styles; + +/// Draw the create provider modal overlay. +pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { + let Some(form) = &app.create_provider_form else { + return; + }; + + match form.phase { + CreateProviderPhase::SelectType => draw_select_type(frame, form, area), + CreateProviderPhase::ChooseMethod => draw_choose_method(frame, form, area), + CreateProviderPhase::EnterKey => draw_enter_key(frame, form, area), + CreateProviderPhase::Creating => draw_creating(frame, form, area), + } +} + +// --------------------------------------------------------------------------- +// Phase 1: Select provider type +// --------------------------------------------------------------------------- + +fn draw_select_type(frame: &mut Frame<'_>, form: &crate::app::CreateProviderForm, area: Rect) { + let modal_width = 50u16.min(area.width.saturating_sub(4)); + #[allow(clippy::cast_possible_truncation)] + let type_rows = form.types.len().clamp(1, 10) as u16; + // header(1) + spacer(1) + types + spacer(1) + hint(1) + let content_height = 1 + 1 + type_rows + 1 + 1; + let modal_height = (content_height + 4).min(area.height.saturating_sub(2)); + let popup_area = centered_rect(modal_width, modal_height, area); + + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(Span::styled(" Create Provider ", styles::HEADING)) + .borders(Borders::ALL) + .border_style(styles::ACCENT) + .padding(Padding::new(2, 2, 1, 1)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // header + Constraint::Length(1), // spacer + Constraint::Length(type_rows), // type list + Constraint::Length(1), // spacer + Constraint::Length(1), // hint + Constraint::Min(0), + ]) + .split(inner); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Select provider type:", + styles::TEXT, + ))), + chunks[0], + ); + + let lines: Vec> = form + .types + .iter() + .enumerate() + .map(|(i, t)| { + let is_cursor = i == form.type_cursor; + let marker = if is_cursor { ">" } else { " " }; + let style = if is_cursor { + styles::ACCENT + } else { + styles::TEXT + }; + Line::from(vec![ + Span::styled(format!(" {marker} "), style), + Span::styled(t.as_str(), style), + ]) + }) + .collect(); + frame.render_widget(Paragraph::new(lines), chunks[2]); + + let hint = Line::from(vec![ + Span::styled("[j/k]", styles::KEY_HINT), + Span::styled(" Navigate ", styles::MUTED), + Span::styled("[Enter]", styles::KEY_HINT), + Span::styled(" Select ", styles::MUTED), + Span::styled("[Esc]", styles::KEY_HINT), + Span::styled(" Cancel", styles::MUTED), + ]); + frame.render_widget(Paragraph::new(hint), chunks[4]); +} + +// --------------------------------------------------------------------------- +// Phase 2: Choose method (autodetect vs manual) +// --------------------------------------------------------------------------- + +fn draw_choose_method(frame: &mut Frame<'_>, form: &crate::app::CreateProviderForm, area: Rect) { + let modal_width = 55u16.min(area.width.saturating_sub(4)); + // header(1) + spacer(1) + type_label(1) + spacer(1) + 2 options + spacer(1) + hint(1) + let content_height = 1 + 1 + 1 + 1 + 2 + 1 + 1; + let modal_height = (content_height + 4).min(area.height.saturating_sub(2)); + let popup_area = centered_rect(modal_width, modal_height, area); + + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(Span::styled(" Create Provider ", styles::HEADING)) + .borders(Borders::ALL) + .border_style(styles::ACCENT) + .padding(Padding::new(2, 2, 1, 1)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // header + Constraint::Length(1), // spacer + Constraint::Length(1), // type label + Constraint::Length(1), // spacer + Constraint::Length(2), // options + Constraint::Length(1), // spacer + Constraint::Length(1), // hint + Constraint::Min(0), + ]) + .split(inner); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "How would you like to provide credentials?", + styles::TEXT, + ))), + chunks[0], + ); + + let selected_type = &form.types[form.type_cursor]; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Type: ", styles::MUTED), + Span::styled(selected_type.as_str(), styles::HEADING), + ])), + chunks[2], + ); + + let options = ["Autodetect from environment", "Enter key manually"]; + let lines: Vec> = options + .iter() + .enumerate() + .map(|(i, label)| { + let is_cursor = i == form.method_cursor; + let marker = if is_cursor { ">" } else { " " }; + let style = if is_cursor { + styles::ACCENT + } else { + styles::TEXT + }; + Line::from(vec![ + Span::styled(format!(" {marker} "), style), + Span::styled(*label, style), + ]) + }) + .collect(); + frame.render_widget(Paragraph::new(lines), chunks[4]); + + let hint = Line::from(vec![ + Span::styled("[j/k]", styles::KEY_HINT), + Span::styled(" Navigate ", styles::MUTED), + Span::styled("[Enter]", styles::KEY_HINT), + Span::styled(" Select ", styles::MUTED), + Span::styled("[Esc]", styles::KEY_HINT), + Span::styled(" Back", styles::MUTED), + ]); + frame.render_widget(Paragraph::new(hint), chunks[6]); +} + +// --------------------------------------------------------------------------- +// Phase 3: Enter key manually (BYO) +// --------------------------------------------------------------------------- + +fn draw_enter_key(frame: &mut Frame<'_>, form: &crate::app::CreateProviderForm, area: Rect) { + let modal_width = 64u16.min(area.width.saturating_sub(4)); + + let has_warning = form.warning.is_some(); + let warning_rows: u16 = if has_warning { 2 } else { 0 }; // warning + spacer + + #[allow(clippy::cast_possible_truncation)] + let content_height = if form.is_generic { + // type(1) + name(2) + spacer(1) + env_name(2) + value(2) + spacer(1) + submit(1) + status(1) + hint(1) + warning_rows + 12 + } else { + let num_creds = form.credentials.len().clamp(1, 8) as u16; + // type(1) + name(2) + spacer(1) + creds + spacer(1) + submit(1) + status(1) + hint(1) + warning_rows + 1 + 2 + 1 + num_creds + 1 + 1 + 1 + 1 + }; + let modal_height = (content_height + 4).min(area.height.saturating_sub(2)); + let popup_area = centered_rect(modal_width, modal_height, area); + + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(Span::styled(" Create Provider ", styles::HEADING)) + .borders(Borders::ALL) + .border_style(styles::ACCENT) + .padding(Padding::new(2, 2, 1, 1)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + // Build dynamic constraints. + let mut constraints: Vec = Vec::new(); + if has_warning { + constraints.push(Constraint::Length(1)); // warning text + constraints.push(Constraint::Length(1)); // spacer + } + constraints.push(Constraint::Length(1)); // type label + constraints.push(Constraint::Length(2)); // name field + constraints.push(Constraint::Length(1)); // spacer + if form.is_generic { + constraints.push(Constraint::Length(2)); // env var name + constraints.push(Constraint::Length(2)); // value + } else { + #[allow(clippy::cast_possible_truncation)] + let num_creds = form.credentials.len().clamp(1, 8) as u16; + constraints.push(Constraint::Length(num_creds)); // credential rows + } + constraints.push(Constraint::Length(1)); // spacer + constraints.push(Constraint::Length(1)); // submit + constraints.push(Constraint::Length(1)); // status + constraints.push(Constraint::Length(1)); // hint + constraints.push(Constraint::Min(0)); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(inner); + + let mut idx = 0; + + // Warning banner. + if let Some(ref warning) = form.warning { + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("⚠ ", styles::STATUS_WARN), + Span::styled(warning.as_str(), styles::STATUS_WARN), + ])), + chunks[idx], + ); + idx += 2; // warning + spacer + } + + // Type label. + let selected_type = &form.types[form.type_cursor]; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Type: ", styles::MUTED), + Span::styled(selected_type.as_str(), styles::HEADING), + ])), + chunks[idx], + ); + idx += 1; + + // Name field. + let name_placeholder = format!("optional (defaults to {selected_type})"); + draw_text_field( + frame, + "Name", + &form.name, + &name_placeholder, + form.key_field == ProviderKeyField::Name, + chunks[idx], + ); + idx += 1; + + // Spacer. + idx += 1; + + if form.is_generic { + // Env var name field. + draw_text_field( + frame, + "Env var name", + &form.generic_env_name, + "e.g. MY_API_KEY", + form.key_field == ProviderKeyField::EnvVarName, + chunks[idx], + ); + idx += 1; + + // Value field (secret). + draw_secret_field( + frame, + "Value", + &form.generic_value, + form.key_field == ProviderKeyField::GenericValue, + chunks[idx], + ); + idx += 1; + } else { + // Credential rows — env var name + masked value on the same line. + let max_name_len = form + .credentials + .iter() + .map(|(n, _)| n.len()) + .max() + .unwrap_or(0); + let lines: Vec> = form + .credentials + .iter() + .enumerate() + .take(8) + .map(|(i, (env_name, value))| { + let is_focused = + form.key_field == ProviderKeyField::Credential && i == form.cred_cursor; + let padded = format!("{:width$}", env_name, width = max_name_len); + let name_style = if is_focused { + styles::ACCENT_BOLD + } else { + styles::TEXT + }; + let mut spans = vec![Span::styled(format!(" {padded}: "), name_style)]; + if value.is_empty() { + if is_focused { + spans.push(Span::styled("_", styles::ACCENT)); + } else { + spans.push(Span::styled("-", styles::MUTED)); + } + } else { + let masked = mask_input_value(value); + spans.push(Span::styled( + masked, + if is_focused { + styles::ACCENT + } else { + styles::MUTED + }, + )); + if is_focused { + spans.push(Span::styled("_", styles::ACCENT)); + } + } + Line::from(spans) + }) + .collect(); + frame.render_widget(Paragraph::new(lines), chunks[idx]); + idx += 1; + } + + // Spacer. + idx += 1; + + // Submit button. + let submit_focused = form.key_field == ProviderKeyField::Submit; + let submit_style = if submit_focused { + styles::ACCENT_BOLD + } else { + styles::MUTED + }; + let submit_label = if submit_focused { + " > Create Provider" + } else { + " Create Provider" + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(submit_label, submit_style))), + chunks[idx], + ); + idx += 1; + + // Status. + if let Some(ref status) = form.status { + let style = if status.contains("required") + || status.contains("failed") + || status.contains("Failed") + { + styles::STATUS_ERR + } else { + styles::STATUS_OK + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(format!(" {status}"), style))), + chunks[idx], + ); + } + idx += 1; + + // Hint. + let hint = Line::from(vec![ + Span::styled("[Tab]", styles::KEY_HINT), + Span::styled(" Next ", styles::MUTED), + Span::styled("[S-Tab]", styles::KEY_HINT), + Span::styled(" Prev ", styles::MUTED), + Span::styled("[Enter]", styles::KEY_HINT), + Span::styled(" Submit ", styles::MUTED), + Span::styled("[Esc]", styles::KEY_HINT), + Span::styled(" Back", styles::MUTED), + ]); + frame.render_widget(Paragraph::new(hint), chunks[idx]); +} + +/// Mask a secret input value for display (truncate with `...` if long). +fn mask_input_value(value: &str) -> String { + let len = value.len(); + if len <= 20 { + "*".repeat(len) + } else { + format!("{}...", "*".repeat(17)) + } +} + +// --------------------------------------------------------------------------- +// Phase 4: Creating (pacman animation + result) +// --------------------------------------------------------------------------- + +fn draw_creating(frame: &mut Frame<'_>, form: &crate::app::CreateProviderForm, area: Rect) { + let modal_width = 55u16.min(area.width.saturating_sub(4)); + // header(1) + spacer(1) + animation(1) + let content_height = 1 + 1 + 1; + let modal_height = (content_height + 4).min(area.height.saturating_sub(2)); + let popup_area = centered_rect(modal_width, modal_height, area); + + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(Span::styled(" Creating Provider ", styles::HEADING)) + .borders(Borders::ALL) + .border_style(styles::ACCENT) + .padding(Padding::new(2, 2, 1, 1)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // header + Constraint::Length(1), // spacer + Constraint::Length(1), // animation + Constraint::Min(0), + ]) + .split(inner); + + let (header, header_style) = match &form.create_result { + Some(Ok(name)) => (format!("Created provider: {name}"), styles::STATUS_OK), + Some(Err(msg)) => (format!("Failed: {msg}"), styles::STATUS_ERR), + None => ("Creating provider...".to_string(), styles::TEXT), + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(header, header_style))), + chunks[0], + ); + + let elapsed_ms = form.anim_start.map_or(0, |s| s.elapsed().as_millis()); + let track_width = chunks[2].width.saturating_sub(1) as usize; + let anim_line = super::create_sandbox::render_chase(track_width, elapsed_ms); + frame.render_widget(Paragraph::new(anim_line), chunks[2]); +} + +// --------------------------------------------------------------------------- +// Provider detail modal (Get) +// --------------------------------------------------------------------------- + +pub fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) { + let Some(detail) = &app.provider_detail else { + return; + }; + + let modal_width = 55u16.min(area.width.saturating_sub(4)); + // name(1) + type(1) + spacer(1) + cred_key(1) + masked(1) + spacer(1) + hint(1) + let content_height = 7; + let modal_height = (content_height + 4).min(area.height.saturating_sub(2)); + let popup_area = centered_rect(modal_width, modal_height, area); + + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(Span::styled(" Provider Detail ", styles::HEADING)) + .borders(Borders::ALL) + .border_style(styles::ACCENT) + .padding(Padding::new(2, 2, 1, 1)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // name + Constraint::Length(1), // type + Constraint::Length(1), // spacer + Constraint::Length(1), // cred key + Constraint::Length(1), // masked value + Constraint::Length(1), // spacer + Constraint::Length(1), // hint + Constraint::Min(0), + ]) + .split(inner); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Name: ", styles::MUTED), + Span::styled(&detail.name, styles::HEADING), + ])), + chunks[0], + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Type: ", styles::MUTED), + Span::styled(&detail.provider_type, styles::TEXT), + ])), + chunks[1], + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Credential: ", styles::MUTED), + Span::styled(&detail.credential_key, styles::TEXT), + ])), + chunks[3], + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Value: ", styles::MUTED), + Span::styled(&detail.masked_value, styles::MUTED), + ])), + chunks[4], + ); + + let hint = Line::from(vec![ + Span::styled("[Esc]", styles::KEY_HINT), + Span::styled(" Close", styles::MUTED), + ]); + frame.render_widget(Paragraph::new(hint), chunks[6]); +} + +// --------------------------------------------------------------------------- +// Update provider modal +// --------------------------------------------------------------------------- + +pub fn draw_update(frame: &mut Frame<'_>, app: &App, area: Rect) { + let Some(form) = &app.update_provider_form else { + return; + }; + + let modal_width = 60u16.min(area.width.saturating_sub(4)); + // name(1) + type(1) + spacer(1) + key_label(1) + value(1) + cursor_hint(1) + spacer(1) + status(1) + hint(1) + let content_height = 9; + let modal_height = (content_height + 4).min(area.height.saturating_sub(2)); + let popup_area = centered_rect(modal_width, modal_height, area); + + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(Span::styled(" Update Provider ", styles::HEADING)) + .borders(Borders::ALL) + .border_style(styles::ACCENT) + .padding(Padding::new(2, 2, 1, 1)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // name + Constraint::Length(1), // type + Constraint::Length(1), // spacer + Constraint::Length(1), // key label + Constraint::Length(1), // value input + Constraint::Length(1), // cursor hint + Constraint::Length(1), // spacer + Constraint::Length(1), // status + Constraint::Length(1), // hint + Constraint::Min(0), + ]) + .split(inner); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Name: ", styles::MUTED), + Span::styled(&form.provider_name, styles::HEADING), + ])), + chunks[0], + ); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Type: ", styles::MUTED), + Span::styled(&form.provider_type, styles::TEXT), + ])), + chunks[1], + ); + + let key_label = if form.credential_key.is_empty() { + "New value:" + } else { + &form.credential_key + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!("{key_label}:"), + styles::ACCENT_BOLD, + ))), + chunks[3], + ); + + // Mask the input value as dots. + let masked: String = "*".repeat(form.new_value.len()); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!(" {masked}"), styles::ACCENT), + Span::styled("_", styles::ACCENT), + ])), + chunks[4], + ); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + " Type the new credential value", + styles::MUTED, + ))), + chunks[5], + ); + + if let Some(ref status) = form.status { + let style = if status.contains("required") + || status.contains("failed") + || status.contains("Failed") + { + styles::STATUS_ERR + } else { + styles::STATUS_OK + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(format!(" {status}"), style))), + chunks[7], + ); + } + + let hint = Line::from(vec![ + Span::styled("[Enter]", styles::KEY_HINT), + Span::styled(" Update ", styles::MUTED), + Span::styled("[Esc]", styles::KEY_HINT), + Span::styled(" Cancel", styles::MUTED), + ]); + frame.render_widget(Paragraph::new(hint), chunks[8]); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn draw_text_field( + frame: &mut Frame<'_>, + label: &str, + value: &str, + placeholder: &str, + focused: bool, + area: Rect, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // label + Constraint::Length(1), // input + ]) + .split(area); + + let label_style = if focused { + styles::ACCENT_BOLD + } else { + styles::TEXT + }; + let mut label_spans = vec![Span::styled(format!("{label}:"), label_style)]; + if !placeholder.is_empty() { + label_spans.push(Span::styled(format!(" {placeholder}"), styles::MUTED)); + } + frame.render_widget(Paragraph::new(Line::from(label_spans)), chunks[0]); + + let display = if value.is_empty() && !focused { + Line::from(Span::styled(" -", styles::MUTED)) + } else if focused { + Line::from(vec![ + Span::styled(format!(" {value}"), styles::ACCENT), + Span::styled("_", styles::ACCENT), + ]) + } else { + Line::from(Span::styled(format!(" {value}"), styles::TEXT)) + }; + frame.render_widget(Paragraph::new(display), chunks[1]); +} + +fn draw_secret_field(frame: &mut Frame<'_>, label: &str, value: &str, focused: bool, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // label + Constraint::Length(1), // input + ]) + .split(area); + + let label_style = if focused { + styles::ACCENT_BOLD + } else { + styles::TEXT + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(format!("{label}:"), label_style))), + chunks[0], + ); + + let masked: String = "*".repeat(value.len()); + let display = if value.is_empty() && !focused { + Line::from(Span::styled(" -", styles::MUTED)) + } else if focused { + Line::from(vec![ + Span::styled(format!(" {masked}"), styles::ACCENT), + Span::styled("_", styles::ACCENT), + ]) + } else { + Line::from(Span::styled(format!(" {masked}"), styles::MUTED)) + }; + frame.render_widget(Paragraph::new(display), chunks[1]); +} + +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let vert = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length((area.height.saturating_sub(height)) / 2), + Constraint::Length(height), + Constraint::Min(0), + ]) + .split(area); + let horiz = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length((area.width.saturating_sub(width)) / 2), + Constraint::Length(width), + Constraint::Min(0), + ]) + .split(vert[1]); + horiz[1] +} diff --git a/crates/navigator-tui/src/ui/create_sandbox.rs b/crates/navigator-tui/src/ui/create_sandbox.rs new file mode 100644 index 000000000..1f537a89c --- /dev/null +++ b/crates/navigator-tui/src/ui/create_sandbox.rs @@ -0,0 +1,409 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; + +use crate::app::{App, CreateFormField, CreatePhase}; +use crate::theme::styles; + +/// Draw the create sandbox modal overlay. +pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { + let Some(form) = &app.create_form else { + return; + }; + + match form.phase { + CreatePhase::Form => draw_form(frame, app, area), + CreatePhase::Creating => draw_creating(frame, app, area), + } +} + +// --------------------------------------------------------------------------- +// Form view +// --------------------------------------------------------------------------- + +fn draw_form(frame: &mut Frame<'_>, app: &App, area: Rect) { + let Some(form) = &app.create_form else { + return; + }; + + let modal_width = 72u16.min(area.width.saturating_sub(4)); + + #[allow(clippy::cast_possible_truncation)] + let provider_rows = form.providers.len().clamp(1, 8) as u16; + let content_height = 3 + 3 + 3 + 1 + 1 + provider_rows + 1 + 1 + 1 + 1 + 1; + let modal_height = (content_height + 3).min(area.height.saturating_sub(2)); + let popup_area = centered_rect(modal_width, modal_height, area); + + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(Span::styled(" Create Sandbox ", styles::HEADING)) + .borders(Borders::ALL) + .border_style(styles::ACCENT) + .padding(Padding::new(2, 2, 1, 1)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + let constraints = [ + Constraint::Length(3), // Name + Constraint::Length(3), // Image + Constraint::Length(3), // Command + Constraint::Length(1), // Spacer + Constraint::Length(1), // Providers label + Constraint::Length(provider_rows), // Provider list + Constraint::Length(1), // Spacer + Constraint::Length(1), // Submit button + Constraint::Length(1), // Status message + Constraint::Length(1), // Spacer + Constraint::Length(1), // Nav hint + Constraint::Min(0), + ]; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(inner); + + // --- Name --- + draw_text_field( + frame, + "Name", + &form.name, + "optional — auto-generated if empty", + form.focused_field == CreateFormField::Name, + chunks[0], + ); + + // --- Image --- + draw_text_field( + frame, + "Image", + &form.image, + "optional — server default if empty", + form.focused_field == CreateFormField::Image, + chunks[1], + ); + + // --- Command --- + draw_text_field( + frame, + "Command", + &form.command, + "entrypoint to run in the sandbox", + form.focused_field == CreateFormField::Command, + chunks[2], + ); + + // --- Providers label --- + let providers_focused = form.focused_field == CreateFormField::Providers; + let prov_label_style = if providers_focused { + styles::ACCENT_BOLD + } else { + styles::TEXT + }; + let prov_hint = if providers_focused { + Span::styled(" [Space] toggle [j/k] navigate", styles::MUTED) + } else { + Span::styled("", styles::MUTED) + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Providers:", prov_label_style), + prov_hint, + ])), + chunks[4], + ); + + // --- Provider list (existing providers by name + type) --- + if form.providers.is_empty() { + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + " (none — create providers first)", + styles::MUTED, + ))), + chunks[5], + ); + } else { + let lines: Vec> = form + .providers + .iter() + .enumerate() + .take(provider_rows as usize) + .map(|(i, p)| { + let checkbox = if p.selected { "[x]" } else { "[ ]" }; + let is_cursor = providers_focused && i == form.provider_cursor; + let marker = if is_cursor { ">" } else { " " }; + let style = if is_cursor { + styles::ACCENT + } else { + styles::TEXT + }; + let type_display = if p.provider_type.is_empty() { + String::new() + } else { + format!(" ({})", p.provider_type) + }; + Line::from(vec![ + Span::styled(format!(" {marker} {checkbox} "), style), + Span::styled(&p.name, style), + Span::styled(type_display, styles::MUTED), + ]) + }) + .collect(); + frame.render_widget(Paragraph::new(lines), chunks[5]); + } + + // --- Submit --- + let submit_focused = form.focused_field == CreateFormField::Submit; + let submit_style = if submit_focused { + styles::ACCENT_BOLD + } else { + styles::MUTED + }; + let submit_label = if submit_focused { + " ▶ Create Sandbox" + } else { + " Create Sandbox" + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(submit_label, submit_style))), + chunks[7], + ); + + // --- Status --- + if let Some(ref status) = form.status { + let style = if status.contains("failed") || status.contains("error") { + styles::STATUS_ERR + } else if status.contains("Created") { + styles::STATUS_OK + } else { + styles::MUTED + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(format!(" {status}"), style))), + chunks[8], + ); + } + + // --- Nav hint --- + let hint = Line::from(vec![ + Span::styled("[Tab]", styles::KEY_HINT), + Span::styled(" Next ", styles::MUTED), + Span::styled("[S-Tab]", styles::KEY_HINT), + Span::styled(" Prev ", styles::MUTED), + Span::styled("[Enter]", styles::KEY_HINT), + Span::styled(" Submit ", styles::MUTED), + Span::styled("[Esc]", styles::KEY_HINT), + Span::styled(" Cancel", styles::MUTED), + ]); + frame.render_widget(Paragraph::new(hint), chunks[10]); +} + +// --------------------------------------------------------------------------- +// Creating animation view +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Creating view — shown after user confirms, sandbox being created +// --------------------------------------------------------------------------- + +fn draw_creating(frame: &mut Frame<'_>, app: &App, area: Rect) { + let Some(form) = &app.create_form else { + return; + }; + + // content: header(1) + spacer(1) + animation(1) + // chrome: border(2) + padding top/bottom(2) + let content_height = 1 + 1 + 1; + let modal_width = 60u16.min(area.width.saturating_sub(4)); + let modal_height = (content_height + 4).min(area.height.saturating_sub(2)); + let popup_area = centered_rect(modal_width, modal_height, area); + + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(Span::styled(" Creating Sandbox ", styles::HEADING)) + .borders(Borders::ALL) + .border_style(styles::ACCENT) + .padding(Padding::new(2, 2, 1, 1)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // header + Constraint::Length(1), // spacer + Constraint::Length(1), // animation + Constraint::Min(0), + ]) + .split(inner); + + // Header — changes once result arrives. + let (header, header_style) = match &form.create_result { + Some(Ok(name)) => (format!("Created sandbox: {name}"), styles::STATUS_OK), + Some(Err(msg)) => (format!("Failed: {msg}"), styles::STATUS_ERR), + None => ("Creating sandbox...".to_string(), styles::TEXT), + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(header, header_style))), + chunks[0], + ); + + // Pacman chase animation. + let elapsed_ms = form.anim_start.map_or(0, |s| s.elapsed().as_millis()); + let track_width = chunks[2].width.saturating_sub(1) as usize; + let anim_line = render_chase(track_width, elapsed_ms); + frame.render_widget(Paragraph::new(anim_line), chunks[2]); +} + +/// Render the NVIDIA pacman chasing a maroon claw across a dot track. +/// +/// The sprite scrolls right across `track_width`, wrapping around. +pub fn render_chase(track_width: usize, elapsed_ms: u128) -> Line<'static> { + if track_width < 10 { + return Line::from(Span::styled("...", styles::MUTED)); + } + + let frame = (elapsed_ms / 140) as usize; + let mouth_open = frame % 2 == 0; + + // Characters. + let pac = if mouth_open { "ᗧ" } else { "●" }; + let claw = ">('>"; // lobster claw facing right + + let dot_char = '·'; + let num_dots: usize = 6; + let claw_len = claw.len(); + + // Sprite total width: pac(1) + gaps with dots(num_dots * 2) + space + claw. + let sprite_width = 1 + num_dots * 2 + 1 + claw_len; + + // Position: how far the left edge of the sprite is from the left wall. + // Cycle length = track_width + sprite_width (fully off-screen before wrap). + let cycle = track_width + sprite_width; + let pos = frame % cycle; + + // Build character-by-character: a track_width buffer of (content, style) slots. + // We'll collect spans by walking the sprite across the track. + let mut buf: Vec<(char, ratatui::style::Style)> = vec![(' ', styles::MUTED); track_width]; + + // Helper: place a character if it's within bounds. + let mut place = |col: usize, ch: char, style: ratatui::style::Style| { + // `col` is the absolute position (can be negative via wrapping, so use isize). + if col < track_width { + buf[col] = (ch, style); + } + }; + + // Pacman position (left edge of sprite). + let pac_col = pos; + // Place pacman. + for (i, ch) in pac.chars().enumerate() { + place(pac_col.wrapping_add(i), ch, styles::ACCENT_BOLD); + } + + // Dots after pacman. + for d in 0..num_dots { + let col = pac_col + 1 + d * 2; + place(col, ' ', styles::MUTED); + place(col + 1, dot_char, styles::MUTED); + } + + // Claw after the dots. + let claw_col = pac_col + 1 + num_dots * 2 + 1; + for (i, ch) in claw.chars().enumerate() { + place(claw_col + i, ch, styles::CLAW); + } + + // Convert buffer to spans (group consecutive same-style chars). + let mut spans: Vec> = Vec::new(); + let mut current_str = String::new(); + let mut current_style = buf[0].1; + + for &(ch, style) in &buf { + if style == current_style { + current_str.push(ch); + } else { + if !current_str.is_empty() { + spans.push(Span::styled(current_str.clone(), current_style)); + current_str.clear(); + } + current_style = style; + current_str.push(ch); + } + } + if !current_str.is_empty() { + spans.push(Span::styled(current_str, current_style)); + } + + Line::from(spans) +} + +// --------------------------------------------------------------------------- +// Form helpers +// --------------------------------------------------------------------------- + +fn draw_text_field( + frame: &mut Frame<'_>, + label: &str, + value: &str, + placeholder: &str, + focused: bool, + area: Rect, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // label + Constraint::Length(1), // input + Constraint::Length(1), // gap + ]) + .split(area); + + let label_style = if focused { + styles::ACCENT_BOLD + } else { + styles::TEXT + }; + let mut label_spans = vec![Span::styled(format!("{label}:"), label_style)]; + if !placeholder.is_empty() { + label_spans.push(Span::styled(format!(" {placeholder}"), styles::MUTED)); + } + frame.render_widget(Paragraph::new(Line::from(label_spans)), chunks[0]); + + let display = if value.is_empty() && !focused { + Line::from(Span::styled(" -", styles::MUTED)) + } else if focused { + Line::from(vec![ + Span::styled(format!(" {value}"), styles::ACCENT), + Span::styled("█", styles::ACCENT), + ]) + } else { + Line::from(Span::styled(format!(" {value}"), styles::TEXT)) + }; + frame.render_widget(Paragraph::new(display), chunks[1]); +} + +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let vert = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length((area.height.saturating_sub(height)) / 2), + Constraint::Length(height), + Constraint::Min(0), + ]) + .split(area); + let horiz = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length((area.width.saturating_sub(width)) / 2), + Constraint::Length(width), + Constraint::Min(0), + ]) + .split(vert[1]); + horiz[1] +} diff --git a/crates/navigator-tui/src/ui/dashboard.rs b/crates/navigator-tui/src/ui/dashboard.rs new file mode 100644 index 000000000..7d007602e --- /dev/null +++ b/crates/navigator-tui/src/ui/dashboard.rs @@ -0,0 +1,117 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Padding, Paragraph, Row, Table}; + +use crate::app::{App, Focus}; +use crate::theme::styles; + +pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(50), + ]) + .split(area); + + draw_cluster_list(frame, app, chunks[0]); + super::providers::draw(frame, app, chunks[1], app.focus == Focus::Providers); + super::sandboxes::draw(frame, app, chunks[2], app.focus == Focus::Sandboxes); +} + +fn draw_cluster_list(frame: &mut Frame<'_>, app: &App, area: Rect) { + let focused = app.focus == Focus::Clusters; + + let header = Row::new(vec![ + Cell::from(Span::styled(" NAME", styles::MUTED)), + Cell::from(Span::styled("TYPE", styles::MUTED)), + Cell::from(Span::styled("STATUS", styles::MUTED)), + ]) + .bottom_margin(1); + + let rows: Vec> = app + .clusters + .iter() + .enumerate() + .map(|(i, entry)| { + let is_active = entry.name == app.cluster_name; + let is_cursor = focused && i == app.cluster_selected; + + let cursor = if is_cursor { ">" } else { " " }; + let dot = if is_active { "* " } else { " " }; + let dot_style = if is_active { + styles::STATUS_OK + } else { + styles::MUTED + }; + let name_style = if is_active { + styles::HEADING + } else { + styles::TEXT + }; + let name_cell = Cell::from(Line::from(vec![ + Span::styled(cursor, styles::ACCENT), + Span::styled(dot, dot_style), + Span::styled(&entry.name, name_style), + ])); + + let type_label = if entry.is_remote { "remote" } else { "local" }; + + let status_cell = if is_active { + let status_style = if app.status_text.contains("Healthy") { + styles::STATUS_OK + } else if app.status_text.contains("Degraded") { + styles::STATUS_WARN + } else if app.status_text.contains("Unhealthy") { + styles::STATUS_ERR + } else { + styles::MUTED + }; + Cell::from(Span::styled(&app.status_text, status_style)) + } else { + Cell::from(Span::styled("-", styles::MUTED)) + }; + + Row::new(vec![ + name_cell, + Cell::from(Span::styled(type_label, styles::MUTED)), + status_cell, + ]) + }) + .collect(); + + let border_style = if focused { + styles::BORDER_FOCUSED + } else { + styles::BORDER + }; + + let block = Block::default() + .title(Span::styled(" Clusters ", styles::HEADING)) + .borders(Borders::ALL) + .border_style(border_style) + .padding(Padding::horizontal(1)); + + let widths = [ + Constraint::Percentage(45), + Constraint::Percentage(20), + Constraint::Percentage(35), + ]; + + let table = Table::new(rows, widths).header(header).block(block); + + frame.render_widget(table, area); + + if app.clusters.is_empty() { + let inner = Rect { + x: area.x + 2, + y: area.y + 2, + width: area.width.saturating_sub(4), + height: area.height.saturating_sub(3), + }; + let msg = Paragraph::new(Span::styled(" No clusters found.", styles::MUTED)); + frame.render_widget(msg, inner); + } +} diff --git a/crates/navigator-tui/src/ui/mod.rs b/crates/navigator-tui/src/ui/mod.rs new file mode 100644 index 000000000..fd6718640 --- /dev/null +++ b/crates/navigator-tui/src/ui/mod.rs @@ -0,0 +1,268 @@ +pub(crate) mod create_provider; +pub(crate) mod create_sandbox; +mod dashboard; +pub(crate) mod providers; +pub(crate) mod sandbox_detail; +pub(crate) mod sandbox_logs; +mod sandbox_policy; +pub(crate) mod sandboxes; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::{self, App, Focus, InputMode, Screen}; +use crate::theme::styles; + +pub fn draw(frame: &mut Frame<'_>, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // title bar + Constraint::Min(0), // main content + Constraint::Length(1), // nav bar + Constraint::Length(1), // command bar + ]) + .split(frame.size()); + + draw_title_bar(frame, app, chunks[0]); + + match app.screen { + Screen::Dashboard => dashboard::draw(frame, app, chunks[1]), + Screen::Sandbox => draw_sandbox_screen(frame, app, chunks[1]), + } + + draw_nav_bar(frame, app, chunks[2]); + draw_command_bar(frame, app, chunks[3]); + + // Modal overlays (drawn last so they're on top). + if app.create_form.is_some() { + create_sandbox::draw(frame, app, frame.size()); + } + if app.create_provider_form.is_some() { + create_provider::draw(frame, app, frame.size()); + } + if app.provider_detail.is_some() { + create_provider::draw_detail(frame, app, frame.size()); + } + if app.update_provider_form.is_some() { + create_provider::draw_update(frame, app, frame.size()); + } +} + +// --------------------------------------------------------------------------- +// Sandbox full-screen +// --------------------------------------------------------------------------- + +fn draw_sandbox_screen(frame: &mut Frame<'_>, app: &mut App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(20), // metadata + Constraint::Percentage(80), // policy or logs + ]) + .split(area); + + sandbox_detail::draw(frame, app, chunks[0]); + + match app.focus { + Focus::SandboxLogs => sandbox_logs::draw(frame, app, chunks[1]), + _ => sandbox_policy::draw(frame, app, chunks[1]), + } + + // Log detail popup renders over the full frame (not constrained to pane). + if app.focus == Focus::SandboxLogs { + if let Some(detail_idx) = app.log_detail_index { + let filtered: Vec<&app::LogLine> = app.filtered_log_lines(); + if let Some(log) = filtered.get(detail_idx) { + sandbox_logs::draw_detail_popup(frame, log, frame.size()); + } + } + } +} + +// --------------------------------------------------------------------------- +// Chrome: title bar, nav bar, command bar +// --------------------------------------------------------------------------- + +fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { + let status_span = match app.status_text.as_str() { + s if s.contains("Healthy") => Span::styled(&app.status_text, styles::STATUS_OK), + s if s.contains("Degraded") => Span::styled(&app.status_text, styles::STATUS_WARN), + s if s.contains("Unhealthy") => Span::styled(&app.status_text, styles::STATUS_ERR), + _ => Span::styled(&app.status_text, styles::MUTED), + }; + + let mut parts: Vec> = vec![ + Span::styled(" Gator", styles::ACCENT_BOLD), + Span::styled(" | ", styles::MUTED), + Span::styled("Current Cluster: ", styles::TEXT), + Span::styled(&app.cluster_name, styles::HEADING), + Span::styled(" (", styles::MUTED), + status_span, + Span::styled(")", styles::MUTED), + Span::styled(" | ", styles::MUTED), + ]; + + match app.screen { + Screen::Dashboard => { + parts.push(Span::styled("Dashboard", styles::TEXT)); + } + Screen::Sandbox => { + let name = app + .sandbox_names + .get(app.sandbox_selected) + .map_or("-", String::as_str); + parts.push(Span::styled("Sandbox: ", styles::TEXT)); + parts.push(Span::styled(name, styles::HEADING)); + } + } + + let title = Line::from(parts); + frame.render_widget(Paragraph::new(title).style(styles::TITLE_BAR), area); +} + +fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { + let spans = match app.screen { + Screen::Dashboard => match app.focus { + Focus::Providers => vec![ + Span::styled(" ", styles::TEXT), + Span::styled("[Tab]", styles::KEY_HINT), + Span::styled(" Switch Panel", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[j/k]", styles::KEY_HINT), + Span::styled(" Navigate", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[Enter]", styles::KEY_HINT), + Span::styled(" Detail", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[c]", styles::KEY_HINT), + Span::styled(" Create", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[u]", styles::KEY_HINT), + Span::styled(" Update", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[d]", styles::KEY_HINT), + Span::styled(" Delete", styles::TEXT), + Span::styled(" | ", styles::BORDER), + Span::styled("[:]", styles::MUTED), + Span::styled(" Command ", styles::MUTED), + Span::styled("[q]", styles::MUTED), + Span::styled(" Quit", styles::MUTED), + ], + Focus::Sandboxes => vec![ + Span::styled(" ", styles::TEXT), + Span::styled("[Tab]", styles::KEY_HINT), + Span::styled(" Switch Panel", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[j/k]", styles::KEY_HINT), + Span::styled(" Navigate", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[Enter]", styles::KEY_HINT), + Span::styled(" Select", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[c]", styles::KEY_HINT), + Span::styled(" Create Sandbox", styles::TEXT), + Span::styled(" | ", styles::BORDER), + Span::styled("[:]", styles::MUTED), + Span::styled(" Command ", styles::MUTED), + Span::styled("[q]", styles::MUTED), + Span::styled(" Quit", styles::MUTED), + ], + _ => vec![ + Span::styled(" ", styles::TEXT), + Span::styled("[Tab]", styles::KEY_HINT), + Span::styled(" Switch Panel", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[j/k]", styles::KEY_HINT), + Span::styled(" Navigate", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[Enter]", styles::KEY_HINT), + Span::styled(" Select", styles::TEXT), + Span::styled(" | ", styles::BORDER), + Span::styled("[:]", styles::MUTED), + Span::styled(" Command ", styles::MUTED), + Span::styled("[q]", styles::MUTED), + Span::styled(" Quit", styles::MUTED), + ], + }, + Screen::Sandbox => match app.focus { + Focus::SandboxLogs => { + let filter_label = app.log_source_filter.label(); + let autoscroll_label = if app.log_autoscroll { + " Autoscroll" + } else { + " Follow" + }; + let autoscroll_style = if app.log_autoscroll { + styles::STATUS_OK + } else { + styles::TEXT + }; + vec![ + Span::styled(" ", styles::TEXT), + Span::styled("[j/k]", styles::KEY_HINT), + Span::styled(" Navigate", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[Enter]", styles::KEY_HINT), + Span::styled(" Detail", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[g/G]", styles::KEY_HINT), + Span::styled(" Top/Bottom", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[f]", styles::KEY_HINT), + Span::styled(autoscroll_label, autoscroll_style), + Span::styled(" ", styles::TEXT), + Span::styled("[s]", styles::KEY_HINT), + Span::styled(format!(" Source: {filter_label}"), styles::TEXT), + Span::styled(" | ", styles::BORDER), + Span::styled("[Esc]", styles::MUTED), + Span::styled(" Policy", styles::MUTED), + Span::styled(" ", styles::TEXT), + Span::styled("[q]", styles::MUTED), + Span::styled(" Quit", styles::MUTED), + ] + } + _ => vec![ + Span::styled(" ", styles::TEXT), + Span::styled("[j/k]", styles::KEY_HINT), + Span::styled(" Scroll", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[g/G]", styles::KEY_HINT), + Span::styled(" Top/Bottom", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[s]", styles::KEY_HINT), + Span::styled(" Shell", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[l]", styles::KEY_HINT), + Span::styled(" Logs", styles::TEXT), + Span::styled(" ", styles::TEXT), + Span::styled("[d]", styles::KEY_HINT), + Span::styled(" Delete", styles::TEXT), + Span::styled(" | ", styles::BORDER), + Span::styled("[Esc]", styles::MUTED), + Span::styled(" Back", styles::MUTED), + Span::styled(" ", styles::TEXT), + Span::styled("[q]", styles::MUTED), + Span::styled(" Quit", styles::MUTED), + ], + }, + }; + + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn draw_command_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { + let line = match app.input_mode { + InputMode::Command => Line::from(vec![ + Span::styled(" :", styles::ACCENT_BOLD), + Span::styled(&app.command_input, styles::TEXT), + Span::styled("_", styles::ACCENT), + ]), + InputMode::Normal => Line::from(vec![Span::styled("", styles::MUTED)]), + }; + + let bar = Paragraph::new(line).block(Block::default().borders(Borders::NONE)); + frame.render_widget(bar, area); +} diff --git a/crates/navigator-tui/src/ui/providers.rs b/crates/navigator-tui/src/ui/providers.rs new file mode 100644 index 000000000..893ff5a02 --- /dev/null +++ b/crates/navigator-tui/src/ui/providers.rs @@ -0,0 +1,99 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Padding, Paragraph, Row, Table}; + +use crate::app::App; +use crate::theme::styles; + +pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { + let header = Row::new(vec![ + Cell::from(Span::styled(" NAME", styles::MUTED)), + Cell::from(Span::styled("TYPE", styles::MUTED)), + Cell::from(Span::styled("CRED KEY", styles::MUTED)), + ]) + .bottom_margin(1); + + let rows: Vec> = (0..app.provider_count) + .map(|i| { + let name = app.provider_names.get(i).map_or("", String::as_str); + let ptype = app.provider_types.get(i).map_or("", String::as_str); + let cred_key = app.provider_cred_keys.get(i).map_or("", String::as_str); + + let selected = focused && i == app.provider_selected; + let name_cell = if selected { + Cell::from(Line::from(vec![ + Span::styled("> ", styles::ACCENT), + Span::styled(name, styles::TEXT), + ])) + } else { + Cell::from(Line::from(vec![ + Span::raw(" "), + Span::styled(name, styles::TEXT), + ])) + }; + + Row::new(vec![ + name_cell, + Cell::from(Span::styled(ptype, styles::MUTED)), + Cell::from(Span::styled(cred_key, styles::MUTED)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Percentage(40), + Constraint::Percentage(25), + Constraint::Percentage(35), + ]; + + let border_style = if focused { + styles::BORDER_FOCUSED + } else { + styles::BORDER + }; + + // Show delete confirmation in the title area if active. + let title = if focused && app.confirm_provider_delete { + let name = app + .provider_names + .get(app.provider_selected) + .map_or("-", String::as_str); + Line::from(vec![ + Span::styled(" Delete '", styles::STATUS_ERR), + Span::styled(name, styles::STATUS_ERR), + Span::styled("'? [y/n] ", styles::STATUS_ERR), + ]) + } else { + Line::from(vec![ + Span::styled(" Providers ", styles::HEADING), + Span::styled("- ", styles::BORDER), + Span::styled(&app.cluster_name, styles::MUTED), + Span::styled(" ", styles::MUTED), + ]) + }; + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style) + .padding(Padding::horizontal(1)); + + let table = Table::new(rows, widths).header(header).block(block); + + frame.render_widget(table, area); + + if app.provider_count == 0 { + let inner = Rect { + x: area.x + 2, + y: area.y + 2, + width: area.width.saturating_sub(4), + height: area.height.saturating_sub(3), + }; + let msg = Paragraph::new(Span::styled( + " No providers. Press [c] to create.", + styles::MUTED, + )); + frame.render_widget(msg, inner); + } +} diff --git a/crates/navigator-tui/src/ui/sandbox_detail.rs b/crates/navigator-tui/src/ui/sandbox_detail.rs new file mode 100644 index 000000000..dd3bb21d2 --- /dev/null +++ b/crates/navigator-tui/src/ui/sandbox_detail.rs @@ -0,0 +1,89 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Padding, Paragraph}; + +use crate::app::App; +use crate::theme::styles; + +/// Draw a compact metadata pane for the currently selected sandbox. +/// +/// This is non-interactive (no focus state) — always rendered with the +/// unfocused border style in the top ~20% of the sandbox screen. +pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { + let idx = app.sandbox_selected; + let name = app.sandbox_names.get(idx).map_or("-", String::as_str); + let phase = app.sandbox_phases.get(idx).map_or("-", String::as_str); + let image = app.sandbox_images.get(idx).map_or("-", String::as_str); + let created = app.sandbox_created.get(idx).map_or("-", String::as_str); + let age = app.sandbox_ages.get(idx).map_or("-", String::as_str); + + let phase_style = match phase { + "Ready" => styles::STATUS_OK, + "Provisioning" => styles::STATUS_WARN, + "Error" => styles::STATUS_ERR, + _ => styles::MUTED, + }; + + let status_indicator = match phase { + "Ready" => "●", + "Provisioning" => "◐", + "Error" => "○", + _ => "…", + }; + + // Row 1: Name + Status + let row1 = Line::from(vec![ + Span::styled(" Name: ", styles::MUTED), + Span::styled(name, styles::HEADING), + Span::styled(" Status: ", styles::MUTED), + Span::styled(format!("{status_indicator} "), phase_style), + Span::styled(phase, phase_style), + ]); + + // Row 2: Image + Created + Age + let row2 = Line::from(vec![ + Span::styled(" Image: ", styles::MUTED), + Span::styled(image, styles::TEXT), + Span::styled(" Created: ", styles::MUTED), + Span::styled(created, styles::TEXT), + Span::styled(" Age: ", styles::MUTED), + Span::styled(age, styles::TEXT), + ]); + + // Row 3: Providers + let providers_str = if app.sandbox_providers_list.is_empty() { + "none".to_string() + } else { + app.sandbox_providers_list.join(", ") + }; + let row3 = Line::from(vec![ + Span::styled(" Providers: ", styles::MUTED), + Span::styled(providers_str, styles::TEXT), + ]); + + let mut lines = vec![Line::from(""), row1, row2, row3]; + + // Delete confirmation in title area (same pattern as provider delete). + if app.confirm_delete { + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" ", styles::TEXT), + Span::styled("Delete sandbox '", styles::STATUS_ERR), + Span::styled(name, styles::STATUS_ERR), + Span::styled("'? ", styles::STATUS_ERR), + Span::styled("[y]", styles::KEY_HINT), + Span::styled(" Confirm ", styles::TEXT), + Span::styled("[Esc]", styles::KEY_HINT), + Span::styled(" Cancel", styles::TEXT), + ])); + } + + let block = Block::default() + .title(Span::styled(format!(" Sandbox: {name} "), styles::HEADING)) + .borders(Borders::ALL) + .border_style(styles::BORDER) // non-interactive — unfocused border + .padding(Padding::horizontal(1)); + + frame.render_widget(Paragraph::new(lines).block(block), area); +} diff --git a/crates/navigator-tui/src/ui/sandbox_logs.rs b/crates/navigator-tui/src/ui/sandbox_logs.rs new file mode 100644 index 000000000..490b975ec --- /dev/null +++ b/crates/navigator-tui/src/ui/sandbox_logs.rs @@ -0,0 +1,388 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}; + +use crate::app::{App, LogLine}; +use crate::theme::styles; + +pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) { + let name = app + .sandbox_names + .get(app.sandbox_selected) + .map_or("-", String::as_str); + + let filter_label = app.log_source_filter.label(); + + let block = Block::default() + .title(Span::styled(format!(" Logs: {name} "), styles::HEADING)) + .borders(Borders::ALL) + .border_style(styles::BORDER_FOCUSED) + .padding(Padding::horizontal(1)); + + // Calculate visible area inside the block (borders + padding). + let inner_height = area.height.saturating_sub(2) as usize; + // Inner width = total width - 2 (borders) - 2 (horizontal padding). + let inner_width = area.width.saturating_sub(4) as usize; + // Store viewport height so autoscroll calculations can use it. + app.log_viewport_height = inner_height; + + // Clamp cursor to visible range before borrowing filtered log lines. + { + let filtered_len = app.filtered_log_lines().len(); + let visible_count = filtered_len + .saturating_sub(app.sandbox_log_scroll) + .min(inner_height); + if visible_count > 0 { + app.log_cursor = app.log_cursor.min(visible_count - 1); + } + } + + // Snapshot the cursor position (already clamped above). + let cursor_pos = app.log_cursor; + + let filtered: Vec<&LogLine> = app.filtered_log_lines(); + + if filtered.is_empty() && app.sandbox_log_lines.is_empty() { + // Still loading. + let lines = vec![Line::from(Span::styled("Loading...", styles::MUTED))]; + let block = block.title_bottom(Line::from(Span::styled( + format!(" filter: {filter_label} "), + styles::MUTED, + ))); + frame.render_widget(Paragraph::new(lines).block(block), area); + return; + } + + let lines: Vec> = filtered + .iter() + .skip(app.sandbox_log_scroll) + .take(inner_height) + .enumerate() + .map(|(i, log)| { + let mut line = render_log_line(log, inner_width.saturating_sub(2)); + if i == cursor_pos { + // Prepend green cursor marker and apply highlight background. + line.spans.insert(0, Span::styled("▌ ", styles::ACCENT)); + line = line.style(styles::LOG_CURSOR); + } else { + line.spans.insert(0, Span::raw(" ")); + } + line + }) + .collect(); + + // Scroll position + autoscroll indicator. + let total = filtered.len(); + let pos = app.sandbox_log_scroll + cursor_pos + 1; + let scroll_info = if total > 0 { + format!(" [{pos}/{total}] ") + } else { + String::new() + }; + + let autoscroll_span = if app.log_autoscroll { + Span::styled(" ● FOLLOWING ", styles::STATUS_OK) + } else { + Span::styled(" ○ PAUSED ", styles::STATUS_WARN) + }; + + let block = block.title_bottom(Line::from(vec![ + autoscroll_span, + Span::styled(scroll_info, styles::MUTED), + Span::styled(format!(" filter: {filter_label} "), styles::MUTED), + ])); + + frame.render_widget(Paragraph::new(lines).block(block), area); + + // NOTE: Detail popup overlay is now rendered by draw_sandbox_screen() in + // mod.rs using frame.size() so it renders over the full screen, not + // constrained to this pane. +} + +// --------------------------------------------------------------------------- +// Detail popup (Enter key) +// --------------------------------------------------------------------------- + +pub fn draw_detail_popup(frame: &mut Frame<'_>, log: &LogLine, area: Rect) { + // Center the popup — 80% width, up to 20 lines tall. + let popup_width = (area.width * 4 / 5).min(area.width.saturating_sub(4)); + let popup_height = 20u16.min(area.height.saturating_sub(4)); + let popup_area = centered_rect(popup_width, popup_height, area); + + frame.render_widget(Clear, popup_area); + + let block = Block::default() + .title(Span::styled(" Log Detail ", styles::HEADING)) + .borders(Borders::ALL) + .border_style(styles::ACCENT) + .padding(Padding::new(1, 1, 0, 0)); + + let ts = format_short_time(log.timestamp_ms); + + let mut lines: Vec> = vec![ + Line::from(vec![ + Span::styled("Time: ", styles::MUTED), + Span::styled(ts, styles::TEXT), + ]), + Line::from(vec![ + Span::styled("Source: ", styles::MUTED), + Span::styled(log.source.as_str(), styles::TEXT), + ]), + Line::from(vec![ + Span::styled("Level: ", styles::MUTED), + Span::styled(log.level.as_str(), level_style(&log.level)), + ]), + ]; + + if !log.target.is_empty() { + lines.push(Line::from(vec![ + Span::styled("Target: ", styles::MUTED), + Span::styled(log.target.as_str(), styles::MUTED), + ])); + } + + lines.push(Line::from(vec![ + Span::styled("Message: ", styles::MUTED), + Span::styled(log.message.as_str(), styles::TEXT), + ])); + + if !log.fields.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled("Fields:", styles::MUTED))); + + let ordered = ordered_fields(log); + for (k, v) in &ordered { + if v.is_empty() { + continue; + } + lines.push(Line::from(vec![ + Span::styled(format!(" {k}: "), styles::MUTED), + Span::styled((*v).to_string(), styles::TEXT), + ])); + } + } + + // Add dismiss hint at the bottom. + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Press Esc or Enter to close", + styles::MUTED, + ))); + + frame.render_widget( + Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }), + popup_area, + ); +} + +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let vert = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length((area.height.saturating_sub(height)) / 2), + Constraint::Length(height), + Constraint::Min(0), + ]) + .split(area); + let horiz = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length((area.width.saturating_sub(width)) / 2), + Constraint::Length(width), + Constraint::Min(0), + ]) + .split(vert[1]); + horiz[1] +} + +// --------------------------------------------------------------------------- +// Log line rendering (compact, truncated) +// --------------------------------------------------------------------------- + +/// Render a single structured log line — no target, smart field order, truncated. +fn render_log_line<'a>(log: &'a LogLine, max_width: usize) -> Line<'a> { + let source_style = match log.source.as_str() { + "sandbox" => styles::ACCENT, + _ => styles::MUTED, + }; + + let ts = format_short_time(log.timestamp_ms); + + let mut spans = vec![ + Span::styled(ts, styles::MUTED), + Span::raw(" "), + Span::styled(format!("{:<7}", log.source), source_style), + Span::raw(" "), + Span::styled(format!("{:<5}", log.level), level_style(&log.level)), + Span::raw(" "), + ]; + + // Message. + spans.push(Span::styled(log.message.as_str(), styles::TEXT)); + + // Structured fields — ordered, non-empty only. + if !log.fields.is_empty() { + let ordered = ordered_fields(log); + for (k, v) in &ordered { + if v.is_empty() { + continue; + } + spans.push(Span::raw(" ")); + spans.push(Span::styled(format!("{k}="), styles::MUTED)); + spans.push(Span::styled((*v).to_string(), styles::TEXT)); + } + } + + // Truncate to max_width. + truncate_line(spans, max_width) +} + +/// Truncate a span list to fit within `max_width` characters, appending `…` if needed. +fn truncate_line(spans: Vec>, max_width: usize) -> Line<'_> { + if max_width == 0 { + return Line::from(spans); + } + + let mut used = 0usize; + let mut out: Vec> = Vec::with_capacity(spans.len()); + + for span in spans { + let content_len = span.content.len(); + if used + content_len <= max_width { + out.push(span); + used += content_len; + } else { + // Partial fit — take what we can and append ellipsis. + let remaining = max_width.saturating_sub(used); + if remaining > 1 { + // Find a safe UTF-8 boundary. + let truncated = safe_truncate(&span.content, remaining - 1); + let mut s = truncated.to_string(); + s.push('…'); + out.push(Span::styled(s, span.style)); + } else if remaining == 1 { + out.push(Span::styled("…", styles::MUTED)); + } + break; + } + } + + Line::from(out) +} + +/// Truncate a string to at most `max_bytes` bytes on a valid UTF-8 char boundary. +fn safe_truncate(s: &str, max_bytes: usize) -> &str { + if s.len() <= max_bytes { + return s; + } + let mut end = max_bytes; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] +} + +// --------------------------------------------------------------------------- +// Field ordering +// --------------------------------------------------------------------------- + +/// Priority field order for CONNECT log lines. +const CONNECT_FIELD_ORDER: &[&str] = &[ + "action", + "dst_host", + "dst_port", + "policy", + "engine", + "src_addr", + "src_port", + // Trailing process ancestry fields + "binary", + "binary_pid", + "cmdline", + "ancestors", + "proxy_addr", + "reason", +]; + +/// Priority field order for L7_REQUEST log lines. +const L7_FIELD_ORDER: &[&str] = &[ + "l7_action", + "l7_target", + "l7_decision", + "dst_host", + "dst_port", + "l7_protocol", + "policy", + "l7_deny_reason", +]; + +/// Return fields in a smart order based on the log message type. +fn ordered_fields<'a>(log: &'a LogLine) -> Vec<(&'a str, &'a str)> { + let order: Option<&[&str]> = if log.message.starts_with("CONNECT") { + Some(CONNECT_FIELD_ORDER) + } else if log.message.starts_with("L7_REQUEST") { + Some(L7_FIELD_ORDER) + } else { + None + }; + + match order { + Some(priority) => { + let mut result: Vec<(&str, &str)> = Vec::with_capacity(log.fields.len()); + // Add priority fields first (in order). + for &key in priority { + if let Some(val) = log.fields.get(key) { + result.push((key, val.as_str())); + } + } + // Add remaining fields alphabetically. + let mut remaining: Vec<(&str, &str)> = log + .fields + .iter() + .filter(|(k, _)| !priority.contains(&k.as_str())) + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + remaining.sort_by_key(|(k, _)| *k); + result.extend(remaining); + result + } + None => { + // Default: alphabetical. + let mut pairs: Vec<(&str, &str)> = log + .fields + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + pairs.sort_by_key(|(k, _)| *k); + pairs + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn level_style(level: &str) -> ratatui::style::Style { + match level { + "ERROR" => styles::STATUS_ERR, + "WARN" => styles::STATUS_WARN, + "INFO" => styles::STATUS_OK, + _ => styles::MUTED, + } +} + +fn format_short_time(epoch_ms: i64) -> String { + if epoch_ms <= 0 { + return String::from("--:--:--"); + } + let secs = epoch_ms / 1000; + let time_of_day = secs % 86400; + let hours = time_of_day / 3600; + let minutes = (time_of_day % 3600) / 60; + let seconds = time_of_day % 60; + format!("{hours:02}:{minutes:02}:{seconds:02}") +} diff --git a/crates/navigator-tui/src/ui/sandbox_policy.rs b/crates/navigator-tui/src/ui/sandbox_policy.rs new file mode 100644 index 000000000..2bfbf8d6b --- /dev/null +++ b/crates/navigator-tui/src/ui/sandbox_policy.rs @@ -0,0 +1,54 @@ +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Padding, Paragraph}; + +use crate::app::App; +use crate::theme::styles; + +/// Draw the scrollable policy viewer pane (bottom ~80% of the sandbox screen). +/// +/// Always focused when visible (the metadata pane above is non-interactive). +pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) { + let version = app.sandbox_policy.as_ref().map_or(0, |p| p.version); + + let title = format!(" Policy (v{version}) "); + + // Calculate inner dimensions (borders + padding). + let inner_height = area.height.saturating_sub(2) as usize; + + if app.policy_lines.is_empty() { + let lines = vec![Line::from(Span::styled("Loading...", styles::MUTED))]; + let block = Block::default() + .title(Span::styled(title, styles::HEADING)) + .borders(Borders::ALL) + .border_style(styles::BORDER_FOCUSED) + .padding(Padding::horizontal(1)); + frame.render_widget(Paragraph::new(lines).block(block), area); + return; + } + + let total = app.policy_lines.len(); + let scroll = app.policy_scroll.min(total.saturating_sub(1)); + + let visible_lines: Vec> = app + .policy_lines + .iter() + .skip(scroll) + .take(inner_height) + .cloned() + .collect(); + + // Scroll position indicator. + let pos = scroll + 1; + let scroll_info = format!(" [{pos}/{total}] "); + + let block = Block::default() + .title(Span::styled(title, styles::HEADING)) + .title_bottom(Line::from(Span::styled(scroll_info, styles::MUTED)).right_aligned()) + .borders(Borders::ALL) + .border_style(styles::BORDER_FOCUSED) + .padding(Padding::horizontal(1)); + + frame.render_widget(Paragraph::new(visible_lines).block(block), area); +} diff --git a/crates/navigator-tui/src/ui/sandboxes.rs b/crates/navigator-tui/src/ui/sandboxes.rs new file mode 100644 index 000000000..f38842194 --- /dev/null +++ b/crates/navigator-tui/src/ui/sandboxes.rs @@ -0,0 +1,101 @@ +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Padding, Paragraph, Row, Table}; + +use crate::app::App; +use crate::theme::styles; + +pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { + let header = Row::new(vec![ + Cell::from(Span::styled(" NAME", styles::MUTED)), + Cell::from(Span::styled("STATUS", styles::MUTED)), + Cell::from(Span::styled("CREATED", styles::MUTED)), + Cell::from(Span::styled("AGE", styles::MUTED)), + Cell::from(Span::styled("IMAGE", styles::MUTED)), + ]) + .bottom_margin(1); + + let rows: Vec> = (0..app.sandbox_count) + .map(|i| { + let name = app.sandbox_names.get(i).map_or("", String::as_str); + let phase = app.sandbox_phases.get(i).map_or("", String::as_str); + let created = app.sandbox_created.get(i).map_or("", String::as_str); + let age = app.sandbox_ages.get(i).map_or("", String::as_str); + let image = app.sandbox_images.get(i).map_or("", String::as_str); + + let phase_style = match phase { + "Ready" => styles::STATUS_OK, + "Provisioning" => styles::STATUS_WARN, + "Error" => styles::STATUS_ERR, + _ => styles::MUTED, + }; + + let selected = focused && i == app.sandbox_selected; + let name_cell = if selected { + Cell::from(Line::from(vec![ + Span::styled("▌ ", styles::ACCENT), + Span::styled(name, styles::TEXT), + ])) + } else { + Cell::from(Line::from(vec![ + Span::raw(" "), + Span::styled(name, styles::TEXT), + ])) + }; + + Row::new(vec![ + name_cell, + Cell::from(Span::styled(phase, phase_style)), + Cell::from(Span::styled(created, styles::MUTED)), + Cell::from(Span::styled(age, styles::MUTED)), + Cell::from(Span::styled(image, styles::MUTED)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Percentage(22), + Constraint::Percentage(12), + Constraint::Percentage(18), + Constraint::Percentage(10), + Constraint::Percentage(38), + ]; + + let border_style = if focused { + styles::BORDER_FOCUSED + } else { + styles::BORDER + }; + + let title = Line::from(vec![ + Span::styled(" Sandboxes ", styles::HEADING), + Span::styled("─ ", styles::BORDER), + Span::styled(&app.cluster_name, styles::MUTED), + Span::styled(" ", styles::MUTED), + ]); + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style) + .padding(Padding::horizontal(1)); + + let table = Table::new(rows, widths).header(header).block(block); + + frame.render_widget(table, area); + + if app.sandbox_count == 0 { + let inner = Rect { + x: area.x + 2, + y: area.y + 2, + width: area.width.saturating_sub(4), + height: area.height.saturating_sub(3), + }; + let msg = Paragraph::new(Span::styled( + " No sandboxes. Press [c] to create.", + styles::MUTED, + )); + frame.render_widget(msg, inner); + } +}