Add separate simulator UI
This commit is contained in:
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