mirror of
https://github.com/yggdrasil-network/yggdrasil-go.git
synced 2025-08-24 16:05:07 +03:00
Implement configuration management in WebUI with API integration for loading and saving configurations
This commit is contained in:
parent
19710fbc19
commit
ee470d32a7
10 changed files with 1120 additions and 50 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,7 +1,6 @@
|
|||
**/TODO
|
||||
/yggdrasil
|
||||
/yggdrasilctl
|
||||
/yggdrasil.conf
|
||||
/yggdrasil.json
|
||||
/yggdrasil.*
|
||||
/run
|
||||
/test
|
|
@ -107,6 +107,7 @@ func main() {
|
|||
}
|
||||
|
||||
cfg := config.GenerateConfig()
|
||||
var configPath string
|
||||
var err error
|
||||
switch {
|
||||
case *ver:
|
||||
|
@ -124,6 +125,7 @@ func main() {
|
|||
}
|
||||
|
||||
case *useconffile != "":
|
||||
configPath = *useconffile
|
||||
f, err := os.Open(*useconffile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
@ -206,6 +208,9 @@ func main() {
|
|||
return
|
||||
}
|
||||
|
||||
// Set current config for web UI
|
||||
config.SetCurrentConfig(configPath, cfg)
|
||||
|
||||
n := &node{}
|
||||
|
||||
// Set up the Yggdrasil node itself.
|
||||
|
|
|
@ -30,6 +30,7 @@ import (
|
|||
"io"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/hjson/hjson-go/v4"
|
||||
|
@ -274,3 +275,190 @@ func (k *KeyBytes) UnmarshalJSON(b []byte) error {
|
|||
*k, err = hex.DecodeString(s)
|
||||
return err
|
||||
}
|
||||
|
||||
// ConfigInfo contains information about the configuration file
|
||||
type ConfigInfo struct {
|
||||
Path string `json:"path"`
|
||||
Format string `json:"format"`
|
||||
Data interface{} `json:"data"`
|
||||
Writable bool `json:"writable"`
|
||||
}
|
||||
|
||||
// Global variables to track the current configuration state
|
||||
var (
|
||||
currentConfigPath string
|
||||
currentConfigData *NodeConfig
|
||||
)
|
||||
|
||||
// SetCurrentConfig sets the current configuration data and path
|
||||
func SetCurrentConfig(path string, cfg *NodeConfig) {
|
||||
currentConfigPath = path
|
||||
currentConfigData = cfg
|
||||
}
|
||||
|
||||
// GetCurrentConfig returns the current configuration information
|
||||
func GetCurrentConfig() (*ConfigInfo, error) {
|
||||
var configPath string
|
||||
var configData *NodeConfig
|
||||
var format string = "hjson"
|
||||
var writable bool = false
|
||||
|
||||
// Use current config if available, otherwise try to read from default location
|
||||
if currentConfigPath != "" && currentConfigData != nil {
|
||||
configPath = currentConfigPath
|
||||
configData = currentConfigData
|
||||
} else {
|
||||
// Fallback to default path
|
||||
defaults := GetDefaults()
|
||||
configPath = defaults.DefaultConfigFile
|
||||
|
||||
// Try to read existing config file
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err == nil {
|
||||
cfg := GenerateConfig()
|
||||
if err := hjson.Unmarshal(data, cfg); err == nil {
|
||||
configData = cfg
|
||||
// Detect format
|
||||
var jsonTest interface{}
|
||||
if json.Unmarshal(data, &jsonTest) == nil {
|
||||
format = "json"
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to parse config file: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No config file exists, use default
|
||||
configData = GenerateConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// Detect format from file if path is known
|
||||
if configPath != "" {
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err == nil {
|
||||
var jsonTest interface{}
|
||||
if json.Unmarshal(data, &jsonTest) == nil {
|
||||
format = "json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if writable
|
||||
if configPath != "" {
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
// File exists, check if writable
|
||||
if file, err := os.OpenFile(configPath, os.O_WRONLY, 0); err == nil {
|
||||
writable = true
|
||||
file.Close()
|
||||
}
|
||||
} else {
|
||||
// File doesn't exist, check if directory is writable
|
||||
dir := filepath.Dir(configPath)
|
||||
if stat, err := os.Stat(dir); err == nil && stat.IsDir() {
|
||||
testFile := filepath.Join(dir, ".yggdrasil_write_test")
|
||||
if file, err := os.Create(testFile); err == nil {
|
||||
file.Close()
|
||||
os.Remove(testFile)
|
||||
writable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &ConfigInfo{
|
||||
Path: configPath,
|
||||
Format: format,
|
||||
Data: configData,
|
||||
Writable: writable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveConfig saves configuration to file
|
||||
func SaveConfig(configData interface{}, configPath, format string) error {
|
||||
// Validate config data
|
||||
var testConfig NodeConfig
|
||||
configBytes, err := json.Marshal(configData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config data: %v", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(configBytes, &testConfig); err != nil {
|
||||
return fmt.Errorf("invalid configuration data: %v", err)
|
||||
}
|
||||
|
||||
// Determine target path
|
||||
targetPath := configPath
|
||||
if targetPath == "" {
|
||||
if currentConfigPath != "" {
|
||||
targetPath = currentConfigPath
|
||||
} else {
|
||||
defaults := GetDefaults()
|
||||
targetPath = defaults.DefaultConfigFile
|
||||
}
|
||||
}
|
||||
|
||||
// Determine format if not specified
|
||||
targetFormat := format
|
||||
if targetFormat == "" {
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
data, readErr := os.ReadFile(targetPath)
|
||||
if readErr == nil {
|
||||
var jsonTest interface{}
|
||||
if json.Unmarshal(data, &jsonTest) == nil {
|
||||
targetFormat = "json"
|
||||
} else {
|
||||
targetFormat = "hjson"
|
||||
}
|
||||
}
|
||||
}
|
||||
if targetFormat == "" {
|
||||
targetFormat = "hjson"
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup if file exists
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
backupPath := targetPath + ".backup"
|
||||
if data, err := os.ReadFile(targetPath); err == nil {
|
||||
if err := os.WriteFile(backupPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to create backup: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(targetPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %v", err)
|
||||
}
|
||||
|
||||
// Marshal to target format
|
||||
var outputData []byte
|
||||
if targetFormat == "json" {
|
||||
outputData, err = json.MarshalIndent(configData, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal JSON: %v", err)
|
||||
}
|
||||
} else {
|
||||
outputData, err = hjson.Marshal(configData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal HJSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write file
|
||||
if err := os.WriteFile(targetPath, outputData, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
// Update current config if this is the current config file
|
||||
if targetPath == currentConfigPath {
|
||||
currentConfigData = &testConfig
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/yggdrasil-network/yggdrasil-go/src/admin"
|
||||
"github.com/yggdrasil-network/yggdrasil-go/src/config"
|
||||
"github.com/yggdrasil-network/yggdrasil-go/src/core"
|
||||
)
|
||||
|
||||
|
@ -381,6 +382,105 @@ func (w *WebUIServer) callAdminHandler(command string, args map[string]interface
|
|||
return w.admin.CallHandler(command, argsBytes)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type ConfigSetRequest struct {
|
||||
ConfigData interface{} `json:"config_data"`
|
||||
ConfigPath string `json:"config_path,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigSetResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
BackupPath string `json:"backup_path,omitempty"`
|
||||
}
|
||||
|
||||
// getConfigHandler handles configuration file reading
|
||||
func (w *WebUIServer) getConfigHandler(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Use config package to get current configuration
|
||||
configInfo, err := config.GetCurrentConfig()
|
||||
if err != nil {
|
||||
w.log.Errorf("Failed to get current config: %v", err)
|
||||
http.Error(rw, "Failed to get configuration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := ConfigResponse{
|
||||
ConfigPath: configInfo.Path,
|
||||
ConfigFormat: configInfo.Format,
|
||||
ConfigData: configInfo.Data,
|
||||
IsWritable: configInfo.Writable,
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(rw).Encode(response); err != nil {
|
||||
w.log.Errorf("Failed to encode config response: %v", err)
|
||||
http.Error(rw, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// setConfigHandler handles configuration file writing
|
||||
func (w *WebUIServer) setConfigHandler(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req ConfigSetRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(rw, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Use config package to save configuration
|
||||
err := config.SaveConfig(req.ConfigData, req.ConfigPath, req.Format)
|
||||
if err != nil {
|
||||
response := ConfigSetResponse{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
}
|
||||
w.writeJSONResponse(rw, response)
|
||||
return
|
||||
}
|
||||
|
||||
// Get current config info for response
|
||||
configInfo, err := config.GetCurrentConfig()
|
||||
var configPath string = req.ConfigPath
|
||||
if err == nil && configInfo != nil {
|
||||
configPath = configInfo.Path
|
||||
}
|
||||
|
||||
response := ConfigSetResponse{
|
||||
Success: true,
|
||||
Message: "Configuration saved successfully",
|
||||
ConfigPath: configPath,
|
||||
BackupPath: configPath + ".backup",
|
||||
}
|
||||
w.writeJSONResponse(rw, response)
|
||||
}
|
||||
|
||||
// writeJSONResponse helper function
|
||||
func (w *WebUIServer) writeJSONResponse(rw http.ResponseWriter, data interface{}) {
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(rw).Encode(data); err != nil {
|
||||
w.log.Errorf("Failed to encode JSON response: %v", err)
|
||||
http.Error(rw, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebUIServer) Start() error {
|
||||
// Validate listen address before starting
|
||||
if w.listen != "" {
|
||||
|
@ -415,6 +515,10 @@ func (w *WebUIServer) Start() error {
|
|||
// Admin API endpoints - with auth
|
||||
mux.HandleFunc("/api/admin/", w.authMiddleware(w.adminAPIHandler))
|
||||
|
||||
// Configuration API endpoints - with auth
|
||||
mux.HandleFunc("/api/config/get", w.authMiddleware(w.getConfigHandler))
|
||||
mux.HandleFunc("/api/config/set", w.authMiddleware(w.setConfigHandler))
|
||||
|
||||
// Setup static files handler (implementation varies by build)
|
||||
setupStaticHandler(mux, w)
|
||||
|
||||
|
|
411
src/webui/static/config.js
Normal file
411
src/webui/static/config.js
Normal file
|
@ -0,0 +1,411 @@
|
|||
// Configuration management functions
|
||||
|
||||
let currentConfig = null;
|
||||
let configMeta = null;
|
||||
|
||||
// Initialize config section
|
||||
async function initConfigSection() {
|
||||
try {
|
||||
await loadConfiguration();
|
||||
} catch (error) {
|
||||
console.error('Failed to load configuration:', error);
|
||||
showNotification('Ошибка загрузки конфигурации', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Load current configuration
|
||||
async function loadConfiguration() {
|
||||
try {
|
||||
const response = await fetch('/api/config/get', {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
currentConfig = data.config_data;
|
||||
configMeta = {
|
||||
path: data.config_path,
|
||||
format: data.config_format,
|
||||
isWritable: data.is_writable
|
||||
};
|
||||
|
||||
renderConfigEditor();
|
||||
updateConfigStatus();
|
||||
} catch (error) {
|
||||
console.error('Error loading configuration:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Render configuration editor
|
||||
function renderConfigEditor() {
|
||||
const configSection = document.getElementById('config-section');
|
||||
|
||||
const configEditor = `
|
||||
<div class="config-container">
|
||||
<div class="config-header">
|
||||
<div class="config-info">
|
||||
<h3 data-key="configuration_file">Файл конфигурации</h3>
|
||||
<div class="config-meta">
|
||||
<span class="config-path" title="${configMeta.path}">${configMeta.path}</span>
|
||||
<span class="config-format ${configMeta.format}">${configMeta.format.toUpperCase()}</span>
|
||||
<span class="config-status ${configMeta.isWritable ? 'writable' : 'readonly'}">
|
||||
${configMeta.isWritable ? '✏️ Редактируемый' : '🔒 Только чтение'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<button onclick="refreshConfiguration()" class="action-btn refresh-btn" data-key="refresh">
|
||||
🔄 Обновить
|
||||
</button>
|
||||
${configMeta.isWritable ? `
|
||||
<button onclick="saveConfiguration()" class="action-btn save-btn" data-key="save_config">
|
||||
💾 Сохранить
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-editor-container">
|
||||
<div class="config-groups">
|
||||
${renderConfigGroups()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
|
@ -10,16 +10,12 @@
|
|||
<script src="static/main.js"></script>
|
||||
<script src="static/app.js"></script>
|
||||
<script src="static/modal.js"></script>
|
||||
<script src="static/config.js"></script>
|
||||
<script src="static/lang/ru.js"></script>
|
||||
<script src="static/lang/en.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Decorative progress bar -->
|
||||
<div class="progress-timer-container">
|
||||
<div class="progress-timer-bar"></div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="header-content">
|
||||
|
@ -112,19 +108,7 @@
|
|||
</div>
|
||||
|
||||
<div id="config-section" class="content-section">
|
||||
<div class="info-grid">
|
||||
<div class="info-card">
|
||||
<h3 data-key="basic_settings">Основные настройки</h3>
|
||||
<p data-key="basic_settings_description">Базовая конфигурация узла</p>
|
||||
<small data-key="coming_soon">Функция в разработке...</small>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3 data-key="network_settings">Сетевые настройки</h3>
|
||||
<p data-key="network_settings_description">Параметры сетевого взаимодействия</p>
|
||||
<small data-key="coming_soon">Функция в разработке...</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading" data-key="loading">Загрузка конфигурации...</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
|
|
@ -98,5 +98,18 @@ window.translations.en = {
|
|||
'add_peer_modal_description': 'Enter peer URI to connect to a new network node',
|
||||
'peer_uri_label': 'Peer URI',
|
||||
'peer_uri_placeholder': 'tcp://example.com:54321',
|
||||
'peer_uri_help': 'Examples: tcp://example.com:54321, tls://peer.yggdrasil.network:443'
|
||||
'peer_uri_help': 'Examples: tcp://example.com:54321, tls://peer.yggdrasil.network:443',
|
||||
|
||||
// Configuration
|
||||
'configuration_file': 'Configuration File',
|
||||
'refresh': 'Refresh',
|
||||
'save_config': 'Save',
|
||||
'config_save_success': 'Configuration saved successfully',
|
||||
'config_save_error': 'Error saving configuration',
|
||||
'config_load_error': 'Error loading configuration',
|
||||
'config_readonly': 'Configuration file is read-only',
|
||||
'config_save_confirm_title': 'Confirm Save',
|
||||
'config_save_confirm_text': 'Are you sure you want to save changes to the configuration file?',
|
||||
'config_backup_info': 'Backup will be created automatically',
|
||||
'config_warning': '⚠️ Warning: Incorrect configuration may cause node failure!'
|
||||
};
|
|
@ -98,5 +98,18 @@ window.translations.ru = {
|
|||
'add_peer_modal_description': 'Введите URI пира для подключения к новому узлу сети',
|
||||
'peer_uri_label': 'URI пира',
|
||||
'peer_uri_placeholder': 'tcp://example.com:54321',
|
||||
'peer_uri_help': 'Примеры: tcp://example.com:54321, tls://peer.yggdrasil.network:443'
|
||||
'peer_uri_help': 'Примеры: tcp://example.com:54321, tls://peer.yggdrasil.network:443',
|
||||
|
||||
// Configuration
|
||||
'configuration_file': 'Файл конфигурации',
|
||||
'refresh': 'Обновить',
|
||||
'save_config': 'Сохранить',
|
||||
'config_save_success': 'Конфигурация сохранена успешно',
|
||||
'config_save_error': 'Ошибка сохранения конфигурации',
|
||||
'config_load_error': 'Ошибка загрузки конфигурации',
|
||||
'config_readonly': 'Файл конфигурации доступен только для чтения',
|
||||
'config_save_confirm_title': 'Подтверждение сохранения',
|
||||
'config_save_confirm_text': 'Вы уверены, что хотите сохранить изменения в конфигурационный файл?',
|
||||
'config_backup_info': 'Резервная копия будет создана автоматически',
|
||||
'config_warning': '⚠️ Внимание: Неправильная конфигурация может привести к сбою работы узла!'
|
||||
};
|
|
@ -167,6 +167,11 @@ function showSection(sectionName) {
|
|||
if (event && event.target) {
|
||||
event.target.closest('.nav-item').classList.add('active');
|
||||
}
|
||||
|
||||
// Initialize section-specific functionality
|
||||
if (sectionName === 'config') {
|
||||
initConfigSection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -114,16 +114,6 @@
|
|||
--shadow-heavy: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Dark theme progress bar adjustments */
|
||||
[data-theme="dark"] .progress-timer-container {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .progress-timer-bar {
|
||||
background: linear-gradient(90deg, #3498db, #27ae60);
|
||||
box-shadow: 0 0 10px rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
@ -138,24 +128,6 @@ body {
|
|||
overflow: hidden; /* Prevent body scroll */
|
||||
}
|
||||
|
||||
/* Decorative progress bar */
|
||||
.progress-timer-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.progress-timer-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3498db, #2ecc71);
|
||||
width: 100%;
|
||||
box-shadow: 0 0 10px rgba(52, 152, 219, 0.5);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr;
|
||||
|
@ -1564,3 +1536,379 @@ button[onclick="copyNodeKey()"]:hover {
|
|||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Configuration Editor Styles */
|
||||
.config-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: var(--bg-info-card);
|
||||
border: 1px solid var(--border-card);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.config-info h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: var(--text-heading);
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.config-meta {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-path {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: var(--bg-nav-item);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-body);
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-format {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.config-format.json {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.config-format.hjson {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.config-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.config-status.writable {
|
||||
background: var(--bg-success);
|
||||
color: var(--text-success);
|
||||
}
|
||||
|
||||
.config-status.readonly {
|
||||
background: var(--bg-warning);
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: var(--bg-nav-active);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: var(--bg-success-dark);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.save-btn.modified {
|
||||
background: var(--bg-warning-dark);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Configuration Groups */
|
||||
.config-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.config-group {
|
||||
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);
|
||||
}
|
||||
|
||||
.config-group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
background: var(--bg-nav-item);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.config-group-header:hover {
|
||||
background: var(--bg-nav-hover);
|
||||
}
|
||||
|
||||
.config-group-header h4 {
|
||||
margin: 0;
|
||||
color: var(--text-heading);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 1.2em;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.config-group-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Configuration Fields */
|
||||
.config-field {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border-card);
|
||||
}
|
||||
|
||||
.config-field:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.config-field-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.config-field-header label {
|
||||
font-weight: bold;
|
||||
color: var(--text-heading);
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.field-type {
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-nav-item);
|
||||
color: var(--text-muted);
|
||||
border-radius: 3px;
|
||||
font-size: 0.8em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.config-field-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.config-field-input {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Input Styles */
|
||||
.config-input, .config-textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-card);
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
background: var(--bg-info-card);
|
||||
color: var(--text-body);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.config-input:focus, .config-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-hover);
|
||||
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
|
||||
}
|
||||
|
||||
.config-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.private-key {
|
||||
font-family: monospace;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Switch Styles */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.4s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
transition: 0.4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--bg-nav-active);
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px var(--bg-nav-active);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
/* Array and Object Input Styles */
|
||||
.array-input, .object-input, .private-key-input {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.array-input small, .object-input small, .private-key-input small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* Save Confirmation Modal */
|
||||
.save-config-confirmation {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.config-save-info {
|
||||
background: var(--bg-nav-item);
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin: 15px 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.config-save-info p {
|
||||
margin: 5px 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--bg-warning);
|
||||
border: 1px solid var(--border-warning);
|
||||
color: var(--text-warning);
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.config-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.config-meta {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.config-path {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.config-field {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.config-field-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Theme Support */
|
||||
[data-theme="dark"] .config-container,
|
||||
[data-theme="dark"] .config-group,
|
||||
[data-theme="dark"] .config-header {
|
||||
background: var(--bg-info-card);
|
||||
border-color: var(--border-card);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .config-input,
|
||||
[data-theme="dark"] .config-textarea {
|
||||
background: var(--bg-nav-item);
|
||||
border-color: var(--border-card);
|
||||
color: var(--text-body);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .config-save-info {
|
||||
background: var(--bg-nav-item);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .warning {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border-color: var(--border-warning);
|
||||
color: var(--text-warning);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue