feat(devices): add rename and forget controls and clarify fleet labels
This commit is contained in:
15 files changed
+411
-21
No files matched your search
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE known_devices SET display_name = ?1, updated_at_unix = ?2 WHERE device_id = ?3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "897b794a7f94af42b545d74a5b859850fc9935f40385061b029fab5892a2aded"
|
||||
}
|
||||
@@ -5,7 +5,7 @@ mod stats;
|
||||
|
||||
pub use devices::{
|
||||
attach_device_identifier, create_known_device, detach_device_identifier, forget_known_device,
|
||||
get_known_device, list_known_devices, merge_known_device,
|
||||
get_known_device, list_known_devices, merge_known_device, rename_known_device,
|
||||
};
|
||||
pub use enroll::{
|
||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||
|
||||
@@ -25,6 +25,38 @@ pub struct DeviceIdentifierRequest {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RenameKnownDeviceRequest {
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
pub async fn rename_known_device(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(device_id): AxumPath<String>,
|
||||
Json(req): Json<RenameKnownDeviceRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
match state
|
||||
.store
|
||||
.rename_known_device(&device_id, &req.display_name)
|
||||
.await
|
||||
{
|
||||
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
|
||||
Ok(None) => Err(ApiError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"known_device_not_found",
|
||||
"known device not found",
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to rename known device");
|
||||
Err(ApiError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"rename_known_device_failed",
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct KnownDeviceResponse {
|
||||
pub device_id: String,
|
||||
|
||||
@@ -26,6 +26,7 @@ pub(crate) struct AgentRuntimeStatus {
|
||||
struct FleetAccumulator {
|
||||
device_key: String,
|
||||
display_name: Option<String>,
|
||||
label_ip: Option<IpAddr>,
|
||||
known_device: Option<KnownDeviceSummary>,
|
||||
pinned: bool,
|
||||
ips: BTreeSet<IpAddr>,
|
||||
@@ -45,6 +46,7 @@ impl Default for FleetAccumulator {
|
||||
Self {
|
||||
device_key: String::new(),
|
||||
display_name: None,
|
||||
label_ip: None,
|
||||
known_device: None,
|
||||
pinned: false,
|
||||
ips: BTreeSet::new(),
|
||||
@@ -55,7 +57,8 @@ impl Default for FleetAccumulator {
|
||||
endpoints: Vec::new(),
|
||||
first_seen_unix: None,
|
||||
last_seen_unix: None,
|
||||
presence: Presence::Offline,
|
||||
// Start at the lowest signal so unknown-only evidence stays unknown.
|
||||
presence: Presence::Unknown,
|
||||
routes: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -194,6 +197,20 @@ fn add_agent_device_to_entry(
|
||||
agent_device: AgentDeviceWithChildren,
|
||||
context: &FleetBuildContext,
|
||||
) {
|
||||
// Retain address evidence for naming only, without promoting stale IPs into
|
||||
// the active summary or wake-route candidates.
|
||||
let label_ip = agent_device
|
||||
.ips
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(
|
||||
agent_device
|
||||
.endpoints
|
||||
.iter()
|
||||
.filter_map(|endpoint| endpoint.key.ip),
|
||||
)
|
||||
.min();
|
||||
entry.label_ip = entry.label_ip.into_iter().chain(label_ip).min();
|
||||
let device_offline = agent_device.device.presence() == Presence::Offline;
|
||||
if let Some(summary) = device_known_device(&agent_device, context)
|
||||
&& entry.known_device.is_none()
|
||||
@@ -473,8 +490,38 @@ impl FleetAccumulator {
|
||||
let display_name = self
|
||||
.display_name
|
||||
.or_else(|| self.hostnames.iter().next().cloned())
|
||||
.or_else(|| self.macs.iter().next().map(|mac| mac.to_string()))
|
||||
.or_else(|| self.ips.iter().next().map(|ip| ip.to_string()))
|
||||
.or_else(|| {
|
||||
// Summary IPs omit offline endpoints; labels must not discard
|
||||
// their address evidence or imply that unknown means offline.
|
||||
let address = self
|
||||
.ips
|
||||
.iter()
|
||||
.next()
|
||||
.copied()
|
||||
.or(self.label_ip)
|
||||
.or_else(|| {
|
||||
self.endpoints
|
||||
.iter()
|
||||
.filter_map(|endpoint| endpoint.ip)
|
||||
.min()
|
||||
})
|
||||
.map(|ip| ip.to_string())
|
||||
.or_else(|| self.macs.iter().next().map(|mac| mac.to_string()))
|
||||
.or_else(|| {
|
||||
self.endpoints
|
||||
.iter()
|
||||
.find_map(|endpoint| endpoint.mac)
|
||||
.map(|mac| mac.to_string())
|
||||
});
|
||||
address.map(|address| {
|
||||
let prefix = if self.presence == Presence::Offline {
|
||||
"Offline device"
|
||||
} else {
|
||||
"Device"
|
||||
};
|
||||
format!("{prefix}: {address}")
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "(unknown device)".to_string());
|
||||
|
||||
FleetDevice {
|
||||
|
||||
@@ -447,11 +447,48 @@ fn unknown_ip_only_offline_device_is_hidden_by_default() {
|
||||
);
|
||||
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].display_name, "(unknown device)");
|
||||
assert_eq!(devices[0].display_name, "Offline device: 192.168.1.2");
|
||||
filter_fleet_devices(&mut devices, &ListFleetDevicesQuery::default());
|
||||
assert!(devices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamed_endpoint_labels_preserve_presence_and_inactive_ips() {
|
||||
for (presence, mac, ip, expected) in [
|
||||
(
|
||||
Presence::Offline,
|
||||
None,
|
||||
Some("192.168.1.2"),
|
||||
"Offline device: 192.168.1.2",
|
||||
),
|
||||
(
|
||||
Presence::Unknown,
|
||||
None,
|
||||
Some("192.168.1.2"),
|
||||
"Device: 192.168.1.2",
|
||||
),
|
||||
(
|
||||
Presence::Offline,
|
||||
Some("aa:bb:cc:dd:ee:01"),
|
||||
None,
|
||||
"Offline device: AA:BB:CC:DD:EE:01",
|
||||
),
|
||||
] {
|
||||
let mut row = offline_ip_only_unknown("agent-a", "test", "192.168.1.2", 20);
|
||||
row.ips.clear();
|
||||
row.device.presence = presence.as_str().into();
|
||||
let mut evidence = endpoint_with_presence(EndpointSource::Neighbor, mac, ip, presence, 20);
|
||||
evidence.hostname = None;
|
||||
row.endpoints = vec![evidence];
|
||||
let devices = build_fleet_devices(Vec::new(), vec![row], &context(&["agent-a"]));
|
||||
assert_eq!(devices[0].display_name, expected);
|
||||
assert_eq!(devices[0].presence, presence);
|
||||
if presence == Presence::Offline {
|
||||
assert!(devices[0].ips.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_all_keeps_unknown_ip_only_offline_device() {
|
||||
let mut devices = build_fleet_devices(
|
||||
@@ -473,7 +510,7 @@ fn visibility_all_keeps_unknown_ip_only_offline_device() {
|
||||
},
|
||||
);
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].display_name, "(unknown device)");
|
||||
assert_eq!(devices[0].display_name, "Offline device: 192.168.1.2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,7 +15,8 @@ pub use control::{
|
||||
StateStatsResponse, attach_device_identifier, create_known_device, detach_device_identifier,
|
||||
enroll, forget_known_device, get_known_device, healthz, issue_enroll_token, list_enroll_tokens,
|
||||
list_fleet_devices, list_known_devices, merge_known_device, refresh_fleet_devices,
|
||||
revoke_agent, revoke_enroll_token, set_agent_nickname, state_stats, wake_fleet_device,
|
||||
rename_known_device, revoke_agent, revoke_enroll_token, set_agent_nickname, state_stats,
|
||||
wake_fleet_device,
|
||||
};
|
||||
pub use terminals::{
|
||||
agent_terminal_ws, attach_terminal, close_terminal, create_terminal, get_terminal,
|
||||
|
||||
@@ -113,7 +113,9 @@ fn control_api_routes() -> Router<AppState> {
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}",
|
||||
get(api::get_known_device).delete(api::forget_known_device),
|
||||
get(api::get_known_device)
|
||||
.patch(api::rename_known_device)
|
||||
.delete(api::forget_known_device),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}/merge",
|
||||
|
||||
@@ -355,6 +355,79 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_preserves_identity_and_forget_removes_identifiers_only() {
|
||||
let ts = TestStore::new().await;
|
||||
let store = ts.store();
|
||||
let device = store
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "lda".into(),
|
||||
pinned: true,
|
||||
notes: Some("keep notes".into()),
|
||||
identifiers: vec![DeviceIdentifierInput {
|
||||
kind: "mac".into(),
|
||||
value: "aa:bb:cc:dd:ee:01".into(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.rename_known_device(&device.device_id, " ")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let renamed = store
|
||||
.rename_known_device(&device.device_id, " Windows PC ")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(renamed.display_name, "Windows PC");
|
||||
assert_eq!(renamed.device_id, device.device_id);
|
||||
assert_eq!(renamed.identifiers.len(), 1);
|
||||
assert_eq!(renamed.notes, device.notes);
|
||||
assert!(renamed.pinned);
|
||||
assert!(
|
||||
store
|
||||
.rename_known_device("missing", "name")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let observed = wakey_core::Device::from_parts(
|
||||
vec![wakey_core::NeighborEntry {
|
||||
mac: Some("aa:bb:cc:dd:ee:01".parse().unwrap()),
|
||||
ip: "192.168.1.2".parse().unwrap(),
|
||||
dev: None,
|
||||
state: wakey_core::NeighborState::Reachable,
|
||||
}],
|
||||
Vec::new(),
|
||||
);
|
||||
store
|
||||
.replace_agent_device_snapshot("agent-a", &[observed])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.forget_known_device(&device.device_id).await.unwrap());
|
||||
assert!(
|
||||
store
|
||||
.get_known_device(&device.device_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.lookup_known_device_by_identifier(DeviceIdentifierInput {
|
||||
kind: "mac".into(),
|
||||
value: "aa:bb:cc:dd:ee:01".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(store.list_agent_device_rows().await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn known_device_can_hold_multiple_manual_mac_identifiers() {
|
||||
let ts = TestStore::new().await;
|
||||
|
||||
@@ -64,6 +64,29 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "db_rename_known_device", skip_all)]
|
||||
pub async fn rename_known_device(
|
||||
&self,
|
||||
device_id: &str,
|
||||
display_name: &str,
|
||||
) -> Result<Option<KnownDevice>> {
|
||||
let display_name = normalize_required_text(display_name, "display_name")?;
|
||||
let now = i64::try_from(now_unix()).context("known device timestamp overflow")?;
|
||||
let result = sqlx::query!(
|
||||
"UPDATE known_devices SET display_name = ?1, updated_at_unix = ?2 WHERE device_id = ?3",
|
||||
display_name,
|
||||
now,
|
||||
device_id
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.context("failed renaming known device")?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
self.get_known_device(device_id).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "db_forget_known_device", skip_all)]
|
||||
pub async fn forget_known_device(&self, device_id: &str) -> Result<bool> {
|
||||
let result = sqlx::query!("DELETE FROM known_devices WHERE device_id = ?1", device_id)
|
||||
|
||||
Reference in new issue
Block a user