Add a Production dashboard with an animated knowledge graph

The sidebar now groups Production under Dashboard and the four existing
pages under Settings, and Production is the landing page. It shows content
in progress, material, content made, approved and LMS-ready counts, plus a
knowledge graph linking Brain documents, results, found sources and
schedules, with the schedule and what is being made right now beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-27 15:57:10 +07:00
parent 6d4850f4ce
commit ddbdbc7d44
8 changed files with 608 additions and 16 deletions

244
src/pages/Production.tsx Normal file
View File

@@ -0,0 +1,244 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { listDocuments } from '../api/engine'
import { Icon } from '../components/Icon'
import { PageHead, Skeleton } from '../components/Page'
import { buildGraph, CLUSTERS, type Cluster, type Graph, type GraphNode } from '../express/graph'
import { describeRule } from '../express/parse'
import { isWorking, listResults, titleOf } from '../express/results'
import { listSchedules } from '../express/schedules'
import { locale, t, type Key } from '../i18n'
const HUB_LABEL: Record<Cluster, Key> = {
sources: 'prod.hub.sources', made: 'prod.hub.made', working: 'prod.hub.working', research: 'prod.hub.research', schedules: 'prod.hub.schedules',
}
const when = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
// The dashboard: what is in the Brain, what the AI made from it, what is still being made, and the
// knowledge graph that ties them together. Shares its queries with the Brain, Schedule and Results pages.
export function ProductionPage() {
const docs = useQuery({ queryKey: ['documents'], queryFn: listDocuments, refetchInterval: 20_000 })
const results = useQuery({
queryKey: ['results'],
queryFn: listResults,
refetchInterval: q => (q.state.data?.materials.some(isWorking) ? 10_000 : 60_000),
})
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
const materials = results.data?.materials ?? []
const working = materials.filter(isWorking)
const made = materials.filter(m => !isWorking(m) && m.status !== 'failed')
const approved = materials.filter(m => m.status === 'accepted')
// No LMS is connected yet: an approved SCORM module is the package that goes into one.
const lms = approved.filter(m => m.kind === 'scorm')
const inBrain = docs.data?.filter(d => d.knowledge === 'ready').length ?? 0
const graph = useMemo(
() => (docs.data && results.data && schedules.data
? buildGraph({ docs: docs.data, materials: results.data.materials, sources: results.data.sources, schedules: schedules.data }, c => t(HUB_LABEL[c]))
: null),
[docs.data, results.data, schedules.data],
)
const num = (n: number, ready: boolean) => (ready ? <strong>{n.toLocaleString(locale())}</strong> : <Skeleton className="skel-num" />)
return (
<>
<PageHead title={t('prod.title')} description={t('prod.description')} />
<div className="metrics prod-metrics">
<div className="metric">
<span><span className="live-dot" />{t('prod.metricWorking')}</span>{num(working.length, !!results.data)}<small>{t('prod.metricWorkingSub')}</small>
</div>
<div className="metric"><span>{t('prod.metricMaterial')}</span>{num(docs.data?.length ?? 0, !!docs.data)}<small>{t('prod.metricMaterialSub', { n: inBrain })}</small></div>
<div className="metric"><span>{t('prod.metricMade')}</span>{num(made.length, !!results.data)}<small>{t('prod.metricMadeSub')}</small></div>
<div className="metric"><span>{t('prod.metricLive')}</span>{num(approved.length, !!results.data)}<small>{t('prod.metricLiveSub')}</small></div>
<div className="metric"><span>{t('prod.metricLms')}</span>{num(lms.length, !!results.data)}<small>{t('prod.metricLmsSub')}</small></div>
</div>
<section className="graph-stage" aria-label={t('prod.graphTitle')}>
{graph
? <KnowledgeGraph graph={graph} />
: <div className="graph-loading" role="status">{docs.isError || results.isError ? t('brain.engineDown') : t('prod.loadingGraph')}</div>}
<aside className="graph-panel">
<div className="graph-stats">
<div><small>{t('prod.nodes')}</small>{graph ? <b>{graph.nodes.filter(n => !n.hub).length}</b> : <b>-</b>}<span>{t('prod.nodesSub')}</span></div>
<div><small>{t('prod.links')}</small>{graph ? <b>{graph.links.filter(l => !l.hub).length}</b> : <b>-</b>}<span>{t('prod.linksSub')}</span></div>
</div>
<h3>{t('prod.scheduleTitle')}</h3>
{schedules.data?.length === 0 && <p className="graph-empty">{t('prod.noSchedules')} <Link to="/instruction">{t('prod.giveInstruction')}</Link></p>}
<ul className="graph-list">
{schedules.data?.slice(0, 4).map(s => (
<li key={s.agent.id}>
<time>{s.active && s.agent.next_run_at ? when(s.agent.next_run_at) : '-'}</time>
<span>{describeRule(s.rule)}</span>
<em className={s.active ? 'on' : undefined}>{t(s.active ? 'sch.active' : 'sch.paused')}</em>
</li>
))}
</ul>
<h3>{t('prod.nowTitle')}</h3>
{results.data && working.length === 0 && <p className="graph-empty">{t('prod.nothingNow')}</p>}
<ul className="graph-list now">
{working.slice(0, 6).map(m => (
<li key={`${m.kind}-${m.agentId}-${m.id}`}>
<i className="node-dot working" />
<span>{titleOf(m)}</span>
<em>{t(`res.state.${m.status as 'preparing' | 'generating'}`)}</em>
</li>
))}
</ul>
{working.length > 6 && <Link className="graph-more" to="/results">{t('prod.more', { n: working.length - 6 })}</Link>}
</aside>
</section>
</>
)
}
// Motion: nodes grow out of their hub on first draw, then drift gently around their place while
// light travels along the links between clusters. Positions are written straight to the DOM each
// frame, so hovering or selecting (React state) never fights the animation.
const DRIFT = 5
const MAX_PARTICLES = 80
const ease = (x: number) => 1 - Math.pow(1 - Math.min(1, Math.max(0, x)), 3)
function curve(ax: number, ay: number, bx: number, by: number, bend: number) {
const mx = (ax + bx) / 2, my = (ay + by) / 2
return { cx: mx - (by - ay) * bend, cy: my + (bx - ax) * bend }
}
function KnowledgeGraph({ graph }: { graph: Graph }) {
const [selected, setSelected] = useState<GraphNode | null>(null)
const [hover, setHover] = useState<string | null>(null)
const nodeEls = useRef<(SVGGElement | null)[]>([])
const linkEls = useRef<(SVGPathElement | null)[]>([])
const particleEls = useRef<(SVGCircleElement | null)[]>([])
const index = useMemo(() => new Map(graph.nodes.map((n, i) => [n.id, i])), [graph])
const pairs = useMemo(() => graph.links.map((l, i) => ({
a: index.get(l.source)!, b: index.get(l.target)!, hub: l.hub, bend: l.hub ? 0.08 * (i % 2 ? 1 : -1) : 0.18 * (i % 2 ? 1 : -1),
})), [graph, index])
const particles = useMemo(() => pairs.map((p, i) => ({ ...p, link: i })).filter(p => !p.hub).slice(0, MAX_PARTICLES), [pairs])
const degree = useMemo(() => {
const d = new Map<string, number>()
for (const l of graph.links) if (!l.hub) { d.set(l.source, (d.get(l.source) ?? 0) + 1); d.set(l.target, (d.get(l.target) ?? 0) + 1) }
return d
}, [graph])
const stars = useMemo(() => Array.from({ length: 70 }, (_, i) => {
const r = (k: number) => (Math.sin(i * 127.1 + k * 311.7) * 43758.5453) % 1
return { x: Math.abs(r(1)) * graph.width, y: Math.abs(r(2)) * graph.height, s: 0.4 + Math.abs(r(3)) * 1.1, d: Math.abs(r(4)) * 6 }
}), [graph])
useLayoutEffect(() => {
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const hubOf = graph.nodes.map(n => index.get(`hub-${n.cluster}`)!)
const phase = graph.nodes.map((_, i) => i * 2.399)
const x = new Float64Array(graph.nodes.length)
const y = new Float64Array(graph.nodes.length)
const t0 = performance.now()
let frame = 0
const draw = (now: number) => {
const t = still ? 1e9 : now - t0
graph.nodes.forEach((n, i) => {
const hub = graph.nodes[hubOf[i]]
const grow = n.hub ? ease(t / 700) : ease((t - 250 - (i % 24) * 25) / 1100)
const drift = still ? 0 : (n.hub ? 0.4 : 1) * DRIFT
const k = t / 1000
x[i] = hub.x + (n.x - hub.x) * grow + Math.sin(k * 0.6 + phase[i]) * drift
y[i] = hub.y + (n.y - hub.y) * grow + Math.cos(k * 0.5 + phase[i] * 1.3) * drift
const el = nodeEls.current[i]
if (el) {
el.setAttribute('transform', `translate(${x[i].toFixed(1)} ${y[i].toFixed(1)})`)
el.style.opacity = n.hub ? '' : String(Math.min(1, grow * 1.4))
}
})
pairs.forEach((p, i) => {
const { cx, cy } = curve(x[p.a], y[p.a], x[p.b], y[p.b], p.bend)
linkEls.current[i]?.setAttribute('d', `M${x[p.a].toFixed(1)} ${y[p.a].toFixed(1)}Q${cx.toFixed(1)} ${cy.toFixed(1)} ${x[p.b].toFixed(1)} ${y[p.b].toFixed(1)}`)
})
particles.forEach((p, i) => {
const el = particleEls.current[i]
if (!el) return
if (still) { el.style.display = 'none'; return }
const u = ((t / 1000) * (0.18 + (i % 5) * 0.03) + i * 0.37) % 1
const { cx, cy } = curve(x[p.a], y[p.a], x[p.b], y[p.b], p.bend)
const v = 1 - u
el.setAttribute('cx', (v * v * x[p.a] + 2 * v * u * cx + u * u * x[p.b]).toFixed(1))
el.setAttribute('cy', (v * v * y[p.a] + 2 * v * u * cy + u * u * y[p.b]).toFixed(1))
el.style.opacity = String(Math.sin(u * Math.PI) * Math.min(1, t / 1600))
})
if (!still) frame = requestAnimationFrame(draw)
}
draw(t0)
if (!still) frame = requestAnimationFrame(draw)
return () => cancelAnimationFrame(frame)
}, [graph, index, pairs, particles])
const focus = hover ?? selected?.id ?? null
const neighbours = useMemo(() => {
if (!focus) return null
const set = new Set([focus])
for (const l of graph.links) {
if (l.source === focus) set.add(l.target)
if (l.target === focus) set.add(l.source)
}
return set
}, [focus, graph])
const lit = (p: { a: number; b: number }) => neighbours?.has(graph.nodes[p.a].id) && neighbours.has(graph.nodes[p.b].id)
return (
<>
<div className="graph-note">
<i className="live-dot" />
{selected
? <><b>{selected.label}</b>{!selected.hub && <span>{t(HUB_LABEL[selected.cluster])}</span>}<button type="button" aria-label={t('res.close')} onClick={() => setSelected(null)}><Icon name="close" /></button></>
: <><b>{t('prod.graphTitle')}</b><span>{t('prod.clickNode')}</span></>}
</div>
<svg className={`graph-svg${neighbours ? ' focused' : ''}`} viewBox={`0 0 ${graph.width} ${graph.height}`} role="img"
aria-label={t('prod.graphLabel', { nodes: graph.nodes.length - CLUSTERS.length })} onClick={() => setSelected(null)}>
<defs>
<radialGradient id="graph-glow"><stop offset="0" stopColor="#2e90fa" stopOpacity=".32" /><stop offset=".5" stopColor="#6941c6" stopOpacity=".08" /><stop offset="1" stopColor="#2e90fa" stopOpacity="0" /></radialGradient>
<radialGradient id="graph-core"><stop offset="0" stopColor="#fff" /><stop offset=".55" stopColor="#fff" stopOpacity=".9" /><stop offset="1" stopColor="#fff" stopOpacity=".2" /></radialGradient>
<filter id="graph-bloom" x="-200%" y="-200%" width="500%" height="500%"><feGaussianBlur stdDeviation="3" result="b" /><feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
</defs>
<g className="graph-stars">
{stars.map((s, i) => <circle key={i} cx={s.x} cy={s.y} r={s.s} style={{ animationDelay: `${s.d}s` }} />)}
</g>
<circle className="graph-glow" cx={500} cy={320} r={320} fill="url(#graph-glow)" />
<ellipse className="graph-orbit slow" cx={500} cy={320} rx={430} ry={275} />
<ellipse className="graph-orbit" cx={500} cy={320} rx={300} ry={195} />
<g className="graph-links">
{pairs.map((p, i) => (
<path key={i} ref={el => { linkEls.current[i] = el }} style={{ ['--c' as string]: `var(--g-${graph.nodes[p.a].cluster})` }}
className={`${p.hub ? 'hub' : 'cross'}${lit(p) ? ' lit' : neighbours ? ' dim' : ''}`} />
))}
</g>
<g className="graph-particles">
{particles.map((p, i) => (
<circle key={i} r={1.8} ref={el => { particleEls.current[i] = el }} className={`c-${graph.nodes[p.a].cluster}${neighbours && !lit(p) ? ' dim' : ''}`} />
))}
</g>
<g>
{graph.nodes.map((n, i) => {
const r = n.hub ? 9 : n.cluster === 'schedules' ? 5.5 : 3.2 + Math.min(3, (degree.get(n.id) ?? 0) * 0.7)
return (
<g key={n.id} ref={el => { nodeEls.current[i] = el }}
className={`graph-node c-${n.cluster}${n.hub ? ' hub' : ''}${neighbours && !neighbours.has(n.id) ? ' dim' : ''}${hover === n.id ? ' hover' : ''}${selected?.id === n.id ? ' sel' : ''}`}
onMouseEnter={() => setHover(n.id)} onMouseLeave={() => setHover(null)}
onClick={e => { e.stopPropagation(); setSelected(n) }}>
<circle className="hit" r={r + 8} />
{n.hub && <><circle className="ring" r={r + 4} /><circle className="ring late" r={r + 4} /><circle className="halo" r={26} /></>}
<circle className="core" r={r} filter="url(#graph-bloom)" />
{n.hub && <circle className="dot" r={r * 0.45} />}
<text y={-r - 8}>{n.label.length > 40 ? n.label.slice(0, 38) + '…' : n.label}</text>
<title>{n.label}</title>
</g>
)
})}
</g>
</svg>
<ul className="graph-legend">
{CLUSTERS.map(c => <li key={c}><i className={`node-dot ${c}`} />{t(HUB_LABEL[c])}</li>)}
</ul>
</>
)
}