Files
ss-tools/frontend/src/routes/settings/+page.svelte
busya 4dc2c6ee71 feat(ui): add ConnectionsTab and InsertMethodSelector for direct DB enhancement
- ConnectionsTab.svelte: Settings tab for DB connection CRUD with
  list, add/edit form, test button, delete with dependency check
- InsertMethodSelector.svelte: radio group for insert method choice
  (SQL Lab / Direct DB) with filtered connection dropdown
- InsertMethodSelector.test.ts: component tests for all UX states
- settings/+page.svelte, settings-utils.ts: register Connections tab
2026-06-11 19:12:54 +03:00

324 lines
14 KiB
Svelte

<!-- #region SettingsPage [C:4] [TYPE Page] [SEMANTICS sveltekit, settings, tabbed, environment, config] -->
<!-- @ingroup Routes -->
<!-- @BRIEF Consolidated Settings Page shell — thin layout that delegates each tab to its own component. -->
<!-- @LAYER Page -->
<!-- @RATIONALE Decomposed from 1451→287 lines by extracting 6 tab components and centralizing state in the page shell. The C4 rating reflects the page's role as tab orchestrator with settings load/save lifecycle. -->
<!-- @REJECTED Keeping all tab content inline was rejected due to INV_7 violations (single contract exceeded 150 lines, cyclomatic complexity > 10). -->
<!-- @DEPRECATED N/A -->
<!-- @REPLACED_BY N/A -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:EnvironmentsTab] -->
<!-- @RELATION DEPENDS_ON -> [LoggingSettings] -->
<!-- @RELATION DEPENDS_ON -> [LlmSettings] -->
<!-- @RELATION DEPENDS_ON -> [MigrationSettings] -->
<!-- @RELATION DEPENDS_ON -> [StorageSettings] -->
<!-- @RELATION DEPENDS_ON -> [FeaturesSettings] -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:SettingsUtils] -->
<!-- @PRE Route is loaded in the authenticated UI shell. -->
<!-- @POST Page exposes consolidated settings tabs; each tab manages its own state. -->
<!-- @SIDE_EFFECT Loads settings data on mount. -->
<!-- @UX_STATE Loading -> Skeleton loader. -->
<!-- @UX_STATE Loaded -> Tabbed settings interface. -->
<!-- @UX_STATE Error -> Error banner with retry. -->
<!-- @UX_FEEDBACK Toast on save. -->
<!-- @UX_RECOVERY Refresh button reloads settings. -->
<script lang="ts">
import { onMount } from "svelte";
import { t } from "$lib/i18n/index.svelte.js";
import { api } from "$lib/api.js";
import { addToast } from "$lib/toasts.svelte.js";
import {
normalizeTab,
readTabFromUrl,
writeTabToUrl,
normalizeLlmSettings,
} from "./settings-utils.js";
import EnvironmentsTab from "./EnvironmentsTab.svelte";
import LoggingSettings from "./LoggingSettings.svelte";
import LlmSettings from "./LlmSettings.svelte";
import MigrationSettings from "./MigrationSettings.svelte";
import StorageSettings from "./StorageSettings.svelte";
import FeaturesSettings from "./FeaturesSettings.svelte";
import SystemSettings from "./SystemSettings.svelte";
import GitSettingsPage from "./git/+page.svelte";
import AutomationSettingsPage from "./automation/+page.svelte";
import LanguagesSettings from "./LanguagesSettings.svelte";
import ConnectionsTab from "./ConnectionsTab.svelte";
let activeTab = $state("environments");
let settings = $state(null);
let isLoading = $state(true);
let error = $state(null);
onMount(() => {
activeTab = readTabFromUrl();
const syncTabFromUrl = () => {
activeTab = readTabFromUrl();
};
window.addEventListener("popstate", syncTabFromUrl);
window.addEventListener("hashchange", syncTabFromUrl);
void loadSettings();
return () => {
window.removeEventListener("popstate", syncTabFromUrl);
window.removeEventListener("hashchange", syncTabFromUrl);
};
});
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 handleTabChange(tab) {
const normalizedTab = normalizeTab(tab);
if (!normalizedTab) return;
activeTab = normalizedTab;
writeTabToUrl(activeTab);
}
async function handleSave() {
console.log("[SettingsPage][Action] Saving settings");
try {
settings.llm = normalizeLlmSettings(settings.llm);
await api.updateConsolidatedSettings(settings);
addToast($t.settings?.save_success, "success");
} catch (err) {
console.error("[SettingsPage][Coherence:Failed]", err);
addToast($t.settings?.save_failed, "error");
}
}
async function handleRefresh() {
await loadSettings();
}
</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-text">
{$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"
onclick={handleRefresh}
>
{$t.common?.refresh}
</button>
</div>
<!-- Error Banner -->
{#if error}
<div
class="bg-destructive-light border border-destructive-ring text-destructive 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"
onclick={loadSettings}
>
{$t.common?.retry}
</button>
</div>
{/if}
<!-- Loading State -->
{#if isLoading}
<div class="rounded-xl border border-border bg-surface-card p-6 shadow-sm">
<div class="space-y-3">
<div class="h-8 animate-pulse rounded bg-surface-muted"></div>
<div class="h-8 animate-pulse rounded bg-surface-muted"></div>
<div class="h-8 animate-pulse rounded bg-surface-muted"></div>
<div class="h-8 animate-pulse rounded bg-surface-muted"></div>
<div class="h-8 animate-pulse rounded bg-surface-muted"></div>
</div>
</div>
{:else if settings}
<!-- Tabs -->
<div class="border-b border-border mb-6">
<button
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
class:text-primary={activeTab === "environments"}
class:border-primary={activeTab === "environments"}
class:text-text-muted={activeTab !== "environments"}
class:border-transparent={activeTab !== "environments"}
class:hover:text-text={activeTab !== "environments"}
class:hover:border-border-strong={activeTab !== "environments"}
aria-current={activeTab === "environments" ? "page" : undefined}
onclick={() => 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-primary={activeTab === "connections"}
class:border-primary={activeTab === "connections"}
class:text-text-muted={activeTab !== "connections"}
class:border-transparent={activeTab !== "connections"}
class:hover:text-text={activeTab !== "connections"}
class:hover:border-border-strong={activeTab !== "connections"}
aria-current={activeTab === "connections" ? "page" : undefined}
onclick={() => handleTabChange("connections")}
>
Connections
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
class:text-primary={activeTab === "logging"}
class:border-primary={activeTab === "logging"}
class:text-text-muted={activeTab !== "logging"}
class:border-transparent={activeTab !== "logging"}
class:hover:text-text={activeTab !== "logging"}
class:hover:border-border-strong={activeTab !== "logging"}
aria-current={activeTab === "logging" ? "page" : undefined}
onclick={() => 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-primary={activeTab === "git"}
class:border-primary={activeTab === "git"}
class:text-text-muted={activeTab !== "git"}
class:border-transparent={activeTab !== "git"}
class:hover:text-text={activeTab !== "git"}
class:hover:border-border-strong={activeTab !== "git"}
aria-current={activeTab === "git" ? "page" : undefined}
onclick={() => handleTabChange("git")}
>
{$t.nav?.settings_git || $t.nav?.git || "Git"}
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
class:text-primary={activeTab === "llm"}
class:border-primary={activeTab === "llm"}
class:text-text-muted={activeTab !== "llm"}
class:border-transparent={activeTab !== "llm"}
class:hover:text-text={activeTab !== "llm"}
class:hover:border-border-strong={activeTab !== "llm"}
aria-current={activeTab === "llm" ? "page" : undefined}
onclick={() => 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-primary={activeTab === "migration"}
class:border-primary={activeTab === "migration"}
class:text-text-muted={activeTab !== "migration"}
class:border-transparent={activeTab !== "migration"}
class:hover:text-text={activeTab !== "migration"}
class:hover:border-border-strong={activeTab !== "migration"}
aria-current={activeTab === "migration" ? "page" : undefined}
onclick={() => 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-primary={activeTab === "storage"}
class:border-primary={activeTab === "storage"}
class:text-text-muted={activeTab !== "storage"}
class:border-transparent={activeTab !== "storage"}
class:hover:text-text={activeTab !== "storage"}
class:hover:border-border-strong={activeTab !== "storage"}
aria-current={activeTab === "storage" ? "page" : undefined}
onclick={() => handleTabChange("storage")}
>
{$t.settings?.storage}
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
class:text-primary={activeTab === "features"}
class:border-primary={activeTab === "features"}
class:text-text-muted={activeTab !== "features"}
class:border-transparent={activeTab !== "features"}
class:hover:text-text={activeTab !== "features"}
class:hover:border-border-strong={activeTab !== "features"}
aria-current={activeTab === "features" ? "page" : undefined}
onclick={() => handleTabChange("features")}
>
{$t.settings?.features || "Features"}
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
class:text-primary={activeTab === "automation"}
class:border-primary={activeTab === "automation"}
class:text-text-muted={activeTab !== "automation"}
class:border-transparent={activeTab !== "automation"}
class:hover:text-text={activeTab !== "automation"}
class:hover:border-border-strong={activeTab !== "automation"}
aria-current={activeTab === "automation" ? "page" : undefined}
onclick={() => handleTabChange("automation")}
>
{$t.settings?.automation || "Automation"}
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
class:text-primary={activeTab === "languages"}
class:border-primary={activeTab === "languages"}
class:text-text-muted={activeTab !== "languages"}
class:border-transparent={activeTab !== "languages"}
class:hover:text-text={activeTab !== "languages"}
class:hover:border-border-strong={activeTab !== "languages"}
aria-current={activeTab === "languages" ? "page" : undefined}
onclick={() => handleTabChange("languages")}
>
{$t.settings?.languages || "Languages"}
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors focus:outline-none border-b-2"
class:text-primary={activeTab === "system"}
class:border-primary={activeTab === "system"}
class:text-text-muted={activeTab !== "system"}
class:border-transparent={activeTab !== "system"}
class:hover:text-text={activeTab !== "system"}
class:hover:border-border-strong={activeTab !== "system"}
aria-current={activeTab === "system" ? "page" : undefined}
onclick={() => handleTabChange("system")}
>
{$t.settings?.system || "System"}
</button>
</div>
<!-- Tab Content -->
<div class="rounded-xl border border-border bg-surface-card p-6 shadow-sm">
{#if activeTab === "environments"}
<EnvironmentsTab bind:settings onSave={loadSettings} />
{:else if activeTab === "connections"}
<ConnectionsTab bind:settings onSave={handleSave} />
{:else if activeTab === "logging"}
<LoggingSettings bind:settings onSave={handleSave} />
{:else if activeTab === "git"}
<GitSettingsPage />
{:else if activeTab === "llm"}
<LlmSettings bind:settings onSave={handleSave} />
{:else if activeTab === "migration"}
<MigrationSettings />
{:else if activeTab === "storage"}
<StorageSettings bind:settings onSave={handleSave} />
{:else if activeTab === "features"}
<FeaturesSettings bind:settings onSave={handleSave} />
{:else if activeTab === "automation"}
<AutomationSettingsPage />
{:else if activeTab === "languages"}
<LanguagesSettings bind:settings onSave={handleSave} />
{:else if activeTab === "system"}
<SystemSettings bind:settings onSave={handleSave} />
{/if}
</div>
{/if}
</div>
<!-- #endregion SettingsPage -->