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
+411 -21

No files matched your search

+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(
deviceId: 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;
setDevices(nextDevices);
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) {
if (seq !== loadSeqRef.current) return;
toast.error("Failed to load fleet", { description: String(err) });
@@ -263,6 +271,7 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
label="Identity"
value={known}
values={knownFilters}
labels={{ known: "saved", unknown: "unsaved" }}
onChange={(value) => setKnown(value as KnownFilter)}
/>
<label
@@ -404,9 +413,9 @@ function FleetDeviceRow({
<div className="truncate font-medium">{device.display_name}</div>
<div className="mt-1 flex flex-wrap gap-1">
{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.sources.map((source) => (
+4 -2
View File
@@ -27,11 +27,13 @@ export function FilterSelect({
label,
value,
values,
labels,
onChange,
}: {
label: string;
value: string;
values: readonly string[];
labels?: Readonly<Record<string, string>>;
onChange: (value: string) => void;
}) {
return (
@@ -39,12 +41,12 @@ export function FilterSelect({
<span>{label}</span>
<Select value={value} onValueChange={(next) => next && onChange(next)}>
<SelectTrigger>
<span>{value.replace("_", " ")}</span>
<span>{labels?.[value] ?? value.replace("_", " ")}</span>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{values.map((item) => (
<SelectItem key={item} value={item}>
{item.replace("_", " ")}
{labels?.[item] ?? item.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
+125 -6
View File
@@ -1,5 +1,5 @@
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 {
@@ -7,6 +7,8 @@ import {
createKnownDevice,
detachDeviceIdentifier,
mergeKnownDevice,
renameKnownDevice,
forgetKnownDevice,
type FleetDevice,
type FleetDeviceEndpoint,
type KnownDevice,
@@ -68,6 +70,8 @@ export function FleetDeviceDetailsDialog({
const [addValue, setAddValue] = useState("");
const [error, setError] = useState("");
const [actionBusy, setActionBusy] = useState(false);
const [editingName, setEditingName] = useState(false);
const [forgetConfirm, setForgetConfirm] = useState(false);
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 =
device.route_candidates.find((r) => r.route_id === routeId) ??
device.recommended_route;
@@ -309,9 +331,36 @@ export function FleetDeviceDetailsDialog({
{fullKnown && (
<div className="grid gap-3">
<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>}
<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 && (
<span className="text-muted-foreground">
: {fullKnown.notes}
@@ -319,6 +368,76 @@ export function FleetDeviceDetailsDialog({
)}
</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 */}
<div className="rounded-md border bg-muted/30 p-3">
<div className="mb-2 text-xs font-medium uppercase text-muted-foreground">
@@ -434,7 +553,7 @@ export function FleetDeviceDetailsDialog({
<Separator />
<div className="grid gap-1.5">
<p className="text-xs text-muted-foreground">
Merge this known device into another:
Merge this saved device into another:
</p>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<Select
@@ -479,7 +598,7 @@ export function FleetDeviceDetailsDialog({
{/* Remember as new known device */}
<div className="grid gap-1.5">
<p className="text-xs text-muted-foreground">
Remember as a new known device:
Remember as a new saved device:
</p>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<Input
@@ -505,7 +624,7 @@ export function FleetDeviceDetailsDialog({
{/* Attach to existing known device */}
<div className="grid gap-1.5">
<p className="text-xs text-muted-foreground">
Or attach to an existing known device:
Or attach to an existing saved device:
</p>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<Select
@@ -518,7 +637,7 @@ export function FleetDeviceDetailsDialog({
? knownDevices.find(
(kd) => kd.device_id === targetDeviceId,
)?.display_name
: "Select known device"}
: "Select saved device"}
</span>
</SelectTrigger>
<SelectContent