mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
fix(monitor): preserve app editor across lifecycle refreshes
This commit is contained in:
@@ -356,6 +356,7 @@ interface ManagedAppInfo {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vmid: number
|
vmid: number
|
||||||
|
cacheRevision?: number
|
||||||
ctIp?: string | null
|
ctIp?: string | null
|
||||||
onChange?: () => void
|
onChange?: () => void
|
||||||
managed?: ManagedAppInfo | null
|
managed?: ManagedAppInfo | null
|
||||||
@@ -497,7 +498,7 @@ function parseArgvInput(value: string): string[] {
|
|||||||
return value.split(",").map((item) => item.trim()).filter(Boolean)
|
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 t = useT()
|
||||||
const isLightTheme = useIsLightTheme()
|
const isLightTheme = useIsLightTheme()
|
||||||
// Seed from `initialData` first, then fall back to the shared cache
|
// 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
|
targetGeneration.current += 1
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generation = targetGeneration.current
|
|
||||||
const request = ++nativeRequest.current
|
|
||||||
setNativeDiscovery(null)
|
setNativeDiscovery(null)
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
setDetectionNotice(null)
|
setDetectionNotice(null)
|
||||||
setSearchingApplications(false)
|
setSearchingApplications(false)
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
|
return () => { targetGeneration.current += 1 }
|
||||||
|
}, [vmid, managed])
|
||||||
|
useEffect(() => {
|
||||||
|
const generation = targetGeneration.current
|
||||||
|
const request = ++nativeRequest.current
|
||||||
if (managed) return
|
if (managed) return
|
||||||
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`).then(result => {
|
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`).then(result => {
|
||||||
if (targetRef.current === vmid && targetGeneration.current === generation
|
if (targetRef.current === vmid && targetGeneration.current === generation
|
||||||
&& nativeRequest.current === request) setNativeDiscovery(result)
|
&& nativeRequest.current === request) setNativeDiscovery(result)
|
||||||
}).catch(() => { /* cached metadata is optional; never initiate a scan */ })
|
}).catch(() => { /* cached metadata is optional; never initiate a scan */ })
|
||||||
return () => { targetGeneration.current += 1 }
|
return () => { nativeRequest.current += 1 }
|
||||||
}, [vmid, managed])
|
}, [vmid, managed, cacheRevision])
|
||||||
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
|
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [searchingApplications, setSearchingApplications] = useState(false)
|
const [searchingApplications, setSearchingApplications] = useState(false)
|
||||||
@@ -638,7 +642,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
if (isCurrent()) setLoading(false)
|
if (isCurrent()) setLoading(false)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [vmid, managed])
|
}, [vmid, managed, cacheRevision])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (managed) return
|
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
|
from https://selfh.st/icons); empty → no logo in the
|
||||||
app card header. */}
|
app card header. */}
|
||||||
<div className="pt-2 border-t border-border/50">
|
<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">
|
<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>
|
{t("vmLxc.appEditor.logoUrlLabel")} <span className="normal-case tracking-normal text-[10px] opacity-70">{t("vmLxc.appEditor.optionalSuffix")}</span>
|
||||||
</Label>
|
</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
|
<Input
|
||||||
id="app-logo"
|
id="app-logo"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1696,9 +1709,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
onChange={(e) => setField({ logo_url: e.target.value })}
|
onChange={(e) => setField({ logo_url: e.target.value })}
|
||||||
placeholder={t("vmLxc.appEditor.portLogoPlaceholder")}
|
placeholder={t("vmLxc.appEditor.portLogoPlaceholder")}
|
||||||
maxLength={512}
|
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
|
{iconPorts.length > 1 && <select
|
||||||
aria-label={t("vmLxc.appEditor.localLogoPort")}
|
aria-label={t("vmLxc.appEditor.localLogoPort")}
|
||||||
value={selectedIconPort ? endpointKey(selectedIconPort) : ""}
|
value={selectedIconPort ? endpointKey(selectedIconPort) : ""}
|
||||||
@@ -1708,21 +1723,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
setDiscoveringLocalLogo(false)
|
setDiscoveringLocalLogo(false)
|
||||||
setIconEndpoint(event.target.value)
|
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)}>
|
{iconPorts.map(entry => <option key={endpointKey(entry)} value={endpointKey(entry)}>
|
||||||
{endpointKey(entry)}
|
{endpointKey(entry)}
|
||||||
</option>)}
|
</option>)}
|
||||||
</select>}
|
</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>
|
<p className="text-[10px] text-muted-foreground mt-1">{t("vmLxc.appEditor.localLogoHelp")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5227,7 +5227,9 @@ export function VirtualMachines() {
|
|||||||
null
|
null
|
||||||
return (
|
return (
|
||||||
<LxcAppPanel
|
<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}
|
vmid={selectedVM.vmid}
|
||||||
ctIp={ctIp}
|
ctIp={ctIp}
|
||||||
onChange={() => mutate()}
|
onChange={() => mutate()}
|
||||||
|
|||||||
@@ -55,6 +55,28 @@ async function editor(ports = [8080]) {
|
|||||||
}
|
}
|
||||||
async function main() {
|
async function main() {
|
||||||
await cachedImageContract()
|
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])
|
const f = await editor([8080, 8443])
|
||||||
assert.equal(f.calls.filter(c => c.url.endsWith('/logo-discovery')).length, 0)
|
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')
|
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: [],
|
evidence: [{kind: 'installation_file', value: '/opt/handmade/server'}], default_ports: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
function fixture({nativeCandidate = candidate} = {}) {
|
function fixture({nativeCandidate = candidate, apps = []} = {}) {
|
||||||
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = []
|
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = [], memoStates = []
|
||||||
const calls = [], cachedWrites = []
|
const calls = [], cachedWrites = []
|
||||||
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
|
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
|
||||||
let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete', scanIssues
|
let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete', scanIssues
|
||||||
let cacheGate, resolveCache, cachePending = false
|
let cacheGate, resolveCache, cachePending = false
|
||||||
let iconGate, resolveIcon, iconResult = {logo_url: '/api/vms/101/apps/logo-cache/' + 'b'.repeat(32)}, iconError
|
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 = {
|
const react = {
|
||||||
useState(initial) {
|
useState(initial) {
|
||||||
const index = cursor++
|
const index = cursor++
|
||||||
@@ -38,7 +39,12 @@ function fixture({nativeCandidate = candidate} = {}) {
|
|||||||
return [states[index], value => {states[index] = typeof value === 'function' ? value(states[index]) : value}]
|
return [states[index], value => {states[index] = typeof value === 'function' ? value(states[index]) : value}]
|
||||||
},
|
},
|
||||||
useRef(value) {const index = cursor++; return refs[index] ||= {current: 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) {
|
useEffect(fn, deps) {
|
||||||
const index = cursor++, prior = effectStates[index]
|
const index = cursor++, prior = effectStates[index]
|
||||||
if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) {
|
if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) {
|
||||||
@@ -75,7 +81,7 @@ function fixture({nativeCandidate = candidate} = {}) {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const cache = {
|
const cache = {
|
||||||
getLxcAppsCached: () => null, fetchLxcApps: async () => seed,
|
getLxcAppsCached: () => null, fetchLxcApps: async () => {bundleReads++; return seed},
|
||||||
subscribeLxcApps: () => () => {}, setLxcAppsCached: (...args) => cachedWrites.push(args),
|
subscribeLxcApps: () => () => {}, setLxcAppsCached: (...args) => cachedWrites.push(args),
|
||||||
}
|
}
|
||||||
const exports = {}
|
const exports = {}
|
||||||
@@ -97,13 +103,34 @@ function fixture({nativeCandidate = candidate} = {}) {
|
|||||||
for (const effect of effects) effect()
|
for (const effect of effects) effect()
|
||||||
return tree
|
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 settle = async () => {for (let i = 0; i < 6; i++) await Promise.resolve(); render()}
|
||||||
const button = key => {
|
const button = key => {
|
||||||
const match = flatten(tree).find(n => n.type === 'Button' && text(n).includes('vmLxc.appEditor.' + key))
|
const match = flatten(tree).find(n => n.type === 'Button' && text(n).includes('vmLxc.appEditor.' + key))
|
||||||
assert.ok(match, 'Missing button ' + key)
|
assert.ok(match, 'Missing button ' + key)
|
||||||
return match
|
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})},
|
holdIcon: () => {iconGate = new Promise(resolve => {resolveIcon = resolve})},
|
||||||
resolveIcon: () => resolveIcon(), setIconResult: value => {iconResult = value},
|
resolveIcon: () => resolveIcon(), setIconResult: value => {iconResult = value},
|
||||||
setIconError: value => {iconError = value},
|
setIconError: value => {iconError = value},
|
||||||
|
|||||||
Reference in New Issue
Block a user