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 ?? ''),
|
...recordRawQuery(dictionaryName, businessKey ?? ''),
|
||||||
enabled: Boolean(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 { useTranslation } from 'react-i18next'
|
||||||
import { LanguageSwitch } from '@nstart/ui'
|
import { LanguageSwitch } from '@nstart/ui'
|
||||||
import { AuthBadge } from '@/auth/AuthBadge'
|
import { AuthBadge } from '@/auth/AuthBadge'
|
||||||
|
import { UpdateBanner } from '@/components/version/UpdateBanner'
|
||||||
|
import { VersionBadge } from '@/components/version/VersionBadge'
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
@@ -17,6 +19,7 @@ function RootLayout() {
|
|||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full flex flex-col bg-white">
|
<div className="min-h-full flex flex-col bg-white">
|
||||||
|
<UpdateBanner />
|
||||||
<header className="border-b border-regolith bg-white">
|
<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">
|
<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">
|
<Link to="/" className="font-primary text-base sm:text-lg text-ultramarain">
|
||||||
@@ -74,6 +77,7 @@ function RootLayout() {
|
|||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<VersionBadge />
|
||||||
<LanguageSwitch
|
<LanguageSwitch
|
||||||
value={i18n.language}
|
value={i18n.language}
|
||||||
options={LANG_OPTIONS}
|
options={LANG_OPTIONS}
|
||||||
|
|||||||
Vendored
+9
@@ -8,3 +8,12 @@ interface ImportMetaEnv {
|
|||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
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
|
||||||
|
|||||||
@@ -4,8 +4,37 @@ import react from '@vitejs/plugin-react'
|
|||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { TanStackRouterVite } from '@tanstack/router-vite-plugin'
|
import { TanStackRouterVite } from '@tanstack/router-vite-plugin'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
// Build identity injected at compile time. Used by /update banner — client
|
||||||
|
// сравнивает свой commit с server's /api/v1/version и показывает "Доступна
|
||||||
|
// новая версия" если различаются.
|
||||||
|
//
|
||||||
|
// Fallbacks для CI / sandboxed builds где git unavailable: env var override,
|
||||||
|
// затем "unknown".
|
||||||
|
const APP_VERSION: string = JSON.parse(
|
||||||
|
readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'),
|
||||||
|
).version
|
||||||
|
const APP_COMMIT: string =
|
||||||
|
process.env.CI_COMMIT_SHORT_SHA ??
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
return execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
} catch {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const APP_BUILT_AT: string = new Date().toISOString()
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(APP_VERSION),
|
||||||
|
__APP_COMMIT__: JSON.stringify(APP_COMMIT),
|
||||||
|
__APP_BUILT_AT__: JSON.stringify(APP_BUILT_AT),
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./src/test/setup.ts'],
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
|||||||
@@ -109,9 +109,54 @@
|
|||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<!-- Resolves git.commit.id.abbrev / git.branch / git.tags из локального .git
|
||||||
|
directory. Properties доступны для других plugin'ов в той же phase'е.
|
||||||
|
CI без .git folder → плагин fallback'ит к "Unknown", это OK. -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>io.github.git-commit-id</groupId>
|
||||||
|
<artifactId>git-commit-id-maven-plugin</artifactId>
|
||||||
|
<version>9.0.1</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>git-info</id>
|
||||||
|
<goals><goal>revision</goal></goals>
|
||||||
|
<phase>validate</phase>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
<configuration>
|
||||||
|
<failOnNoGitDirectory>false</failOnNoGitDirectory>
|
||||||
|
<failOnUnableToExtractRepoInfo>false</failOnUnableToExtractRepoInfo>
|
||||||
|
<skipPoms>false</skipPoms>
|
||||||
|
<generateGitPropertiesFile>false</generateGitPropertiesFile>
|
||||||
|
<includeOnlyProperties>
|
||||||
|
<includeOnlyProperty>^git.commit.id.abbrev$</includeOnlyProperty>
|
||||||
|
<includeOnlyProperty>^git.branch$</includeOnlyProperty>
|
||||||
|
<includeOnlyProperty>^git.tags$</includeOnlyProperty>
|
||||||
|
<includeOnlyProperty>^git.commit.time$</includeOnlyProperty>
|
||||||
|
</includeOnlyProperties>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<!-- Генерирует META-INF/build-info.properties → Spring Boot создаёт
|
||||||
|
BuildProperties bean → VersionController экспонирует /api/v1/version
|
||||||
|
для UI update banner. См. ordinis-rest-api/.../web/VersionController.
|
||||||
|
git.* properties resolved выше через git-commit-id-maven-plugin. -->
|
||||||
|
<execution>
|
||||||
|
<id>build-info</id>
|
||||||
|
<goals><goal>build-info</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<additionalProperties>
|
||||||
|
<commit>${git.commit.id.abbrev}</commit>
|
||||||
|
<branch>${git.branch}</branch>
|
||||||
|
<tag>${git.tags}</tag>
|
||||||
|
<gitTime>${git.commit.time}</gitTime>
|
||||||
|
</additionalProperties>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
|||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.web;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code GET /api/v1/version} — экспонирует server build identity для UI
|
||||||
|
* update-detection banner.
|
||||||
|
*
|
||||||
|
* <p>Frontend (admin-ui) bundle'ит свой commit SHA в build-time через Vite
|
||||||
|
* {@code define.__APP_COMMIT__}. Periodic poll этого endpoint'а сравнивает
|
||||||
|
* server commit vs client commit. Mismatch → "Доступна новая версия" banner с
|
||||||
|
* CTA {@code Обновить} (window.location.reload).
|
||||||
|
*
|
||||||
|
* <p>Source данных:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code BuildProperties} bean создаётся Spring Boot когда есть
|
||||||
|
* {@code META-INF/build-info.properties} (генерируется
|
||||||
|
* spring-boot-maven-plugin {@code build-info} goal в ordinis-app pom).</li>
|
||||||
|
* <li>Дополнительные {@code commit}, {@code branch}, {@code tag}
|
||||||
|
* resolved из CI env vars (см. additionalProperties в plugin config).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Если build-info.properties отсутствует (e.g., запуск в IDE без mvn package)
|
||||||
|
* — возвращает sensible defaults чтобы UI не падал.
|
||||||
|
*
|
||||||
|
* <p>Public endpoint — auth НЕ required (нужен на login screen чтобы клиент
|
||||||
|
* сразу знал stale ли его bundle).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/version")
|
||||||
|
public class VersionController {
|
||||||
|
|
||||||
|
private final Optional<BuildProperties> buildProps;
|
||||||
|
|
||||||
|
// BuildProperties может отсутствовать в dev — оптимально через Optional.
|
||||||
|
public VersionController(@Autowired(required = false) BuildProperties buildProps) {
|
||||||
|
this.buildProps = Optional.ofNullable(buildProps);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public VersionInfo current() {
|
||||||
|
return buildProps.map(b -> new VersionInfo(
|
||||||
|
b.getVersion(),
|
||||||
|
Optional.ofNullable(b.get("commit")).orElse("unknown"),
|
||||||
|
Optional.ofNullable(b.get("branch")).orElse(""),
|
||||||
|
Optional.ofNullable(b.get("tag")).orElse(""),
|
||||||
|
b.getTime() != null ? b.getTime().toString() : ""
|
||||||
|
)).orElseGet(() -> new VersionInfo(
|
||||||
|
"0.0.0-dev",
|
||||||
|
"unknown",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
Instant.now().toString()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable JSON shape для UI client.
|
||||||
|
*
|
||||||
|
* @param version Maven {@code project.version} (e.g. "0.1.0-SNAPSHOT" или "1.1.0")
|
||||||
|
* @param commit short git SHA (e.g. "07c5ca0") — primary update detection key
|
||||||
|
* @param branch git branch (e.g. "main"), empty в tag builds
|
||||||
|
* @param tag git tag if applicable (e.g. "v1.1.0"), empty в branch builds
|
||||||
|
* @param builtAt ISO-8601 instant
|
||||||
|
*/
|
||||||
|
public record VersionInfo(
|
||||||
|
String version,
|
||||||
|
String commit,
|
||||||
|
String branch,
|
||||||
|
String tag,
|
||||||
|
String builtAt
|
||||||
|
) {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user