feat(nacos): add access control and namespace administration
This commit is contained in:
59 files changed
+4563
-479
No files matched your search
@@ -19,7 +19,7 @@ import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
|
||||
import type { VictoriaMetricsExternalConfig } from "@/types/victoriametrics";
|
||||
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
|
||||
import type { MqttConnectionConfig } from "@/types/mqtt";
|
||||
import type { NacosAdminConfig, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosNamespaceInfo, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos";
|
||||
import type { NacosAdminConfig, NacosApiPlane, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosNamespaceInfo, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos";
|
||||
import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
|
||||
import { detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles";
|
||||
@@ -298,6 +298,11 @@ const visibleNacosNamespaces = ref<NacosNamespaceInfo[]>([]);
|
||||
const visibleNacosNamespaceSelection = ref<Set<string>>(new Set());
|
||||
const visibleNacosNamespaceSearchText = ref("");
|
||||
const visibleNacosNamespaceError = ref("");
|
||||
const visibleNacosNamespaceListingPermissionDenied = ref(false);
|
||||
const isResolvingManualNacosNamespaces = ref(false);
|
||||
const visibleNacosNamespaceAccessMode = ref<"automatic" | "manual">("automatic");
|
||||
const nacosDynamicAllNamespaces = ref(false);
|
||||
const visibleNacosNamespaceDynamicAllSupported = ref(false);
|
||||
const showProductionDatabasesDialog = ref(false);
|
||||
const isLoadingProductionDatabases = ref(false);
|
||||
const productionDatabaseNames = ref<string[]>([]);
|
||||
@@ -883,8 +888,9 @@ const nacosImplementation = ref<NacosImplementation>("nacos");
|
||||
// Nacos 2 and 3 expose different API planes. New connections must therefore
|
||||
// choose an explicit version instead of relying on endpoint-shape guessing.
|
||||
const nacosVersionMode = ref<NacosVersionMode>("v2");
|
||||
const nacosApiPlane = ref<NacosApiPlane>("admin");
|
||||
const nacosServerAddr = ref("");
|
||||
const nacosOrdinaryAccount = ref(false);
|
||||
const nacosContextPath = ref("");
|
||||
const nacosManagedNamespacesText = ref("");
|
||||
const nacosRNacosConsoleAddr = ref("");
|
||||
const nacosHistoryEnabled = ref(false);
|
||||
@@ -898,6 +904,11 @@ const nacosTlsSkipVerify = ref(false);
|
||||
const nacosMetricsMode = ref<NacosMetricsMode>("auto");
|
||||
const nacosMetricsUrl = ref("");
|
||||
const nacosPageSize = ref(20);
|
||||
let nacosScopeFingerprintBaseline = "";
|
||||
|
||||
function currentNacosScopeFingerprint(): string {
|
||||
return JSON.stringify([nacosImplementation.value, nacosVersionMode.value, nacosApiPlane.value, nacosServerAddr.value.trim().replace(/\/+$/, ""), nacosContextPath.value.trim(), nacosAuthKind.value, nacosUsername.value.trim()]);
|
||||
}
|
||||
type ConsulConsistency = "default" | "stale" | "consistent";
|
||||
const consulServerAddr = ref("http://127.0.0.1:8500");
|
||||
const consulDatacenter = ref("");
|
||||
@@ -935,15 +946,25 @@ const mqttConnectTimeoutSecs = ref(30);
|
||||
const mqttMaxPacketSizeBytes = ref(16 * 1024 * 1024);
|
||||
const mqttSavedTopics = ref<MqttConnectionConfig["savedTopics"]>([]);
|
||||
const nacosPrimaryAddressPlaceholder = computed(() => {
|
||||
if (nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console") {
|
||||
return "http://127.0.0.1:8080";
|
||||
}
|
||||
return "http://127.0.0.1:8848/nacos";
|
||||
});
|
||||
const nacosServiceAddressHint = computed(() => {
|
||||
if (nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console") {
|
||||
return t("nacos.nacosConsoleServiceAddressHint");
|
||||
}
|
||||
return t("nacos.nacosServiceAddressHint");
|
||||
});
|
||||
const nacosV3AdminEndpointWarning = computed(() => {
|
||||
if (nacosImplementation.value !== "nacos" || nacosVersionMode.value !== "v3" || !nacosServerAddr.value.trim()) return "";
|
||||
try {
|
||||
const url = new URL(nacosServerAddr.value.trim());
|
||||
if (url.port === "8080") {
|
||||
if (nacosApiPlane.value === "admin" && url.port === "8080") {
|
||||
return t("nacos.nacosV3ConsolePortWarning");
|
||||
}
|
||||
if (nacosApiPlane.value === "console" && url.port === "8848") return t("nacos.nacosV3AdminPortWarning");
|
||||
} catch {
|
||||
// The normal form validation will report an invalid URL on save.
|
||||
}
|
||||
@@ -971,6 +992,7 @@ function selectNacosConnectionProfile(profile: NacosConnectionProfile) {
|
||||
}
|
||||
nacosImplementation.value = "nacos";
|
||||
nacosVersionMode.value = profile;
|
||||
if (profile !== "v3") nacosApiPlane.value = "admin";
|
||||
}
|
||||
|
||||
watch(nacosImplementation, (implementation) => {
|
||||
@@ -1459,11 +1481,13 @@ function resetNacosFields(config?: Partial<NacosAdminConfig>) {
|
||||
// Saved `auto` profiles are legacy records. The form always saves an
|
||||
// explicit selection and no longer relies on a separate Console address.
|
||||
nacosVersionMode.value = config?.versionMode === "v3" ? "v3" : "v2";
|
||||
nacosApiPlane.value = config?.versionMode === "v3" ? config?.apiPlane || "admin" : "admin";
|
||||
const serverAddr = config?.serverAddr?.trim() || "";
|
||||
const contextPath = config?.contextPath?.trim() || "";
|
||||
nacosServerAddr.value = serverAddr && contextPath && contextPath !== "/" && !serverAddr.endsWith(contextPath) ? `${serverAddr.replace(/\/+$/, "")}/${contextPath.replace(/^\/+/, "")}` : serverAddr;
|
||||
nacosOrdinaryAccount.value = !!config?.managedNamespaces?.length;
|
||||
nacosServerAddr.value = serverAddr;
|
||||
nacosContextPath.value = contextPath;
|
||||
nacosManagedNamespacesText.value = (config?.managedNamespaces || []).join("\n");
|
||||
nacosDynamicAllNamespaces.value = !!config && !config.managedNamespaces?.length && !Array.isArray(form.value.visible_databases);
|
||||
nacosRNacosConsoleAddr.value = config?.rnacosConsoleAddr?.trim() || "";
|
||||
nacosHistoryEnabled.value = config?.rnacosHistoryEnabled ?? !!config?.rnacosConsoleAddr;
|
||||
const consoleAuth = config?.rnacosConsoleAuth || { kind: "inherit" };
|
||||
@@ -1478,6 +1502,7 @@ function resetNacosFields(config?: Partial<NacosAdminConfig>) {
|
||||
nacosAuthKind.value = auth.kind || "none";
|
||||
nacosUsername.value = auth.username || "nacos";
|
||||
nacosPassword.value = auth.password || "";
|
||||
nacosScopeFingerprintBaseline = currentNacosScopeFingerprint();
|
||||
}
|
||||
|
||||
function hydrateNacosFields(value: unknown) {
|
||||
@@ -1805,7 +1830,8 @@ function buildNacosAdminConfig(): NacosAdminConfig {
|
||||
const normalized = normalizeNacosEndpoint(primaryAddress, {
|
||||
implementation: nacosImplementation.value,
|
||||
versionMode: nacosVersionMode.value,
|
||||
contextPath: undefined,
|
||||
apiPlane: nacosApiPlane.value,
|
||||
contextPath: nacosContextPath.value,
|
||||
});
|
||||
if (nacosImplementation.value === "rnacos" && normalized.warnings.length) {
|
||||
throw new Error(t("connection.nacosRNacosOpenApiRequired"));
|
||||
@@ -1815,11 +1841,7 @@ function buildNacosAdminConfig(): NacosAdminConfig {
|
||||
throw new Error(t("connection.nacosRNacosConsoleUrlRequired"));
|
||||
}
|
||||
let rnacosConsoleAuth: NacosRNacosConsoleAuth | undefined;
|
||||
const usesManagedNamespaces = nacosImplementation.value === "nacos" && nacosAuthKind.value === "usernamePassword" && nacosOrdinaryAccount.value;
|
||||
const managedNamespaces = usesManagedNamespaces ? parseNacosManagedNamespaces(nacosManagedNamespacesText.value) : [];
|
||||
if (usesManagedNamespaces && managedNamespaces.length === 0) {
|
||||
throw new Error(t("nacos.nacosOrdinaryNamespacesRequired"));
|
||||
}
|
||||
const managedNamespaces = nacosImplementation.value === "nacos" && nacosAuthKind.value === "usernamePassword" ? parseNacosManagedNamespaces(nacosManagedNamespacesText.value) : [];
|
||||
let metricsUrl: string | undefined;
|
||||
if (nacosMetricsMode.value === "custom") {
|
||||
try {
|
||||
@@ -1843,6 +1865,7 @@ function buildNacosAdminConfig(): NacosAdminConfig {
|
||||
return {
|
||||
implementation: nacosImplementation.value,
|
||||
versionMode: nacosImplementation.value === "nacos" ? nacosVersionMode.value : undefined,
|
||||
apiPlane: nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" ? nacosApiPlane.value : undefined,
|
||||
serverAddr: normalized.serverAddr,
|
||||
contextPath: normalized.contextPath || undefined,
|
||||
managedNamespaces: managedNamespaces.length ? managedNamespaces : undefined,
|
||||
@@ -3461,6 +3484,24 @@ const shouldShowAgentDriverInstallHint = computed(() => showAgentDriverInstallHi
|
||||
const h2DriverMissing = computed(() => form.value.db_type === "h2" && isH2FileMode.value && agentDrivers.value.find((d) => d.db_type === "h2")?.installed !== true);
|
||||
const agentDriverFocus = computed<DriverStoreFocus>(() => ({ target: "driver", driver: agentDriverInstallKey(form.value.db_type, form.value.driver_profile) }));
|
||||
const canChooseVisibleNacosNamespaces = computed(() => form.value.db_type === "nacos");
|
||||
const isNacosV3AdminPlane = computed(() => nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "admin");
|
||||
const isNacosV3ConsolePlane = computed(() => nacosImplementation.value === "nacos" && nacosVersionMode.value === "v3" && nacosApiPlane.value === "console");
|
||||
const canDetectNacosNamespaceAccess = computed(() => form.value.db_type === "nacos" && nacosImplementation.value === "nacos" && nacosAuthKind.value === "usernamePassword");
|
||||
const nacosManualNamespaceLabelKey = computed(() => (isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespaces" : "nacos.nacosManagedNamespacesNameOrId"));
|
||||
const nacosManualNamespacePlaceholderKey = computed(() => (isNacosV3AdminPlane.value ? "nacos.nacosManagedNamespacesIdPlaceholder" : "nacos.nacosManagedNamespacesPlaceholder"));
|
||||
const nacosManualNamespaceHintKey = computed(() => {
|
||||
if (isNacosV3AdminPlane.value) return "nacos.nacosV3AdminManagedNamespacesHint";
|
||||
if (isNacosV3ConsolePlane.value) return "nacos.nacosV3ConsoleManagedNamespacesHint";
|
||||
return "nacos.nacosManagedNamespacesHint";
|
||||
});
|
||||
const nacosNamespacePickerTitleKey = computed(() => (canDetectNacosNamespaceAccess.value ? "nacos.nacosDetectAccessibleNamespaces" : "nacos.nacosVisibleNamespacesTitle"));
|
||||
const nacosNamespacePickerDescriptionKey = computed(() => (canDetectNacosNamespaceAccess.value ? "nacos.nacosDetectAccessibleNamespacesHint" : "nacos.nacosVisibleNamespacesDescription"));
|
||||
function hasNacosNamespaceScopeForSave(): boolean {
|
||||
if (!canDetectNacosNamespaceAccess.value) return true;
|
||||
if (nacosDynamicAllNamespaces.value) return true;
|
||||
if (parseNacosManagedNamespaces(nacosManagedNamespacesText.value).length > 0) return true;
|
||||
return Array.isArray(form.value.visible_databases) && form.value.visible_databases.length > 0;
|
||||
}
|
||||
const canChooseVisibleDatabases = computed(() => !canChooseVisibleNacosNamespaces.value && connectionCanChooseVisibleDatabases(form.value));
|
||||
const visibleFilterUsesSchemas = computed(() => connectionUsesVisibleSchemaFilter(form.value));
|
||||
const hasVisibleDatabaseFilter = computed(() => Array.isArray(form.value.visible_databases));
|
||||
@@ -3494,7 +3535,7 @@ const filteredVisibleNacosNamespaces = computed(() => {
|
||||
});
|
||||
});
|
||||
const visibleNacosNamespaceSelectedCount = computed(() => visibleNacosNamespaceSelection.value.size);
|
||||
const visibleNacosNamespaceCanSave = computed(() => visibleNacosNamespaceSelection.value.size > 0);
|
||||
const visibleNacosNamespaceCanSave = computed(() => (visibleNacosNamespaceAccessMode.value === "manual" ? parseNacosManagedNamespaces(nacosManagedNamespacesText.value).length > 0 : visibleNacosNamespaceSelection.value.size > 0));
|
||||
const filteredProductionDatabaseNames = computed(() => {
|
||||
const query = productionDatabaseSearchText.value.trim().toLowerCase();
|
||||
if (!query) return productionDatabaseNames.value;
|
||||
@@ -4632,6 +4673,10 @@ function resetVisibleNacosNamespaceDraftState() {
|
||||
visibleNacosNamespaceSelection.value = new Set();
|
||||
visibleNacosNamespaceSearchText.value = "";
|
||||
visibleNacosNamespaceError.value = "";
|
||||
visibleNacosNamespaceListingPermissionDenied.value = false;
|
||||
isResolvingManualNacosNamespaces.value = false;
|
||||
visibleNacosNamespaceAccessMode.value = "automatic";
|
||||
visibleNacosNamespaceDynamicAllSupported.value = false;
|
||||
}
|
||||
|
||||
function resetProductionDatabaseDraftState() {
|
||||
@@ -4715,24 +4760,99 @@ function normalizeVisibleNacosNamespaceSelection(selected: Iterable<string>, nam
|
||||
return normalizeNacosNamespaceSelection(selected, namespaces);
|
||||
}
|
||||
|
||||
function normalizeManualNacosNamespaceNames(namespaces: string[], availableNamespaces: NacosNamespaceInfo[]): string[] {
|
||||
const namespaceIds = new Map(availableNamespaces.map((namespace) => [nacosNamespaceIdentity(namespace.namespace), namespace.namespace]));
|
||||
const namespaceNames = new Map<string, string[]>();
|
||||
for (const namespace of availableNamespaces) {
|
||||
const name = nacosNamespaceLabel(namespace).trim();
|
||||
if (!name) continue;
|
||||
namespaceNames.set(name, [...(namespaceNames.get(name) || []), namespace.namespace]);
|
||||
}
|
||||
|
||||
const normalized: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const namespace of namespaces) {
|
||||
const namespaceId = namespaceIds.get(nacosNamespaceIdentity(namespace));
|
||||
const matchingNames = namespaceNames.get(namespace.trim()) || [];
|
||||
// Display names are only safe to convert when the server returned one
|
||||
// exact match. Keep unknown or ambiguous values intact so the regular
|
||||
// permission check can report them instead of targeting a wrong namespace.
|
||||
const value = namespaceId ?? (matchingNames.length === 1 ? matchingNames[0] : namespace);
|
||||
const identity = nacosNamespaceIdentity(value);
|
||||
if (!seen.has(identity)) {
|
||||
seen.add(identity);
|
||||
normalized.push(value);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function resolveManualNacosNamespaceNames(namespaces: string[]): Promise<string[]> {
|
||||
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
|
||||
try {
|
||||
const submittedConfig = connectionConfigForSubmit(draftId);
|
||||
const draftConfig = {
|
||||
...submittedConfig,
|
||||
// Resolve display names against the authenticated directory rather than
|
||||
// passing them as namespace IDs to the configuration and naming APIs.
|
||||
external_config: submittedConfig.db_type === "nacos" ? { ...(submittedConfig.external_config as NacosAdminConfig), managedNamespaces: undefined } : submittedConfig.external_config,
|
||||
id: draftId,
|
||||
one_time: true,
|
||||
};
|
||||
await api.connectDb(draftConfig);
|
||||
return normalizeManualNacosNamespaceNames(namespaces, await loadReadableNacosNamespaces(draftId, api));
|
||||
} catch {
|
||||
// Some Nacos deployments do not expose a readable namespace directory to
|
||||
// ordinary users. Preserve manually entered IDs for the normal test path.
|
||||
return namespaces;
|
||||
} finally {
|
||||
await api.disconnectDb(draftId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function isNacosNamespaceListingPermissionError(error: unknown): boolean {
|
||||
const message = errorMessage(error);
|
||||
if (/NACOS_ERROR\[(?:v3ManagedNamespacesRequired|managedNamespacesRequired)\]/.test(message)) return true;
|
||||
return /\/v3\/(?:admin|console)\/core\/namespace\/list/.test(message) && /\b403\b/.test(message) && /authorization failed/i.test(message);
|
||||
}
|
||||
|
||||
async function openVisibleNacosNamespacesPicker() {
|
||||
if (!ensureConnectionHostResolvedFromUrl()) return;
|
||||
if (!canChooseVisibleNacosNamespaces.value || isLoadingVisibleNacosNamespaces.value) return;
|
||||
|
||||
isLoadingVisibleNacosNamespaces.value = true;
|
||||
visibleNacosNamespaceError.value = "";
|
||||
visibleNacosNamespaceListingPermissionDenied.value = false;
|
||||
visibleNacosNamespaceDynamicAllSupported.value = false;
|
||||
visibleNacosNamespaceSearchText.value = "";
|
||||
visibleNacosNamespaceAccessMode.value = "automatic";
|
||||
const draftId = buildDraftVisibleDatabasesConnectionId(uuid());
|
||||
|
||||
try {
|
||||
const submittedConfig = connectionConfigForSubmit(draftId);
|
||||
const draftConfig = {
|
||||
...connectionConfigForSubmit(draftId),
|
||||
...submittedConfig,
|
||||
// Automatic detection must not inherit a previously saved manual scope.
|
||||
// Otherwise it would only rediscover that old subset instead of checking
|
||||
// what the currently configured account can really access.
|
||||
external_config: canDetectNacosNamespaceAccess.value && submittedConfig.db_type === "nacos" ? { ...(submittedConfig.external_config as NacosAdminConfig), managedNamespaces: undefined } : submittedConfig.external_config,
|
||||
visible_databases: undefined,
|
||||
id: draftId,
|
||||
one_time: true,
|
||||
};
|
||||
await api.connectDb(draftConfig);
|
||||
const namespaces = await loadReadableNacosNamespaces(draftId, api);
|
||||
visibleNacosNamespaces.value = [...namespaces].sort((left, right) => nacosNamespaceLabel(left).localeCompare(nacosNamespaceLabel(right)));
|
||||
try {
|
||||
const sidebarSnapshot = await api.nacosSidebarSnapshot(draftId);
|
||||
const readableIds = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaces.value.map(nacosNamespaceValue), visibleNacosNamespaces.value);
|
||||
const sidebarIds = normalizeVisibleNacosNamespaceSelection(sidebarSnapshot.namespaces.map(nacosNamespaceValue), visibleNacosNamespaces.value);
|
||||
const readableSet = new Set(readableIds.map(nacosNamespaceIdentity));
|
||||
const sidebarSet = new Set(sidebarIds.map(nacosNamespaceIdentity));
|
||||
visibleNacosNamespaceDynamicAllSupported.value = readableSet.size === sidebarSet.size && [...readableSet].every((namespace) => sidebarSet.has(namespace));
|
||||
} catch {
|
||||
visibleNacosNamespaceDynamicAllSupported.value = false;
|
||||
}
|
||||
const configured = form.value.visible_databases;
|
||||
const initialSelection = Array.isArray(configured) ? normalizeVisibleNacosNamespaceSelection(configured, visibleNacosNamespaces.value) : visibleNacosNamespaces.value.map(nacosNamespaceValue);
|
||||
visibleNacosNamespaceSelection.value = new Set(initialSelection);
|
||||
@@ -4740,7 +4860,8 @@ async function openVisibleNacosNamespacesPicker() {
|
||||
} catch (e: any) {
|
||||
visibleNacosNamespaces.value = [];
|
||||
visibleNacosNamespaceSelection.value = new Set();
|
||||
visibleNacosNamespaceError.value = errorMessage(e);
|
||||
visibleNacosNamespaceListingPermissionDenied.value = isNacosNamespaceListingPermissionError(e);
|
||||
visibleNacosNamespaceError.value = visibleNacosNamespaceListingPermissionDenied.value ? t("nacos.nacosManagedNamespacesRequired") : errorMessage(e);
|
||||
testResult.value = { ok: false, message: visibleNacosNamespaceError.value };
|
||||
showVisibleNacosNamespacesDialog.value = true;
|
||||
} finally {
|
||||
@@ -4765,13 +4886,31 @@ function clearVisibleNacosNamespaceSelection() {
|
||||
}
|
||||
|
||||
function showAllVisibleNacosNamespaces() {
|
||||
nacosDynamicAllNamespaces.value = true;
|
||||
nacosManagedNamespacesText.value = "";
|
||||
form.value.visible_databases = undefined;
|
||||
resetVisibleNacosNamespaceDraftState();
|
||||
}
|
||||
|
||||
function saveVisibleNacosNamespaceSelection() {
|
||||
async function saveVisibleNacosNamespaceSelection() {
|
||||
if (!visibleNacosNamespaceCanSave.value) return;
|
||||
form.value.visible_databases = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaceSelection.value, visibleNacosNamespaces.value);
|
||||
if (visibleNacosNamespaceAccessMode.value === "manual") {
|
||||
const manualNamespaces = parseNacosManagedNamespaces(nacosManagedNamespacesText.value);
|
||||
isResolvingManualNacosNamespaces.value = true;
|
||||
const resolvedNamespaces = isNacosV3AdminPlane.value ? manualNamespaces : await resolveManualNacosNamespaceNames(manualNamespaces);
|
||||
isResolvingManualNacosNamespaces.value = false;
|
||||
nacosManagedNamespacesText.value = resolvedNamespaces.join("\n");
|
||||
form.value.visible_databases = resolvedNamespaces;
|
||||
nacosDynamicAllNamespaces.value = false;
|
||||
} else {
|
||||
nacosManagedNamespacesText.value = "";
|
||||
const selected = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaceSelection.value, visibleNacosNamespaces.value);
|
||||
const all = normalizeVisibleNacosNamespaceSelection(visibleNacosNamespaces.value.map(nacosNamespaceValue), visibleNacosNamespaces.value);
|
||||
const selectsEntireReadableList = selected.length === all.length && selected.every((namespace, index) => namespace === all[index]);
|
||||
const useDynamicAll = selectsEntireReadableList && visibleNacosNamespaceDynamicAllSupported.value;
|
||||
form.value.visible_databases = useDynamicAll ? undefined : selected;
|
||||
nacosDynamicAllNamespaces.value = useDynamicAll;
|
||||
}
|
||||
showVisibleNacosNamespacesDialog.value = false;
|
||||
}
|
||||
|
||||
@@ -5214,6 +5353,26 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => currentNacosScopeFingerprint(),
|
||||
(current) => {
|
||||
if (form.value.db_type !== "nacos") {
|
||||
nacosScopeFingerprintBaseline = current;
|
||||
return;
|
||||
}
|
||||
if (!nacosScopeFingerprintBaseline || current === nacosScopeFingerprintBaseline) {
|
||||
nacosScopeFingerprintBaseline = current;
|
||||
return;
|
||||
}
|
||||
nacosScopeFingerprintBaseline = current;
|
||||
nacosManagedNamespacesText.value = "";
|
||||
nacosDynamicAllNamespaces.value = false;
|
||||
form.value.visible_databases = undefined;
|
||||
resetVisibleNacosNamespaceDraftState();
|
||||
resetTestState();
|
||||
},
|
||||
);
|
||||
|
||||
watch(visibleDatabaseShowSystem, (show) => {
|
||||
if (show) return;
|
||||
const connection = connectionConfigSnapshotForVisibleDatabases();
|
||||
@@ -5404,6 +5563,11 @@ async function persistConnectionNoteVisibilityDraft() {
|
||||
async function save() {
|
||||
if (!ensureConnectionHostResolvedFromUrl()) return;
|
||||
if (isSaving.value) return;
|
||||
if (!hasNacosNamespaceScopeForSave()) {
|
||||
testResult.value = null;
|
||||
await openVisibleNacosNamespacesPicker();
|
||||
return;
|
||||
}
|
||||
const databaseInfoForSave = visibleTestDatabaseInfo.value ?? visibleSavedDatabaseInfo.value;
|
||||
isSaving.value = true;
|
||||
let connectionSaved = false;
|
||||
@@ -6619,11 +6783,28 @@ function openExternalUrl(url: string) {
|
||||
|
||||
<section data-nacos-endpoint-section class="rounded-lg border p-4">
|
||||
<div class="grid gap-4">
|
||||
<div v-if="nacosImplementation === 'nacos' && nacosVersionMode === 'v3'" class="grid gap-1.5">
|
||||
<Label>{{ t("nacos.nacosApiPlane") }}</Label>
|
||||
<div class="grid grid-cols-2 gap-1 rounded-md border bg-muted/20 p-1">
|
||||
<button
|
||||
v-for="plane in ['admin', 'console'] as NacosApiPlane[]"
|
||||
:key="plane"
|
||||
type="button"
|
||||
class="min-w-0 rounded px-3 py-2 text-left transition-colors"
|
||||
:class="nacosApiPlane === plane ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="nacosApiPlane === plane"
|
||||
@click="nacosApiPlane = plane"
|
||||
>
|
||||
<span class="block text-sm font-medium">{{ t(`nacos.nacosApiPlane${plane === "admin" ? "Admin" : "Console"}`) }}</span>
|
||||
<span class="mt-0.5 block text-xs leading-4">{{ t(`nacos.nacosApiPlane${plane === "admin" ? "Admin" : "Console"}Hint`) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("nacos.nacosServiceAddress") }}</Label>
|
||||
<Input v-model="nacosServerAddr" :placeholder="nacosPrimaryAddressPlaceholder" />
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
<template>{{ t("nacos.nacosServiceAddressHint") }}</template>
|
||||
<template>{{ nacosServiceAddressHint }}</template>
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="nacosV3AdminEndpointWarning" class="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs leading-5 text-amber-700 dark:text-amber-400">
|
||||
@@ -6655,29 +6836,12 @@ function openExternalUrl(url: string) {
|
||||
<PasswordInput v-model="nacosPassword" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="nacosImplementation === 'nacos' && nacosAuthKind === 'usernamePassword'" data-nacos-ordinary-user-toggle class="mt-4 border-t pt-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-medium">{{ t("nacos.nacosOrdinaryAccount") }}</span>
|
||||
<span class="mt-0.5 block text-xs leading-5 text-muted-foreground">{{ t("nacos.nacosOrdinaryAccountHint") }}</span>
|
||||
</span>
|
||||
<Switch v-model="nacosOrdinaryAccount" class="mt-0.5 shrink-0" />
|
||||
</div>
|
||||
<div v-if="nacosOrdinaryAccount" class="mt-3 grid gap-1.5 pl-0 sm:pl-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label>{{ t("nacos.nacosManagedNamespaces") }}</Label>
|
||||
<span class="text-[11px] text-muted-foreground">{{ t("nacos.nacosManagedNamespacesSeparator") }}</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="nacosManagedNamespacesText"
|
||||
data-nacos-managed-namespaces
|
||||
rows="2"
|
||||
class="min-h-14 resize-y rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring"
|
||||
:placeholder="t('nacos.nacosManagedNamespacesPlaceholder')"
|
||||
/>
|
||||
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosManagedNamespacesHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="isNacosV3AdminPlane && nacosAuthKind === 'usernamePassword'" class="mt-4 border-t pt-4 text-xs leading-5 text-muted-foreground">
|
||||
{{ t("nacos.nacosV3AdminNamespaceScopeHint") }}
|
||||
</p>
|
||||
<p v-else-if="nacosImplementation === 'rnacos' && nacosAuthKind === 'usernamePassword'" class="mt-4 border-t pt-4 text-xs leading-5 text-muted-foreground">
|
||||
{{ t("nacos.rnacosNamespaceAccessScopeHint") }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section data-nacos-advanced-hint class="flex items-start gap-3 rounded-lg border border-dashed bg-muted/20 px-4 py-3">
|
||||
@@ -8106,6 +8270,17 @@ function openExternalUrl(url: string) {
|
||||
<p class="mt-0.5 text-xs leading-5 text-muted-foreground">{{ t("nacos.nacosAdvancedDescription") }}</p>
|
||||
</div>
|
||||
<div class="grid gap-5 p-4">
|
||||
<div class="grid gap-1.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label>{{ t("connection.nacosContextPath") }}</Label>
|
||||
<Button v-if="nacosContextPath" type="button" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="nacosContextPath = ''">
|
||||
{{ t("connection.nacosContextPathRestoreAuto") }}
|
||||
</Button>
|
||||
</div>
|
||||
<Input v-model="nacosContextPath" :placeholder="t('connection.nacosContextPathPlaceholder')" />
|
||||
<p class="text-[11px] leading-4 text-muted-foreground">{{ t("nacos.nacosContextPathHint") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-1.5">
|
||||
<Label>{{ t("connection.nacosPageSize") }}</Label>
|
||||
<Input v-model.number="nacosPageSize" type="number" min="1" max="500" />
|
||||
@@ -8721,7 +8896,7 @@ function openExternalUrl(url: string) {
|
||||
<Button v-if="canChooseVisibleNacosNamespaces" variant="outline" class="shrink-0" :disabled="isTesting || isSaving || isLoadingVisibleNacosNamespaces || !hasRequiredConnectionTarget" @click="openVisibleNacosNamespacesPicker">
|
||||
<Loader2 v-if="isLoadingVisibleNacosNamespaces" class="mr-1.5 h-4 w-4 animate-spin" />
|
||||
<ListFilter v-else class="mr-1.5 h-4 w-4" />
|
||||
{{ t("nacos.nacosVisibleNamespacesTitle") }}
|
||||
{{ t(nacosNamespacePickerTitleKey) }}
|
||||
</Button>
|
||||
<Button v-else-if="canChooseVisibleDatabases" variant="outline" class="shrink-0" :disabled="isTesting || isSaving || isLoadingVisibleDatabases || !hasRequiredConnectionTarget" @click="openVisibleDatabasesPicker">
|
||||
<Loader2 v-if="isLoadingVisibleDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
|
||||
@@ -8817,51 +8992,89 @@ function openExternalUrl(url: string) {
|
||||
<Dialog v-model:open="showVisibleNacosNamespacesDialog">
|
||||
<DialogContent class="sm:max-w-[460px]" @interact-outside.prevent @escape-key-down.prevent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("nacos.nacosVisibleNamespacesTitle") }}</DialogTitle>
|
||||
<p class="text-sm text-muted-foreground">{{ t("nacos.nacosVisibleNamespacesDescription", { name: form.name || selectedProfile().label }) }}</p>
|
||||
<DialogTitle>{{ t(nacosNamespacePickerTitleKey) }}</DialogTitle>
|
||||
<p class="text-sm text-muted-foreground">{{ t(nacosNamespacePickerDescriptionKey, { name: form.name || selectedProfile().label }) }}</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
|
||||
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input v-model="visibleNacosNamespaceSearchText" :placeholder="t('nacos.nacosSearchNamespaces')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError" />
|
||||
</div>
|
||||
<Tabs v-model="visibleNacosNamespaceAccessMode" class="grid gap-3">
|
||||
<TabsList class="grid h-9 w-full" :class="canDetectNacosNamespaceAccess ? 'grid-cols-2' : 'grid-cols-1'">
|
||||
<TabsTrigger value="automatic">{{ t("nacos.nacosNamespaceAccessAutomatic") }}</TabsTrigger>
|
||||
<TabsTrigger v-if="canDetectNacosNamespaceAccess" value="manual">{{ t("nacos.nacosNamespaceAccessManual") }}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ t("nacos.nacosSelectedNamespaces", { selected: visibleNacosNamespaceSelectedCount, total: visibleNacosNamespaces.length }) }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="selectAllVisibleNacosNamespaces">{{ t("nacos.nacosSelectAll") }}</button>
|
||||
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="clearVisibleNacosNamespaceSelection">{{ t("nacos.nacosClearSelection") }}</button>
|
||||
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="showAllVisibleNacosNamespaces">{{ t("nacos.nacosShowAll") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!isLoadingVisibleNacosNamespaces && !visibleNacosNamespaceError && !visibleNacosNamespaceCanSave" class="text-xs text-destructive">{{ t("nacos.nacosNamespaceSelectionRequired") }}</p>
|
||||
<TabsContent value="automatic" class="m-0 grid gap-3">
|
||||
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
|
||||
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input v-model="visibleNacosNamespaceSearchText" :placeholder="t('nacos.nacosSearchNamespaces')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError" />
|
||||
</div>
|
||||
|
||||
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
|
||||
<div v-if="isLoadingVisibleNacosNamespaces" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div v-else-if="visibleNacosNamespaceError" class="p-3 text-sm text-destructive">{{ t("nacos.nacosLoadNamespacesFailed", { message: visibleNacosNamespaceError }) }}</div>
|
||||
<div v-else-if="!filteredVisibleNacosNamespaces.length" class="p-3 text-sm text-muted-foreground">{{ t("grid.noSearchResults") }}</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="namespace in filteredVisibleNacosNamespaces"
|
||||
:key="nacosNamespaceValue(namespace) || '__public__'"
|
||||
type="button"
|
||||
class="flex min-h-9 w-full min-w-0 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
|
||||
@click="toggleVisibleNacosNamespace(nacosNamespaceValue(namespace))"
|
||||
>
|
||||
<CheckSquare v-if="visibleNacosNamespaceSelection.has(nacosNamespaceValue(namespace))" class="h-4 w-4 shrink-0 text-primary" />
|
||||
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ nacosNamespaceLabel(namespace) }}</span>
|
||||
<span v-if="namespace.namespace && namespace.namespace !== nacosNamespaceLabel(namespace)" class="shrink-0 truncate text-xs text-muted-foreground">{{ namespace.namespace }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ t("nacos.nacosSelectedNamespaces", { selected: visibleNacosNamespaceSelectedCount, total: visibleNacosNamespaces.length }) }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="selectAllVisibleNacosNamespaces">{{ t("nacos.nacosSelectAll") }}</button>
|
||||
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="clearVisibleNacosNamespaceSelection">{{ t("nacos.nacosClearSelection") }}</button>
|
||||
<button v-if="visibleNacosNamespaceDynamicAllSupported" class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingVisibleNacosNamespaces" @click="showAllVisibleNacosNamespaces">{{ t("nacos.nacosShowAll") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!isLoadingVisibleNacosNamespaces && !visibleNacosNamespaceError && !visibleNacosNamespaceCanSave" class="text-xs text-destructive">{{ t("nacos.nacosNamespaceSelectionRequired") }}</p>
|
||||
|
||||
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
|
||||
<div v-if="isLoadingVisibleNacosNamespaces" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
<div v-else-if="visibleNacosNamespaceListingPermissionDenied" class="grid gap-3 p-3 text-sm text-destructive">
|
||||
<p>{{ visibleNacosNamespaceError }}</p>
|
||||
<Button v-if="canDetectNacosNamespaceAccess" variant="outline" size="sm" class="justify-self-start" @click="visibleNacosNamespaceAccessMode = 'manual'">
|
||||
{{ t("nacos.nacosNamespaceAccessManual") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else-if="visibleNacosNamespaceError" class="p-3 text-sm text-destructive">{{ t("nacos.nacosLoadNamespacesFailed", { message: visibleNacosNamespaceError }) }}</div>
|
||||
<div v-else-if="!filteredVisibleNacosNamespaces.length" class="p-3 text-sm text-muted-foreground">{{ t("grid.noSearchResults") }}</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="namespace in filteredVisibleNacosNamespaces"
|
||||
:key="nacosNamespaceValue(namespace) || '__public__'"
|
||||
type="button"
|
||||
class="flex min-h-9 w-full min-w-0 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
|
||||
@click="toggleVisibleNacosNamespace(nacosNamespaceValue(namespace))"
|
||||
>
|
||||
<CheckSquare v-if="visibleNacosNamespaceSelection.has(nacosNamespaceValue(namespace))" class="h-4 w-4 shrink-0 text-primary" />
|
||||
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ nacosNamespaceLabel(namespace) }}</span>
|
||||
<span v-if="namespace.namespace && namespace.namespace !== nacosNamespaceLabel(namespace)" class="shrink-0 truncate text-xs text-muted-foreground">{{ namespace.namespace }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="canDetectNacosNamespaceAccess" value="manual" class="m-0 grid gap-3">
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t("nacos.nacosNamespaceAccessScopeHint") }}</p>
|
||||
<div v-if="isNacosV3AdminPlane" class="flex gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2.5 text-xs leading-5 text-amber-800 dark:text-amber-300">
|
||||
<ShieldAlert class="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p>{{ t("nacos.nacosV3AdminManagedNamespacesHint") }}</p>
|
||||
</div>
|
||||
<div class="grid gap-1.5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Label>{{ t(nacosManualNamespaceLabelKey) }}</Label>
|
||||
<span class="text-[11px] text-muted-foreground">{{ t("nacos.nacosManagedNamespacesSeparator") }}</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="nacosManagedNamespacesText"
|
||||
data-nacos-managed-namespaces
|
||||
rows="6"
|
||||
class="min-h-32 resize-y rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring"
|
||||
:placeholder="t(nacosManualNamespacePlaceholderKey)"
|
||||
/>
|
||||
<p class="text-[11px] leading-4 text-muted-foreground">{{ t(nacosManualNamespaceHintKey) }}</p>
|
||||
</div>
|
||||
<p v-if="!visibleNacosNamespaceCanSave" class="text-xs text-destructive">{{ t("nacos.nacosOrdinaryNamespacesRequired") }}</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showVisibleNacosNamespacesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError || !visibleNacosNamespaceCanSave" @click="saveVisibleNacosNamespaceSelection">{{ t("nacos.save") }}</Button>
|
||||
<Button :disabled="isResolvingManualNacosNamespaces || !visibleNacosNamespaceCanSave || (visibleNacosNamespaceAccessMode === 'automatic' && (isLoadingVisibleNacosNamespaces || !!visibleNacosNamespaceError))" @click="saveVisibleNacosNamespaceSelection">{{ t("nacos.save") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { KeyRound, Loader2, Pencil, Plus, RefreshCw, Search, ShieldCheck, Trash2, UserRound } from "@lucide/vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { KeyRound, Loader2, Pencil, Plus, RefreshCw, Search, ShieldCheck, ShieldPlus, Trash2, UserRound } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -11,9 +11,10 @@ import { Switch } from "@/components/ui/switch";
|
||||
import NacosNamespaceMultiSelect from "@/components/nacos/NacosNamespaceMultiSelect.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache";
|
||||
import { normalizeNacosNamespacesForDisplay } from "@/lib/nacos/nacosNamespaceVisibility";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { NacosConnectionInfo, NacosNamespaceInfo, NacosNamespacePrivilege, NacosOperationCapability, NacosRoleBinding, NacosUserInfo } from "@/types/nacos";
|
||||
import type { NacosAdminConfig, NacosConnectionInfo, NacosNamespaceInfo, NacosNamespacePrivilege, NacosOperationCapability, NacosRoleBinding, NacosUserInfo } from "@/types/nacos";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
@@ -30,6 +31,7 @@ type UserDialogMode = "create" | "edit" | "reset";
|
||||
const users = ref<NacosUserInfo[]>([]);
|
||||
const usersLoading = ref(false);
|
||||
const usersError = ref("");
|
||||
const userOperationNotice = ref<{ kind: "success" | "error"; message: string } | null>(null);
|
||||
const userSearch = ref("");
|
||||
const userPageNo = ref(1);
|
||||
const userPageSize = ref(20);
|
||||
@@ -53,6 +55,7 @@ const userFormNamespaces = ref<NacosNamespaceInfo[]>([]);
|
||||
const userFormNamespacesLoading = ref(false);
|
||||
const userFormNamespacesError = ref("");
|
||||
const pendingUserDelete = ref<NacosUserInfo | null>(null);
|
||||
const userDeleteConfirmation = ref("");
|
||||
const deletingUser = ref(false);
|
||||
const roleDialogOpen = ref(false);
|
||||
const roleDialogError = ref("");
|
||||
@@ -67,12 +70,15 @@ const rnacosCaptchaImage = ref("");
|
||||
const rnacosCaptcha = ref("");
|
||||
const rnacosAuthError = ref("");
|
||||
let rnacosRetry: (() => Promise<void>) | null = null;
|
||||
let latestUserFormNamespacesRequestId = 0;
|
||||
let stopNacosNamespacesChangedListener: (() => void) | null = null;
|
||||
|
||||
const accessControl = computed(() => props.connectionInfo?.capabilities.accessControl);
|
||||
const mode = computed(() => accessControl.value?.mode ?? "unavailable");
|
||||
const isRNacos = computed(() => mode.value === "embeddedRoles");
|
||||
const userTotalPages = computed(() => Math.max(1, Math.ceil(userTotal.value / Math.max(1, userPageSize.value))));
|
||||
const roleTotalPages = computed(() => Math.max(1, Math.ceil(roleTotal.value / Math.max(1, rolePageSize.value))));
|
||||
const userDeleteConfirmed = computed(() => userDeleteConfirmation.value === pendingUserDelete.value?.username);
|
||||
|
||||
function createUserForm() {
|
||||
return {
|
||||
@@ -264,20 +270,35 @@ async function loadRoles(page = rolePageNo.value) {
|
||||
|
||||
async function loadUserFormNamespaces(force = false) {
|
||||
if (!isRNacos.value || userFormNamespacesLoading.value || (userFormNamespaces.value.length && !force)) return;
|
||||
const connectionId = props.connectionId;
|
||||
const requestId = ++latestUserFormNamespacesRequestId;
|
||||
userFormNamespacesLoading.value = true;
|
||||
userFormNamespacesError.value = "";
|
||||
try {
|
||||
userFormNamespaces.value = normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(props.connectionId));
|
||||
const namespaces = normalizeNacosNamespacesForDisplay(await api.nacosListNamespaces(connectionId));
|
||||
if (requestId !== latestUserFormNamespacesRequestId || connectionId !== props.connectionId) return;
|
||||
userFormNamespaces.value = namespaces;
|
||||
} catch (error) {
|
||||
if (requestId !== latestUserFormNamespacesRequestId || connectionId !== props.connectionId) return;
|
||||
userFormNamespacesError.value = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
userFormNamespacesLoading.value = false;
|
||||
if (requestId === latestUserFormNamespacesRequestId) userFormNamespacesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNacosNamespacesChanged(detail: NacosNamespacesChangedDetail) {
|
||||
if (detail.connectionId !== props.connectionId) return;
|
||||
latestUserFormNamespacesRequestId += 1;
|
||||
userFormNamespaces.value = [];
|
||||
userFormNamespacesError.value = "";
|
||||
userFormNamespacesLoading.value = false;
|
||||
if (userDialogOpen.value && isRNacos.value) void loadUserFormNamespaces(true);
|
||||
}
|
||||
|
||||
function openCreateUser() {
|
||||
userDialogMode.value = "create";
|
||||
userDialogError.value = "";
|
||||
userOperationNotice.value = null;
|
||||
userForm.value = createUserForm();
|
||||
userDialogOpen.value = true;
|
||||
void loadUserFormNamespaces();
|
||||
@@ -286,6 +307,7 @@ function openCreateUser() {
|
||||
function openEditUser(user: NacosUserInfo) {
|
||||
userDialogMode.value = isRNacos.value ? "edit" : "reset";
|
||||
userDialogError.value = "";
|
||||
userOperationNotice.value = null;
|
||||
const privilege = user.namespacePrivilege;
|
||||
userForm.value = {
|
||||
username: user.username,
|
||||
@@ -305,8 +327,10 @@ function openEditUser(user: NacosUserInfo) {
|
||||
watch(
|
||||
() => props.connectionId,
|
||||
() => {
|
||||
latestUserFormNamespacesRequestId += 1;
|
||||
userFormNamespaces.value = [];
|
||||
userFormNamespacesError.value = "";
|
||||
userFormNamespacesLoading.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -320,6 +344,7 @@ function toggleRNacosRole(role: string, checked: boolean) {
|
||||
async function saveUser() {
|
||||
if (userSaving.value) return;
|
||||
const username = userForm.value.username.trim();
|
||||
const resettingPassword = userDialogMode.value === "reset";
|
||||
if (!username || (userDialogMode.value !== "edit" && !userForm.value.password)) {
|
||||
userDialogError.value = t("nacos.userCredentialsRequired");
|
||||
return;
|
||||
@@ -350,14 +375,61 @@ async function saveUser() {
|
||||
}
|
||||
userDialogOpen.value = false;
|
||||
await loadUsers(1);
|
||||
if (resettingPassword) {
|
||||
const handledConnectionCredential = await syncCurrentConnectionPassword(username, userForm.value.password);
|
||||
if (!handledConnectionCredential) {
|
||||
userOperationNotice.value = { kind: "success", message: t("nacos.passwordResetSucceeded", { username }) };
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
userDialogError.value = error instanceof Error ? error.message : String(error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
userDialogError.value = message;
|
||||
if (resettingPassword) userOperationNotice.value = { kind: "error", message: t("nacos.passwordResetFailed", { message }) };
|
||||
} finally {
|
||||
userSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCurrentConnectionPassword(username: string, password: string): Promise<boolean> {
|
||||
const config = connectionStore.getConfig(props.connectionId);
|
||||
if (!config || config.db_type !== "nacos") return false;
|
||||
const hasExternalConfig = !!config.external_config && typeof config.external_config === "object";
|
||||
const externalConfig = (config.external_config || {}) as NacosAdminConfig;
|
||||
const primaryAuth = externalConfig.auth;
|
||||
const primaryMatches = (primaryAuth?.kind === "usernamePassword" && primaryAuth.username === username) || (!hasExternalConfig && config.username === username);
|
||||
const consoleAuth = externalConfig.rnacosConsoleAuth;
|
||||
const consoleMatches = consoleAuth?.kind === "usernamePassword" && consoleAuth.username === username;
|
||||
if (!primaryMatches && !consoleMatches) return false;
|
||||
if (config.save_password === false) {
|
||||
try {
|
||||
await api.replaceNacosSessionCredential(props.connectionId, username, password);
|
||||
userOperationNotice.value = { kind: "success", message: `${t("nacos.passwordResetSucceeded", { username })} ${t("nacos.currentSessionPasswordUpdated")}` };
|
||||
} catch {
|
||||
userOperationNotice.value = { kind: "success", message: `${t("nacos.passwordResetSucceeded", { username })} ${t("nacos.currentPasswordNotSaved")}` };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const nextExternal: NacosAdminConfig | undefined = hasExternalConfig
|
||||
? {
|
||||
...externalConfig,
|
||||
auth: primaryMatches ? { kind: "usernamePassword", username, password } : primaryAuth,
|
||||
rnacosConsoleAuth: consoleMatches ? { kind: "usernamePassword", username, password } : consoleAuth,
|
||||
}
|
||||
: undefined;
|
||||
try {
|
||||
await connectionStore.updateConnection({
|
||||
...config,
|
||||
password: primaryMatches ? password : config.password,
|
||||
external_config: nextExternal,
|
||||
});
|
||||
userOperationNotice.value = { kind: "success", message: `${t("nacos.passwordResetSucceeded", { username })} ${t("nacos.currentPasswordSaved")}` };
|
||||
} catch {
|
||||
userOperationNotice.value = { kind: "success", message: `${t("nacos.passwordResetSucceeded", { username })} ${t("nacos.currentPasswordNotSaved")}` };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
const user = pendingUserDelete.value;
|
||||
if (!user || deletingUser.value) return;
|
||||
@@ -367,6 +439,7 @@ async function deleteUser() {
|
||||
await withRNacosAuthentication(async () => {
|
||||
await api.nacosDeleteUser(props.connectionId, user.username);
|
||||
pendingUserDelete.value = null;
|
||||
userDeleteConfirmation.value = "";
|
||||
await loadUsers(Math.min(userPageNo.value, userTotalPages.value));
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -376,6 +449,16 @@ async function deleteUser() {
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteUser(user: NacosUserInfo) {
|
||||
pendingUserDelete.value = user;
|
||||
userDeleteConfirmation.value = "";
|
||||
}
|
||||
|
||||
function closeDeleteUser() {
|
||||
pendingUserDelete.value = null;
|
||||
userDeleteConfirmation.value = "";
|
||||
}
|
||||
|
||||
function openAssignRole() {
|
||||
roleDialogError.value = "";
|
||||
roleForm.value = { username: roleUsername.value.trim(), role: "" };
|
||||
@@ -439,9 +522,20 @@ watch(
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged);
|
||||
if (props.tab === "users") void loadUsers(1);
|
||||
else void loadRoles(1);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
latestUserFormNamespacesRequestId += 1;
|
||||
stopNacosNamespacesChangedListener?.();
|
||||
stopNacosNamespacesChangedListener = null;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
refresh: () => (props.tab === "users" ? loadUsers() : loadRoles()),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -464,6 +558,9 @@ onMounted(() => {
|
||||
</header>
|
||||
<div v-if="!listUsersCapability().supported" class="border-b bg-muted/30 px-3 py-2 text-xs text-muted-foreground">{{ capabilityReason(listUsersCapability()) }}</div>
|
||||
<div v-if="usersError" class="border-b px-3 py-2 text-xs text-destructive">{{ usersError }}</div>
|
||||
<div v-if="userOperationNotice" class="border-b px-3 py-2 text-xs" :class="userOperationNotice.kind === 'success' ? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'" role="status" aria-live="polite">
|
||||
{{ userOperationNotice.message }}
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table class="w-full min-w-[680px] text-sm">
|
||||
<thead class="sticky top-0 z-10 bg-muted/70 text-left text-xs text-muted-foreground">
|
||||
@@ -493,7 +590,7 @@ onMounted(() => {
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button size="sm" variant="ghost" class="h-7 w-7 p-0" :disabled="!isWritable(updateUserCapability())" :title="isRNacos ? t('nacos.editUser') : t('nacos.resetUserPassword')" @click="openEditUser(user)"
|
||||
><Pencil v-if="isRNacos" class="h-3.5 w-3.5" /><KeyRound v-else class="h-3.5 w-3.5" /></Button
|
||||
><Button size="sm" variant="ghost" class="h-7 w-7 p-0 text-destructive hover:text-destructive" :disabled="!isWritable(deleteUserCapability())" :title="t('nacos.deleteUser')" @click="pendingUserDelete = user"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
><Button size="sm" variant="ghost" class="h-7 w-7 p-0 text-destructive hover:text-destructive" :disabled="!isWritable(deleteUserCapability())" :title="t('nacos.deleteUser')" @click="openDeleteUser(user)"><Trash2 class="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -519,7 +616,7 @@ onMounted(() => {
|
||||
<Button size="sm" variant="outline" class="h-8 w-8 px-0" :disabled="rolesLoading || !listRolesCapability().supported" :title="!listRolesCapability().supported ? capabilityReason(listRolesCapability()) : t('nacos.refresh')" :aria-label="t('nacos.refresh')" @click="loadRoles(1)"
|
||||
><Loader2 v-if="rolesLoading" class="h-3.5 w-3.5 animate-spin" /><RefreshCw v-else class="h-3.5 w-3.5"
|
||||
/></Button>
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="!isWritable(assignRoleCapability())" :title="!isWritable(assignRoleCapability()) ? capabilityReason(assignRoleCapability()) : undefined" @click="openAssignRole"><Plus class="h-3.5 w-3.5" />{{ t("nacos.assignRole") }}</Button>
|
||||
<Button size="sm" class="h-8 gap-1.5" :disabled="!isWritable(assignRoleCapability())" :title="!isWritable(assignRoleCapability()) ? capabilityReason(assignRoleCapability()) : undefined" @click="openAssignRole"><ShieldPlus class="h-3.5 w-3.5" />{{ t("nacos.assignRole") }}</Button>
|
||||
</header>
|
||||
<div v-if="rolesError" class="border-b px-3 py-2 text-xs text-destructive">{{ rolesError }}</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
@@ -645,14 +742,22 @@ onMounted(() => {
|
||||
></Dialog
|
||||
>
|
||||
|
||||
<Dialog :open="!!pendingUserDelete" @update:open="!$event && (pendingUserDelete = null)"
|
||||
<Dialog :open="!!pendingUserDelete" @update:open="!$event && closeDeleteUser()"
|
||||
><DialogContent class="sm:max-w-md"
|
||||
><DialogHeader
|
||||
><DialogTitle>{{ t("nacos.deleteUserTitle") }}</DialogTitle
|
||||
><DialogDescription>{{ t("nacos.deleteUserDescription", { username: pendingUserDelete?.username }) }}</DialogDescription></DialogHeader
|
||||
><DialogFooter
|
||||
><Button variant="outline" :disabled="deletingUser" @click="pendingUserDelete = null">{{ t("nacos.cancel") }}</Button
|
||||
><Button variant="destructive" :disabled="deletingUser" @click="deleteUser"><Loader2 v-if="deletingUser" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.delete") }}</Button></DialogFooter
|
||||
>
|
||||
<div class="grid gap-1.5">
|
||||
<Label for="legacy-delete-user-confirm">{{ t("nacos.accessDeleteUserConfirmationLabel") }}</Label>
|
||||
<p id="legacy-delete-user-confirm-hint" class="text-xs text-muted-foreground">
|
||||
{{ t("nacos.accessDeleteUserConfirmationHint") }} <code class="select-all rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">{{ pendingUserDelete?.username }}</code>
|
||||
</p>
|
||||
<Input id="legacy-delete-user-confirm" v-model="userDeleteConfirmation" autocomplete="off" :placeholder="t('nacos.accessDeleteUserConfirmationPlaceholder')" aria-describedby="legacy-delete-user-confirm-hint" />
|
||||
</div>
|
||||
<DialogFooter
|
||||
><Button variant="outline" :disabled="deletingUser" @click="closeDeleteUser">{{ t("nacos.cancel") }}</Button
|
||||
><Button variant="destructive" :disabled="deletingUser || !userDeleteConfirmed" @click="deleteUser"><Loader2 v-if="deletingUser" class="mr-2 h-4 w-4 animate-spin" />{{ t("nacos.delete") }}</Button></DialogFooter
|
||||
></DialogContent
|
||||
></Dialog
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { KeyRound, Loader2, RefreshCw, ShieldCheck, UsersRound } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -16,6 +16,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
type AccessTab = "users" | "roles";
|
||||
type AccessControlWorkspace = { refresh: () => Promise<void> };
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
@@ -23,6 +24,8 @@ const connectionInfo = ref<NacosConnectionInfo | null>(null);
|
||||
const connectionError = ref("");
|
||||
const loading = ref(false);
|
||||
const activeTab = ref<AccessTab>("users");
|
||||
const legacyWorkspace = ref<AccessControlWorkspace | null>(null);
|
||||
const enhancedWorkspaceRef = ref<AccessControlWorkspace | null>(null);
|
||||
|
||||
const accessControl = computed(() => connectionInfo.value?.capabilities.accessControl);
|
||||
const supportsUsers = computed(() => accessControl.value?.listUsers.supported === true);
|
||||
@@ -30,13 +33,17 @@ const supportsRoles = computed(() => accessControl.value?.mode === "roleBindings
|
||||
const enhancedWorkspace = computed(() => accessControl.value?.enhancedWorkspace === true);
|
||||
const permissionWorkspaceUnavailable = computed(() => accessControl.value?.mode === "roleBindings" && accessControl.value.listPermissions?.supported === false && accessControl.value.listPermissions.reason !== "versionUnsupported");
|
||||
|
||||
async function loadConnectionInfo() {
|
||||
async function loadConnectionInfo(refreshWorkspace = false) {
|
||||
loading.value = true;
|
||||
connectionError.value = "";
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId);
|
||||
connectionInfo.value = await api.nacosTestConnection(props.connectionId);
|
||||
connectionInfo.value = await api.nacosTestConnection(props.connectionId, refreshWorkspace);
|
||||
if (!supportsUsers.value && supportsRoles.value) activeTab.value = "roles";
|
||||
if (refreshWorkspace) {
|
||||
await nextTick();
|
||||
await (enhancedWorkspace.value ? enhancedWorkspaceRef.value : legacyWorkspace.value)?.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
connectionInfo.value = null;
|
||||
connectionError.value = error instanceof Error ? error.message : String(error);
|
||||
@@ -74,7 +81,7 @@ watch(
|
||||
<Badge v-if="connectionInfo?.serverVersion" variant="secondary">{{ connectionInfo.serverVersion }}</Badge>
|
||||
<div class="flex-1" />
|
||||
<Badge v-if="readOnly" variant="outline">{{ t("nacos.readOnly") }}</Badge>
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loading" @click="loadConnectionInfo">
|
||||
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loading" @click="loadConnectionInfo(true)">
|
||||
<RefreshCw class="h-3.5 w-3.5" :class="loading ? 'animate-spin' : ''" />
|
||||
{{ t("nacos.refresh") }}
|
||||
</Button>
|
||||
@@ -88,8 +95,8 @@ watch(
|
||||
</div>
|
||||
<template v-else-if="connectionInfo">
|
||||
<div v-if="!supportsUsers && !supportsRoles" class="m-4 rounded border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">{{ t("nacos.accessControlUnavailable") }}</div>
|
||||
<NacosRoleAccessControl v-if="enhancedWorkspace && accessControl" :connection-id="connectionId" :capabilities="accessControl" :read-only="readOnly" :tab="activeTab" @select-user="activeTab = 'users'" @select-role="activeTab = 'roles'" />
|
||||
<NacosAccessControl v-else-if="supportsUsers || supportsRoles" :connection-id="connectionId" :connection-info="connectionInfo" :read-only="readOnly" :tab="activeTab" />
|
||||
<NacosRoleAccessControl v-if="enhancedWorkspace && accessControl" ref="enhancedWorkspaceRef" :connection-id="connectionId" :capabilities="accessControl" :read-only="readOnly" :tab="activeTab" @select-user="activeTab = 'users'" @select-role="activeTab = 'roles'" />
|
||||
<NacosAccessControl v-else-if="supportsUsers || supportsRoles" ref="legacyWorkspace" :connection-id="connectionId" :connection-info="connectionInfo" :read-only="readOnly" :tab="activeTab" />
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -313,6 +313,7 @@ const updateServiceCapability = computed(() => operationCapability(serviceCapabi
|
||||
const deleteServiceCapability = computed(() => operationCapability(serviceCapabilities.value?.deleteService, createServiceCapability.value.supported));
|
||||
const listInstancesCapability = computed(() => operationCapability(serviceCapabilities.value?.listInstances, listServicesCapability.value.supported));
|
||||
const updateInstanceCapability = computed(() => operationCapability(serviceCapabilities.value?.updateInstance, legacyInstanceUpdateSupported.value));
|
||||
const updateInstanceHealthCapability = computed(() => operationCapability(serviceCapabilities.value?.updateInstanceHealth, updateInstanceCapability.value.supported));
|
||||
const registerInstanceCapability = computed(() => operationCapability(serviceCapabilities.value?.registerInstance, updateInstanceCapability.value.supported));
|
||||
const deregisterInstanceCapability = computed(() => operationCapability(serviceCapabilities.value?.deregisterInstance, updateInstanceCapability.value.supported));
|
||||
const supportsServiceManagement = computed(() => listServicesCapability.value.supported);
|
||||
@@ -2876,9 +2877,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="flex items-center gap-1 rounded-md border bg-background p-1">
|
||||
<span class="px-1 text-xs text-muted-foreground">{{ t("nacos.instances") }}</span>
|
||||
<Button size="sm" class="h-7" :disabled="readOnly || !registerInstanceCapability.supported" :title="readOnly || !registerInstanceCapability.supported ? capabilityReason(registerInstanceCapability) : undefined" @click="registerInstanceOpen = true">{{
|
||||
t("nacos.registerInstance")
|
||||
}}</Button>
|
||||
<Button v-if="registerInstanceCapability.supported" size="sm" class="h-7" :disabled="readOnly" :title="readOnly ? capabilityReason(registerInstanceCapability) : undefined" @click="registerInstanceOpen = true">{{ t("nacos.registerInstance") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2986,15 +2985,16 @@ onBeforeUnmount(() => {
|
||||
<Loader2 v-if="isInstanceUpdating(instance)" class="h-3 w-3 animate-spin" />
|
||||
{{ instance.enabled === false ? t("nacos.enable") : t("nacos.disable") }}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-7" :disabled="readOnly || !supportsInstanceUpdate || isInstanceUpdating(instance)" @click="requestUpdateInstance(instance, { healthy: !instance.healthy })">
|
||||
<Button v-if="updateInstanceHealthCapability.supported" size="sm" variant="outline" class="h-7" :disabled="readOnly || isInstanceUpdating(instance)" @click="requestUpdateInstance(instance, { healthy: !instance.healthy })">
|
||||
{{ instance.healthy === false ? t("nacos.markHealthy") : t("nacos.markUnhealthy") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="deregisterInstanceCapability.supported"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 text-destructive"
|
||||
:disabled="readOnly || !deregisterInstanceCapability.supported || isInstanceUpdating(instance)"
|
||||
:title="readOnly || !deregisterInstanceCapability.supported ? capabilityReason(deregisterInstanceCapability) : undefined"
|
||||
:disabled="readOnly || isInstanceUpdating(instance)"
|
||||
:title="readOnly ? capabilityReason(deregisterInstanceCapability) : undefined"
|
||||
@click="pendingInstanceDeregister = instance"
|
||||
>{{ t("nacos.deregister") }}</Button
|
||||
>
|
||||
|
||||
@@ -368,11 +368,11 @@ onUnmounted(() => {
|
||||
<MetricCard :label="t('nacos.dashboardNamespaces')" :value="optionalCount(snapshot?.namespaceCount)" :icon="Layers3" />
|
||||
<MetricCard :label="t('nacos.dashboardConfigs')" :value="optionalCount(snapshot?.configCount)" :sub="namespaceLabel" :icon="Braces" />
|
||||
<MetricCard :label="t('nacos.dashboardServices')" :value="optionalCount(snapshot?.serviceCount)" :sub="namespaceLabel" :icon="Boxes" />
|
||||
<MetricCard :label="t('nacos.dashboardInstances')" :value="optionalCount(metrics?.instanceCount)" :icon="Network" />
|
||||
<MetricCard :label="t('nacos.dashboardClients')" :value="optionalCount(metrics?.clientCount)" :sub="t('nacos.dashboardConnections', { count: optionalCount(metrics?.connectionBasedClientCount) })" :icon="Users" />
|
||||
<MetricCard v-if="metrics" :label="t('nacos.dashboardInstances')" :value="optionalCount(metrics.instanceCount)" :icon="Network" />
|
||||
<MetricCard v-if="metrics" :label="t('nacos.dashboardClients')" :value="optionalCount(metrics.clientCount)" :sub="t('nacos.dashboardConnections', { count: optionalCount(metrics.connectionBasedClientCount) })" :icon="Users" />
|
||||
<MetricCard :label="t('nacos.dashboardNodes')" :value="snapshot?.nodes.length ? `${healthyNodes} / ${snapshot.nodes.length}` : '—'" :icon="Server" />
|
||||
<MetricCard :label="t('nacos.dashboardCpu')" :value="formatDashboardPercent(metrics?.cpu)" :icon="Cpu" />
|
||||
<MetricCard :label="t('nacos.dashboardMemory')" :value="formatDashboardPercent(metrics?.mem)" :sub="t('nacos.dashboardLoad', { value: optionalMetric(metrics?.load) })" :icon="HardDrive" />
|
||||
<MetricCard v-if="metrics" :label="t('nacos.dashboardCpu')" :value="formatDashboardPercent(metrics.cpu)" :icon="Cpu" />
|
||||
<MetricCard v-if="metrics" :label="t('nacos.dashboardMemory')" :value="formatDashboardPercent(metrics.mem)" :sub="t('nacos.dashboardLoad', { value: optionalMetric(metrics.load) })" :icon="HardDrive" />
|
||||
</div>
|
||||
|
||||
<div v-if="prometheus" class="grid shrink-0 grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { AlertTriangle, Check, ChevronLeft, ChevronRight, CircleAlert, KeyRound, Link2, Loader2, LockKeyhole, Pencil, Plus, RefreshCw, RotateCcw, Search, Shield, ShieldCheck, Trash2, UserRound, X } from "@lucide/vue";
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { AlertTriangle, Check, ChevronLeft, ChevronRight, CircleAlert, KeyRound, Link2, Loader2, LockKeyhole, Pencil, Plus, RefreshCw, RotateCcw, Search, Shield, ShieldCheck, ShieldPlus, Trash2, UserPlus, UserRound, X } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { mergeNacosNamespacePermissionAssignments, type NacosNamespacePermissionAction } from "@/lib/nacos/nacosAdmin";
|
||||
import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { NacosAccessControlCapabilities, NacosAccessControlSnapshot, NacosAccessOperationRequest, NacosAccessOperationResult, NacosPermissionDraft, NacosPermissionInfo, NacosUserInfo } from "@/types/nacos";
|
||||
import type { NacosAccessControlCapabilities, NacosAccessControlSnapshot, NacosAccessOperationRequest, NacosAccessOperationResult, NacosAdminConfig, NacosPermissionDraft, NacosPermissionInfo, NacosUserInfo } from "@/types/nacos";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
@@ -28,6 +30,7 @@ type PermissionAction = NacosNamespacePermissionAction;
|
||||
type PermissionAssignment = { namespaceId: string; action: PermissionAction };
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const snapshot = ref<NacosAccessControlSnapshot | null>(null);
|
||||
const loading = ref(false);
|
||||
@@ -59,6 +62,8 @@ const operation = ref<NacosAccessOperationResult | null>(null);
|
||||
const pendingAdvancedPermission = ref<NacosPermissionInfo | null>(null);
|
||||
const advancedPermissionConfirmation = ref("");
|
||||
const retryPasswords = reactive<Record<string, string>>({});
|
||||
let latestSnapshotRequestId = 0;
|
||||
let stopNacosNamespacesChangedListener: (() => void) | null = null;
|
||||
|
||||
const users = computed(() => snapshot.value?.users ?? []);
|
||||
const roles = computed(() => snapshot.value?.roles ?? []);
|
||||
@@ -122,6 +127,8 @@ const canRevokePermission = computed(() => !props.readOnly && props.capabilities
|
||||
const canCreateRole = computed(() => canAssignRole.value && canGrantPermission.value);
|
||||
const canEditUserRoles = computed(() => canAssignRole.value || canRemoveRole.value);
|
||||
const canEditRole = computed(() => canAssignRole.value || canRemoveRole.value || canGrantPermission.value || canRevokePermission.value || canCreateUser.value);
|
||||
const userDeleteConfirmed = computed(() => userForm.confirmation === userForm.username);
|
||||
const roleDeleteConfirmed = computed(() => roleForm.confirmation === roleForm.role);
|
||||
|
||||
function setHasAdded(before: Iterable<string>, after: Iterable<string>) {
|
||||
const original = new Set(before);
|
||||
@@ -179,10 +186,14 @@ const roleOperationWritable = computed(() => {
|
||||
});
|
||||
|
||||
async function loadSnapshot(preserveSelection = true) {
|
||||
const requestId = ++latestSnapshotRequestId;
|
||||
const connectionId = props.connectionId;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
snapshot.value = await api.nacosAccessSnapshot(props.connectionId);
|
||||
const nextSnapshot = await api.nacosAccessSnapshot(connectionId);
|
||||
if (requestId !== latestSnapshotRequestId || connectionId !== props.connectionId) return;
|
||||
snapshot.value = nextSnapshot;
|
||||
if (!preserveSelection || !users.value.some((user) => user.username === selectedUserName.value)) {
|
||||
selectedUserName.value = users.value[0]?.username ?? "";
|
||||
}
|
||||
@@ -190,12 +201,17 @@ async function loadSnapshot(preserveSelection = true) {
|
||||
selectedRoleName.value = roles.value[0]?.role ?? "";
|
||||
}
|
||||
} catch (cause) {
|
||||
if (requestId !== latestSnapshotRequestId || connectionId !== props.connectionId) return;
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (requestId === latestSnapshotRequestId) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNacosNamespacesChanged(detail: NacosNamespacesChangedDetail) {
|
||||
if (detail.connectionId === props.connectionId) void loadSnapshot();
|
||||
}
|
||||
|
||||
function isManagedPermission(permission: NacosPermissionInfo) {
|
||||
return permission.parsedScope?.kind === "namespace" && ["r", "w", "rw"].includes(permission.actionRaw);
|
||||
}
|
||||
@@ -317,6 +333,45 @@ function openDeleteUser() {
|
||||
userDialog.value = "delete";
|
||||
}
|
||||
|
||||
async function syncCurrentConnectionPassword(username: string, password: string) {
|
||||
if (snapshot.value?.currentUsername !== username) return;
|
||||
const config = connectionStore.getConfig(props.connectionId);
|
||||
if (!config || config.db_type !== "nacos") return;
|
||||
const hasExternalConfig = !!config.external_config && typeof config.external_config === "object";
|
||||
const externalConfig = (config.external_config || {}) as NacosAdminConfig;
|
||||
const primaryAuth = externalConfig.auth;
|
||||
const primaryMatches = (primaryAuth?.kind === "usernamePassword" && primaryAuth.username === username) || (!hasExternalConfig && config.username === username);
|
||||
const consoleAuth = externalConfig.rnacosConsoleAuth;
|
||||
const consoleMatches = consoleAuth?.kind === "usernamePassword" && consoleAuth.username === username;
|
||||
if (!primaryMatches && !consoleMatches) return;
|
||||
if (config.save_password === false) {
|
||||
try {
|
||||
await api.replaceNacosSessionCredential(props.connectionId, username, password);
|
||||
toast(t("nacos.currentSessionPasswordUpdated"), 4000);
|
||||
} catch {
|
||||
toast(t("nacos.currentPasswordNotSaved"), 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nextExternal: NacosAdminConfig | undefined = hasExternalConfig
|
||||
? {
|
||||
...externalConfig,
|
||||
auth: primaryMatches ? { kind: "usernamePassword", username, password } : primaryAuth,
|
||||
rnacosConsoleAuth: consoleMatches ? { kind: "usernamePassword", username, password } : consoleAuth,
|
||||
}
|
||||
: undefined;
|
||||
try {
|
||||
await connectionStore.updateConnection({
|
||||
...config,
|
||||
password: primaryMatches ? password : config.password,
|
||||
external_config: nextExternal,
|
||||
});
|
||||
toast(t("nacos.currentPasswordSaved"), 4000);
|
||||
} catch {
|
||||
toast(t("nacos.currentPasswordNotSaved"), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function resetRoleForm(role = "") {
|
||||
Object.assign(roleForm, { role, members: [], newUsers: [], permissions: [], confirmation: "" });
|
||||
availableNamespaceSearch.value = "";
|
||||
@@ -342,7 +397,7 @@ function openEditRole() {
|
||||
|
||||
function openDeleteRole() {
|
||||
if (!selectedRole.value) return;
|
||||
roleForm.confirmation = "";
|
||||
resetRoleForm(selectedRole.value.role);
|
||||
formError.value = "";
|
||||
roleDialog.value = "delete";
|
||||
}
|
||||
@@ -360,7 +415,10 @@ function openAssociatedRole(role: string) {
|
||||
function filterNamespaces<T extends { id?: string; namespaceId?: string; name: string }>(items: T[], query: string) {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
if (!normalized) return items;
|
||||
return items.filter((item) => `${item.name} ${item.id ?? item.namespaceId ?? ""}`.toLocaleLowerCase().includes(normalized));
|
||||
return items.filter((item) => {
|
||||
const id = item.id ?? item.namespaceId ?? "";
|
||||
return item.name.toLocaleLowerCase().includes(normalized) || id.toLocaleLowerCase().includes(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
function moveNamespacesToGranted() {
|
||||
@@ -393,12 +451,17 @@ async function submitUser() {
|
||||
if (userDialog.value === "password") {
|
||||
if (!userForm.password) return void (formError.value = t("nacos.accessPasswordRequired"));
|
||||
if (!(await confirmAccessMutation(`${t("nacos.accessUserDialog.password")}: ${userForm.username}`))) return;
|
||||
const username = userForm.username;
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.nacosUpdateUser(props.connectionId, { username: userForm.username, password: userForm.password });
|
||||
await api.nacosUpdateUser(props.connectionId, { username, password: userForm.password });
|
||||
await syncCurrentConnectionPassword(username, userForm.password);
|
||||
userDialog.value = null;
|
||||
toast(t("nacos.passwordResetSucceeded", { username }), 2500);
|
||||
} catch (cause) {
|
||||
formError.value = cause instanceof Error ? cause.message : String(cause);
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
formError.value = message;
|
||||
toast(t("nacos.passwordResetFailed", { message }), 5000);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
@@ -422,7 +485,8 @@ async function submitRole() {
|
||||
await runOperation({ kind: "deleteRole", role: roleForm.role, confirmation: roleForm.confirmation || undefined }, () => (roleDialog.value = null));
|
||||
return;
|
||||
}
|
||||
if (!roleForm.role.trim() || (!roleForm.members.length && !roleForm.newUsers.length)) {
|
||||
const roleRequiresMember = roleDialog.value === "create" || selectedRole.value?.administrator;
|
||||
if (!roleForm.role.trim() || (roleRequiresMember && !roleForm.members.length && !roleForm.newUsers.length)) {
|
||||
formError.value = t("nacos.accessRoleMemberRequired");
|
||||
return;
|
||||
}
|
||||
@@ -518,7 +582,17 @@ watch(
|
||||
},
|
||||
);
|
||||
watch(normalizedSearch, () => (page.value = 1));
|
||||
onMounted(() => void loadSnapshot(false));
|
||||
onMounted(() => {
|
||||
stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged);
|
||||
void loadSnapshot(false);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
latestSnapshotRequestId += 1;
|
||||
stopNacosNamespacesChangedListener?.();
|
||||
stopNacosNamespacesChangedListener = null;
|
||||
});
|
||||
|
||||
defineExpose({ refresh: () => loadSnapshot() });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -529,8 +603,16 @@ onMounted(() => void loadSnapshot(false));
|
||||
<Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input v-model="search" class="h-8 pl-8" :placeholder="tab === 'users' ? t('nacos.searchUsers') : t('nacos.accessSearchRoles')" />
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" class="h-8 w-8 p-0" :aria-label="t('nacos.refresh')" :disabled="loading" @click="loadSnapshot()">
|
||||
<RefreshCw class="h-3.5 w-3.5" :class="loading ? 'animate-spin' : ''" />
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-8 w-8 shrink-0 p-0"
|
||||
:title="tab === 'users' ? t('nacos.createUser') : t('nacos.accessCreateRole')"
|
||||
:aria-label="tab === 'users' ? t('nacos.createUser') : t('nacos.accessCreateRole')"
|
||||
:disabled="tab === 'users' ? !canCreateUser : !canCreateRole"
|
||||
@click="tab === 'users' ? openCreateUser() : openCreateRole()"
|
||||
>
|
||||
<UserPlus v-if="tab === 'users'" class="h-3.5 w-3.5" />
|
||||
<ShieldPlus v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -554,8 +636,7 @@ onMounted(() => void loadSnapshot(false));
|
||||
<div v-if="!loading && !currentItems.length" class="flex h-32 items-center justify-center px-4 text-center text-xs text-muted-foreground">{{ tab === "users" ? t("nacos.noUsers") : t("nacos.accessNoRoles") }}</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 space-y-2 border-t p-2">
|
||||
<Button class="h-8 w-full" :disabled="tab === 'users' ? !canCreateUser : !canCreateRole" @click="tab === 'users' ? openCreateUser() : openCreateRole()"><Plus class="mr-1.5 h-3.5 w-3.5" />{{ tab === "users" ? t("nacos.createUser") : t("nacos.accessCreateRole") }}</Button>
|
||||
<div class="shrink-0 border-t p-2">
|
||||
<div class="flex h-7 items-center justify-between px-1 text-xs text-muted-foreground">
|
||||
<span>{{ currentItems.length }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -787,20 +868,29 @@ onMounted(() => void loadSnapshot(false));
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="userDialog === 'delete'" class="rounded border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{{ t("nacos.deleteUserDescription", { username: userForm.username }) }}</div>
|
||||
<div v-if="userForm.roles.includes('ROLE_ADMIN') || (snapshot?.currentUsername === userForm.username && userDialog !== 'password')" class="grid gap-1.5">
|
||||
<div v-if="userDialog === 'delete'" class="grid gap-1.5">
|
||||
<Label for="access-delete-user-confirm">{{ t("nacos.accessDeleteUserConfirmationLabel") }}</Label>
|
||||
<p id="access-delete-user-confirm-hint" class="text-xs text-muted-foreground">
|
||||
{{ t("nacos.accessDeleteUserConfirmationHint") }} <code class="select-all rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">{{ userForm.username }}</code>
|
||||
</p>
|
||||
<Input id="access-delete-user-confirm" v-model="userForm.confirmation" autocomplete="off" :placeholder="t('nacos.accessDeleteUserConfirmationPlaceholder')" aria-describedby="access-delete-user-confirm-hint" />
|
||||
</div>
|
||||
<div v-if="userDialog !== 'delete' && (userForm.roles.includes('ROLE_ADMIN') || (snapshot?.currentUsername === userForm.username && userDialog !== 'password'))" class="grid gap-1.5">
|
||||
<Label for="access-user-confirm">{{ t("nacos.accessTypeUsername", { value: userForm.username }) }}</Label
|
||||
><Input id="access-user-confirm" v-model="userForm.confirmation" autocomplete="off" />
|
||||
</div>
|
||||
<p v-if="formError" class="text-sm text-destructive">{{ formError }}</p>
|
||||
<DialogFooter
|
||||
><Button variant="outline" :disabled="saving" @click="userDialog = null">{{ t("nacos.cancel") }}</Button
|
||||
><Button :variant="userDialog === 'delete' ? 'destructive' : 'default'" :disabled="saving || !userOperationWritable" @click="submitUser"><Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />{{ userDialog === "delete" ? t("nacos.delete") : t("nacos.save") }}</Button></DialogFooter
|
||||
><Button :variant="userDialog === 'delete' ? 'destructive' : 'default'" :disabled="saving || !userOperationWritable || (userDialog === 'delete' && !userDeleteConfirmed)" @click="submitUser"
|
||||
><Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />{{ userDialog === "delete" ? t("nacos.delete") : t("nacos.save") }}</Button
|
||||
></DialogFooter
|
||||
>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog :open="!!roleDialog" @update:open="!$event && (roleDialog = null)">
|
||||
<DialogContent class="max-h-[90vh] overflow-auto sm:max-w-2xl">
|
||||
<DialogContent class="max-h-[90vh] overflow-auto sm:max-w-3xl">
|
||||
<DialogHeader
|
||||
><DialogTitle>{{ t(`nacos.accessRoleDialog.${roleDialog}`) }}</DialogTitle
|
||||
><DialogDescription>{{ t(`nacos.accessRoleDialog.${roleDialog}Hint`) }}</DialogDescription></DialogHeader
|
||||
@@ -815,7 +905,9 @@ onMounted(() => void loadSnapshot(false));
|
||||
<div class="mb-2 flex items-end justify-between">
|
||||
<div>
|
||||
<Label>{{ t("nacos.accessMembers") }}</Label>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{{ t("nacos.accessMembersRequiredHint") }}</p>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">
|
||||
{{ t(roleDialog === "edit" && !selectedRole?.administrator ? "nacos.accessMembersOptionalHint" : "nacos.accessMembersRequiredHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" :disabled="!canCreateUser || !canAssignRole" @click="addNewUser"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("nacos.accessInlineCreateUser") }}</Button>
|
||||
</div>
|
||||
@@ -843,17 +935,21 @@ onMounted(() => void loadSnapshot(false));
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{{ t("nacos.accessPermissionRequiredHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid min-h-[17rem] grid-cols-[minmax(0,1fr)_2.5rem_minmax(0,1.15fr)] gap-2 sm:gap-3">
|
||||
<div class="grid h-[min(28rem,45vh)] min-h-[17rem] grid-cols-[minmax(0,1fr)_2.5rem_minmax(0,1.15fr)] gap-2 sm:gap-3">
|
||||
<div class="flex min-h-0 flex-col overflow-hidden rounded border">
|
||||
<div class="border-b bg-muted/30 px-3 py-2 text-sm font-medium">{{ t("nacos.accessUnassignedNamespaces") }}</div>
|
||||
<div class="border-b p-2">
|
||||
<div class="relative"><Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" /><Input v-model="availableNamespaceSearch" class="h-8 pl-8 text-sm" :placeholder="t('nacos.accessSearchNamespaces')" /></div>
|
||||
<div class="relative">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" /><Input v-model="availableNamespaceSearch" class="h-8 pl-8 text-sm" :placeholder="t('nacos.accessSearchNamespaces')" :aria-label="t('nacos.accessSearchNamespaces')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-1.5">
|
||||
<label v-for="namespace in availableNamespaces" :key="namespace.id" class="flex min-h-9 items-center gap-2 rounded px-2 text-sm hover:bg-muted/60">
|
||||
<label v-for="namespace in availableNamespaces" :key="namespace.id" class="flex min-h-11 items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted/60">
|
||||
<input type="checkbox" :checked="selectedAvailableNamespaceIds.includes(namespace.id)" @change="selectedAvailableNamespaceIds = toggle(selectedAvailableNamespaceIds, namespace.id, ($event.target as HTMLInputElement).checked)" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ namespace.name }}</span>
|
||||
<code v-if="namespace.name !== namespace.id" class="truncate text-[10px] text-muted-foreground">{{ namespace.id }}</code>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium" :title="namespace.name">{{ namespace.name }}</span>
|
||||
<code v-if="namespace.name !== namespace.id" class="block break-all text-[10px] leading-4 text-muted-foreground" :title="namespace.id">{{ namespace.id }}</code>
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="!availableNamespaces.length" class="flex h-24 items-center justify-center px-3 text-center text-xs text-muted-foreground">{{ t("nacos.accessNoUnassignedNamespaces") }}</div>
|
||||
</div>
|
||||
@@ -865,12 +961,16 @@ onMounted(() => void loadSnapshot(false));
|
||||
<div class="flex min-h-0 flex-col overflow-hidden rounded border">
|
||||
<div class="border-b bg-muted/30 px-3 py-2 text-sm font-medium">{{ t("nacos.accessGrantedNamespaces") }}</div>
|
||||
<div class="border-b p-2">
|
||||
<div class="relative"><Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" /><Input v-model="grantedNamespaceSearch" class="h-8 pl-8 text-sm" :placeholder="t('nacos.accessSearchNamespaces')" /></div>
|
||||
<div class="relative">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" /><Input v-model="grantedNamespaceSearch" class="h-8 pl-8 text-sm" :placeholder="t('nacos.accessSearchNamespaces')" :aria-label="t('nacos.accessSearchNamespaces')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-1.5">
|
||||
<div v-for="namespace in grantedNamespaces" :key="namespace.namespaceId" class="flex min-h-10 items-center gap-2 rounded px-2 text-sm hover:bg-muted/60">
|
||||
<div v-for="namespace in grantedNamespaces" :key="namespace.namespaceId" class="flex min-h-11 items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted/60">
|
||||
<input type="checkbox" :checked="selectedGrantedNamespaceIds.includes(namespace.namespaceId)" @change="selectedGrantedNamespaceIds = toggle(selectedGrantedNamespaceIds, namespace.namespaceId, ($event.target as HTMLInputElement).checked)" />
|
||||
<span class="min-w-0 flex-1 truncate font-medium">{{ namespace.name }}</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate font-medium" :title="namespace.name">{{ namespace.name }}</span>
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<label
|
||||
v-for="action in ['r', 'w', 'rw'] as PermissionAction[]"
|
||||
@@ -903,13 +1003,18 @@ onMounted(() => void loadSnapshot(false));
|
||||
<template v-else
|
||||
><div class="rounded border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">{{ t("nacos.accessDeleteRoleHint", { role: roleForm.role }) }}</div>
|
||||
<div class="grid gap-1.5">
|
||||
<Label for="access-delete-role-confirm">{{ t("nacos.accessTypeRole", { value: roleForm.role }) }}</Label
|
||||
><Input id="access-delete-role-confirm" v-model="roleForm.confirmation" /></div
|
||||
<Label for="access-delete-role-confirm">{{ t("nacos.accessDeleteRoleConfirmationLabel") }}</Label>
|
||||
<p id="access-delete-role-confirm-hint" class="text-xs text-muted-foreground">
|
||||
{{ t("nacos.accessDeleteRoleConfirmationHint") }} <code class="select-all rounded bg-muted px-1.5 py-0.5 font-mono text-foreground">{{ roleForm.role }}</code>
|
||||
</p>
|
||||
<Input id="access-delete-role-confirm" v-model="roleForm.confirmation" autocomplete="off" :placeholder="t('nacos.accessDeleteRoleConfirmationPlaceholder')" aria-describedby="access-delete-role-confirm-hint" /></div
|
||||
></template>
|
||||
<p v-if="formError" class="text-sm text-destructive">{{ formError }}</p>
|
||||
<DialogFooter
|
||||
><Button variant="outline" :disabled="saving" @click="roleDialog = null">{{ t("nacos.cancel") }}</Button
|
||||
><Button :variant="roleDialog === 'delete' ? 'destructive' : 'default'" :disabled="saving || !roleOperationWritable" @click="submitRole"><Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />{{ roleDialog === "delete" ? t("nacos.delete") : t("nacos.save") }}</Button></DialogFooter
|
||||
><Button :variant="roleDialog === 'delete' ? 'destructive' : 'default'" :disabled="saving || !roleOperationWritable || (roleDialog === 'delete' && !roleDeleteConfirmed)" @click="submitRole"
|
||||
><Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />{{ roleDialog === "delete" ? t("nacos.delete") : t("nacos.save") }}</Button
|
||||
></DialogFooter
|
||||
>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
+98
-9
@@ -9,15 +9,18 @@ import type { NacosConnectionInfo } from "@/types/nacos";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
connectionInfo: null as NacosConnectionInfo | null,
|
||||
ensureConnected: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
refreshLegacyWorkspace: vi.fn(async () => undefined),
|
||||
refreshEnhancedWorkspace: vi.fn(async () => undefined),
|
||||
nacosTestConnection: vi.fn(async () => {
|
||||
if (!mocks.connectionInfo) throw new Error("missing Nacos connection fixture");
|
||||
return mocks.connectionInfo;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
nacosTestConnection: (...args: unknown[]) => mocks.nacosTestConnection(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({ ensureConnected: mocks.ensureConnected }),
|
||||
}));
|
||||
@@ -31,7 +34,10 @@ vi.mock("@/components/nacos/NacosAccessControl.vue", async () => {
|
||||
return {
|
||||
default: {
|
||||
props: ["tab"],
|
||||
setup: (props: { tab: string }) => () => h("div", { "data-testid": "nacos-access-control-workspace", "data-tab": props.tab }),
|
||||
setup: (props: { tab: string }, { expose }: { expose: (value: { refresh: () => Promise<void> }) => void }) => {
|
||||
expose({ refresh: mocks.refreshLegacyWorkspace });
|
||||
return () => h("div", { "data-testid": "nacos-access-control-workspace", "data-tab": props.tab });
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -41,11 +47,14 @@ vi.mock("@/components/nacos/NacosRoleAccessControl.vue", async () => {
|
||||
return {
|
||||
default: {
|
||||
props: ["capabilities"],
|
||||
setup: (props: { capabilities: { createUser: { supported: boolean } } }) => () =>
|
||||
h("div", {
|
||||
"data-testid": "nacos-role-access-control-workspace",
|
||||
"data-can-create-user": String(props.capabilities.createUser.supported),
|
||||
}),
|
||||
setup: (props: { capabilities: { createUser: { supported: boolean } } }, { expose }: { expose: (value: { refresh: () => Promise<void> }) => void }) => {
|
||||
expose({ refresh: mocks.refreshEnhancedWorkspace });
|
||||
return () =>
|
||||
h("div", {
|
||||
"data-testid": "nacos-role-access-control-workspace",
|
||||
"data-can-create-user": String(props.capabilities.createUser.supported),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -98,6 +107,39 @@ describe("NacosAccessControlConsole", () => {
|
||||
expect(legacySource).not.toContain('<textarea v-model="userForm.blacklist"');
|
||||
});
|
||||
|
||||
it("invalidates the r-nacos namespace picker when namespaces change", () => {
|
||||
expect(legacySource).toContain('import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache"');
|
||||
expect(legacySource).toContain("stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged)");
|
||||
expect(legacySource).toContain("if (detail.connectionId !== props.connectionId) return");
|
||||
expect(legacySource).toContain("if (userDialogOpen.value && isRNacos.value) void loadUserFormNamespaces(true)");
|
||||
expect(legacySource).toContain("stopNacosNamespacesChangedListener?.()");
|
||||
});
|
||||
|
||||
it("notifies users when an official Nacos password reset succeeds or fails", () => {
|
||||
expect(legacySource).toContain('const userOperationNotice = ref<{ kind: "success" | "error"; message: string } | null>(null)');
|
||||
expect(legacySource).toContain("await syncCurrentConnectionPassword(username, userForm.value.password)");
|
||||
expect(legacySource).toContain('userOperationNotice.value = { kind: "error", message: t("nacos.passwordResetFailed", { message }) }');
|
||||
expect(legacySource).toContain('v-if="userOperationNotice"');
|
||||
});
|
||||
|
||||
it("requires exact username confirmation before deleting a user in the legacy workspace", () => {
|
||||
expect(legacySource).toContain("const userDeleteConfirmed = computed(() => userDeleteConfirmation.value === pendingUserDelete.value?.username)");
|
||||
expect(legacySource).toContain('@click="openDeleteUser(user)"');
|
||||
expect(legacySource).toContain('t("nacos.accessDeleteUserConfirmationLabel")');
|
||||
expect(legacySource).toContain(':disabled="deletingUser || !userDeleteConfirmed"');
|
||||
});
|
||||
|
||||
it("does not hide user deletion solely because the user has ROLE_ADMIN", () => {
|
||||
expect(legacySource).not.toContain('if (user.roles.includes("ROLE_ADMIN")) return');
|
||||
expect(legacySource).not.toContain("v-if=\"!user.roles.includes('ROLE_ADMIN')\"");
|
||||
});
|
||||
|
||||
it("uses the same role creation icon in the legacy and enhanced workspaces", () => {
|
||||
expect(legacySource).toContain('import { KeyRound, Loader2, Pencil, Plus, RefreshCw, Search, ShieldCheck, ShieldPlus, Trash2, UserRound } from "@lucide/vue"');
|
||||
expect(legacySource).toContain('@click="openAssignRole"><ShieldPlus class="h-3.5 w-3.5" />{{ t("nacos.assignRole") }}');
|
||||
expect(readFileSync(resolve(process.cwd(), "apps/desktop/src/components/nacos/NacosRoleAccessControl.vue"), "utf8")).toContain('<ShieldPlus v-else class="h-3.5 w-3.5" />');
|
||||
});
|
||||
|
||||
it("switches tabs when navigating between associated users and roles", () => {
|
||||
expect(source).toContain("@select-user=\"activeTab = 'users'\"");
|
||||
expect(source).toContain("@select-role=\"activeTab = 'roles'\"");
|
||||
@@ -202,6 +244,53 @@ describe("NacosAccessControlConsole", () => {
|
||||
expect(workspace?.getAttribute("data-can-create-user")).toBe("false");
|
||||
});
|
||||
|
||||
it("refreshes the enhanced workspace snapshot after refreshing connection capabilities", async () => {
|
||||
const supported = { supported: true };
|
||||
mocks.connectionInfo = {
|
||||
serverAddr: "http://127.0.0.1:8848",
|
||||
displayServerAddr: "http://127.0.0.1:8848",
|
||||
namespace: "",
|
||||
auth: "usernamePassword",
|
||||
capabilities: {
|
||||
supportsConfigManagement: true,
|
||||
supportsServiceManagement: true,
|
||||
supportsInstanceUpdate: true,
|
||||
supportsRawApi: true,
|
||||
accessControl: {
|
||||
mode: "roleBindings",
|
||||
listUsers: supported,
|
||||
createUser: supported,
|
||||
updateUser: supported,
|
||||
deleteUser: supported,
|
||||
listRoleBindings: supported,
|
||||
assignRole: supported,
|
||||
removeRole: supported,
|
||||
listPermissions: supported,
|
||||
grantPermission: supported,
|
||||
revokePermission: supported,
|
||||
enhancedWorkspace: true,
|
||||
supportsNamespacePrivileges: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const app = createApp(NacosAccessControlConsole, { connectionId: "nacos-refresh" });
|
||||
mountedApps.push({ app, host });
|
||||
app.mount(host);
|
||||
await settle();
|
||||
mocks.refreshEnhancedWorkspace.mockClear();
|
||||
|
||||
const refreshButton = [...host.querySelectorAll("button")].find((button) => button.textContent?.includes("nacos.refresh"));
|
||||
expect(refreshButton).toBeDefined();
|
||||
refreshButton?.click();
|
||||
await settle();
|
||||
|
||||
expect(mocks.nacosTestConnection).toHaveBeenLastCalledWith("nacos-refresh", true);
|
||||
expect(mocks.refreshEnhancedWorkspace).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("internationalizes the sidebar entry, including trees persisted with the old label", () => {
|
||||
expect(connectionStoreSource).toContain('label: "nacos.accessControlSidebarLabel"');
|
||||
expect(treeItemSource).toContain('if (node.type === "nacos-access-control") return t("nacos.accessControlSidebarLabel")');
|
||||
|
||||
@@ -197,6 +197,12 @@ describe("NacosAdminConsole config workbench layout", () => {
|
||||
expect(source).toContain("border-destructive/50 text-destructive hover:bg-destructive/10");
|
||||
});
|
||||
|
||||
it("hides instance operations that the selected API plane does not support", () => {
|
||||
expect(source).toContain('v-if="registerInstanceCapability.supported"');
|
||||
expect(source).toContain('v-if="updateInstanceHealthCapability.supported"');
|
||||
expect(source).toContain('v-if="deregisterInstanceCapability.supported"');
|
||||
});
|
||||
|
||||
it("separates the service header, filtering controls, and management actions", () => {
|
||||
expect(source).toContain('<header class="shrink-0 border-b bg-background">');
|
||||
expect(source).toContain('class="flex flex-wrap items-center gap-x-4 gap-y-2 border-t bg-muted/30 px-4 py-2"');
|
||||
|
||||
+64
-2
@@ -10,6 +10,15 @@ describe("NacosRoleAccessControl", () => {
|
||||
expect(source).toContain("v-else-if=\"tab === 'roles' && selectedRole\"");
|
||||
});
|
||||
|
||||
it("places the contextual create action beside search instead of duplicating refresh", () => {
|
||||
expect(source).toContain(":aria-label=\"tab === 'users' ? t('nacos.createUser') : t('nacos.accessCreateRole')\"");
|
||||
expect(source).toContain("@click=\"tab === 'users' ? openCreateUser() : openCreateRole()\"");
|
||||
expect(source).toContain("<UserPlus v-if=\"tab === 'users'\"");
|
||||
expect(source).toContain("<ShieldPlus v-else");
|
||||
expect(source).not.toContain(":aria-label=\"t('nacos.refresh')\"");
|
||||
expect(source).not.toContain('class="h-8 w-full"');
|
||||
});
|
||||
|
||||
it("keeps role creation complete with per-namespace permissions", () => {
|
||||
expect(source).toContain("roleForm.newUsers");
|
||||
expect(source).toContain("moveNamespacesToGranted");
|
||||
@@ -20,6 +29,52 @@ describe("NacosRoleAccessControl", () => {
|
||||
expect(source).toContain('kind: "createRole"');
|
||||
});
|
||||
|
||||
it("keeps the selected role identity when opening the delete dialog", () => {
|
||||
expect(source).toContain("resetRoleForm(selectedRole.value.role)");
|
||||
});
|
||||
|
||||
it("makes the exact role-name confirmation requirement explicit before deletion", () => {
|
||||
expect(source).toContain("const roleDeleteConfirmed = computed(() => roleForm.confirmation === roleForm.role)");
|
||||
expect(source).toContain('t("nacos.accessDeleteRoleConfirmationLabel")');
|
||||
expect(source).toContain('t("nacos.accessDeleteRoleConfirmationHint")');
|
||||
expect(source).toContain("roleDialog === 'delete' && !roleDeleteConfirmed");
|
||||
});
|
||||
|
||||
it("requires the exact username before deleting any user", () => {
|
||||
expect(source).toContain("const userDeleteConfirmed = computed(() => userForm.confirmation === userForm.username)");
|
||||
expect(source).toContain('t("nacos.accessDeleteUserConfirmationLabel")');
|
||||
expect(source).toContain('t("nacos.accessDeleteUserConfirmationHint")');
|
||||
expect(source).toContain("userDialog === 'delete' && !userDeleteConfirmed");
|
||||
});
|
||||
|
||||
it("lets the backend workflow protect only the final ROLE_ADMIN member", () => {
|
||||
expect(source).toContain("if (!selectedUser.value) return");
|
||||
expect(source).not.toContain('selectedUser.value.roles.includes("ROLE_ADMIN")');
|
||||
expect(source).not.toContain("v-if=\"!selectedUser.roles.includes('ROLE_ADMIN')\"");
|
||||
});
|
||||
|
||||
it("shows complete IDs only in the unassigned namespace list", () => {
|
||||
expect(source).toContain('class="block truncate font-medium" :title="namespace.name"');
|
||||
expect(source).toContain('class="block break-all text-[10px] leading-4 text-muted-foreground" :title="namespace.id"');
|
||||
expect(source).not.toContain('<code v-if="namespace.name !== namespace.namespaceId"');
|
||||
});
|
||||
|
||||
it("uses a wider, internally scrollable namespace picker and filters by name or ID", () => {
|
||||
expect(source).toContain('class="max-h-[90vh] overflow-auto sm:max-w-3xl"');
|
||||
expect(source).toContain("h-[min(28rem,45vh)]");
|
||||
expect(source).toContain('const id = item.id ?? item.namespaceId ?? ""');
|
||||
expect(source).toContain("item.name.toLocaleLowerCase().includes(normalized) || id.toLocaleLowerCase().includes(normalized)");
|
||||
expect(source).toContain(":aria-label=\"t('nacos.accessSearchNamespaces')\"");
|
||||
});
|
||||
|
||||
it("refreshes the role namespace options after a namespace change for this connection", () => {
|
||||
expect(source).toContain('import { subscribeNacosNamespacesChanged, type NacosNamespacesChangedDetail } from "@/lib/nacos/nacosNamespaceCache"');
|
||||
expect(source).toContain("function handleNacosNamespacesChanged(detail: NacosNamespacesChangedDetail)");
|
||||
expect(source).toContain("if (detail.connectionId === props.connectionId) void loadSnapshot()");
|
||||
expect(source).toContain("stopNacosNamespacesChangedListener = subscribeNacosNamespacesChanged(handleNacosNamespacesChanged)");
|
||||
expect(source).toContain("stopNacosNamespacesChangedListener?.()");
|
||||
});
|
||||
|
||||
it("merges split permission rows before editing an existing role", () => {
|
||||
expect(source).toContain("mergeNacosNamespacePermissionAssignments(managedRolePermissions.value)");
|
||||
});
|
||||
@@ -30,8 +85,8 @@ describe("NacosRoleAccessControl", () => {
|
||||
expect(source).toContain("const canGrantPermission = computed");
|
||||
expect(source).toContain("const userOperationWritable = computed");
|
||||
expect(source).toContain("const roleOperationWritable = computed");
|
||||
expect(source).toContain(':disabled="saving || !userOperationWritable"');
|
||||
expect(source).toContain(':disabled="saving || !roleOperationWritable"');
|
||||
expect(source).toContain(':disabled="saving || !userOperationWritable ||');
|
||||
expect(source).toContain(':disabled="saving || !roleOperationWritable ||');
|
||||
});
|
||||
|
||||
it("surfaces partial operations instead of hiding them in a toast", () => {
|
||||
@@ -41,6 +96,13 @@ describe("NacosRoleAccessControl", () => {
|
||||
expect(source).toContain("accessOperationResult");
|
||||
});
|
||||
|
||||
it("uses the global notification for password reset results", () => {
|
||||
expect(source).toContain('import { useToast } from "@/composables/useToast"');
|
||||
expect(source).toContain("const { toast } = useToast()");
|
||||
expect(source).toContain('toast(t("nacos.passwordResetSucceeded", { username }), 2500)');
|
||||
expect(source).toContain('toast(t("nacos.passwordResetFailed", { message }), 5000)');
|
||||
});
|
||||
|
||||
it("renders associated roles with the same navigable card pattern as role members", () => {
|
||||
expect(source).toContain('v-if="selectedUser.roles.length" class="flex flex-wrap gap-1.5 rounded border bg-muted/20 p-1.5"');
|
||||
expect(source).toContain(":title=\"t('nacos.accessOpenRole', { role })\"");
|
||||
|
||||
@@ -95,6 +95,9 @@ const {
|
||||
editNacosNamespaceDesc,
|
||||
editNacosNamespaceLoading,
|
||||
confirmEditNacosNamespace,
|
||||
showDeleteNacosNamespaceConfirm,
|
||||
deleteNacosNamespaceLoading,
|
||||
confirmDeleteNacosNamespace,
|
||||
showRenameMongoCollectionDialog,
|
||||
renameMongoCollectionName,
|
||||
renameMongoCollectionError,
|
||||
@@ -195,6 +198,7 @@ watch(
|
||||
showEditDatabasePropertiesDialog,
|
||||
showCreateNacosNamespaceDialog,
|
||||
showEditNacosNamespaceDialog,
|
||||
showDeleteNacosNamespaceConfirm,
|
||||
showRenameMongoCollectionDialog,
|
||||
showCloneMongoCollectionDialog,
|
||||
showCreateMongoIndexDialog,
|
||||
@@ -741,6 +745,26 @@ watch(
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showDeleteNacosNamespaceConfirm">
|
||||
<DialogContent class="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("nacos.deleteNamespace") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="flex items-start gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
|
||||
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
<p class="min-w-0 break-words text-muted-foreground [overflow-wrap:anywhere]">
|
||||
{{ t("nacos.deleteNamespaceDescription", { name: node.nacosNamespaceName || node.label, id: node.nacosNamespace || "public" }) }}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="deleteNacosNamespaceLoading" @click="showDeleteNacosNamespaceConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button variant="destructive" :disabled="deleteNacosNamespaceLoading" @click="confirmDeleteNacosNamespace">
|
||||
{{ deleteNacosNamespaceLoading ? t("nacos.deletingNamespace") : t("dangerDialog.confirm") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showCreateSchemaDialog">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -254,6 +254,8 @@ import {
|
||||
editNacosNamespaceName,
|
||||
editNacosNamespaceDesc,
|
||||
editNacosNamespaceLoading,
|
||||
showDeleteNacosNamespaceConfirm,
|
||||
deleteNacosNamespaceLoading,
|
||||
createDatabaseCharsetOptions,
|
||||
createDatabaseCollationsByCharset,
|
||||
createDatabaseCharsetLoading,
|
||||
@@ -497,6 +499,8 @@ const {
|
||||
confirmCreateNacosNamespace,
|
||||
openEditNacosNamespaceDialog,
|
||||
confirmEditNacosNamespace,
|
||||
openDeleteNacosNamespaceConfirm,
|
||||
confirmDeleteNacosNamespace,
|
||||
dropMongoCollection,
|
||||
dropMilvusCollection,
|
||||
dropMongoIndex,
|
||||
@@ -2898,6 +2902,8 @@ const canEditNacosNamespace = computed(() => {
|
||||
return config?.db_type === "nacos" && !config.read_only;
|
||||
});
|
||||
|
||||
const canDeleteNacosNamespace = computed(() => canEditNacosNamespace.value);
|
||||
|
||||
const isDuckDbConnection = computed(() => {
|
||||
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
|
||||
return activeNode.value.type === "connection" && config?.db_type === "duckdb" && connectionNamespaceCreationTarget(config) === "attach";
|
||||
@@ -4450,6 +4456,9 @@ function databaseSpecificDialogCapabilities() {
|
||||
editNacosNamespaceDesc,
|
||||
editNacosNamespaceLoading,
|
||||
confirmEditNacosNamespace,
|
||||
showDeleteNacosNamespaceConfirm,
|
||||
deleteNacosNamespaceLoading,
|
||||
confirmDeleteNacosNamespace,
|
||||
showRenameMongoCollectionDialog,
|
||||
renameMongoCollectionName,
|
||||
renameMongoCollectionError,
|
||||
@@ -5114,6 +5123,16 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
});
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
if (canDeleteNacosNamespace.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({
|
||||
label: t("nacos.deleteNamespace"),
|
||||
action: openDeleteNacosNamespaceConfirm,
|
||||
icon: Trash2,
|
||||
shortcut: shortcutDelete,
|
||||
variant: "destructive" as const,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ export const showEditNacosNamespaceDialog = ref(false);
|
||||
export const editNacosNamespaceName = ref("");
|
||||
export const editNacosNamespaceDesc = ref("");
|
||||
export const editNacosNamespaceLoading = ref(false);
|
||||
export const showDeleteNacosNamespaceConfirm = ref(false);
|
||||
export const deleteNacosNamespaceLoading = ref(false);
|
||||
export const createDatabaseCharsetOptions = ref<string[]>(fallbackCreateDatabaseCharset.charsets);
|
||||
export const createDatabaseCollationsByCharset = ref<Record<string, string[]>>(fallbackCreateDatabaseCharset.collationsByCharset);
|
||||
export const createDatabaseCharsetLoading = ref(false);
|
||||
|
||||
Reference in new issue
Block a user