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