feat(nacos): add Nacos management console (#1456)

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-06-24 01:19:18 +08:00
1 parent ff1559f018
commit 3ebfae05a2
62 files changed
+7314 -55

No files matched your search

@@ -15,6 +15,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Switch } from "@/components/ui/switch";
import type { ConnectionConfig, DatabaseType, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
import type { NacosAdminConfig, NacosAuthConfig } from "@/types/nacos";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
@@ -42,12 +43,17 @@ type DialogStep = "select" | "config";
type DbPickerView = "icon" | "list";
type ConfigTab = "connection" | "advanced" | "tls" | "transport";
type MqTokenSigningMode = "none" | "hs256" | "rs256";
type NacosAuthKind = NacosAuthConfig["kind"];
type JdbcDriverSelectItem = {
id: string;
label: string;
paths: string[];
};
const NACOS_DEFAULT_CONSOLE_URL = "http://127.0.0.1:8085";
const NACOS_LEGACY_SERVER_PORT = "8848";
const NACOS_DOCKER_CONSOLE_PORT = "8085";
type LegacyTransportFields = {
ssh_enabled?: boolean;
ssh_host?: string;
@@ -307,6 +313,14 @@ const mqTlsSkipVerify = ref(false);
const mqPinnedVersion = ref(pinnedVersionToSelection(undefined));
const mqTokenSigningMode = ref<MqTokenSigningMode>("none");
const mqTokenSigningKey = ref("");
const nacosServerAddr = ref(NACOS_DEFAULT_CONSOLE_URL);
const nacosNamespace = ref("");
const nacosContextPath = ref("");
const nacosAuthKind = ref<NacosAuthKind>("none");
const nacosUsername = ref("nacos");
const nacosPassword = ref("");
const nacosTlsSkipVerify = ref(false);
const nacosPageSize = ref(20);
const colorOptions = [
{ value: "", class: "bg-transparent border-dashed", labelKey: "connection.colorNone" },
@@ -509,6 +523,7 @@ const driverProfiles: Record<
iotdb: { type: "iotdb", port: 6667, user: "root", label: "Apache IoTDB", icon: "iotdb" },
etcd: { type: "etcd", port: 2379, user: "", label: "etcd", icon: "etcd" },
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" },
influxdb: { type: "influxdb", port: 8086, user: "", label: "InfluxDB", icon: "InfluxDB" },
custom_mysql: {
@@ -574,6 +589,26 @@ function hydrateMqFields(value: unknown) {
resetMqFields(value as Partial<MqAdminConfig>);
}
function resetNacosFields(config?: Partial<NacosAdminConfig>) {
nacosServerAddr.value = config?.serverAddr?.trim() || NACOS_DEFAULT_CONSOLE_URL;
nacosNamespace.value = config?.namespace || "";
nacosContextPath.value = config?.contextPath || "";
nacosTlsSkipVerify.value = !!config?.tlsSkipVerify;
nacosPageSize.value = Number(config?.pageSize) > 0 ? Number(config?.pageSize) : 20;
const auth = (config?.auth || { kind: "none" }) as NacosAuthConfig;
nacosAuthKind.value = auth.kind || "none";
nacosUsername.value = auth.username || "nacos";
nacosPassword.value = auth.password || "";
}
function hydrateNacosFields(value: unknown) {
if (!value || typeof value !== "object") {
resetNacosFields();
return;
}
resetNacosFields(value as Partial<NacosAdminConfig>);
}
function requireMqField(value: string, message: string): string {
const trimmed = value.trim();
if (!trimmed) throw new Error(message);
@@ -629,6 +664,65 @@ function buildMqAdminConfig(): MqAdminConfig {
};
}
function buildNacosAuth(): NacosAuthConfig {
if (nacosAuthKind.value === "usernamePassword") {
return {
kind: "usernamePassword",
username: requireMqField(nacosUsername.value, t("connection.nacosUsernameRequired")),
password: nacosPassword.value,
};
}
return { kind: "none" };
}
function buildNacosAdminConfig(): NacosAdminConfig {
return {
serverAddr: requireMqField(nacosServerAddr.value, t("connection.nacosConsoleUrlRequired")),
namespace: nacosNamespace.value.trim() || undefined,
contextPath: nacosContextPath.value.trim(),
auth: buildNacosAuth(),
tlsSkipVerify: nacosTlsSkipVerify.value || undefined,
pageSize: Number(nacosPageSize.value) > 0 ? Number(nacosPageSize.value) : 20,
};
}
function dockerNacosConsoleFallbackUrl(serverAddr: string): string | null {
let parsed: URL;
try {
parsed = new URL(serverAddr);
} catch {
return null;
}
const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
const host = parsed.hostname.toLowerCase();
if (port !== NACOS_LEGACY_SERVER_PORT || !["127.0.0.1", "localhost", "::1"].includes(host)) {
return null;
}
parsed.port = NACOS_DOCKER_CONSOLE_PORT;
return parsed.toString().replace(/\/$/, "");
}
function isNacosAdminEndpointNotFound(message: string): boolean {
return /Nacos admin endpoint was not found/i.test(message);
}
async function tryNacosDockerConsoleFallback(config: ConnectionConfig, originalError: string): Promise<string | null> {
if (config.db_type !== "nacos" || !isNacosAdminEndpointNotFound(originalError)) return null;
const fallbackUrl = dockerNacosConsoleFallbackUrl(nacosServerAddr.value);
if (!fallbackUrl || fallbackUrl === nacosServerAddr.value.trim()) return null;
const previousUrl = nacosServerAddr.value;
nacosServerAddr.value = fallbackUrl;
try {
const fallbackConfig = connectionConfigForSubmit(config.id);
const message = await api.testConnection(fallbackConfig);
return `${message} ${t("connection.nacosConsoleUrlAutoAdjusted", { from: previousUrl.trim(), to: fallbackUrl })}`;
} catch {
nacosServerAddr.value = previousUrl;
return null;
}
}
function applyMqAdminUrl(config: LegacyConnectionConfig, adminUrl: string) {
let parsed: URL;
try {
@@ -642,6 +736,19 @@ function applyMqAdminUrl(config: LegacyConnectionConfig, adminUrl: string) {
config.ssl = parsed.protocol === "https:";
}
function applyNacosServerAddr(config: LegacyConnectionConfig, serverAddr: string) {
let parsed: URL;
try {
parsed = new URL(serverAddr);
} catch {
throw new Error("Nacos server address is invalid");
}
const port = Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 8848);
config.host = parsed.hostname;
config.port = port;
config.ssl = parsed.protocol === "https:";
}
function isCustomCompatibleProfile() {
return selectedType.value === "custom_mysql" || selectedType.value === "custom_postgres";
}
@@ -691,6 +798,12 @@ function applyProfile(val: string, preserveConnectionFields = false) {
form.value.database = undefined;
form.value.connection_string = undefined;
}
if (profile.type === "nacos") {
resetNacosFields();
form.value.database = undefined;
form.value.connection_string = undefined;
form.value.url_params = "";
}
}
}
@@ -763,6 +876,11 @@ watch(
} else {
resetMqFields();
}
if (config.db_type === "nacos") {
hydrateNacosFields(config.external_config);
} else {
resetNacosFields();
}
h2ConnectionMode.value = h2ConnectionModeForConfig(config);
customColorInput.value = config.color || "";
selectedTransportLayerId.value = form.value.transport_layers?.[0]?.id || null;
@@ -786,6 +904,7 @@ watch(
selectedType.value = "mysql";
customDriverName.value = "";
resetMqFields();
resetNacosFields();
oceanbaseSubMode.value = "mysql";
h2ConnectionMode.value = "file";
dialogStep.value = "select";
@@ -922,6 +1041,7 @@ const iconTypeMap: Record<string, string> = {
iotdb: "iotdb",
etcd: "etcd",
mq: "mq",
nacos: "nacos",
dm: "dm",
h2: "h2",
snowflake: "snowflake",
@@ -1004,6 +1124,7 @@ const dbOptions: DbOption[] = [
{ value: "iotdb", label: "Apache IoTDB" },
{ value: "etcd", label: "etcd" },
{ value: "mq", label: "Apache Pulsar" },
{ value: "nacos", label: "Nacos" },
{ value: "influxdb", label: "InfluxDB" },
{ value: "iris", label: "IRIS" },
{ value: "jdbc", label: "JDBC" },
@@ -1160,6 +1281,7 @@ const testResultMessage = computed(() => {
});
const hasRequiredConnectionTarget = computed(() => {
if (form.value.db_type === "mq") return !!mqAdminUrl.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());
});
@@ -1209,8 +1331,8 @@ async function testConnection() {
const runId = ++testRunId;
isTesting.value = true;
testResult.value = null;
const config = connectionConfigForSubmit(editingId.value || uuid());
try {
const config = connectionConfigForSubmit(editingId.value || uuid());
const msg = await api.testConnection(config);
if (runId !== testRunId) return;
if (config.db_type === "mongodb" && /legacy driver/i.test(msg)) {
@@ -1219,7 +1341,10 @@ async function testConnection() {
testResult.value = { ok: true, message: msg };
} catch (e: any) {
if (runId !== testRunId) return;
testResult.value = { ok: false, message: mongodbAuthFailureHint(String(e)) };
const message = mongodbAuthFailureHint(String(e));
const fallbackMessage = await tryNacosDockerConsoleFallback(config, message);
if (runId !== testRunId) return;
testResult.value = fallbackMessage ? { ok: true, message: fallbackMessage } : { ok: false, message };
} finally {
if (runId === testRunId) {
isTesting.value = false;
@@ -1301,6 +1426,15 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
config.database = undefined;
config.connection_string = undefined;
config.url_params = "";
} else if (config.db_type === "nacos") {
const nacosConfig = buildNacosAdminConfig();
config.external_config = nacosConfig;
applyNacosServerAddr(config, nacosConfig.serverAddr);
config.username = nacosAuthKind.value === "usernamePassword" ? nacosUsername.value.trim() : "";
config.password = nacosAuthKind.value === "usernamePassword" ? nacosPassword.value : "";
config.database = nacosConfig.namespace || undefined;
config.connection_string = undefined;
config.url_params = "";
} else {
config.external_config = undefined;
}
@@ -2791,6 +2925,54 @@ function openExternalUrl(url: string) {
</div>
</template>
<!-- Nacos: server address, namespace and auth -->
<template v-else-if="form.db_type === 'nacos'">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.nacosConsoleUrl") }}</Label>
<Input v-model="nacosServerAddr" class="col-span-3" placeholder="http://127.0.0.1:8085" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 m-0 text-xs leading-5 text-muted-foreground">{{ t("connection.nacosConsoleUrlHint") }}</p>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.nacosNamespace") }}</Label>
<Input v-model="nacosNamespace" class="col-span-3" placeholder="public" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.nacosContextPath") }}</Label>
<Input v-model="nacosContextPath" class="col-span-3" :placeholder="t('connection.nacosContextPathPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.nacosAuth") }}</Label>
<div class="col-span-3 flex flex-wrap gap-2">
<Button size="sm" :variant="nacosAuthKind === 'none' ? 'default' : 'outline'" @click="nacosAuthKind = 'none'">{{ t("connection.nacosAuthNone") }}</Button>
<Button size="sm" :variant="nacosAuthKind === 'usernamePassword' ? 'default' : 'outline'" @click="nacosAuthKind = 'usernamePassword'">{{ t("connection.nacosAuthUserPassword") }}</Button>
</div>
</div>
<template v-if="nacosAuthKind === 'usernamePassword'">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.user") }}</Label>
<Input v-model="nacosUsername" class="col-span-3" placeholder="nacos" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.password") }}</Label>
<PasswordInput v-model="nacosPassword" class="col-span-3" />
</div>
</template>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.nacosTls") }}</Label>
<label class="col-span-3 inline-flex items-center gap-2">
<input type="checkbox" v-model="nacosTlsSkipVerify" class="mr-0" />
<span class="text-xs text-muted-foreground">{{ t("connection.nacosTlsSkipVerify") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.nacosPageSize") }}</Label>
<Input v-model.number="nacosPageSize" type="number" min="1" max="500" class="col-span-3" />
</div>
</template>
<!-- Redis: host, port, user, password, ssl -->
<template v-else-if="form.db_type === 'redis'">
<div class="grid grid-cols-4 items-center gap-4">
@@ -114,7 +114,7 @@ const showModified = ref(true);
let syncPlanRequestId = 0;
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "etcd"].includes(connection.db_type)));
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "etcd", "mq", "nacos"].includes(connection.db_type)));
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
const filteredSourceTables = computed(() => {
@@ -53,7 +53,7 @@ const targetSchemas = ref<string[]>([]);
const sourceDbVersion = ref<string | null>(null);
const targetDbVersion = ref<string | null>(null);
const sqlConnections = computed(() => store.connections.filter((c: any) => c.db_type !== "mongodb" && c.db_type !== "redis"));
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "etcd", "mq", "nacos"].includes(c.db_type)));
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
@@ -8,6 +8,7 @@ import { ChevronUp, ChevronDown, ChevronRight, X } from "@lucide/vue";
const props = defineProps<{
view: EditorView | null;
tone?: "app" | "editor";
}>();
const { t } = useI18n();
@@ -214,56 +215,224 @@ defineExpose({ openSearch, openReplace, closeSearch });
<template>
<Transition enter-active-class="transition-[transform,opacity] duration-150" leave-active-class="transition-[transform,opacity] duration-100" enter-from-class="opacity-0 -translate-y-1" leave-to-class="opacity-0 -translate-y-1">
<div v-if="searchVisible" class="absolute top-1 right-4 z-[9999] isolate flex flex-col gap-1 rounded-md border bg-popover p-1.5 text-popover-foreground shadow-lg">
<div class="flex items-center gap-0.5">
<button class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" :title="showReplace ? t('editor.search.collapseReplace') : t('editor.search.expandReplace')" @click="showReplace = !showReplace">
<ChevronRight class="w-3 h-3 transition-transform" :class="showReplace && 'rotate-90'" />
<div v-if="searchVisible" class="editor-search-panel absolute right-4 top-3 z-[9999] isolate flex flex-col gap-1 rounded-lg border border-border bg-popover p-1.5 text-popover-foreground shadow-xl ring-1 ring-border/60" :class="{ 'editor-search-panel--editor': tone === 'editor' }">
<div class="flex items-center gap-1">
<button class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" :title="showReplace ? t('editor.search.collapseReplace') : t('editor.search.expandReplace')" @click="showReplace = !showReplace">
<ChevronRight class="h-4 w-4 transition-transform" :class="showReplace && 'rotate-90'" />
</button>
<input
ref="searchInputRef"
v-model="searchText"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="w-48 h-6 text-xs bg-input border rounded px-2 outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
:placeholder="t('editor.search.find')"
@keydown="onSearchKeydown"
/>
<button class="w-6 h-6 flex items-center justify-center rounded text-xs font-mono hover:bg-accent" :class="caseSensitive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" :title="t('editor.search.caseSensitive')" @click="caseSensitive = !caseSensitive">Aa</button>
<button class="w-6 h-6 flex items-center justify-center rounded text-xs font-mono hover:bg-accent" :class="useRegex ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" :title="t('editor.search.regex')" @click="useRegex = !useRegex">.*</button>
<span class="text-xs text-muted-foreground min-w-[3rem] text-center shrink-0">
<div class="flex h-8 w-64 items-center rounded-md border border-input bg-background focus-within:border-ring focus-within:ring-1 focus-within:ring-ring">
<input
ref="searchInputRef"
v-model="searchText"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="h-full min-w-0 flex-1 bg-transparent px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground"
:placeholder="t('editor.search.find')"
@keydown="onSearchKeydown"
/>
<button
class="flex h-6 min-w-7 items-center justify-center rounded px-1.5 text-xs font-medium transition-colors hover:bg-accent hover:text-foreground"
:class="caseSensitive ? 'bg-accent text-foreground' : 'text-muted-foreground'"
:title="t('editor.search.caseSensitive')"
@click="caseSensitive = !caseSensitive"
>
Aa
</button>
<button
class="mr-1 flex h-6 min-w-7 items-center justify-center rounded px-1.5 font-mono text-xs transition-colors hover:bg-accent hover:text-foreground"
:class="useRegex ? 'bg-accent text-foreground' : 'text-muted-foreground'"
:title="t('editor.search.regex')"
@click="useRegex = !useRegex"
>
.*
</button>
</div>
<span class="min-w-[3.4rem] shrink-0 text-center text-xs" :class="searchText && matchCount === 0 ? 'text-destructive' : 'text-muted-foreground'">
{{ searchText && matchCount > 0 ? `${currentMatchIndex}/${matchCount}${matchCountLimited ? "+" : ""}` : t("editor.search.noResults") }}
</span>
<button class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('editor.search.prevMatch')" @click="prevMatch">
<ChevronUp class="w-3.5 h-3.5" />
<button class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" :title="t('editor.search.prevMatch')" @click="prevMatch">
<ChevronUp class="h-4 w-4" />
</button>
<button class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('editor.search.nextMatch')" @click="nextMatch">
<ChevronDown class="w-3.5 h-3.5" />
<button class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" :title="t('editor.search.nextMatch')" @click="nextMatch">
<ChevronDown class="h-4 w-4" />
</button>
<button class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('editor.search.close')" @click="closeSearch">
<X class="w-3.5 h-3.5" />
<button class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" :title="t('editor.search.close')" @click="closeSearch">
<X class="h-4 w-4" />
</button>
</div>
<div v-if="showReplace" class="flex items-center gap-0.5">
<div class="w-5 h-5 shrink-0" />
<input
ref="replaceInputRef"
v-model="replaceText"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="w-48 h-6 text-xs bg-input border rounded px-2 outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
:placeholder="t('editor.search.replace')"
@keydown.enter.prevent="doReplace"
@keydown.escape.prevent="closeSearch"
/>
<button class="h-6 px-1.5 flex items-center justify-center rounded text-xs text-muted-foreground hover:bg-accent hover:text-foreground border" :title="t('editor.search.replace')" @click="doReplace">
<div v-if="showReplace" class="flex items-center gap-1">
<div class="h-7 w-7 shrink-0" />
<div class="flex h-8 w-64 items-center rounded-md border border-input bg-background focus-within:border-ring focus-within:ring-1 focus-within:ring-ring">
<input
ref="replaceInputRef"
v-model="replaceText"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="h-full min-w-0 flex-1 bg-transparent px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground"
:placeholder="t('editor.search.replace')"
@keydown.enter.prevent="doReplace"
@keydown.escape.prevent="closeSearch"
/>
</div>
<button class="flex h-7 items-center justify-center rounded-md border border-border px-2 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" :title="t('editor.search.replace')" @click="doReplace">
{{ t("editor.search.replace") }}
</button>
<button class="h-6 px-1.5 flex items-center justify-center rounded text-xs text-muted-foreground hover:bg-accent hover:text-foreground border" :title="t('editor.search.replaceAll')" @click="doReplaceAll">
<button class="flex h-7 items-center justify-center rounded-md border border-border px-2 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" :title="t('editor.search.replaceAll')" @click="doReplaceAll">
{{ t("editor.search.replaceAll") }}
</button>
</div>
</div>
</Transition>
</template>
<style scoped>
.editor-search-panel {
max-width: min(calc(100vw - 2rem), 620px);
}
.editor-search-panel--editor {
background: #f6f7f9;
border-color: #d8dce3;
border-radius: 6px;
box-shadow:
0 8px 22px rgb(15 23 42 / 0.14),
0 1px 0 rgb(255 255 255 / 0.78) inset;
color: #20242a;
gap: 3px;
max-width: min(calc(100vw - 2rem), 500px);
padding: 4px 6px;
right: 0.75rem;
top: 0.75rem;
}
.editor-search-panel--editor :deep(.h-8) {
height: 27px;
}
.editor-search-panel--editor :deep(.h-7) {
height: 27px;
}
.editor-search-panel--editor :deep(.w-7) {
width: 27px;
}
.editor-search-panel--editor :deep(.w-64) {
width: 230px;
}
.editor-search-panel--editor :deep(button) {
font-size: 12px;
}
.editor-search-panel--editor :deep(button.px-2) {
padding-left: 7px;
padding-right: 7px;
}
.editor-search-panel--editor :deep(.min-w-\[3\.4rem\]) {
min-width: 3.25rem;
}
.editor-search-panel--editor :deep(.border-input) {
background: #ffffff;
border-color: #c7ccd5;
border-radius: 5px;
box-shadow: 0 1px 0 rgb(15 23 42 / 0.03) inset;
}
.editor-search-panel--editor :deep(.focus-within\:border-ring:focus-within) {
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6;
}
.editor-search-panel--editor :deep(input) {
color: #20242a;
font-size: 12px;
}
.editor-search-panel--editor :deep(input::placeholder) {
color: #7a828e;
}
.editor-search-panel--editor :deep(.text-muted-foreground) {
color: #6e7681;
}
.editor-search-panel--editor :deep(.text-destructive) {
color: #c2410c;
}
.editor-search-panel--editor :deep(.hover\:bg-accent:hover),
.editor-search-panel--editor :deep(.bg-accent) {
background: #e6e9ef;
}
.editor-search-panel--editor :deep(.hover\:text-foreground:hover),
.editor-search-panel--editor :deep(.text-foreground) {
color: #1f2329;
}
.editor-search-panel--editor :deep(button.border-border) {
border-color: #d0d5dd;
}
:global(.dark) .editor-search-panel--editor {
background: #20252d;
border-color: #2c333d;
box-shadow:
0 8px 24px rgb(0 0 0 / 0.28),
0 1px 0 rgb(255 255 255 / 0.04) inset;
color: #d4d7dc;
}
:global(.dark) .editor-search-panel--editor :deep(.border-input) {
background: #191d25;
border-color: #2f3742;
box-shadow: none;
}
:global(.dark) .editor-search-panel--editor :deep(.focus-within\:border-ring:focus-within) {
border-color: #4d8dff;
box-shadow: 0 0 0 1px #4d8dff;
}
:global(.dark) .editor-search-panel--editor :deep(input) {
color: #d7dae0;
}
:global(.dark) .editor-search-panel--editor :deep(input::placeholder) {
color: #858c97;
}
:global(.dark) .editor-search-panel--editor :deep(.text-muted-foreground) {
color: #9aa2ad;
}
:global(.dark) .editor-search-panel--editor :deep(.text-destructive) {
color: #f59e7a;
}
:global(.dark) .editor-search-panel--editor :deep(.hover\:bg-accent:hover),
:global(.dark) .editor-search-panel--editor :deep(.bg-accent) {
background: #303844;
}
:global(.dark) .editor-search-panel--editor :deep(.hover\:text-foreground:hover),
:global(.dark) .editor-search-panel--editor :deep(.text-foreground) {
color: #f2f4f8;
}
:global(.dark) .editor-search-panel--editor :deep(button.border-border) {
border-color: #38414d;
}
@media (max-width: 720px) {
.editor-search-panel {
left: 0.75rem;
right: 0.75rem;
}
}
</style>
@@ -65,7 +65,7 @@ const exportCancelled = ref(false);
const pendingPrefillTable = ref("");
const pendingPrefillTables = ref<string[]>([]);
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "etcd"].includes(c.db_type)));
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "etcd", "mq", "nacos"].includes(c.db_type)));
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);
@@ -81,6 +81,7 @@ const assetIcons: Record<string, string> = {
milvus: "milvus.png",
mq: "pulsar",
pulsar: "pulsar",
nacos: "nacos.png",
iris: "iris.png",
influxdb: "influxdb",
};
@@ -2,7 +2,7 @@
import { computed, ref, watch, nextTick } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Check, Lock, Copy, AlertTriangle } from "@lucide/vue";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Check, Lock, Copy, AlertTriangle, Network } from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
@@ -236,6 +236,7 @@ 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 === "nacos") return Network;
if (tab.mode === "objects") return TableProperties;
if (tab.mode === "structure") return PencilRuler;
return Code2;
@@ -329,6 +330,7 @@ function activateTab(tabId: string) {
<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" />
<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" />
<Code2 v-else class="h-3.5 w-3.5" />
@@ -38,6 +38,7 @@ const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/Etcd
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue"));
const DatabaseUserAdmin = defineAsyncComponent(() => import("@/components/admin/DatabaseUserAdmin.vue"));
@@ -1029,6 +1030,12 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</div>
</template>
<template v-else-if="activeTab.mode === 'nacos'">
<div class="flex-1 min-h-0">
<NacosAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :namespace="activeTab.nacosNamespace" :namespace-name="activeTab.nacosNamespaceName" :read-only="activeConnection?.read_only ?? false" />
</div>
</template>
<!-- Objects mode: virtualized database object browser -->
<template v-else-if="activeTab.mode === 'objects' && activeConnection">
<ObjectBrowser
@@ -61,7 +61,7 @@ const activeConnectionValue = computed(() => props.activeConnection?.id || "");
const activeSchemaValue = computed(() => props.activeTab.schema || "");
const supportsExplain = computed(() => {
const dbType = props.activeConnection?.db_type;
return dbType !== "redis" && dbType !== "mongodb" && dbType !== "elasticsearch" && dbType !== "qdrant" && dbType !== "milvus" && dbType !== "etcd";
return dbType !== "redis" && dbType !== "mongodb" && dbType !== "elasticsearch" && dbType !== "qdrant" && dbType !== "milvus" && dbType !== "etcd" && dbType !== "mq" && dbType !== "nacos";
});
const isSingleDb = computed(() => isSingleDatabase(props.activeConnection?.db_type));
const hasDefaultDatabaseOption = computed(() => activeDatabaseOptions.value.includes(""));
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,209 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { Loader2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { buildNacosInlineDiff, buildNacosSideBySideDiff, summarizeNacosConfigDiff, type NacosDiffLineType, type NacosInlineSegment } from "@/lib/nacosAdmin";
const open = defineModel<boolean>("open", { default: false });
const props = withDefaults(
defineProps<{
before: string;
after: string;
title?: string;
beforeLabel?: string;
afterLabel?: string;
confirmLabel?: string;
confirmVariant?: "default" | "destructive";
showConfirm?: boolean;
loading?: boolean;
}>(),
{
title: "",
beforeLabel: "",
afterLabel: "",
confirmLabel: "",
confirmVariant: "default",
showConfirm: true,
loading: false,
},
);
const emit = defineEmits<{
confirm: [];
}>();
const { t } = useI18n();
const inlineCompare = ref(false);
const rows = computed(() => buildNacosSideBySideDiff(props.before, props.after));
const inlineRows = computed(() => buildNacosInlineDiff(props.before, props.after));
const summary = computed(() => summarizeNacosConfigDiff(props.before, props.after));
const dialogOpen = computed({
get: () => open.value,
set: (value) => {
if (props.loading && !value) return;
open.value = value;
},
});
function lineClass(type: NacosDiffLineType, side: "left" | "right") {
return {
"bg-red-500/20 text-red-50": type === "delete" || (type === "modify" && side === "left"),
"bg-emerald-500/18 text-emerald-50": type === "insert" || (type === "modify" && side === "right"),
"text-zinc-200": type === "equal",
"bg-zinc-900/80 text-zinc-600": type === "padding",
};
}
function gutterClass(type: NacosDiffLineType, side: "left" | "right") {
return {
"text-red-300": type === "delete" || (type === "modify" && side === "left"),
"text-emerald-300": type === "insert" || (type === "modify" && side === "right"),
"text-zinc-500": type === "equal" || type === "padding",
};
}
function prefix(type: NacosDiffLineType, side: "left" | "right") {
if (type === "delete" || (type === "modify" && side === "left")) return "-";
if (type === "insert" || (type === "modify" && side === "right")) return "+";
return "";
}
function inlineSegments(content: string, segments: NacosInlineSegment[]) {
return segments.length ? segments : [{ value: content, changed: false }];
}
function inlineClass(type: NacosDiffLineType, changed: boolean) {
if (!changed) return "";
if (type === "delete" || type === "modify") return "nacos-inline-change rounded-[2px] bg-red-500/80 text-red-50";
if (type === "insert") return "nacos-inline-change rounded-[2px] bg-emerald-500/75 text-emerald-50";
return "";
}
function rightInlineClass(type: NacosDiffLineType, changed: boolean) {
if (!changed) return "";
if (type === "modify" || type === "insert") return "nacos-inline-change rounded-[2px] bg-emerald-500/75 text-emerald-50";
return inlineClass(type, changed);
}
function inlineRowClass(type: "equal" | "delete" | "insert") {
return {
"bg-red-500/20 text-red-50": type === "delete",
"bg-emerald-500/18 text-emerald-50": type === "insert",
"text-zinc-200": type === "equal",
};
}
function inlineGutterClass(type: "equal" | "delete" | "insert") {
return {
"text-red-300": type === "delete",
"text-emerald-300": type === "insert",
"text-zinc-500": type === "equal",
};
}
function inlinePrefix(type: "equal" | "delete" | "insert") {
if (type === "delete") return "-";
if (type === "insert") return "+";
return "";
}
function inlineRowSegmentClass(type: "equal" | "delete" | "insert", changed: boolean) {
if (!changed) return "";
if (type === "delete") return "nacos-inline-change rounded-[2px] bg-red-500/80 text-red-50";
if (type === "insert") return "nacos-inline-change rounded-[2px] bg-emerald-500/75 text-emerald-50";
return "";
}
function onConfirm() {
if (props.loading) return;
emit("confirm");
}
</script>
<template>
<Dialog v-model:open="dialogOpen">
<DialogContent :show-close-button="false" class="nacos-config-diff-dialog flex h-[min(88vh,900px)] flex-col gap-0 overflow-hidden rounded-lg p-0 shadow-2xl">
<DialogHeader class="shrink-0 border-b px-5 py-4">
<div class="flex items-center justify-between gap-4">
<DialogTitle class="text-lg font-semibold">{{ title || t("nacos.configDiffTitle") }}</DialogTitle>
<button type="button" class="text-2xl leading-none text-muted-foreground hover:text-foreground" :disabled="loading" @click="open = false">×</button>
</div>
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
<label class="inline-flex items-center gap-2 text-sm text-muted-foreground">
<input v-model="inlineCompare" type="checkbox" class="h-4 w-4 rounded border-border" />
<span>{{ t("nacos.inlineCompare") }}</span>
</label>
<div class="text-xs text-muted-foreground">{{ t("nacos.confirmSaveMessage", { added: summary.addedLines, removed: summary.removedLines }) }}</div>
</div>
</DialogHeader>
<div v-if="!inlineCompare" class="grid min-h-0 flex-1 grid-cols-1 gap-4 bg-background px-5 py-4 lg:grid-cols-2">
<section class="flex min-w-0 min-h-0 flex-col">
<div class="mb-2 text-sm font-medium text-foreground">{{ beforeLabel || t("nacos.currentVersionContent") }}</div>
<div class="min-h-0 flex-1 overflow-auto rounded-sm border border-zinc-700 bg-[#1f1f1f] font-mono text-[13px] leading-6 text-zinc-200">
<div v-for="row in rows" :key="`${row.id}-left`" class="grid min-w-max grid-cols-[52px_22px_minmax(48rem,1fr)]" :class="lineClass(row.leftType, 'left')">
<span class="select-none border-r border-white/8 pr-2 text-right" :class="gutterClass(row.leftType, 'left')">{{ row.leftLineNumber ?? "" }}</span>
<span class="select-none pl-2" :class="gutterClass(row.leftType, 'left')">{{ prefix(row.leftType, "left") }}</span>
<pre class="whitespace-pre px-2"><template v-for="(segment, index) in inlineSegments(row.leftContent, row.leftInline)" :key="index"><span :class="inlineClass(row.leftType, segment.changed)">{{ segment.value }}</span></template></pre>
</div>
</div>
</section>
<section class="flex min-w-0 min-h-0 flex-col">
<div class="mb-2 text-sm font-medium text-foreground">{{ afterLabel || t("nacos.publishVersionContent") }}</div>
<div class="min-h-0 flex-1 overflow-auto rounded-sm border border-zinc-700 bg-[#1f1f1f] font-mono text-[13px] leading-6 text-zinc-200">
<div v-for="row in rows" :key="`${row.id}-right`" class="grid min-w-max grid-cols-[52px_22px_minmax(48rem,1fr)]" :class="lineClass(row.rightType, 'right')">
<span class="select-none border-r border-white/8 pr-2 text-right" :class="gutterClass(row.rightType, 'right')">{{ row.rightLineNumber ?? "" }}</span>
<span class="select-none pl-2" :class="gutterClass(row.rightType, 'right')">{{ prefix(row.rightType, "right") }}</span>
<pre class="whitespace-pre px-2"><template v-for="(segment, index) in inlineSegments(row.rightContent, row.rightInline)" :key="index"><span :class="rightInlineClass(row.rightType, segment.changed)">{{ segment.value }}</span></template></pre>
</div>
</div>
</section>
</div>
<div v-else class="min-h-0 flex-1 bg-background px-5 py-4">
<section class="flex h-full min-h-0 flex-col">
<div class="mb-2 text-sm font-medium text-foreground">{{ t("nacos.inlineCompare") }}</div>
<div class="min-h-0 flex-1 overflow-auto rounded-sm border border-zinc-700 bg-[#1f1f1f] font-mono text-[13px] leading-6 text-zinc-200">
<div v-for="row in inlineRows" :key="row.id" class="grid min-w-max grid-cols-[52px_22px_minmax(96rem,1fr)]" :class="inlineRowClass(row.type)">
<span class="select-none border-r border-white/8 pr-2 text-right" :class="inlineGutterClass(row.type)">{{ row.lineNumber ?? "" }}</span>
<span class="select-none pl-2" :class="inlineGutterClass(row.type)">{{ inlinePrefix(row.type) }}</span>
<pre class="whitespace-pre px-2"><template v-for="(segment, index) in row.segments" :key="index"><span :class="inlineRowSegmentClass(row.type, segment.changed)">{{ segment.value }}</span></template></pre>
</div>
</div>
</section>
</div>
<DialogFooter class="shrink-0 gap-3 border-t bg-muted/20 px-5 pb-6 pt-4">
<Button v-if="showConfirm" :variant="confirmVariant" class="min-w-24 gap-1.5 px-5" :disabled="loading" @click="onConfirm">
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
{{ confirmLabel || t("nacos.publish") }}
</Button>
<Button variant="outline" class="min-w-24 px-5" :disabled="loading" @click="open = false">{{ t("dangerDialog.cancel") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<style>
.nacos-config-diff-dialog {
width: min(96vw, 1440px) !important;
max-width: min(96vw, 1440px) !important;
}
.nacos-inline-change {
box-decoration-break: clone;
padding: 0 1px;
}
@media (max-width: 1023px) {
.nacos-config-diff-dialog {
width: min(96vw, 760px) !important;
max-width: min(96vw, 760px) !important;
}
}
</style>
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { Clock3, Eye, FileText, GitCompare, Loader2, RefreshCw, RotateCcw, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { NacosConfigHistoryItem, NacosConfigItem } from "@/types/nacos";
const open = defineModel<boolean>("open", { default: false });
const props = withDefaults(
defineProps<{
config: NacosConfigItem | null;
items: NacosConfigHistoryItem[];
loading?: boolean;
error?: string;
pageNo: number;
pageSize: number;
totalCount: number;
readOnly?: boolean;
viewingItem?: NacosConfigHistoryItem | null;
viewingContent?: string;
viewingLoading?: boolean;
}>(),
{
items: () => [],
loading: false,
error: "",
readOnly: false,
viewingItem: null,
viewingContent: "",
viewingLoading: false,
},
);
const emit = defineEmits<{
load: [pageNo: number];
view: [item: NacosConfigHistoryItem];
"close-detail": [];
compare: [item: NacosConfigHistoryItem];
rollback: [item: NacosConfigHistoryItem];
}>();
const { t } = useI18n();
const detailOpen = ref(false);
const totalPages = computed(() => Math.max(1, Math.ceil(props.totalCount / Math.max(1, props.pageSize))));
const historyTitle = computed(() => {
if (!props.config) return t("nacos.configHistory");
return `${props.config.dataId} / ${props.config.group || "DEFAULT_GROUP"}`;
});
const namespaceLabel = computed(() => props.config?.namespace || "public");
const dataIdLabel = computed(() => props.config?.dataId || "-");
const groupLabel = computed(() => props.config?.group || "DEFAULT_GROUP");
watch(
() => props.viewingItem,
(item) => {
if (item) detailOpen.value = true;
},
);
watch(detailOpen, (value) => {
if (!value) emit("close-detail");
});
function display(value?: string | null) {
const trimmed = value?.trim();
return trimmed || "-";
}
function operationLabel(value?: string) {
const normalized = value?.trim().toLowerCase();
if (!normalized) return "-";
if (["i", "insert", "create", "add"].includes(normalized)) return t("nacos.historyCreate");
if (["u", "update", "modify", "publish"].includes(normalized)) return t("nacos.historyUpdate");
if (["d", "delete", "remove"].includes(normalized)) return t("nacos.historyDelete");
if (["rollback", "recover"].includes(normalized)) return t("nacos.historyRollback");
return value || "-";
}
function loadPage(pageNo: number) {
if (props.loading || pageNo < 1 || pageNo > totalPages.value) return;
emit("load", pageNo);
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent :show-close-button="false" class="nacos-config-history-dialog flex h-[min(82vh,760px)] flex-col gap-0 overflow-hidden rounded-lg p-0 shadow-2xl">
<DialogHeader class="shrink-0 border-b bg-muted/20 px-5 py-4">
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 items-start gap-3">
<div class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-md border bg-background text-primary">
<Clock3 class="h-4 w-4" />
</div>
<div class="min-w-0">
<DialogTitle class="truncate text-lg font-semibold">{{ t("nacos.configHistory") }}</DialogTitle>
<div class="mt-2 flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
<Badge variant="outline" class="max-w-64 truncate font-mono">namespace={{ namespaceLabel }}</Badge>
<Badge variant="secondary" class="max-w-72 truncate font-mono">dataId={{ dataIdLabel }}</Badge>
<Badge variant="outline" class="max-w-48 truncate font-mono">group={{ groupLabel }}</Badge>
</div>
<div class="sr-only">{{ historyTitle }}</div>
</div>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" @click="open = false">
<X class="h-4 w-4" />
</Button>
</div>
</DialogHeader>
<div v-if="error" class="shrink-0 border-b px-5 py-2 text-xs text-destructive">{{ error }}</div>
<div class="min-h-0 flex-1 overflow-auto">
<table class="w-full text-left text-sm">
<thead class="sticky top-0 z-10 bg-muted/85 text-xs text-muted-foreground">
<tr>
<th class="px-4 py-2 font-medium">Data ID</th>
<th class="px-4 py-2 font-medium">Group</th>
<th class="px-4 py-2 font-medium">{{ t("nacos.updatedAt") }}</th>
<th class="px-4 py-2 font-medium">{{ t("nacos.application") }}</th>
<th class="px-4 py-2 font-medium">{{ t("nacos.operationType") }}</th>
<th class="px-4 py-2 font-medium">{{ t("nacos.operator") }}</th>
<th class="px-4 py-2 text-right font-medium">{{ t("nacos.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="`${item.historyId}:${item.nid ?? ''}`" class="border-b">
<td class="max-w-72 truncate px-4 py-2 font-medium" :title="item.dataId">{{ item.dataId }}</td>
<td class="max-w-44 truncate px-4 py-2 text-xs text-muted-foreground" :title="item.group">{{ item.group || "DEFAULT_GROUP" }}</td>
<td class="whitespace-nowrap px-4 py-2 text-xs text-muted-foreground">{{ display(item.lastModifiedTime) }}</td>
<td class="max-w-40 truncate px-4 py-2 text-xs text-muted-foreground" :title="item.appName || ''">{{ display(item.appName) }}</td>
<td class="px-4 py-2">
<Badge variant="outline">{{ operationLabel(item.operation) }}</Badge>
</td>
<td class="max-w-40 truncate px-4 py-2 text-xs text-muted-foreground" :title="item.operator || ''">{{ display(item.operator) }}</td>
<td class="px-4 py-2 text-right">
<div class="inline-flex items-center gap-1.5">
<Button size="sm" variant="ghost" class="h-7 gap-1 px-2" :disabled="loading" @click="emit('view', item)">
<Eye class="h-3.5 w-3.5" />
{{ t("nacos.view") }}
</Button>
<Button size="sm" variant="ghost" class="h-7 gap-1 px-2" :disabled="loading" @click="emit('compare', item)">
<GitCompare class="h-3.5 w-3.5" />
{{ t("nacos.compare") }}
</Button>
<Button size="sm" variant="ghost" class="h-7 gap-1 px-2 text-destructive hover:text-destructive" :disabled="readOnly || loading" @click="emit('rollback', item)">
<RotateCcw class="h-3.5 w-3.5" />
{{ t("nacos.rollback") }}
</Button>
</div>
</td>
</tr>
</tbody>
</table>
<div v-if="loading" class="flex h-44 items-center justify-center text-sm text-muted-foreground">
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ t("nacos.loadingHistory") }}
</div>
<div v-else-if="items.length === 0" class="flex h-52 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<div class="flex h-10 w-10 items-center justify-center rounded-md border bg-muted/30">
<FileText class="h-4 w-4" />
</div>
<div class="font-medium text-foreground">{{ t("nacos.noHistory") }}</div>
<div class="text-xs">{{ t("nacos.noHistoryHint") }}</div>
</div>
</div>
<DialogFooter class="shrink-0 items-center justify-between gap-3 border-t bg-muted/20 px-5 pb-5 pt-4">
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="loading" @click="loadPage(pageNo)">
<RefreshCw class="h-3.5 w-3.5" />
{{ t("nacos.refresh") }}
</Button>
<div class="flex items-center gap-2 rounded-md border bg-background px-2 py-1 text-xs text-muted-foreground">
<span>{{ t("nacos.total", { count: totalCount }) }}</span>
<Button size="sm" variant="outline" class="h-7" :disabled="pageNo <= 1 || loading" @click="loadPage(pageNo - 1)">{{ t("nacos.prev") }}</Button>
<span>{{ pageNo }} / {{ totalPages }}</span>
<Button size="sm" variant="outline" class="h-7" :disabled="pageNo >= totalPages || loading" @click="loadPage(pageNo + 1)">{{ t("nacos.next") }}</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="detailOpen">
<DialogContent class="nacos-config-history-detail-dialog flex h-[min(88vh,900px)] flex-col gap-0 overflow-hidden p-0">
<DialogHeader class="shrink-0 border-b px-5 py-4">
<DialogTitle class="truncate text-base font-semibold">{{ t("nacos.historyDetail") }}</DialogTitle>
<div v-if="viewingItem" class="mt-2 grid gap-1.5 text-xs text-muted-foreground sm:grid-cols-2">
<div class="min-w-0 truncate font-mono" :title="viewingItem.namespace || 'public'">namespace={{ viewingItem.namespace || "public" }}</div>
<div class="min-w-0 truncate font-mono" :title="viewingItem.dataId">dataId={{ viewingItem.dataId }}</div>
<div class="min-w-0 truncate font-mono" :title="viewingItem.group || 'DEFAULT_GROUP'">group={{ viewingItem.group || "DEFAULT_GROUP" }}</div>
<div class="min-w-0 truncate" :title="display(viewingItem.lastModifiedTime)">{{ t("nacos.updatedAt") }}={{ display(viewingItem.lastModifiedTime) }}</div>
</div>
</DialogHeader>
<div class="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
<div v-if="viewingLoading" class="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ t("nacos.loadingHistory") }}
</div>
<pre v-else class="min-h-full rounded-md border bg-background p-3 font-mono text-xs leading-5">{{ viewingContent || "" }}</pre>
</div>
<DialogFooter class="m-0 shrink-0 rounded-none border-t bg-background px-5 py-5 sm:py-4">
<Button variant="outline" @click="detailOpen = false">{{ t("dangerDialog.cancel") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<style>
.nacos-config-history-dialog {
width: min(96vw, 1280px) !important;
max-width: min(96vw, 1280px) !important;
}
.nacos-config-history-detail-dialog {
width: min(92vw, 1180px) !important;
max-width: min(92vw, 1180px) !important;
}
.nacos-config-history-detail-dialog pre {
min-width: max-content;
white-space: pre;
}
</style>
@@ -81,7 +81,7 @@ const isFiltering = computed(() => !!searchQuery.value.trim() || hasSearchScopeF
const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
connection: ["connection"],
database: ["database", "redis-db", "mq-tenant", "mongo-db"],
database: ["database", "redis-db", "mq-tenant", "nacos-namespace", "mongo-db"],
schema: ["schema"],
table: ["table", "mongo-collection", "vector-collection", "elasticsearch-index"],
view: ["view"],
@@ -340,6 +340,8 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
await store.loadVectorCollections(connId);
} else if (config.db_type === "mq") {
await store.loadMqTenants(connId, loadOptions);
} else if (config.db_type === "nacos") {
await store.loadNacosNamespaces(connId, loadOptions);
} else {
await store.loadDatabases(connId, loadOptions);
}
@@ -348,7 +350,7 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
}
}
if (config.db_type === "mq") return;
if (config.db_type === "mq" || config.db_type === "nacos") return;
if (!("database" in target) || !target.database) return;
// Find the database node
@@ -246,6 +246,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: Database, colorClass: "text-red-400" };
case "mq-tenant":
return { icon: FolderOpen, colorClass: "text-sky-400" };
case "nacos-namespace":
return { icon: FolderOpen, colorClass: "text-sky-500" };
case "etcd-root":
return { icon: Database, colorClass: "text-sky-500" };
case "mongo-db":
@@ -468,6 +470,8 @@ async function toggle() {
await connectionStore.loadVectorCollections(node.connectionId);
} else if (config?.db_type === "mq") {
await connectionStore.loadMqTenants(node.connectionId);
} else if (config?.db_type === "nacos") {
await connectionStore.loadNacosNamespaces(node.connectionId);
} else {
await connectionStore.loadDatabases(node.connectionId);
}
@@ -476,6 +480,8 @@ async function toggle() {
queryStore.createTab(node.connectionId, node.database, tabTitle, "redis");
} else if (node.type === "mq-tenant" && node.connectionId) {
queryStore.openMqAdmin(node.connectionId, { tenant: node.mqTenant || node.label });
} else if (node.type === "nacos-namespace" && node.connectionId) {
queryStore.openNacosAdmin(node.connectionId, { namespace: node.nacosNamespace || "", namespaceName: node.nacosNamespaceName || node.label });
} else if (node.type === "etcd-root" && node.connectionId) {
const tabTitle = `${connectionStore.getConfig(node.connectionId)?.name || "etcd"}:keys`;
queryStore.createTab(node.connectionId, "", tabTitle, "etcd");
@@ -1371,6 +1377,15 @@ const showCreateDatabaseDialog = ref(false);
const createDatabaseName = ref("");
const createDatabaseCharset = ref("utf8mb4");
const createDatabaseCollation = ref("utf8mb4_unicode_ci");
const showCreateNacosNamespaceDialog = ref(false);
const createNacosNamespaceId = ref("");
const createNacosNamespaceName = ref("");
const createNacosNamespaceDesc = ref("");
const createNacosNamespaceLoading = ref(false);
const showEditNacosNamespaceDialog = ref(false);
const editNacosNamespaceName = ref("");
const editNacosNamespaceDesc = ref("");
const editNacosNamespaceLoading = ref(false);
const fallbackCreateDatabaseCharset = fallbackCreateDatabaseCharsetMetadata();
const createDatabaseCharsetOptions = ref<string[]>(fallbackCreateDatabaseCharset.charsets);
const createDatabaseCollationsByCharset = ref<Record<string, string[]>>(fallbackCreateDatabaseCharset.collationsByCharset);
@@ -1851,6 +1866,17 @@ const canCreateDatabase = computed(() => {
return props.node.type === "connection" && (supportsDatabaseCreation(config?.db_type) || config?.db_type === "duckdb" || (config?.db_type === "mongodb" && config.driver_profile !== "mongodb-legacy"));
});
const canCreateNacosNamespace = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return props.node.type === "connection" && config?.db_type === "nacos" && !config.read_only;
});
const canEditNacosNamespace = computed(() => {
if (props.node.type !== "nacos-namespace" || !props.node.connectionId || !props.node.nacosNamespace) return false;
const config = connectionStore.getConfig(props.node.connectionId);
return config?.db_type === "nacos" && !config.read_only;
});
const isDuckDbConnection = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return props.node.type === "connection" && config?.db_type === "duckdb";
@@ -2037,6 +2063,63 @@ async function loadCreateDatabaseCharsetMetadata() {
}
}
function openCreateNacosNamespaceDialog() {
createNacosNamespaceId.value = "";
createNacosNamespaceName.value = "";
createNacosNamespaceDesc.value = "";
showCreateNacosNamespaceDialog.value = true;
}
async function confirmCreateNacosNamespace() {
const node = props.node;
const namespaceName = createNacosNamespaceName.value.trim();
if (!node.connectionId || !namespaceName || createNacosNamespaceLoading.value) return;
createNacosNamespaceLoading.value = true;
try {
await api.nacosCreateNamespace(node.connectionId, {
namespaceId: createNacosNamespaceId.value.trim() || undefined,
namespaceName,
namespaceDesc: createNacosNamespaceDesc.value.trim() || namespaceName,
});
showCreateNacosNamespaceDialog.value = false;
await connectionStore.loadNacosNamespaces(node.connectionId, { force: true });
node.isExpanded = true;
toast(t("nacos.namespaceCreated", { name: namespaceName }), 3000);
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
} finally {
createNacosNamespaceLoading.value = false;
}
}
function openEditNacosNamespaceDialog() {
editNacosNamespaceName.value = props.node.nacosNamespaceName || props.node.label;
editNacosNamespaceDesc.value = props.node.comment || "";
showEditNacosNamespaceDialog.value = true;
}
async function confirmEditNacosNamespace() {
const node = props.node;
const namespaceId = node.nacosNamespace?.trim() || "";
const namespaceName = editNacosNamespaceName.value.trim();
if (!node.connectionId || !namespaceId || !namespaceName || editNacosNamespaceLoading.value) return;
editNacosNamespaceLoading.value = true;
try {
await api.nacosUpdateNamespace(node.connectionId, {
namespaceId,
namespaceName,
namespaceDesc: editNacosNamespaceDesc.value.trim() || namespaceName,
});
showEditNacosNamespaceDialog.value = false;
await connectionStore.loadNacosNamespaces(node.connectionId, { force: true });
toast(t("nacos.namespaceUpdated", { name: namespaceName }), 3000);
} catch (e: any) {
toast(t("contextMenu.tableOperationFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);
} finally {
editNacosNamespaceLoading.value = false;
}
}
function ensureDuckDbFileExtension(path: string): string {
return /\.(duckdb|db)$/i.test(path) ? path : `${path}.duckdb`;
}
@@ -2861,7 +2944,7 @@ const nodeIconClass = computed(() => {
const canConfigureVisibleDatabases = computed(() => {
if (props.node.type !== "connection" || !props.node.connectionId) return false;
const dbType = connectionStore.getConfig(props.node.connectionId)?.db_type;
return dbType !== "elasticsearch" && dbType !== "qdrant" && dbType !== "milvus" && dbType !== "etcd" && dbType !== "mq";
return dbType !== "elasticsearch" && dbType !== "qdrant" && dbType !== "milvus" && dbType !== "etcd" && dbType !== "mq" && dbType !== "nacos";
});
const canCopyFinalProxyPort = computed(() => {
if (props.node.type !== "connection" || !props.node.connectionId) return false;
@@ -3273,6 +3356,13 @@ function treeItemMenuItems(): ContextMenuItem[] {
icon: Plus,
});
}
if (canCreateNacosNamespace.value) {
items.push({
label: t("nacos.createNamespace"),
action: openCreateNacosNamespaceDialog,
icon: FolderPlus,
});
}
items.push({ label: "", separator: true });
if (availableGroups.value.length > 0 || currentGroupId.value) {
const groupChildren: ContextMenuItem[] = availableGroups.value.map((group: { id: string; name: string }) => ({
@@ -3455,6 +3545,22 @@ function treeItemMenuItems(): ContextMenuItem[] {
return items;
}
if (node.type === "nacos-namespace") {
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: FolderOpen });
if (canEditNacosNamespace.value) {
items.push({ label: t("nacos.editNamespace"), action: openEditNacosNamespaceDialog, icon: Pencil });
}
items.push({
label: t("contextMenu.refreshChildren"),
action: refresh,
icon: RefreshCw,
shortcut: shortcutRefresh,
});
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
return items;
}
if (node.type === "mongo-collection") {
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: "", separator: true });
@@ -4004,6 +4110,58 @@ function treeItemMenuItems(): ContextMenuItem[] {
</DialogContent>
</Dialog>
<Dialog v-model:open="showCreateNacosNamespaceDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("nacos.createNamespace") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("nacos.namespaceId") }}</label>
<Input v-model="createNacosNamespaceId" :placeholder="t('nacos.namespaceIdPlaceholder')" @keydown.enter.prevent="confirmCreateNacosNamespace" />
</div>
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("nacos.namespaceName") }}</label>
<Input v-model="createNacosNamespaceName" :placeholder="t('nacos.namespaceNamePlaceholder')" @keydown.enter.prevent="confirmCreateNacosNamespace" />
</div>
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("nacos.namespaceDesc") }}</label>
<Input v-model="createNacosNamespaceDesc" :placeholder="t('nacos.namespaceDescPlaceholder')" @keydown.enter.prevent="confirmCreateNacosNamespace" />
</div>
</div>
<DialogFooter>
<Button variant="outline" :disabled="createNacosNamespaceLoading" @click="showCreateNacosNamespaceDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!createNacosNamespaceName.trim() || createNacosNamespaceLoading" @click="confirmCreateNacosNamespace">
{{ createNacosNamespaceLoading ? t("nacos.creatingNamespace") : t("dangerDialog.confirm") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showEditNacosNamespaceDialog">
<DialogContent class="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>{{ t("nacos.editNamespace") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("nacos.namespaceName") }}</label>
<Input v-model="editNacosNamespaceName" :placeholder="t('nacos.namespaceNamePlaceholder')" @keydown.enter.prevent="confirmEditNacosNamespace" />
</div>
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("nacos.namespaceDesc") }}</label>
<Input v-model="editNacosNamespaceDesc" :placeholder="t('nacos.namespaceDescPlaceholder')" @keydown.enter.prevent="confirmEditNacosNamespace" />
</div>
</div>
<DialogFooter>
<Button variant="outline" :disabled="editNacosNamespaceLoading" @click="showEditNacosNamespaceDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!editNacosNamespaceName.trim() || editNacosNamespaceLoading" @click="confirmEditNacosNamespace">
{{ editNacosNamespaceLoading ? t("nacos.updatingNamespace") : t("dangerDialog.confirm") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DangerConfirmDialog
v-model:open="showDropDatabaseConfirm"
:title="t('contextMenu.confirmDropDatabaseTitle')"
@@ -49,7 +49,7 @@ const terminalStatus = ref<SqlFileStatus | "idle">("idle");
const terminalError = ref("");
const refreshedTarget = ref(false);
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "etcd"].includes(c.db_type)));
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "etcd", "mq", "nacos"].includes(c.db_type)));
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));