These classes are provided by default on the project creation form. You can keep the default colors or change them to suit your need.
-
+
Your browser does not support the video tag.
@@ -70,7 +66,7 @@ const HelpDocsProjects = ({ anchor }) => {
From the Projects page, you can update a project's name, description, event date, and affected countries after creation. Event types and primary classes cannot be changed after the project is created, so review them carefully before creating the project.
-
+
Your browser does not support the video tag.
@@ -83,7 +79,7 @@ const HelpDocsProjects = ({ anchor }) => {
You can delete a project using the ellipse menu on the Projects page. Deleting a project will delete all associated artifacts such as image layers, labels and training results.
-
+
Your browser does not support the video tag.
@@ -92,4 +88,8 @@ const HelpDocsProjects = ({ anchor }) => {
);
};
+HelpDocsProjects.propTypes = {
+ anchor: PropTypes.string,
+};
+
export default HelpDocsProjects;
diff --git a/ui/src/Components/HelpDocs/HelpDocsResults.jsx b/ui/src/Components/HelpDocs/HelpDocsResults.jsx
index ae64c1fb..eb40b944 100644
--- a/ui/src/Components/HelpDocs/HelpDocsResults.jsx
+++ b/ui/src/Components/HelpDocs/HelpDocsResults.jsx
@@ -7,11 +7,7 @@ import resultsVisualizerImage from '../../assets/helpDocs/results/results-visual
import PropTypes from 'prop-types';
import { useEffect } from 'react';
-const HelpDocsModelTraining = ({ anchor }) => {
- HelpDocsModelTraining.propTypes = {
- anchor: PropTypes.string,
- };
-
+const HelpDocsResults = ({ anchor }) => {
useEffect(() => {
if (anchor) {
const element = document.getElementsByName(anchor)[0];
@@ -46,7 +42,7 @@ const HelpDocsModelTraining = ({ anchor }) => {
swipe divider left, to an even split, or right.
-
+
@@ -55,17 +51,21 @@ const HelpDocsModelTraining = ({ anchor }) => {
Predictions as a geopackage
The predicted damage layer is downloadable as a geopackage file (.gpkg) that can then be integrated into other geospatial visualization tools, such as ArcGIS, QGIS, etc.
-
+
Intermediate Outputs
All intermediate outputs, such as the saved labels, training checkpoint files, downloaded building footprints and predictions can be downloaded as a zip file. This is useful for troubleshooting training failures.
-
+
>
);
};
-export default HelpDocsModelTraining;
+HelpDocsResults.propTypes = {
+ anchor: PropTypes.string,
+};
+
+export default HelpDocsResults;
diff --git a/ui/src/Components/Home.jsx b/ui/src/Components/Home.jsx
index b8f1edac..b9f5c356 100644
--- a/ui/src/Components/Home.jsx
+++ b/ui/src/Components/Home.jsx
@@ -1,6 +1,12 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
-import { Button, Text, Tooltip } from "@fluentui/react-components";
+import {
+ Button,
+ MessageBar,
+ MessageBarBody,
+ Text,
+ Tooltip,
+} from "@fluentui/react-components";
import OpenProject from "./Home/OpenProject";
import OngoingJobs from "./Home/OngoingJobs";
import { useState, useEffect, useContext } from "react";
@@ -13,6 +19,7 @@ import { FluentIcon } from "../util/icons";
import PropTypes from "prop-types";
import { formatProjectDate } from "./ProjectManagement/projectStatus";
import CreateEditProjectModal from "./CreateEditProjectModal";
+import { loadHomeData } from "./Home/loadHomeData";
const StatCard = ({ icon, value, label, onClick }) => (
{
const navigate = useNavigate();
- const { setIsLoading, initCurrentTour, setAppHeaderRightButtons, appParams, setAppParams } =
+ const { setIsLoading, initCurrentTour, setAppHeaderRightButtons, appParams } =
useContext(AppContext);
const [dashboardData, setDashboardData] = useState(null);
const [catalog, setCatalog] = useState([]);
const [modalComponent, setModalComponent] = useState(null);
+ const [nowMs] = useState(Date.now);
+ const [loadError, setLoadError] = useState(false);
const openCreateProjectModal = () => {
setModalComponent(
@@ -99,26 +107,30 @@ const Home = () => {
);
};
- useEffect(() => {
- const fetchProjects = async () => {
- setIsLoading(true);
- try {
- const response = await apiGet("GetDashboardData");
- setDashboardData(response);
-
- } catch (error) {
- console.error("Error fetching projects:", error);
+ const fetchProjects = async () => {
+ setIsLoading(true);
+ try {
+ const result = await loadHomeData(apiGet);
+ if (result.dashboardError) {
+ console.error("Error fetching projects:", result.dashboardError);
+ setLoadError(true);
+ } else {
+ setDashboardData(result.dashboardData);
+ setLoadError(false);
}
- try {
- const catalogResponse = await apiGet("GetModelCatalog");
- setCatalog(catalogResponse?.modelCatalog || []);
- } catch (error) {
- // Catalog is supplementary; ignore if unavailable.
- console.warn("Model catalog unavailable for dashboard:", error);
+ if (result.catalogError) {
+ console.warn(
+ "Model catalog unavailable for dashboard:",
+ result.catalogError
+ );
}
+ setCatalog(result.catalog);
+ } finally {
setIsLoading(false);
- };
+ }
+ };
+ useEffect(() => {
initCurrentTour("dashboardGuide");
setAppHeaderRightButtons([
{
@@ -148,7 +160,18 @@ const Home = () => {
}, []);
if (!dashboardData) {
- return <> >;
+ return loadError ? (
+
+
+
+ Dashboard data could not be loaded.
+
+
+
+ Retry
+
+
+ ) : null;
}
const projects = dashboardData.projects || [];
@@ -172,7 +195,6 @@ const Home = () => {
.filter(Boolean)
.sort();
- const nowMs = Date.now();
const newLast30 = projects.filter((project) => {
const created = Date.parse(project.creationDate);
return !Number.isNaN(created) && nowMs - created <= 30 * 86400000;
diff --git a/ui/src/Components/Home/loadHomeData.js b/ui/src/Components/Home/loadHomeData.js
new file mode 100644
index 00000000..500ee6ff
--- /dev/null
+++ b/ui/src/Components/Home/loadHomeData.js
@@ -0,0 +1,16 @@
+export async function loadHomeData(get) {
+ const [dashboard, catalog] = await Promise.allSettled([
+ get("GetDashboardData"),
+ get("GetModelCatalog"),
+ ]);
+
+ return {
+ dashboardData: dashboard.status === "fulfilled" ? dashboard.value : null,
+ dashboardError: dashboard.status === "rejected" ? dashboard.reason : null,
+ catalog:
+ catalog.status === "fulfilled"
+ ? catalog.value?.modelCatalog || []
+ : [],
+ catalogError: catalog.status === "rejected" ? catalog.reason : null,
+ };
+}
\ No newline at end of file
diff --git a/ui/src/Components/Home/loadHomeData.test.js b/ui/src/Components/Home/loadHomeData.test.js
new file mode 100644
index 00000000..9f593d65
--- /dev/null
+++ b/ui/src/Components/Home/loadHomeData.test.js
@@ -0,0 +1,64 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { loadHomeData } from "./loadHomeData.js";
+
+
+function deferred() {
+ let resolve;
+ let reject;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+test("starts dashboard and catalog requests concurrently", async () => {
+ const dashboard = deferred();
+ const catalog = deferred();
+ const calls = [];
+ const loading = loadHomeData((endpoint) => {
+ calls.push(endpoint);
+ return endpoint === "GetDashboardData"
+ ? dashboard.promise
+ : catalog.promise;
+ });
+
+ assert.deepEqual(calls, ["GetDashboardData", "GetModelCatalog"]);
+ catalog.resolve({ modelCatalog: [{ modelId: "model-1" }] });
+ dashboard.resolve({ projects: [{ projectId: "project-1" }] });
+
+ assert.deepEqual(await loading, {
+ dashboardData: { projects: [{ projectId: "project-1" }] },
+ dashboardError: null,
+ catalog: [{ modelId: "model-1" }],
+ catalogError: null,
+ });
+});
+
+test("keeps dashboard data when the optional catalog fails", async () => {
+ const result = await loadHomeData(async (endpoint) => {
+ if (endpoint === "GetModelCatalog") {
+ throw new Error("catalog unavailable");
+ }
+ return { projects: [] };
+ });
+
+ assert.deepEqual(result.dashboardData, { projects: [] });
+ assert.deepEqual(result.catalog, []);
+ assert.match(result.catalogError.message, /catalog unavailable/);
+});
+
+test("reports a required dashboard failure independently", async () => {
+ const result = await loadHomeData(async (endpoint) => {
+ if (endpoint === "GetDashboardData") {
+ throw new Error("dashboard unavailable");
+ }
+ return { modelCatalog: [] };
+ });
+
+ assert.equal(result.dashboardData, null);
+ assert.match(result.dashboardError.message, /dashboard unavailable/);
+ assert.deepEqual(result.catalog, []);
+});
\ No newline at end of file
diff --git a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx
index e5a20338..ddd37d22 100644
--- a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx
+++ b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx
@@ -58,6 +58,8 @@ import {
} from "./interactiveModel.js";
import { getGpu } from "./gpuLogreg.js";
import InteractiveLabelerLoader from "./InteractiveLabelerLoader.jsx";
+import { loadInteractiveArtifacts } from "./loadInteractiveArtifacts.js";
+import { loadInteractiveMetadata } from "./loadInteractiveMetadata.js";
import { readResponseBuffer, waitForMapReady } from "./interactiveLabelerLoading.js";
import KeyboardShortcutHelp from "../KeyboardShortcutHelp.jsx";
import {
@@ -118,7 +120,7 @@ async function fetchArtifactBuffer(url, onProgress, signal) {
const resp = await fetch(url, { signal });
if (!resp.ok) {
throw new Error(
- `Failed to fetch PMTiles archive (HTTP ${resp.status}) at ${url}`
+ `Failed to fetch PMTiles archive (HTTP ${resp.status}).`
);
}
return readResponseBuffer(resp, onProgress);
@@ -453,7 +455,7 @@ async function fetchFeaturesSidecar(url, onProgress, signal) {
const resp = await fetch(url, { signal });
if (!resp.ok) {
throw new Error(
- `Failed to fetch features sidecar (HTTP ${resp.status}) at ${url}`
+ `Failed to fetch features sidecar (HTTP ${resp.status}).`
);
}
const buf = await readResponseBuffer(resp, onProgress);
@@ -482,7 +484,6 @@ async function fetchFeaturesSidecar(url, onProgress, signal) {
}
const matrix = new Float32Array(buf, 16, n * d);
const ms = Math.round(performance.now() - t0);
- // eslint-disable-next-line no-console
console.log(
`[InteractiveLabeler] sidecar loaded: ${n} buildings × ${d} dims (${(buf.byteLength / (1024 * 1024)).toFixed(1)} MB) in ${ms} ms`
);
@@ -681,6 +682,7 @@ const InteractiveLabeler = () => {
if (!window.atlas) {
throw new Error("Azure Maps is unavailable.");
}
+ // eslint-disable-next-line react-hooks/immutability
await createMap(controller.signal);
setIsMapReady(true);
setInitialLoad(null);
@@ -755,38 +757,24 @@ const InteractiveLabeler = () => {
async function createMap(signal) {
signal.throwIfAborted();
setInitialLoad({ step: 0, loaded: null, total: null });
- let layerData = null;
- try {
- layerData = await apiGet(
- `GetLayerLabelingToolData?projectId=${projectId}&imageLayerId=${imageLayerId}`
- );
- } catch {
- // Imagery is optional — labeling works without it.
- }
+ const metadata = await loadInteractiveMetadata({
+ get: apiGet,
+ projectId,
+ imageLayerId,
+ modelId,
+ });
signal.throwIfAborted();
// Cache the imagery URLs for the Advanced → Swipe view, which loads the
// pre-event tiles onto its secondary map (falls back to satellite when
// the layer has no pre-event imagery).
- layerImageryRef.current = layerData?.imagery || null;
+ layerImageryRef.current = metadata.layerData?.imagery || null;
// Resolve the model's PMTiles URL. Models are returned by
// GetLayerModelsDetails; pick ours by modelId. The pmtilesUrl is
// populated by the embedding workflow's postprocessor.
- let pmtilesUrl = "";
- let sidecarUrl = "";
+ const pmtilesUrl = metadata.model?.pmtilesUrl || "";
+ const sidecarUrl = metadata.model?.featuresSidecarUrl || "";
setInitialLoad({ step: 1, loaded: null, total: null });
- try {
- const models = await apiGet(
- `GetLayerModelsDetails?projectId=${projectId}&imageLayerId=${imageLayerId}`
- );
- const model = (models || []).find(
- (m) => String(m.modelId) === String(modelId)
- );
- pmtilesUrl = model?.pmtilesUrl || "";
- sidecarUrl = model?.featuresSidecarUrl || "";
- } catch (e) {
- console.warn("Could not fetch model URLs:", e);
- }
signal.throwIfAborted();
if (!pmtilesUrl) {
throw new Error(
@@ -819,58 +807,53 @@ const InteractiveLabeler = () => {
// and handing pmtiles an in-memory source makes every subsequent range
// read hit the local buffer instead of the network. `getKey()` returns
// browserPmtilesUrl so it matches the `pmtiles://
` source below.
- let pmtilesHeader = null;
setInitialLoad({ step: 2, loaded: 0, total: null });
- try {
- const pmtilesBuffer = await fetchArtifactBuffer(
- browserPmtilesUrl,
- (loaded, total) => setInitialLoad({ step: 2, loaded, total }),
- signal
- );
- const pm = new PMTiles(
- new InMemoryPMTilesSource(browserPmtilesUrl, pmtilesBuffer)
- );
- // Pre-register so the protocol can serve tile reads from the same handle.
- _pmtilesProtocol.add(pm);
- // Read the header so we can place the camera over the archive's bounds
- // (otherwise the map sits at [0, 0] zoom 3 and the user sees no tiles).
- pmtilesHeader = await pm.getHeader();
- } catch (e) {
- console.warn("Failed to load PMTiles archive (continuing):", e);
- }
-
- // Fetch the binary features sidecar and parse the HFTR header. The
- // resulting Float32Array view is the single source of truth for every
- // f_* lookup downstream — the PMTiles archive itself only carries id +
- // overture_id, so the labeler reads feature vectors here, not from
- // tile properties.
- setInitialLoad({ step: 3, loaded: 0, total: null });
- sidecarRef.current = await fetchFeaturesSidecar(
- browserSidecarUrl,
- (loaded, total) => setInitialLoad({ step: 3, loaded, total }),
- signal
- );
+ let pmtilesDone = false;
+ const { pmtilesHeader, sidecar } = await loadInteractiveArtifacts({
+ signal,
+ loadPmtiles: (artifactSignal) =>
+ fetchArtifactBuffer(
+ browserPmtilesUrl,
+ (loaded, total) => setInitialLoad({ step: 2, loaded, total }),
+ artifactSignal
+ )
+ .then(async (pmtilesBuffer) => {
+ const pm = new PMTiles(
+ new InMemoryPMTilesSource(browserPmtilesUrl, pmtilesBuffer)
+ );
+ _pmtilesProtocol.add(pm);
+ return pm.getHeader();
+ })
+ .finally(() => {
+ pmtilesDone = true;
+ setInitialLoad({ step: 3, loaded: 0, total: null });
+ }),
+ loadSidecar: (artifactSignal) =>
+ fetchFeaturesSidecar(
+ browserSidecarUrl,
+ (loaded, total) =>
+ setInitialLoad({
+ step: pmtilesDone ? 3 : 2,
+ loaded,
+ total,
+ }),
+ artifactSignal
+ ),
+ });
+ sidecarRef.current = sidecar;
// Restore this model's previously-saved interactive labels (separate from
// the Building Validation store). Labels are keyed by overture id; we
// re-apply them as feature-state on each moveend hydration when the
// matching building's tile is in view.
setInitialLoad({ step: 4, loaded: null, total: null });
- try {
- const saved = await apiGet(
- `GetInteractiveLabels?projectId=${projectId}&modelId=${modelId}`
+ savedLabelsRef.current = metadata.savedLabels;
+ savedLabelsLoadedRef.current = metadata.savedLabelsLoaded;
+ if (metadata.savedLabelsError) {
+ console.error(
+ "Failed to load saved interactive labels:",
+ metadata.savedLabelsError
);
- savedLabelsRef.current = saved?.labels || {};
- // The save path merges this mirror into the payload, and
- // PutInteractiveLabels replaces the stored document outright. That is
- // only lossless if the mirror really is what the server holds -- if
- // this GET failed we would be merging into an empty base and would
- // wipe the saved set, which is the bug this whole change exists to
- // fix. Record that it succeeded; saving is blocked otherwise.
- savedLabelsLoadedRef.current = true;
- } catch (e) {
- savedLabelsLoadedRef.current = false;
- console.error("Failed to load saved interactive labels:", e);
}
signal.throwIfAborted();
// Restore everything we can before the map exists. Labels saved with a
@@ -923,18 +906,18 @@ const InteractiveLabeler = () => {
position: "bottom-left",
});
- if (layerData?.imagery?.preEventTileUrl) {
+ if (metadata.layerData?.imagery?.preEventTileUrl) {
loadImagery(
- toBrowserTitilerUrl(layerData.imagery.preEventTileUrl),
+ toBrowserTitilerUrl(metadata.layerData.imagery.preEventTileUrl),
map,
{ current: null },
"preEventImageryLayer",
false
);
}
- if (layerData?.imagery?.postEventTileUrl) {
+ if (metadata.layerData?.imagery?.postEventTileUrl) {
loadImagery(
- toBrowserTitilerUrl(layerData.imagery.postEventTileUrl),
+ toBrowserTitilerUrl(metadata.layerData.imagery.postEventTileUrl),
map,
{ current: null },
"postEventImageryLayer",
@@ -1065,7 +1048,7 @@ const InteractiveLabeler = () => {
// (a) detects feature keys on the first f_* props we see;
// (b) restores any saved labels for buildings that just rendered;
// (c) runs viewport-scoped predict if the model has training data.
- const hydrate = () => hydrateViewport(map);
+ const hydrate = () => hydrateViewport();
map.events.add("moveend", () => {
// A move supersedes any pending first-paint retry.
if (initialPaintTimerRef.current) {
@@ -1193,7 +1176,6 @@ const InteractiveLabeler = () => {
// the very symptom this is meant to fix.
refreshCounts();
if (restored > 0 || legacy > 0) {
- // eslint-disable-next-line no-console
console.log(
`[InteractiveLabeler] restored ${restored} saved label(s) by rowId` +
(legacy > 0
@@ -1231,7 +1213,7 @@ const InteractiveLabeler = () => {
//
// Returns the number of rendered features it saw, so callers can tell
// "nothing to do" from "the renderer wasn't ready yet".
- function hydrateViewport(map) {
+ function hydrateViewport() {
const gl = glMapRef.current;
if (!gl) return 0;
if (mapDisposedRef.current) return 0;
@@ -1299,7 +1281,6 @@ const InteractiveLabeler = () => {
refreshCounts();
}
if (corrected > 0) {
- // eslint-disable-next-line no-console
console.warn(
`[InteractiveLabeler] re-placed ${corrected} label(s) whose saved` +
" rowId did not match the tile's overture_id (stale sidecar?)"
@@ -1353,9 +1334,8 @@ const InteractiveLabeler = () => {
// and stops on the first success.
function paintRestoredLabels(map, attempt = 0) {
initialPaintTimerRef.current = null;
- if (hydrateViewport(map) > 0) return;
+ if (hydrateViewport() > 0) return;
if (attempt >= INITIAL_PAINT_MAX_ATTEMPTS) {
- // eslint-disable-next-line no-console
console.warn(
"[InteractiveLabeler] no rendered features after" +
` ${INITIAL_PAINT_MAX_ATTEMPTS} attempts; labels will colour on the` +
@@ -1568,7 +1548,7 @@ const InteractiveLabeler = () => {
}
// ── Labeling ──────────────────────────────────────────────────────────────
- function recordLabel(id, props, cls) {
+ function recordLabel(id, properties, cls) {
const vec = lookupFeatureVector(id);
if (!isValidVector(vec)) return false;
// Capture the Overture id (when present) up front so the save path
@@ -1576,7 +1556,9 @@ const InteractiveLabeler = () => {
// (so labels survive a re-embed that renumbers row-index ids); the
// hydrate path also looks up by Overture id on restore.
const overtureId =
- props && props.overture_id != null ? props.overture_id : id;
+ properties && properties.overture_id != null
+ ? properties.overture_id
+ : id;
labeledMapRef.current[id] = {
label: cls,
features: vec,
@@ -1597,7 +1579,7 @@ const InteractiveLabeler = () => {
uncertaintyOnRef.current ||
misclassifiedOnRef.current
) {
- hydrateViewport(mapRef.current);
+ hydrateViewport();
}
}
function labelBuildings(items, cls) {
@@ -2518,7 +2500,11 @@ const InteractiveLabeler = () => {
].filter((count) => count >= MIN_PER_CLASS).length >= 2;
return (
-
+
controller.abort();
+ signal?.addEventListener("abort", abort, { once: true });
+
+ const invoke = (loader) => {
+ try {
+ return Promise.resolve(loader(controller.signal));
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ };
+
+ try {
+ const [pmtilesHeader, sidecar] = await Promise.all([
+ invoke(loadPmtiles),
+ invoke(loadSidecar),
+ ]);
+ return { pmtilesHeader, sidecar };
+ } catch (error) {
+ controller.abort();
+ throw error;
+ } finally {
+ signal?.removeEventListener("abort", abort);
+ }
+}
\ No newline at end of file
diff --git a/ui/src/Components/InteractiveLabeler/loadInteractiveArtifacts.test.js b/ui/src/Components/InteractiveLabeler/loadInteractiveArtifacts.test.js
new file mode 100644
index 00000000..c3af0e88
--- /dev/null
+++ b/ui/src/Components/InteractiveLabeler/loadInteractiveArtifacts.test.js
@@ -0,0 +1,90 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { loadInteractiveArtifacts } from "./loadInteractiveArtifacts.js";
+
+
+function deferred() {
+ let resolve;
+ let reject;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+test("starts PMTiles and sidecar loads concurrently", async () => {
+ const pmtiles = deferred();
+ const sidecar = deferred();
+ const calls = [];
+ const loading = loadInteractiveArtifacts({
+ loadPmtiles: () => {
+ calls.push("pmtiles");
+ return pmtiles.promise;
+ },
+ loadSidecar: () => {
+ calls.push("sidecar");
+ return sidecar.promise;
+ },
+ });
+
+ assert.deepEqual(calls, ["pmtiles", "sidecar"]);
+ sidecar.resolve({ matrix: [] });
+ pmtiles.resolve({ centerLon: 0 });
+
+ assert.deepEqual(await loading, {
+ pmtilesHeader: { centerLon: 0 },
+ sidecar: { matrix: [] },
+ });
+});
+
+test("rejects when the required PMTiles archive fails", async () => {
+ await assert.rejects(
+ loadInteractiveArtifacts({
+ loadPmtiles: async () => {
+ throw new Error("tiles unavailable");
+ },
+ loadSidecar: async () => ({ matrix: [] }),
+ }),
+ /tiles unavailable/
+ );
+});
+
+test("rejects when the required sidecar fails", async () => {
+ await assert.rejects(
+ loadInteractiveArtifacts({
+ loadPmtiles: async () => null,
+ loadSidecar: async () => {
+ throw new Error("features unavailable");
+ },
+ }),
+ /features unavailable/
+ );
+});
+
+test("aborts the sibling transfer when a required artifact fails", async () => {
+ let sidecarAborted = false;
+
+ await assert.rejects(
+ loadInteractiveArtifacts({
+ loadPmtiles: async () => {
+ throw new Error("tiles unavailable");
+ },
+ loadSidecar: (signal) =>
+ new Promise((resolve, reject) => {
+ signal.addEventListener(
+ "abort",
+ () => {
+ sidecarAborted = true;
+ reject(new DOMException("Aborted", "AbortError"));
+ },
+ { once: true }
+ );
+ }),
+ }),
+ /tiles unavailable/
+ );
+
+ assert.equal(sidecarAborted, true);
+});
\ No newline at end of file
diff --git a/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.js b/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.js
new file mode 100644
index 00000000..f8fd7ef2
--- /dev/null
+++ b/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.js
@@ -0,0 +1,36 @@
+export async function loadInteractiveMetadata({
+ get,
+ projectId,
+ imageLayerId,
+ modelId,
+}) {
+ const [layerResult, modelsResult, labelsResult] = await Promise.allSettled([
+ get(
+ `GetLayerLabelingToolData?projectId=${projectId}` +
+ `&imageLayerId=${imageLayerId}`
+ ),
+ get(
+ `GetLayerModelsDetails?projectId=${projectId}` +
+ `&imageLayerId=${imageLayerId}`
+ ),
+ get(`GetInteractiveLabels?projectId=${projectId}&modelId=${modelId}`),
+ ]);
+
+ if (modelsResult.status === "rejected") throw modelsResult.reason;
+ const model = (modelsResult.value || []).find(
+ (candidate) => String(candidate.modelId) === String(modelId)
+ );
+
+ return {
+ layerData: layerResult.status === "fulfilled" ? layerResult.value : null,
+ layerError: layerResult.status === "rejected" ? layerResult.reason : null,
+ model,
+ savedLabels:
+ labelsResult.status === "fulfilled"
+ ? labelsResult.value?.labels || {}
+ : {},
+ savedLabelsLoaded: labelsResult.status === "fulfilled",
+ savedLabelsError:
+ labelsResult.status === "rejected" ? labelsResult.reason : null,
+ };
+}
\ No newline at end of file
diff --git a/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.test.js b/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.test.js
new file mode 100644
index 00000000..b6f6ce32
--- /dev/null
+++ b/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.test.js
@@ -0,0 +1,74 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { loadInteractiveMetadata } from "./loadInteractiveMetadata.js";
+
+
+function deferred() {
+ let resolve;
+ let reject;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+test("starts imagery, model, and saved-label requests concurrently", async () => {
+ const requests = [deferred(), deferred(), deferred()];
+ const calls = [];
+ const loading = loadInteractiveMetadata({
+ projectId: "project-1",
+ imageLayerId: "layer-1",
+ modelId: "42",
+ get: (endpoint) => {
+ calls.push(endpoint);
+ return requests[calls.length - 1].promise;
+ },
+ });
+
+ assert.equal(calls.length, 3);
+ requests[0].resolve({ imagery: {} });
+ requests[1].resolve([{ modelId: "42", pmtilesUrl: "tiles" }]);
+ requests[2].resolve({ labels: { building: { label: 1 } } });
+
+ const result = await loading;
+ assert.equal(result.model.modelId, "42");
+ assert.equal(result.savedLabelsLoaded, true);
+ assert.deepEqual(result.savedLabels, { building: { label: 1 } });
+});
+
+test("tolerates optional imagery and saved-label failures", async () => {
+ const result = await loadInteractiveMetadata({
+ projectId: "project-1",
+ imageLayerId: "layer-1",
+ modelId: "42",
+ get: async (endpoint) => {
+ if (endpoint.startsWith("GetLayerModelsDetails")) {
+ return [{ modelId: "42" }];
+ }
+ throw new Error("optional unavailable");
+ },
+ });
+
+ assert.equal(result.layerData, null);
+ assert.equal(result.savedLabelsLoaded, false);
+ assert.deepEqual(result.savedLabels, {});
+});
+
+test("rejects when required model metadata fails", async () => {
+ await assert.rejects(
+ loadInteractiveMetadata({
+ projectId: "project-1",
+ imageLayerId: "layer-1",
+ modelId: "42",
+ get: async (endpoint) => {
+ if (endpoint.startsWith("GetLayerModelsDetails")) {
+ throw new Error("models unavailable");
+ }
+ return {};
+ },
+ }),
+ /models unavailable/
+ );
+});
\ No newline at end of file
diff --git a/ui/src/Components/LabelingTool/LabelingTool.jsx b/ui/src/Components/LabelingTool/LabelingTool.jsx
index ab6e4d9b..251c428b 100644
--- a/ui/src/Components/LabelingTool/LabelingTool.jsx
+++ b/ui/src/Components/LabelingTool/LabelingTool.jsx
@@ -22,10 +22,6 @@ import { splitShape } from "./SplitShape.jsx";
import "../../assets/css/drawingToolbar.css";
const LabelingTool = ({ setModalComponent }) => {
- LabelingTool.propTypes = {
- setModalComponent: PropType.func.isRequired,
- };
-
const { projectId, imageLayerId } = useParams();
const {
@@ -56,6 +52,7 @@ const LabelingTool = ({ setModalComponent }) => {
const initializeMap = async () => {
if (window.atlas) {
setIsLoading(true);
+ // eslint-disable-next-line react-hooks/immutability
await createMap();
setIsMapReady(true);
setIsLoading(false);
@@ -81,7 +78,7 @@ const LabelingTool = ({ setModalComponent }) => {
updateDrawingLayerStyles(drawingManager, primaryClassesRef.current);
// Handler: drawingchanged
- const handleDrawingChanged = (e) => {
+ const handleDrawingChanged = () => {
const mode = drawingManager.getOptions().mode;
if (mode === "draw-polygon") {
createShape(drawingManager, selectedPrimaryClass, setDrawingCount);
@@ -90,7 +87,7 @@ const LabelingTool = ({ setModalComponent }) => {
};
// Handler: drawingmodechanged
- const handleDrawingModeChanged = (e) => {
+ const handleDrawingModeChanged = () => {
setTimeout(() => {
const mode = drawingManager.getOptions().mode;
if (mode !== "edit-geometry") {
@@ -111,7 +108,7 @@ const LabelingTool = ({ setModalComponent }) => {
};
// Handler: drawingerased
- const handleDrawingErased = (e) => {
+ const handleDrawingErased = () => {
setDrawingCount(drawingManager.source.shapes.length);
setHasUnsavedChanges(true);
setTimeout(() => {
@@ -151,6 +148,7 @@ const LabelingTool = ({ setModalComponent }) => {
mapRef.current.events.remove("drawingcomplete", drawingManager, handleDrawingComplete);
};
}, [
+ appParams.guidedTourProperties,
mapRef,
drawingManager,
selectedPrimaryClass
@@ -281,6 +279,9 @@ const LabelingTool = ({ setModalComponent }) => {
ref={mapRef}
id="map"
className="labeling-tool-page d-flex flex-grow-1 p-0 m-0"
+ data-map-ready={
+ isMapReady && drawingManager !== null ? "true" : "false"
+ }
>
{isMapReady && drawingManager !== null ? (
@@ -330,4 +331,8 @@ const LabelingTool = ({ setModalComponent }) => {
);
};
+LabelingTool.propTypes = {
+ setModalComponent: PropType.func.isRequired,
+};
+
export default LabelingTool;
diff --git a/ui/src/Components/MapRoute.jsx b/ui/src/Components/MapRoute.jsx
new file mode 100644
index 00000000..dace44c4
--- /dev/null
+++ b/ui/src/Components/MapRoute.jsx
@@ -0,0 +1,59 @@
+import { Button, Spinner } from "@fluentui/react-components";
+import { useEffect, useState } from "react";
+
+import { loadMapRoute } from "../util/azureMapsLoader";
+
+
+export const RouteLoading = () => (
+
+
+
+);
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function createMapRoute(importRoute) {
+ const MapRoute = (props) => {
+ const [attempt, setAttempt] = useState(0);
+ const [routeComponent, setRouteComponent] = useState(null);
+ const [loadError, setLoadError] = useState(false);
+
+ useEffect(() => {
+ let active = true;
+ loadMapRoute(importRoute)()
+ .then((route) => {
+ if (active) setRouteComponent(() => route.default);
+ })
+ .catch(() => {
+ if (active) setLoadError(true);
+ });
+ return () => {
+ active = false;
+ };
+ }, [attempt]);
+
+ if (loadError) {
+ return (
+
+
+ Map assets could not be loaded.
+ {
+ setLoadError(false);
+ setRouteComponent(null);
+ setAttempt((value) => value + 1);
+ }}
+ >
+ Retry
+
+
+
+ );
+ }
+ if (!routeComponent) return ;
+
+ const Component = routeComponent;
+ return ;
+ };
+ return MapRoute;
+}
\ No newline at end of file
diff --git a/ui/src/Components/OpenDataCatalog/OpenDataCatalogPanel.jsx b/ui/src/Components/OpenDataCatalog/OpenDataCatalogPanel.jsx
index ffa32684..8873b1f7 100644
--- a/ui/src/Components/OpenDataCatalog/OpenDataCatalogPanel.jsx
+++ b/ui/src/Components/OpenDataCatalog/OpenDataCatalogPanel.jsx
@@ -40,6 +40,7 @@ import {
bboxContains,
} from "./openDataCatalog";
import { isAzureMapsPlaceholder } from "../../util/azureMapsAuth";
+import { loadAzureMaps } from "../../util/azureMapsLoader";
import OpenDataCatalogMap from "./OpenDataCatalogMap";
import SceneListItem from "./SceneListItem";
@@ -197,6 +198,9 @@ const OpenDataCatalogPanel = ({
const [errors, setErrors] = useState([]);
const [loadError, setLoadError] = useState("");
const [addError, setAddError] = useState("");
+ const [mapsReady, setMapsReady] = useState(false);
+ const [mapsError, setMapsError] = useState(false);
+ const [mapsAttempt, setMapsAttempt] = useState(0);
const [sourceFilter, setSourceFilter] = useState("all");
const [phaseFilter, setPhaseFilter] = useState("all");
@@ -209,8 +213,24 @@ const OpenDataCatalogPanel = ({
// selection (it's a property of the layer, not a scene).
const [clipMode, setClipMode] = useState(false);
+ useEffect(() => {
+ if (!isOpen) return undefined;
+ let active = true;
+ loadAzureMaps()
+ .then(() => {
+ if (active) setMapsReady(true);
+ })
+ .catch(() => {
+ if (active) setMapsError(true);
+ });
+ return () => {
+ active = false;
+ };
+ }, [isOpen, mapsAttempt]);
+
// Leaving draw mode when the previewed scene changes (the AOI itself stays).
useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setClipMode(false);
}, [selectedScene]);
@@ -234,6 +254,7 @@ const OpenDataCatalogPanel = ({
useEffect(() => {
if (!isOpen || events.length > 0) return undefined;
let cancelled = false;
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setDiscovering(true);
setDiscoverErrors([]);
discoverEvents()
@@ -259,6 +280,7 @@ const OpenDataCatalogPanel = ({
useEffect(() => {
if (!isOpen || !event) return undefined;
let cancelled = false;
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
setScenes([]);
setErrors([]);
@@ -511,7 +533,7 @@ const OpenDataCatalogPanel = ({
{/* Map / preview */}
- {isOpen && (
+ {isOpen && mapsReady && (
)}
+ {isOpen && !mapsReady && (
+
+ {mapsError ? (
+
+ Map assets could not be loaded.
+ {
+ setMapsError(false);
+ setMapsAttempt((value) => value + 1);
+ }}
+ >
+ Retry
+
+
+ ) : (
+
+ )}
+
+ )}
{/* Server-side clip AOI toolbar */}
{((selectedScene && selectedScene.cogUrl) || clipAoi) && (
diff --git a/ui/src/Components/PublishedDatasets.jsx b/ui/src/Components/PublishedDatasets.jsx
index 6e9c36e6..e8d3f869 100644
--- a/ui/src/Components/PublishedDatasets.jsx
+++ b/ui/src/Components/PublishedDatasets.jsx
@@ -8,9 +8,15 @@ import {
} from "@fluentui/react-components";
import { AppContext } from "../AppContext";
-import { apiGet } from "../util/api";
+import { apiGetResponse } from "../util/api";
import { FluentIcon } from "../util/icons";
+import {
+ buildPublishedDatasetsEndpoint,
+ preparePublishedDatasetsRequest,
+ shouldPollPublishedDatasets,
+} from "../util/publishedDatasetsRequest";
import { isPublishingStatusActive } from "../util/publishing";
+import { createSingleFlight } from "../util/singleFlight";
import NoResultsMessage from "./NoResultsMessage";
import PublishedDatasetRow from "./PublishedDatasetRow";
@@ -41,41 +47,94 @@ const PublishedDatasets = () => {
const [sort, setSort] = useState({ key: "publishedDate", dir: "desc" });
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
+ const requestRef = useRef(createSingleFlight());
+ const etagsRef = useRef(new Map());
+ const mountedRef = useRef(true);
+ const ownsLoadingRef = useRef(false);
+
+ async function fetchDatasets(showLoading = false, forceRefresh = false) {
+ const endpoint = buildPublishedDatasetsEndpoint({
+ currentPage,
+ pageSize,
+ sort,
+ targetFilter,
+ statusFilter,
+ searchText: normalizedSearchText,
+ });
+ const request = requestRef.current;
+ const startsRequest = preparePublishedDatasetsRequest(
+ request,
+ endpoint,
+ forceRefresh
+ );
+ if (showLoading && startsRequest) {
+ ownsLoadingRef.current = true;
+ setIsLoading(true, "Loading published datasets...");
+ }
+
+ const requestPromise = request.run(endpoint, async (signal) => {
+ try {
+ const headers = {};
+ if (etagsRef.current.has(endpoint)) {
+ headers["If-None-Match"] = etagsRef.current.get(endpoint);
+ }
+ if (forceRefresh) headers["Cache-Control"] = "no-cache";
+ const { data: response, etag, status } = await apiGetResponse(
+ endpoint,
+ { signal, headers }
+ );
+ if (etag) etagsRef.current.set(endpoint, etag);
+ if (!mountedRef.current) return;
+ if (status === 304) {
+ setError("");
+ return;
+ }
- async function fetchDatasets(showLoading = false) {
- if (showLoading) setIsLoading(true, "Loading published datasets...");
- try {
- const query = new URLSearchParams({
- page: String(currentPage),
- pageSize: String(pageSize),
- sortKey: sort.key,
- sortDirection: sort.dir,
+ const nextItems = response.publishedDatasets || [];
+ const nextTotal = response.pagination?.totalCount ?? nextItems.length;
+ setItems(nextItems);
+ setTotalItems(nextTotal);
+ const nextTotalPages = Math.max(1, Math.ceil(nextTotal / pageSize));
+ if (currentPage > nextTotalPages) setCurrentPage(nextTotalPages);
+ setError("");
+ } catch (fetchError) {
+ if (fetchError.name !== "AbortError" && mountedRef.current) {
+ setError(
+ fetchError.message || "Unable to load published datasets."
+ );
+ }
+ }
+ });
+ if (showLoading && startsRequest) {
+ requestPromise.finally(() => {
+ if (ownsLoadingRef.current && !request.isRunning()) {
+ ownsLoadingRef.current = false;
+ setIsLoading(false);
+ }
});
- if (targetFilter !== "all") query.set("target", targetFilter);
- if (statusFilter !== "all") query.set("status", statusFilter);
- if (normalizedSearchText) query.set("search", normalizedSearchText);
- const response = await apiGet(`GetPublishedDatasets?${query}`);
- const nextItems = response.publishedDatasets || [];
- const nextTotal = response.pagination?.totalCount ?? nextItems.length;
- setItems(nextItems);
- setTotalItems(nextTotal);
- const nextTotalPages = Math.max(1, Math.ceil(nextTotal / pageSize));
- if (currentPage > nextTotalPages) setCurrentPage(nextTotalPages);
- setError("");
- } catch (fetchError) {
- setError(fetchError.message || "Unable to load published datasets.");
- } finally {
- if (showLoading) setIsLoading(false);
}
+ return requestPromise;
}
+ useEffect(() => {
+ mountedRef.current = true;
+ const request = requestRef.current;
+ return () => {
+ mountedRef.current = false;
+ if (ownsLoadingRef.current) {
+ ownsLoadingRef.current = false;
+ setIsLoading(false);
+ }
+ request.abort();
+ };
+ }, [setIsLoading]);
+
useEffect(() => {
if (!searchReady) return;
// State updates occur after the awaited API response, not synchronously.
// Show the full-page loading overlay only on the first load (items === null,
// catalog pattern); later filter/search/sort/page changes refetch silently
// so the overlay doesn't flash on every keystroke.
- // eslint-disable-next-line react-hooks/set-state-in-effect
fetchDatasets(items === null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage, pageSize, targetFilter, statusFilter, normalizedSearchText, searchReady, sort]);
@@ -89,14 +148,24 @@ const PublishedDatasets = () => {
// polling first started (which would overwrite fresh results with a stale
// query).
const fetchDatasetsRef = useRef(fetchDatasets);
- fetchDatasetsRef.current = fetchDatasets;
+ useEffect(() => {
+ fetchDatasetsRef.current = fetchDatasets;
+ });
useEffect(() => {
if (!hasActiveItems || !searchReady) return undefined;
- const interval = window.setInterval(
- () => fetchDatasetsRef.current(false),
- 5000,
- );
+ const interval = window.setInterval(() => {
+ if (
+ shouldPollPublishedDatasets({
+ hasActiveItems,
+ searchReady,
+ visibilityState: document.visibilityState,
+ requestRunning: requestRef.current.isRunning(),
+ })
+ ) {
+ fetchDatasetsRef.current(false);
+ }
+ }, 5000);
return () => window.clearInterval(interval);
}, [hasActiveItems, searchReady]);
@@ -244,7 +313,7 @@ const PublishedDatasets = () => {
key={item.datasetId}
item={item}
index={(page - 1) * pageSize + index}
- onRefresh={() => fetchDatasets(false)}
+ onRefresh={() => fetchDatasets(false, true)}
/>
))}
diff --git a/ui/src/Components/Visualizer/Visualizer.jsx b/ui/src/Components/Visualizer/Visualizer.jsx
index 6b79cfd6..a7413233 100644
--- a/ui/src/Components/Visualizer/Visualizer.jsx
+++ b/ui/src/Components/Visualizer/Visualizer.jsx
@@ -29,6 +29,10 @@ const Visualizer = ({ setModalComponent }) => {
const swipeMapRef = useRef(null);
const zoomControlRef = useRef(null);
const [swipeStateMobile, setSwipeStateMobile] = useState("post");
+ const [mapReadiness, setMapReadiness] = useState({
+ primary: false,
+ secondary: false,
+ });
const [imageryValues, setImageryValues] = useState({
opacity: 1,
@@ -178,6 +182,11 @@ const Visualizer = ({ setModalComponent }) => {
await loadStudyArea(primaryMap, visualizerResults.studyArea);
+ setMapReadiness((previous) => ({
+ ...previous,
+ primary: true,
+ }));
+
});
// Secondary map event listeners
@@ -202,6 +211,10 @@ const Visualizer = ({ setModalComponent }) => {
);
loadStudyArea(secondaryMap, visualizerResults.studyArea);
+ setMapReadiness((previous) => ({
+ ...previous,
+ secondary: true,
+ }));
});
// Assign maps to refs
@@ -454,7 +467,12 @@ const Visualizer = ({ setModalComponent }) => {
return (
-
+
diff --git a/ui/src/Components/helpMediaLoading.test.js b/ui/src/Components/helpMediaLoading.test.js
new file mode 100644
index 00000000..34573bdb
--- /dev/null
+++ b/ui/src/Components/helpMediaLoading.test.js
@@ -0,0 +1,32 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+
+const helpFiles = [
+ "HelpDocsImageLayers.jsx",
+ "HelpDocsLabeling.jsx",
+ "HelpDocsModelCatalog.jsx",
+ "HelpDocsModelTraining.jsx",
+ "HelpDocsOverview.jsx",
+ "HelpDocsProjects.jsx",
+ "HelpDocsResults.jsx",
+];
+
+test("help images and videos defer media downloads", async () => {
+ const sources = await Promise.all(
+ helpFiles.map((file) =>
+ readFile(new URL(`./HelpDocs/${file}`, import.meta.url), "utf8")
+ )
+ );
+ const markup = sources.join("\n");
+ const images = markup.match(/
]*>/g) || [];
+ const videos = markup.match(/
]*>/g) || [];
+
+ assert.ok(images.length > 0);
+ assert.ok(videos.length > 0);
+ images.forEach((image) => {
+ assert.match(image, /\bloading="lazy"/);
+ assert.match(image, /\bdecoding="async"/);
+ });
+ videos.forEach((video) => assert.match(video, /\bpreload="none"/));
+});
\ No newline at end of file
diff --git a/ui/src/Components/loadImageLayerFormData.js b/ui/src/Components/loadImageLayerFormData.js
new file mode 100644
index 00000000..edea82a3
--- /dev/null
+++ b/ui/src/Components/loadImageLayerFormData.js
@@ -0,0 +1,12 @@
+export async function loadImageLayerFormData(imageLayerId, projectId, get) {
+ const [imageLayerToEdit, project] = await Promise.all([
+ imageLayerId
+ ? get(
+ `GetLayerDetailView?projectId=${projectId}` +
+ `&imageLayerId=${imageLayerId}`
+ )
+ : Promise.resolve(null),
+ get(`GetProjectDetails?projectId=${projectId}`),
+ ]);
+ return { imageLayerToEdit, project };
+}
\ No newline at end of file
diff --git a/ui/src/assets/css/style.css b/ui/src/assets/css/style.css
index 1a18c656..c30fad0a 100644
--- a/ui/src/assets/css/style.css
+++ b/ui/src/assets/css/style.css
@@ -289,6 +289,14 @@ body {
overflow: hidden;
}
+.route-loading {
+ display: flex;
+ min-height: 240px;
+ width: 100%;
+ align-items: center;
+ justify-content: center;
+}
+
.app-sidebar-backdrop {
display: none;
}
diff --git a/ui/src/util/api.js b/ui/src/util/api.js
index c5b2e759..75412a55 100644
--- a/ui/src/util/api.js
+++ b/ui/src/util/api.js
@@ -1,9 +1,8 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
-const APIUrl = import.meta.env.VITE_API_URL;
-const APIMSubscriptionKey = import.meta.env.VITE_APIM_SUBSCRIPTION_KEY;
-import { upsertUser } from "../AppHelper.js";
+const APIUrl = import.meta.env?.VITE_API_URL || "";
+const APIMSubscriptionKey = import.meta.env?.VITE_APIM_SUBSCRIPTION_KEY;
import { sanitizeRedirectPath } from "./validation.js";
import { fetchJsonResponse } from "./http.js";
@@ -16,40 +15,25 @@ export function buildUrl(endpoint) {
return base;
}
-export async function apiValidateUser(setAppParams) {
- try {
- const staticAppStatus = await fetch("/.auth/me");
- const staticAppUserStatus = await staticAppStatus.json();
- if (staticAppUserStatus.clientPrincipal) {
- var response = await apiGet("GetUserById?userId=" + staticAppUserStatus.clientPrincipal.userDetails);
- if (response && response.status === "Active" || response && response.status === "Inactive") {
- const upsertUserObject = await upsertUser(response);
- if (upsertUserObject) {
- setAppParams((prevParams) => ({
- ...prevParams,
- userId: upsertUserObject.userId,
- // SWA principal object id — matches PublishedDataset.publishedByUser
- // so non-admin publishers are recognized as owners.
- identityId: staticAppUserStatus.clientPrincipal.userId,
- userRoles: upsertUserObject.userRoles,
- userSettings: upsertUserObject.settings,
- userStatus: upsertUserObject.status
- }));
- }
- } else if (response && response.status === "PendingAcceptance") {
- setAppParams((prevParams) => ({
- ...prevParams,
- userId: response.userId,
- identityId: staticAppUserStatus.clientPrincipal.userId,
- userRoles: response.userRoles,
- userSettings: response.settings,
- userStatus: response.status
- }));
- }
- }
- } catch (error) {
- console.error("Error validating user:", error);
+export async function apiValidateUser(setAppParams, get = apiGet) {
+ const response = await get("GetSessionBootstrap");
+ const user = response?.user;
+ const publishing = response?.publishing;
+ if (!user || !publishing) {
+ throw new Error("Invalid session bootstrap response.");
}
+
+ setAppParams((previous) => ({
+ ...previous,
+ userId: user.userId,
+ identityId: user.identityId,
+ userRoles: user.userRoles,
+ userSettings: user.settings,
+ userStatus: user.status,
+ publishingEnabled: !!publishing.publishingEnabled,
+ publishingProviders: publishing.providers || [],
+ }));
+ return response;
}
export async function apiLogout(redirectPath = "/") {
diff --git a/ui/src/util/azureMapsLoader.js b/ui/src/util/azureMapsLoader.js
index 99289d1e..37a94001 100644
--- a/ui/src/util/azureMapsLoader.js
+++ b/ui/src/util/azureMapsLoader.js
@@ -73,10 +73,14 @@ export function loadAzureMaps(documentRef = document) {
loadPromise = Promise.all([
loadStylesheet(documentRef, MAP_CONTROL_CSS),
loadStylesheet(documentRef, DRAWING_CSS),
+ loadScript(documentRef, MAP_CONTROL_JS),
])
- .then(() => loadScript(documentRef, MAP_CONTROL_JS))
- .then(() => loadScript(documentRef, DRAWING_JS))
- .then(() => loadScript(documentRef, SWIPE_JS))
+ .then(() =>
+ Promise.all([
+ loadScript(documentRef, DRAWING_JS),
+ loadScript(documentRef, SWIPE_JS),
+ ])
+ )
.catch((error) => {
loadPromise = null;
throw error;
@@ -84,6 +88,11 @@ export function loadAzureMaps(documentRef = document) {
return loadPromise;
}
+export function loadMapRoute(importRoute, loadMaps = loadAzureMaps) {
+ return () =>
+ Promise.all([loadMaps(), importRoute()]).then(([, route]) => route);
+}
+
export function resetAzureMapsLoaderForTests() {
loadPromise = null;
}
\ No newline at end of file
diff --git a/ui/src/util/azureMapsLoader.test.js b/ui/src/util/azureMapsLoader.test.js
index 6992d473..a4cff4cf 100644
--- a/ui/src/util/azureMapsLoader.test.js
+++ b/ui/src/util/azureMapsLoader.test.js
@@ -3,10 +3,11 @@ import assert from "node:assert/strict";
import {
loadAzureMaps,
+ loadMapRoute,
resetAzureMapsLoaderForTests,
} from "./azureMapsLoader.js";
-function fakeDocument({ failOnce = null } = {}) {
+function fakeDocument({ failOnce = null, autoLoad = true } = {}) {
const elements = [];
const attempts = new Map();
@@ -14,19 +15,21 @@ function fakeDocument({ failOnce = null } = {}) {
return element.src || element.href;
}
- return {
+ const documentRef = {
elements,
head: {
appendChild(element) {
elements.push(element);
const name = asset(element);
attempts.set(name, (attempts.get(name) || 0) + 1);
- queueMicrotask(() => {
- const event = name === failOnce && attempts.get(name) === 1
- ? "error"
- : "load";
- element.listeners.get(event)?.();
- });
+ if (autoLoad) {
+ queueMicrotask(() => {
+ const event = name === failOnce && attempts.get(name) === 1
+ ? "error"
+ : "load";
+ element.listeners.get(event)?.();
+ });
+ }
},
},
createElement(tagName) {
@@ -47,7 +50,12 @@ function fakeDocument({ failOnce = null } = {}) {
const match = selector.match(/data-azure-maps-(?:src|href)="(.+)"/);
return elements.find((element) => asset(element) === match?.[1]) || null;
},
+ dispatchAsset(name, event = "load") {
+ const element = elements.find((candidate) => asset(candidate) === name);
+ element?.listeners.get(event)?.();
+ },
};
+ return documentRef;
}
test.beforeEach(() => resetAzureMapsLoaderForTests());
@@ -69,6 +77,62 @@ test("loads styles, map control, drawing tools, and swipe in order", async () =>
);
});
+test("loads independent map assets in two concurrent phases", async () => {
+ const documentRef = fakeDocument({ autoLoad: false });
+ const initialAssets = [
+ "https://atlas.microsoft.com/sdk/javascript/mapcontrol/3/atlas.min.css",
+ "https://atlas.microsoft.com/sdk/javascript/drawing/1/atlas-drawing.min.css",
+ "https://atlas.microsoft.com/sdk/javascript/mapcontrol/3/atlas.min.js",
+ ];
+ const dependentAssets = [
+ "https://atlas.microsoft.com/sdk/javascript/drawing/1/atlas-drawing.min.js",
+ "/assets/js/azure-maps-swipe-map.min.js",
+ ];
+
+ const loading = loadAzureMaps(documentRef);
+ assert.deepEqual(
+ documentRef.elements.map((element) => element.src || element.href),
+ initialAssets
+ );
+
+ initialAssets.forEach((asset) => documentRef.dispatchAsset(asset));
+ await Promise.resolve();
+ await Promise.resolve();
+ assert.deepEqual(
+ documentRef.elements.map((element) => element.src || element.href),
+ [...initialAssets, ...dependentAssets]
+ );
+
+ dependentAssets.forEach((asset) => documentRef.dispatchAsset(asset));
+ await loading;
+});
+
+test("starts the route import while Azure Maps is loading", async () => {
+ let resolveMaps;
+ let resolveRoute;
+ const calls = [];
+ const loadMaps = () => {
+ calls.push("maps");
+ return new Promise((resolve) => {
+ resolveMaps = resolve;
+ });
+ };
+ const importRoute = () => {
+ calls.push("route");
+ return new Promise((resolve) => {
+ resolveRoute = resolve;
+ });
+ };
+ const routeModule = { default: "route" };
+
+ const loading = loadMapRoute(importRoute, loadMaps)();
+ assert.deepEqual(calls, ["maps", "route"]);
+ resolveRoute(routeModule);
+ resolveMaps();
+
+ assert.equal(await loading, routeModule);
+});
+
test("deduplicates concurrent and completed loads", async () => {
const documentRef = fakeDocument();
diff --git a/ui/src/util/publishedDatasetsRequest.js b/ui/src/util/publishedDatasetsRequest.js
new file mode 100644
index 00000000..ea35e66d
--- /dev/null
+++ b/ui/src/util/publishedDatasetsRequest.js
@@ -0,0 +1,38 @@
+export function buildPublishedDatasetsEndpoint({
+ currentPage,
+ pageSize,
+ sort,
+ targetFilter,
+ statusFilter,
+ searchText,
+}) {
+ const query = new URLSearchParams({
+ page: String(currentPage),
+ pageSize: String(pageSize),
+ sortKey: sort.key,
+ sortDirection: sort.dir,
+ });
+ if (targetFilter !== "all") query.set("target", targetFilter);
+ if (statusFilter !== "all") query.set("status", statusFilter);
+ if (searchText) query.set("search", searchText);
+ return `GetPublishedDatasets?${query}`;
+}
+
+export function shouldPollPublishedDatasets({
+ hasActiveItems,
+ searchReady,
+ visibilityState,
+ requestRunning,
+}) {
+ return (
+ hasActiveItems &&
+ searchReady &&
+ visibilityState === "visible" &&
+ !requestRunning
+ );
+}
+
+export function preparePublishedDatasetsRequest(request, key, forceRefresh) {
+ if (forceRefresh && request.isRunning(key)) request.abort();
+ return !request.isRunning(key);
+}
\ No newline at end of file
diff --git a/ui/src/util/publishedDatasetsRequest.test.js b/ui/src/util/publishedDatasetsRequest.test.js
new file mode 100644
index 00000000..34b4267f
--- /dev/null
+++ b/ui/src/util/publishedDatasetsRequest.test.js
@@ -0,0 +1,93 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ buildPublishedDatasetsEndpoint,
+ preparePublishedDatasetsRequest,
+ shouldPollPublishedDatasets,
+} from "./publishedDatasetsRequest.js";
+
+
+test("builds a stable endpoint from every query field", () => {
+ const endpoint = buildPublishedDatasetsEndpoint({
+ currentPage: 2,
+ pageSize: 20,
+ sort: { key: "name", dir: "asc" },
+ targetFilter: "local",
+ statusFilter: "PUBLISHED",
+ searchText: "damage",
+ });
+
+ assert.equal(
+ endpoint,
+ "GetPublishedDatasets?page=2&pageSize=20&sortKey=name&sortDirection=asc" +
+ "&target=local&status=PUBLISHED&search=damage"
+ );
+});
+
+test("omits inactive filters from the endpoint", () => {
+ const endpoint = buildPublishedDatasetsEndpoint({
+ currentPage: 1,
+ pageSize: 8,
+ sort: { key: "publishedDate", dir: "desc" },
+ targetFilter: "all",
+ statusFilter: "all",
+ searchText: "",
+ });
+
+ assert.equal(
+ endpoint,
+ "GetPublishedDatasets?page=1&pageSize=8" +
+ "&sortKey=publishedDate&sortDirection=desc"
+ );
+});
+
+test("polls only visible active pages without a running request", () => {
+ const ready = {
+ hasActiveItems: true,
+ searchReady: true,
+ visibilityState: "visible",
+ requestRunning: false,
+ };
+
+ assert.equal(shouldPollPublishedDatasets(ready), true);
+ for (const override of [
+ { hasActiveItems: false },
+ { searchReady: false },
+ { visibilityState: "hidden" },
+ { requestRunning: true },
+ ]) {
+ assert.equal(
+ shouldPollPublishedDatasets({ ...ready, ...override }),
+ false
+ );
+ }
+});
+
+test("force refresh aborts a stale same-query request", () => {
+ let running = true;
+ const calls = [];
+ const request = {
+ isRunning(key) {
+ calls.push(["isRunning", key]);
+ return running;
+ },
+ abort() {
+ calls.push(["abort"]);
+ running = false;
+ },
+ };
+
+ const startsRequest = preparePublishedDatasetsRequest(
+ request,
+ "query",
+ true
+ );
+
+ assert.equal(startsRequest, true);
+ assert.deepEqual(calls, [
+ ["isRunning", "query"],
+ ["abort"],
+ ["isRunning", "query"],
+ ]);
+});
\ No newline at end of file
diff --git a/ui/src/util/sessionBootstrap.test.js b/ui/src/util/sessionBootstrap.test.js
new file mode 100644
index 00000000..505a2e2f
--- /dev/null
+++ b/ui/src/util/sessionBootstrap.test.js
@@ -0,0 +1,80 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { apiValidateUser } from "./api.js";
+
+
+test("session bootstrap updates user and publishing state with one request", async () => {
+ const calls = [];
+ const updates = [];
+ const response = {
+ user: {
+ userId: "analyst@example.com",
+ identityId: "object-id",
+ userRoles: ["authenticated", "contributors"],
+ settings: { theme: "dark" },
+ status: "Active",
+ },
+ publishing: {
+ publishingEnabled: true,
+ providers: [{ id: "local" }],
+ },
+ };
+
+ await apiValidateUser(
+ (update) => updates.push(update),
+ async (endpoint) => {
+ calls.push(endpoint);
+ return response;
+ }
+ );
+
+ assert.deepEqual(calls, ["GetSessionBootstrap"]);
+ assert.equal(updates.length, 1);
+ assert.deepEqual(updates[0]({ appTitle: "HASTE" }), {
+ appTitle: "HASTE",
+ userId: "analyst@example.com",
+ identityId: "object-id",
+ userRoles: ["authenticated", "contributors"],
+ userSettings: { theme: "dark" },
+ userStatus: "Active",
+ publishingEnabled: true,
+ publishingProviders: [{ id: "local" }],
+ });
+});
+
+test("session bootstrap rejects an incomplete response", async () => {
+ await assert.rejects(
+ apiValidateUser(() => {}, async () => ({ user: {} })),
+ /Invalid session bootstrap response/
+ );
+});
+
+test("pending acceptance stays blocked without follow-up requests", async () => {
+ const calls = [];
+ const updates = [];
+ const pending = {
+ user: {
+ userId: "analyst@example.com",
+ identityId: "object-id",
+ userRoles: [],
+ settings: {},
+ status: "PendingAcceptance",
+ },
+ publishing: { publishingEnabled: false, providers: [] },
+ };
+ await apiValidateUser(
+ (update) => updates.push(update),
+ async (endpoint) => {
+ calls.push(endpoint);
+ return pending;
+ }
+ );
+
+ assert.deepEqual(calls, ["GetSessionBootstrap"]);
+ assert.equal(updates.length, 1);
+ assert.equal(updates[0]({}).userStatus, "PendingAcceptance");
+ assert.equal(updates[0]({}).identityId, "object-id");
+ assert.deepEqual(updates[0]({}).userRoles, []);
+ assert.equal(updates[0]({}).publishingEnabled, false);
+});
\ No newline at end of file
diff --git a/ui/src/util/sessionStartup.js b/ui/src/util/sessionStartup.js
new file mode 100644
index 00000000..767adec7
--- /dev/null
+++ b/ui/src/util/sessionStartup.js
@@ -0,0 +1,28 @@
+export async function loadSession({
+ validateUser,
+ setAppParams,
+ setIsLoading,
+ setSessionError,
+}) {
+ setSessionError(false);
+ setIsLoading(true);
+ try {
+ await validateUser(setAppParams);
+ return true;
+ } catch {
+ setAppParams((previous) => ({
+ ...previous,
+ userId: null,
+ identityId: null,
+ userRoles: [],
+ userSettings: {},
+ userStatus: null,
+ publishingEnabled: false,
+ publishingProviders: [],
+ }));
+ setSessionError(true);
+ return false;
+ } finally {
+ setIsLoading(false);
+ }
+}
\ No newline at end of file
diff --git a/ui/src/util/sessionStartup.test.js b/ui/src/util/sessionStartup.test.js
new file mode 100644
index 00000000..8a55daea
--- /dev/null
+++ b/ui/src/util/sessionStartup.test.js
@@ -0,0 +1,48 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { loadSession } from "./sessionStartup.js";
+
+
+test("successful session startup always releases loading", async () => {
+ const loading = [];
+ const errors = [];
+ const result = await loadSession({
+ validateUser: async () => {},
+ setAppParams: () => assert.fail("success must not replace app state"),
+ setIsLoading: (value) => loading.push(value),
+ setSessionError: (value) => errors.push(value),
+ });
+
+ assert.equal(result, true);
+ assert.deepEqual(loading, [true, false]);
+ assert.deepEqual(errors, [false]);
+});
+
+test("failed session startup exposes retry state and releases loading", async () => {
+ const loading = [];
+ const errors = [];
+ const updates = [];
+ const result = await loadSession({
+ validateUser: async () => {
+ throw new Error("server detail");
+ },
+ setAppParams: (update) => updates.push(update),
+ setIsLoading: (value) => loading.push(value),
+ setSessionError: (value) => errors.push(value),
+ });
+
+ assert.equal(result, false);
+ assert.deepEqual(loading, [true, false]);
+ assert.deepEqual(errors, [false, true]);
+ assert.deepEqual(updates[0]({ retained: true }), {
+ retained: true,
+ userId: null,
+ identityId: null,
+ userRoles: [],
+ userSettings: {},
+ userStatus: null,
+ publishingEnabled: false,
+ publishingProviders: [],
+ });
+});
\ No newline at end of file
From bd93061ae32aeae815bf6fb870e64efb7c251a1b Mon Sep 17 00:00:00 2001
From: prbatero <42007693+prbatero@users.noreply.github.com>
Date: Wed, 2 Sep 2026 10:12:05 -0400
Subject: [PATCH 4/7] test(perf): enforce route loading budgets
Measure cold and warm direct and in-app navigation across desktop and mobile profiles. Fail on readiness, browser, API, or p95 budget violations while keeping auth state and fixture details out of results.
---
.../tools/route_matrix.cjs | 430 +++++++++++++++++
.../perf-layer-loading/tools/ui_bench.cjs | 434 ++++++++++--------
2 files changed, 684 insertions(+), 180 deletions(-)
create mode 100644 spec/features/perf-app-wide-loading/tools/route_matrix.cjs
diff --git a/spec/features/perf-app-wide-loading/tools/route_matrix.cjs b/spec/features/perf-app-wide-loading/tools/route_matrix.cjs
new file mode 100644
index 00000000..a85bbd00
--- /dev/null
+++ b/spec/features/perf-app-wide-loading/tools/route_matrix.cjs
@@ -0,0 +1,430 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+//
+// Measure direct cold/warm navigation for every HASTE route against a deployed
+// environment. Authentication is supplied as a Playwright storage-state file
+// that must remain outside the repository with mode 0600.
+//
+// NODE_PATH=/tmp/haste-uibench/node_modules node route_matrix.cjs \
+// --ui https://example.azurestaticapps.net \
+// --storage-state /secure/path/state.json \
+// --project --layer --model
+const fs = require("node:fs");
+const path = require("node:path");
+
+function arg(name, fallback = null) {
+ const index = process.argv.indexOf(`--${name}`);
+ return index >= 0 && process.argv[index + 1]
+ ? process.argv[index + 1]
+ : fallback;
+}
+
+function percentile(values, percent) {
+ if (!values.length) return null;
+ const sorted = [...values].sort((left, right) => left - right);
+ const index = Math.min(
+ sorted.length - 1,
+ Math.round((percent / 100) * (sorted.length - 1))
+ );
+ return sorted[index];
+}
+
+const ui = arg("ui");
+const storageState = arg("storage-state");
+const project = arg("project");
+const layer = arg("layer");
+const model = arg("model");
+const repeats = Number.parseInt(arg("repeats", "3"), 10);
+
+if (!ui || !storageState || !project || !layer || !model) {
+ throw new Error(
+ "Required: --ui, --storage-state, --project, --layer, and --model"
+ );
+}
+if (!Number.isInteger(repeats) || repeats < 1) {
+ throw new Error("--repeats must be a positive integer.");
+}
+if (!fs.existsSync(storageState)) {
+ throw new Error("The Playwright storage-state file does not exist.");
+}
+const repositoryRoot = fs.realpathSync(path.resolve(__dirname, "../../../.."));
+const resolvedStorageState = fs.realpathSync(path.resolve(storageState));
+const storageRelative = path.relative(repositoryRoot, resolvedStorageState);
+const storageIsOutsideRepository =
+ storageRelative === ".." ||
+ storageRelative.startsWith(`..${path.sep}`) ||
+ path.isAbsolute(storageRelative);
+if (!storageIsOutsideRepository) {
+ throw new Error("The storage-state file must be outside the repository.");
+}
+const storageStateStats = fs.statSync(resolvedStorageState);
+if (!storageStateStats.isFile()) {
+ throw new Error("The storage-state path must be a regular file.");
+}
+if ((storageStateStats.mode & 0o777) !== 0o600) {
+ throw new Error("The storage-state file must have mode 0600.");
+}
+
+const { chromium } = require("playwright");
+
+const routes = [
+ { name: "home", path: "/", ready: ".home-dashboard-page" },
+ { name: "projects", path: "/projects", ready: ".pgrid-page--projects" },
+ {
+ name: "project",
+ path: `/project/${project}`,
+ ready: ".pgrid-page--layers",
+ },
+ {
+ name: "image-layer",
+ path: `/project/${project}/imageLayer/${layer}`,
+ readyText: "Imagery Preview",
+ },
+ {
+ name: "create-layer",
+ path: `/create-imageLayer/${project}`,
+ ready: ".pgrid-page--scroll",
+ },
+ {
+ name: "edit-layer",
+ path: `/edit-imageLayer/${project}/${layer}`,
+ ready: ".pgrid-page--scroll",
+ },
+ {
+ name: "labeling",
+ path: `/labeling-tool/${project}/${layer}`,
+ ready: ".labeling-tool-page",
+ mapReady: '.labeling-tool-page[data-map-ready="true"]',
+ },
+ {
+ name: "validation",
+ path: `/validation/${project}/${layer}`,
+ ready: ".building-validation-page",
+ mapReady: '.building-validation-page[data-map-ready="true"]',
+ },
+ {
+ name: "interactive-labeler",
+ path: `/interactive-label/${project}/${layer}/${model}`,
+ ready: '[data-route-map="interactive-labeler"]',
+ mapReady:
+ '[data-route-map="interactive-labeler"][data-map-ready="true"]',
+ mapTimeoutMs: 300000,
+ },
+ {
+ name: "visualizer",
+ path: `/visualizer/${project}/${layer}/${model}`,
+ ready: ".visualizer-container",
+ mapReady: '.visualizer-container[data-map-ready="true"]',
+ },
+ { name: "help", path: "/help-docs", ready: ".help-docs" },
+ {
+ name: "published-datasets",
+ path: "/published-datasets",
+ ready: ".pgrid-page--published-datasets",
+ },
+ {
+ name: "model-catalog",
+ path: "/model-catalog",
+ ready: ".pgrid-page--model-catalog",
+ },
+ { name: "admin-users", path: "/admin-users", ready: ".pgrid-page" },
+ {
+ name: "admin-source-types",
+ path: "/admin-source-types",
+ readyText: "Source Type Management",
+ },
+ {
+ name: "admin-labeling",
+ path: "/admin-labeling-tool",
+ readyText: "Labeling Tool Settings",
+ },
+];
+
+const profiles = [
+ { name: "desktop", viewport: { width: 1440, height: 900 } },
+ { name: "mobile", viewport: { width: 390, height: 844 }, isMobile: true },
+];
+
+const homeRoute = routes.find((route) => route.name === "home");
+const helpRoute = routes.find((route) => route.name === "help");
+const twoSecondRoutes = new Set([
+ "help",
+ "admin-users",
+ "admin-source-types",
+ "admin-labeling",
+]);
+
+function getInAppBaseline(route) {
+ return route.name === "home" ? helpRoute : homeRoute;
+}
+
+function getContentLimitMs(route) {
+ return twoSecondRoutes.has(route.name) ? 2000 : 3000;
+}
+
+function getMapLimitMs(route, mode) {
+ if (
+ !route.mapReady ||
+ route.name === "interactive-labeler" ||
+ !mode.includes("warm")
+ ) {
+ return null;
+ }
+ return 3000;
+}
+
+async function waitForRoute(page, route, started) {
+ await page.waitForFunction(
+ ({ ready, readyText }) => {
+ const contentReady = ready
+ ? document.querySelector(ready) !== null
+ : readyText
+ ? document.body?.innerText.includes(readyText)
+ : true;
+ return (
+ contentReady &&
+ !document.querySelector(".route-loading") &&
+ !document.querySelector(".app-loading-layer")
+ );
+ },
+ { ready: route.ready, readyText: route.readyText },
+ { timeout: 60000, polling: 50 }
+ );
+ const contentMs = Date.now() - started;
+ let mapMs = null;
+ if (route.mapReady) {
+ await page.waitForSelector(route.mapReady, {
+ state: "attached",
+ timeout: route.mapTimeoutMs || 60000,
+ });
+ mapMs = Date.now() - started;
+ }
+ return { contentMs, mapMs };
+}
+
+async function navigateInApp(page, routePath) {
+ await page.evaluate((nextPath) => {
+ history.pushState({}, "", nextPath);
+ dispatchEvent(new PopStateEvent("popstate"));
+ }, routePath);
+}
+
+async function prepare(page, route, mode) {
+ if (mode === "cold-direct") return;
+ if (mode === "warm-direct") {
+ await page.goto(new URL(route.path, ui).toString(), {
+ waitUntil: "domcontentloaded",
+ timeout: 60000,
+ });
+ await waitForRoute(page, route, Date.now());
+ return;
+ }
+
+ const baselineRoute = getInAppBaseline(route);
+ await page.goto(new URL(baselineRoute.path, ui).toString(), {
+ waitUntil: "domcontentloaded",
+ timeout: 60000,
+ });
+ await waitForRoute(page, baselineRoute, Date.now());
+ if (mode === "in-app-warm") {
+ await navigateInApp(page, route.path);
+ await waitForRoute(page, route, Date.now());
+ await navigateInApp(page, baselineRoute.path);
+ await waitForRoute(page, baselineRoute, Date.now());
+ }
+}
+
+async function measure(browser, route, profile, mode) {
+ const context = await browser.newContext({
+ storageState: resolvedStorageState,
+ serviceWorkers: "block",
+ viewport: profile.viewport,
+ isMobile: profile.isMobile || false,
+ });
+ const page = await context.newPage();
+ const requests = [];
+ const failures = [];
+ const httpErrors = [];
+ const consoleErrors = [];
+ const pageErrors = [];
+ page.on("request", (request) => requests.push(request.url()));
+ page.on("requestfailed", (request) => failures.push(request.url()));
+ page.on("response", (response) => {
+ if (response.status() >= 400) httpErrors.push(response.status());
+ });
+ page.on("console", (message) => {
+ if (message.type() === "error") consoleErrors.push(message.text());
+ });
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+
+ await prepare(page, route, mode);
+ requests.length = 0;
+ failures.length = 0;
+ httpErrors.length = 0;
+ consoleErrors.length = 0;
+ pageErrors.length = 0;
+ await page.evaluate(() => performance.clearResourceTimings());
+
+ const started = Date.now();
+ if (mode.startsWith("in-app")) {
+ await navigateInApp(page, route.path);
+ } else {
+ await page.goto(new URL(route.path, ui).toString(), {
+ waitUntil: "domcontentloaded",
+ timeout: 60000,
+ });
+ }
+ await page.waitForSelector(".app-main", {
+ state: "visible",
+ timeout: 60000,
+ });
+ const shellMs = Date.now() - started;
+ const { contentMs, mapMs } = await waitForRoute(page, route, started);
+ const resources = await page.evaluate(() =>
+ performance.getEntriesByType("resource").map((entry) => ({
+ name: new URL(entry.name).pathname,
+ duration: Math.round(entry.duration),
+ transferSize: entry.transferSize,
+ }))
+ );
+ const apiDurations = resources
+ .filter((resource) => resource.name.startsWith("/api/"))
+ .map((resource) => resource.duration);
+ const result = {
+ profile: profile.name,
+ mode,
+ shellMs,
+ contentMs,
+ mapMs,
+ requests: requests.length,
+ apiRequests: requests.filter((url) => url.includes("/api/")).length,
+ apiTotalMs: apiDurations.reduce(
+ (total, duration) => total + duration,
+ 0
+ ),
+ apiMaxMs: apiDurations.length ? Math.max(...apiDurations) : null,
+ failedRequests: failures.length,
+ httpErrors: httpErrors.length,
+ consoleErrors: consoleErrors.length,
+ pageErrors: pageErrors.length,
+ transferBytes: resources.reduce(
+ (total, resource) => total + resource.transferSize,
+ 0
+ ),
+ };
+ if (
+ result.failedRequests ||
+ result.httpErrors ||
+ result.consoleErrors ||
+ result.pageErrors
+ ) {
+ throw new Error(
+ `${route.name} ${profile.name} ${mode} produced browser errors`
+ );
+ }
+ await context.close();
+ return result;
+}
+
+(async () => {
+ const browser = await chromium.launch({ headless: true });
+ const output = [];
+ const violations = [];
+ try {
+ for (const profile of profiles) {
+ for (const mode of [
+ "cold-direct",
+ "warm-direct",
+ "in-app-cold",
+ "in-app-warm",
+ ]) {
+ for (const route of routes) {
+ const samples = [];
+ for (let repeat = 0; repeat < repeats; repeat += 1) {
+ try {
+ samples.push(await measure(browser, route, profile, mode));
+ } catch (error) {
+ throw new Error(
+ `${route.name} ${profile.name} ${mode} measurement failed (${error.name || "Error"})`
+ );
+ }
+ }
+ const contentLimitMs = getContentLimitMs(route);
+ const mapLimitMs = getMapLimitMs(route, mode);
+ const contentP95Ms = percentile(
+ samples.map((sample) => sample.contentMs),
+ 95
+ );
+ const mapP95Ms = percentile(
+ samples.map((sample) => sample.mapMs).filter(Number.isFinite),
+ 95
+ );
+ if (contentP95Ms > contentLimitMs) {
+ violations.push({
+ profile: profile.name,
+ mode,
+ route: route.name,
+ metric: "contentP95Ms",
+ observedMs: contentP95Ms,
+ limitMs: contentLimitMs,
+ });
+ }
+ if (mapLimitMs !== null && mapP95Ms > mapLimitMs) {
+ violations.push({
+ profile: profile.name,
+ mode,
+ route: route.name,
+ metric: "mapP95Ms",
+ observedMs: mapP95Ms,
+ limitMs: mapLimitMs,
+ });
+ }
+ output.push({
+ profile: profile.name,
+ mode,
+ route: route.name,
+ repeats,
+ contentLimitMs,
+ mapLimitMs,
+ shellP50Ms: percentile(
+ samples.map((sample) => sample.shellMs),
+ 50
+ ),
+ shellP95Ms: percentile(
+ samples.map((sample) => sample.shellMs),
+ 95
+ ),
+ contentP50Ms: percentile(
+ samples.map((sample) => sample.contentMs),
+ 50
+ ),
+ contentP95Ms,
+ mapP95Ms,
+ apiP95Ms: percentile(
+ samples
+ .map((sample) => sample.apiMaxMs)
+ .filter(Number.isFinite),
+ 95
+ ),
+ transferP95Bytes: percentile(
+ samples.map((sample) => sample.transferBytes),
+ 95
+ ),
+ samples,
+ });
+ }
+ }
+ }
+ } finally {
+ await browser.close();
+ }
+ console.log(JSON.stringify({ routes: output, violations }, null, 2));
+ if (violations.length) {
+ throw new Error(
+ `Route matrix failed ${violations.length} performance limit(s).`
+ );
+ }
+})().catch((error) => {
+ console.error(error.message);
+ process.exitCode = 1;
+});
\ No newline at end of file
diff --git a/spec/features/perf-layer-loading/tools/ui_bench.cjs b/spec/features/perf-layer-loading/tools/ui_bench.cjs
index 751d2327..9cc743c0 100644
--- a/spec/features/perf-layer-loading/tools/ui_bench.cjs
+++ b/spec/features/perf-layer-loading/tools/ui_bench.cjs
@@ -9,9 +9,8 @@
// - the real GetProjectDetails request duration (the expensive call)
// - the 20s background poll: whether it fires and its cost
//
-// The cheap auth/user bootstrap is mocked (crafted SWA admin cookie + route
-// interception of /.auth/me, GetUserById, PutUser) so the page renders without a
-// real login; GetProjectDetails hits the REAL API and is what we measure.
+// The cheap session bootstrap is mocked so the page renders without a real
+// login; GetProjectDetails hits the REAL API and is what we measure.
//
// Run (playwright installed in a scratch dir):
// NODE_PATH=/tmp/haste-uibench/node_modules \
@@ -29,6 +28,23 @@ const UI = arg("ui", "http://localhost:4280");
const API = arg("api", "http://localhost:7071");
const PROJECT = arg("project", "00000000-0000-4000-8000-000050000005");
const POLL_WAIT_MS = parseInt(arg("pollwait", "26000"), 10);
+const ROW_TIMEOUT_MS = parseInt(arg("rowtimeout", "90000"), 10);
+const SCREENSHOT_PATH = arg("shot", null);
+
+if (!Number.isInteger(POLL_WAIT_MS) || POLL_WAIT_MS < 0) {
+ throw new Error("--pollwait must be a non-negative integer.");
+}
+if (!Number.isInteger(ROW_TIMEOUT_MS) || ROW_TIMEOUT_MS < 1) {
+ throw new Error("--rowtimeout must be a positive integer.");
+}
+const uiUrl = new URL(UI);
+const apiUrl = new URL(API);
+if (!["http:", "https:"].includes(uiUrl.protocol)) {
+ throw new Error("--ui must use HTTP or HTTPS.");
+}
+if (!["http:", "https:"].includes(apiUrl.protocol)) {
+ throw new Error("--api must use HTTP or HTTPS.");
+}
const principal = {
identityProvider: "aad",
@@ -37,189 +53,247 @@ const principal = {
userRoles: ["authenticated", "administrators", "contributors"],
claims: [],
};
-const mockUser = {
- userId: "bench@example.com",
- email: "bench@example.com",
- name: "Bench User",
- status: "Active",
- userRoles: ["administrators"],
- identityProvider: "aad",
- settings: { itemsPerPage: 10 },
+const mockSession = {
+ user: {
+ userId: "bench@example.com",
+ identityId: "benchuser",
+ userRoles: ["administrators", "contributors"],
+ settings: { itemsPerPage: 10 },
+ status: "Active",
+ },
+ publishing: {
+ publishingEnabled: true,
+ providers: [],
+ },
};
(async () => {
const browser = await chromium.launch({ headless: true });
- const context = await browser.newContext({ bypassCSP: true });
-
- // Crafted SWA auth cookie: base64(JSON(clientPrincipal)), no signing locally.
- const cookieVal = Buffer.from(JSON.stringify(principal)).toString("base64");
- await context.addCookies([
- { name: "StaticWebAppsAuthCookie", value: cookieVal, domain: "localhost", path: "/" },
- ]);
-
- const page = await context.newPage();
- const consoleErrors = [];
- const requestTimeline = [];
- const trackedRequests = new WeakMap();
- page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
-
- // Mock cheap bootstrap calls (not what we measure).
- await context.route("**/.auth/me", (r) =>
- r.fulfill({ contentType: "application/json", body: JSON.stringify({ clientPrincipal: principal }) })
- );
- await context.route("**/GetUserById**", (r) =>
- r.fulfill({ contentType: "application/json", body: JSON.stringify(mockUser) })
- );
- await context.route("**/PutUser**", (r) =>
- r.fulfill({ contentType: "application/json", body: JSON.stringify(mockUser) })
- );
-
- // Time every GetProjectDetails call (the real, expensive one).
- const gpd = [];
- const requestRecords = new WeakMap();
- page.on("request", (req) => {
- if (!req.url().includes("GetProjectDetails")) return;
- const record = { url: req.url(), startedAt: Date.now(), ms: null };
- requestRecords.set(req, record);
- gpd.push(record);
- });
- page.on("requestfinished", async (req) => {
- if (!req.url().includes("GetProjectDetails")) return;
- const t = req.timing();
- const record = requestRecords.get(req);
- if (record) {
- record.ms = Number.isFinite(t.responseEnd)
- ? Math.round(t.responseEnd)
- : Date.now() - record.startedAt;
- }
- });
- page.on("response", (response) => {
- const record = requestRecords.get(response.request());
- if (!record) return;
- record.status = response.status();
- record.cache = response.headers()["x-haste-cache"] ?? null;
- });
-
- const observedApiOrigins = new Set();
- page.on("request", (req) => {
- const u = req.url();
- if (u.includes("/api/GetProjectDetails")) observedApiOrigins.add(new URL(u).origin);
- });
-
- const t0 = Date.now();
- page.on("request", (req) => {
- const url = req.url();
- if (
- req.resourceType() === "document" ||
- /\.auth\/me|GetUserById|PutUser|GetPublishingProviders/.test(url)
- ) {
- const record = {
- resourceType: req.resourceType(),
- path: new URL(url).pathname,
- startedMs: Date.now() - t0,
- finishedMs: null,
- };
- trackedRequests.set(req, record);
- requestTimeline.push(record);
- }
- });
- page.on("requestfinished", (req) => {
- const record = trackedRequests.get(req);
- if (record) record.finishedMs = Date.now() - t0;
- });
- await page.goto(`${UI}/project/${PROJECT}`, { waitUntil: "commit", timeout: 60000 });
-
- // TTI: first image-layer row (seed names layers "Layer ").
- const rowTimeout = parseInt(arg("rowtimeout", "90000"), 10);
- let tti = null, rowError = null;
try {
- await page.waitForFunction(
- () => !!document.body && /Layer \d+/.test(document.body.innerText),
- null,
- { timeout: rowTimeout, polling: 50 }
+ const context = await browser.newContext({ bypassCSP: true });
+
+ // Crafted SWA auth cookie: base64(JSON(clientPrincipal)), no signing locally.
+ const cookieVal = Buffer.from(JSON.stringify(principal)).toString("base64");
+ await context.addCookies([
+ {
+ name: "StaticWebAppsAuthCookie",
+ value: cookieVal,
+ domain: uiUrl.hostname,
+ path: "/",
+ },
+ ]);
+
+ const page = await context.newPage();
+ const consoleErrors = [];
+ const pageErrors = [];
+ const requestFailures = [];
+ const httpErrors = [];
+ const requestTimeline = [];
+ const trackedRequests = new WeakMap();
+ page.on("console", (message) => {
+ if (message.type() === "error") consoleErrors.push(message.text());
+ });
+ page.on("pageerror", () => pageErrors.push(true));
+ page.on("requestfailed", () => requestFailures.push(true));
+ page.on("response", (response) => {
+ if (response.status() >= 400) httpErrors.push(response.status());
+ });
+
+ // Mock the cheap bootstrap call (not what we measure).
+ await context.route("**/GetSessionBootstrap**", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify(mockSession),
+ })
);
- tti = Date.now() - t0;
- } catch (e) {
- rowError = String(e).split("\n")[0];
- }
+ await context.route("**/api/GetProjectDetails**", (route) => {
+ const source = new URL(route.request().url());
+ const target = new URL(source.pathname + source.search, apiUrl);
+ return route.continue({ url: target.toString() });
+ });
- let bodyText = null;
- try {
- bodyText = (await page.locator("body").innerText()).replace(/\s+/g, " ").slice(0, 600);
- } catch (e) { bodyText = ""; }
- try { await page.screenshot({ path: arg("shot", "/tmp/haste-uibench/shot.png"), fullPage: true }); } catch (e) {}
-
- const interactiveAt = Date.now();
- const initialCalls = gpd.filter((call) => call.startedAt <= interactiveAt);
- const initialGpdMs = initialCalls.length ? initialCalls[0].ms : null;
- const initialGpdStartedMs = initialCalls.length
- ? initialCalls[0].startedAt - t0
- : null;
- const initialGpdFinishedMs =
- initialGpdStartedMs !== null && initialGpdMs !== null
- ? initialGpdStartedMs + initialGpdMs
+ // Time every GetProjectDetails call (the real, expensive one).
+ const gpd = [];
+ const requestRecords = new WeakMap();
+ page.on("request", (request) => {
+ if (!request.url().includes("GetProjectDetails")) return;
+ const record = { startedAt: Date.now(), ms: null };
+ requestRecords.set(request, record);
+ gpd.push(record);
+ });
+ page.on("requestfinished", (request) => {
+ if (!request.url().includes("GetProjectDetails")) return;
+ const timing = request.timing();
+ const record = requestRecords.get(request);
+ if (record) {
+ record.ms = Number.isFinite(timing.responseEnd)
+ ? Math.round(timing.responseEnd)
+ : Date.now() - record.startedAt;
+ }
+ });
+
+ const observedApiOrigins = new Set();
+ page.on("response", (response) => {
+ const record = requestRecords.get(response.request());
+ if (!record) return;
+ record.status = response.status();
+ record.cache = response.headers()["x-haste-cache"] ?? null;
+ observedApiOrigins.add(new URL(response.url()).origin);
+ });
+
+ const t0 = Date.now();
+ page.on("request", (request) => {
+ const url = request.url();
+ if (
+ request.resourceType() === "document" ||
+ /GetSessionBootstrap/.test(url)
+ ) {
+ const record = {
+ kind:
+ request.resourceType() === "document"
+ ? "document"
+ : "session-bootstrap",
+ startedMs: Date.now() - t0,
+ finishedMs: null,
+ };
+ trackedRequests.set(request, record);
+ requestTimeline.push(record);
+ }
+ });
+ page.on("requestfinished", (request) => {
+ const record = trackedRequests.get(request);
+ if (record) record.finishedMs = Date.now() - t0;
+ });
+ await page.goto(new URL(`/project/${PROJECT}`, uiUrl).toString(), {
+ waitUntil: "commit",
+ timeout: 60000,
+ });
+
+ // TTI: first image-layer row (seed names layers "Layer ").
+ let tti;
+ try {
+ await page.waitForFunction(
+ () => !!document.body && /Layer \d+/.test(document.body.innerText),
+ null,
+ { timeout: ROW_TIMEOUT_MS, polling: 50 }
+ );
+ tti = Date.now() - t0;
+ } catch {
+ throw new Error("The project content marker did not become ready.");
+ }
+ if (SCREENSHOT_PATH) {
+ await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
+ }
+
+ const interactiveAt = Date.now();
+ const initialCalls = gpd.filter(
+ (call) => call.startedAt <= interactiveAt
+ );
+ const initialGpdMs = initialCalls.length ? initialCalls[0].ms : null;
+ const initialGpdStartedMs = initialCalls.length
+ ? initialCalls[0].startedAt - t0
: null;
- const gpdCountAfterLoad = initialCalls.length;
-
- // Observe the 20s background poll.
- const pollStart = Date.now();
- await page.waitForTimeout(POLL_WAIT_MS);
- const pollCalls = gpd.filter((call) => call.startedAt >= pollStart);
-
- const result = {
- project: PROJECT,
- time_to_interactive_ms: tti,
- initial_getprojectdetails_started_ms: initialGpdStartedMs,
- initial_getprojectdetails_ms: initialGpdMs,
- initial_getprojectdetails_finished_ms: initialGpdFinishedMs,
- initial_getprojectdetails_status: initialCalls[0]?.status ?? null,
- initial_getprojectdetails_cache: initialCalls[0]?.cache ?? null,
- render_after_project_response_ms:
- tti !== null && initialGpdFinishedMs !== null
- ? Math.max(0, tti - initialGpdFinishedMs)
- : null,
- getprojectdetails_calls_during_load: gpdCountAfterLoad,
- poll_window_ms: POLL_WAIT_MS,
- poll_getprojectdetails_calls: pollCalls.length,
- poll_getprojectdetails_ms: pollCalls.map((c) => c.ms),
- poll_getprojectdetails: pollCalls.map((call) => ({
- ms: call.ms,
- status: call.status ?? null,
- cache: call.cache ?? null,
- })),
- api_origins_observed: [...observedApiOrigins],
- navigation_timing: await page.evaluate(() => {
- const navigation = performance.getEntriesByType("navigation")[0];
- return navigation
- ? {
- responseEnd: Math.round(navigation.responseEnd),
- domInteractive: Math.round(navigation.domInteractive),
- domContentLoadedEventEnd: Math.round(
- navigation.domContentLoadedEventEnd
- ),
- loadEventEnd: Math.round(navigation.loadEventEnd),
- }
+ const initialGpdFinishedMs =
+ initialGpdStartedMs !== null && initialGpdMs !== null
+ ? initialGpdStartedMs + initialGpdMs
: null;
- }),
- slowest_resources: await page.evaluate(() =>
- performance
- .getEntriesByType("resource")
- .sort((left, right) => right.duration - left.duration)
- .slice(0, 10)
- .map((entry) => ({
- path: new URL(entry.name).pathname,
- initiatorType: entry.initiatorType,
- startTime: Math.round(entry.startTime),
- duration: Math.round(entry.duration),
- transferSize: entry.transferSize,
- }))
- ),
- bootstrap_requests: requestTimeline,
- row_wait_error: rowError,
- body_text_sample: bodyText,
- console_errors: consoleErrors.slice(0, 4),
- };
- console.log(JSON.stringify(result, null, 2));
-
- await browser.close();
-})().catch((e) => { console.error("FATAL", e); process.exit(1); });
+ const gpdCountAfterLoad = initialCalls.length;
+
+ // Observe the 20s background poll.
+ const pollStart = Date.now();
+ await page.waitForTimeout(POLL_WAIT_MS);
+ const pollCalls = gpd.filter((call) => call.startedAt >= pollStart);
+
+ const validationFailures = [];
+ if (initialCalls.length !== 1) {
+ validationFailures.push("initial-project-request-count");
+ }
+ if (!Number.isFinite(initialGpdMs)) {
+ validationFailures.push("initial-project-request-timing");
+ }
+ if (
+ initialCalls[0]?.status === undefined ||
+ initialCalls[0].status >= 400
+ ) {
+ validationFailures.push("initial-project-request-status");
+ }
+ if (
+ observedApiOrigins.size !== 1 ||
+ !observedApiOrigins.has(apiUrl.origin)
+ ) {
+ validationFailures.push("api-origin");
+ }
+ if (consoleErrors.length) validationFailures.push("console-errors");
+ if (pageErrors.length) validationFailures.push("page-errors");
+ if (requestFailures.length) validationFailures.push("request-failures");
+ if (httpErrors.length) validationFailures.push("http-errors");
+
+ const result = {
+ time_to_interactive_ms: tti,
+ initial_getprojectdetails_started_ms: initialGpdStartedMs,
+ initial_getprojectdetails_ms: initialGpdMs,
+ initial_getprojectdetails_finished_ms: initialGpdFinishedMs,
+ initial_getprojectdetails_status: initialCalls[0]?.status ?? null,
+ initial_getprojectdetails_cache: initialCalls[0]?.cache ?? null,
+ render_after_project_response_ms:
+ tti !== null && initialGpdFinishedMs !== null
+ ? Math.max(0, tti - initialGpdFinishedMs)
+ : null,
+ getprojectdetails_calls_during_load: gpdCountAfterLoad,
+ poll_window_ms: POLL_WAIT_MS,
+ poll_getprojectdetails_calls: pollCalls.length,
+ poll_getprojectdetails_ms: pollCalls.map((call) => call.ms),
+ poll_getprojectdetails: pollCalls.map((call) => ({
+ ms: call.ms,
+ status: call.status ?? null,
+ cache: call.cache ?? null,
+ })),
+ api_origin_matches_requested:
+ !validationFailures.includes("api-origin"),
+ navigation_timing: await page.evaluate(() => {
+ const navigation = performance.getEntriesByType("navigation")[0];
+ return navigation
+ ? {
+ responseEnd: Math.round(navigation.responseEnd),
+ domInteractive: Math.round(navigation.domInteractive),
+ domContentLoadedEventEnd: Math.round(
+ navigation.domContentLoadedEventEnd
+ ),
+ loadEventEnd: Math.round(navigation.loadEventEnd),
+ }
+ : null;
+ }),
+ slowest_resources: await page.evaluate(() =>
+ performance
+ .getEntriesByType("resource")
+ .sort((left, right) => right.duration - left.duration)
+ .slice(0, 10)
+ .map((entry) => ({
+ path: new URL(entry.name).pathname,
+ initiatorType: entry.initiatorType,
+ startTime: Math.round(entry.startTime),
+ duration: Math.round(entry.duration),
+ transferSize: entry.transferSize,
+ }))
+ ),
+ bootstrap_requests: requestTimeline,
+ console_error_count: consoleErrors.length,
+ page_error_count: pageErrors.length,
+ request_failure_count: requestFailures.length,
+ http_error_count: httpErrors.length,
+ validation_failures: validationFailures,
+ };
+ console.log(JSON.stringify(result, null, 2));
+
+ if (validationFailures.length) {
+ throw new Error(
+ `Project benchmark failed ${validationFailures.length} validation check(s).`
+ );
+ }
+ } finally {
+ await browser.close();
+ }
+})().catch((error) => {
+ console.error("FATAL", error.message);
+ process.exit(1);
+});
From c6d7ef58c0d30ac005c2da4ff706caaf7a62a13d Mon Sep 17 00:00:00 2001
From: prbatero <42007693+prbatero@users.noreply.github.com>
Date: Thu, 3 Sep 2026 16:19:06 -0400
Subject: [PATCH 5/7] feat(api): add focused route loading endpoints
Add allowlisted Labeling Workspace reads and compact cached Active Jobs responses with ACL authorization, direct label lookup, ETags, bounded concurrency, and legacy fallback coverage.
---
api/hastefuncapi/README.md | 16 +
api/hastefuncapi/function_app.py | 115 ++++++
api/hastefuncapi/tests/test_loading_routes.py | 253 +++++++++++++
docs/api/hastefuncapi.md | 16 +
hastelib/src/hastegeo/core/models/loading.py | 56 +++
.../src/hastegeo/core/processors/loading.py | 327 +++++++++++++++++
.../tests/core/processors/test_loading.py | 342 ++++++++++++++++++
7 files changed, 1125 insertions(+)
create mode 100644 api/hastefuncapi/tests/test_loading_routes.py
create mode 100644 hastelib/src/hastegeo/core/models/loading.py
create mode 100644 hastelib/src/hastegeo/core/processors/loading.py
create mode 100644 hastelib/tests/core/processors/test_loading.py
diff --git a/api/hastefuncapi/README.md b/api/hastefuncapi/README.md
index 705735ab..186047d1 100644
--- a/api/hastefuncapi/README.md
+++ b/api/hastefuncapi/README.md
@@ -20,6 +20,7 @@ All functions are defined in `function_app.py` as a single Azure Functions app.
| Method | Route | Description |
|--------|-------|-------------|
| GET | `GetDashboardData` | Aggregated dashboard stats: project summaries, layer info, model status, and system-wide metrics. |
+| GET | `GetActiveJobs` | Compact active imagery, training, and inference jobs. Supports `ETag`/`If-None-Match`. |
| GET | `GetProjects` | All projects with aggregated layer and model counts. |
| GET | `GetProjectDetails` | Project, layer, validation, and optional model details. Supports `ETag`/`If-None-Match`; requires `projectId`. |
| PUT | `PutProject` | Create or update a project. Auto-generates `projectId` and `creationDate` if not provided. |
@@ -46,8 +47,23 @@ does not provide coherence across scaled-out Function workers. Performance heade
| GET | `GetLayerDetailView` | Detail view for a single image layer. Requires `projectId` and `imageLayerId`. |
| GET | `GetLayerModelsDetails` | Model status and model list for a given layer. Requires `projectId` and `imageLayerId`. |
| GET | `GetLayerLabelingToolData` | Label tool data for a given layer. Requires `projectId` and `imageLayerId`. |
+| GET | `GetLabelingWorkspace` | Minimal standard-labeling workspace. Requires `projectId` and `imageLayerId`. |
| PUT | `PutLabelsFromLabelTool` | Save labels for a layer from the label tool. |
+#### Route Loading Endpoints
+
+`GetActiveJobs` requires an active contributor or administrator. It returns one
+compact job list from a process-local cache with a maximum five-second TTL.
+Clients send `If-None-Match`; unchanged responses return an empty `304`.
+
+`GetLabelingWorkspace` requires the same active application role. It returns one
+label project, the target image-layer ID, event types, and primary classes. The
+route uses the image layer's label-project pointer when available and falls back
+to a project-partition scan for legacy records. It does not cache current labels.
+
+Both routes return `400` for invalid identifiers, `403` for insufficient access,
+`404` for missing records, and a generic `500` response for internal failures.
+
### File Upload
| Method | Route | Description |
diff --git a/api/hastefuncapi/function_app.py b/api/hastefuncapi/function_app.py
index 054cb80a..b67bc4ee 100644
--- a/api/hastefuncapi/function_app.py
+++ b/api/hastefuncapi/function_app.py
@@ -16,6 +16,7 @@
import requests # type: ignore
from hastegeo.core.config import Config
from hastegeo.core.models.admin import AdminConfig
+from hastegeo.core.models.loading import ActiveJobs
from hastegeo.core.models.projects import (
BuildingValidation,
ImageLayer,
@@ -44,6 +45,10 @@
from hastegeo.core.processors.embedding import EmbeddingPreprocessor
from hastegeo.core.processors.imagery import ImageryPreProcessor
from hastegeo.core.processors.inference import InferencePreprocessor
+from hastegeo.core.processors.loading import (
+ ActiveJobsProcessor,
+ LabelingWorkspaceProcessor,
+)
from hastegeo.core.processors.metadata import MetadataProcessor
from hastegeo.core.processors.project_details import ProjectDetailsProcessor
from hastegeo.core.processors.publishing import (
@@ -140,6 +145,13 @@
ttl_seconds=_PUBLISHED_DATASETS_CACHE_SECONDS,
max_entries=_PUBLISHED_DATASETS_CACHE_ENTRIES,
)
+_ACTIVE_JOBS_CACHE_SECONDS = configured_cache_value(
+ "HASTE_ACTIVE_JOBS_CACHE_SECONDS", 5, 0, 5
+)
+_active_jobs_cache = AsyncTTLCache(
+ ttl_seconds=_ACTIVE_JOBS_CACHE_SECONDS,
+ max_entries=1,
+)
# Development mode check - when running locally with Docker/Azurite
# Set DEVELOPMENT_MODE=true to disable function key authentication
@@ -672,6 +684,64 @@ async def GetDashboardData(req: func.HttpRequest) -> func.HttpResponse:
)
+@app.route(
+ route="GetActiveJobs",
+ auth_level=AUTH_LEVEL,
+ methods=["GET"],
+)
+async def GetActiveJobs(req: func.HttpRequest) -> func.HttpResponse:
+ """Return the compact set of currently active HASTE jobs."""
+ auth_error = await _require_roles(req, {"administrators", "contributors"})
+ if auth_error:
+ return auth_error
+
+ try:
+
+ async def load_response() -> dict[str, str]:
+ result: ActiveJobs = await ActiveJobsProcessor(
+ config=config
+ ).load()
+ payload = json.dumps(result.model_dump(mode="json"))
+ return {
+ "payload": payload,
+ "etag": '"'
+ + hashlib.sha256(payload.encode()).hexdigest()[:32]
+ + '"',
+ }
+
+ cached_response, cache_hit = await _active_jobs_cache.get_or_create(
+ "active-jobs",
+ load_response,
+ )
+ headers = {
+ "Cache-Control": f"private, max-age={_ACTIVE_JOBS_CACHE_SECONDS}",
+ "ETag": cached_response["etag"],
+ "X-Haste-Cache": "HIT" if cache_hit else "MISS",
+ }
+ if _etag_matches(
+ req.headers.get("If-None-Match"), cached_response["etag"]
+ ):
+ return func.HttpResponse(status_code=304, headers=headers)
+ return func.HttpResponse(
+ cached_response["payload"],
+ status_code=200,
+ mimetype="application/json",
+ headers=headers,
+ )
+ except FileNotFoundError as error:
+ logger.error(f"Active-job stats not found: {error}")
+ return _publishing_error_response(
+ "NOT_FOUND", "Project statistics were not found.", 404
+ )
+ except Exception as error:
+ logger.error(
+ f"Error loading active jobs: {error}\n{traceback.format_exc()}"
+ )
+ return _publishing_error_response(
+ "INTERNAL_ERROR", "Active jobs could not be loaded.", 500
+ )
+
+
@app.route(route="GetProjects", auth_level=AUTH_LEVEL, methods=["GET"])
async def GetProjects(req: func.HttpRequest) -> func.HttpResponse:
"""
@@ -1656,6 +1726,51 @@ async def GetLayerLabelingToolData(req: func.HttpRequest) -> func.HttpResponse:
)
+@app.route(
+ route="GetLabelingWorkspace",
+ auth_level=AUTH_LEVEL,
+ methods=["GET"],
+)
+async def GetLabelingWorkspace(req: func.HttpRequest) -> func.HttpResponse:
+ """Return the minimum records for one standard labeling workspace."""
+ try:
+ project_id = _require_guid_param(req, "projectId")
+ image_layer_id = _require_guid_param(req, "imageLayerId")
+ except ValueError as error:
+ return _bad_request(f"GetLabelingWorkspace: {error}")
+
+ auth_error = await _require_roles(req, {"administrators", "contributors"})
+ if auth_error:
+ return auth_error
+
+ try:
+ workspace = await LabelingWorkspaceProcessor(
+ project_id=project_id,
+ image_layer_id=image_layer_id,
+ config=config,
+ ).load()
+ return func.HttpResponse(
+ json.dumps(workspace.model_dump(mode="json")),
+ status_code=200,
+ mimetype="application/json",
+ )
+ except FileNotFoundError as error:
+ logger.error(f"Labeling workspace not found: {error}")
+ return _publishing_error_response(
+ "NOT_FOUND", "Labeling workspace was not found.", 404
+ )
+ except Exception as error:
+ logger.error(
+ f"Error loading labeling workspace: {error}\n"
+ f"{traceback.format_exc()}"
+ )
+ return _publishing_error_response(
+ "INTERNAL_ERROR",
+ "Labeling workspace could not be loaded.",
+ 500,
+ )
+
+
@app.route(
route="GetAdminSettings",
auth_level=AUTH_LEVEL,
diff --git a/api/hastefuncapi/tests/test_loading_routes.py b/api/hastefuncapi/tests/test_loading_routes.py
new file mode 100644
index 00000000..2d9b2e1b
--- /dev/null
+++ b/api/hastefuncapi/tests/test_loading_routes.py
@@ -0,0 +1,253 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+import io
+import json
+import os
+import unittest
+from contextlib import redirect_stderr
+from unittest.mock import AsyncMock, patch
+
+import azure.functions as func
+from hastegeo.core.models.loading import (
+ ActiveJob,
+ ActiveJobIndicator,
+ ActiveJobs,
+ LabelingImageLayer,
+ LabelingWorkspace,
+)
+from hastegeo.core.models.projects import LabelProject
+from hastegeo.core.utils.async_cache import AsyncTTLCache
+
+os.environ.setdefault("DEVELOPMENT_MODE", "true")
+os.environ.setdefault("METADATA_STORAGE_TYPE", "local")
+os.environ.setdefault("ARTIFACT_STORAGE_TYPE", "local")
+os.environ.setdefault("DATA_PATH", "/tmp/haste-loading-route-tests")
+os.environ.setdefault("TEMP_DATA_PATH", "/tmp/haste-loading-route-tests")
+
+with redirect_stderr(io.StringIO()):
+ from api.hastefuncapi import function_app
+
+PROJECT_ID = "123e4567-e89b-12d3-a456-426614174000"
+LAYER_ID = "123e4567-e89b-12d3-a456-426614174001"
+
+
+def make_request(
+ params: dict | None = None, headers: dict | None = None
+) -> func.HttpRequest:
+ return func.HttpRequest(
+ method="GET",
+ url="http://localhost/api/loading",
+ headers=headers or {},
+ params=params or {},
+ route_params={},
+ body=b"",
+ )
+
+
+def response_json(response: func.HttpResponse) -> dict:
+ return json.loads(response.get_body().decode("utf-8"))
+
+
+class TestLabelingWorkspaceRoute(unittest.IsolatedAsyncioTestCase):
+ def setUp(self) -> None:
+ self.workspace = LabelingWorkspace(
+ labelProject=LabelProject(
+ projectId=PROJECT_ID,
+ imageLayerId=LAYER_ID,
+ labelprojectId="labels-1",
+ labels=[
+ {
+ "properties": {
+ "primaryClass": "Damaged",
+ "source": "Drawn|Imagery",
+ }
+ }
+ ],
+ ),
+ imageLayer=LabelingImageLayer(
+ imageLayerId=LAYER_ID,
+ name="Post event",
+ sourceTypePostEvent="sentinel_2",
+ ),
+ eventTypes=["Wildfire"],
+ primaryClasses=[{"name": "Damaged", "color": "#ff0000"}],
+ )
+
+ async def test_returns_minimum_workspace_response(self) -> None:
+ processor = AsyncMock()
+ processor.load.return_value = self.workspace
+ with patch.object(
+ function_app, "_require_roles", new=AsyncMock(return_value=None)
+ ) as require_roles, patch.object(
+ function_app,
+ "LabelingWorkspaceProcessor",
+ return_value=processor,
+ ) as processor_type:
+ response = await function_app.GetLabelingWorkspace(
+ make_request(
+ {"projectId": PROJECT_ID, "imageLayerId": LAYER_ID}
+ )
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response_json(response)["eventTypes"], ["Wildfire"])
+ self.assertEqual(
+ response_json(response)["imageLayer"]["imageLayerId"], LAYER_ID
+ )
+ properties = response_json(response)["labelProject"]["labels"][0][
+ "properties"
+ ]
+ self.assertEqual(properties["primaryClass"], "Damaged")
+ self.assertNotIn("class", properties)
+ self.assertEqual(
+ set(response_json(response)["imageLayer"]),
+ {"imageLayerId", "name", "sourceTypePostEvent"},
+ )
+ require_roles.assert_awaited_once()
+ processor_type.assert_called_once_with(
+ project_id=PROJECT_ID,
+ image_layer_id=LAYER_ID,
+ config=function_app.config,
+ )
+ processor.load.assert_awaited_once_with()
+
+ async def test_rejects_invalid_ids_before_authorization(self) -> None:
+ with patch.object(
+ function_app, "_require_roles", new=AsyncMock()
+ ) as require_roles, patch.object(
+ function_app, "LabelingWorkspaceProcessor"
+ ) as processor_type:
+ response = await function_app.GetLabelingWorkspace(
+ make_request(
+ {"projectId": "../project", "imageLayerId": LAYER_ID}
+ )
+ )
+
+ self.assertEqual(response.status_code, 400)
+ require_roles.assert_not_awaited()
+ processor_type.assert_not_called()
+
+ async def test_authorization_failure_skips_workspace_load(self) -> None:
+ forbidden = func.HttpResponse("Forbidden", status_code=403)
+ with patch.object(
+ function_app,
+ "_require_roles",
+ new=AsyncMock(return_value=forbidden),
+ ), patch.object(
+ function_app, "LabelingWorkspaceProcessor"
+ ) as processor_type:
+ response = await function_app.GetLabelingWorkspace(
+ make_request(
+ {"projectId": PROJECT_ID, "imageLayerId": LAYER_ID}
+ )
+ )
+
+ self.assertEqual(response.status_code, 403)
+ processor_type.assert_not_called()
+
+ async def test_missing_workspace_returns_safe_not_found(self) -> None:
+ processor = AsyncMock()
+ processor.load.side_effect = FileNotFoundError("private path")
+ with patch.object(
+ function_app, "_require_roles", new=AsyncMock(return_value=None)
+ ), patch.object(
+ function_app,
+ "LabelingWorkspaceProcessor",
+ return_value=processor,
+ ):
+ response = await function_app.GetLabelingWorkspace(
+ make_request(
+ {"projectId": PROJECT_ID, "imageLayerId": LAYER_ID}
+ )
+ )
+
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(response_json(response)["error"]["code"], "NOT_FOUND")
+ self.assertNotIn("private path", response.get_body().decode("utf-8"))
+
+
+class TestActiveJobsRoute(unittest.IsolatedAsyncioTestCase):
+ async def asyncSetUp(self) -> None:
+ self.cache = AsyncTTLCache(ttl_seconds=5, max_entries=1)
+ self.cache_patcher = patch.object(
+ function_app, "_active_jobs_cache", self.cache
+ )
+ self.cache_patcher.start()
+
+ async def asyncTearDown(self) -> None:
+ await self.cache.clear()
+ self.cache_patcher.stop()
+
+ def active_jobs(self) -> ActiveJobs:
+ return ActiveJobs(
+ jobs=[
+ ActiveJob(
+ key="training-project-1-model-1",
+ kind="Training",
+ projectName="Project",
+ name="Model",
+ target="/project/project-1/layer-1",
+ indicator=ActiveJobIndicator(
+ id="ongoingTraining-project-1-model-1",
+ status="InProgress",
+ prefix="Training",
+ contextLabel="Model: Model - Training",
+ ),
+ )
+ ]
+ )
+
+ async def test_returns_etag_and_reuses_cached_representation(self) -> None:
+ processor = AsyncMock()
+ processor.load.return_value = self.active_jobs()
+ authorize = AsyncMock(return_value=None)
+ with patch.object(
+ function_app, "_require_roles", new=authorize
+ ), patch.object(
+ function_app, "ActiveJobsProcessor", return_value=processor
+ ) as processor_type:
+ first = await function_app.GetActiveJobs(make_request())
+ second = await function_app.GetActiveJobs(
+ make_request(headers={"If-None-Match": first.headers["ETag"]})
+ )
+
+ self.assertEqual(first.status_code, 200)
+ self.assertEqual(response_json(first)["jobs"][0]["kind"], "Training")
+ self.assertEqual(first.headers["X-Haste-Cache"], "MISS")
+ self.assertEqual(second.status_code, 304)
+ self.assertEqual(second.get_body(), b"")
+ processor_type.assert_called_once_with(config=function_app.config)
+ processor.load.assert_awaited_once_with()
+ self.assertEqual(authorize.await_count, 2)
+ for call in authorize.await_args_list:
+ self.assertEqual(call.args[1], {"administrators", "contributors"})
+
+ async def test_authorization_failure_skips_active_job_cache(self) -> None:
+ forbidden = func.HttpResponse("Forbidden", status_code=403)
+ with patch.object(
+ function_app,
+ "_require_roles",
+ new=AsyncMock(return_value=forbidden),
+ ), patch.object(function_app, "ActiveJobsProcessor") as processor_type:
+ response = await function_app.GetActiveJobs(make_request())
+
+ self.assertEqual(response.status_code, 403)
+ processor_type.assert_not_called()
+
+ async def test_missing_stats_returns_safe_not_found(self) -> None:
+ processor = AsyncMock()
+ processor.load.side_effect = FileNotFoundError("private path")
+ with patch.object(
+ function_app, "_require_roles", new=AsyncMock(return_value=None)
+ ), patch.object(
+ function_app, "ActiveJobsProcessor", return_value=processor
+ ):
+ response = await function_app.GetActiveJobs(make_request())
+
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(response_json(response)["error"]["code"], "NOT_FOUND")
+ self.assertNotIn("private path", response.get_body().decode("utf-8"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/docs/api/hastefuncapi.md b/docs/api/hastefuncapi.md
index a64ad9f2..c0eaccaf 100644
--- a/docs/api/hastefuncapi.md
+++ b/docs/api/hastefuncapi.md
@@ -27,6 +27,7 @@ All functions are defined in `function_app.py` as a single Azure Functions app.
| Method | Route | Description |
|--------|-------|-------------|
| GET | `GetDashboardData` | Aggregated dashboard stats: project summaries, layer info, model status, and system-wide metrics. |
+| GET | `GetActiveJobs` | Compact active imagery, training, and inference jobs. Supports `ETag`/`If-None-Match`. |
| GET | `GetProjects` | All projects with aggregated layer and model counts. |
| GET | `GetProjectDetails` | Project, layer, validation, and optional model details. Supports `ETag`/`If-None-Match`; requires `projectId`. |
| PUT | `PutProject` | Create or update a project. Auto-generates `projectId` and `creationDate` if not provided. |
@@ -42,8 +43,23 @@ All functions are defined in `function_app.py` as a single Azure Functions app.
| GET | `GetLayerDetailView` | Detail view for a single image layer. Requires `projectId` and `imageLayerId`. |
| GET | `GetLayerModelsDetails` | Model status and model list for a given layer. Requires `projectId` and `imageLayerId`. |
| GET | `GetLayerLabelingToolData` | Label tool data for a given layer. Requires `projectId` and `imageLayerId`. |
+| GET | `GetLabelingWorkspace` | Minimal standard-labeling workspace. Requires `projectId` and `imageLayerId`. |
| PUT | `PutLabelsFromLabelTool` | Save labels for a layer from the label tool. |
+### Route Loading Endpoints
+
+`GetActiveJobs` requires an active contributor or administrator. It returns one
+compact job list from a process-local cache with a maximum five-second TTL.
+Clients send `If-None-Match`; unchanged responses return an empty `304`.
+
+`GetLabelingWorkspace` requires the same active application role. It returns one
+label project, the target image-layer ID, event types, and primary classes. The
+route uses the image layer's label-project pointer when available and falls back
+to a project-partition scan for legacy records. It does not cache current labels.
+
+Both routes return `400` for invalid identifiers, `403` for insufficient access,
+`404` for missing records, and a generic `500` response for internal failures.
+
### File Upload
| Method | Route | Description |
diff --git a/hastelib/src/hastegeo/core/models/loading.py b/hastelib/src/hastegeo/core/models/loading.py
new file mode 100644
index 00000000..86a1aebe
--- /dev/null
+++ b/hastelib/src/hastegeo/core/models/loading.py
@@ -0,0 +1,56 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+"""Response models for route-specific loading endpoints."""
+
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+from .projects import LabelProject, PrimaryClass
+
+
+class LabelingImageLayer(BaseModel):
+ """Allowlisted image-layer fields required by the labeling UI."""
+
+ imageLayerId: str
+ name: str | None = None
+ sourceTypePostEvent: str | None = None
+
+
+class LabelingWorkspace(BaseModel):
+ """Data required to initialize one standard labeling workspace."""
+
+ labelProject: LabelProject
+ imageLayer: LabelingImageLayer
+ eventTypes: list[str] = Field(default_factory=list)
+ primaryClasses: list[PrimaryClass] = Field(default_factory=list)
+
+
+class ActiveJobIndicator(BaseModel):
+ """Progress fields consumed by the dashboard status indicator."""
+
+ id: str
+ currentStep: int = 0
+ totalSteps: int = 0
+ progressPct: float = 0.0
+ status: str
+ statusMessage: str = ""
+ prefix: str
+ contextLabel: str
+
+
+class ActiveJob(BaseModel):
+ """Compact active-job representation for the dashboard."""
+
+ key: str
+ kind: Literal["Imagery", "Training", "Inference"]
+ projectName: str
+ name: str
+ target: str
+ indicator: ActiveJobIndicator
+
+
+class ActiveJobs(BaseModel):
+ """Active jobs across candidate projects."""
+
+ jobs: list[ActiveJob] = Field(default_factory=list)
diff --git a/hastelib/src/hastegeo/core/processors/loading.py b/hastelib/src/hastegeo/core/processors/loading.py
new file mode 100644
index 00000000..4273a8e9
--- /dev/null
+++ b/hastelib/src/hastegeo/core/processors/loading.py
@@ -0,0 +1,327 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+"""Route-specific loading processors."""
+
+import asyncio
+from collections.abc import Callable, Mapping, Sequence
+from typing import Any
+
+from azure.core.exceptions import ResourceNotFoundError
+
+from ..config import Config
+from ..models.loading import (
+ ActiveJob,
+ ActiveJobIndicator,
+ ActiveJobs,
+ LabelingImageLayer,
+ LabelingWorkspace,
+)
+from ..models.projects import ImageLayer, LabelProject, Project
+from .metadata import MetadataProcessor
+
+_TERMINAL_STATUSES = frozenset(
+ {"processed", "completed", "trained", "failed", "cancelled"}
+)
+
+
+def _is_active_status(status: Any) -> bool:
+ return (
+ isinstance(status, str)
+ and bool(status.strip())
+ and status.strip().casefold() not in _TERMINAL_STATUSES
+ )
+
+
+def assemble_active_jobs(
+ projects: Sequence[Mapping[str, Any]],
+ records_by_project: Mapping[
+ str, tuple[Sequence[Mapping[str, Any]], Sequence[Mapping[str, Any]]]
+ ],
+) -> ActiveJobs:
+ """Build compact dashboard jobs from image-layer and model records."""
+ jobs: list[ActiveJob] = []
+ for project in projects:
+ project_id = str(project.get("projectId") or "")
+ if not project_id:
+ continue
+ project_name = str(project.get("name") or "Project")
+ image_layers, models = records_by_project.get(project_id, ([], []))
+
+ for layer in image_layers:
+ layer_id = str(layer.get("imageLayerId") or "")
+ if not layer_id or not _is_active_status(layer.get("status")):
+ continue
+ layer_name = str(layer.get("name") or "Image layer")
+ jobs.append(
+ ActiveJob(
+ key=f"imagery-{project_id}-{layer_id}",
+ kind="Imagery",
+ projectName=project_name,
+ name=layer_name,
+ target=f"/project/{project_id}/{layer_id}",
+ indicator=ActiveJobIndicator(
+ id=f"ongoingImagery-{project_id}-{layer_id}",
+ currentStep=layer.get("currentStep") or 0,
+ totalSteps=layer.get("totalSteps") or 0,
+ progressPct=layer.get("progressPct") or 0.0,
+ status=str(layer["status"]),
+ statusMessage=str(layer.get("statusMessage") or ""),
+ prefix="Imagery",
+ contextLabel=f"Image Layer: {layer_name}",
+ ),
+ )
+ )
+
+ for model in models:
+ model_id = str(model.get("modelId") or "")
+ layer_id = str(model.get("imageLayerId") or "")
+ if not model_id or not layer_id:
+ continue
+ model_name = str(model.get("name") or "Model")
+ target = f"/project/{project_id}/{layer_id}"
+ if _is_active_status(model.get("status")):
+ jobs.append(
+ ActiveJob(
+ key=f"training-{project_id}-{model_id}",
+ kind="Training",
+ projectName=project_name,
+ name=model_name,
+ target=target,
+ indicator=ActiveJobIndicator(
+ id=f"ongoingTraining-{project_id}-{model_id}",
+ currentStep=model.get("currentStep") or 0,
+ totalSteps=model.get("totalSteps") or 0,
+ progressPct=model.get("progressPct") or 0.0,
+ status=str(model["status"]),
+ statusMessage=str(
+ model.get("statusMessage") or ""
+ ),
+ prefix="Training",
+ contextLabel=f"Model: {model_name} - Training",
+ ),
+ )
+ )
+ if _is_active_status(model.get("inferenceStatus")):
+ jobs.append(
+ ActiveJob(
+ key=f"inference-{project_id}-{model_id}",
+ kind="Inference",
+ projectName=project_name,
+ name=model_name,
+ target=target,
+ indicator=ActiveJobIndicator(
+ id=f"ongoingInference-{project_id}-{model_id}",
+ currentStep=model.get("inferenceCurrentStep") or 0,
+ totalSteps=model.get("inferenceTotalSteps") or 0,
+ progressPct=model.get("inferenceProgressPct")
+ or 0.0,
+ status=str(model["inferenceStatus"]),
+ statusMessage=str(
+ model.get("inferenceStatusMessage") or ""
+ ),
+ prefix="Inference",
+ contextLabel=f"Model: {model_name} - Inference",
+ ),
+ )
+ )
+
+ jobs.sort(key=lambda job: job.key)
+ return ActiveJobs(jobs=jobs)
+
+
+class LabelingWorkspaceProcessor:
+ """Load the minimum records for one standard labeling workspace."""
+
+ def __init__(
+ self,
+ project_id: str,
+ image_layer_id: str,
+ config: Config | None = None,
+ processor_factory: Callable[
+ ..., MetadataProcessor
+ ] = MetadataProcessor,
+ ) -> None:
+ self.project_id = project_id
+ self.image_layer_id = image_layer_id
+ self.config = config or Config()
+ self.processor_factory = processor_factory
+
+ def _processor(self, data_type: str) -> MetadataProcessor:
+ return self.processor_factory(
+ data_type=data_type,
+ partition_key=self.project_id,
+ config=self.config,
+ )
+
+ async def load(self) -> LabelingWorkspace:
+ """Load project and layer concurrently, then resolve labels by key."""
+ types = self.config.get_metadata_types()
+ project_task = asyncio.to_thread(
+ self._processor(types.PROJECT.value).load, self.project_id
+ )
+ layer_task = asyncio.to_thread(
+ self._processor(types.IMAGELAYER.value).load,
+ self.image_layer_id,
+ )
+ try:
+ raw_project, raw_layer = await asyncio.gather(
+ project_task, layer_task
+ )
+ except ResourceNotFoundError as error:
+ raise FileNotFoundError(
+ "Labeling workspace records were not found"
+ ) from error
+ project = Project(**raw_project)
+ image_layer = ImageLayer(**raw_layer)
+ if (
+ project.projectId != self.project_id
+ or image_layer.imageLayerId != self.image_layer_id
+ or image_layer.projectId != self.project_id
+ ):
+ raise FileNotFoundError("Labeling workspace records do not match")
+ label_project = await self._load_label_project(image_layer)
+ return LabelingWorkspace(
+ labelProject=label_project,
+ imageLayer=LabelingImageLayer(
+ imageLayerId=self.image_layer_id,
+ name=image_layer.name,
+ sourceTypePostEvent=image_layer.sourceTypePostEvent,
+ ),
+ eventTypes=project.eventTypes or [],
+ primaryClasses=project.primaryClasses or [],
+ )
+
+ async def _load_label_project(
+ self, image_layer: ImageLayer
+ ) -> LabelProject:
+ labels = self._processor(self.config.get_metadata_types().LABELS.value)
+ if image_layer.labelProjectId:
+ try:
+ raw_label = await asyncio.to_thread(
+ labels.load, image_layer.labelProjectId
+ )
+ if (
+ raw_label.get("projectId") == self.project_id
+ and raw_label.get("imageLayerId") == self.image_layer_id
+ and raw_label.get("labelprojectId")
+ == image_layer.labelProjectId
+ ):
+ return LabelProject(**raw_label)
+ except (FileNotFoundError, ResourceNotFoundError):
+ pass
+
+ raw_labels = await asyncio.to_thread(labels.load_all_from_partition)
+ raw_label = next(
+ (
+ label
+ for label in raw_labels
+ if label.get("projectId") == self.project_id
+ and label.get("imageLayerId") == self.image_layer_id
+ ),
+ None,
+ )
+ if raw_label is None:
+ raise FileNotFoundError(
+ f"Label project for image layer {self.image_layer_id} not found"
+ )
+ return LabelProject(**raw_label)
+
+
+class ActiveJobsProcessor:
+ """Load active jobs without assembling complete project details."""
+
+ def __init__(
+ self,
+ config: Config | None = None,
+ processor_factory: Callable[
+ ..., MetadataProcessor
+ ] = MetadataProcessor,
+ max_concurrency: int = 4,
+ ) -> None:
+ if max_concurrency < 1:
+ raise ValueError("max_concurrency must be positive")
+ self.config = config or Config()
+ self.processor_factory = processor_factory
+ self.max_concurrency = max_concurrency
+
+ def _processor(
+ self, data_type: str, partition_key: str | None = None
+ ) -> MetadataProcessor:
+ return self.processor_factory(
+ data_type=data_type,
+ partition_key=partition_key,
+ config=self.config,
+ )
+
+ async def load(self) -> ActiveJobs:
+ """Load candidate project partitions with bounded concurrency."""
+ types = self.config.get_metadata_types()
+ try:
+ stats = await asyncio.to_thread(
+ self._processor(types.PROJECT.value).load, "stats"
+ )
+ except ResourceNotFoundError as error:
+ raise FileNotFoundError(
+ "Project statistics were not found"
+ ) from error
+ projects = [
+ project
+ for project in stats.get("projects", [])
+ if project.get("projectId")
+ and (
+ (project.get("imageLayerCount") or 0) > 0
+ or bool(project.get("imageLayerStats"))
+ or (project.get("modelsCount") or 0) > 0
+ or bool(project.get("modelIds"))
+ )
+ ]
+ semaphore = asyncio.Semaphore(self.max_concurrency)
+
+ async def load_project(
+ project: Mapping[str, Any],
+ ) -> tuple[str, tuple[list[dict[str, Any]], list[dict[str, Any]]],]:
+ project_id = str(project["projectId"])
+ async with semaphore:
+ results = await asyncio.gather(
+ (
+ asyncio.to_thread(
+ self._processor(
+ types.IMAGELAYER.value, project_id
+ ).load_all_from_partition
+ )
+ if (project.get("imageLayerCount") or 0) > 0
+ or project.get("imageLayerStats")
+ else asyncio.sleep(0, result=[])
+ ),
+ (
+ asyncio.to_thread(
+ self._processor(
+ types.MODEL.value, project_id
+ ).load_all_from_partition
+ )
+ if (project.get("modelsCount") or 0) > 0
+ or project.get("modelIds")
+ else asyncio.sleep(0, result=[])
+ ),
+ return_exceptions=True,
+ )
+ errors = [
+ result
+ for result in results
+ if isinstance(result, BaseException)
+ ]
+ if errors:
+ raise errors[0]
+ layers, models = results
+ return project_id, (layers, models)
+
+ results = await asyncio.gather(
+ *(load_project(project) for project in projects),
+ return_exceptions=True,
+ )
+ errors = [
+ result for result in results if isinstance(result, BaseException)
+ ]
+ if errors:
+ raise errors[0]
+ return assemble_active_jobs(projects, dict(results))
diff --git a/hastelib/tests/core/processors/test_loading.py b/hastelib/tests/core/processors/test_loading.py
new file mode 100644
index 00000000..5b590bed
--- /dev/null
+++ b/hastelib/tests/core/processors/test_loading.py
@@ -0,0 +1,342 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+import unittest
+from time import sleep
+from unittest.mock import Mock
+
+from azure.core.exceptions import ResourceNotFoundError
+from hastegeo.core.config import Config
+from hastegeo.core.processors.loading import (
+ ActiveJobsProcessor,
+ LabelingWorkspaceProcessor,
+ assemble_active_jobs,
+)
+
+
+class ProcessorTestCase(unittest.IsolatedAsyncioTestCase):
+ def setUp(self) -> None:
+ self.types = Config.get_metadata_types()
+ self.config = Mock()
+ self.config.get_metadata_types.return_value = self.types
+ self.processors: dict[tuple[str, str | None], Mock] = {}
+
+ def processor(self, data_type: str, partition: str | None) -> Mock:
+ return self.processors.setdefault((data_type, partition), Mock())
+
+ def factory(self, *, data_type, partition_key=None, config):
+ self.assertIs(config, self.config)
+ return self.processor(data_type, partition_key)
+
+
+class TestLabelingWorkspaceProcessor(ProcessorTestCase):
+ def setUp(self) -> None:
+ super().setUp()
+ self.processor(
+ self.types.PROJECT.value, "project-1"
+ ).load.return_value = {
+ "projectId": "project-1",
+ "eventTypes": ["Wildfire"],
+ "primaryClasses": [{"name": "Damaged", "color": "#f00"}],
+ }
+ self.processor(
+ self.types.IMAGELAYER.value, "project-1"
+ ).load.return_value = {
+ "projectId": "project-1",
+ "imageLayerId": "layer-1",
+ "labelProjectId": "labels-1",
+ "name": "Post event",
+ "sourceTypePostEvent": "sentinel_2",
+ }
+ self.labels = self.processor(self.types.LABELS.value, "project-1")
+ self.labels.load.return_value = {
+ "projectId": "project-1",
+ "imageLayerId": "layer-1",
+ "labelprojectId": "labels-1",
+ "labels": [],
+ }
+
+ async def test_load_uses_direct_label_pointer(self) -> None:
+ result = await LabelingWorkspaceProcessor(
+ "project-1", "layer-1", self.config, self.factory
+ ).load()
+
+ self.assertEqual(result.imageLayer.imageLayerId, "layer-1")
+ self.assertEqual(result.imageLayer.name, "Post event")
+ self.assertEqual(result.imageLayer.sourceTypePostEvent, "sentinel_2")
+ self.assertEqual(result.labelProject.labelprojectId, "labels-1")
+ self.assertEqual(result.eventTypes, ["Wildfire"])
+ self.labels.load.assert_called_once_with("labels-1")
+ self.labels.load_all_from_partition.assert_not_called()
+
+ async def test_load_falls_back_when_pointer_is_missing(self) -> None:
+ self.processor(
+ self.types.IMAGELAYER.value, "project-1"
+ ).load.return_value["labelProjectId"] = None
+ self.labels.load_all_from_partition.return_value = [
+ {
+ "projectId": "project-1",
+ "imageLayerId": "layer-1",
+ "labelprojectId": "legacy-labels",
+ }
+ ]
+
+ result = await LabelingWorkspaceProcessor(
+ "project-1", "layer-1", self.config, self.factory
+ ).load()
+
+ self.assertEqual(result.labelProject.labelprojectId, "legacy-labels")
+ self.labels.load.assert_not_called()
+ self.labels.load_all_from_partition.assert_called_once_with()
+
+ async def test_load_rejects_mismatched_layer_record(self) -> None:
+ self.processor(
+ self.types.IMAGELAYER.value, "project-1"
+ ).load.return_value["projectId"] = "different-project"
+
+ with self.assertRaises(FileNotFoundError):
+ await LabelingWorkspaceProcessor(
+ "project-1", "layer-1", self.config, self.factory
+ ).load()
+
+ self.labels.load.assert_not_called()
+
+ async def test_load_rejects_mismatched_pointed_label_record(self) -> None:
+ self.labels.load.return_value["projectId"] = "different-project"
+ self.labels.load_all_from_partition.return_value = []
+
+ with self.assertRaises(FileNotFoundError):
+ await LabelingWorkspaceProcessor(
+ "project-1", "layer-1", self.config, self.factory
+ ).load()
+
+ self.labels.load_all_from_partition.assert_called_once_with()
+
+ async def test_load_falls_back_for_storage_not_found_error(self) -> None:
+ self.labels.load.side_effect = ResourceNotFoundError("missing")
+ self.labels.load_all_from_partition.return_value = [
+ {
+ "projectId": "project-1",
+ "imageLayerId": "layer-1",
+ "labelprojectId": "legacy-labels",
+ }
+ ]
+
+ result = await LabelingWorkspaceProcessor(
+ "project-1", "layer-1", self.config, self.factory
+ ).load()
+
+ self.assertEqual(result.labelProject.labelprojectId, "legacy-labels")
+ self.labels.load_all_from_partition.assert_called_once_with()
+
+ async def test_load_rejects_dangling_pointer_without_fallback(
+ self,
+ ) -> None:
+ self.labels.load.side_effect = FileNotFoundError
+ self.labels.load_all_from_partition.return_value = []
+
+ with self.assertRaises(FileNotFoundError):
+ await LabelingWorkspaceProcessor(
+ "project-1", "layer-1", self.config, self.factory
+ ).load()
+
+ self.labels.load_all_from_partition.assert_called_once_with()
+
+
+class TestAssembleActiveJobs(unittest.TestCase):
+ def test_collects_active_imagery_training_and_inference(self) -> None:
+ result = assemble_active_jobs(
+ [{"projectId": "project-1", "name": "Project"}],
+ {
+ "project-1": (
+ [
+ {
+ "imageLayerId": "layer-1",
+ "name": "Layer",
+ "status": "InProgress",
+ "currentStep": 1,
+ }
+ ],
+ [
+ {
+ "modelId": "42",
+ "imageLayerId": "layer-1",
+ "name": "Model",
+ "status": "Queued",
+ "inferenceStatus": "InProgress",
+ }
+ ],
+ )
+ },
+ )
+
+ self.assertEqual(
+ [job.kind for job in result.jobs],
+ ["Imagery", "Inference", "Training"],
+ )
+ self.assertEqual(len({job.key for job in result.jobs}), 3)
+ for job in result.jobs:
+ self.assertIsInstance(job.indicator.currentStep, int)
+ self.assertIsInstance(job.indicator.totalSteps, int)
+ self.assertIsInstance(job.indicator.progressPct, float)
+
+ def test_excludes_empty_and_terminal_statuses(self) -> None:
+ result = assemble_active_jobs(
+ [{"projectId": "project-1"}],
+ {
+ "project-1": (
+ [
+ {"imageLayerId": "one", "status": "Processed"},
+ {"imageLayerId": "two", "status": "Completed"},
+ {"imageLayerId": "three", "status": "Failed"},
+ ],
+ [
+ {
+ "modelId": "42",
+ "imageLayerId": "one",
+ "status": "Trained",
+ "inferenceStatus": None,
+ }
+ ],
+ )
+ },
+ )
+
+ self.assertEqual(result.jobs, [])
+
+ def test_output_order_is_stable_across_storage_order(self) -> None:
+ projects = [{"projectId": "project-1"}]
+ first = assemble_active_jobs(
+ projects,
+ {
+ "project-1": (
+ [
+ {"imageLayerId": "b", "status": "Queued"},
+ {"imageLayerId": "a", "status": "Queued"},
+ ],
+ [],
+ )
+ },
+ )
+ second = assemble_active_jobs(
+ projects,
+ {
+ "project-1": (
+ [
+ {"imageLayerId": "a", "status": "Queued"},
+ {"imageLayerId": "b", "status": "Queued"},
+ ],
+ [],
+ )
+ },
+ )
+
+ self.assertEqual(first.model_dump_json(), second.model_dump_json())
+
+
+class TestActiveJobsProcessor(ProcessorTestCase):
+ async def test_load_reads_only_candidate_layer_and_model_partitions(
+ self,
+ ) -> None:
+ self.processor(self.types.PROJECT.value, None).load.return_value = {
+ "projects": [
+ {
+ "projectId": "project-1",
+ "name": "One",
+ "imageLayerCount": 1,
+ "modelsCount": 0,
+ },
+ {
+ "projectId": "project-2",
+ "name": "Two",
+ "imageLayerCount": 0,
+ "modelsCount": 0,
+ },
+ ]
+ }
+ self.processor(
+ self.types.IMAGELAYER.value, "project-1"
+ ).load_all_from_partition.return_value = []
+
+ result = await ActiveJobsProcessor(self.config, self.factory).load()
+
+ self.assertEqual(result.jobs, [])
+ self.processor(
+ self.types.PROJECT.value, None
+ ).load.assert_called_once_with("stats")
+ self.assertNotIn(
+ (self.types.IMAGELAYER.value, "project-2"), self.processors
+ )
+ self.assertNotIn(
+ (self.types.MODEL.value, "project-1"), self.processors
+ )
+ self.assertNotIn(
+ (self.types.LABELS.value, "project-1"), self.processors
+ )
+ self.assertNotIn(
+ (self.types.VALIDATION.value, "project-1"), self.processors
+ )
+
+ async def test_load_normalizes_storage_missing_stats(self) -> None:
+ self.processor(
+ self.types.PROJECT.value, None
+ ).load.side_effect = ResourceNotFoundError("missing")
+
+ with self.assertRaises(FileNotFoundError):
+ await ActiveJobsProcessor(self.config, self.factory).load()
+
+ async def test_load_drains_partition_reads_before_raising(self) -> None:
+ self.processor(self.types.PROJECT.value, None).load.return_value = {
+ "projects": [
+ {
+ "projectId": "project-1",
+ "imageLayerCount": 1,
+ "modelsCount": 1,
+ }
+ ]
+ }
+ completed = []
+ self.processor(
+ self.types.IMAGELAYER.value, "project-1"
+ ).load_all_from_partition.side_effect = RuntimeError("layer failure")
+
+ def finish_model_read():
+ sleep(0.02)
+ completed.append("models")
+ return []
+
+ self.processor(
+ self.types.MODEL.value, "project-1"
+ ).load_all_from_partition.side_effect = finish_model_read
+
+ with self.assertRaisesRegex(RuntimeError, "layer failure"):
+ await ActiveJobsProcessor(self.config, self.factory).load()
+
+ self.assertEqual(completed, ["models"])
+
+ async def test_load_uses_ids_when_summary_counts_lag(self) -> None:
+ self.processor(self.types.PROJECT.value, None).load.return_value = {
+ "projects": [
+ {
+ "projectId": "project-1",
+ "imageLayerCount": 0,
+ "modelsCount": 0,
+ "modelIds": ["42"],
+ }
+ ]
+ }
+ self.processor(
+ self.types.MODEL.value, "project-1"
+ ).load_all_from_partition.return_value = []
+
+ await ActiveJobsProcessor(self.config, self.factory).load()
+
+ self.processor(
+ self.types.MODEL.value, "project-1"
+ ).load_all_from_partition.assert_called_once_with()
+ self.assertNotIn(
+ (self.types.IMAGELAYER.value, "project-1"), self.processors
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
From 690bd0d42e070431301942feb4d9e8cfdf8c4b35 Mon Sep 17 00:00:00 2001
From: prbatero <42007693+prbatero@users.noreply.github.com>
Date: Thu, 3 Sep 2026 16:19:15 -0400
Subject: [PATCH 6/7] feat(ui): unify route loading and cancellation
Render Dashboard independently of optional work, replace project fan-out with conditional Active Jobs polling, and abort route-owned requests. Add one staged standard-labeling workspace through AOI map readiness with capability-specific Maps assets and safe retry cleanup.
---
ui/package.json | 2 +-
ui/src/Components/AppBody.jsx | 30 ++-
ui/src/Components/Home.jsx | 70 +++---
ui/src/Components/Home/OngoingJobs.jsx | 101 ++++----
ui/src/Components/Home/activeJobsRequest.js | 15 ++
.../Components/Home/activeJobsRequest.test.js | 59 +++++
ui/src/Components/Home/loadHomeData.js | 18 +-
ui/src/Components/Home/loadHomeData.test.js | 59 ++---
ui/src/Components/Home/ongoingJobs.test.js | 55 ----
ui/src/Components/Home/ongoingJobsUtils.js | 84 -------
.../InteractiveLabeler/InteractiveLabeler.jsx | 1 +
.../InteractiveLabelerLoader.jsx | 205 +--------------
.../loadInteractiveArtifacts.js | 7 +-
.../loadInteractiveArtifacts.test.js | 26 ++
.../loadInteractiveMetadata.js | 13 +-
.../loadInteractiveMetadata.test.js | 9 +-
.../Components/LabelingTool/LabelingTool.jsx | 235 +++++++++++-------
.../LabelingTool/LabelingToolHelper.js | 40 +--
.../LabelingTool/LabelingToolRoute.jsx | 112 +++++++++
.../LabelingTool/labelingToolLoading.js | 77 ++++++
.../LabelingTool/labelingToolLoading.test.js | 104 ++++++++
.../LabelingTool/loadLabelingRoute.js | 18 ++
.../LabelingTool/loadLabelingRoute.test.js | 54 ++++
ui/src/Components/MapRoute.jsx | 17 +-
ui/src/Components/WorkspaceLoader.jsx | 232 +++++++++++++++++
ui/src/assets/css/style.css | 14 ++
ui/src/util/api.js | 4 +-
ui/src/util/api.test.js | 35 +++
ui/src/util/azureMapsLoader.js | 83 +++++--
ui/src/util/azureMapsLoader.test.js | 70 +++++-
30 files changed, 1229 insertions(+), 620 deletions(-)
create mode 100644 ui/src/Components/Home/activeJobsRequest.js
create mode 100644 ui/src/Components/Home/activeJobsRequest.test.js
delete mode 100644 ui/src/Components/Home/ongoingJobs.test.js
delete mode 100644 ui/src/Components/Home/ongoingJobsUtils.js
create mode 100644 ui/src/Components/LabelingTool/LabelingToolRoute.jsx
create mode 100644 ui/src/Components/LabelingTool/labelingToolLoading.js
create mode 100644 ui/src/Components/LabelingTool/labelingToolLoading.test.js
create mode 100644 ui/src/Components/LabelingTool/loadLabelingRoute.js
create mode 100644 ui/src/Components/LabelingTool/loadLabelingRoute.test.js
create mode 100644 ui/src/Components/WorkspaceLoader.jsx
create mode 100644 ui/src/util/api.test.js
diff --git a/ui/package.json b/ui/package.json
index b394aaac..b50e43ec 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -11,7 +11,7 @@
"build:testing": "vite build --mode testing",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"test:interactive-labeler": "node --test src/Components/InteractiveLabeler/interactiveLabelerLoading.test.js src/Components/guidedTourLayout.test.js",
- "test:ongoing-jobs": "node --test src/Components/Home/ongoingJobs.test.js",
+ "test:ongoing-jobs": "node --test src/Components/Home/activeJobsRequest.test.js",
"test:label-store": "node --test src/Components/InteractiveLabeler/labelStore.test.js",
"test:validation-config": "node --test src/Components/BuildingValidation/validationConfig.test.js",
"preview": "vite preview"
diff --git a/ui/src/Components/AppBody.jsx b/ui/src/Components/AppBody.jsx
index 8d8e37c7..49049da1 100644
--- a/ui/src/Components/AppBody.jsx
+++ b/ui/src/Components/AppBody.jsx
@@ -8,12 +8,15 @@ import PropType from "prop-types";
import { AppContext } from "../AppContext";
import { createMapRoute, RouteLoading } from "./MapRoute";
+import { loadAzureMaps } from "../util/azureMapsLoader";
+import LabelingToolRoute from "./LabelingTool/LabelingToolRoute";
const AdminLabelingTool = lazy(() => import("./AdminLabelingTool"));
const AdminSourceTypes = lazy(() => import("./AdminSourceTypes"));
const AdminUsers = lazy(() => import("./AdminUsers"));
const BuildingValidation = createMapRoute(
- () => import("./BuildingValidation/BuildingValidation")
+ () => import("./BuildingValidation/BuildingValidation"),
+ () => loadAzureMaps(document, { drawing: false, swipe: false })
);
const CreateEditImageLayerForm = lazy(
() => import("./CreateEditImageLayerForm")
@@ -23,16 +26,17 @@ const HelpDocs = lazy(() => import("./HelpDocs"));
const Home = lazy(() => import("./Home"));
const ImageLayer = lazy(() => import("./ImageLayer"));
const InteractiveLabeler = createMapRoute(
- () => import("./InteractiveLabeler/InteractiveLabeler")
-);
-const LabelingTool = createMapRoute(
- () => import("./LabelingTool/LabelingTool")
+ () => import("./InteractiveLabeler/InteractiveLabeler"),
+ () => loadAzureMaps(document, { drawing: false, swipe: true })
);
const ModelCatalog = lazy(() => import("./ModelCatalog"));
const Project = lazy(() => import("./Project"));
const Projects = lazy(() => import("./Projects"));
const PublishedDatasets = lazy(() => import("./PublishedDatasets"));
-const Visualizer = createMapRoute(() => import("./Visualizer/Visualizer"));
+const Visualizer = createMapRoute(
+ () => import("./Visualizer/Visualizer"),
+ () => loadAzureMaps(document, { drawing: false, swipe: true })
+);
const AppBody = ({ setModalComponent }) => {
const { appParams } = useContext(AppContext);
@@ -40,9 +44,13 @@ const AppBody = ({ setModalComponent }) => {
appParams.userRoles !== null && appParams.publishingEnabled !== null;
return (
-
+
{appParams.isLoading &&
}
- {routesReady &&
}>
+ {routesReady && }>
{appParams.userRoles !== null && appParams.publishingEnabled && (
} />
)}
@@ -69,7 +77,11 @@ const AppBody = ({ setModalComponent }) => {
/>
}
+ element={
+
+ }
/>
(
{
const navigate = useNavigate();
- const { setIsLoading, initCurrentTour, setAppHeaderRightButtons, appParams } =
+ const { initCurrentTour, setAppHeaderRightButtons, appParams } =
useContext(AppContext);
const [dashboardData, setDashboardData] = useState(null);
const [catalog, setCatalog] = useState([]);
const [modalComponent, setModalComponent] = useState(null);
const [nowMs] = useState(Date.now);
const [loadError, setLoadError] = useState(false);
+ const [loadAttempt, setLoadAttempt] = useState(0);
const openCreateProjectModal = () => {
setModalComponent(
@@ -107,30 +109,8 @@ const Home = () => {
);
};
- const fetchProjects = async () => {
- setIsLoading(true);
- try {
- const result = await loadHomeData(apiGet);
- if (result.dashboardError) {
- console.error("Error fetching projects:", result.dashboardError);
- setLoadError(true);
- } else {
- setDashboardData(result.dashboardData);
- setLoadError(false);
- }
- if (result.catalogError) {
- console.warn(
- "Model catalog unavailable for dashboard:",
- result.catalogError
- );
- }
- setCatalog(result.catalog);
- } finally {
- setIsLoading(false);
- }
- };
-
useEffect(() => {
+ let active = true;
initCurrentTour("dashboardGuide");
setAppHeaderRightButtons([
{
@@ -148,16 +128,41 @@ const Home = () => {
},
]);
- fetchProjects();
+ const controller = new AbortController();
+ const { dashboard, catalog: catalogRequest } = loadHomeData(apiGet, {
+ signal: controller.signal,
+ });
+ dashboard
+ .then((response) => {
+ if (!active) return;
+ setDashboardData(response);
+ setLoadError(false);
+ })
+ .catch((error) => {
+ if (!active || error.name === "AbortError") return;
+ console.error("Error fetching projects:", error);
+ setLoadError(true);
+ });
+ catalogRequest
+ .then((response) => {
+ if (active) setCatalog(response);
+ })
+ .catch((error) => {
+ if (active && error.name !== "AbortError") {
+ console.warn("Model catalog unavailable for dashboard:", error);
+ }
+ });
//On component dismount
return () => {
+ active = false;
+ controller.abort();
setModalComponent(null);
initGuidedTourState("dashboardGuide", appParams.guidedTourProperties);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ }, [loadAttempt]);
if (!dashboardData) {
return loadError ? (
@@ -167,11 +172,18 @@ const Home = () => {
Dashboard data could not be loaded.
-
+ {
+ setLoadError(false);
+ setLoadAttempt((value) => value + 1);
+ }}
+ >
Retry
- ) : null;
+ ) : ;
}
const projects = dashboardData.projects || [];
@@ -326,7 +338,7 @@ const Home = () => {
{isEmpty ? (
) : (
-
+
)}
diff --git a/ui/src/Components/Home/OngoingJobs.jsx b/ui/src/Components/Home/OngoingJobs.jsx
index ce2d5a87..530e8fc1 100644
--- a/ui/src/Components/Home/OngoingJobs.jsx
+++ b/ui/src/Components/Home/OngoingJobs.jsx
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
-import { useEffect, useState } from "react";
-import PropTypes from "prop-types";
+import { useEffect, useRef, useState } from "react";
import {
Button,
MessageBar,
@@ -9,80 +8,78 @@ import {
Spinner,
} from "@fluentui/react-components";
import { useNavigate } from "react-router-dom";
-import { apiGet } from "../../util/api";
+import { apiGetResponse } from "../../util/api";
+import { createSingleFlight } from "../../util/singleFlight";
import StatusIndicator from "../OtherComponents/StatusIndicator";
import NoResultsMessage from "../NoResultsMessage";
-import { extractJobs } from "./ongoingJobsUtils";
+import {
+ ACTIVE_JOBS_ENDPOINT,
+ activeJobsAfterResponse,
+ activeJobsHeaders,
+ shouldPollActiveJobs,
+} from "./activeJobsRequest";
const REFRESH_INTERVAL_MS = 30000;
-// Load project details for the projects that could have running work and
-// surface every in-progress imagery/training/inference job. This runs after
-// the dashboard summary is already on screen, with its own in-block spinner,
-// because walking each project's models can take a while.
-const OngoingJobs = ({ projects }) => {
+const OngoingJobs = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [jobs, setJobs] = useState([]);
const [loadError, setLoadError] = useState("");
const [refreshToken, setRefreshToken] = useState(0);
-
- const projectKey = projects
- .filter((project) =>
- (project.imageLayerCount || 0) > 0 || (project.modelsCount || 0) > 0
- )
- .map((project) => project.projectId)
- .join("|");
+ const requestRef = useRef(createSingleFlight());
+ const etagRef = useRef(null);
useEffect(() => {
- let cancelled = false;
- const projectIds = projectKey ? projectKey.split("|") : [];
+ let mounted = true;
+ const request = requestRef.current;
const load = async (initialLoad = false) => {
+ if (request.isRunning(ACTIVE_JOBS_ENDPOINT)) return;
if (initialLoad) setLoading(true);
-
- const results = await Promise.allSettled(
- projectIds.map((projectId) =>
- apiGet(
- `GetProjectDetails?projectId=${projectId}&includeModels=True`
- )
- .then((res) => ({ projectId, res }))
- )
- );
-
- if (cancelled) return;
-
- const collected = [];
- let failedCount = 0;
- results.forEach((result) => {
- if (result.status === "fulfilled") {
- collected.push(
- ...extractJobs(result.value.projectId, result.value.res)
+ try {
+ await request.run(ACTIVE_JOBS_ENDPOINT, async (signal) => {
+ const { data, etag, status } = await apiGetResponse(
+ ACTIVE_JOBS_ENDPOINT,
+ {
+ signal,
+ headers: activeJobsHeaders(etagRef.current),
+ }
);
- } else {
- failedCount += 1;
+ if (!mounted) return;
+ if (etag) etagRef.current = etag;
+ setJobs((current) =>
+ activeJobsAfterResponse(current, { data, status })
+ );
+ setLoadError("");
+ });
+ } catch (error) {
+ if (error.name !== "AbortError" && mounted) {
+ setLoadError("Ongoing jobs could not be refreshed.");
}
- });
-
- setJobs(collected);
- setLoadError(
- failedCount > 0
- ? `${failedCount} of ${projectIds.length} projects could not be refreshed.`
- : ""
- );
- setLoading(false);
+ } finally {
+ if (mounted) setLoading(false);
+ }
};
load(true);
const intervalId = window.setInterval(() => {
- if (document.visibilityState === "visible") load();
+ if (
+ shouldPollActiveJobs({
+ visibilityState: document.visibilityState,
+ requestRunning: request.isRunning(ACTIVE_JOBS_ENDPOINT),
+ })
+ ) {
+ load();
+ }
}, REFRESH_INTERVAL_MS);
return () => {
- cancelled = true;
+ mounted = false;
window.clearInterval(intervalId);
+ request.abort();
};
- }, [projectKey, refreshToken]);
+ }, [refreshToken]);
if (loading) {
return (
@@ -145,8 +142,4 @@ const OngoingJobs = ({ projects }) => {
);
};
-OngoingJobs.propTypes = {
- projects: PropTypes.array.isRequired,
-};
-
export default OngoingJobs;
diff --git a/ui/src/Components/Home/activeJobsRequest.js b/ui/src/Components/Home/activeJobsRequest.js
new file mode 100644
index 00000000..7d3a8036
--- /dev/null
+++ b/ui/src/Components/Home/activeJobsRequest.js
@@ -0,0 +1,15 @@
+export const ACTIVE_JOBS_ENDPOINT = "GetActiveJobs";
+
+export function activeJobsHeaders(etag) {
+ const headers = {};
+ if (etag) headers["If-None-Match"] = etag;
+ return headers;
+}
+
+export function shouldPollActiveJobs({ visibilityState, requestRunning }) {
+ return visibilityState === "visible" && !requestRunning;
+}
+
+export function activeJobsAfterResponse(currentJobs, { data, status }) {
+ return status === 304 ? currentJobs : data?.jobs || [];
+}
diff --git a/ui/src/Components/Home/activeJobsRequest.test.js b/ui/src/Components/Home/activeJobsRequest.test.js
new file mode 100644
index 00000000..42ec91ef
--- /dev/null
+++ b/ui/src/Components/Home/activeJobsRequest.test.js
@@ -0,0 +1,59 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ ACTIVE_JOBS_ENDPOINT,
+ activeJobsAfterResponse,
+ activeJobsHeaders,
+ shouldPollActiveJobs,
+} from "./activeJobsRequest.js";
+
+test("uses one stable Active Jobs endpoint", () => {
+ assert.equal(ACTIVE_JOBS_ENDPOINT, "GetActiveJobs");
+});
+
+test("retains current jobs after an unchanged response", () => {
+ const current = [{ key: "job-1" }];
+
+ assert.equal(
+ activeJobsAfterResponse(current, { data: null, status: 304 }),
+ current
+ );
+ assert.deepEqual(
+ activeJobsAfterResponse(current, {
+ data: { jobs: [{ key: "job-2" }] },
+ status: 200,
+ }),
+ [{ key: "job-2" }]
+ );
+});
+
+test("adds a conditional header when available", () => {
+ assert.deepEqual(activeJobsHeaders('"etag"'), {
+ "If-None-Match": '"etag"',
+ });
+});
+
+test("polls only while visible and idle", () => {
+ assert.equal(
+ shouldPollActiveJobs({
+ visibilityState: "visible",
+ requestRunning: false,
+ }),
+ true
+ );
+ assert.equal(
+ shouldPollActiveJobs({
+ visibilityState: "hidden",
+ requestRunning: false,
+ }),
+ false
+ );
+ assert.equal(
+ shouldPollActiveJobs({
+ visibilityState: "visible",
+ requestRunning: true,
+ }),
+ false
+ );
+});
diff --git a/ui/src/Components/Home/loadHomeData.js b/ui/src/Components/Home/loadHomeData.js
index 500ee6ff..828dabbf 100644
--- a/ui/src/Components/Home/loadHomeData.js
+++ b/ui/src/Components/Home/loadHomeData.js
@@ -1,16 +1,8 @@
-export async function loadHomeData(get) {
- const [dashboard, catalog] = await Promise.allSettled([
- get("GetDashboardData"),
- get("GetModelCatalog"),
- ]);
-
+export function loadHomeData(get, options = {}) {
return {
- dashboardData: dashboard.status === "fulfilled" ? dashboard.value : null,
- dashboardError: dashboard.status === "rejected" ? dashboard.reason : null,
- catalog:
- catalog.status === "fulfilled"
- ? catalog.value?.modelCatalog || []
- : [],
- catalogError: catalog.status === "rejected" ? catalog.reason : null,
+ dashboard: get("GetDashboardData", options),
+ catalog: get("GetModelCatalog", options).then(
+ (response) => response?.modelCatalog || []
+ ),
};
}
\ No newline at end of file
diff --git a/ui/src/Components/Home/loadHomeData.test.js b/ui/src/Components/Home/loadHomeData.test.js
index 9f593d65..1f8d8ff0 100644
--- a/ui/src/Components/Home/loadHomeData.test.js
+++ b/ui/src/Components/Home/loadHomeData.test.js
@@ -14,51 +14,46 @@ function deferred() {
return { promise, resolve, reject };
}
-test("starts dashboard and catalog requests concurrently", async () => {
+test("starts dashboard and catalog requests concurrently", () => {
const dashboard = deferred();
const catalog = deferred();
const calls = [];
- const loading = loadHomeData((endpoint) => {
- calls.push(endpoint);
+ const options = { signal: new AbortController().signal };
+ const loading = loadHomeData((endpoint, receivedOptions) => {
+ calls.push([endpoint, receivedOptions]);
return endpoint === "GetDashboardData"
? dashboard.promise
: catalog.promise;
- });
-
- assert.deepEqual(calls, ["GetDashboardData", "GetModelCatalog"]);
- catalog.resolve({ modelCatalog: [{ modelId: "model-1" }] });
- dashboard.resolve({ projects: [{ projectId: "project-1" }] });
+ }, options);
- assert.deepEqual(await loading, {
- dashboardData: { projects: [{ projectId: "project-1" }] },
- dashboardError: null,
- catalog: [{ modelId: "model-1" }],
- catalogError: null,
- });
+ assert.deepEqual(calls, [
+ ["GetDashboardData", options],
+ ["GetModelCatalog", options],
+ ]);
+ assert.equal(loading.dashboard, dashboard.promise);
});
-test("keeps dashboard data when the optional catalog fails", async () => {
- const result = await loadHomeData(async (endpoint) => {
- if (endpoint === "GetModelCatalog") {
- throw new Error("catalog unavailable");
- }
- return { projects: [] };
+test("dashboard resolves without waiting for the optional catalog", async () => {
+ const dashboard = deferred();
+ const catalog = deferred();
+ const loading = loadHomeData((endpoint) => {
+ return endpoint === "GetDashboardData"
+ ? dashboard.promise
+ : catalog.promise;
});
- assert.deepEqual(result.dashboardData, { projects: [] });
- assert.deepEqual(result.catalog, []);
- assert.match(result.catalogError.message, /catalog unavailable/);
+ dashboard.resolve({ projects: [] });
+
+ assert.deepEqual(await loading.dashboard, { projects: [] });
+ catalog.resolve({ modelCatalog: [{ modelId: "model-1" }] });
+ assert.deepEqual(await loading.catalog, [{ modelId: "model-1" }]);
});
-test("reports a required dashboard failure independently", async () => {
- const result = await loadHomeData(async (endpoint) => {
- if (endpoint === "GetDashboardData") {
- throw new Error("dashboard unavailable");
- }
- return { modelCatalog: [] };
+test("preserves independent dashboard and catalog failures", async () => {
+ const loading = loadHomeData(async (endpoint) => {
+ throw new Error(`${endpoint} unavailable`);
});
- assert.equal(result.dashboardData, null);
- assert.match(result.dashboardError.message, /dashboard unavailable/);
- assert.deepEqual(result.catalog, []);
+ await assert.rejects(loading.dashboard, /GetDashboardData unavailable/);
+ await assert.rejects(loading.catalog, /GetModelCatalog unavailable/);
});
\ No newline at end of file
diff --git a/ui/src/Components/Home/ongoingJobs.test.js b/ui/src/Components/Home/ongoingJobs.test.js
deleted file mode 100644
index 7c8f62e3..00000000
--- a/ui/src/Components/Home/ongoingJobs.test.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import test from "node:test";
-import assert from "node:assert/strict";
-
-import { extractJobs } from "./ongoingJobsUtils.js";
-
-const projectWithModel = (model) => ({
- name: "Maui",
- imageLayer: [
- {
- imageLayerId: "layer-1",
- name: "Post-event",
- status: "Processed",
- models: [model],
- },
- ],
-});
-
-test("extractJobs includes active training when historical inference is terminal", () => {
- const project = projectWithModel({
- modelId: "model-1",
- name: "Damage model",
- status: "InProgress",
- inferenceStatus: "Processed",
- });
-
- const jobs = extractJobs("project-1", project);
-
- assert.deepEqual(jobs.map((job) => job.kind), ["Training"]);
-});
-
-test("extractJobs includes simultaneous training and inference", () => {
- const project = projectWithModel({
- modelId: "model-1",
- name: "Damage model",
- status: "Queued",
- inferenceStatus: "InProgress",
- });
-
- const jobs = extractJobs("project-1", project);
-
- assert.deepEqual(jobs.map((job) => job.kind), ["Training", "Inference"]);
-});
-
-test("extractJobs excludes terminal and empty statuses", () => {
- const project = projectWithModel({
- modelId: "model-1",
- name: "Damage model",
- status: "Failed",
- inferenceStatus: "",
- });
-
- const jobs = extractJobs("project-1", project);
-
- assert.deepEqual(jobs, []);
-});
\ No newline at end of file
diff --git a/ui/src/Components/Home/ongoingJobsUtils.js b/ui/src/Components/Home/ongoingJobsUtils.js
deleted file mode 100644
index c007af78..00000000
--- a/ui/src/Components/Home/ongoingJobsUtils.js
+++ /dev/null
@@ -1,84 +0,0 @@
-const TERMINAL_STATES = new Set([
- "Processed",
- "Completed",
- "Failed",
- "Cancelled",
-]);
-
-const isOngoing = (status) =>
- typeof status === "string" &&
- status.length > 0 &&
- !TERMINAL_STATES.has(status);
-
-export function extractJobs(projectId, project) {
- const jobs = [];
- const projectName = project.name || "Project";
-
- (project.imageLayer || []).forEach((layer) => {
- const target = `/project/${projectId}/${layer.imageLayerId}`;
-
- if (isOngoing(layer.status)) {
- jobs.push({
- key: `layer-${layer.imageLayerId}`,
- kind: "Imagery",
- projectName,
- name: layer.name,
- target,
- indicator: {
- id: `ongoingImagery-${layer.imageLayerId}`,
- currentStep: layer.currentStep,
- totalSteps: layer.totalSteps,
- progressPct: layer.progressPct,
- status: layer.status,
- statusMessage: layer.statusMessage || "",
- prefix: "Imagery",
- contextLabel: `Image Layer: ${layer.name}`,
- },
- });
- }
-
- (layer.models || []).forEach((model) => {
- if (isOngoing(model.status)) {
- jobs.push({
- key: `training-${model.modelId}`,
- kind: "Training",
- projectName,
- name: model.name,
- target,
- indicator: {
- id: `ongoingTraining-${model.modelId}`,
- currentStep: model.currentStep,
- totalSteps: model.totalSteps,
- progressPct: model.progressPct,
- status: model.status,
- statusMessage: model.statusMessage || "",
- prefix: "Training",
- contextLabel: `Model: ${model.name} · Training`,
- },
- });
- }
-
- if (isOngoing(model.inferenceStatus)) {
- jobs.push({
- key: `inference-${model.modelId}`,
- kind: "Inference",
- projectName,
- name: model.name,
- target,
- indicator: {
- id: `ongoingInference-${model.modelId}`,
- currentStep: model.inferenceCurrentStep,
- totalSteps: model.inferenceTotalSteps,
- progressPct: model.inferenceProgressPct,
- status: model.inferenceStatus,
- statusMessage: model.inferenceStatusMessage || "",
- prefix: "Inference",
- contextLabel: `Model: ${model.name} · Inference`,
- },
- });
- }
- });
- });
-
- return jobs;
-}
\ No newline at end of file
diff --git a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx
index ddd37d22..014e506e 100644
--- a/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx
+++ b/ui/src/Components/InteractiveLabeler/InteractiveLabeler.jsx
@@ -762,6 +762,7 @@ const InteractiveLabeler = () => {
projectId,
imageLayerId,
modelId,
+ signal,
});
signal.throwIfAborted();
// Cache the imagery URLs for the Advanced → Swipe view, which loads the
diff --git a/ui/src/Components/InteractiveLabeler/InteractiveLabelerLoader.jsx b/ui/src/Components/InteractiveLabeler/InteractiveLabelerLoader.jsx
index 70c73ddf..1bd46cbd 100644
--- a/ui/src/Components/InteractiveLabeler/InteractiveLabelerLoader.jsx
+++ b/ui/src/Components/InteractiveLabeler/InteractiveLabelerLoader.jsx
@@ -1,15 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import PropTypes from "prop-types";
-import {
- Button,
- ProgressBar,
- Spinner,
- makeStyles,
- tokens,
-} from "@fluentui/react-components";
-import { FluentIcon } from "../../util/icons";
-import { formatBytes, getLoadProgress } from "./interactiveLabelerLoading.js";
+import WorkspaceLoader from "../WorkspaceLoader";
const LOAD_STEPS = [
"Loading imagery configuration",
@@ -20,193 +12,18 @@ const LOAD_STEPS = [
"Preparing the map",
];
-const useStyles = makeStyles({
- overlay: {
- position: "absolute",
- inset: 0,
- zIndex: 2100,
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- padding: tokens.spacingHorizontalL,
- backgroundColor: tokens.colorNeutralBackgroundAlpha2,
- backdropFilter: "blur(3px)",
- },
- dialog: {
- boxSizing: "border-box",
- width: "min(440px, calc(100vw - 32px))",
- padding: tokens.spacingHorizontalXXL,
- borderRadius: tokens.borderRadiusLarge,
- color: tokens.colorNeutralForeground1,
- backgroundColor: tokens.colorNeutralBackground1,
- border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`,
- boxShadow: tokens.shadow64,
- },
- eyebrow: {
- color: tokens.colorBrandForeground1,
- fontSize: tokens.fontSizeBase100,
- fontWeight: tokens.fontWeightSemibold,
- textTransform: "uppercase",
- },
- title: {
- margin: `${tokens.spacingVerticalXS} 0 ${tokens.spacingVerticalXS}`,
- fontSize: tokens.fontSizeBase500,
- lineHeight: tokens.lineHeightBase600,
- fontWeight: tokens.fontWeightSemibold,
- },
- summary: {
- display: "flex",
- justifyContent: "space-between",
- gap: tokens.spacingHorizontalM,
- marginBottom: tokens.spacingVerticalS,
- color: tokens.colorNeutralForeground3,
- fontSize: tokens.fontSizeBase200,
- },
- steps: {
- display: "grid",
- gap: tokens.spacingVerticalS,
- margin: `${tokens.spacingVerticalL} 0 0`,
- padding: 0,
- listStyle: "none",
- },
- step: {
- display: "grid",
- gridTemplateColumns: "20px minmax(0, 1fr) auto",
- alignItems: "center",
- gap: tokens.spacingHorizontalS,
- minHeight: "24px",
- color: tokens.colorNeutralForeground3,
- fontSize: tokens.fontSizeBase200,
- },
- active: {
- color: tokens.colorNeutralForeground1,
- fontWeight: tokens.fontWeightSemibold,
- },
- done: {
- color: tokens.colorNeutralForeground2,
- },
- icon: {
- display: "inline-flex",
- alignItems: "center",
- justifyContent: "center",
- color: tokens.colorBrandForeground1,
- },
- pending: {
- width: "6px",
- height: "6px",
- borderRadius: "50%",
- backgroundColor: tokens.colorNeutralStroke1,
- },
- weight: {
- color: tokens.colorNeutralForeground3,
- fontSize: tokens.fontSizeBase100,
- fontWeight: tokens.fontWeightRegular,
- whiteSpace: "nowrap",
- },
- errorIcon: {
- color: tokens.colorPaletteRedForeground1,
- },
- message: {
- margin: `${tokens.spacingVerticalS} 0 0`,
- color: tokens.colorNeutralForeground2,
- fontSize: tokens.fontSizeBase300,
- lineHeight: tokens.lineHeightBase400,
- overflowWrap: "anywhere",
- },
- actions: {
- display: "flex",
- justifyContent: "flex-end",
- gap: tokens.spacingHorizontalS,
- marginTop: tokens.spacingVerticalL,
- },
-});
-
const InteractiveLabelerLoader = ({ loadState, error, onRetry, onGoBack }) => {
- const styles = useStyles();
- if (!loadState && !error) return null;
-
- // The failure overlay is deliberately part of the labeler rather than a
- // transient dialog: dismissing the dialog (Escape, backdrop) would
- // otherwise leave a disposed map behind with no way to start over.
- if (error) {
- return (
-
-
-
- Interactive labeler
-
-
Could not load the labeler
-
{error}
-
- {onGoBack && Go back }
- {onRetry && (
-
- Retry
-
- )}
-
-
-
- );
- }
-
- const activeStep = Math.min(loadState.step, LOAD_STEPS.length - 1);
-
return (
-
-
-
Interactive labeler
-
Preparing your workspace
-
- {LOAD_STEPS[activeStep]}
-
- Step {activeStep + 1} of {LOAD_STEPS.length}
-
-
-
-
- {LOAD_STEPS.map((label, index) => {
- const isDone = index < activeStep;
- const isActive = index === activeStep;
- const weight =
- isActive && loadState.loaded
- ? loadState.total
- ? `${formatBytes(loadState.loaded)} of ${formatBytes(loadState.total)}`
- : `${formatBytes(loadState.loaded)} loaded`
- : "";
- return (
-
-
- {isDone ? (
-
- ) : isActive ? (
-
- ) : (
-
- )}
-
- {label}
- {weight && {weight} }
-
- );
- })}
-
-
-
+
);
};
diff --git a/ui/src/Components/InteractiveLabeler/loadInteractiveArtifacts.js b/ui/src/Components/InteractiveLabeler/loadInteractiveArtifacts.js
index add215f3..2e18a2f6 100644
--- a/ui/src/Components/InteractiveLabeler/loadInteractiveArtifacts.js
+++ b/ui/src/Components/InteractiveLabeler/loadInteractiveArtifacts.js
@@ -16,14 +16,17 @@ export async function loadInteractiveArtifacts({
}
};
+ const pmtilesPromise = invoke(loadPmtiles);
+ const sidecarPromise = invoke(loadSidecar);
try {
const [pmtilesHeader, sidecar] = await Promise.all([
- invoke(loadPmtiles),
- invoke(loadSidecar),
+ pmtilesPromise,
+ sidecarPromise,
]);
return { pmtilesHeader, sidecar };
} catch (error) {
controller.abort();
+ await Promise.allSettled([pmtilesPromise, sidecarPromise]);
throw error;
} finally {
signal?.removeEventListener("abort", abort);
diff --git a/ui/src/Components/InteractiveLabeler/loadInteractiveArtifacts.test.js b/ui/src/Components/InteractiveLabeler/loadInteractiveArtifacts.test.js
index c3af0e88..f1213eaa 100644
--- a/ui/src/Components/InteractiveLabeler/loadInteractiveArtifacts.test.js
+++ b/ui/src/Components/InteractiveLabeler/loadInteractiveArtifacts.test.js
@@ -87,4 +87,30 @@ test("aborts the sibling transfer when a required artifact fails", async () => {
);
assert.equal(sidecarAborted, true);
+});
+
+test("waits for the aborted sibling to settle before rejecting", async () => {
+ let settleSidecar;
+ const events = [];
+ const loading = loadInteractiveArtifacts({
+ loadPmtiles: async () => {
+ throw new Error("tiles unavailable");
+ },
+ loadSidecar: (signal) =>
+ new Promise((resolve, reject) => {
+ signal.addEventListener("abort", () => {
+ events.push("aborted");
+ settleSidecar = () => {
+ events.push("settled");
+ reject(new DOMException("Aborted", "AbortError"));
+ };
+ });
+ }),
+ });
+
+ await new Promise((resolve) => setImmediate(resolve));
+ assert.deepEqual(events, ["aborted"]);
+ settleSidecar();
+ await assert.rejects(loading, /tiles unavailable/);
+ assert.deepEqual(events, ["aborted", "settled"]);
});
\ No newline at end of file
diff --git a/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.js b/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.js
index f8fd7ef2..cebeea78 100644
--- a/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.js
+++ b/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.js
@@ -3,17 +3,24 @@ export async function loadInteractiveMetadata({
projectId,
imageLayerId,
modelId,
+ signal,
}) {
+ const options = { signal };
const [layerResult, modelsResult, labelsResult] = await Promise.allSettled([
get(
`GetLayerLabelingToolData?projectId=${projectId}` +
- `&imageLayerId=${imageLayerId}`
+ `&imageLayerId=${imageLayerId}`,
+ options
),
get(
`GetLayerModelsDetails?projectId=${projectId}` +
- `&imageLayerId=${imageLayerId}`
+ `&imageLayerId=${imageLayerId}`,
+ options
+ ),
+ get(
+ `GetInteractiveLabels?projectId=${projectId}&modelId=${modelId}`,
+ options
),
- get(`GetInteractiveLabels?projectId=${projectId}&modelId=${modelId}`),
]);
if (modelsResult.status === "rejected") throw modelsResult.reason;
diff --git a/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.test.js b/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.test.js
index b6f6ce32..1f3659e2 100644
--- a/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.test.js
+++ b/ui/src/Components/InteractiveLabeler/loadInteractiveMetadata.test.js
@@ -17,17 +17,22 @@ function deferred() {
test("starts imagery, model, and saved-label requests concurrently", async () => {
const requests = [deferred(), deferred(), deferred()];
const calls = [];
+ const controller = new AbortController();
const loading = loadInteractiveMetadata({
projectId: "project-1",
imageLayerId: "layer-1",
modelId: "42",
- get: (endpoint) => {
- calls.push(endpoint);
+ signal: controller.signal,
+ get: (endpoint, options) => {
+ calls.push({ endpoint, options });
return requests[calls.length - 1].promise;
},
});
assert.equal(calls.length, 3);
+ calls.forEach((call) => {
+ assert.equal(call.options.signal, controller.signal);
+ });
requests[0].resolve({ imagery: {} });
requests[1].resolve([{ modelId: "42", pmtilesUrl: "tiles" }]);
requests[2].resolve({ labels: { building: { label: 1 } } });
diff --git a/ui/src/Components/LabelingTool/LabelingTool.jsx b/ui/src/Components/LabelingTool/LabelingTool.jsx
index 251c428b..d9e0f0a2 100644
--- a/ui/src/Components/LabelingTool/LabelingTool.jsx
+++ b/ui/src/Components/LabelingTool/LabelingTool.jsx
@@ -1,14 +1,12 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import { useEffect, useRef, useState, useContext } from "react";
-import { apiGet } from "../../util/api";
import {
loadImagery,
parsePrimaryClasses,
updateDrawingLayerStyles,
createShape,
loadStudyArea,
- centrateMap,
} from "./LabelingToolHelper.js";
import { getAzureMapsAuthOptions, isAzureMapsPlaceholder } from "../../util/azureMapsAuth";
import { useParams } from "react-router-dom";
@@ -19,9 +17,22 @@ import PropType from "prop-types";
import { AppContext } from "../../AppContext.jsx";
import { useDrawingUndoRedo } from "./UndoRedo.jsx";
import { splitShape } from "./SplitShape.jsx";
+import { waitForMapReady } from "../InteractiveLabeler/interactiveLabelerLoading.js";
+import {
+ getWorkspaceCameraOptions,
+ getWorkspaceBounds,
+ waitForMapIdle,
+} from "./labelingToolLoading.js";
import "../../assets/css/drawingToolbar.css";
-const LabelingTool = ({ setModalComponent }) => {
+const LabelingTool = ({
+ setModalComponent,
+ workspace,
+ signal,
+ onLoadStep,
+ onReady,
+ onError,
+}) => {
const { projectId, imageLayerId } = useParams();
const {
@@ -32,6 +43,7 @@ const LabelingTool = ({ setModalComponent }) => {
appParams,
} = useContext(AppContext);
+ const mapContainerRef = useRef(null);
const mapRef = useRef(null);
const [drawingManager, setDrawingManager] = useState(null);
const [selectedPrimaryClass, setSelectedPrimaryClass] = useState(0);
@@ -49,13 +61,23 @@ const LabelingTool = ({ setModalComponent }) => {
const { undo, redo } = useDrawingUndoRedo(drawingManager, mapRef);
useEffect(() => {
+ let active = true;
const initializeMap = async () => {
- if (window.atlas) {
- setIsLoading(true);
+ try {
+ signal?.throwIfAborted();
+ if (!window.atlas) throw new Error("Azure Maps is unavailable.");
// eslint-disable-next-line react-hooks/immutability
- await createMap();
+ await createMap(signal);
+ if (!active) return;
setIsMapReady(true);
- setIsLoading(false);
+ onReady();
+ } catch (error) {
+ if (!active || error.name === "AbortError") return;
+ mapRef.current?.dispose();
+ mapRef.current = null;
+ setDrawingManager(null);
+ setIsMapReady(false);
+ onError("The labeling map could not be prepared.");
}
};
@@ -63,6 +85,11 @@ const LabelingTool = ({ setModalComponent }) => {
//On component dismount
return () => {
+ active = false;
+ if (mapRef.current) {
+ mapRef.current.dispose();
+ mapRef.current = null;
+ }
initCurrentTour(null);
setAppHeaderRightButtons([]);
setModalComponent(null);
@@ -71,7 +98,7 @@ const LabelingTool = ({ setModalComponent }) => {
}, []);
useEffect(() => {
- if (!mapRef || !drawingManager) return;
+ if (!mapRef.current || !drawingManager) return;
setDrawingCount(drawingManager.source.shapes.length);
@@ -131,21 +158,21 @@ const LabelingTool = ({ setModalComponent }) => {
};
// Add all the handlers
- mapRef.current.events.add("drawingchanged", drawingManager, handleDrawingChanged);
- mapRef.current.events.add("drawingmodechanged", drawingManager, handleDrawingModeChanged);
- mapRef.current.events.add("drawingstarted", drawingManager, handleDrawingStarted);
- mapRef.current.events.add("drawingerased", drawingManager, handleDrawingErased);
- mapRef.current.events.add("drawingcomplete", drawingManager, handleDrawingComplete);
+ const map = mapRef.current;
+ map.events.add("drawingchanged", drawingManager, handleDrawingChanged);
+ map.events.add("drawingmodechanged", drawingManager, handleDrawingModeChanged);
+ map.events.add("drawingstarted", drawingManager, handleDrawingStarted);
+ map.events.add("drawingerased", drawingManager, handleDrawingErased);
+ map.events.add("drawingcomplete", drawingManager, handleDrawingComplete);
// Handler cleanup
return () => {
initGuidedTourState("labelingToolGuide", appParams.guidedTourProperties);
- if (!mapRef.current) return;
- mapRef.current.events.remove("drawingchanged", drawingManager, handleDrawingChanged);
- mapRef.current.events.remove("drawingmodechanged", drawingManager, handleDrawingModeChanged);
- mapRef.current.events.remove("drawingstarted", drawingManager, handleDrawingStarted);
- mapRef.current.events.remove("drawingerased", drawingManager, handleDrawingErased);
- mapRef.current.events.remove("drawingcomplete", drawingManager, handleDrawingComplete);
+ map.events.remove("drawingchanged", drawingManager, handleDrawingChanged);
+ map.events.remove("drawingmodechanged", drawingManager, handleDrawingModeChanged);
+ map.events.remove("drawingstarted", drawingManager, handleDrawingStarted);
+ map.events.remove("drawingerased", drawingManager, handleDrawingErased);
+ map.events.remove("drawingcomplete", drawingManager, handleDrawingComplete);
};
}, [
appParams.guidedTourProperties,
@@ -171,87 +198,87 @@ const LabelingTool = ({ setModalComponent }) => {
}, [drawingManager]);
- async function createMap() {
- labelingToolDataRef.current = await apiGet(
- "GetLayerLabelingToolData?projectId=" +
- projectId +
- "&imageLayerId=" +
- imageLayerId
- );
- const projectDetails = await apiGet(
- "GetProjectDetails?projectId=" + projectId
- );
- setEventTypes(projectDetails.eventTypes || []);
- setImageLayer(
- projectDetails.imageLayer?.find(
- (layer) => layer.imageLayerId === imageLayerId
- ) || null
- );
-
- const map = new window.atlas.Map(mapRef.current, {
- center: [0, 0],
+ async function createMap(abortSignal) {
+ const labelProject = workspace.labelProject;
+ labelingToolDataRef.current = labelProject;
+ setEventTypes(workspace.eventTypes || []);
+ setImageLayer(workspace.imageLayer || null);
+ const workspaceBounds = getWorkspaceBounds(window.atlas, labelProject);
+ const map = new window.atlas.Map(mapContainerRef.current, {
preserveDrawingBuffer: true,
- zoom: 3,
maxPitch: 0,
pitch: 0,
style: isAzureMapsPlaceholder ? "blank" : "grayscale_light",
language: "en-US",
authOptions: getAzureMapsAuthOptions(),
+ ...(workspaceBounds
+ ? { bounds: workspaceBounds, padding: 24 }
+ : { center: [0, 0], zoom: 3 }),
});
+ mapRef.current = map;
+ let idlePromise = null;
+ const idleController = new AbortController();
+ const abortIdle = () => idleController.abort();
+ abortSignal.addEventListener("abort", abortIdle, { once: true });
+
+ try {
+ await waitForMapReady(map, {
+ signal: abortSignal,
+ onReady: () => {
+ idlePromise = waitForMapIdle(map, {
+ signal: idleController.signal,
+ });
+ map.setUserInteraction({
+ dragRotateInteraction: false,
+ scrollZoomInteraction: true,
+ pinchZoomInteraction: true,
+ pinchRotateInteraction: false,
+ });
+ map.controls.add(new window.atlas.control.ZoomControl(), {
+ position: "bottom-left",
+ });
+
+ loadImagery(
+ labelProject.imagery?.preEventTileUrl || "",
+ map,
+ preImageryRef,
+ "preEventImageryLayer",
+ false,
+ { allowFallback: !isAzureMapsPlaceholder }
+ );
+ loadImagery(
+ labelProject.imagery?.postEventTileUrl || "",
+ map,
+ postImageryRef,
+ "postEventImageryLayer",
+ true,
+ {
+ allowFallback: !isAzureMapsPlaceholder,
+ required: true,
+ }
+ );
+
+ const drawingManagerTemp =
+ new window.atlas.drawing.DrawingManager(map, {});
+ const primaryClasses = workspace.primaryClasses || [];
+ drawingManagerTemp.source.add(labelProject.labels || []);
+ primaryClassesRef.current = parsePrimaryClasses(primaryClasses);
+ setPrimaryClasses(primaryClassesRef.current);
+ setSelectedPrimaryClass(primaryClassesRef.current[0]?.key || 0);
+ loadStudyArea(map, labelProject);
+ setDrawingManager(drawingManagerTemp);
+
+ map.setCamera(getWorkspaceCameraOptions(workspaceBounds));
- map.events.add("ready", async function () {
- // Avoid map rotation
- map.setUserInteraction({
- dragRotateInteraction: false,
- scrollZoomInteraction: true,
- pinchZoomInteraction: true,
- pinchRotateInteraction: false,
- });
-
- map.controls.add(new window.atlas.control.ZoomControl(), {
- position: "bottom-left",
- });
-
- map.setCamera({
- bearing: 0,
+ },
});
-
- loadImagery(
- labelingToolDataRef.current.imagery.preEventTileUrl,
- map,
- preImageryRef,
- "preEventImageryLayer",
- false
- );
-
- loadImagery(
- labelingToolDataRef.current.imagery.postEventTileUrl,
- map,
- postImageryRef,
- "postEventImageryLayer",
- true
- );
-
- var drawingManagerTemp = new window.atlas.drawing.DrawingManager(map, {});
-
-
- const primaryClasses = projectDetails.primaryClasses;
- drawingManagerTemp.source.add(
- labelingToolDataRef.current.labels != null
- ? labelingToolDataRef.current.labels
- : []
+ onLoadStep(2);
+ if (!idlePromise) throw new Error("Azure Maps did not become ready.");
+ await idlePromise;
+ initGuidedTourState(
+ "labelingToolGuide",
+ appParams.guidedTourProperties
);
-
- primaryClassesRef.current = parsePrimaryClasses(primaryClasses);
- setPrimaryClasses(primaryClassesRef.current);
- setSelectedPrimaryClass(primaryClassesRef.current[0].key);
-
-
- const bbox = loadStudyArea(map, labelingToolDataRef.current);
- setDrawingManager(drawingManagerTemp);
- centrateMap(bbox, map, 2500);
-
- initGuidedTourState("labelingToolGuide", appParams.guidedTourProperties);
initCurrentTour("labelingToolGuide");
setAppHeaderRightButtons([
{
@@ -267,16 +294,19 @@ const LabelingTool = ({ setModalComponent }) => {
),
},
]);
- });
-
-
- mapRef.current = map;
+ } catch (error) {
+ idleController.abort();
+ await idlePromise?.catch(() => {});
+ throw error;
+ } finally {
+ abortSignal.removeEventListener("abort", abortIdle);
+ }
}
return (
<>
{
LabelingTool.propTypes = {
setModalComponent: PropType.func.isRequired,
+ workspace: PropType.shape({
+ labelProject: PropType.object.isRequired,
+ imageLayer: PropType.object.isRequired,
+ eventTypes: PropType.array,
+ primaryClasses: PropType.array,
+ }).isRequired,
+ signal: PropType.shape({
+ aborted: PropType.bool,
+ addEventListener: PropType.func,
+ removeEventListener: PropType.func,
+ throwIfAborted: PropType.func,
+ }).isRequired,
+ onLoadStep: PropType.func.isRequired,
+ onReady: PropType.func.isRequired,
+ onError: PropType.func.isRequired,
};
export default LabelingTool;
diff --git a/ui/src/Components/LabelingTool/LabelingToolHelper.js b/ui/src/Components/LabelingTool/LabelingToolHelper.js
index 960e41dd..2c7520cd 100644
--- a/ui/src/Components/LabelingTool/LabelingToolHelper.js
+++ b/ui/src/Components/LabelingTool/LabelingToolHelper.js
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import { apiPut } from "../../util/api";
import settings from "../../assets/json/settings.json";
-import { getAzureMapsAuthOptions } from "../../util/azureMapsAuth";
+import { resolveImageryTileUrl } from "./labelingToolLoading";
export function createShape(drawingManager, selectedPrimaryClass, setDrawingCount) {
@@ -81,25 +81,31 @@ export const layerTypeOptions = [
];
-export function loadImagery(tileUrl, map, imageryRef, customId, isVisible) {
-
- var tempTileUrlPath = tileUrl;
- if (tempTileUrlPath === "") {
- tempTileUrlPath = `https://atlas.microsoft.com/map/tile?api-version=2.1&tilesetId=microsoft.imagery&zoom={z}&x={x}&y={y}`;
+export function loadImagery(
+ tileUrl,
+ map,
+ imageryRef,
+ customId,
+ isVisible,
+ { allowFallback = true, required = false } = {}
+) {
+ const tempTileUrlPath = resolveImageryTileUrl(tileUrl, {
+ allowFallback,
+ required,
+ });
+ if (!tempTileUrlPath) {
+ imageryRef.current = null;
+ return null;
}
-
- imageryRef.current = new window.atlas.layer.TileLayer({
+ const layer = new window.atlas.layer.TileLayer({
tileUrl: tempTileUrlPath,
});
-
- try {
- imageryRef.current.setOptions({ visible: isVisible });
- imageryRef.current.customId = customId;
- map.layers.add(imageryRef.current);
- } catch (error) {
- console.error("Error loading imagery layer:", error);
- }
+ layer.setOptions({ visible: isVisible });
+ layer.customId = customId;
+ map.layers.add(layer);
+ imageryRef.current = layer;
+ return layer;
}
export function centrateMap(bbox, map, duration = 2500) {
@@ -155,7 +161,7 @@ export async function saveLabels(drawingManager, labelingToolDataRef, setIsLoadi
setHasUnsavedChanges(false);
setIsLoading(false);
return (true);
- } catch (error) {
+ } catch {
setIsLoading(false);
return (false);
}
diff --git a/ui/src/Components/LabelingTool/LabelingToolRoute.jsx b/ui/src/Components/LabelingTool/LabelingToolRoute.jsx
new file mode 100644
index 00000000..f0f12567
--- /dev/null
+++ b/ui/src/Components/LabelingTool/LabelingToolRoute.jsx
@@ -0,0 +1,112 @@
+import { useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import PropTypes from "prop-types";
+
+import { apiGet } from "../../util/api";
+import { loadAzureMaps } from "../../util/azureMapsLoader";
+import WorkspaceLoader from "../WorkspaceLoader";
+import { loadLabelingRoute } from "./loadLabelingRoute";
+
+const LOAD_STEPS = [
+ "Loading workspace data and map tools",
+ "Preparing imagery and labels",
+ "Rendering map and drawing tools",
+];
+
+const LabelingToolRoute = ({ setModalComponent }) => {
+ const { projectId, imageLayerId } = useParams();
+ const navigate = useNavigate();
+ const [attempt, setAttempt] = useState(0);
+ const [loadedRoute, setLoadedRoute] = useState(null);
+ const [loadState, setLoadState] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ const loadKey = `${projectId}:${imageLayerId}:${attempt}`;
+
+ useEffect(() => {
+ let active = true;
+ const controller = new AbortController();
+ const requestKey = loadKey;
+
+ loadLabelingRoute({
+ importRoute: () => import("./LabelingTool"),
+ loadMaps: () =>
+ loadAzureMaps(document, { drawing: true, swipe: false }),
+ get: apiGet,
+ projectId,
+ imageLayerId,
+ signal: controller.signal,
+ })
+ .then((result) => {
+ if (!active) return;
+ setLoadState({
+ key: requestKey,
+ value: { step: 1, loaded: null, total: null },
+ });
+ setLoadedRoute({
+ ...result,
+ key: requestKey,
+ signal: controller.signal,
+ });
+ })
+ .catch((error) => {
+ if (!active || error.name === "AbortError") return;
+ controller.abort();
+ setLoadError({
+ key: requestKey,
+ value: "The labeling workspace could not be loaded.",
+ });
+ });
+
+ return () => {
+ active = false;
+ controller.abort();
+ };
+ }, [imageLayerId, loadKey, projectId]);
+
+ const currentRoute = loadedRoute?.key === loadKey ? loadedRoute : null;
+ const Component = currentRoute?.Component;
+ const currentError = loadError?.key === loadKey ? loadError.value : "";
+ const currentLoadState = currentError
+ ? null
+ : loadState?.key === loadKey
+ ? loadState.value
+ : { step: 0, loaded: null, total: null };
+ return (
+
+ navigate(-1)}
+ onRetry={() => setAttempt((value) => value + 1)}
+ />
+ {Component && (
+
+ setLoadState({
+ key: loadKey,
+ value: { step, loaded: null, total: null },
+ })
+ }
+ onReady={() => setLoadState({ key: loadKey, value: null })}
+ onError={(message) => {
+ setLoadError({ key: loadKey, value: message });
+ }}
+ />
+ )}
+
+ );
+};
+
+LabelingToolRoute.propTypes = {
+ setModalComponent: PropTypes.func.isRequired,
+};
+
+export default LabelingToolRoute;
diff --git a/ui/src/Components/LabelingTool/labelingToolLoading.js b/ui/src/Components/LabelingTool/labelingToolLoading.js
new file mode 100644
index 00000000..42ba3689
--- /dev/null
+++ b/ui/src/Components/LabelingTool/labelingToolLoading.js
@@ -0,0 +1,77 @@
+export const MAP_IDLE_TIMEOUT_MS = 30000;
+export const AZURE_MAPS_SATELLITE_TILES =
+ "https://atlas.microsoft.com/map/tile?api-version=2.1&tilesetId=microsoft.imagery&zoom={z}&x={x}&y={y}";
+
+function abortError() {
+ const error = new Error("Labeling workspace initialization was cancelled.");
+ error.name = "AbortError";
+ return error;
+}
+
+export function getWorkspaceBounds(atlas, labelProject) {
+ const features = labelProject?.features || [];
+ if (!features.length) return null;
+ return atlas.data.BoundingBox.fromData({
+ type: "FeatureCollection",
+ features,
+ });
+}
+
+export function getWorkspaceCameraOptions(bounds) {
+ return {
+ ...(bounds ? { bounds, padding: 24 } : {}),
+ bearing: 0,
+ pitch: 0,
+ duration: 0,
+ };
+}
+
+export function resolveImageryTileUrl(
+ tileUrl,
+ { allowFallback = true, required = false } = {}
+) {
+ if (tileUrl) return tileUrl;
+ if (allowFallback) return AZURE_MAPS_SATELLITE_TILES;
+ if (required) {
+ throw new Error("Required post-event imagery is unavailable.");
+ }
+ return null;
+}
+
+export function waitForMapIdle(
+ map,
+ { signal, timeoutMs = MAP_IDLE_TIMEOUT_MS } = {}
+) {
+ return new Promise((resolve, reject) => {
+ let timeoutId;
+ const cleanup = () => {
+ clearTimeout(timeoutId);
+ map.events.remove?.("idle", handleIdle);
+ map.events.remove?.("error", handleError);
+ signal?.removeEventListener("abort", handleAbort);
+ };
+ const settle = (callback, value) => {
+ cleanup();
+ callback(value);
+ };
+ const handleIdle = () => settle(resolve);
+ const handleError = (event) => {
+ const message =
+ event?.error?.message || event?.message || "Azure Maps failed to load.";
+ settle(reject, new Error(message));
+ };
+ const handleAbort = () => settle(reject, abortError());
+
+ if (signal?.aborted) {
+ reject(abortError());
+ return;
+ }
+ map.events.add("idle", handleIdle);
+ map.events.add("error", handleError);
+ signal?.addEventListener("abort", handleAbort, { once: true });
+ timeoutId = setTimeout(
+ () => settle(reject, new Error("Azure Maps timed out while rendering.")),
+ timeoutMs
+ );
+ });
+}
diff --git a/ui/src/Components/LabelingTool/labelingToolLoading.test.js b/ui/src/Components/LabelingTool/labelingToolLoading.test.js
new file mode 100644
index 00000000..2ab2e8e3
--- /dev/null
+++ b/ui/src/Components/LabelingTool/labelingToolLoading.test.js
@@ -0,0 +1,104 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ AZURE_MAPS_SATELLITE_TILES,
+ getWorkspaceCameraOptions,
+ getWorkspaceBounds,
+ resolveImageryTileUrl,
+ waitForMapIdle,
+} from "./labelingToolLoading.js";
+
+function eventTarget() {
+ const listeners = new Map();
+ return {
+ add(name, callback) {
+ listeners.set(name, callback);
+ },
+ remove(name) {
+ listeners.delete(name);
+ },
+ emit(name, value) {
+ listeners.get(name)?.(value);
+ },
+ has(name) {
+ return listeners.has(name);
+ },
+ };
+}
+
+test("getWorkspaceBounds uses only workspace features", () => {
+ const features = [{ type: "Feature", geometry: { type: "Point" } }];
+ const atlas = {
+ data: {
+ BoundingBox: {
+ fromData(value) {
+ return value;
+ },
+ },
+ },
+ };
+
+ const result = getWorkspaceBounds(atlas, { features });
+
+ assert.deepEqual(result, { type: "FeatureCollection", features });
+});
+
+test("getWorkspaceBounds returns null for an empty workspace", () => {
+ assert.equal(getWorkspaceBounds({}, { features: [] }), null);
+});
+
+test("getWorkspaceCameraOptions fits the AOI without animation", () => {
+ const bounds = [-120, 30, -119, 31];
+
+ assert.deepEqual(getWorkspaceCameraOptions(bounds), {
+ bounds,
+ padding: 24,
+ bearing: 0,
+ pitch: 0,
+ duration: 0,
+ });
+});
+
+test("resolveImageryTileUrl controls fallback and required imagery", () => {
+ assert.equal(resolveImageryTileUrl("https://tiles.test/{z}"), "https://tiles.test/{z}");
+ assert.equal(resolveImageryTileUrl(""), AZURE_MAPS_SATELLITE_TILES);
+ assert.equal(
+ resolveImageryTileUrl("", { allowFallback: false }),
+ null
+ );
+ assert.throws(
+ () =>
+ resolveImageryTileUrl("", {
+ allowFallback: false,
+ required: true,
+ }),
+ /Required post-event imagery/
+ );
+});
+
+test("waitForMapIdle resolves and removes listeners", async () => {
+ const events = eventTarget();
+ const loading = waitForMapIdle({ events }, { timeoutMs: 100 });
+
+ events.emit("idle");
+
+ await loading;
+ assert.equal(events.has("idle"), false);
+ assert.equal(events.has("error"), false);
+});
+
+test("waitForMapIdle aborts and removes listeners", async () => {
+ const events = eventTarget();
+ const controller = new AbortController();
+ const loading = waitForMapIdle(
+ { events },
+ { signal: controller.signal, timeoutMs: 100 }
+ );
+
+ controller.abort();
+
+ await assert.rejects(loading, (error) => error.name === "AbortError");
+ assert.equal(events.has("idle"), false);
+ assert.equal(events.has("error"), false);
+});
diff --git a/ui/src/Components/LabelingTool/loadLabelingRoute.js b/ui/src/Components/LabelingTool/loadLabelingRoute.js
new file mode 100644
index 00000000..471598f6
--- /dev/null
+++ b/ui/src/Components/LabelingTool/loadLabelingRoute.js
@@ -0,0 +1,18 @@
+export function loadLabelingRoute({
+ importRoute,
+ loadMaps,
+ get,
+ projectId,
+ imageLayerId,
+ signal,
+}) {
+ const query = new URLSearchParams({ projectId, imageLayerId });
+ return Promise.all([
+ importRoute(),
+ loadMaps(),
+ get(`GetLabelingWorkspace?${query}`, { signal }),
+ ]).then(([route, , workspace]) => ({
+ Component: route.default,
+ workspace,
+ }));
+}
diff --git a/ui/src/Components/LabelingTool/loadLabelingRoute.test.js b/ui/src/Components/LabelingTool/loadLabelingRoute.test.js
new file mode 100644
index 00000000..ad78a3e0
--- /dev/null
+++ b/ui/src/Components/LabelingTool/loadLabelingRoute.test.js
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { loadLabelingRoute } from "./loadLabelingRoute.js";
+
+function deferred() {
+ let resolve;
+ const promise = new Promise((resolvePromise) => {
+ resolve = resolvePromise;
+ });
+ return { promise, resolve };
+}
+
+test("starts route, Maps, and workspace requests concurrently", async () => {
+ const route = deferred();
+ const maps = deferred();
+ const workspace = deferred();
+ const calls = [];
+ const controller = new AbortController();
+
+ const loading = loadLabelingRoute({
+ importRoute: () => {
+ calls.push("route");
+ return route.promise;
+ },
+ loadMaps: () => {
+ calls.push("maps");
+ return maps.promise;
+ },
+ get: (endpoint, options) => {
+ calls.push({ endpoint, options });
+ return workspace.promise;
+ },
+ projectId: "project-1",
+ imageLayerId: "layer-1",
+ signal: controller.signal,
+ });
+
+ assert.deepEqual(calls.slice(0, 2), ["route", "maps"]);
+ assert.equal(
+ calls[2].endpoint,
+ "GetLabelingWorkspace?projectId=project-1&imageLayerId=layer-1"
+ );
+ assert.equal(calls[2].options.signal, controller.signal);
+
+ route.resolve({ default: "LabelingTool" });
+ maps.resolve();
+ workspace.resolve({ imageLayer: { imageLayerId: "layer-1" } });
+
+ assert.deepEqual(await loading, {
+ Component: "LabelingTool",
+ workspace: { imageLayer: { imageLayerId: "layer-1" } },
+ });
+});
diff --git a/ui/src/Components/MapRoute.jsx b/ui/src/Components/MapRoute.jsx
index dace44c4..a709ab07 100644
--- a/ui/src/Components/MapRoute.jsx
+++ b/ui/src/Components/MapRoute.jsx
@@ -1,25 +1,32 @@
import { Button, Spinner } from "@fluentui/react-components";
+import PropTypes from "prop-types";
import { useEffect, useState } from "react";
+import { useLocation } from "react-router-dom";
import { loadMapRoute } from "../util/azureMapsLoader";
-export const RouteLoading = () => (
+export const RouteLoading = ({ label = "Loading page" }) => (
-
+
);
+RouteLoading.propTypes = {
+ label: PropTypes.string,
+};
+
// eslint-disable-next-line react-refresh/only-export-components
-export function createMapRoute(importRoute) {
+export function createMapRoute(importRoute, loadMaps) {
const MapRoute = (props) => {
+ const location = useLocation();
const [attempt, setAttempt] = useState(0);
const [routeComponent, setRouteComponent] = useState(null);
const [loadError, setLoadError] = useState(false);
useEffect(() => {
let active = true;
- loadMapRoute(importRoute)()
+ loadMapRoute(importRoute, loadMaps)()
.then((route) => {
if (active) setRouteComponent(() => route.default);
})
@@ -53,7 +60,7 @@ export function createMapRoute(importRoute) {
if (!routeComponent) return
;
const Component = routeComponent;
- return
;
+ return
;
};
return MapRoute;
}
\ No newline at end of file
diff --git a/ui/src/Components/WorkspaceLoader.jsx b/ui/src/Components/WorkspaceLoader.jsx
new file mode 100644
index 00000000..256b1cf5
--- /dev/null
+++ b/ui/src/Components/WorkspaceLoader.jsx
@@ -0,0 +1,232 @@
+import PropTypes from "prop-types";
+import {
+ Button,
+ ProgressBar,
+ Spinner,
+ makeStyles,
+ tokens,
+} from "@fluentui/react-components";
+
+import { FluentIcon } from "../util/icons";
+import {
+ formatBytes,
+ getLoadProgress,
+} from "./InteractiveLabeler/interactiveLabelerLoading";
+
+const useStyles = makeStyles({
+ overlay: {
+ position: "absolute",
+ inset: 0,
+ zIndex: 2100,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: tokens.spacingHorizontalL,
+ backgroundColor: tokens.colorNeutralBackgroundAlpha2,
+ backdropFilter: "blur(3px)",
+ },
+ dialog: {
+ boxSizing: "border-box",
+ width: "min(440px, calc(100vw - 32px))",
+ padding: tokens.spacingHorizontalXXL,
+ borderRadius: tokens.borderRadiusLarge,
+ color: tokens.colorNeutralForeground1,
+ backgroundColor: tokens.colorNeutralBackground1,
+ border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`,
+ boxShadow: tokens.shadow64,
+ },
+ eyebrow: {
+ color: tokens.colorBrandForeground1,
+ fontSize: tokens.fontSizeBase100,
+ fontWeight: tokens.fontWeightSemibold,
+ textTransform: "uppercase",
+ },
+ title: {
+ margin: `${tokens.spacingVerticalXS} 0 ${tokens.spacingVerticalXS}`,
+ fontSize: tokens.fontSizeBase500,
+ lineHeight: tokens.lineHeightBase600,
+ fontWeight: tokens.fontWeightSemibold,
+ },
+ summary: {
+ display: "flex",
+ justifyContent: "space-between",
+ gap: tokens.spacingHorizontalM,
+ marginBottom: tokens.spacingVerticalS,
+ color: tokens.colorNeutralForeground3,
+ fontSize: tokens.fontSizeBase200,
+ },
+ steps: {
+ display: "grid",
+ gap: tokens.spacingVerticalS,
+ margin: `${tokens.spacingVerticalL} 0 0`,
+ padding: 0,
+ listStyle: "none",
+ },
+ step: {
+ display: "grid",
+ gridTemplateColumns: "20px minmax(0, 1fr) auto",
+ alignItems: "center",
+ gap: tokens.spacingHorizontalS,
+ minHeight: "24px",
+ color: tokens.colorNeutralForeground3,
+ fontSize: tokens.fontSizeBase200,
+ },
+ active: {
+ color: tokens.colorNeutralForeground1,
+ fontWeight: tokens.fontWeightSemibold,
+ },
+ done: {
+ color: tokens.colorNeutralForeground2,
+ },
+ icon: {
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ color: tokens.colorBrandForeground1,
+ },
+ pending: {
+ width: "6px",
+ height: "6px",
+ borderRadius: "50%",
+ backgroundColor: tokens.colorNeutralStroke1,
+ },
+ weight: {
+ color: tokens.colorNeutralForeground3,
+ fontSize: tokens.fontSizeBase100,
+ fontWeight: tokens.fontWeightRegular,
+ whiteSpace: "nowrap",
+ },
+ errorIcon: {
+ color: tokens.colorPaletteRedForeground1,
+ },
+ message: {
+ margin: `${tokens.spacingVerticalS} 0 0`,
+ color: tokens.colorNeutralForeground2,
+ fontSize: tokens.fontSizeBase300,
+ lineHeight: tokens.lineHeightBase400,
+ overflowWrap: "anywhere",
+ },
+ actions: {
+ display: "flex",
+ justifyContent: "flex-end",
+ gap: tokens.spacingHorizontalS,
+ marginTop: tokens.spacingVerticalL,
+ },
+});
+
+const WorkspaceLoader = ({
+ eyebrow,
+ title,
+ steps,
+ loadState,
+ error,
+ errorTitle,
+ onRetry,
+ onGoBack,
+}) => {
+ const styles = useStyles();
+ if (!loadState && !error) return null;
+
+ if (error) {
+ return (
+
+
+
+ {eyebrow}
+
+
{errorTitle}
+
{error}
+
+ {onGoBack && Go back }
+ {onRetry && (
+
+ Retry
+
+ )}
+
+
+
+ );
+ }
+
+ const activeStep = Math.min(loadState.step, steps.length - 1);
+ return (
+
+
+
{eyebrow}
+
{title}
+
+ {steps[activeStep]}
+
+ Step {activeStep + 1} of {steps.length}
+
+
+
+
+ {steps.map((label, index) => {
+ const isDone = index < activeStep;
+ const isActive = index === activeStep;
+ const weight =
+ isActive && loadState.loaded
+ ? loadState.total
+ ? `${formatBytes(loadState.loaded)} of ${formatBytes(loadState.total)}`
+ : `${formatBytes(loadState.loaded)} loaded`
+ : "";
+ return (
+
+
+ {isDone ? (
+
+ ) : isActive ? (
+
+ ) : (
+
+ )}
+
+ {label}
+ {weight && {weight} }
+
+ );
+ })}
+
+
+
+ );
+};
+
+WorkspaceLoader.propTypes = {
+ eyebrow: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ steps: PropTypes.arrayOf(PropTypes.string).isRequired,
+ loadState: PropTypes.shape({
+ step: PropTypes.number.isRequired,
+ loaded: PropTypes.number,
+ total: PropTypes.number,
+ }),
+ error: PropTypes.string,
+ errorTitle: PropTypes.string.isRequired,
+ onRetry: PropTypes.func,
+ onGoBack: PropTypes.func,
+};
+
+export default WorkspaceLoader;
diff --git a/ui/src/assets/css/style.css b/ui/src/assets/css/style.css
index c30fad0a..9ac068ed 100644
--- a/ui/src/assets/css/style.css
+++ b/ui/src/assets/css/style.css
@@ -289,6 +289,20 @@ body {
overflow: hidden;
}
+.app-body-shell--blocked .route-loading,
+.app-body-shell--blocked .dash-jobs-loading,
+.app-body-shell--blocked [data-route-loading="true"] {
+ visibility: hidden;
+}
+
+.labeling-workspace-route {
+ position: relative;
+ display: flex;
+ flex: 1 1 auto;
+ min-width: 0;
+ min-height: 0;
+}
+
.route-loading {
display: flex;
min-height: 240px;
diff --git a/ui/src/util/api.js b/ui/src/util/api.js
index 75412a55..0c54275e 100644
--- a/ui/src/util/api.js
+++ b/ui/src/util/api.js
@@ -47,8 +47,8 @@ export async function apiLogout(redirectPath = "/") {
}
}
-export async function apiGet(endpoint) {
- const response = await apiGetResponse(endpoint);
+export async function apiGet(endpoint, options = {}) {
+ const response = await apiGetResponse(endpoint, options);
return response.data;
}
diff --git a/ui/src/util/api.test.js b/ui/src/util/api.test.js
new file mode 100644
index 00000000..028695ad
--- /dev/null
+++ b/ui/src/util/api.test.js
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { apiGet } from "./api.js";
+
+test("apiGet forwards AbortSignal and request headers", async () => {
+ const originalFetch = globalThis.fetch;
+ const controller = new AbortController();
+ const options = {
+ signal: controller.signal,
+ headers: { "If-None-Match": '"etag"' },
+ };
+ let receivedUrl;
+ let receivedOptions;
+ globalThis.fetch = async (url, requestOptions) => {
+ receivedUrl = url;
+ receivedOptions = requestOptions;
+ return {
+ ok: true,
+ status: 200,
+ headers: { get: () => null },
+ json: async () => ({ value: "loaded" }),
+ };
+ };
+
+ try {
+ const result = await apiGet("GetSomething", options);
+
+ assert.equal(receivedUrl, "GetSomething");
+ assert.equal(receivedOptions, options);
+ assert.deepEqual(result, { value: "loaded" });
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
diff --git a/ui/src/util/azureMapsLoader.js b/ui/src/util/azureMapsLoader.js
index 37a94001..8f3277bc 100644
--- a/ui/src/util/azureMapsLoader.js
+++ b/ui/src/util/azureMapsLoader.js
@@ -11,7 +11,10 @@ const DRAWING_JS =
"https://atlas.microsoft.com/sdk/javascript/drawing/1/atlas-drawing.min.js";
const SWIPE_JS = "/assets/js/azure-maps-swipe-map.min.js";
-let loadPromise = null;
+let controlPromise = null;
+let drawingPromise = null;
+let swipePromise = null;
+const capabilityPromises = new Map();
function loadElement(documentRef, selector, createElement) {
const existing = documentRef.querySelector(selector);
@@ -67,25 +70,70 @@ function loadScript(documentRef, src) {
);
}
-export function loadAzureMaps(documentRef = document) {
- if (loadPromise) return loadPromise;
-
- loadPromise = Promise.all([
- loadStylesheet(documentRef, MAP_CONTROL_CSS),
- loadStylesheet(documentRef, DRAWING_CSS),
+function loadMapControl(documentRef, stylesheet = null) {
+ if (controlPromise) return controlPromise;
+ controlPromise = Promise.all([
+ stylesheet || loadStylesheet(documentRef, MAP_CONTROL_CSS),
loadScript(documentRef, MAP_CONTROL_JS),
+ ]).catch((error) => {
+ controlPromise = null;
+ throw error;
+ });
+ return controlPromise;
+}
+
+function loadDrawing(documentRef, stylesheet) {
+ if (drawingPromise) return drawingPromise;
+ drawingPromise = Promise.all([
+ loadMapControl(documentRef),
+ stylesheet || loadStylesheet(documentRef, DRAWING_CSS),
])
- .then(() =>
- Promise.all([
- loadScript(documentRef, DRAWING_JS),
- loadScript(documentRef, SWIPE_JS),
- ])
- )
+ .then(() => loadScript(documentRef, DRAWING_JS))
.catch((error) => {
- loadPromise = null;
+ drawingPromise = null;
throw error;
});
- return loadPromise;
+ return drawingPromise;
+}
+
+function loadSwipe(documentRef) {
+ if (swipePromise) return swipePromise;
+ swipePromise = loadMapControl(documentRef)
+ .then(() => loadScript(documentRef, SWIPE_JS))
+ .catch((error) => {
+ swipePromise = null;
+ throw error;
+ });
+ return swipePromise;
+}
+
+export function loadAzureMaps(
+ documentRef = document,
+ { drawing = true, swipe = true } = {}
+) {
+ const capabilityKey = `${drawing}:${swipe}`;
+ const existing = capabilityPromises.get(capabilityKey);
+ if (existing) return existing;
+
+ let drawingStylesheet = null;
+ if (!controlPromise) {
+ const controlStylesheet = loadStylesheet(documentRef, MAP_CONTROL_CSS);
+ if (drawing) {
+ drawingStylesheet = loadStylesheet(documentRef, DRAWING_CSS);
+ }
+ loadMapControl(documentRef, controlStylesheet);
+ }
+
+ const loading = Promise.all([
+ loadMapControl(documentRef),
+ ...(drawing ? [loadDrawing(documentRef, drawingStylesheet)] : []),
+ ...(swipe ? [loadSwipe(documentRef)] : []),
+ ]).catch((error) => {
+ capabilityPromises.delete(capabilityKey);
+ throw error;
+ });
+ capabilityPromises.set(capabilityKey, loading);
+ return loading;
}
export function loadMapRoute(importRoute, loadMaps = loadAzureMaps) {
@@ -94,5 +142,8 @@ export function loadMapRoute(importRoute, loadMaps = loadAzureMaps) {
}
export function resetAzureMapsLoaderForTests() {
- loadPromise = null;
+ controlPromise = null;
+ drawingPromise = null;
+ swipePromise = null;
+ capabilityPromises.clear();
}
\ No newline at end of file
diff --git a/ui/src/util/azureMapsLoader.test.js b/ui/src/util/azureMapsLoader.test.js
index a4cff4cf..bdfc50d9 100644
--- a/ui/src/util/azureMapsLoader.test.js
+++ b/ui/src/util/azureMapsLoader.test.js
@@ -65,15 +65,23 @@ test("loads styles, map control, drawing tools, and swipe in order", async () =>
await loadAzureMaps(documentRef);
+ const assets = documentRef.elements.map(
+ (element) => element.src || element.href
+ );
assert.deepEqual(
- documentRef.elements.map((element) => element.src || element.href),
+ assets.slice(0, 3),
[
"https://atlas.microsoft.com/sdk/javascript/mapcontrol/3/atlas.min.css",
"https://atlas.microsoft.com/sdk/javascript/drawing/1/atlas-drawing.min.css",
"https://atlas.microsoft.com/sdk/javascript/mapcontrol/3/atlas.min.js",
+ ]
+ );
+ assert.deepEqual(
+ new Set(assets.slice(3)),
+ new Set([
"https://atlas.microsoft.com/sdk/javascript/drawing/1/atlas-drawing.min.js",
"/assets/js/azure-maps-swipe-map.min.js",
- ]
+ ])
);
});
@@ -96,17 +104,65 @@ test("loads independent map assets in two concurrent phases", async () => {
);
initialAssets.forEach((asset) => documentRef.dispatchAsset(asset));
- await Promise.resolve();
- await Promise.resolve();
- assert.deepEqual(
- documentRef.elements.map((element) => element.src || element.href),
- [...initialAssets, ...dependentAssets]
+ await new Promise((resolve) => setImmediate(resolve));
+ const assets = documentRef.elements.map(
+ (element) => element.src || element.href
);
+ assert.deepEqual(assets.slice(0, 3), initialAssets);
+ assert.deepEqual(new Set(assets.slice(3)), new Set(dependentAssets));
dependentAssets.forEach((asset) => documentRef.dispatchAsset(asset));
await loading;
});
+test("loads drawing without the unused swipe extension", async () => {
+ const documentRef = fakeDocument();
+
+ await loadAzureMaps(documentRef, { drawing: true, swipe: false });
+
+ assert.deepEqual(
+ documentRef.elements.map((element) => element.src || element.href),
+ [
+ "https://atlas.microsoft.com/sdk/javascript/mapcontrol/3/atlas.min.css",
+ "https://atlas.microsoft.com/sdk/javascript/drawing/1/atlas-drawing.min.css",
+ "https://atlas.microsoft.com/sdk/javascript/mapcontrol/3/atlas.min.js",
+ "https://atlas.microsoft.com/sdk/javascript/drawing/1/atlas-drawing.min.js",
+ ]
+ );
+});
+
+test("loads map control without drawing or swipe extensions", async () => {
+ const documentRef = fakeDocument();
+
+ await loadAzureMaps(documentRef, { drawing: false, swipe: false });
+
+ assert.deepEqual(
+ documentRef.elements.map((element) => element.src || element.href),
+ [
+ "https://atlas.microsoft.com/sdk/javascript/mapcontrol/3/atlas.min.css",
+ "https://atlas.microsoft.com/sdk/javascript/mapcontrol/3/atlas.min.js",
+ ]
+ );
+});
+
+test("drawing stylesheet failure does not fail a map-only caller", async () => {
+ const failedAsset =
+ "https://atlas.microsoft.com/sdk/javascript/drawing/1/atlas-drawing.min.css";
+ const documentRef = fakeDocument({ failOnce: failedAsset });
+
+ const drawing = loadAzureMaps(documentRef, {
+ drawing: true,
+ swipe: false,
+ });
+ const mapOnly = loadAzureMaps(documentRef, {
+ drawing: false,
+ swipe: false,
+ });
+
+ await assert.rejects(drawing, /Unable to load/);
+ await mapOnly;
+});
+
test("starts the route import while Azure Maps is loading", async () => {
let resolveMaps;
let resolveRoute;
From cdba8986c081be5ffe5cc48a7c1fd06d811a009c Mon Sep 17 00:00:00 2001
From: prbatero <42007693+prbatero@users.noreply.github.com>
Date: Thu, 3 Sep 2026 16:19:22 -0400
Subject: [PATCH 7/7] test(perf): cover interrupted route loading
Extend the app-wide performance contract for owned loading state, Labeling Workspace, and Active Jobs. Measure staged labeling shell readiness separately and treat only expected navigation aborts as non-failures.
---
.../perf-app-wide-loading/data-model.md | 48 +++++++++++++++++++
spec/features/perf-app-wide-loading/design.md | 46 ++++++++++++++++++
.../perf-app-wide-loading/impact-analysis.md | 4 ++
spec/features/perf-app-wide-loading/plan.md | 7 +++
.../features/perf-app-wide-loading/results.md | 38 +++++++++++----
.../features/perf-app-wide-loading/rollout.md | 2 +
.../perf-app-wide-loading/test-plan.md | 11 +++++
.../tools/request_failure.cjs | 10 ++++
.../tools/request_failure.test.cjs | 18 +++++++
.../tools/route_matrix.cjs | 9 +++-
.../perf-app-wide-loading/user-stories.md | 33 +++++++++++++
11 files changed, 215 insertions(+), 11 deletions(-)
create mode 100644 spec/features/perf-app-wide-loading/tools/request_failure.cjs
create mode 100644 spec/features/perf-app-wide-loading/tools/request_failure.test.cjs
diff --git a/spec/features/perf-app-wide-loading/data-model.md b/spec/features/perf-app-wide-loading/data-model.md
index ce77ff48..b0539b16 100644
--- a/spec/features/perf-app-wide-loading/data-model.md
+++ b/spec/features/perf-app-wide-loading/data-model.md
@@ -4,6 +4,8 @@
- [Persistent Data](#persistent-data)
- [Bootstrap Response](#bootstrap-response)
+- [Labeling Workspace Response](#labeling-workspace-response)
+- [Active Jobs Response](#active-jobs-response)
- [Cache Keys](#cache-keys)
- [Migration](#migration)
@@ -30,12 +32,58 @@ introduced. Existing user ACL and published-dataset records remain compatible.
}
```
+## Labeling Workspace Response
+
+```json
+{
+ "labelProject": {},
+ "imageLayer": {
+ "imageLayerId": "string",
+ "name": "string",
+ "sourceTypePostEvent": "string"
+ },
+ "eventTypes": [],
+ "primaryClasses": []
+}
+```
+
+The embedded records retain their existing field names. The response contains
+one image layer and one label project rather than a complete project view.
+
+## Active Jobs Response
+
+```json
+{
+ "jobs": [
+ {
+ "key": "training-42",
+ "kind": "Training",
+ "projectName": "Project",
+ "name": "Model",
+ "target": "/project/project-id/layer-id",
+ "indicator": {
+ "id": "ongoingTraining-42",
+ "currentStep": 2,
+ "totalSteps": 5,
+ "progressPct": 40,
+ "status": "Running",
+ "statusMessage": "",
+ "prefix": "Training",
+ "contextLabel": "Model: Model - Training"
+ }
+ }
+ ]
+}
+```
+
## Cache Keys
| Data | Key | TTL | Invalidation |
|---|---|---:|---|
| Published dataset page | Caller plus normalized page, size, project, target, status, search, sort | <=5 s | Publishing mutations |
| Browser ETag | Same normalized query | Response lifetime | New `200` or mutation |
+| Active jobs | Shared active-job representation | <=5 s | TTL; queue updates occur out of process |
+| Active Jobs browser ETag | One route-local value | Response lifetime | New `200` |
Authorization state is never stored in these caches.
diff --git a/spec/features/perf-app-wide-loading/design.md b/spec/features/perf-app-wide-loading/design.md
index 800cf24f..aac9a783 100644
--- a/spec/features/perf-app-wide-loading/design.md
+++ b/spec/features/perf-app-wide-loading/design.md
@@ -6,6 +6,9 @@
- [Session Bootstrap](#session-bootstrap)
- [Published Datasets](#published-datasets)
- [Route Loading](#route-loading)
+- [Labeling Workspace](#labeling-workspace)
+- [Active Jobs](#active-jobs)
+- [Cancellation and Loading Ownership](#cancellation-and-loading-ownership)
- [Security](#security)
- [Deferred Work](#deferred-work)
@@ -77,6 +80,48 @@ loading and videos use `preload="none"`.
Independent Home, create/edit, and validation requests run concurrently while
preserving required versus optional failure behavior.
+## Labeling Workspace
+
+### `GET /api/GetLabelingWorkspace`
+
+The route requires `projectId` and `imageLayerId`. It returns the one label
+project, target image layer, project event types, and primary classes required
+by the standard Labeling Tool. Project and image-layer reads overlap. The label
+project is loaded directly through the image layer's existing `labelProjectId`;
+legacy layers without a usable pointer fall back to one partition scan.
+
+The UI starts this request at the same time as the route-specific Azure Maps
+control and drawing assets. It displays one route-owned staged workspace loader
+until data, map readiness, drawing controls, and the first stable map frame are
+ready. The map starts at the workspace bounds without an animated camera flight
+and is disposed if navigation interrupts initialization.
+
+## Active Jobs
+
+### `GET /api/GetActiveJobs`
+
+The route returns a compact list of active imagery, training, and inference
+jobs. It reads the project summary once, loads only image-layer and model
+partitions for candidate projects, and excludes labels, validation records,
+artifacts, and terminal work. A short process-local single-flight cache bounds
+repeat work; ETags support empty `304` responses.
+
+The Dashboard makes one conditional request instead of one
+`GetProjectDetails` request per project. Polls run only while visible, never
+overlap, and abort on route unmount. Dashboard content does not wait for the
+optional model catalog or active-jobs widget.
+
+## Cancellation and Loading Ownership
+
+Route initialization uses route-local loading state. The global blocking
+overlay remains reserved for explicit user actions such as save, delete, and
+publish. A Suspense fallback is suppressed while that blocking overlay is
+visible so only one page-level status surface is exposed.
+
+GET helpers accept an `AbortSignal`. Dashboard, active-job, and Labeling Tool
+requests abort when their owning route unmounts. Late completions cannot clear
+another route's loading state or mutate an unmounted component.
+
## Security
- Identity comes only from the decoded SWA principal; no user ID is accepted
@@ -86,6 +131,7 @@ preserving required versus optional failure behavior.
access.
- Stable sessions do not write user state or call the management plane.
- Caches store data representations, not authorization decisions.
+- Both additive read routes require an active ACL-backed application role.
- Development fallback remains restricted to `DEVELOPMENT_MODE`.
## Deferred Work
diff --git a/spec/features/perf-app-wide-loading/impact-analysis.md b/spec/features/perf-app-wide-loading/impact-analysis.md
index c54cbf97..9c75e95c 100644
--- a/spec/features/perf-app-wide-loading/impact-analysis.md
+++ b/spec/features/perf-app-wide-loading/impact-analysis.md
@@ -26,6 +26,10 @@
| Parallel loading changes error order | medium | Preserve required/optional request semantics in tests |
| Map assets race their prerequisites | medium | Load control before drawing/swipe and cover failures |
| Browser budget varies by network | medium | Record cold/warm desktop/mobile profiles and API timing |
+| Aborted requests are reported as failures | low | Preserve `AbortError` and suppress expected unmount errors |
+| Legacy layer has no valid label pointer | medium | Fall back to one partition scan without changing stored data |
+| Active-job cache briefly trails queue updates | low | TTL at most 5 seconds; never cache authorization |
+| Map is disposed while SDK events fire | medium | Guard teardown, remove listeners, and test interrupted startup |
## Security
diff --git a/spec/features/perf-app-wide-loading/plan.md b/spec/features/perf-app-wide-loading/plan.md
index bd217eef..751b837a 100644
--- a/spec/features/perf-app-wide-loading/plan.md
+++ b/spec/features/perf-app-wide-loading/plan.md
@@ -15,6 +15,9 @@
| 3 | UI bootstrap and independent request fan-out | `ui` | Slice 2 | US-001, US-003 | implemented |
| 4 | Published-dataset TTL/ETag cache and safe polling | `backend-dev`, `ui` | Slice 2 | US-002 | implemented |
| 5 | All-route deterministic performance matrix | `backend-dev`, `ui` | Slices 1-4 | US-004 | in-progress |
+| 6 | Route-local loading and abortable GET lifecycle | `ui` | Slice 3 | US-005, US-007 | implemented |
+| 7 | Labeling Workspace API and staged map initialization | `backend-dev`, `ui` | Slice 6 | US-006 | implemented |
+| 8 | Compact cached Active Jobs API and polling | `backend-dev`, `ui` | Slice 6 | US-007 | implemented |
Each slice is reviewable and testable independently. No infrastructure or
dependency changes are planned.
@@ -29,6 +32,10 @@ dependency changes are planned.
- [ ] Function runtime ingress is restricted to trusted SWA/APIM traffic.
- [ ] No route exceeds the three-second p95 acceptance limit without a
documented data-volume exception.
+- [x] Interrupted navigation aborts route-owned GET and map work.
+- [x] Standard Labeling Tool renders one staged loader through map readiness.
+- [x] Dashboard renders before optional catalog and active-job requests finish.
+- [x] Active Jobs uses one non-overlapping conditional request per poll.
## Agent Summary
diff --git a/spec/features/perf-app-wide-loading/results.md b/spec/features/perf-app-wide-loading/results.md
index be511811..0fc5df17 100644
--- a/spec/features/perf-app-wide-loading/results.md
+++ b/spec/features/perf-app-wide-loading/results.md
@@ -52,6 +52,17 @@ artifacts, so full map readiness cannot have a universal three-second limit.
- Required route failures render retry actions instead of blank content.
- Route benchmarks require route-owned readiness markers, enforce p95 limits,
fail on browser/API errors, and omit authentication and fixture details.
+- Dashboard content no longer waits for the optional model catalog. Route-owned
+ requests abort on navigation, and global blocking actions suppress local
+ loading surfaces.
+- Ongoing Jobs uses one conditional `GetActiveJobs` request instead of one full
+ project-details request per candidate project.
+- The standard Labeling Tool loads its module, Maps capabilities, and one
+ allowlisted `GetLabelingWorkspace` response concurrently. One staged loader
+ remains visible through map readiness, drawing setup, AOI fitting, and a
+ stable map frame.
+- Map routes load only their required control, drawing, or swipe capabilities.
+ Standard labeling no longer waits for the unused swipe extension.
## Expected Impact
@@ -64,15 +75,24 @@ These are expected effects, not post-deployment measurements.
## Local Verification
-The final local regression pass completed with 601 core tests, 72 HTTP API
-tests, 6 queue-trigger tests, and 148 UI tests passing. The production UI build
-transformed 2,419 modules in 431 ms.
-
-Black, isort, and Flake8 passed for the nine feature-owned Python files. ESLint
-passed for 38 changed UI files, both benchmark scripts passed Node syntax
-checks, `git diff --check` passed, and the configured `detect-secrets` hook
-reported no candidates. The Python suites emitted only existing Pydantic v2
-deprecation warnings.
+The final local regression pass completed with 614 core tests, 79 HTTP API
+tests, 6 queue-trigger tests, and 161 UI tests passing. The production UI build
+transformed 2,423 modules in 399 ms.
+
+Black, isort, and Flake8 passed for the five Python files added or updated by
+this follow-up. ESLint passed for 26 changed or new UI files, the three route
+benchmark scripts passed Node syntax checks, `git diff --check` passed, and the
+configured `detect-secrets` hook reported no candidates across 46 feature-owned
+files. The Python suites emitted only existing Pydantic v2 deprecation
+warnings.
+
+A mocked browser interruption test delayed Model Catalog and Active Jobs by two
+seconds, then navigated from Dashboard to Help. Dashboard showed one spinner,
+Help became ready in 38 ms, and both abandoned requests were aborted with no
+remaining loader. A real Azure Maps invalid-auth test confirmed that standard
+labeling retains one persistent retry surface, does not start its tour, and
+raises no application lifecycle exception. Successful production Maps loading
+still requires Dev1 validation with real credentials.
## Open Validation
diff --git a/spec/features/perf-app-wide-loading/rollout.md b/spec/features/perf-app-wide-loading/rollout.md
index ef5ecf32..34dc7545 100644
--- a/spec/features/perf-app-wide-loading/rollout.md
+++ b/spec/features/perf-app-wide-loading/rollout.md
@@ -33,6 +33,8 @@ or publishing status freshness exceeds ten seconds.
| Bootstrap p95 | Legacy chain about 3 s | <1 s |
| Published datasets p95 | 1.89 s post-deploy | <0.75 s warm |
| Project details p95 | 2.27 s post-deploy | <=3 s |
+| Labeling workspace p95 | Not previously measured | <1 s |
+| Active jobs p95 | N project-detail requests | <1 s warm |
| API failures | 0 for evaluated endpoints | No increase |
| Route content-ready p95 | Not previously measured | <=3 s |
diff --git a/spec/features/perf-app-wide-loading/test-plan.md b/spec/features/perf-app-wide-loading/test-plan.md
index e8910455..395cdbbb 100644
--- a/spec/features/perf-app-wide-loading/test-plan.md
+++ b/spec/features/perf-app-wide-loading/test-plan.md
@@ -31,6 +31,17 @@
| MAP-01 | Cold map route | Module and map loading overlap |
| MAP-02 | Asset failure then retry | Loader resets and retries safely |
| HELP-01 | Help route | Images lazy; videos do not preload |
+| LOAD-01 | Blocking action plus lazy route | One visible status surface |
+| LOAD-02 | Navigate during route GET | Request aborts; destination is unaffected |
+| LABEL-01 | Current image layer has label pointer | Direct label read; no partition scan |
+| LABEL-02 | Legacy or dangling label pointer | One compatible partition fallback |
+| LABEL-03 | Standard Labeling Tool startup | Workspace and Maps begin concurrently |
+| LABEL-04 | Map initialization succeeds | Loader remains until map/drawing readiness |
+| LABEL-05 | Navigate during map initialization | Request aborts and map is disposed |
+| HOME-01 | Optional catalog is slow | Dashboard renders without waiting |
+| JOBS-01 | Dashboard has multiple projects | One compact Active Jobs request |
+| JOBS-02 | Active Jobs poll is hidden or in flight | No new request |
+| JOBS-03 | Matching Active Jobs ETag | Existing jobs retained after `304` |
## Performance Matrix
diff --git a/spec/features/perf-app-wide-loading/tools/request_failure.cjs b/spec/features/perf-app-wide-loading/tools/request_failure.cjs
new file mode 100644
index 00000000..9943c1a5
--- /dev/null
+++ b/spec/features/perf-app-wide-loading/tools/request_failure.cjs
@@ -0,0 +1,10 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+function isExpectedNavigationAbort(request) {
+ const failure = request.failure();
+ const errorText = String(failure?.errorText || "").toLowerCase();
+ return errorText.includes("err_aborted") || errorText.includes("aborterror");
+}
+
+module.exports = { isExpectedNavigationAbort };
diff --git a/spec/features/perf-app-wide-loading/tools/request_failure.test.cjs b/spec/features/perf-app-wide-loading/tools/request_failure.test.cjs
new file mode 100644
index 00000000..39b488ef
--- /dev/null
+++ b/spec/features/perf-app-wide-loading/tools/request_failure.test.cjs
@@ -0,0 +1,18 @@
+const assert = require("node:assert/strict");
+const test = require("node:test");
+
+const { isExpectedNavigationAbort } = require("./request_failure.cjs");
+
+function request(errorText) {
+ return { failure: () => (errorText ? { errorText } : null) };
+}
+
+test("accepts browser cancellation caused by navigation", () => {
+ assert.equal(isExpectedNavigationAbort(request("net::ERR_ABORTED")), true);
+ assert.equal(isExpectedNavigationAbort(request("AbortError")), true);
+});
+
+test("rejects genuine request failures", () => {
+ assert.equal(isExpectedNavigationAbort(request("net::ERR_FAILED")), false);
+ assert.equal(isExpectedNavigationAbort(request(null)), false);
+});
diff --git a/spec/features/perf-app-wide-loading/tools/route_matrix.cjs b/spec/features/perf-app-wide-loading/tools/route_matrix.cjs
index a85bbd00..62264518 100644
--- a/spec/features/perf-app-wide-loading/tools/route_matrix.cjs
+++ b/spec/features/perf-app-wide-loading/tools/route_matrix.cjs
@@ -11,6 +11,9 @@
// --project
--layer --model
const fs = require("node:fs");
const path = require("node:path");
+const {
+ isExpectedNavigationAbort,
+} = require("./request_failure.cjs");
function arg(name, fallback = null) {
const index = process.argv.indexOf(`--${name}`);
@@ -93,7 +96,7 @@ const routes = [
{
name: "labeling",
path: `/labeling-tool/${project}/${layer}`,
- ready: ".labeling-tool-page",
+ ready: ".labeling-workspace-route",
mapReady: '.labeling-tool-page[data-map-ready="true"]',
},
{
@@ -248,7 +251,9 @@ async function measure(browser, route, profile, mode) {
const consoleErrors = [];
const pageErrors = [];
page.on("request", (request) => requests.push(request.url()));
- page.on("requestfailed", (request) => failures.push(request.url()));
+ page.on("requestfailed", (request) => {
+ if (!isExpectedNavigationAbort(request)) failures.push(request.url());
+ });
page.on("response", (response) => {
if (response.status() >= 400) httpErrors.push(response.status());
});
diff --git a/spec/features/perf-app-wide-loading/user-stories.md b/spec/features/perf-app-wide-loading/user-stories.md
index 5b46b729..d37f483c 100644
--- a/spec/features/perf-app-wide-loading/user-stories.md
+++ b/spec/features/perf-app-wide-loading/user-stories.md
@@ -42,6 +42,36 @@ changes cannot silently regress the one-to-three-second target.
**Acceptance criteria:** Every route has cold/warm direct and in-app timing,
request counts, asset bytes, and content-ready evidence.
+### US-005: One Owned Loading Experience
+
+**As a** HASTE user, **I want** navigation to show one coherent loading state,
+**so that** progress does not flicker or remain blocked by work from a route I
+already left.
+
+**Acceptance criteria:** Route initialization uses local state, navigation
+aborts owned GET requests and map work, and a stale route cannot clear or retain
+the destination route's loading surface.
+
+### US-006: Fast Standard Labeling Workspace
+
+**As a** disaster analyst, **I want** the standard Labeling Tool to prepare data
+and maps together, **so that** I can begin labeling without a blank map wait.
+
+**Acceptance criteria:** One workspace API returns only the target records,
+Maps and data load concurrently, progress is staged, the map begins at the AOI,
+and initialization is not complete until the map and drawing controls are
+ready.
+
+### US-007: Non-Blocking Dashboard Jobs
+
+**As a** HASTE user, **I want** dashboard summaries to render independently of
+optional catalog and job details, **so that** background status checks do not
+delay navigation.
+
+**Acceptance criteria:** Dashboard content waits only for dashboard data,
+active jobs use one compact conditional request, and hidden, overlapping, or
+unmounted polls perform no continuing work.
+
## Agent Assignment Map
| Story | Implementing Agent(s) | Validating Agent(s) |
@@ -50,6 +80,9 @@ request counts, asset bytes, and content-ready evidence.
| US-002 | `backend-dev`, `ui` | `backend-validation`, `ui-validation` |
| US-003 | `ui` | `ui-validation` |
| US-004 | `backend-dev`, `ui` | `backend-validation`, `ui-validation` |
+| US-005 | `ui` | `ui-validation` |
+| US-006 | `backend-dev`, `ui` | `backend-validation`, `ui-validation` |
+| US-007 | `backend-dev`, `ui` | `backend-validation`, `ui-validation` |
## Out of Scope