feat(nacos): enhance configuration management

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-26 11:20:05 +08:00
1 parent 406b7acdbb
commit 1ebc3fd12b
52 files changed
+7139 -263

No files matched your search

@@ -1292,9 +1292,12 @@ function buildNacosAdminConfig(): NacosAdminConfig {
if (nacosImplementation.value === "rnacos" && normalized.warnings.length) {
throw new Error(t("connection.nacosRNacosOpenApiRequired"));
}
const rnacosConsoleConfigured = nacosImplementation.value === "rnacos" && !!nacosRNacosConsoleAddr.value.trim();
if (nacosImplementation.value === "rnacos" && nacosHistoryEnabled.value && !rnacosConsoleConfigured) {
throw new Error(t("connection.nacosRNacosConsoleUrlRequired"));
}
let rnacosConsoleAuth: NacosRNacosConsoleAuth | undefined;
if (nacosImplementation.value === "rnacos" && nacosHistoryEnabled.value) {
if (!nacosRNacosConsoleAddr.value.trim()) throw new Error(t("connection.nacosRNacosConsoleUrlRequired"));
if (rnacosConsoleConfigured) {
if (nacosConsoleAuthKind.value === "inherit") {
if (nacosAuthKind.value !== "usernamePassword") throw new Error(t("connection.nacosConsoleAuthSeparateRequired"));
rnacosConsoleAuth = { kind: "inherit" };
@@ -1312,7 +1315,7 @@ function buildNacosAdminConfig(): NacosAdminConfig {
serverAddr: normalized.serverAddr,
namespace: nacosNamespace.value.trim() || undefined,
contextPath: normalized.contextPath || undefined,
rnacosConsoleAddr: nacosImplementation.value === "rnacos" && nacosHistoryEnabled.value ? nacosRNacosConsoleAddr.value.trim() || undefined : undefined,
rnacosConsoleAddr: nacosImplementation.value === "rnacos" ? nacosRNacosConsoleAddr.value.trim() || undefined : undefined,
rnacosHistoryEnabled: nacosImplementation.value === "rnacos" ? nacosHistoryEnabled.value : undefined,
rnacosConsoleAuth,
auth: buildNacosAuth(),
@@ -5274,11 +5277,15 @@ function openExternalUrl(url: string) {
</Tooltip>
</div>
</div>
<template v-if="nacosHistoryEnabled">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.nacosRNacosConsoleUrl") }}</Label>
<Input v-model="nacosRNacosConsoleAddr" class="col-span-3" :placeholder="t('connection.nacosRNacosConsoleUrlPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.nacosRNacosConsoleUrl") }}</Label>
<Input v-model="nacosRNacosConsoleAddr" class="col-span-3" :placeholder="t('connection.nacosRNacosConsoleUrlPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 m-0 text-xs leading-5 text-muted-foreground">{{ t("connection.nacosRNacosConsoleUrlHint") }}</p>
</div>
<template v-if="nacosRNacosConsoleAddr.trim()">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.nacosConsoleAuthentication") }}</Label>
<div class="col-span-3 flex gap-2">
@@ -1612,7 +1612,17 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<template v-else-if="activeTab.mode === 'nacos'">
<div class="flex-1 min-h-0">
<NacosAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :namespace="activeTab.nacosNamespace" :namespace-name="activeTab.nacosNamespaceName" :read-only="activeConnection?.read_only ?? false" />
<NacosAdminConsole
:key="activeTab.id"
:connection-id="activeTab.connectionId"
:namespace="activeTab.nacosNamespace"
:namespace-name="activeTab.nacosNamespaceName"
:target-data-id="activeTab.nacosTargetDataId"
:target-group="activeTab.nacosTargetGroup"
:target-keyword="activeTab.nacosTargetKeyword"
:target-request-id="activeTab.nacosTargetRequestId"
:read-only="activeConnection?.read_only ?? false"
/>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,294 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { AlertTriangle, Archive, CheckCircle2, FileUp, Loader2 } 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 type { NacosBatchPreview, NacosBatchReport, NacosConfigSelectionScope, NacosConflictPolicy, NacosNamespaceInfo } from "@/types/nacos";
export type NacosBatchDialogMode = "export" | "import" | "copy";
export interface NacosConfigTransferTarget {
id: string;
label: string;
}
const props = defineProps<{
open: boolean;
mode: NacosBatchDialogMode;
loading: boolean;
selectedCount: number;
filteredCount: number;
targetConnections: NacosConfigTransferTarget[];
targetConnectionId: string;
sourceConnectionId: string;
namespaces: NacosNamespaceInfo[];
currentNamespace: string;
preview: NacosBatchPreview | null;
report: NacosBatchReport | null;
sourceName?: string;
error?: string;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
chooseFile: [];
reset: [];
targetConnectionChange: [connectionId: string];
preview: [payload: { scope: NacosConfigSelectionScope; targetConnectionId: string; targetNamespace: string; policy: NacosConflictPolicy }];
apply: [payload: { scope: NacosConfigSelectionScope; targetConnectionId: string; targetNamespace: string; policy: NacosConflictPolicy }];
export: [scope: NacosConfigSelectionScope];
}>();
const { t } = useI18n();
const scope = ref<NacosConfigSelectionScope>("selected");
const policy = ref<NacosConflictPolicy>("ABORT");
const targetNamespace = ref("");
const titleKey = computed(() => `nacos.batch${props.mode[0].toUpperCase()}${props.mode.slice(1)}Title`);
const descriptionKey = computed(() => `nacos.batch${props.mode[0].toUpperCase()}${props.mode.slice(1)}Description`);
const targetNamespaces = computed(() => props.namespaces.filter((item) => props.targetConnectionId !== props.sourceConnectionId || item.namespace !== props.currentNamespace));
const selectedTargetNamespace = computed(() => {
try {
const namespace = JSON.parse(targetNamespace.value);
return typeof namespace === "string" ? namespace : "";
} catch {
return "";
}
});
const canContinue = computed(() => {
if (props.mode === "import") return !!props.sourceName;
if (props.mode === "copy") return !!props.targetConnectionId && targetNamespace.value !== "" && (scope.value !== "selected" || props.selectedCount > 0);
return scope.value !== "selected" || props.selectedCount > 0;
});
const hasPreviewBlockingErrors = computed(() => !!props.preview && (props.preview.invalid > 0 || (policy.value === "ABORT" && props.preview.conflicts > 0)));
const reportWritten = computed(() => (props.report?.created ?? 0) + (props.report?.overwritten ?? 0));
const reportProcessed = computed(() => reportWritten.value + (props.report?.skipped ?? 0) + (props.report?.failed ?? 0));
const reportNeedsAttention = computed(() => !!props.report && (props.report.aborted || props.report.partial || props.report.cancelled || props.report.failed > 0));
const reportSummaryClass = computed(() => {
if (props.report?.failed) return "text-destructive";
return reportNeedsAttention.value ? "text-amber-600" : "text-emerald-600";
});
const reportSummary = computed(() => {
const report = props.report;
if (!report) return "";
if (report.aborted) return t("nacos.batchAborted");
if (report.cancelled) return t("nacos.batchCancelledSummary", { processed: reportProcessed.value, total: report.total });
if (report.failed) return t("nacos.batchFailedSummary", { written: reportWritten.value, failed: report.failed });
if (report.partial) return t("nacos.batchPartialSummary", { processed: reportProcessed.value, total: report.total });
if (report.skipped) return t("nacos.batchFinishedWithSkipped", { written: reportWritten.value, skipped: report.skipped });
return t("nacos.batchFinished", { written: reportWritten.value });
});
const batchStatusKeys: Record<string, string> = {
create: "nacos.batchStatusCreate",
conflict: "nacos.batchStatusConflict",
invalid: "nacos.batchStatusInvalid",
created: "nacos.batchStatusCreated",
overwritten: "nacos.batchStatusOverwritten",
skipped: "nacos.batchStatusSkipped",
failed: "nacos.batchStatusFailed",
aborted: "nacos.batchStatusAborted",
exported: "nacos.batchStatusExported",
};
function batchStatusLabel(status: string) {
return t(batchStatusKeys[status] ?? "nacos.batchStatusUnknown", { status });
}
function batchStatusClass(status: string) {
if (["create", "created", "overwritten", "exported"].includes(status)) return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
if (["conflict", "skipped", "aborted"].includes(status)) return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
if (["invalid", "failed"].includes(status)) return "border-destructive/30 bg-destructive/10 text-destructive";
return "text-muted-foreground";
}
function resetTargetNamespace() {
if (targetNamespaces.value.some((item) => JSON.stringify(item.namespace) === targetNamespace.value)) return;
targetNamespace.value = targetNamespaces.value[0] ? JSON.stringify(targetNamespaces.value[0].namespace) : "";
}
watch(
() => props.open,
(open) => {
if (!open) return;
scope.value = props.selectedCount ? "selected" : "filtered";
policy.value = "ABORT";
resetTargetNamespace();
},
{ immediate: true },
);
watch(
() => props.targetConnectionId,
() => {
targetNamespace.value = "";
},
);
watch(targetNamespaces, () => {
if (props.open) resetTargetNamespace();
});
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="flex max-h-[82vh] flex-col overflow-hidden sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{{ t(titleKey) }}</DialogTitle>
<DialogDescription>{{ t(descriptionKey) }}</DialogDescription>
</DialogHeader>
<div class="min-h-0 flex-1 space-y-4 overflow-auto">
<div v-if="mode !== 'import'" class="space-y-2">
<div class="text-sm font-medium">{{ t("nacos.exportScope") }}</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="scope" type="radio" name="nacos-batch-scope" value="selected" @change="emit('reset')" />
<span>{{ t("nacos.selectedConfigs", { count: selectedCount }) }}</span>
</label>
<label class="flex items-center gap-2 text-sm">
<input v-model="scope" type="radio" name="nacos-batch-scope" value="filtered" @change="emit('reset')" />
<span>{{ t("nacos.filteredConfigs", { count: filteredCount }) }}</span>
</label>
<label class="flex items-center gap-2 text-sm">
<input v-model="scope" type="radio" name="nacos-batch-scope" value="namespace" @change="emit('reset')" />
<span>{{ t("nacos.namespaceAllConfigs") }}</span>
</label>
</div>
<div v-if="mode === 'import'" class="rounded-md border p-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-medium">{{ t("nacos.importArchive") }}</div>
<div class="truncate text-xs text-muted-foreground">{{ sourceName || t("nacos.noArchiveSelected") }}</div>
</div>
<Button variant="outline" size="sm" :disabled="loading" @click="emit('chooseFile')">
<FileUp class="mr-2 h-4 w-4" />
{{ t("nacos.chooseZip") }}
</Button>
</div>
<div class="mt-3 flex items-start gap-2 rounded-md bg-amber-500/10 p-2 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" />
<span>{{ t("nacos.importSensitiveWarning") }}</span>
</div>
</div>
<div v-if="mode === 'copy'" class="space-y-2">
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<div class="text-sm font-medium">{{ t("nacos.targetConnection") }}</div>
<select
:value="targetConnectionId"
class="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
:disabled="loading || !targetConnections.length"
@change="
emit('targetConnectionChange', ($event.target as HTMLSelectElement).value);
emit('reset');
"
>
<option disabled value="">{{ t("nacos.chooseTargetConnection") }}</option>
<option v-for="connection in targetConnections" :key="connection.id" :value="connection.id">{{ connection.label }}</option>
</select>
<p v-if="!targetConnections.length" class="text-xs text-muted-foreground">{{ t("nacos.noTargetConnections") }}</p>
</div>
<div class="space-y-2">
<div class="text-sm font-medium">{{ t("nacos.targetNamespace") }}</div>
<select v-model="targetNamespace" class="h-10 w-full rounded-md border border-input bg-background px-3 text-sm" :disabled="loading || !targetConnectionId" @change="emit('reset')">
<option disabled value="">{{ t("nacos.chooseTargetNamespace") }}</option>
<option v-for="item in targetNamespaces" :key="item.namespace" :value="JSON.stringify(item.namespace)">{{ item.namespaceShowName || item.namespace || "public" }}</option>
</select>
</div>
</div>
<p class="text-xs text-muted-foreground">{{ t("nacos.copyKeepsSource") }}</p>
</div>
<div v-if="mode !== 'export'" class="space-y-2">
<div class="text-sm font-medium">{{ t("nacos.conflictPolicy") }}</div>
<div class="grid gap-2 sm:grid-cols-3">
<label v-for="value in ['ABORT', 'SKIP', 'OVERWRITE'] as NacosConflictPolicy[]" :key="value" class="flex cursor-pointer items-start gap-2 rounded-md border p-2 text-sm" :class="{ 'border-primary bg-primary/5': policy === value }">
<input v-model="policy" type="radio" name="nacos-batch-policy" :value="value" />
<span>
<span class="block font-medium">{{ t(`nacos.policy${value}`) }}</span>
<span class="block text-xs text-muted-foreground">{{ t(`nacos.policy${value}Hint`) }}</span>
</span>
</label>
</div>
<div v-if="policy === 'OVERWRITE'" class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" />
<span>{{ t("nacos.overwriteWarning") }}</span>
</div>
</div>
<div v-if="preview" class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">{{ t("nacos.previewTotal", { count: preview.total }) }}</Badge>
<Badge variant="outline" class="text-emerald-600">{{ t("nacos.previewCreated", { count: preview.created }) }}</Badge>
<Badge variant="outline" class="text-amber-600">{{ t("nacos.previewConflicts", { count: preview.conflicts }) }}</Badge>
<Badge v-if="preview.invalid" variant="outline" class="text-destructive">{{ t("nacos.previewInvalid", { count: preview.invalid }) }}</Badge>
</div>
<div class="max-h-52 overflow-auto rounded-md border text-xs">
<div v-for="item in preview.items" :key="`${item.namespace}\u0000${item.group}\u0000${item.dataId}`" class="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b px-3 py-2 last:border-b-0">
<div class="min-w-0">
<div class="truncate font-medium">{{ item.dataId }}</div>
<div class="truncate text-muted-foreground">{{ item.group }} · {{ item.namespace || "public" }}</div>
<div v-if="item.message" class="break-all text-destructive">{{ item.message }}</div>
</div>
<Badge variant="outline" :class="batchStatusClass(item.status)">{{ batchStatusLabel(item.status) }}</Badge>
</div>
</div>
</div>
<div v-if="report" class="space-y-2">
<div class="flex items-center gap-2 text-sm font-medium" :class="reportSummaryClass">
<component :is="reportNeedsAttention ? AlertTriangle : CheckCircle2" class="h-4 w-4" />
{{ reportSummary }}
</div>
<div class="flex flex-wrap gap-2">
<Badge variant="outline">{{ t("nacos.reportWritten", { count: reportWritten }) }}</Badge>
<Badge v-if="report.created" variant="outline" class="text-emerald-600">{{ t("nacos.reportCreated", { count: report.created }) }}</Badge>
<Badge v-if="report.overwritten" variant="outline" class="text-emerald-600">{{ t("nacos.reportOverwritten", { count: report.overwritten }) }}</Badge>
<Badge v-if="report.skipped" variant="outline" class="text-amber-600">{{ t("nacos.reportSkipped", { count: report.skipped }) }}</Badge>
<Badge v-if="report.failed" variant="outline" class="text-destructive">{{ t("nacos.reportFailed", { count: report.failed }) }}</Badge>
<Badge v-if="report.cancelled" variant="outline" class="text-amber-600">{{ t("nacos.batchCancelled") }}</Badge>
</div>
<div v-if="report.items.length" class="max-h-52 overflow-auto rounded-md border text-xs">
<div class="border-b bg-muted/30 px-3 py-2 font-medium text-foreground">{{ t("nacos.reportItems", { count: report.items.length }) }}</div>
<div v-for="item in report.items" :key="`${item.namespace}\u0000${item.group}\u0000${item.dataId}`" class="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b px-3 py-2 last:border-b-0">
<div class="min-w-0">
<div class="truncate font-medium">{{ item.dataId }}</div>
<div class="truncate text-muted-foreground">{{ item.group }} · {{ item.namespace || "public" }}</div>
<div v-if="item.message" class="break-all text-destructive">{{ item.message }}</div>
</div>
<Badge variant="outline" :class="batchStatusClass(item.status)">{{ batchStatusLabel(item.status) }}</Badge>
</div>
</div>
</div>
<p v-if="error && !report" class="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">{{ error }}</p>
</div>
<DialogFooter>
<Button :variant="report ? 'default' : 'outline'" :disabled="loading" @click="emit('update:open', false)">{{ t("common.close") }}</Button>
<template v-if="!report">
<Button v-if="mode === 'export'" :disabled="loading || !canContinue" @click="emit('export', scope)">
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
<Archive v-else class="mr-2 h-4 w-4" />
{{ t("nacos.exportZip") }}
</Button>
<template v-else>
<Button v-if="!preview" :disabled="loading || !canContinue" @click="emit('preview', { scope, targetConnectionId, targetNamespace: selectedTargetNamespace, policy })">
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
{{ t("nacos.preview") }}
</Button>
<Button v-else :variant="policy === 'OVERWRITE' ? 'destructive' : 'default'" :disabled="loading || hasPreviewBlockingErrors" @click="emit('apply', { scope, targetConnectionId, targetNamespace: selectedTargetNamespace, policy })">
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
{{ t("nacos.apply") }}
</Button>
</template>
</template>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { AlertTriangle, Download, Loader2, Search, Square, XCircle } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { splitNacosContentLiteralMatches } from "@/lib/nacos/nacosAdmin";
import type { NacosContentMatch, NacosContentSearchResult, NacosNamespaceScope, NacosSearchProgress } from "@/types/nacos";
const props = defineProps<{
open: boolean;
loading: boolean;
result: NacosContentSearchResult | null;
progress: NacosSearchProgress | null;
error?: string;
initialQuery?: string;
exporting?: boolean;
resetKey?: number;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
search: [payload: { query: string; scope: NacosNamespaceScope }];
cancel: [];
navigate: [match: NacosContentMatch, query: string];
export: [];
clear: [];
}>();
const { t } = useI18n();
const query = ref("");
const scope = ref<NacosNamespaceScope>("currentNamespace");
const submittedQuery = ref("");
const activeMatchKey = ref("");
const matches = computed(() => props.result?.matches ?? props.progress?.matches ?? []);
const failures = computed(() => props.result?.failures ?? props.progress?.failures ?? []);
const scanned = computed(() => props.result?.scanned ?? props.progress?.scanned ?? 0);
const matched = computed(() => props.result?.matches.length ?? props.progress?.matched ?? 0);
const isIncomplete = computed(() => !!(props.result?.incomplete || props.result?.truncated || props.result?.cancelled || props.progress?.truncated || props.progress?.cancelled || failures.value.length));
watch(
() => props.open,
(open) => {
if (open) {
if (!query.value) query.value = props.initialQuery ?? "";
}
},
);
watch(
() => props.resetKey,
() => {
query.value = "";
scope.value = "currentNamespace";
submittedQuery.value = "";
activeMatchKey.value = "";
},
);
function submit() {
const value = query.value;
if (!value || props.loading) return;
submittedQuery.value = value;
activeMatchKey.value = "";
emit("search", { query: value, scope: scope.value });
}
function matchKey(match: NacosContentMatch): string {
return `${match.namespace}\u0000${match.group}\u0000${match.dataId}`;
}
function navigateToMatch(match: NacosContentMatch) {
activeMatchKey.value = matchKey(match);
emit("navigate", match, submittedQuery.value || query.value);
}
function clearSearchResults() {
query.value = "";
scope.value = "currentNamespace";
submittedQuery.value = "";
activeMatchKey.value = "";
emit("clear");
}
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="flex max-h-[82vh] sm:max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{{ t("nacos.contentSearchTitle") }}</DialogTitle>
<DialogDescription>{{ t("nacos.contentSearchDescription") }}</DialogDescription>
</DialogHeader>
<div class="flex min-h-0 flex-1 flex-col gap-3">
<form class="grid items-stretch gap-2 md:grid-cols-[minmax(0,1fr)_14rem_auto]" @submit.prevent="submit">
<Input v-model="query" class="!h-9 box-border px-3 text-sm" :placeholder="t('nacos.contentSearchPlaceholder')" autocomplete="off" />
<Select v-model="scope" :disabled="loading">
<SelectTrigger class="!h-9 w-full box-border px-3 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="currentNamespace">{{ t("nacos.currentNamespace") }}</SelectItem>
<SelectItem value="allNamespaces">{{ t("nacos.allNamespaces") }}</SelectItem>
</SelectContent>
</Select>
<div class="flex h-9 gap-2">
<Button type="submit" size="lg" class="!h-9 min-w-24 flex-1 box-border md:flex-none" :disabled="loading || !query">
<Loader2 v-if="loading" class="h-4 w-4 animate-spin" />
<Search v-else class="h-4 w-4" />
{{ t("nacos.search") }}
</Button>
<Button v-if="loading" type="button" size="lg" variant="outline" class="!h-9 min-w-24 flex-1 box-border md:flex-none" @click="emit('cancel')">
<Square class="h-3.5 w-3.5" />
{{ t("nacos.cancel") }}
</Button>
</div>
</form>
<div v-if="scope === 'allNamespaces'" class="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" />
<span>{{ t("nacos.allNamespacesSearchWarning") }}</span>
</div>
<div v-if="loading || result || progress" class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline">{{ t("nacos.scannedCount", { count: scanned }) }}</Badge>
<Badge variant="outline">{{ t("nacos.matchCount", { count: matched }) }}</Badge>
<span v-if="progress?.namespace" class="truncate font-mono">{{ progress.namespace || "public" }}</span>
<span v-if="progress?.total != null">{{ scanned }} / {{ progress.total }}</span>
<Badge v-if="isIncomplete" variant="outline" class="border-amber-500/50 text-amber-700 dark:text-amber-300">{{ t("nacos.incompleteResult") }}</Badge>
<Button v-if="matches.length" type="button" size="sm" variant="outline" class="ml-auto h-7 gap-1.5 px-2.5" :disabled="loading || exporting" @click="emit('export')">
<Loader2 v-if="exporting" class="h-3.5 w-3.5 animate-spin" />
<Download v-else class="h-3.5 w-3.5" />
{{ t("nacos.exportSearchResults") }}
</Button>
</div>
<p v-if="(result || progress) && matches.length" class="text-xs text-muted-foreground">{{ t("nacos.searchResultsRetainedHint") }}</p>
<p v-if="error" class="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">{{ error }}</p>
<div v-if="failures.length" class="max-h-24 overflow-auto rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs">
<div v-for="failure in failures" :key="`${failure.namespace}:${failure.error}`" class="flex gap-2 py-0.5">
<XCircle class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600" />
<span class="font-mono">{{ failure.namespace || "public" }}</span>
<span class="break-all text-muted-foreground">{{ failure.error }}</span>
</div>
</div>
<div class="min-h-52 flex-1 overflow-auto rounded-md border">
<button
v-for="match in matches"
:key="matchKey(match)"
type="button"
class="block w-full border-b px-3 py-2.5 text-left last:border-b-0 hover:bg-accent/60"
:class="activeMatchKey === matchKey(match) ? 'bg-accent/80 shadow-[inset_3px_0_0_hsl(var(--primary))]' : ''"
:aria-pressed="activeMatchKey === matchKey(match)"
@click="navigateToMatch(match)"
>
<div class="flex min-w-0 items-center gap-2 text-sm">
<span class="truncate font-medium">{{ match.dataId }}</span>
<Badge variant="secondary" class="max-w-48 truncate">{{ match.group || "DEFAULT_GROUP" }}</Badge>
<Badge variant="outline" class="max-w-48 truncate">{{ match.namespace || "public" }}</Badge>
<Badge v-if="activeMatchKey === matchKey(match)" variant="outline" class="shrink-0">{{ t("nacos.openedSearchResult") }}</Badge>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">{{ t("nacos.lineNumber", { line: match.lineNumber }) }}</span>
</div>
<pre
class="mt-1 overflow-hidden text-ellipsis whitespace-pre-wrap break-all font-mono text-xs text-muted-foreground"
><template v-for="(segment, segmentIndex) in splitNacosContentLiteralMatches(match.snippet, submittedQuery)" :key="segmentIndex"><mark v-if="segment.matched" class="rounded-sm bg-amber-300/80 px-0.5 text-foreground dark:bg-amber-500/40">{{ segment.text }}</mark><span v-else>{{ segment.text }}</span></template></pre>
</button>
<div v-if="!loading && (result || progress) && matches.length === 0" class="flex min-h-52 items-center justify-center text-sm text-muted-foreground">{{ t("nacos.noContentMatches") }}</div>
<div v-else-if="!result && !progress && !loading" class="flex min-h-52 items-center justify-center px-6 text-center text-sm text-muted-foreground">{{ t("nacos.contentSearchEmptyHint") }}</div>
</div>
</div>
<DialogFooter class="sm:justify-between">
<Button v-if="result || progress || error" type="button" variant="outline" @click="clearSearchResults">
<XCircle class="h-4 w-4" />
{{ t("nacos.clearSearchResults") }}
</Button>
<Button variant="outline" class="sm:ml-auto" @click="emit('update:open', false)">{{ t("common.close") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../NacosAdminConsole.vue", import.meta.url), "utf8");
describe("NacosAdminConsole config workbench layout", () => {
it("keeps the editor as the final and primary workbench surface", () => {
const contextBar = source.indexOf('class="nacos-config-context-bar');
const inspector = source.indexOf('class="nacos-config-inspector');
const toolbar = source.indexOf('class="nacos-editor-toolbar');
const editor = source.indexOf('ref="configEditorHost"');
expect(contextBar).toBeGreaterThan(0);
expect(inspector).toBeGreaterThan(contextBar);
expect(toolbar).toBeGreaterThan(inspector);
expect(editor).toBeGreaterThan(toolbar);
});
it("uses split-pane container queries instead of viewport breakpoints", () => {
expect(source).toContain(".nacos-config-workbench {\n container-type: inline-size;");
expect(source).toContain("@container (min-width: 960px)");
expect(source).toContain("@container (max-width: 480px)");
expect(source.indexOf('class="nacos-editor-actions-secondary')).toBeLessThan(source.indexOf('class="nacos-editor-actions-primary'));
expect(source).toContain("grid-template-columns: minmax(0, 1fr) auto;");
});
it("tracks format and metadata changes as unsaved configuration state", () => {
expect(source).toContain("configType.value !== originalConfigType.value");
expect(source).toContain('(selectedConfig.value.appName || "") !== originalConfigMetadata.value.appName');
expect(source).toContain('(selectedConfig.value.desc || "") !== originalConfigMetadata.value.desc');
expect(source).toContain('(selectedConfig.value.tags || "") !== originalConfigMetadata.value.tags');
});
it("returns a stale batch apply to preview instead of retrying an expired plan", () => {
const staleBranch = source.indexOf('isNacosErrorCode(error, "stalePreview")');
expect(staleBranch).toBeGreaterThan(0);
expect(source.indexOf("batchPreview.value = null;", staleBranch)).toBeGreaterThan(staleBranch);
expect(source.indexOf("batchTransferRequest.value = null;", staleBranch)).toBeGreaterThan(staleBranch);
expect(source.indexOf('batchError.value = t("nacos.previewExpired");', staleBranch)).toBeGreaterThan(staleBranch);
});
});
@@ -0,0 +1,132 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import NacosConfigBatchDialog from "@/components/nacos/NacosConfigBatchDialog.vue";
import type { NacosBatchReport } from "@/types/nacos";
const mountedApps: App[] = [];
async function mountDialog(
targetConnectionId: string,
namespaces = [
{ namespace: "shared", namespaceShowName: "Shared" },
{ namespace: "remote-only", namespaceShowName: "Remote only" },
],
report: NacosBatchReport | null = null,
) {
const onTargetConnectionChange = vi.fn();
const onPreview = vi.fn();
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup: () => () =>
h(NacosConfigBatchDialog, {
open: true,
mode: "copy",
loading: false,
selectedCount: 1,
filteredCount: 1,
targetConnections: [
{ id: "source", label: "Source" },
{ id: "remote", label: "Remote" },
],
targetConnectionId,
sourceConnectionId: "source",
currentNamespace: "shared",
namespaces,
preview: null,
report,
onTargetConnectionChange,
onPreview,
}),
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
await nextTick();
return { onTargetConnectionChange, onPreview };
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("NacosConfigBatchDialog cross-connection sync", () => {
it("keeps a same-named namespace available when the target is another connection", async () => {
const { onPreview } = await mountDialog("remote");
const selects = Array.from(document.body.querySelectorAll("select")) as HTMLSelectElement[];
const targetNamespaceValues = Array.from(selects[1].options).map((option) => option.value);
expect(targetNamespaceValues).toContain(JSON.stringify("shared"));
Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Preview"))
?.click();
await nextTick();
expect(onPreview).toHaveBeenCalledWith({
scope: "selected",
targetConnectionId: "remote",
targetNamespace: "shared",
policy: "ABORT",
});
});
it("excludes the source namespace only for same-connection sync and emits connection changes", async () => {
const { onTargetConnectionChange } = await mountDialog("source");
const selects = Array.from(document.body.querySelectorAll("select")) as HTMLSelectElement[];
const targetNamespaceValues = Array.from(selects[1].options).map((option) => option.value);
expect(targetNamespaceValues).not.toContain(JSON.stringify("shared"));
selects[0].value = "remote";
selects[0].dispatchEvent(new Event("change"));
await nextTick();
expect(onTargetConnectionChange).toHaveBeenCalledWith("remote");
});
it("can select the public namespace on another connection", async () => {
const { onPreview } = await mountDialog("remote", [{ namespace: "", namespaceShowName: "public" }]);
Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Preview"))
?.click();
await nextTick();
expect(onPreview).toHaveBeenCalledWith({
scope: "selected",
targetConnectionId: "remote",
targetNamespace: "",
policy: "ABORT",
});
});
it("shows an accurate result summary and a localized status for every processed config", async () => {
await mountDialog("remote", undefined, {
operationId: "sync-result",
total: 3,
created: 1,
overwritten: 0,
skipped: 1,
failed: 1,
aborted: false,
partial: true,
cancelled: false,
items: [
{ namespace: "target", group: "DEFAULT_GROUP", dataId: "created.yaml", status: "created" },
{ namespace: "target", group: "DEFAULT_GROUP", dataId: "skipped.yaml", status: "skipped" },
{ namespace: "target", group: "DEFAULT_GROUP", dataId: "failed.yaml", status: "failed", message: "target unavailable" },
],
});
expect(document.body.textContent).toContain("Sync partially completed: 1 written, 1 failed");
expect(document.body.textContent).toContain("Configuration details (3)");
expect(document.body.textContent).toContain("Created");
expect(document.body.textContent).toContain("Skipped");
expect(document.body.textContent).toContain("Failed");
expect(document.body.textContent).toContain("target unavailable");
});
});
@@ -77,6 +77,7 @@ import {
editableDatabasePropertyGroups,
supportsDatabaseCreation,
supportsDatabaseSearch,
supportsConnectionQueryActions,
supportsFieldLineage,
supportsObjectBrowserTreeNode,
supportsSchemaDiagram,
@@ -3536,12 +3537,17 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
const supportsQueryActions = supportsConnectionQueryActions(currentDatabaseType());
if (supportsQueryActions) {
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
}
if (currentDatabaseType() === "redis") {
items.push({ label: t("contextMenu.instanceInfo"), action: openRedisInstanceInfo, icon: Info });
}
const sqlHistoryMenu = savedSqlHistorySubmenu();
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
if (supportsQueryActions) {
const sqlHistoryMenu = savedSqlHistorySubmenu();
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
}
if (node.connectionId && connectionSupportsDatabaseUserAdmin(connectionStore.getConfig(node.connectionId))) {
items.push({ label: t("contextMenu.userAdmin"), action: openUserAdmin, icon: UsersRound });
}