Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user