fix(ui): keep device details open across identity changes
This commit is contained in:
12 files changed
+324
-32
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in new issue
Block a user