Add separate simulator UI

This commit is contained in:
masterdev
2026-06-17 01:06:21 +01:00
parent e083a22bff
commit e3d8f856e3
16 changed files with 2361 additions and 14 deletions

12
simulator-ui/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5174
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

12
simulator-ui/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Plast Track Simulator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1241
simulator-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
simulator-ui/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "plast-track-simulator-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^4.3.1",
"typescript": "^6.0.3",
"vite": "^8.0.16"
}
}

353
simulator-ui/src/main.tsx Normal file
View File

@@ -0,0 +1,353 @@
import React, { FormEvent, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";
type Machine = {
id: number;
code: string;
name: string;
status: string;
powered_on: boolean;
};
type ScenarioKey =
| "power_on"
| "power_off"
| "cycle_completed"
| "cycle_burst"
| "cycle_signal_edge"
| "debounce_noise"
| "machine_stopped"
| "recover_with_cycle"
| "alarm_on"
| "alarm_off";
type LogEntry = {
id: number;
label: string;
status: "ok" | "error";
details: string;
createdAt: string;
};
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const scenarios: Array<{ key: ScenarioKey; title: string; description: string }> = [
{
key: "power_on",
title: "Power ON",
description: "Simulate machine power available.",
},
{
key: "power_off",
title: "Power OFF",
description: "Close open downtime and move out of planning.",
},
{
key: "cycle_completed",
title: "Single Cycle",
description: "Publish one normalized completed cycle.",
},
{
key: "cycle_burst",
title: "Cycle Burst",
description: "Generate many cycles with the selected cycle time.",
},
{
key: "cycle_signal_edge",
title: "Passive Edge",
description: "Toggle cycle_signal using the machine configured edge.",
},
{
key: "debounce_noise",
title: "Debounce Noise",
description: "Send a too-fast edge that should be ignored.",
},
{
key: "machine_stopped",
title: "Open Stop",
description: "Force an unqualified downtime if an OF is running.",
},
{
key: "recover_with_cycle",
title: "Recover",
description: "Close downtime by producing a new cycle.",
},
{
key: "alarm_on",
title: "Alarm ON",
description: "Raise the general_alarm passive signal.",
},
{
key: "alarm_off",
title: "Alarm OFF",
description: "Clear the general_alarm passive signal.",
},
];
async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
...init,
});
if (!response.ok) {
const body = await response.text();
throw new Error(body || `Request failed with ${response.status}`);
}
return response.json() as Promise<T>;
}
function parseInputValue(value: string): boolean | number | string {
const normalized = value.trim().toLowerCase();
if (["true", "on", "high", "1"].includes(normalized)) {
return true;
}
if (["false", "off", "low", "0"].includes(normalized)) {
return false;
}
const numeric = Number(value);
return Number.isFinite(numeric) && value.trim() !== "" ? numeric : value;
}
function App() {
const [machines, setMachines] = useState<Machine[]>([]);
const [selectedMachine, setSelectedMachine] = useState("");
const [cycleTimeSec, setCycleTimeSec] = useState(20);
const [burstCount, setBurstCount] = useState(5);
const [rawInputName, setRawInputName] = useState("cycle_signal");
const [rawInputValue, setRawInputValue] = useState("true");
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([]);
useEffect(() => {
apiRequest<Machine[]>("/api/machines")
.then((items) => {
setMachines(items);
setSelectedMachine((current) => current || items[0]?.code || "");
})
.catch((error: Error) => addLog("Load machines", "error", error.message));
}, []);
function addLog(label: string, status: LogEntry["status"], details: string) {
setLogs((current) => [
{
id: Date.now(),
label,
status,
details,
createdAt: new Date().toLocaleTimeString(),
},
...current.slice(0, 24),
]);
}
async function runScenario(scenario: ScenarioKey) {
if (!selectedMachine) {
addLog("Scenario blocked", "error", "Select a machine first.");
return;
}
setLoading(true);
try {
const result = await apiRequest<unknown>("/api/simulator/scenarios", {
method: "POST",
body: JSON.stringify({
machine_id: selectedMachine,
scenario,
cycle_time_sec: cycleTimeSec,
count: burstCount,
}),
});
addLog(scenario, "ok", JSON.stringify(result, null, 2));
} catch (error) {
addLog(scenario, "error", error instanceof Error ? error.message : "Unknown error");
} finally {
setLoading(false);
}
}
async function sendRawInput(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedMachine || !rawInputName.trim()) {
addLog("Raw input blocked", "error", "Machine and input name are required.");
return;
}
setLoading(true);
try {
const result = await apiRequest<unknown>("/api/simulator/scenarios", {
method: "POST",
body: JSON.stringify({
machine_id: selectedMachine,
scenario: "raw_input",
input_name: rawInputName.trim(),
input_value: parseInputValue(rawInputValue),
}),
});
addLog(`raw:${rawInputName}`, "ok", JSON.stringify(result, null, 2));
} catch (error) {
addLog("Raw input", "error", error instanceof Error ? error.message : "Unknown error");
} finally {
setLoading(false);
}
}
const selectedMachineInfo = machines.find((machine) => machine.code === selectedMachine);
return (
<main className="shell">
<section className="hero">
<div>
<p className="eyebrow">Separate test console</p>
<h1>Passive Signal Simulator</h1>
<p className="hero__copy">
Simulate cycles, power states, stops, alarms, signal edges, debounce noise, and raw digital inputs without
adding test controls to the production dashboard.
</p>
</div>
<div className="connection-card">
<span>Backend API</span>
<strong>{API_BASE_URL}</strong>
</div>
</section>
<section className="control-grid">
<article className="panel panel--machine">
<div className="panel__header">
<div>
<p className="eyebrow">Target</p>
<h2>Machine</h2>
</div>
<button className="ghost-button" type="button" onClick={() => window.location.reload()}>
Reload
</button>
</div>
<label className="field">
<span>Machine code</span>
<select value={selectedMachine} onChange={(event) => setSelectedMachine(event.target.value)}>
{machines.map((machine) => (
<option key={machine.code} value={machine.code}>
{machine.code} - {machine.name}
</option>
))}
</select>
</label>
{selectedMachineInfo ? (
<div className="machine-summary">
<span className={`status-dot status-dot--${selectedMachineInfo.status}`} />
<div>
<strong>{selectedMachineInfo.status}</strong>
<small>{selectedMachineInfo.powered_on ? "Powered ON" : "Powered OFF"}</small>
</div>
</div>
) : (
<p className="empty">No machine returned by the backend.</p>
)}
<div className="numeric-grid">
<label className="field">
<span>Cycle time sec</span>
<input
min="1"
step="0.1"
type="number"
value={cycleTimeSec}
onChange={(event) => setCycleTimeSec(Number(event.target.value))}
/>
</label>
<label className="field">
<span>Burst count</span>
<input
min="1"
max="100"
type="number"
value={burstCount}
onChange={(event) => setBurstCount(Number(event.target.value))}
/>
</label>
</div>
</article>
<article className="panel panel--scenarios">
<div className="panel__header">
<div>
<p className="eyebrow">Cases</p>
<h2>Scenario Launcher</h2>
</div>
{loading ? <span className="busy">Sending...</span> : null}
</div>
<div className="scenario-grid">
{scenarios.map((scenario) => (
<button
className="scenario-button"
disabled={loading || !selectedMachine}
key={scenario.key}
type="button"
onClick={() => runScenario(scenario.key)}
>
<strong>{scenario.title}</strong>
<span>{scenario.description}</span>
</button>
))}
</div>
</article>
<article className="panel">
<div className="panel__header">
<div>
<p className="eyebrow">Manual</p>
<h2>Raw Passive Input</h2>
</div>
</div>
<form className="raw-form" onSubmit={sendRawInput}>
<label className="field">
<span>Input name</span>
<input value={rawInputName} onChange={(event) => setRawInputName(event.target.value)} />
</label>
<label className="field">
<span>Input value</span>
<input value={rawInputValue} onChange={(event) => setRawInputValue(event.target.value)} />
</label>
<button className="primary-button" disabled={loading || !selectedMachine} type="submit">
Send raw input
</button>
</form>
</article>
<article className="panel panel--log">
<div className="panel__header">
<div>
<p className="eyebrow">Audit</p>
<h2>Simulation Log</h2>
</div>
<button className="ghost-button" type="button" onClick={() => setLogs([])}>
Clear
</button>
</div>
<div className="log-list">
{logs.length === 0 ? <p className="empty">No simulator event sent yet.</p> : null}
{logs.map((log) => (
<details className={`log-entry log-entry--${log.status}`} key={log.id} open={log.status === "error"}>
<summary>
<span>{log.label}</span>
<small>{log.createdAt}</small>
</summary>
<pre>{log.details}</pre>
</details>
))}
</div>
</article>
</section>
</main>
);
}
createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

