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:
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user