Add separate simulator UI
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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*
|
||||
|
||||
79
README.md
79
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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
12
simulator-ui/Dockerfile
Normal 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
12
simulator-ui/index.html
Normal 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
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
22
simulator-ui/package.json
Normal 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
353
simulator-ui/src/main.tsx
Normal 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
370
simulator-ui/src/styles.css
Normal 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
1
simulator-ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
17
simulator-ui/tsconfig.json
Normal file
17
simulator-ui/tsconfig.json
Normal 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"]
|
||||
}
|
||||
19
simulator-ui/vite.config.ts
Normal file
19
simulator-ui/vite.config.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user