From efaf5a3794e55fbb194c8ffa7103dec2fe1a4cd4 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Wed, 29 Apr 2026 14:39:30 +0200 Subject: [PATCH] =?UTF-8?q?Mise=20en=20place=20d'un=20composant=20permetta?= =?UTF-8?q?nt=20d'am=C3=A9liorer=20l'experience=20de=20mise=20=C3=A0=20jou?= =?UTF-8?q?r=20(via=20un=20rafraichissement=20de=20l'appli).=20Modificatio?= =?UTF-8?q?n=20de=20la=20partie=20web=20pour=20prendre=20la=20modification?= =?UTF-8?q?=20en=20compte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/VersionController.java | 35 +++++++ web/src/app/app.component.html | 2 + web/src/app/app.component.ts | 22 ++++- .../app/services/version-checker.service.ts | 94 +++++++++++++++++++ .../update-banner.component.html | 29 ++++++ .../update-banner.component.scss | 68 ++++++++++++++ .../update-banner/update-banner.component.ts | 34 +++++++ 7 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/VersionController.java create mode 100644 web/src/app/services/version-checker.service.ts create mode 100644 web/src/app/shared/update-banner/update-banner.component.html create mode 100644 web/src/app/shared/update-banner/update-banner.component.scss create mode 100644 web/src/app/shared/update-banner/update-banner.component.ts diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/VersionController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/VersionController.java new file mode 100644 index 0000000..05187a1 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/VersionController.java @@ -0,0 +1,35 @@ +package com.loremind.infrastructure.web.controller; + +import org.springframework.boot.info.BuildProperties; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * Endpoint public exposant la version courante du binaire. + *

+ * Consomme par le frontend pour detecter qu'une mise a jour a ete deployee + * pendant qu'un onglet utilisateur etait deja ouvert : si la version polled + * differe de celle observee au boot, l'UI affiche un bandeau "rechargez". + *

