diff --git a/nginx.conf b/nginx.conf index eb25c72cf..fe5386233 100644 --- a/nginx.conf +++ b/nginx.conf @@ -38,4 +38,12 @@ server { index index.html; try_files $uri $uri/ /index.html; } + + # the page itself is checked on every load, so a deploy reaches browsers at once; the scripts it loads have + # hashed names and may stay cached. add_header here replaces the server's ones, hence HSTS again + location = /index.html { + root /usr/share/nginx/html; + add_header Cache-Control "no-cache" always; + add_header Strict-Transport-Security "max-age=63072000" always; + } } \ No newline at end of file diff --git a/src/JsRoot/GraphData.tsx b/src/JsRoot/GraphData.tsx index ea9c8796f..282b11ee2 100644 --- a/src/JsRoot/GraphData.tsx +++ b/src/JsRoot/GraphData.tsx @@ -61,6 +61,7 @@ export type Estimator = { name: string; metadata?: unknown; pages: Page[]; + outputUuid?: string; scoringOutputJsonRef?: ScoringOutputJSON; }; diff --git a/src/WrapperApp/components/Results/EstimatorsSelect/EstimatorsSelect.tsx b/src/WrapperApp/components/Results/EstimatorsSelect/EstimatorsSelect.tsx index f7e4a1d04..6db58bc3b 100644 --- a/src/WrapperApp/components/Results/EstimatorsSelect/EstimatorsSelect.tsx +++ b/src/WrapperApp/components/Results/EstimatorsSelect/EstimatorsSelect.tsx @@ -1,5 +1,5 @@ import { useTheme } from '@mui/material'; -import { SyntheticEvent, useCallback, useEffect, useState } from 'react'; +import { SyntheticEvent, useCallback, useEffect, useRef, useState } from 'react'; import { useRemoteWorkerSimulation } from '../../../../services/RemoteWorkerSimulationContextProvider'; import { ResultsSimulationDataWithSource } from '../../../../services/StoreService'; @@ -7,6 +7,7 @@ import { StyledTab, StyledTabs } from '../../../../shared/components/Tabs/Styled import { SpecificEstimator } from '../../../../types/RequestTypes'; import { EstimatorPagesByDimensions } from '../../../../types/ResponseTypes'; import { FullSimulationData } from '../../../../types/SimulationService'; +import { partialRevision } from '../../../../util/partialResults'; interface EstimatorsTabProps { tabsValue: number; @@ -31,12 +32,23 @@ const EstimatorsSelect = ({ const [controller] = useState(new AbortController()); const { getEstimatorsPages } = useRemoteWorkerSimulation(); + const revision = partialRevision(simulation); + // the partial results revision each tab was loaded at, so a newer partial or the final results load it again; + // the estimators there at mount were loaded at the revision then (the component is keyed by simulation) + const loadedRevisions = useRef(new Map()); + const mountRevision = useRef(revision); const handleEstimatorTabChange = useCallback( - async (id: number) => { + async (id: number, isStale: () => boolean) => { const currentEstimatorData = simulation.estimators; + const loadedRevision = loadedRevisions.current.has(id) + ? loadedRevisions.current.get(id) + : mountRevision.current; + const estimatorExists = - currentEstimatorData[id] && currentEstimatorData[id].name !== ''; + currentEstimatorData[id] && + currentEstimatorData[id].name !== '' && + loadedRevision === revision; if (estimatorsPagesData) { const estimatorsPagesByDimensions = estimatorsPagesData[id].pagesByDimensions; @@ -50,7 +62,8 @@ const EstimatorsSelect = ({ { jobId: simulationJobId, estimatorName: estimatorsTabData[id], - pageNumbers: pageDimension.pageNums + pageNumbers: pageDimension.pageNums, + revision }, controller.signal, true @@ -61,7 +74,8 @@ const EstimatorsSelect = ({ } } - if (allResults.length === 0) return; + // a newer simulation or tab is being loaded meanwhile + if (allResults.length === 0 || isStale()) return; const aggregationResults = allResults.reduce( (acc, result) => { @@ -97,7 +111,9 @@ const EstimatorsSelect = ({ ...simulation, estimators: newEstimators }; - setResultsSimulationData({ source: 'onSelect', data: newSimulation }); + loadedRevisions.current.set(id, revision); + // not onSelect: a new partial loaded while another screen is open must not switch to Results + setResultsSimulationData({ source: 'onLoad', data: newSimulation }); } } }, @@ -106,14 +122,21 @@ const EstimatorsSelect = ({ estimatorsPagesData, estimatorsTabData, getEstimatorsPages, + revision, setResultsSimulationData, simulation ] ); + // the tab on screen, whenever it or the simulation changes useEffect(() => { - handleEstimatorTabChange(0); - }, [handleEstimatorTabChange]); + let stale = false; + handleEstimatorTabChange(tabsValue, () => stale); + + return () => { + stale = true; + }; + }, [handleEstimatorTabChange, tabsValue]); const handleChange = (_event: SyntheticEvent, newValue: number) => { setTabsValue(newValue); @@ -131,7 +154,6 @@ const EstimatorsSelect = ({ key={`tab_${estimatorName}`} label={estimatorName} value={id} - onClick={() => handleEstimatorTabChange(id)} /> ); })} diff --git a/src/WrapperApp/components/Results/ResultsPanel.tsx b/src/WrapperApp/components/Results/ResultsPanel.tsx index 5ed575aca..c181b2391 100644 --- a/src/WrapperApp/components/Results/ResultsPanel.tsx +++ b/src/WrapperApp/components/Results/ResultsPanel.tsx @@ -17,8 +17,10 @@ import { useStore } from '../../../services/StoreService'; import { InfoTooltip } from '../../../shared/components/tooltip/InfoTooltip'; import { titleToKebabCase } from '../../../ThreeEditor/components/Dialog/CustomDialog'; import { FullSimulationData } from '../../../types/SimulationService'; +import { partialResultsLabel } from '../../../util/partialResults'; import EstimatorsSelect from './EstimatorsSelect/EstimatorsSelect'; import EstimatorTab from './EstimatorTab/EstimatorTab'; +import { useFollowPartialResults } from './useFollowPartialResults'; export interface EstimatorResults extends Estimator { tablePages: Page0D[]; @@ -38,8 +40,29 @@ function ResultsPanel(props: { simulation: FullSimulationData | undefined }) { const { simulation } = props; const [tabsValue, setTabsValue] = useState(0); + // another simulation starts at its first estimator; reset while rendering, before EstimatorsSelect loads a tab + const [shownJobId, setShownJobId] = useState(simulation?.jobId); + + if (simulation?.jobId !== shownJobId) { + setShownJobId(simulation?.jobId); + setTabsValue(0); + } + const [estimatorsResults, setEstimatorsResults] = useState([]); const [groupQuantities, setGroupQuantities] = useState(false); + // re-rendered now and then, so the age of partial results stays right between updates + const [, setNow] = useState(Date.now()); + const partialLabel = simulation && partialResultsLabel(simulation); + const isPartial = partialLabel !== undefined; + + useFollowPartialResults(simulation); + + useEffect(() => { + if (!isPartial) return; + const timer = setInterval(() => setNow(Date.now()), 30000); + + return () => clearInterval(timer); + }, [isPartial]); const userTabInputFilesEstimatorNames = simulation?.input.estimatorsItems?.map( estimator => estimator.name @@ -121,6 +144,13 @@ function ResultsPanel(props: { simulation: FullSimulationData | undefined }) { lineHeight={2}> {title} + {partialLabel && ( + + Partial results: {partialLabel} + + )} 0 && simulation && ( <> { + const shown = latest.current; + + if (!status || shown?.jobId !== status.jobId) return; + const updated = withStatus(shown, status); + + if ( + updated.jobState !== shown.jobState || + partialRevision(updated) !== partialRevision(shown) + ) + setResultsSimulationData({ source: 'onLoad', data: updated }); + }, [status, setResultsSimulationData]); +} diff --git a/src/WrapperApp/components/Simulation/SimulationCard/SimulationCardActions.tsx b/src/WrapperApp/components/Simulation/SimulationCard/SimulationCardActions.tsx index 86ad47c28..1704f4fb9 100644 --- a/src/WrapperApp/components/Simulation/SimulationCard/SimulationCardActions.tsx +++ b/src/WrapperApp/components/Simulation/SimulationCard/SimulationCardActions.tsx @@ -11,6 +11,7 @@ import { SimulationInputFiles, StatusState } from '../../../../types/ResponseTypes'; +import { partialRevision } from '../../../../util/partialResults'; interface SimulationCardActionsProps { simulationStatus: Omit; @@ -96,11 +97,24 @@ export const SimulationCardActions = ({ } } + // results stored while the simulation ran, kept when it was cancelled or failed + const partialAction = partialRevision(simulationStatus) !== undefined && ( + + ); + return ( + {partialAction} {statusAction}