feat(etcd): add agent-backed etcd support
This commit is contained in:
parent
7bd8d29a79
commit
e6062b2b07
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" role="img" viewBox="-6.31 3.69 510.12 493.62">
|
||||
<path fill="#419eda" d="M228.175 224.684a31.994 31.994 0 1 1-32.007-31.986 32.011 32.011 0 0 1 32.007 31.986zm41.37 0a32.007 32.007 0 1 0 32.007-31.986 31.988 31.988 0 0 0-32.007 31.986z"/>
|
||||
<path fill="#419eda" d="M487.371 259.399a87.82 87.82 0 0 1-7.07.267 92.362 92.362 0 0 1-40.621-9.455 377.209 377.209 0 0 0 5.47-71.86 371.808 371.808 0 0 0-46.507-55.11 92.396 92.396 0 0 1 32.772-35.102l6.016-3.729-4.695-5.3a244.906 244.906 0 0 0-85.524-62.377l-6.507-2.828-1.654 6.862a92.144 92.144 0 0 1-23.19 42.096 371.928 371.928 0 0 0-67.006-27.602 370.624 370.624 0 0 0-66.908 27.558 91.915 91.915 0 0 1-23.098-42.003l-1.681-6.884-6.486 2.817a247.232 247.232 0 0 0-85.518 64.359l-4.689 5.3 6.005 3.724a92.249 92.249 0 0 1 32.695 34.917 374.63 374.63 0 0 0-46.425 54.908 376.956 376.956 0 0 0 5.328 72.334 92.115 92.115 0 0 1-40.376 9.374 86.36 86.36 0 0 1-7.086-.268l-7.053-.551.655 7.042a243.413 243.413 0 0 0 32.897 100.678l3.581 6.098 5.372-4.575a92.052 92.052 0 0 1 43.592-20.406 373.97 373.97 0 0 0 37.308 60.76 377.717 377.717 0 0 0 70.69 17.382 91.87 91.87 0 0 1-5.885 48.232l-2.686 6.54 6.9 1.529a246.355 246.355 0 0 0 52.966 5.857l52.954-5.857 6.906-1.529-2.675-6.54a91.755 91.755 0 0 1-5.869-48.286 377.784 377.784 0 0 0 70.407-17.328 372.3 372.3 0 0 0 37.335-60.815 92.233 92.233 0 0 1 43.81 20.428l5.377 4.553 3.587-6.049a242.844 242.844 0 0 0 32.859-100.672l.655-7.037zM324.644 345.45a285.884 285.884 0 0 1-151.628 0 290.124 290.124 0 0 1-46.141-143.385 288.675 288.675 0 0 1 54.957-52.315 293.065 293.065 0 0 1 67.028-36.462 293.976 293.976 0 0 1 66.891 36.364 290.886 290.886 0 0 1 55.198 52.675 292.253 292.253 0 0 1-13.817 74.682 293.726 293.726 0 0 1-32.488 68.441z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -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 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));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
"query",
|
||||
"paged_query",
|
||||
"transaction",
|
||||
"ddl"
|
||||
"ddl",
|
||||
"kv"
|
||||
],
|
||||
"commonMethods": [
|
||||
"handshake",
|
||||
|
|
@ -44,5 +45,11 @@
|
|||
"insert_document",
|
||||
"update_document",
|
||||
"delete_document"
|
||||
],
|
||||
"kvMethods": [
|
||||
"kv_list_prefix",
|
||||
"kv_get",
|
||||
"kv_put",
|
||||
"kv_delete"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ const AGENT_CATALOG: &[AgentCatalogEntry] = &[
|
|||
store_visible: true,
|
||||
profiles: &[],
|
||||
},
|
||||
AgentCatalogEntry { db_type: DatabaseType::Etcd, key: "etcd", label: "etcd", store_visible: true, profiles: &[] },
|
||||
AgentCatalogEntry {
|
||||
db_type: DatabaseType::MongoDb,
|
||||
key: "mongodb",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ pub fn agent_connect_params(config: &ConnectionConfig, host: &str, port: u16, da
|
|||
} else {
|
||||
config.connection_string.as_deref().unwrap_or("").to_string()
|
||||
};
|
||||
let etcd_endpoints =
|
||||
if config.db_type == DatabaseType::Etcd { normalize_etcd_endpoints(config, host, port) } else { String::new() };
|
||||
|
||||
serde_json::json!({
|
||||
"host": host,
|
||||
|
|
@ -31,6 +33,11 @@ pub fn agent_connect_params(config: &ConnectionConfig, host: &str, port: u16, da
|
|||
"sysdba": oracle_uses_sysdba(config),
|
||||
"url_params": config.url_params.as_deref().unwrap_or(""),
|
||||
"connection_string": connection_string,
|
||||
"ssl": config.ssl,
|
||||
"ca_cert_path": config.ca_cert_path,
|
||||
"client_cert_path": config.client_cert_path,
|
||||
"client_key_path": config.client_key_path,
|
||||
"etcd_endpoints": etcd_endpoints,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -190,6 +197,15 @@ fn sap_hana_jdbc_connection_string(config: &ConnectionConfig, host: &str, port:
|
|||
}
|
||||
}
|
||||
|
||||
fn normalize_etcd_endpoints(config: &ConnectionConfig, host: &str, port: u16) -> String {
|
||||
let endpoints = config.etcd_endpoints.trim();
|
||||
if !endpoints.is_empty() {
|
||||
return endpoints.to_string();
|
||||
}
|
||||
let scheme = if config.ssl { "https" } else { "http" };
|
||||
format!("{scheme}://{host}:{port}")
|
||||
}
|
||||
|
||||
fn append_agent_url_params(base: String, params: Option<&str>) -> String {
|
||||
let params = params.unwrap_or("").trim().trim_start_matches(['?', '&']);
|
||||
if params.is_empty() {
|
||||
|
|
@ -225,6 +241,8 @@ mod tests {
|
|||
query_timeout_secs: default_query_timeout_secs(),
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: None,
|
||||
|
|
@ -235,6 +253,7 @@ mod tests {
|
|||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
etcd_endpoints: String::new(),
|
||||
external_config: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::connection::{AppState, PoolKind};
|
||||
use crate::db::agent_driver::{AgentCapability, AgentKvMethod};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KvValue {
|
||||
pub encoding: KvValueEncoding,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KvValueEncoding {
|
||||
Utf8,
|
||||
Base64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KvKeyMetadata {
|
||||
pub create_revision: Option<i64>,
|
||||
pub mod_revision: Option<i64>,
|
||||
pub version: Option<i64>,
|
||||
pub lease: Option<i64>,
|
||||
pub value_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KvKeySummary {
|
||||
pub key: String,
|
||||
#[serde(flatten)]
|
||||
pub metadata: KvKeyMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KvListPrefixRequest {
|
||||
pub prefix: String,
|
||||
pub limit: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub continuation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KvListPrefixResponse {
|
||||
pub keys: Vec<KvKeySummary>,
|
||||
pub continuation: Option<String>,
|
||||
pub revision: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KvGetRequest {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KvGetResponse {
|
||||
pub found: bool,
|
||||
pub key: Option<String>,
|
||||
pub value: Option<KvValue>,
|
||||
pub metadata: Option<KvKeyMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KvPutRequest {
|
||||
pub key: String,
|
||||
pub value: KvValue,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lease: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KvPutResponse {
|
||||
pub revision: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KvDeleteRequest {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KvDeleteResponse {
|
||||
pub deleted: u64,
|
||||
pub revision: Option<i64>,
|
||||
}
|
||||
|
||||
pub fn kv_list_prefix_params(prefix: &str, limit: usize, continuation: Option<&str>) -> serde_json::Value {
|
||||
serde_json::to_value(KvListPrefixRequest {
|
||||
prefix: prefix.to_string(),
|
||||
limit,
|
||||
continuation: continuation.map(str::to_string),
|
||||
})
|
||||
.expect("KV list prefix request should serialize")
|
||||
}
|
||||
|
||||
pub fn kv_get_params(key: &str) -> serde_json::Value {
|
||||
serde_json::to_value(KvGetRequest { key: key.to_string() }).expect("KV get request should serialize")
|
||||
}
|
||||
|
||||
pub fn kv_put_params(key: &str, value: KvValue, lease: Option<i64>) -> serde_json::Value {
|
||||
serde_json::to_value(KvPutRequest { key: key.to_string(), value, lease }).expect("KV put request should serialize")
|
||||
}
|
||||
|
||||
pub fn kv_delete_params(key: &str) -> serde_json::Value {
|
||||
serde_json::to_value(KvDeleteRequest { key: key.to_string() }).expect("KV delete request should serialize")
|
||||
}
|
||||
|
||||
pub async fn kv_list_prefix_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
prefix: &str,
|
||||
limit: usize,
|
||||
continuation: Option<&str>,
|
||||
) -> Result<KvListPrefixResponse, String> {
|
||||
call_agent_kv(state, connection_id, AgentKvMethod::ListPrefix, kv_list_prefix_params(prefix, limit, continuation))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn kv_get_core(state: &AppState, connection_id: &str, key: &str) -> Result<KvGetResponse, String> {
|
||||
call_agent_kv(state, connection_id, AgentKvMethod::Get, kv_get_params(key)).await
|
||||
}
|
||||
|
||||
pub async fn kv_put_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
value: KvValue,
|
||||
lease: Option<i64>,
|
||||
) -> Result<KvPutResponse, String> {
|
||||
call_agent_kv(state, connection_id, AgentKvMethod::Put, kv_put_params(key, value, lease)).await
|
||||
}
|
||||
|
||||
pub async fn kv_delete_core(state: &AppState, connection_id: &str, key: &str) -> Result<KvDeleteResponse, String> {
|
||||
call_agent_kv(state, connection_id, AgentKvMethod::Delete, kv_delete_params(key)).await
|
||||
}
|
||||
|
||||
async fn call_agent_kv<T: serde::de::DeserializeOwned + Send + 'static>(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
method: AgentKvMethod,
|
||||
params: serde_json::Value,
|
||||
) -> Result<T, String> {
|
||||
let connections = state.connections.read().await;
|
||||
let pool = connections.get(connection_id).ok_or("Connection not found")?;
|
||||
match pool {
|
||||
PoolKind::Agent(client) => {
|
||||
let mut client = client.lock().await;
|
||||
if !client.supports_capability(AgentCapability::Kv) {
|
||||
return Err("Agent does not support key-value operations".to_string());
|
||||
}
|
||||
client.call_kv_method(method, params).await
|
||||
}
|
||||
_ => Err("Not an agent key-value connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serializes_kv_list_prefix_params() {
|
||||
assert_eq!(
|
||||
kv_list_prefix_params("/config/", 100, Some("next-token")),
|
||||
serde_json::json!({
|
||||
"prefix": "/config/",
|
||||
"limit": 100,
|
||||
"continuation": "next-token"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
kv_list_prefix_params("", 50, None),
|
||||
serde_json::json!({
|
||||
"prefix": "",
|
||||
"limit": 50
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_kv_get_put_delete_params() {
|
||||
assert_eq!(kv_get_params("/app/name"), serde_json::json!({ "key": "/app/name" }));
|
||||
assert_eq!(
|
||||
kv_put_params("/app/name", KvValue { encoding: KvValueEncoding::Utf8, data: "dbx".to_string() }, Some(42),),
|
||||
serde_json::json!({
|
||||
"key": "/app/name",
|
||||
"value": {
|
||||
"encoding": "utf8",
|
||||
"data": "dbx"
|
||||
},
|
||||
"lease": 42
|
||||
})
|
||||
);
|
||||
assert_eq!(kv_delete_params("/app/name"), serde_json::json!({ "key": "/app/name" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_kv_list_prefix_response() {
|
||||
let decoded: KvListPrefixResponse = serde_json::from_value(serde_json::json!({
|
||||
"keys": [{
|
||||
"key": "/app/name",
|
||||
"createRevision": 1,
|
||||
"modRevision": 2,
|
||||
"version": 3,
|
||||
"lease": 0,
|
||||
"valueSize": 5
|
||||
}],
|
||||
"continuation": "next-token",
|
||||
"revision": 9
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(decoded.keys[0].key, "/app/name");
|
||||
assert_eq!(decoded.keys[0].metadata.mod_revision, Some(2));
|
||||
assert_eq!(decoded.continuation.as_deref(), Some("next-token"));
|
||||
assert_eq!(decoded.revision, Some(9));
|
||||
}
|
||||
}
|
||||
|
|
@ -113,9 +113,14 @@ pub fn jre_needs_install(am: &AgentManager, registry: &AgentRegistry, jre_key: &
|
|||
|
||||
pub fn local_agent_jar_candidates(db_type: &str) -> Vec<PathBuf> {
|
||||
let jar_name = format!("dbx-agent-{db_type}.jar");
|
||||
let relative = PathBuf::from("..").join("dbx-agents").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let nested = PathBuf::from("dbx-agents").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
vec![relative, nested]
|
||||
let relative_driver =
|
||||
PathBuf::from("..").join("dbx-agents").join("drivers").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let nested_driver =
|
||||
PathBuf::from("dbx-agents").join("drivers").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let relative_legacy =
|
||||
PathBuf::from("..").join("dbx-agents").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
let nested_legacy = PathBuf::from("dbx-agents").join(db_type).join("build").join("libs").join(&jar_name);
|
||||
vec![relative_driver, nested_driver, relative_legacy, nested_legacy]
|
||||
}
|
||||
|
||||
pub fn find_local_agent_jar(db_type: &str) -> Option<PathBuf> {
|
||||
|
|
|
|||
|
|
@ -578,6 +578,8 @@ mod tests {
|
|||
query_timeout_secs: 30,
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: Some("postgres://secret".to_string()),
|
||||
|
|
@ -588,6 +590,7 @@ mod tests {
|
|||
redis_sentinel_password: "sentinel".to_string(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
etcd_endpoints: String::new(),
|
||||
external_config: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -475,6 +475,7 @@ impl AppState {
|
|||
| DatabaseType::Tdengine
|
||||
| DatabaseType::Xugu
|
||||
| DatabaseType::Iotdb
|
||||
| DatabaseType::Etcd
|
||||
| DatabaseType::Iris
|
||||
| DatabaseType::Access => {
|
||||
let connect_params =
|
||||
|
|
@ -1134,6 +1135,8 @@ mod tests {
|
|||
query_timeout_secs: crate::models::connection::default_query_timeout_secs(),
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: None,
|
||||
|
|
@ -1144,6 +1147,7 @@ mod tests {
|
|||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
etcd_endpoints: String::new(),
|
||||
external_config: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -493,6 +493,8 @@ mod tests {
|
|||
query_timeout_secs: crate::models::connection::default_query_timeout_secs(),
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: None,
|
||||
|
|
@ -503,6 +505,7 @@ mod tests {
|
|||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
etcd_endpoints: String::new(),
|
||||
external_config: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -48,10 +48,11 @@ pub enum AgentCapability {
|
|||
PagedQuery,
|
||||
Transaction,
|
||||
Ddl,
|
||||
Kv,
|
||||
}
|
||||
|
||||
impl AgentCapability {
|
||||
pub const ALL: [Self; 7] = [
|
||||
pub const ALL: [Self; 8] = [
|
||||
Self::Connect,
|
||||
Self::TestConnection,
|
||||
Self::Metadata,
|
||||
|
|
@ -59,6 +60,7 @@ impl AgentCapability {
|
|||
Self::PagedQuery,
|
||||
Self::Transaction,
|
||||
Self::Ddl,
|
||||
Self::Kv,
|
||||
];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
|
|
@ -70,6 +72,7 @@ impl AgentCapability {
|
|||
Self::PagedQuery => "paged_query",
|
||||
Self::Transaction => "transaction",
|
||||
Self::Ddl => "ddl",
|
||||
Self::Kv => "kv",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -180,6 +183,27 @@ impl MongoAgentMethod {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AgentKvMethod {
|
||||
ListPrefix,
|
||||
Get,
|
||||
Put,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl AgentKvMethod {
|
||||
pub const ALL: [Self; 4] = [Self::ListPrefix, Self::Get, Self::Put, Self::Delete];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ListPrefix => "kv_list_prefix",
|
||||
Self::Get => "kv_get",
|
||||
Self::Put => "kv_put",
|
||||
Self::Delete => "kv_delete",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StderrTail {
|
||||
lines: VecDeque<String>,
|
||||
capacity: usize,
|
||||
|
|
@ -542,6 +566,14 @@ impl AgentDriverClient {
|
|||
self.call(method.as_str(), params).await
|
||||
}
|
||||
|
||||
pub async fn call_kv_method<T: DeserializeOwned + Send + 'static>(
|
||||
&mut self,
|
||||
method: AgentKvMethod,
|
||||
params: Value,
|
||||
) -> Result<T, String> {
|
||||
self.call(method.as_str(), params).await
|
||||
}
|
||||
|
||||
pub async fn mongo_list_databases<T: DeserializeOwned + Send + 'static>(&mut self) -> Result<T, String> {
|
||||
self.call_mongo_method(MongoAgentMethod::ListDatabases, serde_json::json!({})).await
|
||||
}
|
||||
|
|
@ -664,6 +696,9 @@ pub fn is_unsupported_handshake_error(error: &str) -> bool {
|
|||
}
|
||||
|
||||
pub fn agent_supports_capability(handshake: Option<&AgentHandshake>, capability: AgentCapability) -> bool {
|
||||
if capability == AgentCapability::Kv {
|
||||
return handshake.map(|value| value.supports(capability)).unwrap_or(false);
|
||||
}
|
||||
handshake.map(|value| value.supports(capability)).unwrap_or(true)
|
||||
}
|
||||
|
||||
|
|
@ -844,7 +879,7 @@ mod tests {
|
|||
agent_proxy_env_vars, agent_schema_params, agent_schema_table_params, agent_supports_capability,
|
||||
agent_transaction_params, format_agent_process_error, is_unsupported_handshake_error, mongo_collection_params,
|
||||
mongo_database_params, mongo_document_id_params, read_agent_line, AgentCapability, AgentDriverClient,
|
||||
AgentHandshake, AgentMethod, MongoAgentMethod, StderrTail, AGENT_PROTOCOL_VERSION,
|
||||
AgentHandshake, AgentKvMethod, AgentMethod, MongoAgentMethod, StderrTail, AGENT_PROTOCOL_VERSION,
|
||||
};
|
||||
use std::io::Cursor;
|
||||
|
||||
|
|
@ -976,7 +1011,8 @@ mod tests {
|
|||
assert_eq!(AgentCapability::PagedQuery.as_str(), "paged_query");
|
||||
assert_eq!(AgentCapability::Transaction.as_str(), "transaction");
|
||||
assert_eq!(AgentCapability::Ddl.as_str(), "ddl");
|
||||
assert_eq!(AgentCapability::ALL.len(), 7);
|
||||
assert_eq!(AgentCapability::Kv.as_str(), "kv");
|
||||
assert_eq!(AgentCapability::ALL.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1013,6 +1049,15 @@ mod tests {
|
|||
assert_eq!(MongoAgentMethod::DeleteDocument.as_str(), "delete_document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defines_kv_agent_protocol_methods() {
|
||||
assert_eq!(AgentKvMethod::ListPrefix.as_str(), "kv_list_prefix");
|
||||
assert_eq!(AgentKvMethod::Get.as_str(), "kv_get");
|
||||
assert_eq!(AgentKvMethod::Put.as_str(), "kv_put");
|
||||
assert_eq!(AgentKvMethod::Delete.as_str(), "kv_delete");
|
||||
assert_eq!(AgentKvMethod::ALL.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposes_schema_and_query_protocol_wrappers() {
|
||||
let _list_databases = AgentDriverClient::list_databases::<serde_json::Value>;
|
||||
|
|
@ -1042,6 +1087,11 @@ mod tests {
|
|||
let _mongo_delete_document = AgentDriverClient::mongo_delete_document::<serde_json::Value>;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposes_kv_protocol_wrapper() {
|
||||
let _call_kv_method = AgentDriverClient::call_kv_method::<serde_json::Value>;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_mongo_agent_request_params() {
|
||||
assert_eq!(mongo_database_params("app"), serde_json::json!({ "database": "app" }));
|
||||
|
|
@ -1104,6 +1154,10 @@ mod tests {
|
|||
string_array(&contract["mongoLegacyMethods"]),
|
||||
MongoAgentMethod::ALL.iter().map(|method| method.as_str()).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
string_array(&contract["kvMethods"]),
|
||||
AgentKvMethod::ALL.iter().map(|method| method.as_str()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1117,6 +1171,7 @@ mod tests {
|
|||
assert!(handshake.supports(AgentCapability::Connect));
|
||||
assert!(handshake.supports(AgentCapability::Metadata));
|
||||
assert!(!handshake.supports(AgentCapability::Query));
|
||||
assert!(!handshake.supports(AgentCapability::Kv));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1130,6 +1185,8 @@ mod tests {
|
|||
assert!(agent_supports_capability(None, AgentCapability::Query));
|
||||
assert!(agent_supports_capability(Some(&handshake), AgentCapability::Connect));
|
||||
assert!(!agent_supports_capability(Some(&handshake), AgentCapability::Query));
|
||||
assert!(!agent_supports_capability(None, AgentCapability::Kv));
|
||||
assert!(!agent_supports_capability(Some(&handshake), AgentCapability::Kv));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod agent_catalog;
|
||||
pub mod agent_connection;
|
||||
pub mod agent_kv;
|
||||
pub mod agent_manager;
|
||||
pub mod agent_runtime;
|
||||
pub mod agent_service;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ pub struct ConnectionConfig {
|
|||
pub ssl: bool,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub ca_cert_path: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub client_cert_path: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub client_key_path: String,
|
||||
#[serde(default)]
|
||||
pub sysdba: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -54,6 +58,8 @@ pub struct ConnectionConfig {
|
|||
pub redis_sentinel_tls: bool,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub redis_cluster_nodes: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub etcd_endpoints: String,
|
||||
/// Typed configuration for external tabular sources.
|
||||
#[serde(default)]
|
||||
pub external_config: Option<serde_json::Value>,
|
||||
|
|
@ -255,6 +261,7 @@ pub enum DatabaseType {
|
|||
Tdengine,
|
||||
Xugu,
|
||||
Iotdb,
|
||||
Etcd,
|
||||
#[serde(rename = "iris")]
|
||||
Iris,
|
||||
Jdbc,
|
||||
|
|
@ -293,6 +300,10 @@ struct ConnectionConfigData {
|
|||
#[serde(default)]
|
||||
pub ca_cert_path: String,
|
||||
#[serde(default)]
|
||||
pub client_cert_path: String,
|
||||
#[serde(default)]
|
||||
pub client_key_path: String,
|
||||
#[serde(default)]
|
||||
pub sysdba: bool,
|
||||
#[serde(default)]
|
||||
pub oracle_connection_type: Option<String>,
|
||||
|
|
@ -313,6 +324,8 @@ struct ConnectionConfigData {
|
|||
#[serde(default)]
|
||||
pub redis_cluster_nodes: String,
|
||||
#[serde(default)]
|
||||
pub etcd_endpoints: String,
|
||||
#[serde(default)]
|
||||
pub external_config: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub jdbc_driver_class: Option<String>,
|
||||
|
|
@ -344,6 +357,8 @@ impl From<ConnectionConfigData> for ConnectionConfig {
|
|||
query_timeout_secs: data.query_timeout_secs,
|
||||
ssl: data.ssl,
|
||||
ca_cert_path: data.ca_cert_path,
|
||||
client_cert_path: data.client_cert_path,
|
||||
client_key_path: data.client_key_path,
|
||||
sysdba: data.sysdba,
|
||||
oracle_connection_type: data.oracle_connection_type,
|
||||
connection_string: data.connection_string,
|
||||
|
|
@ -354,6 +369,7 @@ impl From<ConnectionConfigData> for ConnectionConfig {
|
|||
redis_sentinel_password: data.redis_sentinel_password,
|
||||
redis_sentinel_tls: data.redis_sentinel_tls,
|
||||
redis_cluster_nodes: data.redis_cluster_nodes,
|
||||
etcd_endpoints: data.etcd_endpoints,
|
||||
external_config: data.external_config,
|
||||
jdbc_driver_class: data.jdbc_driver_class,
|
||||
jdbc_driver_paths: data.jdbc_driver_paths,
|
||||
|
|
@ -691,6 +707,9 @@ impl ConnectionConfig {
|
|||
format!("{base}?{params}")
|
||||
}
|
||||
}
|
||||
DatabaseType::Etcd => {
|
||||
format!("etcd://{host}:{port}")
|
||||
}
|
||||
DatabaseType::Iris => format!("iris://{host}:{port}{db_part}"),
|
||||
DatabaseType::Jdbc => "jdbc:<redacted>".to_string(),
|
||||
}
|
||||
|
|
@ -871,6 +890,13 @@ impl ConnectionConfig {
|
|||
format!("{base}?{params}")
|
||||
}
|
||||
}
|
||||
DatabaseType::Etcd => {
|
||||
if self.username.is_empty() {
|
||||
format!("etcd://{host}:{port}")
|
||||
} else {
|
||||
format!("etcd://{}:{}@{host}:{port}", username, password)
|
||||
}
|
||||
}
|
||||
DatabaseType::Iris => {
|
||||
format!("iris://{}:{}@{host}:{port}{db_part}", username, password)
|
||||
}
|
||||
|
|
@ -1288,6 +1314,8 @@ mod tests {
|
|||
query_timeout_secs: default_query_timeout_secs(),
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: None,
|
||||
|
|
@ -1298,6 +1326,7 @@ mod tests {
|
|||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
etcd_endpoints: String::new(),
|
||||
external_config: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -1614,6 +1614,8 @@ mod tests {
|
|||
query_timeout_secs: 30,
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: Some("jdbc:h2:mem:test".to_string()),
|
||||
|
|
@ -1624,6 +1626,7 @@ mod tests {
|
|||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
etcd_endpoints: String::new(),
|
||||
external_config: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -596,6 +596,8 @@ mod tests {
|
|||
query_timeout_secs: 30,
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: None,
|
||||
|
|
@ -606,6 +608,7 @@ mod tests {
|
|||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
etcd_endpoints: String::new(),
|
||||
external_config: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -3135,6 +3135,8 @@ mod tests {
|
|||
query_timeout_secs: 30,
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: None,
|
||||
|
|
@ -3145,6 +3147,7 @@ mod tests {
|
|||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
etcd_endpoints: String::new(),
|
||||
external_config: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig {
|
|||
query_timeout_secs: 30,
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: None,
|
||||
|
|
@ -39,6 +41,7 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig {
|
|||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
etcd_endpoints: String::new(),
|
||||
external_config: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -243,6 +243,11 @@ async fn main() {
|
|||
.route("/redis/delete-keys", post(routes::redis::delete_keys))
|
||||
.route("/redis/flush-db", post(routes::redis::flush_db))
|
||||
.route("/redis/execute-command", post(routes::redis::execute_command))
|
||||
// etcd
|
||||
.route("/etcd/list-prefix", post(routes::etcd::list_prefix))
|
||||
.route("/etcd/get", post(routes::etcd::get))
|
||||
.route("/etcd/put", post(routes::etcd::put))
|
||||
.route("/etcd/delete", post(routes::etcd::delete))
|
||||
// MongoDB
|
||||
.route("/mongo/list-databases", post(routes::mongo::list_databases))
|
||||
.route("/mongo/list-collections", post(routes::mongo::list_collections))
|
||||
|
|
|
|||
|
|
@ -181,6 +181,8 @@ mod tests {
|
|||
query_timeout_secs: dbx_core::models::connection::default_query_timeout_secs(),
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: None,
|
||||
|
|
@ -191,6 +193,7 @@ mod tests {
|
|||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
etcd_endpoints: String::new(),
|
||||
external_config: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EtcdListPrefixRequest {
|
||||
pub connection_id: String,
|
||||
pub prefix: String,
|
||||
pub limit: usize,
|
||||
pub continuation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EtcdKeyRequest {
|
||||
pub connection_id: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EtcdPutRequest {
|
||||
pub connection_id: String,
|
||||
pub key: String,
|
||||
pub value: dbx_core::agent_kv::KvValue,
|
||||
pub lease: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn list_prefix(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<EtcdListPrefixRequest>,
|
||||
) -> Result<Json<dbx_core::agent_kv::KvListPrefixResponse>, AppError> {
|
||||
let result = dbx_core::agent_kv::kv_list_prefix_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.prefix,
|
||||
req.limit,
|
||||
req.continuation.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<EtcdKeyRequest>,
|
||||
) -> Result<Json<dbx_core::agent_kv::KvGetResponse>, AppError> {
|
||||
let result = dbx_core::agent_kv::kv_get_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn put(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<EtcdPutRequest>,
|
||||
) -> Result<Json<dbx_core::agent_kv::KvPutResponse>, AppError> {
|
||||
let result = dbx_core::agent_kv::kv_put_core(&state.app, &req.connection_id, &req.key, req.value, req.lease)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<EtcdKeyRequest>,
|
||||
) -> Result<Json<dbx_core::agent_kv::KvDeleteResponse>, AppError> {
|
||||
let result =
|
||||
dbx_core::agent_kv::kv_delete_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ pub mod app_settings;
|
|||
pub mod connection;
|
||||
pub mod data_compare;
|
||||
pub mod database_export;
|
||||
pub mod etcd;
|
||||
pub mod history;
|
||||
pub mod jdbc;
|
||||
pub mod layout;
|
||||
|
|
|
|||
|
|
@ -175,6 +175,8 @@ mod tests {
|
|||
query_timeout_secs: dbx_core::models::connection::default_query_timeout_secs(),
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
client_cert_path: String::new(),
|
||||
client_key_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: Some(
|
||||
|
|
@ -187,6 +189,7 @@ mod tests {
|
|||
redis_sentinel_password: String::new(),
|
||||
redis_sentinel_tls: false,
|
||||
redis_cluster_nodes: String::new(),
|
||||
etcd_endpoints: String::new(),
|
||||
external_config: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::connection::AppState;
|
||||
use dbx_core::agent_kv::{KvDeleteResponse, KvGetResponse, KvListPrefixResponse, KvPutResponse, KvValue};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn etcd_list_prefix(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
prefix: String,
|
||||
limit: usize,
|
||||
continuation: Option<String>,
|
||||
) -> Result<KvListPrefixResponse, String> {
|
||||
dbx_core::agent_kv::kv_list_prefix_core(&state, &connection_id, &prefix, limit, continuation.as_deref()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn etcd_get(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
key: String,
|
||||
) -> Result<KvGetResponse, String> {
|
||||
dbx_core::agent_kv::kv_get_core(&state, &connection_id, &key).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn etcd_put(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
key: String,
|
||||
value: KvValue,
|
||||
lease: Option<i64>,
|
||||
) -> Result<KvPutResponse, String> {
|
||||
dbx_core::agent_kv::kv_put_core(&state, &connection_id, &key, value, lease).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn etcd_delete(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
connection_id: String,
|
||||
key: String,
|
||||
) -> Result<KvDeleteResponse, String> {
|
||||
dbx_core::agent_kv::kv_delete_core(&state, &connection_id, &key).await
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ pub mod csv_export;
|
|||
pub mod data_compare;
|
||||
pub mod database_export;
|
||||
pub mod deep_link;
|
||||
pub mod etcd_cmd;
|
||||
pub mod external_db;
|
||||
pub mod external_sql;
|
||||
pub mod history;
|
||||
|
|
|
|||
|
|
@ -479,6 +479,10 @@ pub fn run() {
|
|||
commands::redis_cmd::redis_flush_db,
|
||||
commands::redis_cmd::redis_execute_command,
|
||||
commands::redis_cmd::redis_load_more,
|
||||
commands::etcd_cmd::etcd_list_prefix,
|
||||
commands::etcd_cmd::etcd_get,
|
||||
commands::etcd_cmd::etcd_put,
|
||||
commands::etcd_cmd::etcd_delete,
|
||||
commands::saved_sql::load_saved_sql_library,
|
||||
commands::saved_sql::save_saved_sql_folder,
|
||||
commands::saved_sql::delete_saved_sql_folder,
|
||||
|
|
|
|||
Loading…
Reference in New Issue