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:
2026-06-11 19:12:54 +03:00
parent 61660b345f
commit 20ae1c70bd
5 changed files with 732 additions and 0 deletions

View File

@@ -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 -->

View File

@@ -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

View File

@@ -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"}

View 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 -->

View File

@@ -10,6 +10,7 @@
export const SETTINGS_TABS = [
"environments",
"connections",
"logging",
"git",
"llm",