mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 02:12:12 +00:00
fix: detach closed update viewers and preserve other guest state
This commit is contained in:
@@ -84,6 +84,7 @@ export function ScriptTerminalModal({
|
||||
// after dynamic imports resolve — captures the latest value without
|
||||
// re-binding the closure.
|
||||
const isOpenRef = useRef<boolean>(false)
|
||||
const openGenerationRef = useRef(0)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
const sessionIdRef = useRef<string>(Math.random().toString(36).substring(2, 8))
|
||||
|
||||
@@ -158,8 +159,9 @@ export function ScriptTerminalModal({
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
|
||||
const generation = openGenerationRef.current
|
||||
const ticketedUrl = await getTicketedWsUrl(wsUrl)
|
||||
if (!isOpenRef.current || !termRef.current) return
|
||||
if (!isOpenRef.current || !termRef.current || generation !== openGenerationRef.current) return
|
||||
const ws = new WebSocket(ticketedUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
@@ -326,12 +328,13 @@ export function ScriptTerminalModal({
|
||||
// otherwise we attach a Terminal to a stale ref and open a WS that
|
||||
// nobody reads.
|
||||
const wasOpenAtCall = isOpenRef.current
|
||||
const generation = ++openGenerationRef.current
|
||||
const [TerminalClass, FitAddonClass] = await Promise.all([
|
||||
import("xterm").then((mod) => mod.Terminal),
|
||||
import("xterm-addon-fit").then((mod) => mod.FitAddon),
|
||||
import("xterm/css/xterm.css"),
|
||||
])
|
||||
if (!wasOpenAtCall || !isOpenRef.current) return
|
||||
if (!wasOpenAtCall || !isOpenRef.current || generation !== openGenerationRef.current) return
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 16
|
||||
|
||||
@@ -601,6 +604,7 @@ export function ScriptTerminalModal({
|
||||
if (isOpen) {
|
||||
initializeTerminal()
|
||||
} else {
|
||||
openGenerationRef.current++
|
||||
if (checkConnectionInterval.current) {
|
||||
clearInterval(checkConnectionInterval.current)
|
||||
}
|
||||
@@ -636,6 +640,28 @@ export function ScriptTerminalModal({
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (!backgroundUpdate) return // Transport mode is fixed for this instance.
|
||||
// Conditional parents can unmount an open modal. Detach that viewer too;
|
||||
// only the background job owns its process and completion result.
|
||||
isOpenRef.current = false
|
||||
openGenerationRef.current++
|
||||
for (const ref of [checkConnectionInterval, keepAliveIntervalRef]) {
|
||||
if (ref.current) clearInterval(ref.current)
|
||||
}
|
||||
for (const ref of [waitingTimeoutRef, reconnectTimeoutRef]) {
|
||||
if (ref.current) clearTimeout(ref.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null
|
||||
wsRef.current.onmessage = null
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
termRef.current?.dispose()
|
||||
termRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const updateDeviceType = () => {
|
||||
const width = window.innerWidth
|
||||
|
||||
@@ -2711,6 +2711,12 @@ export function VirtualMachines() {
|
||||
// performs the requests (the data map is only a render seed), so no
|
||||
// invalidation is needed and there is no "Loading applications" flash.
|
||||
void fetchLxcApps(applyVmid)
|
||||
// Recovery can be opened from the job banner while another CT is selected.
|
||||
// Refresh the operated guest without changing that other guest's result UI.
|
||||
if (selectedVMRef.current?.vmid !== applyVmid) {
|
||||
void mutate()
|
||||
return
|
||||
}
|
||||
// Enter the "Comprobando resultado…" state on the Updates tab.
|
||||
// Baseline snapshot lets a downstream useEffect detect when the
|
||||
// SWR poll actually delivers the post-apply counts (as opposed
|
||||
|
||||
@@ -7,9 +7,13 @@ const ts = require(path.join(root, 'node_modules/typescript'))
|
||||
const source = fs.readFileSync(path.join(root, 'components/script-terminal-modal.tsx'), 'utf8')
|
||||
const tree = ts.createSourceFile('modal.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
const handlers = {onmessage: [], onclose: []}
|
||||
let unmount
|
||||
function walk(node) {
|
||||
if (ts.isBinaryExpression(node) && /^ws\.(onmessage|onclose)$/.test(node.left.getText(tree)))
|
||||
handlers[node.left.name.text].push(node.right.getText(tree))
|
||||
if (ts.isCallExpression(node) && node.expression.getText(tree) === 'useEffect'
|
||||
&& node.arguments[0]?.getText(tree).includes('Transport mode is fixed'))
|
||||
unmount = node.arguments[0].getText(tree)
|
||||
ts.forEachChild(node, walk)
|
||||
}
|
||||
walk(tree)
|
||||
@@ -69,6 +73,37 @@ for (const handler of handlers.onclose) {
|
||||
compile(handler, closed.context)({})
|
||||
assert.equal(closed.reconnects.length, 0, 'closing the view cannot reopen a socket')
|
||||
}
|
||||
assert.ok(unmount)
|
||||
for (const backgroundUpdate of [undefined, {runId:'existing'}]) {
|
||||
const f=fixture(backgroundUpdate), closes=[], sends=[], disposals=[]
|
||||
const ref={current:{close:()=>closes.push(true),send:()=>sends.push(true)}}
|
||||
Object.assign(f.context,{wsRef:ref,openGenerationRef:{current:1},
|
||||
checkConnectionInterval:{current:null},reconnectTimeoutRef:{current:null}})
|
||||
f.context.termRef.current.dispose=()=>disposals.push(true)
|
||||
compile(unmount,f.context)()()
|
||||
assert.equal(closes.length,backgroundUpdate?1:0)
|
||||
assert.equal(sends.length,0,'unmount cannot send cancellation or another start')
|
||||
assert.equal(f.finished.length,0)
|
||||
assert.equal(disposals.length,backgroundUpdate?1:0)
|
||||
}
|
||||
const vms=fs.readFileSync(path.join(root,'components/virtual-machines.tsx'),'utf8')
|
||||
const vmTree=ts.createSourceFile('vms.tsx',vms,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX)
|
||||
let complete
|
||||
function visit(n){if(ts.isVariableDeclaration(n)&&n.name.getText(vmTree)==='handleApplyComplete')complete=n.initializer.getText(vmTree);ts.forEachChild(n,visit)}
|
||||
visit(vmTree)
|
||||
async function checkCompletion(){
|
||||
for(const selected of [100,200]){
|
||||
const posts=[],states=[],refresh=[]
|
||||
const context={applyVmid:100,applyStartedAt:Date.now(),selectedVM:{vmid:selected},selectedVMRef:{current:{vmid:selected}},
|
||||
applyRunId:'existing',applyTarget:'app',applyTargetIds:['app:test'],applyTargetLabels:['Test'],applyDockerRefresh:false,
|
||||
vmModalCacheRef:{current:{details:new Map(),schedule:new Map()}},fetchApi:async(...args)=>posts.push(args),
|
||||
fetchLxcApps:id=>refresh.push(id),mutate:()=>{},setUpdatesBaselineCount:v=>states.push(v),setUpdatesResult:v=>states.push(v),setUpdatesRefreshing:v=>states.push(v)}
|
||||
await compile(complete,context)(0,false)
|
||||
assert.equal(posts.length,0,'background completion never sends a second finalization POST')
|
||||
assert.deepEqual(refresh,[100])
|
||||
assert.equal(states.length,selected===100?3:0,'another guest result UI is preserved')
|
||||
}
|
||||
}
|
||||
const local = new Map(), events = []
|
||||
const storage = {getItem:k=>local.get(k), setItem:(k,v)=>local.set(k,v)}
|
||||
const jobs = loadTypeScript('lib/lxc-update-jobs.ts', {localStorage:storage,
|
||||
@@ -80,3 +115,4 @@ assert.equal(jobs.viewedUpdateJobs().length, 1)
|
||||
assert.equal(events.length, 2)
|
||||
assert.equal([...local.values()].join(''), '["opaque-run"]', 'storage has IDs, no commands or credentials')
|
||||
console.log('PASS actual initial/reconnect handlers, auth-independent recovery metadata and legacy completion')
|
||||
checkCompletion().catch(e=>{console.error(e);process.exitCode=1})
|
||||
|
||||
@@ -23,7 +23,7 @@ function executable(node, file, scope) {
|
||||
function fixture(handler, locale, optedIn=true) {
|
||||
const writes=[], callbacks=[], state={closed:0};
|
||||
const t=translator(locale), terminal={write:s=>writes.push(s),writeln:s=>writes.push(s)};
|
||||
const shared={...completion, backgroundUpdate:undefined, isOpenRef:{current:true}, cancelledExitCode:optedIn?6:undefined,t,
|
||||
const shared={...completion, backgroundUpdate:undefined, isOpenRef:{current:true}, openGenerationRef:{current:1}, cancelledExitCode:optedIn?6:undefined,t,
|
||||
completedSuccessfullyMessage:undefined,completedWithErrorMessage:undefined};
|
||||
const getCompletionMessage=executable(messageCallback,source,shared);
|
||||
const scope={...shared,getCompletionMessage,term:terminal,termRef:{current:terminal},
|
||||
|
||||
Reference in New Issue
Block a user