diff --git a/ui/src/views/version/swRegistration.ts b/ui/src/views/version/swRegistration.ts index 00f78ba..39e7541 100644 --- a/ui/src/views/version/swRegistration.ts +++ b/ui/src/views/version/swRegistration.ts @@ -1,7 +1,8 @@ import { registerSW } from 'virtual:pwa-register' +import { store } from '@/store' const ACTIVATION_RETRY_DELAY = 5_000 -const ACTIVATION_TIMEOUT = 30_000 +const ACTIVATION_TIMEOUT = 20_000 let registrationStarted = false let activationInProgress = false @@ -11,10 +12,104 @@ let activateUpdateHandler: ((reloadPage?: boolean) => Promise) | undefined let registrationInitialization: Promise | undefined let activationRetryTimer: number | undefined let activationTimeoutTimer: number | undefined +let overlayStoreUnsubscribe: (() => void) | undefined const watchedInstallingWorkers = new WeakSet() export type ManualUpdateResult = 'up-to-date' | 'updating' | 'unsupported' +type Release = { + version: string + changeLog?: string[] +} + +const getText = (value: unknown) => + value === undefined || value === null ? undefined : String(value) + +function updateOverlayApiVersion(overlay: HTMLElement) { + const apiConfig = store.getState().abpConfig.config?.extraProperties + const apiEnvironment = getText(apiConfig?.environment) + const apiVersion = getText(apiConfig?.version) + + if (!apiEnvironment && !apiVersion) return false + + const apiValue = overlay.querySelector('[data-sw-api-version]') + if (apiValue) { + apiValue.textContent = `${apiEnvironment ?? '-'}:${apiVersion ?? '-'}` + } + + return true +} + +function watchOverlayApiVersion(overlay: HTMLElement) { + overlayStoreUnsubscribe?.() + + if (updateOverlayApiVersion(overlay)) return + + overlayStoreUnsubscribe = store.subscribe(() => { + if (!overlay.isConnected || updateOverlayApiVersion(overlay)) { + overlayStoreUnsubscribe?.() + overlayStoreUnsubscribe = undefined + } + }) +} + +async function loadUpdateOverlayDetails(overlay: HTMLElement) { + const state = store.getState() + const uiEnvironment = import.meta.env.MODE + + let uiVersion = state.locale.currentUiVersion + let latestRelease: Release | undefined + + try { + const response = await fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store' }) + if (!response.ok) throw new Error(`Version request failed with ${response.status}`) + + const releases = ((await response.json()) as { releases?: Release[] }).releases + latestRelease = releases?.[0] + uiVersion = latestRelease?.version ?? uiVersion + } catch (error) { + console.warn('Update details could not be loaded.', error) + } + + // The overlay may have been removed while version.json was loading. + if (!overlay.isConnected) return + + const uiValue = overlay.querySelector('[data-sw-ui-version]') + const releaseTitle = overlay.querySelector('[data-sw-release-title]') + const releaseList = overlay.querySelector('[data-sw-release-list]') + + if (uiValue) uiValue.textContent = `${uiEnvironment}:${uiVersion ?? '-'}` + watchOverlayApiVersion(overlay) + + if (!releaseList || !releaseTitle) return + + const changes = latestRelease?.changeLog?.filter(Boolean) ?? [] + releaseTitle.textContent = latestRelease?.version + ? `What's new in v${latestRelease.version}` + : "What's new" + releaseList.replaceChildren() + + if (changes.length === 0) { + const item = document.createElement('li') + item.className = 'sw-update-empty' + item.textContent = 'Release notes are not available.' + releaseList.appendChild(item) + return + } + + changes.forEach((change) => { + const item = document.createElement('li') + const icon = document.createElement('span') + const text = document.createElement('span') + icon.className = 'sw-update-check' + icon.setAttribute('aria-hidden', 'true') + icon.textContent = '\u2713' + text.textContent = change.replace(/^\s*-\s*/, '') + item.append(icon, text) + releaseList.appendChild(item) + }) +} + function showUpdateOverlay() { if (document.getElementById('sw-update-overlay')) return @@ -36,13 +131,14 @@ function showUpdateOverlay() { display: flex; flex-direction: column; align-items: center; - gap: 20px; + gap: 16px; background: #fff; border-radius: 16px; - padding: 40px 56px; + padding: 32px 40px; box-shadow: 0 8px 40px rgba(0,0,0,0.25); text-align: center; - max-width: 360px; + max-width: 560px; + max-height: min(90vh, 720px); width: 90%; } #sw-update-overlay .sw-update-spinner { @@ -67,22 +163,109 @@ function showUpdateOverlay() { color: #6b7280; margin: 0; } + #sw-update-overlay .sw-update-versions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + padding: 10px 12px; + border-radius: 8px; + background: #f3f4f6; + color: #4b5563; + font-size: 13px; + text-transform: capitalize; + } + #sw-update-overlay .sw-update-separator { + color: #9ca3af; + margin: 0 4px; + } + #sw-update-overlay .sw-update-release { + width: 100%; + min-height: 0; + overflow-y: auto; + text-align: left; + border-top: 1px solid #e5e7eb; + padding-top: 14px; + } + #sw-update-overlay .sw-update-release-title { + margin: 0 0 10px; + color: #374151; + font-size: 14px; + font-weight: 700; + } + #sw-update-overlay .sw-update-list { + display: flex; + flex-direction: column; + gap: 8px; + list-style: none; + padding: 0; + margin: 0; + color: #6b7280; + font-size: 13px; + line-height: 1.45; + } + #sw-update-overlay .sw-update-list li { + display: flex; + align-items: flex-start; + gap: 8px; + } + #sw-update-overlay .sw-update-check { + display: inline-flex; + flex: 0 0 16px; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-top: 1px; + border-radius: 50%; + background: #d1fae5; + color: #059669; + font-size: 11px; + font-weight: 700; + } + #sw-update-overlay .sw-update-empty { + display: block; + color: #9ca3af; + } + @media (max-width: 640px) { + #sw-update-overlay .sw-update-card { + padding: 24px 20px; + } + } ` document.head.appendChild(style) const overlay = document.createElement('div') overlay.id = 'sw-update-overlay' + overlay.setAttribute('role', 'status') + overlay.setAttribute('aria-live', 'polite') overlay.innerHTML = `
-

System Updating

+

System Updating

Loading new version, please wait...
The page will reload automatically.

+
+ UI: Loading... + | + API: Loading... +
+
+

What's new

+
    +
  • Loading release notes...
  • +
+
` document.body.appendChild(overlay) + void loadUpdateOverlayDetails(overlay) } function hideUpdateOverlay() { + overlayStoreUnsubscribe?.() + overlayStoreUnsubscribe = undefined document.getElementById('sw-update-overlay')?.remove() document.getElementById('sw-update-overlay-style')?.remove() }