diff --git a/static/js/admin.js b/static/js/admin.js
index 4c88b9c..cea6d22 100644
--- a/static/js/admin.js
+++ b/static/js/admin.js
@@ -17,32 +17,12 @@ function injectOnceCSS() {
document.head.appendChild(style);
}
-function closeForSid(sid) {
- const entry = liveSockets.get(sid);
- if (!entry) return;
- try { entry.obs && entry.obs.close(); } catch {}
- liveSockets.delete(sid);
-}
-
-export function stopAllAdminWatches() {
- if (slowTimer) { clearInterval(slowTimer); slowTimer = null; }
- if (fastTimer) { clearInterval(fastTimer); fastTimer = null; }
- activeSids.clear();
- for (const sid of Array.from(liveSockets.keys())) closeForSid(sid);
-}
-
function flashDot(cell) {
if (!cell) return;
- const nameWrap = cell.querySelector('.vm-name-wrap') || (() => {
- const w = document.createElement('span'); w.className = 'vm-name-wrap';
- while (cell.firstChild) w.appendChild(cell.firstChild);
- cell.appendChild(w);
- return w;
- })();
const dot = document.createElement('span');
dot.className = 'pulse-dot';
- nameWrap.appendChild(dot);
- setTimeout(() => { try { dot.remove(); } catch {} }, 4200);
+ cell.appendChild(dot);
+ setTimeout(() => dot.remove(), 1500);
}
function setBadgeCell(cell, textOrState) {
@@ -66,203 +46,237 @@ function rebuildTargetSelect(selectEl, currentNode, nodes) {
``
).join('');
selectEl.innerHTML = html;
- const idx = Array.from(selectEl.options).findIndex(o => !o.disabled);
- if (idx >= 0) selectEl.selectedIndex = idx;
+ const idx = Array.from(selectEl.options).findIndex(o => o.disabled);
+ selectEl.selectedIndex = idx >= 0 ? idx : 0;
}
-function setMigrateDisabled(tr, disabled) {
- const btn = tr.querySelector('.act-migrate');
- if (btn) btn.disabled = !!disabled;
+function setMigrateDisabled(tr, isRunning) {
+ const btn = tr?.querySelector('.act-migrate');
+ if (!btn) return;
+ if (isRunning) {
+ btn.removeAttribute('disabled');
+ btn.classList.remove('disabled');
+ } else {
+ btn.setAttribute('disabled', '');
+ btn.classList.add('disabled');
+ }
}
-export async function renderVMAdmin() {
- injectOnceCSS();
- stopAllAdminWatches();
+export function stopAllAdminWatches() {
+ liveSockets.forEach(ws => { try { ws.close(); } catch {} });
+ liveSockets.clear();
+ if (slowTimer) clearInterval(slowTimer);
+ if (fastTimer) clearInterval(fastTimer);
+ slowTimer = null;
+ fastTimer = null;
+ activeSids.clear();
+}
- const data = await api.listAllVmct();
- const arr = Array.isArray(data.all) ? data.all : [];
- const availableNodes = Array.isArray(data.nodes) ? data.nodes : [];
+function ensureWatchOn() {
const tbody = document.querySelector('#vm-admin tbody');
- if (!arr.length) { setRows(tbody, [rowHTML(['Brak VM/CT'])]); return; }
-
- const rows = arr.map(x => {
- const sid = safe(x.sid), type = safe(x.type), name = safe(x.name), node = safe(x.node);
- const nameCell = `${name}`;
- const statusCell = badge('—','dark');
- const actions = `
-
-
-
-
-
-
`;
- const sel = ``;
- const migrateBtn = ``;
- // SID | TYPE | NAME | NODE | STATUS | ACTIONS | TARGET | MIGRATE
- return rowHTML([sid, type.toUpperCase(), nameCell, node, statusCell, actions, sel, migrateBtn], `data-sid="${sid}"`);
- });
-
- setRows(tbody, rows);
-
+ if (!tbody) return;
Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => {
const sid = tr.getAttribute('data-sid');
- const nodeCell = tr.children[3];
- const statusCell= tr.children[4];
- const nameCell = tr.children[2];
- const targetSel = tr.querySelector('.target-node');
+ if (!sid) return;
+ if (liveSockets.has(sid)) return;
- const ensureWatchOn = () => {
- const existing = liveSockets.get(sid);
- if (existing && existing.obs && existing.obs.readyState <= 1) return;
-
- closeForSid(sid);
- const wsObs = new WebSocket(api.wsObserveURL(sid));
- liveSockets.set(sid, { obs: wsObs });
-
- wsObs.onmessage = (ev) => {
+ try {
+ const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/observe?sid=${encodeURIComponent(sid)}`);
+ liveSockets.set(sid, ws);
+ ws.onopen = () => {};
+ ws.onclose = () => { liveSockets.delete(sid); };
+ ws.onerror = () => {};
+ ws.onmessage = (ev) => {
try {
- const msg = JSON.parse(ev.data);
+ const msg = JSON.parse(ev.data || '{}');
+ if (!msg || !msg.type) return;
+ const tr = tbody.querySelector(`tr[data-sid="${sid}"]`);
+ if (!tr) return;
+ const statusCell = tr.children[4];
+ const nameCell = tr.children[2];
+ const nodeCell = tr.children[3];
+ const targetSel = tr.querySelector('.target-node');
- if (msg.type === 'vm' && msg.current) {
- const st = String(msg.current.status || msg.current.qmpstatus || '').toLowerCase();
- const changed = setBadgeCell(statusCell, st);
- const isRunning = /running|online|started/.test(st);
+ if (msg.type === 'status') {
+ const stRaw = String(msg.status || '').toLowerCase();
+ const changed = setBadgeCell(statusCell, stRaw);
+ const isRunning = /running|online|started/.test(stRaw);
setMigrateDisabled(tr, isRunning);
if (changed) flashDot(nameCell);
+ if (stRaw && /running|stopped|shutdown/.test(stRaw)) {
+ setTimeout(() => activeSids.delete(sid), 3000);
+ }
}
- else if (msg.type === 'task-start') {
- setBadgeCell(statusCell, 'working');
- activeSids.add(sid);
- flashDot(nameCell);
- }
- else if (msg.type === 'task') {
- setBadgeCell(statusCell, 'working');
- activeSids.add(sid);
- }
- else if (msg.type === 'moved' && msg.new_node) {
- if (nodeCell && nodeCell.textContent.trim() !== msg.new_node) {
- nodeCell.textContent = msg.new_node;
- rebuildTargetSelect(targetSel, msg.new_node, window.__nodesCache || []);
+
+ if (msg.type === 'node' && msg.node) {
+ const newNode = String(msg.node).trim();
+ if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) {
+ nodeCell.textContent = newNode;
+ rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []);
flashDot(nameCell);
}
- activeSids.add(sid);
- }
- else if (msg.type === 'done') {
- // status końcowy dociągnie kolejny pakiet "vm" lub szybki refresh
- setTimeout(() => activeSids.delete(sid), 4000);
- flashDot(nameCell);
}
} catch {}
};
-
- wsObs.onclose = () => {
- const e = liveSockets.get(sid);
- if (e && e.obs === wsObs) liveSockets.set(sid, { obs: null });
- };
- };
-
- const doAction = async (action, withTarget=false) => {
- try {
- ensureWatchOn();
- if (action !== 'unlock') {
- setBadgeCell(statusCell, 'working');
- activeSids.add(sid);
- }
- const target = withTarget ? (targetSel?.value || '') : undefined;
- const resp = await api.vmAction(sid, action, target);
- if (!resp.ok) throw new Error(resp.error || 'unknown');
- flashDot(nameCell);
- } catch (e) {
- showToast('Error', 'ERROR: ' + (e.message || e), 'danger');
- activeSids.delete(sid);
- }
- };
-
- tr.querySelector('.act-unlock')?.addEventListener('click', () => doAction('unlock'));
- tr.querySelector('.act-start')?.addEventListener('click', () => doAction('start'));
- tr.querySelector('.act-stop')?.addEventListener('click', () => doAction('stop'));
- tr.querySelector('.act-shutdown')?.addEventListener('click',() => doAction('shutdown'));
- tr.querySelector('.act-migrate')?.addEventListener('click', () => doAction('migrate', true));
-
- ensureWatchOn();
- });
-
- window.__nodesCache = availableNodes.slice();
-
- // pełna lista – co 30 s
- slowTimer = setInterval(async () => {
- try {
- const latest = await api.listAllVmct();
- const all = Array.isArray(latest.all) ? latest.all : [];
- const bySid = new Map(all.map(x => [String(x.sid), x]));
- const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : window.__nodesCache || [];
- window.__nodesCache = nodesNow;
-
- Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => {
- const sid = tr.getAttribute('data-sid');
- const rowData = bySid.get(sid);
- if (!rowData) return;
-
- const nodeCell = tr.children[3];
- const statusCell= tr.children[4];
- const nameCell = tr.children[2];
- const targetSel = tr.querySelector('.target-node');
-
- const newNode = String(rowData.node || '').trim();
- if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) {
- nodeCell.textContent = newNode;
- rebuildTargetSelect(targetSel, newNode, nodesNow);
- flashDot(nameCell);
- }
-
- // status z wolnego reconcile — tylko gdy brak „working”, żeby nie zagłuszać WS
- const currentTxt = (statusCell?.innerText || '').toLowerCase();
- if (!/working/.test(currentTxt)) {
- // brak statusu w liście — zostaw jak jest, dociągnie WS/fastTimer
- }
- });
} catch {}
- }, 30000);
+ });
+}
- // tylko aktywne – co 10 s (dociąga precyzyjny status + node)
- fastTimer = setInterval(async () => {
- try {
- const sids = Array.from(activeSids);
- if (!sids.length) return;
- for (const sid of sids) {
- const detail = await api.vmDetail(sid);
- if (!detail || !detail.meta) continue;
- const tr = tbody.querySelector(`tr[data-sid="${sid}"]`);
- if (!tr) continue;
+export async function startAdminWatches() {
+ injectOnceCSS();
- const nodeCell = tr.children[3];
- const statusCell= tr.children[4];
- const nameCell = tr.children[2];
- const targetSel = tr.querySelector('.target-node');
+ const tbody = document.querySelector('#vm-admin tbody');
+ if (!tbody) return;
- const stRaw = String((detail.current && (detail.current.status || detail.current.qmpstatus)) || '').toLowerCase();
- const changed = setBadgeCell(statusCell, stRaw);
- const isRunning = /running|online|started/.test(stRaw);
- setMigrateDisabled(tr, isRunning);
- if (changed) flashDot(nameCell);
+ const availableNodes = await api.listNodes();
+ setRows(tbody, []);
- const newNode = String(detail.node || (detail.meta && detail.meta.node) || '').trim();
- if (newNode) {
- if (nodeCell && nodeCell.textContent.trim() !== newNode) {
+ // inicjalne wypełnienie tabeli
+ try {
+ const latest = await api.listAllVmct();
+ const all = Array.isArray(latest.all) ? latest.all : [];
+ const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : [];
+ window.__nodesCache = nodesNow.slice();
+
+ const rows = all.map(vm => ({
+ sid: safe(vm.sid),
+ name: safe(vm.name || vm.vmid || vm.sid),
+ node: safe(vm.node || '—'),
+ status: safe(vm.status || '—')
+ }));
+
+ const html = rows.map(r => rowHTML([
+ ``,
+ safe(r.sid),
+ safe(r.name),
+ safe(r.node),
+ badge(safe(r.status), /running|online|started/i.test(r.status) ? 'ok' : 'dark'),
+ ``,
+ `
+
+
+
+
`
+ ])).join('');
+
+ setRows(tbody, html);
+
+ // podłącz selecty z node'ami i akcje
+ Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => {
+ const nodeCell = tr.children[3];
+ const targetSel = tr.querySelector('.target-node');
+ rebuildTargetSelect(targetSel, nodeCell?.textContent.trim(), availableNodes);
+
+ const sid = tr.getAttribute('data-sid');
+ const nameCell = tr.children[2];
+
+ async function doAction(kind, needsTarget) {
+ try {
+ const targetNode = needsTarget ? targetSel?.value : undefined;
+ activeSids.add(sid);
+ setBadgeCell(tr.children[4], 'working');
+ setMigrateDisabled(tr, false);
+ const res = await api.vmAction(sid, kind, targetNode);
+ if (res?.ok) {
+ showToast(`Zadanie ${kind} wystartowało dla ${safe(nameCell.textContent)}`);
+ } else {
+ showToast(`Błąd zadania ${kind} dla ${safe(nameCell.textContent)}`, 'danger');
+ }
+ } catch (e) {
+ showToast(`Błąd: ${e?.message || e}`, 'danger');
+ }
+ }
+
+ tr.querySelector('.act-stop')?.addEventListener('click', () => doAction('stop'));
+ tr.querySelector('.act-shutdown')?.addEventListener('click', () => doAction('shutdown'));
+ tr.querySelector('.act-migrate')?.addEventListener('click', () => doAction('migrate', true));
+
+ ensureWatchOn();
+ });
+
+ window.__nodesCache = availableNodes.slice();
+
+ // pełna lista – co 30 s
+ slowTimer = setInterval(async () => {
+ try {
+ const latest = await api.listAllVmct();
+ const all = Array.isArray(latest.all) ? latest.all : [];
+ const bySid = new Map(all.map(x => [String(x.sid), x]));
+ const nodesNow = Array.isArray(latest.nodes) ? latest.nodes : window.__nodesCache || [];
+ window.__nodesCache = nodesNow;
+
+ Array.from(tbody.querySelectorAll('tr[data-sid]')).forEach(tr => {
+ const sid = tr.getAttribute('data-sid');
+ const rowData = bySid.get(sid);
+ if (!rowData) return;
+
+ const nodeCell = tr.children[3];
+ const statusCell= tr.children[4];
+ const nameCell = tr.children[2];
+ const targetSel = tr.querySelector('.target-node');
+
+ const newNode = String(rowData.node || '').trim();
+ if (nodeCell && newNode && nodeCell.textContent.trim() !== newNode) {
nodeCell.textContent = newNode;
- rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []);
+ rebuildTargetSelect(targetSel, newNode, nodesNow);
flashDot(nameCell);
}
- }
- if (stRaw && /running|stopped|shutdown/.test(stRaw)) {
- setTimeout(() => activeSids.delete(sid), 4000);
- }
- }
- } catch {}
- }, 10000);
+ // status z wolnego reconcile — tylko gdy brak „working”, żeby nie zagłuszać WS
+ const currentTxt = (statusCell?.innerText || '').toLowerCase();
+ if (!/working/.test(currentTxt)) {
+ const stRaw = String(rowData.status || '').toLowerCase(); // fallback z /cluster/resources
+ if (stRaw) {
+ const changed = setBadgeCell(statusCell, stRaw);
+ const isRunning = /running|online|started/.test(stRaw);
+ setMigrateDisabled(tr, isRunning);
+ if (changed) flashDot(nameCell);
+ }
+ }
+ });
+ } catch {}
+ }, 30000);
- window.addEventListener('beforeunload', stopAllAdminWatches, { once: true });
+ // tylko aktywne – co 10 s (dociąga precyzyjny status + node)
+ fastTimer = setInterval(async () => {
+ try {
+ const sids = Array.from(activeSids);
+ if (!sids.length) return;
+ for (const sid of sids) {
+ const detail = await api.vmDetail(sid);
+ if (!detail || !detail.meta) continue;
+ const tr = tbody.querySelector(`tr[data-sid="${sid}"]`);
+ if (!tr) continue;
+
+ const nodeCell = tr.children[3];
+ const statusCell= tr.children[4];
+ const nameCell = tr.children[2];
+ const targetSel = tr.querySelector('.target-node');
+
+ const stRaw = String((detail.current && (detail.current.status || detail.current.qmpstatus)) || '').toLowerCase();
+ const changed = setBadgeCell(statusCell, stRaw);
+ const isRunning = /running|online|started/.test(stRaw);
+ setMigrateDisabled(tr, isRunning);
+ if (changed) flashDot(nameCell);
+
+ const newNode = String(detail.node || (detail.meta && detail.meta.node) || '').trim();
+ if (newNode) {
+ if (nodeCell && nodeCell.textContent.trim() !== newNode) {
+ nodeCell.textContent = newNode;
+ rebuildTargetSelect(targetSel, newNode, window.__nodesCache || []);
+ flashDot(nameCell);
+ }
+ }
+
+ if (stRaw && /running|stopped|shutdown/.test(stRaw)) {
+ setTimeout(() => activeSids.delete(sid), 4000);
+ }
+ }
+ } catch {}
+ }, 10000);
+
+ window.addEventListener('beforeunload', stopAllAdminWatches, { once: true });
+ } catch (e) {
+ showToast(`Nie udało się załadować listy: ${e?.message || e}`, 'danger');
+ }
}
diff --git a/static/styles.css b/static/styles.css
index 4f00865..48b454c 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -142,4 +142,16 @@ footer.site-footer a:hover {
100% {
background-position: 0 0;
}
+}
+
+#toast-container {
+ position: fixed;
+ right: max(env(safe-area-inset-right), 1rem);
+ bottom: max(env(safe-area-inset-bottom), 1rem);
+ z-index: 1080;
+ pointer-events: none;
+}
+
+#toast-container .toast {
+ pointer-events: auto;
}
\ No newline at end of file