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

+14
View File
@@ -765,6 +765,20 @@ async function openConnectionQuery(connectionId: string) {
queryStore.openMqAdmin(connectionId);
return;
}
if (initialTarget.kind === "nacos-admin") {
try {
await connectionStore.ensureConnected(connectionId);
await connectionStore.loadNacosNamespaces(connectionId);
} catch (e: any) {
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e?.message || String(e)),
}),
5000,
);
}
return;
}
const tabId = queryStore.createTab(connectionId, initialTarget.database);
try {
await connectionStore.ensureConnected(connectionId);
@@ -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));
+131
View File
@@ -250,6 +250,20 @@ export default {
etcdClientCertBrowse: "Choose client certificate",
etcdClientKeyBrowse: "Choose client private key",
etcdClientCertPairRequired: "Client certificate and private key must be provided together.",
nacosConsoleUrl: "Console URL",
nacosConsoleUrlHint: "Use the Nacos console/admin API address. Nacos 3 Docker usually exposes the console on 8085; older deployments may share 8848 with the service port.",
nacosConsoleUrlRequired: "Nacos Console URL is required",
nacosConsoleUrlAutoAdjusted: "Adjusted Nacos Console URL from {from} to {to}.",
nacosNamespace: "Namespace",
nacosContextPath: "Context Path",
nacosContextPathPlaceholder: "Leave empty or /nacos",
nacosAuth: "Auth",
nacosAuthNone: "None",
nacosAuthUserPassword: "User / Password",
nacosUsernameRequired: "Nacos username is required",
nacosTls: "TLS",
nacosTlsSkipVerify: "Skip certificate verification",
nacosPageSize: "Page Size",
searchDatabasePlaceholder: "Search database types",
iconView: "Icon view",
listView: "List view",
@@ -2720,4 +2734,121 @@ export default {
exportError: "Export failed: {error}",
exportCancelled: "Export cancelled",
},
nacos: {
configs: "Configs",
services: "Services",
raw: "Raw",
namespace: "Namespace",
createNamespace: "Create Namespace",
editNamespace: "Edit Namespace",
creatingNamespace: "Creating...",
updatingNamespace: "Updating...",
namespaceCreated: 'Namespace "{name}" created',
namespaceUpdated: 'Namespace "{name}" updated',
namespaceId: "Namespace ID",
namespaceIdPlaceholder: "Leave empty for Nacos to generate",
namespaceName: "Namespace Name",
namespaceNamePlaceholder: "Enter a namespace name",
namespaceDesc: "Description",
namespaceDescPlaceholder: "Defaults to namespace name",
dataId: "Data ID",
group: "Group",
format: "Format",
content: "Content",
tags: "Tags",
application: "Application",
description: "Description",
advanced: "Advanced",
collapse: "Collapse",
load: "Load",
save: "Save",
saveAs: "Save as",
saving: "Saving...",
saved: "Config saved",
createdAndLoaded: "Created and loaded {dataId}",
savedAndLoaded: "Saved and refreshed {dataId}",
deleted: "Config deleted",
delete: "Delete",
copy: "Copy",
copied: "Copied",
export: "Export",
exported: "Export copied",
history: "History",
configHistory: "Config history",
historyDetail: "History detail",
historyCompareTitle: "History Version Compare",
currentPublishedContent: "Current published content:",
historyVersionContent: "History version content:",
updatedAt: "Updated at",
operationType: "Operation",
operator: "Operator",
view: "View",
compare: "Compare",
rollback: "Rollback",
loadingHistory: "Loading history...",
noHistory: "No history versions",
noHistoryHint: "Refresh if this config was just published. Some Nacos versions may not expose history APIs.",
historyCreate: "Create",
historyUpdate: "Update",
historyDelete: "Delete",
historyRollback: "Rollback",
confirmRollbackTitle: "Rollback Nacos config",
confirmRollbackMessage: "This will roll the current config back to the selected history version.",
rollbackSuccess: "Config rolled back",
publish: "Publish",
dataIdRequired: "Data ID is required",
allGroups: "All groups",
noConfigs: "No configs",
selectConfig: "Select or create a config",
noServices: "No services",
selectService: "Select a service",
service: "Service",
instances: "Instances",
instanceCount: "{count} instances",
refresh: "Refresh",
address: "Address",
cluster: "Cluster",
weight: "Weight",
state: "State",
actions: "Actions",
query: "Query",
body: "Body",
response: "Response",
template: "Template",
configDiffTitle: "Config Content Compare",
inlineCompare: "Inline compare",
currentVersionContent: "Current official version:",
publishVersionContent: "This release content:",
confirmSaveTitle: "Review Nacos config changes",
confirmSaveMessage: "Save this config change? +{added} / -{removed} changed lines.",
confirmDeleteTitle: "Delete Nacos config",
confirmDeleteMessage: "This removes the selected config from Nacos.",
confirmInstanceTitle: "Update Nacos instance",
confirmInstanceMessage: "Confirm the target service instance state.",
confirmRawTitle: "Send mutating Raw API request",
confirmRawMessage: "This Raw API request can modify Nacos state.",
rawTemplate: {
serverState: "Server state",
namespaceList: "Namespace list",
configDetail: "Config detail",
serviceList: "Service list",
instanceList: "Instance list",
},
healthy: "Healthy",
unhealthy: "Unhealthy",
enabled: "Enabled",
offline: "Offline",
ephemeral: "Ephemeral",
persistent: "Persistent",
enable: "Enable",
disable: "Disable",
markHealthy: "Healthy",
markUnhealthy: "Unhealthy",
send: "Send",
total: "{count} total",
prev: "Prev",
next: "Next",
test: "Test",
readOnly: "Read only",
},
};
+119
View File
@@ -230,6 +230,20 @@ export default {
redisSentinelTls: "TLS Sentinel",
redisSentinelTlsHint: "Usar TLS al conectar con nodos Sentinel",
redisKeySeparator: "Separador de espacio de nombres de clave",
nacosConsoleUrl: "URL de consola",
nacosConsoleUrlHint: "Usa la dirección de consola/admin API de Nacos. Nacos 3 Docker normalmente expone la consola en 8085; despliegues antiguos pueden compartir 8848 con el puerto de servicio.",
nacosConsoleUrlRequired: "La URL de consola de Nacos es obligatoria",
nacosConsoleUrlAutoAdjusted: "URL de consola de Nacos ajustada de {from} a {to}.",
nacosNamespace: "Namespace",
nacosContextPath: "Context Path",
nacosContextPathPlaceholder: "Dejar vacío o /nacos",
nacosAuth: "Autenticación",
nacosAuthNone: "Ninguna",
nacosAuthUserPassword: "Usuario / Contraseña",
nacosUsernameRequired: "El usuario de Nacos es obligatorio",
nacosTls: "TLS",
nacosTlsSkipVerify: "Omitir verificación de certificado",
nacosPageSize: "Tamaño de página",
searchDatabasePlaceholder: "Buscar tipos de bases de datos",
iconView: "Vista de íconos",
listView: "Vista de lista",
@@ -2359,4 +2373,109 @@ export default {
exportError: "Error al exportar: {error}",
exportCancelled: "Exportación cancelada",
},
nacos: {
configs: "Configuraciones",
services: "Servicios",
raw: "Raw",
namespace: "Namespace",
dataId: "Data ID",
group: "Grupo",
format: "Formato",
content: "Contenido",
tags: "Etiquetas",
application: "Aplicación",
description: "Descripción",
advanced: "Avanzado",
collapse: "Contraer",
load: "Cargar",
save: "Guardar",
saveAs: "Guardar como",
saving: "Guardando...",
saved: "Configuración guardada",
createdAndLoaded: "{dataId} creado y cargado",
savedAndLoaded: "{dataId} guardado y actualizado",
deleted: "Configuración eliminada",
delete: "Eliminar",
copy: "Copiar",
copied: "Copiado",
export: "Exportar",
exported: "Exportación copiada",
history: "History",
configHistory: "Config history",
historyDetail: "History detail",
historyCompareTitle: "History Version Compare",
currentPublishedContent: "Current published content:",
historyVersionContent: "History version content:",
updatedAt: "Updated at",
operationType: "Operation",
operator: "Operator",
view: "View",
compare: "Compare",
rollback: "Rollback",
loadingHistory: "Loading history...",
noHistory: "No history versions",
noHistoryHint: "Refresh if this config was just published. Some Nacos versions may not expose history APIs.",
historyCreate: "Create",
historyUpdate: "Update",
historyDelete: "Delete",
historyRollback: "Rollback",
confirmRollbackTitle: "Rollback Nacos config",
confirmRollbackMessage: "This will roll the current config back to the selected history version.",
rollbackSuccess: "Config rolled back",
publish: "Publicar",
dataIdRequired: "Data ID es obligatorio",
allGroups: "Todos los grupos",
noConfigs: "Sin configuraciones",
selectConfig: "Selecciona o crea una configuración",
noServices: "Sin servicios",
selectService: "Selecciona un servicio",
service: "Servicio",
instances: "Instancias",
instanceCount: "{count} instancias",
refresh: "Actualizar",
address: "Dirección",
cluster: "Clúster",
weight: "Peso",
state: "Estado",
actions: "Acciones",
query: "Consulta",
body: "Cuerpo",
response: "Respuesta",
template: "Plantilla",
configDiffTitle: "Comparar contenido de configuración",
inlineCompare: "Comparación en línea",
currentVersionContent: "Versión oficial actual:",
publishVersionContent: "Contenido de esta publicación:",
confirmSaveTitle: "Revisar cambios de configuración Nacos",
confirmSaveMessage: "¿Guardar este cambio? +{added} / -{removed} líneas.",
confirmDeleteTitle: "Eliminar configuración Nacos",
confirmDeleteMessage: "Esto elimina la configuración seleccionada de Nacos.",
confirmInstanceTitle: "Actualizar instancia Nacos",
confirmInstanceMessage: "Confirma el estado objetivo de la instancia.",
confirmRawTitle: "Enviar solicitud Raw API modificadora",
confirmRawMessage: "Esta solicitud Raw API puede modificar el estado de Nacos.",
rawTemplate: {
serverState: "Estado del servidor",
namespaceList: "Lista de namespaces",
configDetail: "Detalle de configuración",
serviceList: "Lista de servicios",
instanceList: "Lista de instancias",
},
healthy: "Saludable",
unhealthy: "No saludable",
enabled: "Habilitado",
offline: "Offline",
ephemeral: "Efímera",
persistent: "Persistente",
enable: "Habilitar",
disable: "Deshabilitar",
markHealthy: "Marcar saludable",
markUnhealthy: "Marcar no saludable",
send: "Enviar",
total: "{count} en total",
prev: "Anterior",
next: "Siguiente",
test: "Probar",
readOnly: "Solo lectura",
},
};
+119
View File
@@ -235,6 +235,20 @@ export default {
redisSentinelTls: "TLS Sentinel",
redisSentinelTlsHint: "Usa TLS quando ti connetti ai nodi Sentinel",
redisKeySeparator: "Separatore namespace chiavi",
nacosConsoleUrl: "URL console",
nacosConsoleUrlHint: "Usa l'indirizzo console/admin API di Nacos. Nacos 3 Docker di solito espone la console su 8085; distribuzioni più vecchie possono condividere 8848 con la porta di servizio.",
nacosConsoleUrlRequired: "L'URL console Nacos è obbligatorio",
nacosConsoleUrlAutoAdjusted: "URL console Nacos regolato da {from} a {to}.",
nacosNamespace: "Namespace",
nacosContextPath: "Context Path",
nacosContextPathPlaceholder: "Lascia vuoto o /nacos",
nacosAuth: "Autenticazione",
nacosAuthNone: "Nessuna",
nacosAuthUserPassword: "Utente / Password",
nacosUsernameRequired: "Il nome utente Nacos è obbligatorio",
nacosTls: "TLS",
nacosTlsSkipVerify: "Salta verifica certificato",
nacosPageSize: "Dimensione pagina",
searchDatabasePlaceholder: "Cerca tipi di database",
iconView: "Vista icone",
listView: "Vista elenco",
@@ -2428,4 +2442,109 @@ export default {
exportError: "Esportazione non riuscita: {error}",
exportCancelled: "Esportazione annullata",
},
nacos: {
configs: "Configurazioni",
services: "Servizi",
raw: "Raw",
namespace: "Namespace",
dataId: "Data ID",
group: "Gruppo",
format: "Formato",
content: "Contenuto",
tags: "Tag",
application: "Applicazione",
description: "Descrizione",
advanced: "Avanzate",
collapse: "Comprimi",
load: "Carica",
save: "Salva",
saveAs: "Salva come",
saving: "Salvataggio...",
saved: "Configurazione salvata",
createdAndLoaded: "{dataId} creato e caricato",
savedAndLoaded: "{dataId} salvato e aggiornato",
deleted: "Configurazione eliminata",
delete: "Elimina",
copy: "Copia",
copied: "Copiato",
export: "Esporta",
exported: "Esportazione copiata",
history: "History",
configHistory: "Config history",
historyDetail: "History detail",
historyCompareTitle: "History Version Compare",
currentPublishedContent: "Current published content:",
historyVersionContent: "History version content:",
updatedAt: "Updated at",
operationType: "Operation",
operator: "Operator",
view: "View",
compare: "Compare",
rollback: "Rollback",
loadingHistory: "Loading history...",
noHistory: "No history versions",
noHistoryHint: "Refresh if this config was just published. Some Nacos versions may not expose history APIs.",
historyCreate: "Create",
historyUpdate: "Update",
historyDelete: "Delete",
historyRollback: "Rollback",
confirmRollbackTitle: "Rollback Nacos config",
confirmRollbackMessage: "This will roll the current config back to the selected history version.",
rollbackSuccess: "Config rolled back",
publish: "Pubblica",
dataIdRequired: "Data ID è obbligatorio",
allGroups: "Tutti i gruppi",
noConfigs: "Nessuna configurazione",
selectConfig: "Seleziona o crea una configurazione",
noServices: "Nessun servizio",
selectService: "Seleziona un servizio",
service: "Servizio",
instances: "Istanze",
instanceCount: "{count} istanze",
refresh: "Aggiorna",
address: "Indirizzo",
cluster: "Cluster",
weight: "Peso",
state: "Stato",
actions: "Azioni",
query: "Query",
body: "Corpo",
response: "Risposta",
template: "Template",
configDiffTitle: "Confronto contenuto configurazione",
inlineCompare: "Confronto inline",
currentVersionContent: "Versione ufficiale attuale:",
publishVersionContent: "Contenuto di questa pubblicazione:",
confirmSaveTitle: "Rivedi modifiche configurazione Nacos",
confirmSaveMessage: "Salvare questa modifica? +{added} / -{removed} righe.",
confirmDeleteTitle: "Elimina configurazione Nacos",
confirmDeleteMessage: "Questo rimuove la configurazione selezionata da Nacos.",
confirmInstanceTitle: "Aggiorna istanza Nacos",
confirmInstanceMessage: "Conferma lo stato di destinazione dell'istanza.",
confirmRawTitle: "Invia richiesta Raw API modificante",
confirmRawMessage: "Questa richiesta Raw API può modificare lo stato di Nacos.",
rawTemplate: {
serverState: "Stato server",
namespaceList: "Lista namespace",
configDetail: "Dettaglio configurazione",
serviceList: "Lista servizi",
instanceList: "Lista istanze",
},
healthy: "Sana",
unhealthy: "Non sana",
enabled: "Abilitata",
offline: "Offline",
ephemeral: "Effimera",
persistent: "Persistente",
enable: "Abilita",
disable: "Disabilita",
markHealthy: "Segna sana",
markUnhealthy: "Segna non sana",
send: "Invia",
total: "{count} totali",
prev: "Prec.",
next: "Succ.",
test: "Test",
readOnly: "Sola lettura",
},
};
+119
View File
@@ -249,6 +249,20 @@ export default {
etcdClientCertBrowse: "クライアント証明書を選択",
etcdClientKeyBrowse: "クライアント秘密鍵を選択",
etcdClientCertPairRequired: "クライアント証明書と秘密鍵の両方を指定する必要があります。",
nacosConsoleUrl: "コンソールURL",
nacosConsoleUrlHint: "Nacosのコンソール/admin APIアドレスを指定します。Nacos 3 Dockerでは通常8085、古い構成ではサービス用ポート8848と共用される場合があります。",
nacosConsoleUrlRequired: "NacosコンソールURLは必須です",
nacosConsoleUrlAutoAdjusted: "NacosコンソールURLを{from}から{to}に自動調整しました。",
nacosNamespace: "名前空間",
nacosContextPath: "Context Path",
nacosContextPathPlaceholder: "空欄または /nacos",
nacosAuth: "認証",
nacosAuthNone: "なし",
nacosAuthUserPassword: "ユーザー / パスワード",
nacosUsernameRequired: "Nacosユーザー名は必須です",
nacosTls: "TLS",
nacosTlsSkipVerify: "証明書検証をスキップ",
nacosPageSize: "ページサイズ",
searchDatabasePlaceholder: "データベースタイプを検索",
iconView: "アイコン表示",
listView: "リスト表示",
@@ -2674,4 +2688,109 @@ export default {
exportError: "エクスポートに失敗しました: {error}",
exportCancelled: "エクスポートがキャンセルされました",
},
nacos: {
configs: "設定",
services: "サービス",
raw: "Raw",
namespace: "名前空間",
dataId: "Data ID",
group: "Group",
format: "設定形式",
content: "設定内容",
tags: "タグ",
application: "アプリケーション",
description: "説明",
advanced: "詳細設定",
collapse: "閉じる",
load: "読み込み",
save: "保存",
saveAs: "別名で保存",
saving: "保存中...",
saved: "設定を保存しました",
createdAndLoaded: "{dataId}を作成して読み込みました",
savedAndLoaded: "{dataId}を保存して更新しました",
deleted: "設定を削除しました",
delete: "削除",
copy: "コピー",
copied: "コピーしました",
export: "エクスポート",
exported: "エクスポート内容をコピーしました",
history: "History",
configHistory: "Config history",
historyDetail: "History detail",
historyCompareTitle: "History Version Compare",
currentPublishedContent: "Current published content:",
historyVersionContent: "History version content:",
updatedAt: "Updated at",
operationType: "Operation",
operator: "Operator",
view: "View",
compare: "Compare",
rollback: "Rollback",
loadingHistory: "Loading history...",
noHistory: "No history versions",
noHistoryHint: "Refresh if this config was just published. Some Nacos versions may not expose history APIs.",
historyCreate: "Create",
historyUpdate: "Update",
historyDelete: "Delete",
historyRollback: "Rollback",
confirmRollbackTitle: "Rollback Nacos config",
confirmRollbackMessage: "This will roll the current config back to the selected history version.",
rollbackSuccess: "Config rolled back",
publish: "公開",
dataIdRequired: "Data ID は必須です",
allGroups: "すべてのグループ",
noConfigs: "設定がありません",
selectConfig: "設定を選択または作成",
noServices: "サービスがありません",
selectService: "サービスを選択",
service: "サービス",
instances: "インスタンス",
instanceCount: "{count}件のインスタンス",
refresh: "更新",
address: "アドレス",
cluster: "クラスター",
weight: "重み",
state: "状態",
actions: "操作",
query: "クエリ",
body: "本文",
response: "レスポンス",
template: "テンプレート",
configDiffTitle: "設定内容の比較",
inlineCompare: "行内比較",
currentVersionContent: "現在の正式バージョン:",
publishVersionContent: "今回の公開内容:",
confirmSaveTitle: "Nacos設定の変更を確認",
confirmSaveMessage: "この設定変更を保存しますか? +{added} / -{removed} 行。",
confirmDeleteTitle: "Nacos設定を削除",
confirmDeleteMessage: "選択した設定をNacosから削除します。",
confirmInstanceTitle: "Nacosインスタンスを更新",
confirmInstanceMessage: "対象サービスインスタンスの状態を確認してください。",
confirmRawTitle: "変更系Raw APIリクエストを送信",
confirmRawMessage: "このRaw APIリクエストはNacos状態を変更する可能性があります。",
rawTemplate: {
serverState: "サーバー状態",
namespaceList: "名前空間一覧",
configDetail: "設定詳細",
serviceList: "サービス一覧",
instanceList: "インスタンス一覧",
},
healthy: "正常",
unhealthy: "異常",
enabled: "有効",
offline: "オフライン",
ephemeral: "一時",
persistent: "永続",
enable: "有効化",
disable: "無効化",
markHealthy: "正常にする",
markUnhealthy: "異常にする",
send: "送信",
total: "合計 {count} 件",
prev: "前へ",
next: "次へ",
test: "テスト",
readOnly: "読み取り専用",
},
};
+119
View File
@@ -235,6 +235,20 @@ export default {
redisSentinelTls: "TLS do Sentinel",
redisSentinelTlsHint: "Usar TLS ao conectar aos nós Sentinel",
redisKeySeparator: "Separador de namespace de chave",
nacosConsoleUrl: "URL do console",
nacosConsoleUrlHint: "Use o endereço do console/admin API do Nacos. O Nacos 3 Docker geralmente expõe o console em 8085; implantações antigas podem compartilhar 8848 com a porta de serviço.",
nacosConsoleUrlRequired: "A URL do console Nacos é obrigatória",
nacosConsoleUrlAutoAdjusted: "URL do console Nacos ajustada de {from} para {to}.",
nacosNamespace: "Namespace",
nacosContextPath: "Context Path",
nacosContextPathPlaceholder: "Deixe vazio ou /nacos",
nacosAuth: "Autenticação",
nacosAuthNone: "Nenhuma",
nacosAuthUserPassword: "Usuário / Senha",
nacosUsernameRequired: "O usuário Nacos é obrigatório",
nacosTls: "TLS",
nacosTlsSkipVerify: "Ignorar verificação do certificado",
nacosPageSize: "Tamanho da página",
searchDatabasePlaceholder: "Pesquisar tipos de banco de dados",
iconView: "Visualização em ícones",
listView: "Visualização em lista",
@@ -2439,4 +2453,109 @@ export default {
exportError: "Falha na exportação: {error}",
exportCancelled: "Exportação cancelada",
},
nacos: {
configs: "Configurações",
services: "Serviços",
raw: "Raw",
namespace: "Namespace",
dataId: "Data ID",
group: "Grupo",
format: "Formato",
content: "Conteúdo",
tags: "Tags",
application: "Aplicação",
description: "Descrição",
advanced: "Avançado",
collapse: "Recolher",
load: "Carregar",
save: "Salvar",
saveAs: "Salvar como",
saving: "Salvando...",
saved: "Configuração salva",
createdAndLoaded: "{dataId} criado e carregado",
savedAndLoaded: "{dataId} salvo e atualizado",
deleted: "Configuração excluída",
delete: "Excluir",
copy: "Copiar",
copied: "Copiado",
export: "Exportar",
exported: "Exportação copiada",
history: "History",
configHistory: "Config history",
historyDetail: "History detail",
historyCompareTitle: "History Version Compare",
currentPublishedContent: "Current published content:",
historyVersionContent: "History version content:",
updatedAt: "Updated at",
operationType: "Operation",
operator: "Operator",
view: "View",
compare: "Compare",
rollback: "Rollback",
loadingHistory: "Loading history...",
noHistory: "No history versions",
noHistoryHint: "Refresh if this config was just published. Some Nacos versions may not expose history APIs.",
historyCreate: "Create",
historyUpdate: "Update",
historyDelete: "Delete",
historyRollback: "Rollback",
confirmRollbackTitle: "Rollback Nacos config",
confirmRollbackMessage: "This will roll the current config back to the selected history version.",
rollbackSuccess: "Config rolled back",
publish: "Publicar",
dataIdRequired: "Data ID é obrigatório",
allGroups: "Todos os grupos",
noConfigs: "Nenhuma configuração",
selectConfig: "Selecione ou crie uma configuração",
noServices: "Nenhum serviço",
selectService: "Selecione um serviço",
service: "Serviço",
instances: "Instâncias",
instanceCount: "{count} instâncias",
refresh: "Atualizar",
address: "Endereço",
cluster: "Cluster",
weight: "Peso",
state: "Estado",
actions: "Ações",
query: "Query",
body: "Corpo",
response: "Resposta",
template: "Modelo",
configDiffTitle: "Comparar conteúdo da configuração",
inlineCompare: "Comparação inline",
currentVersionContent: "Versão oficial atual:",
publishVersionContent: "Conteúdo desta publicação:",
confirmSaveTitle: "Revisar alterações da configuração Nacos",
confirmSaveMessage: "Salvar esta alteração? +{added} / -{removed} linhas.",
confirmDeleteTitle: "Excluir configuração Nacos",
confirmDeleteMessage: "Isso remove a configuração selecionada do Nacos.",
confirmInstanceTitle: "Atualizar instância Nacos",
confirmInstanceMessage: "Confirme o estado de destino da instância.",
confirmRawTitle: "Enviar solicitação Raw API mutável",
confirmRawMessage: "Esta solicitação Raw API pode modificar o estado do Nacos.",
rawTemplate: {
serverState: "Estado do servidor",
namespaceList: "Lista de namespaces",
configDetail: "Detalhe da configuração",
serviceList: "Lista de serviços",
instanceList: "Lista de instâncias",
},
healthy: "Saudável",
unhealthy: "Não saudável",
enabled: "Ativado",
offline: "Offline",
ephemeral: "Efêmera",
persistent: "Persistente",
enable: "Ativar",
disable: "Desativar",
markHealthy: "Marcar saudável",
markUnhealthy: "Marcar não saudável",
send: "Enviar",
total: "{count} no total",
prev: "Anterior",
next: "Próximo",
test: "Testar",
readOnly: "Somente leitura",
},
};
+131
View File
@@ -251,6 +251,20 @@ export default {
etcdClientCertBrowse: "选择客户端证书",
etcdClientKeyBrowse: "选择客户端私钥",
etcdClientCertPairRequired: "客户端证书和私钥必须一起填写。",
nacosConsoleUrl: "控制台 URL",
nacosConsoleUrlHint: "填写 Nacos 控制台/admin API 地址。Nacos 3 Docker 通常是 8085;如果旧版控制台和服务共用 8848,也可以填 8848。",
nacosConsoleUrlRequired: "Nacos 控制台 URL 不能为空",
nacosConsoleUrlAutoAdjusted: "已自动将 Nacos 控制台 URL 从 {from} 调整为 {to}。",
nacosNamespace: "命名空间",
nacosContextPath: "Context Path",
nacosContextPathPlaceholder: "留空或填写 /nacos",
nacosAuth: "认证",
nacosAuthNone: "无",
nacosAuthUserPassword: "用户名 / 密码",
nacosUsernameRequired: "Nacos 用户名不能为空",
nacosTls: "TLS",
nacosTlsSkipVerify: "跳过证书验证",
nacosPageSize: "分页大小",
searchDatabasePlaceholder: "搜索数据库类型",
iconView: "图标视图",
listView: "列表视图",
@@ -2744,4 +2758,121 @@ export default {
exportError: "导出失败: {error}",
exportCancelled: "导出已取消",
},
nacos: {
configs: "配置",
services: "服务",
raw: "Raw",
namespace: "命名空间",
createNamespace: "新建命名空间",
editNamespace: "编辑命名空间",
creatingNamespace: "正在创建...",
updatingNamespace: "正在更新...",
namespaceCreated: "命名空间「{name}」已创建",
namespaceUpdated: "命名空间「{name}」已更新",
namespaceId: "命名空间 ID",
namespaceIdPlaceholder: "留空则由 Nacos 自动生成",
namespaceName: "命名空间名",
namespaceNamePlaceholder: "请输入命名空间名",
namespaceDesc: "描述",
namespaceDescPlaceholder: "留空则使用命名空间名",
dataId: "Data ID",
group: "Group",
format: "配置格式",
content: "配置内容",
tags: "标签",
application: "归属应用",
description: "描述",
advanced: "高级配置",
collapse: "收起",
load: "加载",
save: "保存",
saveAs: "另存为",
saving: "保存中...",
saved: "配置已保存",
createdAndLoaded: "已创建并加载 {dataId}",
savedAndLoaded: "已保存并刷新 {dataId}",
deleted: "配置已删除",
delete: "删除",
copy: "复制",
copied: "已复制",
export: "导出",
exported: "导出内容已复制",
history: "历史",
configHistory: "配置历史",
historyDetail: "历史版本详情",
historyCompareTitle: "历史版本对比",
currentPublishedContent: "当前已发布内容:",
historyVersionContent: "历史版本内容:",
updatedAt: "更新时间",
operationType: "操作类型",
operator: "操作人",
view: "查看",
compare: "对比",
rollback: "回滚",
loadingHistory: "正在加载历史版本...",
noHistory: "暂无历史版本",
noHistoryHint: "如果刚发布过配置,请刷新;部分 Nacos 版本可能未开启历史记录接口。",
historyCreate: "新增",
historyUpdate: "更新",
historyDelete: "删除",
historyRollback: "回滚",
confirmRollbackTitle: "回滚 Nacos 配置",
confirmRollbackMessage: "该操作会将当前配置回滚到所选历史版本。",
rollbackSuccess: "配置已回滚",
publish: "发布",
dataIdRequired: "Data ID 不能为空",
allGroups: "全部分组",
noConfigs: "暂无配置",
selectConfig: "选择或新建配置",
noServices: "暂无服务",
selectService: "选择服务",
service: "服务",
instances: "实例",
instanceCount: "{count} 个实例",
refresh: "刷新",
address: "地址",
cluster: "集群",
weight: "权重",
state: "状态",
actions: "操作",
query: "查询参数",
body: "请求体",
response: "响应",
template: "模板",
configDiffTitle: "配置内容对比",
inlineCompare: "行内对比",
currentVersionContent: "当前正式版本内容:",
publishVersionContent: "本次发布内容:",
confirmSaveTitle: "确认保存 Nacos 配置",
confirmSaveMessage: "是否保存这次配置变更?新增 {added} 行,删除 {removed} 行。",
confirmDeleteTitle: "删除 Nacos 配置",
confirmDeleteMessage: "该操作会从 Nacos 删除所选配置。",
confirmInstanceTitle: "更新 Nacos 实例",
confirmInstanceMessage: "请确认目标服务实例状态。",
confirmRawTitle: "发送变更型 Raw API 请求",
confirmRawMessage: "该 Raw API 请求可能会修改 Nacos 状态。",
rawTemplate: {
serverState: "服务状态",
namespaceList: "命名空间列表",
configDetail: "配置详情",
serviceList: "服务列表",
instanceList: "实例列表",
},
healthy: "健康",
unhealthy: "不健康",
enabled: "已启用",
offline: "已下线",
ephemeral: "临时实例",
persistent: "持久实例",
enable: "启用",
disable: "禁用",
markHealthy: "标记健康",
markUnhealthy: "标记不健康",
send: "发送",
total: "共 {count} 条",
prev: "上一页",
next: "下一页",
test: "测试",
readOnly: "只读",
},
};
+119
View File
@@ -235,6 +235,20 @@ export default {
redisSentinelTls: "Sentinel TLS",
redisSentinelTlsHint: "連線 Sentinel 節點時使用 TLS",
redisKeySeparator: "鍵名分隔符(可選)",
nacosConsoleUrl: "控制台 URL",
nacosConsoleUrlHint: "填寫 Nacos 控制台/admin API 位址。Nacos 3 Docker 通常是 8085;如果舊版控制台和服務共用 8848,也可以填 8848。",
nacosConsoleUrlRequired: "Nacos 控制台 URL 不能為空",
nacosConsoleUrlAutoAdjusted: "已自動將 Nacos 控制台 URL 從 {from} 調整為 {to}。",
nacosNamespace: "命名空間",
nacosContextPath: "Context Path",
nacosContextPathPlaceholder: "留空或填寫 /nacos",
nacosAuth: "認證",
nacosAuthNone: "無",
nacosAuthUserPassword: "使用者名稱 / 密碼",
nacosUsernameRequired: "Nacos 使用者名稱不能為空",
nacosTls: "TLS",
nacosTlsSkipVerify: "跳過憑證驗證",
nacosPageSize: "分頁大小",
searchDatabasePlaceholder: "搜尋資料庫類型",
iconView: "圖示檢視",
listView: "清單檢視",
@@ -2453,4 +2467,109 @@ export default {
exportError: "匯出失敗: {error}",
exportCancelled: "匯出已取消",
},
nacos: {
configs: "配置",
services: "服務",
raw: "Raw",
namespace: "命名空間",
dataId: "Data ID",
group: "Group",
format: "配置格式",
content: "配置內容",
tags: "標籤",
application: "歸屬應用",
description: "描述",
advanced: "進階配置",
collapse: "收起",
load: "載入",
save: "儲存",
saveAs: "另存為",
saving: "儲存中...",
saved: "配置已儲存",
createdAndLoaded: "已建立並載入 {dataId}",
savedAndLoaded: "已儲存並重新整理 {dataId}",
deleted: "配置已刪除",
delete: "刪除",
copy: "複製",
copied: "已複製",
export: "匯出",
exported: "匯出內容已複製",
history: "歷史",
configHistory: "配置歷史",
historyDetail: "歷史版本詳情",
historyCompareTitle: "歷史版本對比",
currentPublishedContent: "目前已發布內容:",
historyVersionContent: "歷史版本內容:",
updatedAt: "更新時間",
operationType: "操作類型",
operator: "操作人",
view: "查看",
compare: "對比",
rollback: "回滾",
loadingHistory: "正在載入歷史版本...",
noHistory: "暫無歷史版本",
noHistoryHint: "如果剛發布過配置,請重新整理;部分 Nacos 版本可能未開啟歷史記錄介面。",
historyCreate: "新增",
historyUpdate: "更新",
historyDelete: "刪除",
historyRollback: "回滾",
confirmRollbackTitle: "回滾 Nacos 配置",
confirmRollbackMessage: "該操作會將目前配置回滾到所選歷史版本。",
rollbackSuccess: "配置已回滾",
publish: "發布",
dataIdRequired: "Data ID 不能為空",
allGroups: "全部分組",
noConfigs: "暫無配置",
selectConfig: "選擇或新增配置",
noServices: "暫無服務",
selectService: "選擇服務",
service: "服務",
instances: "實例",
instanceCount: "{count} 個實例",
refresh: "重新整理",
address: "位址",
cluster: "叢集",
weight: "權重",
state: "狀態",
actions: "操作",
query: "查詢參數",
body: "請求體",
response: "回應",
template: "模板",
configDiffTitle: "配置內容對比",
inlineCompare: "行內對比",
currentVersionContent: "目前正式版本內容:",
publishVersionContent: "本次發布內容:",
confirmSaveTitle: "確認儲存 Nacos 配置",
confirmSaveMessage: "是否儲存這次配置變更?新增 {added} 行,刪除 {removed} 行。",
confirmDeleteTitle: "刪除 Nacos 配置",
confirmDeleteMessage: "此操作會從 Nacos 刪除所選配置。",
confirmInstanceTitle: "更新 Nacos 實例",
confirmInstanceMessage: "請確認目標服務實例狀態。",
confirmRawTitle: "傳送變更型 Raw API 請求",
confirmRawMessage: "此 Raw API 請求可能會修改 Nacos 狀態。",
rawTemplate: {
serverState: "服務狀態",
namespaceList: "命名空間列表",
configDetail: "配置詳情",
serviceList: "服務列表",
instanceList: "實例列表",
},
healthy: "健康",
unhealthy: "不健康",
enabled: "已啟用",
offline: "已下線",
ephemeral: "臨時實例",
persistent: "持久實例",
enable: "啟用",
disable: "停用",
markHealthy: "標記健康",
markUnhealthy: "標記不健康",
send: "傳送",
total: "共 {count} 筆",
prev: "上一頁",
next: "下一頁",
test: "測試",
readOnly: "唯讀",
},
};
@@ -24,6 +24,10 @@ describe("quickConnectionOpenTarget", () => {
expect(quickConnectionOpenTarget(connection("mq"))).toEqual({ kind: "mq-admin" });
});
it("opens Nacos connections in the Nacos admin console", () => {
expect(quickConnectionOpenTarget(connection("nacos"))).toEqual({ kind: "nacos-admin" });
});
it("opens regular connections in a query tab", () => {
expect(quickConnectionOpenTarget({ ...connection("postgresql"), database: "app" })).toEqual({
kind: "query",
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { buildNacosConfigDeleteConfirm, buildNacosInlineDiff, buildNacosInstanceConfirm, buildNacosRawRequest, buildNacosSideBySideDiff, isNacosRawMutation, parseNacosRawBody, parseNacosRawQuery, summarizeNacosConfigDiff } from "../nacosAdmin";
describe("nacosAdmin helpers", () => {
it("parses raw query and body text", () => {
expect(parseNacosRawQuery("?dataId=a&group=DEFAULT_GROUP")).toEqual({ dataId: "a", group: "DEFAULT_GROUP" });
expect(parseNacosRawQuery("")).toBeUndefined();
expect(parseNacosRawBody('{"enabled":false}')).toEqual({ enabled: false });
expect(parseNacosRawBody("plain text")).toBe("plain text");
});
it("builds raw requests and detects mutations", () => {
const req = buildNacosRawRequest("post", " /v1/cs/configs ", "a=1", '{"b":2}');
expect(req).toEqual({ method: "post", path: "/v1/cs/configs", query: { a: "1" }, body: { b: 2 } });
expect(isNacosRawMutation("GET")).toBe(false);
expect(isNacosRawMutation("DELETE")).toBe(true);
});
it("summarizes config diffs", () => {
const diff = summarizeNacosConfigDiff("a\nb", "a\nc\nd");
expect(diff.changed).toBe(true);
expect(diff.removedLines).toBe(1);
expect(diff.addedLines).toBe(2);
expect(diff.preview).toContain("- b");
expect(diff.preview).toContain("+ c");
});
it("builds side-by-side config diff rows with inline segments", () => {
const rows = buildNacosSideBySideDiff('cloud:\n secret: "aaa"\n', 'cloud:\n secret: "aaa1"\n enabled: true\n');
expect(rows[0]).toMatchObject({ leftLineNumber: 1, rightLineNumber: 1, leftType: "equal", rightType: "equal" });
expect(rows[1]).toMatchObject({ leftLineNumber: 2, rightLineNumber: 2, leftType: "modify", rightType: "modify" });
expect(rows[1].rightInline.some((segment) => segment.changed && segment.value === "1")).toBe(true);
expect(rows[2]).toMatchObject({ leftLineNumber: null, rightLineNumber: 3, leftType: "padding", rightType: "insert" });
});
it("builds inline config diff rows with character-level changed segments", () => {
const rows = buildNacosInlineDiff('secretId: "aaa1"\n', 'secretId: "aaa2"\n');
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({ lineNumber: 1, type: "delete" });
expect(rows[1]).toMatchObject({ lineNumber: 1, type: "insert" });
expect(rows[0].segments.some((segment) => segment.changed && segment.value === "1")).toBe(true);
expect(rows[1].segments.some((segment) => segment.changed && segment.value === "2")).toBe(true);
});
it("includes identifying fields in confirmations", () => {
expect(buildNacosConfigDeleteConfirm({ namespace: "", dataId: "app.yaml", group: "DEFAULT_GROUP" })).toContain("dataId=app.yaml");
const details = buildNacosInstanceConfirm({ serviceName: "DEFAULT_GROUP@@svc", groupName: "DEFAULT_GROUP" }, { ip: "127.0.0.1", port: 8080, enabled: true, metadata: null }, { enabled: false }, "", "public");
expect(details).toContain("serviceName=DEFAULT_GROUP@@svc");
expect(details).toContain("targetEnabled=false");
});
});
+17
View File
@@ -228,6 +228,23 @@ export const pendingOpenDbFiles = forward("pendingOpenDbFiles");
export const pendingOpenConnectionLinks = forward("pendingOpenConnectionLinks");
export const readExternalSqlFile = forward("readExternalSqlFile");
// Nacos
export const nacosTestConnection = forward("nacosTestConnection");
export const nacosListNamespaces = forward("nacosListNamespaces");
export const nacosCreateNamespace = forward("nacosCreateNamespace");
export const nacosUpdateNamespace = forward("nacosUpdateNamespace");
export const nacosListConfigs = forward("nacosListConfigs");
export const nacosGetConfig = forward("nacosGetConfig");
export const nacosPublishConfig = forward("nacosPublishConfig");
export const nacosDeleteConfig = forward("nacosDeleteConfig");
export const nacosListConfigHistory = forward("nacosListConfigHistory");
export const nacosGetConfigHistory = forward("nacosGetConfigHistory");
export const nacosRollbackConfig = forward("nacosRollbackConfig");
export const nacosListServices = forward("nacosListServices");
export const nacosListInstances = forward("nacosListInstances");
export const nacosUpdateInstance = forward("nacosUpdateInstance");
export const nacosRawRequest = forward("nacosRawRequest");
// Data Transfer
export const startTransfer = forward("startTransfer");
export const cancelTransfer = forward("cancelTransfer");
+4 -1
View File
@@ -1,11 +1,14 @@
import type { ConnectionConfig } from "@/types/database";
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
export type QuickConnectionOpenTarget = { kind: "mq-admin" } | { kind: "query"; database: string };
export type QuickConnectionOpenTarget = { kind: "mq-admin" } | { kind: "nacos-admin" } | { kind: "query"; database: string };
export function quickConnectionOpenTarget(connection: Pick<ConnectionConfig, "db_type" | "database">, databaseOptions: string[] = []): QuickConnectionOpenTarget {
if (connection.db_type === "mq") {
return { kind: "mq-admin" };
}
if (connection.db_type === "nacos") {
return { kind: "nacos-admin" };
}
return { kind: "query", database: resolveDefaultDatabase(connection, databaseOptions) };
}
+86
View File
@@ -96,6 +96,28 @@ import type { DatabaseNameSqlOptions, DropTableChildObjectSqlOptions, DropObject
import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions } from "@/lib/databaseExport";
import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataCompare";
import type { DataGridSavePreparation } from "./tauri";
import type {
NacosConfigHistoryKey,
NacosConfigHistoryList,
NacosConfigHistoryQuery,
NacosConfigItem,
NacosConfigKey,
NacosConfigList,
NacosConfigQuery,
NacosConfigRollbackRequest,
NacosConfigUpsert,
NacosConnectionInfo,
NacosInstanceInfo,
NacosInstanceQuery,
NacosInstanceUpdate,
NacosNamespaceCreate,
NacosNamespaceInfo,
NacosNamespaceUpdate,
NacosRawRequest,
NacosRawResponse,
NacosServiceList,
NacosServiceQuery,
} from "@/types/nacos";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage";
// ---------------------------------------------------------------------------
@@ -1500,6 +1522,70 @@ export async function etcdDelete(connectionId: string, key: string): Promise<KvD
return post("/api/etcd/delete", { connectionId, key });
}
// ---------------------------------------------------------------------------
// Nacos
// ---------------------------------------------------------------------------
export async function nacosTestConnection(connectionId: string): Promise<NacosConnectionInfo> {
return post("/api/nacos/test-connection", { connectionId });
}
export async function nacosListNamespaces(connectionId: string): Promise<NacosNamespaceInfo[]> {
return post("/api/nacos/namespaces/list", { connectionId });
}
export async function nacosCreateNamespace(connectionId: string, req: NacosNamespaceCreate): Promise<void> {
return post("/api/nacos/namespaces/create", { connectionId, req });
}
export async function nacosUpdateNamespace(connectionId: string, req: NacosNamespaceUpdate): Promise<void> {
return post("/api/nacos/namespaces/update", { connectionId, req });
}
export async function nacosListConfigs(connectionId: string, query: NacosConfigQuery): Promise<NacosConfigList> {
return post("/api/nacos/configs/list", { connectionId, query });
}
export async function nacosGetConfig(connectionId: string, key: NacosConfigKey): Promise<NacosConfigItem> {
return post("/api/nacos/configs/get", { connectionId, key });
}
export async function nacosPublishConfig(connectionId: string, req: NacosConfigUpsert): Promise<void> {
return post("/api/nacos/configs/publish", { connectionId, req });
}
export async function nacosDeleteConfig(connectionId: string, key: NacosConfigKey): Promise<void> {
return post("/api/nacos/configs/delete", { connectionId, key });
}
export async function nacosListConfigHistory(connectionId: string, query: NacosConfigHistoryQuery): Promise<NacosConfigHistoryList> {
return post("/api/nacos/configs/history/list", { connectionId, query });
}
export async function nacosGetConfigHistory(connectionId: string, key: NacosConfigHistoryKey): Promise<NacosConfigItem> {
return post("/api/nacos/configs/history/get", { connectionId, key });
}
export async function nacosRollbackConfig(connectionId: string, req: NacosConfigRollbackRequest): Promise<void> {
return post("/api/nacos/configs/history/rollback", { connectionId, req });
}
export async function nacosListServices(connectionId: string, query: NacosServiceQuery): Promise<NacosServiceList> {
return post("/api/nacos/services/list", { connectionId, query });
}
export async function nacosListInstances(connectionId: string, query: NacosInstanceQuery): Promise<NacosInstanceInfo[]> {
return post("/api/nacos/instances/list", { connectionId, query });
}
export async function nacosUpdateInstance(connectionId: string, req: NacosInstanceUpdate): Promise<void> {
return post("/api/nacos/instances/update", { connectionId, req });
}
export async function nacosRawRequest(connectionId: string, req: NacosRawRequest): Promise<NacosRawResponse> {
return post("/api/nacos/raw", { connectionId, req });
}
// ---------------------------------------------------------------------------
// MongoDB
// ---------------------------------------------------------------------------
+83
View File
@@ -0,0 +1,83 @@
import { invoke } from "@tauri-apps/api/core";
import type {
NacosConfigHistoryKey,
NacosConfigHistoryList,
NacosConfigHistoryQuery,
NacosConfigItem,
NacosConfigKey,
NacosConfigList,
NacosConfigQuery,
NacosConfigRollbackRequest,
NacosConfigUpsert,
NacosConnectionInfo,
NacosInstanceInfo,
NacosInstanceQuery,
NacosInstanceUpdate,
NacosNamespaceCreate,
NacosNamespaceInfo,
NacosNamespaceUpdate,
NacosRawRequest,
NacosRawResponse,
NacosServiceList,
NacosServiceQuery,
} from "@/types/nacos";
export async function nacosTestConnection(connectionId: string): Promise<NacosConnectionInfo> {
return invoke("nacos_test_connection", { connectionId });
}
export async function nacosListNamespaces(connectionId: string): Promise<NacosNamespaceInfo[]> {
return invoke("nacos_list_namespaces", { connectionId });
}
export async function nacosCreateNamespace(connectionId: string, req: NacosNamespaceCreate): Promise<void> {
return invoke("nacos_create_namespace", { connectionId, req });
}
export async function nacosUpdateNamespace(connectionId: string, req: NacosNamespaceUpdate): Promise<void> {
return invoke("nacos_update_namespace", { connectionId, req });
}
export async function nacosListConfigs(connectionId: string, query: NacosConfigQuery): Promise<NacosConfigList> {
return invoke("nacos_list_configs", { connectionId, query });
}
export async function nacosGetConfig(connectionId: string, key: NacosConfigKey): Promise<NacosConfigItem> {
return invoke("nacos_get_config", { connectionId, key });
}
export async function nacosPublishConfig(connectionId: string, req: NacosConfigUpsert): Promise<void> {
return invoke("nacos_publish_config", { connectionId, req });
}
export async function nacosDeleteConfig(connectionId: string, key: NacosConfigKey): Promise<void> {
return invoke("nacos_delete_config", { connectionId, key });
}
export async function nacosListConfigHistory(connectionId: string, query: NacosConfigHistoryQuery): Promise<NacosConfigHistoryList> {
return invoke("nacos_list_config_history", { connectionId, query });
}
export async function nacosGetConfigHistory(connectionId: string, key: NacosConfigHistoryKey): Promise<NacosConfigItem> {
return invoke("nacos_get_config_history", { connectionId, key });
}
export async function nacosRollbackConfig(connectionId: string, req: NacosConfigRollbackRequest): Promise<void> {
return invoke("nacos_rollback_config", { connectionId, req });
}
export async function nacosListServices(connectionId: string, query: NacosServiceQuery): Promise<NacosServiceList> {
return invoke("nacos_list_services", { connectionId, query });
}
export async function nacosListInstances(connectionId: string, query: NacosInstanceQuery): Promise<NacosInstanceInfo[]> {
return invoke("nacos_list_instances", { connectionId, query });
}
export async function nacosUpdateInstance(connectionId: string, req: NacosInstanceUpdate): Promise<void> {
return invoke("nacos_update_instance", { connectionId, req });
}
export async function nacosRawRequest(connectionId: string, req: NacosRawRequest): Promise<NacosRawResponse> {
return invoke("nacos_raw_request", { connectionId, req });
}
+323
View File
@@ -0,0 +1,323 @@
import type { NacosConfigHistoryItem, NacosConfigItem, NacosInstanceInfo, NacosRawRequest, NacosServiceInfo } from "@/types/nacos";
import { diffChars, diffLines } from "diff";
export type NacosRawTemplateKey = "serverState" | "namespaceList" | "configDetail" | "serviceList" | "instanceList";
export interface NacosRawTemplate {
key: NacosRawTemplateKey;
method: string;
path: string;
query: string;
body: string;
}
export const NACOS_RAW_TEMPLATES: NacosRawTemplate[] = [
{ key: "serverState", method: "GET", path: "/v3/console/server/state", query: "", body: "" },
{ key: "namespaceList", method: "GET", path: "/v3/console/core/namespace/list", query: "", body: "" },
{
key: "configDetail",
method: "GET",
path: "/v3/console/cs/config",
query: "dataId=application.yaml&groupName=DEFAULT_GROUP&namespaceId=",
body: "",
},
{
key: "serviceList",
method: "GET",
path: "/v3/console/ns/service/list",
query: "pageNo=1&pageSize=20&namespaceId=",
body: "",
},
{
key: "instanceList",
method: "GET",
path: "/v3/console/ns/instance/list",
query: "serviceName=DEFAULT_GROUP@@example&namespaceId=",
body: "",
},
];
export function parseNacosRawQuery(text: string): Record<string, string> | undefined {
const trimmed = text.trim().replace(/^\?/, "");
if (!trimmed) return undefined;
return Object.fromEntries(new URLSearchParams(trimmed).entries());
}
export function parseNacosRawBody(text: string): unknown {
const trimmed = text.trim();
if (!trimmed) return undefined;
try {
return JSON.parse(trimmed);
} catch {
return text;
}
}
export function buildNacosRawRequest(method: string, path: string, queryText: string, bodyText: string): NacosRawRequest {
return {
method,
path: path.trim(),
query: parseNacosRawQuery(queryText),
body: parseNacosRawBody(bodyText),
};
}
export function isNacosRawMutation(method: string): boolean {
return method.trim().toUpperCase() !== "GET";
}
export function formatNacosConfigIdentity(item: Pick<NacosConfigItem, "namespace" | "dataId" | "group">, fallbackNamespace = ""): string {
return [`namespace=${item.namespace || fallbackNamespace || "public"}`, `dataId=${item.dataId}`, `group=${item.group || "DEFAULT_GROUP"}`].join("\n");
}
export function buildNacosConfigExport(item: NacosConfigItem, content: string): string {
return [`# namespace: ${item.namespace || "public"}`, `# dataId: ${item.dataId}`, `# group: ${item.group || "DEFAULT_GROUP"}`, item.configType ? `# type: ${item.configType}` : "", "", content].filter((line, index) => index >= 4 || line).join("\n");
}
export function buildNacosConfigCopy(item: NacosConfigItem, content: string): string {
return `${formatNacosConfigIdentity(item)}\n\n${content}`;
}
export function createNacosSaveAsCopy(item: NacosConfigItem): NacosConfigItem {
return {
...item,
dataId: item.dataId ? `${item.dataId}.copy` : "",
content: item.content ?? "",
};
}
export interface NacosDiffSummary {
changed: boolean;
addedLines: number;
removedLines: number;
preview: string;
}
export type NacosDiffLineType = "equal" | "delete" | "insert" | "modify" | "padding";
export interface NacosInlineSegment {
value: string;
changed: boolean;
}
export interface NacosSideBySideDiffRow {
id: string;
leftLineNumber: number | null;
rightLineNumber: number | null;
leftContent: string;
rightContent: string;
leftType: NacosDiffLineType;
rightType: NacosDiffLineType;
leftInline: NacosInlineSegment[];
rightInline: NacosInlineSegment[];
}
export interface NacosInlineDiffRow {
id: string;
lineNumber: number | null;
content: string;
type: Exclude<NacosDiffLineType, "modify" | "padding">;
segments: NacosInlineSegment[];
}
export function summarizeNacosConfigDiff(before: string, after: string, maxPreviewLines = 40): NacosDiffSummary {
if (before === after) {
return { changed: false, addedLines: 0, removedLines: 0, preview: "No content changes." };
}
const beforeLines = before.split(/\r?\n/);
const afterLines = after.split(/\r?\n/);
const max = Math.max(beforeLines.length, afterLines.length);
const lines: string[] = [];
let addedLines = 0;
let removedLines = 0;
for (let index = 0; index < max; index += 1) {
const left = beforeLines[index];
const right = afterLines[index];
if (left === right) continue;
if (left !== undefined) {
removedLines += 1;
lines.push(`- ${left}`);
}
if (right !== undefined) {
addedLines += 1;
lines.push(`+ ${right}`);
}
if (lines.length >= maxPreviewLines) {
lines.push("...");
break;
}
}
return { changed: true, addedLines, removedLines, preview: lines.join("\n") };
}
function normalizeNacosDiffText(value: string): string {
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
function splitDiffLines(value: string): string[] {
const lines = normalizeNacosDiffText(value).split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines;
}
function inlineSegments(left: string, right: string): { left: NacosInlineSegment[]; right: NacosInlineSegment[] } {
const changes = diffChars(left, right);
const leftSegments: NacosInlineSegment[] = [];
const rightSegments: NacosInlineSegment[] = [];
for (const change of changes) {
if (change.removed) {
leftSegments.push({ value: change.value, changed: true });
} else if (change.added) {
rightSegments.push({ value: change.value, changed: true });
} else {
leftSegments.push({ value: change.value, changed: false });
rightSegments.push({ value: change.value, changed: false });
}
}
return { left: leftSegments, right: rightSegments };
}
function pairChangedLines(leftLines: string[], rightLines: string[], leftStart: number, rightStart: number, rows: NacosSideBySideDiffRow[], nextId: () => string) {
const max = Math.max(leftLines.length, rightLines.length);
for (let index = 0; index < max; index += 1) {
const left = leftLines[index];
const right = rightLines[index];
const hasLeft = left !== undefined;
const hasRight = right !== undefined;
const inline = hasLeft && hasRight ? inlineSegments(left, right) : { left: [], right: [] };
rows.push({
id: nextId(),
leftLineNumber: hasLeft ? leftStart + index : null,
rightLineNumber: hasRight ? rightStart + index : null,
leftContent: left ?? "",
rightContent: right ?? "",
leftType: hasLeft ? (hasRight ? "modify" : "delete") : "padding",
rightType: hasRight ? (hasLeft ? "modify" : "insert") : "padding",
leftInline: inline.left,
rightInline: inline.right,
});
}
}
export function buildNacosSideBySideDiff(before: string, after: string): NacosSideBySideDiffRow[] {
const changes = diffLines(normalizeNacosDiffText(before), normalizeNacosDiffText(after), { newlineIsToken: false });
const rows: NacosSideBySideDiffRow[] = [];
let leftLineNumber = 1;
let rightLineNumber = 1;
let id = 0;
const nextId = () => `nacos-diff-${id++}`;
for (let index = 0; index < changes.length; index += 1) {
const change = changes[index];
if (!change.added && !change.removed) {
for (const line of splitDiffLines(change.value)) {
rows.push({
id: nextId(),
leftLineNumber,
rightLineNumber,
leftContent: line,
rightContent: line,
leftType: "equal",
rightType: "equal",
leftInline: [{ value: line, changed: false }],
rightInline: [{ value: line, changed: false }],
});
leftLineNumber += 1;
rightLineNumber += 1;
}
continue;
}
if (change.removed) {
const leftLines = splitDiffLines(change.value);
const next = changes[index + 1];
if (next?.added) {
const rightLines = splitDiffLines(next.value);
pairChangedLines(leftLines, rightLines, leftLineNumber, rightLineNumber, rows, nextId);
leftLineNumber += leftLines.length;
rightLineNumber += rightLines.length;
index += 1;
} else {
pairChangedLines(leftLines, [], leftLineNumber, rightLineNumber, rows, nextId);
leftLineNumber += leftLines.length;
}
continue;
}
if (change.added) {
const rightLines = splitDiffLines(change.value);
pairChangedLines([], rightLines, leftLineNumber, rightLineNumber, rows, nextId);
rightLineNumber += rightLines.length;
}
}
return rows;
}
function visibleInlineSegments(content: string, segments: NacosInlineSegment[]): NacosInlineSegment[] {
return segments.length ? segments : [{ value: content, changed: false }];
}
export function buildNacosInlineDiff(before: string, after: string): NacosInlineDiffRow[] {
return buildNacosSideBySideDiff(before, after).flatMap((row) => {
if (row.leftType === "equal" && row.rightType === "equal") {
return [
{
id: `${row.id}-equal`,
lineNumber: row.leftLineNumber,
content: row.leftContent,
type: "equal" as const,
segments: visibleInlineSegments(row.leftContent, row.leftInline),
},
];
}
const rows: NacosInlineDiffRow[] = [];
if (row.leftType === "delete" || row.leftType === "modify") {
rows.push({
id: `${row.id}-delete`,
lineNumber: row.leftLineNumber,
content: row.leftContent,
type: "delete",
segments: visibleInlineSegments(row.leftContent, row.leftInline),
});
}
if (row.rightType === "insert" || row.rightType === "modify") {
rows.push({
id: `${row.id}-insert`,
lineNumber: row.rightLineNumber,
content: row.rightContent,
type: "insert",
segments: visibleInlineSegments(row.rightContent, row.rightInline),
});
}
return rows;
});
}
export function buildNacosConfigDeleteConfirm(item: NacosConfigItem, fallbackNamespace = ""): string {
return formatNacosConfigIdentity(item, fallbackNamespace);
}
export function buildNacosConfigHistoryRollbackConfirm(item: NacosConfigHistoryItem, fallbackNamespace = ""): string {
return [`namespace=${item.namespace || fallbackNamespace || "public"}`, `dataId=${item.dataId}`, `group=${item.group || "DEFAULT_GROUP"}`, item.lastModifiedTime ? `historyTime=${item.lastModifiedTime}` : "", item.operator ? `operator=${item.operator}` : ""].filter(Boolean).join("\n");
}
export function buildNacosInstanceConfirm(service: NacosServiceInfo, instance: NacosInstanceInfo, patch: Partial<NacosInstanceInfo>, fallbackGroup = "", namespace = ""): string {
const targetEnabled = patch.enabled ?? instance.enabled;
const targetHealthy = patch.healthy ?? instance.healthy;
return [
`namespace=${namespace || "public"}`,
`serviceName=${service.serviceName}`,
`group=${instance.groupName || service.groupName || fallbackGroup || "DEFAULT_GROUP"}`,
`instance=${instance.ip}:${instance.port}`,
patch.enabled == null ? "" : `targetEnabled=${targetEnabled === false ? "false" : "true"}`,
patch.healthy == null ? "" : `targetHealthy=${targetHealthy === false ? "false" : "true"}`,
]
.filter(Boolean)
.join("\n");
}
export function buildNacosRawMutationConfirm(req: NacosRawRequest): string {
return [`method=${req.method.toUpperCase()}`, `path=${req.path}`, req.query ? `query=${JSON.stringify(req.query)}` : ""].filter(Boolean).join("\n");
}
@@ -33,6 +33,8 @@ export interface SavedOpenTab {
pinned?: boolean;
mode?: QueryTab["mode"];
mqTenant?: string;
nacosNamespace?: string;
nacosNamespaceName?: string;
structureTableName?: string;
objectBrowser?: QueryTab["objectBrowser"];
objectSource?: QueryTab["objectSource"];
@@ -72,6 +74,8 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
pinned: tab.pinned,
mode: tab.mode,
...(tab.mqTenant !== undefined ? { mqTenant: tab.mqTenant } : {}),
...(tab.nacosNamespace !== undefined ? { nacosNamespace: tab.nacosNamespace } : {}),
...(tab.nacosNamespaceName !== undefined ? { nacosNamespaceName: tab.nacosNamespaceName } : {}),
...(tab.structureTableName !== undefined ? { structureTableName: tab.structureTableName } : {}),
objectBrowser: tab.objectBrowser,
objectSource: tab.objectSource,
@@ -29,6 +29,11 @@ export type ActiveTabSidebarTarget =
connectionId: string;
tenant: string;
}
| {
type: "nacos-namespace";
connectionId: string;
namespace: string;
}
| {
type: "query-context";
connectionId: string;
@@ -84,6 +89,10 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
return { type: "mq-tenant", connectionId: tab.connectionId, tenant: tab.mqTenant };
}
if (tab.mode === "nacos") {
return { type: "nacos-namespace", connectionId: tab.connectionId, namespace: tab.nacosNamespace || "" };
}
if (tab.savedSqlId) {
return { type: "saved-sql-file", savedSqlId: tab.savedSqlId };
}
@@ -133,6 +142,10 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
return node.type === "mq-tenant" && node.connectionId === target.connectionId && (node.mqTenant || node.label) === target.tenant;
}
if (target.type === "nacos-namespace") {
return node.type === "nacos-namespace" && node.connectionId === target.connectionId && (node.nacosNamespace || "") === target.namespace;
}
if (target.type === "saved-sql-file") {
return node.type === "saved-sql-file" && node.savedSqlId === target.savedSqlId;
}
+1
View File
@@ -181,6 +181,7 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
if (tab.mode === "vector") return t("tabs.vector");
if (tab.mode === "redis") return t("tabs.redis");
if (tab.mode === "etcd") return t("tabs.etcd");
if (tab.mode === "nacos") return "Nacos";
if (tab.mode === "objects") return t("tabs.objects");
if (tab.mode === "users") return t("tabs.users");
return tab.mode;
+1
View File
@@ -1843,3 +1843,4 @@ export async function exportQueryResultMarkdown(filePath: string, columns: strin
}
export * from "./mq-tauri";
export * from "./nacos-tauri";
@@ -879,6 +879,8 @@ export const useConnectionStore = defineStore("connection", () => {
await loadVectorCollections(connectionId);
} else if (config.db_type === "mq") {
await loadMqTenants(connectionId, { force: true });
} else if (config.db_type === "nacos") {
await loadNacosNamespaces(connectionId, { force: true });
} else {
await loadDatabases(connectionId, { force: true });
}
@@ -1228,6 +1230,47 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function loadNacosNamespaces(connectionId: string, options?: LoadTreeOptions) {
const node = findNode(treeNodes.value, connectionId);
if (!node) return;
node.isLoading = true;
try {
await ensureConnected(connectionId);
if (useCachedChildren(node, options)) return;
const namespaces = await api.nacosListNamespaces(connectionId);
const sorted = [...namespaces].sort((left, right) => {
const leftLabel = left.namespaceShowName || left.namespace || "public";
const rightLabel = right.namespaceShowName || right.namespace || "public";
return leftLabel.localeCompare(rightLabel);
});
setChildren(
node,
sorted.map((namespace) => {
const value = namespace.namespace || "";
const label = namespace.namespaceShowName || value || "public";
return {
id: schemaCacheKey(connectionId, "nacos-namespace", value || "public"),
label,
type: "nacos-namespace" as const,
connectionId,
nacosNamespace: value,
nacosNamespaceName: label,
comment: namespace.namespaceDesc || null,
objectCount: namespace.configCount,
};
}),
);
node.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
} finally {
node.isLoading = false;
}
}
function updateRedisDbKeyStats(connectionId: string, db: number, stats: { loaded?: number; total?: number; totalDelta?: number }) {
const node = findNode(treeNodes.value, `${connectionId}:db${db}`);
if (!node || node.type !== "redis-db") return;
@@ -1996,6 +2039,8 @@ export const useConnectionStore = defineStore("connection", () => {
await loadVectorCollections(node.connectionId);
} else if (config?.db_type === "mq") {
await loadMqTenants(node.connectionId, options);
} else if (config?.db_type === "nacos") {
await loadNacosNamespaces(node.connectionId, options);
} else {
await loadDatabases(node.connectionId, options);
}
@@ -3266,6 +3311,7 @@ export const useConnectionStore = defineStore("connection", () => {
refreshRedisDbKeyCounts,
loadEtcdRoot,
loadMqTenants,
loadNacosNamespaces,
updateRedisDbKeyStats,
loadMongoDatabases,
loadElasticsearchIndices,
+34
View File
@@ -603,6 +603,37 @@ export const useQueryStore = defineStore("query", () => {
return id;
}
function openNacosAdmin(connectionId: string, target?: { namespace?: string; namespaceName?: string }) {
const namespace = target?.namespace ?? "";
const namespaceName = target?.namespaceName || (namespace ? namespace : "public");
const existing = tabs.value.find((tab) => tab.mode === "nacos" && tab.connectionId === connectionId && (tab.nacosNamespace || "") === namespace);
if (existing) {
existing.nacosNamespaceName = namespaceName;
if (!existing.customTitle) existing.title = `${useConnectionStore().getConfig(connectionId)?.name || "Nacos"}:${namespaceName}`;
activeTabId.value = existing.id;
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: `${conn?.name || "Nacos"}:${namespaceName}`,
connectionId,
database: conn?.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "nacos",
nacosNamespace: namespace,
nacosNamespaceName: namespaceName,
};
tabs.value.push(tab);
activeTabId.value = id;
return id;
}
function openTableStructure(connectionId: string, database: string, schema?: string, tableName?: string) {
const resolvedTableName = tableName || "";
if (resolvedTableName) {
@@ -769,6 +800,8 @@ export const useQueryStore = defineStore("query", () => {
explainExecutionId: undefined,
mode: original.mode,
mqTenant: original.mqTenant,
nacosNamespace: original.nacosNamespace,
nacosNamespaceName: original.nacosNamespaceName,
structureTableName: original.structureTableName,
objectBrowser: original.objectBrowser ? { ...original.objectBrowser } : undefined,
objectSource: original.objectSource ? { ...original.objectSource } : undefined,
@@ -2189,6 +2222,7 @@ export const useQueryStore = defineStore("query", () => {
openObjectBrowser,
openUserAdmin,
openMqAdmin,
openNacosAdmin,
openTableStructure,
linkSavedSql,
openSavedSql,
+8 -2
View File
@@ -56,7 +56,8 @@ export type DatabaseType =
| "iris"
| "influxdb"
| "jdbc"
| "mq";
| "mq"
| "nacos";
export interface SqlSnippet {
id: string;
@@ -484,6 +485,7 @@ export type TreeNodeType =
| "trigger"
| "redis-db"
| "mq-tenant"
| "nacos-namespace"
| "etcd-root"
| "mongo-db"
| "mongo-collection"
@@ -517,6 +519,8 @@ export interface TreeNode {
linkedCatalog?: string;
linkedSchema?: string;
mqTenant?: string;
nacosNamespace?: string;
nacosNamespaceName?: string;
schema?: string;
tableName?: string;
tableType?: string;
@@ -591,8 +595,10 @@ export interface QueryTab {
executionId?: string;
isExplaining?: boolean;
explainExecutionId?: string;
mode: "data" | "query" | "redis" | "mongo" | "vector" | "etcd" | "mq" | "objects" | "structure" | "users";
mode: "data" | "query" | "redis" | "mongo" | "vector" | "etcd" | "mq" | "nacos" | "objects" | "structure" | "users";
mqTenant?: string;
nacosNamespace?: string;
nacosNamespaceName?: string;
structureTableName?: string;
objectBrowser?: {
schema?: string;
+200
View File
@@ -0,0 +1,200 @@
export interface NacosCapabilities {
supportsConfigManagement: boolean;
supportsConfigHistory?: boolean;
supportsServiceManagement: boolean;
supportsInstanceUpdate: boolean;
supportsRawApi: boolean;
}
export interface NacosConnectionInfo {
serverAddr: string;
namespace: string;
serverVersion?: string;
auth: string;
capabilities: NacosCapabilities;
raw?: unknown;
}
export interface NacosNamespaceInfo {
namespace: string;
namespaceShowName: string;
namespaceDesc?: string;
configCount?: number;
quota?: number;
namespaceType?: number;
}
export interface NacosNamespaceCreate {
namespaceId?: string;
namespaceName: string;
namespaceDesc?: string;
}
export interface NacosNamespaceUpdate {
namespaceId: string;
namespaceName: string;
namespaceDesc?: string;
}
export interface NacosAuthConfig {
kind: "none" | "usernamePassword";
username?: string;
password?: string;
}
export interface NacosAdminConfig {
serverAddr: string;
namespace?: string;
contextPath?: string;
auth?: NacosAuthConfig;
tlsSkipVerify?: boolean;
pageSize?: number;
}
export interface NacosConfigQuery {
namespace?: string;
group?: string;
dataId?: string;
search?: string;
pageNo?: number;
pageSize?: number;
}
export interface NacosConfigItem {
dataId: string;
group: string;
namespace: string;
appName?: string;
desc?: string;
tags?: string;
configType?: string;
md5?: string;
encryptedDataKey?: string;
content?: string;
}
export interface NacosConfigList {
pageNo: number;
pageSize: number;
totalCount: number;
items: NacosConfigItem[];
}
export interface NacosConfigKey {
namespace?: string;
dataId: string;
group: string;
}
export interface NacosConfigUpsert extends NacosConfigKey {
content: string;
configType?: string;
appName?: string;
desc?: string;
tags?: string;
}
export interface NacosConfigHistoryQuery extends NacosConfigKey {
pageNo?: number;
pageSize?: number;
}
export interface NacosConfigHistoryItem {
historyId: string;
nid?: number;
dataId: string;
group: string;
namespace: string;
appName?: string;
operation?: string;
operator?: string;
lastModifiedTime?: string;
configType?: string;
tags?: string;
md5?: string;
}
export interface NacosConfigHistoryList {
pageNo: number;
pageSize: number;
totalCount: number;
items: NacosConfigHistoryItem[];
}
export interface NacosConfigHistoryKey extends NacosConfigKey {
historyId: string;
nid?: number;
}
export interface NacosConfigRollbackRequest extends NacosConfigHistoryKey {}
export interface NacosServiceQuery {
namespace?: string;
groupName?: string;
serviceName?: string;
pageNo?: number;
pageSize?: number;
}
export interface NacosServiceInfo {
serviceName: string;
groupName?: string;
clusterCount?: number;
ipCount?: number;
healthyInstanceCount?: number;
triggerFlag?: string;
}
export interface NacosServiceList {
pageNo: number;
pageSize: number;
totalCount: number;
items: NacosServiceInfo[];
}
export interface NacosInstanceQuery {
namespace?: string;
serviceName: string;
groupName?: string;
clusters?: string;
}
export interface NacosInstanceInfo {
ip: string;
port: number;
serviceName?: string;
clusterName?: string;
groupName?: string;
healthy?: boolean;
enabled?: boolean;
ephemeral?: boolean;
weight?: number;
metadata?: unknown;
}
export interface NacosInstanceUpdate {
namespace?: string;
serviceName: string;
ip: string;
port: number;
groupName?: string;
clusterName?: string;
healthy?: boolean;
enabled?: boolean;
ephemeral?: boolean;
weight?: number;
metadata?: unknown;
}
export interface NacosRawRequest {
method: string;
path: string;
query?: Record<string, string>;
body?: unknown;
}
export interface NacosRawResponse {
status: number;
body: unknown;
text?: string;
}