Update-System Frontend: Banner + Was-ist-neu-Modal
Beim ersten Login nach einem Update zeigt der Monitor nun ein Modal mit den Release-Notes des Updates (aus RELEASES.json). Wenn waehrend einer laufenden Sitzung ein neues Update live geht, erscheint unten rechts ein Banner mit einem Aktualisieren-Knopf. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -717,5 +717,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/update-system.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
263
src/static/js/update-system.js
Normale Datei
263
src/static/js/update-system.js
Normale Datei
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Update-System fuer den AegisSight Monitor.
|
||||
*
|
||||
* Zeigt zwei Dinge:
|
||||
* 1) Beim ersten Page-Load nach einem Update -> Modal "Was ist neu?"
|
||||
* mit den Eintraegen aus RELEASES.json, die der User noch nicht gesehen hat.
|
||||
*
|
||||
* 2) Wenn der User die Seite offen hat und im Hintergrund ein neues Update
|
||||
* live geht -> kleiner Banner unten rechts:
|
||||
* "Eine neue Version ist verfuegbar. [Jetzt aktualisieren]"
|
||||
*
|
||||
* Datenquellen (Backend):
|
||||
* GET /api/version -> { commit, deployed_at }
|
||||
* GET /api/release-notes -> { entries: [...], current }
|
||||
*
|
||||
* Persistenz im Browser:
|
||||
* localStorage 'aegis_last_seen_release' -> "version"-Feld des zuletzt
|
||||
* gesehenen Eintrags
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000; // alle 60 Sekunden
|
||||
const STORAGE_KEY = 'aegis_last_seen_release';
|
||||
|
||||
let initialBootCommit = null; // Commit-Hash beim Page-Load
|
||||
let pollTimer = null;
|
||||
let updateBannerShown = false;
|
||||
|
||||
// ---- Mini-DOM-Helpers ----
|
||||
function el(tag, attrs, ...children) {
|
||||
const e = document.createElement(tag);
|
||||
for (const k in (attrs || {})) {
|
||||
if (k === 'class') e.className = attrs[k];
|
||||
else if (k === 'html') e.innerHTML = attrs[k];
|
||||
else if (k.startsWith('on')) e.addEventListener(k.slice(2), attrs[k]);
|
||||
else e.setAttribute(k, attrs[k]);
|
||||
}
|
||||
for (const c of children) {
|
||||
if (c == null) continue;
|
||||
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
// ---- Styles inline injecten (kein zusaetzlicher CSS-File noetig) ----
|
||||
function injectStyles() {
|
||||
if (document.getElementById('aegis-update-styles')) return;
|
||||
const css = `
|
||||
#aegis-update-banner {
|
||||
position: fixed; bottom: 24px; right: 24px; z-index: 99999;
|
||||
background: linear-gradient(135deg, #C8A851, #D4B96A);
|
||||
color: #0A1832; padding: 14px 18px; border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(10,24,50,0.4);
|
||||
font-family: 'Inter', -apple-system, sans-serif; font-size: 0.92rem;
|
||||
display: flex; align-items: center; gap: 12px; max-width: 380px;
|
||||
animation: aegis-slide-in 0.4s cubic-bezier(0.4,0,0.2,1);
|
||||
}
|
||||
@keyframes aegis-slide-in {
|
||||
from { transform: translateX(420px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
#aegis-update-banner b { font-weight: 700; }
|
||||
#aegis-update-banner button {
|
||||
background: #0A1832; color: #C8A851; border: 0; padding: 7px 14px;
|
||||
border-radius: 6px; font: inherit; font-size: 0.86rem; font-weight: 600;
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
}
|
||||
#aegis-update-banner button:hover { background: #132844; }
|
||||
#aegis-update-banner .close {
|
||||
background: transparent; color: #0A1832; opacity: 0.6; padding: 0 4px;
|
||||
font-size: 1.2rem; line-height: 1;
|
||||
}
|
||||
#aegis-update-banner .close:hover { opacity: 1; background: transparent; }
|
||||
|
||||
#aegis-update-modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(10,24,50,0.75); z-index: 99998;
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex; align-items: center; justify-content: center; padding: 24px;
|
||||
animation: aegis-fade-in 0.25s ease;
|
||||
}
|
||||
@keyframes aegis-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
#aegis-update-modal {
|
||||
background: #132844; color: #E8E8E8; border-radius: 14px;
|
||||
border: 1px solid rgba(200,168,81,0.25);
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.5);
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
max-width: 540px; width: 100%; max-height: 80vh; overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
#aegis-update-modal header {
|
||||
padding: 22px 28px 18px; border-bottom: 1px solid rgba(200,168,81,0.15);
|
||||
}
|
||||
#aegis-update-modal h2 { margin: 0 0 4px; color: #C8A851; font-size: 1.25rem; font-weight: 700; }
|
||||
#aegis-update-modal header p { margin: 0; color: #A0A8B8; font-size: 0.88rem; }
|
||||
#aegis-update-modal .body { padding: 8px 28px; overflow-y: auto; }
|
||||
.aegis-release { padding: 16px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.aegis-release:last-child { border: 0; }
|
||||
.aegis-release-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 8px; }
|
||||
.aegis-release-title { font-size: 1rem; font-weight: 600; color: #E8E8E8; }
|
||||
.aegis-release-date { font-size: 0.78rem; color: #5A6478; }
|
||||
.aegis-release-items { margin: 0; padding-left: 20px; color: #C0C8D8; font-size: 0.92rem; line-height: 1.6; }
|
||||
.aegis-release-items li { margin-bottom: 4px; }
|
||||
#aegis-update-modal footer {
|
||||
padding: 16px 28px 20px; border-top: 1px solid rgba(200,168,81,0.15);
|
||||
display: flex; justify-content: flex-end;
|
||||
}
|
||||
#aegis-update-modal footer button {
|
||||
background: #C8A851; color: #0A1832; border: 0; padding: 10px 22px;
|
||||
border-radius: 6px; font: inherit; font-size: 0.92rem; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
#aegis-update-modal footer button:hover { background: #D4B96A; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#aegis-update-banner { left: 12px; right: 12px; bottom: 12px; max-width: none; }
|
||||
}`;
|
||||
document.head.appendChild(el('style', { id: 'aegis-update-styles', html: css }));
|
||||
}
|
||||
|
||||
// ---- Backend-Kommunikation ----
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
const r = await fetch('/api/version', { cache: 'no-store' });
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchReleaseNotes(since) {
|
||||
try {
|
||||
const url = '/api/release-notes' + (since ? '?since=' + encodeURIComponent(since) : '');
|
||||
const r = await fetch(url, { cache: 'no-store' });
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Banner ----
|
||||
function showUpdateBanner() {
|
||||
if (updateBannerShown) return;
|
||||
if (document.getElementById('aegis-update-banner')) return;
|
||||
updateBannerShown = true;
|
||||
|
||||
const banner = el('div', { id: 'aegis-update-banner' },
|
||||
el('div', null,
|
||||
el('b', null, 'Update verfügbar'),
|
||||
document.createElement('br'),
|
||||
el('span', { style: 'font-size:0.85rem;opacity:0.85' },
|
||||
'Eine neue Version ist live. Bitte Seite neu laden, um sie zu nutzen.')
|
||||
),
|
||||
el('button', { onclick: () => location.reload() }, 'Aktualisieren'),
|
||||
el('button', {
|
||||
class: 'close', title: 'Schließen',
|
||||
onclick: () => banner.remove()
|
||||
}, '×')
|
||||
);
|
||||
document.body.appendChild(banner);
|
||||
}
|
||||
|
||||
// ---- Modal ----
|
||||
function showWhatsNewModal(entries, currentVersion) {
|
||||
if (document.getElementById('aegis-update-modal-overlay')) return;
|
||||
if (!entries || !entries.length) return;
|
||||
|
||||
const releases = entries.map(e => {
|
||||
const items = (e.items || []).map(i => el('li', null, i));
|
||||
return el('div', { class: 'aegis-release' },
|
||||
el('div', { class: 'aegis-release-head' },
|
||||
el('span', { class: 'aegis-release-title' }, e.title || 'Update'),
|
||||
el('span', { class: 'aegis-release-date' }, e.date || '')
|
||||
),
|
||||
items.length ? el('ul', { class: 'aegis-release-items' }, ...items) : null
|
||||
);
|
||||
});
|
||||
|
||||
const overlay = el('div', { id: 'aegis-update-modal-overlay' },
|
||||
el('div', { id: 'aegis-update-modal' },
|
||||
el('header', null,
|
||||
el('h2', null, 'Was ist neu?'),
|
||||
el('p', null, 'Diese Änderungen sind seit deinem letzten Besuch dazugekommen.')
|
||||
),
|
||||
el('div', { class: 'body' }, ...releases),
|
||||
el('footer', null,
|
||||
el('button', {
|
||||
onclick: () => {
|
||||
// Hoechste (= neueste) Version als gesehen markieren
|
||||
const newest = entries[0]?.version;
|
||||
if (newest) localStorage.setItem(STORAGE_KEY, newest);
|
||||
overlay.remove();
|
||||
}
|
||||
}, 'Verstanden')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// ESC oder Klick auf Hintergrund -> wie "Verstanden"
|
||||
overlay.addEventListener('click', (ev) => {
|
||||
if (ev.target === overlay) {
|
||||
const newest = entries[0]?.version;
|
||||
if (newest) localStorage.setItem(STORAGE_KEY, newest);
|
||||
overlay.remove();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', function escHandler(ev) {
|
||||
if (ev.key === 'Escape' && document.getElementById('aegis-update-modal-overlay')) {
|
||||
const newest = entries[0]?.version;
|
||||
if (newest) localStorage.setItem(STORAGE_KEY, newest);
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// ---- Polling ----
|
||||
async function pollVersion() {
|
||||
const v = await fetchVersion();
|
||||
if (v && v.commit && initialBootCommit && v.commit !== initialBootCommit) {
|
||||
showUpdateBanner();
|
||||
// Polling beenden, sobald Banner gezeigt
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Initial-Boot ----
|
||||
async function init() {
|
||||
injectStyles();
|
||||
|
||||
const v = await fetchVersion();
|
||||
if (v && v.commit) initialBootCommit = v.commit;
|
||||
|
||||
// Was-ist-neu-Modal: nur wenn Eintraege NEUER als 'lastSeen' existieren
|
||||
const lastSeen = localStorage.getItem(STORAGE_KEY);
|
||||
const notes = await fetchReleaseNotes(lastSeen);
|
||||
if (notes && notes.entries && notes.entries.length > 0) {
|
||||
// Wenn lastSeen leer ist (erster Besuch ueberhaupt), kein Modal,
|
||||
// sondern nur den aktuellen Stand als "gesehen" markieren.
|
||||
if (!lastSeen) {
|
||||
if (notes.entries[0]?.version) {
|
||||
localStorage.setItem(STORAGE_KEY, notes.entries[0].version);
|
||||
}
|
||||
} else {
|
||||
// mit etwas Verzoegerung, damit das Dashboard erst rendert
|
||||
setTimeout(() => showWhatsNewModal(notes.entries, v?.commit), 800);
|
||||
}
|
||||
}
|
||||
|
||||
// Polling starten
|
||||
pollTimer = setInterval(pollVersion, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren