feat: real-time WebSocket metrics broadcaster and UI updates

- New metrics_broadcaster service (410 lines) — background broadcaster
  pushing CPU/RAM/disk/network stats to subscribed WebSocket clients
- WebSocket protocol extended: subscribe/unsubscribe channels,
  get_rrd/get_node_rrd requests with request_id
- main.py: metrics broadcaster started in lifespan, WS message dispatcher
- websocket_manager.py: subscription map support
- templates: dashboard, instance_detail, proxmox_server_detail,
  proxmox_vms, workspaces — real-time charts and UI updates
- auth.py, servers.py, settings.py: minor fixes
This commit is contained in:
markmorado
2026-03-15 02:49:38 +01:00
parent c4778d2972
commit ed7f1f779e
14 changed files with 1203 additions and 167 deletions

View File

@@ -286,7 +286,7 @@
</div>
<div class="modal-body p-0">
<div id="taskLogStatus" class="px-3 pt-3"></div>
<pre id="taskLogContent" style="max-height:400px;overflow-y:auto;background:var(--bs-dark,#1e1e2e);color:#cdd6f4;padding:1rem;margin:0;font-size:.82rem;"></pre>
<pre id="taskLogContent" style="max-height:400px;overflow-y:auto;background:var(--bg-surface-solid);color:var(--text-primary);padding:1rem;margin:0;font-size:.82rem;"></pre>
</div>
</div>
</div>

View File

@@ -169,8 +169,61 @@
// Also define as local function for scripts that don't use window prefix
var fetchWithAuth = window.fetchWithAuth;
// Global ESC handler for closing modals
// ==================== Auto token refresh ====================
// Decodes JWT payload (base64url) without verifying signature
function _jwtPayload(token) {
try {
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(base64));
} catch (e) {
return null;
}
}
// Refresh the JWT token silently; returns true on success
async function _refreshToken() {
const token = localStorage.getItem('access_token');
if (!token) return false;
try {
const resp = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
if (resp.ok) {
const data = await resp.json();
localStorage.setItem('access_token', data.access_token);
_scheduleTokenRefresh(); // reschedule with new expiry
return true;
}
} catch (e) { /* network error */ }
return false;
}
// Schedule a proactive refresh 2 minutes before JWT expiry
function _scheduleTokenRefresh() {
if (window._tokenRefreshTimer) clearTimeout(window._tokenRefreshTimer);
const token = localStorage.getItem('access_token');
if (!token) return;
const payload = _jwtPayload(token);
if (!payload || !payload.exp) return;
const nowSec = Math.floor(Date.now() / 1000);
const msUntilRefresh = (payload.exp - nowSec - 120) * 1000; // 2 min before expiry
if (msUntilRefresh <= 0) {
// Token already expired or about to expire - try immediately
_refreshToken();
return;
}
window._tokenRefreshTimer = setTimeout(_refreshToken, msUntilRefresh);
}
// Kick off the scheduler on every page load (except login)
if (window.location.pathname !== '/login') {
_scheduleTokenRefresh();
}
// ==================== End auto token refresh ====================
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
// Find all visible modals and close the topmost one
@@ -1575,7 +1628,7 @@ body.update-in-progress .layout {
// Task Drawer — global WebSocket-based live task indicator
// ================================================================
let taskDrawerOpen = false;
let taskDrawerWS = null;
var taskDrawerWS = null; // var → exposes as window.taskDrawerWS for child templates
let taskDrawerTasks = {};
let taskDrawerReconnectTimer = null;
let taskDrawerReconnectDelay = 2000;
@@ -1679,6 +1732,16 @@ body.update-in-progress .layout {
}
updateTaskBadge();
if (taskDrawerOpen) renderTaskDrawer();
// Route to registered per-type handlers
if (_wsHandlers[msg.type]) { try { _wsHandlers[msg.type](msg); } catch(e) {} }
// Resolve pending request-response promises
if (msg.type === 'rrd_response' && msg.request_id && _wsPendingRequests[msg.request_id]) {
const { resolve, timer } = _wsPendingRequests[msg.request_id];
clearTimeout(timer);
delete _wsPendingRequests[msg.request_id];
if (msg.error) { /* still resolve — caller decides what to do */ }
resolve(msg);
}
} catch {}
};
taskDrawerWS.onclose = () => {
@@ -1690,8 +1753,95 @@ body.update-in-progress .layout {
setInterval(() => {
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) taskDrawerWS.send(JSON.stringify({type:'ping'}));
}, 20000);
// Re-subscribe to all active channels after reconnect
_wsActiveSubscriptions.forEach(channel => {
taskDrawerWS.send(JSON.stringify({type: 'subscribe', channel}));
});
};
}
// ── WebSocket utility API (usable from any page script) ──────────────────
/** Registry of per-message-type handlers. */
const _wsHandlers = {};
/** Pending request-response promises keyed by request_id. */
const _wsPendingRequests = {};
/** Channels this page is currently subscribed to (for reconnect restore). */
const _wsActiveSubscriptions = new Set();
let _wsReqCounter = 0;
/**
* Register a handler function for a specific WebSocket message type.
* Multiple calls with the same type replace the previous handler.
*/
function registerWsHandler(type, fn) {
_wsHandlers[type] = fn;
}
/**
* Call fn immediately if the WebSocket is open, otherwise wait for onopen.
*/
function onWsReady(fn) {
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) {
fn();
} else if (taskDrawerWS) {
const prev = taskDrawerWS.onopen;
taskDrawerWS.onopen = function(e) {
if (prev) prev.call(this, e);
fn();
};
}
}
/**
* Subscribe this tab to a named metrics channel.
* The subscription is remembered so it can be re-sent after reconnect.
*/
function subscribeWsChannel(channel) {
_wsActiveSubscriptions.add(channel);
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) {
taskDrawerWS.send(JSON.stringify({type: 'subscribe', channel}));
}
}
/**
* Unsubscribe from a channel and remove it from the reconnect set.
*/
function unsubscribeWsChannel(channel) {
_wsActiveSubscriptions.delete(channel);
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) {
taskDrawerWS.send(JSON.stringify({type: 'unsubscribe', channel}));
}
}
/**
* Send an on-demand request over WebSocket and return a Promise that
* resolves with the server's rrd_response message.
* Automatically falls back gracefully if WS is unavailable.
* @param {string} action — "get_rrd" or "get_node_rrd"
* @param {object} params — { server_id, node, timeframe, vmid?, inst_type? }
* @returns {Promise<object>} resolves with { data, error? }
*/
function sendWsRequest(action, params) {
return new Promise((resolve, reject) => {
if (!taskDrawerWS || taskDrawerWS.readyState !== WebSocket.OPEN) {
reject(new Error('WebSocket not open'));
return;
}
const request_id = `req_${++_wsReqCounter}_${Date.now()}`;
const timer = setTimeout(() => {
if (_wsPendingRequests[request_id]) {
delete _wsPendingRequests[request_id];
reject(new Error('WS request timed out'));
}
}, 12000);
_wsPendingRequests[request_id] = { resolve, timer };
taskDrawerWS.send(JSON.stringify({type: 'request', action, request_id, params}));
});
}
</script>
<!-- Bootstrap 5 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>

