Fleet UI: Update all button queues another refetch if an update finishes during a refetch (#35997)

This commit is contained in:
RachelElysia
2025-11-20 11:28:04 -05:00
committed by GitHub
parent 425a8b7e41
commit 4074320ae4
3 changed files with 119 additions and 31 deletions
@@ -151,8 +151,6 @@ const DeviceUserPage = ({
const [showInfoModal, setShowInfoModal] = useState(false);
const [showEnrollMdmModal, setShowEnrollMdmModal] = useState(false);
const [enrollUrlError, setEnrollUrlError] = useState<string | null>(null);
const [refetchStartTime, setRefetchStartTime] = useState<number | null>(null);
const [showRefetchSpinner, setShowRefetchSpinner] = useState(false);
const [selectedPolicy, setSelectedPolicy] = useState<IHostPolicy | null>(
null
);
@@ -181,6 +179,11 @@ const DeviceUserPage = ({
const [sortCerts, setSortCerts] = useState<IListSort>({
...CERTIFICATES_DEFAULT_SORT,
});
const [queuedSelfServiceRefetch, setQueuedSelfServiceRefetch] = useState(
false
);
const [refetchStartTime, setRefetchStartTime] = useState<number | null>(null);
const [showRefetchSpinner, setShowRefetchSpinner] = useState(false);
const { data: deviceMacAdminsData } = useQuery(
["macadmins", deviceAuthToken],
@@ -301,7 +304,7 @@ const DeviceUserPage = ({
}
} else {
const totalElapsedTime = Date.now() - refetchStartTime;
if (totalElapsedTime < 60000) {
if (totalElapsedTime < 180000) {
if (responseHost.status === "online") {
setTimeout(() => {
refetchHostDetails();
@@ -464,20 +467,44 @@ const DeviceUserPage = ({
}, [showPolicyDetailsModal, setShowPolicyDetailsModal, setSelectedPolicy]);
// User-initiated refetch always starts a new timer!
const onRefetchHost = async () => {
if (host) {
setShowRefetchSpinner(true);
try {
await deviceUserAPI.refetch(deviceAuthToken);
setRefetchStartTime(Date.now()); // Always reset on user action
setTimeout(() => {
refetchHostDetails();
refetchExtensions();
}, REFETCH_HOST_DETAILS_POLLING_INTERVAL);
} catch (error) {
renderFlash("error", getErrorMessage(error, host.display_name));
resetHostRefetchStates();
}
const onRefetchHost = useCallback(async () => {
if (!host) return;
setShowRefetchSpinner(true);
try {
await deviceUserAPI.refetch(deviceAuthToken);
setRefetchStartTime(Date.now());
setTimeout(() => {
refetchHostDetails();
refetchExtensions();
}, REFETCH_HOST_DETAILS_POLLING_INTERVAL);
} catch (error) {
renderFlash("error", getErrorMessage(error, host.display_name));
resetHostRefetchStates();
}
}, [
host,
deviceAuthToken,
refetchHostDetails,
refetchExtensions,
renderFlash,
]);
// Handles the queue: If there's a queued refetch and not actively refetching, run refetch
useEffect(() => {
if (queuedSelfServiceRefetch && !showRefetchSpinner) {
setQueuedSelfServiceRefetch(false);
onRefetchHost();
}
}, [queuedSelfServiceRefetch, showRefetchSpinner, onRefetchHost]);
// Triggered when a software update finishes
const requestRefetch = () => {
// If a refetch is already happening, queue this refetch
if (showRefetchSpinner) {
setQueuedSelfServiceRefetch(true);
} else {
// Otherwise, run it now
onRefetchHost();
}
};
@@ -622,7 +649,7 @@ const DeviceUserPage = ({
pathname={location.pathname}
queryParams={parseSelfServiceQueryParams(location.query)}
router={router}
refetchHostDetails={refetchHostDetails}
refetchHostDetails={requestRefetch}
isHostDetailsPolling={showRefetchSpinner}
hostSoftwareUpdatedAt={host.software_updated_at}
hostDisplayName={host?.hostname || ""}
@@ -699,7 +726,7 @@ const DeviceUserPage = ({
pathname={location.pathname}
queryParams={parseSelfServiceQueryParams(location.query)}
router={router}
refetchHostDetails={refetchHostDetails}
refetchHostDetails={requestRefetch}
isHostDetailsPolling={showRefetchSpinner}
hostSoftwareUpdatedAt={host.software_updated_at}
hostDisplayName={host?.hostname || ""}
@@ -143,6 +143,13 @@ const SoftwareSelfService = ({
}: ISoftwareSelfServiceProps) => {
const { renderFlash, renderMultiFlash } = useContext(NotificationContext);
/** Used to track software IDs for which the user has initiated an action (install/uninstall) */
const userActionIdsRef = useRef<Set<number>>(new Set());
/** Registers a software ID as user-initiated action */
const registerUserAction = useCallback((id: number) => {
userActionIdsRef.current.add(id);
}, []);
const [selfServiceData, setSelfServiceData] = useState<
IGetDeviceSoftwareResponse | undefined
>(undefined);
@@ -175,14 +182,22 @@ const SoftwareSelfService = ({
const [showOpenInstructionsModal, setShowOpenInstructionsModal] = useState(
false
);
const [recentlyUpdatedIds, setRecentlyUpdatedIds] = useState<Set<number>>(
new Set()
);
const enhancedSoftware: IDeviceSoftwareWithUiStatus[] = useMemo(() => {
if (!selfServiceData) return [];
return selfServiceData.software.map((software) => ({
...software,
ui_status: getUiStatus(software, true, hostSoftwareUpdatedAt),
ui_status: getUiStatus(
software,
true,
hostSoftwareUpdatedAt,
recentlyUpdatedIds
),
}));
}, [selfServiceData, hostSoftwareUpdatedAt]);
}, [selfServiceData, recentlyUpdatedIds, hostSoftwareUpdatedAt]);
const selectedSoftwareForUninstall = useRef<{
softwareId: number;
@@ -264,9 +279,42 @@ const SoftwareSelfService = ({
.map((software) => String(software.id))
);
// Refresh host details if the number of pending installs or uninstalls has decreased
// To update the software library information
if (newPendingSet.size < pendingSoftwareSetRef.current.size) {
// Compare new set with the previous set
const previouslyPending = [...pendingSoftwareSetRef.current];
const completedAppIds = previouslyPending.filter(
(id) => !newPendingSet.has(id)
);
if (completedAppIds.length > 0) {
// Some pending installs/uninstalls finished during the last refresh
// Mark as recently updated if the user actually initiated the action
// so the UI shows the "recently updated" status instead of "update available"
// or similar for install/uninstall
setRecentlyUpdatedIds((prev) => {
const next = new Set(prev);
completedAppIds.forEach((idStr) => {
const id = Number(idStr);
if (userActionIdsRef.current.has(id)) {
next.add(id);
// Remove from the userActionIdsRef
userActionIdsRef.current.delete(id);
// Schedule autoremoval after 2 minutes so the "recently updated" status is not permanent
// It's only surfaced to user as a ui_status if this action completed prior to a
// host details refresh but may not be reflected in host details data returned
setTimeout(() => {
setRecentlyUpdatedIds((latest) => {
const cleared = new Set(latest);
cleared.delete(id);
return cleared;
});
}, 120000);
}
});
return next;
});
// Some pending installs finished during the last refresh
// Trigger an additional refetch to ensure UI status is up-to-date
// If already refetching, queue another refetch
refetchHostDetails();
}
@@ -372,6 +420,7 @@ const SoftwareSelfService = ({
await deviceApi.installSelfServiceSoftware(deviceToken, softwareId);
if (isMountedRef.current) {
onInstallOrUninstall();
registerUserAction(softwareId);
}
} catch (error) {
// We only show toast message if API returns an error
@@ -381,7 +430,7 @@ const SoftwareSelfService = ({
);
}
},
[deviceToken, onInstallOrUninstall, renderFlash]
[deviceToken, onInstallOrUninstall, registerUserAction, renderFlash]
);
const onClickUninstallAction = useCallback(
@@ -414,13 +463,14 @@ const SoftwareSelfService = ({
async (id: number) => {
try {
await deviceApi.installSelfServiceSoftware(deviceToken, id);
registerUserAction(id);
onInstallOrUninstall();
} catch (error) {
// Only show toast message if API returns an error
renderFlash("error", "Couldn't update software. Please try again.");
}
},
[deviceToken, onInstallOrUninstall, renderFlash]
[deviceToken, registerUserAction, onInstallOrUninstall, renderFlash]
);
const onClickUpdateAll = useCallback(async () => {
@@ -467,13 +517,20 @@ const SoftwareSelfService = ({
});
}
// Refresh the data after updates triggered
// Only register success IDs for followup “recently updated” handling
results.forEach((result, idx) => {
if (result.status === "fulfilled") {
registerUserAction(updateAvailableSoftware[idx].id);
}
});
// Refresh data after update is triggered
onInstallOrUninstall();
}, [
deviceToken,
renderFlash,
renderMultiFlash,
enhancedSoftware,
registerUserAction,
onInstallOrUninstall,
]);
@@ -171,7 +171,8 @@ const getNewerDate = (dateStr1: string, dateStr2: string) => {
export const getUiStatus = (
software: IHostSoftware,
isHostOnline: boolean,
hostSoftwareUpdatedAt?: string | null
hostSoftwareUpdatedAt?: string | null,
recentlyUpdatedIds?: Set<number>
): IHostSoftwareUiStatus => {
const { status, installed_versions, source } = software;
@@ -179,6 +180,9 @@ export const getUiStatus = (
const lastUninstallDate = getLastUninstall(software)?.uninstalled_at;
const installerVersion = getInstallerVersion(software);
const isScriptPackage = SCRIPT_PACKAGE_SOURCES.includes(source);
/** True if a recent user-initiated action (install/uninstall) was detected for this software */
const recentUserActionDetected =
recentlyUpdatedIds && recentlyUpdatedIds.has(software.id);
// 0. Script Packages states
if (isScriptPackage) {
@@ -247,7 +251,7 @@ export const getUiStatus = (
// **Recently_uninstalled check comes BEFORE update_available**
if (software.status === null && lastUninstallDate && hostSoftwareUpdatedAt) {
const newerDate = getNewerDate(hostSoftwareUpdatedAt, lastUninstallDate);
if (newerDate === lastUninstallDate) {
if (newerDate === lastUninstallDate || recentUserActionDetected) {
return "recently_uninstalled";
}
}
@@ -266,7 +270,7 @@ export const getUiStatus = (
const newerDate = hostSoftwareUpdatedAt
? getNewerDate(hostSoftwareUpdatedAt, lastInstallDate)
: lastInstallDate;
return newerDate === lastInstallDate
return newerDate === lastInstallDate || recentUserActionDetected
? "recently_updated"
: "update_available";
}
@@ -278,7 +282,7 @@ export const getUiStatus = (
hostSoftwareUpdatedAt
) {
const newerDate = getNewerDate(hostSoftwareUpdatedAt, lastInstallDate);
if (newerDate === lastInstallDate) {
if (newerDate === lastInstallDate || recentUserActionDetected) {
return "recently_installed";
}
}