fix(ui): keep device details open across identity changes

This commit is contained in:
lda committed 2026-10-12 23:59:23 +07:00 Verified
1 parent 4eef8c758f
commit 4e290e6de8
12 files changed
+324 -32

No files matched your search

+12
View File
@@ -339,6 +339,12 @@ export function fetchKnownDevices(): Promise<KnownDevice[]> {
return request<KnownDevice[]>("/api/v1/control/devices");
}
export function fetchKnownDevice(deviceId: string): Promise<KnownDevice> {
return request<KnownDevice>(
`/api/v1/control/devices/${encodeURIComponent(deviceId)}`,
);
}
export function fetchFleetDevices(opts?: {
query?: string;
presence?: string;
@@ -361,6 +367,12 @@ export function fetchFleetDevices(opts?: {
);
}
export function fetchFleetDevice(deviceKey: string): Promise<FleetDevice> {
return request<FleetDevice>(
`/api/v1/control/fleet/devices/${encodeURIComponent(deviceKey)}`,
);
}
export function refreshFleetDevices(input?: {
agentIds?: string[];
timeoutMs?: number;
+63 -13
View File
@@ -4,7 +4,9 @@ import { toast } from "sonner";
import {
fetchFleetDevices,
fetchFleetDevice,
fetchKnownDevices,
fetchKnownDevice,
refreshFleetDevices,
wakeFleetDevice,
type Agent,
@@ -70,6 +72,15 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
const [wakeBusyKey, setWakeBusyKey] = useState("");
const [details, setDetails] = useState<FleetDevice | null>(null);
const loadSeqRef = useRef(0);
const detailsKeyRef = useRef<string | null>(null);
const detailsSeqRef = useRef(0);
function selectDetails(device: FleetDevice | null) {
// Closing or selecting another row invalidates in-flight detail refreshes.
detailsSeqRef.current += 1;
detailsKeyRef.current = device?.device_key ?? null;
setDetails(device);
}
async function loadFleet() {
// Latest request wins: filter changes and debounced searches overlap, and
@@ -91,14 +102,6 @@ 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) });
@@ -107,6 +110,51 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
}
}
async function refreshView(
originKey: string | null = detailsKeyRef.current,
knownDeviceId?: string,
) {
if (!originKey || originKey !== detailsKeyRef.current) {
await loadFleet();
return;
}
// Mutations supply an explicit identity; never infer it from a shared IP.
// Details use their own read, so list filters and limits cannot close them.
const key = knownDeviceId ? `known:${knownDeviceId}` : originKey;
detailsKeyRef.current = key;
const seq = ++detailsSeqRef.current;
try {
const [device] = await Promise.all([fetchFleetDevice(key), loadFleet()]);
if (seq !== detailsSeqRef.current || detailsKeyRef.current !== key)
return;
const saved = device.known_device
? await fetchKnownDevice(device.known_device.device_id)
: null;
if (seq === detailsSeqRef.current && detailsKeyRef.current === key) {
// The dialog's full identity must stay current even if the list refresh
// failed. Dropdown options may come from the list, not mutation controls.
if (saved) {
setKnownDevices((current) =>
current.some((item) => item.device_id === saved.device_id)
? current.map((item) =>
item.device_id === saved.device_id ? saved : item,
)
: [...current, saved],
);
}
setDetails(device);
}
} catch (err) {
if (seq !== detailsSeqRef.current || detailsKeyRef.current !== key)
return;
// A missing/failed identity read must not leave stale mutation controls.
selectDetails(null);
toast.error("Failed to refresh device details", {
description: String(err),
});
}
}
async function refreshAllConnected() {
setRefreshing(true);
try {
@@ -116,7 +164,7 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
? `Refreshed ${result.total_accepted} devices; ${failed.length} agent(s) failed`
: `Refreshed ${result.total_accepted} devices from ${result.agents.length} connected agents`;
toast.success(msg);
await loadFleet();
await refreshView();
} catch (err) {
toast.error("Fleet refresh failed", { description: String(err) });
} finally {
@@ -135,7 +183,7 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
const msg = `Wake sent via ${agentLabel(result.route.agent_id, result.route.nickname)} (${result.route.mac ?? "no mac"})`;
toast.success(msg);
if (onAfterWake) await onAfterWake();
await loadFleet();
await refreshView();
} catch (err) {
toast.error("Wake failed", { description: String(err) });
} finally {
@@ -347,7 +395,7 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
device={device}
busy={wakeBusyKey === device.device_key}
onWake={() => void wakeDevice(device)}
onDetails={() => setDetails(device)}
onDetails={() => selectDetails(device)}
onCopy={(label, value) => void copyValue(label, value)}
/>
))}
@@ -371,13 +419,15 @@ export function DevicesPage({ agents, onAfterWake, onRefresh }: Props) {
open={Boolean(details)}
busy={wakeBusyKey === details?.device_key}
onOpenChange={(open) => {
if (!open) setDetails(null);
if (!open) selectDetails(null);
}}
onWake={(routeId) => {
if (details) void wakeDevice(details, routeId);
}}
onCopy={(label, value) => void copyValue(label, value)}
onChanged={() => void loadFleet()}
onChanged={(knownDeviceId) =>
refreshView(details?.device_key ?? null, knownDeviceId)
}
/>
</section>
);
+16 -10
View File
@@ -48,7 +48,7 @@ type Props = {
onOpenChange: (open: boolean) => void;
onWake: (routeId?: string | null) => void;
onCopy: (label: string, value: string) => void;
onChanged: () => void;
onChanged: (knownDeviceId?: string) => Promise<void>;
};
export function FleetDeviceDetailsDialog({
@@ -99,18 +99,20 @@ export function FleetDeviceDetailsDialog({
(kd) => kd.device_id !== currentDevice.known_device?.device_id,
);
async function wrap(fn: () => Promise<void | { changed?: boolean }>) {
async function wrap(
fn: () => Promise<void | { changed?: boolean; knownDeviceId?: string }>,
) {
setError("");
setActionBusy(true);
try {
const res = await fn();
if (!res || res.changed !== false) {
onChanged();
await onChanged(res?.knownDeviceId);
}
} catch (err: any) {
setError(String(err));
if (err && (err.changed || err.partial)) {
onChanged();
await onChanged(err.knownDeviceId);
}
} finally {
setActionBusy(false);
@@ -125,6 +127,7 @@ export function FleetDeviceDetailsDialog({
identifiers: rowIdentifiers,
});
toast.success(`Remembered as "${result.display_name}"`);
return { knownDeviceId: result.device_id };
});
}
@@ -133,7 +136,7 @@ export function FleetDeviceDetailsDialog({
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
await wrap(async () => {
const ids = fullKnown ? unattachedIdentifiers : rowIdentifiers;
if (ids.length === 0) return;
if (ids.length === 0) return { changed: false };
const results = await Promise.allSettled(
ids.map((id) => attachDeviceIdentifier(targetDeviceId, id)),
@@ -144,14 +147,17 @@ export function FleetDeviceDetailsDialog({
if (errors.length) {
const err = errors[0].reason as Error;
if (changed) (err as any).changed = true;
if (changed) {
(err as any).changed = true;
(err as any).knownDeviceId = targetDeviceId;
}
throw err;
}
toast.success(
`Attached ${ids.length} identifier(s) to "${target?.display_name ?? targetDeviceId}"`,
);
return { changed };
return { changed, knownDeviceId: targetDeviceId };
});
}
@@ -159,12 +165,12 @@ export function FleetDeviceDetailsDialog({
if (!targetDeviceId || !currentDevice.known_device) return;
const target = knownDevices.find((kd) => kd.device_id === targetDeviceId);
await wrap(async () => {
await mergeKnownDevice(
const merged = await mergeKnownDevice(
targetDeviceId,
currentDevice.known_device!.device_id,
);
toast.success(`Merged into "${target?.display_name ?? targetDeviceId}"`);
onOpenChange(false);
return { knownDeviceId: merged.device_id };
});
}
@@ -297,7 +303,7 @@ export function FleetDeviceDetailsDialog({
<Button
size="sm"
onClick={() => onWake(selectedRoute?.route_id ?? null)}
disabled={busy || !selectedRoute?.wakeable}
disabled={actionBusy || busy || !selectedRoute?.wakeable}
>
<Zap className="size-4" aria-hidden />
{busy ? "Waking" : "Wake"}