feat(gui): node window (status strip, observe, belief, store, events, container)
This commit is contained in:
@@ -13,3 +13,4 @@ pub mod fleet;
|
|||||||
pub mod ipc_client;
|
pub mod ipc_client;
|
||||||
pub mod launcher;
|
pub mod launcher;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
pub mod window;
|
||||||
|
|||||||
+87
-12
@@ -1,15 +1,25 @@
|
|||||||
//! tines-gui entry point: no args opens the launcher window;
|
//! tines-gui entry point: no args opens the launcher window;
|
||||||
//! `--node <dir> <container-id> <mode>` opens a node window (Task 8b).
|
//! `--node <dir> <container-id> <mode>` opens a node window.
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
use tines_gui::docker::ContainerId;
|
||||||
|
use tines_gui::fleet::Mode;
|
||||||
|
use tines_gui::launcher::Launcher;
|
||||||
|
use tines_gui::window::{NodeAction, NodeWin};
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
match args.as_slice() {
|
match args.as_slice() {
|
||||||
[_, flag, dir, id, mode] if flag == "--node" => {
|
[_, flag, dir, id, mode] if flag == "--node" => {
|
||||||
let _ = (dir, id, mode);
|
let mode = match mode.as_str() {
|
||||||
eprintln!("tines-gui: node window is Task 8b — not wired yet");
|
"bt" => Mode::Bt,
|
||||||
std::process::exit(1);
|
"sim" => Mode::Sim,
|
||||||
|
other => {
|
||||||
|
eprintln!("tines-gui: unknown mode {other:?} (want bt|sim)");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
run_node_window(dir.into(), ContainerId(id.clone()), mode)?;
|
||||||
}
|
}
|
||||||
[_] => run_launcher()?,
|
[_] => run_launcher()?,
|
||||||
_ => {
|
_ => {
|
||||||
@@ -23,7 +33,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||||||
/// The launcher window: renders via `Launcher::update`, then drains the
|
/// The launcher window: renders via `Launcher::update`, then drains the
|
||||||
/// action queue — the only place subprocess spawns happen.
|
/// action queue — the only place subprocess spawns happen.
|
||||||
struct LauncherApp {
|
struct LauncherApp {
|
||||||
launcher: tines_gui::launcher::Launcher,
|
launcher: Launcher,
|
||||||
theme: tines_gui::theme::OmarchyTheme,
|
theme: tines_gui::theme::OmarchyTheme,
|
||||||
watching: bool,
|
watching: bool,
|
||||||
}
|
}
|
||||||
@@ -44,25 +54,90 @@ impl eframe::App for LauncherApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_launcher() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
/// The node window: renders via `NodeWin::update`, then drains the
|
||||||
|
/// container actions (stop/remove/logs).
|
||||||
|
struct NodeWinApp {
|
||||||
|
node: NodeWin,
|
||||||
|
theme: tines_gui::theme::OmarchyTheme,
|
||||||
|
watching: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for NodeWinApp {
|
||||||
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
|
let ctx = ui.ctx().clone();
|
||||||
|
self.theme = tines_gui::theme::OmarchyTheme::load();
|
||||||
|
self.theme.apply(&ctx);
|
||||||
|
if !self.watching {
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
if tines_gui::theme::OmarchyTheme::watch(move || ctx.request_repaint()).is_ok() {
|
||||||
|
self.watching = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.node.update(&ctx);
|
||||||
|
while let Some(action) = self.node.actions.pop_front() {
|
||||||
|
match action {
|
||||||
|
NodeAction::Stop => {
|
||||||
|
if let Err(e) = self.node.docker.stop(&self.node.container_id) {
|
||||||
|
self.node.error = Some(format!("cannot stop: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NodeAction::Remove => {
|
||||||
|
if let Err(e) = self.node.docker.remove(&self.node.container_id) {
|
||||||
|
self.node.error = Some(format!("cannot remove: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NodeAction::FetchLogs => match self.node.docker.logs(&self.node.container_id, 50) {
|
||||||
|
Ok(tail) => self.node.apply_log_tail(tail),
|
||||||
|
Err(e) => self.node.error = Some(format!("cannot fetch logs: {e}")),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options(title: &str, size: [f32; 2]) -> eframe::NativeOptions {
|
||||||
let viewport = egui::ViewportBuilder::default()
|
let viewport = egui::ViewportBuilder::default()
|
||||||
.with_app_id("tines-gui")
|
.with_app_id("tines-gui")
|
||||||
.with_title("tines — fleet console")
|
.with_title(title)
|
||||||
.with_inner_size([760.0, 520.0]);
|
.with_inner_size(size);
|
||||||
let options = eframe::NativeOptions {
|
eframe::NativeOptions {
|
||||||
viewport,
|
viewport,
|
||||||
glow_options: eframe::egui_glow::GlowConfiguration {
|
glow_options: eframe::egui_glow::GlowConfiguration {
|
||||||
vsync: false,
|
vsync: false,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_launcher() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"tines-gui",
|
"tines-gui",
|
||||||
options,
|
options("tines — fleet console", [760.0, 520.0]),
|
||||||
Box::new(|_cc| {
|
Box::new(|_cc| {
|
||||||
Ok(Box::new(LauncherApp {
|
Ok(Box::new(LauncherApp {
|
||||||
launcher: tines_gui::launcher::Launcher::new(),
|
launcher: Launcher::new(),
|
||||||
|
theme: tines_gui::theme::OmarchyTheme::load(),
|
||||||
|
watching: false,
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("eframe: {e}").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_node_window(
|
||||||
|
dir: std::path::PathBuf,
|
||||||
|
container_id: ContainerId,
|
||||||
|
mode: Mode,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let node = NodeWin::new(dir, container_id, mode);
|
||||||
|
let title = format!("tines — {}", node.name);
|
||||||
|
eframe::run_native(
|
||||||
|
"tines-gui-node",
|
||||||
|
options(&title, [680.0, 480.0]),
|
||||||
|
Box::new(move |_cc| {
|
||||||
|
Ok(Box::new(NodeWinApp {
|
||||||
|
node,
|
||||||
theme: tines_gui::theme::OmarchyTheme::load(),
|
theme: tines_gui::theme::OmarchyTheme::load(),
|
||||||
watching: false,
|
watching: false,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
//! The node window: the whole service control surface for one container.
|
||||||
|
//!
|
||||||
|
//! All node I/O goes through the [`IpcClient`] — [`NodeWin::update`] queues
|
||||||
|
//! requests (Status/List once, Events every 500 ms, Observe/Belief on
|
||||||
|
//! clicks) and drains responses each frame; the UI thread never blocks.
|
||||||
|
//! Container operations (stop/remove/logs) are queued as [`NodeAction`]s
|
||||||
|
//! and drained by `main.rs`, like the launcher's action queue.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use egui::Context;
|
||||||
|
use tines::{BeliefOut, ObservationOut, Request, Response, TransportKind};
|
||||||
|
|
||||||
|
use crate::docker::{ContainerId, Docker};
|
||||||
|
use crate::fleet::Mode;
|
||||||
|
use crate::ipc_client::IpcClient;
|
||||||
|
|
||||||
|
/// The status strip's data, from `Response::Status`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct StatusData {
|
||||||
|
/// Hex of the node's own `NodeId`.
|
||||||
|
pub id: String,
|
||||||
|
/// Next tines sequence number the node will use.
|
||||||
|
pub next_seq: u64,
|
||||||
|
/// Number of observations in the store.
|
||||||
|
pub stored: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One events-feed row.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum FeedEntry {
|
||||||
|
/// A peer joined or was lost.
|
||||||
|
Contact {
|
||||||
|
/// Hex of the peer's `NodeId`.
|
||||||
|
peer: String,
|
||||||
|
/// `true` for a new contact, `false` for a loss.
|
||||||
|
joined: bool,
|
||||||
|
},
|
||||||
|
/// A confirmed delivery.
|
||||||
|
Delivered {
|
||||||
|
/// Hex of the peer the observation reached.
|
||||||
|
peer: String,
|
||||||
|
/// Hex of the delivered observation's id.
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
/// Counted drops drained from the exchange.
|
||||||
|
Dropped {
|
||||||
|
/// Hex of the peer the dropped frame came from, or `None` for the
|
||||||
|
/// overflow slot.
|
||||||
|
peer: Option<String>,
|
||||||
|
/// The drop reason, debug-formatted.
|
||||||
|
reason: String,
|
||||||
|
/// The number of frames folded into the counter.
|
||||||
|
count: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An intent the container panel queued; `main.rs` drains these.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum NodeAction {
|
||||||
|
/// `docker stop` the container.
|
||||||
|
Stop,
|
||||||
|
/// `docker rm` the container.
|
||||||
|
Remove,
|
||||||
|
/// Re-fetch the log tail.
|
||||||
|
FetchLogs,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One node window's full state.
|
||||||
|
pub struct NodeWin {
|
||||||
|
/// The node's IPC client (dialed at the node's service socket).
|
||||||
|
pub client: IpcClient,
|
||||||
|
/// The node name (the config dir's leaf).
|
||||||
|
pub name: String,
|
||||||
|
/// How the node connects to its peers.
|
||||||
|
pub mode: Mode,
|
||||||
|
/// The transport the service is configured for, derived from `mode`.
|
||||||
|
pub transport: TransportKind,
|
||||||
|
/// The latest `Status` answer.
|
||||||
|
pub status: Option<StatusData>,
|
||||||
|
/// Latest beliefs, most recent first.
|
||||||
|
pub beliefs: VecDeque<BeliefOut>,
|
||||||
|
/// The events feed, oldest first.
|
||||||
|
pub feed: VecDeque<FeedEntry>,
|
||||||
|
/// The store browser's rows, from the latest `List`.
|
||||||
|
pub store_rows: VecDeque<ObservationOut>,
|
||||||
|
/// The id of the most recently inserted observation.
|
||||||
|
pub last_id: Option<String>,
|
||||||
|
|
||||||
|
/// The docker frontend (container panel).
|
||||||
|
pub docker: Docker,
|
||||||
|
/// The container this window controls.
|
||||||
|
pub container_id: ContainerId,
|
||||||
|
/// Intents queued by [`NodeWin::update`], drained by `main.rs`.
|
||||||
|
pub actions: VecDeque<NodeAction>,
|
||||||
|
|
||||||
|
/// The observe panel's topic field.
|
||||||
|
pub topic: String,
|
||||||
|
/// The observe panel's payload field.
|
||||||
|
pub payload: String,
|
||||||
|
/// The observe panel's recipient field (hex-encoded on send).
|
||||||
|
pub recipient: String,
|
||||||
|
/// The observe panel's lifetime field (ms).
|
||||||
|
pub lifetime: String,
|
||||||
|
/// When on, the topic/payload/recipient inputs are already hex.
|
||||||
|
pub hex_input: bool,
|
||||||
|
/// The belief panel's topic field.
|
||||||
|
pub belief_topic: String,
|
||||||
|
/// The container's last-fetched log tail.
|
||||||
|
pub log_tail: Option<String>,
|
||||||
|
/// The latest error to surface.
|
||||||
|
pub error: Option<String>,
|
||||||
|
|
||||||
|
/// Outstanding requests (responses not yet drained).
|
||||||
|
pub outstanding: usize,
|
||||||
|
/// When the Events poll last fired (input time).
|
||||||
|
pub last_events_poll: f64,
|
||||||
|
/// Whether the initial Status request went out.
|
||||||
|
status_requested: bool,
|
||||||
|
/// Whether the initial List request went out.
|
||||||
|
list_requested: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode text as lowercase hex (the service's wire form for topics,
|
||||||
|
/// payloads, and node ids).
|
||||||
|
fn to_hex(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len() * 2);
|
||||||
|
for byte in text.as_bytes() {
|
||||||
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NodeWin {
|
||||||
|
/// Build the window: dial the service at `<dir>/sock`, name from the
|
||||||
|
/// dir's leaf, transport from the mode.
|
||||||
|
pub fn new(dir: PathBuf, container_id: ContainerId, mode: Mode) -> NodeWin {
|
||||||
|
let name = dir
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "node".to_string());
|
||||||
|
let transport = match mode {
|
||||||
|
Mode::Bt => TransportKind::Bt,
|
||||||
|
Mode::Sim => TransportKind::Bus,
|
||||||
|
};
|
||||||
|
let client = IpcClient::spawn(dir.join("sock")).expect("ipc worker spawns");
|
||||||
|
NodeWin {
|
||||||
|
client,
|
||||||
|
name,
|
||||||
|
mode,
|
||||||
|
transport,
|
||||||
|
status: None,
|
||||||
|
beliefs: VecDeque::new(),
|
||||||
|
feed: VecDeque::new(),
|
||||||
|
store_rows: VecDeque::new(),
|
||||||
|
last_id: None,
|
||||||
|
docker: Docker,
|
||||||
|
container_id,
|
||||||
|
actions: VecDeque::new(),
|
||||||
|
topic: String::new(),
|
||||||
|
payload: String::new(),
|
||||||
|
recipient: String::new(),
|
||||||
|
lifetime: String::new(),
|
||||||
|
hex_input: false,
|
||||||
|
belief_topic: String::new(),
|
||||||
|
log_tail: None,
|
||||||
|
error: None,
|
||||||
|
outstanding: 0,
|
||||||
|
last_events_poll: f64::NEG_INFINITY,
|
||||||
|
status_requested: false,
|
||||||
|
list_requested: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue a request and count it outstanding.
|
||||||
|
fn send(&mut self, req: Request) {
|
||||||
|
match self.client.request(req) {
|
||||||
|
Ok(_) => self.outstanding += 1,
|
||||||
|
Err(e) => self.error = Some(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply one drained response.
|
||||||
|
fn apply(&mut self, resp: Response) {
|
||||||
|
match resp {
|
||||||
|
Response::Status {
|
||||||
|
id,
|
||||||
|
next_seq,
|
||||||
|
stored,
|
||||||
|
} => {
|
||||||
|
self.status = Some(StatusData {
|
||||||
|
id,
|
||||||
|
next_seq,
|
||||||
|
stored,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Response::List {
|
||||||
|
observations,
|
||||||
|
truncated: _,
|
||||||
|
} => self.store_rows = observations.into(),
|
||||||
|
Response::Belief { obs } => {
|
||||||
|
if let Some(obs) = obs {
|
||||||
|
self.beliefs.push_front(obs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Response::Observe { id } => self.last_id = Some(id),
|
||||||
|
Response::Events {
|
||||||
|
events,
|
||||||
|
contacts,
|
||||||
|
drops,
|
||||||
|
} => {
|
||||||
|
for contact in contacts {
|
||||||
|
self.feed.push_back(FeedEntry::Contact {
|
||||||
|
peer: contact.peer,
|
||||||
|
joined: contact.joined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for event in events {
|
||||||
|
self.feed.push_back(FeedEntry::Delivered {
|
||||||
|
peer: event.peer,
|
||||||
|
id: event.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for drop in drops {
|
||||||
|
self.feed.push_back(FeedEntry::Dropped {
|
||||||
|
peer: drop.peer,
|
||||||
|
reason: drop.reason,
|
||||||
|
count: drop.count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Response::Error { message } => self.error = Some(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the node window and queue intents. Side effects are limited
|
||||||
|
/// to queueing requests on the [`IpcClient`] (responses drain here
|
||||||
|
/// too); container intents wait in `actions` for `main.rs`.
|
||||||
|
pub fn update(&mut self, ctx: &Context) {
|
||||||
|
while let Some((_id, resp)) = self.client.try_recv() {
|
||||||
|
self.outstanding = self.outstanding.saturating_sub(1);
|
||||||
|
self.apply(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.status_requested {
|
||||||
|
self.status_requested = true;
|
||||||
|
self.send(Request::Status);
|
||||||
|
}
|
||||||
|
if !self.list_requested {
|
||||||
|
self.list_requested = true;
|
||||||
|
self.send(Request::List);
|
||||||
|
}
|
||||||
|
let now = ctx.input(|i| i.time);
|
||||||
|
if self.last_events_poll.is_nan() || now - self.last_events_poll >= 0.5 {
|
||||||
|
self.last_events_poll = now;
|
||||||
|
self.send(Request::Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
// egui 0.36 is Ui-based: rebuild the root Ui, like the launcher.
|
||||||
|
let mut ui = egui::Ui::new(
|
||||||
|
ctx.clone(),
|
||||||
|
egui::Id::new("tines-node-root"),
|
||||||
|
egui::UiBuilder::new().max_rect(ctx.content_rect()),
|
||||||
|
);
|
||||||
|
|
||||||
|
egui::Panel::top("strip").show(&mut ui, |ui| {
|
||||||
|
let (id, next_seq, stored) = match &self.status {
|
||||||
|
Some(s) => (s.id.clone(), s.next_seq.to_string(), s.stored.to_string()),
|
||||||
|
None => ("—".to_string(), "—".to_string(), "—".to_string()),
|
||||||
|
};
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(format!(
|
||||||
|
"node {id} · seq {next_seq} · stored {stored} · transport {}",
|
||||||
|
match self.transport {
|
||||||
|
TransportKind::Bt => "bt",
|
||||||
|
TransportKind::Bus => "bus",
|
||||||
|
}
|
||||||
|
));
|
||||||
|
if self.mode == Mode::Bt {
|
||||||
|
ui.separator();
|
||||||
|
ui.label("bt: single peer — addressed observes beyond the configured peer have no route");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
egui::Panel::left("controls")
|
||||||
|
.resizable(true)
|
||||||
|
.show(&mut ui, |ui| {
|
||||||
|
ui.heading("Observe");
|
||||||
|
ui.label("topic:");
|
||||||
|
ui.text_edit_singleline(&mut self.topic);
|
||||||
|
ui.label("payload:");
|
||||||
|
ui.text_edit_singleline(&mut self.payload);
|
||||||
|
ui.label("recipient (optional):");
|
||||||
|
ui.text_edit_singleline(&mut self.recipient);
|
||||||
|
ui.label("lifetime ms (optional):");
|
||||||
|
ui.text_edit_singleline(&mut self.lifetime);
|
||||||
|
ui.checkbox(&mut self.hex_input, "input is hex");
|
||||||
|
if ui.button("Send").clicked() {
|
||||||
|
let encode = |s: &str| {
|
||||||
|
if self.hex_input {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
to_hex(s)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let to = (!self.recipient.is_empty()).then(|| encode(&self.recipient));
|
||||||
|
let lifetime_ms = (!self.lifetime.is_empty())
|
||||||
|
.then(|| self.lifetime.trim().parse::<u64>().ok())
|
||||||
|
.flatten();
|
||||||
|
self.send(Request::Observe {
|
||||||
|
topic: encode(&self.topic),
|
||||||
|
payload: encode(&self.payload),
|
||||||
|
to,
|
||||||
|
lifetime_ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(id) = &self.last_id {
|
||||||
|
ui.label(format!("last: {id}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
ui.heading("Belief");
|
||||||
|
ui.label("topic:");
|
||||||
|
ui.text_edit_singleline(&mut self.belief_topic);
|
||||||
|
if ui.button("Fetch").clicked() {
|
||||||
|
let topic = if self.hex_input {
|
||||||
|
self.belief_topic.clone()
|
||||||
|
} else {
|
||||||
|
to_hex(&self.belief_topic)
|
||||||
|
};
|
||||||
|
self.send(Request::Belief { topic });
|
||||||
|
}
|
||||||
|
for belief in &self.beliefs {
|
||||||
|
ui.label(format!("{}: {}", belief.topic, belief.payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
ui.heading("Container");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Refresh logs").clicked() {
|
||||||
|
self.actions.push_back(NodeAction::FetchLogs);
|
||||||
|
}
|
||||||
|
if ui.button("Stop").clicked() {
|
||||||
|
self.actions.push_back(NodeAction::Stop);
|
||||||
|
}
|
||||||
|
if ui.button("Remove").clicked() {
|
||||||
|
self.actions.push_back(NodeAction::Remove);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(tail) = &self.log_tail {
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height(120.0)
|
||||||
|
.auto_shrink([false, false])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.monospace(tail);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
egui::CentralPanel::default().show(&mut ui, |ui| {
|
||||||
|
ui.heading("Store");
|
||||||
|
if ui.button("Refresh").clicked() {
|
||||||
|
self.send(Request::List);
|
||||||
|
}
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
egui::Grid::new("store").striped(true).show(ui, |ui| {
|
||||||
|
ui.label("id");
|
||||||
|
ui.label("topic");
|
||||||
|
ui.label("payload");
|
||||||
|
ui.label("age ms");
|
||||||
|
ui.end_row();
|
||||||
|
for obs in &self.store_rows {
|
||||||
|
ui.monospace(&obs.id);
|
||||||
|
ui.monospace(&obs.topic);
|
||||||
|
ui.monospace(&obs.payload);
|
||||||
|
ui.label(obs.age_ms.to_string());
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
egui::Panel::right("feed")
|
||||||
|
.resizable(true)
|
||||||
|
.show(&mut ui, |ui| {
|
||||||
|
ui.heading("Events");
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
for entry in &self.feed {
|
||||||
|
match entry {
|
||||||
|
FeedEntry::Contact { peer, joined } => {
|
||||||
|
ui.label(format!(
|
||||||
|
"contact {peer} {}",
|
||||||
|
if *joined { "joined" } else { "lost" }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
FeedEntry::Delivered { peer, id } => {
|
||||||
|
ui.label(format!("delivered {id} to {peer}"));
|
||||||
|
}
|
||||||
|
FeedEntry::Dropped {
|
||||||
|
peer,
|
||||||
|
reason,
|
||||||
|
count,
|
||||||
|
} => {
|
||||||
|
let peer = peer.as_deref().unwrap_or("overflow");
|
||||||
|
ui.label(format!("dropped {count} from {peer}: {reason}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(error) = &self.error {
|
||||||
|
egui::Panel::bottom("error").show(&mut ui, |ui| {
|
||||||
|
ui.colored_label(egui::Color32::from_rgb(0xff, 0x55, 0x55), error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep frames flowing while responses are in flight (the harness
|
||||||
|
// settles when this stops).
|
||||||
|
if self.outstanding > 0 {
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The container panel's latest log tail (set by `main.rs`'s drain).
|
||||||
|
pub fn apply_log_tail(&mut self, tail: String) {
|
||||||
|
self.log_tail = Some(tail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for NodeWin {
|
||||||
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
|
NodeWin::update(self, ui.ctx());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,13 @@ use std::os::unix::fs::PermissionsExt;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use tines::{Request, Response};
|
||||||
|
|
||||||
/// One shared scratch setup per test binary: a temp dir that becomes both
|
/// One shared scratch setup per test binary: a temp dir that becomes both
|
||||||
/// XDG_STATE_HOME (fleet dirs) and a PATH dir holding a fake `tines-busd`.
|
/// XDG_STATE_HOME (fleet dirs) and a PATH dir holding a fake `tines-busd`.
|
||||||
/// Env is process-global, so it is set exactly once before any test touches
|
/// Env is process-global, so it is set exactly once before any test touches
|
||||||
/// `fleet_dir` — a per-test `set_var` from parallel test threads would race.
|
/// `fleet_dir` — a per-test `set_var` from parallel test threads would race.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn env_with_fake_busd() -> &'static PathBuf {
|
pub fn env_with_fake_busd() -> &'static PathBuf {
|
||||||
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
DIR.get_or_init(|| {
|
DIR.get_or_init(|| {
|
||||||
@@ -36,3 +39,124 @@ pub fn env_with_fake_busd() -> &'static PathBuf {
|
|||||||
dir
|
dir
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fake `tines` service speaking the real wire framing
|
||||||
|
/// (`[u32 LE len][JSON]`), answering every request through a handler
|
||||||
|
/// closure and logging every request it receives. Serving runs on a
|
||||||
|
/// thread; dropping the service shuts it down.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct FakeService {
|
||||||
|
/// The socket the service is bound to (what the client dials).
|
||||||
|
pub socket: PathBuf,
|
||||||
|
requests: std::sync::Arc<std::sync::Mutex<Vec<Request>>>,
|
||||||
|
shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
listener: Option<std::os::unix::net::UnixListener>,
|
||||||
|
handle: Option<std::thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl FakeService {
|
||||||
|
/// Bind `socket` and serve requests forever (until dropped).
|
||||||
|
pub fn start<F>(socket: PathBuf, handler: F) -> FakeService
|
||||||
|
where
|
||||||
|
F: Fn(&Request) -> Response + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::os::unix::net::UnixListener;
|
||||||
|
|
||||||
|
if let Some(parent) = socket.parent() {
|
||||||
|
std::fs::create_dir_all(parent).unwrap();
|
||||||
|
}
|
||||||
|
use std::time::Duration;
|
||||||
|
// A restarted service finds the previous one's socket path still on
|
||||||
|
// disk (Linux leaves the file behind), so remove the stale path
|
||||||
|
// first — exactly what a real service does.
|
||||||
|
let _ = std::fs::remove_file(&socket);
|
||||||
|
let listener = UnixListener::bind(&socket).unwrap();
|
||||||
|
listener.set_nonblocking(true).unwrap();
|
||||||
|
|
||||||
|
let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
|
let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
|
||||||
|
let serve_listener = listener.try_clone().unwrap();
|
||||||
|
let serve_requests = std::sync::Arc::clone(&requests);
|
||||||
|
let serve_shutdown = std::sync::Arc::clone(&shutdown);
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
while !serve_shutdown.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
match serve_listener.accept() {
|
||||||
|
Ok((mut stream, _)) => {
|
||||||
|
// Timeout idle reads so `Drop`'s join can always
|
||||||
|
// finish: the serve thread would otherwise block in
|
||||||
|
// `read_exact` on a connection the client keeps
|
||||||
|
// open with nothing in flight.
|
||||||
|
let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
|
||||||
|
// Serve this connection until it closes.
|
||||||
|
loop {
|
||||||
|
let mut len_buf = [0u8; 4];
|
||||||
|
if stream.read_exact(&mut len_buf).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let len = u32::from_le_bytes(len_buf) as usize;
|
||||||
|
let mut buf = vec![0u8; len];
|
||||||
|
if stream.read_exact(&mut buf).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let req: Request = match serde_json::from_slice(&buf) {
|
||||||
|
Ok(req) => req,
|
||||||
|
Err(e) => {
|
||||||
|
let body = serde_json::to_vec(&Response::Error {
|
||||||
|
message: format!("unparseable: {e}"),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let len = (body.len() as u32).to_le_bytes();
|
||||||
|
let _ = stream.write_all(&len);
|
||||||
|
let _ = stream.write_all(&body);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let body = serde_json::to_vec(&handler(&req)).unwrap();
|
||||||
|
serve_requests.lock().unwrap().push(req);
|
||||||
|
let len = (body.len() as u32).to_le_bytes();
|
||||||
|
if stream.write_all(&len).is_err() || stream.write_all(&body).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let _ = stream.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
// Keep the poll tight: the harness can burn dozens
|
||||||
|
// of frames in the time one 10ms sleep takes, and
|
||||||
|
// the client's round trip must land within the
|
||||||
|
// step budget.
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
FakeService {
|
||||||
|
socket,
|
||||||
|
requests,
|
||||||
|
shutdown,
|
||||||
|
listener: Some(listener),
|
||||||
|
handle: Some(handle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when any received request satisfies the predicate.
|
||||||
|
pub fn any_request(&self, pred: impl Fn(&Request) -> bool) -> bool {
|
||||||
|
self.requests.lock().unwrap().iter().any(pred)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for FakeService {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shutdown
|
||||||
|
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
self.listener.take();
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//! Node window tests (egui_kittest, headless). The fake service speaks the
|
||||||
|
//! real wire framing and answers scripted responses; the window is driven
|
||||||
|
//! through `NodeWin::update` and asserted via the harness tree and the
|
||||||
|
//! fake's received-request log.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use egui::accesskit;
|
||||||
|
use egui_kittest::Harness;
|
||||||
|
use egui_kittest::kittest::Queryable;
|
||||||
|
use tines::{ContactOut, Request, Response};
|
||||||
|
use tines_gui::docker::ContainerId;
|
||||||
|
use tines_gui::fleet::Mode;
|
||||||
|
use tines_gui::window::NodeWin;
|
||||||
|
|
||||||
|
/// One scratch dir per test: the fake service binds `<dir>/sock`, which is
|
||||||
|
/// exactly where the node window dials.
|
||||||
|
fn scratch_dir(tag: &str) -> PathBuf {
|
||||||
|
std::env::temp_dir().join(format!(
|
||||||
|
"tines-gui-window-{tag}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||||
|
|
||||||
|
fn new_window(dir: &std::path::Path) -> NodeWin {
|
||||||
|
NodeWin::new(
|
||||||
|
dir.to_path_buf(),
|
||||||
|
ContainerId("deadbeef".to_string()),
|
||||||
|
Mode::Sim,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The harness: generous max steps so async IPC round trips can land
|
||||||
|
/// (the default 4 panics on any slow dial).
|
||||||
|
fn harness_for(dir: &std::path::Path) -> Harness<'static, NodeWin> {
|
||||||
|
let dir = dir.to_path_buf();
|
||||||
|
Harness::builder()
|
||||||
|
.with_max_steps(400)
|
||||||
|
.build_eframe(move |_cc| new_window(&dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A service that answers Status/List/Belief/Observe and returns one
|
||||||
|
/// contact from the first Events drain.
|
||||||
|
fn default_handler() -> impl Fn(&Request) -> Response + Send + Sync + 'static {
|
||||||
|
let contact_sent = Arc::new(AtomicBool::new(false));
|
||||||
|
move |req: &Request| match req {
|
||||||
|
Request::Status => Response::Status {
|
||||||
|
id: "0a0b".to_string(),
|
||||||
|
next_seq: 7,
|
||||||
|
stored: 3,
|
||||||
|
},
|
||||||
|
Request::Events => {
|
||||||
|
if contact_sent.swap(true, Ordering::Relaxed) {
|
||||||
|
Response::Events {
|
||||||
|
events: vec![],
|
||||||
|
contacts: vec![],
|
||||||
|
drops: vec![],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Response::Events {
|
||||||
|
events: vec![],
|
||||||
|
contacts: vec![ContactOut {
|
||||||
|
peer: "bb".to_string(),
|
||||||
|
joined: true,
|
||||||
|
}],
|
||||||
|
drops: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Request::List => Response::List {
|
||||||
|
observations: vec![],
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
Request::Belief { .. } => Response::Belief { obs: None },
|
||||||
|
Request::Observe { .. } => Response::Observe {
|
||||||
|
id: "00".to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The response drain is a background worker: poll the fake's log until
|
||||||
|
/// the predicate holds or the deadline passes.
|
||||||
|
fn wait_for<F: Fn(&Request) -> bool>(fake: &common::FakeService, pred: F) {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
if fake.any_request(&pred) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(Instant::now() < deadline, "request never arrived");
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Focus the `idx`-th text input and type `text` into it. The iterator
|
||||||
|
/// borrows the harness, so it must drop before each `run()`.
|
||||||
|
fn type_into(harness: &mut Harness<'_, NodeWin>, idx: usize, text: &str) {
|
||||||
|
{
|
||||||
|
let mut inputs = harness.get_all_by_role(accesskit::Role::TextInput);
|
||||||
|
inputs.nth(idx).unwrap().focus();
|
||||||
|
}
|
||||||
|
settle(harness);
|
||||||
|
{
|
||||||
|
let mut inputs = harness.get_all_by_role(accesskit::Role::TextInput);
|
||||||
|
inputs.nth(idx).unwrap().type_text(text);
|
||||||
|
}
|
||||||
|
settle(harness);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step the app until every outstanding request has been answered (real
|
||||||
|
/// time between steps: the IPC round trip runs on a background thread, and
|
||||||
|
/// a tight `run()` loop burns hundreds of frames before the first response
|
||||||
|
/// lands — while the 500ms events poll keeps queueing, so `run()` never
|
||||||
|
/// settles and panics at max_steps).
|
||||||
|
fn settle(harness: &mut Harness<'_, NodeWin>) {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
harness.step();
|
||||||
|
if harness.state().outstanding == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"service never answered the outstanding requests"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_strip_shows_node_id() {
|
||||||
|
let dir = scratch_dir("status");
|
||||||
|
let fake = common::FakeService::start(dir.join("sock"), default_handler());
|
||||||
|
let mut harness = harness_for(&dir);
|
||||||
|
settle(&mut harness);
|
||||||
|
|
||||||
|
// The strip is one label; the node id hex must appear in it.
|
||||||
|
assert!(
|
||||||
|
harness.get_all_by_label_contains("0a0b").count() >= 1,
|
||||||
|
"status strip must show the node id answered by Status"
|
||||||
|
);
|
||||||
|
drop(fake);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observe_button_queues_observe() {
|
||||||
|
let dir = scratch_dir("observe");
|
||||||
|
let fake = common::FakeService::start(dir.join("sock"), default_handler());
|
||||||
|
let mut harness = harness_for(&dir);
|
||||||
|
settle(&mut harness);
|
||||||
|
|
||||||
|
// Text inputs render in panel order: topic, payload, recipient,
|
||||||
|
// lifetime. Focus-then-type lands text in each field.
|
||||||
|
type_into(&mut harness, 0, "hi");
|
||||||
|
type_into(&mut harness, 1, "yo");
|
||||||
|
|
||||||
|
harness.get_by_label("Send").click();
|
||||||
|
settle(&mut harness);
|
||||||
|
|
||||||
|
wait_for(&fake, |req| {
|
||||||
|
matches!(
|
||||||
|
req,
|
||||||
|
Request::Observe {
|
||||||
|
topic,
|
||||||
|
payload,
|
||||||
|
to: None,
|
||||||
|
lifetime_ms: None,
|
||||||
|
} if topic == "6869" && payload == "796f"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addressed_fields_queue_observe_to() {
|
||||||
|
let dir = scratch_dir("addressed");
|
||||||
|
let fake = common::FakeService::start(dir.join("sock"), default_handler());
|
||||||
|
let mut harness = harness_for(&dir);
|
||||||
|
settle(&mut harness);
|
||||||
|
|
||||||
|
// topic (0), payload (1), recipient (2), lifetime (3).
|
||||||
|
for (idx, text) in [(0usize, "hi"), (1, "yo"), (2, "aa"), (3, "5000")] {
|
||||||
|
type_into(&mut harness, idx, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
harness.get_by_label("Send").click();
|
||||||
|
settle(&mut harness);
|
||||||
|
|
||||||
|
wait_for(&fake, |req| {
|
||||||
|
matches!(
|
||||||
|
req,
|
||||||
|
Request::Observe {
|
||||||
|
topic,
|
||||||
|
payload,
|
||||||
|
to: Some(to),
|
||||||
|
lifetime_ms: Some(5000),
|
||||||
|
} if topic == "6869" && payload == "796f" && to == "6161"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn feed_shows_contact() {
|
||||||
|
let dir = scratch_dir("feed");
|
||||||
|
let fake = common::FakeService::start(dir.join("sock"), default_handler());
|
||||||
|
let mut harness = harness_for(&dir);
|
||||||
|
settle(&mut harness);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
harness.get_all_by_label_contains("bb").count() >= 1,
|
||||||
|
"the events feed must show the contact's peer hex"
|
||||||
|
);
|
||||||
|
drop(fake);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user