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
@@ -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 },
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user