Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 272 additions & 13 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ clap = { version = "4.6.1", features = ["derive", "env"] }
expectorate = "1.2.0"
# Our crates use only #[tokio::main] + tokio::time; libfalcon pulls its own
# tokio features (they're additive), so we don't need "full".
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "time"] }
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util"] }
tokio-util = { version = "0.7", features = ["rt"] }
ratatui = { version = "0.29", features = ["unstable-rendered-line-info"] }
crossterm = "0.28"
base64 = "0.22"
libfalcon = { git = "https://github.com/oxidecomputer/falcon", branch = "main" }
# Must be the rev libfalcon pins, so instance-spec component types unify.
propolis-client = { git = "https://github.com/oxidecomputer/propolis", rev = "36f20be9bb4c3b362029237f5feb6377c982395f" }
Expand All @@ -29,6 +33,7 @@ serde = "1"
camino = { version = "1.2.2", features = ["serde1"] }
indoc = "2.0.7"
toml_edit = "0.25"
reqwest = { version = "0.13", default-features = false, features = ["cookies", "json", "rustls"] }

# sprockets/trust quorum related deps
attest-mock = { git = "https://github.com/oxidecomputer/dice-util", rev = "4a39ef08d81e5177edee0bddb1146032aa21074d" }
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ generation.
- **`voxel-image/`**: image build machinery (`voxel image create`) and the install
scripts that bake a control-plane image from an omicron commit.

See [`docs/parameters.md`](docs/parameters.md) for the `voxel.toml` reference, there are a LOT of tuning knobs.
See [`docs/parameters.md`](docs/parameters.md) for the `voxel.toml` reference,
there are a LOT of tuning knobs. Terminal-interface operators should also see
the [`voxel tui` guide](docs/voxel-tui.md).

## Building

Expand Down
98 changes: 98 additions & 0 deletions docs/voxel-tui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Voxel TUI operator guide

`voxel tui` is the private, persistent control and observability interface for
a Voxel deployment. The TUI supervises the public `voxel launch`, `voxel
route`, and `voxel destroy` commands as opaque child processes. Its displayed
phase transitions are advisory hints parsed from output, not lifecycle APIs or
proof of deployment state. It tracks deployment state through live probes
implemented by collector and telemetry interfaces.

## Usage

