@@ -1,10 +1,9 @@
import { badge , rowHTML , setRows , safe , showToast } from './helpers.js' ;
import { api } from './api.js' ;
/** Globalny rejestr gniazd: sid -> { obs: WebSocket|null, task: WebSocket|null } */
const liveSockets = new Map ( ) ;
let reconcileTimer = null ;
/** Pomocniczo: zamknij WS-y dla sid */
function closeForSid ( sid ) {
const entry = liveSockets . get ( sid ) ;
if ( ! entry ) return ;
@@ -13,13 +12,17 @@ function closeForSid(sid) {
liveSockets . delete ( sid ) ;
}
/** Exportowany helper do ew. sprzątania z zewnątrz (opcjonalnie) */
export function stopAllAdminWatches ( ) {
for ( const sid of liveSockets . keys ( ) ) closeForSid ( sid ) ;
i f ( reconcileTimer ) { clearInterval ( reconcileTimer ) ; reconcileTimer = null ; }
for ( const sid of Array . from ( liveSockets . keys ( ) ) ) closeForSid ( sid ) ;
}
function setBadge ( cell , val ) {
if ( ! cell ) return ;
cell . innerHTML = val ;
}
export async function renderVMAdmin ( ) {
// zanim przebudujemy tabelę – zamknij WS-y (unikamy „sierotek”)
stopAllAdminWatches ( ) ;
const data = await api . listAllVmct ( ) ;
@@ -44,7 +47,6 @@ export async function renderVMAdmin() {
const tools = ` <div class="d-flex align-items-center gap-2">
<button class="btn btn-outline-primary btn-sm act-migrate">Migrate (offline)</button>
<button class="btn btn-outline-secondary btn-sm act-status">Status</button>
<button class="btn btn-info btn-sm act-watch" title="Live observe">Watching 🔔</button>
</div> ` ;
return rowHTML ( [ sid , type . toUpperCase ( ) , name , node , st , actions , sel , tools ] , ` data-sid=" ${ sid } " ` ) ;
} ) ;
@@ -54,12 +56,10 @@ export async function renderVMAdmin() {
Array . from ( tbody . querySelectorAll ( 'tr[data-sid]' ) ) . forEach ( tr => {
const sid = tr . getAttribute ( 'data-sid' ) ;
const colSpan = tr . children . length ;
const nodeCell = tr . children [ 3 ] ; // kolumna Node
const badgeCell = tr . children [ 4 ] ; // kolumna Status
const nodeCell = tr . children [ 3 ] ;
const badgeCell = tr . children [ 4 ] ;
const targetSel = tr . querySelector ( '.target-node' ) ;
const watchBtn = tr . querySelector ( '.act-watch' ) ;
// subpanel (log)
let sub = tr . nextElementSibling ;
if ( ! sub || ! sub . classList . contains ( 'mig-row' ) ) {
sub = document . createElement ( 'tr' ) ; sub . className = 'mig-row d-none' ;
@@ -88,20 +88,17 @@ export async function renderVMAdmin() {
const html = availableNodes . map ( n =>
` <option value=" ${ n } " ${ n === current ? 'disabled selected' : '' } > ${ n } ${ n === current ? ' (src)' : '' } </option> ` ) . join ( '' ) ;
targetSel . innerHTML = html ;
// ustaw focus na pierwszy dozwolony, jeśli selected jest disabled
const idx = Array . from ( targetSel . options ) . findIndex ( o => ! o . disabled ) ;
if ( idx >= 0 ) targetSel . selectedIndex = idx ;
} ;
const getTarget = ( ) => targetSel ? . value || '' ;
// ---- WS task tail (na bieżący UPID) ----
const openTaskWS = ( upid , node ) => {
if ( ! upid ) return ;
toggleSub ( true ) ;
logPre . textContent = ` UPID: ${ upid } @ ${ node } \n ` ;
// zamknij ewentualnie poprzednie tail WS dla tej VM
const entry = liveSockets . get ( sid ) || { } ;
try { entry . task && entry . task . close ( ) ; } catch { }
const wsTask = new WebSocket ( api . wsTaskURL ( upid , node ) ) ;
@@ -117,80 +114,55 @@ export async function renderVMAdmin() {
} else if ( msg . type === 'status' && msg . status ) {
const ok = String ( msg . status . exitstatus || '' ) . toUpperCase ( ) === 'OK' ;
const s = String ( msg . status . status || '' ) . toLowerCase ( ) ;
if ( badgeCell ) badgeCell . innerHTML = ok ? badge ( 'running' , 'ok' ) :
( s === 'stopped' ? badge ( 'stopped' , 'dark' ) : badge ( 'working' , 'info' ) ) ;
setBadge ( badgeCell , ok ? badge ( 'running' , 'ok' ) :
( s === 'stopped' ? badge ( 'stopped' , 'dark' ) : badge ( 'working' , 'info' ) ) ) ;
} else if ( msg . type === 'done' ) {
const ok = ! ! msg . ok ;
if ( badgeCell ) badgeCell . innerHTML = ok ? badge ( 'running' , 'ok' ) : badge ( 'error' , 'err' ) ;
setBadge ( badgeCell , ok ? badge ( 'running' , 'ok' ) : badge ( 'error' , 'err' ) ) ;
setRowBusy ( false ) ;
setTimeout ( ( ) => toggleSub ( false ) , 1500 ) ;
try { document . getElementById ( 'btnRefresh' ) . click ( ) ; } catch { }
}
} catch { }
} ;
wsTask . onerror = ( ) => { } ;
wsTask . onclose = ( ) => {
// nie czyścimy entry.task, żeby móc rozróżnić zamknięcie „nasze” vs. serwera
} ;
} ;
// ---- WS observe (domyślnie ON) ----
const ensureWatchOn = ( ) => {
// jeśli już obserwujemy – nic nie rób
const existing = liveSockets . get ( sid ) ;
if ( existing && existing . obs && existing . obs . readyState <= 1 ) return ;
// upewnij się, że stare gniazda zamknięte
closeForSid ( sid ) ;
const wsObs = new WebSocket ( api . wsObserveURL ( sid ) ) ;
liveSockets . set ( sid , { obs : wsObs , task : null } ) ;
// wizualnie: włączony
if ( watchBtn ) {
watchBtn . classList . remove ( 'btn-outline-info' ) ;
watchBtn . classList . add ( 'btn-info' ) ;
watchBtn . textContent = 'Watching 🔔' ;
}
wsObs . onmessage = ( ev ) => {
try {
const msg = JSON . parse ( ev . data ) ;
if ( msg . type === 'vm' && msg . current ) {
// live status (działa także dla start/stop/shutdown bez UPID)
const st = String ( msg . current . status || msg . current . qmpstatus || '' ) . toLowerCase ( ) ;
const ok = /running|online|started/ . test ( st ) ;
if ( badgeCell ) {
badgeCell . innerHTML = ok ? badge ( 'running ', 'ok ' ) :
( /stopp|shutdown|offline/ . test ( st ) ? badge ( 'stopped' , 'dark' ) : badge ( st || '—' , 'info' ) ) ;
}
setBadge ( badgeCell , ok ? badge ( 'running' , 'ok' ) :
( /stopp|shutdown|offline/ . test ( st ) ? badge ( 'stopped' , 'dark' ) : badge ( st || '— ', 'inf o' ) ) ) ;
}
else if ( msg . type === 'task-start' && msg . upid && msg . node ) {
// jeżeli akcja wystartowała spoza panelu lub bez UPID od API — podepnij log
openTaskWS ( msg . upid , msg . node ) ;
}
else if ( msg . type === 'task' && msg . upid && msg . status ) {
const stopped = String ( msg . status || '' ) . toLowerCase ( ) === 'stopped' ;
if ( stopped && typeof msg . exitstatus !== 'undefined' ) {
const ok = String ( msg . exitstatus || '' ) . toUpperCase ( ) === 'OK' ;
if ( badgeCell ) badgeCell . innerHTML = ok ? badge ( 'running' , 'ok' ) : badge ( 'error' , 'err' ) ;
setBadge ( badgeCell , ok ? badge ( 'running' , 'ok' ) : badge ( 'error' , 'err' ) ) ;
} else {
if ( badgeCell ) badgeCell . innerHTML = badge ( 'working' , 'info' ) ;
setBadge ( badgeCell , badge ( 'working' , 'info' ) ) ;
}
}
else if ( msg . type === 'moved' && msg . new _node ) {
// VM przeniesiona – od razu popraw wiersz
if ( nodeCell ) nodeCell . textContent = msg . new _node ;
rebuildTargetSelect ( msg . new _node ) ;
try { document . getElementById ( 'btnRefresh' ) . click ( ) ; } catch { }
}
else if ( msg . type === 'done' && typeof msg . ok === 'boolean' ) {
if ( badgeCell ) badgeCell . innerHTML = msg . ok ? badge ( 'running' , 'ok' ) : badge ( 'error' , 'err' ) ;
setBadge ( badgeCell , msg . ok ? badge ( 'running' , 'ok' ) : badge ( 'error' , 'err' ) ) ;
}
} catch { }
@@ -199,16 +171,9 @@ export async function renderVMAdmin() {
wsObs . onclose = ( ) => {
const e = liveSockets . get ( sid ) ;
if ( e && e . obs === wsObs ) {
// oznacz jako wyłączone
liveSockets . set ( sid , { obs : null , task : e . task || null } ) ;
if ( watchBtn ) {
watchBtn . classList . remove ( 'btn-info' ) ;
watchBtn . classList . add ( 'btn-outline-info' ) ;
watchBtn . textContent = 'Watch 🔔' ;
}
}
} ;
wsObs . onerror = ( ) => { } ;
} ;
const doAction = async ( action , withTarget = false ) => {
@@ -216,22 +181,21 @@ export async function renderVMAdmin() {
try {
const target = withTarget ? getTarget ( ) : undefined ;
// ZAWSZE live – zapewnia status/log nawet jeśli API nie odda UPID
ensureWatchOn ( ) ;
if ( action !== 'unlock' ) toggleSub ( true ) ; // pokaż subpanel logów dla akcji z zadaniami
if ( action !== 'unlock' ) {
setBadge ( badgeCell , badge ( 'working' , 'info' ) ) ;
toggleSub ( true ) ;
}
const resp = await api . vmAction ( sid , action , target ) ;
if ( ! resp . ok ) throw new Error ( resp . error || 'unknown' ) ;
if ( ! resp . upid ) {
// np. unlock albo środowisko nie oddało UPID – poczekaj na task-start z observe
logPre . textContent = ` Waiting for task… ( ${ action } ) \n ` ;
showToast ( 'Info' , ` ${ action } zainicjowane ` , 'info' ) ;
setRowBusy ( false ) ; // spinner wyłącz, status/log dojadą z observe
setRowBusy ( false ) ;
return ;
}
// mamy UPID – od razu podłącz tail
openTaskWS ( resp . upid , resp . source _node ) ;
} catch ( e ) {
@@ -241,34 +205,61 @@ export async function renderVMAdmin() {
}
} ;
// Akcje
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 ) ) ;
// Status – pokaż/ukryj subpanel (bez WS)
tr . querySelector ( '.act-status' ) ? . addEventListener ( 'click' , ( ) => toggleSub ( sub . classList . contains ( 'd-none' ) ) ) ;
// Watch 🔔 – manualny toggle (domyślnie jest ON)
if ( watchBtn ) {
watchBtn . addEventListener ( 'click' , ( ) => {
const e = liveSockets . get ( sid ) ;
if ( e && e . obs ) {
closeForSid ( sid ) ;
watchBtn . classList . remove ( 'btn-info' ) ; watchBtn . classList . add ( 'btn-outline-info' ) ;
watchBtn . textContent = 'Watch 🔔' ;
} else {
ensureWatchOn ( ) ;
}
} ) ;
}
// startowo: LIVE bez klikania
ensureWatchOn ( ) ;
} ) ;
// sprzątanie przy zamknięciu karty
reconcileTimer = 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 ] ) ) ;
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 badgeCell = tr . children [ 4 ] ;
const targetSel = tr . querySelector ( '.target-node' ) ;
const newNode = String ( rowData . node || '' ) . trim ( ) ;
if ( nodeCell && newNode && nodeCell . textContent . trim ( ) !== newNode ) {
nodeCell . textContent = newNode ;
if ( targetSel ) {
const options = Array . from ( targetSel . options ) ;
options . forEach ( o => { o . disabled = ( o . value === newNode ) ; o . selected = ( o . value === newNode ) ; } ) ;
const idx = options . findIndex ( o => ! o . disabled ) ;
if ( idx >= 0 ) targetSel . selectedIndex = idx ;
}
}
const running = /running/i . test ( rowData . status || '' ) ;
const currentBadge = badgeCell ? . innerText ? . toLowerCase ( ) || '' ;
const isWorking = currentBadge . includes ( 'working' ) ;
if ( badgeCell && ! isWorking ) {
setBadge ( badgeCell , running ? badge ( 'running' , 'ok' ) : badge ( rowData . status || '—' , 'dark' ) ) ;
}
const existing = liveSockets . get ( sid ) ;
if ( ! ( existing && existing . obs && existing . obs . readyState <= 1 ) ) {
const wsObs = new WebSocket ( api . wsObserveURL ( sid ) ) ;
liveSockets . set ( sid , { obs : wsObs , task : existing ? . task || null } ) ;
wsObs . onmessage = ( ) => { } ;
wsObs . onerror = ( ) => { } ;
}
} ) ;
} catch { }
} , 3000 ) ;
window . addEventListener ( 'beforeunload' , stopAllAdminWatches , { once : true } ) ;
}