370
simulator-ui/src/styles.css Normal file
View File

@@ -0,0 +1,370 @@
:root {
color: #2b2b2b;
background: #f5f7fa;
font-family: "Aptos", "Segoe UI", sans-serif;
--primary: #0a2f5a;
--secondary: #2b2b2b;
--accent: #f28c28;
--surface: #ffffff;
--muted: #6f7a86;
--line: #dde5ee;
--success: #0a5a21;
--danger: #b42318;
--shadow: 0 24px 70px rgba(10, 47, 90, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.shell {
min-height: 100vh;
padding: 34px;
background:
radial-gradient(circle at top left, rgba(242, 140, 40, 0.18), transparent 32rem),
linear-gradient(145deg, #f5f7fa 0%, #edf2f7 45%, #ffffff 100%);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
align-items: end;
margin: 0 auto 24px;
max-width: 1280px;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
color: var(--primary);
letter-spacing: -0.04em;
}
h1 {
max-width: 820px;
font-size: clamp(2.4rem, 7vw, 5.8rem);
line-height: 0.9;
}
h2 {
font-size: 1.45rem;
}
.hero__copy {
max-width: 760px;
color: var(--muted);
font-size: 1.08rem;
line-height: 1.7;
}
.connection-card,
.panel {
border: 1px solid rgba(10, 47, 90, 0.1);
border-radius: 28px;
background: rgba(255, 255, 255, 0.86);
box-shadow: var(--shadow);
backdrop-filter: blur(14px);
}
.connection-card {
min-width: 300px;
padding: 20px;
}
.connection-card span {
display: block;
color: var(--muted);
font-size: 0.8rem;
}
.connection-card strong {
display: block;
margin-top: 8px;
color: var(--secondary);
word-break: break-all;
}
.control-grid {
display: grid;
grid-template-columns: minmax(280px, 0.85fr) minmax(360px, 1.45fr);
gap: 22px;
margin: 0 auto;
max-width: 1280px;
}
.panel {
padding: 22px;
}
.panel--scenarios,
.panel--log {
grid-column: span 1;
}
.panel__header {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.field {
display: grid;
gap: 8px;
color: var(--secondary);
font-weight: 700;
}
.field span {
color: var(--muted);
font-size: 0.84rem;
font-weight: 700;
}
input,
select {
width: 100%;
border: 1px solid var(--line);
border-radius: 16px;
background: #ffffff;
color: var(--secondary);
padding: 13px 14px;
outline: none;
}
input:focus,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(242, 140, 40, 0.15);
}
.machine-summary {
display: flex;
gap: 12px;
align-items: center;
margin: 18px 0;
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
background: #f8fafc;
}
.machine-summary small {
display: block;
margin-top: 3px;
color: var(--muted);
}
.status-dot {
width: 18px;
height: 18px;
border-radius: 99px;
background: var(--muted);
}
.status-dot--production {
background: var(--success);
}
.status-dot--arret_non_qualifie,
.status-dot--arret_qualifie {
background: var(--danger);
}
.status-dot--reglage {
background: var(--accent);
}
.numeric-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.scenario-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.scenario-button {
min-height: 118px;
border: 1px solid rgba(10, 47, 90, 0.12);
border-radius: 22px;
background: linear-gradient(145deg, #ffffff, #f3f7fb);
color: var(--secondary);
padding: 18px;
text-align: left;
transition:
transform 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
}
.scenario-button:hover:not(:disabled) {
transform: translateY(-2px);
border-color: rgba(242, 140, 40, 0.8);
box-shadow: 0 18px 34px rgba(10, 47, 90, 0.12);
}
.scenario-button strong {
display: block;
color: var(--primary);
font-size: 1rem;
}
.scenario-button span {
display: block;
margin-top: 8px;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.45;
}
.raw-form {
display: grid;
gap: 14px;
}
.primary-button,
.ghost-button {
border-radius: 999px;
padding: 11px 16px;
font-weight: 800;
}
.primary-button {
border: 0;
background: var(--accent);
color: #241000;
}
.ghost-button {
border: 1px solid var(--line);
background: #ffffff;
color: var(--primary);
}
.busy {
border-radius: 99px;
background: rgba(242, 140, 40, 0.12);
color: #9a520f;
padding: 8px 12px;
font-size: 0.86rem;
font-weight: 800;
}
.log-list {
display: grid;
gap: 10px;
max-height: 520px;
overflow: auto;
}
.log-entry {
border: 1px solid var(--line);
border-radius: 16px;
background: #ffffff;
overflow: hidden;
}
.log-entry--ok {
border-left: 5px solid var(--success);
}
.log-entry--error {
border-left: 5px solid var(--danger);
}
.log-entry summary {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 13px 14px;
color: var(--primary);
font-weight: 800;
list-style: none;
}
.log-entry summary::-webkit-details-marker {
display: none;
}
.log-entry small {
color: var(--muted);
font-weight: 600;
}
pre {
margin: 0;
border-top: 1px solid var(--line);
padding: 14px;
overflow: auto;
color: #1f2937;
font-size: 0.78rem;
line-height: 1.55;
white-space: pre-wrap;
}
.empty {
margin: 0;
color: var(--muted);
}
@media (max-width: 920px) {
.shell {
padding: 20px;
}
.hero,
.control-grid {
grid-template-columns: 1fr;
}
.connection-card {
min-width: 0;
}
}
@media (max-width: 620px) {
.scenario-grid,
.numeric-grid {
grid-template-columns: 1fr;
}
.panel__header {
align-items: flex-start;
flex-direction: column;
}
}

1
simulator-ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react-swc";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const allowedHosts = (env.VITE_ALLOWED_HOSTS ?? "localhost,127.0.0.1")
.split(",")
.map((host) => host.trim())
.filter(Boolean);
return {
plugins: [react()],
server: {
host: "0.0.0.0",
port: 5174,
allowedHosts,
},
};
});