feat(nacos): add YAML diagnostics for config editor
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
+91
@@ -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();");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user