diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue
index 60257096c..79e638ee2 100644
--- a/apps/desktop/src/components/sidebar/TreeItem.vue
+++ b/apps/desktop/src/components/sidebar/TreeItem.vue
@@ -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) {
diff --git a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue
index ac13d5ab1..0dba61488 100644
--- a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue
+++ b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue
@@ -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));
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index f8074f77b..2f357aa67 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -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",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 49570d87d..66ae64d54 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -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",
diff --git a/apps/desktop/src/lib/__tests__/etcdKeyTree.spec.ts b/apps/desktop/src/lib/__tests__/etcdKeyTree.spec.ts
new file mode 100644
index 000000000..c6dd7232b
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/etcdKeyTree.spec.ts
@@ -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"]);
+ });
+});
diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts
index 63bcde895..c69010d0e 100644
--- a/apps/desktop/src/lib/api.ts
+++ b/apps/desktop/src/lib/api.ts
@@ -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,
diff --git a/apps/desktop/src/lib/connectionPresentation.ts b/apps/desktop/src/lib/connectionPresentation.ts
index d6c34091c..96d71d97d 100644
--- a/apps/desktop/src/lib/connectionPresentation.ts
+++ b/apps/desktop/src/lib/connectionPresentation.ts
@@ -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";
diff --git a/apps/desktop/src/lib/connectionUrl.ts b/apps/desktop/src/lib/connectionUrl.ts
index 9d88a05f4..deb700f68 100644
--- a/apps/desktop/src/lib/connectionUrl.ts
+++ b/apps/desktop/src/lib/connectionUrl.ts
@@ -31,6 +31,7 @@ const SCHEME_PROFILES: Record
= {
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 },
diff --git a/apps/desktop/src/lib/databaseCapabilitySets.ts b/apps/desktop/src/lib/databaseCapabilitySets.ts
index c0c7dcac1..7513d66a4 100644
--- a/apps/desktop/src/lib/databaseCapabilitySets.ts
+++ b/apps/desktop/src/lib/databaseCapabilitySets.ts
@@ -34,7 +34,7 @@ export const SCHEMA_AWARE_TYPES = new Set([
"duckdb",
]);
-export const SQL_FILE_UNSUPPORTED_TYPES = new Set(["redis", "mongodb", "elasticsearch"]);
+export const SQL_FILE_UNSUPPORTED_TYPES = new Set(["redis", "mongodb", "elasticsearch", "etcd"]);
export const DIAGRAM_SUPPORTED_TYPES = new Set([
"mysql",
@@ -109,6 +109,7 @@ export const DATABASE_SEARCH_SUPPORTED_TYPES = new Set([
"tdengine",
"xugu",
"iotdb",
+ "etcd",
"iris",
]);
diff --git a/apps/desktop/src/lib/databaseFeatureSupport.ts b/apps/desktop/src/lib/databaseFeatureSupport.ts
index e3a5fdf45..f33253eb7 100644
--- a/apps/desktop/src/lib/databaseFeatureSupport.ts
+++ b/apps/desktop/src/lib/databaseFeatureSupport.ts
@@ -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 {
diff --git a/apps/desktop/src/lib/etcdKeyTree.ts b/apps/desktop/src/lib/etcdKeyTree.ts
new file mode 100644
index 000000000..2b8cd6816
--- /dev/null
+++ b/apps/desktop/src/lib/etcdKeyTree.ts
@@ -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();
+
+ 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 {
+ const ids = new Set();
+ 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,
+ 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;
+}
diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts
index 047282a6b..bf49d59c4 100644
--- a/apps/desktop/src/lib/http.ts
+++ b/apps/desktop/src/lib/http.ts
@@ -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 {
+ return post("/api/etcd/list-prefix", { connectionId, prefix, limit, continuation });
+}
+
+export async function etcdGet(connectionId: string, key: string): Promise {
+ return post("/api/etcd/get", { connectionId, key });
+}
+
+export async function etcdPut(
+ connectionId: string,
+ key: string,
+ value: KvValue,
+ lease?: number | null,
+): Promise {
+ return post("/api/etcd/put", { connectionId, key, value, lease });
+}
+
+export async function etcdDelete(connectionId: string, key: string): Promise {
+ return post("/api/etcd/delete", { connectionId, key });
+}
+
// ---------------------------------------------------------------------------
// MongoDB
// ---------------------------------------------------------------------------
diff --git a/apps/desktop/src/lib/sidebarActiveTabTarget.ts b/apps/desktop/src/lib/sidebarActiveTabTarget.ts
index b40b2ef60..fd14eacb9 100644
--- a/apps/desktop/src/lib/sidebarActiveTabTarget.ts
+++ b/apps/desktop/src/lib/sidebarActiveTabTarget.ts
@@ -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 &&
diff --git a/apps/desktop/src/lib/tabPresentation.ts b/apps/desktop/src/lib/tabPresentation.ts
index 98eee142a..25f26197e 100644
--- a/apps/desktop/src/lib/tabPresentation.ts
+++ b/apps/desktop/src/lib/tabPresentation.ts
@@ -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;
}
diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts
index ca68c2267..2956d3129 100644
--- a/apps/desktop/src/lib/tauri.ts
+++ b/apps/desktop/src/lib/tauri.ts
@@ -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 {
+ return invoke("etcd_list_prefix", { connectionId, prefix, limit, continuation });
+}
+
+export async function etcdGet(connectionId: string, key: string): Promise {
+ return invoke("etcd_get", { connectionId, key });
+}
+
+export async function etcdPut(
+ connectionId: string,
+ key: string,
+ value: KvValue,
+ lease?: number | null,
+): Promise {
+ return invoke("etcd_put", { connectionId, key, value, lease });
+}
+
+export async function etcdDelete(connectionId: string, key: string): Promise {
+ return invoke("etcd_delete", { connectionId, key });
+}
+
// --- MongoDB ---
export interface MongoDocumentResult {
documents: any[];
diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts
index 943c303bf..8d3759c88 100644
--- a/apps/desktop/src/stores/connectionStore.ts
+++ b/apps/desktop/src/stores/connectionStore.ts
@@ -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,
diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts
index b8cb2b5a8..c3c7b7260 100644
--- a/apps/desktop/src/types/database.ts
+++ b/apps/desktop/src/types/database.ts
@@ -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;
diff --git a/crates/dbx-core/assets/agent-protocol-v1.json b/crates/dbx-core/assets/agent-protocol-v1.json
index 7d9abc7e2..dba880700 100644
--- a/crates/dbx-core/assets/agent-protocol-v1.json
+++ b/crates/dbx-core/assets/agent-protocol-v1.json
@@ -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"
]
}
diff --git a/crates/dbx-core/src/agent_catalog.rs b/crates/dbx-core/src/agent_catalog.rs
index 5764ac189..da2f3ab63 100644
--- a/crates/dbx-core/src/agent_catalog.rs
+++ b/crates/dbx-core/src/agent_catalog.rs
@@ -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",
diff --git a/crates/dbx-core/src/agent_connection.rs b/crates/dbx-core/src/agent_connection.rs
index 70844d4b8..cb22b5896 100644
--- a/crates/dbx-core/src/agent_connection.rs
+++ b/crates/dbx-core/src/agent_connection.rs
@@ -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(),
diff --git a/crates/dbx-core/src/agent_kv.rs b/crates/dbx-core/src/agent_kv.rs
new file mode 100644
index 000000000..8f2a6a2cc
--- /dev/null
+++ b/crates/dbx-core/src/agent_kv.rs
@@ -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,
+ pub mod_revision: Option,
+ pub version: Option,
+ pub lease: Option,
+ pub value_size: Option,
+}
+
+#[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,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct KvListPrefixResponse {
+ pub keys: Vec,
+ pub continuation: Option,
+ pub revision: Option,
+}
+
+#[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,
+ pub value: Option,
+ pub metadata: Option,
+}
+
+#[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,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct KvPutResponse {
+ pub revision: Option,
+}
+
+#[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,
+}
+
+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) -> 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 {
+ 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 {
+ 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,
+) -> Result {
+ 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 {
+ call_agent_kv(state, connection_id, AgentKvMethod::Delete, kv_delete_params(key)).await
+}
+
+async fn call_agent_kv(
+ state: &AppState,
+ connection_id: &str,
+ method: AgentKvMethod,
+ params: serde_json::Value,
+) -> Result {
+ 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));
+ }
+}
diff --git a/crates/dbx-core/src/agent_service.rs b/crates/dbx-core/src/agent_service.rs
index dd18bcbb2..e42001143 100644
--- a/crates/dbx-core/src/agent_service.rs
+++ b/crates/dbx-core/src/agent_service.rs
@@ -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 {
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 {
diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs
index 316163ee8..8bfc1c463 100644
--- a/crates/dbx-core/src/cloud_sync.rs
+++ b/crates/dbx-core/src/cloud_sync.rs
@@ -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(),
diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs
index b34d7e06c..d12802cdc 100644
--- a/crates/dbx-core/src/connection.rs
+++ b/crates/dbx-core/src/connection.rs
@@ -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(),
diff --git a/crates/dbx-core/src/connection_secrets.rs b/crates/dbx-core/src/connection_secrets.rs
index e1607192c..b8caaa939 100644
--- a/crates/dbx-core/src/connection_secrets.rs
+++ b/crates/dbx-core/src/connection_secrets.rs
@@ -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(),
diff --git a/crates/dbx-core/src/db/agent_driver.rs b/crates/dbx-core/src/db/agent_driver.rs
index 92d1d348d..639c8b1ce 100644
--- a/crates/dbx-core/src/db/agent_driver.rs
+++ b/crates/dbx-core/src/db/agent_driver.rs
@@ -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,
capacity: usize,
@@ -542,6 +566,14 @@ impl AgentDriverClient {
self.call(method.as_str(), params).await
}
+ pub async fn call_kv_method(
+ &mut self,
+ method: AgentKvMethod,
+ params: Value,
+ ) -> Result {
+ self.call(method.as_str(), params).await
+ }
+
pub async fn mongo_list_databases(&mut self) -> Result {
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::;
@@ -1042,6 +1087,11 @@ mod tests {
let _mongo_delete_document = AgentDriverClient::mongo_delete_document::;
}
+ #[test]
+ fn exposes_kv_protocol_wrapper() {
+ let _call_kv_method = AgentDriverClient::call_kv_method::;
+ }
+
#[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::>()
);
+ assert_eq!(
+ string_array(&contract["kvMethods"]),
+ AgentKvMethod::ALL.iter().map(|method| method.as_str()).collect::>()
+ );
}
#[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]
diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs
index 468579dd6..b403cfc9b 100644
--- a/crates/dbx-core/src/lib.rs
+++ b/crates/dbx-core/src/lib.rs
@@ -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;
diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs
index 6e6972534..717c6bb58 100644
--- a/crates/dbx-core/src/models/connection.rs
+++ b/crates/dbx-core/src/models/connection.rs
@@ -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,
@@ -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,
@@ -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(default)]
pub jdbc_driver_class: Option,
@@ -344,6 +357,8 @@ impl From 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 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:".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(),
diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs
index d4814fcb6..91f0d189e 100644
--- a/crates/dbx-core/src/query.rs
+++ b/crates/dbx-core/src/query.rs
@@ -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(),
diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs
index 4d650e13b..3c608b548 100644
--- a/crates/dbx-core/src/schema.rs
+++ b/crates/dbx-core/src/schema.rs
@@ -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(),
diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs
index 7ae2be559..1b3d68c45 100644
--- a/crates/dbx-core/src/transfer.rs
+++ b/crates/dbx-core/src/transfer.rs
@@ -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(),
diff --git a/crates/dbx-core/tests/live_postgres_transfer.rs b/crates/dbx-core/tests/live_postgres_transfer.rs
index 3212fb0d8..7a721a625 100644
--- a/crates/dbx-core/tests/live_postgres_transfer.rs
+++ b/crates/dbx-core/tests/live_postgres_transfer.rs
@@ -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(),
diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs
index e47cb7c2b..316463e55 100644
--- a/crates/dbx-web/src/main.rs
+++ b/crates/dbx-web/src/main.rs
@@ -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))
diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs
index 66945466c..e84e9b7b8 100644
--- a/crates/dbx-web/src/routes/connection.rs
+++ b/crates/dbx-web/src/routes/connection.rs
@@ -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(),
diff --git a/crates/dbx-web/src/routes/etcd.rs b/crates/dbx-web/src/routes/etcd.rs
new file mode 100644
index 000000000..c9ce078f8
--- /dev/null
+++ b/crates/dbx-web/src/routes/etcd.rs
@@ -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,
+}
+
+#[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,
+}
+
+pub async fn list_prefix(
+ State(state): State>,
+ Json(req): Json,
+) -> Result, 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>,
+ Json(req): Json,
+) -> Result, 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>,
+ Json(req): Json,
+) -> Result, 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>,
+ Json(req): Json,
+) -> Result, AppError> {
+ let result =
+ dbx_core::agent_kv::kv_delete_core(&state.app, &req.connection_id, &req.key).await.map_err(AppError)?;
+ Ok(Json(result))
+}
diff --git a/crates/dbx-web/src/routes/mod.rs b/crates/dbx-web/src/routes/mod.rs
index 365ea8792..b1db531cd 100644
--- a/crates/dbx-web/src/routes/mod.rs
+++ b/crates/dbx-web/src/routes/mod.rs
@@ -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;
diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs
index e33d5ff36..3939ba760 100644
--- a/src-tauri/src/commands/connection.rs
+++ b/src-tauri/src/commands/connection.rs
@@ -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(),
diff --git a/src-tauri/src/commands/etcd_cmd.rs b/src-tauri/src/commands/etcd_cmd.rs
new file mode 100644
index 000000000..2f46ada45
--- /dev/null
+++ b/src-tauri/src/commands/etcd_cmd.rs
@@ -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>,
+ connection_id: String,
+ prefix: String,
+ limit: usize,
+ continuation: Option,
+) -> Result {
+ 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>,
+ connection_id: String,
+ key: String,
+) -> Result {
+ dbx_core::agent_kv::kv_get_core(&state, &connection_id, &key).await
+}
+
+#[tauri::command]
+pub async fn etcd_put(
+ state: State<'_, Arc>,
+ connection_id: String,
+ key: String,
+ value: KvValue,
+ lease: Option,
+) -> Result {
+ dbx_core::agent_kv::kv_put_core(&state, &connection_id, &key, value, lease).await
+}
+
+#[tauri::command]
+pub async fn etcd_delete(
+ state: State<'_, Arc>,
+ connection_id: String,
+ key: String,
+) -> Result {
+ dbx_core::agent_kv::kv_delete_core(&state, &connection_id, &key).await
+}
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index d09100da0..3b42a09fa 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -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;
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index a32a1a0c9..9c8f0f6a1 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -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,