Files
ss-tools/frontend/src/routes/settings/+page.svelte

1492 lines
56 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- [DEF:SettingsPage:Page] -->
<script>
/**
* @TIER: CRITICAL
* @PURPOSE: Consolidated Settings Page - All settings in one place with tabbed navigation
* @LAYER: UI
* @RELATION: BINDS_TO -> sidebarStore
* @INVARIANT: Always shows tabbed interface with all settings categories
*
* @UX_STATE: Loading -> Shows skeleton loader
* @UX_STATE: Loaded -> Shows tabbed settings interface
* @UX_STATE: Error -> Shows error banner with retry button
* @UX_FEEDBACK: Toast notifications on save success/failure
* @UX_RECOVERY: Refresh button reloads settings data
*/
import { onMount } from "svelte";
import { t } from "$lib/i18n";
import { api } from "$lib/api.js";
import { addToast } from "$lib/toasts";
import ProviderConfig from "../../components/llm/ProviderConfig.svelte";
const DEFAULT_LLM_PROMPTS = {
dashboard_validation_prompt:
'Analyze the attached dashboard screenshot and the following execution logs for health and visual issues.\\n\\nLogs:\\n{logs}\\n\\nProvide the analysis in JSON format with the following structure:\\n{\\n \\"status\\": \\"PASS\\" | \\"WARN\\" | \\"FAIL\\",\\n \\"summary\\": \\"Short summary of findings\\",\\n \\"issues\\": [\\n {\\n \\"severity\\": \\"WARN\\" | \\"FAIL\\",\\n \\"message\\": \\"Description of the issue\\",\\n \\"location\\": \\"Optional location info (e.g. chart name)\\"\\n }\\n ]\\n}',
documentation_prompt:
'Generate professional documentation for the following dataset and its columns.\\nDataset: {dataset_name}\\nColumns: {columns_json}\\n\\nProvide the documentation in JSON format:\\n{\\n \\"dataset_description\\": \\"General description of the dataset\\",\\n \\"column_descriptions\\": [\\n {\\n \\"name\\": \\"column_name\\",\\n \\"description\\": \\"Generated description\\"\\n }\\n ]\\n}',
git_commit_prompt:
"Generate a concise and professional git commit message based on the following diff and recent history.\\nUse Conventional Commits format (e.g., feat: ..., fix: ..., docs: ...).\\n\\nRecent History:\\n{history}\\n\\nDiff:\\n{diff}\\n\\nCommit Message:",
};
const DEFAULT_LLM_PROVIDER_BINDINGS = {
dashboard_validation: "",
documentation: "",
git_commit: "",
};
// State
const SETTINGS_TABS = [
"environments",
"logging",
"connections",
"llm",
"migration",
"storage",
];
let activeTab = "environments";
let settings = null;
let isLoading = true;
let error = null;
// Environment editing state
let editingEnvId = null;
let isAddingEnv = false;
let newEnv = {
id: "",
name: "",
url: "",
username: "",
password: "",
is_default: false,
backup_schedule: {
enabled: false,
cron_expression: "0 0 * * *",
},
};
function normalizeTab(value) {
const normalized = String(value || "").trim().toLowerCase();
const aliases = {
environment: "environments",
env: "environments",
"migration-sync": "migration",
storages: "storage",
};
const resolved = aliases[normalized] || normalized;
return SETTINGS_TABS.includes(resolved) ? resolved : "environments";
}
function readTabFromUrl() {
if (typeof window === "undefined") return "environments";
const params = new URLSearchParams(window.location.search);
const fromQuery = params.get("tab");
if (fromQuery) return normalizeTab(fromQuery);
const fromHash = window.location.hash?.replace(/^#/, "");
if (fromHash) return normalizeTab(fromHash);
return "environments";
}
function writeTabToUrl(tab) {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set("tab", tab);
url.hash = tab;
window.history.replaceState({}, "", url.toString());
}
// Load settings on mount
onMount(async () => {
activeTab = readTabFromUrl();
const syncTabFromUrl = () => {
activeTab = readTabFromUrl();
};
window.addEventListener("popstate", syncTabFromUrl);
window.addEventListener("hashchange", syncTabFromUrl);
await loadSettings();
await loadMigrationSettings();
return () => {
window.removeEventListener("popstate", syncTabFromUrl);
window.removeEventListener("hashchange", syncTabFromUrl);
};
});
// Load consolidated settings from API
async function loadSettings() {
isLoading = true;
error = null;
try {
const response = await api.getConsolidatedSettings();
response.llm = normalizeLlmSettings(response.llm);
settings = response;
} catch (err) {
error = err.message || $t.settings?.load_failed ;
console.error("[SettingsPage][Coherence:Failed]", err);
} finally {
isLoading = false;
}
}
function normalizeLlmSettings(llm) {
const normalized = {
providers: [],
default_provider: "",
prompts: { ...DEFAULT_LLM_PROMPTS },
provider_bindings: { ...DEFAULT_LLM_PROVIDER_BINDINGS },
assistant_planner_provider: "",
assistant_planner_model: "",
...(llm || {}),
};
normalized.prompts = {
...DEFAULT_LLM_PROMPTS,
...(llm?.prompts || {}),
};
normalized.provider_bindings = {
...DEFAULT_LLM_PROVIDER_BINDINGS,
...(llm?.provider_bindings || {}),
};
normalized.assistant_planner_provider =
llm?.assistant_planner_provider || "";
normalized.assistant_planner_model = llm?.assistant_planner_model || "";
return normalized;
}
function isMultimodalModel(modelName) {
const token = (modelName || "").toLowerCase();
if (!token) return false;
return (
token.includes("gpt-4o") ||
token.includes("gpt-4.1") ||
token.includes("vision") ||
token.includes("vl") ||
token.includes("gemini") ||
token.includes("claude-3") ||
token.includes("claude-sonnet-4")
);
}
function getProviderById(providerId) {
if (!providerId) return null;
return (
(settings?.llm_providers || []).find((p) => p.id === providerId) || null
);
}
function isDashboardValidationBindingValid() {
const providerId = settings?.llm?.provider_bindings?.dashboard_validation;
if (!providerId) return true;
const provider = getProviderById(providerId);
return provider ? isMultimodalModel(provider.default_model) : true;
}
// Handle tab change
function handleTabChange(tab) {
const normalizedTab = normalizeTab(tab);
activeTab = normalizedTab;
writeTabToUrl(activeTab);
if (normalizedTab === "migration") {
loadMigrationSettings();
}
}
// Migration Settings State
let migrationCron = "0 2 * * *";
let displayMappings = [];
let mappingsTotal = 0;
let mappingsPage = 0;
let mappingsPageSize = 25;
let mappingsSearch = "";
let mappingsEnvFilter = "";
let mappingsTypeFilter = "";
let isSavingMigration = false;
let isLoadingMigration = false;
let isSyncing = false;
let searchTimeout = null;
$: mappingsTotalPages = Math.max(
1,
Math.ceil(mappingsTotal / mappingsPageSize),
);
async function loadMigrationSettings() {
isLoadingMigration = true;
try {
const settingsRes = await api.requestApi("/migration/settings");
migrationCron = settingsRes.cron;
await loadMappingsPage();
} catch (err) {
console.error("[SettingsPage][Migration] Failed to load:", err);
} finally {
isLoadingMigration = false;
}
}
async function loadMappingsPage() {
try {
const skip = mappingsPage * mappingsPageSize;
let url = `/migration/mappings-data?skip=${skip}&limit=${mappingsPageSize}`;
if (mappingsSearch)
url += `&search=${encodeURIComponent(mappingsSearch)}`;
if (mappingsEnvFilter)
url += `&env_id=${encodeURIComponent(mappingsEnvFilter)}`;
if (mappingsTypeFilter)
url += `&resource_type=${encodeURIComponent(mappingsTypeFilter)}`;
const res = await api.requestApi(url);
displayMappings = res.items || [];
mappingsTotal = res.total || 0;
} catch (err) {
console.error("[SettingsPage][Migration] Failed to load mappings:", err);
}
}
function onMappingsSearchInput(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
mappingsPage = 0;
loadMappingsPage();
}, 300);
}
function onMappingsFilterChange() {
mappingsPage = 0;
loadMappingsPage();
}
function goToMappingsPage(page) {
if (page < 0 || page >= mappingsTotalPages) return;
mappingsPage = page;
loadMappingsPage();
}
async function saveMigrationSettings() {
isSavingMigration = true;
try {
await api.putApi("/migration/settings", { cron: migrationCron });
addToast(
$t.settings?.save_success ,
"success",
);
} catch (err) {
addToast(
$t.settings?.save_failed ,
"error",
);
} finally {
isSavingMigration = false;
}
}
async function triggerSyncNow() {
isSyncing = true;
try {
const result = await api.postApi("/migration/sync-now", {});
addToast(
`Synced ${result.synced_count} environment(s)${result.failed_count > 0 ? `, ${result.failed_count} failed` : ""}`,
result.failed_count > 0 ? "warning" : "success",
);
await loadMigrationSettings();
} catch (err) {
console.error("[SettingsPage][Migration] Sync failed:", err);
addToast(err.message || $t.settings?.migration_sync_failed , "error");
} finally {
isSyncing = false;
}
}
// Handle global settings save (Logging, Storage)
async function handleSave() {
console.log("[SettingsPage][Action] Saving settings");
try {
settings.llm = normalizeLlmSettings(settings.llm);
// In a real app we might want to only send the changed section,
// but updateConsolidatedSettings expects full object or we can use specific endpoints.
// For now we use the consolidated update.
await api.updateConsolidatedSettings(settings);
addToast($t.settings?.save_success , "success");
} catch (err) {
console.error("[SettingsPage][Coherence:Failed]", err);
addToast($t.settings?.save_failed , "error");
}
}
// Handle environment actions
async function handleTestEnv(id) {
console.log(`[SettingsPage][Action] Test environment ${id}`);
addToast($t.settings?.testing_connection , "info");
try {
const result = await api.testEnvironmentConnection(id);
if (result.status === "success") {
addToast($t.settings?.connection_success , "success");
} else {
addToast(
($t.settings?.connection_failed ).replace(
"{error}",
result.message || $t.common?.unknown ,
),
"error",
);
}
} catch (err) {
console.error(
"[SettingsPage][Coherence:Failed] Error testing connection:",
err,
);
addToast($t.settings?.connection_test_failed , "error");
}
}
function editEnv(env) {
console.log(`[SettingsPage][Action] Edit environment ${env.id}`);
newEnv = JSON.parse(JSON.stringify(env)); // Deep copy
// Ensure backup_schedule exists
if (!newEnv.backup_schedule) {
newEnv.backup_schedule = { enabled: false, cron_expression: "0 0 * * *" };
}
editingEnvId = env.id;
isAddingEnv = false;
}
function resetEnvForm() {
newEnv = {
id: "",
name: "",
url: "",
username: "",
password: "",
is_default: false,
backup_schedule: {
enabled: false,
cron_expression: "0 0 * * *",
},
};
editingEnvId = null;
}
async function handleAddOrUpdateEnv() {
try {
console.log(
`[SettingsPage][Action] ${editingEnvId ? "Updating" : "Adding"} environment.`,
);
// Basic validation
if (!newEnv.id || !newEnv.name || !newEnv.url) {
addToast(
$t.settings?.env_required_fields,
"error",
);
return;
}
if (editingEnvId) {
await api.updateEnvironment(editingEnvId, newEnv);
addToast($t.settings?.env_updated , "success");
} else {
await api.addEnvironment(newEnv);
addToast($t.settings?.env_added , "success");
}
resetEnvForm();
editingEnvId = null;
isAddingEnv = false;
await loadSettings();
} catch (error) {
console.error(
"[SettingsPage][Coherence:Failed] Failed to save environment:",
error,
);
addToast(error.message || $t.settings?.env_save_failed , "error");
}
}
async function handleDeleteEnv(id) {
if (
confirm(
$t.settings?.env_delete_confirm,
)
) {
console.log(`[SettingsPage][Action] Delete environment ${id}`);
try {
await api.deleteEnvironment(id);
addToast($t.settings?.env_deleted , "success");
await loadSettings();
} catch (error) {
console.error(
"[SettingsPage][Coherence:Failed] Failed to delete environment:",
error,
);
addToast($t.settings?.env_delete_failed , "error");
}
}
}
</script>
<div class="mx-auto w-full max-w-7xl space-y-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">
{$t.settings?.title }
</h1>
<button
class="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-hover"
on:click={loadSettings}
>
{$t.common?.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={loadSettings}
>
{$t.common?.retry }
</button>
</div>
{/if}
<!-- Loading State -->
{#if isLoading}
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="space-y-3">
<div class="h-8 animate-pulse rounded bg-slate-200"></div>
<div class="h-8 animate-pulse rounded bg-slate-200"></div>
<div class="h-8 animate-pulse rounded bg-slate-200"></div>
<div class="h-8 animate-pulse rounded bg-slate-200"></div>
<div class="h-8 animate-pulse rounded bg-slate-200"></div>
</div>
</div>
{:else if settings}
<!-- Tabs -->
<div class="border-b border-gray-200 mb-6">
<button
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
class:text-blue-600={activeTab === "environments"}
class:border-blue-600={activeTab === "environments"}
class:text-gray-600={activeTab !== "environments"}
class:border-transparent={activeTab !== "environments"}
class:hover:text-gray-800={activeTab !== "environments"}
class:hover:border-gray-300={activeTab !== "environments"}
aria-current={activeTab === "environments" ? "page" : undefined}
on:click={() => handleTabChange("environments")}
>
{$t.settings?.environments }
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
class:text-blue-600={activeTab === "logging"}
class:border-blue-600={activeTab === "logging"}
class:text-gray-600={activeTab !== "logging"}
class:border-transparent={activeTab !== "logging"}
class:hover:text-gray-800={activeTab !== "logging"}
class:hover:border-gray-300={activeTab !== "logging"}
aria-current={activeTab === "logging" ? "page" : undefined}
on:click={() => handleTabChange("logging")}
>
{$t.settings?.logging }
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
class:text-blue-600={activeTab === "connections"}
class:border-blue-600={activeTab === "connections"}
class:text-gray-600={activeTab !== "connections"}
class:border-transparent={activeTab !== "connections"}
class:hover:text-gray-800={activeTab !== "connections"}
class:hover:border-gray-300={activeTab !== "connections"}
aria-current={activeTab === "connections" ? "page" : undefined}
on:click={() => handleTabChange("connections")}
>
{$t.settings?.connections }
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
class:text-blue-600={activeTab === "llm"}
class:border-blue-600={activeTab === "llm"}
class:text-gray-600={activeTab !== "llm"}
class:border-transparent={activeTab !== "llm"}
class:hover:text-gray-800={activeTab !== "llm"}
class:hover:border-gray-300={activeTab !== "llm"}
aria-current={activeTab === "llm" ? "page" : undefined}
on:click={() => handleTabChange("llm")}
>
{$t.settings?.llm }
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
class:text-blue-600={activeTab === "migration"}
class:border-blue-600={activeTab === "migration"}
class:text-gray-600={activeTab !== "migration"}
class:border-transparent={activeTab !== "migration"}
class:hover:text-gray-800={activeTab !== "migration"}
class:hover:border-gray-300={activeTab !== "migration"}
aria-current={activeTab === "migration" ? "page" : undefined}
on:click={() => handleTabChange("migration")}
>
{$t.settings?.migration_sync }
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
class:text-blue-600={activeTab === "storage"}
class:border-blue-600={activeTab === "storage"}
class:text-gray-600={activeTab !== "storage"}
class:border-transparent={activeTab !== "storage"}
class:hover:text-gray-800={activeTab !== "storage"}
class:hover:border-gray-300={activeTab !== "storage"}
aria-current={activeTab === "storage" ? "page" : undefined}
on:click={() => handleTabChange("storage")}
>
{$t.settings?.storage }
</button>
</div>
<!-- Tab Content -->
<div class="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
{#if activeTab === "environments"}
<!-- Environments Tab -->
<div class="text-lg font-medium mb-4">
<h2 class="text-xl font-bold mb-4">
{$t.settings?.environments }
</h2>
<p class="text-gray-600 mb-6">
{$t.settings?.env_description}
</p>
{#if !editingEnvId && !isAddingEnv}
<div class="flex justify-end mb-6">
<button
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
on:click={() => {
isAddingEnv = true;
resetEnvForm();
}}
>
{$t.settings?.env_add }
</button>
</div>
{/if}
{#if editingEnvId || isAddingEnv}
<!-- Add/Edit Environment Form -->
<div class="bg-gray-50 p-6 rounded-lg mb-6 border border-gray-200">
<h3 class="text-lg font-medium mb-4">
{editingEnvId
? $t.settings?.env_edit
: $t.settings?.env_add }
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
for="env_id"
class="block text-sm font-medium text-gray-700"
>{$t.common?.id }</label
>
<input
type="text"
id="env_id"
bind:value={newEnv.id}
disabled={!!editingEnvId}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
<div>
<label
for="env_name"
class="block text-sm font-medium text-gray-700"
>{$t.connections?.name }</label
>
<input
type="text"
id="env_name"
bind:value={newEnv.name}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div>
<div>
<label
for="env_url"
class="block text-sm font-medium text-gray-700">{$t.settings?.env_url}</label
>
<input
type="text"
id="env_url"
bind:value={newEnv.url}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div>
<div>
<label
for="env_user"
class="block text-sm font-medium text-gray-700"
>{$t.connections?.user }</label
>
<input
type="text"
id="env_user"
bind:value={newEnv.username}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div>
<div>
<label
for="env_pass"
class="block text-sm font-medium text-gray-700"
>{$t.connections?.pass }</label
>
<input
type="password"
id="env_pass"
bind:value={newEnv.password}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div>
<div class="flex items-center mt-6">
<input
type="checkbox"
id="env_default"
bind:checked={newEnv.is_default}
class="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<label
for="env_default"
class="ml-2 block text-sm text-gray-900"
>{$t.settings?.env_default }</label
>
</div>
</div>
<h3 class="text-lg font-medium mb-4 mt-6">
{$t.tasks?.backup_schedule }
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-center">
<input
type="checkbox"
id="backup_enabled"
bind:checked={newEnv.backup_schedule.enabled}
class="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<label
for="backup_enabled"
class="ml-2 block text-sm text-gray-900"
>{$t.settings?.enable_auto_backups }</label
>
</div>
<div>
<label
for="cron_expression"
class="block text-sm font-medium text-gray-700"
>{$t.tasks?.cron_label }</label
>
<input
type="text"
id="cron_expression"
bind:value={newEnv.backup_schedule.cron_expression}
placeholder={$t.settings?.cron_placeholder }
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
<p class="text-xs text-gray-500 mt-1">
{$t.tasks?.cron_hint }
</p>
</div>
</div>
<div class="mt-6 flex gap-2 justify-end">
<button
on:click={() => {
isAddingEnv = false;
editingEnvId = null;
resetEnvForm();
}}
class="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300"
>
{$t.common?.cancel }
</button>
<button
on:click={handleAddOrUpdateEnv}
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
{editingEnvId
? $t.settings?.env_update
: $t.settings?.env_add }
</button>
</div>
</div>
{/if}
{#if settings.environments && settings.environments.length > 0}
<div class="mt-6 overflow-x-auto border border-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>{$t.connections?.name }</th
>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>{$t.settings?.env_url }</th
>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>{$t.connections?.user }</th
>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>{$t.settings?.default }</th
>
<th
class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
>{$t.settings?.env_actions }</th
>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each settings.environments as env}
<tr>
<td class="px-6 py-4 whitespace-nowrap">{env.name}</td>
<td class="px-6 py-4 whitespace-nowrap">{env.url}</td>
<td class="px-6 py-4 whitespace-nowrap">{env.username}</td
>
<td class="px-6 py-4 whitespace-nowrap">
{#if env.is_default}
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"
>
{$t.common?.yes }
</span>
{:else}
<span class="text-gray-500">{$t.common?.no }</span>
{/if}
</td>
<td
class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"
>
<button
class="text-green-600 hover:text-green-900 mr-4"
on:click={() => handleTestEnv(env.id)}
>
{$t.settings?.env_test }
</button>
<button
class="text-indigo-600 hover:text-indigo-900 mr-4"
on:click={() => editEnv(env)}
>
{$t.common.edit }
</button>
<button
class="text-red-600 hover:text-red-900"
on:click={() => handleDeleteEnv(env.id)}
>
{$t.settings?.env_delete }
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if !isAddingEnv}
<div
class="mb-4 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700"
>
<p class="font-bold">{$t.settings?.warning }</p>
<p>
No Superset environments configured. You must add at least one
environment to perform backups or migrations.
</p>
</div>
{/if}
</div>
{:else if activeTab === "logging"}
<!-- Logging Tab -->
<div class="text-lg font-medium mb-4">
<h2 class="text-xl font-bold mb-4">
{$t.settings?.logging }
</h2>
<p class="text-gray-600 mb-6">
{$t.settings?.logging_description}
</p>
<div class="bg-gray-50 p-6 rounded-lg border border-gray-200">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
for="log_level"
class="block text-sm font-medium text-gray-700"
>{$t.settings?.log_level }</label
>
<select
id="log_level"
bind:value={settings.logging.level}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
>
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="CRITICAL">CRITICAL</option>
</select>
</div>
<div>
<label
for="task_log_level"
class="block text-sm font-medium text-gray-700"
>{$t.settings?.task_log_level }</label
>
<select
id="task_log_level"
bind:value={settings.logging.task_log_level}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
>
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
</div>
<div class="md:col-span-2">
<label class="flex items-center">
<input
type="checkbox"
id="enable_belief_state"
bind:checked={settings.logging.enable_belief_state}
class="h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
<span class="ml-2 block text-sm text-gray-900"
>{$t.settings?.enable_belief_state }</span
>
</label>
<p class="text-xs text-gray-500 mt-1 ml-6">
{$t.settings?.belief_state_hint}
</p>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
on:click={handleSave}
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
{$t.settings?.save_logging }
</button>
</div>
</div>
</div>
{:else if activeTab === "connections"}
<!-- Connections Tab -->
<div class="text-lg font-medium mb-4">
<h2 class="text-xl font-bold mb-4">
{$t.settings?.connections }
</h2>
<p class="text-gray-600 mb-6">
{$t.settings?.connections_description}
</p>
{#if settings.connections && settings.connections.length > 0}
<!-- Connections list would go here -->
<p class="text-gray-500 italic">
No additional connections configured. Superset database
connections are used by default.
</p>
{:else}
<div
class="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-300"
>
<p class="text-gray-500">
{$t.settings?.no_external_connections}
</p>
<button
class="mt-4 px-4 py-2 border border-blue-600 text-blue-600 rounded hover:bg-blue-50"
>
{$t.connections?.add_new }
</button>
</div>
{/if}
</div>
{:else if activeTab === "llm"}
<!-- LLM Tab -->
<div class="text-lg font-medium mb-4">
<h2 class="text-xl font-bold mb-4">
{$t.settings?.llm }
</h2>
<p class="text-gray-600 mb-6">
{$t.settings?.llm_description}
</p>
<ProviderConfig
providers={settings.llm_providers || []}
onSave={loadSettings}
/>
<div class="mt-6 rounded-lg border border-gray-200 bg-white p-4">
<h3 class="text-base font-semibold text-gray-900">
{$t.settings?.llm_chatbot_settings_title}
</h3>
<p class="mt-1 text-sm text-gray-600">
{$t.settings?.llm_chatbot_settings_description}
</p>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label
for="planner-provider"
class="block text-sm font-medium text-gray-700"
>
{$t.settings?.llm_chatbot_provider }
</label>
<select
id="planner-provider"
bind:value={settings.llm.assistant_planner_provider}
class="mt-1 block w-full rounded-md border border-gray-300 p-2 text-sm"
>
<option value=""
>{$t.dashboard?.use_default }</option
>
{#each settings.llm_providers || [] as provider}
<option value={provider.id}>
{provider.name} ({provider.default_model})
</option>
{/each}
</select>
</div>
<div>
<label
for="planner-model"
class="block text-sm font-medium text-gray-700"
>
{$t.settings?.llm_chatbot_model }
</label>
<input
id="planner-model"
type="text"
bind:value={settings.llm.assistant_planner_model}
placeholder={$t.settings?.llm_chatbot_model_placeholder}
class="mt-1 block w-full rounded-md border border-gray-300 p-2 text-sm"
/>
</div>
</div>
</div>
<div class="mt-6 rounded-lg border border-gray-200 bg-white p-4">
<h3 class="text-base font-semibold text-gray-900">
{$t.settings?.llm_provider_bindings_title}
</h3>
<p class="mt-1 text-sm text-gray-600">
{$t.settings?.llm_provider_bindings_description}
</p>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label
for="binding-dashboard-validation"
class="block text-sm font-medium text-gray-700"
>
{$t.settings?.llm_binding_dashboard_validation}
</label>
<select
id="binding-dashboard-validation"
bind:value={
settings.llm.provider_bindings.dashboard_validation
}
class="mt-1 block w-full rounded-md border border-gray-300 p-2 text-sm"
>
<option value=""
>{$t.dashboard?.use_default }</option
>
{#each settings.llm_providers || [] as provider}
<option value={provider.id}>
{provider.name} ({provider.default_model})
</option>
{/each}
</select>
{#if !isDashboardValidationBindingValid()}
<p class="mt-1 text-xs text-amber-700">
{$t.settings?.llm_multimodal_warning}
</p>
{/if}
</div>
<div>
<label
for="binding-documentation"
class="block text-sm font-medium text-gray-700"
>
{$t.settings?.llm_binding_documentation}
</label>
<select
id="binding-documentation"
bind:value={settings.llm.provider_bindings.documentation}
class="mt-1 block w-full rounded-md border border-gray-300 p-2 text-sm"
>
<option value=""
>{$t.dashboard?.use_default }</option
>
{#each settings.llm_providers || [] as provider}
<option value={provider.id}>
{provider.name} ({provider.default_model})
</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<label
for="binding-git-commit"
class="block text-sm font-medium text-gray-700"
>
{$t.settings?.llm_binding_git_commit }
</label>
<select
id="binding-git-commit"
bind:value={settings.llm.provider_bindings.git_commit}
class="mt-1 block w-full rounded-md border border-gray-300 p-2 text-sm"
>
<option value=""
>{$t.dashboard?.use_default }</option
>
{#each settings.llm_providers || [] as provider}
<option value={provider.id}>
{provider.name} ({provider.default_model})
</option>
{/each}
</select>
</div>
</div>
</div>
<div class="mt-6 rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="text-base font-semibold text-gray-900">
{$t.settings?.llm_prompts_title }
</h3>
<p class="mt-1 text-sm text-gray-600">
{$t.settings?.llm_prompts_description}
</p>
<div class="mt-4 space-y-4">
<div>
<label
for="documentation-prompt"
class="block text-sm font-medium text-gray-700"
>
{$t.settings?.llm_prompt_documentation}
</label>
<textarea
id="documentation-prompt"
bind:value={settings.llm.prompts.documentation_prompt}
rows="8"
class="mt-1 block w-full rounded-md border border-gray-300 p-2 font-mono text-xs"
></textarea>
</div>
<div>
<label
for="dashboard-validation-prompt"
class="block text-sm font-medium text-gray-700"
>
{$t.settings?.llm_prompt_dashboard_validation}
</label>
<textarea
id="dashboard-validation-prompt"
bind:value={settings.llm.prompts.dashboard_validation_prompt}
rows="10"
class="mt-1 block w-full rounded-md border border-gray-300 p-2 font-mono text-xs"
></textarea>
</div>
<div>
<label
for="git-commit-prompt"
class="block text-sm font-medium text-gray-700"
>
{$t.settings?.llm_prompt_git_commit }
</label>
<textarea
id="git-commit-prompt"
bind:value={settings.llm.prompts.git_commit_prompt}
rows="8"
class="mt-1 block w-full rounded-md border border-gray-300 p-2 font-mono text-xs"
></textarea>
</div>
</div>
<div class="mt-4 flex justify-end">
<button
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
on:click={handleSave}
>
{$t.settings?.save_llm_prompts }
</button>
</div>
</div>
</div>
{:else if activeTab === "migration"}
<!-- Migration Sync Tab -->
<div class="text-lg font-medium mb-4">
<h2 class="text-xl font-bold mb-4">
{$t.settings?.migration_sync_title}
</h2>
<p class="text-gray-600 mb-6">
{$t.settings?.migration_sync_description}
</p>
<!-- Cron Configuration -->
<div class="bg-gray-50 p-6 rounded-lg border border-gray-200 mb-6">
<h3 class="text-lg font-medium mb-4">
{$t.settings?.sync_schedule }
</h3>
<div class="flex items-end gap-4">
<div class="flex-grow">
<label
for="migration_cron"
class="block text-sm font-medium text-gray-700"
>{$t.tasks?.cron_label }</label
>
<input
type="text"
id="migration_cron"
bind:value={migrationCron}
placeholder={$t.settings?.migration_cron_placeholder }
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 font-mono text-sm"
/>
<p class="text-xs text-gray-500 mt-1">
{$t.settings?.migration_cron_hint}
</p>
</div>
<button
on:click={saveMigrationSettings}
disabled={isSavingMigration}
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 h-[42px] min-w-[100px] flex items-center justify-center disabled:opacity-50"
>
{isSavingMigration
? $t.settings?.saving
: $t.common?.save }
</button>
<button
on:click={triggerSyncNow}
disabled={isSyncing}
class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 h-[42px] min-w-[150px] flex items-center justify-center gap-2 disabled:opacity-50"
>
{#if isSyncing}
<svg
class="w-4 h-4 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
></path></svg
>
{$t.settings?.syncing }
{:else}
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
></path></svg
>
{$t.settings?.sync_now }
{/if}
</button>
</div>
</div>
<!-- Mappings Table -->
<div class="bg-gray-50 p-6 rounded-lg border border-gray-200">
<h3
class="text-lg font-medium mb-4 flex items-center justify-between"
>
<span
>{$t.settings?.synchronized_resources} <span
class="text-sm font-normal text-gray-500"
>({mappingsTotal})</span
></span
>
<button
on:click={loadMigrationSettings}
class="text-sm text-indigo-600 hover:text-indigo-800 flex items-center gap-1"
disabled={isLoadingMigration}
>
<svg
class="w-4 h-4 {isLoadingMigration ? 'animate-spin' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
></path></svg
>
{$t.common?.refresh }
</button>
</h3>
<!-- Search and Filters -->
<div class="flex flex-wrap gap-3 mb-4">
<div class="flex-1 min-w-[200px]">
<input
type="text"
bind:value={mappingsSearch}
on:input={onMappingsSearchInput}
placeholder={$t.settings?.search_by_name_or_uuid}
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<select
bind:value={mappingsEnvFilter}
on:change={onMappingsFilterChange}
class="px-3 py-2 border border-gray-300 rounded-md text-sm bg-white focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">
{$t.settings?.all_environments }
</option>
<option value="ss1">ss1</option>
<option value="ss2">ss2</option>
</select>
<select
bind:value={mappingsTypeFilter}
on:change={onMappingsFilterChange}
class="px-3 py-2 border border-gray-300 rounded-md text-sm bg-white focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">{$t.settings?.all_types }</option>
<option value="chart">{$t.settings?.type_chart }</option>
<option value="dataset">
{$t.nav?.datasets }
</option>
<option value="dashboard">
{$t.nav?.dashboards }
</option>
</select>
</div>
<div class="overflow-x-auto border border-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-100">
<tr>
<th
class="px-6 py-3 text-left font-medium text-gray-500 uppercase tracking-wider"
>{$t.settings?.resource_name }</th
>
<th
class="px-6 py-3 text-left font-medium text-gray-500 uppercase tracking-wider"
>{$t.settings?.type }</th
>
<th
class="px-6 py-3 text-left font-medium text-gray-500 uppercase tracking-wider"
>UUID</th
>
<th
class="px-6 py-3 text-left font-medium text-gray-500 uppercase tracking-wider"
>{$t.settings?.target_id }</th
>
<th
class="px-6 py-3 text-left font-medium text-gray-500 uppercase tracking-wider"
>{$t.dashboard?.environment }</th
>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#if isLoadingMigration && displayMappings.length === 0}
<tr
><td
colspan="5"
class="px-6 py-8 text-center text-gray-500"
>{$t.settings?.loading_mappings }</td
></tr
>
{:else if displayMappings.length === 0}
<tr
><td
colspan="5"
class="px-6 py-8 text-center text-gray-500"
>{mappingsSearch ||
mappingsEnvFilter ||
mappingsTypeFilter
? $t.settings?.no_matching_resources
: ($t.settings?.no_synchronized_resources)}</td
></tr
>
{:else}
{#each displayMappings as mapping}
<tr class="hover:bg-gray-50">
<td
class="px-6 py-4 whitespace-nowrap font-medium text-gray-900"
>{mapping.resource_name ||
$t.common?.not_available}</td
>
<td class="px-6 py-4 whitespace-nowrap"
><span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {mapping.resource_type ===
'chart'
? 'bg-blue-100 text-blue-800'
: mapping.resource_type === 'dataset'
? 'bg-green-100 text-green-800'
: 'bg-purple-100 text-purple-800'}"
>{mapping.resource_type}</span
></td
>
<td
class="px-6 py-4 whitespace-nowrap font-mono text-xs text-gray-500"
>{mapping.uuid}</td
>
<td
class="px-6 py-4 whitespace-nowrap font-mono text-xs font-bold text-gray-700"
>{mapping.remote_id}</td
>
<td class="px-6 py-4 whitespace-nowrap text-gray-500"
>{mapping.environment_id}</td
>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
<!-- Pagination Controls -->
{#if mappingsTotal > mappingsPageSize}
<div
class="flex items-center justify-between mt-4 text-sm text-gray-600"
>
<span
>{(
$t.dashboard?.showing
)
.replace(
"{start}",
String(mappingsPage * mappingsPageSize + 1),
)
.replace(
"{end}",
String(
Math.min(
(mappingsPage + 1) * mappingsPageSize,
mappingsTotal,
),
),
)
.replace("{total}", String(mappingsTotal))}</span
>
<div class="flex items-center gap-2">
<button
on:click={() => goToMappingsPage(0)}
disabled={mappingsPage === 0}
class="px-2 py-1 rounded border {mappingsPage === 0
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white hover:bg-gray-50 text-gray-700'}">«</button
>
<button
on:click={() => goToMappingsPage(mappingsPage - 1)}
disabled={mappingsPage === 0}
class="px-2 py-1 rounded border {mappingsPage === 0
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white hover:bg-gray-50 text-gray-700'}"></button
>
<span class="px-3 py-1 font-medium"
>{mappingsPage + 1} / {mappingsTotalPages}</span
>
<button
on:click={() => goToMappingsPage(mappingsPage + 1)}
disabled={mappingsPage >= mappingsTotalPages - 1}
class="px-2 py-1 rounded border {mappingsPage >=
mappingsTotalPages - 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white hover:bg-gray-50 text-gray-700'}"></button
>
<button
on:click={() => goToMappingsPage(mappingsTotalPages - 1)}
disabled={mappingsPage >= mappingsTotalPages - 1}
class="px-2 py-1 rounded border {mappingsPage >=
mappingsTotalPages - 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white hover:bg-gray-50 text-gray-700'}">»</button
>
</div>
</div>
{/if}
</div>
</div>
{:else if activeTab === "storage"}
<!-- Storage Tab -->
<div class="text-lg font-medium mb-4">
<h2 class="text-xl font-bold mb-4">
{$t.settings?.storage_title }
</h2>
<p class="text-gray-600 mb-6">
{$t.settings?.storage_description}
</p>
<div class="bg-gray-50 p-6 rounded-lg border border-gray-200">
<div class="grid grid-cols-1 gap-4">
<div>
<label
for="storage_path"
class="block text-sm font-medium text-gray-700"
>{$t.storage?.root }</label
>
<input
type="text"
id="storage_path"
bind:value={settings.storage.root_path}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div>
<div>
<label
for="backup_path"
class="block text-sm font-medium text-gray-700"
>{$t.storage?.backups }</label
>
<input
type="text"
id="backup_path"
bind:value={settings.storage.backup_path}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div>
<div>
<label
for="repo_path"
class="block text-sm font-medium text-gray-700"
>{$t.storage?.repositories }</label
>
<input
type="text"
id="repo_path"
bind:value={settings.storage.repo_path}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"
/>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
on:click={() => handleSave()}
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
{$t.settings?.save_storage_config}
</button>
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
<!-- [/DEF:SettingsPage:Page] -->