Serverpuls: JWT-Cache, Lade-Zustand beim Umschalten, Antwort-Reihenfolge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-30 12:43:13 +00:00
1 parent ec740ca970
commit 16f3150ff1
4 files changed
+116 -8

No files matched your search

+7 -2
View File
@@ -1,4 +1,5 @@
import { account } from './appwrite' import { account } from './appwrite'
import { createJwtCache, fetchWithRetry } from './serverpulsAuth'
/** /**
* Client fuer das Serverpuls-Backend (nur lesend). * Client fuer das Serverpuls-Backend (nur lesend).
@@ -6,9 +7,13 @@ import { account } from './appwrite'
*/ */
const BASE = '/api/serverpuls' const BASE = '/api/serverpuls'
// JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer)
const jwtCache = createJwtCache({ createJWT: () => account.createJWT() })
async function get(path) { async function get(path) {
const { jwt } = await account.createJWT() const res = await fetchWithRetry(jwtCache, (jwt) =>
const res = await fetch(`${BASE}${path}`, { headers: { 'X-Appwrite-JWT': jwt } }) fetch(`${BASE}${path}`, { headers: { 'X-Appwrite-JWT': jwt } })
)
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `Serverpuls-Fehler ${res.status}`) if (!res.ok) throw new Error(data.error || `Serverpuls-Fehler ${res.status}`)
return data return data
+37
View File
@@ -0,0 +1,37 @@
// JWT-Cache fuer Serverpuls (rein, ohne Appwrite-Import; createJWT und Uhr werden injiziert).
export const JWT_TTL_MS = 10 * 60 * 1000
export function createJwtCache({ createJWT, now = Date.now, ttlMs = JWT_TTL_MS }) {
let entry = null // { jwt, at } oder { promise }
let pending = null
async function get() {
if (entry && now() - entry.at < ttlMs) return entry.jwt
if (pending) return pending
const p = (async () => {
try {
const { jwt } = await createJWT()
entry = { jwt, at: now() }
return jwt
} finally {
if (pending === p) pending = null
}
})()
pending = p
return p
}
function invalidate() {
entry = null
}
return { get, invalidate }
}
// doFetch(jwt) -> Response-artig ({status}); bei 401 einmal mit frischem JWT wiederholen.
export async function fetchWithRetry(cache, doFetch) {
const first = await doFetch(await cache.get())
if (first.status !== 401) return first
cache.invalidate()
return doFetch(await cache.get())
}
+16 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useRef, useMemo, useState } from 'react'
import { Navigate } from 'react-router-dom' import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { PageHeader } from '../components/ui' import { PageHeader } from '../components/ui'
@@ -50,7 +50,7 @@ function displayPoints(metric, points, total) {
function tileModel(key, m) { function tileModel(key, m) {
if (!m) return null if (!m) return null
const total = m.total ?? null const total = m.total ?? null
const spark = (m.spark24h || []).map((p) => (key === 'ram' && total ? total - p.v : p.v)) const spark = (m.spark24h || []).map((p) => (key === 'ram' && total && p.v !== null && p.v !== undefined ? total - p.v : p.v))
if (m.value === null || m.value === undefined) return { main: '–', sub: '', spark, delta: null } if (m.value === null || m.value === undefined) return { main: '–', sub: '', spark, delta: null }
switch (key) { switch (key) {
case 'cpu': case 'cpu':
@@ -159,16 +159,21 @@ export default function ServerpulsPage() {
const [tick, setTick] = useState(0) const [tick, setTick] = useState(0)
// Uebersicht, Projekte, Aufraeumbar: beim Laden und jede Minute (nur sichtbar) // Uebersicht, Projekte, Aufraeumbar: beim Laden und jede Minute (nur sichtbar)
const reqId = useRef(0)
const loadMain = useCallback(async () => { const loadMain = useCallback(async () => {
reqId.current += 1
const mine = reqId.current
try { try {
const [o, a, c] = await Promise.all([ const [o, a, c] = await Promise.all([
serverpulsApi.overview(), serverpulsApi.apps(), serverpulsApi.cleanup(), serverpulsApi.overview(), serverpulsApi.apps(), serverpulsApi.cleanup(),
]) ])
if (mine !== reqId.current) return
setOverview(o) setOverview(o)
setAppsData(a) setAppsData(a)
setCleanup(c) setCleanup(c)
setError('') setError('')
} catch (e) { } catch (e) {
if (mine !== reqId.current) return
setError(e.message || 'Serverpuls ist nicht erreichbar.') setError(e.message || 'Serverpuls ist nicht erreichbar.')
} }
setNow(Date.now()) setNow(Date.now())
@@ -206,6 +211,11 @@ export default function ServerpulsPage() {
return () => { dead = true } return () => { dead = true }
}, [isAdmin, selected, detailRange, tick]) }, [isAdmin, selected, detailRange, tick])
const changeMetric = (v) => { if (v !== metric) { setChart(null); setMetric(v) } }
const changeRange = (v) => { if (v !== range) { setChart(null); setRange(v) } }
const selectApp = (id) => { setDetail(null); setSelected(id) }
const changeDetailRange = (v) => { if (v !== detailRange) { setDetail(null); setDetailRange(v) } }
const m = METRICS.find((x) => x.id === metric) const m = METRICS.find((x) => x.id === metric)
const chartData = useMemo(() => { const chartData = useMemo(() => {
@@ -286,8 +296,8 @@ export default function ServerpulsPage() {
<div className="sp-panel-head"> <div className="sp-panel-head">
<h3>Verlauf</h3> <h3>Verlauf</h3>
<div className="sp-controls"> <div className="sp-controls">
<Pills items={METRICS} value={metric} onChange={setMetric} testid="sp-metric-select" /> <Pills items={METRICS} value={metric} onChange={changeMetric} testid="sp-metric-select" />
<Pills items={RANGES} value={range} onChange={setRange} testid="sp-range-select" /> <Pills items={RANGES} value={range} onChange={changeRange} testid="sp-range-select" />
</div> </div>
</div> </div>
{chartData && chartData.data[0].length > 0 {chartData && chartData.data[0].length > 0
@@ -300,12 +310,12 @@ export default function ServerpulsPage() {
<h3>Projekte</h3> <h3>Projekte</h3>
{appsData?.sizesDay && <span className="sp-muted sp-small">Größen von {appsData.sizesDay}</span>} {appsData?.sizesDay && <span className="sp-muted sp-small">Größen von {appsData.sizesDay}</span>}
</div> </div>
<AppsTable rows={appsData?.rows || []} selectedId={selected} onSelect={setSelected} /> <AppsTable rows={appsData?.rows || []} selectedId={selected} onSelect={selectApp} />
{selected && ( {selected && (
<div className="sp-detail" data-testid="sp-detail"> <div className="sp-detail" data-testid="sp-detail">
<div className="sp-panel-head"> <div className="sp-panel-head">
<h3>{detail?.name || selected}</h3> <h3>{detail?.name || selected}</h3>
<Pills items={RANGES} value={detailRange} onChange={setDetailRange} testid="sp-detail-range" /> <Pills items={RANGES} value={detailRange} onChange={changeDetailRange} testid="sp-detail-range" />
</div> </div>
{detailData ? ( {detailData ? (
<div className="sp-detail-charts"> <div className="sp-detail-charts">
+56
View File
@@ -0,0 +1,56 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { createJwtCache, fetchWithRetry } = await loadModule('src/lib/serverpulsAuth.js')
function setup() {
let n = 0
let t = 0
const calls = { create: 0 }
const createJWT = async () => { calls.create += 1; n += 1; await Promise.resolve(); return { jwt: `jwt${n}` } }
const cache = createJwtCache({ createJWT, now: () => t })
return { cache, calls, advance: (ms) => { t += ms } }
}
test('3 parallele Aufrufe -> 1 createJWT', async () => {
const { cache, calls } = setup()
const r = await Promise.all([cache.get(), cache.get(), cache.get()])
assert.deepEqual(r, ['jwt1', 'jwt1', 'jwt1'])
assert.equal(calls.create, 1)
})
test('nach 10 Min. neues JWT, davor gecacht', async () => {
const { cache, calls, advance } = setup()
await cache.get()
advance(9 * 60000)
assert.equal(await cache.get(), 'jwt1')
advance(61000)
assert.equal(await cache.get(), 'jwt2')
assert.equal(calls.create, 2)
})
test('createJWT-Fehler wird nicht gecacht', async () => {
let fail = true
const cache = createJwtCache({ createJWT: async () => { if (fail) throw new Error('x'); return { jwt: 'ok' } }, now: () => 0 })
await assert.rejects(cache.get())
fail = false
assert.equal(await cache.get(), 'ok')
})
test('401 -> genau ein Retry mit frischem JWT', async () => {
const { cache, calls } = setup()
const used = []
const res = await fetchWithRetry(cache, async (jwt) => { used.push(jwt); return { status: used.length === 1 ? 401 : 200 } })
assert.equal(res.status, 200)
assert.deepEqual(used, ['jwt1', 'jwt2'])
assert.equal(calls.create, 2)
})
test('zweites 401 wird durchgereicht, kein dritter Versuch', async () => {
const { cache } = setup()
let n = 0
const res = await fetchWithRetry(cache, async () => { n += 1; return { status: 401 } })
assert.equal(res.status, 401)
assert.equal(n, 2)
})