diff --git a/README.md b/README.md index 0d08654b..1c630b58 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ sudo loginctl enable-linger "$USER" mesh-llm serve --model Qwen2.5-32B # dashboard at http://localhost:3131 ``` -Live topology, per-node GPU capacity, model picker, and built-in chat. Everything comes from `/api/status` (JSON) and `/api/events` (SSE). +Live topology, per-node GPU capacity, model picker, and built-in chat. Live members show only the `Client`, `Standby`, `Loading`, and `Serving` badges. Wakeable provider-backed capacity is shown separately from topology and stays out of routing until it rejoins. Everything comes from `/api/status` (JSON) and `/api/events` (SSE). ## Multimodal Support @@ -500,7 +500,7 @@ When a node is running, open: http://localhost:3131 ``` -The console shows live topology, VRAM usage, loaded models, and built-in chat. It is backed by `/api/status` and `/api/events`. +The console shows live topology with only `Client`, `Standby`, `Loading`, and `Serving` badges for live members, plus separate wakeable capacity, VRAM usage, loaded models, and built-in chat. Wakeable inventory is not part of topology peers or routing until it rejoins. It is backed by `/api/status` and `/api/events`. You can also try the hosted demo: diff --git a/mesh-llm/docs/DESIGN.md b/mesh-llm/docs/DESIGN.md index b121fd4e..e8147350 100644 --- a/mesh-llm/docs/DESIGN.md +++ b/mesh-llm/docs/DESIGN.md @@ -39,7 +39,7 @@ src/ └── system/ Hardware detection, benchmarking, self-update ``` -## Node Roles +## Topology Roles ```rust enum NodeRole { @@ -49,7 +49,7 @@ enum NodeRole { } ``` -Roles are exchanged via gossip. Preferred peers use `meshllm.node.v1` protobuf on QUIC ALPN `mesh-llm/1`; legacy peers may still negotiate `mesh-llm/0` and use the older JSON gossip payloads. A node transitions Worker → Host when elected. +Roles are exchanged via gossip. Live-state badges are separate and use `Client`, `Standby`, `Loading`, and `Serving`. Preferred peers use `meshllm.node.v1` protobuf on QUIC ALPN `mesh-llm/1`; legacy peers may still negotiate `mesh-llm/0` and use the older JSON gossip payloads. A node transitions Worker → Host when elected. A newly connected peer is quarantined until it sends a valid `GossipFrame` with `gen = 1` (quarantine-until-gossip admission model). Only streams 0x01 (GOSSIP) and 0x05 (ROUTE_REQUEST) are accepted before admission. All other streams are rejected until the peer is admitted. diff --git a/mesh-llm/docs/TESTING.md b/mesh-llm/docs/TESTING.md index 6b1d6f21..d62ee884 100644 --- a/mesh-llm/docs/TESTING.md +++ b/mesh-llm/docs/TESTING.md @@ -162,7 +162,7 @@ mesh-llm serve --join ``` - Joiner scans the Hugging Face cache and picks an unserved model already on disk -- Log: "Assigned to serve GLM-4.7-Flash (needed by mesh, already on disk)" +- Log: "Selected to serve GLM-4.7-Flash (needed by mesh, already on disk)" ### 8. Lite client with multi-model @@ -217,6 +217,20 @@ curl -X DELETE localhost:3131/api/runtime/models/Llama-3.2-1B-Instruct-Q4_K_M - Switching models highlights the serving node in topology view - Chat routes to selected model via API proxy +### 11. Console live-state and wakeable capacity + +```bash +cd mesh-llm/ui/ +npm run test:run +npm run typecheck +just build +``` + +- Live badges show only `Client`, `Standby`, `Loading`, and `Serving` +- Wakeable capacity renders in a separate section from topology peers and live nodes +- Wakeable entries do not appear in the topology peer list +- Validation uses `npm run test:run`, `npm run typecheck`, and `just build` + ## Mesh Identity ### 16. Mesh ID generation (originator) diff --git a/mesh-llm/src/api/mod.rs b/mesh-llm/src/api/mod.rs index 4927c0a4..e49792db 100644 --- a/mesh-llm/src/api/mod.rs +++ b/mesh-llm/src/api/mod.rs @@ -32,13 +32,14 @@ use self::routes::dispatch_request; use self::state::ApiInner; use self::status::{ build_gpus, build_ownership_payload, build_runtime_processes_payload, - build_runtime_status_payload, LocalInstance, MeshModelPayload, PeerPayload, - RuntimeProcessesPayload, RuntimeStatusPayload, StatusPayload, + build_runtime_status_payload, LocalInstance, MeshModelPayload, NodeState, PeerPayload, + RuntimeProcessesPayload, RuntimeStatusPayload, StatusPayload, WakeableNode, WakeableNodeState, }; use crate::inference::election; use crate::mesh; use crate::network::{affinity, nostr, proxy}; use crate::plugin; +use crate::runtime::wakeable::{WakeableInventoryEntry, WakeableState}; use std::sync::Arc; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{watch, Mutex}; @@ -243,6 +244,7 @@ impl MeshApi { inventory_scan_running: false, inventory_scan_waiters: Vec::new(), local_instances: Arc::new(Mutex::new(Vec::new())), + wakeable_inventory: crate::runtime::wakeable::WakeableInventory::default(), })), } } @@ -699,29 +701,81 @@ impl MeshApi { .collect() } - fn derive_node_status( + fn derive_local_node_state( is_client: bool, effective_is_host: bool, effective_llama_ready: bool, has_local_worker_activity: bool, - has_split_workers: bool, display_model_name: &str, - peer_count: usize, - ) -> String { + ) -> NodeState { + let has_declared_local_serving_work = (effective_is_host || has_local_worker_activity) + && !display_model_name.trim().is_empty(); + if is_client { - "Client".to_string() - } else if effective_is_host && effective_llama_ready { - if has_split_workers { - "Serving (split)".to_string() - } else { - "Serving".to_string() - } - } else if has_local_worker_activity { - "Worker (split)".to_string() - } else if display_model_name.is_empty() && peer_count == 0 { - "Idle".to_string() + NodeState::Client + } else if effective_llama_ready && has_declared_local_serving_work { + NodeState::Serving + } else if has_declared_local_serving_work { + NodeState::Loading } else { - "Standby".to_string() + NodeState::Standby + } + } + + fn derive_node_status(node_state: NodeState) -> String { + node_state.node_status_alias().to_string() + } + + fn derive_peer_state(peer: &mesh::PeerInfo) -> NodeState { + fn has_nonempty_models(models: &[String]) -> bool { + models.iter().any(|model| !model.trim().is_empty()) + } + + match peer.role { + mesh::NodeRole::Client => NodeState::Client, + mesh::NodeRole::Host { .. } | mesh::NodeRole::Worker => { + let has_runtime_descriptors = peer + .served_model_runtime + .iter() + .any(|runtime| !runtime.model_name.trim().is_empty()); + let has_ready_runtime = peer + .served_model_runtime + .iter() + .any(|runtime| runtime.ready && !runtime.model_name.trim().is_empty()); + let has_assigned_model_work = has_runtime_descriptors + || has_nonempty_models(&peer.serving_models) + || has_nonempty_models(&peer.hosted_models); + let has_legacy_serving_signal = has_nonempty_models(&peer.hosted_models) + || has_nonempty_models(&peer.serving_models) + || peer + .routable_models() + .iter() + .any(|model| !model.trim().is_empty()); + + if has_ready_runtime { + NodeState::Serving + } else if has_runtime_descriptors && has_assigned_model_work { + NodeState::Loading + } else if has_legacy_serving_signal { + NodeState::Serving + } else { + NodeState::Standby + } + } + } + } + + fn build_wakeable_node(entry: WakeableInventoryEntry) -> WakeableNode { + WakeableNode { + logical_id: entry.logical_id, + models: entry.models, + vram_gb: entry.vram_gb, + provider: entry.provider, + state: match entry.state { + WakeableState::Sleeping => WakeableNodeState::Sleeping, + WakeableState::Waking => WakeableNodeState::Waking, + }, + wake_eta_secs: entry.wake_eta_secs, } } @@ -748,6 +802,7 @@ impl MeshApi { nostr_discovery, local_processes, local_instances_arc, + wakeable_inventory, ) = { let inner = self.inner.lock().await; ( @@ -769,6 +824,7 @@ impl MeshApi { inner.nostr_discovery, inner.local_processes.clone(), inner.local_instances.clone(), + inner.wakeable_inventory.clone(), ) }; // inner lock dropped here @@ -801,6 +857,13 @@ impl MeshApi { instances }; + let wakeable_nodes = wakeable_inventory + .status_snapshot() + .await + .into_iter() + .map(Self::build_wakeable_node) + .collect(); + let all_peers = node.peers().await; let local_owner_summary = node.owner_summary().await; let my_models = node.models().await; @@ -816,6 +879,7 @@ impl MeshApi { mesh::NodeRole::Host { .. } => "Host".into(), mesh::NodeRole::Client => "Client".into(), }, + state: Self::derive_peer_state(p), models: p.models.clone(), available_models: p.available_models.clone(), requested_models: p.requested_models.clone(), @@ -862,19 +926,14 @@ impl MeshApi { let mesh_id = node.mesh_id().await; let has_local_worker_activity = has_local_processes || !my_hosted_models.is_empty(); - let has_split_workers = all_peers.iter().any(|p| { - matches!(p.role, mesh::NodeRole::Worker) - && p.is_assigned_model(display_model_name.as_str()) - }); - let node_status = Self::derive_node_status( + let node_state = Self::derive_local_node_state( is_client, effective_is_host, effective_llama_ready, has_local_worker_activity, - has_split_workers, display_model_name.as_str(), - all_peers.len(), ); + let node_status = Self::derive_node_status(node_state); StatusPayload { version: MESH_LLM_VERSION.to_string(), @@ -882,6 +941,7 @@ impl MeshApi { node_id, owner: build_ownership_payload(&local_owner_summary), token, + node_state, node_status, is_host: effective_is_host, is_client, @@ -897,6 +957,7 @@ impl MeshApi { my_vram_gb, model_size_gb: model_size_bytes as f64 / 1e9, peers, + wakeable_nodes, local_instances, launch_pi, launch_goose, @@ -1146,6 +1207,7 @@ mod tests { use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; + use std::time::Instant; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{mpsc, oneshot}; @@ -1534,33 +1596,210 @@ mod tests { } #[test] - fn test_derive_node_status_prefers_client_role() { - let status = MeshApi::derive_node_status(true, true, true, true, true, "Qwen", 2); - assert_eq!(status, "Client"); + fn derive_local_node_state_prefers_client() { + let node_state = MeshApi::derive_local_node_state(true, true, true, true, "Qwen"); + + assert_eq!(node_state, NodeState::Client); + assert_eq!(MeshApi::derive_node_status(node_state), "Client"); + } + + #[test] + fn derive_local_node_state_returns_standby_without_ready_runtime() { + let node_state = MeshApi::derive_local_node_state(false, false, false, false, "Qwen"); + + assert_eq!(node_state, NodeState::Standby); + assert_eq!(MeshApi::derive_node_status(node_state), "Standby"); + } + + #[test] + fn derive_local_node_state_returns_loading_for_declared_but_unready_work() { + let host_loading = MeshApi::derive_local_node_state(false, true, false, false, "Qwen"); + let worker_loading = MeshApi::derive_local_node_state(false, false, false, true, "Qwen"); + + assert_eq!(host_loading, NodeState::Loading); + assert_eq!(worker_loading, NodeState::Loading); + assert_eq!(MeshApi::derive_node_status(host_loading), "Loading"); + assert_eq!(MeshApi::derive_node_status(worker_loading), "Loading"); + } + + #[test] + fn derive_local_node_state_returns_serving_for_ready_runtime() { + let host_serving = MeshApi::derive_local_node_state(false, true, true, false, "Qwen"); + let worker_serving = MeshApi::derive_local_node_state(false, false, true, true, "Qwen"); + + assert_eq!(host_serving, NodeState::Serving); + assert_eq!(worker_serving, NodeState::Serving); + assert_eq!(MeshApi::derive_node_status(host_serving), "Serving"); + assert_eq!(MeshApi::derive_node_status(worker_serving), "Serving"); + } + + #[test] + fn derive_local_node_state_never_emits_legacy_idle_or_split_labels() { + let labels = [ + MeshApi::derive_node_status(MeshApi::derive_local_node_state( + true, true, true, true, "Qwen", + )), + MeshApi::derive_node_status(MeshApi::derive_local_node_state( + false, false, false, false, "Qwen", + )), + MeshApi::derive_node_status(MeshApi::derive_local_node_state( + false, true, false, false, "Qwen", + )), + MeshApi::derive_node_status(MeshApi::derive_local_node_state( + false, false, true, true, "Qwen", + )), + MeshApi::derive_node_status(MeshApi::derive_local_node_state( + false, false, false, false, "", + )), + ]; + + for label in labels { + assert!(matches!( + label.as_str(), + "Client" | "Standby" | "Loading" | "Serving" + )); + assert_ne!(label, "Idle"); + assert_ne!(label, "Serving (split)"); + assert_ne!(label, "Worker (split)"); + } + } + + fn make_test_state_endpoint_id(seed: u8) -> iroh::EndpointId { + let mut bytes = [0u8; 32]; + bytes[0] = seed; + iroh::EndpointId::from(iroh::SecretKey::from_bytes(&bytes).public()) + } + + fn make_test_state_peer(seed: u8, role: mesh::NodeRole) -> mesh::PeerInfo { + let id = make_test_state_endpoint_id(seed); + mesh::PeerInfo { + id, + addr: iroh::EndpointAddr { + id, + addrs: Default::default(), + }, + tunnel_port: None, + role, + models: vec![], + vram_bytes: 0, + rtt_ms: None, + model_source: None, + serving_models: vec![], + hosted_models: vec![], + hosted_models_known: false, + available_models: vec![], + requested_models: vec![], + last_seen: Instant::now(), + last_mentioned: Instant::now(), + moe_recovered_at: None, + version: None, + gpu_name: None, + hostname: None, + is_soc: None, + gpu_vram: None, + gpu_reserved_bytes: None, + gpu_mem_bandwidth_gbps: None, + gpu_compute_tflops_fp32: None, + gpu_compute_tflops_fp16: None, + available_model_metadata: vec![], + experts_summary: None, + available_model_sizes: HashMap::new(), + served_model_descriptors: vec![], + served_model_runtime: vec![], + owner_attestation: None, + owner_summary: crate::crypto::OwnershipSummary::default(), + } + } + + fn make_legacy_peer_fixture( + seed: u8, + role: mesh::NodeRole, + serving_models: Vec<&str>, + ) -> mesh::PeerInfo { + let mut peer = make_test_state_peer(seed, role); + peer.version = Some("0.54.0".into()); + peer.serving_models = serving_models.into_iter().map(str::to_string).collect(); + peer.hosted_models = vec![]; + peer.hosted_models_known = false; + peer.served_model_runtime = vec![]; + peer + } + + #[test] + fn derive_peer_state_prefers_client_role() { + let mut peer = make_test_state_peer(1, mesh::NodeRole::Client); + peer.serving_models = vec!["Qwen".into()]; + peer.hosted_models = vec!["Qwen".into()]; + peer.hosted_models_known = true; + peer.served_model_runtime = vec![mesh::ModelRuntimeDescriptor { + model_name: "Qwen".into(), + identity_hash: None, + context_length: Some(8192), + ready: true, + }]; + + assert_eq!(MeshApi::derive_peer_state(&peer), NodeState::Client); } #[test] - fn test_derive_node_status_standby_when_only_declaring_models() { - let status = MeshApi::derive_node_status(false, false, false, false, false, "Qwen", 1); - assert_eq!(status, "Standby"); + fn derive_peer_state_returns_serving_for_ready_runtime() { + let mut peer = make_test_state_peer(2, mesh::NodeRole::Host { http_port: 9337 }); + peer.serving_models = vec!["Qwen".into()]; + peer.hosted_models = vec!["Qwen".into()]; + peer.hosted_models_known = true; + peer.served_model_runtime = vec![mesh::ModelRuntimeDescriptor { + model_name: "Qwen".into(), + identity_hash: None, + context_length: Some(8192), + ready: true, + }]; + + assert_eq!(MeshApi::derive_peer_state(&peer), NodeState::Serving); } #[test] - fn test_derive_node_status_worker_requires_local_runtime_activity() { - let status = MeshApi::derive_node_status(false, false, false, true, false, "Qwen", 1); - assert_eq!(status, "Worker (split)"); + fn derive_peer_state_returns_loading_for_assigned_but_unready_peer() { + let mut peer = make_test_state_peer(3, mesh::NodeRole::Worker); + peer.serving_models = vec!["Qwen".into()]; + peer.served_model_runtime = vec![mesh::ModelRuntimeDescriptor { + model_name: "Qwen".into(), + identity_hash: None, + context_length: None, + ready: false, + }]; + + assert_eq!(MeshApi::derive_peer_state(&peer), NodeState::Loading); } #[test] - fn test_derive_node_status_marks_split_host() { - let status = MeshApi::derive_node_status(false, true, true, true, true, "Qwen", 1); - assert_eq!(status, "Serving (split)"); + fn derive_peer_state_returns_standby_for_connected_idle_peer() { + let peer = make_test_state_peer(4, mesh::NodeRole::Worker); + + assert_eq!(MeshApi::derive_peer_state(&peer), NodeState::Standby); } #[test] - fn test_derive_node_status_idle_without_model_or_peers() { - let status = MeshApi::derive_node_status(false, false, false, false, false, "", 0); - assert_eq!(status, "Idle"); + fn derive_peer_state_falls_back_to_legacy_serving_models() { + let mut peer = make_test_state_peer(5, mesh::NodeRole::Worker); + peer.serving_models = vec!["Qwen".into()]; + + assert_eq!(MeshApi::derive_peer_state(&peer), NodeState::Serving); + } + + #[test] + fn legacy_peer_fixture_uses_backend_state_fallback() { + let serving_peer = + make_legacy_peer_fixture(6, mesh::NodeRole::Host { http_port: 9337 }, vec!["Qwen"]); + let standby_peer = make_legacy_peer_fixture(7, mesh::NodeRole::Worker, vec![]); + + assert_eq!( + MeshApi::derive_peer_state(&serving_peer), + NodeState::Serving + ); + assert_eq!( + MeshApi::derive_peer_state(&standby_peer), + NodeState::Standby + ); } #[test] @@ -1661,6 +1900,29 @@ mod tests { serde_json::from_str(body).unwrap_or(serde_json::Value::Null) } + async fn replace_test_wakeable_inventory( + state: &MeshApi, + entries: Vec, + ) { + let inventory = { state.inner.lock().await.wakeable_inventory.clone() }; + inventory.replace_for_tests(entries).await; + } + + fn make_test_wakeable_entry( + logical_id: &str, + model: &str, + vram_gb: f32, + ) -> WakeableInventoryEntry { + WakeableInventoryEntry { + logical_id: logical_id.to_string(), + models: vec![model.to_string()], + vram_gb, + provider: Some("test-provider".to_string()), + state: WakeableState::Sleeping, + wake_eta_secs: Some(45), + } + } + fn make_test_peer( seed: u8, role: mesh::NodeRole, @@ -2180,6 +2442,142 @@ mod tests { assert_eq!(worker_stats, HttpRouteStats::default()); } + #[tokio::test] + async fn wakeable_inventory_does_not_change_peer_count() { + let state = build_test_mesh_api().await; + replace_test_wakeable_inventory( + &state, + vec![make_test_wakeable_entry( + "sleeping-node-1", + "wakeable-only-model", + 48.0, + )], + ) + .await; + + let status = state.status().await; + assert!(status.peers.is_empty()); + assert_eq!(status.wakeable_nodes.len(), 1); + assert_eq!(status.wakeable_nodes[0].logical_id, "sleeping-node-1"); + } + + #[tokio::test] + async fn wakeable_inventory_does_not_change_mesh_vram_totals() { + let state = build_test_mesh_api().await; + replace_test_wakeable_inventory( + &state, + vec![make_test_wakeable_entry( + "sleeping-node-1", + "wakeable-only-model", + 48.0, + )], + ) + .await; + + let status = state.status().await; + let peers = vec![make_test_peer( + 0x51, + mesh::NodeRole::Host { http_port: 9337 }, + vec!["wakeable-only-model"], + vec!["wakeable-only-model"], + true, + )]; + let route_stats = http_route_stats("wakeable-only-model", &peers, &[], None, 0.0); + + assert_eq!(status.wakeable_nodes.len(), 1); + assert_eq!(route_stats.node_count, 1); + assert!(route_stats.mesh_vram_gb > 0.0); + } + + #[tokio::test] + async fn wakeable_inventory_is_not_routable_capacity() { + let state = build_test_mesh_api().await; + replace_test_wakeable_inventory( + &state, + vec![make_test_wakeable_entry( + "sleeping-node-1", + "wakeable-only-model", + 48.0, + )], + ) + .await; + + let node = { state.inner.lock().await.node.clone() }; + let status = state.status().await; + let served_models = node.models_being_served().await; + let hosts = node.hosts_for_model("wakeable-only-model").await; + + assert_eq!(status.wakeable_nodes.len(), 1); + assert!(!served_models + .iter() + .any(|model| model == "wakeable-only-model")); + assert!(hosts.is_empty()); + } + + #[tokio::test] + async fn wakeable_inventory_is_excluded_from_v1_models() { + let state = build_test_mesh_api().await; + replace_test_wakeable_inventory( + &state, + vec![make_test_wakeable_entry( + "sleeping-node-1", + "wakeable-only-model", + 48.0, + )], + ) + .await; + + let node = { state.inner.lock().await.node.clone() }; + let served_models = node.models_being_served().await; + + assert!(!served_models + .iter() + .any(|model| model == "wakeable-only-model")); + assert!(served_models.is_empty()); + } + + #[tokio::test] + async fn wakeable_inventory_is_excluded_from_host_selection() { + let state = build_test_mesh_api().await; + replace_test_wakeable_inventory( + &state, + vec![make_test_wakeable_entry( + "sleeping-node-1", + "wakeable-only-model", + 48.0, + )], + ) + .await; + + let node = { state.inner.lock().await.node.clone() }; + let hosts = node.hosts_for_model("wakeable-only-model").await; + + assert!(hosts.is_empty()); + } + + #[test] + fn build_wakeable_node_preserves_typed_internal_state() { + let sleeping = MeshApi::build_wakeable_node(WakeableInventoryEntry { + logical_id: "sleeping-node".to_string(), + models: vec!["test-model".to_string()], + vram_gb: 24.0, + provider: Some("test-provider".to_string()), + state: WakeableState::Sleeping, + wake_eta_secs: Some(45), + }); + let waking = MeshApi::build_wakeable_node(WakeableInventoryEntry { + logical_id: "waking-node".to_string(), + models: vec!["test-model".to_string()], + vram_gb: 24.0, + provider: Some("test-provider".to_string()), + state: WakeableState::Waking, + wake_eta_secs: Some(10), + }); + + assert_eq!(sleeping.state, WakeableNodeState::Sleeping); + assert_eq!(waking.state, WakeableNodeState::Waking); + } + #[tokio::test] async fn test_api_status_includes_local_gpu_benchmark_metrics() { let state = build_test_mesh_api().await; diff --git a/mesh-llm/src/api/state.rs b/mesh-llm/src/api/state.rs index 56182dad..32957120 100644 --- a/mesh-llm/src/api/state.rs +++ b/mesh-llm/src/api/state.rs @@ -62,4 +62,5 @@ pub(super) struct ApiInner { pub(super) inventory_scan_waiters: Vec>, pub(super) local_instances: Arc>>, + pub(super) wakeable_inventory: crate::runtime::wakeable::WakeableInventory, } diff --git a/mesh-llm/src/api/status.rs b/mesh-llm/src/api/status.rs index d6ce02ca..5092512b 100644 --- a/mesh-llm/src/api/status.rs +++ b/mesh-llm/src/api/status.rs @@ -4,6 +4,33 @@ use crate::network::affinity; use crate::system::hardware::expand_gpu_names; use serde::Serialize; +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(super) enum NodeState { + Client, + Standby, + Loading, + Serving, +} + +impl NodeState { + pub(super) const fn node_status_alias(self) -> &'static str { + match self { + Self::Client => "Client", + Self::Standby => "Standby", + Self::Loading => "Loading", + Self::Serving => "Serving", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(super) enum WakeableNodeState { + Sleeping, + Waking, +} + #[derive(Serialize)] pub(super) struct RuntimeStatusPayload { pub(super) models: Vec, @@ -112,6 +139,7 @@ pub(super) struct StatusPayload { pub(super) node_id: String, pub(super) owner: OwnershipPayload, pub(super) token: String, + pub(super) node_state: NodeState, pub(super) node_status: String, pub(super) is_host: bool, pub(super) is_client: bool, @@ -127,6 +155,7 @@ pub(super) struct StatusPayload { pub(super) my_vram_gb: f64, pub(super) model_size_gb: f64, pub(super) peers: Vec, + pub(super) wakeable_nodes: Vec, pub(super) local_instances: Vec, pub(super) launch_pi: Option, pub(super) launch_goose: Option, @@ -142,11 +171,24 @@ pub(super) struct StatusPayload { pub(super) routing_affinity: affinity::AffinityStatsSnapshot, } +#[derive(Clone, Debug, PartialEq, Serialize)] +pub(super) struct WakeableNode { + pub(super) logical_id: String, + pub(super) models: Vec, + pub(super) vram_gb: f32, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) provider: Option, + pub(super) state: WakeableNodeState, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) wake_eta_secs: Option, +} + #[derive(Serialize)] pub(super) struct PeerPayload { pub(super) id: String, pub(super) owner: OwnershipPayload, pub(super) role: String, + pub(super) state: NodeState, pub(super) models: Vec, pub(super) available_models: Vec, pub(super) requested_models: Vec, @@ -390,6 +432,7 @@ mod tests { id: "test-id".to_string(), owner: test_owner_payload(), role: "Worker".to_string(), + state: NodeState::Standby, models: vec![], available_models: vec![], requested_models: vec![], @@ -414,6 +457,7 @@ mod tests { id: "test-id".to_string(), owner: test_owner_payload(), role: "Worker".to_string(), + state: NodeState::Standby, models: vec![], available_models: vec![], requested_models: vec![], @@ -439,6 +483,214 @@ mod tests { assert_eq!(json, "[]"); } + #[test] + fn status_payload_serializes_node_state_and_node_status_alias() { + let status = StatusPayload { + version: "0.60.2".to_string(), + latest_version: None, + node_id: "node-1".to_string(), + owner: test_owner_payload(), + token: "token-1".to_string(), + node_state: NodeState::Loading, + node_status: NodeState::Loading.node_status_alias().to_string(), + is_host: true, + is_client: false, + llama_ready: false, + model_name: "Qwen".to_string(), + models: vec![], + available_models: vec![], + requested_models: vec![], + serving_models: vec![], + hosted_models: vec![], + draft_name: None, + api_port: 3131, + my_vram_gb: 0.0, + model_size_gb: 0.0, + peers: vec![], + wakeable_nodes: vec![], + local_instances: vec![], + launch_pi: None, + launch_goose: None, + inflight_requests: 0, + mesh_id: None, + mesh_name: None, + nostr_discovery: false, + my_hostname: None, + my_is_soc: None, + gpus: vec![], + routing_affinity: affinity::AffinityStatsSnapshot::default(), + }; + + let json = serde_json::to_string(&status).expect("serialization failed"); + assert!(json.contains("\"node_state\":\"loading\"")); + assert!(json.contains("\"node_status\":\"Loading\"")); + } + + #[test] + fn status_payload_keeps_node_status_for_compatibility() { + let status = StatusPayload { + version: "0.60.2".to_string(), + latest_version: None, + node_id: "node-1".to_string(), + owner: test_owner_payload(), + token: "token-1".to_string(), + node_state: NodeState::Serving, + node_status: NodeState::Serving.node_status_alias().to_string(), + is_host: true, + is_client: false, + llama_ready: true, + model_name: "Qwen".to_string(), + models: vec!["Qwen".to_string()], + available_models: vec!["Qwen".to_string()], + requested_models: vec!["Qwen".to_string()], + serving_models: vec!["Qwen".to_string()], + hosted_models: vec!["Qwen".to_string()], + draft_name: None, + api_port: 3131, + my_vram_gb: 24.0, + model_size_gb: 4.0, + peers: vec![], + wakeable_nodes: vec![], + local_instances: vec![], + launch_pi: None, + launch_goose: None, + inflight_requests: 0, + mesh_id: None, + mesh_name: None, + nostr_discovery: false, + my_hostname: None, + my_is_soc: None, + gpus: vec![], + routing_affinity: affinity::AffinityStatsSnapshot::default(), + }; + + let json = serde_json::to_value(&status).expect("serialization failed"); + assert_eq!(json["node_state"], "serving"); + assert_eq!(json["node_status"], "Serving"); + assert!(json.get("node_status").is_some()); + } + + #[test] + fn status_payload_serializes_wakeable_nodes_separately() { + let status = StatusPayload { + version: "0.60.2".to_string(), + latest_version: None, + node_id: "node-1".to_string(), + owner: test_owner_payload(), + token: "token-1".to_string(), + node_state: NodeState::Standby, + node_status: NodeState::Standby.node_status_alias().to_string(), + is_host: false, + is_client: false, + llama_ready: false, + model_name: String::new(), + models: vec![], + available_models: vec![], + requested_models: vec![], + serving_models: vec![], + hosted_models: vec![], + draft_name: None, + api_port: 3131, + my_vram_gb: 0.0, + model_size_gb: 0.0, + peers: vec![], + wakeable_nodes: vec![WakeableNode { + logical_id: "provider-node-1".to_string(), + models: vec!["Qwen".to_string()], + vram_gb: 24.0, + provider: Some("fly".to_string()), + state: WakeableNodeState::Sleeping, + wake_eta_secs: Some(90), + }], + local_instances: vec![], + launch_pi: None, + launch_goose: None, + inflight_requests: 0, + mesh_id: None, + mesh_name: None, + nostr_discovery: false, + my_hostname: None, + my_is_soc: None, + gpus: vec![], + routing_affinity: affinity::AffinityStatsSnapshot::default(), + }; + + let json = serde_json::to_value(&status).expect("serialization failed"); + assert_eq!(json["peers"], serde_json::json!([])); + assert_eq!(json["wakeable_nodes"].as_array().map(Vec::len), Some(1)); + assert_eq!(json["wakeable_nodes"][0]["state"], "sleeping"); + assert_eq!(json["wakeable_nodes"][0]["logical_id"], "provider-node-1"); + } + + #[test] + fn status_payload_defaults_to_empty_wakeable_inventory() { + let status = StatusPayload { + version: "0.60.2".to_string(), + latest_version: None, + node_id: "node-1".to_string(), + owner: test_owner_payload(), + token: "token-1".to_string(), + node_state: NodeState::Standby, + node_status: NodeState::Standby.node_status_alias().to_string(), + is_host: false, + is_client: false, + llama_ready: false, + model_name: String::new(), + models: vec![], + available_models: vec![], + requested_models: vec![], + serving_models: vec![], + hosted_models: vec![], + draft_name: None, + api_port: 3131, + my_vram_gb: 0.0, + model_size_gb: 0.0, + peers: vec![], + wakeable_nodes: vec![], + local_instances: vec![], + launch_pi: None, + launch_goose: None, + inflight_requests: 0, + mesh_id: None, + mesh_name: None, + nostr_discovery: false, + my_hostname: None, + my_is_soc: None, + gpus: vec![], + routing_affinity: affinity::AffinityStatsSnapshot::default(), + }; + + let json = serde_json::to_value(&status).expect("serialization failed"); + assert_eq!(json["wakeable_nodes"], serde_json::json!([])); + assert_eq!(json["peers"], serde_json::json!([])); + } + + #[test] + fn peer_status_serializes_state_without_mutating_role() { + let peer = PeerPayload { + id: "test-id".to_string(), + owner: test_owner_payload(), + role: "Host".to_string(), + state: NodeState::Serving, + models: vec![], + available_models: vec![], + requested_models: vec![], + vram_gb: 8.0, + serving_models: vec!["Qwen".to_string()], + hosted_models: vec!["Qwen".to_string()], + hosted_models_known: true, + version: Some("0.60.2".to_string()), + rtt_ms: Some(12), + hostname: Some("peer.local".to_string()), + is_soc: Some(false), + gpus: vec![], + }; + + let json = serde_json::to_string(&peer).expect("serialization failed"); + assert!(json.contains("\"role\":\"Host\"")); + assert!(json.contains("\"state\":\"serving\"")); + } + #[test] fn test_local_instance_serializes_is_self() { let instance = LocalInstance { diff --git a/mesh-llm/src/network/openai/ingress.rs b/mesh-llm/src/network/openai/ingress.rs index 55593649..328e2da5 100644 --- a/mesh-llm/src/network/openai/ingress.rs +++ b/mesh-llm/src/network/openai/ingress.rs @@ -186,7 +186,7 @@ pub(crate) async fn api_proxy( let use_pipeline = classification .as_ref() - .map(|cl| pipeline::should_pipeline(cl)) + .map(pipeline::should_pipeline) .unwrap_or(false) && request.response_adapter == proxy::ResponseAdapter::None; @@ -269,7 +269,6 @@ pub(crate) async fn api_proxy( &targets, name, &session_hint, - request.body_json.as_ref(), required_tokens, &request.raw, ) @@ -339,10 +338,8 @@ pub(crate) async fn api_proxy( tcp_stream, &targets, name, - request.body_json.as_ref(), + &request, required_tokens, - &request.raw, - request.response_adapter, &affinity, ) .await; @@ -371,7 +368,6 @@ pub(crate) async fn api_proxy( } Err(err) => { let _ = proxy::send_400(tcp_stream, &err.to_string()).await; - return; } }; }); @@ -436,12 +432,12 @@ pub(crate) fn callable_models(targets: &election::ModelTargets) -> Vec { let mut models: Vec = targets .targets .iter() - .filter_map(|(name, hosts)| { + .filter(|(_, hosts)| { hosts .iter() .any(|target| !matches!(target, election::InferenceTarget::None)) - .then(|| name.clone()) }) + .map(|(name, _)| name.clone()) .collect(); models.sort(); models diff --git a/mesh-llm/src/network/openai/transport.rs b/mesh-llm/src/network/openai/transport.rs index 4ee54e3d..01c2114b 100644 --- a/mesh-llm/src/network/openai/transport.rs +++ b/mesh-llm/src/network/openai/transport.rs @@ -1746,7 +1746,7 @@ pub async fn handle_mesh_request( if request.model_name.is_none() || request.model_name.as_deref() == Some("auto") { request.ensure_body_json(); if let Some(body_json) = request.body_json.as_ref() { - let cl = router::classify(&body_json); + let cl = router::classify(body_json); let served = node.models_being_served().await; let media = router::media_requirements(body_json); let available: Vec<(&str, f64)> = served @@ -2008,10 +2008,8 @@ pub async fn route_model_request( tcp_stream: TcpStream, targets: &election::ModelTargets, model: &str, - parsed_body: Option<&serde_json::Value>, + request: &BufferedHttpRequest, required_tokens: Option, - prefetched: &[u8], - response_adapter: ResponseAdapter, affinity: &AffinityRouter, ) -> bool { let route_started = Instant::now(); @@ -2026,7 +2024,7 @@ pub async fn route_model_request( targets, &ordered_candidates, model, - parsed_body, + request.body_json.as_ref(), affinity, ); if matches!(selection.target, election::InferenceTarget::None) { @@ -2075,9 +2073,9 @@ pub async fn route_model_request( &node, &mut tcp_stream, &target, - prefetched, + &request.raw, retry_context_overflow, - response_adapter, + request.response_adapter, ) .await; tracing::info!( @@ -2167,7 +2165,6 @@ pub async fn route_moe_request( targets: &election::ModelTargets, model: &str, session_hint: &str, - _parsed_body: Option<&serde_json::Value>, required_tokens: Option, prefetched: &[u8], ) -> bool { diff --git a/mesh-llm/src/protocol/convert.rs b/mesh-llm/src/protocol/convert.rs index 1fe036eb..e5da0ba4 100644 --- a/mesh-llm/src/protocol/convert.rs +++ b/mesh-llm/src/protocol/convert.rs @@ -286,16 +286,16 @@ fn local_hardware_info_to_proto( } } -fn proto_gpu_info_to_legacy_fields( - gpus: &[crate::proto::node::GpuInfo], -) -> ( - Option, - Option, - Option, - Option, - Option, - Option, -) { +struct LegacyGpuFields { + gpu_name: Option, + gpu_vram: Option, + gpu_reserved_bytes: Option, + gpu_mem_bandwidth_gbps: Option, + gpu_compute_tflops_fp32: Option, + gpu_compute_tflops_fp16: Option, +} + +fn proto_gpu_info_to_legacy_fields(gpus: &[crate::proto::node::GpuInfo]) -> LegacyGpuFields { let names: Vec = gpus.iter().filter_map(|gpu| gpu.name.clone()).collect(); let gpu_name = crate::system::hardware::summarize_gpu_name(&names); let gpu_vram = join_optional_csv( @@ -329,14 +329,14 @@ fn proto_gpu_info_to_legacy_fields( .collect::>(), ); - ( + LegacyGpuFields { gpu_name, gpu_vram, gpu_reserved_bytes, gpu_mem_bandwidth_gbps, gpu_compute_tflops_fp32, gpu_compute_tflops_fp16, - ) + } } /// Returns `true` when a proto descriptor carries a non-empty model name. @@ -535,14 +535,7 @@ pub(crate) fn proto_ann_to_local( .unwrap_or(!pa.hosted_models.is_empty()) .then(|| pa.hosted_models.clone()); let hardware = pa.hardware.as_ref(); - let ( - gpu_name_from_gpus, - gpu_vram_from_gpus, - gpu_reserved_from_gpus, - gpu_mem_bandwidth_from_gpus, - gpu_fp32_from_gpus, - gpu_fp16_from_gpus, - ) = proto_gpu_info_to_legacy_fields( + let legacy_gpu_fields = proto_gpu_info_to_legacy_fields( hardware .map(|hardware| hardware.gpus.as_slice()) .unwrap_or(&[]), @@ -560,17 +553,24 @@ pub(crate) fn proto_ann_to_local( version: pa.version.clone(), model_demand, mesh_id: pa.mesh_id.clone(), - gpu_name: gpu_name_from_gpus.or_else(|| pa.gpu_name.clone()), + gpu_name: legacy_gpu_fields.gpu_name.or_else(|| pa.gpu_name.clone()), hostname: hardware .and_then(|hardware| hardware.hostname.clone()) .or_else(|| pa.hostname.clone()), is_soc: hardware.and_then(|hardware| hardware.is_soc).or(pa.is_soc), - gpu_vram: gpu_vram_from_gpus.or_else(|| pa.gpu_vram.clone()), - gpu_reserved_bytes: gpu_reserved_from_gpus.or_else(|| pa.gpu_reserved_bytes.clone()), - gpu_mem_bandwidth_gbps: gpu_mem_bandwidth_from_gpus + gpu_vram: legacy_gpu_fields.gpu_vram.or_else(|| pa.gpu_vram.clone()), + gpu_reserved_bytes: legacy_gpu_fields + .gpu_reserved_bytes + .or_else(|| pa.gpu_reserved_bytes.clone()), + gpu_mem_bandwidth_gbps: legacy_gpu_fields + .gpu_mem_bandwidth_gbps .or_else(|| pa.gpu_mem_bandwidth_gbps.clone()), - gpu_compute_tflops_fp32: gpu_fp32_from_gpus.or_else(|| pa.gpu_compute_tflops_fp32.clone()), - gpu_compute_tflops_fp16: gpu_fp16_from_gpus.or_else(|| pa.gpu_compute_tflops_fp16.clone()), + gpu_compute_tflops_fp32: legacy_gpu_fields + .gpu_compute_tflops_fp32 + .or_else(|| pa.gpu_compute_tflops_fp32.clone()), + gpu_compute_tflops_fp16: legacy_gpu_fields + .gpu_compute_tflops_fp16 + .or_else(|| pa.gpu_compute_tflops_fp16.clone()), available_model_metadata: Vec::new(), experts_summary: pa.experts_summary.clone(), available_model_sizes: HashMap::new(), diff --git a/mesh-llm/src/runtime/mod.rs b/mesh-llm/src/runtime/mod.rs index 29ff11bd..8f9ac38d 100644 --- a/mesh-llm/src/runtime/mod.rs +++ b/mesh-llm/src/runtime/mod.rs @@ -3,6 +3,7 @@ mod discovery; pub mod instance; mod local; mod proxy; +pub(crate) mod wakeable; use self::discovery::{nostr_rediscovery, start_new_mesh}; use self::local::{ diff --git a/mesh-llm/src/runtime/wakeable.rs b/mesh-llm/src/runtime/wakeable.rs new file mode 100644 index 00000000..b06a0d6d --- /dev/null +++ b/mesh-llm/src/runtime/wakeable.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use tokio::sync::RwLock; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum WakeableState { + Sleeping, + Waking, +} + +impl TryFrom<&str> for WakeableState { + type Error = String; + + fn try_from(value: &str) -> Result { + match value { + "sleeping" => Ok(Self::Sleeping), + "waking" => Ok(Self::Waking), + other => Err(format!("unsupported wakeable state: {other}")), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct WakeableInventoryEntry { + pub(crate) logical_id: String, + pub(crate) models: Vec, + pub(crate) vram_gb: f32, + pub(crate) provider: Option, + pub(crate) state: WakeableState, + pub(crate) wake_eta_secs: Option, +} + +#[derive(Clone, Default)] +pub(crate) struct WakeableInventory { + entries: Arc>>, +} + +impl WakeableInventory { + pub(crate) async fn status_snapshot(&self) -> Vec { + self.entries.read().await.clone() + } + + #[cfg(test)] + pub(crate) async fn replace_for_tests(&self, entries: Vec) { + *self.entries.write().await = entries; + } +} + +#[cfg(test)] +mod tests { + use super::WakeableState; + + #[test] + fn wakeable_state_rejects_unknown_values() { + assert_eq!( + WakeableState::try_from("sleeping"), + Ok(WakeableState::Sleeping) + ); + assert_eq!(WakeableState::try_from("waking"), Ok(WakeableState::Waking)); + assert_eq!( + WakeableState::try_from("invalid-state"), + Err("unsupported wakeable state: invalid-state".to_string()) + ); + } +} diff --git a/mesh-llm/ui/src/App.test.tsx b/mesh-llm/ui/src/App.test.tsx index baa1bc81..5c0664da 100644 --- a/mesh-llm/ui/src/App.test.tsx +++ b/mesh-llm/ui/src/App.test.tsx @@ -8,6 +8,7 @@ import { describeImageAttachmentForPrompt, describeRenderedPagesAsText, } from "./App"; +import type { StatusPayload } from "./features/app-shell/lib/status-types"; function buildProps( overrides: Partial[0]> = {}, @@ -16,7 +17,8 @@ function buildProps( status: { node_id: "node-1", token: "invite-token", - node_status: "host", + node_state: "serving", + node_status: "Serving", is_host: true, is_client: false, llama_ready: true, @@ -78,12 +80,13 @@ function buildProps( }; } -const statusTemplate = { +const statusTemplate: StatusPayload = { version: "1.0.0", latest_version: null, node_id: "node-1", token: "token-123", - node_status: "Host", + node_state: "serving", + node_status: "Serving", is_host: true, is_client: false, llama_ready: true, @@ -101,7 +104,7 @@ const statusTemplate = { inflight_requests: 0, nostr_discovery: false, my_hostname: "host.local", - gpus: [] as unknown[], + gpus: [], }; let statusPayload = createStatusPayload(); @@ -115,8 +118,8 @@ function createStatusPayload() { models: [] as typeof statusTemplate.models, available_models: [] as typeof statusTemplate.available_models, requested_models: [] as typeof statusTemplate.requested_models, - serving_models: [...statusTemplate.serving_models], - hosted_models: [...statusTemplate.hosted_models], + serving_models: [...(statusTemplate.serving_models ?? [])], + hosted_models: [...(statusTemplate.hosted_models ?? [])], gpus: [] as typeof statusTemplate.gpus, }; } @@ -453,6 +456,43 @@ describe("App routing and status", () => { await screen.findByText("Mesh LLM v1.0.0"); }); + it("renders dashboard live-state labels from node_state and peer state", async () => { + statusPayload = { + ...createStatusPayload(), + node_state: "loading", + node_status: "Serving", + is_host: false, + llama_ready: false, + model_name: "", + hosted_models: [], + serving_models: [], + peers: [ + { + id: "peer-standby", + role: "Host", + state: "standby", + models: [], + available_models: [], + requested_models: [], + serving_models: [], + hosted_models: [], + hosted_models_known: true, + vram_gb: 16, + rtt_ms: 18, + hostname: "peer-host.local", + }, + ], + }; + + setPath("/dashboard"); + render(); + + expect((await screen.findAllByText("Loading")).length).toBeGreaterThan(0); + expect((await screen.findAllByText("Standby")).length).toBeGreaterThan(0); + expect(screen.getByText("Host")).toBeInTheDocument(); + expect(screen.queryAllByText("Serving")).toHaveLength(0); + }); + it("keeps client chat disabled until /api/models reports a warm model", async () => { statusPayload = { ...createStatusPayload(), diff --git a/mesh-llm/ui/src/App.tsx b/mesh-llm/ui/src/App.tsx index 59800c47..2e4c0195 100644 --- a/mesh-llm/ui/src/App.tsx +++ b/mesh-llm/ui/src/App.tsx @@ -14,7 +14,6 @@ import { peerAssignedModels, peerPrimaryModel, peerRoutableModels, - peerStatusLabel, readThemeMode, } from "./features/app-shell/lib/status-helpers"; import { useAppRouting } from "./features/app-shell/hooks/useAppRouting"; @@ -116,7 +115,7 @@ export function App() { if (model && model !== "(idle)") addServingNode(model, status.my_vram_gb); } for (const peer of status.peers ?? []) { - if (peer.role === "Client") continue; + if (peer.state === "client") continue; for (const model of new Set(peerRoutableModels(peer))) { if (model && model !== "(idle)") addServingNode(model, peer.vram_gb); } @@ -189,22 +188,20 @@ export function App() { if (status.node_id) { nodes.push({ id: status.node_id, - vram: overviewVramGb(status.is_client, status.my_vram_gb), + vram: overviewVramGb(status.node_state === "client", status.my_vram_gb), + state: status.node_state, self: true, host: status.is_host, - client: status.is_client, + client: status.node_state === "client", serving: status.model_name || "", servingModels: status.hosted_models && status.hosted_models.length > 0 ? status.hosted_models : status.serving_models && status.serving_models.length > 0 ? status.serving_models - : status.model_name + : status.model_name ? [status.model_name] : [], - statusLabel: - status.node_status || - (status.is_client ? "Client" : status.is_host ? "Host" : "Idle"), latencyMs: null, hostname: status.my_hostname, isSoc: status.my_is_soc, @@ -218,13 +215,13 @@ export function App() { : peerAssignedModels(p); nodes.push({ id: p.id, - vram: overviewVramGb(p.role === "Client", p.vram_gb), + vram: overviewVramGb(p.state === "client", p.vram_gb), + state: p.state, self: false, host: /^Host/.test(p.role), - client: p.role === "Client", + client: p.state === "client", serving: peerPrimaryModel(p), servingModels: pModels, - statusLabel: peerStatusLabel(p), latencyMs: p.rtt_ms ?? null, hostname: p.hostname, isSoc: p.is_soc, diff --git a/mesh-llm/ui/src/features/app-shell/lib/status-helpers.test.ts b/mesh-llm/ui/src/features/app-shell/lib/status-helpers.test.ts new file mode 100644 index 00000000..566b537d --- /dev/null +++ b/mesh-llm/ui/src/features/app-shell/lib/status-helpers.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { + formatLiveNodeState, + localRoutableModels, + topologyStatusTone, + topologyStatusTooltip, +} from "./status-helpers"; +import type { LiveNodeState, StatusPayload } from "./status-types"; + +describe("live node state helpers", () => { + it("formats all supported live node states", () => { + const cases: Array<{ + state: LiveNodeState; + label: string; + tone: "good" | "info" | "warn" | "bad" | "neutral"; + tooltip: string; + }> = [ + { + state: "client", + label: "Client", + tone: "info", + tooltip: "Sends requests, but does not contribute VRAM.", + }, + { + state: "standby", + label: "Standby", + tone: "neutral", + tooltip: "Connected, but not currently serving a model.", + }, + { + state: "loading", + label: "Loading", + tone: "warn", + tooltip: "Initializing model work before it can serve requests.", + }, + { + state: "serving", + label: "Serving", + tone: "good", + tooltip: "Actively serving a model.", + }, + ]; + + for (const testCase of cases) { + expect(formatLiveNodeState(testCase.state)).toBe(testCase.label); + expect(topologyStatusTone(testCase.state)).toBe(testCase.tone); + expect(topologyStatusTooltip(testCase.state)).toBe(testCase.tooltip); + } + }); + + it("rejects legacy live-state labels from formatter, tone, and tooltip paths", () => { + const legacyLabels = ["Idle", "Assigned", "Host", "Serving (split)", "Worker (split)"]; + + for (const label of legacyLabels) { + expect(() => formatLiveNodeState(label as LiveNodeState)).toThrow( + `Unsupported live node state: ${label}`, + ); + expect(() => topologyStatusTone(label as LiveNodeState)).toThrow( + `Unsupported live node state: ${label}`, + ); + expect(() => topologyStatusTooltip(label as LiveNodeState)).toThrow( + `Unsupported live node state: ${label}`, + ); + } + }); + + it("uses node_state as the local routable-model source of truth", () => { + const baseStatus: StatusPayload = { + node_id: "local-node", + node_status: "Serving", + node_state: "serving", + token: "token", + is_host: false, + is_client: true, + llama_ready: true, + peers: [], + model_name: "fallback-model", + requested_models: [], + available_models: [], + serving_models: ["serving-model"], + hosted_models: ["hosted-model"], + my_vram_gb: 24, + api_port: 3131, + model_size_gb: 0, + inflight_requests: 0, + version: "test", + latest_version: null, + wakeable_nodes: [], + }; + + expect(localRoutableModels(baseStatus)).toEqual(["hosted-model"]); + expect(localRoutableModels({ ...baseStatus, node_state: "client", is_client: false })).toEqual( + [], + ); + }); +}); diff --git a/mesh-llm/ui/src/features/app-shell/lib/status-helpers.ts b/mesh-llm/ui/src/features/app-shell/lib/status-helpers.ts index 1b94811f..76d327a9 100644 --- a/mesh-llm/ui/src/features/app-shell/lib/status-helpers.ts +++ b/mesh-llm/ui/src/features/app-shell/lib/status-helpers.ts @@ -1,4 +1,12 @@ -import type { MeshModel, Ownership, Peer, StatusPayload, ThemeMode } from "./status-types"; +import { LIVE_NODE_STATE_LABELS } from "./status-types"; +import type { + LiveNodeState, + MeshModel, + Ownership, + Peer, + StatusPayload, + ThemeMode, +} from "./status-types"; import type { TopologyNode } from "./topology-types"; export function modelDisplayName(model?: MeshModel | null) { @@ -23,7 +31,7 @@ export function peerRoutableModels(peer: Peer): string[] { } export function localRoutableModels(status: StatusPayload | null): string[] { - if (!status || status.is_client) return []; + if (!status || status.node_state === "client") return []; const hosted = status.hosted_models?.filter(Boolean) ?? []; if (hosted.length > 0) return hosted; const serving = status.serving_models?.filter(Boolean) ?? []; @@ -40,20 +48,23 @@ export function overviewVramGb(isClient: boolean, vramGb?: number | null) { return Math.max(0, vramGb || 0); } -export function peerStatusLabel(peer: Peer): string { - if (peer.role === "Client") return "Client"; - if (peerRoutableModels(peer).some((model) => model !== "(idle)")) return "Serving"; - if (peerAssignedModels(peer).some((model) => model !== "(idle)")) return "Assigned"; - if (peer.role === "Host") return "Host"; - return "Idle"; +function assertLiveNodeState(state: LiveNodeState): LiveNodeState { + if (!(state in LIVE_NODE_STATE_LABELS)) { + throw new Error(`Unsupported live node state: ${state}`); + } + return state; +} + +export function formatLiveNodeState(state: LiveNodeState): string { + return LIVE_NODE_STATE_LABELS[assertLiveNodeState(state)]; } export function meshGpuVram(status: StatusPayload | null) { if (!status) return 0; return ( - overviewVramGb(status.is_client, status.my_vram_gb) + + overviewVramGb(status.node_state === "client", status.my_vram_gb) + (status.peers || []).reduce( - (sum, peer) => sum + overviewVramGb(peer.role === "Client", peer.vram_gb), + (sum, peer) => sum + overviewVramGb(peer.state === "client", peer.vram_gb), 0, ) ); @@ -105,36 +116,31 @@ export function formatLatency(value?: number | null) { } export function topologyStatusTone( - status: string, + state: LiveNodeState, ): "good" | "info" | "warn" | "bad" | "neutral" { - if (status === "Serving" || status === "Serving (split)") return "good"; - if (status === "Client") return "info"; - if (status === "Host") return "info"; - if (status === "Idle" || status === "Standby") return "neutral"; - if (status === "Worker (split)") return "warn"; - return "neutral"; + switch (assertLiveNodeState(state)) { + case "serving": + return "good"; + case "client": + return "info"; + case "loading": + return "warn"; + case "standby": + return "neutral"; + } } -export function topologyStatusTooltip(status: string) { - if (status === "Serving") { - return "Actively serving a model."; - } - if (status === "Serving (split)") { - return "Serving a split model with the mesh."; - } - if (status === "Worker (split)") { - return "Contributing compute to a split model."; - } - if (status === "Host") { - return "Coordinating requests for the mesh."; - } - if (status === "Client") { - return "Sends requests, but does not contribute VRAM."; - } - if (status === "Idle" || status === "Standby") { - return "Connected, but not serving a model."; +export function topologyStatusTooltip(state: LiveNodeState) { + switch (assertLiveNodeState(state)) { + case "serving": + return "Actively serving a model."; + case "loading": + return "Initializing model work before it can serve requests."; + case "client": + return "Sends requests, but does not contribute VRAM."; + case "standby": + return "Connected, but not currently serving a model."; } - return "Current serving role."; } export function modelStatusTooltip(status?: string) { @@ -175,11 +181,10 @@ export function trimGpuVendor(name: string) { .trim(); } -export function topologyNodeRole(node: Pick): string { +export function topologyNodeRole(node: Pick): string { if (node.client) return "Client"; if (node.host) return "Host"; - if (node.serving && node.serving !== "(idle)") return "Worker"; - return "Idle"; + return "Worker"; } export function readThemeMode(storageKey: string): ThemeMode { diff --git a/mesh-llm/ui/src/features/app-shell/lib/status-types.test.ts b/mesh-llm/ui/src/features/app-shell/lib/status-types.test.ts new file mode 100644 index 00000000..a7646592 --- /dev/null +++ b/mesh-llm/ui/src/features/app-shell/lib/status-types.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { + LIVE_NODE_STATE_LABELS, + type LiveNodeState, + type Peer, + type StatusPayload, + type WakeableNodeState, +} from "./status-types"; + +type OptionalKey = {} extends Pick ? true : false; + +const STATUS_PAYLOAD_NODE_STATE_REQUIRED = false satisfies OptionalKey; +const PEER_STATE_REQUIRED = false satisfies OptionalKey; +const WAKEABLE_NODES_OPTIONAL = true satisfies OptionalKey; + +describe("status type contracts", () => { + it("enumerates the allowed live and wakeable node states", () => { + const liveStates = ["client", "standby", "loading", "serving"] as const satisfies readonly LiveNodeState[]; + const wakeableStates = ["sleeping", "waking"] as const satisfies readonly WakeableNodeState[]; + + expect(liveStates).toEqual(["client", "standby", "loading", "serving"]); + expect(wakeableStates).toEqual(["sleeping", "waking"]); + expect(LIVE_NODE_STATE_LABELS).toEqual({ + client: "Client", + standby: "Standby", + loading: "Loading", + serving: "Serving", + }); + }); + + it("requires live state on status payloads and peers while leaving wakeable_nodes optional", () => { + const statusPayload: StatusPayload = { + node_id: "node-1", + token: "token-1", + node_state: "serving", + node_status: "Serving", + is_host: true, + is_client: false, + llama_ready: true, + model_name: "Qwen", + api_port: 3131, + my_vram_gb: 24, + model_size_gb: 8, + peers: [ + { + id: "peer-1", + role: "Host", + state: "standby", + models: [], + vram_gb: 16, + }, + ], + inflight_requests: 0, + }; + + const peer = { + id: "peer-2", + role: "Worker", + state: "loading", + models: [], + vram_gb: 12, + } satisfies Peer; + + // @ts-expect-error StatusPayload must require node_state + const missingNodeState: StatusPayload = { + node_id: "node-1", + token: "token-1", + node_status: "Serving", + is_host: true, + is_client: false, + llama_ready: true, + model_name: "Qwen", + api_port: 3131, + my_vram_gb: 24, + model_size_gb: 8, + peers: [], + inflight_requests: 0, + }; + + // @ts-expect-error Peer must require state + const missingPeerState: Peer = { + id: "peer-1", + role: "Host", + models: [], + vram_gb: 12, + }; + + expect(statusPayload.node_state).toBe("serving"); + expect(statusPayload.wakeable_nodes).toBeUndefined(); + expect(peer.state).toBe("loading"); + expect(missingNodeState).toBeDefined(); + expect(missingPeerState).toBeDefined(); + expect(STATUS_PAYLOAD_NODE_STATE_REQUIRED).toBe(false); + expect(PEER_STATE_REQUIRED).toBe(false); + expect(WAKEABLE_NODES_OPTIONAL).toBe(true); + }); + + it("rejects legacy labels at the type layer", () => { + // @ts-expect-error legacy labels are not valid live node states + const idle: LiveNodeState = "Idle"; + // @ts-expect-error split-era labels are not valid live node states + const splitServing: LiveNodeState = "Serving (split)"; + // @ts-expect-error topology role labels are not valid live node states + const host: LiveNodeState = "Host"; + // @ts-expect-error wakeable state must not accept live state labels + const standbyWakeable: WakeableNodeState = "standby"; + + expect([idle, splitServing, host, standbyWakeable]).toHaveLength(4); + }); +}); diff --git a/mesh-llm/ui/src/features/app-shell/lib/status-types.ts b/mesh-llm/ui/src/features/app-shell/lib/status-types.ts index 7cd56a7c..83d6ebe5 100644 --- a/mesh-llm/ui/src/features/app-shell/lib/status-types.ts +++ b/mesh-llm/ui/src/features/app-shell/lib/status-types.ts @@ -1,3 +1,14 @@ +export type LiveNodeState = "client" | "standby" | "loading" | "serving"; + +export type WakeableNodeState = "sleeping" | "waking"; + +export const LIVE_NODE_STATE_LABELS: Record = { + client: "Client", + standby: "Standby", + loading: "Loading", + serving: "Serving", +}; + export type MeshModel = { name: string; display_name?: string; @@ -56,6 +67,7 @@ export type Peer = { id: string; owner?: Ownership; role: string; + state: LiveNodeState; models: string[]; available_models?: string[]; requested_models?: string[]; @@ -79,12 +91,22 @@ export type LocalInstance = { is_self: boolean; }; +export type WakeableNode = { + logical_id: string; + models: string[]; + vram_gb: number; + provider?: string; + state: WakeableNodeState; + wake_eta_secs?: number; +}; + export type StatusPayload = { version?: string; latest_version?: string | null; node_id: string; owner?: Ownership; token: string; + node_state: LiveNodeState; node_status: string; is_host: boolean; is_client: boolean; @@ -100,6 +122,7 @@ export type StatusPayload = { model_size_gb: number; mesh_name?: string | null; peers: Peer[]; + wakeable_nodes?: WakeableNode[]; local_instances?: LocalInstance[]; inflight_requests: number; launch_pi?: string | null; diff --git a/mesh-llm/ui/src/features/app-shell/lib/topology-types.ts b/mesh-llm/ui/src/features/app-shell/lib/topology-types.ts index 6fab4a1d..2b5572af 100644 --- a/mesh-llm/ui/src/features/app-shell/lib/topology-types.ts +++ b/mesh-llm/ui/src/features/app-shell/lib/topology-types.ts @@ -1,12 +1,14 @@ +import type { LiveNodeState } from "./status-types"; + export type TopologyNode = { id: string; vram: number; + state: LiveNodeState; self: boolean; host: boolean; client: boolean; serving: string; servingModels: string[]; - statusLabel: string; latencyMs?: number | null; hostname?: string; isSoc?: boolean; diff --git a/mesh-llm/ui/src/features/dashboard/components/DashboardPage.tsx b/mesh-llm/ui/src/features/dashboard/components/DashboardPage.tsx index 12b2b7d9..a36fad23 100644 --- a/mesh-llm/ui/src/features/dashboard/components/DashboardPage.tsx +++ b/mesh-llm/ui/src/features/dashboard/components/DashboardPage.tsx @@ -51,6 +51,7 @@ import { } from "../../../components/ui/tooltip"; import { cn } from "../../../lib/utils"; import { + formatLiveNodeState, formatLatency, localRoutableModels, meshGpuVram, @@ -62,7 +63,6 @@ import { peerAssignedModels, peerPrimaryModel, peerRoutableModels, - peerStatusLabel, shortName, topologyNodeRole, topologyStatusTone, @@ -70,6 +70,7 @@ import { uniqueModels, } from "../../app-shell/lib/status-helpers"; import type { + LiveNodeState, MeshModel, Ownership, StatusPayload, @@ -84,6 +85,7 @@ import { } from "./details"; import { MeshTopologyDiagram } from "./topology"; import { useDashboardDetailStack } from "../hooks/useDashboardDetailStack"; +import { WakeableCapacity } from "./WakeableCapacity"; const DOCS_URL = "https://docs.anarchai.org"; @@ -99,8 +101,8 @@ type NodeSidebarRecord = { title: string; hostname?: string; self: boolean; + state: LiveNodeState; role: string; - statusLabel: string; latencyLabel: string; vramGb: number; vramSharePct: number | null; @@ -187,27 +189,25 @@ export function DashboardPage({ }, [status]); const sortedPeers = useMemo(() => { return [...(status?.peers ?? [])].sort((a, b) => { - const bOverviewVramGb = overviewVramGb(b.role === "Client", b.vram_gb); - const aOverviewVramGb = overviewVramGb(a.role === "Client", a.vram_gb); + const bOverviewVramGb = overviewVramGb(b.state === "client", b.vram_gb); + const aOverviewVramGb = overviewVramGb(a.state === "client", a.vram_gb); return bOverviewVramGb - aOverviewVramGb || a.id.localeCompare(b.id); }); }, [status?.peers]); const peerRows = useMemo(() => { return sortedPeers.map((peer) => { - const statusLabel = peer.role === "Client" ? "Client" : peerStatusLabel(peer); const primaryModel = peerPrimaryModel(peer); const modelLabel = primaryModel && primaryModel !== "(idle)" ? shortName(primaryModel) : "idle"; const latencyLabel = formatLatency(peer.rtt_ms); - const displayVramGb = overviewVramGb(peer.role === "Client", peer.vram_gb); + const displayVramGb = overviewVramGb(peer.state === "client", peer.vram_gb); const sharePct = - peer.role !== "Client" && totalMeshVramGb > 0 + peer.state !== "client" && totalMeshVramGb > 0 ? Math.round((displayVramGb / totalMeshVramGb) * 100) : null; return { ...peer, displayVramGb, - statusLabel, modelLabel, latencyLabel, shareLabel: sharePct == null ? "n/a" : `${sharePct}%`, @@ -226,15 +226,15 @@ export function DashboardPage({ const totalModelVram = selectedCatalogModel.mesh_vram_gb ?? 0; const rows: ActivePeerRow[] = []; const localServing = localRoutableModels(status).includes(targetModel); - if (localServing && !status.is_client) { - const localVram = overviewVramGb(status.is_client, status.my_vram_gb); + const localClientVram = overviewVramGb(status.node_state === "client", status.my_vram_gb); + if (localServing && status.node_state !== "client") { rows.push({ id: status.node_id, latencyLabel: "local", - vramLabel: `${localVram.toFixed(1)} GB`, + vramLabel: `${localClientVram.toFixed(1)} GB`, shareLabel: totalModelVram > 0 - ? `${Math.round((localVram / totalModelVram) * 100)}%` + ? `${Math.round((localClientVram / totalModelVram) * 100)}%` : "n/a", }); } @@ -242,7 +242,7 @@ export function DashboardPage({ const servesTarget = peerRoutableModels(peer).includes(targetModel) || peerAssignedModels(peer).includes(targetModel); - if (!servesTarget || peer.role === "Client") continue; + if (!servesTarget || peer.state === "client") continue; rows.push({ id: peer.id, latencyLabel: peer.latencyLabel, @@ -279,11 +279,11 @@ export function DashboardPage({ title: topologyNode.hostname || topologyNode.id, hostname: topologyNode.hostname, self: topologyNode.self, + state: topologyNode.state, role: topologyNodeRole(topologyNode), - statusLabel: topologyNode.statusLabel, latencyLabel: topologyNode.self ? "local" : formatLatency(topologyNode.latencyMs), vramGb: Math.max(0, topologyNode.vram), - vramSharePct: topologyNode.client + vramSharePct: topologyNode.state === "client" ? null : totalMeshVramGb <= 0 ? 0 @@ -333,8 +333,8 @@ export function DashboardPage({ setIsMeshOverviewFullscreen((prev) => !prev); } - const gpuNodeCount = topologyDiagramNodes.filter((node) => !node.client).length; - const clientCount = topologyDiagramNodes.filter((node) => node.client).length; + const gpuNodeCount = topologyDiagramNodes.filter((node) => node.state !== "client").length; + const clientCount = topologyDiagramNodes.filter((node) => node.state === "client").length; return (
@@ -411,12 +411,14 @@ export function DashboardPage({ value={status?.node_id ?? "n/a"} valueSuffix={ } icon={} - tooltip={`Current node identifier in this mesh. ${topologyStatusTooltip(status?.node_status ?? "n/a")}`} + tooltip={status + ? `Current node identifier in this mesh. ${topologyStatusTooltip(status.node_state)}` + : "Current node identifier in this mesh."} /> unknown )} - {peer.statusLabel} + {formatLiveNodeState(peer.state)} {peer.modelLabel} {peer.latencyLabel} - {peer.role === "Client" ? "n/a" : `${peer.displayVramGb.toFixed(1)} GB`} + {peer.state === "client" ? "n/a" : `${peer.displayVramGb.toFixed(1)} GB`} {peer.shareLabel} @@ -709,6 +711,8 @@ export function DashboardPage({ + + {isMeshOverviewFullscreen && typeof document !== "undefined" ? createPortal(
diff --git a/mesh-llm/ui/src/features/dashboard/components/WakeableCapacity.test.tsx b/mesh-llm/ui/src/features/dashboard/components/WakeableCapacity.test.tsx new file mode 100644 index 00000000..82926884 --- /dev/null +++ b/mesh-llm/ui/src/features/dashboard/components/WakeableCapacity.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest"; + +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { TooltipProvider } from "../../../components/ui/tooltip"; +import type { StatusPayload } from "../../app-shell/lib/status-types"; +import { DashboardPage } from "./DashboardPage"; + +afterEach(() => { + cleanup(); +}); + +describe("WakeableCapacity", () => { + it("renders sleeping and waking inventory outside live topology and peer rows", () => { + renderDashboard( + buildStatus({ + wakeable_nodes: [ + { + logical_id: "vast-a100-1", + state: "sleeping", + models: ["Qwen2.5-72B-Instruct"], + vram_gb: 80, + provider: "Vast", + }, + { + logical_id: "runpod-h100-2", + state: "waking", + models: ["DeepSeek-R1", "Qwen3-32B"], + vram_gb: 94, + wake_eta_secs: 420, + }, + ], + }), + ); + + const section = screen.getByTestId("wakeable-capacity-section"); + expect(within(section).getByText("Wakeable Capacity")).toBeInTheDocument(); + expect(within(section).getByText("Sleeping")).toBeInTheDocument(); + expect(within(section).getByText("Waking")).toBeInTheDocument(); + expect(within(section).getByText("vast-a100-1")).toBeInTheDocument(); + expect(within(section).getByText("runpod-h100-2")).toBeInTheDocument(); + expect(within(section).getByText("Qwen2.5-72B-Instruct")).toBeInTheDocument(); + expect(within(section).getByText("DeepSeek-R1")).toBeInTheDocument(); + expect(within(section).getByText("Qwen3-32B")).toBeInTheDocument(); + expect(within(section).getByText("80.0 GB")).toBeInTheDocument(); + expect(within(section).getByText("94.0 GB")).toBeInTheDocument(); + expect(within(section).getByText("Vast")).toBeInTheDocument(); + expect(within(section).getByText("7 min")).toBeInTheDocument(); + + expect(screen.getByText("No host or worker nodes visible yet.")).toBeInTheDocument(); + expect(screen.getByText("No peers connected")).toBeInTheDocument(); + }); + + it("hides the section when wakeable_nodes is an empty array", () => { + renderDashboard(buildStatus({ wakeable_nodes: [] })); + + expect(screen.queryByTestId("wakeable-capacity-section")).not.toBeInTheDocument(); + expect(screen.queryByText("Wakeable Capacity")).not.toBeInTheDocument(); + }); + + it("hides the section when wakeable_nodes is absent", () => { + renderDashboard(buildStatus()); + + expect(screen.queryByTestId("wakeable-capacity-section")).not.toBeInTheDocument(); + }); +}); + +function renderDashboard(status: StatusPayload) { + return render( + + + , + ); +} + +function buildStatus(overrides: Partial = {}): StatusPayload { + return { + node_id: "node-1", + token: "invite-token", + node_state: "serving", + node_status: "Serving", + is_host: true, + is_client: false, + llama_ready: true, + model_name: "Qwen2.5-32B", + models: ["Qwen2.5-32B"], + available_models: ["Qwen2.5-32B"], + requested_models: [], + serving_models: ["Qwen2.5-32B"], + hosted_models: ["Qwen2.5-32B"], + api_port: 3131, + my_vram_gb: 24, + model_size_gb: 16, + peers: [], + inflight_requests: 0, + ...overrides, + }; +} diff --git a/mesh-llm/ui/src/features/dashboard/components/WakeableCapacity.tsx b/mesh-llm/ui/src/features/dashboard/components/WakeableCapacity.tsx new file mode 100644 index 00000000..e56347f6 --- /dev/null +++ b/mesh-llm/ui/src/features/dashboard/components/WakeableCapacity.tsx @@ -0,0 +1,108 @@ +import { Badge } from "../../../components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card"; +import type { WakeableNode } from "../../app-shell/lib/status-types"; +import { StatusPill } from "./details"; + +const WAKEABLE_NODE_STATE_LABELS = { + sleeping: "Sleeping", + waking: "Waking", +} as const; + +export function WakeableCapacity({ + wakeableNodes, +}: { + wakeableNodes?: WakeableNode[]; +}) { + if (!wakeableNodes?.length) return null; + + return ( + + +
+ Wakeable Capacity +
+ {wakeableNodes.length} node{wakeableNodes.length === 1 ? "" : "s"} +
+
+
+ Provider-backed capacity that can be woken on demand. These nodes are kept + separate from the live topology until they rejoin the mesh. +
+
+ +
+ {wakeableNodes.map((node) => { + const etaLabel = + node.wake_eta_secs == null ? null : formatWakeEta(node.wake_eta_secs); + return ( +
+
+
+
+ {node.logical_id} +
+
+ + {node.provider ? ( + + {node.provider} + + ) : null} +
+
+
+
+ {node.vram_gb.toFixed(1)} GB +
+
VRAM
+
+
+ +
+
+
Models
+ {node.models.length > 0 ? ( +
+ {node.models.map((model) => ( + + {model} + + ))} +
+ ) : ( +
No advertised models
+ )} +
+ + {etaLabel ? ( +
+
ETA
+
{etaLabel}
+
+ ) : null} +
+
+ ); + })} +
+
+
+ ); +} + +function formatWakeEta(seconds: number) { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.ceil(seconds / 60)} min`; + if (seconds < 86_400) return `${Math.ceil(seconds / 3600)} hr`; + return `${Math.ceil(seconds / 86_400)} d`; +} diff --git a/mesh-llm/ui/src/features/dashboard/components/details/NodeSidebar.tsx b/mesh-llm/ui/src/features/dashboard/components/details/NodeSidebar.tsx index 521125e4..5a28c44b 100644 --- a/mesh-llm/ui/src/features/dashboard/components/details/NodeSidebar.tsx +++ b/mesh-llm/ui/src/features/dashboard/components/details/NodeSidebar.tsx @@ -25,6 +25,7 @@ import { TableRow, } from "../../../../components/ui/table"; import { + formatLiveNodeState, formatGpuMemory, modelDisplayName, modelStatusTooltip, @@ -36,6 +37,7 @@ import { uniqueModels, } from "../../../app-shell/lib/status-helpers"; import type { + LiveNodeState, MeshModel, Ownership, } from "../../../app-shell/lib/status-types"; @@ -54,8 +56,8 @@ type NodeSidebarRecord = { title: string; hostname?: string; self: boolean; + state: LiveNodeState; role: string; - statusLabel: string; latencyLabel: string; vramGb: number; vramSharePct: number | null; @@ -140,10 +142,10 @@ export function NodeSidebar({ tooltip={nodeRoleTooltip(node.role)} />
diff --git a/mesh-llm/ui/src/features/dashboard/components/topology/MeshTopologyDiagram.tsx b/mesh-llm/ui/src/features/dashboard/components/topology/MeshTopologyDiagram.tsx index cee1c4d1..2fecbcb9 100644 --- a/mesh-llm/ui/src/features/dashboard/components/topology/MeshTopologyDiagram.tsx +++ b/mesh-llm/ui/src/features/dashboard/components/topology/MeshTopologyDiagram.tsx @@ -11,9 +11,11 @@ import { Minus, Plus, RotateCcw } from "lucide-react"; import { cn } from "../../../../lib/utils"; import { + formatLiveNodeState, formatLatency, shortName, } from "../../../app-shell/lib/status-helpers"; +import type { LiveNodeState } from "../../../app-shell/lib/status-types"; import type { TopologyNode } from "../../../app-shell/lib/topology-types"; import { EmptyPanel } from "../details"; @@ -27,7 +29,7 @@ type RenderNode = { subtitle: string; hostname?: string; role: string; - statusLabel: string; + state: LiveNodeState; latencyLabel: string; vramLabel: string; modelLabel: string; @@ -264,9 +266,9 @@ function nodeUpdateSignature(node: TopologyNode) { vram: node.vram, host: node.host, client: node.client, + state: node.state, serving: node.serving, servingModels: node.servingModels, - statusLabel: node.statusLabel, latencyMs: node.latencyMs, hostname: node.hostname, isSoc: node.isSoc, @@ -294,12 +296,12 @@ function createDebugNode( return { id: `debug-serving-${index}`, vram: 24 + (index % 4) * 6, + state: "serving", self: false, host: false, client: false, serving: model, servingModels: [model], - statusLabel: "Serving", latencyMs: 14 + (index % 5) * 8, hostname: `test-serving-${index}`, isSoc: false, @@ -311,12 +313,12 @@ function createDebugNode( return { id: `debug-worker-${index}`, vram: 12 + (index % 3) * 4, + state: "standby", self: false, host: false, client: false, serving: "", servingModels: [], - statusLabel: "Standby", latencyMs: 24 + (index % 6) * 10, hostname: `test-worker-${index}`, isSoc: index % 2 === 0, @@ -330,12 +332,12 @@ function createDebugNode( return { id: `debug-client-${index}`, vram: 0, + state: "client", self: false, host: false, client: true, serving: "", servingModels: [], - statusLabel: "Client", latencyMs: 42 + (index % 7) * 12, hostname: `test-client-${index}`, isSoc: false, @@ -431,10 +433,10 @@ function useRadarFieldNodes(nodes: TopologyNode[], selectedModel: string, fallba output.push({ id: node.id, label: node.hostname || node.id, - subtitle: "Client", + subtitle: formatLiveNodeState(node.state), hostname: node.hostname, role: "Client", - statusLabel: node.statusLabel, + state: node.state, latencyLabel: formatLatency(node.latencyMs), vramLabel: "n/a", modelLabel: "API-only", @@ -456,10 +458,10 @@ function useRadarFieldNodes(nodes: TopologyNode[], selectedModel: string, fallba output.push({ id: node.id, label: node.hostname || node.id, - subtitle: node.statusLabel, + subtitle: formatLiveNodeState(node.state), hostname: node.hostname, role: node.host ? "Host" : "Worker", - statusLabel: node.statusLabel, + state: node.state, latencyLabel: formatLatency(node.latencyMs), vramLabel: `${Math.max(0, node.vram).toFixed(1)} GB`, modelLabel: models.length > 0 ? models.map(shortName).join(", ") : "idle", @@ -486,10 +488,10 @@ function useRadarFieldNodes(nodes: TopologyNode[], selectedModel: string, fallba output.push({ id: node.id, label: node.hostname || node.id, - subtitle: node.statusLabel, + subtitle: formatLiveNodeState(node.state), hostname: node.hostname, - role: node.host ? "Host" : "Serving", - statusLabel: node.statusLabel, + role: node.host ? "Host" : "Worker", + state: node.state, latencyLabel: formatLatency(node.latencyMs), vramLabel: `${Math.max(0, node.vram).toFixed(1)} GB`, modelLabel: models.length > 0 ? models.map(shortName).join(", ") : "idle", @@ -516,10 +518,10 @@ function useRadarFieldNodes(nodes: TopologyNode[], selectedModel: string, fallba output.push({ id: node.id, label: node.hostname || node.id, - subtitle: focusModel ? shortName(focusModel) : node.statusLabel, + subtitle: formatLiveNodeState(node.state), hostname: node.hostname, - role: node.host ? "Host" : "Serving", - statusLabel: node.statusLabel, + role: node.host ? "Host" : "Worker", + state: node.state, latencyLabel: formatLatency(node.latencyMs), vramLabel: `${Math.max(0, node.vram).toFixed(1)} GB`, modelLabel: models.length > 0 ? models.map(shortName).join(", ") : "idle", @@ -543,10 +545,10 @@ function useRadarFieldNodes(nodes: TopologyNode[], selectedModel: string, fallba const selfRenderNode: RenderNode = { id: selfNode.id, label: selfNode.hostname || (selfNode.client ? "this client" : "this node"), - subtitle: selfNode.client ? "This client" : "This node", + subtitle: formatLiveNodeState(selfNode.state), hostname: selfNode.hostname, - role: selfNode.client ? "Client" : selfNode.host ? "Host" : "Node", - statusLabel: selfNode.statusLabel, + role: selfNode.client ? "Client" : selfNode.host ? "Host" : "Worker", + state: selfNode.state, latencyLabel: "local", vramLabel: selfNode.client ? "n/a" : `${Math.max(0, selfNode.vram).toFixed(1)} GB`, modelLabel: selfNode.client @@ -1726,7 +1728,7 @@ function MeshRadarField({ className="mt-1 text-[10px] uppercase tracking-[0.22em] text-slate-200" style={{ textShadow: "0 1px 10px rgba(0,0,0,0.72)" }} > - {selfNode?.statusLabel || "connected"} + {selfNode ? formatLiveNodeState(selfNode.state) : "connected"}
{hoveredNode ? ( @@ -1748,7 +1750,7 @@ function MeshRadarField({
Role
{hoveredNode.role}
Status
-
{hoveredNode.statusLabel}
+
{formatLiveNodeState(hoveredNode.state)}
VRAM
{hoveredNode.vramLabel}
Latency