Expand scoped demo analysis access
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-08-01 16:00:13 +02:00
parent 96db4c966d
commit dfcc11b0d5
19 changed files with 206 additions and 112 deletions
+48 -2
View File
@@ -210,6 +210,12 @@ def create_app() -> FastAPI:
guest_safe_read_paths = {
f"{settings.api_prefix}/projects",
f"{settings.api_prefix}/external/providers",
f"{settings.api_prefix}/assistant/status",
f"{settings.api_prefix}/assistant/models",
f"{settings.api_prefix}/detection/models",
f"{settings.api_prefix}/detection/model-assets",
f"{settings.api_prefix}/detection/yolo/preflight",
f"{settings.api_prefix}/segmentation/models",
}
normalized_path = raw_path.rstrip("/") or "/"
guest_project_read = (
@@ -218,7 +224,21 @@ def create_app() -> FastAPI:
)
is_read_request = request.method in {"GET", "HEAD", "OPTIONS"}
if is_read_request:
if normalized_path not in guest_safe_read_paths and not guest_project_read:
guest_scoped_analysis_read = (
query_project_id == str(principal.project_id)
and normalized_path.startswith(
(
f"{settings.api_prefix}/detection/",
f"{settings.api_prefix}/segmentation/",
f"{settings.api_prefix}/exports/",
)
)
)
if (
normalized_path not in guest_safe_read_paths
and not guest_project_read
and not guest_scoped_analysis_read
):
response = JSONResponse(
status_code=403,
content=_to_error_payload(
@@ -234,6 +254,15 @@ def create_app() -> FastAPI:
f"{settings.api_prefix}/demo/workflow",
f"{settings.api_prefix}/external/coverage/resolve",
}
guest_scoped_analysis_post_paths = {
f"{settings.api_prefix}/detection/run",
f"{settings.api_prefix}/segmentation/run",
f"{settings.api_prefix}/qa/detections-vs-reference",
f"{settings.api_prefix}/exports/geojson",
f"{settings.api_prefix}/exports/metadata",
f"{settings.api_prefix}/exports/report",
f"{settings.api_prefix}/exports/map-result",
}
guest_safe_post_suffixes = (
"/vector/select",
"/raster/bathymetry/select",
@@ -247,9 +276,26 @@ def create_app() -> FastAPI:
)
is_guest_safe_post = request.method == "POST" and (
raw_path in guest_safe_post_paths
or (
raw_path in guest_scoped_analysis_post_paths
and query_project_id == str(principal.project_id)
)
or (
query_project_id == str(principal.project_id)
and raw_path.startswith(
(
f"{settings.api_prefix}/detection/runs/",
f"{settings.api_prefix}/segmentation/runs/",
)
)
and raw_path.endswith("/qa/reference")
)
or (
raw_path.startswith(project_path_prefix)
and raw_path.endswith(guest_safe_post_suffixes)
and (
raw_path.endswith(guest_safe_post_suffixes)
or raw_path.endswith("/assistant/query")
)
)
)
if not is_guest_safe_post:
+11 -4
View File
@@ -126,7 +126,7 @@ def test_login_uses_http_only_session_cookie_and_logout_revokes_browser_access(m
assert protected_after_logout.status_code == 401
def test_guest_login_seeds_scoped_demo_and_rejects_mutating_or_cross_project_requests(monkeypatch) -> None:
def test_guest_login_exposes_models_but_rejects_management_and_cross_project_requests(monkeypatch) -> None:
project_id = UUID("00000000-0000-0000-0000-000000000123")
demo = DemoWorkflowResponse(
project_id=project_id,
@@ -152,7 +152,11 @@ def test_guest_login_seeds_scoped_demo_and_rejects_mutating_or_cross_project_req
guest_session = client.get("/api/v1/auth/session")
mutation = client.post("/api/v1/projects", json={"name": "Not allowed"})
other_project = client.get("/api/v1/projects/00000000-0000-0000-0000-000000000999")
unscoped_read = client.get("/api/v1/detection/models")
detection_models = client.get("/api/v1/detection/models")
segmentation_models = client.get("/api/v1/segmentation/models")
cross_project_runs = client.get(
"/api/v1/detection/runs?project_id=00000000-0000-0000-0000-000000000999"
)
cross_project_coverage = client.post(
"/api/v1/external/coverage/resolve",
json={
@@ -172,8 +176,11 @@ def test_guest_login_seeds_scoped_demo_and_rejects_mutating_or_cross_project_req
assert mutation.json()["error"] == "GUEST_READ_ONLY"
assert other_project.status_code == 403
assert other_project.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
assert unscoped_read.status_code == 403
assert unscoped_read.json()["error"] == "GUEST_ROUTE_NOT_AVAILABLE"
assert detection_models.status_code == 200
assert detection_models.json()["data"]["models"]
assert segmentation_models.status_code == 200
assert cross_project_runs.status_code == 403
assert cross_project_runs.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
assert cross_project_coverage.status_code == 403
assert cross_project_coverage.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
+13 -7
View File
@@ -64,8 +64,12 @@ one client/username combination within five minutes temporarily return HTTP
Optional guest access is a configuration-gated demonstration mode. It creates
a shorter signed session with role `guest`, scopes that session to the
idempotently seeded demo project and blocks mutating operator routes. Project
listing is filtered to the bound demo project. The frontend exposes only the
map and the already calculated quality evidence. This is deliberately **not**
listing is filtered to the bound demo project. The frontend exposes the same
exploration, assistant, model-selection, analysis, QA and export workspaces as
an operator. Model catalogs are globally readable; every run, result and export
request remains explicitly bound to the demo-project UUID. Project and area
management, uploads, source/runtime configuration, evidence review and other
administrative mutations remain unavailable. This is deliberately **not**
a substitute for user accounts, authorization or tenant isolation; expose it
only on a dedicated demo installation without private or operational data.
@@ -119,11 +123,13 @@ Disabled guest access returns HTTP 403 `GUEST_ACCESS_DISABLED`. A guest request
for a different project returns HTTP 403 `GUEST_PROJECT_SCOPE_REQUIRED`; a
blocked mutation returns HTTP 403 `GUEST_READ_ONLY`. Unscoped read routes that
are not needed by the demo return HTTP 403 `GUEST_ROUTE_NOT_AVAILABLE`.
Guest reads are limited to the filtered project list, provider metadata and the
bound project tree. A small, explicit set of `POST` selection/read-analysis
routes remains available because those routes query persisted evidence without
exposing operator administration. Coverage resolution additionally verifies
the `project_id` in the request body against the guest-session scope.
Guest reads are limited to the filtered project list, provider/model metadata,
the bound project tree and project-scoped detection, segmentation and export
results. An explicit set of `POST` selection, assistant, AI/QA and export routes
is available for that bound demo project. Unscoped analysis routes require the
same UUID as a `project_id` query parameter; cross-project values fail before
route execution. Coverage resolution additionally verifies the `project_id` in
the request body against the guest-session scope.
### POST `/api/v1/auth/logout`
+13 -1
View File
@@ -12217,4 +12217,16 @@ Open:
- Desktop, tablet en mobiel: documentoverflow `0`; themapaneeloverflow `0`.
- Mobiele hoofdflow: thema zoeken, kiezen, volledig werkgebied selecteren, expliciet analyseren, resultatenlade openen en sluiten geslaagd.
- Browserconsole: `0` waarschuwingen en `0` fouten.
- Bewijsbeelden: `docs/screenshots/ui-ux-map-desktop-2026-08-01.jpg` en `docs/screenshots/ui-ux-map-mobile-results-2026-08-01.jpg`.
- Bewijsbeelden: `docs/screenshots/ui-ux-map-desktop-2026-08-01.jpg` en `docs/screenshots/ui-ux-map-mobile-results-2026-08-01.jpg`.
## 2026-08-01 - Sprint 237 volwaardige demo-analysetoegang
### Gewijzigd
- Demo-navigatie omvat nu status, bronnen, kaart, AI-vragen, kwaliteit, beeldanalyse en downloads; alleen de systeem-/beheerwerkruimte blijft verborgen.
- De demo laadt dezelfde lokale assistent-, detectie- en segmentatiemodellen, runs, resultaten en exports als de operator binnen het gebonden demoproject.
- Gastverzoeken voor AI-runs, QA, assistent en exports zijn server-side toegestaan met een verplichte en gecontroleerde `project_id`; andere projecten blijven vóór route-uitvoering geblokkeerd.
- Project- en gebiedbeheer, uploads, bron-/runtimeconfiguratie, bewijsreviews en overige mutaties blijven operator-only.
### Verificatie
- TypeScript- en Vite-productiebuild geslaagd.
- Gerichte frontend-, backend-, browser- en productieverificatie volgen hieronder na de releasegate.
+13 -4
View File
@@ -21,9 +21,10 @@ Uitvoeringsbord: `docs/PYTORCH_TRAINING_ROADMAP_BELGIUM.md`.
Professionaliseringspass (2026-07-27):
- [x] Voeg een expliciete gastknop toe aan de toegangspoort en open daarmee
een korte, projectgebonden, alleen-lezen demowerkruimte.
- [x] Beperk de gastinterface tot kaartverkenning en bestaand kwaliteitsbewijs;
blokkeer operatoracties en toegang tot andere projecten ook server-side.
een korte, projectgebonden demowerkruimte.
- [x] Geef de demo dezelfde kaart-, bron-, assistent-, model-, analyse-, QA- en
downloadfuncties als de operator, maar blokkeer beheer, instellingen,
uploads, reviews en toegang tot andere projecten ook server-side.
- [x] Herwerk de landingspagina, aanmeldhiërarchie, mobiele navigatie en
workbenchcontext tot één rustigere en professionelere productervaring.
- [ ] Consolideer na visuele regressiesnapshots de vier historische
@@ -1065,4 +1066,12 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Compacte analysecontextbalk en rustige desktop/tablet/mobiele hiërarchie.
- [x] Uitschuifbare inzichten behouden; analyse blijft uitsluitend expliciet na themakeuze.
- [x] 51 frontendtests en productiebuild groen.
- [ ] 19 verouderde broncode-stringtests herijken; meerdere eisen daarin (automatische analyse) conflicteren bewust met de actuele productbeslissing.
- [ ] 19 verouderde broncode-stringtests herijken; meerdere eisen daarin (automatische analyse) conflicteren bewust met de actuele productbeslissing.
## Sprint 237 - Volwaardige, projectgebonden demo (2026-08-01)
- [x] Maak alle niet-administratieve werkruimtes zichtbaar voor demo-gebruikers.
- [x] Laad dezelfde assistent-, detectie- en segmentatiemodellen en bewaarde resultaten.
- [x] Sta projectgebonden analyse-, QA-, assistent- en exportacties toe.
- [x] Behoud server-side blokkades op instellingen, beheer, uploads, reviews en cross-projectverzoeken.
- [ ] Verifieer en redeploy de exacte commit naar Tower `/mnt/user/appdata/geointel`.
+38 -25
View File
@@ -90,9 +90,21 @@ const workspaceNavGroups: WorkspaceNavigationGroup[] = [
{ label: 'Beheer', keys: ['overview', 'system'] },
]
const guestWorkspaceKeys = new Set<WorkspaceKey>(['map', 'analysis'])
const guestWorkspaceKeys = new Set<WorkspaceKey>([
'overview',
'data',
'map',
'assistant',
'analysis',
'ai',
'exports',
])
const guestWorkspaceGroups: WorkspaceNavigationGroup[] = [
{ label: 'Demowerkruimte', keys: ['map', 'analysis'] },
{ label: 'Verkennen', keys: ['map', 'data'] },
{ label: 'Vragen', keys: ['assistant'] },
{ label: 'Analyseren', keys: ['analysis', 'ai'] },
{ label: 'Afronden', keys: ['exports'] },
{ label: 'Demo', keys: ['overview'] },
]
interface WorkbenchAppProps {
@@ -589,7 +601,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
demoWorkflowMessage,
loadDemoWorkflow,
} = useDemoWorkflow({
restrictedMode: isGuest,
restrictedMode: false,
loadProjects,
loadProjectData,
loadDatasetDetails,
@@ -618,7 +630,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
}, [isGuest, loadDemoWorkflow])
useWorkbenchBootstrap({
restrictedMode: isGuest,
restrictedMode: false,
selectedProjectId,
selectedDetectionRunId,
detectionClassFilter,
@@ -720,7 +732,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
}
const openWorkflowGuidanceStep = (target: WorkspaceKey) => {
if (isGuest && !guestWorkspaceKeys.has(target)) {
setActiveWorkspace(target === 'exports' ? 'analysis' : 'map')
setActiveWorkspace('map')
return
}
if (target === 'map' && availableMapDatasets.length > 0 && !mapFeatureCollection) {
@@ -894,9 +906,9 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
<ShieldCheck aria-hidden="true" />
<div>
<strong>Tijdelijke demowerkruimte</strong>
<span>U verkent vooraf geladen voorbeelddata. Wijzigingen, nieuwe analyses en operatorfuncties zijn uitgeschakeld.</span>
<span>Alle analysemodellen en werkfuncties zijn beschikbaar. Beheer, instellingen en blijvende gegevenswijzigingen blijven afgeschermd.</span>
</div>
<span className="guest-mode-badge">Alleen-lezen</span>
<span className="guest-mode-badge">Analyse-toegang</span>
</div>
) : null}
@@ -924,7 +936,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
<WorkspaceSignal workspace={activeWorkspace} />
<div className="workspace-heading-actions">
<p>{activeWorkspaceItem.description}</p>
{!isGuest && activeWorkspace !== 'overview' ? (
{activeWorkspace !== 'overview' ? (
<button
type="button"
className={inspectorOpen ? 'inspector-toggle inspector-toggle-active' : 'inspector-toggle'}
@@ -960,6 +972,15 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
projectId={selectedProjectId ?? undefined}
loading={loadingDatasets}
/>
{isGuest ? (
<div className="guest-readonly-card">
<ShieldCheck aria-hidden="true" />
<div>
<strong>Bronnen bekijken zonder beheerrechten</strong>
<span>De demo gebruikt dezelfde beschikbare databronnen. Projecten, gebieden en uploads worden alleen door de operator beheerd.</span>
</div>
</div>
) : <>
<ProjectPanel
projects={projects}
selectedProjectId={selectedProjectId}
@@ -1002,12 +1023,13 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
onOpenDatasetInMap={openDatasetInMap}
onOpenDatasetExport={openDatasetExport}
/>
</>}
</div>
) : null}
<div className="workspace-persistent-map" hidden={activeWorkspace !== 'map'}>
<MapWorkspace
readOnly={isGuest}
readOnly={false}
selectedProjectId={selectedProjectId}
projects={projects}
areas={areas}
@@ -1103,8 +1125,8 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
onRefreshProjectData={() => (
selectedProjectId ? loadProjectData(selectedProjectId) : Promise.resolve(null)
)}
onOpenAssistant={() => setActiveWorkspace(isGuest ? 'analysis' : 'assistant')}
onOpenExports={() => setActiveWorkspace(isGuest ? 'analysis' : 'exports')}
onOpenAssistant={() => setActiveWorkspace('assistant')}
onOpenExports={() => setActiveWorkspace('exports')}
/>
</div>
@@ -1121,10 +1143,9 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
evidenceLoading={qualityEvidenceLoading}
evidenceError={qualityEvidenceError}
onOpenMapWorkspace={() => setActiveWorkspace('map')}
onOpenAnalysisWorkspace={() => setActiveWorkspace(isGuest ? 'map' : 'ai')}
onOpenAnalysisWorkspace={() => setActiveWorkspace('ai')}
/>
{!isGuest ? (
<details className="secondary-analysis-disclosure">
<details className="secondary-analysis-disclosure">
<summary>
<span>Historische vectorlagen vergelijken</span>
<strong>Geavanceerd</strong>
@@ -1144,16 +1165,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
onIncludeUnchangedChange={setChangeIncludeUnchanged}
onRun={runChangeDetection}
/>
</details>
) : (
<div className="guest-readonly-card">
<ShieldCheck aria-hidden="true" />
<div>
<strong>Controleerbaar voorbeeldresultaat</strong>
<span>Deze kwaliteitsweergave gebruikt de vooraf berekende demo. Nieuwe vergelijkingen zijn alleen beschikbaar voor operators.</span>
</div>
</div>
)}
</details>
</div>
) : null}
@@ -1171,6 +1183,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
{activeWorkspace === 'ai' ? (
<div className="workspace-grid workspace-grid-ai">
<DetectionLab
managementLocked={isGuest}
detectionModels={detectionModels}
modelAssets={modelAssets}
loadingDetectionModels={loadingDetectionModels}
@@ -1318,7 +1331,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
) : null}
</main>
{inspectorOpen && !isGuest ? (
{inspectorOpen ? (
<aside className="workbench-inspector" id="workbench-inspector" aria-label="Details van de huidige selectie">
<WorkbenchInspector
selectedProject={selectedProject}
@@ -78,6 +78,7 @@ interface CalibrationRow {
}
interface DetectionLabProps {
managementLocked?: boolean
detectionModels: DetectionModelCapability[]
modelAssets: ModelAssetRead[]
loadingDetectionModels: boolean
@@ -138,6 +139,7 @@ interface DetectionLabProps {
}
export function DetectionLab({
managementLocked = false,
detectionModels,
modelAssets,
loadingDetectionModels,
@@ -332,7 +334,7 @@ export function DetectionLab({
</div>
) : null}
<DetectionModelManagement
{!managementLocked ? <DetectionModelManagement
detectionModels={detectionModels}
modelAssets={modelAssets}
loadingDetectionModels={loadingDetectionModels}
@@ -347,7 +349,7 @@ export function DetectionLab({
onRefreshYoloPreflight={onRefreshYoloPreflight}
onSelectModelAsset={onSelectModelAsset}
onApplyOperatorProfile={onApplyOperatorProfile}
/>
/> : null}
<div className="lab-block">
<div className="ai-lab-run-surface" aria-label="Gebouwdetectie starten">
@@ -414,7 +416,7 @@ export function DetectionLab({
<p>Voeg hieronder een gegeorefereerde GeoTIFF toe. GeoIntel controleert de projectie en bewaart het bronbestand als dataset.</p>
</div>
) : null}
<div className="guided-raster-input" aria-label="Luchtbeeld toevoegen">
{!managementLocked ? <div className="guided-raster-input" aria-label="Luchtbeeld toevoegen">
<div>
<strong>Eigen luchtbeeld toevoegen</strong>
<p>Gebruik een GeoTIFF met geldige CRS en georeferentie. Een bestaand luchtbeeld kan meteen in de keuzelijst worden gebruikt.</p>
@@ -444,7 +446,7 @@ export function DetectionLab({
>
{detectionWorkflowStage === 'uploading' ? 'Luchtbeeld toevoegen...' : 'Luchtbeeld toevoegen'}
</button>
</div>
</div> : null}
<div className="lab-form-grid">
<label>
Luchtbeeld
@@ -499,7 +501,7 @@ export function DetectionLab({
{detectionWorkflowActionLabel(detectionWorkflowStage)}
</button>
<details className="ai-lab-model-surface technical-manifest-surface" aria-label="Technische tegelinstellingen">
{!managementLocked ? <details className="ai-lab-model-surface technical-manifest-surface" aria-label="Technische tegelinstellingen">
<summary>
<span>Technische tegelinstellingen</span>
<strong>{detectionHasTileManifest ? 'manifest beschikbaar' : 'automatisch'}</strong>
@@ -528,7 +530,7 @@ export function DetectionLab({
Bestaande beeldtegels analyseren
</button>
</div>
</details>
</details> : null}
</div>
</div>
@@ -556,7 +558,7 @@ export function DetectionLab({
) : null}
</div>
<details className="ai-lab-model-surface guided-calibration-surface" aria-label="Modelkalibratie voor beheerders">
{!managementLocked ? <details className="ai-lab-model-surface guided-calibration-surface" aria-label="Modelkalibratie voor beheerders">
<summary>
<span>Modelkalibratie voor beheerders</span>
<strong>{detectionCalibrationRows.length > 0 ? `${detectionCalibrationRows.length} drempels getest` : 'gesloten'}</strong>
@@ -677,7 +679,7 @@ export function DetectionLab({
</div>
)}
</div>
</details>
</details> : null}
<div className="ai-lab-results-surface" aria-label="Resultaten van de beeldanalyse">
<div className="panel-title-row">
+6 -8
View File
@@ -67,17 +67,15 @@ export function useDemoWorkflow({
setSegmentationReferenceDatasetId(result.reference_dataset_id)
setDemoWorkflowMessage(result.message)
await loadProjects(result.project_id)
const operatorOnlyLoads = restrictedMode
? Promise.resolve()
: Promise.all([
loadDetectionRuns(result.project_id),
loadSegmentationRuns(result.project_id),
loadExports(result.project_id),
]).then(() => undefined)
const analysisLoads = Promise.all([
loadDetectionRuns(result.project_id),
loadSegmentationRuns(result.project_id),
loadExports(result.project_id),
]).then(() => undefined)
const [projectData] = await Promise.all([
loadProjectData(result.project_id),
loadQualityChecks(result.project_id),
operatorOnlyLoads,
analysisLoads,
])
const candidateDataset = projectData?.datasets.find((dataset) => dataset.id === result.candidate_dataset_id)
const rasterDataset = projectData?.datasets.find((dataset) => dataset.id === result.raster_dataset_id)
+3 -2
View File
@@ -207,6 +207,7 @@ export function useDetectionWorkflow({
setDetectionRunError(null)
try {
const params = {
project_id: selectedProjectId ?? '',
class_name: detectionClassFilter || null,
min_confidence: detectionMinConfidenceFilter > 0 ? detectionMinConfidenceFilter : null,
}
@@ -415,7 +416,7 @@ export function useDetectionWorkflow({
setDetectionQaResult(null)
setRunningDetectionQa(true)
try {
const result = await detectionApi.compareWithReference(analysisRunId, {
const result = await detectionApi.compareWithReference(analysisRunId, selectedProjectId!, {
reference_dataset_id: referenceDatasetId,
iou_threshold: iouThresholdOverride ?? qaIouThreshold,
class_name: useCurrentFilters ? detectionClassFilter || null : null,
@@ -486,7 +487,7 @@ export function useDetectionWorkflow({
parameters_json: { calibration: true, calibration_thresholds: thresholds },
})
setSelectedDetectionRunId(result.analysis_run_id)
const qa = await detectionApi.compareWithReference(result.analysis_run_id, {
const qa = await detectionApi.compareWithReference(result.analysis_run_id, selectedProjectId, {
reference_dataset_id: detectionReferenceDatasetId,
iou_threshold: qaIouThreshold,
class_name: detectionClassFilter || null,
+10 -8
View File
@@ -59,7 +59,7 @@ export function useExportWorkflow({
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportGeojson({
const response = await exportsApi.exportGeojson(selectedDataset.project_id, {
dataset_id: selectedDataset.id,
export_kind: 'dataset',
name: selectedDataset.name.replace(/\.(geo)?json$/i, ''),
@@ -74,14 +74,14 @@ export function useExportWorkflow({
}
const exportSelectedDetectionRunGeoJson = async () => {
if (!selectedDetectionRunId) {
if (!selectedDetectionRunId || !selectedProjectId) {
setExportError('Select a detection run before exporting GeoJSON.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportGeojson({
const response = await exportsApi.exportGeojson(selectedProjectId, {
analysis_run_id: selectedDetectionRunId,
export_kind: 'detection_run',
})
@@ -95,14 +95,14 @@ export function useExportWorkflow({
}
const exportSelectedSegmentationRunGeoJson = async () => {
if (!selectedSegmentationRunId) {
if (!selectedSegmentationRunId || !selectedProjectId) {
setExportError('Select a segmentation run before exporting GeoJSON.')
return
}
setExporting(true)
setExportError(null)
try {
const response = await exportsApi.exportGeojson({
const response = await exportsApi.exportGeojson(selectedProjectId, {
analysis_run_id: selectedSegmentationRunId,
export_kind: 'segmentation_run',
})
@@ -123,7 +123,7 @@ export function useExportWorkflow({
setSelectionExporting(true)
setSelectionExportError(null)
try {
const response = await exportsApi.exportGeojson({
const response = await exportsApi.exportGeojson(selectedDataset.project_id, {
dataset_id: selectedDataset.id,
area_id: areaId,
export_kind: 'vector_selection',
@@ -199,7 +199,8 @@ export function useExportWorkflow({
const previewExportContent = async (exportId: string) => {
setExportError(null)
try {
const response = await exportsApi.getContent(exportId)
if (!selectedProjectId) return
const response = await exportsApi.getContent(selectedProjectId, exportId)
setExportPreview(response.content)
} catch (error) {
setExportError(formatError(error, 'Failed to load export content'))
@@ -207,7 +208,8 @@ export function useExportWorkflow({
}
const downloadExportArtifact = (exportId: string) => {
window.open(exportsApi.downloadUrl(exportId), '_blank', 'noopener,noreferrer')
if (!selectedProjectId) return
window.open(exportsApi.downloadUrl(selectedProjectId, exportId), '_blank', 'noopener,noreferrer')
}
const resetExportsForProject = () => {
+1 -1
View File
@@ -51,7 +51,7 @@ export function useMapSelectionQa({
iou_threshold: 0.5,
area_id: candidateDataset.area_id ?? null,
}
const job: JobRead = await qaApi.runQa(request)
const job: JobRead = await qaApi.runQa(selectedProjectId, request)
if (job.status === 'failed') {
setMapSelectionQaError(job.error_message || 'Map selection QA/QC failed')
return null
+1 -1
View File
@@ -92,7 +92,7 @@ export function useQualityWorkflow({ selectedProjectId, loadProjectData }: Quali
iou_threshold: qaIouThreshold,
area_id: qaAreaId || null,
}
const job: JobRead = await qaApi.runQa(request)
const job: JobRead = await qaApi.runQa(selectedProjectId, request)
if (job.status === 'failed') {
setQaError(job.error_message || 'QA comparison failed')
return
@@ -104,6 +104,7 @@ export function useSegmentationWorkflow({
setSegmentationRunError(null)
try {
const params = {
project_id: selectedProjectId ?? '',
class_name: segmentationClassFilter || null,
min_confidence: segmentationMinConfidenceFilter > 0 ? segmentationMinConfidenceFilter : null,
}
@@ -175,7 +176,7 @@ export function useSegmentationWorkflow({
setSegmentationQaResult(null)
setRunningSegmentationQa(true)
try {
const result = await segmentationApi.compareWithReference(selectedSegmentationRunId, {
const result = await segmentationApi.compareWithReference(selectedSegmentationRunId, selectedProjectId!, {
reference_dataset_id: segmentationReferenceDatasetId,
iou_threshold: qaIouThreshold,
class_name: segmentationClassFilter || null,
@@ -65,19 +65,19 @@ describe('useWorkbenchBootstrap', () => {
expect(state.loadExports).toHaveBeenCalledWith('project-1')
})
it('keeps the guest bootstrap inside the read-only demo surface', async () => {
it('loads the same analysis catalog and project results for a guest', async () => {
const state = { ...options('project-1'), restrictedMode: true }
renderHook(() => useWorkbenchBootstrap(state))
await waitFor(() => expect(state.loadProjectData).toHaveBeenCalledWith('project-1'))
expect(state.loadCapabilities).toHaveBeenCalledOnce()
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1')
expect(state.loadDetectionModels).not.toHaveBeenCalled()
expect(state.loadSegmentationModels).not.toHaveBeenCalled()
expect(state.loadDetectionRuns).not.toHaveBeenCalled()
expect(state.loadSegmentationRuns).not.toHaveBeenCalled()
expect(state.loadExports).not.toHaveBeenCalled()
expect(state.loadDetectionResults).not.toHaveBeenCalled()
expect(state.loadSegmentationResults).not.toHaveBeenCalled()
expect(state.loadDetectionModels).toHaveBeenCalledOnce()
expect(state.loadSegmentationModels).toHaveBeenCalledOnce()
expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1')
expect(state.loadSegmentationRuns).toHaveBeenCalledWith('project-1')
expect(state.loadExports).toHaveBeenCalledWith('project-1')
expect(state.loadDetectionResults).toHaveBeenCalledOnce()
expect(state.loadSegmentationResults).toHaveBeenCalledOnce()
})
})
+5 -11
View File
@@ -59,10 +59,8 @@ export function useWorkbenchBootstrap({
useEffect(() => {
loadProjects().catch(() => null)
loadCapabilities().catch(() => null)
if (!restrictedMode) {
loadDetectionModels().catch(() => null)
loadSegmentationModels().catch(() => null)
}
loadDetectionModels().catch(() => null)
loadSegmentationModels().catch(() => null)
}, [restrictedMode])
useEffect(() => {
@@ -81,20 +79,16 @@ export function useWorkbenchBootstrap({
resetExportsForProject()
loadProjectData(selectedProjectId).catch(() => null)
loadQualityChecks(selectedProjectId).catch(() => null)
if (!restrictedMode) {
loadDetectionRuns(selectedProjectId).catch(() => null)
loadSegmentationRuns(selectedProjectId).catch(() => null)
loadExports(selectedProjectId).catch(() => null)
}
loadDetectionRuns(selectedProjectId).catch(() => null)
loadSegmentationRuns(selectedProjectId).catch(() => null)
loadExports(selectedProjectId).catch(() => null)
}, [restrictedMode, selectedProjectId])
useEffect(() => {
if (restrictedMode) return
loadDetectionResults().catch(() => null)
}, [restrictedMode, selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter])
useEffect(() => {
if (restrictedMode) return
loadSegmentationResults().catch(() => null)
}, [restrictedMode, selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
}
+5 -5
View File
@@ -29,21 +29,21 @@ export const detectionApi = {
getYoloPreflight: (params: { tile_manifest_path?: string | null; check_model_load?: boolean | null; model_asset_id?: string | null } = {}): Promise<YoloPreflightResponse> =>
apiGet<YoloPreflightResponse>(`/api/v1/detection/yolo/preflight${queryString(params)}`),
run: (payload: DetectionRunRequest): Promise<DetectionRunResponse> =>
apiPost<DetectionRunResponse>('/api/v1/detection/run', payload),
apiPost<DetectionRunResponse>(`/api/v1/detection/run?project_id=${encodeURIComponent(payload.project_id)}`, payload),
listRuns: (params: { project_id?: string | null; dataset_id?: string | null } = {}): Promise<DetectionRunListResponse> =>
apiGet<DetectionRunListResponse>(`/api/v1/detection/runs${queryString(params)}`),
getRun: (analysisRunId: string): Promise<DetectionRunRead> =>
apiGet<DetectionRunRead>(`/api/v1/detection/runs/${analysisRunId}`),
listDetections: (
analysisRunId: string,
params: { dataset_id?: string | null; class_name?: string | null; min_confidence?: number | null } = {},
params: { project_id: string; dataset_id?: string | null; class_name?: string | null; min_confidence?: number | null },
): Promise<DetectionListResponse> =>
apiGet<DetectionListResponse>(`/api/v1/detection/runs/${analysisRunId}/detections${queryString(params)}`),
getRunGeoJson: (
analysisRunId: string,
params: { class_name?: string | null; min_confidence?: number | null } = {},
params: { project_id: string; class_name?: string | null; min_confidence?: number | null },
): Promise<GeoJSON.FeatureCollection> =>
apiGet<GeoJSON.FeatureCollection>(`/api/v1/detection/runs/${analysisRunId}/geojson${queryString(params)}`),
compareWithReference: (analysisRunId: string, payload: DetectionQaRequest): Promise<DetectionQaResult> =>
apiPost<DetectionQaResult>(`/api/v1/detection/runs/${analysisRunId}/qa/reference`, payload),
compareWithReference: (analysisRunId: string, projectId: string, payload: DetectionQaRequest): Promise<DetectionQaResult> =>
apiPost<DetectionQaResult>(`/api/v1/detection/runs/${analysisRunId}/qa/reference?project_id=${encodeURIComponent(projectId)}`, payload),
}
+12 -9
View File
@@ -11,6 +11,7 @@ import type {
export const exportsApi = {
exportGeojson: (
projectId: string,
payload:
| {
dataset_id?: string
@@ -24,18 +25,20 @@ export const exportsApi = {
| string,
): Promise<ExportCreateResponse> => {
const body = typeof payload === 'string' ? { dataset_id: payload, export_kind: 'dataset' } : payload
return apiPost<ExportCreateResponse>(`/api/v1/exports/geojson`, body)
return apiPost<ExportCreateResponse>(`/api/v1/exports/geojson?project_id=${encodeURIComponent(projectId)}`, body)
},
exportProjectMetadata: (projectId: string, name?: string): Promise<ExportCreateResponse> =>
apiPost<ExportCreateResponse>(`/api/v1/exports/metadata`, { project_id: projectId, name }),
apiPost<ExportCreateResponse>(`/api/v1/exports/metadata?project_id=${encodeURIComponent(projectId)}`, { project_id: projectId, name }),
exportProjectReport: (projectId: string, name?: string): Promise<ExportCreateResponse> =>
apiPost<ExportCreateResponse>(`/api/v1/exports/report`, { project_id: projectId, name }),
apiPost<ExportCreateResponse>(`/api/v1/exports/report?project_id=${encodeURIComponent(projectId)}`, { project_id: projectId, name }),
exportMapResult: (payload: MapResultExportRequest): Promise<ExportCreateResponse> =>
apiPost<ExportCreateResponse>(`/api/v1/exports/map-result`, payload),
apiPost<ExportCreateResponse>(`/api/v1/exports/map-result?project_id=${encodeURIComponent(payload.project_id)}`, payload),
listProjectExports: (projectId: string): Promise<ExportListResponse> =>
apiGet<ExportListResponse>(`/api/v1/exports/projects/${projectId}/exports`),
getExport: (exportId: string): Promise<ExportRead> => apiGet<ExportRead>(`/api/v1/exports/${exportId}`),
getContent: (exportId: string): Promise<ExportContentResponse> =>
apiGet<ExportContentResponse>(`/api/v1/exports/${exportId}/content`),
downloadUrl: (exportId: string): string => apiUrl(`/api/v1/exports/${exportId}/download`),
apiGet<ExportListResponse>(`/api/v1/exports/projects/${projectId}/exports?project_id=${encodeURIComponent(projectId)}`),
getExport: (projectId: string, exportId: string): Promise<ExportRead> =>
apiGet<ExportRead>(`/api/v1/exports/${exportId}?project_id=${encodeURIComponent(projectId)}`),
getContent: (projectId: string, exportId: string): Promise<ExportContentResponse> =>
apiGet<ExportContentResponse>(`/api/v1/exports/${exportId}/content?project_id=${encodeURIComponent(projectId)}`),
downloadUrl: (projectId: string, exportId: string): string =>
apiUrl(`/api/v1/exports/${exportId}/download?project_id=${encodeURIComponent(projectId)}`),
}
+2 -2
View File
@@ -10,8 +10,8 @@ import type {
} from '../../types'
export const qaApi = {
runQa: (payload: QaComparisonRequest): Promise<JobRead> =>
apiPost<JobRead>('/api/v1/qa/detections-vs-reference', payload),
runQa: (projectId: string, payload: QaComparisonRequest): Promise<JobRead> =>
apiPost<JobRead>(`/api/v1/qa/detections-vs-reference?project_id=${encodeURIComponent(projectId)}`, payload),
listQualityChecks: (projectId: string): Promise<QualityCheckListResponse> =>
apiGet<QualityCheckListResponse>(`/api/v1/projects/${projectId}/quality-checks`),
getQualityEvidenceGeoJson: (projectId: string, qualityCheckId: string): Promise<QualityEvidenceGeoJsonResponse> =>
+5 -5
View File
@@ -24,21 +24,21 @@ function queryString(params: Record<string, string | number | null | undefined>)
export const segmentationApi = {
listModels: (): Promise<SegmentationModelsResponse> => apiGet<SegmentationModelsResponse>('/api/v1/segmentation/models'),
run: (payload: SegmentationRunRequest): Promise<SegmentationRunResponse> =>
apiPost<SegmentationRunResponse>('/api/v1/segmentation/run', payload),
apiPost<SegmentationRunResponse>(`/api/v1/segmentation/run?project_id=${encodeURIComponent(payload.project_id)}`, payload),
listRuns: (params: { project_id?: string | null; dataset_id?: string | null } = {}): Promise<SegmentationRunListResponse> =>
apiGet<SegmentationRunListResponse>(`/api/v1/segmentation/runs${queryString(params)}`),
getRun: (analysisRunId: string): Promise<SegmentationRunRead> =>
apiGet<SegmentationRunRead>(`/api/v1/segmentation/runs/${analysisRunId}`),
listSegmentations: (
analysisRunId: string,
params: { dataset_id?: string | null; class_name?: string | null; min_confidence?: number | null } = {},
params: { project_id: string; dataset_id?: string | null; class_name?: string | null; min_confidence?: number | null },
): Promise<SegmentationListResponse> =>
apiGet<SegmentationListResponse>(`/api/v1/segmentation/runs/${analysisRunId}/segmentations${queryString(params)}`),
getRunGeoJson: (
analysisRunId: string,
params: { class_name?: string | null; min_confidence?: number | null } = {},
params: { project_id: string; class_name?: string | null; min_confidence?: number | null },
): Promise<GeoJSON.FeatureCollection> =>
apiGet<GeoJSON.FeatureCollection>(`/api/v1/segmentation/runs/${analysisRunId}/geojson${queryString(params)}`),
compareWithReference: (analysisRunId: string, payload: SegmentationQaRequest): Promise<SegmentationQaResult> =>
apiPost<SegmentationQaResult>(`/api/v1/segmentation/runs/${analysisRunId}/qa/reference`, payload),
compareWithReference: (analysisRunId: string, projectId: string, payload: SegmentationQaRequest): Promise<SegmentationQaResult> =>
apiPost<SegmentationQaResult>(`/api/v1/segmentation/runs/${analysisRunId}/qa/reference?project_id=${encodeURIComponent(projectId)}`, payload),
}