Implement JSON configuration editor in WebUI with save and restart functionality. Enhance configuration handling by converting data to JSON format and adding validation features. Update styles for improved user experience.

This commit is contained in:
Andy Oknen 2025-08-15 15:59:00 +00:00
parent 4935fcf226
commit 0d0f524071
7 changed files with 650 additions and 352 deletions

View file

@ -8,8 +8,10 @@ import (
"fmt"
"net"
"net/http"
"os"
"strings"
"sync"
"syscall"
"time"
"github.com/yggdrasil-network/yggdrasil-go/src/admin"
@ -384,23 +386,25 @@ func (w *WebUIServer) callAdminHandler(command string, args map[string]interface
// Configuration response structures
type ConfigResponse struct {
ConfigPath string `json:"config_path"`
ConfigFormat string `json:"config_format"`
ConfigData interface{} `json:"config_data"`
IsWritable bool `json:"is_writable"`
ConfigPath string `json:"config_path"`
ConfigFormat string `json:"config_format"`
ConfigJSON string `json:"config_json"`
IsWritable bool `json:"is_writable"`
}
type ConfigSetRequest struct {
ConfigData interface{} `json:"config_data"`
ConfigPath string `json:"config_path,omitempty"`
Format string `json:"format,omitempty"`
ConfigJSON string `json:"config_json"`
ConfigPath string `json:"config_path,omitempty"`
Format string `json:"format,omitempty"`
Restart bool `json:"restart,omitempty"`
}
type ConfigSetResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
ConfigPath string `json:"config_path"`
BackupPath string `json:"backup_path,omitempty"`
Success bool `json:"success"`
Message string `json:"message"`
ConfigPath string `json:"config_path"`
BackupPath string `json:"backup_path,omitempty"`
RestartRequired bool `json:"restart_required"`
}
// getConfigHandler handles configuration file reading
@ -418,10 +422,18 @@ func (w *WebUIServer) getConfigHandler(rw http.ResponseWriter, r *http.Request)
return
}
// Convert config data to formatted JSON string
configBytes, err := json.MarshalIndent(configInfo.Data, "", " ")
if err != nil {
w.log.Errorf("Failed to marshal config to JSON: %v", err)
http.Error(rw, "Failed to format configuration", http.StatusInternalServerError)
return
}
response := ConfigResponse{
ConfigPath: configInfo.Path,
ConfigFormat: configInfo.Format,
ConfigData: configInfo.Data,
ConfigJSON: string(configBytes),
IsWritable: configInfo.Writable,
}
@ -445,8 +457,19 @@ func (w *WebUIServer) setConfigHandler(rw http.ResponseWriter, r *http.Request)
return
}
// Parse JSON configuration
var configData interface{}
if err := json.Unmarshal([]byte(req.ConfigJSON), &configData); err != nil {
response := ConfigSetResponse{
Success: false,
Message: fmt.Sprintf("Invalid JSON configuration: %v", err),
}
w.writeJSONResponse(rw, response)
return
}
// Use config package to save configuration
err := config.SaveConfig(req.ConfigData, req.ConfigPath, req.Format)
err := config.SaveConfig(configData, req.ConfigPath, req.Format)
if err != nil {
response := ConfigSetResponse{
Success: false,
@ -464,11 +487,19 @@ func (w *WebUIServer) setConfigHandler(rw http.ResponseWriter, r *http.Request)
}
response := ConfigSetResponse{
Success: true,
Message: "Configuration saved successfully",
ConfigPath: configPath,
BackupPath: configPath + ".backup",
Success: true,
Message: "Configuration saved successfully",
ConfigPath: configPath,
BackupPath: configPath + ".backup",
RestartRequired: req.Restart,
}
// If restart is requested, trigger server restart
if req.Restart {
w.log.Infof("Configuration saved with restart request")
go w.restartServer()
}
w.writeJSONResponse(rw, response)
}
@ -556,3 +587,23 @@ func (w *WebUIServer) Stop() error {
}
return nil
}
// restartServer triggers a graceful restart of the Yggdrasil process
func (w *WebUIServer) restartServer() {
w.log.Infof("Initiating server restart after configuration change")
// Give some time for the response to be sent
time.Sleep(1 * time.Second)
// Send SIGUSR1 signal to trigger a graceful restart
// This assumes the main process handles SIGUSR1 for restart
proc, err := os.FindProcess(os.Getpid())
if err != nil {
w.log.Errorf("Failed to find current process: %v", err)
return
}
if err := proc.Signal(syscall.SIGUSR1); err != nil {
w.log.Errorf("Failed to send restart signal: %v", err)
}
}

