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
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
<!-- #region Translate.InsertMethodSelector [C:2] [TYPE Component] [SEMANTICS translate,insert,ui,database] -->
|
||||
<!-- @ingroup Translate -->
|
||||
<!-- @BRIEF Radio group for choosing insert method: SQL Lab API (default) or Direct DB Connection.
|
||||
<!-- Both SQL Lab database selector and Direct DB connection selector appear -->
|
||||
<!-- directly under their respective radio with identical indented design. -->
|
||||
<!-- @UX_STATE sqllab_selected -> SQL Lab database selector visible under SQL Lab radio -->
|
||||
<!-- @UX_STATE direct_db_selected -> Direct DB connection dropdown visible under Direct DB radio -->
|
||||
<!-- @UX_STATE no_connections, connection_selected, testing_connection, test_success, test_error -->
|
||||
<!-- @UX_SYMMETRY Both selectors use ml-6 pl-4 border-l-2 border-border spacing -->
|
||||
<!-- @UX_FEEDBACK dialect compatibility badge; connection status indicator; 'No connections' empty state with CTA link -->
|
||||
<!-- @UX_RECOVERY no_connections -> 'Go to Settings - Connections' link; test_error -> error details -->
|
||||
<script lang="ts">
|
||||
import { getT } from "$lib/i18n/index.svelte.js";
|
||||
import { api } from "$lib/api.js";
|
||||
import { ROUTES } from "$lib/routes";
|
||||
import { addToast } from "$lib/toasts.svelte.js";
|
||||
import HelpTooltip from "$lib/ui/HelpTooltip.svelte";
|
||||
|
||||
const _t = $derived(getT());
|
||||
|
||||
interface Connection {
|
||||
id: string;
|
||||
name: string;
|
||||
dialect: string;
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
let {
|
||||
insertMethod = $bindable("sqllab"),
|
||||
connectionId = $bindable(null),
|
||||
targetDatabaseId = $bindable(''),
|
||||
jobDialect = "",
|
||||
databases = [] as Record<string, unknown>[],
|
||||
databasesLoading = false,
|
||||
environmentId = '',
|
||||
} = $props();
|
||||
|
||||
let connections = $state<Connection[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let testResult = $state<string | null>(null);
|
||||
let isTesting = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (insertMethod === "direct_db") {
|
||||
loadConnections();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadConnections() {
|
||||
isLoading = true;
|
||||
try {
|
||||
connections = await api.fetchConnections<Connection[]>();
|
||||
} catch {
|
||||
connections = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const compatibleConnections = $derived(
|
||||
jobDialect && jobDialect !== 'unknown'
|
||||
? connections.filter(c => c.dialect === jobDialect)
|
||||
: connections
|
||||
);
|
||||
|
||||
async function handleTestConnection() {
|
||||
if (!connectionId) return;
|
||||
isTesting = true;
|
||||
testResult = null;
|
||||
try {
|
||||
const r = await api.testConnection(connectionId);
|
||||
if (r.success) {
|
||||
testResult = `✅ Connected — ${r.latency_ms}ms`;
|
||||
addToast(`${_t.settings?.connections_test_success?.replace('{latency}', r.latency_ms) || `✅ Connected — ${r.latency_ms}ms`}`, "success");
|
||||
} else {
|
||||
testResult = `❌ ${r.error}`;
|
||||
addToast(`${_t.settings?.connections_test_failed ?? '❌'} ${r.error}`, "error");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
testResult = `❌ Test failed`;
|
||||
addToast(_t.settings?.connections_test_failed ?? "Connection test failed", "error");
|
||||
} finally {
|
||||
isTesting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Heading provided by parent TargetTabForm -->
|
||||
|
||||
<!-- SQL Lab radio -->
|
||||
<label class="flex items-center gap-2 p-3 border border-border rounded-md cursor-pointer hover:bg-surface-muted/50">
|
||||
<input type="radio" name="insert_method" value="sqllab" bind:group={insertMethod} checked={insertMethod === "sqllab"} />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-text">{_t.translate?.config?.insert_method_sqllab || 'Superset SQL Lab API'}</span>
|
||||
<p class="text-xs text-text-muted">{_t.translate?.config?.insert_method_sqllab_hint || 'Executes via Superset /api/v1/sqllab/execute/'}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- SQL Lab database selector — under SQL Lab radio -->
|
||||
{#if insertMethod === 'sqllab'}
|
||||
<div class="ml-6 pl-4 border-l-2 border-border space-y-2">
|
||||
<label class="block text-sm font-medium text-text flex items-center gap-1">
|
||||
{_t.translate?.config?.target_database || 'Target Database (SQL Lab)'}
|
||||
<HelpTooltip text={_t.translate?.config?.help_target_database || ''} />
|
||||
</label>
|
||||
<select
|
||||
bind:value={targetDatabaseId}
|
||||
class="w-full border border-border-strong rounded-md px-2 py-1.5 text-sm text-text bg-surface-card"
|
||||
disabled={!environmentId || databasesLoading}
|
||||
>
|
||||
<option value="">{_t.translate?.config?.select_target_database || 'Select database...'}</option>
|
||||
{#each databases as db}
|
||||
<option value={String(db.id || db.uuid)}>
|
||||
{db.database_name || db.name} ({db.engine || db.backend || '?'})
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if databasesLoading}
|
||||
<p class="text-xs text-text-muted">{_t.translate?.config?.loading_databases || 'Loading databases...'}</p>
|
||||
{:else if !environmentId}
|
||||
<p class="text-xs text-text-muted">{_t.translate?.config?.select_environment || 'Select environment first'}</p>
|
||||
{:else}
|
||||
<p class="text-xs text-text-muted">{_t.translate?.config?.target_database_hint || ''}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Direct DB radio -->
|
||||
<label class="flex items-center gap-2 p-3 border border-border rounded-md cursor-pointer hover:bg-surface-muted/50">
|
||||
<input type="radio" name="insert_method" value="direct_db" bind:group={insertMethod} checked={insertMethod === "direct_db"} />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-text">{_t.translate?.config?.insert_method_direct_db || 'Direct Database Connection'}</span>
|
||||
<p class="text-xs text-text-muted">{_t.translate?.config?.insert_method_direct_db_hint || 'Executes INSERT directly against target database'}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Direct DB connection selector — under Direct DB radio -->
|
||||
{#if insertMethod === "direct_db"}
|
||||
<div class="ml-6 pl-4 border-l-2 border-border space-y-2">
|
||||
{#if isLoading}
|
||||
<div class="h-8 animate-pulse bg-surface-muted rounded"></div>
|
||||
{:else if connections.length === 0}
|
||||
<div class="text-sm text-text-muted">
|
||||
{_t.translate?.config?.insert_method_no_conns || 'No database connections configured.'}
|
||||
<a href={ROUTES.settings.connections()} class="text-primary hover:underline">{_t.translate?.config?.insert_method_go_settings || 'Go to Settings → Connections'}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="block text-sm font-medium text-text flex items-center gap-1">
|
||||
{_t.translate?.config?.insert_method_connection_label || 'Database Connection'}
|
||||
<HelpTooltip text={_t.translate?.config?.insert_method_connection_help || ''} />
|
||||
</label>
|
||||
<select
|
||||
class="w-full border border-border-strong rounded-md px-2 py-1.5 text-sm text-text bg-surface-card"
|
||||
bind:value={connectionId}
|
||||
>
|
||||
<option value={null}>{_t.translate?.config?.insert_method_select || '— Select connection —'}</option>
|
||||
{#each compatibleConnections as conn (conn.id)}
|
||||
<option value={conn.id}>
|
||||
{conn.name} ({conn.dialect} · {conn.host}:{conn.port})
|
||||
</option>
|
||||
{/each}
|
||||
{#if compatibleConnections.length === 0}
|
||||
<option disabled>{_t.translate?.config?.insert_method_no_compatible?.replace('{dialect}', jobDialect) || 'No compatible connections for dialect: {dialect}'}</option>
|
||||
{/if}
|
||||
</select>
|
||||
|
||||
{#if connectionId}
|
||||
<button
|
||||
class="text-xs text-primary hover:underline disabled:opacity-50"
|
||||
onclick={handleTestConnection}
|
||||
disabled={isTesting}
|
||||
>
|
||||
{isTesting ? (_t.translate?.config?.insert_method_testing || 'Testing...') : (_t.translate?.config?.insert_method_test || 'Test Connection')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if testResult}
|
||||
<p class="text-xs {testResult.startsWith('✅') ? 'text-success' : 'text-destructive'}">{testResult}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion Translate.InsertMethodSelector -->
|
||||
@@ -0,0 +1,144 @@
|
||||
// #region Test.Translate.InsertMethodSelector [C:2] [TYPE Module] [SEMANTICS test, translate, insert-method, ui]
|
||||
// @BRIEF Vitest tests for InsertMethodSelector component states and transitions.
|
||||
// @RELATION BINDS_TO -> [Translate.InsertMethodSelector]
|
||||
// @TEST_INVARIANT: default state -> sqllab_selected
|
||||
// @TEST_INVARIANT: direct_db with connections -> dropdown visible
|
||||
// @TEST_INVARIANT: direct_db no connections -> Settings link visible
|
||||
// @TEST_EDGE: empty connections list -> "Go to Settings" link shown
|
||||
// @TEST_EDGE: dialect mismatch -> connections filtered out
|
||||
// @TEST_INVARIANT: test_connection success/failure -> result displayed
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import InsertMethodSelector from '$lib/components/translate/InsertMethodSelector.svelte';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
vi.mock('$lib/api', () => ({
|
||||
api: {
|
||||
fetchConnections: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('$lib/i18n/index.svelte.js', () => ({
|
||||
getT: () => ({
|
||||
translate: { config: {} },
|
||||
settings: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockConnections = [
|
||||
{ id: 'c1', name: 'Products DB', dialect: 'postgresql', host: 'db.prod', port: 5432 },
|
||||
{ id: 'c2', name: 'Reports DB', dialect: 'postgresql', host: 'db.rpt', port: 5432 },
|
||||
{ id: 'c3', name: 'ClickHouse', dialect: 'clickhouse', host: 'ch.anal', port: 9000 },
|
||||
];
|
||||
|
||||
describe('InsertMethodSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// #region default_state [C:2] [TYPE Function]
|
||||
// @BRIEF Default state shows sqllab selected
|
||||
// @TEST_CONTRACT: insertMethod="sqllab" -> SQL Lab radio checked
|
||||
it('defaults to sqllab insert method', () => {
|
||||
const { container } = render(InsertMethodSelector, {
|
||||
props: { insertMethod: 'sqllab', connectionId: null, jobDialect: 'postgresql' },
|
||||
});
|
||||
|
||||
const radios = container.querySelectorAll('input[type="radio"]');
|
||||
expect(radios[0].checked).toBe(true);
|
||||
expect(radios[1].checked).toBe(false);
|
||||
});
|
||||
// #endregion default_state
|
||||
|
||||
// #region switch_to_direct_db [C:2] [TYPE Function]
|
||||
// @BRIEF Switching to direct_db with connections shows dropdown
|
||||
// @TEST_INVARIANT: click direct_db radio -> connections loaded -> dropdown visible
|
||||
it('shows connection dropdown when switching to direct_db with connections', async () => {
|
||||
vi.mocked(api.fetchConnections).mockResolvedValue(mockConnections);
|
||||
|
||||
const { container } = render(InsertMethodSelector, {
|
||||
props: { insertMethod: 'sqllab', connectionId: null, jobDialect: 'postgresql' },
|
||||
});
|
||||
|
||||
const radios = container.querySelectorAll('input[type="radio"]');
|
||||
fireEvent.click(radios[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.fetchConnections).toHaveBeenCalled();
|
||||
const select = container.querySelector('select');
|
||||
expect(select).toBeTruthy();
|
||||
});
|
||||
});
|
||||
// #endregion switch_to_direct_db
|
||||
|
||||
// #region empty_connections [C:2] [TYPE Function]
|
||||
// @BRIEF Empty connections shows Settings link
|
||||
// @TEST_EDGE: no connections -> "Go to Settings" link rendered
|
||||
it('shows settings link when no connections exist', async () => {
|
||||
vi.mocked(api.fetchConnections).mockResolvedValue([]);
|
||||
|
||||
const { container } = render(InsertMethodSelector, {
|
||||
props: { insertMethod: 'sqllab', connectionId: null, jobDialect: 'postgresql' },
|
||||
});
|
||||
|
||||
const radios = container.querySelectorAll('input[type="radio"]');
|
||||
fireEvent.click(radios[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('Go to Settings');
|
||||
});
|
||||
});
|
||||
// #endregion empty_connections
|
||||
|
||||
// #region dialect_filtering [C:2] [TYPE Function]
|
||||
// @BRIEF Connections filtered by dialect matching jobDialect
|
||||
// @TEST_EDGE: clickhouse dialect -> only clickhouse connections shown
|
||||
it('filters connections by dialect', async () => {
|
||||
vi.mocked(api.fetchConnections).mockResolvedValue(mockConnections);
|
||||
|
||||
const { container } = render(InsertMethodSelector, {
|
||||
props: { insertMethod: 'sqllab', connectionId: null, jobDialect: 'clickhouse' },
|
||||
});
|
||||
|
||||
const radios = container.querySelectorAll('input[type="radio"]');
|
||||
fireEvent.click(radios[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
const select = container.querySelector('select');
|
||||
const options = select?.querySelectorAll('option');
|
||||
const optionTexts = Array.from(options || []).map(o => o.textContent);
|
||||
// Should only have clickhouse connections. PostgreSQL ones filtered out
|
||||
expect(optionTexts.some(t => t?.includes('ClickHouse'))).toBe(true);
|
||||
// Default option "Select connection" counts too
|
||||
expect(optionTexts.some(t => t?.includes('Products DB'))).toBe(false);
|
||||
});
|
||||
});
|
||||
// #endregion dialect_filtering
|
||||
|
||||
// #region test_connection_result [C:2] [TYPE Function]
|
||||
// @BRIEF Connection test success shows result
|
||||
// @TEST_EDGE: test_connection success -> green success message
|
||||
it('calls testConnection API when Test button clicked', async () => {
|
||||
vi.mocked(api.fetchConnections).mockResolvedValue(mockConnections);
|
||||
vi.mocked(api.testConnection).mockResolvedValue({ success: true, latency_ms: 12 });
|
||||
|
||||
render(InsertMethodSelector, {
|
||||
props: { insertMethod: 'direct_db', connectionId: 'c1', jobDialect: 'postgresql' },
|
||||
});
|
||||
|
||||
// The button text is the i18n fallback "Test Connection"
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Connection')).toBeTruthy();
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByText('Test Connection'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.testConnection).toHaveBeenCalledWith('c1');
|
||||
});
|
||||
});
|
||||
// #endregion test_connection_result
|
||||
});
|
||||
// #endregion Test.Translate.InsertMethodSelector
|
||||
@@ -44,6 +44,7 @@
|
||||
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);
|
||||
@@ -159,6 +160,19 @@
|
||||
>
|
||||
{$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"}
|
||||
@@ -282,6 +296,8 @@
|
||||
<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"}
|
||||
|
||||
385
frontend/src/routes/settings/ConnectionsTab.svelte
Normal file
385
frontend/src/routes/settings/ConnectionsTab.svelte
Normal file
@@ -0,0 +1,385 @@
|
||||
<!-- #region Settings.ConnectionsTab [C:2] [TYPE Component] [SEMANTICS settings,connections,ui] -->
|
||||
<!-- @ingroup Settings -->
|
||||
<!-- @BRIEF Settings tab for managing database connections: list, add/edit form, test, delete with dependency check. -->
|
||||
<!-- @UX_STATE idle, loading, empty, populated, adding, editing, testing, test_success, test_error, deleting, delete_blocked -->
|
||||
<!-- @UX_FEEDBACK spinner during test; toast on save/delete; dialect badge colors; masked password with reveal toggle; test result inline -->
|
||||
<!-- @UX_RECOVERY test_error -> edit and retry test; delete_blocked -> 'Show affected jobs' link in warning modal -->
|
||||
<!-- @UX_TEST idle -> {click: "Add Connection"} -> adding -> fill form -> {click: "Test"} -> testing -> test_success -> {click: "Save"} -> populated -->
|
||||
<script lang="ts">
|
||||
import { getT } from "$lib/i18n/index.svelte.js";
|
||||
import { api } from "$lib/api.js";
|
||||
import { addToast } from "$lib/toasts.svelte.js";
|
||||
|
||||
const _t = $derived(getT());
|
||||
|
||||
interface Connection {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
username: string;
|
||||
password: string;
|
||||
dialect: string;
|
||||
extra_params: Record<string, unknown>;
|
||||
pool_size: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
used_by?: number;
|
||||
}
|
||||
|
||||
type ScreenState = "idle" | "loading" | "empty" | "populated" | "adding" | "editing" | "testing" | "test_success" | "test_error" | "deleting" | "delete_blocked";
|
||||
|
||||
let connections = $state<Connection[]>([]);
|
||||
let screenState = $state<ScreenState>("loading");
|
||||
let showForm = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let testResult = $state<{ success: boolean; latency_ms?: number; db_version?: string; error?: string } | null>(null);
|
||||
let blockDeleteJobs = $state<string[]>([]);
|
||||
|
||||
// Form state
|
||||
let form = $state({
|
||||
name: "",
|
||||
host: "",
|
||||
port: 5432,
|
||||
database: "",
|
||||
username: "",
|
||||
password: "",
|
||||
dialect: "postgresql",
|
||||
extra_params: "",
|
||||
pool_size: 5,
|
||||
});
|
||||
|
||||
const DIALECT_OPTIONS = [
|
||||
{ value: "postgresql", label: "PostgreSQL", color: "bg-blue-100 text-blue-800" },
|
||||
{ value: "clickhouse", label: "ClickHouse", color: "bg-orange-100 text-orange-800" },
|
||||
{ value: "mysql", label: "MySQL", color: "bg-teal-100 text-teal-800" },
|
||||
];
|
||||
|
||||
function getDialectBadge(dialect: string): string {
|
||||
const opt = DIALECT_OPTIONS.find(d => d.value === dialect);
|
||||
return opt?.color ?? "bg-gray-100 text-gray-800";
|
||||
}
|
||||
|
||||
async function loadConnections() {
|
||||
screenState = "loading";
|
||||
try {
|
||||
const data = await api.fetchConnections<Connection[]>();
|
||||
connections = data as Connection[];
|
||||
screenState = connections.length === 0 ? "empty" : "populated";
|
||||
} catch (err) {
|
||||
console.error("Failed to load connections:", err);
|
||||
addToast(_t.settings?.connections_load_failed ?? "Failed to load connections", "error");
|
||||
screenState = "empty";
|
||||
}
|
||||
}
|
||||
|
||||
function openAddForm() {
|
||||
editingId = null;
|
||||
showForm = true;
|
||||
form = { name: "", host: "", port: 5432, database: "", username: "", password: "", dialect: "postgresql", extra_params: "", pool_size: 5 };
|
||||
testResult = null;
|
||||
screenState = "adding";
|
||||
}
|
||||
|
||||
function openEditForm(conn: Connection) {
|
||||
editingId = conn.id;
|
||||
showForm = true;
|
||||
form = {
|
||||
name: conn.name,
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
database: conn.database,
|
||||
username: conn.username,
|
||||
password: "",
|
||||
dialect: conn.dialect,
|
||||
extra_params: JSON.stringify(conn.extra_params ?? {}, null, 2),
|
||||
pool_size: conn.pool_size,
|
||||
};
|
||||
testResult = null;
|
||||
screenState = "editing";
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
showForm = false;
|
||||
editingId = null;
|
||||
screenState = connections.length === 0 ? "empty" : "populated";
|
||||
}
|
||||
|
||||
function validateForm(): string | null {
|
||||
if (!form.name.trim()) return "Name is required";
|
||||
if (!form.host.trim()) return "Host is required";
|
||||
if (!form.database.trim()) return "Database is required";
|
||||
if (!form.username.trim()) return "Username is required";
|
||||
if (!editingId && !form.password) return "Password is required";
|
||||
if (form.port < 1 || form.port > 65535) return "Port must be between 1 and 65535";
|
||||
if (form.pool_size < 1 || form.pool_size > 100) return "Pool size must be 1-100";
|
||||
if (form.extra_params.trim()) {
|
||||
try {
|
||||
JSON.parse(form.extra_params);
|
||||
} catch {
|
||||
return "Extra params must be valid JSON";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
addToast(validationError, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name.trim(),
|
||||
host: form.host.trim(),
|
||||
port: form.port,
|
||||
database: form.database.trim(),
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
dialect: form.dialect,
|
||||
pool_size: form.pool_size,
|
||||
};
|
||||
if (form.extra_params.trim()) {
|
||||
payload.extra_params = JSON.parse(form.extra_params);
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await api.updateConnection(editingId, payload);
|
||||
addToast(_t.settings?.connections_updated ?? "Connection updated", "success");
|
||||
} else {
|
||||
await api.createConnection(payload);
|
||||
addToast(_t.settings?.connections_created ?? "Connection created", "success");
|
||||
}
|
||||
showForm = false;
|
||||
editingId = null;
|
||||
await loadConnections();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : _t.settings?.connections_save_failed ?? "Save failed";
|
||||
addToast(msg, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
screenState = "testing";
|
||||
testResult = null;
|
||||
try {
|
||||
const id = editingId ?? "__unsaved";
|
||||
let result: { success: boolean; latency_ms?: number; db_version?: string; error?: string };
|
||||
if (editingId) {
|
||||
result = await api.testConnection<{ success: boolean; latency_ms?: number; db_version?: string; error?: string }>(editingId);
|
||||
} else {
|
||||
// For unsaved connections, test via create with temporary save
|
||||
result = { success: false, error: "Save the connection first to test it" };
|
||||
}
|
||||
testResult = result;
|
||||
screenState = result.success ? "test_success" : "test_error";
|
||||
if (result.success) {
|
||||
addToast(`✅ Connected — ${result.latency_ms}ms`, "success");
|
||||
} else {
|
||||
addToast(`❌ ${result.error}`, "error");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
testResult = { success: false, error: err instanceof Error ? err.message : "Test failed" };
|
||||
screenState = "test_error";
|
||||
addToast("Test failed", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
screenState = "deleting";
|
||||
try {
|
||||
const result = await api.deleteConnection(id);
|
||||
addToast("Connection deleted", "success");
|
||||
await loadConnections();
|
||||
} catch (err: unknown) {
|
||||
// Check for 409 blocking error
|
||||
if (err && typeof err === "object" && "detail" in err) {
|
||||
const detail = (err as { detail: { blocking_jobs?: string[] } }).detail;
|
||||
blockDeleteJobs = detail?.blocking_jobs ?? [];
|
||||
screenState = "delete_blocked";
|
||||
addToast("Cannot delete — connection is in use", "warning");
|
||||
} else {
|
||||
addToast(err instanceof Error ? err.message : "Delete failed", "error");
|
||||
screenState = connections.length === 0 ? "empty" : "populated";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id: string) {
|
||||
if (window.confirm("Are you sure you want to delete this connection?")) {
|
||||
handleDelete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Load on mount
|
||||
$effect(() => {
|
||||
loadConnections();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-text">{_t.settings?.connections_title || 'Database Connections'}</h3>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-primary text-white rounded-md text-sm hover:bg-primary-hover disabled:opacity-50"
|
||||
onclick={openAddForm}
|
||||
disabled={screenState === "adding" || screenState === "editing"}
|
||||
>
|
||||
{_t.settings?.connections_add || '+ Add Connection'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if screenState === "loading"}
|
||||
<div class="animate-pulse space-y-3">
|
||||
{#each [1, 2, 3] as _}
|
||||
<div class="h-16 bg-surface-muted rounded-lg"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else if screenState === "empty" && !showForm}
|
||||
<div class="border-2 border-dashed border-border rounded-lg p-8 text-center">
|
||||
<p class="text-text-muted mb-2">{_t.settings?.connections_empty || 'No database connections configured.'}</p>
|
||||
<p class="text-text-subtle text-sm mb-4">{_t.settings?.connections_empty_hint || 'Add a connection to enable direct database inserts for translation jobs.'}</p>
|
||||
<button
|
||||
class="px-4 py-2 bg-primary text-white rounded-md text-sm hover:bg-primary-hover"
|
||||
onclick={openAddForm}
|
||||
>
|
||||
{_t.settings?.connections_add || '+ Add Connection'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if screenState === "populated" && !showForm}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-border">
|
||||
<thead class="bg-surface-muted">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{_t.settings?.connections_name || 'Name'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{_t.settings?.connections_host || 'Host:Port'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{_t.settings?.connections_database || 'Database'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{_t.settings?.connections_dialect || 'Dialect'}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{_t.settings?.connections_used_by || 'Used By'}</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-text-muted uppercase tracking-wider">{_t.settings?.connections_actions || 'Actions'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
{#each connections as conn (conn.id)}
|
||||
<tr class="hover:bg-surface-muted/50">
|
||||
<td class="px-3 py-2 text-sm font-medium text-text">{conn.name}</td>
|
||||
<td class="px-3 py-2 text-sm text-text-muted">{conn.host}:{conn.port}</td>
|
||||
<td class="px-3 py-2 text-sm text-text-muted">{conn.database}</td>
|
||||
<td class="px-3 py-2 text-sm">
|
||||
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium {getDialectBadge(conn.dialect)}">
|
||||
{conn.dialect}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-text-muted">{conn.used_by ?? 0} {_t.settings?.connections_used_by?.toLowerCase() || 'jobs'}</td>
|
||||
<td class="px-3 py-2 text-sm text-right space-x-2">
|
||||
<button class="text-primary hover:underline text-xs" onclick={() => openEditForm(conn)}>{_t.settings?.connections_edit || 'Edit'}</button>
|
||||
<button class="text-primary hover:underline text-xs" onclick={async () => {
|
||||
const r = await api.testConnection(conn.id);
|
||||
if (r.success) addToast(`✅ ${_t.settings?.connections_test_success?.replace('{latency}', r.latency_ms) || `Connected — ${r.latency_ms}ms`}`, "success");
|
||||
else addToast(`❌ ${_t.settings?.connections_test_failed || 'Test failed'}: ${r.error}`, "error");
|
||||
}}>{_t.settings?.connections_test || 'Test'}</button>
|
||||
<button class="text-destructive hover:underline text-xs" onclick={() => confirmDelete(conn.id)}>{_t.settings?.connections_delete || 'Delete'}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if screenState === "delete_blocked"}
|
||||
<div class="p-4 border border-destructive-ring bg-destructive-light rounded-lg">
|
||||
<p class="text-sm font-medium text-destructive">{_t.settings?.connections_delete_blocked_title || 'Cannot delete connection'}</p>
|
||||
<p class="text-sm text-destructive mt-1">{_t.settings?.connections_delete_blocked_hint?.replace('{count}', blockDeleteJobs.length) || `This connection is used by ${blockDeleteJobs.length} active job(s):`}</p>
|
||||
{#if blockDeleteJobs.length > 0}
|
||||
<ul class="list-disc list-inside mt-1 text-sm text-destructive">
|
||||
{#each blockDeleteJobs as jobName}
|
||||
<li>{jobName}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="text-sm text-destructive mt-1">{_t.settings?.connections_delete_blocked_help || 'Detach or reassign these jobs before deleting.'}</p>
|
||||
<button class="mt-2 text-sm text-primary hover:underline" onclick={() => { screenState = "populated"; blockDeleteJobs = []; }}>
|
||||
{_t.settings?.connections_dismiss || 'Dismiss'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<div class="border border-border rounded-lg p-4 space-y-3 bg-surface-card">
|
||||
<h4 class="text-base font-semibold text-text">{editingId ? (_t.settings?.connections_edit_title || 'Edit Connection') : (_t.settings?.connections_add_title || 'Add Connection')}</h4>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-muted mb-1">{_t.settings?.connections_name_label || 'Name *'}</label>
|
||||
<input class="w-full border border-border-strong rounded-md px-2 py-1.5 text-sm text-text bg-surface-card" bind:value={form.name} placeholder="My PostgreSQL" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-muted mb-1">{_t.settings?.connections_dialect_label || 'Dialect *'}</label>
|
||||
<select class="w-full border border-border-strong rounded-md px-2 py-1.5 text-sm text-text bg-surface-card" bind:value={form.dialect}>
|
||||
{#each DIALECT_OPTIONS as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-muted mb-1">{_t.settings?.connections_host_label || 'Host *'}</label>
|
||||
<input class="w-full border border-border-strong rounded-md px-2 py-1.5 text-sm text-text bg-surface-card" bind:value={form.host} placeholder="db.internal.example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-muted mb-1">{_t.settings?.connections_port_label || 'Port *'}</label>
|
||||
<input type="number" class="w-full border border-border-strong rounded-md px-2 py-1.5 text-sm text-text bg-surface-card" bind:value={form.port} min="1" max="65535" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-muted mb-1">{_t.settings?.connections_db_label || 'Database *'}</label>
|
||||
<input class="w-full border border-border-strong rounded-md px-2 py-1.5 text-sm text-text bg-surface-card" bind:value={form.database} placeholder="products_i18n" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-muted mb-1">{_t.settings?.connections_user_label || 'Username *'}</label>
|
||||
<input class="w-full border border-border-strong rounded-md px-2 py-1.5 text-sm text-text bg-surface-card" bind:value={form.username} placeholder="translator" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-muted mb-1">{editingId ? (_t.settings?.connections_password_keep || 'Password (leave empty to keep)') : (_t.settings?.connections_password_label || 'Password *')}</label>
|
||||
<input type="password" class="w-full border border-border-strong rounded-md px-2 py-1.5 text-sm text-text bg-surface-card" bind:value={form.password} placeholder={editingId ? "********" : "Enter password"} />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-muted mb-1">{_t.settings?.connections_pool_label || 'Pool Size'}</label>
|
||||
<input type="number" class="w-full border border-border-strong rounded-md px-2 py-1.5 text-sm text-text bg-surface-card" bind:value={form.pool_size} min="1" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-muted mb-1">{_t.settings?.connections_extra_label || 'Extra Params (JSON, optional)'}</label>
|
||||
<textarea class="w-full border border-border-strong rounded-md px-2 py-1.5 text-sm text-text bg-surface-card font-mono" bind:value={form.extra_params} rows={2} placeholder={JSON.stringify({"sslmode": "require"})}></textarea>
|
||||
</div>
|
||||
|
||||
{#if testResult}
|
||||
<div class="text-sm p-2 rounded-md {testResult.success ? 'bg-success-light text-success' : 'bg-destructive-light text-destructive'}">
|
||||
{#if testResult.success}
|
||||
✅ {_t.settings?.connections_test_success?.replace('{latency}', testResult.latency_ms) || `Connected — ${testResult.latency_ms}ms`} · {testResult.db_version}
|
||||
{:else}
|
||||
❌ {testResult.error}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button class="px-3 py-1.5 bg-primary text-white rounded-md text-sm hover:bg-primary-hover" onclick={handleSave}>
|
||||
{editingId ? (_t.settings?.connections_update || 'Update Connection') : (_t.settings?.connections_save || 'Save Connection')}
|
||||
</button>
|
||||
<button class="px-3 py-1.5 border border-border rounded-md text-sm text-text hover:bg-surface-muted disabled:opacity-50 disabled:cursor-not-allowed" onclick={handleTest} disabled={!editingId} title={!editingId ? (_t.settings?.connections_save_unsaved_test || 'Save the connection first to test it') : ''}>
|
||||
{_t.settings?.connections_test || 'Test Connection'}
|
||||
</button>
|
||||
<button class="px-3 py-1.5 text-sm text-text-muted hover:text-text" onclick={cancelForm}>
|
||||
{_t.settings?.connections_cancel || 'Cancel'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion Settings.ConnectionsTab -->
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
export const SETTINGS_TABS = [
|
||||
"environments",
|
||||
"connections",
|
||||
"logging",
|
||||
"git",
|
||||
"llm",
|
||||
|
||||
Reference in New Issue
Block a user