feat: version banner UI + GET /api/v1/version backend endpoint
This commit is contained in:
@@ -507,3 +507,30 @@ export const useRecordRaw = (
|
||||
...recordRawQuery(dictionaryName, businessKey ?? ''),
|
||||
enabled: Boolean(businessKey),
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// App version + update detection
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ServerVersion = {
|
||||
version: string
|
||||
commit: string
|
||||
branch: string
|
||||
tag: string
|
||||
builtAt: string
|
||||
}
|
||||
|
||||
export const serverVersionQuery = queryOptions({
|
||||
queryKey: ['version'] as const,
|
||||
queryFn: async (): Promise<ServerVersion> => {
|
||||
const { data } = await apiClient.get<ServerVersion>('/version')
|
||||
return data
|
||||
},
|
||||
// Poll каждые 5 минут — обновление detection без excessive load.
|
||||
// Stale-while-revalidate: даже если нет focus, перезапрашиваем по таймеру.
|
||||
refetchInterval: 5 * 60 * 1000,
|
||||
refetchIntervalInBackground: true,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
|
||||
export const useServerVersion = () => useQuery(serverVersionQuery)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@nstart/ui'
|
||||
import { ArrowClockwise, BookOpen, X } from '@phosphor-icons/react'
|
||||
import { useState } from 'react'
|
||||
import { useAppVersion } from './useAppVersion'
|
||||
|
||||
const DOCS_URL = '/docs/' // ordinis-docs deployment, mirrors prod /docs path
|
||||
|
||||
/**
|
||||
* Banner — показывается top-of-page когда client bundle отстаёт от server.
|
||||
*
|
||||
* <p>Pattern такой же как Altum VS / Geoportal: persistent strip, contextual
|
||||
* CTAs, dismissable (но re-appears после next refetch если still mismatched).
|
||||
*
|
||||
* <p>Two CTAs:
|
||||
* <ul>
|
||||
* <li><b>Обновить</b> — {@code window.location.reload()}. Browser fetches new
|
||||
* bundle (cache-busting через Vite hashed filenames).</li>
|
||||
* <li><b>Что нового →</b> — opens user руководство (changelog / release notes).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Dismiss button (×) — temporarily скрывает banner до следующего
|
||||
* {@code useServerVersion} refetch (5 min). Не persists в localStorage —
|
||||
* критичный update должен progressively становиться более insistent.
|
||||
*/
|
||||
export function UpdateBanner() {
|
||||
const { t } = useTranslation()
|
||||
const { updateAvailable, serverVersion, serverCommit, serverTag } = useAppVersion()
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
if (!updateAvailable || dismissed) return null
|
||||
|
||||
const versionLabel = serverTag || serverVersion || serverCommit?.substring(0, 7) || ''
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="bg-ultramarain text-white"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-2 flex items-center gap-3 flex-wrap text-sm">
|
||||
<ArrowClockwise size={18} weight="bold" className="shrink-0" aria-hidden />
|
||||
<span className="flex-1 min-w-0">
|
||||
{t('updateBanner.message', { version: versionLabel })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => window.open(DOCS_URL, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
<BookOpen size={16} weight="regular" className="mr-1" aria-hidden />
|
||||
{t('updateBanner.docs')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
{t('updateBanner.reload')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('updateBanner.dismiss') as string}
|
||||
onClick={() => setDismissed(true)}
|
||||
className="ml-1 p-1 rounded hover:bg-white/10"
|
||||
>
|
||||
<X size={16} weight="bold" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useAppVersion } from './useAppVersion'
|
||||
|
||||
/**
|
||||
* Subtle version indicator в header. Показывает client version + short commit.
|
||||
*
|
||||
* <p>Hover/title attribute exposes полную идентификацию (server tag, build time).
|
||||
* Используется как secondary affordance для эксплуатационной диагностики
|
||||
* (юзер скриншотит badge в bug report → ops знает точную сборку).
|
||||
*
|
||||
* <p>Если update available — badge подсвечивается dot indicator. Banner делает
|
||||
* primary CTA, badge — passive signal.
|
||||
*/
|
||||
export function VersionBadge() {
|
||||
const {
|
||||
clientVersion,
|
||||
clientCommit,
|
||||
clientBuiltAt,
|
||||
serverVersion,
|
||||
serverCommit,
|
||||
serverTag,
|
||||
updateAvailable,
|
||||
} = useAppVersion()
|
||||
|
||||
const tooltip = [
|
||||
`client: ${clientVersion} (${clientCommit})`,
|
||||
`server: ${serverTag || serverVersion} (${serverCommit ?? '...'})`,
|
||||
`built: ${clientBuiltAt}`,
|
||||
].join('\n')
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-xs text-carbon/60 font-mono select-none"
|
||||
title={tooltip}
|
||||
>
|
||||
{updateAvailable && (
|
||||
<span
|
||||
aria-label="Доступна новая версия"
|
||||
className="inline-block w-1.5 h-1.5 rounded-full bg-ultramarain animate-pulse"
|
||||
/>
|
||||
)}
|
||||
<span>v{clientVersion}</span>
|
||||
<span className="text-carbon/40">·</span>
|
||||
<span>{clientCommit.substring(0, 7)}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useServerVersion } from '@/api/queries'
|
||||
|
||||
/**
|
||||
* App version + update detection hook.
|
||||
*
|
||||
* <p>Returns:
|
||||
* <ul>
|
||||
* <li>{@code clientVersion}, {@code clientCommit}, {@code clientBuiltAt} —
|
||||
* built-time injected via Vite {@code define} (см. vite.config.ts).</li>
|
||||
* <li>{@code serverVersion} — polled из {@code /api/v1/version} каждые 5 мин.</li>
|
||||
* <li>{@code updateAvailable} — true когда client.commit ≠ server.commit AND
|
||||
* обе stamps known (не "unknown" и не undefined).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Update detection conservative — игнорирует "unknown" commit (dev mode без
|
||||
* git OR before-build-info), чтобы не показывать banner ложно.
|
||||
*/
|
||||
export function useAppVersion() {
|
||||
const { data: server, isLoading } = useServerVersion()
|
||||
|
||||
const clientCommit = __APP_COMMIT__
|
||||
const clientVersion = __APP_VERSION__
|
||||
const clientBuiltAt = __APP_BUILT_AT__
|
||||
|
||||
const knownClient = clientCommit !== 'unknown' && clientCommit !== ''
|
||||
const knownServer = Boolean(server?.commit) && server!.commit !== 'unknown'
|
||||
const updateAvailable =
|
||||
knownClient && knownServer && clientCommit !== server!.commit
|
||||
|
||||
return {
|
||||
clientVersion,
|
||||
clientCommit,
|
||||
clientBuiltAt,
|
||||
serverVersion: server?.version,
|
||||
serverCommit: server?.commit,
|
||||
serverTag: server?.tag,
|
||||
updateAvailable,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import type { QueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LanguageSwitch } from '@nstart/ui'
|
||||
import { AuthBadge } from '@/auth/AuthBadge'
|
||||
import { UpdateBanner } from '@/components/version/UpdateBanner'
|
||||
import { VersionBadge } from '@/components/version/VersionBadge'
|
||||
|
||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||
component: RootLayout,
|
||||
@@ -17,6 +19,7 @@ function RootLayout() {
|
||||
const { t, i18n } = useTranslation()
|
||||
return (
|
||||
<div className="min-h-full flex flex-col bg-white">
|
||||
<UpdateBanner />
|
||||
<header className="border-b border-regolith bg-white">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-3 sm:py-4 flex items-center flex-wrap gap-3 sm:gap-6">
|
||||
<Link to="/" className="font-primary text-base sm:text-lg text-ultramarain">
|
||||
@@ -74,6 +77,7 @@ function RootLayout() {
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<VersionBadge />
|
||||
<LanguageSwitch
|
||||
value={i18n.language}
|
||||
options={LANG_OPTIONS}
|
||||
|
||||
Vendored
+9
@@ -8,3 +8,12 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
/**
|
||||
* Build identity injected via Vite {@code define} (см. vite.config.ts).
|
||||
* Используются {@code useAppVersion} hook + {@code UpdateBanner} component
|
||||
* для server-vs-client comparison.
|
||||
*/
|
||||
declare const __APP_VERSION__: string
|
||||
declare const __APP_COMMIT__: string
|
||||
declare const __APP_BUILT_AT__: string
|
||||
|
||||
Reference in New Issue
Block a user