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
|
**/TODO
|
||||||
/yggdrasil
|
/yggdrasil
|
||||||
/yggdrasilctl
|
/yggdrasilctl
|
||||||
/yggdrasil.conf
|
/yggdrasil.*
|
||||||
/yggdrasil.json
|
|
||||||
/run
|
/run
|
||||||
/test
|
/test
|
|
@ -107,6 +107,7 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.GenerateConfig()
|
cfg := config.GenerateConfig()
|
||||||
|
var configPath string
|
||||||
var err error
|
var err error
|
||||||
switch {
|
switch {
|
||||||
case *ver:
|
case *ver:
|
||||||
|
@ -124,6 +125,7 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
case *useconffile != "":
|
case *useconffile != "":
|
||||||
|
configPath = *useconffile
|
||||||
f, err := os.Open(*useconffile)
|
f, err := os.Open(*useconffile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
@ -206,6 +208,9 @@ func main() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set current config for web UI
|
||||||
|
config.SetCurrentConfig(configPath, cfg)
|
||||||
|
|
||||||
n := &node{}
|
n := &node{}
|
||||||
|
|
||||||
// Set up the Yggdrasil node itself.
|
// Set up the Yggdrasil node itself.
|
||||||
|
|
|
@ -30,6 +30,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hjson/hjson-go/v4"
|
"github.com/hjson/hjson-go/v4"
|
||||||
|
@ -274,3 +275,190 @@ func (k *KeyBytes) UnmarshalJSON(b []byte) error {
|
||||||
*k, err = hex.DecodeString(s)
|
*k, err = hex.DecodeString(s)
|
||||||
return err
|
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"
|
"time"
|
||||||
|
|
||||||
"github.com/yggdrasil-network/yggdrasil-go/src/admin"
|
"github.com/yggdrasil-network/yggdrasil-go/src/admin"
|
||||||
|
"github.com/yggdrasil-network/yggdrasil-go/src/config"
|
||||||
"github.com/yggdrasil-network/yggdrasil-go/src/core"
|
"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)
|
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 {
|
func (w *WebUIServer) Start() error {
|
||||||
// Validate listen address before starting
|
// Validate listen address before starting
|
||||||
if w.listen != "" {
|
if w.listen != "" {
|
||||||
|
@ -415,6 +515,10 @@ func (w *WebUIServer) Start() error {
|
||||||
// Admin API endpoints - with auth
|
// Admin API endpoints - with auth
|
||||||
mux.HandleFunc("/api/admin/", w.authMiddleware(w.adminAPIHandler))
|
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)
|
// Setup static files handler (implementation varies by build)
|
||||||
setupStaticHandler(mux, w)
|
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/main.js"></script>
|
||||||
<script src="static/app.js"></script>
|
<script src="static/app.js"></script>
|
||||||
<script src="static/modal.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/ru.js"></script>
|
||||||
<script src="static/lang/en.js"></script>
|
<script src="static/lang/en.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!-- Decorative progress bar -->
|
|
||||||
<div class="progress-timer-container">
|
|
||||||
<div class="progress-timer-bar"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
|
@ -112,19 +108,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="config-section" class="content-section">
|
<div id="config-section" class="content-section">
|
||||||
<div class="info-grid">
|
<div class="loading" data-key="loading">Загрузка конфигурации...</div>
|
||||||
<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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
@ -98,5 +98,18 @@ window.translations.en = {
|
||||||
'add_peer_modal_description': 'Enter peer URI to connect to a new network node',
|
'add_peer_modal_description': 'Enter peer URI to connect to a new network node',
|
||||||
'peer_uri_label': 'Peer URI',
|
'peer_uri_label': 'Peer URI',
|
||||||
'peer_uri_placeholder': 'tcp://example.com:54321',
|
'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 пира для подключения к новому узлу сети',
|
'add_peer_modal_description': 'Введите URI пира для подключения к новому узлу сети',
|
||||||
'peer_uri_label': 'URI пира',
|
'peer_uri_label': 'URI пира',
|
||||||
'peer_uri_placeholder': 'tcp://example.com:54321',
|
'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) {
|
if (event && event.target) {
|
||||||
event.target.closest('.nav-item').classList.add('active');
|
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);
|
--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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -138,24 +128,6 @@ body {
|
||||||
overflow: hidden; /* Prevent body scroll */
|
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 {
|
.container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 250px 1fr;
|
grid-template-columns: 250px 1fr;
|
||||||
|
@ -1563,4 +1535,380 @@ button[onclick="copyNodeKey()"]:hover {
|
||||||
.modal-btn {
|
.modal-btn {
|
||||||
width: 100%;
|
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