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 launcher;
|
||||
pub mod theme;
|
||||
pub mod window;
|
||||
|
||||
+87
-12
@@ -1,15 +1,25 @@
|
||||
//! 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 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>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
match args.as_slice() {
|
||||
[_, flag, dir, id, mode] if flag == "--node" => {
|
||||
let _ = (dir, id, mode);
|
||||
eprintln!("tines-gui: node window is Task 8b — not wired yet");
|
||||
std::process::exit(1);
|
||||
let mode = match mode.as_str() {
|
||||
"bt" => Mode::Bt,
|
||||
"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()?,
|
||||
_ => {
|
||||
@@ -23,7 +33,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
/// The launcher window: renders via `Launcher::update`, then drains the
|
||||
/// action queue — the only place subprocess spawns happen.
|
||||
struct LauncherApp {
|
||||
launcher: tines_gui::launcher::Launcher,
|
||||
launcher: Launcher,
|
||||
theme: tines_gui::theme::OmarchyTheme,
|
||||
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()
|
||||
.with_app_id("tines-gui")
|
||||
.with_title("tines — fleet console")
|
||||
.with_inner_size([760.0, 520.0]);
|
||||
let options = eframe::NativeOptions {
|
||||
.with_title(title)
|
||||
.with_inner_size(size);
|
||||
eframe::NativeOptions {
|
||||
viewport,
|
||||
glow_options: eframe::egui_glow::GlowConfiguration {
|
||||
vsync: false,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn run_launcher() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
eframe::run_native(
|
||||
"tines-gui",
|
||||
options,
|
||||
options("tines — fleet console", [760.0, 520.0]),
|
||||
Box::new(|_cc| {
|
||||
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(),
|
||||
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::sync::OnceLock;
|
||||
|
||||
use tines::{Request, Response};
|
||||
|
||||
/// 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`.
|
||||
/// 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.
|
||||
#[allow(dead_code)]
|
||||
pub fn env_with_fake_busd() -> &'static PathBuf {
|
||||
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
@@ -36,3 +39,124 @@ pub fn env_with_fake_busd() -> &'static PathBuf {
|
||||
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