Files
amneziavpnphp/templates/servers/view.twig
T

710 lines
27 KiB
Twig

{% extends "layout.twig" %}
{% 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 %}
<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>
{% 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">
<i class="fas {% if import_message.type == 'success' %}fa-check-circle{% else %}fa-exclamation-circle{% endif %} mr-2"></i>
{{ import_message.text }}
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="bg-white rounded shadow p-6">
<h3 class="font-bold mb-4">{{ t('servers.server_info') }}</h3>
<dl class="space-y-2">
<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">Subnet</dt><dd>{{ server.vpn_subnet }}</dd></div>
</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 class="bg-white rounded shadow p-6">
<h3 class="font-bold mb-4">{{ t('clients.create') }}</h3>
<form method="POST" action="/servers/{{ server.id }}/clients/create" class="space-y-3" id="createClientForm">
<div>
<input name="name" placeholder="{{ t('clients.name') }}" required class="w-full px-3 py-2 border rounded" id="clientName">
<p class="text-xs text-gray-500 mt-1">Spaces will be replaced with underscore. All characters allowed including Cyrillic.</p>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">{{ t('clients.expiration') }}</label>
<select name="expires_in_days" class="w-full px-3 py-2 border rounded mb-2" id="expirationSelect" onchange="toggleExpirationInput()">
<option value="" selected>{{ t('clients.never_expires') }}</option>
<option value="7">7 {{ t('common.days') }}</option>
<option value="30">30 {{ t('common.days') }}</option>
<option value="60">60 {{ t('common.days') }}</option>
<option value="90">90 {{ t('common.days') }}</option>
<option value="180">180 {{ t('common.days') }}</option>
<option value="365">365 {{ t('common.days') }}</option>
<option value="custom">{{ t('clients.custom_seconds') }}</option>
</select>
<input type="number" name="expires_in_seconds" id="expirationSeconds" class="w-full px-3 py-2 border rounded" placeholder="{{ t('clients.enter_seconds') }}" style="display:none;" min="1">
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">{{ t('clients.traffic_limit') }}</label>
<select name="traffic_limit_gb" class="w-full px-3 py-2 border rounded mb-2" id="trafficSelect" onchange="toggleTrafficInput()">
<option value="" selected>{{ t('clients.unlimited') }}</option>
<option value="1">1 GB</option>
<option value="5">5 GB</option>
<option value="10">10 GB</option>
<option value="25">25 GB</option>
<option value="50">50 GB</option>
<option value="100">100 GB</option>
<option value="250">250 GB</option>
<option value="500">500 GB</option>
<option value="1000">1000 GB (1 TB)</option>
<option value="custom">{{ t('clients.custom_mb') }}</option>
</select>
<input type="number" name="traffic_limit_mb" id="trafficMegabytes" class="w-full px-3 py-2 border rounded" placeholder="{{ t('clients.enter_megabytes') }}" style="display:none;" min="1">
</div>
<button type="submit" class="gradient-bg text-white px-4 py-2 rounded w-full" id="createClientBtn">
<span id="createClientText">{{ t('form.create') }}</span>
<i class="fas fa-spinner fa-spin" id="createClientSpinner" style="display:none;"></i>
</button>
</form>
</div>
</div>
<!-- Backup Section -->
<div class="bg-white rounded shadow mb-8">
<div class="px-6 py-4 border-b flex justify-between items-center">
<h3 class="font-bold">{{ t('backups.title') }}</h3>
<button onclick="createBackup({{ server.id }})" class="gradient-bg text-white px-4 py-2 rounded text-sm">
<i class="fas fa-save"></i> {{ t('backups.create') }}
</button>
</div>
<div id="backupsList" class="p-4">
<div class="text-center text-gray-500 py-4">
<i class="fas fa-spinner fa-spin"></i> {{ t('form.loading') }}
</div>
</div>
</div>
<div class="bg-white rounded shadow">
<div class="px-6 py-4 border-b flex justify-between items-center">
<h3 class="font-bold">{{ t('clients.title') }} ({{ clients|length }})</h3>
<button onclick="syncAllStats({{ server.id }})" class="text-purple-600 hover:text-purple-800 text-sm">
<i class="fas fa-sync-alt"></i> {{ t('clients.sync_stats') }}
</button>
</div>
{% if clients|length > 0 %}
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.ip') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.status') }}</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_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.actions') }}</th>
</tr>
</thead>
<tbody>
{% for client in clients %}
<tr class="border-t">
<td class="px-6 py-4">{{ client.name }}</td>
<td class="px-6 py-4">{{ client.client_ip }}</td>
<td class="px-6 py-4">
{% if client.status == 'active' %}
<span class="px-2 py-1 bg-green-100 text-green-800 rounded text-xs">{{ t('status.active') }}</span>
{% else %}
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">{{ t('status.disabled') }}</span>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
{% if client.expires_at %}
{% set expires_ts = client.expires_at|date('U') %}
{% set now_ts = "now"|date('U') %}
{% set diff_days = ((expires_ts - now_ts) / 86400)|round %}
{% if diff_days < 0 %}
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
<i class="fas fa-exclamation-circle"></i> {{ t('clients.expired') }}
</span>
{% elseif diff_days <= 7 %}
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">
<i class="fas fa-clock"></i> {{ diff_days }} {{ t('common.days') }}
</span>
{% else %}
<span class="text-gray-600">{{ client.expires_at|date('Y-m-d') }}</span>
{% endif %}
{% else %}
<span class="text-gray-400">{{ t('clients.never_expires') }}</span>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
<div class="text-gray-600">
{{ (client.bytes_sent|default(0) / 1024 / 1024)|number_format(2) }} MB
</div>
<div class="text-gray-600">
{{ (client.bytes_received|default(0) / 1024 / 1024)|number_format(2) }} MB
</div>
</td>
<td class="px-6 py-4 text-sm">
{% if client.traffic_limit %}
{% set total_traffic = (client.bytes_sent|default(0) + client.bytes_received|default(0)) %}
{% set limit_gb = (client.traffic_limit / 1073741824)|number_format(2) %}
{% set used_gb = (total_traffic / 1073741824)|number_format(2) %}
{% set percentage = ((total_traffic / client.traffic_limit) * 100)|round %}
{% if percentage >= 100 %}
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
<i class="fas fa-exclamation-circle"></i> {{ t('clients.overlimit') }}
</span>
{% elseif percentage >= 80 %}
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">
{{ used_gb }} / {{ limit_gb }} GB ({{ percentage }}%)
</span>
{% else %}
<span class="text-gray-600">{{ used_gb }} / {{ limit_gb }} GB</span>
{% endif %}
{% else %}
<span class="text-gray-400">{{ t('clients.unlimited') }}</span>
{% endif %}
</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">
{% if client.last_handshake %}
<span class="text-gray-600">{{ client.last_handshake }}</span>
{% else %}
<span class="text-gray-400">{{ t('clients.never') }}</span>
{% endif %}
</td>
<td class="px-6 py-4">
<a href="/clients/{{ client.id }}" class="text-purple-600 hover:text-purple-800 mr-2">{{ t('servers.view') }}</a>
{% if client.status == 'active' %}
<form method="POST" action="/clients/{{ client.id }}/revoke" style="display:inline;">
<button type="submit" class="text-orange-600 hover:text-orange-800 mr-2" onclick="return confirm('{{ t('clients.revoke_confirm') }}')">{{ t('clients.revoke') }}</button>
</form>
{% else %}
<form method="POST" action="/clients/{{ client.id }}/restore" style="display:inline;">
<button type="submit" class="text-green-600 hover:text-green-800 mr-2">{{ t('clients.restore') }}</button>
</form>
{% endif %}
<form method="POST" action="/clients/{{ client.id }}/delete" style="display:inline;">
<button type="submit" class="text-red-600 hover:text-red-800" onclick="return confirm('{{ t('clients.delete_confirm') }}')">{{ t('clients.delete') }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-12 text-center text-gray-500">{{ t('clients.no_clients') }}</div>
{% endif %}
</div>
</div>
<script>
function toggleExpirationInput() {
const select = document.getElementById('expirationSelect');
const input = document.getElementById('expirationSeconds');
if (select.value === 'custom') {
input.style.display = 'block';
input.required = true;
input.focus();
} else {
input.style.display = 'none';
input.required = false;
input.value = '';
}
}
function toggleTrafficInput() {
const select = document.getElementById('trafficSelect');
const input = document.getElementById('trafficMegabytes');
if (select.value === 'custom') {
input.style.display = 'block';
input.required = true;
input.focus();
} else {
input.style.display = 'none';
input.required = false;
input.value = '';
}
}
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('createClientForm');
const clientNameInput = document.getElementById('clientName');
// Auto-replace spaces with underscores
if (clientNameInput) {
clientNameInput.addEventListener('input', function(e) {
// Replace only spaces with underscore, allow all other characters including Cyrillic
let value = e.target.value;
let sanitized = value.replace(/ /g, '_');
if (value !== sanitized) {
e.target.value = sanitized;
}
});
}
if (form) {
form.addEventListener('submit', function(e) {
const btn = document.getElementById('createClientBtn');
const text = document.getElementById('createClientText');
const spinner = document.getElementById('createClientSpinner');
// Show spinner and disable button
btn.disabled = true;
text.style.display = 'none';
spinner.style.display = 'inline-block';
// Form will submit normally
});
}
});
async function syncAllStats(serverId) {
try {
const response = await fetch(`/servers/${serverId}/sync-stats`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
location.reload();
} else {
alert('Failed to sync stats: ' + (data.error || 'Unknown error'));
}
} catch (error) {
alert('Error: ' + error.message);
}
}
// Load backups on page load
document.addEventListener('DOMContentLoaded', function() {
loadBackups({{ server.id }});
});
async function loadBackups(serverId) {
try {
const response = await fetch(`/api/servers/${serverId}/backups`, {
credentials: 'same-origin'
});
if (response.status === 401) {
document.getElementById('backupsList').innerHTML = '<div class="text-center text-red-500 py-4">{{ t("message.error") }}: {{ t("backups.login_required") }}</div>';
return;
}
const data = await response.json();
if (data.success && data.backups.length > 0) {
let html = '<div class="space-y-2">';
data.backups.forEach(backup => {
const size = (backup.backup_size / 1024 / 1024).toFixed(2);
const statusClass = backup.status === 'completed' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800';
html += `
<div class="flex items-center justify-between p-3 border rounded">
<div class="flex-1">
<div class="font-medium">${backup.backup_name}</div>
<div class="text-sm text-gray-600">
${backup.clients_count} {{ t('clients.title') }} • ${size} MB • ${backup.created_at}
</div>
</div>
<div class="flex gap-2">
<span class="px-2 py-1 ${statusClass} rounded text-xs">${backup.status}</span>
${backup.status === 'completed' ? `
<button onclick="restoreBackup(${serverId}, ${backup.id})" class="text-blue-600 hover:text-blue-800 px-3 py-1 border rounded text-sm">
<i class="fas fa-undo"></i> {{ t('backups.restore') }}
</button>
` : ''}
<button onclick="deleteBackup(${backup.id})" class="text-red-600 hover:text-red-800 px-3 py-1 border rounded text-sm">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
});
html += '</div>';
document.getElementById('backupsList').innerHTML = html;
} else {
document.getElementById('backupsList').innerHTML = '<div class="text-center text-gray-500 py-4">{{ t("backups.no_backups") }}</div>';
}
} catch (error) {
document.getElementById('backupsList').innerHTML = `<div class="text-center text-red-500 py-4">Error: ${error.message}</div>`;
}
}
async function createBackup(serverId) {
if (!confirm('{{ t("backups.create_confirm") }}')) return;
// Show loading state
document.getElementById('backupsList').innerHTML = '<div class="text-center text-gray-500 py-4"><i class="fas fa-spinner fa-spin"></i> {{ t("form.processing") }}</div>';
try {
const response = await fetch(`/api/servers/${serverId}/backup`, {
method: 'POST',
credentials: 'same-origin'
});
const data = await response.json();
if (data.success) {
alert('{{ t("backups.created_success") }}');
loadBackups(serverId);
} else {
alert('Error: ' + (data.error || 'Unknown error'));
loadBackups(serverId);
}
} catch (error) {
alert('Error: ' + error.message);
loadBackups(serverId);
}
}
async function restoreBackup(serverId, backupId) {
if (!confirm('{{ t("backups.restore_confirm") }}')) return;
try {
const response = await fetch(`/api/servers/${serverId}/restore`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ backup_id: backupId })
});
const data = await response.json();
console.log('Restore response:', data);
if (data.success !== false) {
let message = `{{ t("backups.restored_success") }}: ${data.restored} {{ t("clients.title") }}`;
if (data.failed > 0) {
message += `\n\nПропущено: ${data.failed}`;
if (data.errors && data.errors.length > 0) {
message += '\n\nПричины:\n' + data.errors.slice(0, 5).join('\n');
if (data.errors.length > 5) {
message += `\n... и ещё ${data.errors.length - 5}`;
}
}
}
alert(message);
if (data.restored > 0) {
location.reload();
} else {
loadBackups(serverId);
}
} else {
let errorMsg = 'Error: ';
if (data.error) {
errorMsg += data.error;
} else if (data.errors && data.errors.length > 0) {
errorMsg += data.errors.join(', ');
} else {
errorMsg += 'Unknown error';
}
alert(errorMsg);
}
} catch (error) {
console.error('Restore error:', error);
alert('Error: ' + error.message);
}
}
async function deleteBackup(backupId) {
if (!confirm('{{ t("backups.delete_confirm") }}')) return;
try {
const response = await fetch(`/api/backups/${backupId}`, {
method: 'DELETE',
credentials: 'same-origin'
});
const data = await response.json();
if (data.success) {
alert('{{ t("backups.deleted_success") }}');
loadBackups({{ server.id }});
} else {
alert('Error: ' + (data.error || 'Unknown error'));
}
} catch (error) {
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>
{% endblock %}