feat(etcd): add agent-backed etcd support

This commit is contained in:
t8y2 committed 2026-06-08 00:58:42 +08:00
1 parent 7bd8d29a79
commit e6062b2b07
49 files changed
+1524 -15

No files matched your search

@@ -118,6 +118,8 @@ const defaultForm = (): ConnectionForm => ({
query_timeout_secs: 30,
ssl: false,
ca_cert_path: "",
client_cert_path: "",
client_key_path: "",
sysdba: false,
oracle_connection_type: "service_name",
connection_string: undefined,
@@ -130,6 +132,7 @@ const defaultForm = (): ConnectionForm => ({
redis_sentinel_password: "",
redis_sentinel_tls: false,
redis_cluster_nodes: "",
etcd_endpoints: "",
});
function defaultSshTunnel(): SshTunnelConfig {
@@ -405,6 +408,7 @@ const driverProfiles: Record<
tdengine: { type: "tdengine", port: 6041, user: "root", label: "TDengine", icon: "tdengine" },
xugu: { type: "xugu", port: 5138, user: "", label: "虚谷 XuguDB", icon: "xugu" },
iotdb: { type: "iotdb", port: 6667, user: "root", label: "Apache IoTDB", icon: "iotdb" },
etcd: { type: "etcd", port: 2379, user: "", label: "etcd", icon: "etcd" },
iris: { type: "iris", port: 1972, user: "_SYSTEM", label: "IRIS", icon: "iris" },
custom_mysql: {
type: "mysql",
@@ -515,6 +519,8 @@ watch(
query_timeout_secs: config.query_timeout_secs ?? 30,
ssl: config.ssl || false,
ca_cert_path: config.ca_cert_path || "",
client_cert_path: config.client_cert_path || "",
client_key_path: config.client_key_path || "",
sysdba: config.sysdba || isOracleSysUser(config),
oracle_connection_type: config.oracle_connection_type || "service_name",
connection_string: config.connection_string,
@@ -527,6 +533,7 @@ watch(
redis_sentinel_password: config.redis_sentinel_password || "",
redis_sentinel_tls: config.redis_sentinel_tls || false,
redis_cluster_nodes: config.redis_cluster_nodes || "",
etcd_endpoints: config.etcd_endpoints || "",
};
selectedTransportLayerId.value = form.value.transport_layers?.[0]?.id || null;
selectedType.value = profile;
@@ -664,6 +671,7 @@ const iconTypeMap: Record<string, string> = {
tdengine: "tdengine",
xugu: "xugu",
iotdb: "iotdb",
etcd: "etcd",
dm: "dm",
h2: "h2",
snowflake: "snowflake",
@@ -737,6 +745,7 @@ const dbOptions = [
{ value: "sundb", label: "SunDB" },
{ value: "xugu", label: "虚谷 XuguDB" },
{ value: "iotdb", label: "Apache IoTDB" },
{ value: "etcd", label: "etcd" },
{ value: "iris", label: "IRIS" },
{ value: "jdbc", label: "JDBC" },
{ value: "custom_mysql", label: "Custom (MySQL)" },
@@ -789,6 +798,7 @@ const tlsCapableDatabaseTypes = new Set<DatabaseType>([
"kwdb",
"opengauss",
"redis",
"etcd",
"clickhouse",
"elasticsearch",
]);
@@ -856,6 +866,12 @@ const redisTlsInsecure = computed({
form.value.url_params = setUrlParam(form.value.url_params, "insecure", value ? "true" : "");
},
});
const etcdEndpointsLines = computed({
get: () => form.value.etcd_endpoints || "",
set: (value: string) => {
form.value.etcd_endpoints = normalizeEndpointLines(value);
},
});
const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access");
const shouldShowAgentDriverInstallHint = computed(() =>
showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile),
@@ -1019,7 +1035,25 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
config.redis_sentinel_tls = undefined;
config.redis_cluster_nodes = undefined;
}
if (config.db_type !== "mysql" && config.db_type !== "clickhouse") {
if (config.db_type === "etcd") {
config.etcd_endpoints = normalizeEndpointLines(config.etcd_endpoints || "");
const firstEndpoint = firstEtcdEndpoint(config.etcd_endpoints);
if (firstEndpoint) {
config.host = firstEndpoint.host;
config.port = firstEndpoint.port;
config.ssl = firstEndpoint.scheme === "https" || !!config.ssl;
}
config.client_cert_path = config.client_cert_path?.trim() || "";
config.client_key_path = config.client_key_path?.trim() || "";
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
throw new Error(t("connection.etcdClientCertPairRequired"));
}
} else {
config.etcd_endpoints = undefined;
config.client_cert_path = undefined;
config.client_key_path = undefined;
}
if (config.db_type !== "mysql" && config.db_type !== "clickhouse" && config.db_type !== "etcd") {
config.ca_cert_path = undefined;
} else {
config.ca_cert_path = config.ca_cert_path?.trim() || "";
@@ -1172,6 +1206,10 @@ function normalizeRedisClusterNodes(value: string): string {
}
function normalizeRedisNodeList(value: string): string {
return normalizeEndpointLines(value);
}
function normalizeEndpointLines(value: string): string {
return value
.split(/[\n,;]+/)
.map((node) => node.trim())
@@ -1218,6 +1256,36 @@ function parseRedisEndpoint(value: string, defaultPort: number): { host: string;
return { host: endpoint, port: defaultPort };
}
function firstEtcdEndpoint(value?: string): { scheme?: string; host: string; port: number } | null {
const first = normalizeEndpointLines(value || "")
.split("\n")
.find(Boolean);
if (!first) return null;
return parseEtcdEndpoint(first);
}
function parseEtcdEndpoint(value: string): { scheme?: string; host: string; port: number } {
const trimmed = value.trim().replace(/^.*@/, "");
const schemeMatch = trimmed.match(/^(https?):\/\//i);
const scheme = schemeMatch?.[1].toLowerCase();
const endpoint = trimmed.replace(/^https?:\/\//i, "").replace(/[/?#].*$/, "");
if (endpoint.startsWith("[")) {
const end = endpoint.indexOf("]");
if (end > 0) {
const host = endpoint.slice(1, end);
const portText = endpoint.slice(end + 1).replace(/^:/, "");
const port = Number(portText);
return { scheme, host, port: Number.isFinite(port) && port > 0 ? port : 2379 };
}
}
const parts = endpoint.split(":");
if (parts.length === 2) {
const port = Number(parts[1]);
return { scheme, host: parts[0], port: Number.isFinite(port) && port > 0 ? port : 2379 };
}
return { scheme, host: endpoint, port: 2379 };
}
function isOracleSysUser(config: Pick<ConnectionConfig, "db_type" | "username">): boolean {
return config.db_type === "oracle" && config.username.trim().toLowerCase() === "sys";
}
@@ -1600,6 +1668,34 @@ async function browsePostgresTlsFile(target: "root" | "cert" | "key") {
}
}
async function browseEtcdTlsFile(target: "ca" | "cert" | "key") {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title:
target === "ca"
? t("connection.etcdCaCertBrowse")
: target === "cert"
? t("connection.etcdClientCertBrowse")
: t("connection.etcdClientKeyBrowse"),
multiple: false,
filters: [
{ name: "PEM", extensions: ["pem", "crt", "cer", "key"] },
{ name: "All Files", extensions: ["*"] },
],
});
if (selected && typeof selected === "string") {
if (target === "ca") {
form.value.ca_cert_path = selected;
} else if (target === "cert") {
form.value.client_cert_path = selected;
} else {
form.value.client_key_path = selected;
}
}
}
}
async function browseDbFilePath() {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
@@ -2222,6 +2318,37 @@ function openExternalUrl(url: string) {
</div>
</template>
<!-- etcd: endpoints, user, password, TLS -->
<template v-else-if="form.db_type === 'etcd'">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label class="text-right mt-2">{{ t("connection.etcdEndpoints") }}</Label>
<div class="col-span-3 space-y-1">
<textarea
v-model="etcdEndpointsLines"
class="flex min-h-[76px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="http://127.0.0.1:2379&#10;https://etcd-2:2379"
spellcheck="false"
/>
<p class="text-xs text-muted-foreground">
{{ t("connection.etcdEndpointsHint") }}
</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.password") }}</Label>
<Input v-model="form.password" type="password" class="col-span-3" />
</div>
</template>
<!-- MongoDB: URL or form -->
<template v-else-if="form.db_type === 'mongodb'">
<div class="grid grid-cols-4 items-center gap-4">
@@ -2455,6 +2582,93 @@ function openExternalUrl(url: string) {
</label>
</div>
<template v-if="form.db_type === 'etcd'">
<div class="grid grid-cols-4 items-start gap-4">
<Label class="pt-2 text-right text-xs">
<span class="inline-flex items-center justify-end gap-1">
<ShieldCheck class="h-3.5 w-3.5" />
{{ t("connection.caCertPath") }}
</span>
</Label>
<div class="col-span-3 space-y-2">
<div class="flex items-center gap-1">
<Input
v-model="form.ca_cert_path"
class="flex-1"
:placeholder="t('connection.etcdCaCertPlaceholder')"
/>
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
class="h-9 w-9 shrink-0"
@click="browseEtcdTlsFile('ca')"
>
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.etcdCaCertBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label class="pt-2 text-right text-xs">
<span class="inline-flex items-center justify-end gap-1">
<KeyRound class="h-3.5 w-3.5" />
{{ t("connection.etcdClientAuth") }}
</span>
</Label>
<div class="col-span-3 grid gap-2">
<div class="flex items-center gap-1">
<Input
v-model="form.client_cert_path"
class="flex-1"
:placeholder="t('connection.etcdClientCertPlaceholder')"
/>
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
class="h-9 w-9 shrink-0"
@click="browseEtcdTlsFile('cert')"
>
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.etcdClientCertBrowse") }}</TooltipContent>
</Tooltip>
</div>
<div class="flex items-center gap-1">
<Input
v-model="form.client_key_path"
class="flex-1"
:placeholder="t('connection.etcdClientKeyPlaceholder')"
/>
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
class="h-9 w-9 shrink-0"
@click="browseEtcdTlsFile('key')"
>
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.etcdClientKeyBrowse") }}</TooltipContent>
</Tooltip>
</div>
<p class="text-[11px] leading-4 text-muted-foreground">
{{ t("connection.etcdClientCertHint") }}
</p>
</div>
</div>
</template>
<template v-if="supportsMysqlTlsOptions">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.mysqlTlsMode") }}</Label>
@@ -131,7 +131,7 @@ const showModified = ref(true);
let syncPlanRequestId = 0;
const sqlConnections = computed(() =>
store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch"].includes(connection.db_type)),
store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(connection.db_type)),
);
const selectedSourceTableNames = computed(() =>
sourceTables.value.filter((table) => selectedSourceTables.value.has(table)),
@@ -67,7 +67,7 @@ function toggleAll() {
}
const sqlConnections = computed(() =>
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch"].includes(c.db_type)),
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(c.db_type)),
);
const canCompare = computed(
@@ -0,0 +1,349 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
ChevronDown,
ChevronRight,
FolderClosed,
FolderOpen,
KeyRound,
Loader2,
Plus,
RefreshCw,
Search,
Trash2,
} from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import * as api from "@/lib/api";
import type { KvGetResponse, KvKeySummary, KvValue } from "@/lib/api";
import {
buildEtcdKeyTree,
collectEtcdGroupIds,
flattenVisibleEtcdKeyTree,
type EtcdKeyTreeNode,
} from "@/lib/etcdKeyTree";
import { useToast } from "@/composables/useToast";
const props = defineProps<{ connectionId: string }>();
const { t } = useI18n();
const { toast } = useToast();
const searchInputRef = ref<HTMLInputElement>();
const prefix = ref("");
const keys = ref<KvKeySummary[]>([]);
const continuation = ref<string | null>(null);
const loading = ref(false);
const loadingMore = ref(false);
const expandedGroupIds = ref<Set<string>>(new Set());
const selectedKey = ref<string | null>(null);
const selectedValue = ref<KvGetResponse | null>(null);
const detailLoading = ref(false);
const detailError = ref("");
const showEditDialog = ref(false);
const editKey = ref("");
const editValue = ref("");
const editError = ref("");
const saving = ref(false);
const showDeleteConfirm = ref(false);
const deleting = ref(false);
const pageSize = 200;
const tree = computed(() => buildEtcdKeyTree(keys.value));
const visibleRows = computed(() => flattenVisibleEtcdKeyTree(tree.value, expandedGroupIds.value));
const selectedMetadata = computed(
() => selectedValue.value?.metadata ?? keys.value.find((key) => key.key === selectedKey.value),
);
const selectedTextValue = computed(() => {
const value = selectedValue.value?.value;
if (!value) return "";
return value.encoding === "utf8" ? value.data : value.data;
});
const selectedValueIsBase64 = computed(() => selectedValue.value?.value?.encoding === "base64");
function preserveExpandedGroups(expandAll = false) {
const available = collectEtcdGroupIds(tree.value);
const next = new Set<string>();
for (const id of expandAll ? available : expandedGroupIds.value) {
if (available.has(id)) next.add(id);
}
expandedGroupIds.value = next;
}
async function loadKeys(reset = true) {
if (reset) {
loading.value = true;
continuation.value = null;
keys.value = [];
selectedKey.value = null;
selectedValue.value = null;
} else {
loadingMore.value = true;
}
try {
const result = await api.etcdListPrefix(
props.connectionId,
prefix.value.trim(),
pageSize,
reset ? null : continuation.value,
);
const existing = new Set(keys.value.map((key) => key.key));
const merged = reset ? result.keys : [...keys.value, ...result.keys.filter((key) => !existing.has(key.key))];
keys.value = merged;
continuation.value = result.continuation || null;
preserveExpandedGroups(!!prefix.value.trim());
} finally {
loading.value = false;
loadingMore.value = false;
}
}
async function loadSelectedKey(key: string) {
selectedKey.value = key;
detailLoading.value = true;
detailError.value = "";
try {
selectedValue.value = await api.etcdGet(props.connectionId, key);
} catch (error) {
detailError.value = error instanceof Error ? error.message : String(error);
} finally {
detailLoading.value = false;
}
}
function toggleGroup(node: EtcdKeyTreeNode) {
if (node.kind !== "group") return;
const next = new Set(expandedGroupIds.value);
if (next.has(node.id)) next.delete(node.id);
else next.add(node.id);
expandedGroupIds.value = next;
}
function onRowClick(node: EtcdKeyTreeNode) {
if (node.kind === "group") {
toggleGroup(node);
} else {
void loadSelectedKey(node.key);
}
}
function openCreateDialog() {
editKey.value = prefix.value.trim();
editValue.value = "";
editError.value = "";
showEditDialog.value = true;
}
function openEditDialog() {
if (!selectedKey.value) return;
editKey.value = selectedKey.value;
editValue.value = selectedTextValue.value;
editError.value = selectedValueIsBase64.value ? t("etcd.base64Readonly") : "";
showEditDialog.value = true;
}
async function saveKey() {
const key = editKey.value.trim();
if (!key) {
editError.value = t("etcd.keyRequired");
return;
}
saving.value = true;
editError.value = "";
try {
const value: KvValue = { encoding: "utf8", data: editValue.value };
await api.etcdPut(props.connectionId, key, value);
showEditDialog.value = false;
await loadKeys(true);
await loadSelectedKey(key);
toast(t("etcd.saved"), 2500);
} catch (error) {
editError.value = error instanceof Error ? error.message : String(error);
} finally {
saving.value = false;
}
}
async function deleteSelectedKey() {
if (!selectedKey.value) return;
deleting.value = true;
try {
await api.etcdDelete(props.connectionId, selectedKey.value);
showDeleteConfirm.value = false;
selectedKey.value = null;
selectedValue.value = null;
await loadKeys(true);
toast(t("etcd.deleted"), 2500);
} finally {
deleting.value = false;
}
}
function metadataLabel(value: number | null | undefined): string {
return value == null ? "-" : String(value);
}
function focusSearch(): boolean {
searchInputRef.value?.focus();
return true;
}
watch(
() => props.connectionId,
() => void loadKeys(true),
);
onMounted(() => void loadKeys(true));
defineExpose({ focusSearch });
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-background">
<div class="flex shrink-0 items-center gap-2 border-b px-3 py-2">
<div class="relative min-w-0 flex-1">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref="searchInputRef"
v-model="prefix"
class="h-8 pl-8"
:placeholder="t('etcd.prefixPlaceholder')"
@keyup.enter="loadKeys(true)"
/>
</div>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loading" @click="loadKeys(true)">
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="h-3.5 w-3.5" />
{{ t("grid.refresh") }}
</Button>
<Button size="sm" class="h-8 gap-1.5" @click="openCreateDialog">
<Plus class="h-3.5 w-3.5" />
{{ t("etcd.newKey") }}
</Button>
</div>
<div class="grid min-h-0 flex-1 grid-cols-[minmax(260px,38%)_1fr]">
<div class="min-h-0 border-r">
<div v-if="loading" class="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ t("etcd.loadingKeys") }}
</div>
<div
v-else-if="visibleRows.length === 0"
class="flex h-full items-center justify-center text-sm text-muted-foreground"
>
{{ t("etcd.empty") }}
</div>
<div v-else class="h-full overflow-auto py-1 text-sm">
<button
v-for="row in visibleRows"
:key="row.node.id"
type="button"
class="flex h-8 w-full items-center gap-1.5 px-2 text-left hover:bg-accent"
:class="{ 'bg-accent/70': row.node.kind === 'leaf' && row.node.key === selectedKey }"
:style="{ paddingLeft: `${8 + row.depth * 18}px` }"
@click="onRowClick(row.node)"
>
<template v-if="row.node.kind === 'group'">
<ChevronDown v-if="expandedGroupIds.has(row.node.id)" class="h-3.5 w-3.5 shrink-0" />
<ChevronRight v-else class="h-3.5 w-3.5 shrink-0" />
<FolderOpen v-if="expandedGroupIds.has(row.node.id)" class="h-4 w-4 shrink-0 text-sky-500" />
<FolderClosed v-else class="h-4 w-4 shrink-0 text-sky-500" />
</template>
<template v-else>
<span class="w-3.5 shrink-0" />
<KeyRound class="h-4 w-4 shrink-0 text-sky-500" />
</template>
<span class="truncate">{{ row.node.label }}</span>
</button>
<div v-if="continuation" class="border-t p-2">
<Button
size="sm"
variant="outline"
class="h-8 w-full gap-1.5"
:disabled="loadingMore"
@click="loadKeys(false)"
>
<Loader2 v-if="loadingMore" class="h-3.5 w-3.5 animate-spin" />
{{ t("etcd.loadMore") }}
</Button>
</div>
</div>
</div>
<div class="min-h-0 overflow-auto">
<div v-if="!selectedKey" class="flex h-full items-center justify-center text-sm text-muted-foreground">
{{ t("etcd.selectKey") }}
</div>
<div v-else class="flex min-h-full flex-col">
<div class="flex shrink-0 items-start justify-between gap-3 border-b px-4 py-3">
<div class="min-w-0">
<div class="truncate font-medium">{{ selectedKey }}</div>
<div class="mt-1 flex flex-wrap gap-1.5 text-xs text-muted-foreground">
<Badge variant="secondary">rev {{ metadataLabel(selectedMetadata?.modRevision) }}</Badge>
<Badge variant="outline">ver {{ metadataLabel(selectedMetadata?.version) }}</Badge>
<Badge variant="outline">lease {{ metadataLabel(selectedMetadata?.lease) }}</Badge>
<Badge variant="outline">{{ metadataLabel(selectedMetadata?.valueSize) }} B</Badge>
</div>
</div>
<div class="flex shrink-0 gap-2">
<Button size="sm" variant="outline" class="h-8" :disabled="selectedValueIsBase64" @click="openEditDialog">
{{ t("etcd.edit") }}
</Button>
<Button size="sm" variant="destructive" class="h-8 gap-1.5" @click="showDeleteConfirm = true">
<Trash2 class="h-3.5 w-3.5" />
{{ t("etcd.delete") }}
</Button>
</div>
</div>
<div v-if="detailLoading" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ t("etcd.loadingValue") }}
</div>
<div v-else-if="detailError" class="p-4 text-sm text-destructive">{{ detailError }}</div>
<div v-else-if="selectedValue && !selectedValue.found" class="p-4 text-sm text-muted-foreground">
{{ t("etcd.notFound") }}
</div>
<pre
v-else
class="dbx-editor-font-family m-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-4 text-sm"
>{{ selectedTextValue }}</pre
>
</div>
</div>
</div>
<Dialog v-model:open="showEditDialog">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{{ editKey ? t("etcd.editKey") : t("etcd.newKey") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3 py-2">
<Input v-model="editKey" :placeholder="t('etcd.keyPlaceholder')" />
<textarea
v-model="editValue"
class="min-h-52 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
spellcheck="false"
/>
<div v-if="editError" class="text-sm text-destructive">{{ editError }}</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showEditDialog = false">{{ t("common.cancel") }}</Button>
<Button :disabled="saving || (!!editError && selectedValueIsBase64)" @click="saveKey">
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
{{ t("common.save") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DangerConfirmDialog
v-model:open="showDeleteConfirm"
:title="t('etcd.deleteTitle')"
:details="selectedKey || ''"
:confirm-label="t('etcd.delete')"
@confirm="deleteSelectedKey"
/>
</div>
</template>
@@ -65,7 +65,7 @@ const pendingPrefillTable = ref("");
const pendingPrefillTables = ref<string[]>([]);
const sqlConnections = computed(() =>
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch"].includes(c.db_type)),
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(c.db_type)),
);
const canExport = computed(
@@ -70,6 +70,7 @@ const assetIcons: Record<string, string> = {
greatsql: "greatsql.webp",
xugu: "xugu.png",
iotdb: "iotdb",
etcd: "etcd",
iris: "iris.png",
};
@@ -12,6 +12,7 @@ import {
Code2,
TableProperties,
PencilRuler,
KeyRound,
Pencil,
Package,
Check,
@@ -224,6 +225,7 @@ const openTabMenuItems = computed(() =>
function tabMenuIcon(tab: QueryTab) {
if (tab.mode === "data") return Table2;
if (tab.mode === "etcd") return KeyRound;
if (tab.mode === "objects") return TableProperties;
if (tab.mode === "structure") return PencilRuler;
return Code2;
@@ -354,6 +356,7 @@ function activateTab(tabId: string) {
>
<span class="shrink-0" :class="tabIconClass(tab)">
<Table2 v-if="tab.mode === 'data'" class="h-3.5 w-3.5" />
<KeyRound v-else-if="tab.mode === 'etcd'" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
<Code2 v-else class="h-3.5 w-3.5" />
@@ -14,6 +14,7 @@ import {
TableProperties,
ChevronDown,
ChevronUp,
Inbox,
RefreshCcw,
Wrench,
} from "@lucide/vue";
@@ -44,6 +45,7 @@ function preloadDataGridComponent() {
const DataGrid = defineAsyncComponent(loadDataGridComponent);
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
const MongoDocBrowser = defineAsyncComponent(() => import("@/components/mongo/MongoDocBrowser.vue"));
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue"));
@@ -147,6 +149,7 @@ const columnVisibilityOptions = computed(
() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? [],
);
const redisKeyBrowserRef = ref<SearchableBrowserHandle>();
const etcdKeyBrowserRef = ref<SearchableBrowserHandle>();
const objectBrowserRef = ref<SearchableBrowserHandle>();
const activeTableMeta = computed(() => props.activeTab.tableMeta);
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
@@ -344,6 +347,7 @@ function onHandleCloseColumnPanel() {
function focusSearch(): boolean {
if (props.activeTab.mode === "redis") return redisKeyBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "objects") return objectBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "query") return queryEditorRef.value?.openSearch() ?? false;
return dataGridRef.value?.focusSearch() ?? false;
@@ -901,6 +905,13 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
</div>
</template>
<!-- etcd mode: key browser -->
<template v-else-if="activeTab.mode === 'etcd'">
<div class="flex-1 min-h-0">
<EtcdKeyBrowser ref="etcdKeyBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
</div>
</template>
<!-- MongoDB mode: document browser -->
<template v-else-if="activeTab.mode === 'mongo'">
<div class="flex-1 min-h-0">
@@ -256,6 +256,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: Zap, colorClass: "text-orange-300" };
case "redis-db":
return { icon: Database, colorClass: "text-red-400" };
case "etcd-root":
return { icon: Database, colorClass: "text-sky-500" };
case "mongo-db":
return { icon: Database, colorClass: "text-yellow-500" };
case "mongo-collection":
@@ -379,6 +381,8 @@ async function toggle() {
const config = connectionStore.getConfig(node.connectionId);
if (config?.db_type === "redis") {
await connectionStore.loadRedisDatabases(node.connectionId);
} else if (config?.db_type === "etcd") {
await connectionStore.loadEtcdRoot(node.connectionId);
} else if (config?.db_type === "mongodb" || config?.db_type === "elasticsearch") {
await connectionStore.loadMongoDatabases(node.connectionId);
} else {
@@ -387,6 +391,9 @@ async function toggle() {
} else if (node.type === "redis-db" && node.connectionId && node.database) {
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "Redis"}:db${node.database}`;
queryStore.createTab(node.connectionId, node.database, tabTitle, "redis");
} else if (node.type === "etcd-root" && node.connectionId) {
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:keys`;
queryStore.createTab(node.connectionId, "", tabTitle, "etcd");
} else if (node.type === "mongo-db" && node.connectionId && node.database) {
await connectionStore.loadMongoCollections(node.connectionId, node.database);
} else if (node.type === "mongo-collection" && node.connectionId && node.database) {
@@ -3040,6 +3047,11 @@ function treeItemMenuItems(): ContextMenuItem[] {
}
// 5. Redis DB / Mongo DB
if (node.type === "etcd-root") {
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Database });
return items;
}
if (node.type === "redis-db" || node.type === "mongo-db") {
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
if (!isNodeDefaultDatabase.value) {
@@ -58,7 +58,7 @@ const terminalError = ref("");
const refreshedTarget = ref(false);
const sqlConnections = computed(() =>
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch"].includes(c.db_type)),
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(c.db_type)),
);
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
+33
View File
@@ -194,6 +194,17 @@ export default {
redisSentinelPassword: "Sentinel Password",
redisSentinelTls: "Sentinel TLS",
redisSentinelTlsHint: "Use TLS when connecting to Sentinel nodes",
etcdEndpoints: "Endpoints",
etcdEndpointsHint: "One endpoint per line. Leave blank to use the host and port above.",
etcdCaCertPlaceholder: "/path/to/ca.crt",
etcdCaCertBrowse: "Choose CA certificate",
etcdClientAuth: "Client Auth",
etcdClientCertPlaceholder: "/path/to/client.crt",
etcdClientKeyPlaceholder: "/path/to/client.key",
etcdClientCertHint: "Client certificate and private key must be provided together when etcd requires mTLS.",
etcdClientCertBrowse: "Choose client certificate",
etcdClientKeyBrowse: "Choose client private key",
etcdClientCertPairRequired: "Client certificate and private key must be provided together.",
searchDatabasePlaceholder: "Search database types",
iconView: "Icon view",
listView: "List view",
@@ -378,6 +389,7 @@ export default {
table: "Table",
tableData: "Table Data",
redis: "Redis",
etcd: "etcd",
mongo: "Mongo",
objects: "Objects",
tooltipTitle: "Title:",
@@ -716,6 +728,8 @@ export default {
loading: "Loading...",
stopping: "Stopping...",
close: "Close",
cancel: "Cancel",
save: "Save",
},
explain: {
title: "Explain Plan",
@@ -1248,6 +1262,25 @@ export default {
zoomOut: "Zoom out",
resetLayout: "Reset layout",
},
etcd: {
prefixPlaceholder: "Prefix, e.g. /app/",
newKey: "New Key",
loadingKeys: "Loading keys...",
empty: "No keys found",
loadMore: "Load more",
selectKey: "Select a key to view its value",
loadingValue: "Loading value...",
notFound: "Key not found",
edit: "Edit",
editKey: "Edit Key",
delete: "Delete",
deleteTitle: "Delete etcd key",
keyPlaceholder: "/path/to/key",
keyRequired: "Key is required",
saved: "Key saved",
deleted: "Key deleted",
base64Readonly: "Base64 values are read-only in this version.",
},
redis: {
selectKey: "Select a key to view its value",
noKeys: "No keys found",
+33
View File
@@ -191,6 +191,17 @@ export default {
redisSentinelPassword: "哨兵密码",
redisSentinelTls: "哨兵 TLS",
redisSentinelTlsHint: "连接 Sentinel 节点时使用 TLS",
etcdEndpoints: "Endpoints",
etcdEndpointsHint: "每行一个 endpoint。留空时使用上面的 host 和端口。",
etcdCaCertPlaceholder: "/path/to/ca.crt",
etcdCaCertBrowse: "选择 CA 证书",
etcdClientAuth: "客户端认证",
etcdClientCertPlaceholder: "/path/to/client.crt",
etcdClientKeyPlaceholder: "/path/to/client.key",
etcdClientCertHint: "etcd 要求 mTLS 时,客户端证书和私钥必须一起填写。",
etcdClientCertBrowse: "选择客户端证书",
etcdClientKeyBrowse: "选择客户端私钥",
etcdClientCertPairRequired: "客户端证书和私钥必须一起填写。",
searchDatabasePlaceholder: "搜索数据库类型",
iconView: "图标视图",
listView: "列表视图",
@@ -374,6 +385,7 @@ export default {
table: "表",
tableData: "数据表",
redis: "Redis",
etcd: "etcd",
mongo: "Mongo",
objects: "对象",
tooltipTitle: "标题:",
@@ -704,6 +716,8 @@ export default {
loading: "加载中...",
stopping: "正在停止...",
close: "关闭",
cancel: "取消",
save: "保存",
},
explain: {
title: "执行计划",
@@ -1222,6 +1236,25 @@ export default {
zoomOut: "缩小",
resetLayout: "重置布局",
},
etcd: {
prefixPlaceholder: "Prefix,例如 /app/",
newKey: "新建 Key",
loadingKeys: "正在加载 Key...",
empty: "未找到 Key",
loadMore: "加载更多",
selectKey: "选择一个 Key 查看值",
loadingValue: "正在加载值...",
notFound: "Key 不存在",
edit: "编辑",
editKey: "编辑 Key",
delete: "删除",
deleteTitle: "删除 etcd Key",
keyPlaceholder: "/path/to/key",
keyRequired: "Key 不能为空",
saved: "Key 已保存",
deleted: "Key 已删除",
base64Readonly: "Base64 值当前版本只读。",
},
redis: {
selectKey: "选择一个 key 查看值",
noKeys: "未找到 key",
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { buildEtcdKeyTree, flattenVisibleEtcdKeyTree } from "@/lib/etcdKeyTree";
describe("etcd key tree", () => {
it("groups slash-delimited keys", () => {
const tree = buildEtcdKeyTree([
{ key: "/app/config/name", modRevision: 3 },
{ key: "/app/config/env", modRevision: 4 },
{ key: "/service/api", modRevision: 5 },
]);
expect(tree.map((node) => node.label)).toEqual(["app", "service"]);
const app = tree[0];
expect(app.kind).toBe("group");
if (app.kind === "group") {
expect(app.children.map((node) => node.label)).toEqual(["config"]);
}
});
it("flattens only expanded groups", () => {
const tree = buildEtcdKeyTree([{ key: "/app/config/name" }, { key: "/plain" }]);
const rows = flattenVisibleEtcdKeyTree(tree, new Set(["group:app"]));
expect(rows.map((row) => `${row.depth}:${row.node.label}`)).toEqual(["0:app", "1:config", "0:plain"]);
});
});
+14
View File
@@ -220,6 +220,12 @@ export const redisFlushDb = forward("redisFlushDb");
export const redisExecuteCommand = forward("redisExecuteCommand");
export const redisLoadMore = forward("redisLoadMore");
// etcd
export const etcdListPrefix = forward("etcdListPrefix");
export const etcdGet = forward("etcdGet");
export const etcdPut = forward("etcdPut");
export const etcdDelete = forward("etcdDelete");
// MongoDB
export const mongoListDatabases = forward("mongoListDatabases");
export const mongoListCollections = forward("mongoListCollections");
@@ -281,6 +287,14 @@ export type {
RedisScanResult,
RedisCommandSafety,
RedisCommandResult,
KvValueEncoding,
KvValue,
KvKeyMetadata,
KvKeySummary,
KvListPrefixResponse,
KvGetResponse,
KvPutResponse,
KvDeleteResponse,
MongoDocumentResult,
HistoryEntry,
SqlFileStatus,
@@ -100,6 +100,9 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string {
case "redis":
return "redis://:password@host:port/0";
case "etcd":
return "etcd://host:2379";
case "sqlite":
return "sqlite:///absolute/path/to/database.db";
+1
View File
@@ -31,6 +31,7 @@ const SCHEME_PROFILES: Record<string, ConnectionProfile> = {
redshift: { type: "redshift", profile: "redshift", label: "Redshift", defaultPort: 5439 },
redis: { type: "redis", profile: "redis", label: "Redis", defaultPort: 6379 },
rediss: { type: "redis", profile: "redis", label: "Redis", defaultPort: 6379 },
etcd: { type: "etcd", profile: "etcd", label: "etcd", defaultPort: 2379 },
mongodb: { type: "mongodb", profile: "mongodb", label: "MongoDB", defaultPort: 27017 },
"mongodb+srv": { type: "mongodb", profile: "mongodb", label: "MongoDB", defaultPort: 27017 },
clickhouse: { type: "clickhouse", profile: "clickhouse", label: "ClickHouse", defaultPort: 8123 },
@@ -34,7 +34,7 @@ export const SCHEMA_AWARE_TYPES = new Set<DatabaseType>([
"duckdb",
]);
export const SQL_FILE_UNSUPPORTED_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch"]);
export const SQL_FILE_UNSUPPORTED_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch", "etcd"]);
export const DIAGRAM_SUPPORTED_TYPES = new Set<DatabaseType>([
"mysql",
@@ -109,6 +109,7 @@ export const DATABASE_SEARCH_SUPPORTED_TYPES = new Set<DatabaseType>([
"tdengine",
"xugu",
"iotdb",
"etcd",
"iris",
]);
@@ -93,7 +93,7 @@ export function supportsDriverManagement(dbType?: DatabaseType): boolean {
}
export function supportsObjectBrowser(dbType?: DatabaseType): boolean {
return !!dbType && !["redis", "mongodb", "elasticsearch"].includes(dbType);
return !!dbType && !["redis", "mongodb", "elasticsearch", "etcd"].includes(dbType);
}
export function supportsObjectBrowserTreeNode(dbType: DatabaseType | undefined, nodeType: TreeNodeType): boolean {
+131
View File
@@ -0,0 +1,131 @@
import type { KvKeySummary } from "./api";
export interface EtcdKeyTreeLeafNode {
kind: "leaf";
id: string;
label: string;
key: string;
pathSegments: string[];
createRevision?: number | null;
modRevision?: number | null;
version?: number | null;
lease?: number | null;
valueSize?: number | null;
}
export interface EtcdKeyTreeGroupNode {
kind: "group";
id: string;
label: string;
pathSegments: string[];
children: EtcdKeyTreeNode[];
}
export type EtcdKeyTreeNode = EtcdKeyTreeLeafNode | EtcdKeyTreeGroupNode;
export interface EtcdKeyTreeRow {
node: EtcdKeyTreeNode;
depth: number;
}
function keySegments(key: string): string[] {
return key.split("/").filter(Boolean);
}
function groupId(pathSegments: string[]): string {
return `group:${pathSegments.join("\u0000")}`;
}
function leafId(key: string): string {
return `leaf:${key}`;
}
function sortNodes(nodes: EtcdKeyTreeNode[]): EtcdKeyTreeNode[] {
return [...nodes]
.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "group" ? -1 : 1;
return a.label.localeCompare(b.label);
})
.map((node) => (node.kind === "group" ? { ...node, children: sortNodes(node.children) } : node));
}
export function buildEtcdKeyTree(keys: KvKeySummary[]): EtcdKeyTreeNode[] {
const root: EtcdKeyTreeNode[] = [];
const groups = new Map<string, EtcdKeyTreeGroupNode>();
for (const key of keys) {
const segments = keySegments(key.key);
if (segments.length <= 1) {
root.push({
kind: "leaf",
id: leafId(key.key),
label: segments[0] || key.key || "/",
key: key.key,
pathSegments: segments,
createRevision: key.createRevision,
modRevision: key.modRevision,
version: key.version,
lease: key.lease,
valueSize: key.valueSize,
});
continue;
}
let current = root;
const groupSegments: string[] = [];
for (const segment of segments.slice(0, -1)) {
groupSegments.push(segment);
const id = groupId(groupSegments);
let group = groups.get(id);
if (!group) {
group = { kind: "group", id, label: segment, pathSegments: [...groupSegments], children: [] };
groups.set(id, group);
current.push(group);
}
current = group.children;
}
current.push({
kind: "leaf",
id: leafId(key.key),
label: segments[segments.length - 1],
key: key.key,
pathSegments: segments,
createRevision: key.createRevision,
modRevision: key.modRevision,
version: key.version,
lease: key.lease,
valueSize: key.valueSize,
});
}
return sortNodes(root);
}
export function collectEtcdGroupIds(nodes: EtcdKeyTreeNode[]): Set<string> {
const ids = new Set<string>();
const walk = (entries: EtcdKeyTreeNode[]) => {
for (const node of entries) {
if (node.kind !== "group") continue;
ids.add(node.id);
walk(node.children);
}
};
walk(nodes);
return ids;
}
export function flattenVisibleEtcdKeyTree(
nodes: EtcdKeyTreeNode[],
expandedGroupIds: ReadonlySet<string>,
depth = 0,
): EtcdKeyTreeRow[] {
const rows: EtcdKeyTreeRow[] = [];
for (const node of nodes) {
rows.push({ node, depth });
if (node.kind === "group" && expandedGroupIds.has(node.id)) {
rows.push(...flattenVisibleEtcdKeyTree(node.children, expandedGroupIds, depth + 1));
}
}
return rows;
}
+35
View File
@@ -40,6 +40,11 @@ import type {
RedisValue,
RedisScanResult,
RedisCommandResult,
KvValue,
KvListPrefixResponse,
KvGetResponse,
KvPutResponse,
KvDeleteResponse,
MongoDocumentResult,
HistoryEntry,
SqlFileRequest,
@@ -1388,6 +1393,36 @@ export async function redisLoadMore(
return post("/api/redis/load-more", { connectionId, db, keyRaw, keyType, cursor, count });
}
// ---------------------------------------------------------------------------
// etcd
// ---------------------------------------------------------------------------
export async function etcdListPrefix(
connectionId: string,
prefix: string,
limit: number,
continuation?: string | null,
): Promise<KvListPrefixResponse> {
return post("/api/etcd/list-prefix", { connectionId, prefix, limit, continuation });
}
export async function etcdGet(connectionId: string, key: string): Promise<KvGetResponse> {
return post("/api/etcd/get", { connectionId, key });
}
export async function etcdPut(
connectionId: string,
key: string,
value: KvValue,
lease?: number | null,
): Promise<KvPutResponse> {
return post("/api/etcd/put", { connectionId, key, value, lease });
}
export async function etcdDelete(connectionId: string, key: string): Promise<KvDeleteResponse> {
return post("/api/etcd/delete", { connectionId, key });
}
// ---------------------------------------------------------------------------
// MongoDB
// ---------------------------------------------------------------------------
@@ -15,6 +15,10 @@ export type ActiveTabSidebarTarget =
database: string;
collectionName: string;
}
| {
type: "etcd-root";
connectionId: string;
}
| {
type: "saved-sql-file";
savedSqlId: string;
@@ -56,6 +60,10 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
};
}
if (tab.mode === "etcd") {
return { type: "etcd-root", connectionId: tab.connectionId };
}
if (tab.mode === "query") {
if (!tab.connectionId || !tab.database) return null;
return {
@@ -100,6 +108,10 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
return node.type === "database" && node.connectionId === target.connectionId && node.label === target.database;
}
if (target.type === "etcd-root") {
return node.type === "etcd-root" && node.connectionId === target.connectionId;
}
return (
(node.type === "table" || node.type === "view") &&
node.connectionId === target.connectionId &&
+5
View File
@@ -59,6 +59,10 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@${database}`;
}
if (tab.mode === "etcd") {
if (compact) return connectionDisplayName(tab.connectionId);
return `${connectionDisplayName(tab.connectionId)}@keys`;
}
if (tab.mode === "objects") {
const schema = tab.objectBrowser?.schema;
if (compact) return schema || tab.title;
@@ -103,6 +107,7 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
if (tab.mode === "query") return t("tabs.sql");
if (tab.mode === "mongo") return t("tabs.mongo");
if (tab.mode === "redis") return t("tabs.redis");
if (tab.mode === "etcd") return t("tabs.etcd");
if (tab.mode === "objects") return t("tabs.objects");
return tab.mode;
}
+68
View File
@@ -1209,6 +1209,74 @@ export async function redisLoadMore(
return invoke("redis_load_more", { connectionId, db, keyRaw, keyType, cursor, count });
}
// --- etcd ---
export type KvValueEncoding = "utf8" | "base64";
export interface KvValue {
encoding: KvValueEncoding;
data: string;
}
export interface KvKeyMetadata {
createRevision?: number | null;
modRevision?: number | null;
version?: number | null;
lease?: number | null;
valueSize?: number | null;
}
export interface KvKeySummary extends KvKeyMetadata {
key: string;
}
export interface KvListPrefixResponse {
keys: KvKeySummary[];
continuation?: string | null;
revision?: number | null;
}
export interface KvGetResponse {
found: boolean;
key?: string | null;
value?: KvValue | null;
metadata?: KvKeyMetadata | null;
}
export interface KvPutResponse {
revision?: number | null;
}
export interface KvDeleteResponse {
deleted: number;
revision?: number | null;
}
export async function etcdListPrefix(
connectionId: string,
prefix: string,
limit: number,
continuation?: string | null,
): Promise<KvListPrefixResponse> {
return invoke("etcd_list_prefix", { connectionId, prefix, limit, continuation });
}
export async function etcdGet(connectionId: string, key: string): Promise<KvGetResponse> {
return invoke("etcd_get", { connectionId, key });
}
export async function etcdPut(
connectionId: string,
key: string,
value: KvValue,
lease?: number | null,
): Promise<KvPutResponse> {
return invoke("etcd_put", { connectionId, key, value, lease });
}
export async function etcdDelete(connectionId: string, key: string): Promise<KvDeleteResponse> {
return invoke("etcd_delete", { connectionId, key });
}
// --- MongoDB ---
export interface MongoDocumentResult {
documents: any[];
@@ -242,6 +242,7 @@ export const useConnectionStore = defineStore("connection", () => {
postgres: "PostgreSQL",
sqlite: "SQLite",
redis: "Redis",
etcd: "etcd",
duckdb: "DuckDB",
clickhouse: "ClickHouse",
sqlserver: "SQL Server",
@@ -703,6 +704,8 @@ export const useConnectionStore = defineStore("connection", () => {
clearLoadedChildrenCache(connectionId);
if (config.db_type === "redis") {
await loadRedisDatabases(connectionId);
} else if (config.db_type === "etcd") {
await loadEtcdRoot(connectionId);
} else if (config.db_type === "mongodb") {
await loadMongoDatabases(connectionId);
} else {
@@ -960,6 +963,40 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function loadEtcdRoot(connectionId: string) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
setChildren(
node,
withSavedSqlRoot(
connectionId,
[
{
id: `${connectionId}:etcd`,
label: "Keys",
type: "etcd-root" as const,
connectionId,
database: "",
isExpanded: false,
children: [],
},
],
node,
),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
function updateRedisDbKeyStats(
connectionId: string,
db: number,
@@ -1466,6 +1503,8 @@ export const useConnectionStore = defineStore("connection", () => {
const config = getConfig(node.connectionId);
if (config?.db_type === "redis") {
await loadRedisDatabases(node.connectionId);
} else if (config?.db_type === "etcd") {
await loadEtcdRoot(node.connectionId);
} else if (config?.db_type === "mongodb" || config?.db_type === "elasticsearch") {
await loadMongoDatabases(node.connectionId);
} else {
@@ -2254,6 +2293,7 @@ export const useConnectionStore = defineStore("connection", () => {
initFromDisk,
loadDatabases,
loadRedisDatabases,
loadEtcdRoot,
updateRedisDbKeyStats,
loadMongoDatabases,
loadMongoCollections,
+6 -1
View File
@@ -46,6 +46,7 @@ export type DatabaseType =
| "tdengine"
| "xugu"
| "iotdb"
| "etcd"
| "iris"
| "jdbc";
@@ -76,6 +77,8 @@ export interface ConnectionConfig {
query_timeout_secs?: number;
ssl?: boolean;
ca_cert_path?: string;
client_cert_path?: string;
client_key_path?: string;
sysdba?: boolean;
oracle_connection_type?: "service_name" | "sid";
connection_string?: string;
@@ -88,6 +91,7 @@ export interface ConnectionConfig {
redis_sentinel_password?: string;
redis_sentinel_tls?: boolean;
redis_cluster_nodes?: string;
etcd_endpoints?: string;
one_time?: boolean;
}
@@ -305,6 +309,7 @@ export type TreeNodeType =
| "fkey"
| "trigger"
| "redis-db"
| "etcd-root"
| "mongo-db"
| "mongo-collection";
@@ -384,7 +389,7 @@ export interface QueryTab {
executionId?: string;
isExplaining?: boolean;
explainExecutionId?: string;
mode: "data" | "query" | "redis" | "mongo" | "objects" | "structure";
mode: "data" | "query" | "redis" | "mongo" | "etcd" | "objects" | "structure";
structureTableName?: string;
objectBrowser?: {
schema?: string;