feat(nacos): add YAML diagnostics for config editor

This commit is contained in:
jearhe authored and GitHub committed 2026-09-19 08:52:39 +08:00
1 parent d10216e302
commit f3e5909a20
21 files changed
+886 -33

No files matched your search

@@ -3,6 +3,7 @@ import { useUpdateBlocker } from "@/lib/app/updatePreparation";
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, shallowRef, useId, watch } from "vue";
import { Compartment, StateEffect, StateField } from "@codemirror/state";
import { ensureSyntaxTree } from "@codemirror/language";
import { setDiagnostics } from "@codemirror/lint";
import { Decoration, EditorView } from "@codemirror/view";
import { Archive, ArrowLeftRight, CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, Clipboard, Columns3, Download, ExternalLink, FileClock, FileInput, FileText, Loader2, Maximize2, Minimize2, Network, Plus, RefreshCw, Save, Search, Send, Server, Trash2, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
@@ -60,8 +61,10 @@ import { useTheme } from "@/composables/useTheme";
import { executeWithProductionContextGuard } from "@/lib/database/productionExecutionGuard";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { validateNacosConfigContent, type NacosConfigDiagnostic } from "@/lib/nacos/nacosConfigValidation";
import { validateNacosConfigContent, nacosConfigDiagnosticSeverity, nacosConfigValidationBlocksPublish, type NacosConfigDiagnostic } from "@/lib/nacos/nacosConfigValidation";
import { loadNacosConfigLanguage, resolveNacosConfigFormat } from "@/lib/nacos/nacosConfigLanguage";
import { nacosConfigYamlLintDiagnostics, nacosConfigYamlLintExtension } from "@/lib/nacos/nacosConfigYamlLint";
import { translateNacosYamlDiagnostic } from "@/lib/nacos/nacosYamlDiagnostics";
import type { NacosConfigEditorViewport } from "@/types/database";
import type {
NacosBatchPreview,
@@ -550,6 +553,8 @@ function currentCustomThemeColors() {
}
function configValidationHighlightExtension() {
// Formats without a native linter keep this decoration; YAML uses the linter
// installed by `nacosConfigYamlLintExtension` instead (#9405).
const decorationsFor = (state: import("@codemirror/state").EditorState, diagnostics: NacosConfigDiagnostic[]) => {
const ranges = diagnostics
.filter(() => state.doc.length > 0)
@@ -632,6 +637,7 @@ async function mountConfigEditor() {
},
}),
configEditorLanguage.of(language),
...(format === "yaml" ? [nacosConfigYamlLintExtension(configDiagnosticTranslate)] : []),
configValidationHighlight.of(configValidationHighlightExtension()),
configEditorTheme.of(theme),
configEditorFontTheme.of(editorFontTheme(EditorView, editorSettings.fontSize, editorSettings.fontFamily, { fixedHeight: true, scrollable: true })),
@@ -670,6 +676,9 @@ async function mountConfigEditor() {
return;
}
configEditorView.value = view;
// `linter()` only schedules a run after a document change, so a config opened
// with an existing error would look clean until the first keystroke.
if (format === "yaml") view.dispatch(setDiagnostics(view.state, nacosConfigYamlLintDiagnostics(view.state.doc.toString(), configDiagnosticTranslate)));
view.scrollDOM.addEventListener("scroll", scheduleConfigEditorViewportCapture, { passive: true });
restoreConfigEditorViewport();
}
@@ -844,6 +853,24 @@ function clearConfigValidation(clearHighlight = true) {
}
}
/** True when the open config is YAML, which shows diagnostics through CodeMirror's linter rather than the legacy decoration. */
const configUsesYamlLint = computed(() => resolveNacosConfigFormat(configType.value, configDataId.value) === "yaml");
const configValidationHasError = computed(() => configValidationDiagnostics.value.some((diagnostic) => nacosConfigDiagnosticSeverity(diagnostic) === "error"));
function configDiagnosticTranslate(key: string, params: Record<string, string>) {
return t(key, params);
}
/** YAML diagnostics carry a code so the message follows the active locale; other formats keep their parser text. */
function configValidationMessage(diagnostic: NacosConfigDiagnostic): string {
return diagnostic.code ? translateNacosYamlDiagnostic({ code: diagnostic.code, message: diagnostic.message, params: diagnostic.params ?? {} }, configDiagnosticTranslate) : diagnostic.message;
}
function configValidationSeverityLabel(diagnostic: NacosConfigDiagnostic): string {
return nacosConfigDiagnosticSeverity(diagnostic) === "error" ? t("nacos.validationSeverityError") : t("nacos.validationSeverityWarning");
}
function refreshConfigValidationHighlights(content: string, generation: number, editorSessionId: number) {
if (!configValidationHighlightActive) return;
queueMicrotask(() => {
@@ -862,11 +889,18 @@ function validateCurrentConfig(showSuccess = true): boolean {
configValidationDiagnostics.value = diagnostics;
configValidationHighlightActive = false;
configEditorView.value?.dispatch({ effects: setConfigValidationHighlight.of([]) });
if (diagnostics.length) {
// Only errors block publishing: a parser warning must never trap a config the
// user is entitled to publish (#9405).
if (nacosConfigValidationBlocksPublish(diagnostics)) {
configValidationOpen.value = true;
return false;
}
if (showSuccess) toast(t("nacos.validationPassed"), 2000);
if (!showSuccess) return true;
if (diagnostics.length) {
configValidationOpen.value = true;
return true;
}
toast(t("nacos.validationPassed"), 2000);
return true;
}
@@ -875,9 +909,11 @@ function focusConfigValidationDiagnostic() {
const view = configEditorView.value;
if (!diagnostic || !view) return;
configValidationOpen.value = false;
configValidationHighlightActive = true;
// The YAML linter already marks every diagnostic, so only move the cursor;
// reusing the legacy decoration on top would double-render the same range.
configValidationHighlightActive = !configUsesYamlLint.value;
view.dispatch({
effects: setConfigValidationHighlight.of(configValidationDiagnostics.value),
effects: setConfigValidationHighlight.of(configUsesYamlLint.value ? [] : configValidationDiagnostics.value),
selection: { anchor: diagnostic.from },
scrollIntoView: true,
});
@@ -3366,13 +3402,16 @@ useUpdateBlocker(() =>
<Dialog :open="configValidationOpen" @update:open="configValidationOpen = $event">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{{ t("nacos.validationErrorTitle") }}</DialogTitle>
<DialogDescription>{{ t("nacos.validationErrorDescription") }}</DialogDescription>
<DialogTitle>{{ configValidationHasError ? t("nacos.validationErrorTitle") : t("nacos.validationWarningTitle") }}</DialogTitle>
<DialogDescription>{{ configValidationHasError ? t("nacos.validationErrorDescription") : t("nacos.validationWarningDescription") }}</DialogDescription>
</DialogHeader>
<div v-if="configValidationDiagnostics.length" class="max-h-[min(50vh,24rem)] overflow-y-auto rounded-md border border-destructive/30 bg-destructive/5 text-sm">
<div v-for="(diagnostic, index) in configValidationDiagnostics" :key="`${diagnostic.from}-${diagnostic.to}-${index}`" class="border-b border-destructive/15 p-4 last:border-b-0">
<div v-if="configValidationDiagnostics.length" class="max-h-[min(50vh,24rem)] overflow-y-auto rounded-md border text-sm" :class="configValidationHasError ? 'border-destructive/30 bg-destructive/5' : 'border-warning/30 bg-warning/5'">
<div v-for="(diagnostic, index) in configValidationDiagnostics" :key="`${diagnostic.from}-${diagnostic.to}-${index}`" class="border-b p-4 last:border-b-0" :class="configValidationHasError ? 'border-destructive/15' : 'border-warning/15'">
<div class="min-w-0 space-y-2">
<p class="break-words font-medium text-destructive">{{ diagnostic.message }}</p>
<p class="break-words font-medium" :class="nacosConfigDiagnosticSeverity(diagnostic) === 'error' ? 'text-destructive' : 'text-warning'">
<span class="me-1.5 text-xs uppercase tracking-wide">{{ configValidationSeverityLabel(diagnostic) }}</span
>{{ configValidationMessage(diagnostic) }}
</p>
<div class="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{{ t("nacos.validationLine", { line: diagnostic.line }) }}</span>
<span>{{ t("nacos.validationColumn", { column: diagnostic.column }) }}</span>
@@ -3735,6 +3774,32 @@ useUpdateBlocker(() =>
text-underline-offset: 3px;
}
/* The YAML linter marks ranges with `@codemirror/lint`'s hardcoded palette;
override it so the underline follows the active light/dark theme the way the
legacy decoration above already does. */
.nacos-config-editor :deep(.cm-lintRange-error),
.nacos-config-editor :deep(.cm-lintRange-warning) {
background-image: none;
text-decoration: underline wavy;
text-underline-offset: 3px;
}
.nacos-config-editor :deep(.cm-lintRange-error) {
text-decoration-color: var(--destructive);
}
.nacos-config-editor :deep(.cm-lintRange-warning) {
text-decoration-color: var(--warning);
}
.nacos-config-editor :deep(.cm-diagnostic-error) {
border-left-color: var(--destructive);
}
.nacos-config-editor :deep(.cm-diagnostic-warning) {
border-left-color: var(--warning);
}
.nacos-config-workbench {
container-type: inline-size;
}
@@ -0,0 +1,91 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import en from "@/i18n/locales/en";
const source = readFileSync(new URL("../NacosAdminConsole.vue", import.meta.url), "utf8");
/**
* The lint itself is covered by real CodeMirror integration specs
* (`lib/__tests__/nacos/nacosConfigYamlLint.spec.ts` and
* `packages/app-tests/nacosYamlDiagnostics.test.ts`). These assertions pin the
* wiring that only exists inside this component, which is too large to mount.
*/
describe("NacosAdminConsole YAML diagnostics wiring", () => {
it("installs and seeds the linter only for YAML configs", () => {
expect(source).toContain('...(format === "yaml" ? [nacosConfigYamlLintExtension(configDiagnosticTranslate)] : []),');
expect(source).toContain('if (format === "yaml") view.dispatch(setDiagnostics(view.state, nacosConfigYamlLintDiagnostics(view.state.doc.toString(), configDiagnosticTranslate)));');
// A YAML-only extension: each helper has exactly one call site, inside the gated branch.
expect(source.match(/nacosConfigYamlLintExtension\(/g)).toHaveLength(1);
expect(source.match(/nacosConfigYamlLintDiagnostics\(/g)).toHaveLength(1);
});
it("keeps the lint logic out of the component", () => {
// The component only formats and displays diagnostics; it never parses YAML itself.
expect(source).not.toContain("analyzeNacosYaml(");
expect(source).not.toContain("parseAllDocuments(");
expect(source).not.toContain("parseDocument(");
expect(source).toContain('import { setDiagnostics } from "@codemirror/lint";');
expect(source).toContain('import { nacosConfigYamlLintDiagnostics, nacosConfigYamlLintExtension } from "@/lib/nacos/nacosConfigYamlLint";');
expect(source).toContain('import { translateNacosYamlDiagnostic } from "@/lib/nacos/nacosYamlDiagnostics";');
});
it("blocks publishing on error severity only", () => {
expect(source).toContain("if (nacosConfigValidationBlocksPublish(diagnostics)) {");
expect(source).not.toContain('diagnostics.some((diagnostic) => nacosConfigDiagnosticSeverity(diagnostic) === "error")');
// `showSuccess` now also means "the user asked for validation", so a warnings-only
// run reports them instead of a success toast, and publishing is not blocked.
expect(source).toContain("if (!showSuccess) return true;");
});
it("does not double-mark YAML diagnostics with the legacy decoration", () => {
expect(source).toContain('const configUsesYamlLint = computed(() => resolveNacosConfigFormat(configType.value, configDataId.value) === "yaml");');
expect(source).toContain("configValidationHighlightActive = !configUsesYamlLint.value;");
expect(source).toContain("effects: setConfigValidationHighlight.of(configUsesYamlLint.value ? [] : configValidationDiagnostics.value),");
});
it("reports severity and localized messages in the validation dialog", () => {
expect(source).toContain('{{ configValidationHasError ? t("nacos.validationErrorTitle") : t("nacos.validationWarningTitle") }}');
expect(source).toContain('{{ configValidationHasError ? t("nacos.validationErrorDescription") : t("nacos.validationWarningDescription") }}');
// Asserted separately because the formatter may wrap the spans.
expect(source).toContain("{{ configValidationSeverityLabel(diagnostic) }}");
expect(source).toContain("{{ configValidationMessage(diagnostic) }}");
// The localized text is resolved through the shared helper, never a raw parser string.
expect(source).toContain("return diagnostic.code ? translateNacosYamlDiagnostic({ code: diagnostic.code, message: diagnostic.message, params: diagnostic.params ?? {} }, configDiagnosticTranslate) : diagnostic.message;");
});
it("localizes every key it references, including the new severity labels", () => {
const referenced = [
"validationErrorTitle",
"validationErrorDescription",
"validationWarningTitle",
"validationWarningDescription",
"validationSeverityError",
"validationSeverityWarning",
"yamlDiagnosticDuplicateKey",
"yamlDiagnosticTabIndent",
"yamlDiagnosticSyntaxError",
"yamlDiagnosticParserWarning",
];
const nacos = en.nacos as Record<string, unknown>;
for (const key of referenced) {
expect(typeof nacos[key], key).toBe("string");
expect((nacos[key] as string).length, key).toBeGreaterThan(0);
}
});
it("adds no watcher, listener, or observer for live diagnostics", () => {
// CodeMirror owns the debounce; a component-level watcher would re-parse on
// every keystroke and leak across config switches.
expect(source.match(/watch\(/g)).toHaveLength(6);
expect(source.match(/addEventListener\(/g)).toHaveLength(1);
expect(source.match(/removeEventListener\(/g)).toHaveLength(1);
expect(source.match(/onMounted\(/g)).toHaveLength(1);
expect(source.match(/new ResizeObserver/g)).toHaveLength(1);
});
it("still tears the editor view down when the config changes", () => {
expect(source).toContain("configEditorView.value?.destroy();");
expect(source).toContain("configEditorView.value = null;");
expect(source).toContain("await refreshConfigEditor();");
});
});