From 7c4555454c6aa48901a8802df2076f89aa40b804 Mon Sep 17 00:00:00 2001 From: dazakdev Date: Wed, 5 Aug 2026 20:05:12 +0200 Subject: [PATCH 01/11] chore: open results-pipeline integration branch Long-lived integration branch for the results pipeline rework: binary exchange format instead of JSON, fetching numeric data as ArrayBuffer, lazy per-page loading, and live partial result previews. Tracked in Linear as YAP-47 .. YAP-69. Co-Authored-By: Claude Opus 5 From 32d60bfb9c97c338dfb9417b29695e20998eb055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20=C5=BBak?= <83332385+dazakdev@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:56:40 +0200 Subject: [PATCH 02/11] perf: poll simulation status without the task list (#2436) Ask for task totals instead of the full task list (needs yaptide/yaptide#1681), stop polling hidden tabs, find the current simulation by its input, and let failed cache fetches reject instead of hanging. Co-Authored-By: Claude Opus 5.5 --- .../SimulationCard/SimulationCardContent.tsx | 74 +++---------------- .../SimulationsGrid/SimulationsGridHelpers.ts | 16 ++-- .../hooks/useUpdateCurrentSimulationEffect.ts | 33 +++++++-- src/services/CacheMap.ts | 20 +++-- src/services/LoaderService.tsx | 13 +--- src/services/RemoteWorkerSimulationService.ts | 30 ++------ src/types/ResponseTypes.ts | 24 ++++-- src/util/taskTotals.test.ts | 59 +++++++++++++++ src/util/taskTotals.ts | 44 +++++++++++ 9 files changed, 194 insertions(+), 119 deletions(-) create mode 100644 src/util/taskTotals.test.ts create mode 100644 src/util/taskTotals.ts diff --git a/src/WrapperApp/components/Simulation/SimulationCard/SimulationCardContent.tsx b/src/WrapperApp/components/Simulation/SimulationCard/SimulationCardContent.tsx index 0b453a672..ff956bb8a 100644 --- a/src/WrapperApp/components/Simulation/SimulationCard/SimulationCardContent.tsx +++ b/src/WrapperApp/components/Simulation/SimulationCard/SimulationCardContent.tsx @@ -1,14 +1,8 @@ import { Box, CardContent, Chip, LinearProgress, Typography } from '@mui/material'; import { useEffect, useState } from 'react'; -import { - currentJobStatusData, - currentTaskStatusData, - JobUnknownStatus, - SimulationInfo, - StatusState, - TaskUnknownStatus -} from '../../../../types/ResponseTypes'; +import { JobUnknownStatus, SimulationInfo, StatusState } from '../../../../types/ResponseTypes'; +import { TaskTotals, taskTotals } from '../../../../util/taskTotals'; import { millisecondsToTimeString } from '../../../../util/time'; interface SimulationCardContentProps { @@ -20,38 +14,10 @@ interface SimulationCardContentProps { highlightColor: string; } -function getValidatedPrimaries(primariesNumber: number | undefined): number { - return primariesNumber ?? 0; -} - -function percentOfSimulatedPrimaries( - requestedPrimariesSum: number, - simulatedPrimariesSum: number -): number { - return requestedPrimariesSum > 0 - ? Math.min(100, (simulatedPrimariesSum / requestedPrimariesSum) * 100) - : 0; -} - -function calculateSimulationProgress(jobTasksStatus: TaskUnknownStatus[]): number { - if (jobTasksStatus.length === 0) { - return 0; - } +function simulationProgress(totals: TaskTotals | undefined): number { + if (!totals || totals.requestedPrimaries <= 0) return 0; - const { requestedPrimariesSum, simulatedPrimariesSum } = jobTasksStatus.reduce( - ( - acc: { requestedPrimariesSum: number; simulatedPrimariesSum: number }, - taskStatus: TaskUnknownStatus - ) => { - acc.requestedPrimariesSum += getValidatedPrimaries(taskStatus.requestedPrimaries); - acc.simulatedPrimariesSum += getValidatedPrimaries(taskStatus.simulatedPrimaries); - - return acc; - }, - { requestedPrimariesSum: 0, simulatedPrimariesSum: 0 } - ); - - return percentOfSimulatedPrimaries(requestedPrimariesSum, simulatedPrimariesSum); + return Math.min(100, (totals.simulatedPrimaries / totals.requestedPrimaries) * 100); } function needsProgressBar(state: StatusState | undefined) { @@ -72,10 +38,7 @@ export function SimulationProgress(props: { const [simulationProgressPercent, setSimulationProgressPercent] = useState(0); useEffect(() => { - if (simulationStatus.jobTasksStatus) { - const jobTasksStatus = simulationStatus.jobTasksStatus; - setSimulationProgressPercent(calculateSimulationProgress(jobTasksStatus)); - } + setSimulationProgressPercent(simulationProgress(taskTotals(simulationStatus))); }, [simulationStatus, setSimulationProgressPercent]); return ( @@ -110,6 +73,8 @@ export const SimulationCardContent = ({ simulationStatus, highlightColor }: SimulationCardContentProps) => { + const totals = taskTotals(simulationStatus); + return ( - {currentJobStatusData['hasSpecificProperty']( - simulationStatus, - 'jobTasksStatus' - ) && ( + {totals && ( <> - currentTaskStatusData['hasSpecificProperty']( - taskStatus, - 'requestedPrimaries' - ) - ? acc + taskStatus.requestedPrimaries - : acc, - 0 as number - )}`} + label={`primaries: ${totals.requestedPrimaries}`} /> )} @@ -167,10 +120,7 @@ export const SimulationCardContent = ({ {needsProgressBar(simulationStatus.jobState) - ? currentJobStatusData['hasSpecificProperty']( - simulationStatus, - 'jobTasksStatus' - ) && ( + ? totals && ( - getPageStatus(simulationInfo, true, handleBeforeCacheWrite, controller.signal).then( - s => { - setSimulationsStatusData([...(s ?? [])]); - } - ), + // a tab nobody looks at does not poll; the next tick after it is shown again catches up + document.hidden + ? Promise.resolve() + : getPageStatus( + simulationInfo, + true, + handleBeforeCacheWrite, + controller.signal + ).then(s => { + setSimulationsStatusData([...(s ?? [])]); + }), [ getPageStatus, simulationInfo, diff --git a/src/WrapperApp/components/Simulation/SimulationsGrid/hooks/useUpdateCurrentSimulationEffect.ts b/src/WrapperApp/components/Simulation/SimulationsGrid/hooks/useUpdateCurrentSimulationEffect.ts index 9600bac93..e97b4c8ba 100644 --- a/src/WrapperApp/components/Simulation/SimulationsGrid/hooks/useUpdateCurrentSimulationEffect.ts +++ b/src/WrapperApp/components/Simulation/SimulationsGrid/hooks/useUpdateCurrentSimulationEffect.ts @@ -13,28 +13,45 @@ export const useUpdateCurrentSimulationEffect = ( const { simulationsStatusData, yaptideEditor } = state; useEffect(() => { + // every poll starts a new run; a slower older run must not overwrite what a newer one set + let stale = false; const updateCurrentSimulation = async () => { if (shouldConnect && yaptideEditor) { const hash = yaptideEditor.toSerialized().hash; - const currentStatus = simulationsStatusData?.find(async s => { - if (currentJobStatusData[StatusState.COMPLETED](s)) { - const jobInputs = await getJobInputs(s, controller.signal); + // find() with an async callback always took the first simulation, a promise is truthy + let currentStatus; - return jobInputs?.input.inputJson?.hash === hash; - } + for (const s of simulationsStatusData ?? []) { + if (!currentJobStatusData[StatusState.COMPLETED](s)) continue; + // one failed request must not stop the search + const jobInputs = await getJobInputs(s, controller.signal).catch( + () => undefined + ); + + if (jobInputs?.input.inputJson?.hash === hash) { + currentStatus = s; - return false; - }); + break; + } + } const currentSimulation = currentStatus - ? await getFullSimulationData(currentStatus, controller.signal) + ? await getFullSimulationData(currentStatus, controller.signal).catch( + () => undefined + ) : undefined; + if (stale) return; + if (currentSimulation) yaptideEditor.setResults(currentSimulation); else yaptideEditor.setResults(null); } }; updateCurrentSimulation(); + + return () => { + stale = true; + }; }, [ simulationsStatusData, yaptideEditor, diff --git a/src/services/CacheMap.ts b/src/services/CacheMap.ts index e66e71eb1..72b4e1f7e 100644 --- a/src/services/CacheMap.ts +++ b/src/services/CacheMap.ts @@ -35,11 +35,21 @@ export default class CacheMap { key: string, beforeWriteCallback?: (key: string, value: T) => void ) { - const cachePromise = new Promise(async resolve => fn(resolve)).then(data => { - this.set(key, data, beforeWriteCallback); - - return data; - }); + const cachePromise: Promise = new Promise((resolve, reject) => { + // a failed fetch must reject, not leave every caller waiting on this cache entry forever + Promise.resolve(fn(resolve)).catch(reject); + }).then( + data => { + this.set(key, data, beforeWriteCallback); + + return data; + }, + error => { + if (this.data.get(key) === cachePromise) this.delete(key); + + throw error; + } + ); this.set(key, cachePromise); diff --git a/src/services/LoaderService.tsx b/src/services/LoaderService.tsx index 60ad75474..d2af34661 100644 --- a/src/services/LoaderService.tsx +++ b/src/services/LoaderService.tsx @@ -21,15 +21,10 @@ const isEditorJson = (data: unknown): data is EditorJson => { }; export const isFullSimulationData = (data: unknown): data is FullSimulationData => { - return hasFields( - data, - 'input', - 'estimators', - 'jobState', - 'jobId', - 'jobTasksStatus', - 'metadata', - 'startTime' + return ( + hasFields(data, 'input', 'estimators', 'jobState', 'jobId', 'metadata', 'startTime') && + // saved before the status carried totals instead of the task list, or after + (hasFields(data, 'jobTasksStatus') || hasFields(data, 'jobTasksSummary')) ); }; diff --git a/src/services/RemoteWorkerSimulationService.ts b/src/services/RemoteWorkerSimulationService.ts index 14deb9f6f..b9ea16836 100644 --- a/src/services/RemoteWorkerSimulationService.ts +++ b/src/services/RemoteWorkerSimulationService.ts @@ -50,6 +50,7 @@ import { } from '../types/ResponseTypes'; import { FullSimulationData, SimulationService } from '../types/SimulationService'; import { camelToSnakeCase } from '../util/Notation/Notation'; +import { taskTotals } from '../util/taskTotals'; import { ValidateShape } from '../util/Types'; import { SimulationSourceType } from '../WrapperApp/components/Simulation/RunSimulationForm'; import CacheMap from './CacheMap'; @@ -62,35 +63,15 @@ const CACHEABLE_STATUSES = new Set([ const VALID_STATUSES = new Set(ValidStatusStates); -//TODO: fix backend responses and remove this function -//TODO: (2025) Find out what does the above mean & fix +// a completed simulation is cached once every task has ended function validStatusToCache(data: JobStatusCompleted | JobStatusFailed) { if (data.jobState === StatusState.FAILED) { return true; } - return data.jobTasksStatus.every(task => { - if (task.taskState && task.taskState === StatusState.FAILED) { - return true; - } - - if (task.taskState && task.taskState === StatusState.COMPLETED) { - if (!task.startTime || !task.endTime) - console.warn('There are missing times in COMPLETED task:', task); - - if (task.requestedPrimaries !== task.simulatedPrimaries) { - console.warn( - 'Requested primaries and simulated primaries are not equal in COMPLETED task:', - task - ); - task.simulatedPrimaries = task.requestedPrimaries; - } - - return true; - } + const totals = taskTotals(data); - return false; - }); + return totals !== undefined && totals.finishedTasks === totals.ntasks; } /** @@ -325,7 +306,8 @@ export default class RemoteWorkerSimulationService implements SimulationService return this.authKy .get(`jobs/${runType}`, { signal, - searchParams: camelToSnakeCase({ jobId }) + // totals are enough for the simulations list, the task list would be ~70 kB for 500 tasks + searchParams: camelToSnakeCase({ jobId, tasks: false }) }) .json() .then(response => { diff --git a/src/types/ResponseTypes.ts b/src/types/ResponseTypes.ts index 996f510e1..14b295f3d 100644 --- a/src/types/ResponseTypes.ts +++ b/src/types/ResponseTypes.ts @@ -202,43 +202,54 @@ export type TaskUnknownStatus = { /* ------------------------------------ */ // Types defining all possible types of job status data +/** + * Totals over the tasks of a simulation, sent by the server instead of the full task list. + */ +export type JobTasksSummary = { + ntasks: number; + // keys go through the snake_case to camelCase conversion; single-word task states are unchanged + taskStates: Record; + requestedPrimaries: number; + simulatedPrimaries: number; +}; + type JobStatusType = TypeIdentifiedByKey< 'jobState', T, - U & YaptideResponse + U & YaptideResponse & { jobTasksSummary?: JobTasksSummary } >; export type JobStatusCompleted = JobStatusType< StatusState.COMPLETED, { - jobTasksStatus: Array; + jobTasksStatus?: Array; } >; export type JobStatusCanceled = JobStatusType< StatusState.CANCELED, { - jobTasksStatus: Array; + jobTasksStatus?: Array; } >; export type JobStatusMergingQueued = JobStatusType< StatusState.MERGING_QUEUED, { - jobTasksStatus: Array; + jobTasksStatus?: Array; } >; export type JobStatusMergingRunning = JobStatusType< StatusState.MERGING_RUNNING, { - jobTasksStatus: Array; + jobTasksStatus?: Array; } >; export type JobStatusRunning = JobStatusType< StatusState.RUNNING, - { jobTasksStatus: Array } + { jobTasksStatus?: Array } >; export type JobStatusPending = JobStatusType; @@ -270,6 +281,7 @@ export type JobUnknownStatus = { | StatusState.CANCELED; message?: string; jobTasksStatus?: Array; + jobTasksSummary?: JobTasksSummary; }; type ResponseJobRequestFailure = Omit< diff --git a/src/util/taskTotals.test.ts b/src/util/taskTotals.test.ts new file mode 100644 index 000000000..a02171f65 --- /dev/null +++ b/src/util/taskTotals.test.ts @@ -0,0 +1,59 @@ +import { StatusState } from '../types/ResponseTypes'; +import { taskTotals } from './taskTotals'; + +describe('taskTotals', () => { + it('reads the totals from the server summary', () => { + expect( + taskTotals({ + jobTasksSummary: { + ntasks: 3, + taskStates: { + [StatusState.COMPLETED]: 1, + [StatusState.FAILED]: 1, + [StatusState.RUNNING]: 1 + }, + requestedPrimaries: 3000, + simulatedPrimaries: 2400 + } + }) + ).toEqual({ + ntasks: 3, + requestedPrimaries: 3000, + simulatedPrimaries: 2400, + finishedTasks: 2 + }); + }); + + it('computes the same totals from a task list', () => { + expect( + taskTotals({ + jobTasksStatus: [ + { + taskState: StatusState.COMPLETED, + requestedPrimaries: 1000, + simulatedPrimaries: 1000 + }, + { + taskState: StatusState.FAILED, + requestedPrimaries: 1000, + simulatedPrimaries: 1000 + }, + { + taskState: StatusState.RUNNING, + requestedPrimaries: 1000, + simulatedPrimaries: 400 + } + ] + }) + ).toEqual({ + ntasks: 3, + requestedPrimaries: 3000, + simulatedPrimaries: 2400, + finishedTasks: 2 + }); + }); + + it('has nothing to say without a summary or a list', () => { + expect(taskTotals({})).toBeUndefined(); + }); +}); diff --git a/src/util/taskTotals.ts b/src/util/taskTotals.ts new file mode 100644 index 000000000..ca1fe7c39 --- /dev/null +++ b/src/util/taskTotals.ts @@ -0,0 +1,44 @@ +import { JobTasksSummary, StatusState, TaskUnknownStatus } from '../types/ResponseTypes'; + +export type TaskTotals = { + ntasks: number; + requestedPrimaries: number; + simulatedPrimaries: number; + finishedTasks: number; +}; + +/** + * Totals over the tasks of a simulation: from the server's summary, or from the task list + * where only the list is known (simulations run in the browser). + */ +export function taskTotals(status: { + jobTasksSummary?: JobTasksSummary; + jobTasksStatus?: TaskUnknownStatus[]; +}): TaskTotals | undefined { + const summary = status.jobTasksSummary; + + if (summary) { + const states = summary.taskStates; + + return { + ntasks: summary.ntasks, + requestedPrimaries: summary.requestedPrimaries, + simulatedPrimaries: summary.simulatedPrimaries, + finishedTasks: (states[StatusState.COMPLETED] ?? 0) + (states[StatusState.FAILED] ?? 0) + }; + } + + return status.jobTasksStatus?.reduce( + (totals, task) => ({ + ntasks: totals.ntasks + 1, + requestedPrimaries: totals.requestedPrimaries + (task.requestedPrimaries ?? 0), + simulatedPrimaries: totals.simulatedPrimaries + (task.simulatedPrimaries ?? 0), + finishedTasks: + totals.finishedTasks + + (task.taskState === StatusState.COMPLETED || task.taskState === StatusState.FAILED + ? 1 + : 0) + }), + { ntasks: 0, requestedPrimaries: 0, simulatedPrimaries: 0, finishedTasks: 0 } + ); +} From 7e71f9b39f25167576ed8152b5715ef8ebdd569d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20=C5=BBak?= <83332385+dazakdev@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:13:27 +0200 Subject: [PATCH 03/11] feat: read result pages from object storage links (#2437) Pages kept in object storage come from the backend as signed links; they are fetched in parallel, without credentials, and converted like the API responses. Pages still sent inline are used as before. Co-authored-by: Claude Opus 5.5 --- src/libs/converter | 2 +- src/services/RemoteWorkerSimulationService.ts | 8 ++++- src/services/storedPages.test.ts | 30 ++++++++++++++++++ src/services/storedPages.ts | 31 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 src/services/storedPages.test.ts create mode 100644 src/services/storedPages.ts diff --git a/src/libs/converter b/src/libs/converter index 10223d4b9..47791d417 160000 --- a/src/libs/converter +++ b/src/libs/converter @@ -1 +1 @@ -Subproject commit 10223d4b9916650b8c6b9b6a5f9d7d9ecefe6106 +Subproject commit 47791d41729f01e8241b9f2a28e7c98b99dcaca8 diff --git a/src/services/RemoteWorkerSimulationService.ts b/src/services/RemoteWorkerSimulationService.ts index b9ea16836..aae84f68d 100644 --- a/src/services/RemoteWorkerSimulationService.ts +++ b/src/services/RemoteWorkerSimulationService.ts @@ -54,6 +54,7 @@ import { taskTotals } from '../util/taskTotals'; import { ValidateShape } from '../util/Types'; import { SimulationSourceType } from '../WrapperApp/components/Simulation/RunSimulationForm'; import CacheMap from './CacheMap'; +import { resolveStoredPages } from './storedPages'; const CACHEABLE_STATUSES = new Set([ StatusState.FAILED, @@ -383,6 +384,11 @@ export default class RemoteWorkerSimulationService implements SimulationService }) .json(); + await Promise.all( + response.estimators.map(async estimator => { + estimator.pages = await resolveStoredPages(estimator.pages, signal); + }) + ); updateEstimators(response.estimators); const jobInputs = await this.getJobInputs(info, signal, cache); @@ -475,7 +481,7 @@ export default class RemoteWorkerSimulationService implements SimulationService const estimator: Estimator[] = [ { name: estimatorName, - pages: response.pages + pages: await resolveStoredPages(response.pages, signal) } ]; diff --git a/src/services/storedPages.test.ts b/src/services/storedPages.test.ts new file mode 100644 index 000000000..d13014935 --- /dev/null +++ b/src/services/storedPages.test.ts @@ -0,0 +1,30 @@ +import ky from 'ky'; + +import { Page } from '../JsRoot/GraphData'; +import { resolveStoredPages } from './storedPages'; + +jest.mock('ky', () => ({ get: jest.fn() })); + +describe('resolveStoredPages', () => { + it('fetches pages kept in storage and converts their keys, keeping pages sent inline', async () => { + (ky.get as jest.Mock).mockReturnValue({ + json: () => + Promise.resolve({ metadata: { page_number: 1 }, axis_dim1: { values: [1] } }) + }); + const inline = { dimensions: 0, metadata: { pageNumber: 0 } } as unknown as Page; + const stored = { + pageNumber: 1, + url: 'https://storage/p1', + format: 'page-json-gzip-v1' + } as unknown as Page; + + const pages = await resolveStoredPages([inline, stored]); + + expect(pages[0]).toBe(inline); + expect(pages[1]).toEqual({ metadata: { pageNumber: 1 }, axisDim1: { values: [1] } }); + expect(ky.get).toHaveBeenCalledWith( + 'https://storage/p1', + expect.objectContaining({ retry: 2 }) + ); + }); +}); diff --git a/src/services/storedPages.ts b/src/services/storedPages.ts new file mode 100644 index 000000000..113329ab6 --- /dev/null +++ b/src/services/storedPages.ts @@ -0,0 +1,31 @@ +import ky from 'ky'; + +import { Page } from '../JsRoot/GraphData'; +import { snakeToCamelCase } from '../util/Notation/Notation'; + +/** + * A result page kept in object storage: the backend sends a signed link instead of the page. + */ +type StoredPage = { pageNumber: number; url: string; format: string }; + +const isStoredPage = (page: unknown): page is StoredPage => + typeof (page as StoredPage | undefined)?.url === 'string'; + +/** + * Replaces pages kept in object storage with the pages themselves, fetched in parallel from their signed links. + * The page JSON goes through the same snake_case to camelCase conversion as the backend's API responses. + */ +export function resolveStoredPages(pages: Page[], signal?: AbortSignal): Promise { + return Promise.all( + pages.map(page => + isStoredPage(page) + ? ky + // no timeout: requests beyond the browser's ~6 connections wait in its queue, and a large page + // on a slow link takes long; the abort signal still stops them + .get(page.url, { signal, retry: 2, timeout: false }) + .json() + .then(json => snakeToCamelCase(json, true) as unknown as Page) + : page + ) + ); +} From 8a71379e8a0224f44cf2b6e9b7f42655958ef569 Mon Sep 17 00:00:00 2001 From: Maciej Borowiec <81316152+Mosder@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:25:08 +0200 Subject: [PATCH 04/11] feat: show partial results of running simulations, updated from server-sent events (#2440) - the simulation card shows what the partial results cover ("37% of primaries, 2 minutes ago") and a Partial Results button, also for a cancelled or failed simulation that has them - the results panel follows GET /events/simulation while it shows partial results; a new partial or the final results replace the shown status, and EstimatorsSelect loads the estimator on screen again - EstimatorsSelect is the only loader of estimator pages: it remembers the partial revision each tab was loaded at, loads the tab on screen (not always tab 0) and does not switch to the Results screen - estimator pages are cached per partial revision, so a new partial is fetched and the final results never come from a cached partial Co-authored-by: Mosder --- .../EstimatorsSelect/EstimatorsSelect.tsx | 40 ++++++++--- .../components/Results/ResultsPanel.tsx | 23 +++++++ .../Results/useFollowPartialResults.ts | 68 +++++++++++++++++++ .../SimulationCard/SimulationCardActions.tsx | 14 ++++ .../SimulationCard/SimulationCardContent.tsx | 5 ++ .../SimulationsGrid/SimulationsGridHelpers.ts | 6 +- src/services/RemoteWorkerSimulationService.ts | 14 ++-- src/types/RequestTypes.ts | 3 +- src/types/ResponseTypes.ts | 12 +++- src/util/partialResults.test.ts | 29 ++++++++ src/util/partialResults.ts | 36 ++++++++++ 11 files changed, 234 insertions(+), 16 deletions(-) create mode 100644 src/WrapperApp/components/Results/useFollowPartialResults.ts create mode 100644 src/util/partialResults.test.ts create mode 100644 src/util/partialResults.ts 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..2c0b15b69 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[]; @@ -40,6 +42,19 @@ function ResultsPanel(props: { simulation: FullSimulationData | undefined }) { const [tabsValue, setTabsValue] = useState(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 +136,13 @@ function ResultsPanel(props: { simulation: FullSimulationData | undefined }) { lineHeight={2}> {title} + {partialLabel && ( + + Partial results: {partialLabel} + + )} 0 && simulation && ( <> { + if (!following || !jobId) return; + let source: EventSource | undefined; + let reopen: ReturnType | undefined; + + const open = () => { + source = new EventSource( + `${backendUrl}/events/simulation?job_id=${encodeURIComponent(jobId)}`, + { withCredentials: true } + ); + + source.onmessage = event => { + const status = snakeToCamelCase( + JSON.parse(event.data) as object, + true + ) as JobUnknownStatus; + + // the server ends the stream after a final state, the browser would open it again + if (status.jobState && FINAL_STATES.has(status.jobState)) source?.close(); + const shown = latest.current; + + if (!shown || shown.jobId !== jobId) return; + const updated = { ...shown, ...status } as FullSimulationData; + + if (partialRevision(updated) !== partialRevision(shown)) + setResultsSimulationData({ source: 'onLoad', data: updated }); + }; + + source.onerror = () => { + if (source?.readyState === EventSource.CLOSED) reopen = setTimeout(open, REOPEN_MS); + }; + }; + open(); + + return () => { + clearTimeout(reopen); + source?.close(); + }; + }, [following, jobId, backendUrl, 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}