Skip to content

Commit 4e0fbf5

Browse files
committed
feat(FR-2453): add JSON schema validation and autocomplete for definition files in Monaco editor (#6438)
## Summary Resolves #6438(FR-2453) Adds JSON Schema-based validation and autocomplete for model/service definition files when edited in the VFolder file editor (Monaco): - **`model-definition.yaml` / `.yml`** — YAML syntax highlighting (built-in) + schema validation warnings + **schema-based autocomplete** - **`service-definition.toml`** — TOML syntax highlighting (custom monarch tokenizer) + schema validation warnings - **Shared validation core** (`monacoSchemaValidator.ts`) — Ajv compiled once per validator, version counter prevents stale async results - **YAML autocomplete** (`monacoYamlCompletion.ts`) — CompletionItemProvider that suggests schema properties based on cursor context (indentation-aware path detection), marks required properties, provides value snippets - Schema files: `resources/model-definition.schema.json` and `resources/service-definition.schema.json` (Draft 2020-12) - Validation runs on mount and debounced on content changes (300ms) - No interference with regular file editing ### Files Changed | File | Description | |------|-------------| | `react/src/helper/monacoSchemaValidator.ts` | **New** — Shared schema validation core (Ajv caching, race condition guard, disposable pattern) | | `react/src/helper/monacoYamlValidator.ts` | **New** — YAML-specific parser + error mapping | | `react/src/helper/monacoYamlCompletion.ts` | **New** — Schema-based YAML autocomplete (indentation-aware path detection, $ref resolution) | | `react/src/helper/monacoTomlValidator.ts` | **New** — TOML-specific parser + error mapping | | `react/src/helper/monacoTomlLanguage.ts` | **New** — TOML language registration + monarch tokenizer | | `react/src/components/VFolderTextFileEditorModal.tsx` | Schema dispatch by file type (YAML/TOML) | | `react/package.json` | Add `yaml`, `ajv` (moved to deps) | | `resources/model-definition.schema.json` | **New** — JSON Schema for model definition | | `resources/service-definition.schema.json` | **New** — JSON Schema for service definition | ### Verification ``` === Relay: PASS === === Lint: PASS === === Format: PASS === === TypeScript: PASS === === ALL PASS === ``` ### Design Notes - Initially attempted `monaco-yaml` for full YAML autocomplete + validation, but its web worker is incompatible with CDN-loaded Monaco (`@monaco-editor/react` loads Monaco from jsDelivr). Implemented manual validation (yaml parser + ajv) and a custom CompletionItemProvider for autocomplete instead. - Ajv schema compilation happens once per validator lifetime (not per keystroke). A version counter prevents stale async validation results from overwriting newer ones. - YAML autocomplete uses indentation-aware path detection: splits array items into container + key entries, trims to ancestors based on cursor indent, then navigates the schema tree to suggest remaining properties. ## Test plan - [x] Open `model-definition.yaml` in vFolder editor → YAML syntax highlighting + yellow warning markers for schema violations - [x] In `model-definition.yaml`, type a property key → autocomplete suggests schema properties with descriptions and required markers - [x] Open `service-definition.toml` in vFolder editor → TOML syntax highlighting + yellow warning markers for schema violations - [x] Open a regular file (e.g., `.ts`) → no schema markers, no errors - [x] `scripts/verify.sh` passes (Relay, Lint, Format, TypeScript) ## Screenshots ### YAML Schema Autocomplete (model-definition.yaml) ![YAML autocomplete](https://raw.githubusercontent.com/lablup/backend.ai-webui/assets/images/screenshots/pr-6438/20260406-144331-yaml-autocomplete.png) ### YAML Schema Validation (model-definition.yaml) ![YAML validation markers](https://raw.githubusercontent.com/lablup/backend.ai-webui/assets/images/screenshots/pr-6438/20260406-140618-yaml-validation.png) ### TOML Schema Validation (service-definition.toml) ![TOML validation markers](https://raw.githubusercontent.com/lablup/backend.ai-webui/assets/images/screenshots/pr-6438/20260406-140622-toml-validation.png)
1 parent b090ef2 commit 4e0fbf5

30 files changed

Lines changed: 1426 additions & 4 deletions

react/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@uiw/react-codemirror": "^4.25.8",
3131
"ahooks": "catalog:",
3232
"ai": "^5.0.150",
33+
"ajv": "^8.18.0",
3334
"ansi_up": "^6.0.6",
3435
"antd": "catalog:",
3536
"antd-style": "catalog:",
@@ -81,6 +82,7 @@
8182
"use-query-params": "^2.2.2",
8283
"uuid": "catalog:",
8384
"web-vitals": "^3.5.2",
85+
"yaml": "^2.8.3",
8486
"zod": "^4.3.6"
8587
},
8688
"scripts": {
@@ -131,7 +133,6 @@
131133
"@types/relay-runtime": "catalog:",
132134
"@types/relay-test-utils": "catalog:",
133135
"@types/uuid": "^11.0.0",
134-
"ajv": "^8.18.0",
135136
"babel-jest": "catalog:",
136137
"babel-plugin-react-compiler": "catalog:",
137138
"babel-plugin-relay": "^20.1.1",

react/src/components/VFolderTextFileEditorModal.tsx

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,15 @@ import {
1919
useErrorMessageResolver,
2020
BAIText,
2121
BAIAlert,
22+
BAIButton,
2223
} from 'backend.ai-ui';
23-
import React, { Suspense, useRef } from 'react';
24+
import React, {
25+
Suspense,
26+
useCallback,
27+
useEffect,
28+
useRef,
29+
useState,
30+
} from 'react';
2431
import { useTranslation } from 'react-i18next';
2532

2633
const MonacoEditor = React.lazy(() =>
@@ -57,6 +64,26 @@ const detectLanguageAndMimeType = (monaco: Monaco, fileName: string) => {
5764
return { detectedLanguage: 'plaintext', detectedMimeType: 'text/plain' };
5865
};
5966

67+
type SchemaMapping = {
68+
schemaUrl: string;
69+
type: 'yaml' | 'toml';
70+
};
71+
72+
const definitionSchemaMap: Record<string, SchemaMapping> = {
73+
'model-definition.yaml': {
74+
schemaUrl: '/resources/model-definition.schema.json',
75+
type: 'yaml',
76+
},
77+
'model-definition.yml': {
78+
schemaUrl: '/resources/model-definition.schema.json',
79+
type: 'yaml',
80+
},
81+
'service-definition.toml': {
82+
schemaUrl: '/resources/service-definition.schema.json',
83+
type: 'toml',
84+
},
85+
};
86+
6087
const VFolderTextFileEditorModal: React.FC<VFolderTextFileEditorModalProps> = ({
6188
targetVFolderId,
6289
currentPath,
@@ -69,7 +96,7 @@ const VFolderTextFileEditorModal: React.FC<VFolderTextFileEditorModalProps> = ({
6996

7097
const { t } = useTranslation();
7198
const { isDarkMode } = useThemeMode();
72-
const { message } = App.useApp();
99+
const { message, modal } = App.useApp();
73100
const baiClient = useConnectedBAIClient();
74101
const { getErrorMessage } = useErrorMessageResolver();
75102
const { token } = theme.useToken();
@@ -78,6 +105,18 @@ const VFolderTextFileEditorModal: React.FC<VFolderTextFileEditorModalProps> = ({
78105
const queryClient = useQueryClient();
79106
const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
80107
const detectedMimeTypeRef = useRef<string>('text/plain');
108+
const abortControllerRef = useRef<AbortController | null>(null);
109+
const disposablesRef = useRef<{ dispose(): void }[]>([]);
110+
const [isDirty, setIsDirty] = useState(false);
111+
112+
useEffect(() => {
113+
return () => {
114+
abortControllerRef.current?.abort();
115+
disposablesRef.current.forEach((d) => d.dispose());
116+
disposablesRef.current = [];
117+
};
118+
}, []);
119+
81120
const filePath =
82121
currentPath === '.'
83122
? fileInfo?.name
@@ -182,6 +221,39 @@ const VFolderTextFileEditorModal: React.FC<VFolderTextFileEditorModalProps> = ({
182221
},
183222
});
184223

224+
const handleRequestClose = useCallback(() => {
225+
if (!isDirty) {
226+
onRequestClose();
227+
return;
228+
}
229+
const confirmInstance = modal.confirm({
230+
title: t('data.explorer.EditFileUnsavedChangesTitle', {
231+
fileName: fileInfo?.name,
232+
}),
233+
content: t('data.explorer.EditFileUnsavedChangesDescription'),
234+
icon: null,
235+
okText: t('button.Save'),
236+
cancelText: t('button.Cancel'),
237+
footer: (_, { OkBtn, CancelBtn }) => (
238+
<BAIFlex justify="end" gap="xs">
239+
<CancelBtn />
240+
<BAIButton
241+
onClick={() => {
242+
confirmInstance.destroy();
243+
onRequestClose();
244+
}}
245+
>
246+
{t('button.DontSave')}
247+
</BAIButton>
248+
<OkBtn />
249+
</BAIFlex>
250+
),
251+
onOk: () => {
252+
saveMutation.mutate();
253+
},
254+
});
255+
}, [isDirty, modal, t, fileInfo?.name, onRequestClose, saveMutation]);
256+
185257
const skeletonWithPadding = (
186258
<Skeleton
187259
active
@@ -198,6 +270,7 @@ const VFolderTextFileEditorModal: React.FC<VFolderTextFileEditorModalProps> = ({
198270
destroyOnHidden
199271
okText={t('button.Save')}
200272
cancelText={t('button.Cancel')}
273+
keyboard={false}
201274
{...modalProps}
202275
title={
203276
<>
@@ -211,7 +284,7 @@ const VFolderTextFileEditorModal: React.FC<VFolderTextFileEditorModalProps> = ({
211284
)}
212285
</>
213286
}
214-
onCancel={() => onRequestClose()}
287+
onCancel={() => handleRequestClose()}
215288
onOk={() => saveMutation.mutate()}
216289
confirmLoading={saveMutation.isPending}
217290
okButtonProps={{ disabled: !!loadError }}
@@ -240,6 +313,10 @@ const VFolderTextFileEditorModal: React.FC<VFolderTextFileEditorModalProps> = ({
240313
) : (
241314
<MonacoEditor
242315
defaultValue={fileContent ?? ''}
316+
defaultPath={fileInfo?.name}
317+
onChange={() => {
318+
setIsDirty(true);
319+
}}
243320
beforeMount={(monaco) => {
244321
if (fileInfo?.name) {
245322
const { detectedMimeType } = detectLanguageAndMimeType(
@@ -260,6 +337,58 @@ const VFolderTextFileEditorModal: React.FC<VFolderTextFileEditorModalProps> = ({
260337
if (model) {
261338
monaco.editor.setModelLanguage(model, detectedLanguage);
262339
}
340+
341+
const mapping = definitionSchemaMap[fileInfo.name];
342+
if (mapping) {
343+
const abortController = new AbortController();
344+
abortControllerRef.current = abortController;
345+
fetch(mapping.schemaUrl, {
346+
signal: abortController.signal,
347+
})
348+
.then((res) => (res.ok ? res.json() : undefined))
349+
.then(async (schema) => {
350+
if (!schema || abortController.signal.aborted || !model)
351+
return;
352+
353+
if (mapping.type === 'yaml') {
354+
const { createYamlValidator } =
355+
await import('../helper/monacoYamlValidator');
356+
disposablesRef.current.push(
357+
createYamlValidator(monaco, model, schema),
358+
);
359+
360+
const { createYamlCompletionProvider } =
361+
await import('../helper/monacoYamlCompletion');
362+
disposablesRef.current.push(
363+
createYamlCompletionProvider(monaco, model, schema),
364+
);
365+
} else if (mapping.type === 'toml') {
366+
const { registerTomlLanguage } =
367+
await import('../helper/monacoTomlLanguage');
368+
registerTomlLanguage(monaco);
369+
monaco.editor.setModelLanguage(model, 'toml');
370+
371+
const { createTomlValidator } =
372+
await import('../helper/monacoTomlValidator');
373+
const disposable = createTomlValidator(
374+
monaco,
375+
model,
376+
schema,
377+
);
378+
disposablesRef.current.push(disposable);
379+
}
380+
})
381+
.catch((e) => {
382+
if (
383+
e instanceof DOMException &&
384+
e.name === 'AbortError'
385+
)
386+
return;
387+
// Log unexpected errors (schema fetch, import, or init failure)
388+
// eslint-disable-next-line no-console
389+
console.warn('Schema validation setup failed:', e);
390+
});
391+
}
263392
}
264393
}}
265394
theme={isDarkMode ? 'vs-dark' : 'light'}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type { Monaco } from '@monaco-editor/react';
2+
3+
type ITextModel = Parameters<Monaco['editor']['setModelMarkers']>[0];
4+
type IMarkerData = Parameters<Monaco['editor']['setModelMarkers']>[2][number];
5+
6+
export type { ITextModel, IMarkerData };
7+
8+
export function escapeRegExp(str: string): string {
9+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10+
}
11+
12+
export function formatPath(instancePath: string): string {
13+
if (!instancePath) return 'root';
14+
return instancePath.replace(/^\//, '').replace(/\//g, '.');
15+
}
16+
17+
interface ParseErrorInfo {
18+
message: string;
19+
line: number;
20+
column: number;
21+
}
22+
23+
interface ValidatorConfig {
24+
/** Unique owner string for Monaco markers (e.g., 'yaml-schema') */
25+
owner: string;
26+
/** Parse source text into a JS object. Throw on syntax errors. */
27+
parse: (text: string) => unknown;
28+
/** Extract line/column from a parse error, or null if not a parse error. */
29+
mapParseError: (error: unknown) => ParseErrorInfo | null;
30+
/**
31+
* Find the approximate line number for a JSON path (e.g., "/models/0/id")
32+
* by searching the model text for the top-level key.
33+
*/
34+
findLineForPath: (model: ITextModel, instancePath: string) => number;
35+
}
36+
37+
const DEBOUNCE_MS = 300;
38+
39+
/**
40+
* Creates a schema validator that validates on mount and on content changes.
41+
* Ajv is compiled once; a version counter prevents stale async results.
42+
* Returns a disposable that cleans up the listener and markers.
43+
*/
44+
export function createSchemaValidator(
45+
monaco: Monaco,
46+
model: ITextModel,
47+
schema: object,
48+
config: ValidatorConfig,
49+
): { dispose(): void } {
50+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
51+
let version = 0;
52+
let disposed = false;
53+
54+
// Compile schema once, reuse on every validation call.
55+
const validateFnPromise = import('ajv/dist/2020').then(
56+
({ default: Ajv2020 }) => {
57+
const ajv = new Ajv2020({ allErrors: true });
58+
return ajv.compile(schema);
59+
},
60+
);
61+
62+
async function doValidate() {
63+
const thisVersion = ++version;
64+
const text = model.getValue();
65+
const markers: IMarkerData[] = [];
66+
67+
const validate = await validateFnPromise;
68+
if (thisVersion !== version || disposed) return;
69+
70+
try {
71+
const parsed = config.parse(text);
72+
const valid = validate(parsed);
73+
74+
if (!valid && validate.errors) {
75+
for (const err of validate.errors) {
76+
const line = config.findLineForPath(model, err.instancePath);
77+
const path = formatPath(err.instancePath);
78+
markers.push({
79+
severity: monaco.MarkerSeverity.Warning,
80+
message: `${path}: ${err.message ?? 'Schema validation error'}`,
81+
startLineNumber: line,
82+
startColumn: 1,
83+
endLineNumber: line,
84+
endColumn: model.getLineMaxColumn(line),
85+
});
86+
}
87+
}
88+
} catch (e: unknown) {
89+
const parseErr = config.mapParseError(e);
90+
if (parseErr) {
91+
markers.push({
92+
severity: monaco.MarkerSeverity.Error,
93+
message: parseErr.message,
94+
startLineNumber: parseErr.line,
95+
startColumn: parseErr.column,
96+
endLineNumber: parseErr.line,
97+
endColumn: model.getLineMaxColumn(parseErr.line),
98+
});
99+
} else if (e instanceof Error) {
100+
markers.push({
101+
severity: monaco.MarkerSeverity.Error,
102+
message: e.message,
103+
startLineNumber: 1,
104+
startColumn: 1,
105+
endLineNumber: 1,
106+
endColumn: model.getLineMaxColumn(1),
107+
});
108+
}
109+
}
110+
111+
if (thisVersion !== version || disposed) return;
112+
monaco.editor.setModelMarkers(model, config.owner, markers);
113+
}
114+
115+
// Initial validation (fire-and-forget with error handling)
116+
doValidate().catch(() => {});
117+
118+
const listener = model.onDidChangeContent(() => {
119+
if (debounceTimer) clearTimeout(debounceTimer);
120+
debounceTimer = setTimeout(() => {
121+
doValidate().catch(() => {});
122+
}, DEBOUNCE_MS);
123+
});
124+
125+
return {
126+
dispose() {
127+
disposed = true;
128+
if (debounceTimer) clearTimeout(debounceTimer);
129+
listener.dispose();
130+
monaco.editor.setModelMarkers(model, config.owner, []);
131+
},
132+
};
133+
}

0 commit comments

Comments
 (0)