Fleet UI: Update all button queues another refetch if an update finishes during a refetch (#35997)
This commit is contained in:
@@ -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 auto‑removal 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 follow‑up “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";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user