Implement Admin API integration in WebUI for enhanced node management

This commit is contained in:
Andy Oknen 2025-07-30 15:53:09 +00:00
parent 3187114780
commit 675e2e71a5
10 changed files with 1055 additions and 47 deletions

View file

@ -309,6 +309,12 @@ func main() {
}
n.webui = webui.Server(listenAddr, cfg.WebUI.Password, logger)
// Connect WebUI with AdminSocket for direct API access
if n.admin != nil {
n.webui.SetAdmin(n.admin)
}
if cfg.WebUI.Password != "" {
logger.Infof("WebUI password authentication enabled")
} else {

View file

@ -277,6 +277,43 @@ func (a *AdminSocket) Stop() error {
return nil
}
// CallHandler calls an admin handler directly by name without using socket
func (a *AdminSocket) CallHandler(name string, args json.RawMessage) (interface{}, error) {
if a == nil {
return nil, errors.New("admin socket not initialized")
}
reqname := strings.ToLower(name)
handler, ok := a.handlers[reqname]
if !ok {
return nil, fmt.Errorf("unknown action '%s', try 'list' for help", reqname)
}
return handler.handler(args)
}
// GetAvailableCommands returns list of available admin commands
func (a *AdminSocket) GetAvailableCommands() []ListEntry {
if a == nil {
return nil
}
var list []ListEntry
for name, handler := range a.handlers {
list = append(list, ListEntry{
Command: name,
Description: handler.desc,
Fields: handler.args,
})
}
sort.SliceStable(list, func(i, j int) bool {
return strings.Compare(list[i].Command, list[j].Command) < 0
})
return list
}
// listen is run by start and manages API connections.
func (a *AdminSocket) listen() {
defer a.listener.Close()

View file

@ -40,18 +40,18 @@ import (
// options that are necessary for an Yggdrasil node to run. You will need to
// supply one of these structs to the Yggdrasil core when starting a node.
type NodeConfig struct {
PrivateKey KeyBytes `comment:"Your private key. DO NOT share this with anyone!"`
PrivateKeyPath string `comment:"The path to your private key file in PEM format."`
PrivateKey KeyBytes `json:",omitempty" comment:"Your private key. DO NOT share this with anyone!"`
PrivateKeyPath string `json:",omitempty" comment:"The path to your private key file in PEM format."`
Certificate *tls.Certificate `json:"-"`
Peers []string `comment:"List of outbound peer connection strings (e.g. tls://a.b.c.d:e or\nsocks://a.b.c.d:e/f.g.h.i:j). Connection strings can contain options,\nsee https://yggdrasil-network.github.io/configurationref.html#peers.\nYggdrasil has no concept of bootstrap nodes - all network traffic\nwill transit peer connections. Therefore make sure to only peer with\nnearby nodes that have good connectivity and low latency. Avoid adding\npeers to this list from distant countries as this will worsen your\nnode's connectivity and performance considerably."`
InterfacePeers map[string][]string `comment:"List of connection strings for outbound peer connections in URI format,\narranged by source interface, e.g. { \"eth0\": [ \"tls://a.b.c.d:e\" ] }.\nYou should only use this option if your machine is multi-homed and you\nwant to establish outbound peer connections on different interfaces.\nOtherwise you should use \"Peers\"."`
Listen []string `comment:"Listen addresses for incoming connections. You will need to add\nlisteners in order to accept incoming peerings from non-local nodes.\nThis is not required if you wish to establish outbound peerings only.\nMulticast peer discovery will work regardless of any listeners set\nhere. Each listener should be specified in URI format as above, e.g.\ntls://0.0.0.0:0 or tls://[::]:0 to listen on all interfaces."`
AdminListen string `comment:"Listen address for admin connections. Default is to listen for local\nconnections either on TCP/9001 or a UNIX socket depending on your\nplatform. Use this value for yggdrasilctl -endpoint=X. To disable\nthe admin socket, use the value \"none\" instead."`
AdminListen string `json:",omitempty" comment:"Listen address for admin connections. Default is to listen for local\nconnections either on TCP/9001 or a UNIX socket depending on your\nplatform. Use this value for yggdrasilctl -endpoint=X. To disable\nthe admin socket, use the value \"none\" instead."`
MulticastInterfaces []MulticastInterfaceConfig `comment:"Configuration for which interfaces multicast peer discovery should be\nenabled on. Regex is a regular expression which is matched against an\ninterface name, and interfaces use the first configuration that they\nmatch against. Beacon controls whether or not your node advertises its\npresence to others, whereas Listen controls whether or not your node\nlistens out for and tries to connect to other advertising nodes. See\nhttps://yggdrasil-network.github.io/configurationref.html#multicastinterfaces\nfor more supported options."`
AllowedPublicKeys []string `comment:"List of peer public keys to allow incoming peering connections\nfrom. If left empty/undefined then all connections will be allowed\nby default. This does not affect outgoing peerings, nor does it\naffect link-local peers discovered via multicast.\nWARNING: THIS IS NOT A FIREWALL and DOES NOT limit who can reach\nopen ports or services running on your machine!"`
IfName string `comment:"Local network interface name for TUN adapter, or \"auto\" to select\nan interface automatically, or \"none\" to run without TUN."`
IfMTU uint64 `comment:"Maximum Transmission Unit (MTU) size for your local TUN interface.\nDefault is the largest supported size for your platform. The lowest\npossible value is 1280."`
LogLookups bool `comment:"Log lookups for peers and nodes. This is useful for debugging and\nmonitoring the network. It is disabled by default."`
LogLookups bool `json:",omitempty" comment:"Log lookups for peers and nodes. This is useful for debugging and\nmonitoring the network. It is disabled by default."`
NodeInfoPrivacy bool `comment:"By default, nodeinfo contains some defaults including the platform,\narchitecture and Yggdrasil version. These can help when surveying\nthe network and diagnosing network routing problems. Enabling\nnodeinfo privacy prevents this, so that only items specified in\n\"NodeInfo\" are sent back if specified."`
NodeInfo map[string]interface{} `comment:"Optional nodeinfo. This must be a { \"key\": \"value\", ... } map\nor set as null. This is entirely optional but, if set, is visible\nto the whole network on request."`
WebUI WebUIConfig `comment:"Web interface configuration for managing the node through a browser."`
@ -82,7 +82,6 @@ func GenerateConfig() *NodeConfig {
// Create a node configuration and populate it.
cfg := new(NodeConfig)
cfg.NewPrivateKey()
cfg.PrivateKeyPath = ""
cfg.Listen = []string{}
cfg.AdminListen = defaults.DefaultAdminListen
cfg.Peers = []string{}

View file

@ -12,6 +12,7 @@ import (
"sync"
"time"
"github.com/yggdrasil-network/yggdrasil-go/src/admin"
"github.com/yggdrasil-network/yggdrasil-go/src/core"
)
@ -24,6 +25,7 @@ type WebUIServer struct {
sessionsMux sync.RWMutex
failedAttempts map[string]*FailedLoginInfo // IP -> failed login info
attemptsMux sync.RWMutex
admin *admin.AdminSocket // Admin socket reference for direct API calls
}
type LoginRequest struct {
@ -49,9 +51,15 @@ func Server(listen string, password string, log core.Logger) *WebUIServer {
log: log,
sessions: make(map[string]time.Time),
failedAttempts: make(map[string]*FailedLoginInfo),
admin: nil, // Will be set later via SetAdmin
}
}
// SetAdmin sets the admin socket reference for direct API calls
func (w *WebUIServer) SetAdmin(admin *admin.AdminSocket) {
w.admin = admin
}
// generateSessionID creates a random session ID
func (w *WebUIServer) generateSessionID() string {
bytes := make([]byte, 32)
@ -299,6 +307,67 @@ func (w *WebUIServer) logoutHandler(rw http.ResponseWriter, r *http.Request) {
http.Redirect(rw, r, "/login.html", http.StatusSeeOther)
}
// adminAPIHandler handles direct admin API calls
func (w *WebUIServer) adminAPIHandler(rw http.ResponseWriter, r *http.Request) {
if w.admin == nil {
http.Error(rw, "Admin API not available", http.StatusServiceUnavailable)
return
}
// Extract command from URL path
// /api/admin/getSelf -> getSelf
path := strings.TrimPrefix(r.URL.Path, "/api/admin/")
command := strings.Split(path, "/")[0]
if command == "" {
// Return list of available commands
commands := w.admin.GetAvailableCommands()
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(map[string]interface{}{
"status": "success",
"commands": commands,
})
return
}
var args map[string]interface{}
if r.Method == http.MethodPost {
if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
args = make(map[string]interface{})
}
} else {
args = make(map[string]interface{})
}
// Call admin handler directly
result, err := w.callAdminHandler(command, args)
if err != nil {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusBadRequest)
json.NewEncoder(rw).Encode(map[string]interface{}{
"status": "error",
"error": err.Error(),
})
return
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(map[string]interface{}{
"status": "success",
"response": result,
})
}
// callAdminHandler calls admin handlers directly without socket
func (w *WebUIServer) callAdminHandler(command string, args map[string]interface{}) (interface{}, error) {
argsBytes, err := json.Marshal(args)
if err != nil {
argsBytes = []byte("{}")
}
return w.admin.CallHandler(command, argsBytes)
}
func (w *WebUIServer) Start() error {
// Validate listen address before starting
if w.listen != "" {
@ -330,6 +399,9 @@ func (w *WebUIServer) Start() error {
mux.HandleFunc("/auth/login", w.loginHandler)
mux.HandleFunc("/auth/logout", w.logoutHandler)
// Admin API endpoints - with auth
mux.HandleFunc("/api/admin/", w.authMiddleware(w.adminAPIHandler))
// Setup static files handler (implementation varies by build)
setupStaticHandler(mux, w)

181
src/webui/static/api.js Normal file
View file

@ -0,0 +1,181 @@
/**
* Yggdrasil Admin API Client
* Provides JavaScript interface for accessing Yggdrasil admin functions
*/
class YggdrasilAPI {
constructor() {
this.baseURL = '/api/admin';
}
/**
* Generic method to call admin API endpoints
* @param {string} command - Admin command name
* @param {Object} args - Command arguments
* @returns {Promise<Object>} - API response
*/
async callAdmin(command, args = {}) {
const url = command ? `${this.baseURL}/${command}` : this.baseURL;
const options = {
method: Object.keys(args).length > 0 ? 'POST' : 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'same-origin' // Include session cookies
};
if (Object.keys(args).length > 0) {
options.body = JSON.stringify(args);
}
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.status === 'error') {
throw new Error(data.error || 'Unknown API error');
}
return data.response || data.commands;
} catch (error) {
console.error(`API call failed for ${command}:`, error);
throw error;
}
}
/**
* Get list of available admin commands
* @returns {Promise<Array>} - List of available commands
*/
async getCommands() {
return await this.callAdmin('');
}
/**
* Get information about this node
* @returns {Promise<Object>} - Node information
*/
async getSelf() {
return await this.callAdmin('getSelf');
}
/**
* Get list of connected peers
* @returns {Promise<Object>} - Peers information
*/
async getPeers() {
return await this.callAdmin('getPeers');
}
/**
* Get tree routing information
* @returns {Promise<Object>} - Tree information
*/
async getTree() {
return await this.callAdmin('getTree');
}
/**
* Get established paths through this node
* @returns {Promise<Object>} - Paths information
*/
async getPaths() {
return await this.callAdmin('getPaths');
}
/**
* Get established traffic sessions with remote nodes
* @returns {Promise<Object>} - Sessions information
*/
async getSessions() {
return await this.callAdmin('getSessions');
}
/**
* Add a peer to the peer list
* @param {string} uri - Peer URI (e.g., "tls://example.com:12345")
* @param {string} int - Network interface (optional)
* @returns {Promise<Object>} - Add peer response
*/
async addPeer(uri, int = '') {
return await this.callAdmin('addPeer', { uri, int });
}
/**
* Remove a peer from the peer list
* @param {string} uri - Peer URI to remove
* @param {string} int - Network interface (optional)
* @returns {Promise<Object>} - Remove peer response
*/
async removePeer(uri, int = '') {
return await this.callAdmin('removePeer', { uri, int });
}
}
/**
* Data formatting utilities
*/
class YggdrasilUtils {
/**
* Format bytes to human readable format
* @param {number} bytes - Bytes count
* @returns {string} - Formatted string (e.g., "1.5 MB")
*/
static formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Format duration to human readable format
* @param {number} seconds - Duration in seconds
* @returns {string} - Formatted duration
*/
static formatDuration(seconds) {
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
return `${Math.round(seconds / 86400)}d`;
}
/**
* Format public key for display (show first 8 and last 8 chars)
* @param {string} key - Full public key
* @returns {string} - Shortened key
*/
static formatPublicKey(key) {
if (!key || key.length < 16) return key;
return `${key.substring(0, 8)}...${key.substring(key.length - 8)}`;
}
/**
* Get status color class based on peer state
* @param {boolean} up - Whether peer is up
* @returns {string} - CSS class name
*/
static getPeerStatusClass(up) {
return up ? 'status-online' : 'status-offline';
}
/**
* Get status text based on peer state
* @param {boolean} up - Whether peer is up
* @returns {string} - Status text
*/
static getPeerStatusText(up) {
return up ? 'Online' : 'Offline';
}
}
// Create global API instance
window.yggAPI = new YggdrasilAPI();
window.yggUtils = YggdrasilUtils;

275
src/webui/static/app.js Normal file
View file

@ -0,0 +1,275 @@
/**
* Yggdrasil WebUI Application Logic
* Integrates admin API with the user interface
*/
// Global state
let nodeInfo = null;
let peersData = null;
let isLoading = false;
/**
* Load and display node information
*/
async function loadNodeInfo() {
try {
const info = await window.yggAPI.getSelf();
nodeInfo = info;
updateNodeInfoDisplay(info);
return info;
} catch (error) {
console.error('Failed to load node info:', error);
showError('Failed to load node information: ' + error.message);
throw error;
}
}
/**
* Load and display peers information
*/
async function loadPeers() {
try {
const data = await window.yggAPI.getPeers();
peersData = data;
updatePeersDisplay(data);
return data;
} catch (error) {
console.error('Failed to load peers:', error);
showError('Failed to load peers information: ' + error.message);
throw error;
}
}
/**
* Update node information in the UI
*/
function updateNodeInfoDisplay(info) {
// Update status section elements
updateElementText('node-address', info.address || 'N/A');
updateElementText('node-subnet', info.subnet || 'N/A');
updateElementText('node-key', yggUtils.formatPublicKey(info.key) || 'N/A');
updateElementText('node-version', `${info.build_name} ${info.build_version}` || 'N/A');
updateElementText('routing-entries', info.routing_entries || '0');
// Update full key display (for copy functionality)
updateElementData('node-key-full', info.key || '');
}
/**
* Update peers information in the UI
*/
function updatePeersDisplay(data) {
const peersContainer = document.getElementById('peers-list');
if (!peersContainer) return;
peersContainer.innerHTML = '';
if (!data.peers || data.peers.length === 0) {
peersContainer.innerHTML = '<div class="no-data">No peers connected</div>';
return;
}
data.peers.forEach(peer => {
const peerElement = createPeerElement(peer);
peersContainer.appendChild(peerElement);
});
// Update peer count
updateElementText('peers-count', data.peers.length.toString());
updateElementText('peers-online', data.peers.filter(p => p.up).length.toString());
}
/**
* Create HTML element for a single peer
*/
function createPeerElement(peer) {
const div = document.createElement('div');
div.className = 'peer-item';
const statusClass = yggUtils.getPeerStatusClass(peer.up);
const statusText = yggUtils.getPeerStatusText(peer.up);
div.innerHTML = `
<div class="peer-header">
<div class="peer-address">${peer.address || 'N/A'}</div>
<div class="peer-status ${statusClass}">${statusText}</div>
</div>
<div class="peer-details">
<div class="peer-uri" title="${peer.remote || 'N/A'}">${peer.remote || 'N/A'}</div>
<div class="peer-stats">
<span> ${yggUtils.formatBytes(peer.bytes_recvd || 0)}</span>
<span> ${yggUtils.formatBytes(peer.bytes_sent || 0)}</span>
${peer.up && peer.latency ? `<span>RTT: ${(peer.latency / 1000000).toFixed(1)}ms</span>` : ''}
</div>
</div>
${peer.remote ? `<button class="peer-remove-btn" onclick="removePeerConfirm('${peer.remote}')">Remove</button>` : ''}
`;
return div;
}
/**
* Add a new peer
*/
async function addPeer() {
const uri = prompt('Enter peer URI:\nExamples:\n• tcp://example.com:54321\n• tls://peer.yggdrasil.network:443');
if (!uri || uri.trim() === '') {
showWarning('Peer URI is required');
return;
}
// Basic URI validation
if (!uri.includes('://')) {
showError('Invalid URI format. Must include protocol (tcp://, tls://, etc.)');
return;
}
try {
showInfo('Adding peer...');
await window.yggAPI.addPeer(uri.trim());
showSuccess(`Peer added successfully: ${uri.trim()}`);
await loadPeers(); // Refresh peer list
} catch (error) {
showError('Failed to add peer: ' + error.message);
}
}
/**
* Remove peer with confirmation
*/
function removePeerConfirm(uri) {
if (confirm(`Remove peer?\n${uri}`)) {
removePeer(uri);
}
}
/**
* Remove a peer
*/
async function removePeer(uri) {
try {
showInfo('Removing peer...');
await window.yggAPI.removePeer(uri);
showSuccess(`Peer removed successfully: ${uri}`);
await loadPeers(); // Refresh peer list
} catch (error) {
showError('Failed to remove peer: ' + error.message);
}
}
/**
* Helper function to update element text content
*/
function updateElementText(id, text) {
const element = document.getElementById(id);
if (element) {
element.textContent = text;
}
}
/**
* Helper function to update element data attribute
*/
function updateElementData(id, data) {
const element = document.getElementById(id);
if (element) {
element.setAttribute('data-value', data);
}
}
/**
* Copy text to clipboard
*/
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
showSuccess('Copied to clipboard');
} catch (error) {
console.error('Failed to copy:', error);
showError('Failed to copy to clipboard');
}
}
/**
* Copy node key to clipboard
*/
function copyNodeKey() {
const element = document.getElementById('node-key-full');
if (element) {
const key = element.getAttribute('data-value');
if (key) {
copyToClipboard(key);
}
}
}
/**
* Auto-refresh data
*/
function startAutoRefresh() {
// Refresh every 30 seconds
setInterval(async () => {
if (!isLoading) {
try {
await Promise.all([loadNodeInfo(), loadPeers()]);
} catch (error) {
console.error('Auto-refresh failed:', error);
}
}
}, 30000);
}
/**
* Initialize application
*/
async function initializeApp() {
try {
// Ensure API is available
if (typeof window.yggAPI === 'undefined') {
console.error('yggAPI is not available');
showError('Failed to initialize API client');
return;
}
isLoading = true;
showInfo('Loading dashboard...');
// Load initial data
await Promise.all([loadNodeInfo(), loadPeers()]);
showSuccess('Dashboard loaded successfully');
// Start auto-refresh
startAutoRefresh();
} catch (error) {
showError('Failed to initialize dashboard: ' + error.message);
} finally {
isLoading = false;
}
}
// Wait for DOM and API to be ready
function waitForAPI() {
console.log('Checking for yggAPI...', typeof window.yggAPI);
if (typeof window.yggAPI !== 'undefined') {
console.log('yggAPI found, initializing app...');
initializeApp();
} else {
console.log('yggAPI not ready yet, retrying...');
// Retry after a short delay
setTimeout(waitForAPI, 100);
}
}
// Initialize when DOM is ready
console.log('App.js loaded, document ready state:', document.readyState);
if (document.readyState === 'loading') {
console.log('Document still loading, waiting for DOMContentLoaded...');
document.addEventListener('DOMContentLoaded', waitForAPI);
} else {
console.log('Document already loaded, starting API check...');
initializeApp();
}

View file

@ -6,8 +6,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Yggdrasil Web Interface</title>
<link rel="stylesheet" href="static/style.css">
<script src="static/api.js"></script>
<script src="static/app.js"></script>
<script src="static/lang/ru.js"></script>
<script src="static/lang/en.js"></script>
<script srс="static/lang/en.js"></script>
</head>
<body>
@ -56,24 +58,36 @@
<div id="status-section" class="content-section active">
<div class="status-card">
<h2 data-key="status_title">Состояние узла</h2>
<div class="status-indicator">
<span class="status-dot active"></span>
<span data-key="status_active">Активен</span>
</div>
<p data-key="status_description">WebUI запущен и доступен</p>
<p data-key="status_description">Информация о текущем состоянии вашего узла Yggdrasil</p>
</div>
<div class="info-grid">
<div class="info-card">
<h3 data-key="network_info">Сетевая информация</h3>
<p><span data-key="address">Адрес</span>: 200:1234:5678:9abc::1</p>
<p><span data-key="subnet">Подсеть</span>: 300:1234:5678:9abc::/64</p>
<h3 data-key="node_info">Информация об узле</h3>
<p><span data-key="public_key">Публичный ключ</span>: <span id="node-key"
data-key="loading">Загрузка...</span> <button onclick="copyNodeKey()"
style="margin-left: 8px; font-size: 12px;">📋</button></p>
<p><span data-key="version">Версия</span>: <span id="node-version"
data-key="loading">Загрузка...</span></p>
<p><span data-key="routing_entries">Записей маршрутизации</span>: <span id="routing-entries"
data-key="loading">Загрузка...</span></p>
<span id="node-key-full" data-value="" style="display: none;"></span>
</div>
<div class="info-card">
<h3 data-key="statistics">Статистика</h3>
<p><span data-key="uptime">Время работы</span>: 2д 15ч 42м</p>
<p><span data-key="connections">Активных соединений</span>: 3</p>
<h3 data-key="network_info">Сетевая информация</h3>
<p><span data-key="address">Адрес</span>: <span id="node-address"
data-key="loading">Загрузка...</span></p>
<p><span data-key="subnet">Подсеть</span>: <span id="node-subnet"
data-key="loading">Загрузка...</span></p>
</div>
<div class="info-card">
<h3 data-key="statistics">Статистика пиров</h3>
<p><span data-key="total_peers">Всего пиров</span>: <span id="peers-count"
data-key="loading">Загрузка...</span></p>
<p><span data-key="online_peers">Онлайн пиров</span>: <span id="peers-online"
data-key="loading">Загрузка...</span></p>
</div>
</div>
</div>
@ -85,16 +99,17 @@
</div>
<div class="info-grid">
<div class="info-card">
<h3 data-key="active_peers">Активные пиры</h3>
<p data-key="active_connections">Количество активных соединений</p>
<small data-key="coming_soon">Функция в разработке...</small>
</div>
<div class="info-card">
<h3 data-key="add_peer">Добавить пир</h3>
<p data-key="add_peer_description">Подключение к новому узлу</p>
<small data-key="coming_soon">Функция в разработке...</small>
<button onclick="addPeer()" class="action-btn" data-key="add_peer_btn">Добавить пир</button>
</div>
</div>
<div class="peers-container">
<h3 data-key="connected_peers">Подключенные пиры</h3>
<div id="peers-list" class="peers-list">
<div class="loading" data-key="loading">Загрузка...</div>
</div>
</div>
</div>
@ -127,6 +142,9 @@
</footer>
</div>
<!-- Notifications container -->
<div class="notifications-container" id="notifications-container"></div>
<script>
let currentLanguage = localStorage.getItem('yggdrasil-language') || 'ru';
let currentTheme = localStorage.getItem('yggdrasil-theme') || 'light';
@ -143,23 +161,18 @@
function toggleTheme() {
currentTheme = currentTheme === 'light' ? 'dark' : 'light';
localStorage.setItem('yggdrasil-theme', currentTheme);
applyTheme();
localStorage.setItem('yggdrasil-theme', currentTheme);
}
function applyTheme() {
const body = document.body;
const themeIcon = document.querySelector('.theme-icon');
document.documentElement.setAttribute('data-theme', currentTheme);
const themeBtn = document.getElementById('theme-btn');
if (currentTheme === 'dark') {
body.setAttribute('data-theme', 'dark');
themeIcon.textContent = '☀️';
themeBtn.title = window.translations[currentLanguage]['theme_light'] || 'Light theme';
} else {
body.removeAttribute('data-theme');
themeIcon.textContent = '🌙';
themeBtn.title = window.translations[currentLanguage]['theme_dark'] || 'Dark theme';
if (themeBtn) {
const icon = themeBtn.querySelector('.theme-icon');
if (icon) {
icon.textContent = currentTheme === 'light' ? '🌙' : '☀️';
}
}
}
@ -167,19 +180,12 @@
currentLanguage = lang;
localStorage.setItem('yggdrasil-language', lang);
// Update active button
// Update button states
document.querySelectorAll('.lang-btn').forEach(btn => btn.classList.remove('active'));
document.getElementById('lang-' + lang).classList.add('active');
// Update all texts
updateTexts();
applyTheme(); // Update theme button tooltip
}
function logout() {
const confirmText = window.translations[currentLanguage]['logout_confirm'];
if (confirm(confirmText)) {
window.location.href = '/auth/logout';
}
}
function showSection(sectionName) {
@ -201,6 +207,80 @@
event.target.closest('.nav-item').classList.add('active');
}
// Notification system (shared with app.js)
let notificationId = 0;
function showNotification(message, type = 'info', title = null, duration = 5000) {
const container = document.getElementById('notifications-container');
const id = ++notificationId;
const icons = {
success: '✅',
error: '❌',
warning: '⚠️',
info: ''
};
const titles = {
success: window.translations[currentLanguage]['notification_success'] || 'Success',
error: window.translations[currentLanguage]['notification_error'] || 'Error',
warning: window.translations[currentLanguage]['notification_warning'] || 'Warning',
info: window.translations[currentLanguage]['notification_info'] || 'Information'
};
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.id = `notification-${id}`;
notification.innerHTML = `
<div class="notification-icon">${icons[type] || icons.info}</div>
<div class="notification-content">
<div class="notification-title">${title || titles[type]}</div>
<div class="notification-message">${message}</div>
</div>
<button class="notification-close" onclick="removeNotification(${id})">&times;</button>
`;
container.appendChild(notification);
// Auto remove after duration
if (duration > 0) {
setTimeout(() => {
removeNotification(id);
}, duration);
}
return id;
}
function removeNotification(id) {
const notification = document.getElementById(`notification-${id}`);
if (notification) {
notification.classList.add('removing');
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}
}
function showSuccess(message, title = null) {
return showNotification(message, 'success', title);
}
function showError(message, title = null) {
return showNotification(message, 'error', title);
}
function showWarning(message, title = null) {
return showNotification(message, 'warning', title);
}
function showInfo(message, title = null) {
return showNotification(message, 'info', title);
}
// Initialize language and theme on page load
document.addEventListener('DOMContentLoaded', function () {
// Set active language button

View file

@ -36,5 +36,12 @@ window.translations.en = {
'password_label': 'Password:',
'access_dashboard': 'Access Dashboard',
'error_invalid_password': 'Invalid password. Please try again.',
'error_too_many_attempts': 'Too many failed attempts. Please wait 1 minute before trying again.'
'error_too_many_attempts': 'Too many failed attempts. Please wait 1 minute before trying again.',
'add_peer_btn': 'Add Peer',
'notification_success': 'Success',
'notification_error': 'Error',
'notification_warning': 'Warning',
'notification_info': 'Information',
'dashboard_loaded': 'Dashboard loaded successfully',
'welcome': 'Welcome'
};

View file

@ -36,5 +36,12 @@ window.translations.ru = {
'password_label': 'Пароль:',
'access_dashboard': 'Войти в панель',
'error_invalid_password': 'Неверный пароль. Попробуйте снова.',
'error_too_many_attempts': 'Слишком много неудачных попыток. Подождите 1 минуту перед повторной попыткой.'
'error_too_many_attempts': 'Слишком много неудачных попыток. Подождите 1 минуту перед повторной попыткой.',
'add_peer_btn': 'Добавить пир',
'notification_success': 'Успешно',
'notification_error': 'Ошибка',
'notification_warning': 'Предупреждение',
'notification_info': 'Информация',
'dashboard_loaded': 'Панель загружена успешно',
'welcome': 'Добро пожаловать'
};

View file

@ -227,6 +227,179 @@ header p {
font-size: 16px;
}
.action-btn {
background: var(--bg-nav-active);
color: var(--text-white);
border: 1px solid var(--bg-nav-active-border);
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
margin-top: 10px;
}
.action-btn:hover {
background: var(--bg-nav-active-border);
border-color: var(--bg-nav-active-border);
transform: translateY(-1px);
}
/* Notification system */
.notifications-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
max-width: 350px;
}
.notification {
background: var(--bg-info-card);
border: 1px solid var(--border-card);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 4px 12px var(--shadow-medium);
display: flex;
align-items: flex-start;
gap: 12px;
animation: slideIn 0.3s ease-out;
position: relative;
overflow: hidden;
}
.notification::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--bg-nav-active);
}
.notification.success::before {
background: #28a745;
}
.notification.error::before {
background: #dc3545;
}
.notification.warning::before {
background: #ffc107;
}
.notification.info::before {
background: var(--bg-nav-active);
}
.notification-icon {
font-size: 20px;
line-height: 1;
flex-shrink: 0;
margin-top: 2px;
}
.notification-content {
flex: 1;
color: var(--text-primary);
}
.notification-title {
font-weight: 600;
margin-bottom: 4px;
font-size: 14px;
color: var(--text-body);
}
.notification-message {
font-size: 13px;
line-height: 1.4;
color: var(--text-body);
}
.notification-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 18px;
line-height: 1;
padding: 0;
margin-left: 8px;
transition: color 0.2s ease;
}
.notification-close:hover {
color: var(--text-primary);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
max-height: 200px;
margin-bottom: 12px;
}
to {
transform: translateX(100%);
opacity: 0;
max-height: 0;
margin-bottom: 0;
}
}
.notification.removing {
animation: slideOut 0.3s ease-in forwards;
}
[data-theme="dark"] .notification {
background: var(--bg-sidebar);
border-color: var(--border-sidebar);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
/* Mobile responsiveness for notifications */
@media (max-width: 768px) {
.notifications-container {
bottom: 10px;
right: 10px;
left: 10px;
max-width: none;
}
.notification {
padding: 12px;
font-size: 14px;
}
.notification-title {
font-size: 13px;
}
.notification-message {
font-size: 12px;
}
.notification-icon {
font-size: 18px;
}
}
.layout {
display: flex;
gap: 20px;
@ -473,3 +646,174 @@ footer {
grid-template-columns: 1fr;
}
}
/* Peers container and list styles */
.peers-container {
margin-top: 2rem;
}
.peers-container h3 {
color: var(--text-heading);
margin-bottom: 1rem;
font-size: 1.1rem;
}
.peers-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.peer-item {
background: var(--bg-info-card);
border: 1px solid var(--border-card);
border-radius: 8px;
padding: 1rem;
box-shadow: 0 2px 4px var(--shadow-light);
transition: all 0.2s;
}
.peer-item:hover {
border-color: var(--border-hover);
box-shadow: 0 4px 8px var(--shadow-medium);
}
.peer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.peer-address {
font-family: 'Courier New', monospace;
font-weight: bold;
color: var(--text-heading);
font-size: 0.9rem;
}
.peer-status {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: bold;
}
.peer-status.status-online {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.peer-status.status-offline {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
[data-theme="dark"] .peer-status.status-online {
background: #155724;
color: #d4edda;
}
[data-theme="dark"] .peer-status.status-offline {
background: #721c24;
color: #f8d7da;
}
.peer-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.peer-uri {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: var(--text-muted);
word-break: break-all;
}
.peer-stats {
display: flex;
gap: 1rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.peer-stats span {
background: var(--bg-nav-item);
padding: 0.25rem 0.5rem;
border-radius: 4px;
border: 1px solid var(--border-card);
}
.peer-remove-btn {
background: var(--bg-logout);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
margin-top: 0.5rem;
align-self: flex-start;
transition: all 0.2s;
}
.peer-remove-btn:hover {
background: var(--bg-logout-hover);
}
.no-data {
text-align: center;
color: var(--text-muted);
font-style: italic;
padding: 2rem;
}
.loading {
text-align: center;
color: var(--text-muted);
font-style: italic;
padding: 1rem;
}
/* Status indicators */
#node-key, #node-version, #node-address, #node-subnet, #routing-entries, #peers-count, #peers-online {
font-family: 'Courier New', monospace;
font-weight: bold;
color: var(--text-heading);
}
/* Copy button styling */
button[onclick="copyNodeKey()"] {
background: var(--bg-nav-item);
border: 1px solid var(--border-card);
cursor: pointer;
border-radius: 3px;
transition: all 0.2s;
}
button[onclick="copyNodeKey()"]:hover {
background: var(--bg-nav-hover);
border-color: var(--border-hover);
}
/* Responsive design for peer items */
@media (max-width: 768px) {
.peer-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.peer-stats {
flex-direction: column;
gap: 0.5rem;
}
.peer-address {
font-size: 0.8rem;
}
}