diff --git a/src/lib/serverpulsApi.js b/src/lib/serverpulsApi.js index 402f460..f999b85 100644 --- a/src/lib/serverpulsApi.js +++ b/src/lib/serverpulsApi.js @@ -1,4 +1,5 @@ import { account } from './appwrite' +import { createJwtCache, fetchWithRetry } from './serverpulsAuth' /** * Client fuer das Serverpuls-Backend (nur lesend). @@ -6,9 +7,13 @@ import { account } from './appwrite' */ const BASE = '/api/serverpuls' +// JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer) +const jwtCache = createJwtCache({ createJWT: () => account.createJWT() }) + async function get(path) { - const { jwt } = await account.createJWT() - const res = await fetch(`${BASE}${path}`, { headers: { 'X-Appwrite-JWT': jwt } }) + const res = await fetchWithRetry(jwtCache, (jwt) => + fetch(`${BASE}${path}`, { headers: { 'X-Appwrite-JWT': jwt } }) + ) const data = await res.json().catch(() => ({})) if (!res.ok) throw new Error(data.error || `Serverpuls-Fehler ${res.status}`) return data diff --git a/src/lib/serverpulsAuth.js b/src/lib/serverpulsAuth.js new file mode 100644 index 0000000..5648bc7 --- /dev/null +++ b/src/lib/serverpulsAuth.js @@ -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()) +} diff --git a/src/pages/ServerpulsPage.jsx b/src/pages/ServerpulsPage.jsx index 2814735..c3e2d8a 100644 --- a/src/pages/ServerpulsPage.jsx +++ b/src/pages/ServerpulsPage.jsx @@ -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 { useAuth } from '../context/AuthContext' import { PageHeader } from '../components/ui' @@ -50,7 +50,7 @@ function displayPoints(metric, points, total) { function tileModel(key, m) { if (!m) return 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 } switch (key) { case 'cpu': @@ -159,16 +159,21 @@ export default function ServerpulsPage() { const [tick, setTick] = useState(0) // Uebersicht, Projekte, Aufraeumbar: beim Laden und jede Minute (nur sichtbar) + const reqId = useRef(0) const loadMain = useCallback(async () => { + reqId.current += 1 + const mine = reqId.current try { const [o, a, c] = await Promise.all([ serverpulsApi.overview(), serverpulsApi.apps(), serverpulsApi.cleanup(), ]) + if (mine !== reqId.current) return setOverview(o) setAppsData(a) setCleanup(c) setError('') } catch (e) { + if (mine !== reqId.current) return setError(e.message || 'Serverpuls ist nicht erreichbar.') } setNow(Date.now()) @@ -206,6 +211,11 @@ export default function ServerpulsPage() { return () => { dead = true } }, [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 chartData = useMemo(() => { @@ -286,8 +296,8 @@ export default function ServerpulsPage() {

Verlauf

- - + +
{chartData && chartData.data[0].length > 0 @@ -300,12 +310,12 @@ export default function ServerpulsPage() {

Projekte

{appsData?.sizesDay && Größen von {appsData.sizesDay}} - + {selected && (

{detail?.name || selected}

- +
{detailData ? (
diff --git a/tests/unit/serverpulsAuth.test.mjs b/tests/unit/serverpulsAuth.test.mjs new file mode 100644 index 0000000..43341f4 --- /dev/null +++ b/tests/unit/serverpulsAuth.test.mjs @@ -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) +})