diff --git a/docs/adr/0001-core-endpoints-and-wake-routes.md b/docs/adr/0001-core-endpoints-and-wake-routes.md index 5140662..e5cdb55 100644 --- a/docs/adr/0001-core-endpoints-and-wake-routes.md +++ b/docs/adr/0001-core-endpoints-and-wake-routes.md @@ -77,6 +77,13 @@ Current DHCP leases are summary-eligible and may support wake if they include a `Device.presence` is derived from all endpoints, not only summary-eligible endpoints. This lets concrete offline evidence matter while allowing stronger live evidence to override it. +The control plane derives stored snapshot presence and fleet presence from the +maximum endpoint signal whenever endpoints exist. A cached summary must not +override endpoint evidence; endpoint-less legacy rows fall back to their stored +summary. Presence describes the last observation, not guaranteed current +reachability. Agent disconnection does not rewrite endpoint presence or expire +the retained snapshot. + The main fleet API embeds endpoints inside each fleet device. API responses expose all endpoints, including offline and unknown endpoints. The UI may collapse offline/unknown endpoints in the primary row, but details must expose them for copy/debug/history. ## Wakeability and Wake Route Ordering diff --git a/src/service/inventory.rs b/src/service/inventory.rs index 32106b1..fa70230 100644 --- a/src/service/inventory.rs +++ b/src/service/inventory.rs @@ -189,11 +189,13 @@ pub fn local_observation_to_fact( } const fn presence_rank(presence: Presence) -> u8 { + // Match the core signal order: Unknown has no verdict, while Offline + // carries concrete evidence and belongs above it in the inventory list. match presence { Presence::Online => 3, Presence::LikelyOnline => 2, - Presence::Unknown => 1, - Presence::Offline => 0, + Presence::Offline => 1, + Presence::Unknown => 0, } } @@ -224,6 +226,23 @@ mod tests { }] } + #[test] + fn inventory_sorts_offline_before_unknown() { + let neighbors = [NeighborState::Incomplete, NeighborState::Failed] + .into_iter() + .enumerate() + .map(|(index, state)| NeighborEntry { + ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, index as u8 + 10)), + dev: None, + mac: None, + state, + }) + .collect(); + let devices = merge_devices(neighbors, Vec::new(), &InventoryQueryBuilder::new().build()); + assert_eq!(devices[0].presence, Presence::Offline); + assert_eq!(devices[1].presence, Presence::Unknown); + } + #[test] fn merge_devices_applies_and_across_categories() { let query = InventoryQueryBuilder::new() diff --git a/wakey-control-plane/src/api/control/fleet/build.rs b/wakey-control-plane/src/api/control/fleet/build.rs index 7e5fe3d..bf1cfea 100644 --- a/wakey-control-plane/src/api/control/fleet/build.rs +++ b/wakey-control-plane/src/api/control/fleet/build.rs @@ -211,7 +211,8 @@ fn add_agent_device_to_entry( ) .min(); entry.label_ip = entry.label_ip.into_iter().chain(label_ip).min(); - let device_offline = agent_device.device.presence() == Presence::Offline; + let device_presence = agent_device.presence(); + let device_offline = device_presence == Presence::Offline; if let Some(summary) = device_known_device(&agent_device, context) && entry.known_device.is_none() { @@ -262,7 +263,6 @@ fn add_agent_device_to_entry( .map(|current| current.max(last_seen)) .unwrap_or(last_seen), ); - let device_presence = agent_device.device.presence(); entry.presence = std::cmp::max(entry.presence, device_presence); let agent_id = agent_device.device.agent_id.clone(); diff --git a/wakey-control-plane/src/api/control/fleet/tests.rs b/wakey-control-plane/src/api/control/fleet/tests.rs index 2adca26..be0d6ea 100644 --- a/wakey-control-plane/src/api/control/fleet/tests.rs +++ b/wakey-control-plane/src/api/control/fleet/tests.rs @@ -452,6 +452,42 @@ fn unknown_ip_only_offline_device_is_hidden_by_default() { assert!(devices.is_empty()); } +#[test] +fn endpoint_presence_overrides_conflicting_cached_summary() { + for connected in [false, true] { + for (cached, evidence) in [ + (Presence::Unknown, Presence::Offline), + (Presence::Online, Presence::Unknown), + (Presence::Offline, Presence::Online), + ] { + let mut row = offline_ip_only_unknown("agent-a", "ip:192.168.1.2", "192.168.1.2", 20); + row.device.presence = cached.as_str().into(); + let mut endpoint = endpoint_with_presence( + EndpointSource::Neighbor, + None, + Some("192.168.1.2"), + evidence, + 20, + ); + endpoint.hostname = None; + row.endpoints = vec![endpoint]; + let ctx = if connected { + context(&["agent-a"]) + } else { + context(&[]) + }; + let devices = build_fleet_devices(Vec::new(), vec![row], &ctx); + assert_eq!(devices[0].presence, evidence); + assert_eq!(devices[0].endpoints[0].presence, evidence); + assert_eq!(devices[0].agents[0].connected, connected); + if evidence == Presence::Offline { + assert_eq!(devices[0].display_name, "Offline device: 192.168.1.2"); + assert!(devices[0].ips.is_empty()); + } + } + } +} + #[test] fn unnamed_endpoint_labels_preserve_presence_and_inactive_ips() { for (presence, mac, ip, expected) in [ diff --git a/wakey-control-plane/src/state/store/agent_devices.rs b/wakey-control-plane/src/state/store/agent_devices.rs index faa4b03..db2fbd2 100644 --- a/wakey-control-plane/src/state/store/agent_devices.rs +++ b/wakey-control-plane/src/state/store/agent_devices.rs @@ -37,7 +37,8 @@ impl Store { let device_key = device_key_from_id(device_id); incoming_keys.insert(device_key.clone()); - let presence = presence_to_str(device.presence); + let endpoints = endpoints_for_storage(device); + let presence = presence_to_str(endpoint_presence(&endpoints, device.presence)); let display_name: Option = None; sqlx::query!( @@ -118,7 +119,6 @@ impl Store { .await .context("failed deleting device endpoints")?; - let endpoints = endpoints_for_storage(device); if !endpoints.is_empty() { let mut builder = sqlx::QueryBuilder::new( "INSERT INTO agent_device_endpoints \ @@ -566,6 +566,41 @@ mod tests { } } + #[tokio::test] + async fn snapshot_presence_is_derived_from_stored_endpoints() { + let ts = TestStore::new().await; + let mut device = sample_device("aa:bb:cc:dd:ee:01", "192.168.1.10", "pc"); + // Compatibility snapshots can lack endpoints but carry raw neighbors. + device.presence = Presence::Unknown; + device.neighbors[0].state = NeighborState::Failed; + ts.store() + .replace_agent_device_snapshot("agent-a", &[device.clone()]) + .await + .unwrap(); + let rows = ts + .store() + .list_agent_device_rows_for_agent("agent-a") + .await + .unwrap(); + assert_eq!(rows[0].device.presence, "offline"); + assert_eq!(rows[0].endpoints[0].presence, Presence::Offline); + + device.presence = Presence::Online; + device.endpoints = rows[0].endpoints.clone(); + device.endpoints[0].presence = Presence::Unknown; + ts.store() + .replace_agent_device_snapshot("agent-a", &[device]) + .await + .unwrap(); + let rows = ts + .store() + .list_agent_device_rows_for_agent("agent-a") + .await + .unwrap(); + assert_eq!(rows[0].device.presence, "unknown"); + assert_eq!(rows[0].endpoints[0].presence, Presence::Unknown); + } + #[tokio::test] async fn snapshot_inserts_devices_and_children() { let ts = TestStore::new().await; diff --git a/wakey-control-plane/src/state/types.rs b/wakey-control-plane/src/state/types.rs index 7cb277a..8cace1f 100644 --- a/wakey-control-plane/src/state/types.rs +++ b/wakey-control-plane/src/state/types.rs @@ -218,6 +218,24 @@ pub struct AgentDeviceWithChildren { pub facts: Vec, } +impl AgentDeviceWithChildren { + /// Last-observed presence, not a claim of current reachability or freshness. + pub fn presence(&self) -> Presence { + endpoint_presence(&self.endpoints, self.device.presence()) + } +} + +/// Endpoints are authoritative when present; endpoint-less legacy rows retain +/// their summary until a new snapshot replaces them. Never merge the cached +/// summary into endpoint evidence, as it may contain a contradictory signal. +pub(crate) fn endpoint_presence(endpoints: &[DeviceEndpoint], fallback: Presence) -> Presence { + endpoints + .iter() + .map(|endpoint| endpoint.presence) + .max() + .unwrap_or(fallback) +} + fn endpoint_source_from_str(raw: &str) -> Option { match raw { "neighbor" => Some(EndpointSource::Neighbor),