diff --git a/README.md b/README.md
index 572ed36..67b2708 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,7 @@
[](https://react.dev)
[](https://mui.com)
[](LICENSE)
+[](https://hub.docker.com/r/polinux/web-cli)
A powerful web-based interface for executing commands on local and remote Linux servers. Built with Go and React with Material-UI for a professional, modern user experience.
@@ -71,6 +72,13 @@ A powerful web-based interface for executing commands on local and remote Linux
- **Script Presets**: Save execution configurations (server, SSH key, user, env vars)
- **Environment Integration**: Inject stored environment variables into scripts
+### Developer Tools
+- **YAML Validator**: Validate, format, and auto-fix YAML documents with Monaco Editor
+- **JSON Validator**: Validate, format, and auto-fix JSON data with Monaco Editor
+- **Auto-Fix**: Automatic repair of common issues (tabs, indentation, trailing commas, etc.)
+- **Diff View**: Side-by-side comparison showing exactly what was changed by auto-fix
+- **Syntax Highlighting**: Full VS Code-style editing experience
+
### Security
- **Authentication**: HTTP Basic Auth and Bearer token support (configurable)
- **TLS/HTTPS Support**: Native TLS support with optional HTTPS enforcement
@@ -108,9 +116,12 @@ A powerful web-based interface for executing commands on local and remote Linux
- **[React Router v6](https://reactrouter.com/)**: Client-side routing
- **[Material-UI (MUI) v5](https://mui.com/)**: Professional component library
- **[xterm.js](https://xtermjs.org/)**: Terminal emulator for the browser
+- **[Monaco Editor](https://microsoft.github.io/monaco-editor/)**: VS Code editor for YAML/JSON tools
- **[Vite](https://vitejs.dev/)**: Fast build tool and dev server
- **[Emotion](https://emotion.sh/)**: CSS-in-JS styling
- **[Swagger UI](https://swagger.io/tools/swagger-ui/)**: Interactive API documentation
+- **[js-yaml](https://github.com/nodeca/js-yaml)**: YAML parsing and formatting
+- **[jsonrepair](https://github.com/josdejong/jsonrepair)**: JSON auto-fix and repair
## 📸 Screenshots
@@ -682,12 +693,48 @@ curl -H "Authorization: Bearer your-token" http://localhost:7777/api/health
Web CLI is available as a Docker image for easy deployment.
**Image Details:**
-- **Registry:** Docker Hub (`polinux/web-cli`)
+- **Registry:** Docker Hub ([`polinux/web-cli`](https://hub.docker.com/r/polinux/web-cli))
- **Base Image:** Debian Bookworm (slim) - provides full bash support for script execution
- **Platforms:** `linux/amd64`, `linux/arm64`
- **Size:** ~100MB compressed
+- **Tags:** `latest`, `dev`, `main`, version tags (e.g., `v0.2.3`)
+
+**Quick Start - Standalone Docker Compose:**
+
+Create a `docker-compose.yml` file anywhere on your system:
+
+```yaml
+services:
+ web-cli:
+ image: polinux/web-cli:latest
+ container_name: web-cli
+ restart: unless-stopped
+ ports:
+ - "7777:7777"
+ volumes:
+ - web-cli-data:/data
+ environment:
+ - AUTH_ENABLED=true
+ - AUTH_USERNAME=admin
+ - AUTH_PASSWORD=changeme123
+
+volumes:
+ web-cli-data:
+```
+
+Then run:
+
+```bash
+# Start the container
+docker compose up -d
+
+# View logs
+docker compose logs -f
+
+# Access at http://localhost:7777
+```
-**Quick Start with Docker Compose:**
+**Quick Start with Repository:**
```bash
# Clone the repository
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index cc44a89..5d75472 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,11 +10,14 @@
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
+ "@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^5.15.0",
"@mui/material": "^5.15.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"ansi-to-react": "^6.1.6",
+ "js-yaml": "^4.1.1",
+ "jsonrepair": "^3.13.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0"
@@ -1002,6 +1005,29 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@monaco-editor/loader": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
+ "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
+ "license": "MIT",
+ "dependencies": {
+ "state-local": "^1.0.6"
+ }
+ },
+ "node_modules/@monaco-editor/react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
+ "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@monaco-editor/loader": "^1.5.0"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.25.0 < 1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/@mui/core-downloads-tracker": {
"version": "5.18.0",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz",
@@ -1826,7 +1852,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "dev": true,
"license": "Python-2.0"
},
"node_modules/array-buffer-byte-length": {
@@ -3845,7 +3870,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -3906,6 +3930,15 @@
"node": ">=6"
}
},
+ "node_modules/jsonrepair": {
+ "version": "3.13.1",
+ "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.1.tgz",
+ "integrity": "sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw==",
+ "license": "ISC",
+ "bin": {
+ "jsonrepair": "bin/cli.js"
+ }
+ },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -4919,6 +4952,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/state-local": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
+ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
+ "license": "MIT"
+ },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index b205ebd..94cb544 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,11 +12,14 @@
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
+ "@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^5.15.0",
"@mui/material": "^5.15.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"ansi-to-react": "^6.1.6",
+ "js-yaml": "^4.1.1",
+ "jsonrepair": "^3.13.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0"
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index cae4ab1..b42ce4d 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -13,6 +13,9 @@ import ScriptsPage from './components/ScriptsPage';
import LocalScripts from './components/LocalScripts';
import RemoteScripts from './components/RemoteScripts';
import Terminal from './components/Terminal';
+import ToolsPage from './components/ToolsPage';
+import YamlValidator from './components/YamlValidator';
+import JsonValidator from './components/JsonValidator';
/**
* Main App component - handles theme management and renders the application with routing
@@ -53,6 +56,9 @@ function App() {
} />
} />
} />
+ } />
+ } />
+ } />
diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx
index 5517e6b..14e973f 100644
--- a/frontend/src/components/Dashboard.jsx
+++ b/frontend/src/components/Dashboard.jsx
@@ -17,6 +17,7 @@ import {
PlayArrow,
CloudQueue,
Computer,
+ Build,
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
@@ -87,6 +88,14 @@ const Dashboard = () => {
action: 'Open Terminal',
onClick: () => navigate('/terminal'),
},
+ // Row 5: Developer Tools
+ {
+ icon: ,
+ title: 'Tools',
+ description: 'Validate and format YAML, JSON, and other data formats',
+ action: 'Open Tools',
+ onClick: () => navigate('/tools'),
+ },
];
return (
diff --git a/frontend/src/components/JsonValidator.jsx b/frontend/src/components/JsonValidator.jsx
new file mode 100644
index 0000000..6a3737e
--- /dev/null
+++ b/frontend/src/components/JsonValidator.jsx
@@ -0,0 +1,426 @@
+import React, { useState, useRef } from 'react';
+import Editor, { DiffEditor } from '@monaco-editor/react';
+import { jsonrepair } from 'jsonrepair';
+import {
+ Paper,
+ Grid,
+ Button,
+ Typography,
+ Box,
+ Snackbar,
+ Alert,
+ Collapse,
+ IconButton,
+} from '@mui/material';
+import { useTheme } from '@mui/material/styles';
+import {
+ CheckCircle,
+ FormatAlignLeft,
+ AutoFixHigh,
+ ContentCopy,
+ Clear,
+ CompareArrows,
+ Close,
+} from '@mui/icons-material';
+import ToolLayout from './shared/ToolLayout';
+import ValidationResult from './shared/ValidationResult';
+
+/**
+ * JsonValidator - JSON validation, formatting, and beautification tool
+ * Uses Monaco Editor for syntax highlighting and native JSON.parse for validation
+ */
+const JsonValidator = () => {
+ const [input, setInput] = useState('');
+ const [output, setOutput] = useState('');
+ const [errors, setErrors] = useState([]);
+ const [isValid, setIsValid] = useState(null);
+ const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' });
+ const [showDiff, setShowDiff] = useState(false);
+ const [diffOriginal, setDiffOriginal] = useState('');
+ const [diffModified, setDiffModified] = useState('');
+ const editorRef = useRef(null);
+
+ const theme = useTheme();
+ const isDarkMode = theme.palette.mode === 'dark';
+
+ // Store editor reference for programmatic updates
+ const handleEditorDidMount = (editor) => {
+ editorRef.current = editor;
+ };
+
+ // Update editor value programmatically
+ const updateEditorValue = (newValue) => {
+ setInput(newValue);
+ if (editorRef.current) {
+ const model = editorRef.current.getModel();
+ if (model) {
+ model.setValue(newValue);
+ }
+ }
+ };
+
+ // Helper to extract line/column from JSON parse error
+ const parseJsonError = (error, jsonString) => {
+ const message = error.message;
+
+ // Try to extract position from error message
+ // Format: "... at position 123" or "Unexpected token ... at position 123"
+ const positionMatch = message.match(/position\s+(\d+)/i);
+
+ if (positionMatch) {
+ const position = parseInt(positionMatch[1], 10);
+ const lines = jsonString.substring(0, position).split('\n');
+ const line = lines.length;
+ const column = lines[lines.length - 1].length + 1;
+
+ return {
+ line,
+ column,
+ message: message,
+ };
+ }
+
+ return { message };
+ };
+
+ // Validate JSON syntax
+ const handleValidate = () => {
+ if (!input.trim()) {
+ setErrors([{ message: 'Please enter some JSON content to validate' }]);
+ setIsValid(false);
+ return;
+ }
+
+ try {
+ JSON.parse(input);
+ setErrors([]);
+ setIsValid(true);
+ setOutput(input);
+ } catch (e) {
+ const errorInfo = parseJsonError(e, input);
+ setErrors([errorInfo]);
+ setIsValid(false);
+ setOutput('');
+ }
+ };
+
+ // Format/beautify JSON with configurable indentation
+ const handleFormat = () => {
+ if (!input.trim()) {
+ showSnackbar('Please enter some JSON content first', 'warning');
+ return;
+ }
+
+ try {
+ const parsed = JSON.parse(input);
+ const formatted = JSON.stringify(parsed, null, 2);
+ setOutput(formatted);
+ setErrors([]);
+ setIsValid(true);
+ showSnackbar('JSON formatted successfully', 'success');
+ } catch (e) {
+ const errorInfo = parseJsonError(e, input);
+ setErrors([errorInfo]);
+ setIsValid(false);
+ }
+ };
+
+ // Auto-fix JSON issues using jsonrepair library
+ // Handles: trailing commas, missing quotes, missing commas, missing brackets,
+ // comments, single quotes, unquoted keys, and structural issues
+ const handleAutoFix = () => {
+ if (!input.trim()) {
+ showSnackbar('Please enter some JSON content first', 'warning');
+ return;
+ }
+
+ try {
+ const originalInput = input;
+
+ // Use jsonrepair to fix structural and syntax issues
+ const repaired = jsonrepair(input);
+
+ // Parse and re-stringify for consistent formatting
+ const parsed = JSON.parse(repaired);
+ const formatted = JSON.stringify(parsed, null, 2);
+
+ // Detect what was fixed
+ const fixes = [];
+ if (originalInput !== repaired) {
+ if (/,\s*[}\]]/.test(originalInput)) fixes.push('trailing commas');
+ if (/\/\/|\/\*/.test(originalInput)) fixes.push('comments');
+ if (/'/.test(originalInput) && !/'/.test(formatted)) fixes.push('quotes');
+ if (/}\s*,\s*}/.test(originalInput)) fixes.push('structural issues');
+ if ((originalInput.match(/{/g) || []).length !== (originalInput.match(/}/g) || []).length) {
+ fixes.push('mismatched braces');
+ }
+ if ((originalInput.match(/\[/g) || []).length !== (originalInput.match(/]/g) || []).length) {
+ fixes.push('mismatched brackets');
+ }
+ // Generic fix message if we can't detect specifics
+ if (fixes.length === 0) fixes.push('syntax errors');
+ }
+
+ updateEditorValue(formatted);
+ setOutput(formatted);
+ setErrors([]);
+ setIsValid(true);
+
+ // Set diff state to show changes
+ if (originalInput !== formatted) {
+ setDiffOriginal(originalInput);
+ setDiffModified(formatted);
+ setShowDiff(true);
+ }
+
+ if (fixes.length > 0) {
+ showSnackbar(`Auto-fixed: ${fixes.join(', ')}. View diff below.`, 'success');
+ } else {
+ showSnackbar('JSON is already valid, formatted successfully', 'success');
+ }
+ } catch (repairError) {
+ // jsonrepair couldn't fix it - show the error
+ const errorInfo = parseJsonError(repairError, input);
+ setErrors([errorInfo]);
+ setIsValid(false);
+ setShowDiff(false);
+ showSnackbar('Could not auto-fix: ' + repairError.message, 'error');
+ }
+ };
+
+ // Minify JSON (compact format)
+ const handleMinify = () => {
+ if (!input.trim()) {
+ showSnackbar('Please enter some JSON content first', 'warning');
+ return;
+ }
+
+ try {
+ const parsed = JSON.parse(input);
+ const minified = JSON.stringify(parsed);
+ setOutput(minified);
+ setErrors([]);
+ setIsValid(true);
+ showSnackbar('JSON minified successfully', 'success');
+ } catch (e) {
+ const errorInfo = parseJsonError(e, input);
+ setErrors([errorInfo]);
+ setIsValid(false);
+ }
+ };
+
+ // Copy output to clipboard
+ const handleCopy = async () => {
+ if (!output) {
+ showSnackbar('No output to copy', 'warning');
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(output);
+ showSnackbar('Copied to clipboard!', 'success');
+ } catch (err) {
+ showSnackbar('Failed to copy to clipboard', 'error');
+ }
+ };
+
+ // Clear all state
+ const handleClear = () => {
+ updateEditorValue('');
+ setOutput('');
+ setErrors([]);
+ setIsValid(null);
+ setShowDiff(false);
+ setDiffOriginal('');
+ setDiffModified('');
+ };
+
+ // Show snackbar notification
+ const showSnackbar = (message, severity = 'success') => {
+ setSnackbar({ open: true, message, severity });
+ };
+
+ const handleCloseSnackbar = () => {
+ setSnackbar({ ...snackbar, open: false });
+ };
+
+ // Monaco editor options
+ const editorOptions = {
+ minimap: { enabled: false },
+ fontSize: 14,
+ lineNumbers: 'on',
+ scrollBeyondLastLine: false,
+ wordWrap: 'on',
+ automaticLayout: true,
+ formatOnPaste: true,
+ };
+
+ return (
+
+
+
+ {/* Input Section */}
+
+
+ Input
+
+
+ setInput(value || '')}
+ onMount={handleEditorDidMount}
+ options={editorOptions}
+ />
+
+
+ }
+ onClick={handleValidate}
+ aria-label="Validate JSON syntax"
+ >
+ Validate
+
+ }
+ onClick={handleFormat}
+ aria-label="Format and beautify JSON"
+ >
+ Format
+
+
+ }
+ onClick={handleAutoFix}
+ aria-label="Auto-fix common JSON issues"
+ >
+ Auto-Fix
+
+ }
+ onClick={handleClear}
+ aria-label="Clear input and output"
+ >
+ Clear
+
+
+
+
+ {/* Output Section */}
+
+
+ Output
+
+
+
+
+
+ }
+ onClick={handleCopy}
+ disabled={!output}
+ aria-label="Copy output to clipboard"
+ >
+ Copy Output
+
+ {showDiff && (
+ }
+ onClick={() => setShowDiff(!showDiff)}
+ color="info"
+ aria-label="Hide diff comparison view"
+ >
+ Hide Diff
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Diff View - Shows changes made by Auto-Fix */}
+
+
+
+
+ Changes Made by Auto-Fix
+
+ setShowDiff(false)} size="small" aria-label="Close diff view">
+
+
+
+
+ Red = removed, Green = added. Left side shows original, right side shows fixed version.
+
+
+
+
+
+
+
+ {/* Snackbar for notifications */}
+
+
+ {snackbar.message}
+
+
+
+ );
+};
+
+export default JsonValidator;
diff --git a/frontend/src/components/ToolsPage.jsx b/frontend/src/components/ToolsPage.jsx
new file mode 100644
index 0000000..f842f2c
--- /dev/null
+++ b/frontend/src/components/ToolsPage.jsx
@@ -0,0 +1,101 @@
+import React from 'react';
+import {
+ Container,
+ Grid,
+ Card,
+ CardContent,
+ Typography,
+ Button,
+ Box,
+} from '@mui/material';
+import { ArrowBack, Code, DataObject } from '@mui/icons-material';
+import { useNavigate } from 'react-router-dom';
+
+/**
+ * ToolsPage - Hub page displaying available developer tools as cards
+ * Follows the same pattern as Dashboard for consistency
+ */
+const ToolsPage = () => {
+ const navigate = useNavigate();
+
+ const tools = [
+ {
+ icon: ,
+ title: 'YAML Validator',
+ description: 'Validate, format, and auto-fix YAML documents with syntax highlighting',
+ action: 'Open YAML Tool',
+ onClick: () => navigate('/tools/yaml'),
+ },
+ {
+ icon: ,
+ title: 'JSON Validator',
+ description: 'Validate, format, and beautify JSON data with error detection',
+ action: 'Open JSON Tool',
+ onClick: () => navigate('/tools/json'),
+ },
+ ];
+
+ return (
+
+
+ }
+ onClick={() => navigate('/')}
+ sx={{ mb: 2 }}
+ >
+ Back to Dashboard
+
+
+
+
+
+ Developer Tools
+
+
+ Validate, format, and fix common data formats
+
+
+
+
+ {tools.map((tool, index) => (
+
+
+
+
+ {tool.icon}
+
+
+ {tool.title}
+
+
+ {tool.description}
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default ToolsPage;
diff --git a/frontend/src/components/YamlValidator.jsx b/frontend/src/components/YamlValidator.jsx
new file mode 100644
index 0000000..7f67898
--- /dev/null
+++ b/frontend/src/components/YamlValidator.jsx
@@ -0,0 +1,507 @@
+import React, { useState, useRef } from 'react';
+import Editor, { DiffEditor } from '@monaco-editor/react';
+import yaml from 'js-yaml';
+import {
+ Paper,
+ Grid,
+ Button,
+ Typography,
+ Box,
+ Snackbar,
+ Alert,
+ Collapse,
+ IconButton,
+} from '@mui/material';
+import { useTheme } from '@mui/material/styles';
+import {
+ CheckCircle,
+ FormatAlignLeft,
+ AutoFixHigh,
+ ContentCopy,
+ Clear,
+ CompareArrows,
+ Close,
+} from '@mui/icons-material';
+import ToolLayout from './shared/ToolLayout';
+import ValidationResult from './shared/ValidationResult';
+
+/**
+ * YamlValidator - YAML validation, formatting, and auto-fix tool
+ * Uses Monaco Editor for syntax highlighting and js-yaml for parsing
+ */
+const YamlValidator = () => {
+ const [input, setInput] = useState('');
+ const [output, setOutput] = useState('');
+ const [errors, setErrors] = useState([]);
+ const [isValid, setIsValid] = useState(null);
+ const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' });
+ const [showDiff, setShowDiff] = useState(false);
+ const [diffOriginal, setDiffOriginal] = useState('');
+ const [diffModified, setDiffModified] = useState('');
+ const editorRef = useRef(null);
+
+ const theme = useTheme();
+ const isDarkMode = theme.palette.mode === 'dark';
+
+ // Store editor reference for programmatic updates
+ const handleEditorDidMount = (editor) => {
+ editorRef.current = editor;
+ };
+
+ // Update editor value programmatically
+ const updateEditorValue = (newValue) => {
+ setInput(newValue);
+ if (editorRef.current) {
+ const model = editorRef.current.getModel();
+ if (model) {
+ model.setValue(newValue);
+ }
+ }
+ };
+
+ // Validate YAML syntax
+ const handleValidate = () => {
+ if (!input.trim()) {
+ setErrors([{ message: 'Please enter some YAML content to validate' }]);
+ setIsValid(false);
+ return;
+ }
+
+ try {
+ yaml.load(input, { schema: yaml.DEFAULT_SCHEMA });
+ setErrors([]);
+ setIsValid(true);
+ setOutput(input);
+ } catch (e) {
+ if (e.mark) {
+ setErrors([{
+ line: e.mark.line + 1,
+ column: e.mark.column + 1,
+ message: e.reason || e.message,
+ }]);
+ } else {
+ setErrors([{ message: e.message }]);
+ }
+ setIsValid(false);
+ setOutput('');
+ }
+ };
+
+ // Format/beautify YAML
+ const handleFormat = () => {
+ if (!input.trim()) {
+ showSnackbar('Please enter some YAML content first', 'warning');
+ return;
+ }
+
+ try {
+ const parsed = yaml.load(input);
+ const formatted = yaml.dump(parsed, {
+ indent: 2,
+ lineWidth: -1,
+ noRefs: true,
+ sortKeys: false,
+ });
+ setOutput(formatted);
+ setErrors([]);
+ setIsValid(true);
+ showSnackbar('YAML formatted successfully', 'success');
+ } catch (e) {
+ if (e.mark) {
+ setErrors([{
+ line: e.mark.line + 1,
+ column: e.mark.column + 1,
+ message: e.reason || e.message,
+ }]);
+ } else {
+ setErrors([{ message: e.message }]);
+ }
+ setIsValid(false);
+ }
+ };
+
+ // Auto-fix common YAML issues
+ const handleAutoFix = () => {
+ if (!input.trim()) {
+ showSnackbar('Please enter some YAML content first', 'warning');
+ return;
+ }
+
+ const originalInput = input;
+ let fixed = input;
+ let fixes = [];
+
+ // Remove BOM if present
+ if (fixed.charCodeAt(0) === 0xFEFF) {
+ fixed = fixed.slice(1);
+ fixes.push('BOM removed');
+ }
+
+ // Convert tabs to 2 spaces (YAML doesn't allow tabs for indentation)
+ if (fixed.includes('\t')) {
+ fixed = fixed.replace(/\t/g, ' ');
+ fixes.push('tabs converted to spaces');
+ }
+
+ // Remove trailing whitespace from each line
+ const beforeTrailing = fixed;
+ fixed = fixed.replace(/[ \t]+$/gm, '');
+ if (beforeTrailing !== fixed) {
+ fixes.push('trailing whitespace removed');
+ }
+
+ // Remove multiple consecutive blank lines
+ const beforeBlankLines = fixed;
+ fixed = fixed.replace(/\n{3,}/g, '\n\n');
+ if (beforeBlankLines !== fixed) {
+ fixes.push('extra blank lines removed');
+ }
+
+ // Quote values that contain ': ' (colon-space) which confuses the parser
+ // This is needed because js-yaml is strict about colons in unquoted values
+ const quoteProblematicValues = (text) => {
+ const lines = text.split('\n');
+ const result = [];
+
+ for (const line of lines) {
+ // Skip empty lines and comments
+ if (!line.trim() || line.trim().startsWith('#')) {
+ result.push(line);
+ continue;
+ }
+
+ // Match key: value pattern
+ const match = line.match(/^(\s*)([^:]+):\s+(.+)$/);
+ if (match) {
+ const [, indent, key, value] = match;
+ // Check if value contains ': ' and is not already quoted
+ if (value.includes(': ') && !value.startsWith('"') && !value.startsWith("'")) {
+ // Quote the value with double quotes, escaping any existing quotes
+ const escapedValue = value.replace(/"/g, '\\"');
+ result.push(`${indent}${key}: "${escapedValue}"`);
+ continue;
+ }
+ }
+ result.push(line);
+ }
+
+ return result.join('\n');
+ };
+
+ const beforeQuoting = fixed;
+ fixed = quoteProblematicValues(fixed);
+ if (beforeQuoting !== fixed) {
+ fixes.push('problematic values quoted');
+ }
+
+ // Normalize indentation to multiples of 2 spaces
+ // This fixes cases like 3-space indent which YAML doesn't accept
+ const normalizeIndentation = (text) => {
+ const lines = text.split('\n');
+ const result = [];
+
+ for (const line of lines) {
+ // Skip empty lines - preserve as-is
+ if (!line.trim()) {
+ result.push(line);
+ continue;
+ }
+
+ // Get leading whitespace
+ const match = line.match(/^(\s*)/);
+ const indent = match ? match[1].length : 0;
+ const content = line.trimStart();
+
+ // Round indent to nearest multiple of 2
+ // If indent is odd, round down (e.g., 3 -> 2, 5 -> 4)
+ const normalizedIndent = Math.floor(indent / 2) * 2;
+
+ result.push(' '.repeat(normalizedIndent) + content);
+ }
+
+ return result.join('\n');
+ };
+
+ const beforeIndentNorm = fixed;
+ fixed = normalizeIndentation(fixed);
+ if (beforeIndentNorm !== fixed) {
+ fixes.push('indentation normalized');
+ }
+
+ // Ensure trailing newline
+ if (fixed.length > 0 && !fixed.endsWith('\n')) {
+ fixed += '\n';
+ fixes.push('trailing newline added');
+ }
+
+ // Try to parse and re-format for consistent output
+ // This will also fix indentation issues by re-serializing
+ try {
+ const parsed = yaml.load(fixed);
+ const formatted = yaml.dump(parsed, {
+ indent: 2,
+ lineWidth: -1,
+ noRefs: true,
+ sortKeys: false,
+ });
+
+ fixed = formatted;
+ fixes.push('reformatted');
+
+ setErrors([]);
+ setIsValid(true);
+ setOutput(formatted);
+
+ updateEditorValue(fixed);
+
+ // Set diff state to show changes
+ if (originalInput !== fixed) {
+ setDiffOriginal(originalInput);
+ setDiffModified(fixed);
+ setShowDiff(true);
+ }
+
+ if (fixes.length > 0) {
+ showSnackbar(`Auto-fixed: ${fixes.join(', ')}. View diff below.`, 'success');
+ } else {
+ showSnackbar('No issues found to fix', 'info');
+ }
+ } catch (e) {
+ // If parsing fails, show the error and the partially fixed content
+ if (e.mark) {
+ setErrors([{
+ line: e.mark.line + 1,
+ column: e.mark.column + 1,
+ message: e.reason || e.message,
+ }]);
+ } else {
+ setErrors([{ message: e.message }]);
+ }
+ setIsValid(false);
+ setOutput('');
+
+ // Still update the editor with partial fixes (tabs, whitespace)
+ updateEditorValue(fixed);
+
+ if (originalInput !== fixed) {
+ setDiffOriginal(originalInput);
+ setDiffModified(fixed);
+ setShowDiff(true);
+ }
+
+ const partialFixes = fixes.filter(f => f !== 'reformatted');
+ if (partialFixes.length > 0) {
+ showSnackbar(`Partial fix applied: ${partialFixes.join(', ')}. Manual fixes still needed.`, 'warning');
+ } else {
+ showSnackbar('Could not auto-fix: ' + (e.reason || e.message), 'error');
+ }
+ }
+ };
+
+ // Copy output to clipboard
+ const handleCopy = async () => {
+ if (!output) {
+ showSnackbar('No output to copy', 'warning');
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(output);
+ showSnackbar('Copied to clipboard!', 'success');
+ } catch (err) {
+ showSnackbar('Failed to copy to clipboard', 'error');
+ }
+ };
+
+ // Clear all state
+ const handleClear = () => {
+ updateEditorValue('');
+ setOutput('');
+ setErrors([]);
+ setIsValid(null);
+ setShowDiff(false);
+ setDiffOriginal('');
+ setDiffModified('');
+ };
+
+ // Show snackbar notification
+ const showSnackbar = (message, severity = 'success') => {
+ setSnackbar({ open: true, message, severity });
+ };
+
+ const handleCloseSnackbar = () => {
+ setSnackbar({ ...snackbar, open: false });
+ };
+
+ // Monaco editor options
+ const editorOptions = {
+ minimap: { enabled: false },
+ fontSize: 14,
+ lineNumbers: 'on',
+ scrollBeyondLastLine: false,
+ wordWrap: 'on',
+ automaticLayout: true,
+ };
+
+ return (
+
+
+
+ {/* Input Section */}
+
+
+ Input
+
+
+ setInput(value || '')}
+ onMount={handleEditorDidMount}
+ options={editorOptions}
+ />
+
+
+ }
+ onClick={handleValidate}
+ aria-label="Validate YAML syntax"
+ >
+ Validate
+
+ }
+ onClick={handleFormat}
+ aria-label="Format and beautify YAML"
+ >
+ Format
+
+ }
+ onClick={handleAutoFix}
+ aria-label="Auto-fix common YAML issues"
+ >
+ Auto-Fix
+
+ }
+ onClick={handleClear}
+ aria-label="Clear input and output"
+ >
+ Clear
+
+
+
+
+ {/* Output Section */}
+
+
+ Output
+
+
+
+
+
+ }
+ onClick={handleCopy}
+ disabled={!output}
+ aria-label="Copy output to clipboard"
+ >
+ Copy Output
+
+ {showDiff && (
+ }
+ onClick={() => setShowDiff(!showDiff)}
+ color="info"
+ aria-label="Hide diff comparison view"
+ >
+ Hide Diff
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Diff View - Shows changes made by Auto-Fix */}
+
+
+
+
+ Changes Made by Auto-Fix
+
+ setShowDiff(false)} size="small" aria-label="Close diff view">
+
+
+
+
+ Red = removed, Green = added. Left side shows original, right side shows fixed version.
+
+
+
+
+
+
+
+ {/* Snackbar for notifications */}
+
+
+ {snackbar.message}
+
+
+
+ );
+};
+
+export default YamlValidator;
diff --git a/frontend/src/components/shared/ToolLayout.jsx b/frontend/src/components/shared/ToolLayout.jsx
new file mode 100644
index 0000000..d04c033
--- /dev/null
+++ b/frontend/src/components/shared/ToolLayout.jsx
@@ -0,0 +1,48 @@
+import React, { memo } from 'react';
+import PropTypes from 'prop-types';
+import { Container, Box, Typography, Button } from '@mui/material';
+import { ArrowBack } from '@mui/icons-material';
+import { useNavigate } from 'react-router-dom';
+
+/**
+ * ToolLayout - Common page layout wrapper for tool pages
+ * Provides consistent styling with back button, title, and description
+ */
+const ToolLayout = memo(({ title, description, backPath = '/tools', children }) => {
+ const navigate = useNavigate();
+
+ return (
+
+
+ }
+ onClick={() => navigate(backPath)}
+ sx={{ mb: 2 }}
+ aria-label="Navigate back to tools page"
+ >
+ Back to Tools
+
+
+ {title}
+
+
+ {description}
+
+
+
+ {children}
+
+
+ );
+});
+
+ToolLayout.displayName = 'ToolLayout';
+
+ToolLayout.propTypes = {
+ title: PropTypes.string.isRequired,
+ description: PropTypes.string,
+ backPath: PropTypes.string,
+ children: PropTypes.node.isRequired,
+};
+
+export default ToolLayout;
diff --git a/frontend/src/components/shared/ValidationResult.jsx b/frontend/src/components/shared/ValidationResult.jsx
new file mode 100644
index 0000000..36292a5
--- /dev/null
+++ b/frontend/src/components/shared/ValidationResult.jsx
@@ -0,0 +1,87 @@
+import React, { memo } from 'react';
+import PropTypes from 'prop-types';
+import { Alert, Typography, List, ListItem, ListItemIcon, ListItemText } from '@mui/material';
+import { CheckCircle, Error as ErrorIcon } from '@mui/icons-material';
+
+/**
+ * ValidationResult - Displays validation success or error messages
+ * Shows line numbers and descriptions for each error
+ */
+const ValidationResult = memo(({ isValid, errors = [], successMessage = 'Valid!' }) => {
+ // Not validated yet
+ if (isValid === null) {
+ return null;
+ }
+
+ // Valid state
+ if (isValid) {
+ return (
+ }
+ sx={{ mb: 2 }}
+ >
+ {successMessage}
+
+ );
+ }
+
+ // Error state
+ return (
+ }
+ sx={{ mb: 2 }}
+ >
+
+ Validation Failed
+
+ {errors.length > 0 && (
+
+ {errors.map((error, index) => (
+
+
+
+
+
+ {error.line && error.column
+ ? `Line ${error.line}, Column ${error.column}: `
+ : error.line
+ ? `Line ${error.line}: `
+ : ''
+ }
+ {error.message}
+
+ }
+ secondary={error.suggestion && (
+
+ Suggestion: {error.suggestion}
+
+ )}
+ />
+
+ ))}
+
+ )}
+
+ );
+});
+
+ValidationResult.displayName = 'ValidationResult';
+
+ValidationResult.propTypes = {
+ isValid: PropTypes.bool,
+ errors: PropTypes.arrayOf(
+ PropTypes.shape({
+ line: PropTypes.number,
+ column: PropTypes.number,
+ message: PropTypes.string.isRequired,
+ suggestion: PropTypes.string,
+ })
+ ),
+ successMessage: PropTypes.string,
+};
+
+export default ValidationResult;