feat(nacos): improve service and instance management

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-06 12:16:52 +08:00
1 parent b5ba730404
commit c754b85295
38 files changed
+4787 -695

No files matched your search

@@ -7,13 +7,19 @@ describe("connection dialog scrolling", () => {
it("keeps every configuration tab inside a shrinkable form viewport", () => {
expect(dialogSource).toContain("return `${widthClass} connection-dialog-content--config`;");
expect(dialogSource.match(/<TabsContent[^>]*class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">/g)).toHaveLength(4);
expect(dialogSource.match(/class="connection-form-body grid min-h-0 flex-1 gap-4 overflow-y-auto/g)).toHaveLength(4);
expect(dialogSource.match(/class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto/g)).toHaveLength(4);
expect(dialogSource).not.toMatch(/\.connection-dialog-content--config\s*\{[\s\S]*?height:\s*min\(720px/);
expect(dialogSource).toMatch(/@media \(max-height: 720px\)[\s\S]*?height:\s*calc\(var\(--dbx-viewport-height\) - 2rem\);/);
expect(dialogSource).toMatch(/\.connection-dialog-content--config \.connection-form-body\s*\{[\s\S]*?align-content:\s*start;/);
});
it("keeps the transport form inside a shrinkable scroll viewport", () => {
expect(dialogSource).toContain('<TabsContent v-if="canUseTransportLayers" value="transport" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">');
expect(dialogSource).toContain('class="connection-form-body grid min-h-0 flex-1 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2"');
expect(dialogSource).toContain('class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto overflow-x-hidden pt-4 pr-2 pb-6"');
});
it("keeps conditional Nacos authentication fields from shrinking earlier cards", () => {
expect(dialogSource).toContain(":class=\"{ 'connection-form-body--nacos': form.db_type === 'nacos' }\"");
expect(dialogSource).toMatch(/\.connection-form-body--nacos\s*\{[\s\S]*?grid-auto-rows:\s*max-content;/);
});
});
@@ -0,0 +1,66 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
describe("Nacos connection dialog layout", () => {
it("presents implementation and version as one explicit connection profile", () => {
expect(source).toContain("data-nacos-profile-selector");
expect(source).toContain('v-for="profile in NACOS_CONNECTION_PROFILES"');
expect(source).toContain("selectNacosConnectionProfile(profile.value)");
expect(source).not.toContain("nacosVersionMode = 'auto'");
expect(source).not.toContain("tryNacosDockerConsoleFallback");
expect(source).not.toContain("dockerNacosConsoleFallbackUrl");
});
it("keeps the primary form focused on endpoints and authentication", () => {
const mainStart = source.indexOf("data-nacos-profile-selector");
const mainEnd = source.indexOf("<!-- Redis: host, port, user, password, ssl -->", mainStart);
const main = source.slice(mainStart, mainEnd);
expect(main).toContain("data-nacos-endpoint-section");
expect(main).toContain("data-nacos-access-section");
expect(main).toContain("data-nacos-advanced-hint");
expect(main).toContain('t("nacos.nacosAdvancedHint")');
expect(main).toContain("@click=\"configTab = 'advanced'\"");
expect(main).toContain('v-model="nacosServerAddr"');
expect(main).not.toContain('v-model="nacosV3ConsoleAddr"');
expect(main).not.toContain('v-model="nacosNamespace"');
expect(main).toContain('t("nacos.nacosAuthHint")');
expect(main).not.toContain('v-model="nacosMetricsMode"');
expect(main).not.toContain('v-model.number="nacosPageSize"');
});
it("moves low-frequency and r-nacos console settings to the advanced tab", () => {
const advancedStart = source.indexOf("data-nacos-advanced-settings");
const advancedEnd = source.indexOf('v-if="showGaussdbConnectionMode"', advancedStart);
const advanced = source.slice(advancedStart, advancedEnd);
expect(advanced).not.toContain('v-model="nacosContextPathInput"');
expect(advanced).not.toContain("配置上下文路径");
expect(advanced).toContain('v-model="nacosMetricsMode"');
expect(advanced).toContain('v-model="nacosRNacosConsoleAddr"');
expect(advanced).toContain('v-if="nacosHistoryEnabled"');
expect(advanced).toContain('t("nacos.nacosRnacosDisabledHint")');
expect(advanced).toContain('v-model="nacosTlsSkipVerify"');
expect(advanced).toContain('v-model.number="nacosPageSize"');
});
it("documents product default ports instead of local Docker mappings", () => {
expect(source).toContain('t("nacos.nacosServiceAddressHint")');
expect(source).toContain('t("nacos.nacosMetricsHint")');
expect(source).not.toContain("DBX 不需要配置该地址");
const mainStart = source.indexOf("data-nacos-profile-selector");
const mainEnd = source.indexOf("<!-- Redis: host, port, user, password, ssl -->", mainStart);
expect(source.slice(mainStart, mainEnd)).not.toContain('placeholder="http://127.0.0.1:8080"');
expect(source).not.toContain("http://127.0.0.1:8010");
expect(source).not.toContain("http://127.0.0.1:8818");
});
it("uses a dedicated namespace selector instead of the database selector", () => {
expect(source).toContain('t("nacos.nacosVisibleNamespacesTitle")');
expect(source).toContain("openVisibleNacosNamespacesPicker");
expect(source).toContain("api.nacosListNamespaces(draftId)");
expect(source).toContain("showVisibleNacosNamespacesDialog");
});
});
+3 -1
View File
@@ -195,8 +195,10 @@ describe("nacosAdmin helpers", () => {
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");
const details = buildNacosInstanceConfirm({ serviceName: "DEFAULT_GROUP@@svc", groupName: "DEFAULT_GROUP" }, { ip: "127.0.0.1", port: 8080, clusterName: "blue", ephemeral: false, enabled: true, metadata: null }, { enabled: false }, "", "public");
expect(details).toContain("serviceName=DEFAULT_GROUP@@svc");
expect(details).toContain("cluster=blue");
expect(details).toContain("ephemeral=false");
expect(details).toContain("targetEnabled=false");
});
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { filterNacosNamespacesForSidebar, normalizeNacosNamespaceSelection, normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
const namespaces = [
{ namespace: "", namespaceShowName: "public" },
{ namespace: "dev", namespaceShowName: "开发" },
{ namespace: "prod", namespaceShowName: "生产" },
];
describe("filterNacosNamespacesForSidebar", () => {
it("keeps all namespaces when no visibility filter is configured", () => {
expect(filterNacosNamespacesForSidebar(namespaces, undefined)).toEqual(namespaces);
});
it("filters by namespace id and preserves the public empty identifier", () => {
expect(filterNacosNamespacesForSidebar(namespaces, ["", "prod"])).toEqual([namespaces[0], namespaces[2]]);
});
it("matches a legacy empty public selection with a concrete public namespace", () => {
const v3Namespaces = [{ namespace: "public", namespaceShowName: "public" }, namespaces[2]];
expect(filterNacosNamespacesForSidebar(v3Namespaces, ["", "prod"])).toEqual(v3Namespaces);
expect(normalizeNacosNamespaceSelection([""], v3Namespaces)).toEqual(["public"]);
});
it("preserves the endpoint-specific public ID when normalizing selections", () => {
expect(normalizeNacosNamespaceSelection(["public"], namespaces)).toEqual([""]);
});
it("keeps only the concrete public namespace when both legacy forms are returned", () => {
const duplicatePublic = [namespaces[0], { namespace: "public", namespaceShowName: "public" }, namespaces[1]];
expect(normalizeNacosNamespacesForDisplay(duplicatePublic)).toEqual([duplicatePublic[1], duplicatePublic[2]]);
});
});
+6
View File
@@ -359,8 +359,14 @@ export const nacosRollbackConfig = forward("nacosRollbackConfig");
export const nacosGetRNacosConsoleCaptcha = forward("nacosGetRNacosConsoleCaptcha");
export const nacosLoginRNacosConsole = forward("nacosLoginRNacosConsole");
export const nacosListServices = forward("nacosListServices");
export const nacosGetService = forward("nacosGetService");
export const nacosCreateService = forward("nacosCreateService");
export const nacosUpdateService = forward("nacosUpdateService");
export const nacosDeleteService = forward("nacosDeleteService");
export const nacosListInstances = forward("nacosListInstances");
export const nacosUpdateInstance = forward("nacosUpdateInstance");
export const nacosRegisterInstance = forward("nacosRegisterInstance");
export const nacosDeregisterInstance = forward("nacosDeregisterInstance");
export const nacosGetDashboard = forward("nacosGetDashboard");
export const nacosRawRequest = forward("nacosRawRequest");
+30 -2
View File
@@ -183,8 +183,10 @@ import type {
NacosConnectionInfo,
NacosRNacosConsoleCaptcha,
NacosInstanceInfo,
NacosInstanceRef,
NacosInstanceRegistration,
NacosInstanceQuery,
NacosInstanceUpdate,
NacosInstanceUpdateRequest,
NacosDashboardQuery,
NacosDashboardSnapshot,
NacosNamespaceCreate,
@@ -193,7 +195,9 @@ import type {
NacosRawRequest,
NacosRawResponse,
NacosServiceList,
NacosServiceDetail,
NacosServiceQuery,
NacosServiceUpsert,
NacosSearchProgress,
} from "@/types/nacos";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
@@ -2806,14 +2810,38 @@ export async function nacosListServices(connectionId: string, query: NacosServic
return post("/api/nacos/services/list", { connectionId, query });
}
export async function nacosGetService(connectionId: string, query: NacosServiceQuery): Promise<NacosServiceDetail> {
return post("/api/nacos/services/get", { connectionId, query });
}
export async function nacosCreateService(connectionId: string, req: NacosServiceUpsert): Promise<void> {
return post("/api/nacos/services/create", { connectionId, req });
}
export async function nacosUpdateService(connectionId: string, req: NacosServiceUpsert): Promise<void> {
return post("/api/nacos/services/update", { connectionId, req });
}
export async function nacosDeleteService(connectionId: string, query: NacosServiceQuery): Promise<void> {
return post("/api/nacos/services/delete", { 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> {
export async function nacosUpdateInstance(connectionId: string, req: NacosInstanceUpdateRequest): Promise<void> {
return post("/api/nacos/instances/update", { connectionId, req });
}
export async function nacosRegisterInstance(connectionId: string, req: NacosInstanceRegistration): Promise<void> {
return post("/api/nacos/instances/register", { connectionId, req });
}
export async function nacosDeregisterInstance(connectionId: string, req: NacosInstanceRef): Promise<void> {
return post("/api/nacos/instances/deregister", { connectionId, req });
}
export async function nacosGetDashboard(connectionId: string, query: NacosDashboardQuery): Promise<NacosDashboardSnapshot> {
return post("/api/nacos/dashboard", { connectionId, query });
}
+30 -2
View File
@@ -21,15 +21,19 @@ import type {
NacosDashboardSnapshot,
NacosRNacosConsoleCaptcha,
NacosInstanceInfo,
NacosInstanceRef,
NacosInstanceRegistration,
NacosInstanceQuery,
NacosInstanceUpdate,
NacosInstanceUpdateRequest,
NacosNamespaceCreate,
NacosNamespaceInfo,
NacosNamespaceUpdate,
NacosRawRequest,
NacosRawResponse,
NacosServiceList,
NacosServiceDetail,
NacosServiceQuery,
NacosServiceUpsert,
NacosSearchProgress,
} from "@/types/nacos";
@@ -121,14 +125,38 @@ export async function nacosListServices(connectionId: string, query: NacosServic
return invoke("nacos_list_services", { connectionId, query });
}
export async function nacosGetService(connectionId: string, query: NacosServiceQuery): Promise<NacosServiceDetail> {
return invoke("nacos_get_service", { connectionId, query });
}
export async function nacosCreateService(connectionId: string, req: NacosServiceUpsert): Promise<void> {
return invoke("nacos_create_service", { connectionId, req });
}
export async function nacosUpdateService(connectionId: string, req: NacosServiceUpsert): Promise<void> {
return invoke("nacos_update_service", { connectionId, req });
}
export async function nacosDeleteService(connectionId: string, query: NacosServiceQuery): Promise<void> {
return invoke("nacos_delete_service", { 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> {
export async function nacosUpdateInstance(connectionId: string, req: NacosInstanceUpdateRequest): Promise<void> {
return invoke("nacos_update_instance", { connectionId, req });
}
export async function nacosRegisterInstance(connectionId: string, req: NacosInstanceRegistration): Promise<void> {
return invoke("nacos_register_instance", { connectionId, req });
}
export async function nacosDeregisterInstance(connectionId: string, req: NacosInstanceRef): Promise<void> {
return invoke("nacos_deregister_instance", { connectionId, req });
}
export async function nacosGetDashboard(connectionId: string, query: NacosDashboardQuery): Promise<NacosDashboardSnapshot> {
return invoke("nacos_get_dashboard", { connectionId, query });
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { nacosInstanceMatchesPatch, nacosInstanceRefIdentity, nacosIpAddressIsValid, nacosJsonObjectMatches, nacosServiceDetailMatches } from "../nacosServiceManagement";
describe("Nacos service management state reconciliation", () => {
it("keeps unknown, persistent and ephemeral instances as separate identities", () => {
const base = { serviceName: "api", ip: "127.0.0.1", port: 8080, clusterName: "blue" };
expect(new Set([nacosInstanceRefIdentity(base), nacosInstanceRefIdentity({ ...base, ephemeral: false }), nacosInstanceRefIdentity({ ...base, ephemeral: true })]).size).toBe(3);
});
it("validates IPv4 and IPv6 instance addresses without accepting host names", () => {
expect(nacosIpAddressIsValid("127.0.0.1")).toBe(true);
expect(nacosIpAddressIsValid("2001:db8::1")).toBe(true);
expect(nacosIpAddressIsValid("999.0.0.1")).toBe(false);
expect(nacosIpAddressIsValid("localhost")).toBe(false);
});
it("compares metadata independently of object key order", () => {
expect(nacosJsonObjectMatches({ owner: "dbx", nested: { b: 2, a: 1 } }, { nested: { a: 1, b: 2 }, owner: "dbx" })).toBe(true);
});
it("verifies only fields present in an instance patch", () => {
const instance = { ip: "127.0.0.1", port: 8080, weight: 0.3, enabled: false, healthy: true, metadata: { role: "api" } };
expect(nacosInstanceMatchesPatch(instance, { weight: 0.3000000001, metadata: { role: "api" } })).toBe(true);
expect(nacosInstanceMatchesPatch(instance, { metadata: { role: "worker" } })).toBe(false);
expect(nacosInstanceMatchesPatch({ ip: "127.0.0.1", port: 8080 }, { weight: 1 })).toBe(false);
});
it("treats Nacos none selectors as the UI's unconfigured selector", () => {
const expected = { serviceName: "api", metadata: { owner: "dbx" }, protectThreshold: 0.5 };
expect(nacosServiceDetailMatches({ serviceName: "api", metadata: { owner: "dbx" }, protectThreshold: 0.5, selector: { type: "NoneSelector", contextType: "NONE" } }, expected)).toBe(true);
expect(nacosServiceDetailMatches({ serviceName: "api", metadata: { owner: "dbx" } }, expected)).toBe(false);
});
});
+9 -5
View File
@@ -12,26 +12,26 @@ export interface NacosRawTemplate {
}
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: "serverState", method: "GET", path: "/v3/admin/core/state", query: "", body: "" },
{ key: "namespaceList", method: "GET", path: "/v3/admin/core/namespace/list", query: "", body: "" },
{
key: "configDetail",
method: "GET",
path: "/v3/console/cs/config",
path: "/v3/admin/cs/config",
query: "dataId=application.yaml&groupName=DEFAULT_GROUP&namespaceId=",
body: "",
},
{
key: "serviceList",
method: "GET",
path: "/v3/console/ns/service/list",
path: "/v3/admin/ns/service/list",
query: "pageNo=1&pageSize=20&namespaceId=",
body: "",
},
{
key: "instanceList",
method: "GET",
path: "/v3/console/ns/instance/list",
path: "/v3/admin/ns/instance/list",
query: "serviceName=DEFAULT_GROUP@@example&namespaceId=",
body: "",
},
@@ -702,8 +702,12 @@ export function buildNacosInstanceConfirm(service: NacosServiceInfo, instance: N
`serviceName=${service.serviceName}`,
`group=${instance.groupName || service.groupName || fallbackGroup || "DEFAULT_GROUP"}`,
`instance=${instance.ip}:${instance.port}`,
`cluster=${instance.clusterName || "DEFAULT"}`,
`ephemeral=${instance.ephemeral === true ? "true" : instance.ephemeral === false ? "false" : "unknown"}`,
patch.enabled == null ? "" : `targetEnabled=${targetEnabled === false ? "false" : "true"}`,
patch.healthy == null ? "" : `targetHealthy=${targetHealthy === false ? "false" : "true"}`,
patch.weight == null ? "" : `targetWeight=${patch.weight}`,
patch.metadata == null ? "" : `targetMetadata=${JSON.stringify(patch.metadata)}`,
]
.filter(Boolean)
.join("\n");
@@ -0,0 +1,51 @@
import type { NacosNamespaceInfo } from "@/types/nacos";
/**
* Nacos 2/r-nacos may represent the default namespace with an empty ID, while
* Nacos 3 returns the concrete `public` ID. Older backend builds could expose
* both forms at once; prefer the concrete ID and keep every other namespace.
*/
export function normalizeNacosNamespacesForDisplay(namespaces: NacosNamespaceInfo[]): NacosNamespaceInfo[] {
const normalized = new Map<string, NacosNamespaceInfo>();
for (const namespace of namespaces) {
const identity = nacosNamespaceIdentity(namespace.namespace);
const existing = normalized.get(identity);
// Prefer the concrete public ID when both legacy representations are returned.
if (!existing || (identity === "public" && namespace.namespace === "public" && existing.namespace === "")) {
normalized.set(identity, namespace);
}
}
return [...normalized.values()];
}
/** Returns the stable display/filter identity without changing the value sent to Nacos. */
export function nacosNamespaceIdentity(namespace: string): string {
return namespace === "" || namespace === "public" ? "public" : namespace;
}
/** Converts selected identities back to the namespace IDs returned by this Nacos endpoint. */
export function normalizeNacosNamespaceSelection(selected: Iterable<string>, namespaces: NacosNamespaceInfo[]): string[] {
const available = new Map<string, string>();
for (const namespace of normalizeNacosNamespacesForDisplay(namespaces)) {
available.set(nacosNamespaceIdentity(namespace.namespace), namespace.namespace);
}
const result: string[] = [];
const seen = new Set<string>();
for (const value of selected) {
const identity = nacosNamespaceIdentity(value);
const namespace = available.get(identity);
if (namespace !== undefined && !seen.has(identity)) {
seen.add(identity);
result.push(namespace);
}
}
return result;
}
/** `visible_databases` stores the namespace identifiers selected for a Nacos connection. */
export function filterNacosNamespacesForSidebar(namespaces: NacosNamespaceInfo[], visibleNamespaces: string[] | undefined): NacosNamespaceInfo[] {
const normalized = normalizeNacosNamespacesForDisplay(namespaces);
if (!Array.isArray(visibleNamespaces)) return normalized;
const visible = new Set(visibleNamespaces.map(nacosNamespaceIdentity));
return normalized.filter((namespace) => visible.has(nacosNamespaceIdentity(namespace.namespace)));
}
@@ -0,0 +1,57 @@
import type { NacosInstanceInfo, NacosInstancePatch, NacosInstanceRef, NacosServiceDetail, NacosServiceUpsert } from "@/types/nacos";
function canonicalJson(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalJson);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nested]) => [key, canonicalJson(nested)]),
);
}
return value;
}
export function nacosJsonObjectMatches(left: unknown, right: unknown) {
return JSON.stringify(canonicalJson(left ?? {})) === JSON.stringify(canonicalJson(right ?? {}));
}
export function nacosIpAddressIsValid(value: string) {
const input = value.trim();
if (!input) return false;
if (input.includes(":")) {
try {
const parsed = new URL(`http://[${input}]/`);
return parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]");
} catch {
return false;
}
}
const octets = input.split(".");
return octets.length === 4 && octets.every((octet) => /^\d{1,3}$/.test(octet) && (octet === "0" || !octet.startsWith("0")) && Number(octet) <= 255);
}
export function nacosInstanceRefIdentity(ref: NacosInstanceRef) {
const lifetime = ref.ephemeral === true ? "ephemeral" : ref.ephemeral === false ? "persistent" : "unknown";
return [ref.namespace || "public", ref.groupName || "DEFAULT_GROUP", ref.serviceName, ref.ip, ref.port, ref.clusterName || "DEFAULT", lifetime].join("\u0000");
}
export function nacosInstanceMatchesPatch(instance: NacosInstanceInfo, patch: NacosInstancePatch) {
const weightMatches = patch.weight == null || (instance.weight != null && Math.abs(instance.weight - patch.weight) <= 1e-6);
const metadataMatches = patch.metadata == null || nacosJsonObjectMatches(instance.metadata, patch.metadata);
return (patch.enabled == null || instance.enabled === patch.enabled) && (patch.healthy == null || instance.healthy === patch.healthy) && weightMatches && metadataMatches;
}
export function normalizeNacosSelector(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length === 0) return null;
const selector = value as Record<string, unknown>;
const type = String(selector.type ?? "").toLowerCase();
const contextType = String(selector.contextType ?? "").toUpperCase();
if (type === "none" || type === "noneselector" || contextType === "NONE") return null;
return canonicalJson(selector);
}
export function nacosServiceDetailMatches(detail: NacosServiceDetail, expected: NacosServiceUpsert) {
const thresholdMatches = expected.protectThreshold == null || (detail.protectThreshold != null && Math.abs(detail.protectThreshold - expected.protectThreshold) <= 1e-6);
return nacosJsonObjectMatches(detail.metadata, expected.metadata) && thresholdMatches && JSON.stringify(normalizeNacosSelector(detail.selector)) === JSON.stringify(normalizeNacosSelector(expected.selector));
}