feat(zookeeper): add agent client browsing and node management

This commit is contained in:
miracle authored and GitHub committed 2026-06-25 00:40:48 +08:00
1 parent a0df43c391
commit 0effda91b3
65 files changed
+2037 -420

No files matched your search

@@ -26,6 +26,7 @@ import { applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnecti
import type { ConnectionDeepLinkDraft } from "@/lib/connectionDeepLink";
import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connectionPresentation";
import { h2ConnectionModeForConfig, h2FileJdbcUrl, h2FilePathFromJdbcUrl, type H2ConnectionMode } from "@/lib/h2Connection";
import { firstZooKeeperEndpoint, normalizeZooKeeperConnectString } from "@/lib/zookeeperConnection";
import { isLocalFileTypeDb } from "@/lib/connectionFile";
import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedVersion } from "@/lib/mqPinnedVersionOptions";
import { mongodbAuthFailureHint, mongoUrlParam, setMongoUrlParam } from "@/lib/mongoConnectionOptions";
@@ -523,6 +524,7 @@ const driverProfiles: Record<
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" },
zookeeper: { type: "zookeeper", port: 2181, user: "", label: "Apache ZooKeeper", icon: "zookeeper" },
mq: { type: "mq", port: 8080, user: "", label: "Apache Pulsar", icon: "pulsar", host: "127.0.0.1" },
nacos: { type: "nacos", port: 8848, user: "nacos", label: "Nacos", icon: "nacos", host: "127.0.0.1" },
iris: { type: "iris", port: 1972, user: "_SYSTEM", label: "IRIS", icon: "iris" },
@@ -799,6 +801,14 @@ function applyProfile(val: string, preserveConnectionFields = false) {
form.value.database = undefined;
form.value.connection_string = undefined;
}
if (profile.type === "zookeeper") {
form.value.database = undefined;
form.value.connection_string = "";
form.value.ssl = false;
form.value.ca_cert_path = "";
form.value.client_cert_path = "";
form.value.client_key_path = "";
}
if (profile.type === "nacos") {
resetNacosFields();
form.value.database = undefined;
@@ -1042,6 +1052,7 @@ const iconTypeMap: Record<string, string> = {
xugu: "xugu",
iotdb: "iotdb",
etcd: "etcd",
zookeeper: "zookeeper",
mq: "mq",
nacos: "nacos",
dm: "dm",
@@ -1126,6 +1137,7 @@ const dbOptions: DbOption[] = [
{ value: "xugu", label: "虚谷 XuguDB" },
{ value: "iotdb", label: "Apache IoTDB" },
{ value: "etcd", label: "etcd" },
{ value: "zookeeper", label: "Apache ZooKeeper" },
{ value: "mq", label: "Apache Pulsar" },
{ value: "nacos", label: "Nacos" },
{ value: "influxdb", label: "InfluxDB" },
@@ -1251,6 +1263,12 @@ const etcdEndpointsLines = computed({
form.value.etcd_endpoints = normalizeEndpointLines(value);
},
});
const zookeeperConnectString = computed({
get: () => form.value.connection_string || "",
set: (value: string) => {
form.value.connection_string = normalizeZooKeeperConnectString(value);
},
});
const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access" && !isH2FileMode.value);
const shouldShowAgentDriverInstallHint = computed(() => showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile));
const h2DriverMissing = computed(() => form.value.db_type === "h2" && isH2FileMode.value && agentDrivers.value.find((d) => d.db_type === "h2")?.installed !== true);
@@ -1284,6 +1302,7 @@ const testResultMessage = computed(() => {
});
const hasRequiredConnectionTarget = computed(() => {
if (form.value.db_type === "mq") return !!mqAdminUrl.value.trim();
if (form.value.db_type === "zookeeper") return !!(form.value.host || form.value.connection_string || connectionUrlInput.value.trim());
if (form.value.db_type === "nacos") return !!nacosServerAddr.value.trim();
if (isH2FileMode.value) return !!(form.value.host.trim() || h2FilePathFromJdbcUrl(form.value.connection_string));
return !!(form.value.host || (mongoUseUrl.value && form.value.connection_string) || (form.value.db_type === "jdbc" && form.value.connection_string) || connectionUrlInput.value.trim());
@@ -1500,6 +1519,17 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
if (config.db_type === "redis") {
config.redis_key_separator = config.redis_key_separator?.trim() ?? ":";
}
if (config.db_type === "zookeeper") {
const normalizedConnectString = normalizeZooKeeperConnectString(config.connection_string || "");
config.connection_string = normalizedConnectString || undefined;
const firstEndpoint = firstZooKeeperEndpoint(normalizedConnectString || (config.host ? `${config.host}:${config.port || 2181}` : ""));
if (firstEndpoint) {
config.host = firstEndpoint.host;
config.port = firstEndpoint.port;
}
config.database = undefined;
config.ssl = false;
}
if (config.db_type === "etcd") {
config.etcd_endpoints = normalizeEndpointLines(config.etcd_endpoints || "");
const firstEndpoint = firstEtcdEndpoint(config.etcd_endpoints);
@@ -3083,6 +3113,37 @@ function openExternalUrl(url: string) {
</div>
</template>
<!-- ZooKeeper: host, connect string, user, password -->
<template v-else-if="form.db_type === 'zookeeper'">
<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" placeholder="127.0.0.1" />
<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.zookeeperConnectString") }}</Label>
<div class="col-span-3 space-y-1">
<textarea
v-model="zookeeperConnectString"
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="127.0.0.1:2181&#10;zk-2:2181"
spellcheck="false"
/>
<p class="text-xs text-muted-foreground">
{{ t("connection.zookeeperConnectStringHint") }}
</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>
<PasswordInput v-model="form.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">
@@ -114,7 +114,11 @@ const showModified = ref(true);
let syncPlanRequestId = 0;
<<<<<<< HEAD
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "etcd", "zookeeper", "mq", "nacos"].includes(connection.db_type)));
=======
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "etcd", "mq", "nacos"].includes(connection.db_type)));
>>>>>>> origin/main
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
const filteredSourceTables = computed(() => {
@@ -1,297 +1,52 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, ref } 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 KvKeyBrowser from "@/components/kv/KvKeyBrowser.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 browserRef = ref<InstanceType<typeof KvKeyBrowser> | null>(null);
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");
const etcdApi = {
listPrefix: api.etcdListPrefix,
get: api.etcdGet,
put: (connectionId: string, key: string, value: api.KvValue) => api.etcdPut(connectionId, key, value),
deleteKey: api.etcdDelete,
};
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);
}
const labels = computed(() => ({
prefixPlaceholder: t("etcd.prefixPlaceholder"),
newKey: t("etcd.newKey"),
loadingKeys: t("etcd.loadingKeys"),
empty: t("etcd.empty"),
loadMore: t("etcd.loadMore"),
selectKey: t("etcd.selectKey"),
loadingValue: t("etcd.loadingValue"),
notFound: t("etcd.notFound"),
edit: t("etcd.edit"),
editKey: t("etcd.editKey"),
delete: t("etcd.delete"),
deleteTitle: t("etcd.deleteTitle"),
keyPlaceholder: t("etcd.keyPlaceholder"),
keyRequired: t("etcd.keyRequired"),
saved: t("etcd.saved"),
deleted: t("etcd.deleted"),
base64Readonly: t("etcd.base64Readonly"),
}));
function focusSearch(): boolean {
searchInputRef.value?.focus();
return true;
return browserRef.value?.focusSearch() ?? false;
}
watch(
() => props.connectionId,
() => void loadKeys(true),
);
onMounted(() => void loadKeys(true));
defineExpose({ focusSearch });
function refresh(): boolean {
return browserRef.value?.refresh() ?? false;
}
defineExpose({ focusSearch, refresh });
</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>
<KvKeyBrowser ref="browserRef" :connection-id="props.connectionId" :api="etcdApi" :labels="labels" />
</template>
@@ -65,7 +65,11 @@ const exportCancelled = ref(false);
const pendingPrefillTable = ref("");
const pendingPrefillTables = ref<string[]>([]);
<<<<<<< HEAD
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type)));
=======
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "etcd", "mq", "nacos"].includes(c.db_type)));
>>>>>>> origin/main
const canExport = computed(() => connectionId.value && database.value && schema.value && !loadingTables.value && !tableError.value && (tables.value.length === 0 || selectedTables.value.length > 0) && (includeStructure.value || includeData.value || includeObjects.value) && !isExporting.value);
@@ -85,6 +85,7 @@ const assetIcons: Record<string, string> = {
nacos: "nacos.png",
iris: "iris.png",
influxdb: "influxdb",
zookeeper: "zookeeper",
};
const letterIcons: Record<string, { letter: string; color: string }> = {};
@@ -0,0 +1,536 @@
<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 { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import type { KvCreateMode, KvGetResponse, KvKeySummary, KvPutOptions, KvPutResponse, KvValue } from "@/lib/api";
import { buildKvKeyTree, flattenVisibleKvKeyTree, preserveKvExpandedGroupIds, type KvKeyTreeNode } from "@/lib/kvKeyTree";
import { refreshedKvSelectionKey } from "@/lib/kvRefreshSelection";
import { formatZooKeeperMetadataRows, formatZooKeeperSummaryBadges, prettyPrintJsonText } from "@/lib/kvValueDisplay";
import { useToast } from "@/composables/useToast";
interface KvKeyBrowserLabels {
prefixPlaceholder: string;
newKey: string;
loadingKeys: string;
empty: string;
loadMore: string;
selectKey: string;
loadingValue: string;
notFound: string;
edit: string;
editKey: string;
delete: string;
deleteTitle: string;
keyPlaceholder: string;
keyRequired: string;
saved: string;
deleted: string;
base64Readonly: string;
createMode?: string;
add?: string;
value?: string;
metadata?: string;
prettyJson?: string;
invalidJson?: string;
summaryRevision?: string;
summaryVersion?: string;
summaryLease?: string;
summarySize?: string;
}
interface KvCreateModeOption {
value: KvCreateMode;
label: string;
}
interface KvKeyBrowserApi {
listPrefix: (connectionId: string, prefix: string, limit: number, continuation?: string | null) => Promise<{ keys: KvKeySummary[]; continuation?: string | null }>;
get: (connectionId: string, key: string) => Promise<KvGetResponse>;
put: (connectionId: string, key: string, value: KvValue, options?: KvPutOptions | null) => Promise<KvPutResponse>;
deleteKey: (connectionId: string, key: string) => Promise<{ deleted: number }>;
}
const props = withDefaults(
defineProps<{
connectionId: string;
labels: KvKeyBrowserLabels;
api: KvKeyBrowserApi;
supportsCreateModes?: boolean;
createModeOptions?: KvCreateModeOption[];
enableNodeActions?: boolean;
metadataStyle?: "default" | "zookeeper";
}>(),
{
supportsCreateModes: false,
createModeOptions: () => [],
enableNodeActions: false,
metadataStyle: "default",
},
);
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 isCreating = ref(false);
const editKey = ref("");
const editValue = ref("");
const editError = ref("");
const saving = ref(false);
const showDeleteConfirm = ref(false);
const deleting = ref(false);
const selectedCreateMode = ref<KvCreateMode>("persistent");
const selectedPrettyValue = ref<string | null>(null);
const pageSize = 200;
type LoadKeysOptions = {
preserveSelection?: boolean;
};
const tree = computed(() => buildKvKeyTree(keys.value));
const visibleRows = computed(() => flattenVisibleKvKeyTree(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 displayedSelectedTextValue = computed(() => selectedPrettyValue.value ?? selectedTextValue.value);
const selectedValueIsBase64 = computed(() => selectedValue.value?.value?.encoding === "base64");
const showCreateModeSelect = computed(() => props.supportsCreateModes && isCreating.value && props.createModeOptions.length > 0);
const zookeeperSummaryBadges = computed(() =>
formatZooKeeperSummaryBadges(selectedMetadata.value, {
revision: props.labels.summaryRevision,
version: props.labels.summaryVersion,
lease: props.labels.summaryLease,
size: props.labels.summarySize,
}),
);
const zookeeperMetadataRows = computed(() => formatZooKeeperMetadataRows(selectedMetadata.value));
const selectedValueCanPrettyJson = computed(() => selectedValue.value?.value?.encoding === "utf8" && prettyPrintJsonText(selectedTextValue.value).ok);
const editValueCanPrettyJson = computed(() => prettyPrintJsonText(editValue.value).ok);
function preserveExpandedGroups(expandAll = false) {
expandedGroupIds.value = preserveKvExpandedGroupIds(tree.value, expandedGroupIds.value, expandAll);
}
async function loadKeys(reset = true, options: LoadKeysOptions = {}) {
const keyToRestore = options.preserveSelection ? selectedKey.value : null;
if (reset) {
loading.value = true;
continuation.value = null;
keys.value = [];
if (!options.preserveSelection) {
selectedKey.value = null;
selectedValue.value = null;
}
} else {
loadingMore.value = true;
}
try {
const result = await props.api.listPrefix(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());
if (reset && options.preserveSelection) {
const restoredKey = refreshedKvSelectionKey(keyToRestore, merged);
if (restoredKey) {
await loadSelectedKey(restoredKey);
} else {
selectedKey.value = null;
selectedValue.value = null;
}
}
} finally {
loading.value = false;
loadingMore.value = false;
}
}
async function loadSelectedKey(key: string) {
selectedKey.value = key;
selectedPrettyValue.value = null;
detailLoading.value = true;
detailError.value = "";
try {
selectedValue.value = await props.api.get(props.connectionId, key);
} catch (error) {
detailError.value = error instanceof Error ? error.message : String(error);
} finally {
detailLoading.value = false;
}
}
function toggleGroup(node: KvKeyTreeNode) {
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 nodePath(node: KvKeyTreeNode): string {
if (node.kind === "leaf") return node.key;
return `/${node.pathSegments.join("/")}`;
}
function onRowClick(node: KvKeyTreeNode) {
if (node.kind === "group") {
toggleGroup(node);
if (props.enableNodeActions) void loadSelectedKey(nodePath(node));
} else {
void loadSelectedKey(node.key);
}
}
function onRowDoubleClick(node: KvKeyTreeNode) {
if (node.kind === "leaf") {
void loadSelectedKey(node.key).then(() => openEditDialog());
}
}
function createKeyPrefix(parentPath?: string): string {
const path = parentPath ?? prefix.value.trim();
if (!path) return "";
if (path === "/") return "/";
return path.endsWith("/") ? path : `${path}/`;
}
function openCreateDialog(parentPath?: string) {
isCreating.value = true;
editKey.value = createKeyPrefix(parentPath);
editValue.value = "";
editError.value = "";
selectedCreateMode.value = props.createModeOptions[0]?.value || "persistent";
showEditDialog.value = true;
}
function openEditDialog() {
if (!selectedKey.value) return;
isCreating.value = false;
editKey.value = selectedKey.value;
editValue.value = selectedTextValue.value;
editError.value = selectedValueIsBase64.value ? props.labels.base64Readonly : "";
showEditDialog.value = true;
}
function putOptions(): KvPutOptions | undefined {
if (!props.supportsCreateModes) return undefined;
if (isCreating.value) {
return { writeMode: "create", createMode: selectedCreateMode.value };
}
return { writeMode: "update" };
}
async function saveKey() {
const key = editKey.value.trim();
if (!key) {
editError.value = props.labels.keyRequired;
return;
}
saving.value = true;
editError.value = "";
try {
const value: KvValue = { encoding: "utf8", data: editValue.value };
const response = await props.api.put(props.connectionId, key, value, putOptions());
const keyToSelect = response.createdKey || response.key || key;
showEditDialog.value = false;
await loadKeys(true);
await loadSelectedKey(keyToSelect);
toast(props.labels.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 props.api.deleteKey(props.connectionId, selectedKey.value);
showDeleteConfirm.value = false;
selectedKey.value = null;
selectedValue.value = null;
await loadKeys(true);
toast(props.labels.deleted, 2500);
} finally {
deleting.value = false;
}
}
function selectNodeForAction(node: KvKeyTreeNode) {
const key = nodePath(node);
if (selectedKey.value !== key) void loadSelectedKey(key);
else selectedKey.value = key;
}
function openDeleteForNode(node: KvKeyTreeNode) {
selectNodeForAction(node);
showDeleteConfirm.value = true;
}
function nodeContextMenuItems(node: KvKeyTreeNode): ContextMenuItem[] {
if (!props.enableNodeActions) return [];
return [
{
label: props.labels.add || props.labels.newKey,
icon: Plus,
action: () => openCreateDialog(nodePath(node)),
},
{
label: props.labels.delete,
icon: Trash2,
variant: "destructive",
action: () => openDeleteForNode(node),
},
];
}
function onRowContextMenu(event: MouseEvent, node: KvKeyTreeNode, openContextMenu: (event: MouseEvent) => void) {
if (!props.enableNodeActions) return;
selectNodeForAction(node);
openContextMenu(event);
}
function rowIsSelected(node: KvKeyTreeNode): boolean {
return nodePath(node) === selectedKey.value;
}
function prettifySelectedJson() {
const result = prettyPrintJsonText(selectedTextValue.value);
if (result.ok && result.value != null) {
selectedPrettyValue.value = result.value;
} else {
toast(props.labels.invalidJson || "Invalid JSON", 2500);
}
}
function prettifyEditJson() {
const result = prettyPrintJsonText(editValue.value);
if (result.ok && result.value != null) {
editValue.value = result.value;
editError.value = "";
} else {
editError.value = props.labels.invalidJson || "Invalid JSON";
}
}
function metadataLabel(value: number | null | undefined): string {
return value == null ? "-" : String(value);
}
function focusSearch(): boolean {
searchInputRef.value?.focus();
return true;
}
function refresh(): boolean {
void loadKeys(true, { preserveSelection: true });
return true;
}
watch(
() => props.connectionId,
() => void loadKeys(true),
);
watch(
() => props.createModeOptions,
(options) => {
if (!options.some((option) => option.value === selectedCreateMode.value)) {
selectedCreateMode.value = options[0]?.value || "persistent";
}
},
{ immediate: true },
);
onMounted(() => void loadKeys(true));
defineExpose({ focusSearch, refresh });
</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="labels.prefixPlaceholder" @keyup.enter="loadKeys(true)" />
</div>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loading" @click="loadKeys(true, { preserveSelection: 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" />
{{ labels.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" />
{{ labels.loadingKeys }}
</div>
<div v-else-if="visibleRows.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">
{{ labels.empty }}
</div>
<div v-else class="h-full overflow-auto py-1 text-sm">
<CustomContextMenu v-for="row in visibleRows" :key="row.node.id" :items="nodeContextMenuItems(row.node)" v-slot="{ onContextMenu }">
<button
type="button"
class="flex h-8 w-full items-center gap-1.5 px-2 text-left hover:bg-accent"
:class="{ 'bg-accent/70': rowIsSelected(row.node) }"
:style="{ paddingLeft: `${8 + row.depth * 18}px` }"
@click="onRowClick(row.node)"
@dblclick.stop.prevent="onRowDoubleClick(row.node)"
@contextmenu="(event) => onRowContextMenu(event, row.node, onContextMenu)"
>
<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>
</CustomContextMenu>
<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" />
{{ labels.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">
{{ labels.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" :class="{ 'text-blue-600 dark:text-blue-400': metadataStyle === 'zookeeper' }">{{ selectedKey }}</div>
<div class="mt-1 flex flex-wrap gap-1.5 text-xs text-muted-foreground">
<template v-if="metadataStyle === 'zookeeper'">
<Badge v-for="badge in zookeeperSummaryBadges" :key="badge.label" variant="outline" class="rounded-full">
{{ `${badge.label} ${badge.value}` }}
</Badge>
</template>
<template v-else>
<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>
</template>
</div>
</div>
<div class="flex shrink-0 gap-2">
<Button size="sm" variant="outline" class="h-8" :disabled="selectedValueIsBase64" @click="openEditDialog">
{{ labels.edit }}
</Button>
<Button size="sm" variant="destructive" class="h-8 gap-1.5" @click="showDeleteConfirm = true">
<Trash2 class="h-3.5 w-3.5" />
{{ labels.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" />
{{ labels.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">
{{ labels.notFound }}
</div>
<div v-else class="flex min-h-0 flex-1 flex-col gap-4 p-4">
<div class="min-h-0">
<div v-if="metadataStyle === 'zookeeper'" class="mb-2 text-xs font-medium text-muted-foreground">{{ labels.value || "Value" }}</div>
<pre class="dbx-editor-font-family m-0 max-h-[40vh] min-h-32 overflow-auto rounded-md border bg-muted/20 whitespace-pre-wrap break-words p-3 text-sm">{{ displayedSelectedTextValue }}</pre>
<div v-if="selectedValueCanPrettyJson" class="mt-2 flex justify-end">
<Button size="sm" variant="outline" class="h-8" @click="prettifySelectedJson">
{{ labels.prettyJson || "Pretty" }}
</Button>
</div>
</div>
<div v-if="metadataStyle === 'zookeeper'" class="grid gap-3 border-t pt-4">
<div class="text-xs font-medium text-muted-foreground">{{ labels.metadata || "Metadata" }}</div>
<div class="grid gap-x-10 gap-y-4 sm:grid-cols-2">
<div v-for="row in zookeeperMetadataRows" :key="row.label" class="grid grid-cols-[minmax(96px,auto)_1fr] items-baseline gap-5 text-sm">
<div class="text-foreground">{{ row.label }}</div>
<div class="dbx-editor-font-family min-w-0 break-all text-blue-600 dark:text-blue-400">{{ row.value }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<Dialog v-model:open="showEditDialog">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{{ isCreating ? labels.newKey : labels.editKey }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3 py-2">
<Input v-model="editKey" :placeholder="labels.keyPlaceholder" />
<div v-if="showCreateModeSelect" class="grid grid-cols-4 items-center gap-3">
<Label class="text-right text-xs">{{ labels.createMode || "Create Mode" }}</Label>
<Select v-model="selectedCreateMode">
<SelectTrigger class="col-span-3 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in createModeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<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="editValueCanPrettyJson" class="flex justify-end">
<Button size="sm" variant="outline" class="h-8" @click="prettifyEditJson">
{{ labels.prettyJson || "Pretty" }}
</Button>
</div>
<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="labels.deleteTitle" :details="selectedKey || ''" :confirm-label="labels.delete" @confirm="deleteSelectedKey" />
</div>
</template>
@@ -235,7 +235,7 @@ const openTabMenuItems = computed(() =>
function tabMenuIcon(tab: QueryTab) {
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis") return Table2;
if (tab.mode === "vector") return TableProperties;
if (tab.mode === "etcd") return KeyRound;
if (tab.mode === "etcd" || tab.mode === "zookeeper") return KeyRound;
if (tab.mode === "nacos") return Network;
if (tab.mode === "objects") return TableProperties;
if (tab.mode === "structure") return PencilRuler;
@@ -329,7 +329,7 @@ function activateTab(tabId: string) {
<span class="shrink-0" :class="tabIconClass(tab)">
<Table2 v-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis'" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
<KeyRound v-else-if="tab.mode === 'etcd'" class="h-3.5 w-3.5" />
<KeyRound v-else-if="tab.mode === 'etcd' || tab.mode === 'zookeeper'" class="h-3.5 w-3.5" />
<Network v-else-if="tab.mode === 'nacos'" 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" />
@@ -37,6 +37,7 @@ const DataGrid = defineAsyncComponent(loadDataGridComponent);
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/RedisDashboard.vue"));
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
const ZooKeeperKeyBrowser = defineAsyncComponent(() => import("@/components/zookeeper/ZooKeeperKeyBrowser.vue"));
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
@@ -94,6 +95,7 @@ type DataGridHandle = {
type SearchableBrowserHandle = {
focusSearch: () => boolean;
refresh?: () => boolean;
};
const props = defineProps<{
@@ -145,6 +147,7 @@ onMounted(() => {
} else {
setTimeout(preload, 300);
}
window.addEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser);
});
watch(
@@ -167,6 +170,7 @@ const columnVisibilitySearch = ref("");
const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []);
const redisKeyBrowserRef = ref<SearchableBrowserHandle>();
const etcdKeyBrowserRef = ref<SearchableBrowserHandle>();
const zookeeperKeyBrowserRef = ref<SearchableBrowserHandle>();
const objectBrowserRef = ref<SearchableBrowserHandle>();
const activeTableMeta = computed(() => props.activeTab.tableMeta);
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
@@ -298,7 +302,10 @@ const queryRunningElapsedSeconds = computed(() => (queryRunningElapsed.value / 1
watch(() => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.queryExecutionStartedAt] as const, startQueryRunningElapsedTimer, { immediate: true });
onUnmounted(stopQueryRunningElapsedTimer);
onUnmounted(() => {
stopQueryRunningElapsedTimer();
window.removeEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser);
});
watch(
hasQueryOutput,
@@ -474,17 +481,26 @@ 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 === "zookeeper") return zookeeperKeyBrowserRef.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;
}
function refreshData(): boolean {
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.refresh?.() ?? false;
if (!dataGridRef.value) return false;
void dataGridRef.value.onToolbarRefresh();
return true;
}
function onRefreshActiveKvBrowser(event: Event) {
const detail = (event as CustomEvent<{ mode?: string; connectionId?: string }>).detail;
if (!detail || props.activeTab.mode !== detail.mode || props.activeTab.connectionId !== detail.connectionId) return;
void nextTick(() => refreshData());
}
async function exportResultArchive() {
if (resultArchiveExporting.value) return;
resultArchiveExporting.value = true;
@@ -1034,6 +1050,13 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</div>
</template>
<!-- ZooKeeper mode: znode browser -->
<template v-else-if="activeTab.mode === 'zookeeper'">
<div class="flex-1 min-h-0">
<ZooKeeperKeyBrowser ref="zookeeperKeyBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
</div>
</template>
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
<template v-else-if="activeTab.mode === 'mongo'">
<div class="flex-1 min-h-0">
@@ -61,7 +61,11 @@ const activeConnectionValue = computed(() => props.activeConnection?.id || "");
const activeSchemaValue = computed(() => props.activeTab.schema || "");
const supportsExplain = computed(() => {
const dbType = props.activeConnection?.db_type;
<<<<<<< HEAD
return dbType !== "redis" && dbType !== "mongodb" && dbType !== "elasticsearch" && dbType !== "qdrant" && dbType !== "milvus" && dbType !== "etcd" && dbType !== "zookeeper" && dbType !== "mq" && dbType !== "nacos";
=======
return dbType !== "redis" && dbType !== "mongodb" && dbType !== "elasticsearch" && dbType !== "qdrant" && dbType !== "milvus" && dbType !== "weaviate" && dbType !== "etcd" && dbType !== "mq" && dbType !== "nacos";
>>>>>>> origin/main
});
const isSingleDb = computed(() => isSingleDatabase(props.activeConnection?.db_type));
const hasDefaultDatabaseOption = computed(() => activeDatabaseOptions.value.includes(""));
@@ -251,7 +255,11 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
</template>
</SearchableSelect>
</div>
<<<<<<< HEAD
<div v-if="activeConnection?.db_type !== 'elasticsearch' && activeConnection?.db_type !== 'qdrant' && activeConnection?.db_type !== 'milvus' && activeConnection?.db_type !== 'zookeeper' && !isSingleDb" class="flex items-center gap-1">
=======
<div v-if="activeConnection?.db_type !== 'elasticsearch' && activeConnection?.db_type !== 'qdrant' && activeConnection?.db_type !== 'milvus' && activeConnection?.db_type !== 'weaviate' && !isSingleDb" class="flex items-center gap-1">
>>>>>>> origin/main
<Database class="h-3.5 w-3.5 shrink-0" />
<SearchableSelect
:model-value="activeDatabaseValue"
@@ -378,7 +378,7 @@ function resolveLoadedLocateTarget(target: ActiveTabSidebarTarget, candidate: Qu
}
async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: { force?: boolean }) {
if (target.type === "saved-sql-file" || target.type === "etcd-root") return;
if (target.type === "saved-sql-file" || target.type === "etcd-root" || target.type === "zookeeper-root") return;
const connId = target.connectionId;
if (!connId) return;
@@ -254,6 +254,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: FolderOpen, colorClass: "text-sky-500" };
case "etcd-root":
return { icon: Database, colorClass: "text-sky-500" };
case "zookeeper-root":
return { icon: Database, colorClass: "text-blue-500" };
case "mongo-db":
return { icon: Database, colorClass: "text-yellow-500" };
case "mongo-collection":
@@ -471,6 +473,8 @@ async function toggle() {
await connectionStore.loadRedisDatabases(node.connectionId);
} else if (config?.db_type === "etcd") {
await connectionStore.loadEtcdRoot(node.connectionId);
} else if (config?.db_type === "zookeeper") {
await connectionStore.loadZooKeeperRoot(node.connectionId);
} else if (config?.db_type === "mongodb") {
await connectionStore.loadMongoDatabases(node.connectionId);
} else if (config?.db_type === "elasticsearch") {
@@ -494,6 +498,11 @@ async function toggle() {
} else if (node.type === "etcd-root" && node.connectionId) {
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:keys`;
queryStore.createTab(node.connectionId, "", tabTitle, "etcd");
refreshActiveKvBrowserAfterOpen("etcd", node.connectionId);
} else if (node.type === "zookeeper-root" && node.connectionId) {
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "ZooKeeper"}:keys`;
queryStore.createTab(node.connectionId, "", tabTitle, "zookeeper");
refreshActiveKvBrowserAfterOpen("zookeeper", node.connectionId);
} else if (node.type === "user-admin" && node.connectionId) {
queryStore.openUserAdmin(node.connectionId);
} else if (node.type === "mongo-db" && node.connectionId && node.database) {
@@ -572,6 +581,12 @@ function runRowClickAction() {
}
}
function refreshActiveKvBrowserAfterOpen(mode: "etcd" | "zookeeper", connectionId: string) {
void nextTick(() => {
window.dispatchEvent(new CustomEvent("dbx-refresh-active-kv-browser", { detail: { mode, connectionId } }));
});
}
async function loadMoreObjectGroupChildren() {
try {
await connectionStore.loadMoreObjectGroupChildren(props.node);
@@ -3632,7 +3647,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
}
// 5. Redis DB / Mongo DB
if (node.type === "etcd-root") {
if (node.type === "etcd-root" || node.type === "zookeeper-root") {
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Database });
return items;
}
@@ -49,7 +49,11 @@ const terminalStatus = ref<SqlFileStatus | "idle">("idle");
const terminalError = ref("");
const refreshedTarget = ref(false);
<<<<<<< HEAD
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type)));
=======
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "etcd", "mq", "nacos"].includes(c.db_type)));
>>>>>>> origin/main
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import KvKeyBrowser from "@/components/kv/KvKeyBrowser.vue";
import * as api from "@/lib/api";
import type { KvCreateMode } from "@/lib/api";
const props = defineProps<{ connectionId: string }>();
const { t } = useI18n();
const browserRef = ref<InstanceType<typeof KvKeyBrowser> | null>(null);
const zookeeperApi = {
listPrefix: api.zookeeperListPrefix,
get: api.zookeeperGet,
put: api.zookeeperPut,
deleteKey: api.zookeeperDelete,
};
const createModeOptions = computed<{ value: KvCreateMode; label: string }[]>(() => [
{ value: "persistent", label: t("zookeeper.createModePersistent") },
{ value: "ephemeral", label: t("zookeeper.createModeEphemeral") },
{ value: "persistent_sequential", label: t("zookeeper.createModePersistentSequential") },
{ value: "ephemeral_sequential", label: t("zookeeper.createModeEphemeralSequential") },
]);
const labels = computed(() => ({
prefixPlaceholder: t("zookeeper.prefixPlaceholder"),
newKey: t("zookeeper.newKey"),
loadingKeys: t("zookeeper.loadingKeys"),
empty: t("zookeeper.empty"),
loadMore: t("zookeeper.loadMore"),
selectKey: t("zookeeper.selectKey"),
loadingValue: t("zookeeper.loadingValue"),
notFound: t("zookeeper.notFound"),
edit: t("zookeeper.edit"),
editKey: t("zookeeper.editKey"),
delete: t("zookeeper.delete"),
deleteTitle: t("zookeeper.deleteTitle"),
keyPlaceholder: t("zookeeper.keyPlaceholder"),
keyRequired: t("zookeeper.keyRequired"),
saved: t("zookeeper.saved"),
deleted: t("zookeeper.deleted"),
base64Readonly: t("zookeeper.base64Readonly"),
createMode: t("zookeeper.createMode"),
add: t("zookeeper.add"),
value: t("zookeeper.value"),
metadata: t("zookeeper.metadata"),
prettyJson: t("zookeeper.prettyJson"),
invalidJson: t("zookeeper.invalidJson"),
summaryRevision: t("zookeeper.summaryRevision"),
summaryVersion: t("zookeeper.summaryVersion"),
summaryLease: t("zookeeper.summaryLease"),
summarySize: t("zookeeper.summarySize"),
}));
function focusSearch(): boolean {
return browserRef.value?.focusSearch() ?? false;
}
function refresh(): boolean {
return browserRef.value?.refresh() ?? false;
}
defineExpose({ focusSearch, refresh });
</script>
<template>
<KvKeyBrowser ref="browserRef" :connection-id="props.connectionId" :api="zookeeperApi" :labels="labels" supports-create-modes enable-node-actions metadata-style="zookeeper" :create-mode-options="createModeOptions" />
</template>