Polish QA and export workspaces
This commit is contained in:
@@ -579,3 +579,11 @@ Added:
|
||||
- Added `scripts/verify_demo_export_workflow.sh` to smoke test demo seeding, QA/QC visibility, metadata/report/vector exports, export listing and artifact downloads through the browser-facing URL.
|
||||
- Added backend tests for export persistence, artifact writing, raster rejection, HTML report creation, canonical export envelopes and raw file downloads.
|
||||
- No migrations, new dependencies, live provider fetching, AI inference, LiDAR, Copilot, Training Studio or separate Reports module were introduced.
|
||||
|
||||
## Sprint 51 QA/QC and export workspace polish (2026-06-17)
|
||||
|
||||
- Polished the QA/QC workspace with persisted-check summary tiles, clearer empty state, quality-check cards and metric chips.
|
||||
- Polished the Exports workspace with artifact action groups, latest-export card, export history cards and preview panel framing.
|
||||
- Preserved existing API clients, callbacks and export/QA behavior.
|
||||
- Added regression coverage for the QA/QC and Exports workspace structure.
|
||||
- No API contracts, migrations, product capabilities, live provider fetching or AI/model dependency changes were introduced.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_quality_panel_uses_workbench_summary_and_cards() -> None:
|
||||
quality_panel = (
|
||||
ROOT / "frontend" / "src" / "components" / "quality" / "QualityResultsPanel.tsx"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert 'className="quality-results-panel"' in quality_panel
|
||||
assert 'className="quality-summary-grid"' in quality_panel
|
||||
assert 'className="quality-check-list"' in quality_panel
|
||||
assert 'className="quality-check-card"' in quality_panel
|
||||
assert "No persisted QA/QC results yet" in quality_panel
|
||||
assert "check.metrics.map" in quality_panel
|
||||
assert "fetch(" not in quality_panel
|
||||
|
||||
|
||||
def test_export_center_uses_artifact_actions_and_cards() -> None:
|
||||
export_center = (ROOT / "frontend" / "src" / "components" / "exports" / "ExportCenter.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
export_preview = (ROOT / "frontend" / "src" / "components" / "exports" / "ExportPreview.tsx").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert 'className="export-center"' in export_center
|
||||
assert 'className="export-action-grid"' in export_center
|
||||
assert 'className="latest-export-card"' in export_center
|
||||
assert 'className="export-list"' in export_center
|
||||
assert 'className="export-card"' in export_center
|
||||
assert "onExportDataset" in export_center
|
||||
assert "onExportDetectionRun" in export_center
|
||||
assert "onExportSegmentationRun" in export_center
|
||||
assert "download-only HTML artifact" in export_center
|
||||
assert 'className="export-preview-panel"' in export_preview
|
||||
@@ -2091,3 +2091,27 @@ Limitations:
|
||||
|
||||
Next recommended pass:
|
||||
- Deploy to Tower and verify the Data, Map and AI Labs workspaces on `http://192.168.10.150:1202`.
|
||||
|
||||
## Sprint 51 QA/QC and export workspace polish (2026-06-17)
|
||||
|
||||
Changed:
|
||||
- Refined the QA/QC workspace so persisted checks are shown as summary tiles, quality-check cards and metric chips instead of a raw nested list.
|
||||
- Refined the Exports workspace with grouped export actions, latest-export status, artifact history cards and a framed JSON/GeoJSON preview panel.
|
||||
- Kept the existing `useQualityWorkflow` and `useExportWorkflow` dataflow intact; no API client calls or backend contracts changed.
|
||||
- Added regression coverage for the polished QA/QC and Exports workspace structure.
|
||||
|
||||
Tested:
|
||||
- `cd frontend && npm run typecheck`
|
||||
- `cd backend && python -m pytest tests/test_sprint27_frontend_workflow_hooks.py tests/test_sprint30_workbench_components.py tests/test_sprint47_workbench_interaction_smoke.py -q`
|
||||
- `cd backend && python -m pytest tests/test_sprint51_quality_export_polish.py -q`
|
||||
- `cd frontend && npm run build`
|
||||
- `bash scripts/run_readiness_check.sh` (`207 passed`)
|
||||
|
||||
Open:
|
||||
- Commit, push, deploy to Tower and verify the browser-facing workbench.
|
||||
|
||||
Limitations:
|
||||
- This pass remains UI polish only. It does not add product capabilities, change API contracts, alter migrations, fetch live providers or enable new AI models.
|
||||
|
||||
Next recommended pass:
|
||||
- Verify QA/QC and Exports on the deployed Tower workbench, then continue with selected-object inspector detail tabs.
|
||||
|
||||
+4
-1
@@ -318,4 +318,7 @@ This file now starts with the current implementation status. Older preparation/b
|
||||
- [x] Replace the one-page workflow panel stack with a task-based workbench shell.
|
||||
- [x] Add persistent project/AOI/dataset/layer context.
|
||||
- [x] Move selected dataset details into a persistent inspector.
|
||||
- [ ] Deploy the shell refactor to Tower and run live browser smoke on port 1202.
|
||||
- [x] Deploy the shell refactor to Tower and run live browser smoke on port 1202.
|
||||
- [x] Polish Data, Map and AI Labs workspaces.
|
||||
- [x] Polish QA/QC and Exports workspaces.
|
||||
- [ ] Add selected-object inspector detail tabs for project, AOI, dataset, QA check, export and AI run context.
|
||||
|
||||
@@ -54,57 +54,103 @@ export function ExportCenter({
|
||||
onDownload,
|
||||
}: ExportCenterProps): JSX.Element {
|
||||
const canExportDataset = Boolean(selectedDataset && isVectorDatasetType(selectedDataset.dataset_type))
|
||||
const geojsonExports = exports.filter((item) => item.export_type.includes('geojson')).length
|
||||
const reportExports = exports.filter((item) => item.export_type === 'project_report_html').length
|
||||
|
||||
return (
|
||||
<section data-testid="export-center">
|
||||
<h2>Export Center</h2>
|
||||
<button type="button" onClick={onRefresh} disabled={!selectedProjectId || loadingExports} data-testid="refresh-exports">
|
||||
Refresh exports
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportProjectMetadata}
|
||||
disabled={!selectedProjectId || exporting}
|
||||
data-testid="export-project-metadata"
|
||||
>
|
||||
Export project metadata JSON
|
||||
</button>
|
||||
<button type="button" onClick={onExportProjectReport} disabled={!selectedProjectId || exporting}>
|
||||
Export project report HTML
|
||||
</button>
|
||||
<button type="button" onClick={onExportDataset} disabled={!canExportDataset || exporting}>
|
||||
Export selected vector GeoJSON
|
||||
</button>
|
||||
<button type="button" onClick={onExportDetectionRun} disabled={!selectedDetectionRunId || exporting}>
|
||||
Export selected detection run GeoJSON
|
||||
</button>
|
||||
<button type="button" onClick={onExportSegmentationRun} disabled={!selectedSegmentationRunId || exporting}>
|
||||
Export selected segmentation run GeoJSON
|
||||
</button>
|
||||
<section className="export-center" data-testid="export-center">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<h2>Export Center</h2>
|
||||
<p className="muted">Create handoff artifacts from persisted datasets, AI runs and project metadata.</p>
|
||||
</div>
|
||||
<span className="count-pill">{exports.length} exports</span>
|
||||
</div>
|
||||
<div className="quality-summary-grid">
|
||||
<div>
|
||||
<span>GeoJSON</span>
|
||||
<strong>{geojsonExports}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Reports</span>
|
||||
<strong>{reportExports}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Selected dataset</span>
|
||||
<strong>{selectedDataset?.name ?? 'none'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="export-action-grid">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={onRefresh}
|
||||
disabled={!selectedProjectId || loadingExports}
|
||||
data-testid="refresh-exports"
|
||||
>
|
||||
Refresh exports
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-action"
|
||||
onClick={onExportProjectMetadata}
|
||||
disabled={!selectedProjectId || exporting}
|
||||
data-testid="export-project-metadata"
|
||||
>
|
||||
Export project metadata JSON
|
||||
</button>
|
||||
<button type="button" className="secondary-action" onClick={onExportProjectReport} disabled={!selectedProjectId || exporting}>
|
||||
Export project report HTML
|
||||
</button>
|
||||
<button type="button" className="secondary-action" onClick={onExportDataset} disabled={!canExportDataset || exporting}>
|
||||
Export selected vector GeoJSON
|
||||
</button>
|
||||
<button type="button" className="secondary-action" onClick={onExportDetectionRun} disabled={!selectedDetectionRunId || exporting}>
|
||||
Export selected detection run GeoJSON
|
||||
</button>
|
||||
<button type="button" className="secondary-action" onClick={onExportSegmentationRun} disabled={!selectedSegmentationRunId || exporting}>
|
||||
Export selected segmentation run GeoJSON
|
||||
</button>
|
||||
</div>
|
||||
{exportError ? <p className="error">{exportError}</p> : null}
|
||||
{latestExport ? (
|
||||
<p>
|
||||
Latest export: {latestExport.export_type} {'->'} {latestExport.path}
|
||||
</p>
|
||||
<div className="latest-export-card">
|
||||
<span>Latest export</span>
|
||||
<strong>{latestExport.export_type}</strong>
|
||||
<p>{latestExport.path}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{exports.length === 0 ? <p>No exports registered yet.</p> : null}
|
||||
<ul>
|
||||
{exports.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<strong>No exports registered yet.</strong>
|
||||
<p>Create metadata, GeoJSON or HTML report artifacts once the active project has data to hand off.</p>
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="export-list">
|
||||
{exports.map((item) => (
|
||||
<li key={item.id}>
|
||||
<strong>{item.export_type}</strong>
|
||||
<div>status: {item.status}</div>
|
||||
<div>path: {item.storage_path}</div>
|
||||
<div>export id: {item.id}</div>
|
||||
{canPreviewJson(item) ? (
|
||||
<button type="button" onClick={() => onPreviewContent(item.id)}>
|
||||
Preview JSON content
|
||||
<li className="export-card" key={item.id}>
|
||||
<div className="export-card-header">
|
||||
<div>
|
||||
<strong>{item.export_type}</strong>
|
||||
<div className="entity-meta">
|
||||
<span>export id: {item.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={item.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>{item.status}</span>
|
||||
</div>
|
||||
<p>{item.storage_path}</p>
|
||||
<div className="button-row">
|
||||
{canPreviewJson(item) ? (
|
||||
<button type="button" className="secondary-action" onClick={() => onPreviewContent(item.id)}>
|
||||
Preview JSON content
|
||||
</button>
|
||||
) : (
|
||||
<span className="status-badge">download-only HTML artifact</span>
|
||||
)}
|
||||
<button type="button" className="primary-action" onClick={() => onDownload(item.id)}>
|
||||
Download artifact
|
||||
</button>
|
||||
) : (
|
||||
<div>Preview: download-only HTML artifact</div>
|
||||
)}
|
||||
<button type="button" onClick={() => onDownload(item.id)}>
|
||||
Download artifact
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -8,8 +8,14 @@ export function ExportPreview({ content }: ExportPreviewProps): JSX.Element | nu
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>Export Preview</h2>
|
||||
<section className="export-preview-panel">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<h2>Export Preview</h2>
|
||||
<p className="muted">JSON and GeoJSON content preview for the selected export artifact.</p>
|
||||
</div>
|
||||
<span className="count-pill">preview</span>
|
||||
</div>
|
||||
<pre className="job-result">{JSON.stringify(content, null, 2)}</pre>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -13,27 +13,84 @@ export function QualityResultsPanel({
|
||||
qualityChecksError,
|
||||
onRefresh,
|
||||
}: QualityResultsPanelProps): JSX.Element {
|
||||
const completedChecks = qualityChecks.filter((check) => check.status === 'ok' || check.status === 'completed').length
|
||||
const latestCheck = qualityChecks[0] ?? null
|
||||
|
||||
return (
|
||||
<section data-testid="quality-results-panel">
|
||||
<h2>QA/QC Results</h2>
|
||||
<button type="button" onClick={onRefresh} disabled={!selectedProjectId} data-testid="refresh-quality-results">
|
||||
Refresh QA/QC results
|
||||
</button>
|
||||
<section className="quality-results-panel" data-testid="quality-results-panel">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<h2>QA/QC Results</h2>
|
||||
<p className="muted">Persisted quality checks from dataset, detection and segmentation comparisons.</p>
|
||||
</div>
|
||||
<span className="count-pill">{qualityChecks.length} checks</span>
|
||||
</div>
|
||||
<div className="quality-summary-grid">
|
||||
<div>
|
||||
<span>Completed</span>
|
||||
<strong>{completedChecks}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Latest score</span>
|
||||
<strong>{latestCheck?.score ?? 'n/a'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Latest status</span>
|
||||
<strong>{latestCheck?.status ?? 'waiting'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={onRefresh}
|
||||
disabled={!selectedProjectId}
|
||||
data-testid="refresh-quality-results"
|
||||
>
|
||||
Refresh QA/QC results
|
||||
</button>
|
||||
</div>
|
||||
{qualityChecksError ? <p className="error">{qualityChecksError}</p> : null}
|
||||
{qualityChecks.length === 0 ? <p>No persisted QA/QC results yet</p> : null}
|
||||
<ul>
|
||||
{qualityChecks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<strong>No persisted QA/QC results yet</strong>
|
||||
<p>Run dataset, detection or segmentation QA against a reference layer to populate this workspace.</p>
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="quality-check-list">
|
||||
{qualityChecks.map((check) => (
|
||||
<li key={check.id}>
|
||||
<strong>{check.check_type}</strong>
|
||||
<div>status: {check.status}</div>
|
||||
<div>score: {check.score ?? 'n/a'}</div>
|
||||
<div>candidate: {check.candidate_dataset_id ?? 'n/a'}</div>
|
||||
<div>reference: {check.reference_dataset_id}</div>
|
||||
<div>quality check: {check.id}</div>
|
||||
<ul>
|
||||
<li className="quality-check-card" key={check.id}>
|
||||
<div className="quality-check-header">
|
||||
<div>
|
||||
<strong>{check.check_type}</strong>
|
||||
<div className="entity-meta">
|
||||
<span>candidate: {check.candidate_dataset_id ?? 'n/a'}</span>
|
||||
<span>reference: {check.reference_dataset_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={check.status === 'ok' || check.status === 'completed' ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{check.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="quality-score-row">
|
||||
<div>
|
||||
<span>Score</span>
|
||||
<strong>{check.score ?? 'n/a'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Metrics</span>
|
||||
<strong>{check.metrics.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Quality check</span>
|
||||
<strong>{check.id}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="metric-list">
|
||||
{check.metrics.map((metric) => (
|
||||
<li key={metric.id}>
|
||||
{metric.metric_key}: {metric.metric_value ?? 'n/a'}
|
||||
<span>{metric.metric_key}</span>
|
||||
<strong>{metric.metric_value ?? 'n/a'}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
+146
-5
@@ -847,7 +847,10 @@ section th {
|
||||
|
||||
.entity-list,
|
||||
.dataset-list,
|
||||
.model-list {
|
||||
.model-list,
|
||||
.quality-check-list,
|
||||
.export-list,
|
||||
.metric-list {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding: 0;
|
||||
@@ -858,7 +861,11 @@ section th {
|
||||
.dataset-card,
|
||||
.model-card,
|
||||
.lab-block,
|
||||
.layer-control-card {
|
||||
.layer-control-card,
|
||||
.quality-check-card,
|
||||
.export-card,
|
||||
.latest-export-card,
|
||||
.empty-state {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
@@ -898,12 +905,18 @@ button.entity-card {
|
||||
|
||||
.dataset-card,
|
||||
.model-card,
|
||||
.lab-block {
|
||||
.lab-block,
|
||||
.quality-check-card,
|
||||
.export-card,
|
||||
.latest-export-card,
|
||||
.empty-state {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.dataset-card-header,
|
||||
.model-card {
|
||||
.model-card,
|
||||
.quality-check-header,
|
||||
.export-card-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
@@ -937,6 +950,131 @@ button.entity-card {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.quality-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.quality-summary-grid > div {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.72rem;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.quality-summary-grid span,
|
||||
.quality-score-row span,
|
||||
.latest-export-card span,
|
||||
.metric-list span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.quality-summary-grid strong {
|
||||
display: block;
|
||||
margin-top: 0.28rem;
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.quality-score-row {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem 5rem minmax(0, 1fr);
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.quality-score-row > div {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.quality-score-row strong {
|
||||
display: block;
|
||||
margin-top: 0.22rem;
|
||||
overflow: hidden;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-list {
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.metric-list li {
|
||||
display: grid;
|
||||
gap: 0.16rem;
|
||||
margin-top: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.58rem;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.metric-list strong {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.75rem;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.empty-state p,
|
||||
.latest-export-card p,
|
||||
.export-card p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.export-action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
|
||||
.export-action-grid button {
|
||||
min-height: 3.2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.latest-export-card {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
margin-top: 0.75rem;
|
||||
border-color: rgba(15, 118, 110, 0.28);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.export-list {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.export-card p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.export-preview-panel .job-result {
|
||||
max-height: 38rem;
|
||||
}
|
||||
|
||||
.map-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 1fr) minmax(10rem, 0.8fr) minmax(10rem, 0.8fr) minmax(14rem, 1fr);
|
||||
@@ -1045,7 +1183,10 @@ button.entity-card {
|
||||
|
||||
.dataset-upload-form,
|
||||
.map-toolbar,
|
||||
.lab-form-grid {
|
||||
.lab-form-grid,
|
||||
.quality-summary-grid,
|
||||
.quality-score-row,
|
||||
.export-action-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user