Workers P2: Kennzahlen, Worker-Karte mit Spur und Worker-Liste

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 03:58:22 +00:00
1 parent 85b5f3cedb
commit f2975e26f0
4 files changed
+226

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import Badge from '../ui/Badge'
import { counterLines, formatCount, formatUsd, safeLink, statusTone, tokenTotal, traceRows, workerStatusLabel } from '../../lib/workersView'
import { MODEL_IDS, hatForWorker, roleOf } from '../../lib/workersRoster'
import { drawWorker, setGrid } from './engine/sprites'
const DASH = '–'
// Modell-Chip je Hut (Wörterbuch statt zusammengesetzter Klasse: der CSS-Test in workers.css findet die Namen)
const CHIP = {
opus: 'wk-chip-opus',
sonnet: 'wk-chip-sonnet',
haiku: 'wk-chip-haiku',
overseer: 'wk-chip-overseer',
}
const PORTRAIT_W = 72
const PORTRAIT_H = 96
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
// Status aus dem Zustandseintrag; ein nicht angeschlossener Worker ist grau (wie in indexWorkers)
function statusOf(worker) {
if (!isRecord(worker)) return ''
return worker.connected === false ? 'grau' : worker.status
}
// Porträt der Figur auf einem eigenen Canvas (Raster 4, Figur bei 6/8)
function Portrait({ hat, grey }) {
const ref = useRef(null)
useEffect(() => {
const canvas = ref.current
const ctx = canvas && canvas.getContext ? canvas.getContext('2d') : null
if (!ctx) return
ctx.clearRect(0, 0, PORTRAIT_W, PORTRAIT_H)
setGrid(ctx, { k: 4 })
drawWorker(ctx, 6, 8, { hat, grey, scale: 1 })
}, [hat, grey])
return <canvas ref={ref} className="wk-portrait" width={PORTRAIT_W} height={PORTRAIT_H} aria-hidden="true" />
}
// Einziger Ort der Karte, an dem ein Ziel für einen Link entsteht: nur was safeLink durchlässt wird ein Link
// (extern in neuem Tab mit noopener, intern über den Router), alles andere bleibt Text.
function TraceText({ link, children }) {
const safe = link ? safeLink(link.href) : null
if (!safe) return <>{children}</>
if (safe.external) return <a href={safe.href} target="_blank" rel="noopener noreferrer">{children}</a>
return <Link to={safe.href}>{children}</Link>
}
// Karte eines Workers: Porträt, Rolle, Modell, Status, Tageswerte und die letzten Aktionen.
// worker = Zustandseintrag (status, task, counters, connected), trace = Antwort von api.trace (oder null).
export default function WorkerCard({ workerId, worker, trace, loading, error, onClose }) {
if (!workerId) {
return (
<div className="wk-card">
<p className="wk-hint">Klicke eine Figur oder wähle einen Worker aus der Liste.</p>
</div>
)
}
const role = roleOf(workerId)
const info = trace && isRecord(trace.worker) ? trace.worker : null
const name = (info && info.name) || (role && role.name) || workerId
const roleText = (info && info.role) || (role && role.role) || ''
const modelId = (info && info.modelId) || (role ? MODEL_IDS[role.model] : '')
const hat = hatForWorker(workerId)
const status = statusOf(worker)
const today = trace && isRecord(trace.today) ? trace.today : null
const counters = counterLines(today && isRecord(today.counters) ? today.counters : worker && worker.counters)
const task = isRecord(worker) && typeof worker.task === 'string' && worker.task.trim() !== '' ? worker.task : DASH
const rows = traceRows(trace && trace.events, Date.now())
return (
<section className="wk-card" aria-label="Worker-Karte">
{onClose && <button type="button" className="btn btn-quiet" onClick={onClose} aria-label="Karte schließen">×</button>}
<Portrait hat={hat} grey={status === 'grau'} />
<h2 className="wk-name">{name}</h2>
{roleText && <p className="wk-role">{roleText}</p>}
<p>
{modelId && <span className={`wk-chip ${CHIP[hat]}`}>{modelId}</span>}
<Badge tone={statusTone(status)}>{workerStatusLabel(status)}</Badge>
</p>
{error && <div className="msg err" role="alert">{error}</div>}
<dl className="wk-facts">
<div><dt>Macht gerade</dt><dd>{task}</dd></div>
<div><dt>Tokens heute</dt><dd>{formatCount(today ? tokenTotal(today.tokens) : null)}</dd></div>
<div><dt>Kosten heute</dt><dd>{formatUsd(today ? today.costUsd : null)}</dd></div>
</dl>
{counters.length > 0 && (
<div className="wk-counters">
<h3>Zähler heute</h3>
<dl>
{counters.map((c) => (
<div key={c.key}><dt>{c.label}</dt><dd>{c.value}</dd></div>
))}
</dl>
</div>
)}
{(loading || trace) && (
<>
<h3>Letzte Aktionen</h3>
{loading && <p className="wk-hint">Spur wird geladen …</p>}
{!loading && rows.length === 0 && <p className="wk-hint">Noch keine Aktionen.</p>}
{rows.length > 0 && (
<ol className="wk-trace">
{rows.map((row, i) => (
<li key={`${row.id}-${i}`}>
<span className="wk-trace-time">{row.time}</span>{' '}
<TraceText link={row.link}>{row.text}</TraceText>
</li>
))}
</ol>
)}
</>
)}
</section>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { workerStatusLabel } from '../../lib/workersView'
import { FLOORS, FLOOR_TITLES, ROLES } from '../../lib/workersRoster'
// Statuspunkt je Dienst-Status (Wörterbuch statt zusammengesetzter Klasse: der CSS-Test in workers.css findet die Namen)
const DOT = {
arbeitet: 'wk-dot-arbeitet',
wartet: 'wk-dot-wartet',
leerlauf: 'wk-dot-leerlauf',
grau: 'wk-dot-grau',
fehler: 'wk-dot-fehler',
}
// Etagen von oben nach unten mit ihren Workern; Etagen ohne Worker (Pult, Tresor) entfallen
const GROUPS = FLOORS.map((floor) => ({ floor, roles: ROLES.filter((r) => r.floor === floor) })).filter((g) => g.roles.length > 0)
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
// workers = Tabelle nach workerId (indexWorkers/reduceEvents) oder die Liste aus state.workers; unbekannt -> null
function entryOf(workers, workerId) {
if (Array.isArray(workers)) return workers.find((w) => isRecord(w) && w.workerId === workerId) ?? null
return isRecord(workers) && Object.hasOwn(workers, workerId) && isRecord(workers[workerId]) ? workers[workerId] : null
}
// Status aus dem Eintrag; ein nicht angeschlossener Worker ist grau (wie in indexWorkers)
function statusOf(entry) {
if (!entry) return ''
return entry.connected === false ? 'grau' : entry.status
}
// Alle Worker je Etage als Knopfliste. Ein Klick meldet die workerId über onSelect, der gewählte Knopf ist aria-pressed.
export default function WorkerList({ workers, selectedId, onSelect }) {
return (
<div className="wk-list">
{GROUPS.map((group) => (
<section key={group.floor}>
<h3 className="wk-list-floor">{FLOOR_TITLES[group.floor]}</h3>
<ul>
{group.roles.map((role) => {
const status = statusOf(entryOf(workers, role.workerId))
const label = workerStatusLabel(status)
return (
<li key={role.workerId}>
<button
type="button"
data-wk-worker={role.workerId}
aria-pressed={selectedId === role.workerId}
aria-label={`${role.name}, ${label}`}
onClick={() => { if (onSelect) onSelect(role.workerId) }}
>
<span className={Object.hasOwn(DOT, status) ? `wk-dot ${DOT[status]}` : 'wk-dot'} aria-hidden="true" />
<span>{role.name}</span>
<span>{label}</span>
</button>
</li>
)
})}
</ul>
</section>
))}
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
import Stat from '../ui/Stat'
import { kpiTiles } from '../../lib/workersView'
// Sechs Kennzahlen des Hustler-Dienstes (state.kpis). Fehlende Daten ergeben Striche, nie NaN (kpiTiles).
export default function WorkersKpis({ kpis }) {
return (
<div className="stats wk-kpis">
{kpiTiles(kpis).map((tile) => (
<Stat key={tile.key} label={tile.label} value={tile.value} sub={tile.sub} tone={tile.tone} />
))}
</div>
)
}
+26
View File
@@ -0,0 +1,26 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
// Die React-Teile der Hustler-Seite sind unter Node nicht ladbar (JSX, Canvas, Router): geprüft wird der Quelltext
// (Muster adminRoute.test.mjs). Pfade relativ zu src/components/workers/.
const read = (p) => readFileSync(new URL('../../src/components/workers/' + p, import.meta.url), 'utf8')
test('WorkerCard: Links nur über safeLink, extern mit rel noopener; deutsche Beschriftungen (Review Focus 5)', () => {
const src = read('WorkerCard.jsx')
assert.match(src, /safeLink\(/); assert.match(src, /rel="noopener noreferrer"/); assert.match(src, /target="_blank"/)
for (const t of ['Macht gerade', 'Tokens heute', 'Kosten heute', 'Letzte Aktionen']) assert.ok(src.includes(t), t)
assert.match(src, /MODEL_IDS/); assert.match(src, /wk-chip-/)
// jeder href und jedes to kommt aus dem safeLink-Ergebnis, nie aus dem Ereignis selbst
assert.match(src, /href=\{safe\.href\}/); assert.match(src, /to=\{safe\.href\}/)
assert.doesNotMatch(src, /\b(?:href|to)=\{(?!safe\.href\})/)
})
test('kein innerHTML in Komponenten und Engine', () => {
for (const dir of ['', 'engine/']) for (const f of readdirSync(new URL('../../src/components/workers/' + dir, import.meta.url))) { if (!/\.(jsx|js)$/.test(f)) continue; const s = read(dir + f); assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/, dir + f) }
})
test('WorkersKpis nutzt Stat und kpiTiles; WorkerList: Knopf je Worker mit data-wk-worker, aria-pressed und Statustext', () => {
assert.match(read('WorkersKpis.jsx'), /kpiTiles\(/); assert.match(read('WorkersKpis.jsx'), /<Stat /)
const l = read('WorkerList.jsx'); assert.match(l, /data-wk-worker=/); assert.match(l, /aria-pressed=/); assert.match(l, /aria-label=/); assert.match(l, /FLOOR_TITLES/)
// die Liste enthält keine Links: ein Klick wählt nur aus
assert.doesNotMatch(l, /<a[\s>]|<Link[\s>]|href=/)
})