This commit is contained in:
2026-02-15 11:11:30 +03:00
parent 4a0273a604
commit 026239e3bf
20 changed files with 60656 additions and 58958 deletions

View File

@@ -29,14 +29,16 @@
<Sidebar />
<!-- Main content area with TopNavbar -->
<div class="flex flex-col {isExpanded ? 'ml-60' : 'ml-16'} transition-all duration-200">
<div class="flex flex-col min-h-screen {isExpanded ? 'md:ml-60' : 'md:ml-16'} transition-all duration-200">
<!-- Top Navigation Bar -->
<TopNavbar />
<!-- Breadcrumbs -->
<Breadcrumbs />
<div class="mt-16">
<Breadcrumbs />
</div>
<!-- Page content -->
<div class="p-4 pt-20">
<div class="p-4 flex-grow">
<slot />
</div>

View File

@@ -10,7 +10,11 @@
* @UX_STATE: Loading -> Shows skeleton loader
* @UX_STATE: Loaded -> Shows dataset grid with mapping progress
* @UX_STATE: Error -> Shows error banner with retry button
* @UX_STATE: Selecting -> Checkboxes checked, floating action panel appears
* @UX_STATE: BulkAction-Modal -> Map Columns or Generate Docs modal open
* @UX_FEEDBACK: Clicking task status opens Task Drawer
* @UX_FEEDBACK: Mapped % column shows progress bar + percentage text
* @UX_FEEDBACK: Floating panel slides up from bottom when items selected
* @UX_RECOVERY: Refresh button reloads dataset list
*/
@@ -19,12 +23,45 @@
import { t } from '$lib/i18n';
import { openDrawerForTask } from '$lib/stores/taskDrawer.js';
import { api } from '$lib/api.js';
import { debounce } from '$lib/utils/debounce.js';
// State
let selectedEnv = null;
let datasets = [];
let isLoading = true;
let error = null;
// Pagination state
let currentPage = 1;
let pageSize = 10;
let totalPages = 1;
let total = 0;
// Selection state
let selectedIds = new Set();
let isAllSelected = false;
let isAllVisibleSelected = false;
// Search state
let searchQuery = '';
// Bulk action modal state
let showMapColumnsModal = false;
let showGenerateDocsModal = false;
let mapSourceType = 'postgresql';
let mapConnectionId = null;
let mapFileData = null;
let llmProvider = '';
let llmOptions = {};
// Environment options - will be loaded from API
let environments = [];
// Debounced search function
const debouncedSearch = debounce((query) => {
searchQuery = query;
loadDatasets();
}, 300);
// Load environments and datasets on mount
onMount(async () => {
@@ -59,7 +96,21 @@
isLoading = true;
error = null;
try {
const response = await api.getDatasets(selectedEnv);
const response = await api.getDatasets(selectedEnv, {
search: searchQuery || undefined,
page: currentPage,
page_size: pageSize
});
// Preserve selected IDs across pagination
const newSelectedIds = new Set();
response.datasets.forEach(d => {
if (selectedIds.has(d.id)) {
newSelectedIds.add(d.id);
}
});
selectedIds = newSelectedIds;
datasets = response.datasets.map(d => ({
id: d.id,
table_name: d.table_name,
@@ -75,6 +126,13 @@
} : null,
actions: ['map_columns'] // All datasets have map columns option
}));
// Update pagination state
total = response.total;
totalPages = response.total_pages;
// Update selection state
updateSelectionState();
} catch (err) {
error = err.message || 'Failed to load datasets';
console.error('[DatasetHub][Coherence:Failed]', err);
@@ -86,15 +144,155 @@
// Handle environment change
function handleEnvChange(event) {
selectedEnv = event.target.value;
currentPage = 1;
selectedIds.clear();
loadDatasets();
}
// Handle search input
function handleSearch(event) {
debouncedSearch(event.target.value);
}
// Handle page change
function handlePageChange(page) {
currentPage = page;
loadDatasets();
}
// Handle page size change
function handlePageSizeChange(event) {
pageSize = parseInt(event.target.value);
currentPage = 1;
loadDatasets();
}
// Update selection state based on current selection
function updateSelectionState() {
const visibleCount = datasets.length;
const totalCount = total;
isAllSelected = selectedIds.size === totalCount && totalCount > 0;
isAllVisibleSelected = selectedIds.size === visibleCount && visibleCount > 0;
}
// Handle checkbox change for individual dataset
function handleCheckboxChange(dataset, event) {
if (event.target.checked) {
selectedIds.add(dataset.id);
} else {
selectedIds.delete(dataset.id);
}
selectedIds = selectedIds; // Trigger reactivity
updateSelectionState();
}
// Handle select all
async function handleSelectAll() {
if (isAllSelected) {
selectedIds.clear();
} else {
// Get all dataset IDs from API (including non-visible ones)
try {
const response = await api.getDatasetIds(selectedEnv, {
search: searchQuery || undefined
});
response.dataset_ids.forEach(id => selectedIds.add(id));
} catch (err) {
console.error('[DatasetHub][Coherence:Failed] Failed to fetch all dataset IDs:', err);
// Fallback to selecting visible datasets if API fails
datasets.forEach(d => selectedIds.add(d.id));
}
}
selectedIds = selectedIds; // Trigger reactivity
updateSelectionState();
}
// Handle select visible
function handleSelectVisible() {
if (isAllVisibleSelected) {
datasets.forEach(d => selectedIds.delete(d.id));
} else {
datasets.forEach(d => selectedIds.add(d.id));
}
selectedIds = selectedIds; // Trigger reactivity
updateSelectionState();
}
// Handle action click
function handleAction(dataset, action) {
console.log(`[DatasetHub][Action] ${action} on dataset ${dataset.table_name}`);
if (action === 'map_columns') {
// Navigate to mapping interface
goto(`/mapper?dataset_id=${dataset.id}`);
// Show map columns modal
showMapColumnsModal = true;
mapSourceType = 'postgresql';
mapConnectionId = null;
mapFileData = null;
} else if (action === 'generate_docs') {
// Show generate docs modal
showGenerateDocsModal = true;
llmProvider = '';
llmOptions = {};
}
}
// Handle bulk map columns
async function handleBulkMapColumns() {
if (selectedIds.size === 0) return;
try {
const response = await api.postApi('/datasets/map-columns', {
env_id: selectedEnv,
dataset_ids: Array.from(selectedIds),
source_type: mapSourceType,
connection_id: mapConnectionId || undefined,
file_data: mapFileData || undefined
});
console.log('[DatasetHub][Action] Bulk map columns task created:', response.task_id);
// Close modal and open task drawer
showMapColumnsModal = false;
selectedIds.clear();
updateSelectionState();
if (response.task_id) {
openDrawerForTask(response.task_id);
}
} catch (err) {
console.error('[DatasetHub][Coherence:Failed]', err);
alert('Failed to create mapping task');
}
}
// Handle bulk generate docs
async function handleBulkGenerateDocs() {
if (selectedIds.size === 0) return;
if (!llmProvider) {
alert('Please select an LLM provider');
return;
}
try {
const response = await api.postApi('/datasets/generate-docs', {
env_id: selectedEnv,
dataset_ids: Array.from(selectedIds),
llm_provider: llmProvider,
options: llmOptions
});
console.log('[DatasetHub][Action] Bulk generate docs task created:', response.task_id);
// Close modal and open task drawer
showGenerateDocsModal = false;
selectedIds.clear();
updateSelectionState();
if (response.task_id) {
openDrawerForTask(response.task_id);
}
} catch (err) {
console.error('[DatasetHub][Coherence:Failed]', err);
alert('Failed to create documentation generation task');
}
}
@@ -111,7 +309,7 @@
if (!status) return '';
switch (status.toLowerCase()) {
case 'running':
return '<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/></svg>';
return '<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 0 0 1-8 8z"/></svg>';
case 'success':
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>';
case 'error':
@@ -162,6 +360,10 @@
@apply px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors;
}
.search-input {
@apply px-4 py-2 border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500;
}
.error-banner {
@apply bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 flex items-center justify-between;
}
@@ -170,6 +372,14 @@
@apply px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors;
}
.toolbar {
@apply flex items-center justify-between mb-4 gap-4;
}
.selection-buttons {
@apply flex items-center gap-2;
}
.dataset-grid {
@apply bg-white border border-gray-200 rounded-lg overflow-hidden;
}
@@ -186,6 +396,10 @@
@apply border-b-0;
}
.col-checkbox {
@apply col-span-1;
}
.col-table-name {
@apply col-span-3 font-medium text-gray-900;
}
@@ -203,7 +417,7 @@
}
.col-actions {
@apply col-span-2;
@apply col-span-1;
}
.mapping-progress {
@@ -233,6 +447,58 @@
.skeleton {
@apply animate-pulse bg-gray-200 rounded;
}
.floating-panel {
@apply fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg p-4 transition-transform transform translate-y-full;
}
.floating-panel.visible {
@apply transform translate-y-0;
}
.modal-overlay {
@apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50;
}
.modal {
@apply bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto;
}
.modal-header {
@apply px-6 py-4 border-b border-gray-200 flex items-center justify-between relative;
}
.close-modal-btn {
@apply absolute top-4 right-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-all;
}
.modal-body {
@apply px-6 py-4;
}
.modal-footer {
@apply px-6 py-4 border-t border-gray-200 flex justify-end gap-3;
}
.pagination {
@apply flex items-center justify-between px-4 py-3 bg-gray-50 border-t border-gray-200;
}
.pagination-info {
@apply text-sm text-gray-600;
}
.pagination-controls {
@apply flex items-center gap-2;
}
.page-btn {
@apply px-3 py-1 border border-gray-300 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed;
}
.page-btn.active {
@apply bg-blue-600 text-white border-blue-600;
}
</style>
<div class="container">
@@ -265,6 +531,7 @@
{#if isLoading}
<div class="dataset-grid">
<div class="grid-header">
<div class="col-checkbox skeleton h-4"></div>
<div class="col-table-name skeleton h-4"></div>
<div class="col-schema skeleton h-4"></div>
<div class="col-mapping skeleton h-4"></div>
@@ -273,6 +540,7 @@
</div>
{#each Array(5) as _}
<div class="grid-row">
<div class="col-checkbox skeleton h-4"></div>
<div class="col-table-name skeleton h-4"></div>
<div class="col-schema skeleton h-4"></div>
<div class="col-mapping skeleton h-4"></div>
@@ -290,10 +558,45 @@
<p>{$t.datasets?.empty || 'No datasets found'}</p>
</div>
{:else}
<!-- Toolbar -->
<div class="toolbar">
<div class="selection-buttons">
<button
class="action-btn"
on:click={handleSelectAll}
disabled={total === 0}
>
{isAllSelected ? 'Deselect All' : 'Select All'}
</button>
<button
class="action-btn"
on:click={handleSelectVisible}
disabled={datasets.length === 0}
>
{isAllVisibleSelected ? 'Deselect Visible' : 'Select Visible'}
</button>
{#if selectedIds.size > 0}
<span class="text-sm text-gray-600">
{selectedIds.size} selected
</span>
{/if}
</div>
<div>
<input
type="text"
class="search-input"
placeholder="Search datasets..."
on:input={handleSearch}
value={searchQuery}
/>
</div>
</div>
<!-- Dataset Grid -->
<div class="dataset-grid">
<!-- Grid Header -->
<div class="grid-header">
<div class="col-checkbox"></div>
<div class="col-table-name">{$t.datasets?.table_name || 'Table Name'}</div>
<div class="col-schema">{$t.datasets?.schema || 'Schema'}</div>
<div class="col-mapping">{$t.datasets?.mapped_fields || 'Mapped Fields'}</div>
@@ -304,9 +607,23 @@
<!-- Grid Rows -->
{#each datasets as dataset}
<div class="grid-row">
<!-- Checkbox -->
<div class="col-checkbox">
<input
type="checkbox"
checked={selectedIds.has(dataset.id)}
on:change={(e) => handleCheckboxChange(dataset, e)}
/>
</div>
<!-- Table Name -->
<div class="col-table-name">
{dataset.table_name}
<a
href={`/datasets/${dataset.id}?env_id=${selectedEnv}`}
class="text-blue-600 hover:text-blue-800 hover:underline"
>
{dataset.table_name}
</a>
</div>
<!-- Schema -->
@@ -355,21 +672,241 @@
<!-- Actions -->
<div class="col-actions">
<div class="flex space-x-2">
{#if dataset.actions.includes('map_columns')}
<button
class="action-btn primary"
on:click={() => handleAction(dataset, 'map_columns')}
aria-label={$t.datasets?.action_map_columns || 'Map Columns'}
>
{$t.datasets?.action_map_columns || 'Map Columns'}
</button>
{/if}
</div>
{#if dataset.actions.includes('map_columns')}
<button
class="action-btn primary"
on:click={() => handleAction(dataset, 'map_columns')}
aria-label={$t.datasets?.action_map_columns || 'Map Columns'}
>
{$t.datasets?.action_map_columns || 'Map Columns'}
</button>
{/if}
</div>
</div>
{/each}
</div>
<!-- Pagination -->
{#if totalPages > 1}
<div class="pagination">
<div class="pagination-info">
Showing {((currentPage - 1) * pageSize) + 1}-{Math.min(currentPage * pageSize, total)} of {total}
</div>
<div class="pagination-controls">
<button
class="page-btn"
on:click={() => handlePageChange(1)}
disabled={currentPage === 1}
>
First
</button>
<button
class="page-btn"
on:click={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
Previous
</button>
{#each Array.from({length: totalPages}, (_, i) => i + 1) as pageNum}
<button
class="page-btn {pageNum === currentPage ? 'active' : ''}"
on:click={() => handlePageChange(pageNum)}
>
{pageNum}
</button>
{/each}
<button
class="page-btn"
on:click={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
</button>
<button
class="page-btn"
on:click={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages}
>
Last
</button>
</div>
<div>
<select
class="env-dropdown"
value={pageSize}
on:change={handlePageSizeChange}
>
<option value={5}>5 per page</option>
<option value={10}>10 per page</option>
<option value={25}>25 per page</option>
<option value={50}>50 per page</option>
<option value={100}>100 per page</option>
</select>
</div>
</div>
{/if}
<!-- Floating Bulk Action Panel -->
{#if selectedIds.size > 0}
<div class="floating-panel visible">
<div class="flex items-center justify-between max-w-7xl mx-auto">
<div class="flex items-center gap-4">
<span class="font-medium">
{selectedIds.size} selected
</span>
</div>
<div class="flex gap-3">
<button
class="action-btn primary"
on:click={() => showMapColumnsModal = true}
>
Map Columns
</button>
<button
class="action-btn primary"
on:click={() => showGenerateDocsModal = true}
>
Generate Docs
</button>
<button
class="action-btn"
on:click={() => selectedIds.clear()}
>
Cancel
</button>
</div>
</div>
</div>
{/if}
{/if}
<!-- Map Columns Modal -->
{#if showMapColumnsModal}
<div class="modal-overlay" on:click={() => showMapColumnsModal = false}>
<div class="modal" on:click|stopPropagation>
<div class="modal-header">
<h2 class="text-xl font-bold">Bulk Column Mapping</h2>
<button on:click={() => showMapColumnsModal = false} class="close-modal-btn" aria-label="Close modal">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Source Type</label>
<select
class="env-dropdown w-full"
bind:value={mapSourceType}
>
<option value="postgresql">PostgreSQL Comments</option>
<option value="xlsx">XLSX File</option>
</select>
</div>
{#if mapSourceType === 'postgresql'}
<div>
<label class="block text-sm font-medium mb-2">Connection ID</label>
<input
type="text"
class="search-input w-full"
placeholder="Enter connection ID..."
bind:value={mapConnectionId}
/>
</div>
{:else}
<div>
<label class="block text-sm font-medium mb-2">XLSX File</label>
<input
type="file"
class="w-full"
accept=".xlsx,.xls"
bind:files={mapFileData}
/>
</div>
{/if}
<div>
<label class="block text-sm font-medium mb-2">Selected Datasets</label>
<div class="max-h-40 overflow-y-auto">
{#each Array.from(selectedIds) as id}
{#each datasets as d}
{#if d.id === id}
<div class="text-sm py-1 border-b border-gray-200">{d.table_name}</div>
{/if}
{/each}
{/each}
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="action-btn" on:click={() => showMapColumnsModal = false}>Cancel</button>
<button
class="action-btn primary"
on:click={handleBulkMapColumns}
disabled={selectedIds.size === 0}
>
Start Mapping
</button>
</div>
</div>
</div>
{/if}
<!-- Generate Docs Modal -->
{#if showGenerateDocsModal}
<div class="modal-overlay" on:click={() => showGenerateDocsModal = false}>
<div class="modal" on:click|stopPropagation>
<div class="modal-header">
<h2 class="text-xl font-bold">Bulk Documentation Generation</h2>
<button on:click={() => showGenerateDocsModal = false} class="close-modal-btn" aria-label="Close modal">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">LLM Provider</label>
<select
class="env-dropdown w-full"
bind:value={llmProvider}
>
<option value="">Select LLM provider...</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="cohere">Cohere</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2">Selected Datasets</label>
<div class="max-h-40 overflow-y-auto">
{#each Array.from(selectedIds) as id}
{#each datasets as d}
{#if d.id === id}
<div class="text-sm py-1 border-b border-gray-200">{d.table_name}</div>
{/if}
{/each}
{/each}
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="action-btn" on:click={() => showGenerateDocsModal = false}>Cancel</button>
<button
class="action-btn primary"
on:click={handleBulkGenerateDocs}
disabled={!llmProvider || selectedIds.size === 0}
>
Generate Documentation
</button>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,418 @@
<!-- [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 = '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 || '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(`/dashboards?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) {
// Placeholder: In real implementation, this would check if column has mapping
return column.description ? 100 : 0;
}
</script>
<style>
.container {
@apply max-w-7xl mx-auto px-4 py-6;
}
.header {
@apply flex items-center justify-between mb-6;
}
.back-btn {
@apply flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors;
}
.title {
@apply text-2xl font-bold text-gray-900;
}
.subtitle {
@apply text-sm text-gray-500 mt-1;
}
.detail-grid {
@apply grid grid-cols-1 lg:grid-cols-3 gap-6;
}
.detail-card {
@apply bg-white border border-gray-200 rounded-lg p-6;
}
.card-title {
@apply text-lg font-semibold text-gray-900 mb-4;
}
.info-row {
@apply flex justify-between py-2 border-b border-gray-100 last:border-0;
}
.info-label {
@apply text-sm text-gray-500;
}
.info-value {
@apply text-sm font-medium text-gray-900;
}
.columns-section {
@apply lg:col-span-2;
}
.columns-grid {
@apply grid grid-cols-1 md:grid-cols-2 gap-3;
}
.column-item {
@apply p-3 border border-gray-200 rounded-lg hover:border-blue-300 transition-colors;
}
.column-header {
@apply flex items-center justify-between mb-2;
}
.column-name {
@apply font-medium text-gray-900;
}
.column-type {
@apply text-xs px-2 py-1 rounded;
}
.column-meta {
@apply flex items-center gap-2 text-xs text-gray-500;
}
.column-description {
@apply text-sm text-gray-600 mt-2;
}
.mapping-badge {
@apply inline-flex items-center px-2 py-0.5 text-xs rounded-full;
}
.mapping-badge.mapped {
@apply bg-green-100 text-green-800;
}
.mapping-badge.unmapped {
@apply bg-gray-100 text-gray-600;
}
.linked-dashboards-list {
@apply space-y-2;
}
.linked-dashboard-item {
@apply flex items-center gap-3 p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors;
}
.dashboard-icon {
@apply w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600;
}
.dashboard-info {
@apply flex-1;
}
.dashboard-title {
@apply font-medium text-gray-900;
}
.dashboard-id {
@apply text-xs text-gray-500;
}
.sql-section {
@apply mt-6;
}
.sql-code {
@apply bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm font-mono;
}
.empty-state {
@apply py-8 text-center text-gray-500;
}
.skeleton {
@apply animate-pulse bg-gray-200 rounded;
}
.error-banner {
@apply bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 flex items-center justify-between;
}
.retry-btn {
@apply px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors;
}
</style>
<div class="container">
<!-- Header -->
<div class="header">
<div>
<button class="back-btn" 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="title mt-4">{dataset.table_name}</h1>
<p class="subtitle">{dataset.schema}{dataset.database}</p>
{:else if !isLoading}
<h1 class="title mt-4">{$t.datasets?.detail_title || 'Dataset Details'}</h1>
{/if}
</div>
<button class="retry-btn" on:click={loadDatasetDetail}>
{$t.common?.refresh || 'Refresh'}
</button>
</div>
<!-- Error Banner -->
{#if error}
<div class="error-banner">
<span>{error}</span>
<button class="retry-btn" on:click={loadDatasetDetail}>
{$t.common?.retry || 'Retry'}
</button>
</div>
{/if}
<!-- Loading State -->
{#if isLoading}
<div class="detail-grid">
<div class="detail-card">
<div class="skeleton h-6 w-1/2 mb-4"></div>
{#each Array(5) as _}
<div class="info-row">
<div class="skeleton h-4 w-20"></div>
<div class="skeleton h-4 w-32"></div>
</div>
{/each}
</div>
<div class="detail-card columns-section">
<div class="skeleton h-6 w-1/3 mb-4"></div>
<div class="columns-grid">
{#each Array(4) as _}
<div class="column-item">
<div class="skeleton h-4 w-full mb-2"></div>
<div class="skeleton h-3 w-16"></div>
</div>
{/each}
</div>
</div>
</div>
{:else if dataset}
<div class="detail-grid">
<!-- Dataset Info Card -->
<div class="detail-card">
<h2 class="card-title">{$t.datasets?.info || 'Dataset Information'}</h2>
<div class="info-row">
<span class="info-label">{$t.datasets?.table_name || 'Table Name'}</span>
<span class="info-value">{dataset.table_name}</span>
</div>
<div class="info-row">
<span class="info-label">{$t.datasets?.schema || 'Schema'}</span>
<span class="info-value">{dataset.schema || '-'}</span>
</div>
<div class="info-row">
<span class="info-label">{$t.datasets?.database || 'Database'}</span>
<span class="info-value">{dataset.database}</span>
</div>
<div class="info-row">
<span class="info-label">{$t.datasets?.columns_count || 'Columns'}</span>
<span class="info-value">{dataset.column_count}</span>
</div>
<div class="info-row">
<span class="info-label">{$t.datasets?.linked_dashboards || 'Linked Dashboards'}</span>
<span class="info-value">{dataset.linked_dashboard_count}</span>
</div>
{#if dataset.is_sqllab_view}
<div class="info-row">
<span class="info-label">{$t.datasets?.type || 'Type'}</span>
<span class="info-value">SQL Lab View</span>
</div>
{/if}
{#if dataset.created_on}
<div class="info-row">
<span class="info-label">{$t.datasets?.created || 'Created'}</span>
<span class="info-value">{new Date(dataset.created_on).toLocaleDateString()}</span>
</div>
{/if}
{#if dataset.changed_on}
<div class="info-row">
<span class="info-label">{$t.datasets?.updated || 'Updated'}</span>
<span class="info-value">{new Date(dataset.changed_on).toLocaleDateString()}</span>
</div>
{/if}
</div>
<!-- Linked Dashboards Card -->
{#if dataset.linked_dashboards && dataset.linked_dashboards.length > 0}
<div class="detail-card">
<h2 class="card-title">{$t.datasets?.linked_dashboards || 'Linked Dashboards'} ({dataset.linked_dashboard_count})</h2>
<div class="linked-dashboards-list">
{#each dataset.linked_dashboards as dashboard}
<div
class="linked-dashboard-item"
on:click={() => navigateToDashboard(dashboard.id)}
role="button"
tabindex="0"
>
<div class="dashboard-icon">
<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="dashboard-info">
<div class="dashboard-title">{dashboard.title}</div>
<div class="dashboard-id">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="detail-card columns-section">
<h2 class="card-title">{$t.datasets?.columns || 'Columns'} ({dataset.column_count})</h2>
{#if dataset.columns && dataset.columns.length > 0}
<div class="columns-grid">
{#each dataset.columns as column}
<div class="column-item">
<div class="column-header">
<span class="column-name">{column.name}</span>
{#if column.type}
<span class="column-type {getColumnTypeClass(column.type)}">{column.type}</span>
{/if}
</div>
<div class="column-meta">
{#if column.is_dttm}
<span class="text-xs text-green-600">📅 Date/Time</span>
{/if}
{#if !column.is_active}
<span class="text-xs text-gray-400">(Inactive)</span>
{/if}
<span class="mapping-badge {column.description ? 'mapped' : 'unmapped'}">
{column.description ? '✓ Mapped' : 'Unmapped'}
</span>
</div>
{#if column.description}
<p class="column-description">{column.description}</p>
{/if}
</div>
{/each}
</div>
{:else}
<div class="empty-state">
{$t.datasets?.no_columns || 'No columns found'}
</div>
{/if}
</div>
<!-- SQL Section (for SQL Lab views) -->
{#if dataset.sql}
<div class="detail-card sql-section lg:col-span-3">
<h2 class="card-title">{$t.datasets?.sql_query || 'SQL Query'}</h2>
<pre class="sql-code">{dataset.sql}</pre>
</div>
{/if}
</div>
{:else}
<div class="empty-state">
<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] -->

View File

@@ -68,6 +68,24 @@
addToast($t.settings?.save_failed || 'Failed to save settings', 'error');
}
}
// Placeholder functions for environment actions
function handleTestEnv(id) {
console.log(`[SettingsPage][Action] Test environment ${id}`);
addToast('Environment test started', 'info');
}
function editEnv(env) {
console.log(`[SettingsPage][Action] Edit environment ${env.id}`);
// TODO: Open edit modal
}
function handleDeleteEnv(id) {
if (confirm('Are you sure you want to delete this environment?')) {
console.log(`[SettingsPage][Action] Delete environment ${id}`);
// TODO: Call API to delete
}
}
</script>
<style>
@@ -265,6 +283,7 @@
</div>
{/if}
</div>
{/if}
</div>
<!-- [/DEF:SettingsPage:Page] -->