View file

@ -0,0 +1,313 @@
// JSON Editor functionality for configuration
// Initialize JSON editor with basic syntax highlighting and features
function initJSONEditor() {
const textarea = document.getElementById('config-json-textarea');
const lineNumbersContainer = document.getElementById('line-numbers-container');
const statusElement = document.getElementById('editor-status');
const cursorElement = document.getElementById('cursor-position');
if (!textarea) return;
// Set initial content
textarea.value = currentConfigJSON || '';
// Update line numbers
updateLineNumbers();
// Add event listeners
textarea.addEventListener('input', function() {
updateLineNumbers();
updateEditorStatus();
onConfigChange();
});
textarea.addEventListener('scroll', syncLineNumbers);
textarea.addEventListener('keydown', handleEditorKeydown);
textarea.addEventListener('selectionchange', updateCursorPosition);
textarea.addEventListener('click', updateCursorPosition);
textarea.addEventListener('keyup', updateCursorPosition);
// Initial status update
updateEditorStatus();
updateCursorPosition();
}
// Update line numbers
function updateLineNumbers() {
const textarea = document.getElementById('config-json-textarea');
const lineNumbersContainer = document.getElementById('line-numbers-container');
if (!textarea || !lineNumbersContainer) return;
const lines = textarea.value.split('\n');
const lineNumbers = lines.map((_, index) =>
`<span class="line-number">${index + 1}</span>`
).join('');
lineNumbersContainer.innerHTML = lineNumbers;
}
// Sync line numbers scroll with textarea
function syncLineNumbers() {
const textarea = document.getElementById('config-json-textarea');
const lineNumbersContainer = document.getElementById('line-numbers-container');
if (!textarea || !lineNumbersContainer) return;
lineNumbersContainer.scrollTop = textarea.scrollTop;
}
// Toggle line numbers visibility
function toggleLineNumbers() {
const checkbox = document.getElementById('line-numbers');
const lineNumbersContainer = document.getElementById('line-numbers-container');
const editorWrapper = document.querySelector('.editor-wrapper');
if (checkbox.checked) {
lineNumbersContainer.style.display = 'block';
editorWrapper.classList.add('with-line-numbers');
} else {
lineNumbersContainer.style.display = 'none';
editorWrapper.classList.remove('with-line-numbers');
}
}
// Handle special editor keydown events
function handleEditorKeydown(event) {
const textarea = event.target;
// Tab handling for indentation
if (event.key === 'Tab') {
event.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
if (event.shiftKey) {
// Remove indentation
const beforeCursor = textarea.value.substring(0, start);
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
const currentLine = textarea.value.substring(lineStart, end);
if (currentLine.startsWith(' ')) {
textarea.value = textarea.value.substring(0, lineStart) +
currentLine.substring(2) +
textarea.value.substring(end);
textarea.selectionStart = Math.max(lineStart, start - 2);
textarea.selectionEnd = end - 2;
}
} else {
// Add indentation
textarea.value = textarea.value.substring(0, start) +
' ' +
textarea.value.substring(end);
textarea.selectionStart = start + 2;
textarea.selectionEnd = start + 2;
}
updateLineNumbers();
updateEditorStatus();
}
// Auto-closing brackets and quotes
if (event.key === '{') {
insertMatchingCharacter(textarea, '{', '}');
event.preventDefault();
} else if (event.key === '[') {
insertMatchingCharacter(textarea, '[', ']');
event.preventDefault();
} else if (event.key === '"') {
insertMatchingCharacter(textarea, '"', '"');
event.preventDefault();
}
}
// Insert matching character (auto-closing)
function insertMatchingCharacter(textarea, open, close) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
textarea.value = textarea.value.substring(0, start) +
open + selectedText + close +
textarea.value.substring(end);
if (selectedText) {
textarea.selectionStart = start + 1;
textarea.selectionEnd = start + 1 + selectedText.length;
} else {
textarea.selectionStart = start + 1;
textarea.selectionEnd = start + 1;
}
}
// Update editor status (validation)
function updateEditorStatus() {
const textarea = document.getElementById('config-json-textarea');
const statusElement = document.getElementById('editor-status');
if (!textarea || !statusElement) return;
try {
if (textarea.value.trim() === '') {
statusElement.textContent = 'Пустая конфигурация';
statusElement.className = 'status-text warning';
return;
}
JSON.parse(textarea.value);
statusElement.textContent = 'Валидный JSON';
statusElement.className = 'status-text success';
} catch (error) {
statusElement.textContent = `Ошибка JSON: ${error.message}`;
statusElement.className = 'status-text error';
}
}
// Update cursor position display
function updateCursorPosition() {
const textarea = document.getElementById('config-json-textarea');
const cursorElement = document.getElementById('cursor-position');
if (!textarea || !cursorElement) return;
const cursorPos = textarea.selectionStart;
const beforeCursor = textarea.value.substring(0, cursorPos);
const line = beforeCursor.split('\n').length;
const column = beforeCursor.length - beforeCursor.lastIndexOf('\n');
cursorElement.textContent = `Строка ${line}, Столбец ${column}`;
}
// Format JSON with proper indentation
function formatJSON() {
const textarea = document.getElementById('config-json-textarea');
if (!textarea) return;
try {
const parsed = JSON.parse(textarea.value);
const formatted = JSON.stringify(parsed, null, 2);
textarea.value = formatted;
updateLineNumbers();
updateEditorStatus();
showNotification('JSON отформатирован', 'success');
} catch (error) {
showNotification(`Ошибка форматирования: ${error.message}`, 'error');
}
}
// Validate JSON configuration
function validateJSON() {
const textarea = document.getElementById('config-json-textarea');
if (!textarea) return;
try {
JSON.parse(textarea.value);
showNotification('JSON конфигурация валидна', 'success');
} catch (error) {
showNotification(`Ошибка валидации JSON: ${error.message}`, 'error');
}
}
// Save configuration
async function saveConfiguration(restart = false) {
const textarea = document.getElementById('config-json-textarea');
if (!textarea) {
showNotification('Редактор не найден', 'error');
return;
}
// Validate JSON before saving
try {
JSON.parse(textarea.value);
} catch (error) {
showNotification(`Невозможно сохранить: ${error.message}`, 'error');
return;
}
try {
const response = await fetch('/api/config/set', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({
config_json: textarea.value,
restart: restart
})
});
const result = await response.json();
if (result.success) {
currentConfigJSON = textarea.value;
if (restart) {
showNotification('Конфигурация сохранена. Сервер перезапускается...', 'success');
} else {
showNotification('Конфигурация сохранена успешно', 'success');
}
onConfigChange(); // Update button states
} else {
showNotification(`Ошибка сохранения: ${result.message}`, 'error');
}
} catch (error) {
console.error('Error saving configuration:', error);
showNotification(`Ошибка сохранения: ${error.message}`, 'error');
}
}
// Save configuration and restart server
async function saveAndRestartConfiguration() {
const confirmation = confirm('Сохранить конфигурацию и перезапустить сервер?\n\nВнимание: Соединение будет прервано на время перезапуска.');
if (confirmation) {
await saveConfiguration(true);
}
}
// Refresh configuration from server
async function refreshConfiguration() {
const textarea = document.getElementById('config-json-textarea');
// Check if there are unsaved changes
if (textarea && textarea.value !== currentConfigJSON) {
const confirmation = confirm('У вас есть несохраненные изменения. Продолжить обновление?');
if (!confirmation) {
return;
}
}
try {
await loadConfiguration();
showNotification('Конфигурация обновлена', 'success');
} catch (error) {
showNotification('Ошибка обновления конфигурации', 'error');
}
}
// Update configuration status
function updateConfigStatus() {
// This function can be used to show additional status information
// Currently handled by updateEditorStatus
}
// Handle configuration changes
function onConfigChange() {
// Mark configuration as modified
const saveBtn = document.querySelector('.save-btn');
const restartBtn = document.querySelector('.restart-btn');
const textarea = document.getElementById('config-json-textarea');
const hasChanges = textarea && textarea.value !== currentConfigJSON;
if (saveBtn) {
saveBtn.style.fontWeight = hasChanges ? 'bold' : 'normal';
}
if (restartBtn) {
restartBtn.style.fontWeight = hasChanges ? 'bold' : 'normal';
}
}

