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