Files
tines/tines-gui/src/launcher.rs
T

331 lines
13 KiB
Rust

//! The launcher window: fleet creation, node/container controls, and
//! per-node window spawns.
//!
//! [`Launcher::update`] renders and queues intents — it never spawns a
//! process (except the one-time bus daemon inside `Fleet::create`, which is
//! a non-blocking `spawn`). Everything else drains through
//! [`Launcher::drain_actions`], called by `main.rs` after each frame; the
//! headless tests drive `update` and assert on the queued actions.
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::process::Child;
use egui::Context;
use crate::config::fleet_dir;
use crate::docker::{ContainerId, Docker};
use crate::fleet::{Fleet, Mode};
/// One node container the launcher knows about.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContainerEntry {
/// The node name (also the dir and container-name stem).
pub name: String,
/// The container id `docker run -d` returned.
pub id: ContainerId,
/// How the node connects to its peers.
pub mode: Mode,
}
/// An intent the UI queued; `main.rs` executes these by draining the queue.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LauncherAction {
/// Add the node to the fleet and start its container.
StartContainer {
/// The node name.
name: String,
/// How the node connects to its peers.
mode: Mode,
},
/// Stop a running container.
StopContainer(ContainerId),
/// Remove a stopped container.
RemoveContainer(ContainerId),
/// Spawn a dedicated window process for this node.
OpenWindow {
/// The node name.
name: String,
/// The node's config dir (the `--node` arg).
dir: PathBuf,
/// The container the window controls.
id: ContainerId,
/// How the node connects to its peers.
mode: Mode,
},
/// Build the runtime image.
BuildImage,
}
/// The launcher window's full state.
pub struct Launcher {
/// The created fleet; `None` until Create succeeds.
pub fleet: Option<Fleet>,
/// The docker frontend.
pub docker: Docker,
/// Known node containers (only successful starts land here).
pub containers: Vec<ContainerEntry>,
/// Spawned node-window processes.
pub children: Vec<Child>,
/// Intents queued by [`Launcher::update`], drained by `main.rs`.
pub actions: VecDeque<LauncherAction>,
/// The fleet-name field.
pub fleet_name: String,
/// The node-name field for Add.
pub node_name: String,
/// The bt peer field (bt mode only; the pinned `StartContainer` action
/// carries no peer, so it is read from here at drain time).
pub node_peer: String,
/// The mode picker.
pub mode: Mode,
/// The selected row's name (drives the log pane).
pub selected: Option<String>,
/// The selected container's last-fetched log tail.
pub log_tail: Option<String>,
/// The latest error to surface.
pub error: Option<String>,
/// `docker inspect` results, refreshed at most once per second.
running: HashMap<ContainerId, bool>,
/// When the running map was last refreshed.
last_running_check: f64,
}
impl Default for Launcher {
fn default() -> Self {
Self::new()
}
}
impl Launcher {
/// An empty launcher: no fleet, no containers, sim mode.
pub fn new() -> Launcher {
Launcher {
fleet: None,
docker: Docker,
containers: Vec::new(),
children: Vec::new(),
actions: VecDeque::new(),
fleet_name: String::new(),
node_name: String::new(),
node_peer: String::new(),
mode: Mode::Sim,
selected: None,
log_tail: None,
error: None,
running: HashMap::new(),
last_running_check: f64::NEG_INFINITY,
}
}
/// Render the launcher and queue intents. Side effects are limited to
/// the fleet creation itself (a non-blocking daemon spawn) and one
/// `docker logs` fetch when a row is selected.
pub fn update(&mut self, ctx: &Context) {
let now = ctx.input(|i| i.time);
if now - self.last_running_check > 1.0 {
self.last_running_check = now;
self.running = self
.containers
.iter()
.map(|c| (c.id.clone(), self.docker.is_running(&c.id)))
.collect();
}
// egui 0.36 is Ui-based: rebuild the root Ui the eframe App would
// have passed, so the panels have a parent.
let mut ui = egui::Ui::new(
ctx.clone(),
egui::Id::new("tines-gui-root"),
egui::UiBuilder::new().max_rect(ctx.content_rect()),
);
egui::Panel::top("controls").show(&mut ui, |ui| {
ui.horizontal(|ui| {
ui.label("Mode:");
ui.selectable_value(&mut self.mode, Mode::Bt, "Bt");
ui.selectable_value(&mut self.mode, Mode::Sim, "Sim");
});
ui.horizontal(|ui| {
ui.label("Fleet name:");
ui.text_edit_singleline(&mut self.fleet_name);
if ui.button("Create").clicked() {
match Fleet::create(self.fleet_name.trim()) {
Ok(fleet) => {
self.fleet = Some(fleet);
self.error = None;
}
Err(e) => self.error = Some(format!("cannot create fleet: {e}")),
}
}
});
if self.mode == Mode::Bt {
ui.horizontal(|ui| {
ui.label("Peer (bt):");
ui.text_edit_singleline(&mut self.node_peer);
});
}
ui.horizontal(|ui| {
ui.label("Node name:");
ui.text_edit_singleline(&mut self.node_name);
if ui.button("Add").clicked() {
self.actions.push_back(LauncherAction::StartContainer {
name: self.node_name.trim().to_string(),
mode: self.mode,
});
}
});
if ui.button("Build image").clicked() {
self.actions.push_back(LauncherAction::BuildImage);
}
if let Some(error) = &self.error {
ui.colored_label(egui::Color32::from_rgb(0xff, 0x55, 0x55), error);
}
});
egui::CentralPanel::default().show(&mut ui, |ui| {
egui::Grid::new("nodes")
.striped(true)
.min_col_width(80.0)
.show(ui, |ui| {
ui.label("node");
ui.label("mode");
ui.label("state");
ui.label("");
ui.end_row();
for entry in &self.containers {
let running = self.running.get(&entry.id).copied().unwrap_or(false);
let name = entry.name.clone();
if ui
.selectable_label(self.selected.as_deref() == Some(&name), &entry.name)
.clicked()
{
self.selected = Some(name.clone());
self.log_tail = self.docker.logs(&entry.id, 50).ok();
}
ui.label(match entry.mode {
Mode::Bt => "bt",
Mode::Sim => "sim",
});
ui.label(if running { "running" } else { "stopped" });
ui.horizontal(|ui| {
if ui.button("Open").clicked() {
self.actions.push_back(LauncherAction::OpenWindow {
name: name.clone(),
dir: fleet_dir(self.fleet_name.trim()).join(&name),
id: entry.id.clone(),
mode: entry.mode,
});
}
if ui.button("Stop").clicked() {
self.actions
.push_back(LauncherAction::StopContainer(entry.id.clone()));
}
if ui.button("Remove").clicked() {
self.actions
.push_back(LauncherAction::RemoveContainer(entry.id.clone()));
}
});
ui.end_row();
}
});
});
egui::Panel::bottom("logs").show(&mut ui, |ui| {
ui.heading(match &self.selected {
Some(name) => format!("logs — {name}"),
None => "logs — select a container".to_string(),
});
if let Some(tail) = &self.log_tail {
egui::ScrollArea::vertical()
.max_height(120.0)
.auto_shrink([false, false])
.show(ui, |ui| {
ui.monospace(tail);
});
}
});
}
/// Execute every queued intent. This is the only place containers are
/// started/stopped/removed and node windows spawned; `main.rs` calls it
/// after each frame.
pub fn drain_actions(&mut self) {
while let Some(action) = self.actions.pop_front() {
match action {
LauncherAction::StartContainer { name, mode } => {
let Some(fleet) = self.fleet.as_ref() else {
self.error = Some("create a fleet first".to_string());
continue;
};
let peer = (mode == Mode::Bt && !self.node_peer.trim().is_empty())
.then(|| self.node_peer.trim().to_string());
if let Err(e) = fleet.add_node(&name, None, mode, peer.as_deref()) {
self.error = Some(format!("cannot add node {name}: {e}"));
continue;
}
let started = match mode {
Mode::Bt => self.docker.start_bt(&name, self.fleet_name.trim()),
Mode::Sim => self.docker.start_sim(&name, self.fleet_name.trim()),
};
match started {
Ok(id) => self.containers.push(ContainerEntry { name, id, mode }),
Err(e) => self.error = Some(format!("cannot start {name}: {e}")),
}
}
LauncherAction::StopContainer(id) => {
if let Err(e) = self.docker.stop(&id) {
self.error = Some(format!("cannot stop {}: {e}", id.0));
}
}
LauncherAction::RemoveContainer(id) => {
if let Err(e) = self.docker.remove(&id) {
self.error = Some(format!("cannot remove {}: {e}", id.0));
}
self.containers.retain(|c| c.id != id);
if self.selected.as_ref() == Some(&id.0) {
self.selected = None;
self.log_tail = None;
}
}
LauncherAction::OpenWindow {
name,
dir,
id,
mode,
} => {
let mode_arg = match mode {
Mode::Bt => "bt",
Mode::Sim => "sim",
};
match std::env::current_exe().and_then(|exe| {
std::process::Command::new(exe)
.arg("--node")
.arg(&dir)
.arg(&id.0)
.arg(mode_arg)
.spawn()
}) {
Ok(child) => self.children.push(child),
Err(e) => {
self.error = Some(format!("cannot open window for {name}: {e}"));
}
}
}
LauncherAction::BuildImage => {
if let Err(e) = self.docker.build_image() {
self.error = Some(format!("image build failed: {e}"));
}
}
}
}
}
}
impl eframe::App for Launcher {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
Launcher::update(self, ui.ctx());
}
}