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

6
.gitignore vendored
View File

@@ -32,6 +32,12 @@ frontend/build/
frontend/coverage/
frontend/.vite/
frontend/*.tsbuildinfo
simulator-ui/node_modules/
simulator-ui/dist/
simulator-ui/build/
simulator-ui/coverage/
simulator-ui/.vite/
simulator-ui/*.tsbuildinfo
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -37,12 +37,14 @@ Services:
- `backend`: FastAPI API, MQTT consumer, downtime logic, OEE computation, WebSocket notifications
- `edge-simulator`: publishes simulated machine events on MQTT
- `frontend`: React + TypeScript operator/dashboard interface
- `simulator-ui`: separate React + TypeScript test console for sending simulated events
## Repository Layout
```text
/backend
/frontend
/simulator-ui
/edge-simulator
/database/init.sql
/database/seed.sql
@@ -65,6 +67,7 @@ docker compose up --build
Exposed services:
- frontend: `http://localhost:5173`
- simulator UI: `http://localhost:5174`
- backend API: `http://localhost:8000`
- backend health: `http://localhost:8000/health`
- PostgreSQL: `localhost:5432`
@@ -80,6 +83,7 @@ file into the web editor, because the stack also needs these repository files:
- `mqtt/mosquitto.conf`
- `backend/`
- `frontend/`
- `simulator-ui/`
- `edge-simulator/`
### 1. Create The Stack
@@ -116,11 +120,14 @@ POSTGRES_DB=plasttrack
POSTGRES_USER=plasttrack
POSTGRES_PASSWORD=change-this-password
FRONTEND_ORIGIN=http://SERVER_IP:5173
FRONTEND_ORIGINS=http://SERVER_IP:5173,http://localhost:5173,http://127.0.0.1:5173
FRONTEND_ORIGINS=http://SERVER_IP:5173,http://SERVER_IP:5174,http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174
BACKEND_PORT=8000
VITE_API_BASE_URL=http://SERVER_IP:8000
VITE_WS_URL=ws://SERVER_IP:8000/ws/dashboard
VITE_ALLOWED_HOSTS=SERVER_IP,localhost,127.0.0.1
SIMULATOR_PORT=5174
SIMULATOR_API_BASE_URL=http://SERVER_IP:8000
SIMULATOR_ALLOWED_HOSTS=SERVER_IP,localhost,127.0.0.1
```
For a domain with HTTPS and a reverse proxy, use:
@@ -130,11 +137,14 @@ POSTGRES_DB=plasttrack
POSTGRES_USER=plasttrack
POSTGRES_PASSWORD=change-this-password
FRONTEND_ORIGIN=https://plasttrack.example.com
FRONTEND_ORIGINS=https://plasttrack.example.com
FRONTEND_ORIGINS=https://plasttrack.example.com,https://simulator.plasttrack.example.com
BACKEND_PORT=8000
VITE_API_BASE_URL=https://api.plasttrack.example.com
VITE_WS_URL=wss://api.plasttrack.example.com/ws/dashboard
VITE_ALLOWED_HOSTS=plasttrack.example.com
SIMULATOR_PORT=5174
SIMULATOR_API_BASE_URL=https://api.plasttrack.example.com
SIMULATOR_ALLOWED_HOSTS=simulator.plasttrack.example.com
```
Important: do not keep `localhost` in `VITE_API_BASE_URL` or `VITE_WS_URL`
@@ -151,13 +161,14 @@ change `BACKEND_PORT` instead of editing `docker-compose.yml`. For example:
BACKEND_PORT=8001
VITE_API_BASE_URL=http://SERVER_IP:8001
VITE_WS_URL=ws://SERVER_IP:8001/ws/dashboard
SIMULATOR_API_BASE_URL=http://SERVER_IP:8001
```
If the browser shows a CORS error, make sure the exact frontend origin is in
`FRONTEND_ORIGINS`. Example:
```env
FRONTEND_ORIGINS=https://plasttrack.sable.ynsdev.site
FRONTEND_ORIGINS=https://plasttrack.sable.ynsdev.site,https://simulator.plasttrack.sable.ynsdev.site
```
If the frontend is served over HTTPS, the WebSocket URL must use `wss://`, not
@@ -166,13 +177,15 @@ same-domain reverse proxy variables:
```env
FRONTEND_ORIGIN=https://plasttrack.sable.ynsdev.site
FRONTEND_ORIGINS=https://plasttrack.sable.ynsdev.site
FRONTEND_ORIGINS=https://plasttrack.sable.ynsdev.site,https://simulator.plasttrack.sable.ynsdev.site
VITE_API_BASE_URL=https://plasttrack.sable.ynsdev.site
VITE_WS_URL=wss://plasttrack.sable.ynsdev.site/ws/dashboard
VITE_ALLOWED_HOSTS=plasttrack.sable.ynsdev.site,localhost,127.0.0.1
SIMULATOR_API_BASE_URL=https://plasttrack.sable.ynsdev.site
SIMULATOR_ALLOWED_HOSTS=simulator.plasttrack.sable.ynsdev.site,localhost,127.0.0.1
```
The reverse proxy must route:
The main dashboard reverse proxy must route:
```text
/api/* -> backend:8000
@@ -180,6 +193,14 @@ The reverse proxy must route:
/* -> frontend:5173
```
The simulator must use a separate link, not a tab inside the operator frontend:
```text
https://simulator.plasttrack.example.com -> simulator-ui:5174
```
If `SIMULATOR_API_BASE_URL` points to the main dashboard domain, that dashboard reverse proxy must also route `/api/*` to the backend.
### 3. Deploy
Click `Deploy the stack`.
@@ -187,6 +208,7 @@ Click `Deploy the stack`.
Expected exposed ports:
- frontend: `5173`
- simulator UI: `5174`
- backend API: `8000`
- MQTT: `1883`
- MQTT WebSocket listener: `9001`
@@ -213,6 +235,12 @@ Open:
http://SERVER_IP:5173
```
Open the simulator test console:
```text
http://SERVER_IP:5174
```
Check the backend:
```text
@@ -325,6 +353,11 @@ Reference data:
- `GET /api/reference-data`
Simulator:
- `POST /api/simulator/events`
- `POST /api/simulator/scenarios`
WebSocket:
- `ws://localhost:8000/ws/dashboard`
@@ -400,7 +433,12 @@ Implemented rules:
## How The Simulator Works
The simulator publishes:
There are two simulator components:
- `edge-simulator`: automatic MQTT publisher for continuous demo data
- `simulator-ui`: separate browser console for manually triggering test cases
The edge simulator publishes:
- `digital_input_changed` for `machine_power_on`
- `digital_input_changed` for `cycle_signal`
@@ -408,6 +446,25 @@ The simulator publishes:
Each machine gets a nominal cycle time, a pulse width, and a random stop probability. The backend consumes these MQTT events, detects the configured cycle edge, and updates production data in PostgreSQL.
The separate simulator UI calls backend simulator endpoints and can trigger:
- power on/off
- single normalized cycle
- cycle burst
- passive `cycle_signal` edge using each machine's configured edge
- debounce noise that should be ignored
- forced stop / open downtime
- recovery with a new cycle
- alarm on/off
- raw passive digital input by name and value
Use the simulator UI through its own URL, for example:
```text
http://localhost:5174
https://simulator.plasttrack.example.com
```
## Connecting A Real Passive Gateway Later
To replace the simulator with a real gateway:
@@ -461,6 +518,14 @@ npm install
npm run build
```
Simulator UI build check:
```bash
cd simulator-ui
npm install
npm run build
```
## Current Limits
- No authentication or role management
@@ -469,7 +534,7 @@ npm run build
- No historical reporting endpoints beyond daily OEE/TRS
- No real passive gateway adapter package yet; the simulator publishes normalized MQTT payloads
- WebSocket currently pushes refresh notifications, then the UI refetches data
- The simulator models passive-signal-derived events, not actual electrical IO reads
- The simulators model passive-signal-derived events, not actual electrical IO reads
## Current UI Modules

View File

@@ -19,7 +19,7 @@ class Settings(BaseSettings):
mqtt_topic: str = "plasttrack/machines/+/events"
mqtt_client_id: str = "plast-track-backend"
frontend_origin: str | None = None
frontend_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
frontend_origins: str = "http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174"
downtime_check_interval_sec: int = 5
database_startup_retries: int = 30
database_startup_retry_delay_sec: float = 2.0

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
from fastapi import Depends, FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
@@ -13,9 +13,17 @@ from sqlalchemy.orm import Session
from .config import get_settings
from .database import Base, SessionLocal, engine, get_db
from .models import Cycle, Downtime, DowntimeReason, Machine, MachineEvent, ProductionOrder, Scrap, ScrapReason
from .schemas import DowntimeQualify, MachineConfigUpdate, ProductionOrderCreate, ScrapCreate
from .schemas import (
DowntimeQualify,
MachineConfigUpdate,
MachineEventIn,
ProductionOrderCreate,
ScrapCreate,
SimulatorEventCreate,
SimulatorScenarioCreate,
)
from .services.dashboard import build_dashboard_snapshot, serialize_machine_card, serialize_machine_config
from .services.events import detect_missing_cycles, get_open_downtime, get_running_order
from .services.events import detect_missing_cycles, get_open_downtime, get_running_order, handle_machine_event
from .services.mqtt_listener import MqttListener
from .services.oee import compute_daily_oee, compute_machine_oee
from .services.realtime import hub
@@ -259,6 +267,160 @@ def get_machine_events(machine_id: int, limit: int = Query(default=50, le=200),
]
def _get_machine_by_code_or_404(db: Session, machine_code: str) -> Machine:
machine = db.execute(select(Machine).where(Machine.code == machine_code)).scalar_one_or_none()
if machine is None:
raise HTTPException(status_code=404, detail="Machine not found")
return machine
def _emit_simulator_event(db: Session, payload: SimulatorEventCreate) -> dict:
_get_machine_by_code_or_404(db, payload.machine_id)
incoming = MachineEventIn(
machine_id=payload.machine_id,
event_type=payload.event_type,
timestamp=payload.timestamp or datetime.now(UTC),
cycle_time_sec=payload.cycle_time_sec,
source="simulator-ui",
input_name=payload.input_name,
input_value=payload.input_value,
)
result = handle_machine_event(db, incoming)
if result["should_refresh"]:
hub.notify({"type": "refresh", "source": "simulator-ui", **result})
return result
@app.post("/api/simulator/events")
def create_simulator_event(payload: SimulatorEventCreate, db: Session = Depends(get_db)) -> dict:
return _emit_simulator_event(db, payload)
@app.post("/api/simulator/scenarios")
def run_simulator_scenario(payload: SimulatorScenarioCreate, db: Session = Depends(get_db)) -> dict:
machine = _get_machine_by_code_or_404(db, payload.machine_id)
now = datetime.now(UTC)
cycle_time_sec = float(payload.cycle_time_sec or 20)
events: list[SimulatorEventCreate] = []
if payload.scenario == "power_on":
events.append(SimulatorEventCreate(machine_id=machine.code, event_type="machine_power_on", timestamp=now))
elif payload.scenario == "power_off":
events.append(SimulatorEventCreate(machine_id=machine.code, event_type="machine_power_off", timestamp=now))
elif payload.scenario == "cycle_completed":
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="cycle_completed",
timestamp=now,
cycle_time_sec=cycle_time_sec,
)
)
elif payload.scenario == "cycle_burst":
first_event_time = now - timedelta(seconds=cycle_time_sec * max(payload.count - 1, 0))
for index in range(payload.count):
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="cycle_completed",
timestamp=first_event_time + timedelta(seconds=cycle_time_sec * index),
cycle_time_sec=cycle_time_sec,
)
)
elif payload.scenario == "cycle_signal_edge":
current_value = machine.cycle_signal_edge != "falling"
previous_value = not current_value
events.extend(
[
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now - timedelta(milliseconds=max(machine.debounce_ms + 100, 500)),
input_name="cycle_signal",
input_value=previous_value,
),
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name="cycle_signal",
input_value=current_value,
cycle_time_sec=cycle_time_sec,
),
]
)
elif payload.scenario == "debounce_noise":
current_value = machine.cycle_signal_edge != "falling"
previous_value = not current_value
events.extend(
[
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now - timedelta(milliseconds=max(machine.debounce_ms - 50, 1)),
input_name="cycle_signal",
input_value=previous_value,
),
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name="cycle_signal",
input_value=current_value,
cycle_time_sec=cycle_time_sec,
),
]
)
elif payload.scenario == "machine_stopped":
events.append(SimulatorEventCreate(machine_id=machine.code, event_type="machine_stopped", timestamp=now))
elif payload.scenario == "recover_with_cycle":
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="cycle_completed",
timestamp=now,
cycle_time_sec=cycle_time_sec,
)
)
elif payload.scenario == "alarm_on":
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name="general_alarm",
input_value=True,
)
)
elif payload.scenario == "alarm_off":
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name="general_alarm",
input_value=False,
)
)
elif payload.scenario == "raw_input":
if not payload.input_name:
raise HTTPException(status_code=400, detail="input_name is required for raw_input")
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name=payload.input_name,
input_value=payload.input_value,
)
)
else:
raise HTTPException(status_code=400, detail="Unsupported simulator scenario")
results = [_emit_simulator_event(db, event) for event in events]
return {"scenario": payload.scenario, "machine_id": machine.code, "events": results}
@app.get("/api/machines/{machine_id}/cycles")
def get_machine_cycles(machine_id: int, limit: int = Query(default=100, le=500), db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(

View File

@@ -57,3 +57,21 @@ class MachineConfigUpdate(BaseModel):
min_cycle_time_sec: int | None = Field(default=None, ge=1)
max_cycle_time_sec: int | None = Field(default=None, ge=1)
stop_detection_delay_sec: int | None = Field(default=None, ge=1)
class SimulatorEventCreate(BaseModel):
machine_id: str
event_type: str
timestamp: datetime | None = None
input_name: str | None = None
input_value: bool | int | str | None = None
cycle_time_sec: float | None = Field(default=None, gt=0)
class SimulatorScenarioCreate(BaseModel):
machine_id: str
scenario: str
cycle_time_sec: float | None = Field(default=None, gt=0)
count: int = Field(default=1, ge=1, le=100)
input_name: str | None = None
input_value: bool | int | str | None = None

View File

@@ -6,9 +6,9 @@ from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from app.database import Base
from app.main import start_production_order
from app.main import run_simulator_scenario, start_production_order
from app.models import Cycle, Downtime, Machine, MachineEvent, ProductionOrder
from app.schemas import MachineEventIn
from app.schemas import MachineEventIn, SimulatorScenarioCreate
from app.services.events import _is_matching_edge, detect_missing_cycles, handle_machine_event, normalize_signal_value, parse_machine_event
@@ -157,3 +157,41 @@ def test_start_order_rejects_second_running_order_on_machine(db_session: Session
assert exc.value.status_code == 400
assert exc.value.detail == "Another order is already running on this machine"
def test_simulator_cycle_burst_uses_production_event_logic(db_session: Session) -> None:
machine = seed_running_machine(db_session)
result = run_simulator_scenario(
SimulatorScenarioCreate(
machine_id=machine.code,
scenario="cycle_burst",
cycle_time_sec=12,
count=3,
),
db_session,
)
cycles = db_session.execute(select(Cycle).where(Cycle.machine_id == machine.id)).scalars().all()
assert result["scenario"] == "cycle_burst"
assert len(result["events"]) == 3
assert len(cycles) == 3
assert {cycle.cycle_time_sec for cycle in cycles} == {12}
def test_simulator_debounce_noise_is_ignored(db_session: Session) -> None:
machine = seed_running_machine(db_session)
result = run_simulator_scenario(
SimulatorScenarioCreate(
machine_id=machine.code,
scenario="debounce_noise",
cycle_time_sec=12,
),
db_session,
)
cycles = db_session.execute(select(Cycle).where(Cycle.machine_id == machine.id)).scalars().all()
assert result["scenario"] == "debounce_noise"
assert len(result["events"]) == 2
assert cycles == []

View File

@@ -39,7 +39,7 @@ services:
MQTT_PORT: 1883
MQTT_TOPIC: plasttrack/machines/+/events
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:5173}
FRONTEND_ORIGINS: ${FRONTEND_ORIGINS:-http://localhost:5173,http://127.0.0.1:5173}
FRONTEND_ORIGINS: ${FRONTEND_ORIGINS:-http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174}
depends_on:
database:
condition: service_healthy
@@ -72,5 +72,16 @@ services:
ports:
- "5173:5173"
simulator-ui:
build:
context: ./simulator-ui
environment:
VITE_API_BASE_URL: ${SIMULATOR_API_BASE_URL:-http://localhost:8000}
VITE_ALLOWED_HOSTS: ${SIMULATOR_ALLOWED_HOSTS:-localhost,127.0.0.1,simulator.plasttrack.sable.ynsdev.site}
depends_on:
- backend
ports:
- "${SIMULATOR_PORT:-5174}:5174"
volumes:
postgres_data:

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,
},
};
});