feat(devices): add rename and forget controls and clarify fleet labels

This commit is contained in:
lda committed 2026-10-10 17:28:44 +07:00 Verified
1 parent acecb57b50
commit 7caba52e57
15 files changed
+410 -20

No files matched your search

@@ -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"
}
+3 -3
View File
@@ -22,10 +22,10 @@ live browser recovery and shutdown behavior are not established by those checks.
## Device Management And Presentation ## Device Management And Presentation
- [ ] Make **forget remembered device** reachable in the frontend. Verify the existing backend before adding routes: remove the known identity and its identifiers, not agent observations. An observed device may reappear as unknown. - [x] Make **forget remembered device** reachable in the frontend, with confirmation. The existing backend removes the known identity and its identifiers, not agent observations. An observed device may reappear as unknown.
- [ ] Add remembered-device rename support, with backend validation and frontend editing. - [x] Add remembered-device rename support, with backend validation and frontend editing (`PATCH /api/v1/control/devices/{device_id}`). Pinned state and identifiers are preserved.
- [ ] Check and complete editing of remembered-device notes. - [ ] Check and complete editing of remembered-device notes.
- [ ] Replace generic unnamed-row labels with useful fallbacks. Offline IP-only rows: `Offline device: <ip>`; unknown-presence IP-only rows: `Device: <ip>`. Use MAC fallback when no IP exists; retain a generic label only when neither exists. - [x] Replace generic unnamed-row labels with useful fallbacks. Offline IP-only rows: `Offline device: <ip>`; unknown-presence IP-only rows: `Device: <ip>`. Use MAC fallback when no IP exists; retain a generic label only when neither exists. Inactive endpoint addresses can label a row without becoming active IPs or wake routes. Unknown-only fleet groups remain unknown.
- [ ] Review frontend visibility defaults. Keep an explicit all-rows option; do not hide evidence just to fix poor labels. The user deliberately enabled all-row querying. - [ ] Review frontend visibility defaults. Keep an explicit all-rows option; do not hide evidence just to fix poor labels. The user deliberately enabled all-row querying.
Identifier detachment already exists in the current frontend source; it is **not** Identifier detachment already exists in the current frontend source; it is **not**
+21
View File
@@ -409,6 +409,27 @@ export function createKnownDevice(input: {
}); });
} }
export function renameKnownDevice(
deviceId: string,
displayName: string,
): Promise<KnownDevice> {
return request<KnownDevice>(
`/api/v1/control/devices/${encodeURIComponent(deviceId)}`,
{
method: "PATCH",
body: JSON.stringify({ display_name: displayName }),
},
);
}
export function forgetKnownDevice(
deviceId: string,
): Promise<{ device_id: string; forgotten: boolean }> {
return request(`/api/v1/control/devices/${encodeURIComponent(deviceId)}`, {
method: "DELETE",
});
}
export function attachDeviceIdentifier( export function attachDeviceIdentifier(
deviceId: string, deviceId: string,
identifier: { kind: string; value: string }, identifier: { kind: string; value: string },
+11 -2
View File
@@ -91,6 +91,14 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
if (seq !== loadSeqRef.current) return; if (seq !== loadSeqRef.current) return;
setDevices(nextDevices); setDevices(nextDevices);
setKnownDevices(nextKnownDevices); setKnownDevices(nextKnownDevices);
// Keep the open dialog current after rename/identifier mutations.
setDetails((current) =>
current
? (nextDevices.find(
(device) => device.device_key === current.device_key,
) ?? null)
: null,
);
} catch (err) { } catch (err) {
if (seq !== loadSeqRef.current) return; if (seq !== loadSeqRef.current) return;
toast.error("Failed to load fleet", { description: String(err) }); toast.error("Failed to load fleet", { description: String(err) });
@@ -263,6 +271,7 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
label="Identity" label="Identity"
value={known} value={known}
values={knownFilters} values={knownFilters}
labels={{ known: "saved", unknown: "unsaved" }}
onChange={(value) => setKnown(value as KnownFilter)} onChange={(value) => setKnown(value as KnownFilter)}
/> />
<label <label
@@ -404,9 +413,9 @@ function FleetDeviceRow({
<div className="truncate font-medium">{device.display_name}</div> <div className="truncate font-medium">{device.display_name}</div>
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{device.known_device ? ( {device.known_device ? (
<Badge variant="secondary">known</Badge> <Badge variant="secondary">saved</Badge>
) : ( ) : (
<Badge variant="outline">unknown</Badge> <Badge variant="outline">unsaved</Badge>
)} )}
{device.pinned && <Badge variant="outline">pinned</Badge>} {device.pinned && <Badge variant="outline">pinned</Badge>}
{device.sources.map((source) => ( {device.sources.map((source) => (
+4 -2
View File
@@ -27,11 +27,13 @@ export function FilterSelect({
label, label,
value, value,
values, values,
labels,
onChange, onChange,
}: { }: {
label: string; label: string;
value: string; value: string;
values: readonly string[]; values: readonly string[];
labels?: Readonly<Record<string, string>>;
onChange: (value: string) => void; onChange: (value: string) => void;
}) { }) {
return ( return (
@@ -39,12 +41,12 @@ export function FilterSelect({
<span>{label}</span> <span>{label}</span>
<Select value={value} onValueChange={(next) => next && onChange(next)}> <Select value={value} onValueChange={(next) => next && onChange(next)}>
<SelectTrigger> <SelectTrigger>
<span>{value.replace("_", " ")}</span> <span>{labels?.[value] ?? value.replace("_", " ")}</span>
</SelectTrigger> </SelectTrigger>
<SelectContent alignItemWithTrigger={false}> <SelectContent alignItemWithTrigger={false}>
{values.map((item) => ( {values.map((item) => (
<SelectItem key={item} value={item}> <SelectItem key={item} value={item}>
{item.replace("_", " ")} {labels?.[item] ?? item.replace("_", " ")}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
+125 -6
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Copy, Minus, Plus, Zap } from "lucide-react"; import { Copy, Minus, Plus, Zap, Pencil, Trash2, Check, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -7,6 +7,8 @@ import {
createKnownDevice, createKnownDevice,
detachDeviceIdentifier, detachDeviceIdentifier,
mergeKnownDevice, mergeKnownDevice,
renameKnownDevice,
forgetKnownDevice,
type FleetDevice, type FleetDevice,
type FleetDeviceEndpoint, type FleetDeviceEndpoint,
type KnownDevice, type KnownDevice,
@@ -68,6 +70,8 @@ export function FleetDeviceDetailsDialog({
const [addValue, setAddValue] = useState(""); const [addValue, setAddValue] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [actionBusy, setActionBusy] = useState(false); const [actionBusy, setActionBusy] = useState(false);
const [editingName, setEditingName] = useState(false);
const [forgetConfirm, setForgetConfirm] = useState(false);
const rowIdentifiers = device ? identifiersFor(device) : []; const rowIdentifiers = device ? identifiersFor(device) : [];
@@ -184,6 +188,24 @@ export function FleetDeviceDetailsDialog({
}); });
} }
async function handleRename() {
if (!fullKnown || !displayName.trim()) return;
await wrap(async () => {
await renameKnownDevice(fullKnown.device_id, displayName.trim());
setEditingName(false);
toast.success("Device renamed");
});
}
async function handleForget() {
if (!fullKnown) return;
await wrap(async () => {
await forgetKnownDevice(fullKnown.device_id);
toast.success("Remembered device forgotten");
onOpenChange(false);
});
}
const selectedRoute = const selectedRoute =
device.route_candidates.find((r) => r.route_id === routeId) ?? device.route_candidates.find((r) => r.route_id === routeId) ??
device.recommended_route; device.recommended_route;
@@ -309,9 +331,36 @@ export function FleetDeviceDetailsDialog({
{fullKnown && ( {fullKnown && (
<div className="grid gap-3"> <div className="grid gap-3">
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<Badge variant="secondary">known</Badge> <Badge variant="secondary">saved</Badge>
{fullKnown.pinned && <Badge variant="outline">pinned</Badge>} {fullKnown.pinned && <Badge variant="outline">pinned</Badge>}
<span className="font-medium">{fullKnown.display_name}</span> <span className="font-medium">{fullKnown.display_name}</span>
<Button
variant="ghost"
size="icon-sm"
title="Rename device"
aria-label="Rename device"
disabled={actionBusy}
onClick={() => {
setDisplayName(fullKnown.display_name);
setEditingName(true);
setForgetConfirm(false);
}}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Forget remembered device"
aria-label="Forget remembered device"
disabled={actionBusy}
onClick={() => {
setForgetConfirm(true);
setEditingName(false);
}}
>
<Trash2 className="size-3.5" />
</Button>
{fullKnown.notes && ( {fullKnown.notes && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
: {fullKnown.notes} : {fullKnown.notes}
@@ -319,6 +368,76 @@ export function FleetDeviceDetailsDialog({
)} )}
</div> </div>
{editingName && (
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
void handleRename();
}}
>
<Input
aria-label="Device name"
autoFocus
value={displayName}
disabled={actionBusy}
onChange={(event) => setDisplayName(event.target.value)}
/>
<Button
type="submit"
size="icon-sm"
title="Save name"
aria-label="Save name"
disabled={actionBusy || !displayName.trim()}
>
<Check className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
title="Cancel rename"
aria-label="Cancel rename"
disabled={actionBusy}
onClick={() => setEditingName(false)}
>
<X className="size-4" />
</Button>
</form>
)}
{forgetConfirm && (
<div
className="grid gap-2"
role="group"
aria-label="Confirm forget device"
>
<p className="text-sm">
Forget “{fullKnown.display_name}” and its remembered
identifiers? Agent observations remain; it may reappear as
an unsaved device.
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="destructive"
size="sm"
disabled={actionBusy}
onClick={() => void handleForget()}
>
<Trash2 className="size-4" />
Forget device
</Button>
<Button
variant="outline"
size="sm"
disabled={actionBusy}
onClick={() => setForgetConfirm(false)}
>
Cancel
</Button>
</div>
</div>
)}
{/* Current identifiers with remove buttons */} {/* Current identifiers with remove buttons */}
<div className="rounded-md border bg-muted/30 p-3"> <div className="rounded-md border bg-muted/30 p-3">
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground"> <div className="mb-2 text-xs font-medium uppercase text-muted-foreground">
@@ -434,7 +553,7 @@ export function FleetDeviceDetailsDialog({
<Separator /> <Separator />
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Merge this known device into another: Merge this saved device into another:
</p> </p>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2"> <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<Select <Select
@@ -479,7 +598,7 @@ export function FleetDeviceDetailsDialog({
{/* Remember as new known device */} {/* Remember as new known device */}
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Remember as a new known device: Remember as a new saved device:
</p> </p>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2"> <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<Input <Input
@@ -505,7 +624,7 @@ export function FleetDeviceDetailsDialog({
{/* Attach to existing known device */} {/* Attach to existing known device */}
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Or attach to an existing known device: Or attach to an existing saved device:
</p> </p>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2"> <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<Select <Select
@@ -518,7 +637,7 @@ export function FleetDeviceDetailsDialog({
? knownDevices.find( ? knownDevices.find(
(kd) => kd.device_id === targetDeviceId, (kd) => kd.device_id === targetDeviceId,
)?.display_name )?.display_name
: "Select known device"} : "Select saved device"}
</span> </span>
</SelectTrigger> </SelectTrigger>
<SelectContent <SelectContent
@@ -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"
}
+1 -1
View File
@@ -5,7 +5,7 @@ mod stats;
pub use devices::{ pub use devices::{
attach_device_identifier, create_known_device, detach_device_identifier, forget_known_device, 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::{ pub use enroll::{
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse, EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
@@ -25,6 +25,38 @@ pub struct DeviceIdentifierRequest {
pub value: String, 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)] #[derive(Debug, Serialize, Deserialize)]
pub struct KnownDeviceResponse { pub struct KnownDeviceResponse {
pub device_id: String, pub device_id: String,
@@ -26,6 +26,7 @@ pub(crate) struct AgentRuntimeStatus {
struct FleetAccumulator { struct FleetAccumulator {
device_key: String, device_key: String,
display_name: Option<String>, display_name: Option<String>,
label_ip: Option<IpAddr>,
known_device: Option<KnownDeviceSummary>, known_device: Option<KnownDeviceSummary>,
pinned: bool, pinned: bool,
ips: BTreeSet<IpAddr>, ips: BTreeSet<IpAddr>,
@@ -45,6 +46,7 @@ impl Default for FleetAccumulator {
Self { Self {
device_key: String::new(), device_key: String::new(),
display_name: None, display_name: None,
label_ip: None,
known_device: None, known_device: None,
pinned: false, pinned: false,
ips: BTreeSet::new(), ips: BTreeSet::new(),
@@ -55,7 +57,8 @@ impl Default for FleetAccumulator {
endpoints: Vec::new(), endpoints: Vec::new(),
first_seen_unix: None, first_seen_unix: None,
last_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(), routes: BTreeMap::new(),
} }
} }
@@ -194,6 +197,20 @@ fn add_agent_device_to_entry(
agent_device: AgentDeviceWithChildren, agent_device: AgentDeviceWithChildren,
context: &FleetBuildContext, 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; let device_offline = agent_device.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()
@@ -473,8 +490,38 @@ impl FleetAccumulator {
let display_name = self let display_name = self
.display_name .display_name
.or_else(|| self.hostnames.iter().next().cloned()) .or_else(|| self.hostnames.iter().next().cloned())
.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.macs.iter().next().map(|mac| mac.to_string()))
.or_else(|| self.ips.iter().next().map(|ip| ip.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()); .unwrap_or_else(|| "(unknown device)".to_string());
FleetDevice { FleetDevice {
@@ -447,11 +447,48 @@ fn unknown_ip_only_offline_device_is_hidden_by_default() {
); );
assert_eq!(devices.len(), 1); 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()); filter_fleet_devices(&mut devices, &ListFleetDevicesQuery::default());
assert!(devices.is_empty()); 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] #[test]
fn visibility_all_keeps_unknown_ip_only_offline_device() { fn visibility_all_keeps_unknown_ip_only_offline_device() {
let mut devices = build_fleet_devices( 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.len(), 1);
assert_eq!(devices[0].display_name, "(unknown device)"); assert_eq!(devices[0].display_name, "Offline device: 192.168.1.2");
} }
#[test] #[test]
+2 -1
View File
@@ -15,7 +15,8 @@ pub use control::{
StateStatsResponse, attach_device_identifier, create_known_device, detach_device_identifier, StateStatsResponse, attach_device_identifier, create_known_device, detach_device_identifier,
enroll, forget_known_device, get_known_device, healthz, issue_enroll_token, list_enroll_tokens, 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, 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::{ pub use terminals::{
agent_terminal_ws, attach_terminal, close_terminal, create_terminal, get_terminal, agent_terminal_ws, attach_terminal, close_terminal, create_terminal, get_terminal,
+3 -1
View File
@@ -113,7 +113,9 @@ fn control_api_routes() -> Router<AppState> {
) )
.route( .route(
"/api/v1/control/devices/{device_id}", "/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( .route(
"/api/v1/control/devices/{device_id}/merge", "/api/v1/control/devices/{device_id}/merge",
+73
View File
@@ -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] #[tokio::test]
async fn known_device_can_hold_multiple_manual_mac_identifiers() { async fn known_device_can_hold_multiple_manual_mac_identifiers() {
let ts = TestStore::new().await; 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)] #[tracing::instrument(name = "db_forget_known_device", skip_all)]
pub async fn forget_known_device(&self, device_id: &str) -> Result<bool> { 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) let result = sqlx::query!("DELETE FROM known_devices WHERE device_id = ?1", device_id)