Improve LXC updater selection, error handling and dashboard consistency

This commit is contained in:
MacRimi
2026-09-05 16:10:31 +02:00
parent 4f38d0e2e7
commit 6644b62f63
33 changed files with 5660 additions and 660 deletions
+82 -5
View File
@@ -17,6 +17,82 @@ export type LxcAppsBundle = {
const dataCache = new Map<number, LxcAppsBundle>()
const inFlight = new Map<number, Promise<LxcAppsBundle | null>>()
const cacheRevision = new Map<number, number>()
type AppWatchState = {
id: string
state_revision?: number
managed_oci_app_id?: string | null
checked_at?: string | null
[key: string]: any
}
const observedStates = new Map<number, AppWatchState[]>()
const listeners = new Map<number, Set<(bundle: LxcAppsBundle) => void>>()
const stateFields = [
"installed_version", "latest_version", "latest_published_at",
"update_available", "error", "checked_at",
] as const
function withObservedStates(vmid: number, sidecar: any): any {
if (!Array.isArray(sidecar?.apps)) return sidecar
const observations = new Map((observedStates.get(vmid) || []).map(item => [item.id, item]))
let changed = false
let revision = sidecar._revision || 0
const apps = sidecar.apps.map((app: any) => {
const observed = observations.get(app.id)
if (!observed || observed.managed_oci_app_id) return app
if (observed.state_revision && sidecar._revision) {
if (observed.state_revision < sidecar._revision) return app
} else if (!observed.checked_at || (app.state?.checked_at && observed.checked_at < app.state.checked_at)) {
return app
}
const state = { ...app.state }
let stateChanged = false
for (const field of stateFields) {
if (field in observed && observed[field] !== state[field]) {
state[field] = observed[field]
stateChanged = true
}
}
revision = Math.max(revision, observed.state_revision || 0)
if (!stateChanged) return app
changed = true
return { ...app, state }
})
return changed || revision !== (sidecar._revision || 0)
? { ...sidecar, apps, _revision: revision }
: sidecar
}
function publish(vmid: number, bundle: LxcAppsBundle): void {
dataCache.set(vmid, bundle)
listeners.get(vmid)?.forEach(listener => listener(bundle))
}
export function subscribeLxcApps(vmid: number, listener: (bundle: LxcAppsBundle) => void): () => void {
const subscribers = listeners.get(vmid) || new Set()
subscribers.add(listener)
listeners.set(vmid, subscribers)
const current = dataCache.get(vmid)
if (current) listener(current)
return () => {
subscribers.delete(listener)
if (!subscribers.size) listeners.delete(vmid)
}
}
// Reuse the existing VM-list feed; this never starts a detection or an HTTP request.
export function syncLxcAppsState(vmid: number, watches: AppWatchState[]): void {
const previous = observedStates.get(vmid) || []
const previousRevision = Math.max(0, ...previous.map(item => item.state_revision || 0))
const nextRevision = Math.max(0, ...watches.map(item => item.state_revision || 0))
if (nextRevision && nextRevision < previousRevision) return
observedStates.set(vmid, watches)
const current = dataCache.get(vmid)
if (!current) return
const sidecar = withObservedStates(vmid, current.sidecar)
if (sidecar === current.sidecar) return
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
publish(vmid, { ...current, sidecar })
}
export function getLxcAppsCached(vmid: number): LxcAppsBundle | undefined {
return dataCache.get(vmid)
@@ -34,12 +110,12 @@ export function setLxcAppsCached(
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
const current = dataCache.get(vmid)
const bundle: LxcAppsBundle = {
sidecar,
sidecar: withObservedStates(vmid, sidecar),
suggestions: suggestions === undefined
? (current?.suggestions ?? null)
: suggestions,
}
dataCache.set(vmid, bundle)
publish(vmid, bundle)
return bundle
}
@@ -53,13 +129,13 @@ export function fetchLxcApps(vmid: number): Promise<LxcAppsBundle | null> {
])
.then(([sc, sug]) => {
if (!sc) return null
const bundle: LxcAppsBundle = { sidecar: sc, suggestions: sug }
const bundle: LxcAppsBundle = { sidecar: withObservedStates(vmid, sc), suggestions: sug }
// A successful write may have completed while these GETs were in
// flight. Never let that older response overwrite the mutation result.
if ((cacheRevision.get(vmid) || 0) !== startedRevision) {
return dataCache.get(vmid) ?? null
}
dataCache.set(vmid, bundle)
publish(vmid, bundle)
return bundle
})
.finally(() => {
@@ -72,6 +148,7 @@ export function fetchLxcApps(vmid: number): Promise<LxcAppsBundle | null> {
export function invalidateLxcApps(vmid: number): void {
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
dataCache.delete(vmid)
observedStates.delete(vmid)
}
// Seed the cache from the bulk modal-cache endpoint. Both registered
@@ -85,5 +162,5 @@ export function seedLxcAppsCache(
if (!sidecar) return
const existing = dataCache.get(vmid)
if (existing) return // per-panel fetch already ran, don't overwrite
dataCache.set(vmid, { sidecar, suggestions: suggestions ?? null })
publish(vmid, { sidecar: withObservedStates(vmid, sidecar), suggestions: suggestions ?? null })
}