fix traffic reboot
This commit is contained in:
@@ -1246,28 +1246,92 @@ class InstallProtocolManager
|
|||||||
|
|
||||||
// Check if client exists
|
// Check if client exists
|
||||||
$clients = &$config['inbounds'][0]['settings']['clients'];
|
$clients = &$config['inbounds'][0]['settings']['clients'];
|
||||||
foreach ($clients as $c) {
|
$duplicateFound = false;
|
||||||
|
foreach ($clients as $k => $c) {
|
||||||
if (($c['id'] ?? '') === $clientId) {
|
if (($c['id'] ?? '') === $clientId) {
|
||||||
// Already exists
|
// Already exists by ID (exact match)
|
||||||
Logger::appendInstall($server->getId(), "Client $clientId already exists in X-Ray config");
|
Logger::appendInstall($server->getId(), "Client $clientId already exists in X-Ray config");
|
||||||
return ['success' => true, 'message' => 'Client already exists'];
|
return ['success' => true, 'message' => 'Client already exists'];
|
||||||
}
|
}
|
||||||
}
|
if (($c['email'] ?? '') === (!empty($options['login']) ? $options['login'] : $clientId)) {
|
||||||
|
// Email conflict! (Different ID but same email)
|
||||||
|
// This happens if user re-adds a client with same login but new UUID (after deleting from DB)
|
||||||
|
Logger::appendInstall($server->getId(), "Client email already exists in X-Ray config. Updating ID/Level.");
|
||||||
|
|
||||||
// Add client
|
// Update existing client entry with new UUID
|
||||||
$email = !empty($options['login']) ? $options['login'] : $clientId;
|
$clients[$k]['id'] = $clientId;
|
||||||
$newClient = ['id' => $clientId, 'email' => $email];
|
$clients[$k]['level'] = 0; // Ensure level 0
|
||||||
|
|
||||||
// Detect flow from other clients or default
|
$duplicateFound = true;
|
||||||
$flow = 'xtls-rprx-vision'; // Default for Reality
|
break;
|
||||||
if (!empty($clients)) {
|
|
||||||
if (isset($clients[0]['flow'])) {
|
|
||||||
$flow = $clients[0]['flow'];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$newClient['flow'] = $flow;
|
|
||||||
|
|
||||||
$clients[] = $newClient;
|
if (!$duplicateFound) {
|
||||||
|
// Add new client (no conflict)
|
||||||
|
$email = !empty($options['login']) ? $options['login'] : $clientId;
|
||||||
|
$newClient = ['id' => $clientId, 'email' => $email];
|
||||||
|
|
||||||
|
// Detect flow from other clients or default
|
||||||
|
$flow = 'xtls-rprx-vision'; // Default for Reality
|
||||||
|
if (!empty($clients)) {
|
||||||
|
if (isset($clients[0]['flow'])) {
|
||||||
|
$flow = $clients[0]['flow'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$newClient['flow'] = $flow;
|
||||||
|
$newClient['level'] = 0; // Explicitly set level 0
|
||||||
|
|
||||||
|
$clients[] = $newClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix JSON encoding issues (empty objects becoming arrays)
|
||||||
|
if (isset($config['stats']) && empty($config['stats'])) {
|
||||||
|
$config['stats'] = new stdClass();
|
||||||
|
}
|
||||||
|
if (isset($config['policy']['levels']) && is_array($config['policy']['levels'])) {
|
||||||
|
// Check if it's an indexed array (0, 1...) which is wrong for X-ray levels map
|
||||||
|
if (array_keys($config['policy']['levels']) === range(0, count($config['policy']['levels']) - 1)) {
|
||||||
|
$newLevels = new stdClass();
|
||||||
|
foreach ($config['policy']['levels'] as $idx => $lvl) {
|
||||||
|
$newLevels->{(string) $idx} = $lvl;
|
||||||
|
}
|
||||||
|
$config['policy']['levels'] = $newLevels;
|
||||||
|
} elseif (empty($config['policy']['levels'])) {
|
||||||
|
$config['policy']['levels'] = new stdClass();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!isset($config['policy'])) {
|
||||||
|
$config['policy'] = new stdClass();
|
||||||
|
}
|
||||||
|
if (!isset($config['policy']['levels'])) {
|
||||||
|
$config['policy']['levels'] = new stdClass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce Level 0 Policy with limitIp
|
||||||
|
if (!isset($config['policy']['levels']->{'0'})) {
|
||||||
|
$config['policy']['levels']->{'0'} = new stdClass();
|
||||||
|
}
|
||||||
|
$level0 = $config['policy']['levels']->{'0'};
|
||||||
|
// Cast to object if array
|
||||||
|
if (is_array($level0)) {
|
||||||
|
$level0 = (object) $level0;
|
||||||
|
$config['policy']['levels']->{'0'} = $level0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set restriction parameters
|
||||||
|
$level0->limitIp = 1;
|
||||||
|
$level0->handshake = 4;
|
||||||
|
$level0->connIdle = 300;
|
||||||
|
$level0->uplinkOnly = 2;
|
||||||
|
$level0->downlinkOnly = 5;
|
||||||
|
$level0->statsUserUplink = true;
|
||||||
|
$level0->statsUserDownlink = true;
|
||||||
|
$level0->bufferSize = 4;
|
||||||
|
// It's an assoc array, duplicate it to stdClass to ensure object encoding
|
||||||
|
$config['policy']['levels'] = (object) $config['policy']['levels'];
|
||||||
|
|
||||||
|
|
||||||
// 3. Write config back
|
// 3. Write config back
|
||||||
$newJson = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
$newJson = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
|||||||
+122
-31
@@ -14,6 +14,69 @@ class ServerMonitoring
|
|||||||
{
|
{
|
||||||
private VpnServer $server;
|
private VpnServer $server;
|
||||||
private array $serverData;
|
private array $serverData;
|
||||||
|
private array $xrayStatsCache = [];
|
||||||
|
private bool $xrayStatsFetched = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all X-ray user stats in one batch
|
||||||
|
* Returns true on success, false on failure (SSH / JSON error)
|
||||||
|
*/
|
||||||
|
private function fetchXrayStats(): bool
|
||||||
|
{
|
||||||
|
if ($this->xrayStatsFetched) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$containerName = $this->serverData['container_name'];
|
||||||
|
if (strpos($containerName, 'xray') === false) {
|
||||||
|
$this->xrayStatsFetched = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use --reset=true to get delta since last check and prevent counter reset on restart
|
||||||
|
// Note: The container name is hardcoded to 'amnezia-xray' in the provided snippet.
|
||||||
|
// Assuming this is intentional or will be corrected by the user later.
|
||||||
|
$cmd = "docker exec amnezia-xray xray api statsquery --pattern 'user>>>' --reset=true --server=127.0.0.1:10085";
|
||||||
|
$json = $this->execSSH($cmd);
|
||||||
|
|
||||||
|
if (!$json || trim($json) === '') {
|
||||||
|
// Assuming a log method exists or needs to be added, for now, using error_log
|
||||||
|
error_log("Failed to fetch X-ray stats (empty response)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
if (!isset($data['stat'])) {
|
||||||
|
// If empty stats, but successful connection, it's fine (just no traffic delta)
|
||||||
|
$this->xrayStatsCache = [];
|
||||||
|
$this->xrayStatsFetched = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = [];
|
||||||
|
foreach ($data['stat'] as $item) {
|
||||||
|
// "user>>>email>>>traffic>>>downlink"
|
||||||
|
$parts = explode('>>>', $item['name']);
|
||||||
|
if (count($parts) >= 4) {
|
||||||
|
$email = $parts[1];
|
||||||
|
$type = $parts[3]; // 'downlink' or 'uplink'
|
||||||
|
|
||||||
|
if (!isset($stats[$email])) {
|
||||||
|
$stats[$email] = ['up' => 0, 'down' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'uplink') {
|
||||||
|
$stats[$email]['up'] += (int) $item['value'];
|
||||||
|
} elseif ($type === 'downlink') {
|
||||||
|
$stats[$email]['down'] += (int) $item['value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->xrayStatsCache = $stats;
|
||||||
|
$this->xrayStatsFetched = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public function __construct(int $serverId)
|
public function __construct(int $serverId)
|
||||||
{
|
{
|
||||||
@@ -46,6 +109,12 @@ class ServerMonitoring
|
|||||||
*/
|
*/
|
||||||
public function collectClientMetrics(): array
|
public function collectClientMetrics(): array
|
||||||
{
|
{
|
||||||
|
// Pre-fetch X-ray stats
|
||||||
|
if (!$this->fetchXrayStats()) {
|
||||||
|
error_log("Failed to fetch X-ray stats, preventing DB overwrite");
|
||||||
|
return []; // Abort if stats collection failed
|
||||||
|
}
|
||||||
|
|
||||||
$clients = VpnClient::listByServer($this->serverData['id']);
|
$clients = VpnClient::listByServer($this->serverData['id']);
|
||||||
$results = [];
|
$results = [];
|
||||||
|
|
||||||
@@ -55,6 +124,12 @@ class ServerMonitoring
|
|||||||
|
|
||||||
$stats = $this->getClientStats($client);
|
$stats = $this->getClientStats($client);
|
||||||
if ($stats) {
|
if ($stats) {
|
||||||
|
// Check if speed values are excessively high (spike detection)
|
||||||
|
// Use 10Gbps (1250 MB/s) as sanity limit. 1250 * 1024 * 1024 ~ 1.3e9
|
||||||
|
// Actually ServerMonitoring calculates bytes/sec.
|
||||||
|
// If speed is > 2 Gbit/s likely an error (unless on 10G link, but rare)
|
||||||
|
// Let's rely on simple positive check for now.
|
||||||
|
|
||||||
$this->saveClientMetrics($client['id'], $stats);
|
$this->saveClientMetrics($client['id'], $stats);
|
||||||
$results[] = [
|
$results[] = [
|
||||||
'client_id' => $client['id'],
|
'client_id' => $client['id'],
|
||||||
@@ -181,45 +256,61 @@ class ServerMonitoring
|
|||||||
private function getClientStats(array $client): ?array
|
private function getClientStats(array $client): ?array
|
||||||
{
|
{
|
||||||
$db = DB::conn();
|
$db = DB::conn();
|
||||||
|
// this->fetchXrayStats() call moved to collectClientMetrics to handle failure gracefully
|
||||||
|
|
||||||
// Get current stats from server
|
// Get current stats from server
|
||||||
$containerName = $this->serverData['container_name'];
|
$containerName = $this->serverData['container_name'];
|
||||||
$bytesReceived = 0;
|
$bytesReceived = 0;
|
||||||
$bytesSent = 0;
|
$bytesSent = 0;
|
||||||
|
|
||||||
if (strpos($containerName, 'xray') !== false) {
|
$slug = $this->serverData['slug']; // Assuming 'slug' is available in serverData
|
||||||
// X-Ray Logic
|
|
||||||
$identifier = null;
|
|
||||||
// Best effort to find UUID/Email
|
|
||||||
if (!empty($client['config']) && preg_match('/vless:\\/\\/([0-9a-fA-F-]{36})@/i', $client['config'], $m)) {
|
|
||||||
$identifier = $m[1];
|
|
||||||
} elseif (!empty($client['name'])) { // Often name IS the UUID for XRay
|
|
||||||
$identifier = $client['name'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($identifier) {
|
if ($slug === 'xray' || $slug === 'vless') {
|
||||||
// Query X-Ray API
|
// Retrieve DELTA from cache
|
||||||
$cmd = sprintf(
|
if ($this->xrayStatsFetched) {
|
||||||
"docker exec %s xray api statsquery --server=127.0.0.1:10085 --pattern 'user>>>%s>>>traffic>>>' 2>/dev/null",
|
// Try to find by UUID first (if we tracked it) or Email/Name
|
||||||
escapeshellarg($containerName),
|
// Our cache is keyed by "email" from the stats query "user>>>email>>>..."
|
||||||
escapeshellarg($identifier)
|
// In VpnClient.php, the X-ray config uses client 'id' (uuid) as 'id' and 'email' as 'email'.
|
||||||
);
|
// Usually Amnezia sets email = uuid or name.
|
||||||
|
// Let's try keys: client['id'], client['name'], client['email'] (if exists)
|
||||||
|
|
||||||
$json = $this->execSSH($cmd);
|
// In our previous fetchXrayStats, we keyed by $parts[1].
|
||||||
if ($json) {
|
|
||||||
$data = json_decode($json, true);
|
$key = $client['id']; // UUID
|
||||||
if (isset($data['stat']) && is_array($data['stat'])) {
|
if (!isset($this->xrayStatsCache[$key])) {
|
||||||
foreach ($data['stat'] as $row) {
|
// Try name
|
||||||
if (strpos($row['name'], '>>>uplink') !== false) {
|
$key = $client['name'];
|
||||||
$bytesSent = (int) $row['value'];
|
}
|
||||||
}
|
|
||||||
if (strpos($row['name'], '>>>downlink') !== false) {
|
if (isset($this->xrayStatsCache[$key])) {
|
||||||
$bytesReceived = (int) $row['value'];
|
$xStats = $this->xrayStatsCache[$key];
|
||||||
}
|
|
||||||
}
|
// CRITICAL FIX: Add DELTA to existing DB values
|
||||||
}
|
// We need to get the current total bytes from the DB first
|
||||||
} else {
|
$stmt = $db->prepare("SELECT bytes_sent, bytes_received FROM vpn_clients WHERE id = ?");
|
||||||
// SSH command failed or returned empty for X-Ray stats
|
$stmt->execute([$client['id']]);
|
||||||
|
$currentDbStats = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$bytesSent = ($currentDbStats['bytes_sent'] ?? 0) + (int) $xStats['up'];
|
||||||
|
$bytesReceived = ($currentDbStats['bytes_received'] ?? 0) + (int) $xStats['down'];
|
||||||
|
|
||||||
|
// Calculate speed based on DELTA (since Reset=true, value IS the delta since last check)
|
||||||
|
// If we check every 60s, speed = delta / 60.
|
||||||
|
// But exact interval varies.
|
||||||
|
// For now, let's trust the delta.
|
||||||
|
|
||||||
|
// Simple speed aproximation: Delta / (Now - LastCheck)
|
||||||
|
// But we don't have exact LastCheck time per client easily here.
|
||||||
|
// However, sparklines use a separate API.
|
||||||
|
// The 'speed_up'/'speed_down' columns in DB are usually "Current Speed".
|
||||||
|
// If we just gathered a delta over X seconds...
|
||||||
|
// Let's approximate: X-ray stats delta.
|
||||||
|
// We can just store the 'current speed' as calculated by (Delta Bytes / Interval).
|
||||||
|
// But we don't know the exact interval since the LAST fetch was run by the cron.
|
||||||
|
// Assuming cron runs every minute?
|
||||||
|
// If we assume 1 minute (60s):
|
||||||
|
$speedUp = round($xStats['up'] / 60);
|
||||||
|
$speedDown = round($xStats['down'] / 60);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+96
-30
@@ -301,18 +301,18 @@
|
|||||||
<span class="text-gray-600">{{ client.expires_at|date('Y-m-d') }}</span>
|
<span class="text-gray-600">{{ client.expires_at|date('Y-m-d') }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-gray-400">{{ t('clients.never_expires') }}</span>
|
<span class="text-green-500 text-xl" title="{{ t('clients.never_expires') }}">∞</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm">
|
<td class="px-2 py-2 text-xs">
|
||||||
<div class="text-gray-600">
|
<div class="text-gray-600 font-mono">
|
||||||
↑ {{ (client.bytes_sent|default(0) / 1024 / 1024)|number_format(2) }} MB
|
↑{{ (client.bytes_sent|default(0) / 1024 / 1024)|number_format(2) }} MB
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-600">
|
<div class="text-gray-600 font-mono">
|
||||||
↓ {{ (client.bytes_received|default(0) / 1024 / 1024)|number_format(2) }} MB
|
↓{{ (client.bytes_received|default(0) / 1024 / 1024)|number_format(2) }} MB
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm">
|
<td class="px-2 py-2 text-xs text-center">
|
||||||
{% if client.traffic_limit %}
|
{% if client.traffic_limit %}
|
||||||
{% set total_traffic = (client.bytes_sent|default(0) + client.bytes_received|default(0)) %}
|
{% set total_traffic = (client.bytes_sent|default(0) + client.bytes_received|default(0)) %}
|
||||||
{% set limit_gb = (client.traffic_limit / 1073741824)|number_format(2) %}
|
{% set limit_gb = (client.traffic_limit / 1073741824)|number_format(2) %}
|
||||||
@@ -320,29 +320,35 @@
|
|||||||
{% set percentage = ((total_traffic / client.traffic_limit) * 100)|round %}
|
{% set percentage = ((total_traffic / client.traffic_limit) * 100)|round %}
|
||||||
|
|
||||||
{% if percentage >= 100 %}
|
{% if percentage >= 100 %}
|
||||||
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
|
<span class="px-2 py-1 bg-red-100 text-red-800 rounded">
|
||||||
<i class="fas fa-exclamation-circle"></i> {{ t('clients.overlimit') }}
|
<i class="fas fa-exclamation-circle"></i> {{ t('clients.overlimit') }}
|
||||||
</span>
|
</span>
|
||||||
{% elseif percentage >= 80 %}
|
{% elseif percentage >= 80 %}
|
||||||
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">
|
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded">
|
||||||
{{ used_gb }} / {{ limit_gb }} GB ({{ percentage }}%)
|
{{ used_gb }} / {{ limit_gb }} GB ({{ percentage }}%)
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-gray-600">{{ used_gb }} / {{ limit_gb }} GB</span>
|
<span class="text-gray-600">{{ used_gb }} / {{ limit_gb }} GB</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-gray-400">{{ t('clients.unlimited') }}</span>
|
<span class="text-green-500 text-lg" title="{{ t('clients.unlimited') }}">∞</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm">
|
<td class="px-2 py-2 text-xs">
|
||||||
<div id="client-speed-{{ client.id }}" class="text-gray-600 text-xs">
|
<div class="flex flex-col items-center" style="width: 120px; max-width: 120px;">
|
||||||
<span class="text-green-600">↑ {{ (client.speed_up|default(0) / 1024)|number_format(1) }} KB/s</span><br>
|
<div style="height: 30px; width: 100%;">
|
||||||
<span class="text-blue-600">↓ {{ (client.speed_down|default(0) / 1024)|number_format(1) }} KB/s</span>
|
<canvas id="clientSparkline-{{ client.id }}"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="client-speed-{{ client.id }}" class="text-gray-600 text-[10px] mt-1 font-mono text-center leading-tight">
|
||||||
|
<div class="text-green-600 whitespace-nowrap">↑{{ ((client.speed_up|default(0) * 8) / 1000000)|number_format(2) }} Mbit</div>
|
||||||
|
<div class="text-blue-600 whitespace-nowrap">↓{{ ((client.speed_down|default(0) * 8) / 1000000)|number_format(2) }} Mbit</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm">
|
<td class="px-2 py-2 text-xs whitespace-nowrap text-right">
|
||||||
{% if client.last_handshake %}
|
{% if client.last_handshake %}
|
||||||
<span class="text-gray-600">{{ client.last_handshake }}</span>
|
<span class="text-gray-600 block">{{ client.last_handshake|split(' ')|first }}</span>
|
||||||
|
<span class="text-gray-400 block">{{ client.last_handshake|split(' ')|last }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-gray-400">{{ t('clients.never') }}</span>
|
<span class="text-gray-400">{{ t('clients.never') }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -867,39 +873,99 @@ if (document.getElementById('cpuSparkline')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update client speeds
|
// Update client speeds
|
||||||
|
let clientCharts = {};
|
||||||
|
|
||||||
async function updateClientSpeeds() {
|
async function updateClientSpeeds() {
|
||||||
const clientRows = document.querySelectorAll('[id^="client-speed-"]');
|
const clientRows = document.querySelectorAll('[id^="client-speed-"]');
|
||||||
|
|
||||||
console.log('Found client speed rows:', clientRows.length);
|
|
||||||
|
|
||||||
for (const row of clientRows) {
|
for (const row of clientRows) {
|
||||||
const clientId = row.id.replace('client-speed-', '');
|
const clientId = row.id.replace('client-speed-', '');
|
||||||
|
const canvasId = `clientSparkline-${clientId}`;
|
||||||
console.log(`Fetching metrics for client ${clientId}`);
|
const canvas = document.getElementById(canvasId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/clients/${clientId}/metrics?hours=1`, {
|
const response = await fetch(`/api/clients/${clientId}/metrics?hours=24`, { // Fetch 24h for sparkline
|
||||||
credentials: 'same-origin'
|
credentials: 'same-origin'
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
console.log(`Client ${clientId} metrics:`, data);
|
if (data.success && data.metrics) {
|
||||||
|
const metrics = data.metrics; // Use all points for chart
|
||||||
|
|
||||||
if (data.success && data.metrics && data.metrics.length > 0) {
|
// 1. Render/Update Chart
|
||||||
const latest = data.metrics[data.metrics.length - 1];
|
if (canvas) {
|
||||||
const speedUp = parseFloat(latest.speed_up_kbps).toFixed(1);
|
const labels = metrics.map((_, i) => i);
|
||||||
const speedDown = parseFloat(latest.speed_down_kbps).toFixed(1);
|
const dataUp = metrics.map(m => (parseFloat(m.speed_up_kbps) / 1000)); // Mbps
|
||||||
|
const dataDown = metrics.map(m => (parseFloat(m.speed_down_kbps) / 1000)); // Mbps
|
||||||
|
|
||||||
|
if (clientCharts[clientId]) {
|
||||||
|
// Update existing chart
|
||||||
|
clientCharts[clientId].data.labels = labels;
|
||||||
|
clientCharts[clientId].data.datasets[0].data = dataUp;
|
||||||
|
clientCharts[clientId].data.datasets[1].data = dataDown;
|
||||||
|
clientCharts[clientId].update('none');
|
||||||
|
} else {
|
||||||
|
// Create new chart
|
||||||
|
clientCharts[clientId] = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Up',
|
||||||
|
data: dataUp,
|
||||||
|
borderColor: '#16a34a', // green-600
|
||||||
|
borderWidth: 1.5,
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: false,
|
||||||
|
tension: 0.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Down',
|
||||||
|
data: dataDown,
|
||||||
|
borderColor: '#2563eb', // blue-600
|
||||||
|
borderWidth: 1.5,
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: false,
|
||||||
|
tension: 0.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
||||||
|
scales: {
|
||||||
|
x: { display: false },
|
||||||
|
y: { display: false, beginAtZero: true }
|
||||||
|
},
|
||||||
|
animation: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update Text Badge (Last Known Speed)
|
||||||
|
if (metrics.length > 0) {
|
||||||
|
const latest = metrics[metrics.length - 1];
|
||||||
|
const speedUp = (parseFloat(latest.speed_up_kbps) / 1000).toFixed(2);
|
||||||
|
const speedDown = (parseFloat(latest.speed_down_kbps) / 1000).toFixed(2);
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="text-green-600 whitespace-nowrap">↑${speedUp} Mbit</div>
|
||||||
|
<div class="text-blue-600 whitespace-nowrap">↓${speedDown} Mbit</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
row.innerHTML = '<span class="text-gray-400">-</span>';
|
||||||
|
}
|
||||||
|
|
||||||
// Format as compact badge
|
|
||||||
row.innerHTML = `<span class="text-xs text-gray-700">↑${speedUp} ↓${speedDown} KB/s</span>`;
|
|
||||||
} else {
|
} else {
|
||||||
console.log(`No metrics for client ${clientId}`);
|
|
||||||
row.innerHTML = '<span class="text-xs text-gray-400">-</span>';
|
row.innerHTML = '<span class="text-xs text-gray-400">-</span>';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to fetch metrics for client ${clientId}:`, error);
|
console.error(`Failed to fetch metrics for client ${clientId}:`, error);
|
||||||
row.innerHTML = '<span class="text-xs text-gray-400">-</span>';
|
row.innerHTML = '<span class="text-xs text-gray-400">Error</span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user