Implement configuration management in WebUI with API integration for loading and saving configurations

This commit is contained in:
Andy Oknen 2025-08-04 08:30:55 +00:00
parent 19710fbc19
commit ee470d32a7
10 changed files with 1120 additions and 50 deletions

3
.gitignore vendored
View file

@ -1,7 +1,6 @@
**/TODO
/yggdrasil
/yggdrasilctl
/yggdrasil.conf
/yggdrasil.json
/yggdrasil.*
/run
/test

View file

@ -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.

View file

@ -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
}

View file

@ -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
View 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');
}
}

View file

@ -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>

View file

@ -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!'
};

View file

@ -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': '⚠️ Внимание: Неправильная конфигурация может привести к сбою работы узла!'
};

View file

@ -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();
}
}
/**

View file

@ -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);
}