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"
|
||||||
|
}
|
||||||
+3
-3
@@ -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**
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+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::{
|
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(|| self.macs.iter().next().map(|mac| mac.to_string()))
|
.or_else(|| {
|
||||||
.or_else(|| self.ips.iter().next().map(|ip| ip.to_string()))
|
// 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());
|
.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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user