fix
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
418
frontend/src/routes/datasets/[id]/+page.svelte
Normal file
418
frontend/src/routes/datasets/[id]/+page.svelte
Normal 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] -->
|
||||
@@ -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] -->
|
||||
|
||||
Reference in New Issue
Block a user