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)
84 lines
3.8 KiB
Svelte
84 lines
3.8 KiB
Svelte
<!-- #region DatasetPreview [C:3] [TYPE Component] [SEMANTICS ui,dataset,detail,columns,metrics] -->
|
|
<!-- @BRIEF Right panel showing dataset detail: header info, columns table, metrics table, linked dashboards. Presentational. -->
|
|
<!-- @LAYER UI -->
|
|
<!-- @RELATION DEPENDS_ON -> [ColumnsTable] -->
|
|
<!-- @RELATION DEPENDS_ON -> [MetricsTable] -->
|
|
<!-- @UX_STATE NoSelection -> "Select a dataset" placeholder. -->
|
|
<!-- @UX_STATE Loading -> Skeleton. -->
|
|
<!-- @UX_STATE Loaded -> Full detail. -->
|
|
<!-- @UX_STATE Error -> Error banner with retry. -->
|
|
<script>
|
|
import { t } from "$lib/i18n";
|
|
import ColumnsTable from "./ColumnsTable.svelte";
|
|
import MetricsTable from "./MetricsTable.svelte";
|
|
|
|
let { dataset = null, isLoading = false, error = null, onretry, envId } = $props();
|
|
</script>
|
|
|
|
{#if isLoading}
|
|
<div class="p-6 space-y-4 animate-pulse" aria-busy="true">
|
|
<div class="h-5 bg-gray-200 rounded w-1/2"></div>
|
|
<div class="h-4 bg-gray-200 rounded w-1/3"></div>
|
|
<div class="space-y-2 mt-4">
|
|
{#each Array(3) as _, i (i)}
|
|
<div class="h-3 bg-gray-100 rounded w-full"></div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{:else if error}
|
|
<div class="p-6 flex flex-col items-center justify-center gap-3" role="alert">
|
|
<div class="bg-red-50 border border-red-300 text-red-700 px-4 py-3 rounded text-sm">{error}</div>
|
|
{#if onretry}
|
|
<button class="px-3 py-1 text-sm bg-primary text-white rounded hover:bg-primary-hover" onclick={onretry}>
|
|
{$t.common?.retry ?? 'Retry'}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{:else if !dataset}
|
|
<div class="p-6 flex items-center justify-center min-h-[300px] text-gray-400 text-sm">
|
|
<div class="text-center">
|
|
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>
|
|
<p>{$t.datasets?.no_selection ?? 'Select a dataset from the list'}</p>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="overflow-y-auto h-full">
|
|
<!-- Header -->
|
|
<div class="px-6 pt-5 pb-3 border-b border-gray-200">
|
|
<h2 class="text-lg font-bold text-gray-900">{dataset.table_name ?? dataset.id}</h2>
|
|
<div class="flex gap-4 text-sm text-gray-500 mt-1 flex-wrap">
|
|
{#if dataset.schema}<span>📁 {dataset.schema}</span>{/if}
|
|
<span>{dataset.column_count ?? 0} {$t.datasets?.columns?.toLowerCase() ?? 'columns'}</span>
|
|
<span>{dataset.metric_count ?? 0} {$t.datasets?.metric_count ?? 'metrics'}</span>
|
|
{#if dataset.linked_dashboard_count > 0}
|
|
<span>📊 {dataset.linked_dashboard_count} dashboards</span>
|
|
{/if}
|
|
</div>
|
|
{#if dataset.linked_dashboards?.length}
|
|
<div class="flex gap-1.5 mt-2 flex-wrap">
|
|
{#each dataset.linked_dashboards as dash}
|
|
<a href={`/dashboards/${dash.slug || dash.id}`} class="inline-block px-2 py-0.5 text-xs bg-indigo-50 text-indigo-700 rounded-full hover:bg-indigo-100">
|
|
{dash.title}
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#if dataset.created_on}
|
|
<div class="text-xs text-gray-400 mt-1">{$t.dashboard?.created ?? 'Created'}: {dataset.created_on}</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Columns -->
|
|
<div class="px-6 py-4 border-b border-gray-100">
|
|
<h3 class="text-sm font-semibold text-gray-700 mb-2">{$t.datasets?.columns ?? 'Columns'} ({dataset.column_count ?? 0})</h3>
|
|
<ColumnsTable columns={dataset.columns ?? []} datasetId={dataset.id} {envId} />
|
|
</div>
|
|
|
|
<!-- Metrics -->
|
|
<div class="px-6 py-4">
|
|
<h3 class="text-sm font-semibold text-gray-700 mb-2">{$t.datasets?.metrics ?? 'Metrics'} ({dataset.metric_count ?? 0})</h3>
|
|
<MetricsTable metrics={dataset.metrics ?? []} datasetId={dataset.id} {envId} />
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
<!-- #endregion DatasetPreview --> |