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:
1 parent
85b5f3cedb
commit
f2975e26f0
4 files changed
+226
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user