View file

@ -1,7 +1,8 @@
// Configuration management functions
let currentConfig = null;
let currentConfigJSON = null;
let configMeta = null;
let configEditor = null;
// Initialize config section
async function initConfigSection() {
@ -26,7 +27,7 @@ async function loadConfiguration() {
}
const data = await response.json();
currentConfig = data.config_data;
currentConfigJSON = data.config_json;
configMeta = {
path: data.config_path,
format: data.config_format,
@ -41,7 +42,7 @@ async function loadConfiguration() {
}
}
// Render configuration editor
// Render configuration editor with JSON syntax highlighting
function renderConfigEditor() {
const configSection = document.getElementById('config-section');
@ -62,17 +63,50 @@ function renderConfigEditor() {
<button onclick="refreshConfiguration()" class="action-btn refresh-btn" data-key="refresh">
🔄 Обновить
</button>
<button onclick="formatJSON()" class="action-btn format-btn" data-key="format">
📝 Форматировать
</button>
<button onclick="validateJSON()" class="action-btn validate-btn" data-key="validate">
Проверить
</button>
${configMeta.isWritable ? `
<button onclick="saveConfiguration()" class="action-btn save-btn" data-key="save_config">
💾 Сохранить
</button>
<button onclick="saveAndRestartConfiguration()" class="action-btn restart-btn" data-key="save_and_restart">
🔄 Сохранить и перезапустить
</button>
` : ''}
</div>
</div>
<div class="config-editor-container">
<div class="config-groups">
${renderConfigGroups()}
<div class="config-json-editor">
<div class="editor-header">
<span class="editor-title" data-key="json_configuration">JSON Конфигурация</span>
<div class="editor-controls">
<span class="line-numbers-toggle">
<input type="checkbox" id="line-numbers" checked onchange="toggleLineNumbers()">
<label for="line-numbers" data-key="line_numbers">Номера строк</label>
</span>
</div>
</div>
<div class="editor-wrapper">
<div class="line-numbers" id="line-numbers-container"></div>
<textarea
id="config-json-textarea"
class="json-editor"
spellcheck="false"
${configMeta.isWritable ? '' : 'readonly'}
placeholder="Загрузка конфигурации..."
oninput="onConfigChange()"
onscroll="syncLineNumbers()"
>${currentConfigJSON || ''}</textarea>
</div>
<div class="editor-status">
<span id="editor-status" class="status-text"></span>
<span id="cursor-position" class="cursor-position"></span>
</div>
</div>
</div>
</div>
@ -80,332 +114,5 @@ function renderConfigEditor() {
configSection.innerHTML = configEditor;
updateTexts();
}
// Render configuration groups
function renderConfigGroups() {
const groups = [
{
key: 'network',
title: 'Сетевые настройки',
fields: ['Peers', 'InterfacePeers', 'Listen', 'AllowedPublicKeys']
},
{
key: 'identity',
title: 'Идентификация',
fields: ['PrivateKey', 'PrivateKeyPath']
},
{
key: 'interface',
title: 'Сетевой интерфейс',
fields: ['IfName', 'IfMTU']
},
{
key: 'multicast',
title: 'Multicast',
fields: ['MulticastInterfaces']
},
{
key: 'admin',
title: 'Администрирование',
fields: ['AdminListen']
},
{
key: 'webui',
title: 'Веб-интерфейс',
fields: ['WebUI']
},
{
key: 'nodeinfo',
title: 'Информация об узле',
fields: ['NodeInfo', 'NodeInfoPrivacy', 'LogLookups']
}
];
return groups.map(group => `
<div class="config-group">
<div class="config-group-header" onclick="toggleConfigGroup('${group.key}')">
<h4>${group.title}</h4>
<span class="toggle-icon"></span>
</div>
<div class="config-group-content" id="config-group-${group.key}">
${group.fields.map(field => renderConfigField(field)).join('')}
</div>
</div>
`).join('');
}
// Render individual config field
function renderConfigField(fieldName) {
const value = currentConfig[fieldName];
const fieldType = getFieldType(fieldName, value);
const fieldDescription = getFieldDescription(fieldName);
return `
<div class="config-field">
<div class="config-field-header">
<label for="config-${fieldName}">${fieldName}</label>
<span class="field-type">${fieldType}</span>
</div>
<div class="config-field-description">${fieldDescription}</div>
<div class="config-field-input">
${renderConfigInput(fieldName, value, fieldType)}
</div>
</div>
`;
}
// Render config input based on type
function renderConfigInput(fieldName, value, fieldType) {
switch (fieldType) {
case 'boolean':
return `
<label class="switch">
<input type="checkbox" id="config-${fieldName}"
${value ? 'checked' : ''}
onchange="updateConfigValue('${fieldName}', this.checked)">
<span class="slider"></span>
</label>
`;
case 'number':
return `
<input type="number" id="config-${fieldName}"
value="${value || ''}"
onchange="updateConfigValue('${fieldName}', parseInt(this.value) || 0)"
class="config-input">
`;
case 'string':
return `
<input type="text" id="config-${fieldName}"
value="${value || ''}"
onchange="updateConfigValue('${fieldName}', this.value)"
class="config-input">
`;
case 'array':
return `
<div class="array-input">
<textarea id="config-${fieldName}"
rows="4"
onchange="updateConfigArrayValue('${fieldName}', this.value)"
class="config-textarea">${Array.isArray(value) ? value.join('\n') : ''}</textarea>
<small>Одно значение на строку</small>
</div>
`;
case 'object':
return `
<div class="object-input">
<textarea id="config-${fieldName}"
rows="6"
onchange="updateConfigObjectValue('${fieldName}', this.value)"
class="config-textarea">${JSON.stringify(value, null, 2)}</textarea>
<small>JSON формат</small>
</div>
`;
case 'private_key':
return `
<div class="private-key-input">
<input type="password" id="config-${fieldName}"
value="${value ? '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••' : ''}"
readonly
class="config-input private-key">
<small>Приватный ключ (только для чтения)</small>
</div>
`;
default:
return `
<textarea id="config-${fieldName}"
rows="3"
onchange="updateConfigValue('${fieldName}', this.value)"
class="config-textarea">${JSON.stringify(value, null, 2)}</textarea>
`;
}
}
// Get field type for rendering
function getFieldType(fieldName, value) {
if (fieldName === 'PrivateKey') return 'private_key';
if (fieldName.includes('MTU') || fieldName === 'Port') return 'number';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return 'number';
if (typeof value === 'string') return 'string';
if (Array.isArray(value)) return 'array';
if (typeof value === 'object') return 'object';
return 'string';
}
// Get field description
function getFieldDescription(fieldName) {
const descriptions = {
'Peers': 'Список исходящих peer соединений (например: tls://адрес:порт)',
'InterfacePeers': 'Peer соединения по интерфейсам',
'Listen': 'Адреса для входящих соединений',
'AllowedPublicKeys': 'Разрешенные публичные ключи для входящих соединений',
'PrivateKey': 'Приватный ключ узла (НЕ ПЕРЕДАВАЙТЕ НИКОМУ!)',
'PrivateKeyPath': 'Путь к файлу с приватным ключом в формате PEM',
'IfName': 'Имя TUN интерфейса ("auto" для автовыбора, "none" для отключения)',
'IfMTU': 'Максимальный размер передаваемого блока (MTU) для TUN интерфейса',
'MulticastInterfaces': 'Настройки multicast интерфейсов для обнаружения peers',
'AdminListen': 'Адрес для подключения админского интерфейса',
'WebUI': 'Настройки веб-интерфейса',
'NodeInfo': 'Дополнительная информация об узле (видна всей сети)',
'NodeInfoPrivacy': 'Скрыть информацию о платформе и версии',
'LogLookups': 'Логировать поиск peers и узлов'
};
return descriptions[fieldName] || 'Параметр конфигурации';
}
// Update config value
function updateConfigValue(fieldName, value) {
if (currentConfig) {
currentConfig[fieldName] = value;
markConfigAsModified();
}
}
// Update array config value
function updateConfigArrayValue(fieldName, value) {
if (currentConfig) {
const lines = value.split('\n').filter(line => line.trim() !== '');
currentConfig[fieldName] = lines;
markConfigAsModified();
}
}
// Update object config value
function updateConfigObjectValue(fieldName, value) {
if (currentConfig) {
try {
currentConfig[fieldName] = JSON.parse(value);
markConfigAsModified();
} catch (error) {
console.error('Invalid JSON for field', fieldName, ':', error);
}
}
}
// Mark config as modified
function markConfigAsModified() {
const saveButton = document.querySelector('.save-btn');
if (saveButton) {
saveButton.classList.add('modified');
}
}
// Toggle config group
function toggleConfigGroup(groupKey) {
const content = document.getElementById(`config-group-${groupKey}`);
const icon = content.parentNode.querySelector('.toggle-icon');
if (content.style.display === 'none') {
content.style.display = 'block';
icon.textContent = '▼';
} else {
content.style.display = 'none';
icon.textContent = '▶';
}
}
// Update config status display
function updateConfigStatus() {
// This function could show config validation status, etc.
}
// Refresh configuration
async function refreshConfiguration() {
try {
await loadConfiguration();
showNotification('Конфигурация обновлена', 'success');
} catch (error) {
showNotification('Ошибка обновления конфигурации', 'error');
}
}
// Save configuration
async function saveConfiguration() {
if (!configMeta.isWritable) {
showNotification('Файл конфигурации доступен только для чтения', 'error');
return;
}
showModal({
title: 'config_save_confirm_title',
content: `
<div class="save-config-confirmation">
<p data-key="config_save_confirm_text">Вы уверены, что хотите сохранить изменения в конфигурационный файл?</p>
<div class="config-save-info">
<p><strong>Файл:</strong> ${configMeta.path}</p>
<p><strong>Формат:</strong> ${configMeta.format.toUpperCase()}</p>
<p><strong data-key="config_backup_info">Резервная копия:</strong> Будет создана автоматически</p>
</div>
<div class="warning">
<span data-key="config_warning"> Внимание: Неправильная конфигурация может привести к сбою работы узла!</span>
</div>
</div>
`,
buttons: [
{
text: 'modal_cancel',
type: 'secondary',
action: 'close'
},
{
text: 'save_config',
type: 'danger',
callback: () => {
confirmSaveConfiguration();
return true; // Close modal
}
}
]
});
}
// Confirm and perform save
async function confirmSaveConfiguration() {
try {
const response = await fetch('/api/config/set', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({
config_data: currentConfig,
config_path: configMeta.path,
format: configMeta.format
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
showNotification(`Конфигурация сохранена: ${data.config_path}`, 'success');
if (data.backup_path) {
showNotification(`Резервная копия: ${data.backup_path}`, 'info');
}
// Remove modified indicator
const saveButton = document.querySelector('.save-btn');
if (saveButton) {
saveButton.classList.remove('modified');
}
} else {
showNotification(`Ошибка сохранения: ${data.message}`, 'error');
}
} catch (error) {
console.error('Error saving configuration:', error);
showNotification('Ошибка сохранения конфигурации', 'error');
}
initJSONEditor();
}

View file

@ -11,6 +11,7 @@
<script src="static/app.js"></script>
<script src="static/modal.js"></script>
<script src="static/config.js"></script>
<script src="static/config-editor.js"></script>
<script src="static/lang/ru.js"></script>
<script src="static/lang/en.js"></script>
</head>

View file

@ -104,6 +104,11 @@ window.translations.en = {
'configuration_file': 'Configuration File',
'refresh': 'Refresh',
'save_config': 'Save',
'save_and_restart': 'Save and Restart',
'format': 'Format',
'validate': 'Validate',
'json_configuration': 'JSON Configuration',
'line_numbers': 'Line Numbers',
'config_save_success': 'Configuration saved successfully',
'config_save_error': 'Error saving configuration',
'config_load_error': 'Error loading configuration',

View file

@ -104,6 +104,11 @@ window.translations.ru = {
'configuration_file': 'Файл конфигурации',
'refresh': 'Обновить',
'save_config': 'Сохранить',
'save_and_restart': 'Сохранить и перезапустить',
'format': 'Форматировать',
'validate': 'Проверить',
'json_configuration': 'JSON Конфигурация',
'line_numbers': 'Номера строк',
'config_save_success': 'Конфигурация сохранена успешно',
'config_save_error': 'Ошибка сохранения конфигурации',
'config_load_error': 'Ошибка загрузки конфигурации',

View file

@ -1912,3 +1912,219 @@ input:checked + .slider:before {
border-color: var(--border-warning);
color: var(--text-warning);
}
/* JSON Editor Styles */
.config-json-editor {
background: var(--bg-info-card);
border: 1px solid var(--border-card);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: var(--bg-nav-item);
border-bottom: 1px solid var(--border-card);
}
.editor-title {
font-weight: bold;
color: var(--text-heading);
font-size: 1.1em;
}
.editor-controls {
display: flex;
align-items: center;
gap: 15px;
}
.line-numbers-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.9em;
color: var(--text-muted);
}
.line-numbers-toggle input[type="checkbox"] {
margin: 0;
}
.editor-wrapper {
position: relative;
display: flex;
height: 500px;
font-family: 'Courier New', Monaco, 'Lucida Console', monospace;
}
.editor-wrapper.with-line-numbers .json-editor {
padding-left: 60px;
}
.line-numbers {
position: absolute;
left: 0;
top: 0;
width: 50px;
height: 100%;
background: var(--bg-nav-item);
border-right: 1px solid var(--border-card);
overflow: hidden;
font-size: 0.9em;
line-height: 1.4;
padding: 10px 5px;
box-sizing: border-box;
z-index: 1;
}
.line-number {
display: block;
text-align: right;
color: var(--text-muted);
user-select: none;
padding-right: 8px;
min-height: 1.4em;
}
.json-editor {
flex: 1;
border: none;
outline: none;
resize: none;
font-family: 'Courier New', Monaco, 'Lucida Console', monospace;
font-size: 14px;
line-height: 1.4;
padding: 10px;
background: var(--bg-info-card);
color: var(--text-body);
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
tab-size: 2;
}
.json-editor:focus {
outline: none;
background: var(--bg-info-card);
}
.json-editor::placeholder {
color: var(--text-muted);
font-style: italic;
}
.editor-status {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background: var(--bg-nav-item);
border-top: 1px solid var(--border-card);
font-size: 0.9em;
}
.status-text {
font-weight: 500;
}
.status-text.success {
color: var(--text-success);
}
.status-text.error {
color: var(--text-error);
}
.status-text.warning {
color: var(--text-warning);
}
.cursor-position {
color: var(--text-muted);
font-family: 'Courier New', monospace;
font-size: 0.8em;
}
/* Additional action buttons */
.format-btn {
background: var(--bg-nav-active);
color: white;
}
.validate-btn {
background: var(--bg-warning-dark);
color: white;
}
.restart-btn {
background: var(--text-error);
color: white;
}
.restart-btn:hover {
background: #c62828;
}
/* JSON Syntax highlighting (basic) */
.json-editor {
color: var(--text-body);
}
/* Mobile responsiveness for JSON editor */
@media (max-width: 768px) {
.editor-header {
flex-direction: column;
gap: 10px;
align-items: stretch;
}
.editor-controls {
justify-content: center;
}
.editor-wrapper {
height: 400px;
}
.json-editor {
font-size: 12px;
}
.line-numbers {
width: 40px;
padding: 10px 3px;
font-size: 0.8em;
}
.editor-wrapper.with-line-numbers .json-editor {
padding-left: 45px;
}
.config-actions {
flex-wrap: wrap;
}
.action-btn {
flex: 1;
min-width: 120px;
}
}
/* Dark theme support for JSON editor */
[data-theme="dark"] .config-json-editor,
[data-theme="dark"] .editor-header,
[data-theme="dark"] .editor-status,
[data-theme="dark"] .line-numbers {
background: var(--bg-info-card);
border-color: var(--border-card);
}
[data-theme="dark"] .json-editor {
background: var(--bg-info-card);
color: var(--text-body);
}