✨ feat(connection): 增加可见数据库选择功能 (#944)
感谢 @eryajf 的贡献!此功能允许用户在创建或编辑连接时选择可见的数据库,支持多种数据库类型。
This commit is contained in:
parent
ae483efb98
commit
41758e6bb1
|
|
@ -40,6 +40,7 @@ import {
|
|||
ArrowLeft,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CheckSquare,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
|
|
@ -50,12 +51,27 @@ import {
|
|||
KeyRound,
|
||||
Link2,
|
||||
List,
|
||||
ListFilter,
|
||||
Loader2,
|
||||
Pipette,
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Trash2,
|
||||
} from "@lucide/vue";
|
||||
import {
|
||||
buildDraftVisibleDatabasesConnectionId,
|
||||
connectionCanChooseVisibleDatabases,
|
||||
initialVisibleDatabaseSelection,
|
||||
visibleDatabaseSelectionIsStale,
|
||||
} from "@/lib/connectionVisibleDatabases";
|
||||
import {
|
||||
canSaveVisibleDatabaseSelection,
|
||||
filterDatabaseNamesForConnection,
|
||||
isSystemDatabaseName,
|
||||
normalizeVisibleDatabaseSelection,
|
||||
} from "@/lib/visibleDatabases";
|
||||
|
||||
type DbOption = { value: string; label: string };
|
||||
type DbCategory = { key: string; title: string; options: DbOption[] };
|
||||
|
|
@ -107,6 +123,13 @@ const isTesting = ref(false);
|
|||
const isSaving = ref(false);
|
||||
const testResult = ref<{ ok: boolean; message: string } | null>(null);
|
||||
const editingId = ref<string | null>(null);
|
||||
const showVisibleDatabasesDialog = ref(false);
|
||||
const isLoadingVisibleDatabases = ref(false);
|
||||
const visibleDatabaseNames = ref<string[]>([]);
|
||||
const visibleDatabaseSelection = ref<Set<string>>(new Set());
|
||||
const visibleDatabaseSearchText = ref("");
|
||||
const visibleDatabaseError = ref("");
|
||||
const visibleDatabaseShowSystem = ref(false);
|
||||
let testRunId = 0;
|
||||
|
||||
const defaultForm = (): ConnectionForm => ({
|
||||
|
|
@ -142,6 +165,7 @@ const defaultForm = (): ConnectionForm => ({
|
|||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: "",
|
||||
etcd_endpoints: "",
|
||||
visible_databases: undefined,
|
||||
});
|
||||
|
||||
function defaultSshTunnel(): SshTunnelConfig {
|
||||
|
|
@ -573,6 +597,7 @@ watch(
|
|||
redis_sentinel_tls: config.redis_sentinel_tls || false,
|
||||
redis_cluster_nodes: config.redis_cluster_nodes || "",
|
||||
etcd_endpoints: config.etcd_endpoints || "",
|
||||
visible_databases: config.visible_databases,
|
||||
};
|
||||
h2ConnectionMode.value = h2ConnectionModeForConfig(config);
|
||||
customColorInput.value = config.color || "";
|
||||
|
|
@ -950,6 +975,30 @@ const canUseTransportLayers = computed(
|
|||
const shouldShowAgentDriverInstallHint = computed(() =>
|
||||
showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile),
|
||||
);
|
||||
const canChooseVisibleDatabases = computed(() => connectionCanChooseVisibleDatabases(form.value));
|
||||
const hasVisibleDatabaseFilter = computed(() => Array.isArray(form.value.visible_databases));
|
||||
const visibleDatabaseSummary = computed(() => {
|
||||
const configured = form.value.visible_databases;
|
||||
if (!Array.isArray(configured)) return t("visibleDatabases.showAll");
|
||||
return t("visibleDatabases.selectedCount", { selected: configured.length, total: visibleDatabaseNames.value.length });
|
||||
});
|
||||
const listedVisibleDatabaseNames = computed(() => {
|
||||
const connection = connectionConfigSnapshotForVisibleDatabases();
|
||||
if (visibleDatabaseShowSystem.value) return visibleDatabaseNames.value;
|
||||
return filterDatabaseNamesForConnection(visibleDatabaseNames.value, connection);
|
||||
});
|
||||
const filteredVisibleDatabaseNames = computed(() => {
|
||||
const query = visibleDatabaseSearchText.value.trim().toLowerCase();
|
||||
if (!query) return listedVisibleDatabaseNames.value;
|
||||
return listedVisibleDatabaseNames.value.filter((name) => name.toLowerCase().includes(query));
|
||||
});
|
||||
const visibleDatabaseSelectedCount = computed(() => visibleDatabaseSelection.value.size);
|
||||
const visibleDatabaseTotalCount = computed(() => listedVisibleDatabaseNames.value.length);
|
||||
const visibleDatabaseCanSave = computed(() => canSaveVisibleDatabaseSelection([...visibleDatabaseSelection.value]));
|
||||
const visibleDatabaseHasSystemDatabases = computed(() => {
|
||||
const connection = connectionConfigSnapshotForVisibleDatabases();
|
||||
return visibleDatabaseNames.value.some((database) => isSystemDatabaseName(connection.db_type, database));
|
||||
});
|
||||
const testResultMessage = computed(() => {
|
||||
if (!testResult.value) return "";
|
||||
return testResult.value.ok ? t("connection.testSuccess") : testResult.value.message;
|
||||
|
|
@ -1190,9 +1239,21 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
|
|||
delete legacy.proxy_port;
|
||||
delete legacy.proxy_username;
|
||||
delete legacy.proxy_password;
|
||||
config.visible_databases =
|
||||
Array.isArray(config.visible_databases) && config.visible_databases.length > 0
|
||||
? config.visible_databases
|
||||
: undefined;
|
||||
return config as ConnectionConfig;
|
||||
}
|
||||
|
||||
function connectionConfigSnapshotForVisibleDatabases(): ConnectionConfig {
|
||||
return {
|
||||
...(form.value as ConnectionConfig),
|
||||
id: editingId.value || "draft",
|
||||
visible_databases: form.value.visible_databases,
|
||||
};
|
||||
}
|
||||
|
||||
function getUrlParam(params: string | undefined, key: string): string {
|
||||
const parsed = new URLSearchParams((params || "").trim().replace(/^\?/, ""));
|
||||
return parsed.get(key) || "";
|
||||
|
|
@ -1401,6 +1462,95 @@ function resetTestState() {
|
|||
testResult.value = null;
|
||||
}
|
||||
|
||||
function resetVisibleDatabaseDraftState() {
|
||||
showVisibleDatabasesDialog.value = false;
|
||||
isLoadingVisibleDatabases.value = false;
|
||||
visibleDatabaseNames.value = [];
|
||||
visibleDatabaseSelection.value = new Set();
|
||||
visibleDatabaseSearchText.value = "";
|
||||
visibleDatabaseError.value = "";
|
||||
visibleDatabaseShowSystem.value = false;
|
||||
}
|
||||
|
||||
async function openVisibleDatabasesPicker() {
|
||||
if (!ensureConnectionHostResolvedFromUrl()) return;
|
||||
if (!canChooseVisibleDatabases.value || isLoadingVisibleDatabases.value) return;
|
||||
|
||||
isLoadingVisibleDatabases.value = true;
|
||||
visibleDatabaseError.value = "";
|
||||
visibleDatabaseSearchText.value = "";
|
||||
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
|
||||
const draftConfig = {
|
||||
...connectionConfigForSubmit(draftId),
|
||||
id: draftId,
|
||||
one_time: true,
|
||||
};
|
||||
|
||||
try {
|
||||
await api.connectDb(draftConfig);
|
||||
const names = await loadVisibleDatabaseNames(draftId, draftConfig);
|
||||
visibleDatabaseNames.value = names;
|
||||
const initialSelection = initialVisibleDatabaseSelection(names, form.value.visible_databases, draftConfig);
|
||||
visibleDatabaseSelection.value = new Set(initialSelection);
|
||||
visibleDatabaseShowSystem.value = initialSelection.some((database) =>
|
||||
isSystemDatabaseName(draftConfig.db_type, database),
|
||||
);
|
||||
showVisibleDatabasesDialog.value = true;
|
||||
} catch (e: any) {
|
||||
visibleDatabaseNames.value = [];
|
||||
visibleDatabaseSelection.value = new Set();
|
||||
visibleDatabaseError.value = mongodbAuthFailureHint(String(e?.message || e));
|
||||
testResult.value = { ok: false, message: visibleDatabaseError.value };
|
||||
} finally {
|
||||
await api.disconnectDb(draftId).catch(() => undefined);
|
||||
isLoadingVisibleDatabases.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVisibleDatabaseNames(connectionId: string, config: ConnectionConfig): Promise<string[]> {
|
||||
if (config.db_type === "oracle" || config.db_type === "dameng") {
|
||||
return api.listSchemas(connectionId, config.database || "");
|
||||
}
|
||||
if (config.db_type === "redis") {
|
||||
return (await api.redisListDatabases(connectionId)).map((database) => String(database.db));
|
||||
}
|
||||
if (config.db_type === "mongodb") {
|
||||
return api.mongoListDatabases(connectionId);
|
||||
}
|
||||
return (await api.listDatabases(connectionId)).map((database) => database.name);
|
||||
}
|
||||
|
||||
function toggleVisibleDatabase(database: string) {
|
||||
const next = new Set(visibleDatabaseSelection.value);
|
||||
if (next.has(database)) next.delete(database);
|
||||
else next.add(database);
|
||||
visibleDatabaseSelection.value = next;
|
||||
}
|
||||
|
||||
function selectAllVisibleDatabases() {
|
||||
visibleDatabaseSelection.value = new Set(listedVisibleDatabaseNames.value);
|
||||
}
|
||||
|
||||
function clearVisibleDatabaseSelection() {
|
||||
visibleDatabaseSelection.value = new Set();
|
||||
}
|
||||
|
||||
function showAllVisibleDatabases() {
|
||||
form.value.visible_databases = undefined;
|
||||
visibleDatabaseSelection.value = new Set();
|
||||
visibleDatabaseNames.value = [];
|
||||
showVisibleDatabasesDialog.value = false;
|
||||
}
|
||||
|
||||
function saveVisibleDatabaseSelection() {
|
||||
if (!visibleDatabaseCanSave.value) return;
|
||||
form.value.visible_databases = normalizeVisibleDatabaseSelection(
|
||||
[...visibleDatabaseSelection.value],
|
||||
visibleDatabaseNames.value,
|
||||
);
|
||||
showVisibleDatabasesDialog.value = false;
|
||||
}
|
||||
|
||||
function applyConnectionUrl() {
|
||||
if (applyConnectionUrlToForm(connectionUrlInput.value)) {
|
||||
toast(t("connection.parseConnectionUrlApplied"), 2000);
|
||||
|
|
@ -1433,6 +1583,7 @@ function resetForm() {
|
|||
dbPickerView.value = "icon";
|
||||
dbSearchQuery.value = "";
|
||||
configTab.value = "connection";
|
||||
resetVisibleDatabaseDraftState();
|
||||
resetTestState();
|
||||
}
|
||||
|
||||
|
|
@ -1536,6 +1687,25 @@ watch([() => form.value.db_type, () => form.value.username], () => {
|
|||
if (isOracleSysUser(form.value)) form.value.sysdba = true;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => connectionConfigSnapshotForVisibleDatabases(),
|
||||
(current, previous) => {
|
||||
if (!previous || !form.value.visible_databases?.length) return;
|
||||
if (!visibleDatabaseSelectionIsStale(previous, current)) return;
|
||||
form.value.visible_databases = undefined;
|
||||
visibleDatabaseNames.value = [];
|
||||
visibleDatabaseSelection.value = new Set();
|
||||
},
|
||||
);
|
||||
|
||||
watch(visibleDatabaseShowSystem, (show) => {
|
||||
if (show) return;
|
||||
const connection = connectionConfigSnapshotForVisibleDatabases();
|
||||
visibleDatabaseSelection.value = new Set(
|
||||
[...visibleDatabaseSelection.value].filter((database) => !isSystemDatabaseName(connection.db_type, database)),
|
||||
);
|
||||
});
|
||||
|
||||
watch(canUseTransportLayers, (value) => {
|
||||
if (!value && configTab.value === "transport") {
|
||||
configTab.value = "connection";
|
||||
|
|
@ -3447,6 +3617,17 @@ function openExternalUrl(url: string) {
|
|||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
<Button
|
||||
v-if="canChooseVisibleDatabases"
|
||||
variant="outline"
|
||||
class="shrink-0"
|
||||
:disabled="isTesting || isSaving || isLoadingVisibleDatabases || !hasRequiredConnectionTarget"
|
||||
@click="openVisibleDatabasesPicker"
|
||||
>
|
||||
<Loader2 v-if="isLoadingVisibleDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
|
||||
<ListFilter v-else class="mr-1.5 h-4 w-4" />
|
||||
{{ hasVisibleDatabaseFilter ? visibleDatabaseSummary : t("contextMenu.selectVisibleDatabases") }}
|
||||
</Button>
|
||||
<Button variant="outline" class="shrink-0" :disabled="isTesting || isSaving" @click="testConnection">
|
||||
{{ isTesting ? t("connection.testing") : t("connection.test") }}
|
||||
</Button>
|
||||
|
|
@ -3463,4 +3644,117 @@ function openExternalUrl(url: string) {
|
|||
</template>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showVisibleDatabasesDialog">
|
||||
<DialogContent class="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("visibleDatabases.title") }}</DialogTitle>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t("visibleDatabases.description", { connection: form.name || selectedProfile().label }) }}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
|
||||
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="visibleDatabaseSearchText"
|
||||
:placeholder="t('visibleDatabases.searchPlaceholder')"
|
||||
class="h-8 border-0 px-0 shadow-none focus-visible:ring-0"
|
||||
:disabled="isLoadingVisibleDatabases || !!visibleDatabaseError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{{
|
||||
t("visibleDatabases.selectedCount", {
|
||||
selected: visibleDatabaseSelectedCount,
|
||||
total: visibleDatabaseTotalCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="hover:text-foreground disabled:opacity-50"
|
||||
:disabled="isLoadingVisibleDatabases"
|
||||
@click="selectAllVisibleDatabases"
|
||||
>
|
||||
{{ t("visibleDatabases.selectAll") }}
|
||||
</button>
|
||||
<button
|
||||
class="hover:text-foreground disabled:opacity-50"
|
||||
:disabled="isLoadingVisibleDatabases"
|
||||
@click="clearVisibleDatabaseSelection"
|
||||
>
|
||||
{{ t("visibleDatabases.clear") }}
|
||||
</button>
|
||||
<button
|
||||
class="hover:text-foreground disabled:opacity-50"
|
||||
:disabled="isLoadingVisibleDatabases"
|
||||
@click="showAllVisibleDatabases"
|
||||
>
|
||||
{{ t("visibleDatabases.showAll") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="!isLoadingVisibleDatabases && !visibleDatabaseError && !visibleDatabaseCanSave"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
{{ t("visibleDatabases.emptySelection") }}
|
||||
</p>
|
||||
|
||||
<label
|
||||
v-if="visibleDatabaseHasSystemDatabases"
|
||||
class="flex h-8 items-center gap-2 rounded-md px-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<input
|
||||
v-model="visibleDatabaseShowSystem"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 accent-primary"
|
||||
:disabled="isLoadingVisibleDatabases || !!visibleDatabaseError"
|
||||
/>
|
||||
<span>{{ t("visibleDatabases.showSystemDatabases") }}</span>
|
||||
</label>
|
||||
|
||||
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
|
||||
<div
|
||||
v-if="isLoadingVisibleDatabases"
|
||||
class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div v-else-if="visibleDatabaseError" class="p-3 text-sm text-destructive">
|
||||
{{ t("visibleDatabases.loadFailed", { message: visibleDatabaseError }) }}
|
||||
</div>
|
||||
<div v-else-if="!filteredVisibleDatabaseNames.length" class="p-3 text-sm text-muted-foreground">
|
||||
{{ t("grid.noSearchResults") }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="database in filteredVisibleDatabaseNames"
|
||||
:key="database"
|
||||
type="button"
|
||||
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
|
||||
@click="toggleVisibleDatabase(database)"
|
||||
>
|
||||
<CheckSquare v-if="visibleDatabaseSelection.has(database)" class="h-4 w-4 shrink-0 text-primary" />
|
||||
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{{ database }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showVisibleDatabasesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button
|
||||
:disabled="isLoadingVisibleDatabases || !!visibleDatabaseError || !visibleDatabaseCanSave"
|
||||
@click="saveVisibleDatabaseSelection"
|
||||
>
|
||||
{{ t("visibleDatabases.save") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
|||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import { DIAGRAM_SQL_TYPES, isSchemaAware as isSchemaAwareDatabase } from "@/lib/databaseCapabilities";
|
||||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import {
|
||||
buildDiagramRelationships,
|
||||
filterDiagramTables,
|
||||
|
|
@ -372,7 +373,10 @@ async function loadDatabases(id: string) {
|
|||
try {
|
||||
await store.ensureConnected(id);
|
||||
const dbs = await api.listDatabases(id);
|
||||
databases.value = dbs.map((db) => db.name);
|
||||
databases.value = databaseOptionsForConnection(
|
||||
dbs.map((db) => db.name),
|
||||
store.getConfig(id),
|
||||
);
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import type {
|
||||
|
|
@ -375,7 +376,10 @@ async function loadSchemas(side: "source" | "target", preferredSchema = "") {
|
|||
async function loadDatabases(connectionId: string, side: "source" | "target") {
|
||||
if (!connectionId) return;
|
||||
await store.ensureConnected(connectionId);
|
||||
const names = (await api.listDatabases(connectionId)).map((database) => database.name);
|
||||
const names = databaseOptionsForConnection(
|
||||
(await api.listDatabases(connectionId)).map((database) => database.name),
|
||||
store.getConfig(connectionId),
|
||||
);
|
||||
if (side === "source") {
|
||||
sourceDatabases.value = names;
|
||||
sourceDatabase.value = names.length === 1 ? names[0] : "";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
|||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import type { TableDiff, TableSchemaDetail } from "@/lib/schemaDiff";
|
||||
import type { TableInfo } from "@/types/database";
|
||||
|
|
@ -110,7 +111,10 @@ async function loadDatabases(connectionId: string, side: "source" | "target") {
|
|||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const dbs = await api.listDatabases(connectionId);
|
||||
const names = dbs.map((d) => d.name);
|
||||
const names = databaseOptionsForConnection(
|
||||
dbs.map((d) => d.name),
|
||||
store.getConfig(connectionId),
|
||||
);
|
||||
if (side === "source") {
|
||||
sourceDatabases.value = names;
|
||||
sourceDatabase.value = names.length === 1 ? names[0] : "";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
|||
import * as api from "@/lib/api";
|
||||
import type { ExportProgress } from "@/lib/api";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { generateDatabaseExportId } from "@/lib/databaseExport";
|
||||
import { buildSelectedTablesPayload } from "@/lib/databaseExportSelection";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
|
|
@ -93,7 +94,10 @@ async function loadDatabases(connId: string) {
|
|||
try {
|
||||
await store.ensureConnected(connId);
|
||||
const dbs = await api.listDatabases(connId);
|
||||
const names = dbs.map((d) => d.name);
|
||||
const names = databaseOptionsForConnection(
|
||||
dbs.map((d) => d.name),
|
||||
store.getConfig(connId),
|
||||
);
|
||||
databases.value = names;
|
||||
database.value = names.length === 1 ? names[0] : "";
|
||||
schemas.value = [];
|
||||
|
|
|
|||
|
|
@ -2556,7 +2556,8 @@ const nodeIconClass = computed(() => {
|
|||
});
|
||||
const canConfigureVisibleDatabases = computed(() => {
|
||||
if (props.node.type !== "connection" || !props.node.connectionId) return false;
|
||||
return connectionStore.getConfig(props.node.connectionId)?.db_type !== "elasticsearch";
|
||||
const dbType = connectionStore.getConfig(props.node.connectionId)?.db_type;
|
||||
return dbType !== "elasticsearch" && dbType !== "etcd";
|
||||
});
|
||||
const canCopyFinalProxyPort = computed(() => {
|
||||
if (props.node.type !== "connection" || !props.node.connectionId) return false;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import {
|
||||
canSaveVisibleDatabaseSelection,
|
||||
filterDatabaseNamesForConnection,
|
||||
isSystemDatabaseName,
|
||||
normalizeVisibleDatabaseSelection,
|
||||
|
|
@ -45,6 +46,7 @@ const filteredDatabaseNames = computed(() => {
|
|||
});
|
||||
const selectedCount = computed(() => selectedNames.value.size);
|
||||
const totalCount = computed(() => listedDatabaseNames.value.length);
|
||||
const canSaveSelection = computed(() => canSaveVisibleDatabaseSelection([...selectedNames.value]));
|
||||
const hasSystemDatabases = computed(() =>
|
||||
databaseNames.value.some((database) => isSystemDatabaseName(connection.value?.db_type, database)),
|
||||
);
|
||||
|
|
@ -126,6 +128,7 @@ async function showAllDatabases() {
|
|||
}
|
||||
|
||||
async function saveSelection() {
|
||||
if (!canSaveSelection.value) return;
|
||||
await connectionStore.setVisibleDatabases(props.connectionId, [...selectedNames.value]);
|
||||
emit("update:open", false);
|
||||
}
|
||||
|
|
@ -169,6 +172,9 @@ async function saveSelection() {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!isLoading && !errorMessage && !canSaveSelection" class="text-xs text-destructive">
|
||||
{{ t("visibleDatabases.emptySelection") }}
|
||||
</p>
|
||||
|
||||
<label
|
||||
v-if="hasSystemDatabases"
|
||||
|
|
@ -211,7 +217,7 @@ async function saveSelection() {
|
|||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="emit('update:open', false)">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="isLoading || !!errorMessage" @click="saveSelection">
|
||||
<Button :disabled="isLoading || !!errorMessage || !canSaveSelection" @click="saveSelection">
|
||||
{{ t("visibleDatabases.save") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import {
|
||||
cancelSqlFileExecution,
|
||||
executeSqlFile,
|
||||
|
|
@ -199,7 +200,10 @@ async function loadDatabasesForConnection(id: string) {
|
|||
loadingDatabases.value = true;
|
||||
try {
|
||||
await store.ensureConnected(id);
|
||||
const names = (await listDatabases(id)).map((db) => db.name);
|
||||
const names = databaseOptionsForConnection(
|
||||
(await listDatabases(id)).map((db) => db.name),
|
||||
store.getConfig(id),
|
||||
);
|
||||
if (token !== databaseLoadToken) return;
|
||||
databaseOptions.value = names;
|
||||
database.value = chooseDatabase(names, id);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import * as api from "@/lib/api";
|
|||
import type { TransferProgress, TransferMode } from "@/lib/api";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { isSchemaAware, supportsTransfer } from "@/lib/databaseCapabilities";
|
||||
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
|
||||
import { nextTransferTerminalState } from "@/lib/transferProgressState";
|
||||
import { ArrowRightLeft, Check, X, Loader2, Square, CheckSquare } from "@lucide/vue";
|
||||
|
||||
|
|
@ -107,9 +108,10 @@ async function loadDatabases(connectionId: string, target: "source" | "target")
|
|||
if (!connectionId) return;
|
||||
try {
|
||||
await store.ensureConnected(connectionId);
|
||||
const names = isMongoConnection(connectionId)
|
||||
const rawNames = isMongoConnection(connectionId)
|
||||
? await api.mongoListDatabases(connectionId)
|
||||
: (await api.listDatabases(connectionId)).map((d) => d.name);
|
||||
const names = databaseOptionsForConnection(rawNames, store.getConfig(connectionId));
|
||||
if (target === "source") {
|
||||
sourceDatabases.value = names;
|
||||
sourceDatabase.value = names.length === 1 ? names[0] : "";
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@ export function useDatabaseOptions() {
|
|||
await connectionStore.ensureConnected(connectionId);
|
||||
if (connection.db_type === "redis") {
|
||||
const dbs = await api.redisListDatabases(connectionId);
|
||||
databaseOptions.value[connectionId] = dbs.map((db) => String(db.db));
|
||||
databaseOptions.value[connectionId] = databaseOptionsForConnection(
|
||||
dbs.map((db) => String(db.db)),
|
||||
connection,
|
||||
);
|
||||
} else if (connection.db_type === "mongodb") {
|
||||
databaseOptions.value[connectionId] = filterDatabaseNamesForConnection(
|
||||
await api.mongoListDatabases(connectionId),
|
||||
|
|
|
|||
|
|
@ -1146,6 +1146,7 @@ export default {
|
|||
showAll: "Show all",
|
||||
showSystemDatabases: "Show system databases",
|
||||
save: "Save",
|
||||
emptySelection: "Select at least one database, or use Show all to clear the filter.",
|
||||
loadFailed: "Failed to load databases: {message}",
|
||||
},
|
||||
tree: {
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,7 @@ export default {
|
|||
showAll: "Mostrar todo",
|
||||
showSystemDatabases: "Mostrar bases de datos del sistema",
|
||||
save: "Guardar",
|
||||
emptySelection: "Selecciona al menos una base de datos, o usa Mostrar todo para quitar el filtro.",
|
||||
loadFailed: "No se pudieron cargar las bases de datos: {message}",
|
||||
},
|
||||
tree: {
|
||||
|
|
|
|||
|
|
@ -1136,6 +1136,7 @@ export default {
|
|||
showAll: "Mostra tutto",
|
||||
showSystemDatabases: "Mostra database di sistema",
|
||||
save: "Salva",
|
||||
emptySelection: "Seleziona almeno un database, oppure usa Mostra tutto per rimuovere il filtro.",
|
||||
loadFailed: "Impossibile caricare i database: {message}",
|
||||
},
|
||||
tree: {
|
||||
|
|
|
|||
|
|
@ -1131,6 +1131,7 @@ export default {
|
|||
showAll: "Mostrar todos",
|
||||
showSystemDatabases: "Mostrar bancos de dados do sistema",
|
||||
save: "Salvar",
|
||||
emptySelection: "Selecione pelo menos um banco de dados, ou use Mostrar todos para limpar o filtro.",
|
||||
loadFailed: "Falha ao carregar bancos de dados: {message}",
|
||||
},
|
||||
tree: {
|
||||
|
|
|
|||
|
|
@ -1120,6 +1120,7 @@ export default {
|
|||
showAll: "显示全部",
|
||||
showSystemDatabases: "显示系统库",
|
||||
save: "保存",
|
||||
emptySelection: "至少选择一个数据库,或使用“显示全部”清除过滤。",
|
||||
loadFailed: "加载数据库失败:{message}",
|
||||
},
|
||||
tree: {
|
||||
|
|
|
|||
|
|
@ -1067,6 +1067,7 @@ export default {
|
|||
showAll: "顯示全部",
|
||||
showSystemDatabases: "顯示系統庫",
|
||||
save: "儲存",
|
||||
emptySelection: "至少選擇一個資料庫,或使用「顯示全部」清除篩選。",
|
||||
loadFailed: "載入資料庫失敗:{message}",
|
||||
},
|
||||
tree: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
import { filterDatabaseNamesForConnection, normalizeVisibleDatabaseSelection } from "@/lib/visibleDatabases";
|
||||
|
||||
const DRAFT_VISIBLE_DATABASES_PREFIX = "__visible_draft_";
|
||||
|
||||
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["elasticsearch", "etcd"]);
|
||||
|
||||
type VisibleDatabaseConnectionFields = Pick<
|
||||
ConnectionConfig,
|
||||
| "db_type"
|
||||
| "driver_profile"
|
||||
| "host"
|
||||
| "port"
|
||||
| "username"
|
||||
| "database"
|
||||
| "connection_string"
|
||||
| "url_params"
|
||||
| "redis_connection_mode"
|
||||
| "redis_sentinel_master"
|
||||
| "redis_sentinel_nodes"
|
||||
| "redis_cluster_nodes"
|
||||
| "etcd_endpoints"
|
||||
| "jdbc_driver_class"
|
||||
>;
|
||||
|
||||
export function buildDraftVisibleDatabasesConnectionId(seed: string): string {
|
||||
return `${DRAFT_VISIBLE_DATABASES_PREFIX}${seed}`;
|
||||
}
|
||||
|
||||
export function connectionCanChooseVisibleDatabases(
|
||||
connection: Pick<ConnectionConfig, "db_type"> | undefined,
|
||||
): boolean {
|
||||
return !!connection?.db_type && !UNSUPPORTED_VISIBLE_DATABASE_TYPES.has(connection.db_type);
|
||||
}
|
||||
|
||||
export function initialVisibleDatabaseSelection(
|
||||
databaseNames: string[],
|
||||
visibleDatabases: string[] | undefined,
|
||||
connection?: Pick<ConnectionConfig, "db_type" | "driver_profile" | "visible_databases">,
|
||||
): string[] {
|
||||
if (Array.isArray(visibleDatabases)) {
|
||||
return normalizeVisibleDatabaseSelection(visibleDatabases, databaseNames);
|
||||
}
|
||||
return filterDatabaseNamesForConnection(databaseNames, connection);
|
||||
}
|
||||
|
||||
export function visibleDatabaseSelectionIsStale(
|
||||
previous: VisibleDatabaseConnectionFields,
|
||||
current: VisibleDatabaseConnectionFields,
|
||||
): boolean {
|
||||
return visibleDatabaseFingerprint(previous) !== visibleDatabaseFingerprint(current);
|
||||
}
|
||||
|
||||
function visibleDatabaseFingerprint(connection: VisibleDatabaseConnectionFields): string {
|
||||
return JSON.stringify({
|
||||
db_type: connection.db_type,
|
||||
driver_profile: connection.driver_profile || "",
|
||||
host: connection.host || "",
|
||||
port: Number(connection.port) || 0,
|
||||
username: connection.username || "",
|
||||
database: connection.database || "",
|
||||
connection_string: connection.connection_string || "",
|
||||
url_params: connection.url_params || "",
|
||||
redis_connection_mode: connection.redis_connection_mode || "",
|
||||
redis_sentinel_master: connection.redis_sentinel_master || "",
|
||||
redis_sentinel_nodes: connection.redis_sentinel_nodes || "",
|
||||
redis_cluster_nodes: connection.redis_cluster_nodes || "",
|
||||
etcd_endpoints: connection.etcd_endpoints || "",
|
||||
jdbc_driver_class: connection.jdbc_driver_class || "",
|
||||
});
|
||||
}
|
||||
|
|
@ -68,6 +68,10 @@ export function visibleDatabaseFilterIsEnabled(visibleDatabases: string[] | unde
|
|||
return Array.isArray(visibleDatabases);
|
||||
}
|
||||
|
||||
export function canSaveVisibleDatabaseSelection(selectedNames: string[]): boolean {
|
||||
return selectedNames.length > 0;
|
||||
}
|
||||
|
||||
export function filterVisibleDatabaseNames(databaseNames: string[], visibleDatabases: string[] | undefined): string[] {
|
||||
if (!visibleDatabaseFilterIsEnabled(visibleDatabases)) return databaseNames;
|
||||
const visible = new Set(visibleDatabases);
|
||||
|
|
|
|||
|
|
@ -749,6 +749,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
};
|
||||
await persistConnections(nextConnections);
|
||||
connections.value = nextConnections;
|
||||
invalidateCompletionCache(connectionId);
|
||||
rebuildTreeNodes();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,6 +112,9 @@ pub async fn disconnect_db(
|
|||
drop(connections);
|
||||
|
||||
app.reset_connection_transport(&body.connection_id).await;
|
||||
if body.connection_id.starts_with("__visible_draft_") {
|
||||
app.configs.write().await.remove(&body.connection_id);
|
||||
}
|
||||
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
|
@ -289,4 +292,26 @@ mod tests {
|
|||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_db_removes_visible_database_draft_config() {
|
||||
let (state, dir) = test_web_state().await;
|
||||
let conn_path = dir.join("draft.db");
|
||||
let draft_id = "__visible_draft_test";
|
||||
std::fs::File::create(&conn_path).unwrap();
|
||||
|
||||
{
|
||||
let mut configs = state.app.configs.write().await;
|
||||
configs.insert(draft_id.to_string(), sqlite_config(draft_id, &conn_path.to_string_lossy()));
|
||||
}
|
||||
|
||||
let result =
|
||||
disconnect_db(State(state.clone()), Json(DisconnectRequest { connection_id: draft_id.to_string() })).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let configs = state.app.configs.read().await;
|
||||
assert!(!configs.contains_key(draft_id));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
import {
|
||||
buildDraftVisibleDatabasesConnectionId,
|
||||
connectionCanChooseVisibleDatabases,
|
||||
visibleDatabaseSelectionIsStale,
|
||||
initialVisibleDatabaseSelection,
|
||||
} from "../../apps/desktop/src/lib/connectionVisibleDatabases.ts";
|
||||
import type { ConnectionConfig } from "../../apps/desktop/src/types/database.ts";
|
||||
|
||||
function config(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "conn",
|
||||
name: "Local",
|
||||
db_type: "mysql",
|
||||
driver_profile: "mysql",
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
username: "root",
|
||||
password: "",
|
||||
database: undefined,
|
||||
visible_databases: undefined,
|
||||
transport_layers: [],
|
||||
connect_timeout_secs: 5,
|
||||
query_timeout_secs: 30,
|
||||
idle_timeout_secs: 60,
|
||||
ssl: false,
|
||||
ca_cert_path: "",
|
||||
client_cert_path: "",
|
||||
client_key_path: "",
|
||||
sysdba: false,
|
||||
jdbc_driver_paths: [],
|
||||
redis_sentinel_master: "",
|
||||
redis_sentinel_nodes: "",
|
||||
redis_sentinel_username: "",
|
||||
redis_sentinel_password: "",
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: "",
|
||||
etcd_endpoints: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("draft visible database connection ids are namespaced", () => {
|
||||
assert.equal(buildDraftVisibleDatabasesConnectionId("abc"), "__visible_draft_abc");
|
||||
});
|
||||
|
||||
test("visible databases can be chosen for catalog-like connections only", () => {
|
||||
assert.equal(connectionCanChooseVisibleDatabases({ db_type: "mysql" }), true);
|
||||
assert.equal(connectionCanChooseVisibleDatabases({ db_type: "redis" }), true);
|
||||
assert.equal(connectionCanChooseVisibleDatabases({ db_type: "mongodb" }), true);
|
||||
assert.equal(connectionCanChooseVisibleDatabases({ db_type: "oracle" }), true);
|
||||
assert.equal(connectionCanChooseVisibleDatabases({ db_type: "elasticsearch" }), false);
|
||||
assert.equal(connectionCanChooseVisibleDatabases({ db_type: "etcd" }), false);
|
||||
});
|
||||
|
||||
test("initial selection uses configured visible databases when available", () => {
|
||||
assert.deepEqual(initialVisibleDatabaseSelection(["app", "analytics", "billing"], ["billing", "missing"]), [
|
||||
"billing",
|
||||
]);
|
||||
});
|
||||
|
||||
test("initial selection uses default visible database names when no filter is configured", () => {
|
||||
assert.deepEqual(initialVisibleDatabaseSelection(["app", "mysql", "sys"], undefined, config()), ["app"]);
|
||||
});
|
||||
|
||||
test("visible database selection is stale when connection target changes", () => {
|
||||
const previous = config({ host: "db.internal", visible_databases: ["app"] });
|
||||
assert.equal(visibleDatabaseSelectionIsStale(previous, config({ host: "db.internal" })), false);
|
||||
assert.equal(visibleDatabaseSelectionIsStale(previous, config({ host: "db2.internal" })), true);
|
||||
assert.equal(visibleDatabaseSelectionIsStale(previous, config({ username: "readonly" })), true);
|
||||
assert.equal(visibleDatabaseSelectionIsStale(previous, config({ database: "admin" })), true);
|
||||
});
|
||||
|
|
@ -13,3 +13,23 @@ test("non tree-schema connections keep an empty database option list", () => {
|
|||
test("database options preserve returned catalogs when available", () => {
|
||||
assert.deepEqual(databaseOptionsForConnection(["app", "analytics"], { db_type: "jdbc" }), ["app", "analytics"]);
|
||||
});
|
||||
|
||||
test("database options respect visible database filters", () => {
|
||||
assert.deepEqual(
|
||||
databaseOptionsForConnection(["app", "analytics", "billing"], {
|
||||
db_type: "mysql",
|
||||
visible_databases: ["billing", "missing"],
|
||||
}),
|
||||
["billing"],
|
||||
);
|
||||
});
|
||||
|
||||
test("redis database options respect visible database filters", () => {
|
||||
assert.deepEqual(
|
||||
databaseOptionsForConnection(["0", "1", "2"], {
|
||||
db_type: "redis",
|
||||
visible_databases: ["2"],
|
||||
}),
|
||||
["2"],
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
filterDatabaseNamesForConnection,
|
||||
filterVisibleDatabaseNames,
|
||||
isSystemDatabaseName,
|
||||
canSaveVisibleDatabaseSelection,
|
||||
normalizeVisibleDatabaseSelection,
|
||||
visibleDatabaseFilterIsEnabled,
|
||||
} from "../../apps/desktop/src/lib/visibleDatabases.ts";
|
||||
|
|
@ -23,6 +24,11 @@ test("empty configured visible database filter hides every database", () => {
|
|||
assert.equal(visibleDatabaseFilterIsEnabled([]), true);
|
||||
});
|
||||
|
||||
test("empty visible database selection cannot be saved", () => {
|
||||
assert.equal(canSaveVisibleDatabaseSelection(["app"]), true);
|
||||
assert.equal(canSaveVisibleDatabaseSelection([]), false);
|
||||
});
|
||||
|
||||
test("normalizes selected database names against fresh database names", () => {
|
||||
assert.deepEqual(normalizeVisibleDatabaseSelection(["billing", "missing", "app", "app"], ["app", "billing"]), [
|
||||
"billing",
|
||||
|
|
|
|||
|
|
@ -610,6 +610,9 @@ pub async fn disconnect_db(state: State<'_, Arc<AppState>>, connection_id: Strin
|
|||
}
|
||||
drop(conns);
|
||||
state.reset_connection_transport(&connection_id).await;
|
||||
if connection_id.starts_with("__visible_draft_") {
|
||||
state.configs.write().await.remove(&connection_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue