Serverpuls: JWT-Cache, Lade-Zustand beim Umschalten, Antwort-Reihenfolge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec740ca970
commit
16f3150ff1
4 files changed
+116
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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() {
|
||||
<div className="sp-panel-head">
|
||||
<h3>Verlauf</h3>
|
||||
<div className="sp-controls">
|
||||
<Pills items={METRICS} value={metric} onChange={setMetric} testid="sp-metric-select" />
|
||||
<Pills items={RANGES} value={range} onChange={setRange} testid="sp-range-select" />
|
||||
<Pills items={METRICS} value={metric} onChange={changeMetric} testid="sp-metric-select" />
|
||||
<Pills items={RANGES} value={range} onChange={changeRange} testid="sp-range-select" />
|
||||
</div>
|
||||
</div>
|
||||
{chartData && chartData.data[0].length > 0
|
||||
@@ -300,12 +310,12 @@ export default function ServerpulsPage() {
|
||||
<h3>Projekte</h3>
|
||||
{appsData?.sizesDay && <span className="sp-muted sp-small">Größen von {appsData.sizesDay}</span>}
|
||||
</div>
|
||||
<AppsTable rows={appsData?.rows || []} selectedId={selected} onSelect={setSelected} />
|
||||
<AppsTable rows={appsData?.rows || []} selectedId={selected} onSelect={selectApp} />
|
||||
{selected && (
|
||||
<div className="sp-detail" data-testid="sp-detail">
|
||||
<div className="sp-panel-head">
|
||||
<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>
|
||||
{detailData ? (
|
||||
<div className="sp-detail-charts">
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user