mirror of
https://github.com/yggdrasil-network/yggdrasil-go.git
synced 2025-08-25 16:35:07 +03:00
Implement Admin API integration in WebUI for enhanced node management
This commit is contained in:
parent
3187114780
commit
675e2e71a5
10 changed files with 1055 additions and 47 deletions
|
@ -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
181
src/webui/static/api.js
Normal 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
275
src/webui/static/app.js
Normal 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();
|
||||
}
|
|
@ -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})">×</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
|
||||
|
|
|
@ -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'
|
||||
};
|
|
@ -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': 'Добро пожаловать'
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue