Frontend: "Neue Version verfügbar"-Banner nach Deploy
Die Cache-Header waren schon optimal (index.html no-store, Assets immutable) – aber eine bereits laufende SPA holt sich nach einem Deploy keinen neuen Code, bis der Nutzer neu lädt. Das fängt kein Cache-Header ab. useVersionCheck holt die (server-seitig no-store) index.html periodisch (5 min) + beim Zurückkehren zum Tab, extrahiert die Menge der referenzierten Vite-Asset-Hashes als Signatur und vergleicht sie mit dem Startstand. Ändert sie sich (= neuer Build deployt), zeigt UpdateBanner oben im Layout einen dezenten Hinweis mit "Jetzt neu laden". Kein Backend-/Build-Change; im Dev-Modus No-op (Vite-Dev liefert keine /assets/<hash>-Dateien → leere Signatur → kein Alarm). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,17 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
|
||||
## ✅ Erledigt
|
||||
|
||||
- [x] **🔄 „Neue Version verfügbar"-Banner (offener-Tab-Problem)**
|
||||
- Cache-Header waren schon optimal (index.html `no-store`, Assets
|
||||
`immutable`) – aber eine laufende SPA holt sich nach einem Deploy
|
||||
nicht von selbst neuen Code. Neuer `useVersionCheck`-Hook holt die
|
||||
(immer frische) index.html periodisch + beim Zurückkehren zum Tab,
|
||||
vergleicht die Vite-Asset-Hash-Signatur mit dem Startstand. Bei
|
||||
Änderung erscheint ein dezentes Banner „Neue Version verfügbar →
|
||||
Jetzt neu laden" (`UpdateBanner`, oben im Layout).
|
||||
- Kein Backend-/Build-Change nötig; im Dev-Modus automatisch No-op
|
||||
(keine `/assets/<hash>`-Dateien → leere Signatur).
|
||||
|
||||
- [x] **👁 Kundenportal: Toggle „Deaktivierte Verträge anzeigen"**
|
||||
- Der Admin-/Mitarbeiter-Blick (CustomerDetail) hatte den Toggle schon;
|
||||
jetzt auch in der Kundenportal-Vertragsübersicht (`ContractList`,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react';
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
import { useVersionCheck } from '../hooks/useVersionCheck';
|
||||
|
||||
/**
|
||||
* Zeigt ein dezentes Banner, sobald nach einem Deploy eine neue Frontend-
|
||||
* Version verfügbar ist (siehe useVersionCheck). "Jetzt neu laden" lädt die
|
||||
* Seite neu und holt damit den frischen Code; "später" blendet für die
|
||||
* laufende Session aus.
|
||||
*/
|
||||
export default function UpdateBanner() {
|
||||
const updateAvailable = useVersionCheck();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (!updateAvailable || dismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-blue-600 text-white px-6 py-2.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<RefreshCw className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Eine neue Version ist verfügbar. Bitte neu laden, um sie zu nutzen.</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-3 py-1 text-sm font-medium bg-white text-blue-700 rounded hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
Jetzt neu laden
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="p-1 text-white/80 hover:text-white rounded"
|
||||
title="Später"
|
||||
aria-label="Hinweis ausblenden"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { gdprApi } from '../../services/api';
|
||||
import Sidebar from './Sidebar';
|
||||
import ScrollToTopButton from '../ScrollToTopButton';
|
||||
import BirthdayModal from '../BirthdayModal';
|
||||
import UpdateBanner from '../UpdateBanner';
|
||||
import { AlertTriangle, ArrowRight, Building, Shield } from 'lucide-react';
|
||||
|
||||
function ConsentBanner() {
|
||||
@@ -80,6 +81,7 @@ export default function Layout() {
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<UpdateBanner />
|
||||
<ConsentBanner />
|
||||
<main className="flex-1 p-8">
|
||||
<Outlet />
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Erkennt, ob nach einem Deploy eine neue Frontend-Version vorliegt, während
|
||||
* der Nutzer die App offen hat (der einzige Fall, den Cache-Header NICHT
|
||||
* abfangen können – eine laufende SPA holt sich nicht von selbst neuen Code).
|
||||
*
|
||||
* Prinzip: Die `index.html` wird server-seitig mit `no-store` ausgeliefert,
|
||||
* ist also immer frisch. Sie referenziert die Vite-Hash-Assets
|
||||
* (`/assets/index-<hash>.js`), deren Namen sich bei jedem Build ändern. Wir
|
||||
* holen die index.html periodisch (und beim Zurückkehren zum Tab), extrahieren
|
||||
* die Menge der referenzierten Assets als "Signatur" und vergleichen sie mit
|
||||
* der beim App-Start geladenen. Ändert sie sich → neue Version deployt.
|
||||
*/
|
||||
function extractAssetSignature(html: string): string {
|
||||
const matches = html.match(/\/assets\/[A-Za-z0-9._-]+/g) || [];
|
||||
return [...new Set(matches)].sort().join('|');
|
||||
}
|
||||
|
||||
async function fetchSignature(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch('/index.html', { cache: 'no-store' });
|
||||
if (!res.ok) return null;
|
||||
const sig = extractAssetSignature(await res.text());
|
||||
return sig || null;
|
||||
} catch {
|
||||
// Netz-Aussetzer o.ä. – kein Alarm, nächster Intervall versucht es erneut.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useVersionCheck(intervalMs = 5 * 60 * 1000): boolean {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const baseline = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Kein Dev-Guard nötig: der Vite-Dev-Server liefert keine
|
||||
// `/assets/<hash>`-Dateien aus → extractAssetSignature ist leer →
|
||||
// fetchSignature gibt null zurück → check() bricht ab, kein Alarm.
|
||||
// Im Prod-Build greift die Hash-Signatur wie gewünscht.
|
||||
let cancelled = false;
|
||||
|
||||
const check = async () => {
|
||||
const sig = await fetchSignature();
|
||||
if (cancelled || !sig) return;
|
||||
if (baseline.current === null) {
|
||||
baseline.current = sig; // erster Lauf = Referenzstand
|
||||
} else if (sig !== baseline.current) {
|
||||
setUpdateAvailable(true);
|
||||
}
|
||||
};
|
||||
|
||||
check(); // sofort Baseline setzen
|
||||
const id = window.setInterval(check, intervalMs);
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') check();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}, [intervalMs]);
|
||||
|
||||
return updateAvailable;
|
||||
}
|
||||
Reference in New Issue
Block a user