fix(devices): derive presence from endpoints and align inventory ordering

This commit is contained in:
lda committed 2026-10-10 19:01:42 +07:00 Verified
1 parent 7caba52e57
commit f91186a7b1
6 files changed
+121 -6

No files matched your search

@@ -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. `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. 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 ## Wakeability and Wake Route Ordering
+21 -2
View File
@@ -189,11 +189,13 @@ pub fn local_observation_to_fact(
} }
const fn presence_rank(presence: Presence) -> u8 { 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 { match presence {
Presence::Online => 3, Presence::Online => 3,
Presence::LikelyOnline => 2, Presence::LikelyOnline => 2,
Presence::Unknown => 1, Presence::Offline => 1,
Presence::Offline => 0, 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] #[test]
fn merge_devices_applies_and_across_categories() { fn merge_devices_applies_and_across_categories() {
let query = InventoryQueryBuilder::new() let query = InventoryQueryBuilder::new()
@@ -211,7 +211,8 @@ fn add_agent_device_to_entry(
) )
.min(); .min();
entry.label_ip = entry.label_ip.into_iter().chain(label_ip).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) if let Some(summary) = device_known_device(&agent_device, context)
&& entry.known_device.is_none() && entry.known_device.is_none()
{ {
@@ -262,7 +263,6 @@ fn add_agent_device_to_entry(
.map(|current| current.max(last_seen)) .map(|current| current.max(last_seen))
.unwrap_or(last_seen), .unwrap_or(last_seen),
); );
let device_presence = agent_device.device.presence();
entry.presence = std::cmp::max(entry.presence, device_presence); entry.presence = std::cmp::max(entry.presence, device_presence);
let agent_id = agent_device.device.agent_id.clone(); let agent_id = agent_device.device.agent_id.clone();
@@ -452,6 +452,42 @@ fn unknown_ip_only_offline_device_is_hidden_by_default() {
assert!(devices.is_empty()); 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] #[test]
fn unnamed_endpoint_labels_preserve_presence_and_inactive_ips() { fn unnamed_endpoint_labels_preserve_presence_and_inactive_ips() {
for (presence, mac, ip, expected) in [ for (presence, mac, ip, expected) in [
@@ -37,7 +37,8 @@ impl Store {
let device_key = device_key_from_id(device_id); let device_key = device_key_from_id(device_id);
incoming_keys.insert(device_key.clone()); 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<String> = None; let display_name: Option<String> = None;
sqlx::query!( sqlx::query!(
@@ -118,7 +119,6 @@ impl Store {
.await .await
.context("failed deleting device endpoints")?; .context("failed deleting device endpoints")?;
let endpoints = endpoints_for_storage(device);
if !endpoints.is_empty() { if !endpoints.is_empty() {
let mut builder = sqlx::QueryBuilder::new( let mut builder = sqlx::QueryBuilder::new(
"INSERT INTO agent_device_endpoints \ "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] #[tokio::test]
async fn snapshot_inserts_devices_and_children() { async fn snapshot_inserts_devices_and_children() {
let ts = TestStore::new().await; let ts = TestStore::new().await;
+18
View File
@@ -218,6 +218,24 @@ pub struct AgentDeviceWithChildren {
pub facts: Vec<String>, pub facts: Vec<String>,
} }
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<EndpointSource> { fn endpoint_source_from_str(raw: &str) -> Option<EndpointSource> {
match raw { match raw {
"neighbor" => Some(EndpointSource::Neighbor), "neighbor" => Some(EndpointSource::Neighbor),