diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index f5fe4612ca..c4a70c453e 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -151,8 +151,6 @@ const DeviceUserPage = ({ const [showInfoModal, setShowInfoModal] = useState(false); const [showEnrollMdmModal, setShowEnrollMdmModal] = useState(false); const [enrollUrlError, setEnrollUrlError] = useState(null); - const [refetchStartTime, setRefetchStartTime] = useState(null); - const [showRefetchSpinner, setShowRefetchSpinner] = useState(false); const [selectedPolicy, setSelectedPolicy] = useState( null ); @@ -181,6 +179,11 @@ const DeviceUserPage = ({ const [sortCerts, setSortCerts] = useState({ ...CERTIFICATES_DEFAULT_SORT, }); + const [queuedSelfServiceRefetch, setQueuedSelfServiceRefetch] = useState( + false + ); + const [refetchStartTime, setRefetchStartTime] = useState(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 || ""} diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx index be43bc4981..e54c5f0201 100644 --- a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx @@ -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>(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>( + 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, ]); diff --git a/frontend/pages/hosts/details/cards/Software/helpers.tsx b/frontend/pages/hosts/details/cards/Software/helpers.tsx index 0fbcf7f870..e53e39ed1a 100644 --- a/frontend/pages/hosts/details/cards/Software/helpers.tsx +++ b/frontend/pages/hosts/details/cards/Software/helpers.tsx @@ -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 ): 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"; } }