fix(devices): derive presence from endpoints and align inventory ordering
This commit is contained in:
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in new issue
Block a user