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