mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-19 05:50:41 +08:00
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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'"
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
navigator-core = { path = "../navigator-core" }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user