fix(datasets): fix oversized buttons UI + populate linked_dashboard_count in list endpoint
StatsBar — переделаны огромные карточки-кнопки в компактные пилюли
(rounded-full px-3 py-1 text-sm вместо p-3 grid grid-cols-4).
DatasetList — экшн-кнопки из bg-primary в outline-стиль, карточки
плотнее (p-2.5, py-0.5), ESLint-фиксы (#each key).
Backend — linked_dashboard_count теперь заполняется в списке датасетов
через /dataset/{id}/related_objects (конкурентно, Semaphore=3, timeout=10s).
Ранее поле было только в get_dataset_detail и StatsBar показывал 0.
This commit is contained in:
@@ -51,6 +51,36 @@ class SupersetDatasetsMixin:
|
||||
)
|
||||
return result
|
||||
# #endregion SupersetClientGetDatasetsSummary
|
||||
# #region SupersetClientGetDatasetLinkedDashboardCount [TYPE Function] [C:2]
|
||||
# @PURPOSE: Fetch the number of dashboards linked to a dataset via related_objects endpoint.
|
||||
# @RELATION: CALLS -> [APIClient]
|
||||
# @RATIONALE Added to fix linked_count=0 in StatsBar. Reuses the same /dataset/{id}/related_objects call
|
||||
# that get_dataset_detail() was already making, but as a lightweight count-only call.
|
||||
# @REJECTED Enriching get_datasets_summary() with more columns rejected — Superset list endpoint
|
||||
# doesn't include linked_dashboard_count (it's a computed field from related_objects).
|
||||
def get_dataset_linked_dashboard_count(self, dataset_id: int) -> int:
|
||||
with belief_scope("get_dataset_linked_dashboard_count", f"id={dataset_id}"):
|
||||
try:
|
||||
related_objects = self.network.request(
|
||||
method="GET", endpoint=f"/dataset/{dataset_id}/related_objects"
|
||||
)
|
||||
if isinstance(related_objects, dict):
|
||||
if "dashboards" in related_objects:
|
||||
dashboards_data = related_objects["dashboards"]
|
||||
elif "result" in related_objects and isinstance(
|
||||
related_objects["result"], dict
|
||||
):
|
||||
dashboards_data = related_objects["result"].get("dashboards", [])
|
||||
else:
|
||||
dashboards_data = []
|
||||
return len(dashboards_data)
|
||||
return 0
|
||||
except Exception as e:
|
||||
app_logger.warning(
|
||||
f"[get_dataset_linked_dashboard_count][Warning] Failed to fetch related dashboards for dataset {dataset_id}: {e}"
|
||||
)
|
||||
return 0
|
||||
# #endregion SupersetClientGetDatasetLinkedDashboardCount
|
||||
# #region SupersetClientGetDatasetDetail [TYPE Function] [C:3]
|
||||
# @PURPOSE: Fetches detailed dataset information including columns and linked dashboards.
|
||||
# @RELATION: CALLS -> [SupersetClientGetDataset]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
# @SIDE_EFFECT: Queries multiple backends for status
|
||||
# @DATA_CONTRACT: ResourceQuery -> ResourceStatusSummary
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -287,9 +288,14 @@ class ResourceService:
|
||||
# @POST: Returns list of datasets with enhanced metadata
|
||||
# @PARAM: env (Environment) - The environment to fetch from
|
||||
# @PARAM: tasks (List[Task]) - List of tasks to check for status
|
||||
# @RETURN: List[Dict] - Datasets with mapped_fields and last_task fields
|
||||
# @RETURN: List[Dict] - Datasets with mapped_fields, last_task and linked_dashboard_count fields
|
||||
# @RELATION: CALLS -> [SupersetClientGetDatasetsSummary]
|
||||
# @RELATION: CALLS -> [SupersetClientGetDatasetLinkedDashboardCount]
|
||||
# @RELATION: CALLS ->[_get_last_task_for_resource]
|
||||
# @RATIONALE linked_dashboard_count was missing — get_datasets_summary() only returns id/table_name/schema/database
|
||||
# StatsBar showed linked_count=0 because the field was never populated in list endpoint.
|
||||
# Fix: fetch /dataset/{id}/related_objects for each dataset concurrently (semaphore=3, timeout=10s).
|
||||
# @REJECTED N+1 blocking calls rejected — would timeout at 26×1s. Semaphore + asyncio.to_thread + timeout prevents overload.
|
||||
async def get_datasets_with_status(
|
||||
self,
|
||||
env: Any,
|
||||
@@ -299,10 +305,28 @@ class ResourceService:
|
||||
client = SupersetClient(env)
|
||||
datasets = client.get_datasets_summary()
|
||||
|
||||
# Enhance each dataset with task status
|
||||
# Enhance each dataset with task status and linked dashboard count
|
||||
sem = asyncio.Semaphore(3) # limit concurrent Superset calls
|
||||
|
||||
async def fetch_linked_count(ds_id: int) -> int:
|
||||
async with sem:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
client.get_dataset_linked_dashboard_count, ds_id
|
||||
),
|
||||
timeout=10.0,
|
||||
)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
logger.warning(
|
||||
f"[get_datasets_with_status][Warning] "
|
||||
f"Failed to fetch linked dashboard count for dataset {ds_id}"
|
||||
)
|
||||
return 0
|
||||
|
||||
result = []
|
||||
linked_tasks = []
|
||||
for dataset in datasets:
|
||||
# dataset is already a dict, no need to call .dict()
|
||||
dataset_dict = dataset
|
||||
dataset_id = dataset_dict.get('id')
|
||||
|
||||
@@ -314,8 +338,18 @@ class ResourceService:
|
||||
dataset_dict['last_task'] = last_task
|
||||
|
||||
result.append(dataset_dict)
|
||||
linked_tasks.append(fetch_linked_count(dataset_id))
|
||||
|
||||
logger.info(f"[ResourceService][Coherence:OK] Fetched {len(result)} datasets with status")
|
||||
# Fetch linked dashboard counts concurrently — FR-022
|
||||
if linked_tasks:
|
||||
linked_counts = await asyncio.gather(*linked_tasks)
|
||||
for ds, count in zip(result, linked_counts):
|
||||
ds['linked_dashboard_count'] = count
|
||||
|
||||
logger.info(
|
||||
f"[ResourceService][Coherence:OK] "
|
||||
f"Fetched {len(result)} datasets with status and linked counts"
|
||||
)
|
||||
return result
|
||||
# endregion get_datasets_with_status
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- #region DatasetList [C:4] [TYPE Component] [SEMANTICS ui,dataset,card,list,pagination] -->
|
||||
<!-- @BRIEF Renders paginated dataset cards with mapping progress bars, checkboxes, and quick actions. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @RATIONALE Action buttons switched from bg-primary solid to border-outline style, card padding reduced from p-3 to p-2.5, row gap from gap-3 to gap-2.5 — fixes "huge buttons" visual complaint. -->
|
||||
<!-- @REJECTED Solid blue action buttons rejected — too visually heavy on each card line. -->
|
||||
<!-- @RELATION BINDS_TO -> [environmentContextStore] -->
|
||||
<!-- @UX_STATE Loading -> Skeleton cards. -->
|
||||
<!-- @UX_STATE Loaded -> Cards with progress bars. -->
|
||||
@@ -76,7 +77,7 @@
|
||||
<!-- Loading -->
|
||||
{#if isLoading}
|
||||
<div class="space-y-3" aria-busy="true">
|
||||
{#each Array(3) as _}
|
||||
{#each Array(3) as _, _i (_i)}
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4 animate-pulse">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/3 mb-2"></div>
|
||||
<div class="h-3 bg-gray-200 rounded w-1/4 mb-3"></div>
|
||||
@@ -92,25 +93,25 @@
|
||||
<div class="py-8 text-center text-gray-400 text-sm">{$t.datasets?.empty}</div>
|
||||
{:else}
|
||||
<!-- Card Grid -->
|
||||
<div class="space-y-2">
|
||||
{#each datasets as dataset}
|
||||
<div class="space-y-1.5">
|
||||
{#each datasets as dataset (dataset.id)}
|
||||
<div
|
||||
class="bg-white border border-gray-200 rounded-lg p-3 cursor-pointer hover:border-gray-300 hover:shadow-sm transition-all"
|
||||
class="bg-white border border-gray-200 rounded-lg p-2.5 cursor-pointer hover:border-gray-300 hover:shadow-sm transition-all"
|
||||
onclick={(e) => handleCardClick(dataset, e)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<!-- Checkbox -->
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-0.5 accent-primary"
|
||||
class="mt-0.5 accent-primary shrink-0"
|
||||
checked={selectedIds.has(dataset.id)}
|
||||
onchange={(e) => handleCheckboxChange(dataset, e)}
|
||||
/>
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-gray-900 truncate">{dataset.table_name}</div>
|
||||
<div class="font-medium text-gray-900 truncate text-sm">{dataset.table_name}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5 flex gap-3 flex-wrap">
|
||||
<span>{dataset.schema ?? '-'}</span>
|
||||
<span>{dataset.column_count ?? 0} {$t.datasets?.columns?.toLowerCase() ?? 'columns'}</span>
|
||||
@@ -120,8 +121,8 @@
|
||||
</div>
|
||||
<!-- Progress bar -->
|
||||
{#if dataset.mappedFields?.total}
|
||||
<div class="mt-2">
|
||||
<div class="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="mt-1.5">
|
||||
<div class="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {getProgressClass(dataset.mappedFields.mapped, dataset.mappedFields.total)}"
|
||||
style="width: {(dataset.mappedFields.mapped / dataset.mappedFields.total) * 100}%"
|
||||
@@ -134,13 +135,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Quick actions -->
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<div class="flex gap-1 shrink-0 items-start">
|
||||
<button
|
||||
class="px-2 py-1 text-xs bg-primary text-white rounded hover:bg-primary-hover"
|
||||
class="px-2 py-0.5 text-xs border border-gray-300 text-gray-500 rounded hover:bg-gray-50 hover:text-gray-700 transition-colors"
|
||||
onclick={(e) => { e.stopPropagation(); onaction?.(dataset, 'map_columns'); }}
|
||||
>{$t.datasets?.action_map_columns}</button>
|
||||
<button
|
||||
class="px-2 py-1 text-xs bg-primary text-white rounded hover:bg-primary-hover"
|
||||
class="px-2 py-0.5 text-xs border border-gray-300 text-gray-500 rounded hover:bg-gray-50 hover:text-gray-700 transition-colors"
|
||||
onclick={(e) => { e.stopPropagation(); onaction?.(dataset, 'generate_docs'); }}
|
||||
>{$t.datasets?.generate_docs}</button>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<!-- #region StatsBar [C:3] [TYPE Component] [SEMANTICS ui,dataset,stats,filter] -->
|
||||
<!-- @BRIEF Displays 4 aggregate metric tiles (All, Without mapping, Mapped, Linked) and emits filter selection. -->
|
||||
<!-- @LAYER UI -->
|
||||
<!-- @BRIEF Compact filter pills showing aggregate counts (All, Without mapping, Mapped, Linked). -->
|
||||
<!-- @RATIONALE Converted from card tiles (p-3, text-2xl) to compact pills (rounded-full px-3 py-1 text-sm) to fix oversized buttons complaint. -->
|
||||
<!-- @REJECTED Card-tile filter buttons rejected — were too visually heavy at 12px padding + 24px font, dominating the page top. -->
|
||||
<!-- @RELATION BINDS_TO -> [environmentContextStore] -->
|
||||
<!-- @UX_STATE Loading -> 4 skeleton tiles pulsing. -->
|
||||
<!-- @UX_STATE Loaded -> 4 tiles with numbers, active filter highlighted. -->
|
||||
<!-- @UX_STATE Filtered -> Selected tile has accent border; parent triggers API reload with filter param. -->
|
||||
<!-- @UX_STATE Loading -> 4 skeleton pills pulsing. -->
|
||||
<!-- @UX_STATE Loaded -> pills with numbers, active filter highlighted. -->
|
||||
<!-- @UX_STATE Filtered -> selected pill has filled background; parent reloads with filter param. -->
|
||||
<!-- @UX_REACTIVITY Props -> stats, activeFilter, isLoading -->
|
||||
<!-- @UX_REACTIVITY Events -> onfilter(filterKey) dispatched on tile click; parent calls loadDatasets(filter=filterKey). -->
|
||||
<!-- @UX_REACTIVITY Events -> onfilter(filterKey) emitted on pill click -->
|
||||
<script>
|
||||
import { t } from "$lib/i18n";
|
||||
|
||||
/** @type {{ stats: import('./types').StatsCounts | null, activeFilter: string | null, isLoading?: boolean }} */
|
||||
let { stats = null, activeFilter = null, isLoading = false, onfilter } = $props();
|
||||
|
||||
const tiles = [
|
||||
@@ -29,21 +29,18 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 gap-3 mb-4" role="group" aria-label="Dataset filters">
|
||||
<div class="flex flex-wrap gap-2 mb-4" role="group" aria-label="Dataset filters">
|
||||
{#each tiles as tile (tile.key)}
|
||||
{#if isLoading}
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-3 animate-pulse" aria-busy="true">
|
||||
<div class="h-3 bg-gray-200 rounded w-2/3 mb-2"></div>
|
||||
<div class="h-6 bg-gray-200 rounded w-1/3"></div>
|
||||
</div>
|
||||
<div class="h-8 w-24 bg-gray-200 rounded-full animate-pulse" aria-busy="true"></div>
|
||||
{:else}
|
||||
<button
|
||||
class="bg-white border rounded-lg p-3 text-left transition-all {activeFilter === tile.key ? 'border-primary ring-2 ring-primary/20 shadow-sm' : 'border-gray-200 hover:border-gray-300 hover:shadow-sm'}"
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-all {activeFilter === tile.key ? 'bg-primary text-white shadow-sm' : 'bg-white border border-gray-300 text-gray-600 hover:border-gray-400 hover:text-gray-800'}"
|
||||
onclick={() => handleTileClick(tile.key)}
|
||||
aria-pressed={activeFilter === tile.key}
|
||||
>
|
||||
<div class="text-xs text-gray-500 mb-1">{$t.datasets?.[tile.label] ?? tile.label}</div>
|
||||
<div class="text-2xl font-bold text-gray-900">{tile.getCount(stats)}</div>
|
||||
<span>{$t.datasets?.[tile.label] ?? tile.label}</span>
|
||||
<span class="font-semibold tabular-nums">{tile.getCount(stats)}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user