feat: Implement server and client metrics collection and monitoring
- Added a new PHP script for collecting server metrics every 30 seconds. - Created a ServerMonitoring class to handle metrics collection for CPU, RAM, Disk, and Network. - Introduced database tables for storing server and client metrics. - Updated server view template to display real-time metrics using Chart.js. - Added translations for monitoring UI elements. - Created a new monitoring template for detailed server metrics visualization. - Implemented client speed tracking and display in the monitoring UI.
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metrics Collector
|
||||||
|
*
|
||||||
|
* Runs continuously and collects metrics every 30 seconds
|
||||||
|
* Usage: php bin/collect_metrics.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
require_once __DIR__ . '/../inc/Config.php';
|
||||||
|
require_once __DIR__ . '/../inc/DB.php';
|
||||||
|
require_once __DIR__ . '/../inc/VpnServer.php';
|
||||||
|
require_once __DIR__ . '/../inc/VpnClient.php';
|
||||||
|
require_once __DIR__ . '/../inc/ServerMonitoring.php';
|
||||||
|
|
||||||
|
// Set timezone
|
||||||
|
date_default_timezone_set('UTC');
|
||||||
|
|
||||||
|
// Enable error logging
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 1);
|
||||||
|
|
||||||
|
echo "[" . date('Y-m-d H:i:s') . "] Metrics collector started\n";
|
||||||
|
|
||||||
|
// Main loop
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
// Get all active servers
|
||||||
|
$servers = VpnServer::listAll();
|
||||||
|
|
||||||
|
foreach ($servers as $server) {
|
||||||
|
if ($server['status'] !== 'active') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
echo "[" . date('Y-m-d H:i:s') . "] Collecting metrics for server #{$server['id']} ({$server['name']})\n";
|
||||||
|
|
||||||
|
$monitoring = new ServerMonitoring($server['id']);
|
||||||
|
|
||||||
|
// Collect server metrics
|
||||||
|
$serverMetrics = $monitoring->collectMetrics();
|
||||||
|
echo " Server: CPU={$serverMetrics['cpu_percent']}% RAM={$serverMetrics['ram_used_mb']}/{$serverMetrics['ram_total_mb']}MB ";
|
||||||
|
echo "Disk={$serverMetrics['disk_used_gb']}/{$serverMetrics['disk_total_gb']}GB ";
|
||||||
|
echo "Net RX={$serverMetrics['network_rx_mbps']}Mbps TX={$serverMetrics['network_tx_mbps']}Mbps\n";
|
||||||
|
|
||||||
|
// Collect client metrics
|
||||||
|
$clientMetrics = $monitoring->collectClientMetrics();
|
||||||
|
|
||||||
|
if (!empty($clientMetrics)) {
|
||||||
|
foreach ($clientMetrics as $cm) {
|
||||||
|
echo " Client #{$cm['client_id']} ({$cm['client_name']}): UP={$cm['speed_up_kbps']}Kbps DOWN={$cm['speed_down_kbps']}Kbps\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo " No active clients\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo " ERROR: " . $e->getMessage() . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean old metrics
|
||||||
|
ServerMonitoring::cleanOldMetrics();
|
||||||
|
|
||||||
|
// Calculate sleep time
|
||||||
|
$executionTime = microtime(true) - $startTime;
|
||||||
|
$sleepTime = max(0, 30 - $executionTime);
|
||||||
|
|
||||||
|
echo "[" . date('Y-m-d H:i:s') . "] Collection completed in " . round($executionTime, 2) . "s, sleeping for " . round($sleepTime, 2) . "s\n\n";
|
||||||
|
|
||||||
|
if ($sleepTime > 0) {
|
||||||
|
sleep((int)$sleepTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo "[" . date('Y-m-d H:i:s') . "] FATAL ERROR: " . $e->getMessage() . "\n";
|
||||||
|
echo "Retrying in 30 seconds...\n\n";
|
||||||
|
sleep(30);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServerMonitoring - Collect and store server metrics
|
||||||
|
*
|
||||||
|
* Collects:
|
||||||
|
* - CPU usage
|
||||||
|
* - RAM usage
|
||||||
|
* - Disk usage
|
||||||
|
* - Network speed
|
||||||
|
* - Client traffic speed
|
||||||
|
*/
|
||||||
|
class ServerMonitoring
|
||||||
|
{
|
||||||
|
private VpnServer $server;
|
||||||
|
private array $serverData;
|
||||||
|
|
||||||
|
public function __construct(int $serverId)
|
||||||
|
{
|
||||||
|
$this->server = new VpnServer($serverId);
|
||||||
|
$this->serverData = $this->server->getData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all server metrics
|
||||||
|
*/
|
||||||
|
public function collectMetrics(): array
|
||||||
|
{
|
||||||
|
$metrics = [
|
||||||
|
'cpu_percent' => $this->getCpuUsage(),
|
||||||
|
'ram_used_mb' => $this->getRamUsed(),
|
||||||
|
'ram_total_mb' => $this->getRamTotal(),
|
||||||
|
'disk_used_gb' => $this->getDiskUsed(),
|
||||||
|
'disk_total_gb' => $this->getDiskTotal(),
|
||||||
|
'network_rx_mbps' => $this->getNetworkRxSpeed(),
|
||||||
|
'network_tx_mbps' => $this->getNetworkTxSpeed(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->saveServerMetrics($metrics);
|
||||||
|
|
||||||
|
return $metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect client traffic metrics
|
||||||
|
*/
|
||||||
|
public function collectClientMetrics(): array
|
||||||
|
{
|
||||||
|
$clients = VpnClient::listByServer($this->serverData['id']);
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($clients as $client) {
|
||||||
|
if ($client['status'] !== 'active') continue;
|
||||||
|
|
||||||
|
$stats = $this->getClientStats($client);
|
||||||
|
if ($stats) {
|
||||||
|
$this->saveClientMetrics($client['id'], $stats);
|
||||||
|
$results[] = [
|
||||||
|
'client_id' => $client['id'],
|
||||||
|
'client_name' => $client['name'],
|
||||||
|
'speed_up_kbps' => $stats['speed_up_kbps'],
|
||||||
|
'speed_down_kbps' => $stats['speed_down_kbps'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CPU usage percentage
|
||||||
|
*/
|
||||||
|
private function getCpuUsage(): ?float
|
||||||
|
{
|
||||||
|
$cmd = "top -bn1 | grep 'Cpu(s)' | sed 's/.*, *\\([0-9.]*\\)%* id.*/\\1/' | awk '{print 100 - \$1}'";
|
||||||
|
$result = $this->execSSH($cmd);
|
||||||
|
|
||||||
|
return $result ? (float)trim($result) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get RAM used in MB
|
||||||
|
*/
|
||||||
|
private function getRamUsed(): ?int
|
||||||
|
{
|
||||||
|
$cmd = "free -m | grep Mem | awk '{print \$3}'";
|
||||||
|
$result = $this->execSSH($cmd);
|
||||||
|
|
||||||
|
return $result ? (int)trim($result) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total RAM in MB
|
||||||
|
*/
|
||||||
|
private function getRamTotal(): ?int
|
||||||
|
{
|
||||||
|
$cmd = "free -m | grep Mem | awk '{print \$2}'";
|
||||||
|
$result = $this->execSSH($cmd);
|
||||||
|
|
||||||
|
return $result ? (int)trim($result) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get disk used in GB
|
||||||
|
*/
|
||||||
|
private function getDiskUsed(): ?float
|
||||||
|
{
|
||||||
|
$cmd = "df -BG / | tail -1 | awk '{print \$3}' | sed 's/G//'";
|
||||||
|
$result = $this->execSSH($cmd);
|
||||||
|
|
||||||
|
return $result ? (float)trim($result) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total disk in GB
|
||||||
|
*/
|
||||||
|
private function getDiskTotal(): ?float
|
||||||
|
{
|
||||||
|
$cmd = "df -BG / | tail -1 | awk '{print \$2}' | sed 's/G//'";
|
||||||
|
$result = $this->execSSH($cmd);
|
||||||
|
|
||||||
|
return $result ? (float)trim($result) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get network RX speed in Mbps
|
||||||
|
*/
|
||||||
|
private function getNetworkRxSpeed(): ?float
|
||||||
|
{
|
||||||
|
// Get bytes received on main interface
|
||||||
|
$cmd = "cat /sys/class/net/\$(ip route | grep default | awk '{print \$5}' | head -1)/statistics/rx_bytes";
|
||||||
|
$bytes1 = $this->execSSH($cmd);
|
||||||
|
|
||||||
|
if (!$bytes1) return null;
|
||||||
|
|
||||||
|
sleep(1); // Wait 1 second
|
||||||
|
|
||||||
|
$bytes2 = $this->execSSH($cmd);
|
||||||
|
|
||||||
|
if (!$bytes2) return null;
|
||||||
|
|
||||||
|
// Calculate speed in Mbps
|
||||||
|
$bytesPerSec = (int)$bytes2 - (int)$bytes1;
|
||||||
|
$mbps = ($bytesPerSec * 8) / 1000000;
|
||||||
|
|
||||||
|
return round($mbps, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get network TX speed in Mbps
|
||||||
|
*/
|
||||||
|
private function getNetworkTxSpeed(): ?float
|
||||||
|
{
|
||||||
|
// Get bytes transmitted on main interface
|
||||||
|
$cmd = "cat /sys/class/net/\$(ip route | grep default | awk '{print \$5}' | head -1)/statistics/tx_bytes";
|
||||||
|
$bytes1 = $this->execSSH($cmd);
|
||||||
|
|
||||||
|
if (!$bytes1) return null;
|
||||||
|
|
||||||
|
sleep(1); // Wait 1 second
|
||||||
|
|
||||||
|
$bytes2 = $this->execSSH($cmd);
|
||||||
|
|
||||||
|
if (!$bytes2) return null;
|
||||||
|
|
||||||
|
// Calculate speed in Mbps
|
||||||
|
$bytesPerSec = (int)$bytes2 - (int)$bytes1;
|
||||||
|
$mbps = ($bytesPerSec * 8) / 1000000;
|
||||||
|
|
||||||
|
return round($mbps, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client current stats and calculate speed
|
||||||
|
*/
|
||||||
|
private function getClientStats(array $client): ?array
|
||||||
|
{
|
||||||
|
$db = DB::conn();
|
||||||
|
|
||||||
|
// Get current stats from server
|
||||||
|
$containerName = $this->serverData['container_name'];
|
||||||
|
$publicKey = $client['public_key'];
|
||||||
|
|
||||||
|
$cmd = "docker exec {$containerName} wg show all dump | grep '{$publicKey}' | awk '{print \$6, \$7}'";
|
||||||
|
$result = $this->execSSH($cmd);
|
||||||
|
|
||||||
|
if (!$result) return null;
|
||||||
|
|
||||||
|
list($bytesReceived, $bytesSent) = explode(' ', trim($result));
|
||||||
|
|
||||||
|
// Get previous metrics (30 seconds ago)
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT bytes_sent, bytes_received, collected_at
|
||||||
|
FROM client_metrics
|
||||||
|
WHERE client_id = ?
|
||||||
|
ORDER BY collected_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
");
|
||||||
|
$stmt->execute([$client['id']]);
|
||||||
|
$previous = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$speedUp = 0;
|
||||||
|
$speedDown = 0;
|
||||||
|
|
||||||
|
if ($previous) {
|
||||||
|
$timeDiff = time() - strtotime($previous['collected_at']);
|
||||||
|
if ($timeDiff > 0) {
|
||||||
|
// Calculate speed in Kbps
|
||||||
|
$bytesDiffSent = (int)$bytesSent - (int)$previous['bytes_sent'];
|
||||||
|
$bytesDiffReceived = (int)$bytesReceived - (int)$previous['bytes_received'];
|
||||||
|
|
||||||
|
$speedUp = round(($bytesDiffSent * 8) / $timeDiff / 1000, 2);
|
||||||
|
$speedDown = round(($bytesDiffReceived * 8) / $timeDiff / 1000, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'bytes_sent' => (int)$bytesSent,
|
||||||
|
'bytes_received' => (int)$bytesReceived,
|
||||||
|
'speed_up_kbps' => $speedUp,
|
||||||
|
'speed_down_kbps' => $speedDown,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save server metrics to database
|
||||||
|
*/
|
||||||
|
private function saveServerMetrics(array $metrics): void
|
||||||
|
{
|
||||||
|
$db = DB::conn();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO server_metrics
|
||||||
|
(server_id, cpu_percent, ram_used_mb, ram_total_mb, disk_used_gb, disk_total_gb, network_rx_mbps, network_tx_mbps)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$this->serverData['id'],
|
||||||
|
$metrics['cpu_percent'],
|
||||||
|
$metrics['ram_used_mb'],
|
||||||
|
$metrics['ram_total_mb'],
|
||||||
|
$metrics['disk_used_gb'],
|
||||||
|
$metrics['disk_total_gb'],
|
||||||
|
$metrics['network_rx_mbps'],
|
||||||
|
$metrics['network_tx_mbps'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save client metrics to database
|
||||||
|
*/
|
||||||
|
private function saveClientMetrics(int $clientId, array $stats): void
|
||||||
|
{
|
||||||
|
$db = DB::conn();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO client_metrics
|
||||||
|
(client_id, bytes_sent, bytes_received, speed_up_kbps, speed_down_kbps)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$clientId,
|
||||||
|
$stats['bytes_sent'],
|
||||||
|
$stats['bytes_received'],
|
||||||
|
$stats['speed_up_kbps'],
|
||||||
|
$stats['speed_down_kbps'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get server metrics for last 24 hours
|
||||||
|
*/
|
||||||
|
public static function getServerMetrics(int $serverId, int $hours = 24): array
|
||||||
|
{
|
||||||
|
$db = DB::conn();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT *
|
||||||
|
FROM server_metrics
|
||||||
|
WHERE server_id = ?
|
||||||
|
AND collected_at >= DATE_SUB(NOW(), INTERVAL ? HOUR)
|
||||||
|
ORDER BY collected_at ASC
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([$serverId, $hours]);
|
||||||
|
|
||||||
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client metrics for last 24 hours
|
||||||
|
*/
|
||||||
|
public static function getClientMetrics(int $clientId, int $hours = 24): array
|
||||||
|
{
|
||||||
|
$db = DB::conn();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT *
|
||||||
|
FROM client_metrics
|
||||||
|
WHERE client_id = ?
|
||||||
|
AND collected_at >= DATE_SUB(NOW(), INTERVAL ? HOUR)
|
||||||
|
ORDER BY collected_at ASC
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([$clientId, $hours]);
|
||||||
|
|
||||||
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean old metrics (older than 24 hours)
|
||||||
|
*/
|
||||||
|
public static function cleanOldMetrics(): void
|
||||||
|
{
|
||||||
|
$db = DB::conn();
|
||||||
|
|
||||||
|
// Clean server metrics
|
||||||
|
$db->exec("DELETE FROM server_metrics WHERE collected_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)");
|
||||||
|
|
||||||
|
// Clean client metrics
|
||||||
|
$db->exec("DELETE FROM client_metrics WHERE collected_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute SSH command on server
|
||||||
|
*/
|
||||||
|
private function execSSH(string $cmd): ?string
|
||||||
|
{
|
||||||
|
$host = $this->serverData['host'];
|
||||||
|
$port = $this->serverData['port'];
|
||||||
|
$username = $this->serverData['username'];
|
||||||
|
$password = $this->serverData['password'];
|
||||||
|
|
||||||
|
$sshCmd = sprintf(
|
||||||
|
'sshpass -p %s ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p %d %s@%s %s 2>/dev/null',
|
||||||
|
escapeshellarg($password),
|
||||||
|
$port,
|
||||||
|
escapeshellarg($username),
|
||||||
|
escapeshellarg($host),
|
||||||
|
escapeshellarg($cmd)
|
||||||
|
);
|
||||||
|
|
||||||
|
$output = shell_exec($sshCmd);
|
||||||
|
|
||||||
|
return $output ?: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-7
@@ -689,15 +689,11 @@ class VpnClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format bytes to human-readable string
|
* Format bytes to human-readable string (always in MB)
|
||||||
*/
|
*/
|
||||||
private function formatBytes(int $bytes): string {
|
private function formatBytes(int $bytes): string {
|
||||||
if ($bytes === 0) return '0 B';
|
$mb = $bytes / 1048576; // 1024 * 1024
|
||||||
|
return number_format($mb, 2) . ' MB';
|
||||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
$i = floor(log($bytes) / log(1024));
|
|
||||||
|
|
||||||
return round($bytes / pow(1024, $i), 2) . ' ' . $units[$i];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Add server metrics tables
|
||||||
|
-- This migration adds functionality to store and display server monitoring data
|
||||||
|
|
||||||
|
-- Server metrics (CPU, RAM, Disk, Network)
|
||||||
|
CREATE TABLE IF NOT EXISTS server_metrics (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
server_id INT UNSIGNED NOT NULL,
|
||||||
|
cpu_percent DECIMAL(5,2) NULL COMMENT 'CPU usage percentage',
|
||||||
|
ram_used_mb INT UNSIGNED NULL COMMENT 'RAM used in MB',
|
||||||
|
ram_total_mb INT UNSIGNED NULL COMMENT 'Total RAM in MB',
|
||||||
|
disk_used_gb DECIMAL(10,2) NULL COMMENT 'Disk used in GB',
|
||||||
|
disk_total_gb DECIMAL(10,2) NULL COMMENT 'Total disk in GB',
|
||||||
|
network_rx_mbps DECIMAL(10,2) NULL COMMENT 'Network receive speed in Mbps',
|
||||||
|
network_tx_mbps DECIMAL(10,2) NULL COMMENT 'Network transmit speed in Mbps',
|
||||||
|
collected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_server_time (server_id, collected_at),
|
||||||
|
FOREIGN KEY (server_id) REFERENCES vpn_servers(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Client traffic metrics (speed tracking)
|
||||||
|
CREATE TABLE IF NOT EXISTS client_metrics (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
client_id INT UNSIGNED NOT NULL,
|
||||||
|
bytes_sent BIGINT UNSIGNED DEFAULT 0 COMMENT 'Bytes sent at this moment',
|
||||||
|
bytes_received BIGINT UNSIGNED DEFAULT 0 COMMENT 'Bytes received at this moment',
|
||||||
|
speed_up_kbps DECIMAL(10,2) NULL COMMENT 'Upload speed in Kbps',
|
||||||
|
speed_down_kbps DECIMAL(10,2) NULL COMMENT 'Download speed in Kbps',
|
||||||
|
collected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_client_time (client_id, collected_at),
|
||||||
|
FOREIGN KEY (client_id) REFERENCES vpn_clients(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
-- Add translations for monitoring UI elements
|
||||||
|
|
||||||
|
INSERT INTO translations (translation_key, language_code, translation_value) VALUES
|
||||||
|
-- Speed
|
||||||
|
('common.speed', 'en', 'Speed'),
|
||||||
|
('common.speed', 'ru', 'Скорость'),
|
||||||
|
('common.speed', 'es', 'Velocidad'),
|
||||||
|
('common.speed', 'de', 'Geschwindigkeit'),
|
||||||
|
('common.speed', 'fr', 'Vitesse'),
|
||||||
|
('common.speed', 'zh', '速度'),
|
||||||
|
|
||||||
|
-- Metrics
|
||||||
|
('common.metrics', 'en', 'Metrics'),
|
||||||
|
('common.metrics', 'ru', 'Метрики'),
|
||||||
|
('common.metrics', 'es', 'Métricas'),
|
||||||
|
('common.metrics', 'de', 'Metriken'),
|
||||||
|
('common.metrics', 'fr', 'Métriques'),
|
||||||
|
('common.metrics', 'zh', '指标'),
|
||||||
|
|
||||||
|
-- Server Info
|
||||||
|
('servers.server_info', 'en', 'Server Info'),
|
||||||
|
('servers.server_info', 'ru', 'Информация о сервере'),
|
||||||
|
('servers.server_info', 'es', 'Información del servidor'),
|
||||||
|
('servers.server_info', 'de', 'Serverinformationen'),
|
||||||
|
('servers.server_info', 'fr', 'Informations sur le serveur'),
|
||||||
|
('servers.server_info', 'zh', '服务器信息'),
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
('common.status', 'en', 'Status'),
|
||||||
|
('common.status', 'ru', 'Статус'),
|
||||||
|
('common.status', 'es', 'Estado'),
|
||||||
|
('common.status', 'de', 'Status'),
|
||||||
|
('common.status', 'fr', 'Statut'),
|
||||||
|
('common.status', 'zh', '状态'),
|
||||||
|
|
||||||
|
-- Client Configuration
|
||||||
|
('clients.configuration', 'en', 'Client Configuration'),
|
||||||
|
('clients.configuration', 'ru', 'Конфигурация клиента'),
|
||||||
|
('clients.configuration', 'es', 'Configuración del cliente'),
|
||||||
|
('clients.configuration', 'de', 'Client-Konfiguration'),
|
||||||
|
('clients.configuration', 'fr', 'Configuration du client'),
|
||||||
|
('clients.configuration', 'zh', '客户端配置'),
|
||||||
|
|
||||||
|
-- Traffic Statistics
|
||||||
|
('clients.traffic_stats', 'en', 'Traffic Statistics'),
|
||||||
|
('clients.traffic_stats', 'ru', 'Статистика трафика'),
|
||||||
|
('clients.traffic_stats', 'es', 'Estadísticas de tráfico'),
|
||||||
|
('clients.traffic_stats', 'de', 'Traffic-Statistiken'),
|
||||||
|
('clients.traffic_stats', 'fr', 'Statistiques de trafic'),
|
||||||
|
('clients.traffic_stats', 'zh', '流量统计'),
|
||||||
|
|
||||||
|
-- Uploaded
|
||||||
|
('common.uploaded', 'en', 'Uploaded'),
|
||||||
|
('common.uploaded', 'ru', 'Отправлено'),
|
||||||
|
('common.uploaded', 'es', 'Subido'),
|
||||||
|
('common.uploaded', 'de', 'Hochgeladen'),
|
||||||
|
('common.uploaded', 'fr', 'Envoyé'),
|
||||||
|
('common.uploaded', 'zh', '上传'),
|
||||||
|
|
||||||
|
-- Downloaded
|
||||||
|
('common.downloaded', 'en', 'Downloaded'),
|
||||||
|
('common.downloaded', 'ru', 'Получено'),
|
||||||
|
('common.downloaded', 'es', 'Descargado'),
|
||||||
|
('common.downloaded', 'de', 'Heruntergeladen'),
|
||||||
|
('common.downloaded', 'fr', 'Reçu'),
|
||||||
|
('common.downloaded', 'zh', '下载'),
|
||||||
|
|
||||||
|
-- Total
|
||||||
|
('common.total', 'en', 'Total'),
|
||||||
|
('common.total', 'ru', 'Всего'),
|
||||||
|
('common.total', 'es', 'Total'),
|
||||||
|
('common.total', 'de', 'Gesamt'),
|
||||||
|
('common.total', 'fr', 'Total'),
|
||||||
|
('common.total', 'zh', '总计'),
|
||||||
|
|
||||||
|
-- Created
|
||||||
|
('common.created', 'en', 'Created'),
|
||||||
|
('common.created', 'ru', 'Создан'),
|
||||||
|
('common.created', 'es', 'Creado'),
|
||||||
|
('common.created', 'de', 'Erstellt'),
|
||||||
|
('common.created', 'fr', 'Créé'),
|
||||||
|
('common.created', 'zh', '创建时间'),
|
||||||
|
|
||||||
|
-- IP Address
|
||||||
|
('common.ip_address', 'en', 'IP Address'),
|
||||||
|
('common.ip_address', 'ru', 'IP-адрес'),
|
||||||
|
('common.ip_address', 'es', 'Dirección IP'),
|
||||||
|
('common.ip_address', 'de', 'IP-Adresse'),
|
||||||
|
('common.ip_address', 'fr', 'Adresse IP'),
|
||||||
|
('common.ip_address', 'zh', 'IP地址')
|
||||||
|
|
||||||
|
ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value);
|
||||||
+157
-4
@@ -19,6 +19,7 @@ require_once __DIR__ . '/../inc/VpnClient.php';
|
|||||||
require_once __DIR__ . '/../inc/Translator.php';
|
require_once __DIR__ . '/../inc/Translator.php';
|
||||||
require_once __DIR__ . '/../inc/JWT.php';
|
require_once __DIR__ . '/../inc/JWT.php';
|
||||||
require_once __DIR__ . '/../inc/PanelImporter.php';
|
require_once __DIR__ . '/../inc/PanelImporter.php';
|
||||||
|
require_once __DIR__ . '/../inc/ServerMonitoring.php';
|
||||||
|
|
||||||
// Load environment configuration
|
// Load environment configuration
|
||||||
Config::load(__DIR__ . '/../.env');
|
Config::load(__DIR__ . '/../.env');
|
||||||
@@ -48,6 +49,29 @@ Translator::init();
|
|||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
$appName = Config::get('APP_NAME', 'Amnezia VPN Panel');
|
$appName = Config::get('APP_NAME', 'Amnezia VPN Panel');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to authenticate user from JWT or session
|
||||||
|
* Returns user array or null if unauthorized
|
||||||
|
*/
|
||||||
|
function authenticateRequest(): ?array {
|
||||||
|
// Check JWT token in Authorization header
|
||||||
|
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||||
|
if ($authHeader && preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
|
||||||
|
$token = $matches[1];
|
||||||
|
$user = JWT::verify($token);
|
||||||
|
if ($user) {
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to session
|
||||||
|
if (isset($_SESSION['user_id'])) {
|
||||||
|
return Auth::user();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
View::init(__DIR__ . '/../templates', [
|
View::init(__DIR__ . '/../templates', [
|
||||||
'app_name' => $appName,
|
'app_name' => $appName,
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
@@ -421,6 +445,36 @@ Router::get('/servers/{id}', function ($params) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Server monitoring page
|
||||||
|
Router::get('/servers/{id}/monitoring', function ($params) {
|
||||||
|
requireAuth();
|
||||||
|
$serverId = (int)$params['id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$server = new VpnServer($serverId);
|
||||||
|
$serverData = $server->getData();
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
$user = Auth::user();
|
||||||
|
if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo 'Forbidden';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get clients for this server
|
||||||
|
$clients = VpnClient::listByServer($serverId);
|
||||||
|
|
||||||
|
View::render('servers/monitoring.twig', [
|
||||||
|
'server' => $serverData,
|
||||||
|
'clients' => $clients,
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo 'Server not found';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Delete server
|
// Delete server
|
||||||
Router::post('/servers/{id}/delete', function ($params) {
|
Router::post('/servers/{id}/delete', function ($params) {
|
||||||
requireAuth();
|
requireAuth();
|
||||||
@@ -1272,12 +1326,112 @@ Router::post('/api/clients/{id}/restore', function ($params) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API: Get server metrics
|
||||||
|
Router::get('/api/servers/{id}/metrics', function ($params) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Check authentication - either JWT or session
|
||||||
|
$user = null;
|
||||||
|
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||||
|
|
||||||
|
if ($authHeader && preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
|
||||||
|
// JWT authentication
|
||||||
|
$token = $matches[1];
|
||||||
|
$user = JWT::verify($token);
|
||||||
|
} else if (isset($_SESSION['user_id'])) {
|
||||||
|
// Session authentication
|
||||||
|
$user = Auth::user();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$serverId = (int)$params['id'];
|
||||||
|
$hours = isset($_GET['hours']) ? (float)$_GET['hours'] : 24;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$server = new VpnServer($serverId);
|
||||||
|
$serverData = $server->getData();
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'Forbidden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metrics = ServerMonitoring::getServerMetrics($serverId, $hours);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'metrics' => $metrics]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Get client metrics
|
||||||
|
Router::get('/api/clients/{id}/metrics', function ($params) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Check authentication - either JWT or session
|
||||||
|
$user = null;
|
||||||
|
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||||
|
|
||||||
|
if ($authHeader && preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
|
||||||
|
// JWT authentication
|
||||||
|
$token = $matches[1];
|
||||||
|
$user = JWT::verify($token);
|
||||||
|
} else if (isset($_SESSION['user_id'])) {
|
||||||
|
// Session authentication
|
||||||
|
$user = Auth::user();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientId = (int)$params['id'];
|
||||||
|
$hours = isset($_GET['hours']) ? (float)$_GET['hours'] : 24;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client = new VpnClient($clientId);
|
||||||
|
$clientData = $client->getData();
|
||||||
|
|
||||||
|
// Get server to check ownership
|
||||||
|
$server = new VpnServer($clientData['server_id']);
|
||||||
|
$serverData = $server->getData();
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'Forbidden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$metrics = ServerMonitoring::getClientMetrics($clientId, $hours);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'metrics' => $metrics]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// API: Get server clients
|
// API: Get server clients
|
||||||
Router::get('/api/servers/{id}/clients', function ($params) {
|
Router::get('/api/servers/{id}/clients', function ($params) {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
$user = JWT::requireAuth();
|
$user = authenticateRequest();
|
||||||
if (!$user) return;
|
if (!$user) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$serverId = (int)$params['id'];
|
$serverId = (int)$params['id'];
|
||||||
|
|
||||||
@@ -1286,8 +1440,7 @@ Router::get('/api/servers/{id}/clients', function ($params) {
|
|||||||
$serverData = $server->getData();
|
$serverData = $server->getData();
|
||||||
|
|
||||||
// Check ownership
|
// Check ownership
|
||||||
$user = Auth::user();
|
if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') {
|
||||||
if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) {
|
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['error' => 'Forbidden']);
|
echo json_encode(['error' => 'Forbidden']);
|
||||||
return;
|
return;
|
||||||
|
|||||||
+11
-11
@@ -6,19 +6,19 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
<div class="bg-white rounded shadow p-6">
|
<div class="bg-white rounded shadow p-6">
|
||||||
<h3 class="font-bold mb-4">Client Configuration</h3>
|
<h3 class="font-bold mb-4">{{ t('clients.configuration') }}</h3>
|
||||||
<dl class="space-y-2 mb-4">
|
<dl class="space-y-2 mb-4">
|
||||||
<div><dt class="text-sm text-gray-600">IP Address</dt><dd>{{ client.client_ip }}</dd></div>
|
<div><dt class="text-sm text-gray-600">{{ t('common.ip_address') }}</dt><dd>{{ client.client_ip }}</dd></div>
|
||||||
<div><dt class="text-sm text-gray-600">Status</dt>
|
<div><dt class="text-sm text-gray-600">{{ t('common.status') }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{% if client.status == 'active' %}
|
{% if client.status == 'active' %}
|
||||||
<span class="px-2 py-1 bg-green-100 text-green-800 rounded text-xs">Active</span>
|
<span class="px-2 py-1 bg-green-100 text-green-800 rounded text-xs">{{ t('status.active') }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">Disabled</span>
|
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">{{ t('status.disabled') }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div><dt class="text-sm text-gray-600">Created</dt><dd>{{ client.created_at }}</dd></div>
|
<div><dt class="text-sm text-gray-600">{{ t('common.created') }}</dt><dd>{{ client.created_at }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="/clients/{{ client.id }}/download" class="gradient-bg text-white px-4 py-2 rounded">
|
<a href="/clients/{{ client.id }}/download" class="gradient-bg text-white px-4 py-2 rounded">
|
||||||
@@ -42,16 +42,16 @@
|
|||||||
|
|
||||||
<div class="bg-white rounded shadow p-6">
|
<div class="bg-white rounded shadow p-6">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="font-bold">Traffic Statistics</h3>
|
<h3 class="font-bold">{{ t('clients.traffic_stats') }}</h3>
|
||||||
<button onclick="syncStats({{ client.id }})" class="text-purple-600 hover:text-purple-800 text-sm">
|
<button onclick="syncStats({{ client.id }})" class="text-purple-600 hover:text-purple-800 text-sm">
|
||||||
<i class="fas fa-sync-alt"></i> Refresh
|
<i class="fas fa-sync-alt"></i> Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<dl class="space-y-2" id="stats">
|
<dl class="space-y-2" id="stats">
|
||||||
<div><dt class="text-sm text-gray-600">Uploaded</dt><dd id="stat-sent">{{ client.bytes_sent|default(0)|number_format }} B</dd></div>
|
<div><dt class="text-sm text-gray-600">{{ t('common.uploaded') }}</dt><dd id="stat-sent">{{ (client.bytes_sent|default(0) / 1048576)|number_format(2) }} MB</dd></div>
|
||||||
<div><dt class="text-sm text-gray-600">Downloaded</dt><dd id="stat-received">{{ client.bytes_received|default(0)|number_format }} B</dd></div>
|
<div><dt class="text-sm text-gray-600">{{ t('common.downloaded') }}</dt><dd id="stat-received">{{ (client.bytes_received|default(0) / 1048576)|number_format(2) }} MB</dd></div>
|
||||||
<div><dt class="text-sm text-gray-600">Total</dt><dd id="stat-total">{{ (client.bytes_sent|default(0) + client.bytes_received|default(0))|number_format }} B</dd></div>
|
<div><dt class="text-sm text-gray-600">{{ t('common.total') }}</dt><dd id="stat-total">{{ ((client.bytes_sent|default(0) + client.bytes_received|default(0)) / 1048576)|number_format(2) }} MB</dd></div>
|
||||||
<div><dt class="text-sm text-gray-600">Last Handshake</dt>
|
<div><dt class="text-sm text-gray-600">{{ t('clients.last_handshake') }}</dt>
|
||||||
<dd id="stat-last-seen">
|
<dd id="stat-last-seen">
|
||||||
{% if client.last_handshake %}
|
{% if client.last_handshake %}
|
||||||
{{ client.last_handshake }}
|
{{ client.last_handshake }}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
{% block styles %}{% endblock %}
|
||||||
<style>
|
<style>
|
||||||
.gradient-bg {
|
.gradient-bg {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
{% extends "layout.twig" %}
|
{% extends "layout.twig" %}
|
||||||
{% block title %}{{ t('servers.title') }}{% endblock %}
|
{% block title %}{{ t('servers.title') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<style>
|
||||||
|
.server-metrics {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.metric-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.metric-badge i {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.metric-sparkline {
|
||||||
|
height: 20px;
|
||||||
|
width: 40px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
@@ -27,12 +56,13 @@
|
|||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('servers.name') }}</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('servers.name') }}</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('servers.host') }}</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('servers.host') }}</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('servers.status') }}</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('servers.status') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('common.metrics') }}</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('servers.actions') }}</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('servers.actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for server in servers %}
|
{% for server in servers %}
|
||||||
<tr class="border-t">
|
<tr class="border-t" data-server-id="{{ server.id }}">
|
||||||
<td class="px-6 py-4 font-medium">{{ server.name }}</td>
|
<td class="px-6 py-4 font-medium">{{ server.name }}</td>
|
||||||
<td class="px-6 py-4">{{ server.host }}</td>
|
<td class="px-6 py-4">{{ server.host }}</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
@@ -40,6 +70,30 @@
|
|||||||
{{ server.status }}
|
{{ server.status }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if server.status == 'active' %}
|
||||||
|
<div class="server-metrics" id="metrics-{{ server.id }}">
|
||||||
|
<div class="metric-badge">
|
||||||
|
<i class="fas fa-microchip" style="color:#dc3545"></i>
|
||||||
|
<span class="metric-val" data-metric="cpu">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-badge">
|
||||||
|
<i class="fas fa-memory" style="color:#ffc107"></i>
|
||||||
|
<span class="metric-val" data-metric="ram">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-badge">
|
||||||
|
<i class="fas fa-hdd" style="color:#17a2b8"></i>
|
||||||
|
<span class="metric-val" data-metric="disk">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-badge">
|
||||||
|
<i class="fas fa-network-wired" style="color:#28a745"></i>
|
||||||
|
<span class="metric-val" data-metric="network">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 text-xs">Not deployed</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="px-6 py-4 space-x-3">
|
<td class="px-6 py-4 space-x-3">
|
||||||
<a href="/servers/{{ server.id }}" class="text-purple-600 hover:text-purple-900">
|
<a href="/servers/{{ server.id }}" class="text-purple-600 hover:text-purple-900">
|
||||||
<i class="fas fa-eye mr-1"></i>{{ t('servers.view') }}
|
<i class="fas fa-eye mr-1"></i>{{ t('servers.view') }}
|
||||||
@@ -64,3 +118,65 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Get all active server IDs
|
||||||
|
const activeServers = [
|
||||||
|
{% for server in servers %}
|
||||||
|
{% if server.status == 'active' %}
|
||||||
|
{{ server.id }},
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function updateServerMetrics(serverId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/servers/${serverId}/metrics?hours=1`, {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
console.log(`Server ${serverId} metrics:`, data);
|
||||||
|
|
||||||
|
if (data.success && data.metrics.length > 0) {
|
||||||
|
const latest = data.metrics[data.metrics.length - 1];
|
||||||
|
const metricsDiv = document.getElementById(`metrics-${serverId}`);
|
||||||
|
|
||||||
|
console.log(`Server ${serverId} latest:`, latest);
|
||||||
|
|
||||||
|
if (metricsDiv) {
|
||||||
|
const cpuVal = parseFloat(latest.cpu_percent).toFixed(1);
|
||||||
|
metricsDiv.querySelector('[data-metric="cpu"]').textContent = `${cpuVal}%`;
|
||||||
|
|
||||||
|
const ramPercent = (latest.ram_used_mb / latest.ram_total_mb * 100).toFixed(0);
|
||||||
|
metricsDiv.querySelector('[data-metric="ram"]').textContent = `${ramPercent}%`;
|
||||||
|
|
||||||
|
const diskPercent = (latest.disk_used_gb / latest.disk_total_gb * 100).toFixed(0);
|
||||||
|
metricsDiv.querySelector('[data-metric="disk"]').textContent = `${diskPercent}%`;
|
||||||
|
|
||||||
|
const netRx = parseFloat(latest.network_rx_mbps).toFixed(1);
|
||||||
|
const netTx = parseFloat(latest.network_tx_mbps).toFixed(1);
|
||||||
|
metricsDiv.querySelector('[data-metric="network"]').textContent = `↓${netRx}↑${netTx}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch metrics for server ${serverId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAllMetrics() {
|
||||||
|
activeServers.forEach(serverId => {
|
||||||
|
updateServerMetrics(serverId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
if (activeServers.length > 0) {
|
||||||
|
updateAllMetrics();
|
||||||
|
// Update every 30 seconds
|
||||||
|
setInterval(updateAllMetrics, 30000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
{% extends "layout.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ server.name }} - Monitoring{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<style>
|
||||||
|
.metrics-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card h3 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 250px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-metrics {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clients-metrics h3 {
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-speed-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-speeds {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-up {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-down {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-info {
|
||||||
|
text-align: center;
|
||||||
|
padding: 15px;
|
||||||
|
background: #e7f3ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #0066cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #007bff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width: 1400px; margin: 0 auto;">
|
||||||
|
<a href="/servers/{{ server.id }}" class="back-link">← Back to Server</a>
|
||||||
|
|
||||||
|
<h1>{{ server.name }} - Monitoring</h1>
|
||||||
|
|
||||||
|
<div class="refresh-info">
|
||||||
|
Auto-refresh every 30 seconds
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metrics-container">
|
||||||
|
<div class="metric-card">
|
||||||
|
<h3>
|
||||||
|
CPU Usage
|
||||||
|
<span class="metric-value" id="cpu-current">--</span>
|
||||||
|
</h3>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="cpu-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-card">
|
||||||
|
<h3>
|
||||||
|
RAM Usage
|
||||||
|
<span class="metric-value" id="ram-current">--</span>
|
||||||
|
</h3>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="ram-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-card">
|
||||||
|
<h3>
|
||||||
|
Disk Usage
|
||||||
|
<span class="metric-value" id="disk-current">--</span>
|
||||||
|
</h3>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="disk-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-card">
|
||||||
|
<h3>
|
||||||
|
Network Speed
|
||||||
|
<span class="metric-value" id="network-current">--</span>
|
||||||
|
</h3>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="network-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clients-metrics">
|
||||||
|
<h3>Client Speeds</h3>
|
||||||
|
<div id="clients-list">
|
||||||
|
<div class="no-data">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const serverId = {{ server.id }};
|
||||||
|
let charts = {};
|
||||||
|
|
||||||
|
// Chart configurations
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: true,
|
||||||
|
type: 'time',
|
||||||
|
time: {
|
||||||
|
unit: 'minute',
|
||||||
|
displayFormats: {
|
||||||
|
minute: 'HH:mm'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize charts
|
||||||
|
function initCharts() {
|
||||||
|
// CPU Chart
|
||||||
|
charts.cpu = new Chart(document.getElementById('cpu-chart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
label: 'CPU %',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#dc3545',
|
||||||
|
backgroundColor: 'rgba(220, 53, 69, 0.1)',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartOptions,
|
||||||
|
scales: {
|
||||||
|
...chartOptions.scales,
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// RAM Chart
|
||||||
|
charts.ram = new Chart(document.getElementById('ram-chart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
label: 'RAM MB',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#ffc107',
|
||||||
|
backgroundColor: 'rgba(255, 193, 7, 0.1)',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: chartOptions
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disk Chart
|
||||||
|
charts.disk = new Chart(document.getElementById('disk-chart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Disk GB',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#17a2b8',
|
||||||
|
backgroundColor: 'rgba(23, 162, 184, 0.1)',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: chartOptions
|
||||||
|
});
|
||||||
|
|
||||||
|
// Network Chart
|
||||||
|
charts.network = new Chart(document.getElementById('network-chart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'RX Mbps',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#007bff',
|
||||||
|
backgroundColor: 'rgba(0, 123, 255, 0.1)',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'TX Mbps',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#28a745',
|
||||||
|
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartOptions,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update server metrics
|
||||||
|
async function updateServerMetrics() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/servers/${serverId}/metrics?hours=1`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + localStorage.getItem('auth_token')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.metrics.length > 0) {
|
||||||
|
const metrics = data.metrics;
|
||||||
|
const latest = metrics[metrics.length - 1];
|
||||||
|
|
||||||
|
// Update current values
|
||||||
|
document.getElementById('cpu-current').textContent = `${latest.cpu_percent}%`;
|
||||||
|
document.getElementById('ram-current').textContent =
|
||||||
|
`${latest.ram_used_mb} / ${latest.ram_total_mb} MB (${Math.round(latest.ram_used_mb / latest.ram_total_mb * 100)}%)`;
|
||||||
|
document.getElementById('disk-current').textContent =
|
||||||
|
`${latest.disk_used_gb} / ${latest.disk_total_gb} GB (${Math.round(latest.disk_used_gb / latest.disk_total_gb * 100)}%)`;
|
||||||
|
document.getElementById('network-current').textContent =
|
||||||
|
`↓ ${latest.network_rx_mbps} Mbps ↑ ${latest.network_tx_mbps} Mbps`;
|
||||||
|
|
||||||
|
// Update charts
|
||||||
|
const timestamps = metrics.map(m => new Date(m.collected_at));
|
||||||
|
|
||||||
|
charts.cpu.data.labels = timestamps;
|
||||||
|
charts.cpu.data.datasets[0].data = metrics.map(m => parseFloat(m.cpu_percent));
|
||||||
|
charts.cpu.update('none');
|
||||||
|
|
||||||
|
charts.ram.data.labels = timestamps;
|
||||||
|
charts.ram.data.datasets[0].data = metrics.map(m => parseInt(m.ram_used_mb));
|
||||||
|
charts.ram.update('none');
|
||||||
|
|
||||||
|
charts.disk.data.labels = timestamps;
|
||||||
|
charts.disk.data.datasets[0].data = metrics.map(m => parseFloat(m.disk_used_gb));
|
||||||
|
charts.disk.update('none');
|
||||||
|
|
||||||
|
charts.network.data.labels = timestamps;
|
||||||
|
charts.network.data.datasets[0].data = metrics.map(m => parseFloat(m.network_rx_mbps));
|
||||||
|
charts.network.data.datasets[1].data = metrics.map(m => parseFloat(m.network_tx_mbps));
|
||||||
|
charts.network.update('none');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch server metrics:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update client speeds
|
||||||
|
async function updateClientSpeeds() {
|
||||||
|
try {
|
||||||
|
const clientIds = {{ clients|map(c => c.id)|json_encode|raw }};
|
||||||
|
|
||||||
|
if (clientIds.length === 0) {
|
||||||
|
document.getElementById('clients-list').innerHTML =
|
||||||
|
'<div class="no-data">No active clients</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientsData = [];
|
||||||
|
|
||||||
|
for (const clientId of clientIds) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/clients/${clientId}/metrics?hours=1`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + localStorage.getItem('auth_token')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.metrics.length > 0) {
|
||||||
|
const latest = data.metrics[data.metrics.length - 1];
|
||||||
|
const client = {{ clients|json_encode|raw }}.find(c => c.id === clientId);
|
||||||
|
|
||||||
|
clientsData.push({
|
||||||
|
name: client.name,
|
||||||
|
speedUp: parseFloat(latest.speed_up_kbps),
|
||||||
|
speedDown: parseFloat(latest.speed_down_kbps)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch client ${clientId} metrics:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by total speed
|
||||||
|
clientsData.sort((a, b) => (b.speedUp + b.speedDown) - (a.speedUp + a.speedDown));
|
||||||
|
|
||||||
|
// Render clients list
|
||||||
|
const html = clientsData.map(c => `
|
||||||
|
<div class="client-speed-item">
|
||||||
|
<span class="client-name">${c.name}</span>
|
||||||
|
<div class="client-speeds">
|
||||||
|
<span class="speed-up">↑ ${c.speedUp.toFixed(2)} Kbps</span>
|
||||||
|
<span class="speed-down">↓ ${c.speedDown.toFixed(2)} Kbps</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
document.getElementById('clients-list').innerHTML = html ||
|
||||||
|
'<div class="no-data">No active traffic</div>';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update client speeds:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
initCharts();
|
||||||
|
updateServerMetrics();
|
||||||
|
updateClientSpeeds();
|
||||||
|
|
||||||
|
// Auto-refresh every 30 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
updateServerMetrics();
|
||||||
|
updateClientSpeeds();
|
||||||
|
}, 30000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+277
-3
@@ -1,8 +1,50 @@
|
|||||||
{% extends "layout.twig" %}
|
{% extends "layout.twig" %}
|
||||||
{% block title %}{{ server.name }}{% endblock %}
|
{% block title %}{{ server.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
<style>
|
||||||
|
.metric-mini {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.metric-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.metric-icon {
|
||||||
|
width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.metric-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
.metric-chart {
|
||||||
|
height: 30px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.metric-value {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
min-width: 70px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||||
<div class="mb-6"><h1 class="text-3xl font-bold">{{ server.name }}</h1><p class="text-gray-600">{{ server.host }}</p></div>
|
<div class="mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">{{ server.name }}</h1>
|
||||||
|
<p class="text-gray-600">{{ server.host }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if import_message %}
|
{% if import_message %}
|
||||||
<div class="mb-6 {% if import_message.type == 'success' %}bg-green-50 border-green-400 text-green-700{% else %}bg-red-50 border-red-400 text-red-700{% endif %} border px-4 py-3 rounded">
|
<div class="mb-6 {% if import_message.type == 'success' %}bg-green-50 border-green-400 text-green-700{% else %}bg-red-50 border-red-400 text-red-700{% endif %} border px-4 py-3 rounded">
|
||||||
@@ -13,12 +55,41 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||||
<div class="bg-white rounded shadow p-6">
|
<div class="bg-white rounded shadow p-6">
|
||||||
<h3 class="font-bold mb-4">Server Info</h3>
|
<h3 class="font-bold mb-4">{{ t('servers.server_info') }}</h3>
|
||||||
<dl class="space-y-2">
|
<dl class="space-y-2">
|
||||||
<div><dt class="text-sm text-gray-600">Status</dt><dd><span class="px-2 py-1 bg-green-100 text-green-800 rounded text-sm">{{ server.status }}</span></dd></div>
|
<div><dt class="text-sm text-gray-600">{{ t('common.status') }}</dt><dd><span class="px-2 py-1 bg-green-100 text-green-800 rounded text-sm">{{ server.status }}</span></dd></div>
|
||||||
<div><dt class="text-sm text-gray-600">VPN Port</dt><dd>{{ server.vpn_port }}</dd></div>
|
<div><dt class="text-sm text-gray-600">VPN Port</dt><dd>{{ server.vpn_port }}</dd></div>
|
||||||
<div><dt class="text-sm text-gray-600">Subnet</dt><dd>{{ server.vpn_subnet }}</dd></div>
|
<div><dt class="text-sm text-gray-600">Subnet</dt><dd>{{ server.vpn_subnet }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
{% if server.status == 'active' %}
|
||||||
|
<div class="metric-mini" id="serverMetrics">
|
||||||
|
<div class="metric-row">
|
||||||
|
<i class="fas fa-microchip metric-icon" style="color:#dc3545"></i>
|
||||||
|
<span class="metric-label">CPU</span>
|
||||||
|
<canvas id="cpuSparkline" class="metric-chart"></canvas>
|
||||||
|
<div class="metric-value" id="cpuValue">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-row">
|
||||||
|
<i class="fas fa-memory metric-icon" style="color:#ffc107"></i>
|
||||||
|
<span class="metric-label">RAM</span>
|
||||||
|
<canvas id="ramSparkline" class="metric-chart"></canvas>
|
||||||
|
<div class="metric-value" id="ramValue">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-row">
|
||||||
|
<i class="fas fa-hdd metric-icon" style="color:#17a2b8"></i>
|
||||||
|
<span class="metric-label">Disk</span>
|
||||||
|
<canvas id="diskSparkline" class="metric-chart"></canvas>
|
||||||
|
<div class="metric-value" id="diskValue">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-row">
|
||||||
|
<i class="fas fa-network-wired metric-icon" style="color:#28a745"></i>
|
||||||
|
<span class="metric-label">Network</span>
|
||||||
|
<canvas id="networkSparkline" class="metric-chart"></canvas>
|
||||||
|
<div class="metric-value" id="networkValue">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white rounded shadow p-6">
|
<div class="bg-white rounded shadow p-6">
|
||||||
<h3 class="font-bold mb-4">{{ t('clients.create') }}</h3>
|
<h3 class="font-bold mb-4">{{ t('clients.create') }}</h3>
|
||||||
@@ -97,6 +168,7 @@
|
|||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.expiration') }}</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.expiration') }}</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.traffic') }}</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.traffic') }}</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.traffic_limit') }}</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.traffic_limit') }}</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('common.speed') }}</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.last_handshake') }}</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.last_handshake') }}</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.actions') }}</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -164,6 +236,9 @@
|
|||||||
<span class="text-gray-400">{{ t('clients.unlimited') }}</span>
|
<span class="text-gray-400">{{ t('clients.unlimited') }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm">
|
||||||
|
<div id="client-speed-{{ client.id }}" class="text-gray-400">-</div>
|
||||||
|
</td>
|
||||||
<td class="px-6 py-4 text-sm">
|
<td class="px-6 py-4 text-sm">
|
||||||
{% if client.last_handshake %}
|
{% if client.last_handshake %}
|
||||||
<span class="text-gray-600">{{ client.last_handshake }}</span>
|
<span class="text-gray-600">{{ client.last_handshake }}</span>
|
||||||
@@ -431,5 +506,204 @@ async function deleteBackup(backupId) {
|
|||||||
alert('Error: ' + error.message);
|
alert('Error: ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server metrics sparklines
|
||||||
|
{% if server.status == 'active' %}
|
||||||
|
const serverId = {{ server.id }};
|
||||||
|
let sparklineCharts = {};
|
||||||
|
|
||||||
|
function initSparklines() {
|
||||||
|
const chartConfig = {
|
||||||
|
type: 'line',
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: { enabled: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { display: false },
|
||||||
|
y: { display: false, beginAtZero: true }
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
line: { borderWidth: 2, tension: 0.4 },
|
||||||
|
point: { radius: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sparklineCharts.cpu = new Chart(document.getElementById('cpuSparkline'), {
|
||||||
|
...chartConfig,
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
data: [],
|
||||||
|
borderColor: '#dc3545',
|
||||||
|
backgroundColor: 'rgba(220, 53, 69, 0.1)',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartConfig.options,
|
||||||
|
scales: {
|
||||||
|
...chartConfig.options.scales,
|
||||||
|
y: { display: false, beginAtZero: true, max: 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sparklineCharts.ram = new Chart(document.getElementById('ramSparkline'), {
|
||||||
|
...chartConfig,
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
data: [],
|
||||||
|
borderColor: '#ffc107',
|
||||||
|
backgroundColor: 'rgba(255, 193, 7, 0.1)',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sparklineCharts.disk = new Chart(document.getElementById('diskSparkline'), {
|
||||||
|
...chartConfig,
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
data: [],
|
||||||
|
borderColor: '#17a2b8',
|
||||||
|
backgroundColor: 'rgba(23, 162, 184, 0.1)',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sparklineCharts.network = new Chart(document.getElementById('networkSparkline'), {
|
||||||
|
...chartConfig,
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
data: [],
|
||||||
|
borderColor: '#28a745',
|
||||||
|
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateServerMetrics() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/servers/${serverId}/metrics?hours=1`, {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
console.log('Metrics response:', data);
|
||||||
|
|
||||||
|
if (data.success && data.metrics.length > 0) {
|
||||||
|
const metrics = data.metrics.slice(-20); // Last 20 points
|
||||||
|
const latest = metrics[metrics.length - 1];
|
||||||
|
|
||||||
|
// Update values with more details
|
||||||
|
const cpuVal = parseFloat(latest.cpu_percent).toFixed(1);
|
||||||
|
document.getElementById('cpuValue').textContent = `${cpuVal}%`;
|
||||||
|
|
||||||
|
const ramUsed = Math.round(latest.ram_used_mb / 1024 * 10) / 10;
|
||||||
|
const ramTotal = Math.round(latest.ram_total_mb / 1024 * 10) / 10;
|
||||||
|
const ramPercent = (latest.ram_used_mb / latest.ram_total_mb * 100).toFixed(0);
|
||||||
|
document.getElementById('ramValue').textContent = `${ramPercent}% (${ramUsed}G)`;
|
||||||
|
|
||||||
|
const diskUsed = parseFloat(latest.disk_used_gb).toFixed(0);
|
||||||
|
const diskTotal = parseFloat(latest.disk_total_gb).toFixed(0);
|
||||||
|
const diskPercent = (latest.disk_used_gb / latest.disk_total_gb * 100).toFixed(0);
|
||||||
|
document.getElementById('diskValue').textContent = `${diskPercent}% (${diskUsed}G)`;
|
||||||
|
|
||||||
|
const netRx = parseFloat(latest.network_rx_mbps).toFixed(1);
|
||||||
|
const netTx = parseFloat(latest.network_tx_mbps).toFixed(1);
|
||||||
|
document.getElementById('networkValue').textContent = `↓${netRx} ↑${netTx}`;
|
||||||
|
|
||||||
|
// Update sparklines
|
||||||
|
const labels = metrics.map((_, i) => i);
|
||||||
|
|
||||||
|
sparklineCharts.cpu.data.labels = labels;
|
||||||
|
sparklineCharts.cpu.data.datasets[0].data = metrics.map(m => parseFloat(m.cpu_percent));
|
||||||
|
sparklineCharts.cpu.update('none');
|
||||||
|
|
||||||
|
sparklineCharts.ram.data.labels = labels;
|
||||||
|
sparklineCharts.ram.data.datasets[0].data = metrics.map(m => (m.ram_used_mb / m.ram_total_mb * 100));
|
||||||
|
sparklineCharts.ram.update('none');
|
||||||
|
|
||||||
|
sparklineCharts.disk.data.labels = labels;
|
||||||
|
sparklineCharts.disk.data.datasets[0].data = metrics.map(m => (m.disk_used_gb / m.disk_total_gb * 100));
|
||||||
|
sparklineCharts.disk.update('none');
|
||||||
|
|
||||||
|
sparklineCharts.network.data.labels = labels;
|
||||||
|
sparklineCharts.network.data.datasets[0].data = metrics.map(m => parseFloat(m.network_rx_mbps) + parseFloat(m.network_tx_mbps));
|
||||||
|
sparklineCharts.network.update('none');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch metrics:', error);
|
||||||
|
// Show error in UI
|
||||||
|
document.getElementById('cpuValue').textContent = 'Error';
|
||||||
|
document.getElementById('ramValue').textContent = 'Error';
|
||||||
|
document.getElementById('diskValue').textContent = 'Error';
|
||||||
|
document.getElementById('networkValue').textContent = 'Error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize sparklines if active
|
||||||
|
if (document.getElementById('cpuSparkline')) {
|
||||||
|
initSparklines();
|
||||||
|
updateServerMetrics();
|
||||||
|
setInterval(updateServerMetrics, 30000); // Update every 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update client speeds
|
||||||
|
async function updateClientSpeeds() {
|
||||||
|
const clientRows = document.querySelectorAll('[id^="client-speed-"]');
|
||||||
|
|
||||||
|
console.log('Found client speed rows:', clientRows.length);
|
||||||
|
|
||||||
|
for (const row of clientRows) {
|
||||||
|
const clientId = row.id.replace('client-speed-', '');
|
||||||
|
|
||||||
|
console.log(`Fetching metrics for client ${clientId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/clients/${clientId}/metrics?hours=1`, {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
console.log(`Client ${clientId} metrics:`, data);
|
||||||
|
|
||||||
|
if (data.success && data.metrics && data.metrics.length > 0) {
|
||||||
|
const latest = data.metrics[data.metrics.length - 1];
|
||||||
|
const speedUp = parseFloat(latest.speed_up_kbps).toFixed(1);
|
||||||
|
const speedDown = parseFloat(latest.speed_down_kbps).toFixed(1);
|
||||||
|
|
||||||
|
// Format as compact badge
|
||||||
|
row.innerHTML = `<span class="text-xs text-gray-700">↑${speedUp} ↓${speedDown} KB/s</span>`;
|
||||||
|
} else {
|
||||||
|
console.log(`No metrics for client ${clientId}`);
|
||||||
|
row.innerHTML = '<span class="text-xs text-gray-400">-</span>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch metrics for client ${clientId}:`, error);
|
||||||
|
row.innerHTML = '<span class="text-xs text-gray-400">-</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update client speeds on load and every 30 seconds
|
||||||
|
if (document.querySelector('[id^="client-speed-"]')) {
|
||||||
|
updateClientSpeeds();
|
||||||
|
setInterval(updateClientSpeeds, 30000);
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user