Build Voxel and prepare images as described in the [project
quickstart](../README.md#quickstart), then start the interface:

```console
$ cargo build -p voxel --bin voxel
$ pfexec ./target/debug/voxel tui
```

Logs are written to `<resolved workdir>/voxel-tui.log` and displayed in a
bounded on-screen history. The TUI can launch and destroy Voxel deployments,
and its session can attach to or detach from new or existing deployments. Use
`voxel tui resume` to resume the most recent detached session, or add `--choose`
to select one.

## Views and actions

The TUI has two alternate views, Deployment and Monitoring. Each view has a
number of foldable sections stacked within it. `Up` and `Down` move within
nested content and hand focus to the adjacent section at an effective edge;
section traversal wraps. `Tab` and `Shift-Tab` always move section focus, and
`Space` folds or expands the focused section.

| Key | Action |
|---|---|
| `1` / `2` | Switch between views |
| `Tab` / `Shift-Tab` | Next / previous top-level section |
| `Space` | Fold / expand the focused section |
| `Left` / `Right` | Previous / next rack in Rack Summary |
| `Up` / `Down` | Navigate nested content, handing off at an edge |
| `PageUp` / `PageDown` | Page nested content without changing section focus |
| `Enter` | Open the selected topology resource detail |
| `?` / `F1` | Open or close Help |
| `Esc` | Dismiss the topmost confirmation, Help, detail, or selection |
| `f` | Cycle the level filter while Deployment Logs is focused |
| `l` / `r` | Request launch / route |
| `c` / `x` | Cancel and leave resources / cancel and destroy |
| `d` | Detach and leave deployment resources in place |
| `y` | Copy the full fallback command from the detach dialog |
| `n` | Reject a confirmation |
| `q` | Quit, destroying resources first unless observed stopped |

### Deployment

The Deployment view tracks the launch and teardown of a Voxel deployment, and
contains **Overall Progress**, **Phases**, **Status**, **Current Phase**, and
**Logs** in separate sections. Live probes are used to track observed deployment
and route state.

Logs are displayed oldest to newest, with new entries edging older ones out of
the bounded view. Press `f` to filter them by level: All, Info, Warning, or
Error. The complete history remains in the durable log file.

### Monitoring

The Monitoring view exposes the state of a Voxel deployment, and contains
**Rack Summary**, **Topology**, and **Top Zones by Traffic** in separate
sections.
Rack Summary reports RSS readiness, aggregate RX/TX, health counts, and history.
Topology presents fleet routers, the shared switch-fabric bus, rack-local switch
zones, and sleds in the order of router → switch zone → sled.

The resource inspector sits beside the topology on wide layouts and below it on
compact layouts. Health summarizes sled-agent, maintenance services, zones, and
NTP probes for sleds, and traffic collection for routers and switch zones. The
inspector also shows the last successful probe, latest collection error,
RX/TX/total and packet rates, a 60-second sparkline, and zones. Missing samples
say collecting or unavailable rather than reporting zero. For the purposes of
a basic labelled implementation, traffic is considered normal at or below 100
KB/s, elevated above 100 KB/s through 5 MB/s, and high above 5 MB/s. **Top Zones
by Traffic** is a separate rack-wide ranking.

### Display

Wide terminals place topology and inspector side by side. Compact windows will
stack them and page resources rather than hiding labels, and prioritize focussed
sections while keeping folded sections minimized. Below `48x16`, the TUI
displays its minimum-size requirement instead of clipping an unsafe layout.

## Testing

```sh
cargo test -p voxel tui::app::tests -- --nocapture
cargo test -p voxel tui::terminal::tests -- --nocapture
cargo test -p voxel tui::ui::confirm_dialog::tests -- --nocapture
cargo test -p voxel tui::ui::widgets::tests -- --nocapture
cargo test -p voxel tui::effects::tests -- --nocapture
```
8 changes: 7 additions & 1 deletion voxel/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ path = "src/main.rs"

[dependencies]
tokio.workspace = true
tokio-util.workspace = true
ratatui.workspace = true
crossterm.workspace = true
base64.workspace = true
libfalcon.workspace = true
propolis-client.workspace = true
anyhow.workspace = true
Expand All @@ -31,11 +35,13 @@ wicketd-commission-client.workspace = true
# `launch --wicket-setup` (drive setup through wicketd).
toml = "0.9"
serde_json = "1"
serde = { workspace = true, features = ["derive"] }
# Unpack TUF control-plane composites (GNU tar format; illumos tar rejects it).
tar = "0.4"
flate2 = "1"
reqwest = { version = "0.13", default-features = false, features = ["rustls"] }
libc = "0.2"
reqwest.workspace = true
chrono = { version = "0.4", features = ["serde"] }

# Sprockets/trust-quorum test keys - generated at launch (replaces a4x2's
# pre-generated per-sled sprockets material that prep.sh used to rsync in).
Expand Down
91 changes: 91 additions & 0 deletions voxel/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ mod sp_cmd;
mod sp_host;
mod topo;
mod tufrepo;
mod tui;
mod util;
mod wicket_setup;

Expand Down Expand Up @@ -79,6 +80,11 @@ struct Cli {

#[derive(Subcommand)]
enum Cmd {
/// Open the terminal interface.
Tui {
#[command(subcommand)]
cmd: Option<TuiCmd>,
},
/// Launch the rack and stream RSS bring-up progress.
Launch {
/// Don't watch RSS bring-up after launch.
Expand Down Expand Up @@ -217,6 +223,16 @@ enum Cmd {
},
}

#[derive(Subcommand)]
enum TuiCmd {
/// Resume a detached TUI session.
Resume {
/// Choose from all available sessions.
#[arg(long)]
choose: bool,
},
}

#[derive(Subcommand)]
enum ConfigCmd {
/// Print the effective configuration.
Expand Down Expand Up @@ -771,9 +787,55 @@ fn anchor_workdir(
Ok(())
}

fn resolve_tui_context(
cli: &Cli,
config_path: &Utf8Path,
config: VoxelConfig,
invocation_dir: &Utf8Path,
) -> anyhow::Result<tui::TuiContext> {
let make_absolute = |path: Utf8PathBuf| {
if path.is_absolute() { path } else { invocation_dir.join(path) }
};
let workdir = cli
.workdir
.clone()
.or_else(|| config.falcon.workdir.clone().map(Utf8PathBuf::from))
.or_else(|| config_path.parent().map(Utf8Path::to_path_buf))
.map(make_absolute)
.context("cannot resolve TUI workdir")?;
let dataset = std::env::var("FALCON_DATASET")
.unwrap_or_else(|_| "rpool/falcon".to_string());
let build_root = std::env::var("BUILD_ROOT")
.map(Utf8PathBuf::from)
.unwrap_or_else(|_| {
Utf8PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/root".into()),
)
.join("voxel-builds")
});

Ok(tui::TuiContext::new(
config_path.to_path_buf(),
workdir,
cli.name.clone(),
dataset,
make_absolute(build_root),
config,
std::env::current_exe().context("resolve current voxel executable")?,
std::env::vars_os().collect(),
))
}

#[tokio::main]
async fn main() -> Result<(), Error> {
let cli = Cli::parse();
if let Cmd::Tui { cmd: Some(TuiCmd::Resume { choose }) } = &cli.cmd {
return tui::resume(*choose);
}
let invocation_dir = Utf8PathBuf::try_from(
std::env::current_dir().context("resolve invocation directory")?,
)
.context("invocation directory is not valid UTF-8")?;
// Anchor to the project root before anything touches cargo-bay/.falcon.
let config_path = discover_config(cli.config.as_deref());
// A missing config means defaults; an existing one that fails to load or
Expand All @@ -786,6 +848,19 @@ async fn main() -> Result<(), Error> {
resolve_falcon_env(&cli, cfg.as_ref());
anchor_workdir(&cli, cfg.as_ref(), &config_path)?;
match &cli.cmd {
Cmd::Tui { cmd } => {
debug_assert!(
cmd.is_none(),
"resume dispatched before configuration"
);
let context = resolve_tui_context(
&cli,
&config_path,
load_config(&config_path)?,
&invocation_dir,
)?;
tui::run(context).await
}
Cmd::Launch { no_progress, no_route, emu, sp_firmware } => {
// One flag: emulated SPs, the RoT bridge on top of them, and
// wicketd-driven setup are the same configuration in practice, and
Expand Down Expand Up @@ -1067,3 +1142,19 @@ mod tests {
assert!(find("deadbeef").ends_with("omicron-deadbeef"));
}
}

#[cfg(test)]
mod main_tests {
use super::Cli;
use clap::Parser;

#[test]
fn parses_tui_commands_and_preserves_launch_parsing() {
assert!(Cli::try_parse_from(["voxel", "tui"]).is_ok());
assert!(Cli::try_parse_from(["voxel", "tui", "resume"]).is_ok());
assert!(
Cli::try_parse_from(["voxel", "tui", "resume", "--choose"]).is_ok()
);
assert!(Cli::try_parse_from(["voxel", "launch", "--emu"]).is_ok());
}
}
Loading