feat(boardwalk): expose the admin build id and show a reload banner when a new build is deployed (quick-261028-rqb)
- boardwalk.BuildID hashes the dist tree plus the binary's build info once
per handler; X-Summer-Build on every response, {prefix}/build.json and the
summer-admin-build meta carry it
- the SPA reads the booted build, re-checks on focus, visibility and every
5 minutes, and shows UpdateBanner on a different build
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<meta name="summer-admin-build" content="__SUMMER_ADMIN_BUILD__" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
||||
<title>SummerCMS</title>
|
||||
</head>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, watchEffect } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { documentTitleForRoute } from './app/pageTitle'
|
||||
import AppShell from './components/shell/AppShell.vue'
|
||||
import UpdateBanner from './components/ui/UpdateBanner.vue'
|
||||
import { remountRevision } from './state/useRemount'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -17,11 +18,13 @@ watchEffect(() => {
|
||||
<!-- Keyed by path, not full path: a new controller or record remounts its
|
||||
view, while list query changes (search, sort, page) keep it. The
|
||||
remount revision is part of the key, so a view can remount itself in
|
||||
place (Create and new). -->
|
||||
place (Create and new). The new-version banner sits outside the
|
||||
v-if, so it also shows on the login screen. -->
|
||||
<AppShell v-if="inShell">
|
||||
<RouterView v-slot="{ Component, route: current }">
|
||||
<component :is="Component" :key="`${current.path}#${remountRevision}`" />
|
||||
</RouterView>
|
||||
</AppShell>
|
||||
<RouterView v-else />
|
||||
<UpdateBanner />
|
||||
</template>
|
||||
|
||||
142
admin/src/app/buildUpdate.ts
Normal file
142
admin/src/app/buildUpdate.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// New-version check. An admin tab opened before a deploy keeps running the
|
||||
// old bundle against the new backend, sometimes for days, because the session
|
||||
// refreshes silently; a plugin field type the old bundle does not know then
|
||||
// shows as "Unsupported field type". The server (boardwalk) writes the build
|
||||
// id into the served index and answers it at {base}/build.json. The tab
|
||||
// remembers the build it booted with (runtime.build), re-checks on focus, on
|
||||
// visibility and on an interval, and on a different build shows a
|
||||
// non-blocking banner (UpdateBanner). It never reloads by itself, so unsaved
|
||||
// edits stay where they are.
|
||||
import { readonly, ref } from 'vue'
|
||||
import { runtime } from './runtime'
|
||||
|
||||
/** Re-check interval while the tab is visible: 5 minutes. */
|
||||
export const BUILD_CHECK_INTERVAL = 5 * 60 * 1000
|
||||
/** Minimum gap between two checks: 30 seconds. */
|
||||
export const BUILD_CHECK_MIN_GAP = 30 * 1000
|
||||
|
||||
/**
|
||||
* Whether seen is a different build than booted. Ids are not ordered: any
|
||||
* difference is a different deploy, a rollback included. An empty id on
|
||||
* either side is never a new build.
|
||||
*/
|
||||
export function isNewBuild(booted: string, seen: string): boolean {
|
||||
return booted !== '' && seen !== '' && booted !== seen
|
||||
}
|
||||
|
||||
/**
|
||||
* The current build from {base}/build.json, or "" when the request fails,
|
||||
* the response is not ok or the body has no string build. It never throws.
|
||||
*/
|
||||
export async function fetchBuild(): Promise<string> {
|
||||
try {
|
||||
// Resolve fetch per call so tests can replace globalThis.fetch.
|
||||
const response = await globalThis.fetch(`${runtime.base}/build.json`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (!response.ok) {
|
||||
return ''
|
||||
}
|
||||
const body: unknown = await response.json()
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return ''
|
||||
}
|
||||
const build = (body as { build?: unknown }).build
|
||||
return typeof build === 'string' ? build : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const available = ref(false)
|
||||
let latest = ''
|
||||
let dismissed = ''
|
||||
|
||||
/** True while a build different from the booted one waits for a reload. */
|
||||
export const newBuildAvailable = readonly(available)
|
||||
|
||||
/**
|
||||
* Records a build seen on the server. A build different from the booted one
|
||||
* shows the banner, unless it is the build the administrator dismissed.
|
||||
*/
|
||||
export function noteBuild(seen: string, booted: string = runtime.build): void {
|
||||
if (isNewBuild(booted, seen) && seen !== dismissed) {
|
||||
latest = seen
|
||||
available.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Hides the banner until a build different from the current one is seen. */
|
||||
export function dismissNewBuild(): void {
|
||||
dismissed = latest
|
||||
available.value = false
|
||||
}
|
||||
|
||||
/** Clears the module state; for tests. */
|
||||
export function resetBuildUpdate(): void {
|
||||
latest = ''
|
||||
dismissed = ''
|
||||
available.value = false
|
||||
}
|
||||
|
||||
export interface BuildWatchOptions {
|
||||
/** The build the tab booted with; default runtime.build. */
|
||||
booted?: string
|
||||
/** Reads the current build; default fetchBuild. */
|
||||
check?: () => Promise<string>
|
||||
/** Interval between checks while visible; default BUILD_CHECK_INTERVAL. */
|
||||
interval?: number
|
||||
/** Minimum gap between two checks; default BUILD_CHECK_MIN_GAP. */
|
||||
minGap?: number
|
||||
win?: Pick<Window, 'addEventListener' | 'removeEventListener' | 'setInterval' | 'clearInterval'>
|
||||
doc?: Pick<Document, 'addEventListener' | 'removeEventListener' | 'visibilityState'>
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the new-version check and returns its stop function. With no booted
|
||||
* build (the served index carries none, as under the dev server) it installs
|
||||
* nothing. A check runs on visibilitychange to visible, on window focus and
|
||||
* on the interval, skipped while the tab is hidden, while a check is in
|
||||
* flight or within minGap of the last check.
|
||||
*/
|
||||
export function startBuildWatch(options: BuildWatchOptions = {}): () => void {
|
||||
const booted = options.booted ?? runtime.build
|
||||
if (booted === '') {
|
||||
return () => undefined
|
||||
}
|
||||
const check = options.check ?? fetchBuild
|
||||
const interval = options.interval ?? BUILD_CHECK_INTERVAL
|
||||
const minGap = options.minGap ?? BUILD_CHECK_MIN_GAP
|
||||
const win = options.win ?? window
|
||||
const doc = options.doc ?? document
|
||||
const now = options.now ?? Date.now
|
||||
let inFlight = false
|
||||
let lastStart = Number.NEGATIVE_INFINITY
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
if (doc.visibilityState === 'hidden' || inFlight || now() - lastStart < minGap) {
|
||||
return
|
||||
}
|
||||
lastStart = now()
|
||||
inFlight = true
|
||||
try {
|
||||
noteBuild(await check(), booted)
|
||||
} finally {
|
||||
inFlight = false
|
||||
}
|
||||
}
|
||||
const trigger = (): void => {
|
||||
run().catch(() => undefined)
|
||||
}
|
||||
|
||||
doc.addEventListener('visibilitychange', trigger)
|
||||
win.addEventListener('focus', trigger)
|
||||
const timer = win.setInterval(trigger, interval)
|
||||
return () => {
|
||||
doc.removeEventListener('visibilitychange', trigger)
|
||||
win.removeEventListener('focus', trigger)
|
||||
win.clearInterval(timer)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
// Runtime configuration read once from the served index.html. The Go server
|
||||
// (boardwalk) writes the configured backend.uri into the summer-admin-base
|
||||
// meta; the SPA derives its router base and API base from it (D-02, D-03).
|
||||
// It also writes the build id into the summer-admin-build meta, the build the
|
||||
// tab booted with (buildUpdate.ts compares it with build.json).
|
||||
export const DEFAULT_BASE = '/backend'
|
||||
const TOKEN = '__SUMMER_ADMIN_BASE__'
|
||||
const BUILD_TOKEN = '__SUMMER_ADMIN_BUILD__'
|
||||
const BUILD_SHAPE = /^[0-9a-f]{6,64}$/
|
||||
|
||||
export function readBase(doc: Document = document): string {
|
||||
const content = doc.querySelector<HTMLMetaElement>('meta[name="summer-admin-base"]')?.content.trim() ?? ''
|
||||
@@ -13,6 +17,18 @@ export function readBase(doc: Document = document): string {
|
||||
return trimmed === '' ? DEFAULT_BASE : trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* The build id from the summer-admin-build meta, or "" when the meta is
|
||||
* missing, empty, the unreplaced token (the dev server) or not lowercase hex.
|
||||
*/
|
||||
export function readBuild(doc: Document = document): string {
|
||||
const content = doc.querySelector<HTMLMetaElement>('meta[name="summer-admin-build"]')?.content.trim() ?? ''
|
||||
if (content === BUILD_TOKEN || !BUILD_SHAPE.test(content)) {
|
||||
return ''
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
const base = readBase()
|
||||
|
||||
export const runtime = {
|
||||
@@ -20,4 +36,6 @@ export const runtime = {
|
||||
base,
|
||||
/** Admin API root, the mount path plus /api/v1. */
|
||||
api: `${base}/api/v1`,
|
||||
/** The build id this tab booted with; "" turns the version check off. */
|
||||
build: readBuild(),
|
||||
} as const
|
||||
|
||||
41
admin/src/components/ui/UpdateBanner.vue
Normal file
41
admin/src/components/ui/UpdateBanner.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from '@lucide/vue'
|
||||
import { dismissNewBuild, newBuildAvailable } from '../../app/buildUpdate'
|
||||
import { t } from '../../app/i18n'
|
||||
import { page } from '../../state/usePreferences'
|
||||
|
||||
// New-version banner (buildUpdate.ts): shown while the server answers a build
|
||||
// different from the one this tab booted with. It never reloads by itself;
|
||||
// Reload goes through page.reload, so a dirty form's beforeunload prompt
|
||||
// still asks, and Close hides it until another build is deployed.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Bottom right, 88px up so it clears the sticky form footer, like the toasts. -->
|
||||
<div
|
||||
v-if="newBuildAvailable"
|
||||
data-update-banner
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed right-4 bottom-[88px] z-50 flex max-w-[420px] items-center gap-3 rounded-[14px] bg-[#1d2740] py-3 pr-2.5 pl-3.5 text-white shadow-toast"
|
||||
>
|
||||
<span class="font-medium">{{ t('backend::lang.version.available') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-update-reload
|
||||
class="shrink-0 rounded-pager bg-accent px-3 py-1.5 font-medium text-on-accent transition-opacity duration-150 ease-out hover:opacity-90"
|
||||
@click="page.reload()"
|
||||
>
|
||||
{{ t('backend::lang.version.reload') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-update-close
|
||||
:aria-label="t('backend::lang.form.close')"
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-pager text-[#c3cbda] transition-colors duration-150 ease-out hover:bg-side-hover hover:text-white"
|
||||
@click="dismissNewBuild()"
|
||||
>
|
||||
<X :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { createAdminRouter } from './app/router'
|
||||
import { onUnauthorized } from './api/client'
|
||||
import { startBuildWatch } from './app/buildUpdate'
|
||||
import { loadFieldTypes } from './app/fieldTypes'
|
||||
import { loadStrings } from './app/i18n'
|
||||
import { expireSession, me } from './state/useAuth'
|
||||
@@ -37,6 +38,9 @@ async function boot(): Promise<void> {
|
||||
app.use(router)
|
||||
await router.isReady()
|
||||
app.mount('#app')
|
||||
// The new-version check; it stays off when the served index carries no
|
||||
// build (the dev server).
|
||||
startBuildWatch()
|
||||
}
|
||||
|
||||
void boot()
|
||||
|
||||
Reference in New Issue
Block a user