diff --git a/frontend/src/pages/Encounter/Encounter.jsx b/frontend/src/pages/Encounter/Encounter.jsx index 232285b7ef..0ab979e088 100644 --- a/frontend/src/pages/Encounter/Encounter.jsx +++ b/frontend/src/pages/Encounter/Encounter.jsx @@ -38,6 +38,11 @@ import { Divider } from "antd"; import { get } from "lodash-es"; import CollabModal from "./CollabModal"; import Alert from "react-bootstrap/Alert"; +import { + shouldContinuePollingEncounter, + POLL_INTERVAL_MS, + MAX_POLL_CYCLES, +} from "./pollingHelpers"; const Encounter = observer(() => { const [store] = useState(() => new EncounterStore()); @@ -72,26 +77,7 @@ const Encounter = observer(() => { useEffect(() => { let cancelled = false; let timeoutId = null; - - const isTerminalDetectionStatus = (status) => - !status || - status === "complete" || - status === "error" || - status === "pending"; - - const shouldContinuePolling = (encounterData) => { - const mediaAssets = Array.isArray(encounterData?.mediaAssets) - ? encounterData.mediaAssets - : []; - - if (mediaAssets.length === 0) { - return false; - } - - return mediaAssets.some( - (asset) => !isTerminalDetectionStatus(asset?.detectionStatus), - ); - }; + let pollCount = 0; const fetchEncounter = async () => { try { @@ -108,8 +94,12 @@ const Encounter = observer(() => { store.setMediaAssets(res.data.mediaAssets); } - if (shouldContinuePolling(res.data)) { - timeoutId = window.setTimeout(fetchEncounter, 3000); + pollCount++; + if ( + pollCount < MAX_POLL_CYCLES && + shouldContinuePollingEncounter(res.data) + ) { + timeoutId = window.setTimeout(fetchEncounter, POLL_INTERVAL_MS); } } catch (_err) { if (!cancelled && isInitialLoad.current) { diff --git a/frontend/src/pages/Encounter/ImageCard.jsx b/frontend/src/pages/Encounter/ImageCard.jsx index 7a85be934a..c4f0c3fc95 100644 --- a/frontend/src/pages/Encounter/ImageCard.jsx +++ b/frontend/src/pages/Encounter/ImageCard.jsx @@ -14,6 +14,7 @@ import Tooltip from "../../components/ToolTip"; import axios from "axios"; import { useIntl } from "react-intl"; import SpotMappingIcon2 from "../../components/icons/SpotMappingIcon2"; +import { isAssetActivelyAwaitingDetection } from "./pollingHelpers"; const ImageCard = observer(({ store = {} }) => { const imgRef = useRef(null); @@ -67,17 +68,11 @@ const ImageCard = observer(({ store = {} }) => { JSON.stringify(editAnnotationParams), ); - const isTerminalDetectionStatus = (status) => - !status || - status === "complete" || - status === "error" || - status === "pending"; - const selectedAsset = store.encounterData?.mediaAssets?.[store.selectedImageIndex]; - const selectedAssetDetectionStatus = selectedAsset?.detectionStatus; const isDetectionInProgress = - !!selectedAsset && !isTerminalDetectionStatus(selectedAssetDetectionStatus); + !!selectedAsset && + isAssetActivelyAwaitingDetection(selectedAsset, store.encounterData); const handleEnter = (text) => setTip((s) => ({ ...s, show: true, text })); const handleMove = (e) => { diff --git a/frontend/src/pages/Encounter/pollingHelpers.js b/frontend/src/pages/Encounter/pollingHelpers.js new file mode 100644 index 0000000000..af3b941c61 --- /dev/null +++ b/frontend/src/pages/Encounter/pollingHelpers.js @@ -0,0 +1,43 @@ +// Shared detection-status predicates used by Encounter.jsx polling loop and +// ImageCard.jsx "Detection in progress" indicator. Keeping them here avoids +// the two files drifting out of sync. + +const POLL_INTERVAL_MS = 3000; +const MAX_POLL_CYCLES = 100; // 100 cycles * 3s = ~5 minutes + +const isAnnotationTrivial = (a) => a?.isTrivial === true || a?.trivial === true; + +export const isTerminalDetectionStatus = (status) => + !status || + status === "complete" || + status === "error" || + status === "pending"; + +// True when an asset is from a bulk import and detection has been queued +// to WBIA but the callback hasn't returned yet. In this state the API +// returns detectionStatus=null and only a trivial placeholder annotation. +// Scoped to encounters that have an importTaskId so we don't poll forever +// on legacy/manual encounters that intentionally never run detection. +export const isAwaitingBulkImportDetection = (asset, encounterData) => { + if (!encounterData?.importTaskId) return false; + if (asset?.detectionStatus !== null && asset?.detectionStatus !== undefined) + return false; + const anns = Array.isArray(asset?.annotations) ? asset.annotations : []; + return anns.length > 0 && anns.every(isAnnotationTrivial); +}; + +export const isAssetActivelyAwaitingDetection = (asset, encounterData) => + !isTerminalDetectionStatus(asset?.detectionStatus) || + isAwaitingBulkImportDetection(asset, encounterData); + +export const shouldContinuePollingEncounter = (encounterData) => { + const mediaAssets = Array.isArray(encounterData?.mediaAssets) + ? encounterData.mediaAssets + : []; + if (mediaAssets.length === 0) return false; + return mediaAssets.some((asset) => + isAssetActivelyAwaitingDetection(asset, encounterData), + ); +}; + +export { POLL_INTERVAL_MS, MAX_POLL_CYCLES }; diff --git a/frontend/src/pages/Encounter/stores/EncounterStore.js b/frontend/src/pages/Encounter/stores/EncounterStore.js index bdc491626b..7796dbdb8e 100644 --- a/frontend/src/pages/Encounter/stores/EncounterStore.js +++ b/frontend/src/pages/Encounter/stores/EncounterStore.js @@ -162,7 +162,7 @@ class EncounterStore { setMediaAssets(mediaAssets) { if (this._encounterData) { - this._encounterData.mediaAssets = mediaAssets; + this._encounterData = { ...this._encounterData, mediaAssets }; } } diff --git a/frontend/src/service-worker.js b/frontend/src/service-worker.js index f677fe0cef..58d1c2dff6 100644 --- a/frontend/src/service-worker.js +++ b/frontend/src/service-worker.js @@ -68,9 +68,10 @@ registerRoute( }), ); +// Never cache API endpoints — responses are not idempotent and stale data +// (e.g. boundingBox, detectionStatus) breaks polling-driven UI updates. registerRoute( - // ({url}) => true, - () => true, + ({ url }) => !url.pathname.startsWith("/api/"), new NetworkFirst(), ); diff --git a/src/main/java/org/ecocean/media/Feature.java b/src/main/java/org/ecocean/media/Feature.java index 62ca63f7d4..6da114baf2 100644 --- a/src/main/java/org/ecocean/media/Feature.java +++ b/src/main/java/org/ecocean/media/Feature.java @@ -23,6 +23,7 @@ public class Feature implements java.io.Serializable { protected FeatureType type; protected JSONObject parameters; + protected String parametersAsString; // this link back to the objs with .features that include us protected Annotation annotation; @@ -52,6 +53,7 @@ public Feature(final String id, final FeatureType type, final JSONObject params) this.id = id; this.type = type; this.parameters = params; + if (params != null) this.parametersAsString = params.toString(); this.setRevision(); } @@ -90,31 +92,34 @@ public MediaAsset getMediaAsset() { } public JSONObject getParameters() { -// System.out.println("getParameters() called -> " + parameters); + if (parameters != null) return parameters; + if (parametersAsString == null) return null; + try { + parameters = new JSONObject(parametersAsString); + } catch (JSONException je) { + System.out.println(this + " -- error parsing parameters json string (" + + parametersAsString + "): " + je.toString()); + return null; + } return parameters; } public void setParameters(JSONObject p) { -// System.out.println("setParameters(" + p + ") called"); parameters = p; + parametersAsString = (p == null) ? null : p.toString(); } + // only DataNucleus should be calling get/setParametersAsString. always use get/setParameters() instead. public String getParametersAsString() { -// System.out.println("getParametersAsString() called -> " + parameters); + if (parametersAsString != null) return parametersAsString; if (parameters == null) return null; - return parameters.toString(); + parametersAsString = parameters.toString(); + return parametersAsString; } public void setParametersAsString(String p) { -// System.out.println("setParametersAsString(" + p + ") called"); - if (p == null) return; - try { - parameters = new JSONObject(p); - } catch (JSONException je) { - System.out.println(this + " -- error parsing parameters json string (" + p + "): " + - je.toString()); - parameters = null; - } + parametersAsString = p; + parameters = null; // force lazy re-parse on next getParameters() } public long getRevision() { diff --git a/src/main/resources/org/ecocean/media/package.jdo b/src/main/resources/org/ecocean/media/package.jdo index 6738f60044..5a98162ac5 100755 --- a/src/main/resources/org/ecocean/media/package.jdo +++ b/src/main/resources/org/ecocean/media/package.jdo @@ -116,9 +116,9 @@ - + - +