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

@@ -86,6 +86,22 @@ class SupersetDatasetsMixin:
"description": col.get("description", ""),
}
)
metrics = dataset.get("metrics", [])
metric_info = []
for metric in metrics:
metric_id = metric.get("id")
if metric_id is None:
continue
metric_info.append(
{
"id": int(metric_id),
"metric_name": metric.get("metric_name"),
"expression": metric.get("expression"),
"verbose_name": metric.get("verbose_name"),
"description": metric.get("description", ""),
"metric_type": metric.get("metric_type"),
}
)
linked_dashboards = []
try:
related_objects = self.network.request(
@@ -139,6 +155,8 @@ class SupersetDatasetsMixin:
"description": dataset.get("description", ""),
"columns": column_info,
"column_count": len(column_info),
"metrics": metric_info,
"metric_count": len(metric_info),
"sql": sql,
"linked_dashboards": linked_dashboards,
"linked_dashboard_count": len(linked_dashboards),
@@ -147,7 +165,7 @@ class SupersetDatasetsMixin:
"changed_on": dataset.get("changed_on"),
}
app_logger.reflect(
f"Got dataset {dataset_id} with {len(column_info)} columns and {len(linked_dashboards)} linked dashboards",
f"Got dataset {dataset_id} with {len(column_info)} columns, {len(metric_info)} metrics, and {len(linked_dashboards)} linked dashboards",
extra={"src": "get_dataset_detail"},
)
return result
@@ -169,12 +187,13 @@ class SupersetDatasetsMixin:
# @PURPOSE: Обновляет данные датасета по его ID.
# @SIDE_EFFECT: Modifies resource in upstream Superset environment.
# @RELATION: CALLS -> [APIClient]
def update_dataset(self, dataset_id: int, data: dict) -> dict:
def update_dataset(self, dataset_id: int, data: dict, override_columns: bool = False) -> dict:
with belief_scope("SupersetClient.update_dataset", f"id={dataset_id}"):
app_logger.info("[update_dataset][Enter] Updating dataset %s.", dataset_id)
endpoint = f"/dataset/{dataset_id}?override_columns={'true' if override_columns else 'false'}"
response = self.network.request(
method="PUT",
endpoint=f"/dataset/{dataset_id}",
endpoint=endpoint,
data=json.dumps(data),
headers={"Content-Type": "application/json"},
)

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

View File

@@ -318,5 +318,19 @@ class TaskManager:
add_log_callback=self._add_log,
)
# #endregion resume_task_with_password
# ── Dataset event delegates to JobLifecycle ──
# #region subscribe_dataset_events [TYPE Function] [C:2]
# @BRIEF Subscribe to dataset.updated events for an environment.
async def subscribe_dataset_events(self, env_id: str) -> asyncio.Queue:
return await self.lifecycle.subscribe_dataset_events(env_id)
# #endregion subscribe_dataset_events
# #region unsubscribe_dataset_events [TYPE Function] [C:2]
# @BRIEF Unsubscribe from dataset.updated events.
def unsubscribe_dataset_events(self, env_id: str, queue: asyncio.Queue):
self.lifecycle.unsubscribe_dataset_events(env_id, queue)
# #endregion unsubscribe_dataset_events
# #endregion TaskManager
# #endregion TaskManagerModule