Files
ss-tools/frontend/src/routes/datasets/+page.svelte
busya 2bf686674e feat(datasets): add SQL template chips for column mapping modal
Как на maintenance с шаблоном баннера — добавил чипсы-шаблоны
SQL-запросов над textarea в модалке маппинга колонок:

- information_schema — вставляет запрос к information_schema
  с JOIN pg_description для получения описаний колонок
- Custom table — вставляет запрос к кастомной таблице маппинга
- Clear — очищает поле

Чипсы в стиле rounded-full с hover-эффектами, подпись
"Шаблоны:" и title-tooltip на каждой кнопке.

i18n: 6 новых ключей (sql_templates, sql_template_*).
2026-05-24 09:51:31 +03:00

467 lines
20 KiB
Svelte

<!-- #region DatasetHub [C:5] [TYPE Page] [SEMANTICS sveltekit,dataset,workspace] -->
<script>
import { onMount, onDestroy } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { fromStore } from "svelte/store";
import { t } from "$lib/i18n";
import { api } from "$lib/api.js";
import HelpTooltip from "$lib/ui/HelpTooltip.svelte";
import { debounce } from "$lib/utils/debounce.js";
import {
environmentContextStore,
initializeEnvironmentContext,
setSelectedEnvironment,
} from "$lib/stores/environmentContext.js";
import {
openDrawerForTaskIfPreferred,
} from "$lib/stores/taskDrawer.js";
import StatsBar from "./StatsBar.svelte";
import DatasetList from "./DatasetList.svelte";
import DatasetPreview from "./DatasetPreview.svelte";
const envCtx = fromStore(environmentContextStore);
let selectedEnv = $state(null);
let datasets = $state([]);
let dsError = $state(null), dsLoading = $state(true);
let stats = $state(null);
let activeFilter = $state(null);
let page = $state(1), pageSize = $state(10), total = $state(0), totalPages = $state(1);
let searchQuery = $state("");
let selectedIds = $state(new SvelteSet());
let selectedDatasetId = $state(null), detailData = $state(null), detailLoading = $state(false), detailError = $state(null);
let showMapColumnsModal = $state(false), showGenerateDocsModal = $state(false);
let mapSourceType = $state("sqllab"), mapDatabases = $state([]), mapDatabaseId = $state(null), mapSqlQuery = $state(""), mapFileData = $state(null);
let llmProvider = $state(""), llmOptions = $state({});
let isMobile = $state(false), mobileSlideOpen = $state(false);
let ws = null;
// Debounced search
const debouncedSearch = debounce((q) => { searchQuery = q; page = 1; loadDatasets(); }, 300);
async function loadDatasets(filter, p) {
if (!selectedEnv) return;
dsLoading = true;
dsError = null;
try {
const resp = await api.getDatasets(selectedEnv, {
search: searchQuery || undefined,
filter: filter ?? activeFilter ?? undefined,
page: p ?? page,
page_size: pageSize,
});
datasets = resp.datasets.map(d => ({
id: d.id,
table_name: d.table_name,
schema: d.schema,
database: d.database,
column_count: d.mapped_fields?.total ?? 0,
metric_count: d.metric_count ?? 0,
mappedFields: d.mapped_fields ? { total: d.mapped_fields.total, mapped: d.mapped_fields.mapped } : null,
lastTask: d.last_task ? { status: d.last_task.status, id: d.last_task.task_id } : null,
}));
stats = resp.stats ?? null;
total = resp.total ?? 0;
totalPages = resp.total_pages ?? 1;
page = resp.page ?? 1;
// Clean selected IDs
const keepIds = new Set();
datasets.forEach(d => { if (selectedIds.has(d.id)) keepIds.add(d.id); });
selectedIds = keepIds;
} catch (e) {
dsError = e.message || $t.datasets?.load_failed;
} finally {
dsLoading = false;
}
}
async function loadDetail(datasetId) {
if (!datasetId) return;
detailLoading = true;
detailError = null;
try {
const d = await api.getDatasetDetail(selectedEnv, datasetId);
detailData = { ...d, schema: d.schema_name ?? d.schema };
} catch (e) {
detailError = e.message || $t.datasets?.load_detail_failed;
} finally {
detailLoading = false;
}
}
function selectDataset(id) {
selectedDatasetId = id;
detailData = null;
detailError = null;
if (isMobile) mobileSlideOpen = true;
loadDetail(id);
}
function closeDetail() {
selectedDatasetId = null;
detailData = null;
detailError = null;
mobileSlideOpen = false;
}
function handleFilter(filterKey) {
activeFilter = filterKey;
page = 1;
loadDatasets(filterKey, 1);
}
function handlePageChange(p) {
page = p;
loadDatasets(undefined, p);
}
function handleAction(dataset, action) {
if (action === "map_columns") {
selectedIds = new SvelteSet([dataset.id]);
showMapColumnsModal = true;
mapSourceType = "sqllab";
mapDatabaseId = null;
mapSqlQuery = "";
mapFileData = null;
// Fetch databases on open
if (selectedEnv) {
api.getEnvironmentDatabases(selectedEnv).then(dbs => mapDatabases = dbs ?? []).catch(() => {});
}
} else if (action === "generate_docs") {
selectedIds = new SvelteSet([dataset.id]);
showGenerateDocsModal = true;
llmProvider = "";
llmOptions = {};
}
}
function handleCheckbox(dataset, checked) {
if (checked === "all") {
datasets.forEach(d => selectedIds.add(d.id));
selectedIds = new Set(selectedIds);
} else if (checked === "clear-all") {
selectedIds = new SvelteSet();
} else if (checked) {
selectedIds.add(dataset.id);
selectedIds = new Set(selectedIds);
} else {
selectedIds.delete(dataset.id);
selectedIds = new Set(selectedIds);
}
}
// Bulk actions
async function handleBulkMapColumns() {
if (selectedIds.size === 0) return;
if (mapSourceType === "sqllab" && !mapDatabaseId) return;
if (mapSourceType === "xlsx" && (!mapFileData || mapFileData.length === 0)) return;
try {
let fileData = null;
if (mapSourceType === "xlsx" && mapFileData?.length) fileData = mapFileData[0].name;
const resp = await api.postApi("/datasets/map-columns", {
env_id: selectedEnv, dataset_ids: [...selectedIds],
source_type: mapSourceType, database_id: mapDatabaseId || undefined, sql_query: mapSqlQuery || undefined, file_data: fileData || undefined,
});
showMapColumnsModal = false;
selectedIds = new SvelteSet();
if (resp.task_id) openDrawerForTaskIfPreferred(resp.task_id);
} catch (e) {
alert($t.datasets?.mapping_task_failed);
}
}
async function handleBulkGenerateDocs() {
if (selectedIds.size === 0) return;
if (!llmProvider) { alert($t.datasets?.select_llm_provider); return; }
try {
const resp = await api.postApi("/datasets/generate-docs", {
env_id: selectedEnv, dataset_ids: [...selectedIds], llm_provider: llmProvider, options: llmOptions,
});
showGenerateDocsModal = false;
selectedIds = new SvelteSet();
if (resp.task_id) openDrawerForTaskIfPreferred(resp.task_id);
} catch (e) {
alert($t.datasets?.docs_task_failed);
}
}
// WebSocket
function connectWs() {
if (!selectedEnv) return;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const baseUrl = `${proto}//${window.location.host}`;
ws = new WebSocket(`${baseUrl}/ws/datasets/${selectedEnv}`);
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === "dataset.updated") {
loadDatasets();
// Refresh detail too if it's one of the updated datasets
if (selectedDatasetId && msg.payload?.dataset_ids?.includes(selectedDatasetId)) {
loadDetail(selectedDatasetId);
}
}
} catch {}
};
ws.onerror = () => {};
}
function disconnectWs() {
if (ws) { ws.close(); ws = null; }
}
// Mobile check
function checkMobile() { isMobile = window.innerWidth < 768; }
let resizeHandler = null;
onMount(async () => {
await initializeEnvironmentContext();
selectedEnv = envCtx.current?.selectedEnvId;
if (selectedEnv) {
setSelectedEnvironment(selectedEnv);
await loadDatasets();
connectWs();
}
checkMobile();
resizeHandler = () => { checkMobile(); };
window.addEventListener("resize", resizeHandler);
document.addEventListener("keydown", handleEscape);
});
onDestroy(() => {
disconnectWs();
if (resizeHandler) window.removeEventListener("resize", resizeHandler);
document.removeEventListener("keydown", handleEscape);
});
function handleEscape(e) { if (e.key === "Escape" && mobileSlideOpen) closeDetail(); }
// Environment change
$effect(() => {
const next = envCtx.current?.selectedEnvId;
if (next && next !== selectedEnv) {
selectedEnv = next;
setSelectedEnvironment(next);
page = 1;
selectedIds = new SvelteSet();
closeDetail();
disconnectWs();
loadDatasets();
connectWs();
}
});
</script>
<div class="max-w-screen-2xl mx-auto px-4 pt-4 pb-6">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-900">{$t.nav?.datasets}</h1>
<div class="flex items-center gap-3">
<span class="px-2 py-1 text-xs rounded border border-gray-300 bg-gray-50 text-gray-600">
{$t.datasets?.all}: {stats?.total ?? 0}
</span>
<button class="px-3 py-1 text-sm bg-primary text-white rounded hover:bg-primary-hover" title={$t.datasets?.refresh_tip} onclick={() => loadDatasets()}>
{$t.common?.refresh ?? '⟳'}
</button>
</div>
</div>
<!-- Error Banner for list -->
{#if dsError}
<div class="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded mb-4 text-sm flex items-center justify-between" role="alert">
<span>{dsError}</span>
<button class="px-2 py-0.5 bg-red-600 text-white rounded text-xs" onclick={() => loadDatasets()}>{$t.common?.retry ?? 'Retry'}</button>
</div>
{/if}
<!-- Stats Bar -->
<StatsBar {stats} activeFilter={activeFilter} isLoading={dsLoading} onfilter={handleFilter} />
<!-- Split View -->
<div class="grid gap-4 {isMobile ? 'block' : 'grid-cols-[40%_60%]'}" style="min-height: 60vh">
<!-- Left Panel: Dataset List -->
<div class={isMobile && mobileSlideOpen ? 'hidden' : ''}>
<DatasetList
{datasets}
{selectedIds}
isLoading={dsLoading}
error={null}
{total}
{page}
{totalPages}
{pageSize}
{searchQuery}
onselect={selectDataset}
onaction={handleAction}
onpagechange={handlePageChange}
onsearch={debouncedSearch}
oncheckbox={handleCheckbox}
/>
</div>
<!-- Right Panel (Desktop inline / Mobile slide-over) -->
{#if isMobile && mobileSlideOpen}
<!-- Backdrop -->
<button
class="fixed inset-0 bg-black/30 z-40"
onclick={closeDetail}
aria-label="Close detail"
></button>
<!-- Slide-over -->
<div
class="fixed inset-y-0 right-0 w-[90vw] max-w-lg bg-white shadow-xl z-50 overflow-y-auto"
role="dialog"
aria-modal="true"
>
<div class="sticky top-0 bg-white border-b border-gray-200 px-4 py-2 flex items-center justify-between z-10">
<span class="font-medium text-sm">{detailData?.table_name ?? $t.datasets?.no_selection}</span>
<button class="text-gray-400 hover:text-gray-700 text-lg" onclick={closeDetail} aria-label="Close">&times;</button>
</div>
<DatasetPreview dataset={detailData} isLoading={detailLoading} error={detailError} onretry={() => detailData && loadDetail(detailData.id)} envId={selectedEnv} />
</div>
{:else if !isMobile}
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<DatasetPreview dataset={detailData} isLoading={detailLoading} error={detailError} onretry={() => selectedDatasetId && loadDetail(selectedDatasetId)} envId={selectedEnv} />
</div>
{/if}
</div>
<!-- Bulk Action Panel (bottom) -->
{#if selectedIds.size >= 2}
<div class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg p-3 z-50">
<div class="max-w-screen-2xl mx-auto flex items-center justify-between">
<span class="font-medium text-sm">{selectedIds.size} {$t.datasets?.selected}</span>
<div class="flex gap-2">
<button class="px-3 py-1.5 text-sm bg-primary text-white rounded hover:bg-primary-hover" title={$t.datasets?.bulk_map_tip} onclick={() => { showMapColumnsModal = true; mapSourceType = "sqllab"; mapDatabaseId = null; mapSqlQuery = ""; mapFileData = null; if (selectedEnv) api.getEnvironmentDatabases(selectedEnv).then(dbs => mapDatabases = dbs ?? []).catch(() => {}); }}>
🗺 {$t.datasets?.action_map_columns}
</button>
<button class="px-3 py-1.5 text-sm bg-primary text-white rounded hover:bg-primary-hover" title={$t.datasets?.bulk_docs_tip} onclick={() => { showGenerateDocsModal = true; }}>
📄 {$t.datasets?.generate_docs}
</button>
<button class="px-3 py-1.5 text-sm border border-gray-300 rounded hover:bg-gray-100" title={$t.datasets?.clear_selection_tip} onclick={() => selectedIds = new SvelteSet()}>
{$t.common?.cancel ?? 'Clear'}
</button>
</div>
</div>
</div>
{/if}
<!-- Map Columns Modal -->
{#if showMapColumnsModal}
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" onclick={(e) => { if (e.target === e.currentTarget) showMapColumnsModal = false; }}>
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full" style="max-height: 90vh; overflow-y: auto;" onclick={(e) => e.stopPropagation()}>
<h3 class="text-lg font-semibold mb-4">{$t.datasets?.bulk_map_columns}</h3>
<p class="text-sm text-gray-600 mb-3">{$t.datasets?.selected_count?.replace('{count}', String(selectedIds.size))}</p>
<span class="flex items-center gap-1.5 mb-1.5">
<span class="text-sm font-medium text-gray-700">{$t.datasets?.source_type ?? 'Тип источника'}</span>
<HelpTooltip text={$t.datasets?.source_type_tip || ""} />
</span>
<select
class="w-full border rounded px-2 py-1.5 mb-3"
title={$t.datasets?.source_type_tip}
bind:value={mapSourceType}
onchange={() => { if (mapSourceType === 'sqllab' && mapDatabases.length === 0 && selectedEnv) { api.getEnvironmentDatabases(selectedEnv).then(dbs => mapDatabases = dbs ?? []).catch(() => {}); } }}
>
<option value="sqllab">{$t.datasets?.source_sqllab ?? 'Superset SQL Lab'}</option>
<option value="xlsx">{$t.datasets?.source_xlsx}</option>
</select>
{#if mapSourceType === "sqllab"}
<span class="flex items-center gap-1.5 mb-1.5">
<label class="text-sm font-medium text-gray-700" for="map-database-select">{$t.datasets?.source_database ?? 'База данных'}</label>
<HelpTooltip text={$t.datasets?.database_tip || ""} />
</span>
<select
id="map-database-select"
class="w-full border rounded px-2 py-1.5 mb-3"
title={$t.datasets?.database_tip}
bind:value={mapDatabaseId}
>
<option value="">-- {$t.datasets?.select_database ?? 'выберите базу данных'} --</option>
{#each mapDatabases as db (db.id)}
<option value={db.id}>{db.database_name || db.name}</option>
{/each}
</select>
<span class="flex items-center gap-1.5 mb-1.5">
<label class="text-sm font-medium text-gray-700" for="map-sql-query">
{$t.datasets?.sql_query ?? 'SQL-запрос'}
<span class="text-xs text-gray-400 font-normal ml-1">({$t.datasets?.sql_query_hint ?? 'оставьте пустым для information_schema'})</span>
</label>
<HelpTooltip text={$t.datasets?.sql_query_tip || ""} />
</span>
<!-- SQL template chips -->
<div class="flex flex-wrap gap-1.5 mb-2">
<span class="text-xs text-gray-400 mr-0.5 self-center">{$t.datasets?.sql_templates ?? 'Шаблоны'}:</span>
<button
type="button"
class="px-2 py-0.5 text-xs border border-gray-300 rounded-full text-gray-500 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-600 transition-colors"
title={$t.datasets?.sql_template_info_schema_tip}
onclick={() => { mapSqlQuery = "SELECT c.column_name, c.data_type, pgd.description\nFROM information_schema.columns c\nLEFT JOIN pg_catalog.pg_description pgd ON pgd.objsubid = c.ordinal_position\n AND pgd.objoid = (SELECT attrelid FROM pg_attribute WHERE attrelid = (SELECT oid FROM pg_class WHERE relname = c.table_name) AND attname = c.column_name)\nWHERE c.table_schema = 'public'\n AND c.table_name = 'your_table_name'\nORDER BY c.ordinal_position;" }}
>information_schema</button>
<button
type="button"
class="px-2 py-0.5 text-xs border border-gray-300 rounded-full text-gray-500 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-600 transition-colors"
title={$t.datasets?.sql_template_custom_tip}
onclick={() => { mapSqlQuery = "SELECT column_name, description AS verbose_name\nFROM my_column_mappings\nWHERE table_name = 'your_table_name'\nORDER BY column_name;" }}
>{$t.datasets?.sql_template_custom ?? 'Кастомная таблица'}</button>
<button
type="button"
class="px-2 py-0.5 text-xs border border-gray-300 rounded-full text-gray-400 hover:border-red-300 hover:text-red-500 transition-colors"
onclick={() => { mapSqlQuery = ""; }}
>{$t.datasets?.sql_template_clear ?? 'Очистить'}</button>
</div>
<textarea
id="map-sql-query"
class="w-full border rounded px-2 py-1.5 mb-4 font-mono text-xs"
rows="4"
title={$t.datasets?.sql_query_tip}
placeholder="SELECT column_name, description AS verbose_name FROM my_mappings WHERE table_name = 'my_table';"
bind:value={mapSqlQuery}
></textarea>
{:else}
<span class="flex items-center gap-1.5 mb-1.5">
<label class="text-sm font-medium text-gray-700" for="map-file-input">{$t.datasets?.xlsx_file ?? 'XLSX-файл'}</label>
<HelpTooltip text={$t.datasets?.file_input_tip || ""} />
</span>
<input
id="map-file-input"
type="file"
accept=".xlsx"
class="w-full mb-4"
title={$t.datasets?.file_input_tip}
onchange={(e) => mapFileData = e.target.files}
/>
{/if}
<div class="flex justify-end gap-2">
<button class="px-3 py-1.5 border rounded hover:bg-gray-100" title={$t.datasets?.cancel_tip} onclick={() => showMapColumnsModal = false}>{$t.common?.cancel}</button>
<button class="px-3 py-1.5 bg-primary text-white rounded hover:bg-primary-hover" title={$t.datasets?.start_mapping_tip} onclick={handleBulkMapColumns}>{$t.datasets?.start_mapping}</button>
</div>
</div>
</div>
{/if}
<!-- Generate Docs Modal -->
{#if showGenerateDocsModal}
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" onclick={(e) => { if (e.target === e.currentTarget) showGenerateDocsModal = false; }}>
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full" onclick={(e) => e.stopPropagation()}>
<h3 class="text-lg font-semibold mb-4">{$t.datasets?.bulk_docs_generation}</h3>
<p class="text-sm text-gray-600 mb-3">{$t.datasets?.selected_count?.replace('{count}', String(selectedIds.size))}</p>
<span class="flex items-center gap-1.5 mb-1.5">
<span class="text-sm font-medium text-gray-700">{$t.datasets?.select_llm_provider ?? 'LLM-провайдер'}</span>
<HelpTooltip text={$t.datasets?.llm_provider_tip || ""} />
</span>
<select class="w-full border rounded px-2 py-1.5 mb-4" title={$t.datasets?.llm_provider_tip} bind:value={llmProvider}>
<option value="">{$t.datasets?.select_llm_provider_option}</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="local">Local LLM</option>
</select>
<div class="flex justify-end gap-2">
<button class="px-3 py-1.5 border rounded hover:bg-gray-100" title={$t.datasets?.cancel_tip} onclick={() => showGenerateDocsModal = false}>{$t.common?.cancel}</button>
<button class="px-3 py-1.5 bg-primary text-white rounded hover:bg-primary-hover" title={$t.datasets?.start_docs_tip} onclick={handleBulkGenerateDocs}>{$t.datasets?.generate_docs}</button>
</div>
</div>
</div>
{/if}
</div>
<!-- #endregion DatasetHub -->