feat(gator): interactive TUI for Navigator (#57)

Initial TUI aka Gator
This commit is contained in:
John T. Myers
2026-03-02 09:56:42 -08:00
committed by GitHub
parent d0e10e97cb
commit c0547c500e
36 changed files with 6258 additions and 504 deletions
+454
View File
@@ -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: <name> (<status>) │ <screen/context>
```
## 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<Event>`. 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 <name>` | `[d]` on sandbox detail, then `[y]` to confirm |
| `nav sandbox logs <name>` | `[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<usize>` 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: <filter> │ [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/<name>/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<String, String>).
- `GetSandboxLogsRequest` fields: `sandbox_id`, `lines` (u32), `since_ms` (i64), `sources` (Vec<String>), `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 <pod-name> -n <namespace>
```
### 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.
Generated
+202 -7
View File
@@ -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"
+143
View File
@@ -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.
+9
View File
@@ -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'"
+2
View File
@@ -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 }
+10
View File
@@ -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}"))?;
+13 -201
View File
@@ -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<DevInferencePolicy>,
#[serde(default)]
filesystem_policy: Option<DevFilesystemPolicy>,
#[serde(default)]
landlock: Option<DevLandlockPolicy>,
#[serde(default)]
process: Option<DevProcessPolicy>,
#[serde(default)]
network_policies: HashMap<String, DevNetworkPolicyRule>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct DevFilesystemPolicy {
#[serde(default)]
include_workdir: bool,
#[serde(default)]
read_only: Vec<String>,
#[serde(default)]
read_write: Vec<String>,
}
#[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<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct DevNetworkPolicyRule {
#[serde(default)]
name: String,
#[serde(default)]
endpoints: Vec<DevNetworkEndpoint>,
#[serde(default)]
binaries: Vec<DevNetworkBinary>,
}
#[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<DevL7Rule>,
}
#[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<SandboxPolicy> {
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 {
+6 -2
View File
@@ -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<NavigatorClient<Channel>> {
pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result<Channel> {
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<NavigatorClie
let materials = require_tls_materials(server, tls)?;
let tls_config = build_tonic_tls_config(&materials);
endpoint = endpoint.tls_config(tls_config).into_diagnostic()?;
let channel = endpoint.connect().await.into_diagnostic()?;
endpoint.connect().await.into_diagnostic()
}
pub async fn grpc_client(server: &str, tls: &TlsOptions) -> Result<NavigatorClient<Channel>> {
let channel = build_channel(server, tls).await?;
Ok(NavigatorClient::new(channel))
}
+17
View File
@@ -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
+252
View File
@@ -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<InferenceDef>,
#[serde(default)]
filesystem_policy: Option<FilesystemDef>,
#[serde(default)]
landlock: Option<LandlockDef>,
#[serde(default)]
process: Option<ProcessDef>,
#[serde(default)]
network_policies: HashMap<String, NetworkPolicyRuleDef>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct FilesystemDef {
#[serde(default)]
include_workdir: bool,
#[serde(default)]
read_only: Vec<String>,
#[serde(default)]
read_write: Vec<String>,
}
#[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<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct NetworkPolicyRuleDef {
#[serde(default)]
name: String,
#[serde(default)]
endpoints: Vec<NetworkEndpointDef>,
#[serde(default)]
binaries: Vec<NetworkBinaryDef>,
}
#[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<L7RuleDef>,
}
#[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<SandboxPolicy> {
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<SandboxPolicy> {
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();
}
}
-1
View File
@@ -9,7 +9,6 @@ repository.workspace = true
[dependencies]
navigator-core = { path = "../navigator-core" }
serde_json = { workspace = true }
thiserror = { workspace = true }
[lints]
-54
View File
@@ -1,10 +1,5 @@
use std::path::{Component, Path, PathBuf};
pub trait DiscoveryContext {
fn env_var(&self, key: &str) -> Option<String>;
fn expand_home(&self, path: &str) -> Option<PathBuf>;
fn path_exists(&self, path: &Path) -> bool;
fn read_to_string(&self, path: &Path) -> Option<String>;
}
pub struct RealDiscoveryContext;
@@ -13,53 +8,4 @@ impl DiscoveryContext for RealDiscoveryContext {
fn env_var(&self, key: &str) -> Option<String> {
std::env::var(key).ok()
}
fn expand_home(&self, path: &str) -> Option<PathBuf> {
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<String> {
std::fs::read_to_string(path).ok()
}
}
pub(crate) fn expand_home_relative(home: &Path, stripped: &str) -> Option<PathBuf> {
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"))
);
}
}
@@ -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<String, String>,
) {
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());
}
}
+15 -3
View File
@@ -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<Option<DiscoveredProvider>, 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::<Vec<_>>();
@@ -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<Option<crate::DiscoveredProvider>, 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());
}
}
@@ -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<Option<crate::DiscoveredProvider>, 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());
}
}
@@ -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<Option<crate::DiscoveredProvider>, 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());
}
}
@@ -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<Option<crate::DiscoveredProvider>, 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());
}
}
@@ -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<Option<crate::DiscoveredProvider>, ProviderError> {
discover_with_spec(&SPEC, &RealDiscoveryContext)
}
fn credential_env_vars(&self) -> &'static [&'static str] {
SPEC.credential_env_vars
}
}
#[cfg(test)]
@@ -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<Option<crate::DiscoveredProvider>, 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());
}
}
+1 -43
View File
@@ -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<String, String>,
files: HashMap<PathBuf, String>,
exists: HashSet<PathBuf>,
home: Option<PathBuf>,
}
impl MockDiscoveryContext {
@@ -16,51 +11,14 @@ impl MockDiscoveryContext {
Self::default()
}
pub fn with_home(mut self, home: impl Into<PathBuf>) -> 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<PathBuf>, 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<PathBuf>) -> Self {
self.exists.insert(path.into());
self
}
}
impl DiscoveryContext for MockDiscoveryContext {
fn env_var(&self, key: &str) -> Option<String> {
self.env.get(key).cloned()
}
fn expand_home(&self, path: &str) -> Option<PathBuf> {
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<String> {
self.files.get(path).cloned()
}
}
pub fn home_join(home: &str, rel: &str) -> PathBuf {
PathBuf::from(home).join(rel)
}
+16 -1
View File
@@ -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>(&provider.name)
+27
View File
@@ -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
File diff suppressed because it is too large Load Diff
+115
View File
@@ -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<LogLine>),
/// Result of a create sandbox request: `Ok(name)` or `Err(message)`.
CreateResult(Result<String, String>),
/// Result of creating a provider on the gateway: `Ok(name)` or `Err(message)`.
ProviderCreateResult(Result<String, String>),
/// Provider detail fetched from gateway.
ProviderDetailFetched(Result<Box<navigator_core::proto::Provider>, String>),
/// Provider update result: `Ok(name)` or `Err(message)`.
ProviderUpdateResult(Result<String, String>),
/// Provider delete result: `Ok(deleted)` or `Err(message)`.
ProviderDeleteResult(Result<bool, String>),
}
pub struct EventHandler {
rx: mpsc::UnboundedReceiver<Event>,
// Kept alive so the spawned task's `tx` doesn't see a closed channel.
_keepalive: mpsc::UnboundedSender<Event>,
/// When true, the background reader stops polling stdin.
paused: Arc<AtomicBool>,
}
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<Event> {
self.rx.recv().await
}
/// Get a sender handle for dispatching events from background tasks.
pub fn sender(&self) -> mpsc::UnboundedSender<Event> {
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);
}
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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);
}
@@ -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<Line<'_>> = 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<Line<'_>> = 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<Constraint> = 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<Line<'_>> = 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]
}
@@ -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<Line<'_>> = 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<Span<'static>> = 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]
}
+117
View File
@@ -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<Row<'_>> = 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);
}
}
+268
View File
@@ -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<Span<'_>> = 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);
}
+99
View File
@@ -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<Row<'_>> = (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);
}
}
@@ -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);
}
+388
View File
@@ -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<Line<'_>> = 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<Line<'_>> = 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<Span<'_>>, max_width: usize) -> Line<'_> {
if max_width == 0 {
return Line::from(spans);
}
let mut used = 0usize;
let mut out: Vec<Span<'_>> = 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}")
}
@@ -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<Line<'_>> = 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);
}
+101
View File
@@ -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<Row<'_>> = (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);
}
}