Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).

Modification de la partie web pour prendre la modification en compte
This commit is contained in:
2026-04-29 14:39:30 +02:00
parent 4fe93b5ff3
commit fd19cd3b62
7 changed files with 281 additions and 3 deletions

View File

@@ -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.
* <p>
* 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".
* <p>
* 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<String, String> getVersion() {
return Map.of("version", version);
}
}

View File

@@ -1,3 +1,5 @@
<app-update-banner></app-update-banner>
<div class="app-container">
<app-sidebar></app-sidebar>

View File

@@ -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 {

View File

@@ -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<string | null>(null);
private readonly _remoteVersion = signal<string | null>(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<typeof setTimeout> | 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<void> {
// 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<void> {
const v = await this.fetchVersion();
if (v) this._remoteVersion.set(v);
}
private async fetchVersion(): Promise<string | null> {
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;
}
}
}

View File

@@ -0,0 +1,29 @@
<div
class="update-banner"
*ngIf="versionChecker.hasUpdate() && !dismissed"
role="status"
aria-live="polite"
>
<div class="banner-content">
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
<span>
Une nouvelle version de LoreMind est disponible
(<strong>{{ versionChecker.remoteVersion() }}</strong>).
Recharge pour profiter des dernieres ameliorations.
</span>
</div>
<div class="banner-actions">
<button type="button" class="btn-reload" (click)="reload()">
Recharger
</button>
<button
type="button"
class="btn-dismiss"
(click)="dismiss()"
title="Fermer (sera reaffiche au prochain demarrage)"
aria-label="Fermer"
>
<lucide-icon [img]="X" [size]="16"></lucide-icon>
</button>
</div>
</div>

View File

@@ -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;
}

View File

@@ -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;
}
}