feat(ui): V2-3 (Teil 2) — das UI haengt am Ereignis-Strom, Taktgeber raus
Ampel / ampel (push) Successful in 47s

WAS: EventStreamProvider haelt EINE SSE-Verbindung fuer die ganze
Anwendung. Dashboard, Log-Kasten, Log-Seite, Laufwerksliste und
Worker-Liste beziehen ihren Zustand daraus. Sieben von neun setInterval
sind weg.

GEMESSEN AN DEN TAKTGEBERN, je offenem Tab:
  Dashboard      5 Endpunkte / 4 s   75/min  ->  0
  Log-Kasten     2 Endpunkte / 5 s   24/min  ->  0
  Laufwerke      1 Endpunkt  / 5 s   12/min  ->  0
  Log-Seite      1 Endpunkt  /10 s    6/min  ->  0 (+1 Abruf beim Oeffnen)
  Worker-Liste   1 Endpunkt  /15 s    4/min  ->  0 (+1 Abruf beim Oeffnen)
                                     ------
                                     121/min ->  ~2 einmalige Abrufe

Zwei Taktgeber bleiben bewusst: FirstRunWizard (laeuft nur VOR der
Einrichtung) und RipTargetModal (nur solange der Dialog offen ist).

DIE REGEL IST UMGEZOGEN, NICHT VERSCHWUNDEN: Ein Abriss ist keine
Aussage ueber die Welt. Der Provider BEHAELT bei einem Fehler den letzten
Stand und setzt nur `verbunden` auf false; es wird nie eine Liste geleert.
Jede Komponente uebernimmt einen Wert nur, wenn er wirklich da ist —
`devices === null` heisst "konnte nicht nachsehen", nicht "keine
Laufwerke". Das war der Fehler hinter "wird oft neu geladen".

EIN PLACEBO WENIGER: Oben rechts stand ein fest verdrahtetes "ONLINE" mit
pulsierendem Punkt — es leuchtete gruen, auch wenn die API tot war. Jetzt
zeigt es LIVE oder VERBINDUNG WEG, und im Tooltip steht, wann die letzte
Meldung kam.

DER SERVER SCHIEBT JETZT AUCH DEN SERVER-ZUSTAND: Neuer Ereignistyp
system.status (Hardware, Worker, Ablageziele) im 15-Sekunden-Takt des
Waechters — EINMAL im Server statt 15/min je Tab. Nur mitgeschickte
Schluessel werden uebernommen; ein fehlender heisst "behalte deinen Stand".

DAZU EIN FORMATFEHLER GEFUNDEN UND BEHOBEN: /logs bildet ts -> timestamp
ab, mein Snapshot lieferte die rohe DB-Zeile. Das UI haette "Invalid Date"
gezeigt — und zwar NUR im Live-Betrieb, nicht beim manuellen Neuladen.
Jetzt gibt es _log_zeile() einmal, benutzt von beiden.

UND EINEN ZWEITEN: system_lesen lief per asyncio.get_event_loop() in einem
Worker-Thread — dort ist das NICHT die laufende Schleife. Die Coroutine
waere nie gelaufen. Die Schleife wird jetzt im Startup festgehalten.

