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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<app-update-banner></app-update-banner>
|
||||||
|
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<app-sidebar></app-sidebar>
|
<app-sidebar></app-sidebar>
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,23 @@ import { RouterOutlet } from '@angular/router';
|
|||||||
import { SidebarComponent } from './sidebar/sidebar.component';
|
import { SidebarComponent } from './sidebar/sidebar.component';
|
||||||
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
|
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
|
||||||
import { GlobalSearchComponent } from './shared/global-search/global-search.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 { LayoutService } from './services/layout.service';
|
||||||
import { GlobalSearchService } from './services/global-search.service';
|
import { GlobalSearchService } from './services/global-search.service';
|
||||||
|
import { VersionCheckerService } from './services/version-checker.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet, SidebarComponent, SecondarySidebarComponent, GlobalSearchComponent, AsyncPipe, NgIf],
|
imports: [
|
||||||
|
RouterOutlet,
|
||||||
|
SidebarComponent,
|
||||||
|
SecondarySidebarComponent,
|
||||||
|
GlobalSearchComponent,
|
||||||
|
UpdateBannerComponent,
|
||||||
|
AsyncPipe,
|
||||||
|
NgIf,
|
||||||
|
],
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrls: ['./app.component.scss']
|
styleUrls: ['./app.component.scss']
|
||||||
})
|
})
|
||||||
@@ -19,8 +29,14 @@ export class AppComponent {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private layoutService: LayoutService,
|
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'])
|
@HostListener('document:keydown', ['$event'])
|
||||||
onKeydown(event: KeyboardEvent): void {
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
|||||||
94
web/src/app/services/version-checker.service.ts
Normal file
94
web/src/app/services/version-checker.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
34
web/src/app/shared/update-banner/update-banner.component.ts
Normal file
34
web/src/app/shared/update-banner/update-banner.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user