Initial commit

This commit is contained in:
2026-08-24 15:35:32 +05:30
commit fcade251a6
51 changed files with 11565 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
/**
* Active alarms, most severe first.
*
* Every alarm carries its severity as a WORD as well as a colour, and "Explain"
* hands the alarm to the copilot - which is the moment the demo stops looking
* like a dashboard and starts looking like a twin that understands itself.
*/
import { useStore } from '../store.js';
import { duration } from '../lib/format.js';
const SEVERITY_ORDER = ['critical', 'major', 'warning', 'predictive', 'info'];
export function AlarmFeed({ onExplain }) {
const frame = useStore((s) => s.frame);
const select = useStore((s) => s.select);
if (!frame) return null;
const alarms = frame.analytics.alarms;
const counts = SEVERITY_ORDER.map((sev) => ({
sev,
n: alarms.filter((a) => a.severity === sev).length,
})).filter((x) => x.n > 0);
return (
<div className="card">
<h2 className="card-title">
Alarms
<span className="hint">
{counts.length
? counts.map((c) => `${c.n} ${c.sev}`).join(' · ')
: 'all clear'}
</span>
</h2>
{alarms.length === 0 ? (
<div className="empty">
No active alarms. All signals are within limits and no threshold crossings are projected.
</div>
) : (
<div className="alarm-list">
{alarms.map((a) => (
<div className={`alarm ${a.severity}`} key={a.key}>
<div className="alarm-top">
<span className={`sev ${a.severity}`}>
{a.severity === 'predictive' ? 'Trend' : a.severity}
</span>
<button
className="who"
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
onClick={() => select(a.station)}
title={`Select ${a.station}`}
>
{a.station}{a.signal ? `.${a.signal}` : ''}
</button>
<span className="muted small" style={{ marginLeft: 'auto' }}>
{duration(frame.t - a.raisedAt)} ago
</span>
</div>
<div className="msg">{a.message}</div>
<div className="actions">
<button onClick={() => onExplain(a)}>Explain this</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
/**
* The plant copilot panel.
*
* Two deliberate choices here.
*
* The provider selector is visible rather than buried in config. A strong
* reasoning model gives markedly better analysis but can take 15-20 s to first
* token, which is a long silence in front of a customer; the local model answers
* in a couple of seconds; the rule engine answers instantly and offline. Being
* able to switch mid-demo, and to SHOW that the reasoning is reproducible without
* any model at all, is worth the panel space.
*
* The suggestion chips exist so nobody has to type on stage.
*/
import { useEffect, useRef, useState } from 'react';
import { useStore } from '../store.js';
import { askCopilot, abortAsk } from '../lib/copilotClient.js';
import { renderBold } from '../lib/format.js';
const CHIPS = [
'Why is OEE down?',
"What's wrong with CNC-02?",
'What happens if I raise line speed 10%?',
'Draft a maintenance work order.',
'Which station is the constraint right now?',
];
const PROVIDERS = [
{ id: 'auto', label: 'Auto' },
{ id: 'openrouter', label: 'Cloud' },
{ id: 'ollama', label: 'Local' },
{ id: 'fallback', label: 'Offline rules' },
];
function Message({ msg }) {
if (msg.role === 'user') return <div className="msg user">{msg.content}</div>;
return (
<div className="msg assistant">
<div className="who">{msg.label || 'Copilot'}</div>
{msg.note && <div className="note">{msg.note}</div>}
{renderBold(msg.content).map((part, i) =>
typeof part === 'string'
? <span key={i}>{part}</span>
: <strong key={i}>{part.bold}</strong>,
)}
</div>
);
}
export function Copilot() {
const messages = useStore((s) => s.copilot.messages);
const streaming = useStore((s) => s.copilot.streaming);
const elapsedMs = useStore((s) => s.copilot.elapsedMs);
const provider = useStore((s) => s.copilot.provider);
const setCopilot = useStore((s) => s.setCopilot);
const configured = useStore((s) => (s.meta ? s.meta.copilot : null));
const [text, setText] = useState('');
const logRef = useRef(null);
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [messages]);
const submit = (e) => {
e.preventDefault();
const q = text.trim();
if (!q) return;
setText('');
askCopilot(q);
};
return (
<div className="card copilot" style={{ minHeight: 0 }}>
<h2 className="card-title">
Plant copilot
<span className="hint">{configured ? configured.label : 'detecting…'}</span>
</h2>
<div className="btn-group" style={{ marginBottom: 10 }}>
{PROVIDERS.map((p) => (
<button
key={p.id}
className={provider === p.id ? 'active' : ''}
disabled={streaming}
onClick={() => setCopilot({ provider: p.id })}
title={
p.id === 'auto' ? 'Use whichever provider the server detected'
: p.id === 'openrouter' ? 'Cloud reasoning model — best analysis, slowest to first token'
: p.id === 'ollama' ? 'Local model — fast, needs Ollama running'
: 'Deterministic rule engine — instant, works with no network at all'
}
>
{p.label}
</button>
))}
</div>
<div className="copilot-log" ref={logRef}>
{messages.length === 0 && (
<div className="empty" style={{ textAlign: 'left', lineHeight: 1.6 }}>
Ask about the line and I will answer from live telemetry — station states,
readings against their limits, OEE attribution and measured trends.
<br /><br />
I am not told which fault was injected; the diagnosis comes from the data.
</div>
)}
{messages.map((m, i) => <Message key={i} msg={m} />)}
{streaming && (
<div className="thinking">
<span className="spinner" />
Thinking… {(elapsedMs / 1000).toFixed(1)}s
<button style={{ padding: '2px 7px', fontSize: 10.5 }} onClick={abortAsk}>Stop</button>
</div>
)}
</div>
<div className="chips">
{CHIPS.map((c) => (
<button key={c} disabled={streaming} onClick={() => askCopilot(c)}>{c}</button>
))}
</div>
<form className="ask" onSubmit={submit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Ask about the line…"
disabled={streaming}
/>
<button type="submit" disabled={streaming || !text.trim()}>Ask</button>
</form>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Line KPI tiles.
*
* OEE gets a hero figure; its three factors sit beside it as their own tiles with
* magnitude meters. Deliberately tiles rather than a chart: these are five
* single headline numbers, and a bar chart of five unrelated measures would be
* harder to read, not easier.
*/
import { useStore } from '../store.js';
function Meter({ value, color }) {
return (
<div className="meter">
<span style={{ width: `${Math.max(0, Math.min(100, value))}%`, background: color }} />
</div>
);
}
/** Colour the OEE factors by how bad the loss is, always beside a number. */
function factorColor(v) {
if (v >= 0.95) return 'var(--good)';
if (v >= 0.85) return 'var(--series-1)';
if (v >= 0.70) return 'var(--warning)';
return 'var(--critical)';
}
export function KpiRow() {
const kpi = useStore((s) => (s.frame ? s.frame.kpi : null));
const windowSec = useStore((s) => (s.meta ? s.meta.kpiWindowSec : 1200));
if (!kpi) {
return <div className="card"><div className="empty">Waiting for telemetry…</div></div>;
}
const factors = [
{ label: 'Availability', v: kpi.availability, foot: 'Stoppage losses' },
{ label: 'Performance', v: kpi.performance, foot: 'Micro-stops, slow cycles' },
{ label: 'Quality', v: kpi.quality, foot: 'Rejects at INS-04' },
];
return (
<div className="kpi-row">
<div className="tile hero">
<span className="label">OEE · rolling {Math.round(windowSec / 60)} min</span>
<span className="value" style={{ color: factorColor(kpi.oee) }}>
{(kpi.oee * 100).toFixed(1)}<span className="unit">%</span>
</span>
<Meter value={kpi.oee * 100} color={factorColor(kpi.oee)} />
<span className="foot">Availability × Performance × Quality</span>
</div>
{factors.map((f) => (
<div className="tile" key={f.label}>
<span className="label">{f.label}</span>
<span className="value">{(f.v * 100).toFixed(1)}<span className="unit">%</span></span>
<Meter value={f.v * 100} color={factorColor(f.v)} />
<span className="foot">{f.foot}</span>
</div>
))}
<div className="tile">
<span className="label">Output</span>
<span className="value">{kpi.throughputPerHour.toFixed(0)}<span className="unit">/h</span></span>
<span className="foot">
{kpi.good.toLocaleString()} good · {kpi.rejected.toLocaleString()} rejected
</span>
</div>
<div className="tile">
<span className="label">Energy</span>
<span className="value">{kpi.energyKw.toFixed(0)}<span className="unit">kW</span></span>
<span className="foot">
{kpi.energyPerUnit > 0 ? `${kpi.energyPerUnit.toFixed(3)} kWh/unit` : '—'}
</span>
</div>
</div>
);
}
+432
View File
@@ -0,0 +1,432 @@
/**
* Copilot settings.
*
* Rendered as a full-screen overlay rather than a route: the dashboard keeps
* streaming behind it, so changing a model never interrupts the demo, and there is
* no router dependency for a single page.
*
* The Test button is the point of this page as much as the model list is. A model
* that answers brilliantly in 30 seconds is unusable in front of a customer, and
* that is not something you want to discover mid-demo. Measure first.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useStore } from '../store.js';
const PROVIDERS = [
{ id: 'auto', title: 'Auto', desc: 'Cloud if a key is set, else local, else offline rules.' },
{ id: 'openrouter', title: 'Cloud', desc: 'OpenRouter. Best analysis, slowest to first token.' },
{ id: 'ollama', title: 'Local', desc: 'Ollama on this machine. Fast, needs it running.' },
{ id: 'fallback', title: 'Offline rules', desc: 'Deterministic rule engine. Instant, no network.' },
];
const EFFORTS = ['none', 'low', 'medium', 'high'];
/** Latency bands for the test result, in ms to first token. */
function latencyClass(ms) {
if (ms === null || ms === undefined) return 'bad';
if (ms < 4000) return 'good';
if (ms < 12000) return 'slow';
return 'bad';
}
function latencyVerdict(ms) {
if (ms < 4000) return 'Fast enough to present live.';
if (ms < 12000) return 'Usable, but there will be a noticeable pause on stage.';
return 'Too slow for a live beat — use this for follow-up questions only.';
}
function bytes(n) {
if (!n) return null;
return `${(n / 1e9).toFixed(1)} GB`;
}
function ModelPanel({
title, provider, models, error, loading, selected, active,
search, onSearch, onPick, onRefresh, onTest, testing, result, children,
}) {
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return models;
return models.filter((m) => m.id.toLowerCase().includes(q) || (m.name || '').toLowerCase().includes(q));
}, [models, search]);
const shown = filtered.slice(0, 80);
return (
<div className={`model-panel ${active ? 'active' : ''}`}>
<div className="model-panel-head">
<span className="t">{title}</span>
{active && <span className="badge live"><span className="dot" />Active</span>}
<span className="spacer" />
<button onClick={onRefresh} disabled={loading}>{loading ? 'Loading…' : 'Refresh'}</button>
<button onClick={onTest} disabled={testing}>{testing ? 'Testing…' : 'Test'}</button>
</div>
{children}
{error ? (
<div className="test-result bad">{error}</div>
) : (
<>
<input
type="search"
placeholder={`Filter ${models.length} models…`}
value={search}
onChange={(e) => onSearch(e.target.value)}
/>
<div className="model-list">
{shown.length === 0 && <div className="empty">No models match that filter.</div>}
{shown.map((m) => (
<button
key={m.id}
className={`model-row ${selected === m.id ? 'sel' : ''}`}
onClick={() => onPick(m.id)}
title={m.name}
>
<span className="mid">{m.id}</span>
<span className="meta">
{provider === 'openrouter'
? [
m.contextLength ? `${Math.round(m.contextLength / 1000)}k ctx` : null,
m.promptPerM !== null ? `$${m.promptPerM.toFixed(2)}/M` : null,
].filter(Boolean).join(' · ')
: [m.parameterSize, m.quantization, bytes(m.sizeBytes)].filter(Boolean).join(' · ')}
</span>
</button>
))}
</div>
{filtered.length > shown.length && (
<span className="small muted">
Showing {shown.length} of {filtered.length} matches — narrow the filter to see more.
</span>
)}
</>
)}
{result && (
<div className={`test-result ${result.ok ? latencyClass(result.firstTokenMs) : 'bad'}`}>
{result.ok ? (
<>
<span className="lat">{result.firstTokenMs} ms</span> to first token
{result.totalMs !== result.firstTokenMs && <> · {result.totalMs} ms total</>}
<br />{latencyVerdict(result.firstTokenMs)}
{provider === 'ollama' && (
<><br /><span className="muted">
A first test after switching models includes loading it into memory. Run it twice.
</span></>
)}
</>
) : (
<>Test failed: {result.error}</>
)}
</div>
)}
</div>
);
}
export function SettingsPage() {
const close = useStore((s) => s.closeSettings);
const setMeta = useStore((s) => s.setMeta);
const setCopilot = useStore((s) => s.setCopilot);
const [settings, setSettings] = useState(null);
const [status, setStatus] = useState(null);
const [errors, setErrors] = useState([]);
const [flash, setFlash] = useState(null);
const [orModels, setOrModels] = useState({ models: [], error: null, loading: true });
const [olModels, setOlModels] = useState({ models: [], error: null, loading: true });
const [orSearch, setOrSearch] = useState('');
const [olSearch, setOlSearch] = useState('');
const [testing, setTesting] = useState(null);
const [results, setResults] = useState({});
// --- load ---
useEffect(() => {
fetch('/api/settings')
.then((r) => r.json())
.then((j) => { setSettings(j.settings); setStatus(j.status); })
.catch((e) => setErrors([`Could not load settings: ${e.message}`]));
}, []);
const loadModels = useCallback((provider, force = false) => {
const set = provider === 'openrouter' ? setOrModels : setOlModels;
set((s) => ({ ...s, loading: true }));
fetch(`/api/copilot/models?provider=${provider}${force ? '&force=1' : ''}`)
.then((r) => r.json())
.then((j) => set({ models: j.models || [], error: j.error || null, loading: false }))
.catch((e) => set({ models: [], error: e.message, loading: false }));
}, []);
useEffect(() => {
loadModels('openrouter');
loadModels('ollama');
}, [loadModels]);
// Escape closes, as an overlay should.
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') close(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [close]);
const save = async (patch) => {
setErrors([]);
try {
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const j = await res.json();
if (!j.ok) { setErrors(j.errors || ['Rejected.']); return; }
setSettings(j.settings);
setStatus(j.status);
setFlash('Saved');
setTimeout(() => setFlash(null), 1600);
// Refresh the badge shown in the header and the copilot panel.
const meta = await fetch('/api/meta').then((r) => r.json());
setMeta(meta);
// A provider chosen here becomes the default for new questions.
if (patch.provider) setCopilot({ provider: patch.provider });
// The host may have moved, so the local model list could be stale.
if (patch.ollamaHost) loadModels('ollama', true);
} catch (e) {
setErrors([e.message]);
}
};
const resetAll = async () => {
const j = await fetch('/api/settings/reset', { method: 'POST' }).then((r) => r.json());
setSettings(j.settings);
setStatus(j.status);
setFlash('Reset to environment defaults');
setTimeout(() => setFlash(null), 2200);
const meta = await fetch('/api/meta').then((r) => r.json());
setMeta(meta);
};
const runTest = async (provider, model) => {
setTesting(provider);
try {
const j = await fetch('/api/copilot/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, model }),
}).then((r) => r.json());
setResults((r) => ({ ...r, [provider]: j }));
} catch (e) {
setResults((r) => ({ ...r, [provider]: { ok: false, error: e.message } }));
} finally {
setTesting(null);
}
};
if (!settings) {
return (
<div className="settings-scrim" onClick={close}>
<div className="settings" onClick={(e) => e.stopPropagation()}>
<div className="settings-body"><div className="empty">Loading settings…</div></div>
</div>
</div>
);
}
const effective = status ? status.provider : 'fallback';
return (
<div className="settings-scrim" onClick={close}>
<div className="settings" onClick={(e) => e.stopPropagation()}>
<div className="settings-head">
<h2>Copilot settings</h2>
{status && (
<span className="badge live"><span className="dot" />{status.label}</span>
)}
<span className="spacer" />
{flash && <span className="saved-flash">{flash}</span>}
<button onClick={close}>Close</button>
</div>
<div className="settings-body">
{errors.length > 0 && (
<div className="banner">{errors.join(' · ')}</div>
)}
<div className="settings-section">
<h3>Provider</h3>
<p className="desc">
Which engine answers by default. You can still override per question from the
copilot panel. {status && <>Currently resolving to <strong>{effective}</strong> — {status.detail}.</>}
</p>
<div className="provider-grid">
{PROVIDERS.map((p) => (
<button
key={p.id}
className={`provider-opt ${settings.provider === p.id ? 'sel' : ''}`}
onClick={() => save({ provider: p.id })}
>
<span className="t">{p.title}</span>
<span className="d">{p.desc}</span>
</button>
))}
</div>
{!settings.openRouterKeyPresent && (
<span className="small muted">
No OPENROUTER_API_KEY is set, so Cloud is unavailable and the model list cannot be
fetched. Add the key to .env and restart the server.
</span>
)}
</div>
<div className="settings-section">
<h3>Model</h3>
<p className="desc">
Pick a model per provider, or type an id directly. Test measures time to first
token — the number that decides whether a model is presentable live.
</p>
<div className="model-cols">
<ModelPanel
title="Cloud · OpenRouter"
provider="openrouter"
models={orModels.models}
error={orModels.error}
loading={orModels.loading}
selected={settings.openRouterModel}
active={effective === 'openrouter'}
search={orSearch}
onSearch={setOrSearch}
onPick={(id) => save({ openRouterModel: id })}
onRefresh={() => loadModels('openrouter', true)}
onTest={() => runTest('openrouter', settings.openRouterModel)}
testing={testing === 'openrouter'}
result={results.openrouter}
>
<input
type="text"
className="mono"
value={settings.openRouterModel}
onChange={(e) => setSettings((s) => ({ ...s, openRouterModel: e.target.value }))}
onBlur={(e) => e.target.value && save({ openRouterModel: e.target.value.trim() })}
spellCheck={false}
/>
</ModelPanel>
<ModelPanel
title="Local · Ollama"
provider="ollama"
models={olModels.models}
error={olModels.error}
loading={olModels.loading}
selected={settings.ollamaModel}
active={effective === 'ollama'}
search={olSearch}
onSearch={setOlSearch}
onPick={(id) => save({ ollamaModel: id })}
onRefresh={() => loadModels('ollama', true)}
onTest={() => runTest('ollama', settings.ollamaModel)}
testing={testing === 'ollama'}
result={results.ollama}
>
<input
type="text"
className="mono"
value={settings.ollamaModel}
onChange={(e) => setSettings((s) => ({ ...s, ollamaModel: e.target.value }))}
onBlur={(e) => e.target.value && save({ ollamaModel: e.target.value.trim() })}
spellCheck={false}
/>
</ModelPanel>
</div>
</div>
<div className="settings-section">
<h3>Generation</h3>
<p className="desc">
Reasoning tokens count against the output budget on most providers. A reasoning
model on a tight budget spends it all thinking and returns nothing, so keep effort
low and the budget generous unless you have a reason not to.
</p>
<div className="gen-grid">
<div className="field" style={{ marginBottom: 0 }}>
<div className="field-head">
<span>Temperature</span>
<span className="v">{settings.temperature.toFixed(2)}</span>
</div>
<input
type="range" min={0} max={1} step={0.05}
value={settings.temperature}
onChange={(e) => setSettings((s) => ({ ...s, temperature: Number(e.target.value) }))}
onMouseUp={(e) => save({ temperature: Number(e.target.value) })}
onKeyUp={(e) => save({ temperature: Number(e.target.value) })}
/>
<span className="small muted">Low keeps diagnoses consistent between runs.</span>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<div className="field-head"><span>Max output tokens</span></div>
<input
type="number" min={128} max={8000} step={128}
value={settings.maxTokens}
onChange={(e) => setSettings((s) => ({ ...s, maxTokens: Number(e.target.value) }))}
onBlur={(e) => save({ maxTokens: Number(e.target.value) })}
/>
<span className="small muted">Includes reasoning tokens on most providers.</span>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<div className="field-head"><span>Reasoning effort</span></div>
<div className="btn-group">
{EFFORTS.map((e) => (
<button
key={e}
className={settings.reasoningEffort === e ? 'active' : ''}
onClick={() => save({ reasoningEffort: e })}
>
{e}
</button>
))}
</div>
<span className="small muted">Local models map none/low to reasoning off.</span>
</div>
</div>
</div>
<div className="settings-section">
<h3>Ollama host</h3>
<p className="desc">
Accepts a bare host, host:port, or full URL. Ollama often sets OLLAMA_HOST to
0.0.0.0 machine-wide, which is a bind address and not dialable — that is normalized
automatically.
</p>
<input
type="text"
className="mono"
value={settings.ollamaHost}
onChange={(e) => setSettings((s) => ({ ...s, ollamaHost: e.target.value }))}
onBlur={(e) => e.target.value && save({ ollamaHost: e.target.value.trim() })}
spellCheck={false}
/>
<span className="small muted">Dialing: <span className="mono">{settings.resolvedOllamaHost}</span></span>
</div>
</div>
<div className="settings-foot">
<button onClick={() => runTest('fallback')} disabled={testing === 'fallback'}>
Test offline rules
</button>
{results.fallback && (
<span className="small" style={{ color: 'var(--good)' }}>
{results.fallback.firstTokenMs} ms · always available
</span>
)}
<span className="spacer" />
<button className="danger" onClick={resetAll}>Reset to defaults</button>
<button onClick={close}>Done</button>
</div>
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Trend charts for the selected station.
*
* Most signals get their own single-series chart. The oven is the exception: its
* three zone temperatures belong on one axis because comparing them to each other
* (and to setpoint) is the whole diagnosis, and they share a unit. Series colours
* there are the first three validated categorical slots.
*/
import { useStore } from '../store.js';
import { TrendChart } from './TrendChart.jsx';
import { duration } from '../lib/format.js';
/**
* Per-station chart grouping. Anything not named here becomes its own chart.
*/
const GROUPS = {
'OVN-03': [
{ title: 'Zone temperatures', keys: ['zone1Temp', 'zone2Temp', 'zone3Temp'] },
{ keys: ['tempDeviation'] },
{ keys: ['burnerDuty'] },
],
};
/** Turn per-signal history buffers into rows Recharts can consume. */
function buildRows(history, stationId, keys) {
const buffers = keys.map((k) => history[`${stationId}.${k}`] || []);
const base = buffers.reduce((a, b) => (b.length > a.length ? b : a), []);
if (!base.length) return [];
return base.map((point, i) => {
const row = { t: point.t };
keys.forEach((k, ki) => {
const buf = buffers[ki];
// Buffers are appended in lockstep, so align from the end to stay correct
// if one signal started later than another.
const idx = buf.length - (base.length - i);
row[k] = idx >= 0 && idx < buf.length ? buf[idx].v : null;
});
return row;
});
}
export function StationDetail() {
const selected = useStore((s) => s.selected);
const meta = useStore((s) => s.meta);
const history = useStore((s) => s.history);
const station = useStore((s) => (s.frame ? s.frame.stations.find((x) => x.id === selected) : null));
const predictions = useStore((s) => (s.frame ? s.frame.analytics.predictions : []));
if (!meta || !station) return null;
const spec = meta.stations.find((x) => x.id === selected);
if (!spec) return null;
const charted = spec.signals.filter((g) => g.chart);
const groups = GROUPS[selected] || charted.map((g) => ({ keys: [g.key] }));
const myPredictions = predictions.filter((p) => p.station === selected);
return (
<div className="card">
<h2 className="card-title">
{station.id} · {station.name}
<span className="hint">
{station.online ? `${charted.length} live signals` : 'Station is not reporting — traces paused'}
</span>
</h2>
{!station.online && (
<div className="banner" style={{ marginBottom: 12 }}>
No data from {station.id}. The readings shown elsewhere are the last known values, and
the charts stop rather than drawing a flat line that would look like a healthy reading.
</div>
)}
{myPredictions.length > 0 && (
<div className="stack" style={{ marginBottom: 12 }}>
{myPredictions.map((p) => (
<div className="alarm predictive" key={p.signal}>
<div className="alarm-top">
<span className="sev predictive">Trend</span>
<span className="who">{p.station}.{p.signal}</span>
</div>
<div className="msg">
{p.label} is rising {p.slopePerMin.toFixed(4)} {p.unit}/min. At this rate it reaches
the {p.threshold} {p.unit} limit in about <strong>{p.eta}</strong> of run time.
<span className="muted"> Least-squares fit over {duration(p.windowSec)}, r² {p.r2.toFixed(2)}.</span>
</div>
</div>
))}
</div>
)}
<div className="chart-grid">
{groups.map((group) => {
const series = group.keys
.map((k) => spec.signals.find((g) => g.key === k))
.filter(Boolean);
if (!series.length) return null;
const rows = buildRows(history, selected, group.keys);
return (
<TrendChart
key={group.keys.join('+')}
title={group.title || series[0].label}
series={series}
data={rows}
/>
);
})}
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
/**
* The five stations in process order, with WIP buffer levels between them.
*
* Clicking a card selects that station everywhere - here, in the detail charts,
* and in the 3D scene. The buffer pills between cards are what make blocking and
* starvation legible: when the packer stops, you watch these fill from the right.
*/
import { useStore } from '../store.js';
import { primarySignal, num, stateMeta, signalStatus, STATUS_COLOR } from '../lib/format.js';
function BufferPill({ level, capacity }) {
const frac = capacity > 0 ? level / capacity : 0;
const full = level >= capacity;
return (
<div
title={`Work in progress: ${level} of ${capacity}${full ? ' — full, upstream station is blocked' : ''}`}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
minWidth: 34, paddingTop: 26,
}}
>
<div style={{
width: 8, height: 42, borderRadius: 999, background: 'var(--grid)',
display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', overflow: 'hidden',
}}>
<div style={{
height: `${frac * 100}%`,
background: full ? 'var(--serious)' : 'var(--series-1)',
borderRadius: 999,
}} />
</div>
<span className="mono" style={{ fontSize: 9.5, color: full ? 'var(--serious)' : 'var(--text-muted)' }}>
{level}
</span>
</div>
);
}
export function StationStrip() {
const frame = useStore((s) => s.frame);
const meta = useStore((s) => s.meta);
const selected = useStore((s) => s.selected);
const select = useStore((s) => s.select);
if (!frame || !meta) return null;
return (
<div className="card">
<h2 className="card-title">
Line 1 · stations
<span className="hint">WIP between stations shown as vertical bars</span>
</h2>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 4 }}>
{frame.stations.map((st, i) => {
const spec = meta.stations.find((x) => x.id === st.id);
const g = primarySignal(spec);
const value = g ? st.signals[g.key] : null;
const sm = stateMeta(st.state);
const status = signalStatus(g, value);
return (
<div key={st.id} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<button
className={`station-card ${selected === st.id ? 'sel' : ''}`}
style={{ borderLeftColor: sm.color, flex: 1, minWidth: 0 }}
onClick={() => select(st.id)}
aria-pressed={selected === st.id}
>
<div>
<div className="id">{st.id}</div>
<div className="nm">{st.name}</div>
</div>
{/* Colour plus the state word: never colour alone. */}
<span className={`state s-${st.state}`}>
<span className="dot" style={{ background: sm.color }} />
{st.online ? sm.label : 'No data'}
</span>
{g && (
<div>
<div className="primary" style={{ color: STATUS_COLOR[status] }}>
{num(value, g.precision)}<span className="u">{g.unit}</span>
</div>
<div className="nm">{g.label}</div>
</div>
)}
</button>
{i < frame.buffers.length && (
<BufferPill level={frame.buffers[i]} capacity={frame.bufferCapacity} />
)}
</div>
);
})}
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Header: identity, the simulated clock, and the clock multiplier.
*
* The speed control is not a convenience. A bearing trend that reads convincingly
* over twenty simulated minutes cannot be watched in real time in a meeting, and
* 60x turns that into twenty seconds. The multiplier is always shown next to the
* clock so nobody is misled about how fast time is passing.
*/
import { useState } from 'react';
import { useStore } from '../store.js';
import { control } from '../hooks/useTelemetry.js';
import { clock } from '../lib/format.js';
export function TimeControls() {
const frame = useStore((s) => s.frame);
const meta = useStore((s) => s.meta);
const connected = useStore((s) => s.connected);
const clearHistory = useStore((s) => s.clearHistory);
const openSettings = useStore((s) => s.openSettings);
const copilotLabel = useStore((s) => (s.meta ? s.meta.copilot.label : null));
const [busy, setBusy] = useState(false);
const speeds = meta ? meta.speeds : [0, 1, 5, 20, 60];
const current = frame ? frame.sim.speed : 1;
const canTime = meta ? meta.source.capabilities.timeControl : false;
const send = async (path, body, after) => {
setBusy(true);
try {
await control(path, body);
if (after) after();
} catch {
/* the control panel surfaces errors; the header stays quiet */
} finally {
setBusy(false);
}
};
return (
<header className="header">
<div className="brand">
<h1>LINE-1 Digital Twin</h1>
<span className="sub">Assembly &amp; Cure · 5 stations</span>
</div>
<span className={`badge ${connected ? 'live' : 'stale'}`}>
<span className="dot" />
{connected ? 'Telemetry live' : 'Disconnected'}
</span>
{meta && (
<span className="badge">
<span className="dot" style={{ background: 'var(--series-1)' }} />
Source: {meta.source.kind}
</span>
)}
<div className="header-spacer" />
<span className="clock">
{frame ? clock(frame.t) : '00:00:00'}
<span className="muted"> sim</span>
</span>
<div className="btn-group">
{speeds.map((s) => (
<button
key={s}
className={current === s ? 'active' : ''}
disabled={!canTime || busy}
onClick={() => send('speed', { speed: s })}
title={s === 0 ? 'Pause the simulation' : `Run at ${s}x real time`}
>
{s === 0 ? 'Pause' : `${s}×`}
</button>
))}
</div>
<button
className="danger"
disabled={busy || !canTime}
onClick={() => send('reset', {}, clearHistory)}
title="Reset the line, clear faults and learned baselines"
>
Reset
</button>
<button
onClick={openSettings}
title="Choose the copilot provider and model, and measure its latency"
>
{copilotLabel ? `AI: ${copilotLabel.split(' · ')[0]}` : 'AI'} ⚙
</button>
</header>
);
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Trend chart for one or more signals on a station.
*
* Mark specs follow the data-viz method: 2px lines, no per-point dots, a
* recessive horizontal-only grid, muted axes, and a crosshair tooltip. Threshold
* lines are drawn in reserved status colours and always carry a text label, so a
* limit is never indicated by colour alone.
*
* The y-domain auto-fits the data rather than using the signal's full physical
* range: a bearing drifting from 1.6 to 2.2 mm/s is invisible on a fixed 0-6
* axis, and that drift is the entire point. A threshold is folded into the domain
* once the data gets within reach of it, so the line you are climbing toward
* appears exactly when it becomes relevant.
*/
import { memo } from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ResponsiveContainer,
} from 'recharts';
import { duration, num } from '../lib/format.js';
const SERIES_COLORS = ['var(--series-1)', 'var(--series-2)', 'var(--series-3)'];
function CustomTooltip({ active, payload, label, series }) {
if (!active || !payload || !payload.length) return null;
return (
<div className="tooltip">
<div className="t-time">t = {duration(label)}</div>
{payload.map((p) => {
const s = series.find((x) => x.key === p.dataKey);
return (
<div className="t-row" key={p.dataKey}>
<span className="legend-swatch" style={{ background: p.stroke, height: 2, width: 10 }} />
<span>{s ? s.label : p.dataKey}</span>
<span className="t-val">{num(p.value, s ? s.precision : 2)}</span>
<span className="muted">{s ? s.unit : ''}</span>
</div>
);
})}
</div>
);
}
/**
* series: [{ key, label, unit, precision, warnHigh, alarmHigh, warnLow, alarmLow }]
* data: [{ t, <key>: value, ... }]
*/
function TrendChartBase({ title, series, data, height = 150 }) {
if (!data || data.length < 2) {
return (
<div className="chart-box">
{title && <div className="chart-head"><span className="chart-name">{title}</span></div>}
<div className="empty" style={{ height }}>Collecting data…</div>
</div>
);
}
// --- domain ---
let lo = Infinity;
let hi = -Infinity;
for (const row of data) {
for (const s of series) {
const v = row[s.key];
if (Number.isFinite(v)) { if (v < lo) lo = v; if (v > hi) hi = v; }
}
}
if (!Number.isFinite(lo)) { lo = 0; hi = 1; }
// Pull a threshold into view only once the data approaches it.
const marks = [];
for (const s of series) {
const span = Math.max(1e-6, hi - lo);
const consider = (value, kind) => {
if (value === undefined) return;
const near = value > hi ? value - hi < span * 2.5 : lo - value < span * 2.5;
if (!near) return;
marks.push({ value, kind, label: s.unit ? `${kind === 'alarm' ? 'Alarm' : 'Warn'} ${value}` : String(value) });
if (value > hi) hi = value;
if (value < lo) lo = value;
};
consider(s.alarmHigh, 'alarm');
consider(s.warnHigh, 'warn');
consider(s.alarmLow, 'alarm');
consider(s.warnLow, 'warn');
}
// Clamp to the signal's declared physical range. Padding and threshold
// inclusion can otherwise push the axis outside what the quantity can be - a
// spindle load axis running to -8% and 106%, or negative rpm. An impossible
// number on an axis is the kind of detail a plant engineer spots immediately.
const specMin = Math.min(...series.map((s) => (s.min ?? -Infinity)));
const specMax = Math.max(...series.map((s) => (s.max ?? Infinity)));
const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.002, 1e-4);
const domain = [
Math.max(specMin, lo - pad),
Math.min(specMax, hi + pad),
];
const primary = series[0];
return (
<div className="chart-box">
{title && (
<div className="chart-head">
<span className="chart-name">{title}</span>
{series.length === 1 && (
<span className="chart-now">
{num(data[data.length - 1][primary.key], primary.precision)}
<span className="u">{primary.unit}</span>
</span>
)}
</div>
)}
<ResponsiveContainer width="100%" height={height}>
<LineChart data={data} margin={{ top: 6, right: 8, bottom: 2, left: -18 }}>
<CartesianGrid stroke="var(--grid)" strokeWidth={1} vertical={false} />
<XAxis
dataKey="t"
type="number"
domain={['dataMin', 'dataMax']}
tickFormatter={duration}
tick={{ fill: 'var(--text-muted)', fontSize: 10 }}
stroke="var(--axis)"
minTickGap={44}
/>
<YAxis
domain={domain}
tick={{ fill: 'var(--text-muted)', fontSize: 10 }}
stroke="var(--axis)"
width={46}
tickFormatter={(v) => num(v, primary.precision > 1 ? 1 : primary.precision)}
/>
<Tooltip
content={<CustomTooltip series={series} />}
cursor={{ stroke: 'var(--text-muted)', strokeWidth: 1, strokeDasharray: '3 3' }}
isAnimationActive={false}
/>
{marks.map((m, i) => (
<ReferenceLine
key={`${m.kind}-${m.value}-${i}`}
y={m.value}
stroke={m.kind === 'alarm' ? 'var(--critical)' : 'var(--warning)'}
strokeDasharray="4 4"
strokeWidth={1}
label={{
value: m.label,
position: 'insideTopRight',
fill: m.kind === 'alarm' ? 'var(--critical)' : 'var(--warning)',
fontSize: 9.5,
}}
/>
))}
{series.map((s, i) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
stroke={SERIES_COLORS[i % SERIES_COLORS.length]}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
isAnimationActive={false}
connectNulls={false}
/>
))}
</LineChart>
</ResponsiveContainer>
{/* A legend is always present for two or more series, and each item carries
the name and its current value, so identity never rests on hue alone. */}
{series.length > 1 && (
<div className="legend">
{series.map((s, i) => (
<span className="legend-item" key={s.key}>
<span className="legend-swatch" style={{ background: SERIES_COLORS[i % SERIES_COLORS.length] }} />
{s.label}
<span className="mono" style={{ color: 'var(--text-primary)' }}>
{num(data[data.length - 1][s.key], s.precision)}
</span>
<span className="muted">{s.unit}</span>
</span>
))}
</div>
)}
</div>
);
}
export const TrendChart = memo(TrendChartBase);
+132
View File
@@ -0,0 +1,132 @@
/**
* The what-if panel: the controls that make this a twin rather than a dashboard.
*
* Setpoints are applied on release rather than on every slider tick, so dragging
* does not spam the model with intermediate values and pollute the event log.
*
* Controls the telemetry source cannot honour are disabled with a reason. A read
* -only historian or MQTT feed lets you observe the plant, not drive it, and the
* UI must say so rather than offering buttons that silently do nothing.
*/
import { useEffect, useState } from 'react';
import { useStore } from '../store.js';
import { control } from '../hooks/useTelemetry.js';
export function WhatIfPanel() {
const frame = useStore((s) => s.frame);
const meta = useStore((s) => s.meta);
const [setpoint, setSetpoint] = useState(305);
const [lineSpeed, setLineSpeed] = useState(100);
const [error, setError] = useState(null);
const serverSetpoint = frame ? frame.controls.setpoint : 305;
const serverLineSpeed = frame ? frame.controls.lineSpeedPct : 100;
// Follow the server unless the user is mid-drag, so a reset or an external
// change is reflected here instead of the slider drifting out of sync.
const [dragging, setDragging] = useState(null);
useEffect(() => { if (dragging !== 'setpoint') setSetpoint(serverSetpoint); }, [serverSetpoint, dragging]);
useEffect(() => { if (dragging !== 'lineSpeed') setLineSpeed(serverLineSpeed); }, [serverLineSpeed, dragging]);
if (!frame || !meta) return null;
const caps = meta.source.capabilities;
const activeFaults = new Set(frame.faults.map((f) => f.id));
const send = async (path, body) => {
try {
setError(null);
await control(path, body);
} catch (err) {
setError(err.message);
}
};
return (
<div className="card">
<h2 className="card-title">
What-if controls
<span className="hint">{caps.setpointControl ? 'live on the model' : 'read-only source'}</span>
</h2>
{!caps.setpointControl && (
<div className="banner" style={{ marginBottom: 12 }}>
This telemetry source is read-only, so setpoints and faults cannot be driven from here.
</div>
)}
{error && <div className="banner" style={{ marginBottom: 12 }}>{error}</div>}
<div className="field">
<div className="field-head">
<span>Oven setpoint</span>
<span className="v">{setpoint} °C</span>
</div>
<input
type="range" min={250} max={340} step={1} value={setpoint}
disabled={!caps.setpointControl}
onChange={(e) => { setDragging('setpoint'); setSetpoint(Number(e.target.value)); }}
onMouseUp={(e) => { setDragging(null); send('setpoint', { value: Number(e.target.value) }); }}
onTouchEnd={(e) => { setDragging(null); send('setpoint', { value: Number(e.target.value) }); }}
onKeyUp={(e) => { setDragging(null); send('setpoint', { value: Number(e.target.value) }); }}
/>
<span className="small muted">
Zone 2 responds with a ~50 s lag. Reject rate climbs once deviation passes about 6 °C.
</span>
</div>
<div className="field">
<div className="field-head">
<span>Line speed</span>
<span className="v">{lineSpeed}%</span>
</div>
<input
type="range" min={50} max={130} step={1} value={lineSpeed}
disabled={!caps.setpointControl}
onChange={(e) => { setDragging('lineSpeed'); setLineSpeed(Number(e.target.value)); }}
onMouseUp={(e) => { setDragging(null); send('line-speed', { value: Number(e.target.value) }); }}
onTouchEnd={(e) => { setDragging(null); send('line-speed', { value: Number(e.target.value) }); }}
onKeyUp={(e) => { setDragging(null); send('line-speed', { value: Number(e.target.value) }); }}
/>
<span className="small muted">
CNC-02 is the bottleneck at 4.4 s. Faster cycles raise spindle load.
</span>
</div>
<div className="divider" />
<h2 className="card-title">Inject a fault</h2>
<div className="fault-list">
{meta.faults.map((f) => {
const on = activeFaults.has(f.id);
return (
<div className={`fault-row ${on ? 'on' : ''}`} key={f.id}>
<span className="nm">
{f.label}
<small>{f.station} · {f.severity}</small>
</span>
<button
className={on ? 'danger active' : ''}
disabled={!caps.faultInjection}
title={f.description}
onClick={() => send('fault', { id: f.id, action: on ? 'clear' : 'inject' })}
>
{on ? 'Clear' : 'Inject'}
</button>
</div>
);
})}
</div>
<div className="divider" />
<div className="inline">
<button disabled={!caps.setpointControl} onClick={() => send('tool-change', {})}>
Tool change
</button>
<span className="small muted">Resets CNC-02 tool wear, as a maintenance intervention would.</span>
</div>
</div>
);
}