+ * Volontairement public (pas d'auth) : la version est deja exposee dans le + * JAR / l'image Docker, aucun risque de leak. + */ +@RestController +@RequestMapping("/api/version") +public class VersionController { + + private final String version; + + public VersionController(@Nullable BuildProperties buildProperties) { + this.version = buildProperties != null ? buildProperties.getVersion() : "dev"; + } + + @GetMapping + public Map getVersion() { + return Map.of("version", version); + } +} diff --git a/web/src/app/app.component.html b/web/src/app/app.component.html index 32f7a01..5bd96fa 100644 --- a/web/src/app/app.component.html +++ b/web/src/app/app.component.html @@ -1,3 +1,5 @@ + +

diff --git a/web/src/app/app.component.ts b/web/src/app/app.component.ts index 662f983..5e1e924 100644 --- a/web/src/app/app.component.ts +++ b/web/src/app/app.component.ts @@ -4,13 +4,23 @@ import { RouterOutlet } from '@angular/router'; import { SidebarComponent } from './sidebar/sidebar.component'; import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component'; import { GlobalSearchComponent } from './shared/global-search/global-search.component'; +import { UpdateBannerComponent } from './shared/update-banner/update-banner.component'; import { LayoutService } from './services/layout.service'; import { GlobalSearchService } from './services/global-search.service'; +import { VersionCheckerService } from './services/version-checker.service'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet, SidebarComponent, SecondarySidebarComponent, GlobalSearchComponent, AsyncPipe, NgIf], + imports: [ + RouterOutlet, + SidebarComponent, + SecondarySidebarComponent, + GlobalSearchComponent, + UpdateBannerComponent, + AsyncPipe, + NgIf, + ], templateUrl: './app.component.html', styleUrls: ['./app.component.scss'] }) @@ -19,8 +29,14 @@ export class AppComponent { constructor( private layoutService: LayoutService, - private globalSearch: GlobalSearchService - ) {} + private globalSearch: GlobalSearchService, + versionChecker: VersionCheckerService, + ) { + // Demarre la detection de mise a jour en arriere-plan. + // Si une nouvelle version est deployee pendant la session, l'UpdateBanner + // s'affichera automatiquement. + versionChecker.start(); + } @HostListener('document:keydown', ['$event']) onKeydown(event: KeyboardEvent): void { diff --git a/web/src/app/services/version-checker.service.ts b/web/src/app/services/version-checker.service.ts new file mode 100644 index 0000000..484b058 --- /dev/null +++ b/web/src/app/services/version-checker.service.ts @@ -0,0 +1,94 @@ +import { Injectable, signal, computed } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +/** + * Detecte qu'une nouvelle version de LoreMind a ete deployee pendant qu'un + * onglet utilisateur est deja ouvert. + * + * Strategie : polling toutes les {@link POLL_INTERVAL_MS} sur /api/version. + * La version observee au boot est figee comme reference. Si la version polled + * differe, {@link hasUpdate} passe a true et l'UI peut afficher un bandeau + * proposant un reload. + * + * Pourquoi pas un Service Worker ? Trop lourd pour le besoin (offline pas + * pertinent, lifecycle complexe). Polling simple = ~15 lignes, fait le job. + */ +@Injectable({ providedIn: 'root' }) +export class VersionCheckerService { + private static readonly POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + private static readonly INITIAL_DELAY_MS = 30 * 1000; // 30s avant premier poll + + private readonly _bootVersion = signal(null); + private readonly _remoteVersion = signal(null); + private readonly _hasUpdate = computed( + () => { + const boot = this._bootVersion(); + const remote = this._remoteVersion(); + return boot !== null && remote !== null && boot !== remote; + } + ); + + /** True quand la version remote differe de celle observee au boot. */ + readonly hasUpdate = this._hasUpdate; + /** Version observee a l'ouverture du tab (figee). */ + readonly bootVersion = this._bootVersion.asReadonly(); + /** Derniere version observee sur le backend. */ + readonly remoteVersion = this._remoteVersion.asReadonly(); + + private timer: ReturnType | null = null; + + constructor(private http: HttpClient) {} + + /** + * Demarre le polling. A appeler une fois au boot de l'app. + * Ignore si deja demarre (idempotent). + */ + start(): void { + if (this.timer !== null) return; + void this.fetchInitial(); + this.timer = setInterval( + () => void this.poll(), + VersionCheckerService.POLL_INTERVAL_MS, + ); + } + + stop(): void { + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + } + + /** Recharge l'app (force re-fetch index.html + assets). */ + reload(): void { + window.location.reload(); + } + + private async fetchInitial(): Promise { + // Petit delai pour laisser le bootstrap se finir avant le premier appel. + await new Promise(r => setTimeout(r, VersionCheckerService.INITIAL_DELAY_MS)); + const v = await this.fetchVersion(); + if (v && this._bootVersion() === null) { + this._bootVersion.set(v); + this._remoteVersion.set(v); + } + } + + private async poll(): Promise { + const v = await this.fetchVersion(); + if (v) this._remoteVersion.set(v); + } + + private async fetchVersion(): Promise { + try { + const resp = await firstValueFrom( + this.http.get<{ version: string }>('/api/version'), + ); + return resp?.version ?? null; + } catch { + // Backend down / restart en cours — on ignore, on retentera au prochain tick + return null; + } + } +} diff --git a/web/src/app/shared/update-banner/update-banner.component.html b/web/src/app/shared/update-banner/update-banner.component.html new file mode 100644 index 0000000..796d2f7 --- /dev/null +++ b/web/src/app/shared/update-banner/update-banner.component.html @@ -0,0 +1,29 @@ +
+ + +
diff --git a/web/src/app/shared/update-banner/update-banner.component.scss b/web/src/app/shared/update-banner/update-banner.component.scss new file mode 100644 index 0000000..b081069 --- /dev/null +++ b/web/src/app/shared/update-banner/update-banner.component.scss @@ -0,0 +1,68 @@ +.update-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 20px; + background: linear-gradient(90deg, #6d28d9, #7c3aed); + color: #fff; + font-size: 0.9rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + + .banner-content { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + } + + .banner-actions { + display: flex; + align-items: center; + gap: 8px; + } + + .btn-reload { + background: #fff; + color: #6d28d9; + border: 0; + padding: 6px 14px; + border-radius: 4px; + font-weight: 600; + cursor: pointer; + font-size: 0.85rem; + + &:hover { + background: rgba(255, 255, 255, 0.92); + } + } + + .btn-dismiss { + background: transparent; + border: 0; + color: rgba(255, 255, 255, 0.85); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + + &:hover { + background: rgba(255, 255, 255, 0.15); + color: #fff; + } + } +} + +// Decale le contenu de l'app vers le bas quand le bandeau est present +// (bandeau fixed, ne pousse pas naturellement le layout). +:host:has(.update-banner) { + display: block; +} diff --git a/web/src/app/shared/update-banner/update-banner.component.ts b/web/src/app/shared/update-banner/update-banner.component.ts new file mode 100644 index 0000000..208c17a --- /dev/null +++ b/web/src/app/shared/update-banner/update-banner.component.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { LucideAngularModule, RefreshCw, X } from 'lucide-angular'; +import { VersionCheckerService } from '../../services/version-checker.service'; + +/** + * Bandeau global affiche en haut de l'app quand une nouvelle version de + * LoreMind a ete deployee pendant que l'utilisateur avait deja l'onglet + * ouvert. Propose un reload en un clic. + */ +@Component({ + selector: 'app-update-banner', + standalone: true, + imports: [CommonModule, LucideAngularModule], + templateUrl: './update-banner.component.html', + styleUrls: ['./update-banner.component.scss'] +}) +export class UpdateBannerComponent { + readonly RefreshCw = RefreshCw; + readonly X = X; + + /** L'utilisateur a explicitement ferme le bandeau pour cette session. */ + dismissed = false; + + constructor(public versionChecker: VersionCheckerService) {} + + reload(): void { + this.versionChecker.reload(); + } + + dismiss(): void { + this.dismissed = true; + } +}