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

@ -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;
@ -472,4 +645,175 @@ footer {
.info-grid {
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;
}
}