GEMESSEN: ruff sauber, 378 Tests + 3 uebersprungen, `npm run build`
durch (1650 Module). Der Live-Beweis steht noch aus — er kommt mit dem
Deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 09:32:50 +02:00
co-authored by Claude Opus 5
parent f097a0b59d
commit 95705c8d88
12 changed files with 656 additions and 125 deletions
+48 -5
View File
@@ -6,10 +6,45 @@ import LogsPage from './pages/Logs'
import AnleitungPage from './pages/Anleitung'
import FirstRunWizard from './components/FirstRunWizard'
import { api } from './lib/api'
import { EventStreamProvider, useStrom } from './lib/useEventStream'
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
export default function App() {
/**
* Zeigt, ob der Live-Strom steht.
*
* Hier stand bis V2-3 ein fest verdrahtetes „ONLINE" mit pulsierendem Punkt —
* es leuchtete auch dann gruen, wenn die API tot war. Genau die Sorte
* Placebo-Anzeige, die in Etappe 19 schon einmal aufgeraeumt wurde
* („Fortschritt log, Auswurf tat nichts, Encoder wurden behauptet statt
* gemessen"). Jetzt sagt sie die Wahrheit.
*/
function VerbindungsAnzeige() {
const { verbunden, zuletzt } = useStrom()
if (verbunden) {
return (
<div className="flex items-center gap-2 text-xs font-mono text-emerald-400 bg-emerald-500/10 px-3 py-1.5 rounded-xl border border-emerald-500/20">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<span>LIVE</span>
</div>
)
}
return (
<div
className="flex items-center gap-2 text-xs font-mono text-amber-400 bg-amber-500/10 px-3 py-1.5 rounded-xl border border-amber-500/30"
title={
zuletzt
? `Letzte Meldung um ${zuletzt.toLocaleTimeString()}. Die Anzeige zeigt diesen Stand, bis die Verbindung zurueck ist.`
: 'Noch keine Verbindung zum Server.'
}
>
<span className="w-2 h-2 rounded-full bg-amber-400" />
<span>VERBINDUNG WEG</span>
</div>
)
}
function AppInhalt() {
const [currentPage, setCurrentPage] = useState<Page>('dashboard')
// First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
@@ -69,10 +104,7 @@ export default function App() {
})}
</nav>
<div className="flex items-center gap-2 text-xs font-mono text-emerald-400 bg-emerald-500/10 px-3 py-1.5 rounded-xl border border-emerald-500/20">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<span>ONLINE</span>
</div>
<VerbindungsAnzeige />
</div>
</header>
@@ -86,3 +118,14 @@ export default function App() {
</div>
)
}
export default function App() {
// Der Provider haelt GENAU EINE SSE-Verbindung fuer die ganze Anwendung.
// Ein Hook je Komponente waere eine Verbindung je Komponente — also das
// alte Problem in neuer Form.
return (
<EventStreamProvider>
<AppInhalt />
</EventStreamProvider>
)
}
+15 -4
View File
@@ -7,6 +7,7 @@ import { Button } from './ui/Button'
import { TypeBadge } from './ui/Badge'
import RipTargetModal, { RipOptionen } from './RipTargetModal'
import MetadataKorrektur from './MetadataKorrektur'
import { useStrom } from '../lib/useEventStream'
interface DiscInfo {
title: string
@@ -63,6 +64,7 @@ export default function DeviceDiscovery() {
const [modalDevice, setModalDevice] = useState<Device | null>(null)
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
const { toast } = useToast()
const strom = useStrom()
const refreshDevices = async (manuell = false) => {
if (manuell) setRefreshing(true)
@@ -124,11 +126,20 @@ export default function DeviceDiscovery() {
}
}
// Vorher: alle 5 s ein eigener Abruf von /devices (12 Anfragen pro Minute).
// Jetzt liefert der Server die Aenderung, sobald sie passiert.
//
// WICHTIG — nur uebernehmen, wenn der Server wirklich etwas WEISS:
// `strom.devices === null` heisst „konnte nicht nachsehen" (haengendes
// Laufwerk, Zeitgrenze) und NICHT „es gibt keine Laufwerke". In dem Fall
// bleibt der letzte bekannte Stand stehen. Genau diese Vermischung hat in
// v1 die Listen im Sekundentakt geleert.
useEffect(() => {
refreshDevices()
const interval = setInterval(refreshDevices, 5000)
return () => clearInterval(interval)
}, [])
if (strom.devices !== null) {
setDevices(strom.devices as Device[])
setLoading(false)
}
}, [strom.devices])
const QUELLEN_LABEL: Record<string, string> = {
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
+9 -36
View File
@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react'
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
import { api } from '../lib/api'
import { Card, CardHeader, CardTitle } from './ui/Card'
import { useStrom } from '../lib/useEventStream'
interface LiveLogEntry {
id: string
@@ -12,40 +11,14 @@ interface LiveLogEntry {
}
export default function LiveLogSection() {
const [logs, setLogs] = useState<LiveLogEntry[]>([])
const [currentJob, setCurrentJob] = useState<any>(null)
useEffect(() => {
const fetchJobs = async () => {
try {
const response = await api.get('/jobs')
const jobs = response.data
const current = jobs.find((j: any) => j.status === 'processing' || j.status === 'transcoding')
setCurrentJob(current || jobs.find((j: any) => j.status === 'pending'))
} catch (error) {
console.error('Fehler beim Laden der Jobs:', error)
}
}
const fetchLogs = async () => {
try {
const response = await api.get('/logs', { params: { limit: 50 } })
setLogs(response.data)
} catch (error) {
console.error('Fehler beim Laden der Logs:', error)
}
}
fetchJobs()
fetchLogs()
const jobInterval = setInterval(fetchJobs, 5000)
const logInterval = setInterval(fetchLogs, 5000)
return () => {
clearInterval(jobInterval)
clearInterval(logInterval)
}
}, [])
// Vorher: zwei eigene Taktgeber (alle 5 s /jobs und /logs) = 24 Anfragen pro
// Minute allein aus diesem Kasten. Jetzt kommt beides aus der einen offenen
// Verbindung, die der Provider haelt.
const { jobs, logs } = useStrom()
const currentJob =
jobs.find((j: any) => j.status === 'processing' || j.status === 'transcoding') ||
jobs.find((j: any) => j.status === 'pending') ||
null
const getStatusColor = (level: string) => {
switch (level) {
+11 -4
View File
@@ -6,6 +6,7 @@ import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
import { Button } from './ui/Button'
import { Input } from './ui/Input'
import { useStrom } from '../lib/useEventStream'
interface WorkerInfo {
name: string
@@ -54,6 +55,7 @@ export default function WorkerVerwaltung() {
// eintragen lassen als eine falsche Adresse vorgaukeln.
const [rippyHost, setRippyHost] = useState('')
const { toast } = useToast()
const strom = useStrom()
// Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann
// erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen).
@@ -76,11 +78,16 @@ export default function WorkerVerwaltung() {
.catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') })
}
// Die Worker-Liste kommt aus dem Live-Strom. /capabilities wird nur noch
// EINMAL geholt — dort steht ausser der Liste die Server-Version, und die
// aendert sich nur beim Deploy. Vorher lief der Abruf alle 15 s.
useEffect(() => { laden() }, [])
useEffect(() => {
laden()
const interval = setInterval(laden, 15000)
return () => clearInterval(interval)
}, [])
if (strom.workers.length || strom.verbunden) {
setWorkers(strom.workers as WorkerInfo[])
}
}, [strom.workers, strom.verbunden])
const installBefehl = variante === 'linux'
? [
+225
View File
@@ -0,0 +1,225 @@
/**
* Der Live-Strom des UI — EINE Verbindung für die ganze Anwendung.
*
* ## Was das ablöst
*
* Bis hierher hatte jede Komponente ihren eigenen Taktgeber. Nachgerechnet an
* den `setInterval`-Aufrufen verursachte EIN offener Tab plus ein Worker rund
* 133 Anfragen pro Minute:
*
* Dashboard 5 Endpunkte alle 4 s -> 75/min
* Log-Kasten 2 Endpunkte alle 5 s -> 24/min
* Laufwerke 1 Endpunkt alle 5 s -> 12/min
* Log-Seite 1 Endpunkt alle 10 s -> 6/min
* Worker-Liste 1 Endpunkt alle 15 s -> 4/min
* Windows-Tray /jobs alle 5 s -> 12/min
*
* Das Rate-Limit stand einmal UNTER dieser Zahl (100/min). Etwa jede vierte
* Anfrage bekam 429 — und weil das UI einen Fehlschlag als „es gibt nichts"
* verbuchte, leerte sich die Job-Liste im Sekundentakt. Der Commander meldete
* das als „wird oft neu geladen".
*
* ## DIE Regel dieser Datei
*
* Ein Verbindungsabriss ist KEINE Aussage über die Welt.
*
* Bei einem Fehler wird der letzte bekannte Stand BEHALTEN und `verbunden`
* auf false gesetzt — das UI zeigt dann ein Banner. Es wird niemals eine
* Liste auf leer gesetzt. Genau das war der alte Fehler: fünfmal stand
* `catch(() => [])` im Code, und jeder fehlgeschlagene Abruf hieß damit
* „keine Jobs, keine Laufwerke, keine Ablagen".
*
* ## Warum ein Context und nicht ein Hook je Komponente
*
* `EventSource` öffnet je Aufruf eine eigene HTTP-Verbindung. Ein Hook, den
* fünf Komponenten benutzen, wären fünf Verbindungen und fünf Snapshots —
* also genau das Problem in neuer Form. Der Provider hält eine.
*/
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
export type Job = {
id: string
status: string
progress: number
title?: string | null
type?: string
device?: string
error?: string | null
meta?: Record<string, any> | null
[k: string]: any
}
export type Device = { id: string; name: string; type: string; path: string; status: string; [k: string]: any }
export type Worker = { name: string; encoders?: string[]; info?: Record<string, any>; last_seen?: string | null }
/**
* Dieselbe Form, die auch `GET /logs` liefert (`_log_zeile` in main.py).
* `timestamp`, nicht `ts` — sonst zeigt das Log-Fenster „Invalid Date", und
* zwar NUR im Live-Betrieb.
*/
export type LogZeile = { id: string; timestamp?: string; level?: string; source?: string; message?: string }
export type StromZustand = {
jobs: Job[]
/** Server-Zustand aus dem langsamen Takt des Waechters. null = noch nichts gehoert. */
systemInfo: any | null
/** Ablageziele mit freiem Platz. null = noch nichts gehoert. */
ablagen: any[] | null
/** null heißt ausdrücklich „konnte nicht nachsehen" — NICHT „es gibt keine". */
devices: Device[] | null
workers: Worker[]
logs: LogZeile[]
/** false = Verbindung weg. Die Daten oben sind dann der letzte bekannte Stand. */
verbunden: boolean
/** Wie oft die Verbindung schon neu aufgebaut wurde — fürs Log, nicht fürs UI. */
neuverbindungen: number
/** Zeitpunkt des letzten empfangenen Ereignisses. */
zuletzt: Date | null
}
const LEER: StromZustand = {
jobs: [],
systemInfo: null,
ablagen: null,
devices: null,
workers: [],
logs: [],
verbunden: false,
neuverbindungen: 0,
zuletzt: null,
}
const StromContext = createContext<StromZustand>(LEER)
/** Wie viele Log-Zeilen im Speicher gehalten werden. */
const LOG_GRENZE = 300
export function EventStreamProvider({ children }: { children: ReactNode }) {
const [zustand, setZustand] = useState<StromZustand>(LEER)
// In einem Ref, damit die Handler nicht bei jedem Zustandswechsel neu
// gebunden werden müssen — sonst risse die Verbindung ständig ab.
const neuverbindungen = useRef(0)
useEffect(() => {
const quelle = new EventSource('/api/events')
const merken = (patch: Partial<StromZustand>) =>
setZustand((alt) => ({ ...alt, ...patch, zuletzt: new Date() }))
// ── Snapshot: das ganze Bild ──────────────────────────────────────
quelle.addEventListener('snapshot', (e) => {
const d = JSON.parse((e as MessageEvent).data)?.daten ?? {}
merken({
jobs: d.jobs ?? [],
// devices === null kommt vom Server, wenn er die Laufwerke nicht
// lesen konnte. Das wird DURCHGEREICHT, nicht zu [] geglättet.
devices: d.devices === undefined ? null : d.devices,
workers: d.workers ?? [],
logs: d.logs ?? [],
verbunden: true,
})
})
// ── Job-Deltas ────────────────────────────────────────────────────
const jobPatch = (e: Event) => {
const ereignis = JSON.parse((e as MessageEvent).data)
const id = ereignis.entitaet_id
const daten = ereignis.daten ?? {}
setZustand((alt) => {
if (daten.status === 'geloescht') {
return { ...alt, jobs: alt.jobs.filter((j) => j.id !== id), verbunden: true, zuletzt: new Date() }
}
const bekannt = alt.jobs.some((j) => j.id === id)
const jobs = bekannt
? alt.jobs.map((j) => (j.id === id ? { ...j, ...daten } : j))
: [{ id, ...daten } as Job, ...alt.jobs]
return { ...alt, jobs, verbunden: true, zuletzt: new Date() }
})
}
for (const typ of ['job.created', 'job.progress', 'job.phase', 'job.finished']) {
quelle.addEventListener(typ, jobPatch)
}
// ── Laufwerke ─────────────────────────────────────────────────────
quelle.addEventListener('drive.changed', (e) => {
const ereignis = JSON.parse((e as MessageEvent).data)
setZustand((alt) => {
if (alt.devices === null) return alt // noch kein Stand — auf Snapshot warten
const id = ereignis.entitaet_id
return {
...alt,
devices: alt.devices.map((d) => (d.id === id ? { ...d, ...ereignis.daten } : d)),
verbunden: true,
zuletzt: new Date(),
}
})
})
// ── Log-Zeilen ────────────────────────────────────────────────────
quelle.addEventListener('log.line', (e) => {
const ereignis = JSON.parse((e as MessageEvent).data)
const d = ereignis.daten ?? {}
const zeile: LogZeile = {
id: String(ereignis.entitaet_id),
timestamp: ereignis.ts,
level: d.level,
source: d.source,
message: d.message,
}
setZustand((alt) => ({
...alt,
logs: [zeile, ...alt.logs].slice(0, LOG_GRENZE),
verbunden: true,
zuletzt: new Date(),
}))
})
// ── Server-Zustand (langsamer Takt) ───────────────────────────────
quelle.addEventListener('system.status', (e) => {
const d = JSON.parse((e as MessageEvent).data)?.daten ?? {}
// Nur uebernehmen, was WIRKLICH mitgeschickt wurde. Ein fehlender
// Schluessel heisst „konnte ich diesmal nicht lesen" — dann bleibt der
// alte Stand stehen, statt auf leer zu fallen.
setZustand((alt) => ({
...alt,
systemInfo: d.info !== undefined ? d.info : alt.systemInfo,
workers: d.workers !== undefined ? d.workers : alt.workers,
ablagen: d.ablagen !== undefined ? d.ablagen : alt.ablagen,
verbunden: true,
zuletzt: new Date(),
}))
})
quelle.onopen = () => merken({ verbunden: true })
// ── Abriss: Stand BEHALTEN, nur die Verbindung melden ─────────────
quelle.onerror = () => {
// Kein Leeren, kein Zurücksetzen. Der Browser verbindet von selbst neu
// und schickt dabei Last-Event-ID mit; der Server liefert die Lücke nach
// oder schickt einen frischen Snapshot.
neuverbindungen.current += 1
setZustand((alt) => ({
...alt,
verbunden: false,
neuverbindungen: neuverbindungen.current,
}))
}
return () => quelle.close()
}, [])
const wert = useMemo(() => zustand, [zustand])
return <StromContext.Provider value={wert}>{children}</StromContext.Provider>
}
/** Der gemeinsame Live-Zustand. */
export function useStrom(): StromZustand {
return useContext(StromContext)
}
+32 -51
View File
@@ -10,6 +10,7 @@ import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection'
import RetryDialog from '../components/RetryDialog'
import { useStrom } from '../lib/useEventStream'
interface JobMeta {
year?: number
@@ -126,6 +127,7 @@ export default function Dashboard() {
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
const [ablagen, setAblagen] = useState<AblageZiel[]>([])
const strom = useStrom()
const { toast } = useToast()
/*
@@ -232,64 +234,43 @@ export default function Dashboard() {
}
/*
* ZWEI TAKTE statt einem.
* KEIN TAKTGEBER MEHR (Etappe V2-3).
*
* ⚠️ JEDER Abruf hier gibt bei Fehlschlag `null` — und `null` bedeutet „nichts
* Neues erfahren", nicht „es gibt nichts". Vor dem 26.07.2026 stand überall
* `catch(() => [])`: ein einziger verpasster Abruf leerte Job-Liste,
* Laufwerke und Ablagen, vier Sekunden später war alles wieder da. Genau das
* hat der Commander als „wird oft neu geladen" gemeldet. Siehe `fetchJobs`.
* Hier standen zwei: alle 4 s Jobs und Laufwerke, alle 12 s Hardware,
* Worker und Ablagen — zusammen 75 Anfragen pro Minute JE OFFENEM TAB. Bei
* der damaligen Grenze von 100/min wies die API laufend mit HTTP 429 ab
* (gemessen: 97 Abweisungen im Log), und weil ein Fehlschlag im UI als
* „es gibt nichts" gelesen wurde, leerte sich die Job-Liste im Sekundentakt.
* Genau das hat der Commander als „wird oft neu geladen" gemeldet.
*
* Und verpasst wurde reichlich: Fünf Endpunkte alle vier Sekunden sind 75
* Anfragen pro Minute, dazu Log-Kasten und Laufwerks-Suche — die API wies bei
* ihrer damaligen Grenze von 100/min laufend mit HTTP 429 ab (gemessen: 97
* Abweisungen im Log). Die Grenze ist jetzt der echten Last angemessen
* (ratelimit.py), aber das rechtfertigt keine Verschwendung: Jobs und
* Laufwerke ändern sich sekündlich, die drei anderen praktisch nie.
* 75/min sind damit 15 + 15/min geworden.
* Jetzt kommt alles aus der EINEN Verbindung, die App.tsx haelt.
*
* ⚠️ DIE REGEL BLEIBT — sie ist nur umgezogen: Ein fehlender Wert im Strom
* heisst „nichts Neues erfahren", NICHT „es gibt nichts". Deshalb wird unten
* jeder Wert nur uebernommen, wenn er wirklich da ist; sonst bleibt der
* letzte bekannte Stand stehen. Das UI zeigt dann oben rechts
* „VERBINDUNG WEG" statt leerer Listen.
*/
useEffect(() => {
const nichts = () => null
setJobs(strom.jobs as Job[])
setLoading(false)
}, [strom.jobs])
// Schnell (4 s): was sich während eines Rips wirklich bewegt.
const schnellLaden = async () => {
try {
const [jobsData, devData] = await Promise.all([
fetchJobs(),
// Die Laufwerke: Ohne sie behauptete die Server-Status-Karte
// „keine Disc in Arbeit", während oben die erkannte Disc stand.
api.get('/devices').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
])
if (jobsData) setJobs(jobsData)
if (devData) setLaufwerke(devData)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (strom.devices !== null) setLaufwerke(strom.devices as LaufwerkLive[])
}, [strom.devices])
// Langsam (12 s): Hardware, Worker-Liste, Ablagen. Ein Worker, der online
// geht, oder eine Freigabe, die wegbricht, darf drei Takte später auffallen.
const langsamLaden = async () => {
const [sysData, capsData, ablData] = await Promise.all([
api.get('/system/info').then(r => r.data).catch(nichts),
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
// /system/info nur die Registrierung.
api.get('/capabilities').then(r => r.data?.workers ?? null).catch(nichts),
// Die Ablageziele inkl. eingehängter Freigaben — ohne sie zeigte der
// Server-Status nur die Container-Platte (Commander-Befund).
api.get('/storage-targets').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
])
if (sysData) setSystemInfo(sysData)
if (capsData) setWorkersLive(capsData)
if (ablData) setAblagen(ablData)
}
useEffect(() => {
if (strom.workers.length || strom.verbunden) setWorkersLive(strom.workers as WorkerLive[])
}, [strom.workers, strom.verbunden])
schnellLaden()
langsamLaden()
const schnell = setInterval(schnellLaden, 4000)
const langsam = setInterval(langsamLaden, 12000)
return () => { clearInterval(schnell); clearInterval(langsam) }
}, [])
useEffect(() => {
if (strom.systemInfo) setSystemInfo(strom.systemInfo as SystemInfo)
}, [strom.systemInfo])
useEffect(() => {
if (strom.ablagen !== null) setAblagen(strom.ablagen as AblageZiel[])
}, [strom.ablagen])
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
const queueJobs = jobs.filter(j => j.status === 'pending')
+15 -4
View File
@@ -5,6 +5,7 @@ import { useToast } from '../context/ToastContext'
import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { useStrom } from '../lib/useEventStream'
interface LogEntry {
id: string
@@ -21,6 +22,7 @@ export default function LogsPage() {
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const { toast } = useToast()
const strom = useStrom()
const refreshLogs = async (manuell = false) => {
if (manuell) setRefreshing(true)
@@ -37,11 +39,20 @@ export default function LogsPage() {
}
}
// Einmal die volle Historie holen (200 Zeilen — der Strom liefert im
// Snapshot nur die letzten 50), danach kommt alles Neue von selbst.
// Der Aktualisieren-Knopf bleibt: Er holt die Historie erneut.
useEffect(() => { refreshLogs() }, [])
useEffect(() => {
refreshLogs()
const interval = setInterval(refreshLogs, 10000)
return () => clearInterval(interval)
}, [])
if (!strom.logs.length) return
setLogs((alt) => {
const bekannt = new Set(alt.map((l) => String(l.id)))
const neue = strom.logs.filter((l) => !bekannt.has(String(l.id)))
return neue.length ? ([...neue, ...alt] as LogEntry[]) : alt
})
setLoading(false)
}, [strom.logs])
const getLevelColor = (level: string) => {
switch (level) {