fix(monitor): preserve app editor across lifecycle refreshes

This commit is contained in:
VAIO73
2026-10-18 19:21:52 +00:00
parent 0a06a81871
commit 3071095109
4 changed files with 82 additions and 25 deletions
+24 -18
View File
@@ -356,6 +356,7 @@ interface ManagedAppInfo {
interface Props {
vmid: number
cacheRevision?: number
ctIp?: string | null
onChange?: () => void
managed?: ManagedAppInfo | null
@@ -497,7 +498,7 @@ function parseArgvInput(value: string): string[] {
return value.split(",").map((item) => item.trim()).filter(Boolean)
}
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: Props) {
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, cacheRevision = 0 }: Props) {
const t = useT()
const isLightTheme = useIsLightTheme()
// Seed from `initialData` first, then fall back to the shared cache
@@ -519,20 +520,23 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
targetGeneration.current += 1
}
useEffect(() => {
const generation = targetGeneration.current
const request = ++nativeRequest.current
setNativeDiscovery(null)
setEditing(null)
setDetectionNotice(null)
setSearchingApplications(false)
setSaving(false)
return () => { targetGeneration.current += 1 }
}, [vmid, managed])
useEffect(() => {
const generation = targetGeneration.current
const request = ++nativeRequest.current
if (managed) return
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`).then(result => {
if (targetRef.current === vmid && targetGeneration.current === generation
&& nativeRequest.current === request) setNativeDiscovery(result)
}).catch(() => { /* cached metadata is optional; never initiate a scan */ })
return () => { targetGeneration.current += 1 }
}, [vmid, managed])
return () => { nativeRequest.current += 1 }
}, [vmid, managed, cacheRevision])
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchingApplications, setSearchingApplications] = useState(false)
@@ -638,7 +642,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
if (isCurrent()) setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vmid, managed])
}, [vmid, managed, cacheRevision])
useEffect(() => {
if (managed) return
@@ -1686,9 +1690,18 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
from https://selfh.st/icons); empty → no logo in the
app card header. */}
<div className="pt-2 border-t border-border/50">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label htmlFor="app-logo" className="text-xs uppercase tracking-wider text-muted-foreground">
{t("vmLxc.appEditor.logoUrlLabel")} <span className="normal-case tracking-normal text-[10px] opacity-70">{t("vmLxc.appEditor.optionalSuffix")}</span>
</Label>
<Button type="button" variant="outline" size="sm"
disabled={!selectedIconPort || discoveringLocalLogo || saving}
onClick={discoverLocalLogo}>
{discoveringLocalLogo ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Search className="h-3.5 w-3.5 mr-1" />}
{t("vmLxc.appEditor.discoverLocalLogo")}
</Button>
</div>
<div className="flex items-center gap-2 mt-2">
<Input
id="app-logo"
type="text"
@@ -1696,9 +1709,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
onChange={(e) => setField({ logo_url: e.target.value })}
placeholder={t("vmLxc.appEditor.portLogoPlaceholder")}
maxLength={512}
className="text-sm mt-2 font-mono"
className="text-sm font-mono"
/>
<div className="flex flex-wrap items-center gap-2 mt-2">
{draft.logo_url && LOCAL_LOGO_CACHE_PATH.test(draft.logo_url) &&
<ThemeAwareLogo src={draft.logo_url} className="h-8 w-8 shrink-0 object-contain" />}
</div>
{iconPorts.length > 1 && <select
aria-label={t("vmLxc.appEditor.localLogoPort")}
value={selectedIconPort ? endpointKey(selectedIconPort) : ""}
@@ -1708,21 +1723,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
setDiscoveringLocalLogo(false)
setIconEndpoint(event.target.value)
}}
className="rounded border bg-background px-2 py-1 text-xs"
className="mt-2 rounded border bg-background px-2 py-1 text-xs"
>
{iconPorts.map(entry => <option key={endpointKey(entry)} value={endpointKey(entry)}>
{endpointKey(entry)}
</option>)}
</select>}
<Button type="button" variant="outline" size="sm"
disabled={!selectedIconPort || discoveringLocalLogo || saving}
onClick={discoverLocalLogo}>
{discoveringLocalLogo ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Search className="h-3.5 w-3.5 mr-1" />}
{t("vmLxc.appEditor.discoverLocalLogo")}
</Button>
{draft.logo_url && LOCAL_LOGO_CACHE_PATH.test(draft.logo_url) &&
<ThemeAwareLogo src={draft.logo_url} className="h-8 w-8 object-contain" />}
</div>
<p className="text-[10px] text-muted-foreground mt-1">{t("vmLxc.appEditor.localLogoHelp")}</p>
</div>
+3 -1
View File
@@ -5227,7 +5227,9 @@ export function VirtualMachines() {
null
return (
<LxcAppPanel
key={`${selectedVM.vmid}-${selectedVM.modal_cache_revision ?? 0}`}
// Refresh server data without destroying an unsaved editor.
key={selectedVM.vmid}
cacheRevision={selectedVM.modal_cache_revision ?? 0}
vmid={selectedVM.vmid}
ctIp={ctIp}
onChange={() => mutate()}
@@ -55,6 +55,28 @@ async function editor(ports = [8080]) {
}
async function main() {
await cachedImageContract()
const existing = fixture({apps: [{id: 'existing', name: 'Saved app', ports: [{port: 8080, scheme: 'http'}], state: {}}]})
existing.renderParent(); await existing.settle()
const click = async key => {
const node = flatten(existing.tree()).find(n => ['button', 'Button'].includes(n.type) && text(n).includes('vmLxc.appEditor.' + key))
assert.ok(node, key); await node.props.onClick(); existing.render()
}
await click('editButton'); await click('editFieldsButton')
logo(existing).props.onChange({target: {value: 'https://example.test/draft.png'}}); existing.render()
existing.holdIcon()
const pendingExisting = existing.button('discoverLocalLogo').props.onClick(); existing.render()
const reads = existing.bundleReads()
existing.renderParent(101, 1); await existing.settle(); existing.render()
assert.equal(logo(existing)?.props.value, 'https://example.test/draft.png', 'lifecycle refresh must retain the open existing-app editor and unsaved fields')
assert.ok(existing.bundleReads() > reads, 'revision still reloads the saved app data')
existing.resolveIcon(); await pendingExisting; existing.render(); existing.render()
assert.match(logo(existing).props.value, /logo-cache/, 'successful lookup leaves existing editor open')
assert.equal(existing.calls.filter(c => ['POST', 'PUT', 'DELETE'].includes(c.method) && !c.url.endsWith('/logo-discovery')).length, 0)
await click('cancelButton')
await click('editFieldsButton')
assert.equal(logo(existing).props.value, '', 'cancel discards draft icon')
existing.renderParent(102, 1); await existing.settle(); existing.render()
assert.equal(logo(existing), undefined, 'switching guest must discard the old editor')
const f = await editor([8080, 8443])
assert.equal(f.calls.filter(c => c.url.endsWith('/logo-discovery')).length, 0)
const select = flatten(f.tree()).find(n => n.props?.['aria-label'] === 'vmLxc.appEditor.localLogoPort')
@@ -23,14 +23,15 @@ const candidate = {
evidence: [{kind: 'installation_file', value: '/opt/handmade/server'}], default_ports: [],
}
function fixture({nativeCandidate = candidate} = {}) {
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = []
function fixture({nativeCandidate = candidate, apps = []} = {}) {
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = [], memoStates = []
const calls = [], cachedWrites = []
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete', scanIssues
let cacheGate, resolveCache, cachePending = false
let iconGate, resolveIcon, iconResult = {logo_url: '/api/vms/101/apps/logo-cache/' + 'b'.repeat(32)}, iconError
const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}}
const seed = {sidecar: {vmid: 101, apps}, suggestions: {extras: [], port_suggestions: []}}
let bundleReads = 0
const react = {
useState(initial) {
const index = cursor++
@@ -38,7 +39,12 @@ function fixture({nativeCandidate = candidate} = {}) {
return [states[index], value => {states[index] = typeof value === 'function' ? value(states[index]) : value}]
},
useRef(value) {const index = cursor++; return refs[index] ||= {current: value}},
useMemo(fn) {cursor++; return fn()}, useCallback(fn) {cursor++; return fn},
useMemo(fn, deps) {
const index = cursor++, prior = memoStates[index]
if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) memoStates[index] = {deps, value: fn()}
return memoStates[index].value
},
useCallback(fn, deps) {return react.useMemo(() => fn, deps)},
useEffect(fn, deps) {
const index = cursor++, prior = effectStates[index]
if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) {
@@ -75,7 +81,7 @@ function fixture({nativeCandidate = candidate} = {}) {
return []
}
const cache = {
getLxcAppsCached: () => null, fetchLxcApps: async () => seed,
getLxcAppsCached: () => null, fetchLxcApps: async () => {bundleReads++; return seed},
subscribeLxcApps: () => () => {}, setLxcAppsCached: (...args) => cachedWrites.push(args),
}
const exports = {}
@@ -97,13 +103,34 @@ function fixture({nativeCandidate = candidate} = {}) {
for (const effect of effects) effect()
return tree
}
// Exercise React's key identity boundary using the actual parent expression.
const parent = ts.createSourceFile('parent.tsx', fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let keyExpression
function visit(node) {
if (ts.isJsxSelfClosingElement(node) && node.tagName.getText(parent) === 'LxcAppPanel') {
keyExpression = node.attributes.properties.find(p => p.name?.getText(parent) === 'key').initializer.expression.getText(parent)
}
ts.forEachChild(node, visit)
}
visit(parent)
const parentKey = new Function('selectedVM', 'return ' + keyExpression)
let lastKey
function renderParent(vmid = 101, revision = 0) {
const key = parentKey({vmid, modal_cache_revision: revision})
if (lastKey !== undefined && lastKey !== key) {
effectStates.forEach(effect => effect?.cleanup?.())
states = []; refs = []; effectStates = []; memoStates = []
}
lastKey = key
return render({vmid, cacheRevision: revision, initialData: seed})
}
const settle = async () => {for (let i = 0; i < 6; i++) await Promise.resolve(); render()}
const button = key => {
const match = flatten(tree).find(n => n.type === 'Button' && text(n).includes('vmLxc.appEditor.' + key))
assert.ok(match, 'Missing button ' + key)
return match
}
return {render, settle, calls, cachedWrites, button, tree: () => tree,
return {render, renderParent, settle, calls, cachedWrites, button, tree: () => tree, bundleReads: () => bundleReads,
holdIcon: () => {iconGate = new Promise(resolve => {resolveIcon = resolve})},
resolveIcon: () => resolveIcon(), setIconResult: value => {iconResult = value},
setIconError: value => {iconError = value},