feat(zookeeper): add agent client browsing and node management
This commit is contained in:
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 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>
|
||||
Reference in new issue
Block a user