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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ========
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// Загрузить данные при открытии страницы
|
||||
{% if proxmox_servers %}
|
||||
// Wait for auth token to be validated
|
||||
setTimeout(() => {
|
||||
loadAllStatuses();
|
||||
loadServersTable();
|
||||
// Hook for workspace switch
|
||||
window.reloadPageData = loadServersTable;
|
||||
}, 500);
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
<!-- Модальное окно мониторинга -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user