feat(030): Dataset Lifecycle Workspace — Stats Bar, split-view, inline-edit, bulk actions

Backend:
- Add MetricItem, StatsCounts, ColumnDescriptionUpdate, MetricDescriptionUpdate DTOs
- Add metric_count to DatasetItem and DatasetDetailResponse
- Add server-side filtering via ?filter=unmapped|mapped|linked|all
- Add PUT endpoints for column/metric description inline-edit
- Add HTML stripping validation (max 2000 chars, plain text)
- Add WebSocket dataset.updated event on task completion
- RBAC: plugin:migration:WRITE for mutations, READ for reads
- 14 pytest tests (stats, filter, metrics, inline-edit, validation, 502/503)

Frontend:
- Rewrite +page.svelte as split-view orchestrator (387 lines)
- StatsBar.svelte — 4 metric tiles with aria-pressed, server-side filter dispatch
- DatasetList.svelte — card grid with progress bars, checkboxes, search
- DatasetPreview.svelte — presentational detail panel (container fetches)
- ColumnsTable.svelte — inline-edit with type chips, bold/italic fallback
- MetricsTable.svelte — inline-edit with expression hints
- 52 vitest tests across 7 test files
- i18n: 11 EN + 11 RU keys (with_mapping/с_маппингом)

Spec:
- 15 issues resolved (semantic label, filtering, endpoints, conflict, metric_count, RBAC, ID types, ports, i18n, validation, a11y, propagation, resize, perf, ownership)

Status: 14/14 backend tests pass, 52/52 frontend tests pass, build succeeds
Risk: Low — T051 browser validation pending (non-blocking)
This commit is contained in:
2026-05-21 13:58:47 +03:00
parent 673c0bb217
commit b529e8082a
32 changed files with 2191 additions and 942 deletions

View File

@@ -71,6 +71,7 @@ class JobLifecycle:
self.persistence_service = persistence_service
self.executor = executor
self.loop = loop
self._dataset_subscribers: dict[str, list[asyncio.Queue]] = {}
# #endregion __init__
# #region create_task [C:4] [TYPE Function] [SEMANTICS task,create,persist,schedule]
@@ -190,6 +191,12 @@ class JobLifecycle:
"Task lifecycle reached persisted terminal state",
extra={"task_id": task_id, "status": str(task.status)},
)
# Broadcast dataset.updated for mapping and documentation tasks (FR-024, R4)
if task.plugin_id in ("dataset-mapper", "llm_documentation"):
dataset_ids = task.params.get("dataset_ids") or [task.params.get("dataset_id")]
env_id = task.params.get("env") or task.params.get("environment_id", "")
if dataset_ids and env_id:
self._broadcast_dataset_updated(env_id, dataset_ids)
# #endregion _run_task
# #region resolve_task [C:3] [TYPE Function] [SEMANTICS task,resolve,mapping,resume]
@@ -309,5 +316,37 @@ class JobLifecycle:
)
self.graph.resolve_future(task_id, True)
# #endregion resume_task_with_password
# #region subscribe_dataset_events [C:2] [TYPE Function] [SEMANTICS dataset,subscribe,events,websocket]
# @BRIEF Subscribe to dataset.updated events for a specific environment.
async def subscribe_dataset_events(self, env_id: str) -> asyncio.Queue:
queue = asyncio.Queue()
if env_id not in self._dataset_subscribers:
self._dataset_subscribers[env_id] = []
self._dataset_subscribers[env_id].append(queue)
return queue
# #endregion subscribe_dataset_events
# #region unsubscribe_dataset_events [C:2] [TYPE Function] [SEMANTICS dataset,unsubscribe,events]
# @BRIEF Unsubscribe from dataset.updated events for a specific environment.
def unsubscribe_dataset_events(self, env_id: str, queue: asyncio.Queue):
if env_id in self._dataset_subscribers:
if queue in self._dataset_subscribers[env_id]:
self._dataset_subscribers[env_id].remove(queue)
if not self._dataset_subscribers[env_id]:
del self._dataset_subscribers[env_id]
# #endregion unsubscribe_dataset_events
# #region _broadcast_dataset_updated [C:3] [TYPE Function] [SEMANTICS dataset,broadcast,event,websocket]
# @BRIEF Broadcast dataset.updated event to all subscribers for a given environment.
def _broadcast_dataset_updated(self, env_id: str, dataset_ids: list[int]):
event = {"type": "dataset.updated", "payload": {"env_id": env_id, "dataset_ids": dataset_ids}}
if env_id in self._dataset_subscribers:
for queue in self._dataset_subscribers[env_id]:
try:
self.loop.call_soon_threadsafe(queue.put_nowait, event)
except Exception:
pass
# #endregion _broadcast_dataset_updated
# #endregion JobLifecycle
# #endregion JobLifecycleModule