View File

@@ -469,10 +469,10 @@ function escapeHtml(str) {
}
// Two-speed refresh:
// • refreshVMs() — every refresh_interval (fast, 1 request)
// • WS push (dashboard_metrics channel) — every 10 s from server
// • _refreshNodeStats — max once per 60 s (slow, many requests)
let _lastNodeRefresh = 0;
const NODE_REFRESH_MIN_MS = 60000; // node charts refresh at most every 60 s
const NODE_REFRESH_MIN_MS = 300000; // HTTP fallback every 5 min (WS provides node data normally)
async function refreshVMs() {
showRefreshSpin(true);
@@ -480,6 +480,17 @@ async function refreshVMs() {
const res = await fetchWithAuth('/proxmox/api/resources/all', { hideOnForbidden: true });
if (!res || !res.ok || res.forbidden) { showRefreshSpin(false); return; }
const data = await res.json();
renderDashboardData(data);
} catch (e) {
// silent
} finally {
showRefreshSpin(false);
}
}
function renderDashboardData(data) {
showRefreshSpin(true);
try {
let totalVms = 0, runningVms = 0, totalCt = 0, runningCt = 0;
const allVms = [];
@@ -532,15 +543,89 @@ async function refreshVMs() {
}
}
// Trigger node chart refresh if enough time has passed
const now = Date.now();
if (now - _lastNodeRefresh >= NODE_REFRESH_MIN_MS) {
_lastNodeRefresh = now;
const onlineServers = (data.servers || []).filter(s => !s.error && s.id);
if (onlineServers.length > 0) {
_refreshNodeStats(onlineServers).then(() => {
markLastUpdated();
// Use node data from WS payload (cpu_pct/ram_pct/disk_pct/rrd_*) if available
const allNodes = [];
for (const server of (data.servers || [])) {
if (Array.isArray(server.nodes)) {
for (const node of server.nodes) allNodes.push(node);
}
}
if (allNodes.length > 0) {
const avgCpu = Math.round(allNodes.reduce((s, n) => s + (n.cpu_pct || 0), 0) / allNodes.length);
const avgRam = Math.round(allNodes.reduce((s, n) => s + (n.ram_pct || 0), 0) / allNodes.length);
const avgDisk = Math.round(allNodes.reduce((s, n) => s + (n.disk_pct || 0), 0) / allNodes.length);
const cpuPctEl = document.getElementById('cpuPct');
const ramPctEl = document.getElementById('ramPct');
const diskPctEl = document.getElementById('diskPct');
if (cpuPctEl) { cpuPctEl.textContent = avgCpu + '%'; setPctBadgeColor(cpuPctEl, avgCpu); document.getElementById('cpuBar').style.width = avgCpu + '%'; }
if (ramPctEl) { ramPctEl.textContent = avgRam + '%'; setPctBadgeColor(ramPctEl, avgRam); document.getElementById('ramBar').style.width = avgRam + '%'; }
if (diskPctEl) { diskPctEl.textContent = avgDisk + '%'; setPctBadgeColor(diskPctEl, avgDisk); document.getElementById('diskBar').style.width = avgDisk + '%'; }
// Mini charts from WS-provided RRD slices
const cpuHistoryAll = [], ramHistoryAll = [], diskHistoryAll = [];
for (const node of allNodes) {
if (Array.isArray(node.rrd_cpu) && node.rrd_cpu.length > 0) {
cpuHistoryAll.push(node.rrd_cpu);
ramHistoryAll.push(node.rrd_ram || []);
diskHistoryAll.push(node.rrd_disk || []);
}
}
if (cpuHistoryAll.length > 0) {
const len = Math.max(...cpuHistoryAll.map(a => a.length));
const avgH = arrays => Array.from({ length: len }, (_, i) => {
const vals = arrays.map(a => i < a.length ? a[i] : (a[a.length - 1] || 0));
return vals.reduce((s, v) => s + v, 0) / vals.length;
});
const cpuSlice = avgH(cpuHistoryAll);
const ramSlice = avgH(ramHistoryAll);
const diskSlice = avgH(diskHistoryAll);
requestAnimationFrame(() => {
drawMiniLineChart('cpuMiniChart', cpuSlice, '#3b82f6', 'rgba(59,130,246,0.15)');
drawMiniLineChart('ramMiniChart', ramSlice, '#8b5cf6', 'rgba(139,92,246,0.15)');
if (diskSlice.some(v => v > 0)) {
drawMiniBarChart('diskMiniChart', diskSlice, 'rgba(16,185,129,0.7)');
} else {
drawMiniBarChart('diskMiniChart', Array(24).fill(0).map((_, i) => (i / 23) * avgDisk * 0.9 + avgDisk * 0.1), 'rgba(16,185,129,0.7)');
}
});
}
// Nodes panel
const panel = document.getElementById('nodesPanel');
const onlineMeta = document.getElementById('nodesOnlineMeta');
if (onlineMeta) onlineMeta.textContent = allNodes.length + '/' + allNodes.length + ' ' + t('online').toLowerCase();
if (panel) {
panel.innerHTML = allNodes.map(node => `
<div class="node-item">
<div class="node-item-header">
<div class="node-item-name">
<span class="node-dot-online"></span>
${escapeHtml(node.name)}
</div>
</div>
<div class="node-bar-row">
<span class="node-bar-lbl">CPU</span>
<div class="node-bar-wrap"><div class="node-bar-fill cpu" style="width:${node.cpu_pct}%"></div></div>
<span class="node-bar-pct">${node.cpu_pct}%</span>
</div>
<div class="node-bar-row">
<span class="node-bar-lbl">RAM</span>
<div class="node-bar-wrap"><div class="node-bar-fill ram" style="width:${node.ram_pct}%"></div></div>
<span class="node-bar-pct">${node.ram_pct}%</span>
</div>
</div>
`).join('');
}
_lastNodeRefresh = Date.now();
} else {
// Fallback: HTTP poll if WS has no node data yet
const now = Date.now();
if (now - _lastNodeRefresh >= NODE_REFRESH_MIN_MS) {
_lastNodeRefresh = now;
const onlineServers = (data.servers || []).filter(s => !s.error && s.id);
if (onlineServers.length > 0) {
_refreshNodeStats(onlineServers).then(() => { markLastUpdated(); });
}
}
}
@@ -552,7 +637,7 @@ async function refreshVMs() {
}
}
// Init — reads refresh_interval from panel settings, falls back to 10s
// Init — loads initial data then subscribes to WebSocket push updates
document.addEventListener('DOMContentLoaded', async function() {
// Initial full load (VMs + nodes)
showRefreshSpin(true);
@@ -569,18 +654,21 @@ document.addEventListener('DOMContentLoaded', async function() {
} catch (e) {/* ignore */}
await refreshVMs();
let intervalMs = 10000; // default 10s
try {
const r = await fetchWithAuth('/settings/api/panel');
if (r && r.ok) {
const cfg = await r.json();
if (cfg.refresh_interval && cfg.refresh_interval > 0) {
intervalMs = cfg.refresh_interval * 1000;
// Subscribe to WebSocket push updates.
// subscribeWsChannel queues the subscription and sends it on WS open/reconnect.
if (typeof subscribeWsChannel === 'function') {
subscribeWsChannel('dashboard_metrics');
}
if (typeof registerWsHandler === 'function') {
registerWsHandler('metrics_update', (msg) => {
if (msg.channel === 'dashboard_metrics') {
renderDashboardData(msg.data);
}
}
} catch (e) {/* use default */}
setInterval(refreshVMs, intervalMs);
});
}
window.addEventListener('beforeunload', () => {
if (typeof unsubscribeWsChannel === 'function') unsubscribeWsChannel('dashboard_metrics');
});
});
// ======== Alerts Modal ========

View File

@@ -807,15 +807,19 @@ function displayCommandOutput(result, command = null) {
async function loadRRDData(timeframe = 'hour') {
try {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/rrddata?node=${instanceNode}&timeframe=${timeframe}`);
if (!response.ok) return;
const rrdData = await response.json();
const data = Array.isArray(rrdData) ? rrdData : (rrdData.data || []);
if (data.length > 0) {
drawCharts(data, timeframe);
let rrdData = null;
// WS request-response
if (typeof sendWsRequest === 'function') {
try {
const resp = await sendWsRequest('get_rrd', {
server_id: serverId, vmid, inst_type: instanceType,
node: instanceNode, timeframe
});
if (resp && resp.data) rrdData = resp.data;
} catch { rrdData = null; }
}
const data = Array.isArray(rrdData) ? rrdData : (rrdData.data || []);
if (data.length > 0) drawCharts(data, timeframe);
} catch (error) {
console.error('Error loading RRD data:', error);
}
@@ -2159,13 +2163,95 @@ function initPage() {
loadInstanceData();
loadHAStatus();
// Автообновление каждые 2 секунды
// Auto-refresh RRD charts every 30 s via WS (Proxmox RRD updates ~60 s)
setInterval(() => {
if (document.getElementById('instance-content').style.display !== 'none') {
loadStatus();
const content = document.getElementById('instance-content');
if (content && content.style.display !== 'none') {
loadRRDData(currentTimeframe);
}
}, 2000);
}, 30000);
// Subscribe to WS push for live metrics; fall back to polling without WS.
const _instCh = `instance_metrics:${serverId}_${vmid}_${instanceType}_${instanceNode}`;
if (typeof registerWsHandler === 'function') {
registerWsHandler('metrics_update', (msg) => {
if (msg.channel !== _instCh) return;
const status = msg.data;
if (!status) return;
const el = document.getElementById('instance-content');
if (!el || el.style.display === 'none') return;
const cpuPercent = ((status.cpu || 0) * 100).toFixed(1);
const cpuEl = document.getElementById('metric-cpu');
const cpuBar = document.getElementById('metric-cpu-bar');
if (cpuEl) cpuEl.textContent = `${cpuPercent}%`;
if (cpuBar) {
cpuBar.style.width = `${cpuPercent}%`;
cpuBar.className = `progress-fill ${cpuPercent > 80 ? 'danger' : cpuPercent > 60 ? 'warning' : ''}`;
}
const memUsed = status.mem || 0;
const memMax = status.maxmem || 1;
const memPercent = ((memUsed / memMax) * 100).toFixed(1);
const memEl = document.getElementById('metric-memory');
const memDetail = document.getElementById('metric-memory-detail');
const memBar = document.getElementById('metric-memory-bar');
if (memEl) memEl.textContent = `${memPercent}%`;
if (memDetail) memDetail.textContent = `${formatBytes(memUsed)} / ${formatBytes(memMax)}`;
if (memBar) {
memBar.style.width = `${memPercent}%`;
memBar.className = `progress-fill ${memPercent > 80 ? 'danger' : memPercent > 60 ? 'warning' : ''}`;
}
const diskUsed = status.disk || 0;
const diskMax = status.maxdisk || 0;
const diskCard = document.getElementById('disk-metric-card');
if (diskMax > 0 && diskUsed > 0) {
const diskPercent = ((diskUsed / diskMax) * 100).toFixed(1);
const diskEl = document.getElementById('metric-disk');
const diskDetail = document.getElementById('metric-disk-detail');
const diskBar = document.getElementById('metric-disk-bar');
if (diskEl) diskEl.textContent = `${diskPercent}%`;
if (diskDetail) diskDetail.textContent = `${formatBytes(diskUsed)} / ${formatBytes(diskMax)}`;
if (diskBar) {
diskBar.style.width = `${diskPercent}%`;
diskBar.className = `progress-fill ${diskPercent > 80 ? 'danger' : diskPercent > 60 ? 'warning' : ''}`;
diskBar.style.display = '';
}
if (diskCard) diskCard.style.display = '';
} else if (diskCard && diskMax <= 0) {
diskCard.style.display = 'none';
}
const uptEl = document.getElementById('metric-uptime');
if (uptEl) uptEl.textContent = formatUptime(status.uptime);
const netinEl = document.getElementById('metric-netin');
const netoutEl = document.getElementById('metric-netout');
if (netinEl) netinEl.textContent = formatBytes(status.netin || 0);
if (netoutEl) netoutEl.textContent = formatBytes(status.netout || 0);
const drEl = document.getElementById('metric-diskread');
const dwEl = document.getElementById('metric-diskwrite');
if (drEl) drEl.textContent = formatBytes(status.diskread || 0);
if (dwEl) dwEl.textContent = formatBytes(status.diskwrite || 0);
instanceStatus = status.status;
updateStatus(status.status);
updateButtons(status.status);
});
}
// Subscribe to WS push — queued and sent when WS opens/reconnects
if (typeof subscribeWsChannel === 'function') {
subscribeWsChannel(_instCh);
}
window.addEventListener('beforeunload', () => {
if (typeof unsubscribeWsChannel === 'function') unsubscribeWsChannel(_instCh);
});
// Check if we should auto-open VNC console
const urlParams = new URLSearchParams(window.location.search);

View File

@@ -212,17 +212,16 @@ function showHostMonitorModal() {
<span id="updateIndicator" style="display: inline-block; margin-left: 0.5rem; width: 8px; height: 8px; background: var(--accent); border-radius: 50%; opacity: 0; transition: opacity 0.3s ease;"></span>
`;
document.getElementById('monitorContent').innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading_data')}...</div>`;
delete document.getElementById('monitorContent').dataset.inVmMode;
modal.style.display = 'flex';
loadHostMonitorData();
if (window.monitorInterval) clearInterval(window.monitorInterval);
window.monitorInterval = setInterval(() => {
if (modal.style.display === 'flex') {
loadHostMonitorData();
}
}, 5000);
// Subscribe to WS push for live node status
if (typeof subscribeWsChannel === 'function') {
subscribeWsChannel(`server_metrics:${serverId}_${hostNode}`);
}
}
async function loadHostMonitorData() {
@@ -233,30 +232,42 @@ async function loadHostMonitorData() {
const updateIndicator = document.getElementById('updateIndicator');
if (updateIndicator) updateIndicator.style.opacity = '1';
// Загружаем статус и RRD данные отдельно
const statusRes = await fetchWithAuth(`/proxmox/api/${serverId}/node/status?node=${hostNode}`);
if (!statusRes.ok) {
throw new Error(`HTTP error: ${statusRes.status}`);
// Use cached WS status if available, otherwise fall back to HTTP
let status = window._lastServerMetrics || null;
if (!status) {
const statusRes = await fetchWithAuth(`/proxmox/api/${serverId}/node/status?node=${hostNode}`);
if (!statusRes.ok) throw new Error(`HTTP error: ${statusRes.status}`);
status = await statusRes.json();
}
const status = await statusRes.json();
// Пытаемся загрузить RRD данные, но не падаем если не получилось
// RRD data via WS request
let rrdData = { data: [] };
try {
const rrdRes = await fetchWithAuth(`/proxmox/api/${serverId}/node/rrddata?node=${hostNode}&timeframe=hour`);
if (rrdRes.ok) {
rrdData = await rrdRes.json();
if (typeof sendWsRequest === 'function') {
const resp = await sendWsRequest('get_node_rrd', { server_id: serverId, node: hostNode, timeframe: 'hour' });
if (resp && resp.data) rrdData = resp.data;
}
} catch (rrdError) {
// RRD data error - charts will be unavailable
}
} catch { /* charts will be unavailable */ }
if (isFirstLoad) {
renderHostMonitorData(status, rrdData);
monitorContent.dataset.initialized = 'true';
// Auto-refresh RRD charts every 30 s while modal is open
if (window.monitorInterval) clearInterval(window.monitorInterval);
window.monitorInterval = setInterval(async () => {
const _modal = document.getElementById('monitorModal');
if (!_modal || _modal.style.display !== 'flex' || document.getElementById('monitorContent')?.dataset.inVmMode) {
clearInterval(window.monitorInterval);
window.monitorInterval = null;
return;
}
try {
if (typeof sendWsRequest === 'function') {
const resp = await sendWsRequest('get_node_rrd', { server_id: serverId, node: hostNode, timeframe: 'hour' });
if (resp && resp.data) updateHostCharts(resp.data);
}
} catch { /* silent */ }
}, 30000);
} else {
updateHostMonitorData(status, rrdData);
}
@@ -651,6 +662,12 @@ function updateHostCharts(rrdResponse) {
}
}
// Global: called from loadResources() when hostNode is first discovered
function _subscribeToServerMetrics() {
if (!hostNode || typeof subscribeWsChannel !== 'function') return;
subscribeWsChannel(`server_metrics:${serverId}_${hostNode}`);
}
async function loadResources() {
const container = document.getElementById('resources-container');
const loading = document.getElementById('loading');
@@ -677,6 +694,7 @@ async function loadResources() {
hostNode = vms[0]?.node || containers[0]?.node;
if (hostNode) {
loadHostMetrics();
_subscribeToServerMetrics();
}
}
@@ -891,13 +909,70 @@ window.addEventListener('DOMContentLoaded', () => {
}
loadResources();
// Автообновление метрик хоста каждые 5 секунд
setInterval(() => {
if (hostNode && document.getElementById('host-metrics').style.display !== 'none') {
loadHostMetrics();
if (typeof registerWsHandler === 'function') {
registerWsHandler('metrics_update', (msg) => {
if (!msg.channel) return;
// ── server / node metrics ────────────────────────────────────────
if (msg.channel.startsWith('server_metrics:')) {
const status = msg.data && msg.data.status;
if (!status) return;
// Cache for use by host monitor modal (avoids HTTP fetch)
window._lastServerMetrics = status;
// Update header host-metrics panel
const headerEl = document.getElementById('host-metrics');
if (headerEl && headerEl.style.display !== 'none') {
const cpuPercent = ((status.cpu || 0) * 100).toFixed(1);
const cpuEl = document.getElementById('host-cpu');
const cpuBar = document.getElementById('host-cpu-bar');
if (cpuEl) cpuEl.textContent = `${cpuPercent}%`;
if (cpuBar) {
cpuBar.style.width = `${cpuPercent}%`;
cpuBar.className = `progress-fill ${cpuPercent > 80 ? 'danger' : cpuPercent > 60 ? 'warning' : ''}`;
}
const memPercent = (((status.memory?.used || 0) / (status.memory?.total || 1)) * 100).toFixed(1);
const memEl = document.getElementById('host-memory');
const memBar = document.getElementById('host-memory-bar');
if (memEl) memEl.textContent = `${memPercent}%`;
if (memBar) {
memBar.style.width = `${memPercent}%`;
memBar.className = `progress-fill ${memPercent > 80 ? 'danger' : memPercent > 60 ? 'warning' : ''}`;
}
const uptime = status.uptime || 0;
const days = Math.floor(uptime / 86400);
const hours = Math.floor((uptime % 86400) / 3600);
const uptEl = document.getElementById('host-uptime');
if (uptEl) uptEl.textContent = days > 0 ? `${days}d ${hours}h` : `${hours}h`;
const loadavg = status.loadavg || [0, 0, 0];
const lv = typeof loadavg === 'string' ? parseFloat(loadavg) : (Array.isArray(loadavg) ? loadavg[0] : 0);
const laEl = document.getElementById('host-loadavg');
if (laEl) laEl.textContent = (Number(lv) || 0).toFixed(2);
}
// Update host monitor modal if open (not in VM mode)
const modal = document.getElementById('monitorModal');
if (modal && modal.style.display === 'flex' && !document.getElementById('monitorContent')?.dataset.inVmMode) {
updateHostMonitorData(status, null);
}
return;
}
// ── VM / LXC instance metrics ────────────────────────────────────
if (msg.channel.startsWith('instance_metrics:')) {
const modal = document.getElementById('monitorModal');
if (!modal || modal.style.display !== 'flex') return;
const mc = document.getElementById('monitorContent');
if (!mc || mc.dataset.vmChannel !== msg.channel) return;
updateMonitorData(msg.data, null);
}
});
}
window.addEventListener('beforeunload', () => {
if (hostNode && typeof unsubscribeWsChannel === 'function') {
unsubscribeWsChannel(`server_metrics:${serverId}_${hostNode}`);
}
}, 5000);
});
});
// === ФУНКЦИИ МОНИТОРИНГА ===
@@ -909,17 +984,17 @@ function showMonitorModal(serverId, node, vmid, type, name) {
<span id="updateIndicator" style="display: inline-block; margin-left: 0.5rem; width: 8px; height: 8px; background: var(--accent); border-radius: 50%; opacity: 0; transition: opacity 0.3s ease;"></span>
`;
document.getElementById('monitorContent').innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> Загрузка данных...</div>';
document.getElementById('monitorContent').dataset.inVmMode = '1';
document.getElementById('monitorContent').dataset.vmChannel = `instance_metrics:${serverId}_${vmid}_${type}_${node}`;
modal.style.display = 'flex';
loadMonitorData(serverId, node, vmid, type);
if (window.monitorInterval) clearInterval(window.monitorInterval);
window.monitorInterval = setInterval(() => {
if (modal.style.display === 'flex') {
loadMonitorData(serverId, node, vmid, type);
}
}, 5000);
// Subscribe to WS push for live instance status
if (typeof subscribeWsChannel === 'function') {
subscribeWsChannel(`instance_metrics:${serverId}_${vmid}_${type}_${node}`);
}
}
function closeMonitorModal() {
@@ -929,7 +1004,12 @@ function closeMonitorModal() {
modal.style.display = 'none';
if (monitorContent) {
// Unsubscribe from WS channels set during open
const vmCh = monitorContent.dataset.vmChannel;
if (vmCh && typeof unsubscribeWsChannel === 'function') unsubscribeWsChannel(vmCh);
delete monitorContent.dataset.initialized;
delete monitorContent.dataset.inVmMode;
delete monitorContent.dataset.vmChannel;
}
if (window.monitorInterval) {
@@ -949,17 +1029,20 @@ async function loadMonitorData(serverId, node, vmid, type) {
// Конвертировать тип: qemu -> vm, lxc -> container
const apiType = type === 'qemu' ? 'vm' : 'container';
const [statusRes, rrdRes] = await Promise.all([
fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/status?node=${node}`),
fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/rrddata?node=${node}&timeframe=hour`)
]);
if (!statusRes.ok || !rrdRes.ok) {
throw new Error(`HTTP error: ${statusRes.status} / ${rrdRes.status}`);
const statusRes = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/status?node=${node}`);
if (!statusRes.ok) {
throw new Error(`HTTP error: ${statusRes.status}`);
}
const status = await statusRes.json();
const rrdData = await rrdRes.json();
// RRD data via WS request
let rrdData = { data: [] };
try {
if (typeof sendWsRequest === 'function') {
const resp = await sendWsRequest('get_rrd', { server_id: serverId, node, vmid, inst_type: type, timeframe: 'hour' });
if (resp && resp.data) rrdData = resp.data;
}
} catch { /* charts unavailable */ }
if (isFirstLoad) {
renderMonitorData(status, rrdData, type);

View File

@@ -31,8 +31,6 @@
</div>
<div class="card">
{% if proxmox_servers %}
<div class="table-container">
<table class="data-table" id="serversTable">
<thead>
@@ -59,63 +57,14 @@
</tr>
</thead>
<tbody id="serversTableBody">
{% for server in proxmox_servers|sort(attribute='name') %}
<tr id="server-row-{{ server.id }}" data-server-id="{{ server.id }}" data-name="{{ server.name|lower }}" data-ip="{{ server.ip_address }}" data-node="{{ server.hostname or '' }}" data-vms="0" data-lxc="0" data-status="loading">
<td>
<div style="font-weight: 600; color: var(--text-primary);">{{ server.name }}</div>
{% if server.cluster_name %}
<span class="cluster-badge"><i class="fa-solid fa-network-wired"></i> {{ server.cluster_name }}</span>
{% endif %}
{% if server.description %}
<div style="font-size: 0.75rem; color: var(--text-muted);">{{ server.description }}</div>
{% endif %}
</td>
<td>
<div style="font-family: monospace; font-size: 0.875rem;">{{ server.ip_address }}:{{ server.port }}</div>
<div style="font-size: 0.75rem; color: var(--text-muted);"><i class="fa-solid fa-user"></i> {{ server.api_user }}</div>
</td>
<td>
{% if server.hostname and server.hostname != server.ip_address %}
<span style="font-size: 0.875rem; color: var(--info);">{{ server.hostname }}</span>
{% else %}
<span style="color: var(--text-muted);">—</span>
{% endif %}
</td>
<td id="server-vms-{{ server.id }}" style="text-align: center;">
<span style="color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></span>
</td>
<td id="server-lxc-{{ server.id }}" style="text-align: center;">
<span style="color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></span>
</td>
<td id="server-status-{{ server.id }}" style="text-align: center;">
<span class="status-badge status-loading"><i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}</span>
</td>
<td style="text-align: right;">
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button onclick="testConnection({{ server.id }})" class="btn btn-sm" style="background: var(--success); color: var(--text-on-success);" title="{{ t('test') }}">
<i class="fa-solid fa-plug"></i> {{ t('test') }}
</button>
<button onclick="window.location.href='/proxmox/server/{{ server.id }}'" class="btn btn-sm" style="background: var(--info); color: var(--text-on-info);" title="{{ t('details') }}">
<i class="fa-solid fa-chart-simple"></i> {{ t('details') }}
</button>
<button onclick="editServer({{ server.id }}, '{{ server.name }}')" class="btn btn-sm" style="background: var(--warning); color: var(--text-on-warning);" title="{{ t('edit') }}" data-permission="proxmox.servers.edit">
<i class="fa-solid fa-pen"></i>
</button>
<button onclick="deleteServer({{ server.id }}, '{{ server.name }}')" class="btn btn-sm btn-delete" title="{{ t('delete') }}" data-permission="proxmox.servers.delete">
<i class="fa-solid fa-trash"></i>
</button>
</div>
<tr id="servers-loading-row">
<td colspan="7" style="text-align:center; color:var(--text-muted); padding:2rem;">
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p style="color: var(--text-secondary);">
{{ t('no_proxmox_servers') }} <a href="#" onclick="showAddServerModal(); return false;" style="color: var(--success);">{{ t('add_first_server') }}</a>.
</p>
{% endif %}
</div>
<style>
@@ -1966,13 +1915,96 @@ async function controlResource(serverId, node, vmid, action, type) {
}
}
// -----------------------------------------------------------------------
// Dynamic server table loader (workspace-aware)
// -----------------------------------------------------------------------
async function loadServersTable() {
const tbody = document.getElementById('serversTableBody');
if (!tbody) return;
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:2rem;"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading') || 'Загрузка...'}</td></tr>`;
try {
const resp = await fetchWithAuth('/proxmox/api/servers');
if (!resp || !resp.ok) throw new Error('Failed to load servers');
const servers = await resp.json();
if (!servers || servers.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:2rem;">
${t('no_proxmox_servers') || 'Нет серверов.'} <a href="#" onclick="showAddServerModal();return false;" style="color:var(--success);">${t('add_first_server') || 'Добавить первый'}</a>
</td></tr>`;
return;
}
servers.sort((a, b) => a.name.localeCompare(b.name));
tbody.innerHTML = servers.map(server => `
<tr id="server-row-${server.id}" data-server-id="${server.id}" data-name="${(server.name || '').toLowerCase()}" data-ip="${server.ip_address || ''}" data-node="${server.hostname || ''}" data-vms="0" data-lxc="0" data-status="loading">
<td>
<div style="font-weight:600;color:var(--text-primary);">${escapeHtml(server.name)}</div>
${server.cluster_name ? `<span class="cluster-badge"><i class="fa-solid fa-network-wired"></i> ${escapeHtml(server.cluster_name)}</span>` : ''}
${server.description ? `<div style="font-size:0.75rem;color:var(--text-muted);">${escapeHtml(server.description)}</div>` : ''}
</td>
<td>
<div style="font-family:monospace;font-size:0.875rem;">${escapeHtml(server.ip_address)}:${server.port || 8006}</div>
<div style="font-size:0.75rem;color:var(--text-muted);"><i class="fa-solid fa-user"></i> ${escapeHtml(server.api_user || '')}</div>
</td>
<td>
${(server.hostname && server.hostname !== server.ip_address)
? `<span style="font-size:0.875rem;color:var(--info);">${escapeHtml(server.hostname)}</span>`
: `<span style="color:var(--text-muted);">—</span>`}
</td>
<td id="server-vms-${server.id}" style="text-align:center;">
<span style="color:var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></span>
</td>
<td id="server-lxc-${server.id}" style="text-align:center;">
<span style="color:var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i></span>
</td>
<td id="server-status-${server.id}" style="text-align:center;">
<span class="status-badge status-loading"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading') || 'Загрузка'}</span>
</td>
<td style="text-align:right;">
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
<button onclick="testConnection(${server.id})" class="btn btn-sm" style="background:var(--success);color:var(--text-on-success);" title="${t('test') || 'Тест'}">
<i class="fa-solid fa-plug"></i> ${t('test') || 'Тест'}
</button>
<button onclick="window.location.href='/proxmox/server/${server.id}'" class="btn btn-sm" style="background:var(--info);color:var(--text-on-info);" title="${t('details') || 'Детали'}">
<i class="fa-solid fa-chart-simple"></i> ${t('details') || 'Детали'}
</button>
<button onclick="editServer(${server.id}, ${JSON.stringify(server.name)})" class="btn btn-sm" style="background:var(--warning);color:var(--text-on-warning);" title="${t('edit') || 'Изменить'}" data-permission="proxmox.servers.edit">
<i class="fa-solid fa-pen"></i>
</button>
<button onclick="deleteServer(${server.id}, ${JSON.stringify(server.name)})" class="btn btn-sm btn-delete" title="${t('delete') || 'Удалить'}" data-permission="proxmox.servers.delete">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
</tr>
`).join('');
// Apply permission visibility after re-render
if (typeof applyPermissions === 'function') applyPermissions();
// Load statuses for the new rows
loadAllStatuses();
} catch (e) {
console.error('loadServersTable error:', e);
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--danger);padding:2rem;">${t('error') || 'Ошибка'}: ${e.message}</td></tr>`;
}
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Загрузить данные при открытии страницы
{% if proxmox_servers %}
// Wait for auth token to be validated
setTimeout(() => {
loadAllStatuses();
loadServersTable();
// Hook for workspace switch
window.reloadPageData = loadServersTable;
}, 500);
{% endif %}
</script>
<!-- Модальное окно мониторинга -->

View File

@@ -277,12 +277,6 @@
<h2><i class="fa-solid fa-gear"></i> {{ t('panel_settings') }}</h2>
<form id="panel-form" class="settings-form">
<div class="form-group">
<label for="refresh_interval">{{ t('refresh_interval_label') }}</label>
<input type="number" id="refresh_interval" min="1" max="60" value="5">
<small>{{ t('refresh_interval_help') }}</small>
</div>
<div class="form-group">
<label for="log_retention_days">{{ t('log_retention_label') }}</label>
<input type="number" id="log_retention_days" min="1" max="365" value="30">
@@ -1222,7 +1216,6 @@ document.addEventListener('DOMContentLoaded', function() {
const oldLang = panelSettings ? panelSettings.language : 'ru';
const data = {
refresh_interval: parseInt(document.getElementById('refresh_interval').value),
log_retention_days: parseInt(document.getElementById('log_retention_days').value),
language: currentLang
};
@@ -1398,7 +1391,6 @@ document.addEventListener('DOMContentLoaded', function() {
displayPanelInfo(settings);
// Fill form (only for admins)
document.getElementById('refresh_interval').value = settings.refresh_interval;
document.getElementById('log_retention_days').value = settings.log_retention_days;
document.getElementById('language').value = settings.language || 'ru';
}
@@ -1412,10 +1404,6 @@ document.addEventListener('DOMContentLoaded', function() {
}
panelInfo.innerHTML = `
<div class="info-item">
<label>${t('refresh_interval')}</label>
<div class="value">${settings.refresh_interval} ${t('sec')}</div>
</div>
<div class="info-item">
<label>${t('log_retention_days')}</label>
<div class="value">${settings.log_retention_days} ${t('days')}</div>

View File

@@ -80,6 +80,88 @@
</div>
<style>
/* ── Workspace Modal Overlay ────────────────────────────────── */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
z-index: 1050;
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
}
.modal-container {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55);
animation: modalIn 0.2s ease;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.96) translateY(-8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-header {
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
padding: 1.25rem 1.5rem !important;
border-bottom: 1px solid var(--border-color) !important;
background: transparent !important;
}
.modal-header h3 {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
}
.modal-body {
padding: 1.5rem !important;
background: transparent !important;
color: var(--text-primary) !important;
}
.modal-footer {
display: flex !important;
justify-content: flex-end !important;
gap: 0.75rem !important;
padding: 1rem 1.5rem !important;
border-top: 1px solid var(--border-color) !important;
background: transparent !important;
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius-sm);
transition: color 0.15s, background 0.15s;
}
.modal-close:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
/* ── Workspace Grid ─────────────────────────────────────────── */
.ws-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));