diff --git a/src/webui/server.go b/src/webui/server.go index 0848d101..73f24979 100644 --- a/src/webui/server.go +++ b/src/webui/server.go @@ -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) + } +} diff --git a/src/webui/static/config-editor.js b/src/webui/static/config-editor.js new file mode 100644 index 00000000..d0f69468 --- /dev/null +++ b/src/webui/static/config-editor.js @@ -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) => + `${index + 1}` + ).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'; + } +} \ No newline at end of file diff --git a/src/webui/static/config.js b/src/webui/static/config.js index 28e8d0f5..575c200c 100644 --- a/src/webui/static/config.js +++ b/src/webui/static/config.js @@ -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() { + + ${configMeta.isWritable ? ` + ` : ''}
Вы уверены, что хотите сохранить изменения в конфигурационный файл?
-Файл: ${configMeta.path}
-Формат: ${configMeta.format.toUpperCase()}
-Резервная копия: Будет создана автоматически
-