Files
ss-tools/frontend/src/routes/datasets/[id]/+page.svelte

394 lines
14 KiB
Svelte

<!-- [DEF:DatasetDetail:Page] -->
<script>
/**
* @TIER: CRITICAL
* @PURPOSE: Dataset Detail View - Shows detailed dataset information with columns, SQL, and linked dashboards
* @LAYER: UI
* @RELATION: BINDS_TO -> sidebarStore
* @INVARIANT: Always shows dataset details when loaded
*
* @UX_STATE: Loading -> Shows skeleton loader
* @UX_STATE: Loaded -> Shows dataset details with columns and linked dashboards
* @UX_STATE: Error -> Shows error banner with retry button
* @UX_FEEDBACK: Clicking linked dashboard navigates to dashboard detail
* @UX_RECOVERY: Refresh button reloads dataset details
*/
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { t } from "$lib/i18n";
import { api } from "$lib/api.js";
import { openDrawerForTask } from "$lib/stores/taskDrawer.js";
// Get dataset ID from URL params
$: datasetId = $page.params.id;
$: envId = $page.url.searchParams.get("env_id") || "";
// State
let dataset = null;
let isLoading = true;
let error = null;
// Load dataset details on mount
onMount(async () => {
await loadDatasetDetail();
});
// Load dataset details from API
async function loadDatasetDetail() {
if (!datasetId || !envId) {
error = $t.datasets?.missing_context || "Missing dataset ID or environment ID";
isLoading = false;
return;
}
isLoading = true;
error = null;
try {
const response = await api.getDatasetDetail(envId, datasetId);
dataset = response;
} catch (err) {
error = err.message || ($t.datasets?.load_detail_failed || "Failed to load dataset details");
console.error("[DatasetDetail][Coherence:Failed]", err);
} finally {
isLoading = false;
}
}
// Navigate to linked dashboard
function navigateToDashboard(dashboardId) {
goto(`/dashboards/${dashboardId}?env_id=${envId}`);
}
// Navigate back to dataset list
function goBack() {
goto(`/datasets?env_id=${envId}`);
}
// Get column type icon/color
function getColumnTypeClass(type) {
if (!type) return "text-gray-500";
const lowerType = type.toLowerCase();
if (
lowerType.includes("int") ||
lowerType.includes("float") ||
lowerType.includes("num")
) {
return "text-blue-600 bg-blue-50";
} else if (lowerType.includes("date") || lowerType.includes("time")) {
return "text-green-600 bg-green-50";
} else if (
lowerType.includes("str") ||
lowerType.includes("text") ||
lowerType.includes("char")
) {
return "text-purple-600 bg-purple-50";
} else if (lowerType.includes("bool")) {
return "text-orange-600 bg-orange-50";
}
return "text-gray-600 bg-gray-50";
}
// Get mapping progress percentage
function getMappingProgress(column) {
return column.description ? 100 : 0;
}
</script>
<div class="max-w-7xl mx-auto px-4 py-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<button
class="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors"
on:click={goBack}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
{$t.common?.back || "Back to Datasets"}
</button>
{#if dataset}
<h1 class="text-2xl font-bold text-gray-900 mt-4">
{dataset.table_name}
</h1>
<p class="text-sm text-gray-500 mt-1">
{dataset.schema} • {dataset.database}
</p>
{:else if !isLoading}
<h1 class="text-2xl font-bold text-gray-900 mt-4">
{$t.datasets?.detail_title || "Dataset Details"}
</h1>
{/if}
</div>
<button
class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors"
on:click={loadDatasetDetail}
>
{$t.common?.refresh || "Refresh"}
</button>
</div>
<!-- Error Banner -->
{#if error}
<div
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 flex items-center justify-between"
>
<span>{error}</span>
<button
class="px-4 py-2 bg-destructive text-white rounded hover:bg-destructive-hover transition-colors"
on:click={loadDatasetDetail}
>
{$t.common?.retry || "Retry"}
</button>
</div>
{/if}
<!-- Loading State -->
{#if isLoading}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="bg-white border border-gray-200 rounded-lg p-6">
<div class="animate-pulse bg-gray-200 rounded h-6 w-1/2 mb-4"></div>
{#each Array(5) as _}
<div
class="flex justify-between py-2 border-b border-gray-100 last:border-0"
>
<div class="animate-pulse bg-gray-200 rounded h-4 w-20"></div>
<div class="animate-pulse bg-gray-200 rounded h-4 w-32"></div>
</div>
{/each}
</div>
<div class="bg-white border border-gray-200 rounded-lg p-6 lg:col-span-2">
<div class="animate-pulse bg-gray-200 rounded h-6 w-1/3 mb-4"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each Array(4) as _}
<div class="p-3 border border-gray-200 rounded-lg">
<div
class="animate-pulse bg-gray-200 rounded h-4 w-full mb-2"
></div>
<div class="animate-pulse bg-gray-200 rounded h-3 w-16"></div>
</div>
{/each}
</div>
</div>
</div>
{:else if dataset}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Dataset Info Card -->
<div class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">
{$t.datasets?.info || "Dataset Information"}
</h2>
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-500"
>{$t.datasets?.table_name || "Table Name"}</span
>
<span class="text-sm font-medium text-gray-900"
>{dataset.table_name}</span
>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-500"
>{$t.datasets?.schema || "Schema"}</span
>
<span class="text-sm font-medium text-gray-900"
>{dataset.schema || "-"}</span
>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-500"
>{$t.datasets?.database || "Database"}</span
>
<span class="text-sm font-medium text-gray-900"
>{dataset.database}</span
>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-500"
>{$t.datasets?.columns_count || "Columns"}</span
>
<span class="text-sm font-medium text-gray-900"
>{dataset.column_count}</span
>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-500"
>{$t.datasets?.linked_dashboards || "Linked Dashboards"}</span
>
<span class="text-sm font-medium text-gray-900"
>{dataset.linked_dashboard_count}</span
>
</div>
{#if dataset.is_sqllab_view}
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-500"
>{$t.datasets?.type || "Type"}</span
>
<span class="text-sm font-medium text-gray-900">{$t.datasets?.sql_lab_view || "SQL Lab View"}</span>
</div>
{/if}
{#if dataset.created_on}
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-500"
>{$t.datasets?.created || "Created"}</span
>
<span class="text-sm font-medium text-gray-900"
>{new Date(dataset.created_on).toLocaleDateString()}</span
>
</div>
{/if}
{#if dataset.changed_on}
<div class="flex justify-between py-2">
<span class="text-sm text-gray-500"
>{$t.datasets?.updated || "Updated"}</span
>
<span class="text-sm font-medium text-gray-900"
>{new Date(dataset.changed_on).toLocaleDateString()}</span
>
</div>
{/if}
</div>
<!-- Linked Dashboards Card -->
{#if dataset.linked_dashboards && dataset.linked_dashboards.length > 0}
<div class="bg-white border border-gray-200 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">
{$t.datasets?.linked_dashboards || "Linked Dashboards"} ({dataset.linked_dashboard_count})
</h2>
<div class="space-y-2">
{#each dataset.linked_dashboards as dashboard}
<div
class="flex items-center gap-3 p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
on:click={() => navigateToDashboard(dashboard.id)}
role="button"
tabindex="0"
>
<div
class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
</div>
<div class="flex-1">
<div class="font-medium text-gray-900">{dashboard.title}</div>
<div class="text-xs text-gray-500">
ID: {dashboard.id}{#if dashboard.slug}
• {dashboard.slug}{/if}
</div>
</div>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-gray-400"
>
<path d="M9 18l6-6-6-6" />
</svg>
</div>
{/each}
</div>
</div>
{/if}
<!-- Columns Card -->
<div class="bg-white border border-gray-200 rounded-lg p-6 lg:col-span-2">
<h2 class="text-lg font-semibold text-gray-900 mb-4">
{$t.datasets?.columns || "Columns"} ({dataset.column_count})
</h2>
{#if dataset.columns && dataset.columns.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each dataset.columns as column}
<div
class="p-3 border border-gray-200 rounded-lg hover:border-blue-300 transition-colors"
>
<div class="flex items-center justify-between mb-2">
<span class="font-medium text-gray-900">{column.name}</span>
{#if column.type}
<span
class="text-xs px-2 py-1 rounded {getColumnTypeClass(
column.type,
)}">{column.type}</span
>
{/if}
</div>
<div class="flex items-center gap-2 text-xs text-gray-500">
{#if column.is_dttm}
<span class="text-xs text-green-600">📅 {$t.datasets?.date_time || "Date/Time"}</span>
{/if}
{#if !column.is_active}
<span class="text-xs text-gray-400">({$t.datasets?.inactive || "Inactive"})</span>
{/if}
<span
class="inline-flex items-center px-2 py-0.5 text-xs rounded-full {column.description
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'}"
>
{column.description ? `✓ ${$t.datasets?.mapped || "Mapped"}` : ($t.datasets?.unmapped || "Unmapped")}
</span>
</div>
{#if column.description}
<p class="text-sm text-gray-600 mt-2">{column.description}</p>
{/if}
</div>
{/each}
</div>
{:else}
<div class="py-8 text-center text-gray-500">
{$t.datasets?.no_columns || "No columns found"}
</div>
{/if}
</div>
<!-- SQL Section (for SQL Lab views) -->
{#if dataset.sql}
<div
class="bg-white border border-gray-200 rounded-lg p-6 mt-6 lg:col-span-3"
>
<h2 class="text-lg font-semibold text-gray-900 mb-4">
{$t.datasets?.sql_query || "SQL Query"}
</h2>
<pre
class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm font-mono">{dataset.sql}</pre>
</div>
{/if}
</div>
{:else}
<div class="py-8 text-center text-gray-500">
<svg
class="w-16 h-16 mx-auto mb-4 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 3h18v18H3V3zm16 16V5H5v14h14z" />
</svg>
<p>{$t.datasets?.not_found || "Dataset not found"}</p>
</div>
{/if}
</div>
<!-- [/DEF:DatasetDetail:Page] -->