feat(nacos): enhance configuration management

This commit is contained in:
二丫讲梵 2026-07-26 11:20:05 +08:00 committed by GitHub
parent 406b7acdbb
commit 1ebc3fd12b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 7139 additions and 263 deletions

20
Cargo.lock generated
View File

@ -1916,6 +1916,7 @@ dependencies = [
"rustls-pemfile 2.2.0",
"serde",
"serde_json",
"serde_yaml_ng",
"sha2 0.10.9",
"sqlparser",
"ssfmt",
@ -7369,6 +7370,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_yaml_ng"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
dependencies = [
"indexmap 2.14.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serdect"
version = "0.4.3"
@ -9250,6 +9264,12 @@ dependencies = [
"ctutils",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.7.1"

View File

@ -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">

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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);
});
});

View File

@ -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");
});
});

View File

@ -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 });
}

View File

@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { shallowRef } from "vue";
import type { TreeNode } from "@/types/database";
import { createNacosNamespaceDesc, createNacosNamespaceId, createNacosNamespaceLoading, createNacosNamespaceName, showCreateNacosNamespaceDialog, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState";
const mocks = vi.hoisted(() => ({
toast: vi.fn(),
nacosCreateNamespace: vi.fn(),
notifyNacosNamespacesChanged: vi.fn(),
loadNacosNamespaces: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${JSON.stringify(params)}` : key),
}),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("@/lib/backend/api", () => ({
nacosCreateNamespace: (...args: unknown[]) => mocks.nacosCreateNamespace(...args),
}));
vi.mock("@/lib/nacos/nacosNamespaceCache", () => ({
notifyNacosNamespacesChanged: (...args: unknown[]) => mocks.notifyNacosNamespacesChanged(...args),
}));
import { useSidebarDatabaseSpecificMutationRuntime } from "@/composables/useSidebarDatabaseSpecificMutationRuntime";
function connectionNode(): TreeNode {
return {
id: "conn-1",
label: "Nacos",
type: "connection",
connectionId: "conn-1",
isExpanded: false,
};
}
describe("Nacos namespace creation cache invalidation", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.nacosCreateNamespace.mockResolvedValue(undefined);
mocks.loadNacosNamespaces.mockResolvedValue(undefined);
sidebarFormTarget.value = connectionNode();
createNacosNamespaceId.value = "new-space";
createNacosNamespaceName.value = "New Space";
createNacosNamespaceDesc.value = "Created during sync";
createNacosNamespaceLoading.value = false;
showCreateNacosNamespaceDialog.value = true;
});
it("notifies open views for the same connection immediately after creation succeeds", async () => {
const { confirmCreateNacosNamespace } = useSidebarDatabaseSpecificMutationRuntime({
activeNode: shallowRef(connectionNode()),
connectionStore: {
treeNodes: [],
loadNacosNamespaces: mocks.loadNacosNamespaces,
} as any,
});
await confirmCreateNacosNamespace();
expect(mocks.nacosCreateNamespace).toHaveBeenCalledWith("conn-1", {
namespaceId: "new-space",
namespaceName: "New Space",
namespaceDesc: "Created during sync",
});
expect(mocks.notifyNacosNamespacesChanged).toHaveBeenCalledWith("conn-1");
expect(mocks.notifyNacosNamespacesChanged.mock.invocationCallOrder[0]).toBeLessThan(mocks.loadNacosNamespaces.mock.invocationCallOrder[0]);
expect(showCreateNacosNamespaceDialog.value).toBe(false);
});
it("does not invalidate namespace caches when creation fails", async () => {
mocks.nacosCreateNamespace.mockRejectedValueOnce(new Error("create failed"));
const { confirmCreateNacosNamespace } = useSidebarDatabaseSpecificMutationRuntime({
activeNode: shallowRef(connectionNode()),
connectionStore: {
treeNodes: [],
loadNacosNamespaces: mocks.loadNacosNamespaces,
} as any,
});
await confirmCreateNacosNamespace();
expect(mocks.notifyNacosNamespacesChanged).not.toHaveBeenCalled();
expect(mocks.loadNacosNamespaces).not.toHaveBeenCalled();
expect(showCreateNacosNamespaceDialog.value).toBe(true);
expect(createNacosNamespaceLoading.value).toBe(false);
});
});

View File

@ -1,9 +1,10 @@
import { computed, ref } from "vue";
import { computed, ref, type Ref } from "vue";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
export const NACOS_CONFIG_LIST_COLUMN_WIDTHS_STORAGE_KEY = "dbx-nacos-config-list-column-widths";
export const DEFAULT_NACOS_CONFIG_LIST_COLUMN_WIDTHS = [280, 180, 180, 96] as const;
const MIN_NACOS_CONFIG_LIST_COLUMN_WIDTHS = [180, 120, 120, 72] as const;
export const NACOS_CONFIG_LIST_HORIZONTAL_PADDING = 24;
const MIN_NACOS_CONFIG_LIST_COLUMN_WIDTHS = [140, 96, 96, 72] as const;
function minWidthForColumn(index: number) {
return MIN_NACOS_CONFIG_LIST_COLUMN_WIDTHS[index] ?? MIN_NACOS_CONFIG_LIST_COLUMN_WIDTHS[MIN_NACOS_CONFIG_LIST_COLUMN_WIDTHS.length - 1];
@ -37,29 +38,63 @@ function gridTemplateColumnsForWidths(widths: readonly number[]) {
return widths.map((width) => `${width}px`).join(" ");
}
export function useNacosConfigListColumnResize() {
const columnWidths = ref(loadNacosConfigListColumnWidths());
export function fitNacosConfigListColumnWidths(widths: readonly number[], availableWidth: number): number[] {
const normalized = normalizeNacosConfigListColumnWidths([...widths]) ?? [...DEFAULT_NACOS_CONFIG_LIST_COLUMN_WIDTHS];
const targetWidth = Math.floor(availableWidth) - NACOS_CONFIG_LIST_HORIZONTAL_PADDING;
if (targetWidth <= 0) return normalized;
const minimums = normalized.map((_, index) => minWidthForColumn(index));
const minimumTotal = minimums.reduce((sum, width) => sum + width, 0);
if (targetWidth <= minimumTotal) return minimums;
const preferredExtras = normalized.map((width, index) => Math.max(0, width - minimums[index]!));
const preferredExtraTotal = preferredExtras.reduce((sum, width) => sum + width, 0);
const availableExtra = targetWidth - minimumTotal;
const fitted = minimums.map((minimum, index) => {
const weight = preferredExtraTotal > 0 ? preferredExtras[index]! / preferredExtraTotal : 1 / minimums.length;
return minimum + Math.floor(availableExtra * weight);
});
fitted[fitted.length - 1]! += targetWidth - fitted.reduce((sum, width) => sum + width, 0);
return fitted;
}
export function useNacosConfigListColumnResize(availableWidth?: Readonly<Ref<number>>) {
const preferredColumnWidths = ref(loadNacosConfigListColumnWidths());
const resizingColumnIndex = ref<number | null>(null);
const columnWidths = computed(() => {
const width = availableWidth?.value ?? 0;
return width > 0 ? fitNacosConfigListColumnWidths(preferredColumnWidths.value, width) : preferredColumnWidths.value;
});
const gridTemplateColumns = computed(() => gridTemplateColumnsForWidths(columnWidths.value));
const totalWidth = computed(() => columnWidths.value.reduce((sum, width) => sum + width, 0));
const minWidth = computed(() => `${totalWidth.value}px`);
const minWidth = computed(() => `${totalWidth.value + NACOS_CONFIG_LIST_HORIZONTAL_PADDING}px`);
function onResizeStart(columnIndex: number, event: MouseEvent) {
if (columnIndex < 0 || columnIndex >= columnWidths.value.length) return;
if (columnIndex < 0 || columnIndex >= columnWidths.value.length - 1) return;
event.preventDefault();
const startX = event.clientX;
const startWidth = columnWidths.value[columnIndex] ?? minWidthForColumn(columnIndex);
const startWidths = [...columnWidths.value];
const startWidth = startWidths[columnIndex] ?? minWidthForColumn(columnIndex);
const nextStartWidth = startWidths[columnIndex + 1] ?? minWidthForColumn(columnIndex + 1);
const pairWidth = startWidth + nextStartWidth;
resizingColumnIndex.value = columnIndex;
const onMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX;
columnWidths.value[columnIndex] = Math.max(minWidthForColumn(columnIndex), startWidth + delta);
const minimum = minWidthForColumn(columnIndex);
const nextMinimum = minWidthForColumn(columnIndex + 1);
const width = Math.min(pairWidth - nextMinimum, Math.max(minimum, startWidth + delta));
const nextWidths = [...startWidths];
nextWidths[columnIndex] = width;
nextWidths[columnIndex + 1] = pairWidth - width;
preferredColumnWidths.value = nextWidths;
};
const onUp = (moveEvent: MouseEvent) => {
onMove(moveEvent);
resizingColumnIndex.value = null;
saveNacosConfigListColumnWidths(columnWidths.value);
preferredColumnWidths.value = [...columnWidths.value];
saveNacosConfigListColumnWidths(preferredColumnWidths.value);
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};

View File

@ -5,6 +5,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import type { TreeNode } from "@/types/database";
import * as api from "@/lib/backend/api";
import { translateBackendError } from "@/i18n/backend-errors";
import { notifyNacosNamespacesChanged } from "@/lib/nacos/nacosNamespaceCache";
import { findSidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
import { isRenamableMongoCollection, mongoCollectionKindFromNode, mongoDropAllIndexesPreview, mongoDropCollectionPreview, mongoDropDatabasePreview, mongoDropIndexPreview, mongoRenameCollectionPreview } from "@/lib/sidebar/mongoCollectionMutation";
import { runMongoSidebarMutation } from "@/lib/sidebar/runMongoSidebarMutation";
@ -170,6 +171,7 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba
namespaceName,
namespaceDesc: createNacosNamespaceDesc.value.trim() || namespaceName,
});
notifyNacosNamespacesChanged(node.connectionId);
showCreateNacosNamespaceDialog.value = false;
await connectionStore.loadNacosNamespaces(node.connectionId, { force: true });
const liveNode = findSidebarActionTarget(connectionStore.treeNodes, node);

View File

@ -347,7 +347,7 @@ export default {
"r-nacos configuration history is available only through its independent console (normally port 10848). The console has a separate login session from the Nacos-compatible OpenAPI: the OpenAPI may not require authentication while the console still does. Use the same r-nacos user account unless you intentionally need a different one.",
nacosRNacosConsoleUrl: "r-nacos Console URL",
nacosRNacosConsoleUrlPlaceholder: "http://127.0.0.1:10848",
nacosRNacosConsoleUrlHint: "Optional. Required only for r-nacos configuration history; use the independent console URL (10848), not the OpenAPI URL.",
nacosRNacosConsoleUrlHint: "Optional. Use the independent console URL (normally port 10848), not the OpenAPI URL; it enables configuration history and reads r-nacos configuration types and descriptions.",
nacosRNacosOpenApiRequired: "The r-nacos primary address must use the Nacos-compatible API, not the independent console URL.",
nacosRNacosConsoleUrlRequired: "r-nacos configuration history requires a console URL.",
nacosConsoleAuthSeparateRequired: "Primary authentication is None; configure separate console credentials.",
@ -5470,6 +5470,12 @@ export default {
description: "Description",
advanced: "Advanced",
collapse: "Collapse",
configIdentity: "Config identity",
newConfigDraft: "New config",
draft: "Draft",
unsaved: "Unsaved",
published: "Published",
readOnlyState: "Read-only",
load: "Load",
save: "Save",
saveAs: "Save as",
@ -5486,6 +5492,97 @@ export default {
exported: "Exported",
exportedTo: "Exported to {path}",
exportFailed: "Export failed: {message}",
contentSearch: "Full-text search",
contentSearchTitle: "Full-text search across Nacos configs",
contentSearchDescription: "Run a case-sensitive literal search over current published config content. History versions are not searched.",
contentSearchPlaceholder: "Database URL, account, or any config fragment",
search: "Search",
cancel: "Cancel",
currentNamespace: "Current namespace",
allNamespaces: "All namespaces in this connection",
allNamespacesSearchWarning: "Searching all namespaces may enumerate many configs. It can be slow on Nacos 2.x and r-nacos and adds server load.",
scannedCount: "{count} scanned",
matchCount: "{count} matches",
incompleteResult: "Results may be incomplete",
lineNumber: "Line {line}",
noContentMatches: "No config contains this content",
contentSearchEmptyHint: "Enter a keyword to search. Keywords, content, and snippets are not persisted to disk or logs.",
searchResults: "Search results",
searchResultsRetainedHint: "Open a result in the content area. This result set stays available from Search results in the top toolbar.",
openedSearchResult: "Opened",
exportSearchResults: "Export results",
clearSearchResults: "Clear search results",
searchResultsExported: "Search results exported",
searchResultsExportedTo: "Search results exported to {path}",
batchExport: "Batch export",
batchImport: "Batch import",
copyToNamespace: "Config sync",
selectedCount: "{count} selected",
retryConnectionInfo: "Retry connection info probe",
resizeColumn: "Drag to resize column",
selectCurrentPage: "Select current page",
selectConfigForBatch: "Select config {dataId}",
batchExportTitle: "Batch export Nacos configs",
batchExportDescription: "Create a Nacos console compatible ZIP. It contains decrypted plaintext configuration and must be stored securely.",
batchImportTitle: "Batch import Nacos configs",
batchImportDescription: "Safely parse the archive and preview conflicts before publishing each item into the current namespace.",
batchCopyTitle: "Sync configs to another namespace",
batchCopyDescription: "Sync uses one-way copy semantics and never deletes or changes source configs. Target conflicts use the selected policy.",
exportScope: "Config scope",
selectedConfigs: "Selected configs ({count})",
filteredConfigs: "All matching the current filter (estimated {count})",
namespaceAllConfigs: "All configs in this namespace",
importArchive: "Nacos ZIP archive",
noArchiveSelected: "No ZIP selected",
chooseZip: "Choose ZIP",
importSensitiveWarning: "A compatible ZIP may contain plaintext database passwords, keys, and other secrets. Import trusted archives only.",
targetConnection: "Target connection",
chooseTargetConnection: "Choose a target Nacos connection",
noTargetConnections: "No writable Nacos connections are available. Add one or remove the target connection's read-only setting.",
targetNamespace: "Target namespace",
chooseTargetNamespace: "Choose another namespace",
copyKeepsSource: "Sync uses one-way copy semantics. Source namespace configs stay unchanged.",
conflictPolicy: "Conflict policy",
policyABORT: "Abort on conflict",
policyABORTHint: "Write nothing when preview finds a conflict",
policySKIP: "Skip conflicts",
policySKIPHint: "Create only configs absent from the target",
policyOVERWRITE: "Overwrite target",
policyOVERWRITEHint: "Replace same-key target configs with the source",
overwriteWarning: "Overwrite changes published target configs and requires one more confirmation.",
overwriteConfirm: "Overwrite all conflicting configs in the target namespace? This cannot be rolled back automatically.",
preview: "Preview",
apply: "Apply",
exportZip: "Export ZIP",
previewTotal: "{count} total",
previewCreated: "{count} new",
previewConflicts: "{count} conflicts",
previewInvalid: "{count} invalid",
previewExpired: "The preview is stale. Run preview again.",
batchFinished: "Sync complete: {written} written",
batchAborted: "Sync not run: a conflict prevented all writes",
batchPartial: "Batch operation partially completed; review failed items",
batchCancelled: "Cancelled",
batchCancelledSummary: "Sync cancelled: {processed}/{total} processed",
batchFailedSummary: "Sync partially completed: {written} written, {failed} failed",
batchPartialSummary: "Sync incomplete: {processed}/{total} processed",
batchFinishedWithSkipped: "Sync complete: {written} written, {skipped} skipped",
batchStatusCreate: "Will create",
batchStatusConflict: "Conflict",
batchStatusInvalid: "Invalid",
batchStatusCreated: "Created",
batchStatusOverwritten: "Overwritten",
batchStatusSkipped: "Skipped",
batchStatusFailed: "Failed",
batchStatusAborted: "Not run",
batchStatusExported: "Exported",
batchStatusUnknown: "{status}",
reportWritten: "{count} written",
reportCreated: "{count} created",
reportOverwritten: "{count} overwritten",
reportSkipped: "{count} skipped",
reportFailed: "{count} failed",
reportItems: "Configuration details ({count})",
history: "History",
configHistory: "Config history",
historyUnavailable: "Configuration history is unavailable for this connection. Configure the r-nacos console URL and reconnect to enable it.",
@ -5493,7 +5590,7 @@ export default {
historyConsoleUrlMissing: "Configuration history needs an r-nacos console address.",
historyConsoleCredentialsMissing: "Configuration history needs r-nacos console credentials.",
rnacosConsoleAuthTitle: "r-nacos console verification",
rnacosConsoleAuthDescription: "Enter the verification code from the r-nacos console to access configuration history.",
rnacosConsoleAuthDescription: "Enter the r-nacos console verification code to read configuration types, descriptions, and history.",
rnacosCaptchaLabel: "Verification code",
rnacosCaptchaPlaceholder: "Enter the code shown above",
rnacosCaptchaRequired: "A verification code is required.",

View File

@ -334,7 +334,7 @@ export default withEnglishFallback({
"El historial de configuración de r-nacos solo está disponible mediante su consola independiente (normalmente el puerto 10848). La consola tiene una sesión de inicio de sesión distinta de la OpenAPI compatible con Nacos: la OpenAPI puede no requerir autenticación mientras que la consola sí. Use la misma cuenta de usuario de r-nacos salvo que necesite usar otra cuenta.",
nacosRNacosConsoleUrl: "URL de consola r-nacos",
nacosRNacosConsoleUrlPlaceholder: "http://127.0.0.1:10848",
nacosRNacosConsoleUrlHint: "Opcional. Solo se necesita para el historial de configuración de r-nacos; use la consola independiente (10848), no la URL de OpenAPI.",
nacosRNacosConsoleUrlHint: "Opcional. Use la URL de la consola independiente (normalmente el puerto 10848), no la URL de OpenAPI; permite consultar el historial y leer el tipo y la descripción de las configuraciones de r-nacos.",
nacosRNacosOpenApiRequired: "La dirección principal de r-nacos debe usar la API compatible con Nacos, no la URL de la consola independiente.",
nacosRNacosConsoleUrlRequired: "El historial de configuración de r-nacos requiere una URL de consola.",
nacosConsoleAuthSeparateRequired: "La autenticación principal es Ninguna; configure credenciales de consola independientes.",
@ -4250,6 +4250,12 @@ export default withEnglishFallback({
description: "Descripción",
advanced: "Avanzado",
collapse: "Contraer",
configIdentity: "Identidad de configuración",
newConfigDraft: "Nueva configuración",
draft: "Borrador",
unsaved: "Sin guardar",
published: "Publicada",
readOnlyState: "Solo lectura",
load: "Cargar",
save: "Guardar",
saveAs: "Guardar como",
@ -4266,6 +4272,97 @@ export default withEnglishFallback({
exported: "Exportado",
exportedTo: "Exportado a {path}",
exportFailed: "Error al exportar: {message}",
contentSearch: "Búsqueda de texto completo",
contentSearchTitle: "Búsqueda de texto completo en configuraciones Nacos",
contentSearchDescription: "Realiza una búsqueda literal con distinción de mayúsculas en el contenido publicado actual. No se buscan versiones históricas.",
contentSearchPlaceholder: "URL de base de datos, cuenta o fragmento de configuración",
search: "Buscar",
cancel: "Cancelar",
currentNamespace: "Namespace actual",
allNamespaces: "Todos los namespaces de esta conexión",
allNamespacesSearchWarning: "Buscar en todos los namespaces puede recorrer muchas configuraciones. Puede ser lento en Nacos 2.x y r-nacos y aumentar la carga del servidor.",
scannedCount: "{count} analizadas",
matchCount: "{count} coincidencias",
incompleteResult: "Los resultados pueden estar incompletos",
lineNumber: "Línea {line}",
noContentMatches: "Ninguna configuración contiene este contenido",
contentSearchEmptyHint: "Introduzca una palabra clave. Las palabras clave, el contenido y los fragmentos no se guardan en disco ni en los registros.",
searchResults: "Resultados de búsqueda",
searchResultsRetainedHint: "Abra un resultado en el área de contenido. Este conjunto seguirá disponible desde Resultados de búsqueda en la barra superior.",
openedSearchResult: "Abierta",
exportSearchResults: "Exportar resultados",
clearSearchResults: "Borrar resultados",
searchResultsExported: "Resultados de búsqueda exportados",
searchResultsExportedTo: "Resultados de búsqueda exportados a {path}",
batchExport: "Exportación por lotes",
batchImport: "Importación por lotes",
copyToNamespace: "Sincronizar configuraciones",
selectedCount: "{count} seleccionadas",
retryConnectionInfo: "Reintentar detección de la conexión",
resizeColumn: "Arrastre para cambiar el ancho de la columna",
selectCurrentPage: "Seleccionar página actual",
selectConfigForBatch: "Seleccionar configuración {dataId}",
batchExportTitle: "Exportar configuraciones Nacos por lotes",
batchExportDescription: "Crea un ZIP compatible con la consola Nacos. Contiene configuraciones descifradas en texto plano y debe almacenarse de forma segura.",
batchImportTitle: "Importar configuraciones Nacos por lotes",
batchImportDescription: "Analiza el archivo de forma segura y muestra los conflictos antes de publicar cada elemento en el namespace actual.",
batchCopyTitle: "Sincronizar configuraciones con otro namespace",
batchCopyDescription: "La sincronización es una copia unidireccional y no elimina ni modifica el origen. Los conflictos del destino siguen la política seleccionada.",
exportScope: "Ámbito de configuraciones",
selectedConfigs: "Configuraciones seleccionadas ({count})",
filteredConfigs: "Todas las del filtro actual (estimadas: {count})",
namespaceAllConfigs: "Todas las configuraciones de este namespace",
importArchive: "Archivo ZIP de Nacos",
noArchiveSelected: "No se seleccionó ningún ZIP",
chooseZip: "Elegir ZIP",
importSensitiveWarning: "Un ZIP compatible puede contener contraseñas, claves y otros secretos en texto plano. Importe solo archivos de confianza.",
targetConnection: "Conexión de destino",
chooseTargetConnection: "Seleccione una conexión Nacos de destino",
noTargetConnections: "No hay conexiones Nacos con escritura disponibles. Añada una o desactive el modo de solo lectura del destino.",
targetNamespace: "Namespace de destino",
chooseTargetNamespace: "Seleccione otro namespace",
copyKeepsSource: "La sincronización usa una copia unidireccional. Las configuraciones del namespace de origen no cambian.",
conflictPolicy: "Política de conflictos",
policyABORT: "Abortar al encontrar conflictos",
policyABORTHint: "No escribir nada si la vista previa detecta un conflicto",
policySKIP: "Omitir conflictos",
policySKIPHint: "Crear solo configuraciones que no existan en el destino",
policyOVERWRITE: "Sobrescribir destino",
policyOVERWRITEHint: "Reemplazar configuraciones de destino con la misma clave",
overwriteWarning: "Sobrescribir modifica configuraciones publicadas del destino y requiere una confirmación adicional.",
overwriteConfirm: "¿Sobrescribir todas las configuraciones en conflicto del namespace de destino? No se puede revertir automáticamente.",
preview: "Vista previa",
apply: "Aplicar",
exportZip: "Exportar ZIP",
previewTotal: "{count} en total",
previewCreated: "{count} nuevas",
previewConflicts: "{count} conflictos",
previewInvalid: "{count} no válidas",
previewExpired: "La vista previa ha caducado. Vuelva a generarla.",
batchFinished: "Sincronización completada: {written} escritas",
batchAborted: "Sincronización no ejecutada: un conflicto impidió todas las escrituras",
batchPartial: "La operación por lotes se completó parcialmente; revise los elementos fallidos",
batchCancelled: "Cancelada",
batchCancelledSummary: "Sincronización cancelada: {processed}/{total} procesadas",
batchFailedSummary: "Sincronización parcial: {written} escritas, {failed} fallidas",
batchPartialSummary: "Sincronización incompleta: {processed}/{total} procesadas",
batchFinishedWithSkipped: "Sincronización completada: {written} escritas, {skipped} omitidas",
batchStatusCreate: "Se creará",
batchStatusConflict: "Conflicto",
batchStatusInvalid: "No válida",
batchStatusCreated: "Creada",
batchStatusOverwritten: "Sobrescrita",
batchStatusSkipped: "Omitida",
batchStatusFailed: "Fallida",
batchStatusAborted: "No ejecutada",
batchStatusExported: "Exportada",
batchStatusUnknown: "{status}",
reportWritten: "{count} escritas",
reportCreated: "{count} creadas",
reportOverwritten: "{count} sobrescritas",
reportSkipped: "{count} omitidas",
reportFailed: "{count} fallidas",
reportItems: "Detalles de configuraciones ({count})",
history: "History",
configHistory: "Config history",
historyUnavailable: "El historial de configuración no está disponible para esta conexión. Configure la URL de la consola r-nacos y vuelva a conectarse para habilitarlo.",
@ -4273,7 +4370,7 @@ export default withEnglishFallback({
historyConsoleUrlMissing: "El historial de configuración necesita una URL de consola r-nacos.",
historyConsoleCredentialsMissing: "El historial de configuración necesita credenciales de consola r-nacos.",
rnacosConsoleAuthTitle: "Verificación de consola r-nacos",
rnacosConsoleAuthDescription: "Introduzca el código de verificación de la consola r-nacos para acceder al historial.",
rnacosConsoleAuthDescription: "Introduzca el código de verificación de la consola r-nacos para leer tipos, descripciones e historial de configuraciones.",
rnacosCaptchaLabel: "Código de verificación",
rnacosCaptchaPlaceholder: "Introduzca el código mostrado arriba",
rnacosCaptchaRequired: "Se requiere un código de verificación.",

View File

@ -333,7 +333,7 @@ export default withEnglishFallback({
"La cronologia di configurazione di r-nacos è disponibile solo tramite la console separata (normalmente sulla porta 10848). La console usa una sessione di accesso distinta dall'OpenAPI compatibile con Nacos: l'OpenAPI potrebbe non richiedere l'autenticazione, mentre la console sì. Usa lo stesso account utente r-nacos, salvo che sia necessario usarne uno diverso.",
nacosRNacosConsoleUrl: "URL console r-nacos",
nacosRNacosConsoleUrlPlaceholder: "http://127.0.0.1:10848",
nacosRNacosConsoleUrlHint: "Facoltativo. Necessario solo per la cronologia delle configurazioni r-nacos; usare la console separata (10848), non l'URL OpenAPI.",
nacosRNacosConsoleUrlHint: "Facoltativo. Usa l'URL della console separata (normalmente porta 10848), non l'URL OpenAPI; abilita la cronologia e la lettura di tipo e descrizione delle configurazioni r-nacos.",
nacosRNacosOpenApiRequired: "L'indirizzo principale di r-nacos deve usare l'API compatibile con Nacos, non l'URL della console separata.",
nacosRNacosConsoleUrlRequired: "La cronologia delle configurazioni r-nacos richiede un URL della console.",
nacosConsoleAuthSeparateRequired: "L'autenticazione principale è Nessuna; configura credenziali della console separate.",
@ -4248,6 +4248,12 @@ export default withEnglishFallback({
description: "Descrizione",
advanced: "Avanzate",
collapse: "Comprimi",
configIdentity: "Identità configurazione",
newConfigDraft: "Nuova configurazione",
draft: "Bozza",
unsaved: "Non salvata",
published: "Pubblicata",
readOnlyState: "Sola lettura",
load: "Carica",
save: "Salva",
saveAs: "Salva come",
@ -4264,6 +4270,97 @@ export default withEnglishFallback({
exported: "Esportato",
exportedTo: "Esportato in {path}",
exportFailed: "Esportazione non riuscita: {message}",
contentSearch: "Ricerca full-text",
contentSearchTitle: "Ricerca full-text nelle configurazioni Nacos",
contentSearchDescription: "Esegue una ricerca letterale con distinzione tra maiuscole e minuscole nel contenuto pubblicato corrente. Le versioni storiche non vengono cercate.",
contentSearchPlaceholder: "URL database, account o frammento di configurazione",
search: "Cerca",
cancel: "Annulla",
currentNamespace: "Namespace corrente",
allNamespaces: "Tutti i namespace di questa connessione",
allNamespacesSearchWarning: "La ricerca in tutti i namespace può analizzare molte configurazioni. Può essere lenta su Nacos 2.x e r-nacos e aumentare il carico del server.",
scannedCount: "{count} analizzate",
matchCount: "{count} corrispondenze",
incompleteResult: "I risultati potrebbero essere incompleti",
lineNumber: "Riga {line}",
noContentMatches: "Nessuna configurazione contiene questo contenuto",
contentSearchEmptyHint: "Inserisci una parola chiave. Parole chiave, contenuti e frammenti non vengono salvati su disco o nei log.",
searchResults: "Risultati ricerca",
searchResultsRetainedHint: "Apri un risultato nell'area contenuti. Questo insieme rimarrà disponibile da Risultati ricerca nella barra superiore.",
openedSearchResult: "Aperta",
exportSearchResults: "Esporta risultati",
clearSearchResults: "Cancella risultati",
searchResultsExported: "Risultati della ricerca esportati",
searchResultsExportedTo: "Risultati della ricerca esportati in {path}",
batchExport: "Esportazione in blocco",
batchImport: "Importazione in blocco",
copyToNamespace: "Sincronizza configurazioni",
selectedCount: "{count} selezionate",
retryConnectionInfo: "Riprova rilevamento connessione",
resizeColumn: "Trascina per ridimensionare la colonna",
selectCurrentPage: "Seleziona pagina corrente",
selectConfigForBatch: "Seleziona configurazione {dataId}",
batchExportTitle: "Esporta configurazioni Nacos in blocco",
batchExportDescription: "Crea uno ZIP compatibile con la console Nacos. Contiene configurazioni decrittografate in chiaro e deve essere conservato in modo sicuro.",
batchImportTitle: "Importa configurazioni Nacos in blocco",
batchImportDescription: "Analizza in sicurezza l'archivio e mostra i conflitti prima di pubblicare ogni elemento nel namespace corrente.",
batchCopyTitle: "Sincronizza configurazioni in un altro namespace",
batchCopyDescription: "La sincronizzazione è una copia unidirezionale e non elimina né modifica l'origine. I conflitti di destinazione seguono la strategia selezionata.",
exportScope: "Ambito configurazioni",
selectedConfigs: "Configurazioni selezionate ({count})",
filteredConfigs: "Tutte quelle del filtro corrente (stimate {count})",
namespaceAllConfigs: "Tutte le configurazioni di questo namespace",
importArchive: "Archivio ZIP Nacos",
noArchiveSelected: "Nessun ZIP selezionato",
chooseZip: "Scegli ZIP",
importSensitiveWarning: "Uno ZIP compatibile può contenere password, chiavi e altri segreti in chiaro. Importa solo archivi attendibili.",
targetConnection: "Connessione di destinazione",
chooseTargetConnection: "Scegli una connessione Nacos di destinazione",
noTargetConnections: "Non sono disponibili connessioni Nacos scrivibili. Aggiungine una o disattiva la sola lettura sulla destinazione.",
targetNamespace: "Namespace di destinazione",
chooseTargetNamespace: "Scegli un altro namespace",
copyKeepsSource: "La sincronizzazione usa una copia unidirezionale. Le configurazioni del namespace di origine restano invariate.",
conflictPolicy: "Strategia conflitti",
policyABORT: "Interrompi in caso di conflitto",
policyABORTHint: "Non scrivere nulla se l'anteprima rileva un conflitto",
policySKIP: "Ignora conflitti",
policySKIPHint: "Crea solo configurazioni assenti nella destinazione",
policyOVERWRITE: "Sovrascrivi destinazione",
policyOVERWRITEHint: "Sostituisci le configurazioni di destinazione con la stessa chiave",
overwriteWarning: "La sovrascrittura modifica configurazioni pubblicate nella destinazione e richiede un'ulteriore conferma.",
overwriteConfirm: "Sovrascrivere tutte le configurazioni in conflitto nel namespace di destinazione? Non è possibile annullare automaticamente.",
preview: "Anteprima",
apply: "Applica",
exportZip: "Esporta ZIP",
previewTotal: "{count} totali",
previewCreated: "{count} nuove",
previewConflicts: "{count} conflitti",
previewInvalid: "{count} non valide",
previewExpired: "L'anteprima non è più valida. Eseguila di nuovo.",
batchFinished: "Sincronizzazione completata: {written} scritte",
batchAborted: "Sincronizzazione non eseguita: un conflitto ha impedito tutte le scritture",
batchPartial: "Operazione in blocco completata parzialmente; controlla gli elementi non riusciti",
batchCancelled: "Annullata",
batchCancelledSummary: "Sincronizzazione annullata: {processed}/{total} elaborate",
batchFailedSummary: "Sincronizzazione parziale: {written} scritte, {failed} non riuscite",
batchPartialSummary: "Sincronizzazione incompleta: {processed}/{total} elaborate",
batchFinishedWithSkipped: "Sincronizzazione completata: {written} scritte, {skipped} ignorate",
batchStatusCreate: "Da creare",
batchStatusConflict: "Conflitto",
batchStatusInvalid: "Non valida",
batchStatusCreated: "Creata",
batchStatusOverwritten: "Sovrascritta",
batchStatusSkipped: "Ignorata",
batchStatusFailed: "Non riuscita",
batchStatusAborted: "Non eseguita",
batchStatusExported: "Esportata",
batchStatusUnknown: "{status}",
reportWritten: "{count} scritte",
reportCreated: "{count} create",
reportOverwritten: "{count} sovrascritte",
reportSkipped: "{count} ignorate",
reportFailed: "{count} non riuscite",
reportItems: "Dettagli configurazioni ({count})",
history: "History",
configHistory: "Config history",
historyUnavailable: "La cronologia delle configurazioni non è disponibile per questa connessione. Configura l'URL della console r-nacos e riconnettiti per abilitarla.",
@ -4271,7 +4368,7 @@ export default withEnglishFallback({
historyConsoleUrlMissing: "La cronologia delle configurazioni richiede un URL della console r-nacos.",
historyConsoleCredentialsMissing: "La cronologia delle configurazioni richiede le credenziali della console r-nacos.",
rnacosConsoleAuthTitle: "Verifica console r-nacos",
rnacosConsoleAuthDescription: "Inserisci il codice di verifica della console r-nacos per accedere alla cronologia.",
rnacosConsoleAuthDescription: "Inserisci il codice di verifica della console r-nacos per leggere tipi, descrizioni e cronologia delle configurazioni.",
rnacosCaptchaLabel: "Codice di verifica",
rnacosCaptchaPlaceholder: "Inserisci il codice mostrato sopra",
rnacosCaptchaRequired: "È richiesto un codice di verifica.",

View File

@ -327,7 +327,7 @@ export default withEnglishFallback({
"r-nacos の設定履歴は、独立コンソール(通常はポート 10848からのみ利用できます。コンソールと Nacos 互換 OpenAPI は別のログインセッションを使用するため、OpenAPI の認証が無効でもコンソールへのログインが必要になる場合があります。通常は同じ r-nacos ユーザーアカウントを使用し、別のアカウントが必要な場合のみ個別に設定してください。",
nacosRNacosConsoleUrl: "r-nacos コンソール URL",
nacosRNacosConsoleUrlPlaceholder: "http://127.0.0.1:10848",
nacosRNacosConsoleUrlHint: "任意。r-nacos の設定履歴にのみ必要です。OpenAPI URL ではなく、独立コンソール10848を指定してください。",
nacosRNacosConsoleUrlHint: "任意。OpenAPI URL ではなく、独立コンソールの URL通常はポート 10848を指定してください。設定履歴と r-nacos 設定の形式・説明を取得できます。",
nacosRNacosOpenApiRequired: "r-nacos のメインアドレスには、独立コンソール URL ではなく Nacos 互換 API アドレスを使用してください。",
nacosRNacosConsoleUrlRequired: "r-nacos の設定履歴にはコンソール URL が必要です。",
nacosConsoleAuthSeparateRequired: "メイン接続の認証が未設定です。コンソール用の認証情報を別途設定してください。",
@ -4236,6 +4236,12 @@ export default withEnglishFallback({
description: "説明",
advanced: "詳細設定",
collapse: "閉じる",
configIdentity: "設定識別情報",
newConfigDraft: "新規設定",
draft: "下書き",
unsaved: "未保存",
published: "公開済み",
readOnlyState: "読み取り専用",
load: "読み込み",
save: "保存",
saveAs: "別名で保存",
@ -4252,6 +4258,97 @@ export default withEnglishFallback({
exported: "エクスポートしました",
exportedTo: "{path} にエクスポートしました",
exportFailed: "エクスポートに失敗しました: {message}",
contentSearch: "全文検索",
contentSearchTitle: "Nacos 設定の全文検索",
contentSearchDescription: "現在公開されている設定内容を、大文字と小文字を区別する文字列で検索します。履歴バージョンは検索しません。",
contentSearchPlaceholder: "データベース URL、アカウント、設定の一部など",
search: "検索",
cancel: "キャンセル",
currentNamespace: "現在の名前空間",
allNamespaces: "この接続のすべての名前空間",
allNamespacesSearchWarning: "すべての名前空間を検索すると多数の設定を走査します。Nacos 2.x と r-nacos では時間がかかり、サーバー負荷が増える場合があります。",
scannedCount: "{count} 件を走査",
matchCount: "{count} 件一致",
incompleteResult: "結果が不完全な可能性があります",
lineNumber: "{line} 行目",
noContentMatches: "この内容を含む設定はありません",
contentSearchEmptyHint: "キーワードを入力してください。キーワード、設定内容、抜粋はディスクやログに保存されません。",
searchResults: "検索結果",
searchResultsRetainedHint: "結果をコンテンツ領域で開きます。この結果一覧は上部ツールバーの「検索結果」から引き続き参照できます。",
openedSearchResult: "表示中",
exportSearchResults: "結果をエクスポート",
clearSearchResults: "検索結果をクリア",
searchResultsExported: "検索結果をエクスポートしました",
searchResultsExportedTo: "検索結果を {path} にエクスポートしました",
batchExport: "一括エクスポート",
batchImport: "一括インポート",
copyToNamespace: "設定同期",
selectedCount: "{count} 件選択",
retryConnectionInfo: "接続情報の検出を再試行",
resizeColumn: "ドラッグして列幅を変更",
selectCurrentPage: "現在のページを選択",
selectConfigForBatch: "設定 {dataId} を選択",
batchExportTitle: "Nacos 設定を一括エクスポート",
batchExportDescription: "Nacos コンソール互換の ZIP を作成します。復号された平文の設定が含まれるため、安全に保管してください。",
batchImportTitle: "Nacos 設定を一括インポート",
batchImportDescription: "アーカイブを安全に解析し、競合を確認してから各項目を現在の名前空間に公開します。",
batchCopyTitle: "別の名前空間へ設定を同期",
batchCopyDescription: "同期は一方向コピーで、元の設定を削除・変更しません。宛先の競合には選択したポリシーを適用します。",
exportScope: "設定範囲",
selectedConfigs: "選択した設定({count} 件)",
filteredConfigs: "現在のフィルターに一致するすべて(推定 {count} 件)",
namespaceAllConfigs: "この名前空間のすべての設定",
importArchive: "Nacos ZIP アーカイブ",
noArchiveSelected: "ZIP が選択されていません",
chooseZip: "ZIP を選択",
importSensitiveWarning: "互換 ZIP にはデータベースパスワードやキーなどの機密情報が平文で含まれる場合があります。信頼できるアーカイブのみインポートしてください。",
targetConnection: "宛先接続",
chooseTargetConnection: "宛先の Nacos 接続を選択",
noTargetConnections: "書き込み可能な Nacos 接続がありません。接続を追加するか、宛先接続の読み取り専用設定を解除してください。",
targetNamespace: "宛先名前空間",
chooseTargetNamespace: "別の名前空間を選択",
copyKeepsSource: "同期は一方向コピーです。元の名前空間の設定は変更されません。",
conflictPolicy: "競合ポリシー",
policyABORT: "競合時に中止",
policyABORTHint: "プレビューで競合が見つかった場合は何も書き込まない",
policySKIP: "競合をスキップ",
policySKIPHint: "宛先に存在しない設定のみ作成",
policyOVERWRITE: "宛先を上書き",
policyOVERWRITEHint: "同じキーの宛先設定を元の設定で置換",
overwriteWarning: "上書きすると公開済みの宛先設定が変更されるため、実行前に再確認します。",
overwriteConfirm: "宛先名前空間の競合する設定をすべて上書きしますか?自動的には元に戻せません。",
preview: "プレビュー",
apply: "実行",
exportZip: "ZIP をエクスポート",
previewTotal: "合計 {count} 件",
previewCreated: "新規 {count} 件",
previewConflicts: "競合 {count} 件",
previewInvalid: "無効 {count} 件",
previewExpired: "プレビューが古くなりました。もう一度プレビューしてください。",
batchFinished: "同期完了: {written} 件を書き込み",
batchAborted: "同期未実行: 競合があるため書き込みを中止しました",
batchPartial: "一括操作が一部のみ完了しました。失敗した項目を確認してください",
batchCancelled: "キャンセル済み",
batchCancelledSummary: "同期をキャンセルしました: {processed}/{total} 件処理",
batchFailedSummary: "同期が一部完了: {written} 件書き込み、{failed} 件失敗",
batchPartialSummary: "同期未完了: {processed}/{total} 件処理",
batchFinishedWithSkipped: "同期完了: {written} 件書き込み、{skipped} 件スキップ",
batchStatusCreate: "作成予定",
batchStatusConflict: "競合",
batchStatusInvalid: "無効",
batchStatusCreated: "作成済み",
batchStatusOverwritten: "上書き済み",
batchStatusSkipped: "スキップ済み",
batchStatusFailed: "失敗",
batchStatusAborted: "未実行",
batchStatusExported: "エクスポート済み",
batchStatusUnknown: "{status}",
reportWritten: "{count} 件書き込み",
reportCreated: "{count} 件作成",
reportOverwritten: "{count} 件上書き",
reportSkipped: "{count} 件スキップ",
reportFailed: "{count} 件失敗",
reportItems: "設定の詳細({count} 件)",
history: "History",
configHistory: "Config history",
historyUnavailable: "この接続では設定履歴を利用できません。r-nacos コンソール URL を設定して再接続してください。",
@ -4259,7 +4356,7 @@ export default withEnglishFallback({
historyConsoleUrlMissing: "設定履歴を使用するには r-nacos コンソール URL が必要です。",
historyConsoleCredentialsMissing: "設定履歴を使用するには r-nacos コンソールの認証情報が必要です。",
rnacosConsoleAuthTitle: "r-nacos コンソール認証",
rnacosConsoleAuthDescription: "設定履歴にアクセスするには、r-nacos コンソールの認証コードを入力してください。",
rnacosConsoleAuthDescription: "設定形式、説明、履歴を取得するには、r-nacos コンソールの認証コードを入力してください。",
rnacosCaptchaLabel: "認証コード",
rnacosCaptchaPlaceholder: "上の画像に表示されたコードを入力",
rnacosCaptchaRequired: "認証コードを入力してください。",

View File

@ -334,7 +334,7 @@ export default withEnglishFallback({
"O histórico de configuração do r-nacos está disponível apenas pelo console independente (normalmente na porta 10848). O console usa uma sessão de login separada da OpenAPI compatível com Nacos: a OpenAPI pode não exigir autenticação, enquanto o console ainda exige. Use a mesma conta de usuário do r-nacos, a menos que você precise usar outra conta.",
nacosRNacosConsoleUrl: "URL do console r-nacos",
nacosRNacosConsoleUrlPlaceholder: "http://127.0.0.1:10848",
nacosRNacosConsoleUrlHint: "Opcional. Necessária apenas para o histórico de configurações do r-nacos; use o console independente (10848), não a URL OpenAPI.",
nacosRNacosConsoleUrlHint: "Opcional. Use a URL do console independente (normalmente a porta 10848), não a URL OpenAPI; ela habilita o histórico e a leitura do tipo e da descrição das configurações do r-nacos.",
nacosRNacosOpenApiRequired: "O endereço principal do r-nacos deve usar a API compatível com Nacos, não a URL do console independente.",
nacosRNacosConsoleUrlRequired: "O histórico de configurações do r-nacos exige uma URL do console.",
nacosConsoleAuthSeparateRequired: "A autenticação principal é Nenhuma; configure credenciais de console separadas.",
@ -4250,6 +4250,12 @@ export default withEnglishFallback({
description: "Descrição",
advanced: "Avançado",
collapse: "Recolher",
configIdentity: "Identidade da configuração",
newConfigDraft: "Nova configuração",
draft: "Rascunho",
unsaved: "Não salva",
published: "Publicada",
readOnlyState: "Somente leitura",
load: "Carregar",
save: "Salvar",
saveAs: "Salvar como",
@ -4266,6 +4272,97 @@ export default withEnglishFallback({
exported: "Exportado",
exportedTo: "Exportado para {path}",
exportFailed: "Falha ao exportar: {message}",
contentSearch: "Pesquisa de texto completo",
contentSearchTitle: "Pesquisa de texto completo nas configurações do Nacos",
contentSearchDescription: "Executa uma pesquisa literal com diferenciação de maiúsculas e minúsculas no conteúdo publicado atual. As versões do histórico não são pesquisadas.",
contentSearchPlaceholder: "URL do banco, conta ou qualquer trecho de configuração",
search: "Pesquisar",
cancel: "Cancelar",
currentNamespace: "Namespace atual",
allNamespaces: "Todos os namespaces desta conexão",
allNamespacesSearchWarning: "Pesquisar em todos os namespaces pode percorrer muitas configurações. Pode ser lento no Nacos 2.x e no r-nacos e aumentar a carga do servidor.",
scannedCount: "{count} verificadas",
matchCount: "{count} correspondências",
incompleteResult: "Os resultados podem estar incompletos",
lineNumber: "Linha {line}",
noContentMatches: "Nenhuma configuração contém este conteúdo",
contentSearchEmptyHint: "Digite uma palavra-chave. Palavras-chave, conteúdo e trechos não são persistidos em disco nem nos logs.",
searchResults: "Resultados da pesquisa",
searchResultsRetainedHint: "Abra um resultado na área de conteúdo. Este conjunto continuará disponível em Resultados da pesquisa na barra superior.",
openedSearchResult: "Aberta",
exportSearchResults: "Exportar resultados",
clearSearchResults: "Limpar resultados",
searchResultsExported: "Resultados da pesquisa exportados",
searchResultsExportedTo: "Resultados da pesquisa exportados para {path}",
batchExport: "Exportação em lote",
batchImport: "Importação em lote",
copyToNamespace: "Sincronizar configurações",
selectedCount: "{count} selecionadas",
retryConnectionInfo: "Tentar detectar a conexão novamente",
resizeColumn: "Arraste para redimensionar a coluna",
selectCurrentPage: "Selecionar página atual",
selectConfigForBatch: "Selecionar configuração {dataId}",
batchExportTitle: "Exportar configurações do Nacos em lote",
batchExportDescription: "Cria um ZIP compatível com o console do Nacos. Ele contém configurações descriptografadas em texto simples e deve ser armazenado com segurança.",
batchImportTitle: "Importar configurações do Nacos em lote",
batchImportDescription: "Analisa o arquivo com segurança e mostra os conflitos antes de publicar cada item no namespace atual.",
batchCopyTitle: "Sincronizar configurações com outro namespace",
batchCopyDescription: "A sincronização é uma cópia unidirecional e não exclui nem altera a origem. Os conflitos no destino seguem a política selecionada.",
exportScope: "Escopo das configurações",
selectedConfigs: "Configurações selecionadas ({count})",
filteredConfigs: "Todas do filtro atual (estimativa: {count})",
namespaceAllConfigs: "Todas as configurações deste namespace",
importArchive: "Arquivo ZIP do Nacos",
noArchiveSelected: "Nenhum ZIP selecionado",
chooseZip: "Escolher ZIP",
importSensitiveWarning: "Um ZIP compatível pode conter senhas de banco, chaves e outros segredos em texto simples. Importe apenas arquivos confiáveis.",
targetConnection: "Conexão de destino",
chooseTargetConnection: "Escolha uma conexão Nacos de destino",
noTargetConnections: "Não há conexões Nacos graváveis disponíveis. Adicione uma ou desative o modo somente leitura da conexão de destino.",
targetNamespace: "Namespace de destino",
chooseTargetNamespace: "Escolha outro namespace",
copyKeepsSource: "A sincronização usa cópia unidirecional. As configurações do namespace de origem permanecem inalteradas.",
conflictPolicy: "Política de conflitos",
policyABORT: "Abortar ao encontrar conflito",
policyABORTHint: "Não gravar nada se a prévia encontrar um conflito",
policySKIP: "Ignorar conflitos",
policySKIPHint: "Criar somente configurações ausentes no destino",
policyOVERWRITE: "Sobrescrever destino",
policyOVERWRITEHint: "Substituir configurações de mesma chave no destino pela origem",
overwriteWarning: "A sobrescrita altera configurações publicadas no destino e exige uma confirmação adicional.",
overwriteConfirm: "Sobrescrever todas as configurações em conflito no namespace de destino? Isso não pode ser desfeito automaticamente.",
preview: "Prévia",
apply: "Aplicar",
exportZip: "Exportar ZIP",
previewTotal: "{count} no total",
previewCreated: "{count} novas",
previewConflicts: "{count} conflitos",
previewInvalid: "{count} inválidas",
previewExpired: "A prévia está desatualizada. Gere-a novamente.",
batchFinished: "Sincronização concluída: {written} gravadas",
batchAborted: "Sincronização não executada: um conflito impediu todas as gravações",
batchPartial: "A operação em lote foi concluída parcialmente; revise os itens com falha",
batchCancelled: "Cancelada",
batchCancelledSummary: "Sincronização cancelada: {processed}/{total} processadas",
batchFailedSummary: "Sincronização parcial: {written} gravadas, {failed} com falha",
batchPartialSummary: "Sincronização incompleta: {processed}/{total} processadas",
batchFinishedWithSkipped: "Sincronização concluída: {written} gravadas, {skipped} ignoradas",
batchStatusCreate: "Será criada",
batchStatusConflict: "Conflito",
batchStatusInvalid: "Inválida",
batchStatusCreated: "Criada",
batchStatusOverwritten: "Sobrescrita",
batchStatusSkipped: "Ignorada",
batchStatusFailed: "Falha",
batchStatusAborted: "Não executada",
batchStatusExported: "Exportada",
batchStatusUnknown: "{status}",
reportWritten: "{count} gravadas",
reportCreated: "{count} criadas",
reportOverwritten: "{count} sobrescritas",
reportSkipped: "{count} ignoradas",
reportFailed: "{count} com falha",
reportItems: "Detalhes das configurações ({count})",
history: "History",
configHistory: "Config history",
historyUnavailable: "O histórico de configurações não está disponível para esta conexão. Configure a URL do console r-nacos e reconecte para habilitá-lo.",
@ -4273,7 +4370,7 @@ export default withEnglishFallback({
historyConsoleUrlMissing: "O histórico de configurações precisa de uma URL do console r-nacos.",
historyConsoleCredentialsMissing: "O histórico de configurações precisa das credenciais do console r-nacos.",
rnacosConsoleAuthTitle: "Verificação do console r-nacos",
rnacosConsoleAuthDescription: "Informe o código de verificação do console r-nacos para acessar o histórico.",
rnacosConsoleAuthDescription: "Informe o código de verificação do console r-nacos para ler tipos, descrições e histórico das configurações.",
rnacosCaptchaLabel: "Código de verificação",
rnacosCaptchaPlaceholder: "Informe o código exibido acima",
rnacosCaptchaRequired: "É necessário informar um código de verificação.",

View File

@ -348,7 +348,7 @@ export default withEnglishFallback({
nacosConfigurationHistoryHint: "r-nacos 的配置历史仅通过独立控制台提供(通常为 10848 端口)。控制台与兼容 Nacos 的 OpenAPI 使用不同的登录会话OpenAPI 可以不启用认证,而控制台仍需登录。通常填写同一套 r-nacos 用户凭据;仅在需要使用另一账号时单独设置。",
nacosRNacosConsoleUrl: "r-nacos 控制台 URL",
nacosRNacosConsoleUrlPlaceholder: "http://127.0.0.1:10848",
nacosRNacosConsoleUrlHint: "可选。仅 r-nacos 的配置历史需要填写:使用独立控制台服务地址10848不是 OpenAPI 地址。",
nacosRNacosConsoleUrlHint: "可选。使用独立控制台服务地址(通常为 10848不是 OpenAPI 地址;它用于配置历史,以及读取 r-nacos 配置的类型和描述。",
nacosRNacosOpenApiRequired: "r-nacos 主地址必须使用兼容 Nacos 的 API 地址,不能使用独立控制台 URL。",
nacosRNacosConsoleUrlRequired: "启用 r-nacos 配置历史时必须填写控制台 URL。",
nacosConsoleAuthSeparateRequired: "主连接未启用认证;请单独设置控制台凭据。",
@ -5456,6 +5456,12 @@ export default withEnglishFallback({
description: "描述",
advanced: "高级配置",
collapse: "收起",
configIdentity: "配置标识",
newConfigDraft: "新配置",
draft: "草稿",
unsaved: "未保存",
published: "已发布",
readOnlyState: "只读",
load: "加载",
save: "保存",
saveAs: "另存为",
@ -5472,6 +5478,97 @@ export default withEnglishFallback({
exported: "导出完成",
exportedTo: "已导出到 {path}",
exportFailed: "导出失败:{message}",
contentSearch: "全文搜索",
contentSearchTitle: "Nacos 配置全文搜索",
contentSearchDescription: "在当前已发布的配置正文中执行大小写敏感的字面包含搜索。不会搜索历史版本。",
contentSearchPlaceholder: "输入数据库地址、账号、配置片段等",
search: "搜索",
cancel: "取消",
currentNamespace: "当前命名空间",
allNamespaces: "当前连接全部命名空间",
allNamespacesSearchWarning: "搜索全部命名空间需要枚举大量配置,在 Nacos 2.x 和 r-nacos 上可能较慢,并会增加服务器负载。",
scannedCount: "已扫描 {count}",
matchCount: "命中 {count}",
incompleteResult: "结果可能不完整",
lineNumber: "第 {line} 行",
noContentMatches: "没有找到包含该内容的配置",
contentSearchEmptyHint: "输入关键词开始搜索。关键词、正文和片段不会保存到磁盘或日志。",
searchResults: "搜索结果",
searchResultsRetainedHint: "点击结果将在内容区打开配置;本次结果会保留,可通过顶部“搜索结果”继续浏览。",
openedSearchResult: "已打开",
exportSearchResults: "导出结果",
clearSearchResults: "清除搜索结果",
searchResultsExported: "搜索结果已导出",
searchResultsExportedTo: "搜索结果已导出到 {path}",
batchExport: "批量导出",
batchImport: "批量导入",
copyToNamespace: "配置同步",
selectedCount: "已选 {count} 项",
retryConnectionInfo: "重试连接信息探测",
resizeColumn: "拖拽调整列宽",
selectCurrentPage: "选择当前页",
selectConfigForBatch: "选择配置 {dataId}",
batchExportTitle: "批量导出 Nacos 配置",
batchExportDescription: "生成与 Nacos 控制台兼容的 ZIP 归档。归档内包含解密后的明文配置,请安全保存。",
batchImportTitle: "批量导入 Nacos 配置",
batchImportDescription: "先安全解析并预览冲突,确认后再逐项发布到当前命名空间。",
batchCopyTitle: "同步配置到其他命名空间",
batchCopyDescription: "同步采用单向复制语义,不会删除或修改源配置;目标配置按所选冲突策略处理。",
exportScope: "配置范围",
selectedConfigs: "已选配置({count}",
filteredConfigs: "当前筛选全部(预计 {count}",
namespaceAllConfigs: "当前命名空间全部",
importArchive: "Nacos ZIP 归档",
noArchiveSelected: "尚未选择 ZIP 文件",
chooseZip: "选择 ZIP",
importSensitiveWarning: "官方兼容 ZIP 可能包含数据库密码、密钥等明文敏感内容。仅导入可信归档。",
targetConnection: "目标连接",
chooseTargetConnection: "请选择目标 Nacos 连接",
noTargetConnections: "没有可写入的 Nacos 连接。请先新增连接或取消目标连接的只读设置。",
targetNamespace: "目标命名空间",
chooseTargetNamespace: "请选择其他命名空间",
copyKeepsSource: "同步采用单向复制语义,源命名空间中的配置保持不变。",
conflictPolicy: "冲突策略",
policyABORT: "遇冲突终止",
policyABORTHint: "预检发现冲突时零写入",
policySKIP: "跳过冲突",
policySKIPHint: "仅创建目标中不存在的配置",
policyOVERWRITE: "覆盖目标",
policyOVERWRITEHint: "用源配置覆盖同名目标配置",
overwriteWarning: "覆盖会修改目标命名空间中的已发布配置,执行前需要再次确认。",
overwriteConfirm: "确认覆盖目标命名空间中所有冲突配置吗?此操作不可自动回滚。",
preview: "预览",
apply: "执行",
exportZip: "导出 ZIP",
previewTotal: "共 {count}",
previewCreated: "新增 {count}",
previewConflicts: "冲突 {count}",
previewInvalid: "无效 {count}",
previewExpired: "预览已失效,请重新预览。",
batchFinished: "同步完成:成功写入 {written} 项",
batchAborted: "同步未执行:检测到冲突,未写入任何配置",
batchPartial: "批量操作部分完成,请检查失败项",
batchCancelled: "已取消",
batchCancelledSummary: "同步已取消:已处理 {processed}/{total} 项",
batchFailedSummary: "同步部分完成:成功写入 {written} 项,失败 {failed} 项",
batchPartialSummary: "同步未完成:已处理 {processed}/{total} 项",
batchFinishedWithSkipped: "同步完成:成功写入 {written} 项,跳过 {skipped} 项",
batchStatusCreate: "将新增",
batchStatusConflict: "冲突",
batchStatusInvalid: "无效",
batchStatusCreated: "已新增",
batchStatusOverwritten: "已覆盖",
batchStatusSkipped: "已跳过",
batchStatusFailed: "失败",
batchStatusAborted: "未执行",
batchStatusExported: "已导出",
batchStatusUnknown: "{status}",
reportWritten: "成功写入 {count}",
reportCreated: "新增 {count}",
reportOverwritten: "覆盖 {count}",
reportSkipped: "跳过 {count}",
reportFailed: "失败 {count}",
reportItems: "配置明细({count} 项)",
history: "历史",
configHistory: "配置历史",
historyUnavailable: "当前连接无法使用配置历史。请配置 r-nacos 控制台 URL 并重新连接后再试。",
@ -5479,7 +5576,7 @@ export default withEnglishFallback({
historyConsoleUrlMissing: "配置历史需要填写 r-nacos 控制台地址。",
historyConsoleCredentialsMissing: "配置历史需要填写 r-nacos 控制台凭据。",
rnacosConsoleAuthTitle: "r-nacos 控制台验证",
rnacosConsoleAuthDescription: "请输入 r-nacos 控制台验证码,以访问配置历史。",
rnacosConsoleAuthDescription: "请输入 r-nacos 控制台验证码,以读取配置类型、描述和配置历史。",
rnacosCaptchaLabel: "验证码",
rnacosCaptchaPlaceholder: "输入上图中的验证码",
rnacosCaptchaRequired: "请输入验证码。",

View File

@ -333,7 +333,7 @@ export default withEnglishFallback({
nacosConfigurationHistoryHint: "r-nacos 的設定歷史僅能透過獨立主控台使用(通常為 10848 連接埠)。主控台與相容 Nacos 的 OpenAPI 使用不同的登入工作階段OpenAPI 可以不啟用驗證,但主控台仍需要登入。通常填寫同一套 r-nacos 使用者憑證;僅在需要使用另一個帳號時個別設定。",
nacosRNacosConsoleUrl: "r-nacos 控制台 URL",
nacosRNacosConsoleUrlPlaceholder: "http://127.0.0.1:10848",
nacosRNacosConsoleUrlHint: "選填。僅 r-nacos 的設定歷史需要填寫使用獨立控制台服務位址10848不是 OpenAPI 位址。",
nacosRNacosConsoleUrlHint: "選填。請使用獨立控制台服務位址(通常為 10848 連接埠),不是 OpenAPI 位址;它用於設定歷史,以及讀取 r-nacos 設定的格式和描述。",
nacosRNacosOpenApiRequired: "r-nacos 主要位址必須使用相容 Nacos 的 API 位址,不能使用獨立控制台 URL。",
nacosRNacosConsoleUrlRequired: "啟用 r-nacos 設定歷史時必須填寫控制台 URL。",
nacosConsoleAuthSeparateRequired: "主要連線未啟用驗證;請個別設定控制台憑證。",
@ -4051,6 +4051,12 @@ export default withEnglishFallback({
description: "描述",
advanced: "進階配置",
collapse: "收起",
configIdentity: "設定識別",
newConfigDraft: "新設定",
draft: "草稿",
unsaved: "未儲存",
published: "已發布",
readOnlyState: "唯讀",
load: "載入",
save: "儲存",
saveAs: "另存為",
@ -4067,6 +4073,97 @@ export default withEnglishFallback({
exported: "匯出完成",
exportedTo: "已匯出到 {path}",
exportFailed: "匯出失敗:{message}",
contentSearch: "全文搜尋",
contentSearchTitle: "Nacos 設定全文搜尋",
contentSearchDescription: "在目前已發布的設定內容中執行區分大小寫的字面包含搜尋。不會搜尋歷史版本。",
contentSearchPlaceholder: "輸入資料庫位址、帳號、設定片段等",
search: "搜尋",
cancel: "取消",
currentNamespace: "目前命名空間",
allNamespaces: "目前連線的所有命名空間",
allNamespacesSearchWarning: "搜尋所有命名空間需要列舉大量設定,在 Nacos 2.x 和 r-nacos 上可能較慢,並會增加伺服器負載。",
scannedCount: "已掃描 {count}",
matchCount: "符合 {count}",
incompleteResult: "結果可能不完整",
lineNumber: "第 {line} 行",
noContentMatches: "找不到包含此內容的設定",
contentSearchEmptyHint: "輸入關鍵字開始搜尋。關鍵字、內容和片段不會儲存到磁碟或記錄檔。",
searchResults: "搜尋結果",
searchResultsRetainedHint: "點選結果將在內容區開啟設定;本次結果會保留,可透過頂端的「搜尋結果」繼續瀏覽。",
openedSearchResult: "已開啟",
exportSearchResults: "匯出結果",
clearSearchResults: "清除搜尋結果",
searchResultsExported: "搜尋結果已匯出",
searchResultsExportedTo: "搜尋結果已匯出到 {path}",
batchExport: "批次匯出",
batchImport: "批次匯入",
copyToNamespace: "設定同步",
selectedCount: "已選 {count} 項",
retryConnectionInfo: "重試連線資訊偵測",
resizeColumn: "拖曳調整欄寬",
selectCurrentPage: "選取目前頁面",
selectConfigForBatch: "選取設定 {dataId}",
batchExportTitle: "批次匯出 Nacos 設定",
batchExportDescription: "建立與 Nacos 控制台相容的 ZIP 壓縮檔。檔案內包含解密後的純文字設定,請妥善保管。",
batchImportTitle: "批次匯入 Nacos 設定",
batchImportDescription: "先安全解析壓縮檔並預覽衝突,確認後再逐項發布到目前命名空間。",
batchCopyTitle: "同步設定到其他命名空間",
batchCopyDescription: "同步採用單向複製,不會刪除或修改來源設定;目標設定依所選衝突策略處理。",
exportScope: "設定範圍",
selectedConfigs: "已選設定({count}",
filteredConfigs: "目前篩選的全部設定(預估 {count}",
namespaceAllConfigs: "目前命名空間的全部設定",
importArchive: "Nacos ZIP 壓縮檔",
noArchiveSelected: "尚未選取 ZIP 檔案",
chooseZip: "選擇 ZIP",
importSensitiveWarning: "相容 ZIP 可能包含資料庫密碼、金鑰等純文字敏感內容。僅匯入可信任的壓縮檔。",
targetConnection: "目標連線",
chooseTargetConnection: "請選擇目標 Nacos 連線",
noTargetConnections: "沒有可寫入的 Nacos 連線。請新增連線或取消目標連線的唯讀設定。",
targetNamespace: "目標命名空間",
chooseTargetNamespace: "請選擇其他命名空間",
copyKeepsSource: "同步採用單向複製,來源命名空間中的設定保持不變。",
conflictPolicy: "衝突策略",
policyABORT: "遇到衝突時終止",
policyABORTHint: "預覽發現衝突時不寫入任何設定",
policySKIP: "略過衝突",
policySKIPHint: "僅建立目標中不存在的設定",
policyOVERWRITE: "覆寫目標",
policyOVERWRITEHint: "用來源設定覆寫同鍵值的目標設定",
overwriteWarning: "覆寫會修改目標命名空間中已發布的設定,執行前需要再次確認。",
overwriteConfirm: "確定要覆寫目標命名空間中的所有衝突設定嗎?此操作無法自動復原。",
preview: "預覽",
apply: "執行",
exportZip: "匯出 ZIP",
previewTotal: "共 {count} 項",
previewCreated: "新增 {count} 項",
previewConflicts: "衝突 {count} 項",
previewInvalid: "無效 {count} 項",
previewExpired: "預覽已失效,請重新預覽。",
batchFinished: "同步完成:成功寫入 {written} 項",
batchAborted: "同步未執行:偵測到衝突,未寫入任何設定",
batchPartial: "批次操作部分完成,請檢查失敗項目",
batchCancelled: "已取消",
batchCancelledSummary: "同步已取消:已處理 {processed}/{total} 項",
batchFailedSummary: "同步部分完成:成功寫入 {written} 項,失敗 {failed} 項",
batchPartialSummary: "同步未完成:已處理 {processed}/{total} 項",
batchFinishedWithSkipped: "同步完成:成功寫入 {written} 項,略過 {skipped} 項",
batchStatusCreate: "將新增",
batchStatusConflict: "衝突",
batchStatusInvalid: "無效",
batchStatusCreated: "已新增",
batchStatusOverwritten: "已覆寫",
batchStatusSkipped: "已略過",
batchStatusFailed: "失敗",
batchStatusAborted: "未執行",
batchStatusExported: "已匯出",
batchStatusUnknown: "{status}",
reportWritten: "成功寫入 {count} 項",
reportCreated: "新增 {count} 項",
reportOverwritten: "覆寫 {count} 項",
reportSkipped: "略過 {count} 項",
reportFailed: "失敗 {count} 項",
reportItems: "設定明細({count} 項)",
history: "歷史",
configHistory: "配置歷史",
historyUnavailable: "目前連線無法使用設定歷史。請設定 r-nacos 控制台 URL 並重新連線後再試。",
@ -4074,7 +4171,7 @@ export default withEnglishFallback({
historyConsoleUrlMissing: "設定歷史需要填寫 r-nacos 控制台位址。",
historyConsoleCredentialsMissing: "設定歷史需要填寫 r-nacos 控制台憑證。",
rnacosConsoleAuthTitle: "r-nacos 控制台驗證",
rnacosConsoleAuthDescription: "請輸入 r-nacos 控制台驗證碼以存取設定歷史。",
rnacosConsoleAuthDescription: "請輸入 r-nacos 控制台驗證碼,以讀取設定格式、描述和設定歷史。",
rnacosCaptchaLabel: "驗證碼",
rnacosCaptchaPlaceholder: "輸入上圖中的驗證碼",
rnacosCaptchaRequired: "請輸入驗證碼。",

View File

@ -1,19 +1,31 @@
import { describe, expect, it } from "vitest";
import {
buildNacosContentSearchCsv,
buildNacosConfigExportFileName,
buildNacosConfigDeleteConfirm,
buildNacosInlineDiff,
buildNacosInstanceConfirm,
buildNacosRawRequest,
buildNacosSideBySideDiff,
canDeleteNacosConfig,
canStartNacosConfigDelete,
canStartNacosConfigSave,
createNacosConfigDeleteSnapshot,
createNacosConfigSaveSnapshot,
createNacosLatestRequestGuard,
isNacosRawMutation,
isNacosErrorCode,
isNacosConfigSaveSnapshotCurrent,
isNacosConfigDeleteSnapshotInScope,
nacosConfigFileExtension,
parseNacosRawBody,
parseNacosRawQuery,
normalizeNacosEndpoint,
resolveRNacosOpenApiFallback,
resolveNacosConfigCopyText,
resolveNacosConfigSaveCompletion,
sanitizeNacosConfigFileNameSegment,
splitNacosContentLiteralMatches,
summarizeNacosConfigDiff,
} from "@/lib/nacos/nacosAdmin";
@ -49,6 +61,12 @@ describe("nacosAdmin helpers", () => {
expect(isNacosRawMutation("DELETE")).toBe(true);
});
it("recognizes structured Nacos errors without matching unrelated failures", () => {
expect(isNacosErrorCode(new Error("NACOS_ERROR[stalePreview]: preview again"), "stalePreview")).toBe(true);
expect(isNacosErrorCode("NACOS_ERROR[authFailed]: forbidden", "stalePreview")).toBe(false);
expect(isNacosErrorCode(new Error("stalePreview"), "stalePreview")).toBe(false);
});
it("redirects r-nacos console settings to the compatible OpenAPI endpoint", () => {
expect(resolveRNacosOpenApiFallback("http://rnacos.example:10848", "/rnacos")).toEqual({
serverAddr: "http://rnacos.example:8848",
@ -113,4 +131,236 @@ describe("nacosAdmin helpers", () => {
expect(resolveNacosConfigCopyText("", "editor", "state")).toBe("editor");
expect(resolveNacosConfigCopyText("", "", "state")).toBe("state");
});
it("rejects stale config detail requests after a newer selection or invalidation", () => {
const guard = createNacosLatestRequestGuard();
const first = guard.begin();
const second = guard.begin();
expect(guard.isCurrent(first)).toBe(false);
expect(guard.isCurrent(second)).toBe(true);
guard.invalidate();
expect(guard.isCurrent(second)).toBe(false);
});
it("keeps a late A detail response from replacing a newer B selection", async () => {
const guard = createNacosLatestRequestGuard();
let resolveA!: (value: string) => void;
let resolveB!: (value: string) => void;
const responseA = new Promise<string>((resolve) => {
resolveA = resolve;
});
const responseB = new Promise<string>((resolve) => {
resolveB = resolve;
});
let selected = "";
const load = async (response: Promise<string>) => {
const requestId = guard.begin();
const detail = await response;
if (guard.isCurrent(requestId)) selected = detail;
};
const loadingA = load(responseA);
const loadingB = load(responseB);
resolveB("B");
await loadingB;
resolveA("A");
await loadingA;
expect(selected).toBe("B");
});
it("keeps save payloads immutable and applies them only to the unchanged editor session", () => {
const editedConfig = {
namespace: "dev",
dataId: "application.yaml",
group: "DEFAULT_GROUP",
configType: "yaml",
appName: "gateway",
desc: "published settings",
tags: "prod",
};
const snapshot = createNacosConfigSaveSnapshot({
requestId: 4,
editorSessionId: 9,
connectionId: "nacos-a",
originalKey: { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" },
config: editedConfig,
content: "server:\n port: 8080",
configType: "yaml",
});
editedConfig.desc = "changed after publish started";
expect(snapshot.config.desc).toBe("published settings");
expect(snapshot.content).toBe("server:\n port: 8080");
expect(
isNacosConfigSaveSnapshotCurrent(snapshot, {
latestRequestId: 4,
editorSessionId: 9,
connectionId: "nacos-a",
originalKey: { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" },
config: { ...snapshot.config },
content: snapshot.content,
configType: snapshot.configType,
}),
).toBe(true);
});
it("does not apply a completed save to another selection or to later edits", () => {
const snapshot = createNacosConfigSaveSnapshot({
requestId: 1,
editorSessionId: 3,
connectionId: "nacos-a",
originalKey: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP" },
config: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP", desc: "A" },
content: "value: A",
configType: "yaml",
});
const matchingState = {
latestRequestId: 1,
editorSessionId: 3,
connectionId: "nacos-a",
originalKey: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP" },
config: { ...snapshot.config },
content: snapshot.content,
configType: snapshot.configType,
};
expect(isNacosConfigSaveSnapshotCurrent(snapshot, { ...matchingState, editorSessionId: 4, config: { ...snapshot.config, dataId: "b.yaml" } })).toBe(false);
expect(isNacosConfigSaveSnapshotCurrent(snapshot, { ...matchingState, content: "value: edited while saving" })).toBe(false);
expect(isNacosConfigSaveSnapshotCurrent(snapshot, { ...matchingState, latestRequestId: 2 })).toBe(false);
});
it("advances the published baseline without overwriting edits made while saving", () => {
const originalContent = "value: O";
const publishedContent = "value: S";
const laterContent = "value: T";
const snapshot = createNacosConfigSaveSnapshot({
requestId: 7,
editorSessionId: 12,
connectionId: "nacos-a",
originalKey: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP" },
config: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP", desc: "snapshot metadata" },
content: publishedContent,
configType: "yaml",
});
const completion = resolveNacosConfigSaveCompletion(snapshot, {
latestRequestId: 7,
editorSessionId: 12,
connectionId: "nacos-a",
originalKey: { namespace: "dev", dataId: "a.yaml", group: "DEFAULT_GROUP" },
config: { ...snapshot.config, desc: "edited metadata" },
content: laterContent,
configType: "yaml",
});
expect(completion.kind).toBe("saved-with-later-edits");
if (completion.kind === "stale") throw new Error("expected a relevant save completion");
expect(completion.baseline.content).toBe(publishedContent);
expect(laterContent).not.toBe(completion.baseline.content);
expect(originalContent).not.toBe(completion.baseline.content);
});
it("keeps a renamed draft separate from the identity published by an in-flight save", () => {
const snapshot = createNacosConfigSaveSnapshot({
requestId: 8,
editorSessionId: 13,
connectionId: "nacos-a",
originalKey: null,
config: { namespace: "dev", dataId: "draft-a.yaml", group: "DEFAULT_GROUP" },
content: "value: S",
configType: "yaml",
});
const completion = resolveNacosConfigSaveCompletion(snapshot, {
latestRequestId: 8,
editorSessionId: 13,
connectionId: "nacos-a",
originalKey: null,
config: { ...snapshot.config, dataId: "draft-b.yaml" },
content: "value: T",
configType: "yaml",
});
expect(completion).toEqual({ kind: "stale" });
});
it("never allows a draft or read-only config to enter the delete flow", () => {
const publishedKey = { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" };
expect(canDeleteNacosConfig(false, publishedKey)).toBe(true);
expect(canDeleteNacosConfig(false, null)).toBe(false);
expect(canDeleteNacosConfig(true, publishedKey)).toBe(false);
});
it("keeps save and delete mutations mutually exclusive in both directions", () => {
const idle = {
readOnly: false,
saving: false,
deleting: false,
hasPendingDelete: false,
hasPendingSave: false,
};
const publishedKey = { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" };
expect(canStartNacosConfigSave(idle)).toBe(true);
expect(canStartNacosConfigSave({ ...idle, saving: true })).toBe(false);
expect(canStartNacosConfigSave({ ...idle, deleting: true })).toBe(false);
expect(canStartNacosConfigSave({ ...idle, hasPendingDelete: true })).toBe(false);
expect(canStartNacosConfigDelete(idle, publishedKey)).toBe(true);
expect(canStartNacosConfigDelete({ ...idle, saving: true }, publishedKey)).toBe(false);
expect(canStartNacosConfigDelete({ ...idle, deleting: true }, publishedKey)).toBe(false);
expect(canStartNacosConfigDelete({ ...idle, hasPendingSave: true }, publishedKey)).toBe(false);
expect(canStartNacosConfigDelete({ ...idle, hasPendingDelete: true }, publishedKey)).toBe(false);
});
it("freezes delete confirmation scope and never redirects it to a newly selected connection", async () => {
const key = { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" };
const config = { ...key, desc: "old connection config" };
const snapshot = createNacosConfigDeleteSnapshot("old-connection", key, config);
key.dataId = "mutated.yaml";
config.desc = "mutated after confirmation";
const deletedConnections: string[] = [];
const executeIfCurrent = async (connectionId: string, namespace: string) => {
if (!isNacosConfigDeleteSnapshotInScope(snapshot, connectionId, namespace)) return;
deletedConnections.push(snapshot.connectionId);
};
await executeIfCurrent("new-connection", "dev");
expect(deletedConnections).toEqual([]);
expect(snapshot).toMatchObject({
connectionId: "old-connection",
key: { namespace: "dev", dataId: "application.yaml", group: "DEFAULT_GROUP" },
config: { desc: "old connection config" },
});
});
it("splits every case-sensitive literal content match for safe highlighting", () => {
expect(splitNacosContentLiteralMatches("url=/deploy/deploy?mode=Deploy", "deploy")).toEqual([
{ text: "url=/", matched: false },
{ text: "deploy", matched: true },
{ text: "/", matched: false },
{ text: "deploy", matched: true },
{ text: "?mode=Deploy", matched: false },
]);
expect(splitNacosContentLiteralMatches("<script>alert(1)</script>", "alert")).toEqual([
{ text: "<script>", matched: false },
{ text: "alert", matched: true },
{ text: "(1)</script>", matched: false },
]);
});
it("exports content search matches as UTF-8 CSV and neutralizes spreadsheet formulas", () => {
const csv = buildNacosContentSearchCsv([
{
namespace: "",
group: "dev",
dataId: '=IMPORTXML("https://example.test")',
lineNumber: 12,
snippet: 'url: "jdbc:mysql://db/a,b"\nuser: root',
},
]);
expect(csv.startsWith("\uFEFF")).toBe(true);
expect(csv).toContain('"public","dev","\'=IMPORTXML(""https://example.test"")","12"');
expect(csv).toContain('"url: ""jdbc:mysql://db/a,b""\nuser: root"');
});
});

View File

@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createNacosNamespaceRequestGuard, NACOS_NAMESPACES_CHANGED_EVENT, notifyNacosNamespacesChanged, subscribeNacosNamespacesChanged } from "@/lib/nacos/nacosNamespaceCache";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Nacos namespace request guard", () => {
it("prevents an older response from replacing a newer namespace list", async () => {
const guard = createNacosNamespaceRequestGuard();
const committedLists: string[][] = [];
let resolveOlder!: (value: string[]) => void;
let resolveNewer!: (value: string[]) => void;
const olderResponse = new Promise<string[]>((resolve) => {
resolveOlder = resolve;
});
const newerResponse = new Promise<string[]>((resolve) => {
resolveNewer = resolve;
});
const olderRequest = guard.start("conn-1");
const commitOlder = olderResponse.then((namespaces) => {
if (guard.isCurrent(olderRequest, "conn-1")) committedLists.push(namespaces);
});
const newerRequest = guard.start("conn-1");
const commitNewer = newerResponse.then((namespaces) => {
if (guard.isCurrent(newerRequest, "conn-1")) committedLists.push(namespaces);
});
resolveNewer(["public", "new-space"]);
await commitNewer;
resolveOlder(["public"]);
await commitOlder;
expect(committedLists).toEqual([["public", "new-space"]]);
});
it("rejects pending responses after invalidation or a connection change", () => {
const guard = createNacosNamespaceRequestGuard();
const request = guard.start("conn-1");
expect(guard.isCurrent(request, "conn-2")).toBe(false);
guard.invalidate();
expect(guard.isCurrent(request, "conn-1")).toBe(false);
});
});
describe("Nacos namespace change events", () => {
it("notifies open views for the affected connection and supports unsubscribe", () => {
const listeners = new Map<string, Set<(event: Event) => void>>();
vi.stubGlobal("window", {
addEventListener: (type: string, listener: (event: Event) => void) => {
const handlers = listeners.get(type) ?? new Set();
handlers.add(listener);
listeners.set(type, handlers);
},
removeEventListener: (type: string, listener: (event: Event) => void) => {
listeners.get(type)?.delete(listener);
},
dispatchEvent: (event: Event) => {
listeners.get(event.type)?.forEach((listener) => listener(event));
return true;
},
});
const listener = vi.fn();
const unsubscribe = subscribeNacosNamespacesChanged(listener);
notifyNacosNamespacesChanged("conn-1");
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith({ connectionId: "conn-1" });
expect(listeners.get(NACOS_NAMESPACES_CHANGED_EVENT)?.size).toBe(1);
unsubscribe();
notifyNacosNamespacesChanged("conn-1");
expect(listener).toHaveBeenCalledOnce();
expect(listeners.get(NACOS_NAMESPACES_CHANGED_EVENT)?.size).toBe(0);
});
});

View File

@ -330,6 +330,13 @@ export const nacosListConfigs = forward("nacosListConfigs");
export const nacosGetConfig = forward("nacosGetConfig");
export const nacosPublishConfig = forward("nacosPublishConfig");
export const nacosDeleteConfig = forward("nacosDeleteConfig");
export const nacosSearchConfigContent = forward("nacosSearchConfigContent");
export const nacosCancelConfigContentSearch = forward("nacosCancelConfigContentSearch");
export const nacosExportConfigs = forward("nacosExportConfigs");
export const nacosPreviewConfigImport = forward("nacosPreviewConfigImport");
export const nacosApplyConfigImport = forward("nacosApplyConfigImport");
export const nacosPreviewConfigTransfer = forward("nacosPreviewConfigTransfer");
export const nacosApplyConfigTransfer = forward("nacosApplyConfigTransfer");
export const nacosListConfigHistory = forward("nacosListConfigHistory");
export const nacosGetConfigHistory = forward("nacosGetConfigHistory");
export const nacosRollbackConfig = forward("nacosRollbackConfig");

View File

@ -144,6 +144,13 @@ import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, Da
import { apiUrl, apiWebSocketUrl } from "@/lib/common/webPath";
import type { DataGridSavePreparation } from "@/lib/backend/tauri";
import type {
NacosBatchPreview,
NacosBatchReport,
NacosConfigSelector,
NacosConfigTransferRequest,
NacosConflictPolicy,
NacosContentSearchRequest,
NacosContentSearchResult,
NacosConfigHistoryKey,
NacosConfigHistoryList,
NacosConfigHistoryQuery,
@ -165,6 +172,7 @@ import type {
NacosRawResponse,
NacosServiceList,
NacosServiceQuery,
NacosSearchProgress,
} from "@/types/nacos";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
@ -2111,6 +2119,93 @@ export async function nacosDeleteConfig(connectionId: string, key: NacosConfigKe
return post("/api/nacos/configs/delete", { connectionId, key });
}
export async function nacosSearchConfigContent(connectionId: string, req: NacosContentSearchRequest, onProgress?: (progress: NacosSearchProgress) => void): Promise<NacosContentSearchResult> {
const response = await fetch(apiUrl("/api/nacos/configs/search"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ connectionId, req }),
});
if (!response.ok) throw new Error(await response.text());
if (!response.body) throw new Error("Nacos content search did not return a response stream");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let result: NacosContentSearchResult | null = null;
const consumeLine = (line: string) => {
if (!line.startsWith("data:")) return;
const data = line.slice(5).trim();
if (!data) return;
const event = JSON.parse(data) as { type: "progress"; progress: NacosSearchProgress } | { type: "result"; result: NacosContentSearchResult } | { type: "error"; error: string };
if (event.type === "progress") onProgress?.(event.progress);
else if (event.type === "result") result = event.result;
else throw new Error(event.error);
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) consumeLine(line);
}
buffer += decoder.decode();
if (buffer) consumeLine(buffer);
if (!result) throw new Error("Nacos content search stream ended without a final result");
return result;
} finally {
await reader.cancel().catch(() => {});
}
}
export async function nacosCancelConfigContentSearch(operationId: string): Promise<boolean> {
const result = await post<{ cancelled: boolean }>("/api/nacos/configs/search/cancel", { operationId });
return result.cancelled;
}
export async function nacosExportConfigs(connectionId: string, selector: NacosConfigSelector, _destination: string, fileName = "nacos-configs.zip"): Promise<void> {
const response = await fetch(apiUrl("/api/nacos/configs/export"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ connectionId, selector, fileName }),
});
if (!response.ok) throw new Error(await response.text());
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
export async function nacosPreviewConfigImport(connectionId: string, targetNamespace: string, archivePath: string | File): Promise<NacosBatchPreview> {
if (!(archivePath instanceof File)) throw new Error("Nacos ZIP import in web mode requires a File object");
const formData = new FormData();
formData.append("connectionId", connectionId);
formData.append("targetNamespace", targetNamespace);
formData.append("file", archivePath, archivePath.name);
const response = await fetch(apiUrl("/api/nacos/configs/import/preview"), { method: "POST", body: formData });
if (!response.ok) throw new Error(await response.text());
return response.json();
}
export async function nacosApplyConfigImport(connectionId: string, operationId: string, targetNamespace: string, _archivePath: string | File, planHash: string, conflictPolicy: NacosConflictPolicy, archiveToken?: string): Promise<NacosBatchReport> {
if (!archiveToken) throw new Error("The Nacos import preview token is missing or expired");
return post("/api/nacos/configs/import/apply", { connectionId, operationId, targetNamespace, archiveToken, planHash, conflictPolicy });
}
export async function nacosPreviewConfigTransfer(req: NacosConfigTransferRequest): Promise<NacosBatchPreview> {
return post("/api/nacos/configs/copy/preview", { req });
}
export async function nacosApplyConfigTransfer(req: NacosConfigTransferRequest, planHash: string): Promise<NacosBatchReport> {
return post("/api/nacos/configs/copy/apply", { req, planHash });
}
export async function nacosListConfigHistory(connectionId: string, query: NacosConfigHistoryQuery): Promise<NacosConfigHistoryList> {
return post("/api/nacos/configs/history/list", { connectionId, query });
}

View File

@ -1,5 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
import { Channel, invoke } from "@tauri-apps/api/core";
import type {
NacosBatchPreview,
NacosBatchReport,
NacosConfigSelector,
NacosConfigTransferRequest,
NacosConflictPolicy,
NacosContentSearchRequest,
NacosContentSearchResult,
NacosConfigHistoryKey,
NacosConfigHistoryList,
NacosConfigHistoryQuery,
@ -21,6 +28,7 @@ import type {
NacosRawResponse,
NacosServiceList,
NacosServiceQuery,
NacosSearchProgress,
} from "@/types/nacos";
export async function nacosTestConnection(connectionId: string): Promise<NacosConnectionInfo> {
@ -55,6 +63,38 @@ export async function nacosDeleteConfig(connectionId: string, key: NacosConfigKe
return invoke("nacos_delete_config", { connectionId, key });
}
export async function nacosSearchConfigContent(connectionId: string, req: NacosContentSearchRequest, onProgress?: (progress: NacosSearchProgress) => void): Promise<NacosContentSearchResult> {
const channel = new Channel<NacosSearchProgress>();
channel.onmessage = (progress) => onProgress?.(progress);
return invoke("nacos_search_config_content", { connectionId, req, onProgress: channel });
}
export async function nacosCancelConfigContentSearch(operationId: string): Promise<boolean> {
return invoke("nacos_cancel_operation", { operationId });
}
export async function nacosExportConfigs(connectionId: string, selector: NacosConfigSelector, destination: string, _fileName?: string): Promise<void> {
return invoke("nacos_export_configs", { connectionId, selector, destination });
}
export async function nacosPreviewConfigImport(connectionId: string, targetNamespace: string, archivePath: string | File): Promise<NacosBatchPreview> {
if (typeof archivePath !== "string") throw new Error("Desktop Nacos ZIP import requires a local file path");
return invoke("nacos_preview_config_import", { connectionId, targetNamespace, archivePath });
}
export async function nacosApplyConfigImport(connectionId: string, operationId: string, targetNamespace: string, archivePath: string | File, planHash: string, conflictPolicy: NacosConflictPolicy, _archiveToken?: string): Promise<NacosBatchReport> {
if (typeof archivePath !== "string") throw new Error("Desktop Nacos ZIP import requires a local file path");
return invoke("nacos_apply_config_import", { connectionId, operationId, targetNamespace, archivePath, planHash, conflictPolicy });
}
export async function nacosPreviewConfigTransfer(req: NacosConfigTransferRequest): Promise<NacosBatchPreview> {
return invoke("nacos_preview_config_transfer", { req });
}
export async function nacosApplyConfigTransfer(req: NacosConfigTransferRequest, planHash: string): Promise<NacosBatchReport> {
return invoke("nacos_apply_config_transfer", { req, planHash });
}
export async function nacosListConfigHistory(connectionId: string, query: NacosConfigHistoryQuery): Promise<NacosConfigHistoryList> {
return invoke("nacos_list_config_history", { connectionId, query });
}

View File

@ -73,6 +73,10 @@ export function supportsClearableQuerySchema(dbType?: DatabaseType): boolean {
return !!dbType && CLEARABLE_QUERY_SCHEMA_TYPES.has(dbType);
}
export function supportsConnectionQueryActions(dbType?: DatabaseType): boolean {
return dbType !== "nacos";
}
export function usesFetchFirst(dbType?: DatabaseType): boolean {
return !!dbType && FETCH_FIRST_TYPES.has(dbType);
}

View File

@ -1,4 +1,4 @@
import type { NacosConfigHistoryItem, NacosConfigItem, NacosImplementation, NacosInstanceInfo, NacosRawRequest, NacosServiceInfo, NacosVersionMode } from "@/types/nacos";
import type { NacosConfigHistoryItem, NacosConfigItem, NacosConfigKey, NacosContentMatch, NacosImplementation, NacosInstanceInfo, NacosRawRequest, NacosServiceInfo, NacosVersionMode } from "@/types/nacos";
import { diffChars, diffLines } from "diff";
export type NacosRawTemplateKey = "serverState" | "namespaceList" | "configDetail" | "serviceList" | "instanceList";
@ -163,6 +163,11 @@ export function isNacosRawMutation(method: string): boolean {
return method.trim().toUpperCase() !== "GET";
}
export function isNacosErrorCode(error: unknown, code: string): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes(`NACOS_ERROR[${code}]`);
}
export function formatNacosConfigIdentity(item: Pick<NacosConfigItem, "namespace" | "dataId" | "group">, fallbackNamespace = ""): string {
return [`namespace=${item.namespace || fallbackNamespace || "public"}`, `dataId=${item.dataId}`, `group=${item.group || "DEFAULT_GROUP"}`].join("\n");
}
@ -179,6 +184,232 @@ export function resolveNacosConfigCopyText(selectionText: string, editorText: st
return selectionText || editorText || stateText;
}
export interface NacosLatestRequestGuard {
begin(): number;
invalidate(): void;
isCurrent(requestId: number): boolean;
}
export function createNacosLatestRequestGuard(): NacosLatestRequestGuard {
let sequence = 0;
return {
begin() {
sequence += 1;
return sequence;
},
invalidate() {
sequence += 1;
},
isCurrent(requestId) {
return requestId === sequence;
},
};
}
export interface NacosConfigSaveSnapshot {
requestId: number;
editorSessionId: number;
connectionId: string;
wasCreating: boolean;
originalKey: NacosConfigKey | null;
targetKey: NacosConfigKey;
config: NacosConfigItem;
content: string;
configType: string;
}
export interface NacosConfigSaveSnapshotInput {
requestId: number;
editorSessionId: number;
connectionId: string;
fallbackNamespace?: string;
originalKey: NacosConfigKey | null;
config: NacosConfigItem;
content: string;
configType: string;
}
export function createNacosConfigSaveSnapshot(input: NacosConfigSaveSnapshotInput): NacosConfigSaveSnapshot {
const dataId = input.config.dataId.trim();
const group = input.config.group.trim() || "DEFAULT_GROUP";
const namespace = input.originalKey?.namespace || input.config.namespace || input.fallbackNamespace || undefined;
const configType = input.configType || "text";
const config: NacosConfigItem = {
...input.config,
namespace: namespace || "",
dataId,
group,
content: input.content,
configType,
};
return {
requestId: input.requestId,
editorSessionId: input.editorSessionId,
connectionId: input.connectionId,
wasCreating: !input.originalKey,
originalKey: input.originalKey ? { ...input.originalKey } : null,
targetKey: { namespace, dataId, group },
config,
content: input.content,
configType,
};
}
export interface NacosConfigEditorComparableState {
latestRequestId: number;
editorSessionId: number;
connectionId: string;
originalKey: NacosConfigKey | null;
config: NacosConfigItem | null;
content: string;
configType: string;
}
function sameNacosConfigKey(left: NacosConfigKey | null, right: NacosConfigKey | null): boolean {
if (!left || !right) return left === right;
return (left.namespace || "") === (right.namespace || "") && left.dataId === right.dataId && (left.group || "DEFAULT_GROUP") === (right.group || "DEFAULT_GROUP");
}
export function isNacosConfigSaveSnapshotSameEditor(snapshot: NacosConfigSaveSnapshot, current: NacosConfigEditorComparableState): boolean {
const config = current.config;
return (
snapshot.requestId === current.latestRequestId &&
snapshot.editorSessionId === current.editorSessionId &&
snapshot.connectionId === current.connectionId &&
sameNacosConfigKey(snapshot.originalKey, current.originalKey) &&
!!config &&
snapshot.config.dataId === config.dataId.trim() &&
snapshot.config.group === (config.group.trim() || "DEFAULT_GROUP") &&
(snapshot.config.namespace || "") === (config.namespace || snapshot.targetKey.namespace || "")
);
}
export function isNacosConfigSaveSnapshotCurrent(snapshot: NacosConfigSaveSnapshot, current: NacosConfigEditorComparableState): boolean {
const config = current.config;
return (
isNacosConfigSaveSnapshotSameEditor(snapshot, current) &&
!!config &&
snapshot.content === current.content &&
snapshot.configType === current.configType &&
(snapshot.config.appName || "") === (config.appName || "") &&
(snapshot.config.desc || "") === (config.desc || "") &&
(snapshot.config.tags || "") === (config.tags || "")
);
}
export type NacosConfigSaveCompletion =
| { kind: "stale" }
| {
kind: "saved" | "saved-with-later-edits";
originalKey: NacosConfigKey;
savedConfig: NacosConfigItem;
baseline: {
content: string;
configType: string;
appName: string;
desc: string;
tags: string;
};
};
export function resolveNacosConfigSaveCompletion(snapshot: NacosConfigSaveSnapshot, current: NacosConfigEditorComparableState): NacosConfigSaveCompletion {
if (!isNacosConfigSaveSnapshotSameEditor(snapshot, current)) return { kind: "stale" };
return {
kind: isNacosConfigSaveSnapshotCurrent(snapshot, current) ? "saved" : "saved-with-later-edits",
originalKey: { ...snapshot.targetKey },
savedConfig: { ...snapshot.config },
baseline: {
content: snapshot.content,
configType: snapshot.configType,
appName: snapshot.config.appName || "",
desc: snapshot.config.desc || "",
tags: snapshot.config.tags || "",
},
};
}
export function canDeleteNacosConfig(readOnly: boolean, originalKey: NacosConfigKey | null): boolean {
return !readOnly && !!originalKey;
}
export interface NacosConfigMutationGuardState {
readOnly: boolean;
saving: boolean;
deleting: boolean;
hasPendingDelete: boolean;
hasPendingSave: boolean;
}
export function canStartNacosConfigSave(state: NacosConfigMutationGuardState): boolean {
return !state.readOnly && !state.saving && !state.deleting && !state.hasPendingDelete;
}
export function canStartNacosConfigDelete(state: NacosConfigMutationGuardState, originalKey: NacosConfigKey | null): boolean {
return canDeleteNacosConfig(state.readOnly, originalKey) && !state.saving && !state.deleting && !state.hasPendingSave && !state.hasPendingDelete;
}
export interface NacosConfigDeleteSnapshot {
connectionId: string;
key: NacosConfigKey;
config: NacosConfigItem;
}
export function createNacosConfigDeleteSnapshot(connectionId: string, key: NacosConfigKey, config: NacosConfigItem): NacosConfigDeleteSnapshot {
return {
connectionId,
key: {
namespace: key.namespace || undefined,
dataId: key.dataId,
group: key.group || "DEFAULT_GROUP",
},
config: {
...config,
namespace: key.namespace || "",
dataId: key.dataId,
group: key.group || "DEFAULT_GROUP",
},
};
}
export function isNacosConfigDeleteSnapshotInScope(snapshot: NacosConfigDeleteSnapshot, connectionId: string, namespace?: string): boolean {
return snapshot.connectionId === connectionId && (snapshot.key.namespace || "") === (namespace || "");
}
export interface NacosLiteralMatchSegment {
text: string;
matched: boolean;
}
export function splitNacosContentLiteralMatches(text: string, query: string): NacosLiteralMatchSegment[] {
if (!text || !query) return text ? [{ text, matched: false }] : [];
const segments: NacosLiteralMatchSegment[] = [];
let cursor = 0;
while (cursor < text.length) {
const matchIndex = text.indexOf(query, cursor);
if (matchIndex < 0) {
segments.push({ text: text.slice(cursor), matched: false });
break;
}
if (matchIndex > cursor) {
segments.push({ text: text.slice(cursor, matchIndex), matched: false });
}
segments.push({ text: text.slice(matchIndex, matchIndex + query.length), matched: true });
cursor = matchIndex + query.length;
}
return segments;
}
function nacosSearchCsvCell(value: string | number): string {
let text = String(value);
if (/^[\t\r ]*[=+\-@]/.test(text)) text = `'${text}`;
return `"${text.replaceAll('"', '""')}"`;
}
export function buildNacosContentSearchCsv(matches: readonly NacosContentMatch[]): string {
const rows: Array<Array<string | number>> = [["namespace", "group", "dataId", "lineNumber", "snippet"], ...matches.map((match) => [match.namespace || "public", match.group || "DEFAULT_GROUP", match.dataId, match.lineNumber, match.snippet])];
return `\uFEFF${rows.map((row) => row.map(nacosSearchCsvCell).join(",")).join("\r\n")}\r\n`;
}
export function sanitizeNacosConfigFileNameSegment(value: string): string {
const sanitized = value
.trim()

View File

@ -0,0 +1,40 @@
export const NACOS_NAMESPACES_CHANGED_EVENT = "dbx:nacos-namespaces-changed";
export interface NacosNamespacesChangedDetail {
connectionId: string;
}
export interface NacosNamespaceRequestGuard {
invalidate: () => void;
start: (connectionId: string) => number;
isCurrent: (requestId: number, connectionId: string) => boolean;
}
export function createNacosNamespaceRequestGuard(): NacosNamespaceRequestGuard {
let latestRequestId = 0;
let latestConnectionId = "";
return {
invalidate: () => {
latestRequestId++;
latestConnectionId = "";
},
start: (connectionId) => {
latestConnectionId = connectionId;
return ++latestRequestId;
},
isCurrent: (requestId, connectionId) => requestId === latestRequestId && connectionId === latestConnectionId,
};
}
export function notifyNacosNamespacesChanged(connectionId: string) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent<NacosNamespacesChangedDetail>(NACOS_NAMESPACES_CHANGED_EVENT, { detail: { connectionId } }));
}
export function subscribeNacosNamespacesChanged(listener: (detail: NacosNamespacesChangedDetail) => void): () => void {
if (typeof window === "undefined") return () => undefined;
const handleEvent = (event: Event) => listener((event as CustomEvent<NacosNamespacesChangedDetail>).detail);
window.addEventListener(NACOS_NAMESPACES_CHANGED_EVENT, handleEvent);
return () => window.removeEventListener(NACOS_NAMESPACES_CHANGED_EVENT, handleEvent);
}

View File

@ -1425,12 +1425,18 @@ export const useQueryStore = defineStore("query", () => {
return id;
}
function openNacosAdmin(connectionId: string, target?: { namespace?: string; namespaceName?: string }) {
function openNacosAdmin(connectionId: string, target?: { namespace?: string; namespaceName?: string; dataId?: string; group?: string; keyword?: string }) {
const namespace = target?.namespace ?? "";
const namespaceName = target?.namespaceName || (namespace ? namespace : "public");
const existing = tabs.value.find((tab) => tab.mode === "nacos" && tab.connectionId === connectionId && (tab.nacosNamespace || "") === namespace);
if (existing) {
existing.nacosNamespaceName = namespaceName;
if (target?.dataId) {
existing.nacosTargetDataId = target.dataId;
existing.nacosTargetGroup = target.group || "DEFAULT_GROUP";
existing.nacosTargetKeyword = target.keyword;
existing.nacosTargetRequestId = (existing.nacosTargetRequestId ?? 0) + 1;
}
if (!existing.customTitle) existing.title = `${useConnectionStore().getConfig(connectionId)?.name || "Nacos"}:${namespaceName}`;
switchTab(existing.id);
return existing.id;
@ -1450,12 +1456,24 @@ export const useQueryStore = defineStore("query", () => {
mode: "nacos",
nacosNamespace: namespace,
nacosNamespaceName: namespaceName,
nacosTargetDataId: target?.dataId,
nacosTargetGroup: target?.group,
nacosTargetKeyword: target?.keyword,
nacosTargetRequestId: target?.dataId ? 1 : undefined,
};
tabs.value.push(tab);
activeTabId.value = id;
return id;
}
function clearNacosNavigationTarget(connectionId: string, namespace: string, requestId?: number) {
const tab = tabs.value.find((candidate) => candidate.mode === "nacos" && candidate.connectionId === connectionId && (candidate.nacosNamespace || "") === namespace);
if (!tab || (requestId !== undefined && tab.nacosTargetRequestId !== requestId)) return;
tab.nacosTargetDataId = undefined;
tab.nacosTargetGroup = undefined;
tab.nacosTargetKeyword = undefined;
}
function applyTableStructureInitialTab(tab: QueryTab, initialTab?: TableInfoTab, initialTarget?: TableStructureEditorTarget) {
if (!initialTab && !initialTarget?.name) return;
if (initialTab) tab.structureInitialTab = initialTab;
@ -4583,6 +4601,7 @@ export const useQueryStore = defineStore("query", () => {
openDamengJobAdmin,
openMqAdmin,
openNacosAdmin,
clearNacosNavigationTarget,
openTableStructure,
linkSavedSql,
linkExternalSqlPath,

View File

@ -898,6 +898,10 @@ export interface QueryTab {
mqInitialTab?: "topics";
nacosNamespace?: string;
nacosNamespaceName?: string;
nacosTargetDataId?: string;
nacosTargetGroup?: string;
nacosTargetKeyword?: string;
nacosTargetRequestId?: number;
structureTableName?: string;
structureInitialTab?: TableInfoTab;
structureInitialTabRequestId?: number;

View File

@ -98,6 +98,146 @@ export interface NacosConfigList {
items: NacosConfigItem[];
}
export type NacosNamespaceScope = "currentNamespace" | "allNamespaces";
export interface NacosContentSearchRequest {
operationId: string;
namespace?: string;
scope: NacosNamespaceScope;
query: string;
group?: string;
dataId?: string;
maxResults?: number;
}
export interface NacosContentMatch {
namespace: string;
group: string;
dataId: string;
lineNumber: number;
snippet: string;
}
export interface NacosSearchFailure {
namespace: string;
error: string;
}
export interface NacosSearchProgress {
operationId: string;
phase: string;
namespace?: string;
scanned: number;
total?: number;
matched: number;
matches: NacosContentMatch[];
failures: NacosSearchFailure[];
truncated: boolean;
cancelled: boolean;
done: boolean;
}
export interface NacosContentSearchResult {
operationId: string;
scanned: number;
matches: NacosContentMatch[];
failures: NacosSearchFailure[];
truncated: boolean;
cancelled: boolean;
incomplete: boolean;
}
export type NacosConfigSelectionScope = "selected" | "filtered" | "namespace";
export interface NacosConfigSelector {
namespace: string;
scope: NacosConfigSelectionScope;
keys?: NacosConfigKey[];
query?: NacosConfigQuery;
}
export type NacosConflictPolicy = "ABORT" | "SKIP" | "OVERWRITE";
export interface NacosBatchPreviewItem {
namespace: string;
group: string;
dataId: string;
status: string;
message?: string;
}
export interface NacosBatchPreview {
planHash: string;
total: number;
created: number;
conflicts: number;
invalid: number;
items: NacosBatchPreviewItem[];
/** Web mode keeps an uploaded archive server-side behind this short-lived token. */
archiveToken?: string;
}
export interface NacosBatchItemResult {
namespace: string;
group: string;
dataId: string;
status: string;
message?: string;
}
export interface NacosBatchReport {
operationId: string;
planHash?: string;
total: number;
created: number;
overwritten: number;
skipped: number;
failed: number;
aborted: boolean;
partial: boolean;
cancelled: boolean;
items: NacosBatchItemResult[];
}
export interface NacosConfigTransferRequest {
operationId: string;
sourceConnectionId: string;
targetConnectionId: string;
source: NacosConfigSelector;
targetNamespace: string;
conflictPolicy: NacosConflictPolicy;
}
export interface NacosConfigExportRequest {
operationId: string;
selector: NacosConfigSelector;
targetPath?: string;
}
export interface NacosConfigExportResult {
operationId: string;
exported: number;
fileName?: string;
path?: string;
downloadToken?: string;
}
export interface NacosConfigImportPreviewRequest {
operationId: string;
namespace: string;
sourcePath?: string;
archiveToken?: string;
}
export interface NacosConfigImportApplyRequest {
operationId: string;
namespace: string;
planHash: string;
archiveToken?: string;
sourcePath?: string;
conflictPolicy: NacosConflictPolicy;
}
export interface NacosConfigKey {
namespace?: string;
dataId: string;

View File

@ -40,6 +40,7 @@ sqlite-sqlcipher = ["rusqlite/bundled-sqlcipher-vendored-openssl"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["arbitrary_precision", "preserve_order"] }
serde_yaml_ng = "0.10"
json5 = "0.4"
regex = "1"
winnow = "1.0"

View File

@ -0,0 +1,354 @@
use std::collections::{HashMap, HashSet};
use std::io::{Cursor, Read, Write};
use std::path::{Component, Path};
use serde::{Deserialize, Serialize};
use zip::write::SimpleFileOptions;
use crate::nacos::types::{NacosConfigItem, NacosConfigUpsert};
pub const MAX_ARCHIVE_BYTES: u64 = 100 * 1024 * 1024;
pub const MAX_UNCOMPRESSED_BYTES: u64 = 256 * 1024 * 1024;
pub const MAX_CONFIG_BYTES: u64 = 20 * 1024 * 1024;
pub const MAX_METADATA_BYTES: u64 = 2 * 1024 * 1024;
pub const MAX_CONFIG_ITEMS: usize = 10_000;
const METADATA_PATH: &str = ".metadata.yml";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct MetadataItem {
group: String,
#[serde(rename = "dataId")]
data_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
desc: Option<String>,
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
config_type: Option<String>,
#[serde(rename = "appName", default, skip_serializing_if = "Option::is_none")]
app_name: Option<String>,
#[serde(rename = "configTags", default, skip_serializing_if = "Option::is_none")]
config_tags: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MetadataDocument {
metadata: Vec<MetadataItem>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum CompatibleMetadata {
Document(MetadataDocument),
Items(Vec<MetadataItem>),
}
impl CompatibleMetadata {
fn into_items(self) -> Vec<MetadataItem> {
match self {
Self::Document(document) => document.metadata,
Self::Items(items) => items,
}
}
}
pub fn encode_config_archive(configs: &[NacosConfigItem]) -> Result<Vec<u8>, String> {
if configs.len() > MAX_CONFIG_ITEMS {
return Err(format!("Nacos archive contains more than {MAX_CONFIG_ITEMS} configurations"));
}
let mut sorted = configs.to_vec();
sorted.sort_by(|left, right| {
(&left.group, &left.data_id, &left.namespace).cmp(&(&right.group, &right.data_id, &right.namespace))
});
let mut seen = HashSet::new();
let mut metadata = Vec::with_capacity(sorted.len());
let mut actual_uncompressed = 0u64;
for config in &sorted {
validate_config_key(&config.group, &config.data_id)?;
if !seen.insert((config.group.clone(), config.data_id.clone())) {
return Err(format!("Duplicate Nacos configuration key: {}/{}", config.group, config.data_id));
}
let content = config
.content
.as_deref()
.ok_or_else(|| format!("Nacos configuration {}/{} has no content", config.group, config.data_id))?;
if content.len() as u64 > MAX_CONFIG_BYTES {
return Err(format!("Nacos configuration {}/{} exceeds the 20 MiB limit", config.group, config.data_id));
}
actual_uncompressed = add_uncompressed_size(actual_uncompressed, content.len() as u64)?;
metadata.push(MetadataItem {
group: config.group.clone(),
data_id: config.data_id.clone(),
desc: config.desc.clone(),
config_type: config.config_type.clone(),
app_name: config.app_name.clone(),
config_tags: config.tags.clone(),
});
}
let metadata = serde_yaml_ng::to_string(&MetadataDocument { metadata })
.map_err(|error| format!("Failed to encode Nacos archive metadata: {error}"))?;
if metadata.len() as u64 > MAX_METADATA_BYTES {
return Err("Nacos archive metadata exceeds the 2 MiB limit".to_string());
}
add_uncompressed_size(actual_uncompressed, metadata.len() as u64)?;
let cursor = Cursor::new(Vec::new());
let mut archive = zip::ZipWriter::new(cursor);
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated).unix_permissions(0o600);
archive
.start_file(METADATA_PATH, options)
.map_err(|error| format!("Failed to create Nacos archive metadata entry: {error}"))?;
archive
.write_all(metadata.as_bytes())
.map_err(|error| format!("Failed to write Nacos archive metadata: {error}"))?;
for config in sorted {
archive
.start_file(archive_entry_path(&config.group, &config.data_id), options)
.map_err(|error| format!("Failed to create Nacos archive entry: {error}"))?;
archive
.write_all(config.content.as_deref().unwrap_or_default().as_bytes())
.map_err(|error| format!("Failed to write Nacos archive entry: {error}"))?;
}
let bytes = archive.finish().map_err(|error| format!("Failed to finish Nacos archive: {error}"))?.into_inner();
if bytes.len() as u64 > MAX_ARCHIVE_BYTES {
return Err("Nacos archive exceeds the 100 MiB limit".to_string());
}
Ok(bytes)
}
pub fn decode_config_archive(bytes: &[u8], target_namespace: &str) -> Result<Vec<NacosConfigUpsert>, String> {
if bytes.len() as u64 > MAX_ARCHIVE_BYTES {
return Err("Nacos archive exceeds the 100 MiB limit".to_string());
}
let mut archive =
zip::ZipArchive::new(Cursor::new(bytes)).map_err(|error| format!("Invalid Nacos ZIP archive: {error}"))?;
if archive.len() > MAX_CONFIG_ITEMS.saturating_add(1) {
return Err(format!("Nacos archive contains more than {MAX_CONFIG_ITEMS} configurations"));
}
let mut entries = HashMap::<String, Vec<u8>>::new();
let mut actual_uncompressed = 0u64;
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(|error| format!("Invalid Nacos ZIP entry: {error}"))?;
let name = entry.name().to_string();
validate_archive_path(&name)?;
if entry.is_dir() {
continue;
}
if entry.unix_mode().is_some_and(|mode| mode & 0o170000 == 0o120000) {
return Err(format!("Nacos archive symlink is not allowed: {name}"));
}
let declared_limit = if name == METADATA_PATH { MAX_METADATA_BYTES } else { MAX_CONFIG_BYTES };
if entry.size() > declared_limit {
return Err(format!("Nacos archive entry exceeds its size limit: {name}"));
}
let mut content = Vec::with_capacity(entry.size().min(declared_limit) as usize);
entry
.by_ref()
.take(declared_limit + 1)
.read_to_end(&mut content)
.map_err(|error| format!("Failed to read Nacos archive entry {name}: {error}"))?;
if content.len() as u64 > declared_limit {
return Err(format!("Nacos archive entry exceeds its size limit: {name}"));
}
actual_uncompressed = add_uncompressed_size(actual_uncompressed, content.len() as u64)?;
if entries.insert(name.clone(), content).is_some() {
return Err(format!("Duplicate Nacos archive path: {name}"));
}
}
let metadata = entries.remove(METADATA_PATH).ok_or_else(|| "Nacos archive is missing .metadata.yml".to_string())?;
let metadata: CompatibleMetadata =
serde_yaml_ng::from_slice(&metadata).map_err(|error| format!("Invalid Nacos archive metadata: {error}"))?;
let metadata = metadata.into_items();
if metadata.len() > MAX_CONFIG_ITEMS {
return Err(format!("Nacos archive contains more than {MAX_CONFIG_ITEMS} configurations"));
}
let mut keys = HashSet::new();
let mut configs = Vec::with_capacity(metadata.len());
for item in metadata {
validate_config_key(&item.group, &item.data_id)?;
if !keys.insert((item.group.clone(), item.data_id.clone())) {
return Err(format!("Duplicate Nacos configuration key: {}/{}", item.group, item.data_id));
}
let path = archive_entry_path(&item.group, &item.data_id);
// Archives exported by older DBX versions and the Nacos console use
// the direct `group/dataId` layout. Keep accepting it as a fallback;
// new exports only use that layout when both components are safe.
let legacy_path = format!("{}/{}", item.group, item.data_id);
let content = entries
.remove(&path)
.or_else(|| entries.remove(&legacy_path))
.ok_or_else(|| format!("Nacos archive metadata references a missing file: {path}"))?;
let content =
String::from_utf8(content).map_err(|_| format!("Nacos configuration is not valid UTF-8: {path}"))?;
configs.push(NacosConfigUpsert {
namespace: Some(target_namespace.to_string()),
data_id: item.data_id,
group: item.group,
content,
config_type: item.config_type,
app_name: item.app_name,
desc: item.desc,
tags: item.config_tags,
});
}
if let Some(orphan) = entries.keys().next() {
return Err(format!("Nacos archive contains an orphan file not present in metadata: {orphan}"));
}
Ok(configs)
}
fn validate_archive_path(name: &str) -> Result<(), String> {
let bytes = name.as_bytes();
let has_windows_drive_prefix =
bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'/' | b'\\');
if name.is_empty()
|| name.contains('\0')
|| name.contains('\\')
|| name.starts_with('/')
|| has_windows_drive_prefix
{
return Err(format!("Unsafe Nacos archive path: {name}"));
}
let path = Path::new(name);
if path.components().any(|component| !matches!(component, Component::Normal(_))) {
return Err(format!("Unsafe Nacos archive path: {name}"));
}
Ok(())
}
fn add_uncompressed_size(current: u64, addition: u64) -> Result<u64, String> {
let total = current.checked_add(addition).ok_or_else(|| "Nacos archive uncompressed size overflow".to_string())?;
if total > MAX_UNCOMPRESSED_BYTES {
return Err("Nacos archive exceeds the 256 MiB uncompressed limit".to_string());
}
Ok(total)
}
fn validate_config_key(group: &str, data_id: &str) -> Result<(), String> {
for (label, value) in [("group", group), ("dataId", data_id)] {
if value.trim().is_empty() || value.contains('\0') {
return Err(format!("Invalid Nacos configuration {label}: {value:?}"));
}
}
Ok(())
}
fn archive_entry_path(group: &str, data_id: &str) -> String {
if is_safe_archive_component(group) && is_safe_archive_component(data_id) {
return format!("{group}/{data_id}");
}
// Metadata is the source of truth for the original identifiers. Hex keeps
// the ZIP entry path portable and collision-free without treating Nacos
// identifiers as filesystem paths.
format!("configs/{}--{}", hex_encode(group.as_bytes()), hex_encode(data_id.as_bytes()))
}
fn is_safe_archive_component(value: &str) -> bool {
!value.is_empty() && value != "." && value != ".." && !value.contains(['/', '\\', '\0'])
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0f) as usize] as char);
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
fn config(group: &str, data_id: &str, content: &str) -> NacosConfigItem {
NacosConfigItem {
data_id: data_id.to_string(),
group: group.to_string(),
namespace: "source".to_string(),
app_name: Some("dbx".to_string()),
desc: Some("test".to_string()),
tags: Some("one,two".to_string()),
config_type: Some("yaml".to_string()),
md5: None,
encrypted_data_key: Some("must-not-leak".to_string()),
content: Some(content.to_string()),
}
}
#[test]
fn archive_round_trip_preserves_migratable_fields_only() {
let bytes = encode_config_archive(&[config("DEFAULT_GROUP", "app.yaml", "数据库: mysql://db")]).unwrap();
let decoded = decode_config_archive(&bytes, "target").unwrap();
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].namespace.as_deref(), Some("target"));
assert_eq!(decoded[0].content, "数据库: mysql://db");
assert_eq!(decoded[0].tags.as_deref(), Some("one,two"));
}
#[test]
fn rejects_path_traversal() {
let cursor = Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(cursor);
zip.start_file("../evil", SimpleFileOptions::default()).unwrap();
zip.write_all(b"bad").unwrap();
let bytes = zip.finish().unwrap().into_inner();
let error = decode_config_archive(&bytes, "target").unwrap_err();
assert!(error.contains("Unsafe"));
}
#[test]
fn rejects_windows_absolute_path() {
let cursor = Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(cursor);
zip.start_file("C:/secret", SimpleFileOptions::default()).unwrap();
zip.write_all(b"bad").unwrap();
let bytes = zip.finish().unwrap().into_inner();
let error = decode_config_archive(&bytes, "target").unwrap_err();
assert!(error.contains("Unsafe"));
}
#[test]
fn enforces_total_uncompressed_limit_arithmetically() {
assert_eq!(add_uncompressed_size(MAX_UNCOMPRESSED_BYTES - 1, 1).unwrap(), MAX_UNCOMPRESSED_BYTES);
assert!(add_uncompressed_size(MAX_UNCOMPRESSED_BYTES, 1).unwrap_err().contains("256 MiB"));
}
#[test]
fn accepts_official_list_shaped_metadata() {
let cursor = Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(cursor);
zip.start_file(METADATA_PATH, SimpleFileOptions::default()).unwrap();
zip.write_all(b"- group: DEFAULT_GROUP\n dataId: app.properties\n type: properties\n").unwrap();
zip.start_file("DEFAULT_GROUP/app.properties", SimpleFileOptions::default()).unwrap();
zip.write_all(b"a=1").unwrap();
let bytes = zip.finish().unwrap().into_inner();
assert_eq!(decode_config_archive(&bytes, "").unwrap()[0].content, "a=1");
}
#[test]
fn archive_round_trip_preserves_identifiers_with_path_separators() {
let bytes = encode_config_archive(&[config("team/dev", "apps/service.yaml", "enabled: true")]).unwrap();
let archive = zip::ZipArchive::new(Cursor::new(bytes.clone())).unwrap();
assert!(archive.file_names().any(|name| name.starts_with("configs/")));
let decoded = decode_config_archive(&bytes, "target").unwrap();
assert_eq!(decoded[0].group, "team/dev");
assert_eq!(decoded[0].data_id, "apps/service.yaml");
}
#[test]
fn accepts_legacy_nested_path_for_separator_identifiers() {
let cursor = Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(cursor);
zip.start_file(METADATA_PATH, SimpleFileOptions::default()).unwrap();
zip.write_all(b"- group: team/dev\n dataId: apps/service.yaml\n").unwrap();
zip.start_file("team/dev/apps/service.yaml", SimpleFileOptions::default()).unwrap();
zip.write_all(b"enabled: true").unwrap();
let bytes = zip.finish().unwrap().into_inner();
let decoded = decode_config_archive(&bytes, "target").unwrap();
assert_eq!(decoded[0].group, "team/dev");
assert_eq!(decoded[0].data_id, "apps/service.yaml");
}
}

View File

@ -0,0 +1,787 @@
use std::path::Path;
use std::sync::Arc;
use futures::{stream, StreamExt};
use sha2::{Digest, Sha256};
use crate::connection::AppState;
use crate::nacos::archive::{decode_config_archive, encode_config_archive, MAX_ARCHIVE_BYTES};
use crate::nacos::port::NacosAdmin;
use crate::nacos::search::{begin_operation, finish_operation};
use crate::nacos::service::{ensure_connection_writable, get_admin};
use crate::nacos::types::{
NacosBatchItemResult, NacosBatchPreview, NacosBatchPreviewItem, NacosBatchReport, NacosConfigItem, NacosConfigKey,
NacosConfigSelectionScope, NacosConfigSelector, NacosConfigTransferRequest, NacosConfigUpsert, NacosConflictPolicy,
};
const PAGE_SIZE: u32 = 500;
const DETAIL_CONCURRENCY: usize = 8;
pub async fn export_config_archive(
admin: Arc<dyn NacosAdmin>,
selector: NacosConfigSelector,
destination: &Path,
) -> Result<NacosBatchReport, String> {
let configs = resolve_selector(admin, &selector).await?;
let (configs, bytes) = tokio::task::spawn_blocking(move || {
let bytes = encode_config_archive(&configs)?;
Ok::<_, String>((configs, bytes))
})
.await
.map_err(|error| format!("Failed to encode Nacos configuration archive: {error}"))??;
tokio::fs::write(destination, bytes)
.await
.map_err(|error| format!("Failed to write Nacos configuration archive {}: {error}", destination.display()))?;
let items = configs
.iter()
.map(|config| NacosBatchItemResult {
namespace: config.namespace.clone(),
group: config.group.clone(),
data_id: config.data_id.clone(),
status: "exported".to_string(),
message: None,
})
.collect();
Ok(NacosBatchReport {
operation_id: uuid::Uuid::new_v4().to_string(),
plan_hash: None,
total: configs.len() as u64,
created: 0,
overwritten: 0,
skipped: 0,
failed: 0,
aborted: false,
partial: false,
cancelled: false,
items,
})
}
pub async fn preview_import(
admin: Arc<dyn NacosAdmin>,
target_namespace: &str,
archive_path: &Path,
) -> Result<NacosBatchPreview, String> {
let configs = read_archive(archive_path, target_namespace).await?;
preview_configs(admin, &configs).await
}
pub async fn apply_import(
admin: Arc<dyn NacosAdmin>,
target_namespace: &str,
archive_path: &Path,
operation_id: &str,
plan_hash: &str,
policy: &NacosConflictPolicy,
) -> Result<NacosBatchReport, String> {
let configs = read_archive(archive_path, target_namespace).await?;
apply_configs(admin, configs, operation_id, plan_hash, policy).await
}
pub async fn preview_transfer(
source_admin: Arc<dyn NacosAdmin>,
target_admin: Arc<dyn NacosAdmin>,
request: &NacosConfigTransferRequest,
) -> Result<NacosBatchPreview, String> {
let configs = transfer_configs(source_admin, request).await?;
preview_configs(target_admin, &configs).await
}
pub async fn apply_transfer(
source_admin: Arc<dyn NacosAdmin>,
target_admin: Arc<dyn NacosAdmin>,
request: &NacosConfigTransferRequest,
plan_hash: &str,
) -> Result<NacosBatchReport, String> {
let configs = transfer_configs(source_admin, request).await?;
apply_configs(target_admin, configs, &request.operation_id, plan_hash, &request.conflict_policy).await
}
pub async fn nacos_export_config_archive_core(
state: &AppState,
conn_id: &str,
selector: NacosConfigSelector,
destination: &Path,
) -> Result<NacosBatchReport, String> {
let admin = get_admin(state, conn_id).await?;
export_config_archive(admin, selector, destination).await
}
pub async fn nacos_preview_config_import_core(
state: &AppState,
conn_id: &str,
target_namespace: &str,
archive_path: &Path,
) -> Result<NacosBatchPreview, String> {
ensure_connection_writable(state, conn_id, "Import Nacos configurations").await?;
let admin = get_admin(state, conn_id).await?;
preview_import(admin, target_namespace, archive_path).await
}
pub async fn nacos_apply_config_import_core(
state: &AppState,
conn_id: &str,
target_namespace: &str,
archive_path: &Path,
operation_id: &str,
plan_hash: &str,
policy: &NacosConflictPolicy,
) -> Result<NacosBatchReport, String> {
ensure_connection_writable(state, conn_id, "Import Nacos configurations").await?;
let admin = get_admin(state, conn_id).await?;
apply_import(admin, target_namespace, archive_path, operation_id, plan_hash, policy).await
}
pub async fn nacos_preview_config_transfer_core(
state: &AppState,
request: &NacosConfigTransferRequest,
) -> Result<NacosBatchPreview, String> {
ensure_connection_writable(state, &request.target_connection_id, "Copy Nacos configurations").await?;
let source_admin = get_admin(state, &request.source_connection_id).await?;
let target_admin = get_admin(state, &request.target_connection_id).await?;
preview_transfer(source_admin, target_admin, request).await
}
pub async fn nacos_apply_config_transfer_core(
state: &AppState,
request: &NacosConfigTransferRequest,
plan_hash: &str,
) -> Result<NacosBatchReport, String> {
ensure_connection_writable(state, &request.target_connection_id, "Copy Nacos configurations").await?;
let source_admin = get_admin(state, &request.source_connection_id).await?;
let target_admin = get_admin(state, &request.target_connection_id).await?;
apply_transfer(source_admin, target_admin, request, plan_hash).await
}
async fn transfer_configs(
source_admin: Arc<dyn NacosAdmin>,
request: &NacosConfigTransferRequest,
) -> Result<Vec<NacosConfigUpsert>, String> {
let source = resolve_selector(source_admin, &request.source).await?;
source
.into_iter()
.map(|config| {
let content = config
.content
.ok_or_else(|| format!("Nacos configuration {}/{} has no content", config.group, config.data_id))?;
Ok(NacosConfigUpsert {
namespace: Some(request.target_namespace.clone()),
data_id: config.data_id,
group: config.group,
content,
config_type: config.config_type,
app_name: config.app_name,
desc: config.desc,
tags: config.tags,
})
})
.collect()
}
async fn resolve_selector(
admin: Arc<dyn NacosAdmin>,
selector: &NacosConfigSelector,
) -> Result<Vec<NacosConfigItem>, String> {
let keys = match selector.scope {
NacosConfigSelectionScope::Selected => selector
.keys
.iter()
.map(|key| NacosConfigKey {
namespace: Some(selector.namespace.clone()),
data_id: key.data_id.clone(),
group: key.group.clone(),
})
.collect(),
NacosConfigSelectionScope::Filtered | NacosConfigSelectionScope::Namespace => {
let mut query = selector.query.clone().unwrap_or(crate::nacos::types::NacosConfigQuery {
namespace: None,
group: None,
data_id: None,
app_name: None,
search: None,
page_no: None,
page_size: None,
});
query.namespace = Some(selector.namespace.clone());
if matches!(selector.scope, NacosConfigSelectionScope::Namespace) {
query.group = None;
query.data_id = None;
query.app_name = None;
query.search = None;
}
let mut keys = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut page_no = 1;
loop {
query.page_no = Some(page_no);
query.page_size = Some(PAGE_SIZE);
let page = admin.list_configs(query.clone()).await?;
let total = page.total_count;
let empty = page.items.is_empty();
let before = keys.len();
keys.extend(page.items.into_iter().filter_map(|item| {
let key = NacosConfigKey {
namespace: Some(selector.namespace.clone()),
data_id: item.data_id,
group: item.group,
};
seen.insert((key.namespace.clone(), key.group.clone(), key.data_id.clone())).then_some(key)
}));
if empty || total == 0 || keys.len() as u64 >= total {
break;
}
if keys.len() == before {
return Err(
"Nacos configuration pagination made no progress; the server repeated a page".to_string()
);
}
page_no = page_no
.checked_add(1)
.ok_or_else(|| "Nacos configuration pagination exceeded the supported page range".to_string())?;
}
keys
}
};
let mut seen = std::collections::HashSet::new();
let keys: Vec<_> = keys
.into_iter()
.filter(|key| seen.insert((key.namespace.clone(), key.group.clone(), key.data_id.clone())))
.collect();
let details = stream::iter(keys)
.map(|key| {
let admin = admin.clone();
async move { admin.get_config(key).await }
})
.buffer_unordered(DETAIL_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut configs = details.into_iter().collect::<Result<Vec<_>, _>>()?;
configs.sort_by(|left, right| {
(&left.namespace, &left.group, &left.data_id).cmp(&(&right.namespace, &right.group, &right.data_id))
});
Ok(configs)
}
async fn preview_configs(
admin: Arc<dyn NacosAdmin>,
configs: &[NacosConfigUpsert],
) -> Result<NacosBatchPreview, String> {
let mut items = Vec::with_capacity(configs.len());
let mut target_snapshots = Vec::with_capacity(configs.len());
let mut created = 0;
let mut conflicts = 0;
for config in configs {
let target = existing_config(admin.as_ref(), config).await?;
let status = if target.is_some() {
conflicts += 1;
"conflict"
} else {
created += 1;
"create"
};
target_snapshots.push(target);
items.push(NacosBatchPreviewItem {
namespace: config.namespace.clone().unwrap_or_default(),
group: config.group.clone(),
data_id: config.data_id.clone(),
status: status.to_string(),
message: None,
});
}
Ok(NacosBatchPreview {
plan_hash: plan_hash(configs, &target_snapshots),
total: configs.len() as u64,
created,
conflicts,
invalid: 0,
items,
})
}
async fn apply_configs(
admin: Arc<dyn NacosAdmin>,
configs: Vec<NacosConfigUpsert>,
operation_id: &str,
expected_plan_hash: &str,
policy: &NacosConflictPolicy,
) -> Result<NacosBatchReport, String> {
let preview = preview_configs(admin.clone(), &configs).await?;
if preview.plan_hash != expected_plan_hash {
return Err(
"NACOS_ERROR[stalePreview]: Nacos import/copy preview is stale; preview again before applying".to_string()
);
}
if matches!(policy, NacosConflictPolicy::Abort) && preview.conflicts > 0 {
return Ok(NacosBatchReport {
operation_id: operation_id.to_string(),
plan_hash: Some(preview.plan_hash),
total: configs.len() as u64,
created: 0,
overwritten: 0,
skipped: configs.len() as u64,
failed: 0,
aborted: true,
partial: false,
cancelled: false,
items: preview
.items
.into_iter()
.map(|item| NacosBatchItemResult {
namespace: item.namespace,
group: item.group,
data_id: item.data_id,
status: "aborted".to_string(),
message: Some("Conflict policy ABORT prevented all writes".to_string()),
})
.collect(),
});
}
let token = begin_operation(operation_id)?;
struct Guard(String);
impl Drop for Guard {
fn drop(&mut self) {
finish_operation(&self.0);
}
}
let _guard = Guard(operation_id.to_string());
let mut report = NacosBatchReport {
operation_id: operation_id.to_string(),
plan_hash: Some(preview.plan_hash),
total: configs.len() as u64,
created: 0,
overwritten: 0,
skipped: 0,
failed: 0,
aborted: false,
partial: false,
cancelled: false,
items: Vec::with_capacity(configs.len()),
};
for (config, preview_item) in configs.into_iter().zip(preview.items) {
if token.is_cancelled() {
report.cancelled = true;
break;
}
let conflict = preview_item.status == "conflict";
if conflict && matches!(policy, NacosConflictPolicy::Skip) {
report.skipped += 1;
report.items.push(batch_result(&config, "skipped", None));
continue;
}
match admin.publish_config(config.clone()).await {
Ok(()) if conflict => {
report.overwritten += 1;
report.items.push(batch_result(&config, "overwritten", None));
}
Ok(()) => {
report.created += 1;
report.items.push(batch_result(&config, "created", None));
}
Err(error) => {
report.failed += 1;
report.items.push(batch_result(&config, "failed", Some(error)));
}
}
}
report.partial =
report.cancelled || report.failed > 0 || report.created + report.overwritten + report.skipped < report.total;
Ok(report)
}
async fn existing_config(
admin: &dyn NacosAdmin,
config: &NacosConfigUpsert,
) -> Result<Option<NacosConfigItem>, String> {
let namespace = config.namespace.clone().unwrap_or_default();
let page = admin
.list_configs(crate::nacos::types::NacosConfigQuery {
namespace: Some(namespace.clone()),
group: Some(config.group.clone()),
data_id: Some(config.data_id.clone()),
app_name: None,
search: None,
page_no: Some(1),
page_size: Some(PAGE_SIZE),
})
.await?;
let exists = page.items.iter().any(|item| item.group == config.group && item.data_id == config.data_id);
if !exists {
return Ok(None);
}
admin
.get_config(NacosConfigKey {
namespace: Some(namespace),
data_id: config.data_id.clone(),
group: config.group.clone(),
})
.await
.map(Some)
}
fn plan_hash(configs: &[NacosConfigUpsert], targets: &[Option<NacosConfigItem>]) -> String {
let mut digest = Sha256::new();
digest.update(b"dbx-nacos-batch-plan-v1");
let mut entries = configs.iter().zip(targets.iter()).collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| {
(left.namespace.as_deref().unwrap_or_default(), left.group.as_str(), left.data_id.as_str()).cmp(&(
right.namespace.as_deref().unwrap_or_default(),
right.group.as_str(),
right.data_id.as_str(),
))
});
for (config, target) in entries {
hash_field(&mut digest, config.namespace.as_deref().unwrap_or_default());
hash_field(&mut digest, &config.group);
hash_field(&mut digest, &config.data_id);
hash_field(&mut digest, &config.content);
hash_field(&mut digest, config.config_type.as_deref().unwrap_or_default());
hash_field(&mut digest, config.app_name.as_deref().unwrap_or_default());
hash_field(&mut digest, config.desc.as_deref().unwrap_or_default());
hash_field(&mut digest, config.tags.as_deref().unwrap_or_default());
if let Some(target) = target {
digest.update([1]);
hash_field(&mut digest, target.md5.as_deref().unwrap_or_default());
hash_field(&mut digest, target.content.as_deref().unwrap_or_default());
} else {
digest.update([0]);
}
}
digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect()
}
fn hash_field(digest: &mut Sha256, value: &str) {
digest.update((value.len() as u64).to_be_bytes());
digest.update(value.as_bytes());
}
fn batch_result(config: &NacosConfigUpsert, status: &str, message: Option<String>) -> NacosBatchItemResult {
NacosBatchItemResult {
namespace: config.namespace.clone().unwrap_or_default(),
group: config.group.clone(),
data_id: config.data_id.clone(),
status: status.to_string(),
message,
}
}
async fn read_archive(path: &Path, target_namespace: &str) -> Result<Vec<NacosConfigUpsert>, String> {
let path = path.to_path_buf();
let target_namespace = target_namespace.to_string();
tokio::task::spawn_blocking(move || {
let metadata = std::fs::metadata(&path)
.map_err(|error| format!("Failed to inspect Nacos archive {}: {error}", path.display()))?;
if metadata.len() > MAX_ARCHIVE_BYTES {
return Err("Nacos archive exceeds the 100 MiB limit".to_string());
}
let bytes = std::fs::read(&path)
.map_err(|error| format!("Failed to read Nacos archive {}: {error}", path.display()))?;
decode_config_archive(&bytes, &target_namespace)
})
.await
.map_err(|error| format!("Failed to process Nacos configuration archive: {error}"))?
}
#[cfg(test)]
mod tests {
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::RwLock;
use async_trait::async_trait;
use super::*;
use crate::nacos::types::*;
#[derive(Default)]
struct MockAdmin {
configs: RwLock<HashMap<(String, String, String), NacosConfigItem>>,
failed_data_ids: RwLock<HashSet<String>>,
publish_count: AtomicUsize,
}
impl MockAdmin {
fn insert(&self, item: NacosConfigItem) {
self.configs
.write()
.unwrap()
.insert((item.namespace.clone(), item.group.clone(), item.data_id.clone()), item);
}
fn fail_publish(&self, data_id: &str) {
self.failed_data_ids.write().unwrap().insert(data_id.to_string());
}
}
#[async_trait]
impl NacosAdmin for MockAdmin {
async fn test_connection(&self) -> Result<NacosConnectionInfo, String> {
Err("unused".to_string())
}
async fn list_namespaces(&self) -> Result<Vec<NacosNamespaceInfo>, String> {
Err("unused".to_string())
}
async fn create_namespace(&self, _: NacosNamespaceCreate) -> Result<(), String> {
Err("unused".to_string())
}
async fn update_namespace(&self, _: NacosNamespaceUpdate) -> Result<(), String> {
Err("unused".to_string())
}
async fn list_configs(&self, query: NacosConfigQuery) -> Result<NacosConfigList, String> {
let namespace = query.namespace.unwrap_or_default();
let items: Vec<_> = self
.configs
.read()
.unwrap()
.values()
.filter(|item| item.namespace == namespace)
.filter(|item| query.group.as_ref().is_none_or(|group| item.group == *group))
.filter(|item| query.data_id.as_ref().is_none_or(|data_id| item.data_id == *data_id))
.cloned()
.collect();
Ok(NacosConfigList {
page_no: query.page_no.unwrap_or(1),
page_size: query.page_size.unwrap_or(PAGE_SIZE),
total_count: items.len() as u64,
items,
})
}
async fn search_config_content_page(
&self,
_: &str,
_: &str,
_: u32,
_: u32,
) -> Result<Option<NacosConfigList>, String> {
Err("unused".to_string())
}
async fn get_config(&self, key: NacosConfigKey) -> Result<NacosConfigItem, String> {
self.configs
.read()
.unwrap()
.get(&(key.namespace.unwrap_or_default(), key.group, key.data_id))
.cloned()
.ok_or_else(|| "not found".to_string())
}
async fn publish_config(&self, req: NacosConfigUpsert) -> Result<(), String> {
if self.failed_data_ids.read().unwrap().contains(&req.data_id) {
return Err("simulated publish failure".to_string());
}
self.publish_count.fetch_add(1, Ordering::SeqCst);
let namespace = req.namespace.unwrap_or_default();
self.insert(NacosConfigItem {
data_id: req.data_id,
group: req.group,
namespace,
app_name: req.app_name,
desc: req.desc,
tags: req.tags,
config_type: req.config_type,
md5: None,
encrypted_data_key: None,
content: Some(req.content),
});
Ok(())
}
async fn delete_config(&self, _: NacosConfigKey) -> Result<(), String> {
Err("unused".to_string())
}
async fn list_config_history(&self, _: NacosConfigHistoryQuery) -> Result<NacosConfigHistoryList, String> {
Err("unused".to_string())
}
async fn get_config_history(&self, _: NacosConfigHistoryKey) -> Result<NacosConfigItem, String> {
Err("unused".to_string())
}
async fn rollback_config(&self, _: NacosConfigRollbackRequest) -> Result<(), String> {
Err("unused".to_string())
}
async fn get_rnacos_console_captcha(&self) -> Result<NacosRNacosConsoleCaptcha, String> {
Err("unused".to_string())
}
async fn login_rnacos_console(&self, _: Option<String>) -> Result<(), String> {
Err("unused".to_string())
}
async fn list_services(&self, _: NacosServiceQuery) -> Result<NacosServiceList, String> {
Err("unused".to_string())
}
async fn list_instances(&self, _: NacosInstanceQuery) -> Result<Vec<NacosInstanceInfo>, String> {
Err("unused".to_string())
}
async fn update_instance(&self, _: NacosInstanceUpdate) -> Result<(), String> {
Err("unused".to_string())
}
async fn raw_request(&self, _: NacosRawRequest) -> Result<NacosRawResponse, String> {
Err("unused".to_string())
}
}
fn upsert(data_id: &str, content: &str) -> NacosConfigUpsert {
NacosConfigUpsert {
namespace: Some("target".to_string()),
data_id: data_id.to_string(),
group: "DEFAULT_GROUP".to_string(),
content: content.to_string(),
config_type: Some("text".to_string()),
app_name: None,
desc: None,
tags: None,
}
}
fn existing(data_id: &str, content: &str) -> NacosConfigItem {
NacosConfigItem {
data_id: data_id.to_string(),
group: "DEFAULT_GROUP".to_string(),
namespace: "target".to_string(),
app_name: None,
desc: None,
tags: None,
config_type: Some("text".to_string()),
md5: Some(format!("md5-{content}")),
encrypted_data_key: None,
content: Some(content.to_string()),
}
}
async fn preview_hash(admin: Arc<MockAdmin>, configs: &[NacosConfigUpsert]) -> String {
preview_configs(admin, configs).await.unwrap().plan_hash
}
#[tokio::test]
async fn stale_preview_is_rejected_before_any_publish() {
let admin = Arc::new(MockAdmin::default());
let configs = vec![upsert("app", "new")];
let hash = preview_hash(admin.clone(), &configs).await;
admin.insert(existing("app", "concurrent"));
let error = apply_configs(
admin.clone(),
configs,
&uuid::Uuid::new_v4().to_string(),
&hash,
&NacosConflictPolicy::Overwrite,
)
.await
.unwrap_err();
assert!(error.contains("stalePreview"));
assert_eq!(admin.publish_count.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn apply_accepts_preview_when_concurrent_source_reads_complete_in_another_order() {
let admin = Arc::new(MockAdmin::default());
let preview_configs = vec![upsert("first", "one"), upsert("second", "two")];
let hash = preview_hash(admin.clone(), &preview_configs).await;
// `resolve_selector` fetches details concurrently, so the same selected
// configurations can reach apply in a different completion order.
let apply_configs_in_completion_order = vec![upsert("second", "two"), upsert("first", "one")];
let report = apply_configs(
admin.clone(),
apply_configs_in_completion_order,
&uuid::Uuid::new_v4().to_string(),
&hash,
&NacosConflictPolicy::Overwrite,
)
.await
.unwrap();
assert_eq!(report.created, 2);
assert_eq!(admin.publish_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn abort_policy_writes_nothing_when_any_conflict_exists() {
let admin = Arc::new(MockAdmin::default());
admin.insert(existing("conflict", "old"));
let configs = vec![upsert("conflict", "new"), upsert("fresh", "value")];
let hash = preview_hash(admin.clone(), &configs).await;
let report = apply_configs(
admin.clone(),
configs,
&uuid::Uuid::new_v4().to_string(),
&hash,
&NacosConflictPolicy::Abort,
)
.await
.unwrap();
assert!(report.aborted);
assert_eq!(report.skipped, 2);
assert_eq!(admin.publish_count.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn skip_and_overwrite_policies_report_per_item_outcomes() {
let skip_admin = Arc::new(MockAdmin::default());
skip_admin.insert(existing("conflict", "old"));
let configs = vec![upsert("conflict", "new"), upsert("fresh", "value")];
let skip_hash = preview_hash(skip_admin.clone(), &configs).await;
let skipped = apply_configs(
skip_admin.clone(),
configs.clone(),
&uuid::Uuid::new_v4().to_string(),
&skip_hash,
&NacosConflictPolicy::Skip,
)
.await
.unwrap();
assert_eq!((skipped.created, skipped.skipped, skipped.overwritten), (1, 1, 0));
assert_eq!(skip_admin.publish_count.load(Ordering::SeqCst), 1);
let overwrite_admin = Arc::new(MockAdmin::default());
overwrite_admin.insert(existing("conflict", "old"));
let overwrite_hash = preview_hash(overwrite_admin.clone(), &configs).await;
let overwritten = apply_configs(
overwrite_admin.clone(),
configs,
&uuid::Uuid::new_v4().to_string(),
&overwrite_hash,
&NacosConflictPolicy::Overwrite,
)
.await
.unwrap();
assert_eq!((overwritten.created, overwritten.skipped, overwritten.overwritten), (1, 0, 1));
assert_eq!(overwrite_admin.publish_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn publish_failure_keeps_successes_and_marks_report_partial() {
let admin = Arc::new(MockAdmin::default());
admin.fail_publish("bad");
let configs = vec![upsert("good", "value"), upsert("bad", "value")];
let hash = preview_hash(admin.clone(), &configs).await;
let report = apply_configs(
admin.clone(),
configs,
&uuid::Uuid::new_v4().to_string(),
&hash,
&NacosConflictPolicy::Overwrite,
)
.await
.unwrap();
assert_eq!((report.created, report.failed), (1, 1));
assert!(report.partial);
assert_eq!(admin.publish_count.load(Ordering::SeqCst), 1);
}
}

View File

@ -59,7 +59,8 @@ pub struct NacosAdminConfig {
pub context_path: String,
/// Optional r-nacos authenticated-console address. This is separate from
/// the OpenAPI server address because r-nacos exposes console-only APIs
/// (including config history) on its console service, normally port 10848.
/// (including config history plus config type and description metadata) on
/// its console service, normally port 10848.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub rnacos_console_addr: String,
/// `None` preserves legacy records where supplying a console address

View File

@ -239,7 +239,7 @@ impl NacosOpenApiAdmin {
fn rnacos_console_endpoint(&self, path: &str) -> Result<String, String> {
if self.cfg.rnacos_console_addr.is_empty() {
return Err(
"r-nacos config history requires an r-nacos console URL (the independent console service, normally port 10848)"
"r-nacos configuration metadata and history require an r-nacos console URL (the independent console service, normally port 10848)"
.to_string(),
);
}
@ -273,7 +273,7 @@ impl NacosOpenApiAdmin {
if captcha.required {
return Err(classified_error(
"rnacosConsoleCaptchaRequired",
"r-nacos console requires a CAPTCHA before configuration history can be accessed",
"r-nacos console requires a CAPTCHA before configuration metadata or history can be accessed",
));
}
self.login_rnacos_console_with_captcha(None).await
@ -386,6 +386,63 @@ impl NacosOpenApiAdmin {
}
}
async fn get_rnacos_console_json_without_login(
&self,
path: &str,
query: &[(String, String)],
) -> Result<Value, String> {
let response = self
.http
.get(self.rnacos_console_endpoint(path)?)
.query(query)
.send()
.await
.map_err(|e| format!("r-nacos console request to {path} failed: {e}"))?;
let response = error_for_status(response, path).await?;
let value = response_json_or_text(response).await?;
match value.get("success").and_then(Value::as_bool) {
Some(true) => Ok(value),
Some(false) => Err(format!("r-nacos console {path} failed: {}", rnacos_console_error_detail(&value))),
None => Err(format!(
"r-nacos console {path} returned an unexpected unauthenticated response instead of API JSON"
)),
}
}
/// Metadata reads also support `RNACOS_ENABLE_NO_AUTH_CONSOLE=true`.
/// Once an authenticated session exists, use it directly. Before login,
/// first try the endpoint without a token and only start the CAPTCHA-aware
/// login flow when the console actually rejects the anonymous request.
async fn get_rnacos_console_metadata_json(
&self,
path: &str,
query: Vec<(String, String)>,
) -> Result<Value, String> {
let has_valid_token = self
.rnacos_console_session
.lock()
.await
.token
.as_ref()
.is_some_and(|token| token.expires_at > Instant::now() + Duration::from_secs(30));
if has_valid_token {
return self.get_rnacos_console_json(path, query).await;
}
match self.get_rnacos_console_json_without_login(path, &query).await {
Ok(value) => Ok(value),
Err(anonymous_error) if self.cfg.has_effective_rnacos_console_credentials() => self
.get_rnacos_console_json(path, query)
.await
.map_err(|authenticated_error| {
format!(
"{authenticated_error}; anonymous r-nacos console metadata request also failed: {anonymous_error}"
)
}),
Err(error) => Err(error),
}
}
async fn list_rnacos_console_namespaces(&self) -> Result<Vec<NacosNamespaceInfo>, String> {
let value = self.get_rnacos_console_json("/rnacos/api/console/v2/namespaces/list", Vec::new()).await?;
Ok(parse_namespaces(value))
@ -456,6 +513,57 @@ impl NacosOpenApiAdmin {
.await
}
/// r-nacos's Nacos-compatible configuration API returns the raw content
/// only. The independent console keeps the user-facing metadata such as
/// `configType` and `desc`, so enrich that compatibility response when a
/// console has been configured. Metadata is deliberately best-effort for
/// network/version failures. A CAPTCHA requirement is propagated so the UI
/// can authenticate once and retry the interrupted list/detail request.
async fn enrich_rnacos_config_metadata(&self, mut config: NacosConfigItem) -> Result<NacosConfigItem, String> {
if !self.is_explicit_rnacos()
|| (config.config_type.is_some() && config.desc.is_some())
|| self.cfg.rnacos_console_addr.is_empty()
{
return Ok(config);
}
let metadata = self
.get_rnacos_console_metadata_json(
"/rnacos/api/console/v2/config/info",
vec![
("tenant".to_string(), config.namespace.clone()),
("dataId".to_string(), config.data_id.clone()),
("group".to_string(), config.group.clone()),
],
)
.await
.map(|value| {
parse_config_detail(value, config.data_id.clone(), config.group.clone(), config.namespace.clone())
});
let metadata = match metadata {
Ok(metadata) => metadata,
Err(error) if error.contains("[rnacosConsoleCaptchaRequired]") => return Err(error),
// Metadata remains optional. Network, permission, or version
// mismatches must not hide configuration content obtained from
// the compatible OpenAPI.
Err(_) => return Ok(config),
};
if config.desc.is_none() {
config.desc = metadata.desc;
}
if config.config_type.is_none() {
config.config_type = metadata.config_type;
}
if config.md5.is_none() {
config.md5 = metadata.md5;
}
if config.content.is_none() {
config.content = metadata.content;
}
Ok(config)
}
async fn get_server_state(&self) -> Result<NacosServerStateProbe, String> {
let mut errors = Vec::new();
// r-nacos implements the Nacos client OpenAPI but not the console state endpoints.
@ -607,21 +715,33 @@ impl NacosOpenApiAdmin {
let group = group.unwrap_or_default();
let app_name = app_name_filter.unwrap_or_default();
let scan_page_size = page_size.max(self.cfg.page_size).clamp(100, 500);
let max_scan_pages = 10;
let mut matched = Vec::new();
let mut seen = HashSet::new();
let mut current_page = 1;
while current_page <= max_scan_pages {
loop {
let value =
self.get_config_list_value(&namespace, "", &group, &app_name, current_page, scan_page_size).await?;
let list = parse_config_list(value, namespace.clone(), current_page, scan_page_size);
matched.extend(list.items.into_iter().filter(|item| item.data_id.to_lowercase().contains(&filter)));
let total_count = list.total_count;
let empty = list.items.is_empty();
let before = seen.len();
for item in list.items {
let identity = (item.namespace.clone(), item.group.clone(), item.data_id.clone());
if seen.insert(identity) && item.data_id.to_lowercase().contains(&filter) {
matched.push(item);
}
}
let scanned = u64::from(current_page) * u64::from(scan_page_size);
if scanned >= list.total_count || list.total_count == 0 {
if empty || total_count == 0 || seen.len() as u64 >= total_count {
break;
}
current_page += 1;
if seen.len() == before {
return Err("Nacos configuration pagination made no progress; the server repeated a page".to_string());
}
current_page = current_page
.checked_add(1)
.ok_or_else(|| "Nacos configuration pagination exceeded the supported page range".to_string())?;
}
let total_count = matched.len() as u64;
@ -633,7 +753,12 @@ impl NacosOpenApiAdmin {
async fn enrich_missing_config_formats(&self, mut list: NacosConfigList) -> NacosConfigList {
for item in list.items.iter_mut() {
if item.config_type.is_some() {
// Normal Nacos lists already carry descriptions when available.
// r-nacos's compatibility list does not carry either the type or
// description, and its configured console can supply both.
let needs_rnacos_description =
self.is_explicit_rnacos() && item.desc.is_none() && !self.cfg.rnacos_console_addr.is_empty();
if item.config_type.is_some() && !needs_rnacos_description {
continue;
}
let detail = self
@ -644,7 +769,12 @@ impl NacosOpenApiAdmin {
})
.await;
if let Ok(detail) = detail {
item.config_type = detail.config_type;
if item.config_type.is_none() {
item.config_type = detail.config_type;
}
if item.desc.is_none() {
item.desc = detail.desc;
}
}
}
list
@ -713,6 +843,18 @@ fn qualified_nacos_service_name(service_name: &str, group_name: Option<&str>) ->
}
}
fn content_search_endpoint_is_unsupported(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
lower.contains("returned 404")
|| lower.contains("returned 405")
|| lower.contains("returned 410")
// Spring-based gateways and some Nacos distributions wrap an
// unmapped admin route as HTTP 500 instead of returning 404.
|| lower.contains("no static resource")
|| lower.contains("unsupported content search")
|| lower.contains("unsupportedcontentsearch")
}
#[async_trait]
impl NacosAdmin for NacosOpenApiAdmin {
async fn test_connection(&self) -> Result<NacosConnectionInfo, String> {
@ -810,6 +952,9 @@ impl NacosAdmin for NacosOpenApiAdmin {
let mut v1_form =
vec![("namespaceName".to_string(), namespace_name), ("namespaceDesc".to_string(), namespace_desc)];
if let Some(namespace_id) = namespace_id {
// Nacos 3.x Console API uses `customNamespaceId`; passing only
// `namespaceId` is accepted but ignored and causes a generated UUID.
v3_form.push(("customNamespaceId".to_string(), namespace_id.clone()));
v3_form.push(("namespaceId".to_string(), namespace_id.clone()));
v1_form.push(("customNamespaceId".to_string(), namespace_id.clone()));
v1_form.push(("namespaceId".to_string(), namespace_id));
@ -906,6 +1051,88 @@ impl NacosAdmin for NacosOpenApiAdmin {
Ok(parsed)
}
async fn search_config_content_page(
&self,
namespace: &str,
query: &str,
page_no: u32,
page_size: u32,
) -> Result<Option<NacosConfigList>, String> {
if self.is_explicit_rnacos() || matches!(self.cfg.version_mode, Some(NacosVersionMode::V2)) {
return Ok(None);
}
let page_no = page_no.max(1);
let page_size = page_size.clamp(1, 500);
// The native endpoint interprets `configDetail` using Nacos wildcard
// syntax. Callers only reach this fast path for wildcard-safe literal
// queries, so wrapping the term gives us contains semantics; every
// candidate is still fetched and verified with Rust `str::contains`.
let config_detail = format!("*{query}*");
let attempts = [
(
"/v3/admin/cs/config/list",
vec![
("configDetail".to_string(), config_detail.clone()),
("search".to_string(), "blur".to_string()),
("namespaceId".to_string(), namespace.to_string()),
("pageNo".to_string(), page_no.to_string()),
("pageSize".to_string(), page_size.to_string()),
],
),
(
"/v3/console/cs/config/searchDetail",
vec![
("configDetail".to_string(), config_detail),
("search".to_string(), "blur".to_string()),
("namespaceId".to_string(), namespace.to_string()),
("pageNo".to_string(), page_no.to_string()),
("pageSize".to_string(), page_size.to_string()),
],
),
];
let mut unsupported = false;
for (path, params) in attempts {
if !self.api_path_allowed(path) {
unsupported = true;
continue;
}
let response = match self.request(reqwest::Method::GET, path, params, None, None).await {
Ok(response) => response,
Err(error) if content_search_endpoint_is_unsupported(&error) => {
unsupported = true;
continue;
}
Err(error) => return Err(error),
};
let status = response.status();
if matches!(
status,
reqwest::StatusCode::NOT_FOUND | reqwest::StatusCode::METHOD_NOT_ALLOWED | reqwest::StatusCode::GONE
) {
unsupported = true;
continue;
}
let response = match error_for_status(response, path).await {
Ok(response) => response,
Err(error) if content_search_endpoint_is_unsupported(&error) => {
unsupported = true;
continue;
}
Err(error) => return Err(error),
};
let value = response_json_or_text(response).await?;
return Ok(Some(parse_config_list(value, namespace.to_string(), page_no, page_size)));
}
if unsupported {
Ok(None)
} else {
Err(classified_error(
"unsupportedContentSearch",
"No compatible Nacos content-search endpoint is available",
))
}
}
async fn get_config(&self, key: NacosConfigKey) -> Result<NacosConfigItem, String> {
let namespace = self.namespace(key.namespace.as_deref());
let v3_params = vec![
@ -936,9 +1163,10 @@ impl NacosAdmin for NacosOpenApiAdmin {
let text =
resp.text().await.map_err(|e| format!("Failed to read Nacos config response: {e}"))?;
if let Ok(value) = serde_json::from_str::<Value>(&text) {
return Ok(parse_config_detail(value, key.data_id, key.group, namespace));
let detail = parse_config_detail(value, key.data_id, key.group, namespace);
return self.enrich_rnacos_config_metadata(detail).await;
}
return Ok(NacosConfigItem {
let detail = NacosConfigItem {
data_id: key.data_id,
group: key.group,
namespace,
@ -949,11 +1177,13 @@ impl NacosAdmin for NacosOpenApiAdmin {
md5: None,
encrypted_data_key: None,
content: Some(text),
});
};
return self.enrich_rnacos_config_metadata(detail).await;
}
Ok(resp) => {
let value = response_json_or_text(resp).await?;
return Ok(parse_config_detail(value, key.data_id, key.group, namespace));
let detail = parse_config_detail(value, key.data_id, key.group, namespace);
return self.enrich_rnacos_config_metadata(detail).await;
}
Err(err) => errors.push(err),
},
@ -1605,7 +1835,7 @@ fn parse_config_list(value: Value, namespace: String, page_no: u32, page_size: u
group: string_field(&item, &["group", "groupName"]),
namespace: string_field(&item, &["tenant", "namespaceId"]).if_empty(&namespace),
app_name: optional_string_field(&item, &["appName", "app_name"]),
desc: optional_string_field(&item, &["desc", "description"]),
desc: optional_string_field(&item, &["desc", "description", "configDesc", "config_desc"]),
tags: optional_string_field(&item, &["tags", "configTags", "config_tags"]),
config_type: config_format_for_item(&item),
md5: optional_string_field(&item, &["md5"]),
@ -1623,12 +1853,13 @@ fn parse_config_detail(value: Value, data_id: String, group: String, namespace:
group: string_field(data, &["group", "groupName"]).if_empty(&group),
namespace: string_field(data, &["tenant", "namespaceId"]).if_empty(&namespace),
app_name: optional_string_field(data, &["appName", "app_name"]),
desc: optional_string_field(data, &["desc", "description"]),
desc: optional_string_field(data, &["desc", "description", "configDesc", "config_desc"]),
tags: optional_string_field(data, &["tags", "configTags", "config_tags"]),
config_type: config_format_for_item(data).or_else(|| infer_config_format(&data_id)),
md5: optional_string_field(data, &["md5"]),
encrypted_data_key: optional_string_field(data, &["encryptedDataKey"]),
content: optional_string_field(data, &["content"]).or_else(|| value.as_str().map(str::to_string)),
content: optional_string_field(data, &["content", "value", "configValue", "config_value"])
.or_else(|| value.as_str().map(str::to_string)),
}
}
@ -2031,6 +2262,15 @@ mod tests {
socket.write_all(response.as_bytes()).await.unwrap();
}
async fn write_text_response(socket: &mut tokio::net::TcpStream, body: &str) {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
socket.write_all(response.as_bytes()).await.unwrap();
}
async fn write_json_response_with_captcha_token(socket: &mut tokio::net::TcpStream, body: &str) {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nCaptcha-Token: 1234567890abcdeffedcba0987654321\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
@ -2112,6 +2352,95 @@ mod tests {
server.await.unwrap();
}
#[tokio::test]
async fn v3_console_namespace_creation_uses_custom_namespace_id() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert_eq!(request.split_whitespace().next(), Some("POST"));
assert_eq!(request.split_whitespace().nth(1), Some("/nacos/v3/console/core/namespace"));
assert!(request.contains("customNamespaceId=team-dev"));
write_json_response(&mut socket, r#"{"code":0,"message":"success","data":true}"#).await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.context_path = "/nacos".to_string();
config.version_mode = Some(NacosVersionMode::V3);
let admin = NacosOpenApiAdmin::new(config).unwrap();
admin
.create_namespace(NacosNamespaceCreate {
namespace_id: Some("team-dev".to_string()),
namespace_name: "Team Development".to_string(),
namespace_desc: Some("Development environment".to_string()),
})
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn v2_namespace_creation_uses_legacy_custom_namespace_id() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert_eq!(request.split_whitespace().next(), Some("POST"));
assert_eq!(request.split_whitespace().nth(1), Some("/nacos/v1/console/namespaces"));
assert!(request.contains("customNamespaceId=team-v2"));
write_json_response(&mut socket, "true").await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.context_path = "/nacos".to_string();
config.version_mode = Some(NacosVersionMode::V2);
let admin = NacosOpenApiAdmin::new(config).unwrap();
admin
.create_namespace(NacosNamespaceCreate {
namespace_id: Some("team-v2".to_string()),
namespace_name: "Team V2".to_string(),
namespace_desc: None,
})
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn rnacos_namespace_creation_falls_back_to_nacos_compatible_v1_route() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
for expected_path in ["/v3/console/core/namespace", "/v3/console/core/namespace/create"] {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert_eq!(request.split_whitespace().nth(1), Some(expected_path));
write_not_found_response(&mut socket).await;
}
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert_eq!(request.split_whitespace().nth(1), Some("/v1/console/namespaces"));
assert!(request.contains("customNamespaceId=team-rnacos"));
write_json_response(&mut socket, "true").await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.implementation = Some(NacosImplementation::RNacos);
let admin = NacosOpenApiAdmin::new(config).unwrap();
admin
.create_namespace(NacosNamespaceCreate {
namespace_id: Some("team-rnacos".to_string()),
namespace_name: "Team r-nacos".to_string(),
namespace_desc: None,
})
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn version_mode_auto_falls_back_from_v3_to_v1_config_paths() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
@ -2171,6 +2500,276 @@ mod tests {
server.await.unwrap();
}
#[tokio::test]
async fn rnacos_config_detail_enriches_raw_openapi_content_with_console_metadata() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
for expected_path in ["/v3/console/cs/config", "/v3/console/cs/config/detail"] {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with(expected_path));
write_not_found_response(&mut socket).await;
}
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_text_response(&mut socket, "cloud_providers: {}\n").await;
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert!(request.split_whitespace().nth(1).unwrap().starts_with("/rnacos/api/console/v2/config/info?"));
assert!(request.contains("tenant=ops"));
assert!(request.contains("dataId=qilong-test"));
assert!(request.contains("group=qilong-test"));
assert!(request.to_ascii_lowercase().contains("token: console-token"));
write_json_response(
&mut socket,
r#"{"success":true,"data":{"value":"cloud_providers: {}\n","md5":"abc123","configType":"YAML","desc":"r-nacos description"}}"#,
)
.await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.implementation = Some(NacosImplementation::RNacos);
config.rnacos_console_addr = format!("http://{address}");
config.rnacos_console_auth = crate::nacos::config::NacosRNacosConsoleAuth::UsernamePassword {
username: "admin".to_string(),
password: "admin".to_string(),
};
let admin = NacosOpenApiAdmin::new(config).unwrap();
admin.rnacos_console_session.lock().await.token = Some(RNacosConsoleToken {
token: "console-token".to_string(),
expires_at: Instant::now() + Duration::from_secs(300),
});
let detail = admin
.get_config(NacosConfigKey {
namespace: Some("ops".to_string()),
data_id: "qilong-test".to_string(),
group: "qilong-test".to_string(),
})
.await
.unwrap();
assert_eq!(detail.content.as_deref(), Some("cloud_providers: {}\n"));
assert_eq!(detail.config_type.as_deref(), Some("yaml"));
assert_eq!(detail.desc.as_deref(), Some("r-nacos description"));
assert_eq!(detail.md5.as_deref(), Some("abc123"));
server.await.unwrap();
}
#[tokio::test]
async fn rnacos_config_metadata_supports_no_auth_console() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
for expected_path in ["/v3/console/cs/config", "/v3/console/cs/config/detail"] {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with(expected_path));
write_not_found_response(&mut socket).await;
}
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_text_response(&mut socket, "cloud_providers: {}\n").await;
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert!(request.split_whitespace().nth(1).unwrap().starts_with("/rnacos/api/console/v2/config/info?"));
assert!(!request.to_ascii_lowercase().contains("\ntoken:"));
write_json_response(
&mut socket,
r#"{"success":true,"data":{"configType":"YAML","desc":"anonymous console metadata"}}"#,
)
.await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.implementation = Some(NacosImplementation::RNacos);
config.rnacos_console_addr = format!("http://{address}");
let admin = NacosOpenApiAdmin::new(config).unwrap();
let detail = admin
.get_config(NacosConfigKey {
namespace: Some("ops".to_string()),
data_id: "qilong-test".to_string(),
group: "qilong-test".to_string(),
})
.await
.unwrap();
assert_eq!(detail.config_type.as_deref(), Some("yaml"));
assert_eq!(detail.desc.as_deref(), Some("anonymous console metadata"));
server.await.unwrap();
}
#[tokio::test]
async fn rnacos_config_metadata_propagates_captcha_requirement_for_ui_retry() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
for expected_path in ["/v3/console/cs/config", "/v3/console/cs/config/detail"] {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with(expected_path));
write_not_found_response(&mut socket).await;
}
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_text_response(&mut socket, "cloud_providers: {}\n").await;
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/rnacos/api/console/v2/config/info?"));
write_text_response(&mut socket, "<html><body>login required</body></html>").await;
let (mut socket, _) = listener.accept().await.unwrap();
assert_eq!(read_request_target(&mut socket).await, "/rnacos/api/console/v2/login/captcha");
write_json_response_with_captcha_token(&mut socket, r#"{"success":true,"data":"captcha-image"}"#).await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.implementation = Some(NacosImplementation::RNacos);
config.rnacos_console_addr = format!("http://{address}");
config.rnacos_console_auth = crate::nacos::config::NacosRNacosConsoleAuth::UsernamePassword {
username: "admin".to_string(),
password: "admin".to_string(),
};
let admin = NacosOpenApiAdmin::new(config).unwrap();
let error = admin
.get_config(NacosConfigKey {
namespace: Some("ops".to_string()),
data_id: "qilong-test".to_string(),
group: "qilong-test".to_string(),
})
.await
.unwrap_err();
assert!(error.contains("NACOS_ERROR[rnacosConsoleCaptchaRequired]"));
server.await.unwrap();
}
#[tokio::test]
async fn rnacos_config_list_enriches_type_and_description_from_console() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v3/console/cs/config/list?"));
write_not_found_response(&mut socket).await;
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_json_response(
&mut socket,
r#"{"totalCount":1,"pageItems":[{"dataId":"qilong-test","group":"qilong-test","tenant":"ops"}]}"#,
)
.await;
for expected_path in ["/v3/console/cs/config", "/v3/console/cs/config/detail"] {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with(expected_path));
write_not_found_response(&mut socket).await;
}
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_text_response(&mut socket, "cloud_providers: {}\n").await;
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert!(request.split_whitespace().nth(1).unwrap().starts_with("/rnacos/api/console/v2/config/info?"));
write_json_response(
&mut socket,
r#"{"success":true,"data":{"configType":"YAML","desc":"r-nacos description"}}"#,
)
.await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.implementation = Some(NacosImplementation::RNacos);
config.rnacos_console_addr = format!("http://{address}");
config.rnacos_console_auth = crate::nacos::config::NacosRNacosConsoleAuth::UsernamePassword {
username: "admin".to_string(),
password: "admin".to_string(),
};
let admin = NacosOpenApiAdmin::new(config).unwrap();
admin.rnacos_console_session.lock().await.token = Some(RNacosConsoleToken {
token: "console-token".to_string(),
expires_at: Instant::now() + Duration::from_secs(300),
});
let list = admin
.list_configs(NacosConfigQuery {
namespace: Some("ops".to_string()),
group: None,
data_id: None,
app_name: None,
search: None,
page_no: Some(1),
page_size: Some(20),
})
.await
.unwrap();
assert_eq!(list.items.len(), 1);
assert_eq!(list.items[0].config_type.as_deref(), Some("yaml"));
assert_eq!(list.items[0].desc.as_deref(), Some("r-nacos description"));
server.await.unwrap();
}
#[tokio::test]
async fn rnacos_config_list_enriches_description_from_no_auth_console_when_type_is_inferred() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v3/console/cs/config/list?"));
write_not_found_response(&mut socket).await;
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_json_response(
&mut socket,
r#"{"totalCount":1,"pageItems":[{"dataId":"application.yaml","group":"DEFAULT_GROUP","tenant":"ops"}]}"#,
)
.await;
for expected_path in ["/v3/console/cs/config", "/v3/console/cs/config/detail"] {
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with(expected_path));
write_not_found_response(&mut socket).await;
}
let (mut socket, _) = listener.accept().await.unwrap();
assert!(read_request_target(&mut socket).await.starts_with("/v1/cs/configs?"));
write_text_response(&mut socket, "server:\n port: 8848\n").await;
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
assert!(request.split_whitespace().nth(1).unwrap().starts_with("/rnacos/api/console/v2/config/info?"));
assert!(!request.to_ascii_lowercase().contains("\ntoken:"));
write_json_response(
&mut socket,
r#"{"success":true,"data":{"configType":"YAML","desc":"anonymous list description"}}"#,
)
.await;
});
let mut config = test_admin_config(format!("http://{address}"));
config.implementation = Some(NacosImplementation::RNacos);
config.rnacos_console_addr = format!("http://{address}");
let admin = NacosOpenApiAdmin::new(config).unwrap();
let list = admin
.list_configs(NacosConfigQuery {
namespace: Some("ops".to_string()),
group: None,
data_id: None,
app_name: None,
search: None,
page_no: Some(1),
page_size: Some(20),
})
.await
.unwrap();
assert_eq!(list.items[0].config_type.as_deref(), Some("yaml"));
assert_eq!(list.items[0].desc.as_deref(), Some("anonymous list description"));
server.await.unwrap();
}
#[tokio::test]
async fn explicit_rnacos_lists_openapi_namespaces_without_console_url_when_health_is_unavailable() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
@ -2711,6 +3310,31 @@ mod tests {
assert_eq!(parsed.content.as_deref(), Some("hello"));
}
#[test]
fn parses_rnacos_console_config_info_metadata() {
let parsed = parse_config_detail(
serde_json::json!({
"success": true,
"data": {
"value": "cloud_providers: {}\n",
"md5": "abc123",
"configType": "YAML",
"desc": "r-nacos description"
}
}),
"qilong-test".to_string(),
"qilong-test".to_string(),
"ops".to_string(),
);
assert_eq!(parsed.data_id, "qilong-test");
assert_eq!(parsed.group, "qilong-test");
assert_eq!(parsed.namespace, "ops");
assert_eq!(parsed.content.as_deref(), Some("cloud_providers: {}\n"));
assert_eq!(parsed.config_type.as_deref(), Some("yaml"));
assert_eq!(parsed.desc.as_deref(), Some("r-nacos description"));
assert_eq!(parsed.md5.as_deref(), Some("abc123"));
}
#[test]
fn builds_v3_publish_form_fields() {
let (v3_form, v1_form) = build_publish_forms(
@ -3076,4 +3700,14 @@ mod tests {
assert_eq!(classify_nacos_error("404 Not Found"), "apiVersionMismatch");
assert_eq!(classify_nacos_error("connection refused"), "connectionFailed");
}
#[test]
fn treats_gateway_wrapped_missing_content_search_routes_as_unsupported() {
assert!(content_search_endpoint_is_unsupported(
r#"NACOS_ERROR[contextPathMismatch]: Nacos admin /v3/admin/cs/config/list returned 500 Internal Server Error: {"message":"No static resource v3/admin/cs/config/list."}"#
));
assert!(!content_search_endpoint_is_unsupported(
"NACOS_ERROR[requestFailed]: Nacos admin /v3/admin/cs/config/list returned 500 Internal Server Error: database unavailable"
));
}
}

View File

@ -6,9 +6,12 @@
//! still supports an older Rust toolchain. A future SDK adapter can implement
//! the same port without changing commands, routes, or frontend contracts.
pub mod archive;
pub mod batch;
pub mod config;
pub mod http;
pub mod port;
pub mod search;
pub mod service;
pub mod types;

View File

@ -9,6 +9,17 @@ pub trait NacosAdmin: Send + Sync {
async fn create_namespace(&self, req: NacosNamespaceCreate) -> Result<(), String>;
async fn update_namespace(&self, req: NacosNamespaceUpdate) -> Result<(), String>;
async fn list_configs(&self, query: NacosConfigQuery) -> Result<NacosConfigList, String>;
/// Returns `Ok(None)` only when the server does not expose a native
/// content-search endpoint. Authentication, throttling and transport
/// failures are returned as errors so callers never amplify them with a
/// more expensive full scan.
async fn search_config_content_page(
&self,
namespace: &str,
query: &str,
page_no: u32,
page_size: u32,
) -> Result<Option<NacosConfigList>, String>;
async fn get_config(&self, key: NacosConfigKey) -> Result<NacosConfigItem, String>;
async fn publish_config(&self, req: NacosConfigUpsert) -> Result<(), String>;
async fn delete_config(&self, key: NacosConfigKey) -> Result<(), String>;

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
use std::future::Future;
use crate::connection::AppState;
use crate::models::connection::DatabaseType;
use crate::nacos::types::*;
@ -48,6 +50,24 @@ pub async fn nacos_list_configs_core(
admin.list_configs(query).await
}
pub async fn nacos_search_config_content_core<F, Fut>(
state: &AppState,
conn_id: &str,
request: NacosContentSearchRequest,
on_progress: F,
) -> Result<NacosContentSearchResult, String>
where
F: Fn(NacosSearchProgress) -> Fut + Send + Sync,
Fut: Future<Output = ()> + Send,
{
let admin = get_admin(state, conn_id).await?;
crate::nacos::search::search_config_content(admin, request, on_progress).await
}
pub fn nacos_cancel_operation_core(operation_id: &str) -> bool {
crate::nacos::search::cancel_operation(operation_id)
}
pub async fn nacos_get_config_core(
state: &AppState,
conn_id: &str,
@ -155,7 +175,7 @@ pub async fn nacos_raw_request_core(
admin.raw_request(req).await
}
async fn get_admin(
pub(crate) async fn get_admin(
state: &AppState,
conn_id: &str,
) -> Result<std::sync::Arc<dyn crate::nacos::port::NacosAdmin>, String> {
@ -167,7 +187,7 @@ async fn get_admin(
state.nacos_registry.get_or_build_config(conn_id, admin_config).await
}
async fn ensure_connection_writable(state: &AppState, conn_id: &str, action: &str) -> Result<(), String> {
pub(crate) async fn ensure_connection_writable(state: &AppState, conn_id: &str, action: &str) -> Result<(), String> {
let cfg = state.configs.read().await.get(conn_id).cloned().ok_or("Connection not found")?;
if cfg.read_only {
Err(format!("{action} is blocked because this connection is read-only"))

View File

@ -133,6 +133,172 @@ pub struct NacosConfigList {
pub items: Vec<NacosConfigItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum NacosNamespaceScope {
#[default]
CurrentNamespace,
AllNamespaces,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosContentSearchRequest {
pub operation_id: String,
#[serde(default)]
pub namespace: Option<String>,
#[serde(default)]
pub scope: NacosNamespaceScope,
pub query: String,
#[serde(default)]
pub group: Option<String>,
#[serde(default)]
pub data_id: Option<String>,
#[serde(default)]
pub max_results: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosContentMatch {
pub namespace: String,
pub group: String,
pub data_id: String,
pub line_number: u64,
pub snippet: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosSearchFailure {
pub namespace: String,
pub error: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosSearchProgress {
pub operation_id: String,
pub phase: String,
#[serde(default)]
pub namespace: Option<String>,
pub scanned: u64,
#[serde(default)]
pub total: Option<u64>,
pub matched: u64,
#[serde(default)]
pub matches: Vec<NacosContentMatch>,
#[serde(default)]
pub failures: Vec<NacosSearchFailure>,
pub truncated: bool,
pub cancelled: bool,
pub done: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosContentSearchResult {
pub operation_id: String,
pub scanned: u64,
pub matches: Vec<NacosContentMatch>,
pub failures: Vec<NacosSearchFailure>,
pub truncated: bool,
pub cancelled: bool,
pub incomplete: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum NacosConfigSelectionScope {
Selected,
Filtered,
#[default]
Namespace,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosConfigSelector {
pub namespace: String,
#[serde(default)]
pub scope: NacosConfigSelectionScope,
#[serde(default)]
pub keys: Vec<NacosConfigKey>,
#[serde(default)]
pub query: Option<NacosConfigQuery>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum NacosConflictPolicy {
#[default]
Abort,
Skip,
Overwrite,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosBatchPreviewItem {
pub namespace: String,
pub group: String,
pub data_id: String,
pub status: String,
#[serde(default)]
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosBatchPreview {
pub plan_hash: String,
pub total: u64,
pub created: u64,
pub conflicts: u64,
pub invalid: u64,
pub items: Vec<NacosBatchPreviewItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosBatchItemResult {
pub namespace: String,
pub group: String,
pub data_id: String,
pub status: String,
#[serde(default)]
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosBatchReport {
pub operation_id: String,
#[serde(default)]
pub plan_hash: Option<String>,
pub total: u64,
pub created: u64,
pub overwritten: u64,
pub skipped: u64,
pub failed: u64,
pub aborted: bool,
pub partial: bool,
pub cancelled: bool,
pub items: Vec<NacosBatchItemResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NacosConfigTransferRequest {
pub operation_id: String,
pub source_connection_id: String,
pub target_connection_id: String,
pub source: NacosConfigSelector,
pub target_namespace: String,
#[serde(default)]
pub conflict_policy: NacosConflictPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NacosConfigUpsert {

View File

@ -1800,7 +1800,7 @@ mod tests {
comment: None,
});
let diffs = diff_indexes(&[source_index.clone()], &[target_index]);
let diffs = diff_indexes(std::slice::from_ref(&source_index), &[target_index]);
assert_eq!(diffs.len(), 1);
assert_eq!(diffs[0].diff_type, "modified");
assert_eq!(diffs[0].changes, vec![format!("columns: attr, attr2 → attr, attr2, {functional_key_part}")]);

View File

@ -201,8 +201,8 @@ pub async fn logout(State(state): State<Arc<WebState>>, req: Request<axum::body:
(StatusCode::OK, [("set-cookie", cookie.as_str())], Json(serde_json::json!({"ok": true}))).into_response()
}
fn extract_session_token<B>(req: &Request<B>) -> Option<String> {
let cookie_header = req.headers().get("cookie")?.to_str().ok()?;
pub fn session_token_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
let cookie_header = headers.get("cookie")?.to_str().ok()?;
for pair in cookie_header.split(';') {
let pair = pair.trim();
if let Some(value) = pair.strip_prefix("dbx_session=") {
@ -214,6 +214,10 @@ fn extract_session_token<B>(req: &Request<B>) -> Option<String> {
None
}
fn extract_session_token<B>(req: &Request<B>) -> Option<String> {
session_token_from_headers(req.headers())
}
pub async fn auth_middleware(
State(state): State<Arc<WebState>>,
req: Request<axum::body::Body>,

View File

@ -203,6 +203,7 @@ async fn main() {
sse_channels: RwLock::new(HashMap::new()),
table_import_channels: RwLock::new(HashMap::new()),
sql_file_executions: RwLock::new(HashMap::new()),
nacos_imports: RwLock::new(HashMap::new()),
login_rate_limit: tokio::sync::Mutex::new(state::LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),
});
@ -470,6 +471,13 @@ async fn main() {
.route("/nacos/instances/list", post(routes::nacos::list_instances))
.route("/nacos/instances/update", post(routes::nacos::update_instance))
.route("/nacos/raw", post(routes::nacos::raw_request))
.route("/nacos/configs/search", post(routes::nacos::search_config_content))
.route("/nacos/configs/search/cancel", post(routes::nacos::cancel_operation))
.route("/nacos/configs/export", post(routes::nacos::export_configs))
.route("/nacos/configs/import/preview", post(routes::nacos::preview_config_import))
.route("/nacos/configs/import/apply", post(routes::nacos::apply_config_import))
.route("/nacos/configs/copy/preview", post(routes::nacos::preview_config_transfer))
.route("/nacos/configs/copy/apply", post(routes::nacos::apply_config_transfer))
// MongoDB
.route("/mongo/list-databases", post(routes::mongo::list_databases))
.route("/mongo/list-collections", post(routes::mongo::list_collections))

View File

@ -495,6 +495,7 @@ mod tests {
sse_channels: RwLock::new(HashMap::new()),
table_import_channels: RwLock::new(HashMap::new()),
sql_file_executions: RwLock::new(HashMap::new()),
nacos_imports: RwLock::new(HashMap::new()),
login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),
});

View File

@ -836,6 +836,7 @@ mod tests {
sse_channels: RwLock::new(HashMap::new()),
table_import_channels: RwLock::new(HashMap::new()),
sql_file_executions: RwLock::new(HashMap::new()),
nacos_imports: RwLock::new(HashMap::new()),
login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),
});

View File

@ -1542,6 +1542,7 @@ mod tests {
sse_channels: RwLock::new(HashMap::new()),
sql_file_executions: RwLock::new(HashMap::new()),
table_import_channels: RwLock::new(HashMap::new()),
nacos_imports: RwLock::new(HashMap::new()),
login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),
});

View File

@ -1,10 +1,22 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use axum::extract::State;
use axum::body::Body;
use axum::extract::{Multipart, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::Response;
use axum::Json;
use futures::Stream;
use tokio::io::AsyncWriteExt;
use crate::error::AppError;
use crate::state::WebState;
use crate::state::{NacosImportContext, WebState};
const MAX_NACOS_ARCHIVE_BYTES: usize = 100 * 1024 * 1024;
const NACOS_IMPORT_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const NACOS_SEARCH_PROGRESS_BUFFER: usize = 16;
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
@ -104,6 +116,52 @@ pub(crate) struct RawReq {
req: dbx_core::nacos::NacosRawRequest,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ContentSearchReq {
connection_id: String,
req: dbx_core::nacos::NacosContentSearchRequest,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CancelOperationReq {
operation_id: String,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ConfigExportReq {
connection_id: String,
selector: dbx_core::nacos::NacosConfigSelector,
#[serde(default)]
file_name: Option<String>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ConfigImportApplyReq {
connection_id: String,
operation_id: String,
target_namespace: String,
archive_token: String,
plan_hash: String,
conflict_policy: dbx_core::nacos::NacosConflictPolicy,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ConfigTransferReq {
req: dbx_core::nacos::NacosConfigTransferRequest,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ConfigTransferApplyReq {
req: dbx_core::nacos::NacosConfigTransferRequest,
plan_hash: String,
}
pub async fn test_connection(
State(state): State<Arc<WebState>>,
Json(req): Json<ConnReq>,
@ -273,3 +331,410 @@ pub async fn raw_request(
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn search_config_content(
State(state): State<Arc<WebState>>,
Json(req): Json<ContentSearchReq>,
) -> Result<Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, AppError> {
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(NACOS_SEARCH_PROGRESS_BUFFER);
let stream = async_stream::stream! {
let progress_tx = tx.clone();
// Keep the channel open until the search future yields its terminal
// result so the biased receiver branch never wins with `None`.
let _channel_guard = tx;
let search = dbx_core::nacos::service::nacos_search_config_content_core(
&state.app,
&req.connection_id,
req.req,
move |progress| {
let progress_tx = progress_tx.clone();
let event = serde_json::json!({ "type": "progress", "progress": progress });
async move {
let _ = progress_tx.send(event.to_string()).await;
}
},
);
tokio::pin!(search);
loop {
tokio::select! {
biased;
data = rx.recv() => {
if let Some(data) = data {
yield Ok(Event::default().data(data));
}
}
result = &mut search => {
let event = match result {
Ok(result) => serde_json::json!({ "type": "result", "result": result }),
Err(error) => serde_json::json!({ "type": "error", "error": error }),
};
yield Ok(Event::default().data(event.to_string()));
break;
}
}
}
};
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
pub async fn cancel_operation(Json(req): Json<CancelOperationReq>) -> Json<serde_json::Value> {
let cancelled = dbx_core::nacos::service::nacos_cancel_operation_core(&req.operation_id);
Json(serde_json::json!({ "cancelled": cancelled }))
}
pub async fn export_configs(
State(state): State<Arc<WebState>>,
Json(req): Json<ConfigExportReq>,
) -> Result<Response, AppError> {
let export_dir = state.data_dir.join("tmp").join("nacos_export");
tokio::fs::create_dir_all(&export_dir).await.map_err(|error| AppError::from(error.to_string()))?;
let archive_path = export_dir.join(format!("{}.zip", uuid::Uuid::new_v4()));
let export_result = dbx_core::nacos::batch::nacos_export_config_archive_core(
&state.app,
&req.connection_id,
req.selector,
&archive_path,
)
.await;
if let Err(error) = export_result {
let _ = tokio::fs::remove_file(&archive_path).await;
return Err(AppError::from(error));
}
let archive = tokio::fs::read(&archive_path).await.map_err(|error| AppError::from(error.to_string()));
let _ = tokio::fs::remove_file(&archive_path).await;
let archive = archive?;
let file_name = sanitize_archive_file_name(req.file_name.as_deref().unwrap_or("nacos-configs.zip"));
let content_disposition = archive_content_disposition(&file_name);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/zip")
.header(header::CONTENT_DISPOSITION, content_disposition)
.body(Body::from(archive))
.map_err(|error| AppError::from(error.to_string()))
}
pub async fn preview_config_import(
State(state): State<Arc<WebState>>,
headers: HeaderMap,
mut multipart: Multipart,
) -> Result<Json<serde_json::Value>, AppError> {
let import_dir = nacos_import_dir(&state.data_dir);
tokio::fs::create_dir_all(&import_dir).await.map_err(|error| AppError::from(error.to_string()))?;
cleanup_expired_nacos_imports(&state, &import_dir, NACOS_IMPORT_TTL).await;
let archive_token = uuid::Uuid::new_v4().to_string();
let archive_path = import_dir.join(format!("{archive_token}.zip"));
let mut connection_id = None;
let mut target_namespace = None;
let mut uploaded = false;
while let Some(mut field) = multipart.next_field().await.map_err(|error| AppError::from(error.to_string()))? {
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"file" => {
if uploaded {
cleanup_nacos_import(&archive_path).await;
return Err(AppError::from("Only one Nacos archive may be uploaded".to_string()));
}
if let Err(error) = write_nacos_archive(&mut field, &archive_path).await {
cleanup_nacos_import(&archive_path).await;
return Err(error);
}
uploaded = true;
}
"connectionId" => {
connection_id = Some(field.text().await.map_err(|error| AppError::from(error.to_string()))?);
}
"targetNamespace" => {
target_namespace = Some(field.text().await.map_err(|error| AppError::from(error.to_string()))?);
}
_ => {}
}
}
let Some(connection_id) = connection_id.filter(|value| !value.trim().is_empty()) else {
cleanup_nacos_import(&archive_path).await;
return Err(AppError::from("connectionId is required".to_string()));
};
let Some(target_namespace) = target_namespace else {
cleanup_nacos_import(&archive_path).await;
return Err(AppError::from("targetNamespace is required".to_string()));
};
if !uploaded {
return Err(AppError::from("No Nacos archive uploaded".to_string()));
}
let preview = dbx_core::nacos::batch::nacos_preview_config_import_core(
&state.app,
&connection_id,
&target_namespace,
&archive_path,
)
.await;
let preview = match preview {
Ok(preview) => preview,
Err(error) => {
cleanup_nacos_import(&archive_path).await;
return Err(AppError::from(error));
}
};
let plan_hash = preview.plan_hash.clone();
let mut value = serde_json::to_value(preview).map_err(|error| AppError::from(error.to_string()))?;
let object = value.as_object_mut().ok_or_else(|| AppError::from("Invalid Nacos import preview".to_string()))?;
object.insert("archiveToken".to_string(), serde_json::Value::String(archive_token.clone()));
state.nacos_imports.write().await.insert(
archive_token,
NacosImportContext {
owner_session: crate::auth::session_token_from_headers(&headers),
connection_id,
target_namespace,
plan_hash,
},
);
Ok(Json(value))
}
pub async fn apply_config_import(
State(state): State<Arc<WebState>>,
headers: HeaderMap,
Json(req): Json<ConfigImportApplyReq>,
) -> Result<Json<dbx_core::nacos::NacosBatchReport>, AppError> {
let archive_path = match nacos_import_path(&state.data_dir, &req.archive_token) {
Ok(path) => path,
Err(error) => {
state.nacos_imports.write().await.remove(&req.archive_token);
return Err(error);
}
};
let import_context = match state.nacos_imports.write().await.remove(&req.archive_token) {
Some(context) => context,
None => {
cleanup_nacos_import(&archive_path).await;
return Err(AppError::from("Nacos import preview token is missing or expired".to_string()));
}
};
if let Err(error) = validate_nacos_import_context(
&import_context,
crate::auth::session_token_from_headers(&headers).as_deref(),
&req,
) {
cleanup_nacos_import(&archive_path).await;
return Err(error);
}
let result = dbx_core::nacos::batch::nacos_apply_config_import_core(
&state.app,
&req.connection_id,
&req.target_namespace,
&archive_path,
&req.operation_id,
&req.plan_hash,
&req.conflict_policy,
)
.await;
cleanup_nacos_import(&archive_path).await;
result.map(Json).map_err(AppError::from)
}
pub async fn preview_config_transfer(
State(state): State<Arc<WebState>>,
Json(req): Json<ConfigTransferReq>,
) -> Result<Json<dbx_core::nacos::NacosBatchPreview>, AppError> {
dbx_core::nacos::batch::nacos_preview_config_transfer_core(&state.app, &req.req)
.await
.map(Json)
.map_err(AppError::from)
}
pub async fn apply_config_transfer(
State(state): State<Arc<WebState>>,
Json(req): Json<ConfigTransferApplyReq>,
) -> Result<Json<dbx_core::nacos::NacosBatchReport>, AppError> {
dbx_core::nacos::batch::nacos_apply_config_transfer_core(&state.app, &req.req, &req.plan_hash)
.await
.map(Json)
.map_err(AppError::from)
}
fn nacos_import_dir(data_dir: &Path) -> PathBuf {
data_dir.join("tmp").join("nacos_import")
}
fn nacos_import_path(data_dir: &Path, archive_token: &str) -> Result<PathBuf, AppError> {
uuid::Uuid::parse_str(archive_token).map_err(|_| AppError::from("Invalid Nacos archive token".to_string()))?;
let path = nacos_import_dir(data_dir).join(format!("{archive_token}.zip"));
if !path.is_file() {
return Err(AppError::from("Nacos import archive is no longer available".to_string()));
}
let metadata = std::fs::metadata(&path).map_err(|error| AppError::from(error.to_string()))?;
if metadata
.modified()
.ok()
.is_some_and(|modified| archive_is_expired(modified, SystemTime::now(), NACOS_IMPORT_TTL))
{
let _ = std::fs::remove_file(&path);
return Err(AppError::from("Nacos import archive token has expired".to_string()));
}
Ok(path)
}
fn archive_is_expired(modified: SystemTime, now: SystemTime, max_age: Duration) -> bool {
now.duration_since(modified).is_ok_and(|age| age > max_age)
}
fn validate_nacos_import_context(
context: &NacosImportContext,
owner_session: Option<&str>,
req: &ConfigImportApplyReq,
) -> Result<(), AppError> {
if context.owner_session.as_deref() != owner_session
|| context.connection_id != req.connection_id
|| context.target_namespace != req.target_namespace
|| context.plan_hash != req.plan_hash
{
return Err(AppError::from("Nacos import preview token does not match this apply request".to_string()));
}
Ok(())
}
async fn write_nacos_archive(
field: &mut axum::extract::multipart::Field<'_>,
archive_path: &Path,
) -> Result<(), AppError> {
let mut archive = tokio::fs::File::create(archive_path).await.map_err(|error| AppError::from(error.to_string()))?;
let mut uploaded_bytes = 0usize;
while let Some(chunk) = field.chunk().await.map_err(|error| AppError::from(error.to_string()))? {
uploaded_bytes = uploaded_bytes.saturating_add(chunk.len());
if uploaded_bytes > MAX_NACOS_ARCHIVE_BYTES {
return Err(AppError::from(format!(
"Nacos archive is too large: {uploaded_bytes} bytes received (max {MAX_NACOS_ARCHIVE_BYTES} bytes)"
)));
}
archive.write_all(&chunk).await.map_err(|error| AppError::from(error.to_string()))?;
}
archive.flush().await.map_err(|error| AppError::from(error.to_string()))
}
async fn cleanup_nacos_import(path: &Path) {
let _ = tokio::fs::remove_file(path).await;
}
async fn cleanup_expired_nacos_imports(state: &WebState, dir: &Path, max_age: Duration) {
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return;
};
let now = SystemTime::now();
let mut expired_tokens = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let Ok(metadata) = entry.metadata().await else {
continue;
};
let expired = metadata.modified().ok().and_then(|modified| now.duration_since(modified).ok());
if expired.is_some_and(|age| age > max_age) {
if let Some(token) = entry.path().file_stem().and_then(|token| token.to_str()) {
expired_tokens.push(token.to_string());
}
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
if !expired_tokens.is_empty() {
let mut imports = state.nacos_imports.write().await;
for token in expired_tokens {
imports.remove(&token);
}
}
}
fn sanitize_archive_file_name(value: &str) -> String {
let file_name =
value.rsplit(['/', '\\']).next().unwrap_or("nacos-configs.zip").replace(['\r', '\n', '"', ';'], "_");
let file_name = file_name.trim();
let file_name = if file_name.is_empty() { "nacos-configs.zip" } else { file_name };
if file_name.to_ascii_lowercase().ends_with(".zip") {
file_name.to_string()
} else {
format!("{file_name}.zip")
}
}
fn archive_content_disposition(file_name: &str) -> String {
let fallback = file_name
.chars()
.map(|character| if character.is_ascii_graphic() { character } else { '_' })
.collect::<String>();
format!("attachment; filename=\"{fallback}\"; filename*=UTF-8''{}", encode_rfc5987_value(file_name))
}
fn encode_rfc5987_value(value: &str) -> String {
use std::fmt::Write;
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric()
|| matches!(byte, b'!' | b'#' | b'$' | b'&' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~')
{
encoded.push(char::from(byte));
} else {
write!(&mut encoded, "%{byte:02X}").expect("writing to a String cannot fail");
}
}
encoded
}
#[cfg(test)]
mod batch_tests {
use super::*;
#[test]
fn archive_tokens_cannot_escape_the_upload_directory() {
let data_dir = Path::new("/tmp/dbx-nacos-route-test");
assert!(nacos_import_path(data_dir, "../outside").is_err());
assert!(nacos_import_path(data_dir, "not-a-uuid").is_err());
}
#[test]
fn archive_download_names_are_safe_and_have_zip_extension() {
assert_eq!(sanitize_archive_file_name("../../prod\r\n\".zip"), "prod___.zip");
assert_eq!(sanitize_archive_file_name("configs"), "configs.zip");
assert_eq!(sanitize_archive_file_name(""), "nacos-configs.zip");
assert_eq!(
archive_content_disposition("配置.zip"),
"attachment; filename=\"__.zip\"; filename*=UTF-8''%E9%85%8D%E7%BD%AE.zip"
);
}
#[test]
fn archive_token_expiration_uses_the_configured_ttl() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(2 * 24 * 60 * 60);
assert!(!archive_is_expired(now - NACOS_IMPORT_TTL, now, NACOS_IMPORT_TTL));
assert!(archive_is_expired(now - NACOS_IMPORT_TTL - Duration::from_secs(1), now, NACOS_IMPORT_TTL));
assert!(!archive_is_expired(now + Duration::from_secs(1), now, NACOS_IMPORT_TTL));
}
#[test]
fn import_tokens_require_the_preview_session_and_context() {
let context = NacosImportContext {
owner_session: Some("preview-session".to_string()),
connection_id: "connection-a".to_string(),
target_namespace: "namespace-a".to_string(),
plan_hash: "preview-plan".to_string(),
};
let mut request = ConfigImportApplyReq {
connection_id: "connection-a".to_string(),
operation_id: "operation".to_string(),
target_namespace: "namespace-a".to_string(),
archive_token: uuid::Uuid::new_v4().to_string(),
plan_hash: "preview-plan".to_string(),
conflict_policy: Default::default(),
};
assert!(validate_nacos_import_context(&context, Some("preview-session"), &request).is_ok());
assert!(validate_nacos_import_context(&context, Some("other-session"), &request).is_err());
request.connection_id = "connection-b".to_string();
assert!(validate_nacos_import_context(&context, Some("preview-session"), &request).is_err());
}
}

View File

@ -10,6 +10,14 @@ pub struct LoginRateLimit {
pub locked_until: Option<std::time::Instant>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NacosImportContext {
pub owner_session: Option<String>,
pub connection_id: String,
pub target_namespace: String,
pub plan_hash: String,
}
pub struct WebState {
pub app: Arc<AppState>,
pub data_dir: PathBuf,
@ -20,6 +28,7 @@ pub struct WebState {
pub sse_channels: RwLock<HashMap<String, broadcast::Sender<String>>>,
pub table_import_channels: RwLock<HashMap<String, watch::Sender<String>>>,
pub sql_file_executions: RwLock<HashMap<String, CancellationToken>>,
pub nacos_imports: RwLock<HashMap<String, NacosImportContext>>,
pub login_rate_limit: Mutex<LoginRateLimit>,
/// Table export temp files: export_id -> (file_path, format)
pub export_files: RwLock<HashMap<String, (String, String)>>,

View File

@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { canConfigureVisibleSchemasForTreeNode, isSchemaAware, supportsClearableQuerySchema, supportsDatabaseCreation, usesTreeSchemaMode } from "../../apps/desktop/src/lib/database/databaseCapabilities.ts";
import { canConfigureVisibleSchemasForTreeNode, isSchemaAware, supportsClearableQuerySchema, supportsConnectionQueryActions, supportsDatabaseCreation, usesTreeSchemaMode } from "../../apps/desktop/src/lib/database/databaseCapabilities.ts";
test("TDengine uses database/catalog tree nodes without a schema layer", () => {
assert.equal(isSchemaAware("tdengine"), false);
@ -44,3 +44,9 @@ test("only explicitly supported query schemas can be cleared from query tabs", (
assert.equal(supportsClearableQuerySchema("sqlserver"), false);
assert.equal(supportsClearableQuerySchema("jdbc"), false);
});
test("Nacos connection menus do not expose SQL-style query actions", () => {
assert.equal(supportsConnectionQueryActions("nacos"), false);
assert.equal(supportsConnectionQueryActions("mysql"), true);
assert.equal(supportsConnectionQueryActions("redis"), true);
});

View File

@ -1,8 +1,10 @@
import assert from "node:assert/strict";
import { ref } from "vue";
import { afterEach, beforeEach, test } from "vitest";
import {
NACOS_CONFIG_LIST_COLUMN_WIDTHS_STORAGE_KEY,
DEFAULT_NACOS_CONFIG_LIST_COLUMN_WIDTHS,
NACOS_CONFIG_LIST_HORIZONTAL_PADDING,
useNacosConfigListColumnResize,
} from "../../apps/desktop/src/composables/useNacosConfigListColumnResize.ts";
@ -73,13 +75,13 @@ afterEach(() => {
restoreLocalStorage = undefined;
});
test("Nacos config list keeps overflow tied to the computed width and updates it after drag resizing", () => {
test("Nacos config list adjusts adjacent columns without changing the table width", () => {
const layout = useNacosConfigListColumnResize();
assert.deepEqual(layout.columnWidths.value, [...DEFAULT_NACOS_CONFIG_LIST_COLUMN_WIDTHS]);
assert.equal(layout.gridTemplateColumns.value, "280px 180px 180px 96px");
assert.equal(layout.totalWidth.value, 736);
assert.equal(layout.minWidth.value, "736px");
assert.equal(layout.minWidth.value, `${736 + NACOS_CONFIG_LIST_HORIZONTAL_PADDING}px`);
assert.equal(layout.totalWidth.value > 700, true);
assert.equal(layout.totalWidth.value > 820, false);
@ -96,17 +98,31 @@ test("Nacos config list keeps overflow tied to the computed width and updates it
documentHarness!.dispatchMouseEvent("mousemove", 400);
assert.deepEqual(layout.columnWidths.value, [400, 180, 180, 96]);
assert.equal(layout.gridTemplateColumns.value, "400px 180px 180px 96px");
assert.equal(layout.totalWidth.value, 856);
assert.equal(layout.minWidth.value, "856px");
assert.equal(layout.totalWidth.value > 820, true);
assert.deepEqual(layout.columnWidths.value, [364, 96, 180, 96]);
assert.equal(layout.gridTemplateColumns.value, "364px 96px 180px 96px");
assert.equal(layout.totalWidth.value, 736);
assert.equal(layout.minWidth.value, `${736 + NACOS_CONFIG_LIST_HORIZONTAL_PADDING}px`);
documentHarness!.dispatchMouseEvent("mouseup", 400);
assert.equal(layout.resizingColumnIndex.value, null);
assert.equal(localStorage.getItem(NACOS_CONFIG_LIST_COLUMN_WIDTHS_STORAGE_KEY), JSON.stringify([400, 180, 180, 96]));
assert.equal(localStorage.getItem(NACOS_CONFIG_LIST_COLUMN_WIDTHS_STORAGE_KEY), JSON.stringify([364, 96, 180, 96]));
const restoredLayout = useNacosConfigListColumnResize();
assert.deepEqual(restoredLayout.columnWidths.value, [400, 180, 180, 96]);
assert.deepEqual(restoredLayout.columnWidths.value, [364, 96, 180, 96]);
});
test("Nacos config list fits every default column into the actual list viewport", () => {
const viewportWidth = ref(600);
const layout = useNacosConfigListColumnResize(viewportWidth);
assert.deepEqual(layout.columnWidths.value, [212, 139, 139, 86]);
assert.equal(layout.totalWidth.value, 600 - NACOS_CONFIG_LIST_HORIZONTAL_PADDING);
assert.equal(layout.minWidth.value, "600px");
viewportWidth.value = 800;
assert.equal(layout.totalWidth.value, 800 - NACOS_CONFIG_LIST_HORIZONTAL_PADDING);
assert.equal(layout.minWidth.value, "800px");
assert.equal(layout.columnWidths.value.every((width) => width > 0), true);
});

View File

@ -1,5 +1,7 @@
use std::path::Path;
use std::sync::Arc;
use tauri::ipc::Channel;
use tauri::State;
use crate::commands::connection::AppState;
@ -153,3 +155,89 @@ pub async fn nacos_raw_request(
) -> Result<dbx_core::nacos::NacosRawResponse, String> {
dbx_core::nacos::service::nacos_raw_request_core(&state, &connection_id, req).await
}
#[tauri::command]
pub async fn nacos_search_config_content(
state: State<'_, Arc<AppState>>,
connection_id: String,
req: dbx_core::nacos::NacosContentSearchRequest,
on_progress: Channel<dbx_core::nacos::NacosSearchProgress>,
) -> Result<dbx_core::nacos::NacosContentSearchResult, String> {
dbx_core::nacos::service::nacos_search_config_content_core(&state, &connection_id, req, move |progress| {
let _ = on_progress.send(progress);
std::future::ready(())
})
.await
}
#[tauri::command]
pub async fn nacos_cancel_operation(operation_id: String) -> Result<bool, String> {
Ok(dbx_core::nacos::service::nacos_cancel_operation_core(&operation_id))
}
#[tauri::command]
pub async fn nacos_export_configs(
state: State<'_, Arc<AppState>>,
connection_id: String,
selector: dbx_core::nacos::NacosConfigSelector,
destination: String,
) -> Result<(), String> {
dbx_core::nacos::batch::nacos_export_config_archive_core(&state, &connection_id, selector, Path::new(&destination))
.await
.map(|_| ())
}
#[tauri::command]
pub async fn nacos_preview_config_import(
state: State<'_, Arc<AppState>>,
connection_id: String,
target_namespace: String,
archive_path: String,
) -> Result<dbx_core::nacos::NacosBatchPreview, String> {
dbx_core::nacos::batch::nacos_preview_config_import_core(
&state,
&connection_id,
&target_namespace,
Path::new(&archive_path),
)
.await
}
#[tauri::command]
pub async fn nacos_apply_config_import(
state: State<'_, Arc<AppState>>,
connection_id: String,
operation_id: String,
target_namespace: String,
archive_path: String,
plan_hash: String,
conflict_policy: dbx_core::nacos::NacosConflictPolicy,
) -> Result<dbx_core::nacos::NacosBatchReport, String> {
dbx_core::nacos::batch::nacos_apply_config_import_core(
&state,
&connection_id,
&target_namespace,
Path::new(&archive_path),
&operation_id,
&plan_hash,
&conflict_policy,
)
.await
}
#[tauri::command]
pub async fn nacos_preview_config_transfer(
state: State<'_, Arc<AppState>>,
req: dbx_core::nacos::NacosConfigTransferRequest,
) -> Result<dbx_core::nacos::NacosBatchPreview, String> {
dbx_core::nacos::batch::nacos_preview_config_transfer_core(&state, &req).await
}
#[tauri::command]
pub async fn nacos_apply_config_transfer(
state: State<'_, Arc<AppState>>,
req: dbx_core::nacos::NacosConfigTransferRequest,
plan_hash: String,
) -> Result<dbx_core::nacos::NacosBatchReport, String> {
dbx_core::nacos::batch::nacos_apply_config_transfer_core(&state, &req, &plan_hash).await
}

View File

@ -1565,6 +1565,13 @@ pub fn run() {
commands::nacos_cmd::nacos_list_instances,
commands::nacos_cmd::nacos_update_instance,
commands::nacos_cmd::nacos_raw_request,
commands::nacos_cmd::nacos_search_config_content,
commands::nacos_cmd::nacos_cancel_operation,
commands::nacos_cmd::nacos_export_configs,
commands::nacos_cmd::nacos_preview_config_import,
commands::nacos_cmd::nacos_apply_config_import,
commands::nacos_cmd::nacos_preview_config_transfer,
commands::nacos_cmd::nacos_apply_config_transfer,
commands::saved_sql::load_saved_sql_library,
commands::saved_sql::load_saved_sql_file,
commands::saved_sql::save_saved_sql_folder,