From e8f98230f2f35c3cef184507464b501fb20e93b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Sun, 10 May 2026 16:50:50 +0000 Subject: [PATCH] feat: version banner UI + GET /api/v1/version backend endpoint --- ordinis-admin-ui/src/api/queries.ts | 27 +++++++ .../src/components/version/UpdateBanner.tsx | 74 +++++++++++++++++ .../src/components/version/VersionBadge.tsx | 46 +++++++++++ .../src/components/version/useAppVersion.ts | 40 +++++++++ ordinis-admin-ui/src/routes/__root.tsx | 4 + ordinis-admin-ui/src/vite-env.d.ts | 9 +++ ordinis-admin-ui/vite.config.ts | 29 +++++++ ordinis-app/pom.xml | 45 +++++++++++ .../restapi/web/VersionController.java | 81 +++++++++++++++++++ 9 files changed, 355 insertions(+) create mode 100644 ordinis-admin-ui/src/components/version/UpdateBanner.tsx create mode 100644 ordinis-admin-ui/src/components/version/VersionBadge.tsx create mode 100644 ordinis-admin-ui/src/components/version/useAppVersion.ts create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/VersionController.java diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 101928f..9a6ce4e 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -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 => { + const { data } = await apiClient.get('/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) diff --git a/ordinis-admin-ui/src/components/version/UpdateBanner.tsx b/ordinis-admin-ui/src/components/version/UpdateBanner.tsx new file mode 100644 index 0000000..2fbceab --- /dev/null +++ b/ordinis-admin-ui/src/components/version/UpdateBanner.tsx @@ -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. + * + *

Pattern такой же как Altum VS / Geoportal: persistent strip, contextual + * CTAs, dismissable (но re-appears после next refetch если still mismatched). + * + *

Two CTAs: + *

    + *
  • Обновить — {@code window.location.reload()}. Browser fetches new + * bundle (cache-busting через Vite hashed filenames).
  • + *
  • Что нового → — opens user руководство (changelog / release notes).
  • + *
+ * + *

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 ( +

+
+ + + {t('updateBanner.message', { version: versionLabel })} + +
+ + + +
+
+
+ ) +} diff --git a/ordinis-admin-ui/src/components/version/VersionBadge.tsx b/ordinis-admin-ui/src/components/version/VersionBadge.tsx new file mode 100644 index 0000000..73335a8 --- /dev/null +++ b/ordinis-admin-ui/src/components/version/VersionBadge.tsx @@ -0,0 +1,46 @@ +import { useAppVersion } from './useAppVersion' + +/** + * Subtle version indicator в header. Показывает client version + short commit. + * + *

Hover/title attribute exposes полную идентификацию (server tag, build time). + * Используется как secondary affordance для эксплуатационной диагностики + * (юзер скриншотит badge в bug report → ops знает точную сборку). + * + *

Если 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 ( + + {updateAvailable && ( + + )} + v{clientVersion} + · + {clientCommit.substring(0, 7)} + + ) +} diff --git a/ordinis-admin-ui/src/components/version/useAppVersion.ts b/ordinis-admin-ui/src/components/version/useAppVersion.ts new file mode 100644 index 0000000..19681fe --- /dev/null +++ b/ordinis-admin-ui/src/components/version/useAppVersion.ts @@ -0,0 +1,40 @@ +import { useServerVersion } from '@/api/queries' + +/** + * App version + update detection hook. + * + *

Returns: + *

    + *
  • {@code clientVersion}, {@code clientCommit}, {@code clientBuiltAt} — + * built-time injected via Vite {@code define} (см. vite.config.ts).
  • + *
  • {@code serverVersion} — polled из {@code /api/v1/version} каждые 5 мин.
  • + *
  • {@code updateAvailable} — true когда client.commit ≠ server.commit AND + * обе stamps known (не "unknown" и не undefined).
  • + *
+ * + *

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, + } +} diff --git a/ordinis-admin-ui/src/routes/__root.tsx b/ordinis-admin-ui/src/routes/__root.tsx index 17922b0..98b0811 100644 --- a/ordinis-admin-ui/src/routes/__root.tsx +++ b/ordinis-admin-ui/src/routes/__root.tsx @@ -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 (

+
@@ -74,6 +77,7 @@ function RootLayout() {
+ { + 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({ + define: { + __APP_VERSION__: JSON.stringify(APP_VERSION), + __APP_COMMIT__: JSON.stringify(APP_COMMIT), + __APP_BUILT_AT__: JSON.stringify(APP_BUILT_AT), + }, test: { environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], diff --git a/ordinis-app/pom.xml b/ordinis-app/pom.xml index 3f918ae..fc6f8a7 100644 --- a/ordinis-app/pom.xml +++ b/ordinis-app/pom.xml @@ -109,9 +109,54 @@ + + + io.github.git-commit-id + git-commit-id-maven-plugin + 9.0.1 + + + git-info + revision + validate + + + + false + false + false + false + + ^git.commit.id.abbrev$ + ^git.branch$ + ^git.tags$ + ^git.commit.time$ + + + org.springframework.boot spring-boot-maven-plugin + + + + build-info + build-info + + + ${git.commit.id.abbrev} + ${git.branch} + ${git.tags} + ${git.commit.time} + + + + org.apache.maven.plugins diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/VersionController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/VersionController.java new file mode 100644 index 0000000..b188f23 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/VersionController.java @@ -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. + * + *

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). + * + *

Source данных: + *

    + *
  • {@code BuildProperties} bean создаётся Spring Boot когда есть + * {@code META-INF/build-info.properties} (генерируется + * spring-boot-maven-plugin {@code build-info} goal в ordinis-app pom).
  • + *
  • Дополнительные {@code commit}, {@code branch}, {@code tag} + * resolved из CI env vars (см. additionalProperties в plugin config).
  • + *
+ * + *

Если build-info.properties отсутствует (e.g., запуск в IDE без mvn package) + * — возвращает sensible defaults чтобы UI не падал. + * + *

Public endpoint — auth НЕ required (нужен на login screen чтобы клиент + * сразу знал stale ли его bundle). + */ +@RestController +@RequestMapping("/api/v1/version") +public class VersionController { + + private final Optional 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 + ) {} +}