Add simulator language selector
This commit is contained in:
@@ -30,58 +30,65 @@ type LogEntry = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
type Language = "en" | "fr";
|
||||||
|
type Translation = {
|
||||||
|
en: string;
|
||||||
|
fr: string;
|
||||||
|
};
|
||||||
|
|
||||||
const scenarios: Array<{ key: ScenarioKey; title: string; description: string }> = [
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||||
|
const LANGUAGE_STORAGE_KEY = "plast-track-simulator-language";
|
||||||
|
|
||||||
|
const scenarios: Array<{ key: ScenarioKey; title: Translation; description: Translation }> = [
|
||||||
{
|
{
|
||||||
key: "power_on",
|
key: "power_on",
|
||||||
title: "Power ON",
|
title: { en: "Power ON", fr: "Marche ON" },
|
||||||
description: "Simulate machine power available.",
|
description: { en: "Simulate machine power available.", fr: "Simuler la presence alimentation machine." },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "power_off",
|
key: "power_off",
|
||||||
title: "Power OFF",
|
title: { en: "Power OFF", fr: "Arret OFF" },
|
||||||
description: "Close open downtime and move out of planning.",
|
description: { en: "Close open downtime and move out of planning.", fr: "Fermer l'arret ouvert et sortir du planning." },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "cycle_completed",
|
key: "cycle_completed",
|
||||||
title: "Single Cycle",
|
title: { en: "Single Cycle", fr: "Cycle simple" },
|
||||||
description: "Publish one normalized completed cycle.",
|
description: { en: "Publish one normalized completed cycle.", fr: "Publier un cycle termine normalise." },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "cycle_burst",
|
key: "cycle_burst",
|
||||||
title: "Cycle Burst",
|
title: { en: "Cycle Burst", fr: "Rafale cycles" },
|
||||||
description: "Generate many cycles with the selected cycle time.",
|
description: { en: "Generate many cycles with the selected cycle time.", fr: "Generer plusieurs cycles avec le temps choisi." },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "cycle_signal_edge",
|
key: "cycle_signal_edge",
|
||||||
title: "Passive Edge",
|
title: { en: "Passive Edge", fr: "Front passif" },
|
||||||
description: "Toggle cycle_signal using the machine configured edge.",
|
description: { en: "Toggle cycle_signal using the machine configured edge.", fr: "Basculer cycle_signal selon le front configure." },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "debounce_noise",
|
key: "debounce_noise",
|
||||||
title: "Debounce Noise",
|
title: { en: "Debounce Noise", fr: "Bruit anti-rebond" },
|
||||||
description: "Send a too-fast edge that should be ignored.",
|
description: { en: "Send a too-fast edge that should be ignored.", fr: "Envoyer un front trop rapide qui doit etre ignore." },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "machine_stopped",
|
key: "machine_stopped",
|
||||||
title: "Open Stop",
|
title: { en: "Open Stop", fr: "Ouvrir arret" },
|
||||||
description: "Force an unqualified downtime if an OF is running.",
|
description: { en: "Force an unqualified downtime if an OF is running.", fr: "Forcer un arret non qualifie si un OF est en cours." },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "recover_with_cycle",
|
key: "recover_with_cycle",
|
||||||
title: "Recover",
|
title: { en: "Recover", fr: "Reprise" },
|
||||||
description: "Close downtime by producing a new cycle.",
|
description: { en: "Close downtime by producing a new cycle.", fr: "Fermer l'arret avec un nouveau cycle." },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "alarm_on",
|
key: "alarm_on",
|
||||||
title: "Alarm ON",
|
title: { en: "Alarm ON", fr: "Alarme ON" },
|
||||||
description: "Raise the general_alarm passive signal.",
|
description: { en: "Raise the general_alarm passive signal.", fr: "Activer le signal passif general_alarm." },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "alarm_off",
|
key: "alarm_off",
|
||||||
title: "Alarm OFF",
|
title: { en: "Alarm OFF", fr: "Alarme OFF" },
|
||||||
description: "Clear the general_alarm passive signal.",
|
description: { en: "Clear the general_alarm passive signal.", fr: "Desactiver le signal passif general_alarm." },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -113,6 +120,10 @@ function parseInputValue(value: string): boolean | number | string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [language, setLanguage] = useState<Language>(() => {
|
||||||
|
const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||||
|
return savedLanguage === "en" || savedLanguage === "fr" ? savedLanguage : "fr";
|
||||||
|
});
|
||||||
const [machines, setMachines] = useState<Machine[]>([]);
|
const [machines, setMachines] = useState<Machine[]>([]);
|
||||||
const [selectedMachine, setSelectedMachine] = useState("");
|
const [selectedMachine, setSelectedMachine] = useState("");
|
||||||
const [cycleTimeSec, setCycleTimeSec] = useState(20);
|
const [cycleTimeSec, setCycleTimeSec] = useState(20);
|
||||||
@@ -122,15 +133,27 @@ function App() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
|
||||||
|
function t(en: string, fr: string) {
|
||||||
|
return language === "fr" ? fr : en;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tt(value: Translation) {
|
||||||
|
return value[language];
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiRequest<Machine[]>("/api/machines")
|
apiRequest<Machine[]>("/api/machines")
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
setMachines(items);
|
setMachines(items);
|
||||||
setSelectedMachine((current) => current || items[0]?.code || "");
|
setSelectedMachine((current) => current || items[0]?.code || "");
|
||||||
})
|
})
|
||||||
.catch((error: Error) => addLog("Load machines", "error", error.message));
|
.catch((error: Error) => addLog(t("Load machines", "Chargement machines"), "error", error.message));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
function addLog(label: string, status: LogEntry["status"], details: string) {
|
function addLog(label: string, status: LogEntry["status"], details: string) {
|
||||||
setLogs((current) => [
|
setLogs((current) => [
|
||||||
{
|
{
|
||||||
@@ -146,7 +169,7 @@ function App() {
|
|||||||
|
|
||||||
async function runScenario(scenario: ScenarioKey) {
|
async function runScenario(scenario: ScenarioKey) {
|
||||||
if (!selectedMachine) {
|
if (!selectedMachine) {
|
||||||
addLog("Scenario blocked", "error", "Select a machine first.");
|
addLog(t("Scenario blocked", "Scenario bloque"), "error", t("Select a machine first.", "Selectionner une machine d'abord."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -160,9 +183,10 @@ function App() {
|
|||||||
count: burstCount,
|
count: burstCount,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
addLog(scenario, "ok", JSON.stringify(result, null, 2));
|
const scenarioLabel = scenarios.find((item) => item.key === scenario)?.title;
|
||||||
|
addLog(scenarioLabel ? tt(scenarioLabel) : scenario, "ok", JSON.stringify(result, null, 2));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addLog(scenario, "error", error instanceof Error ? error.message : "Unknown error");
|
addLog(scenario, "error", error instanceof Error ? error.message : t("Unknown error", "Erreur inconnue"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -171,7 +195,7 @@ function App() {
|
|||||||
async function sendRawInput(event: FormEvent<HTMLFormElement>) {
|
async function sendRawInput(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!selectedMachine || !rawInputName.trim()) {
|
if (!selectedMachine || !rawInputName.trim()) {
|
||||||
addLog("Raw input blocked", "error", "Machine and input name are required.");
|
addLog(t("Raw input blocked", "Entree brute bloquee"), "error", t("Machine and input name are required.", "Machine et nom entree sont obligatoires."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -187,45 +211,57 @@ function App() {
|
|||||||
});
|
});
|
||||||
addLog(`raw:${rawInputName}`, "ok", JSON.stringify(result, null, 2));
|
addLog(`raw:${rawInputName}`, "ok", JSON.stringify(result, null, 2));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addLog("Raw input", "error", error instanceof Error ? error.message : "Unknown error");
|
addLog(t("Raw input", "Entree brute"), "error", error instanceof Error ? error.message : t("Unknown error", "Erreur inconnue"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedMachineInfo = machines.find((machine) => machine.code === selectedMachine);
|
const selectedMachineInfo = machines.find((machine) => machine.code === selectedMachine);
|
||||||
|
const machinePowerLabel = selectedMachineInfo?.powered_on ? t("Powered ON", "Sous tension") : t("Powered OFF", "Hors tension");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Separate test console</p>
|
<p className="eyebrow">{t("Separate test console", "Console de test separee")}</p>
|
||||||
<h1>Passive Signal Simulator</h1>
|
<h1>{t("Passive Signal Simulator", "Simulateur signaux passifs")}</h1>
|
||||||
<p className="hero__copy">
|
<p className="hero__copy">
|
||||||
Simulate cycles, power states, stops, alarms, signal edges, debounce noise, and raw digital inputs without
|
{t(
|
||||||
adding test controls to the production dashboard.
|
"Simulate cycles, power states, stops, alarms, signal edges, debounce noise, and raw digital inputs without adding test controls to the production dashboard.",
|
||||||
|
"Simuler cycles, etats alimentation, arrets, alarmes, fronts signal, bruit anti-rebond et entrees digitales brutes sans ajouter de controles de test au tableau de production.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="hero__side">
|
||||||
|
<label className="language-select">
|
||||||
|
<span>{t("Language", "Langue")}</span>
|
||||||
|
<select value={language} onChange={(event) => setLanguage(event.target.value as Language)}>
|
||||||
|
<option value="fr">Francais</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<div className="connection-card">
|
<div className="connection-card">
|
||||||
<span>Backend API</span>
|
<span>{t("Backend API", "API backend")}</span>
|
||||||
<strong>{API_BASE_URL}</strong>
|
<strong>{API_BASE_URL}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="control-grid">
|
<section className="control-grid">
|
||||||
<article className="panel panel--machine">
|
<article className="panel panel--machine">
|
||||||
<div className="panel__header">
|
<div className="panel__header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Target</p>
|
<p className="eyebrow">{t("Target", "Cible")}</p>
|
||||||
<h2>Machine</h2>
|
<h2>{t("Machine", "Machine")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button className="ghost-button" type="button" onClick={() => window.location.reload()}>
|
<button className="ghost-button" type="button" onClick={() => window.location.reload()}>
|
||||||
Reload
|
{t("Reload", "Recharger")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Machine code</span>
|
<span>{t("Machine code", "Code machine")}</span>
|
||||||
<select value={selectedMachine} onChange={(event) => setSelectedMachine(event.target.value)}>
|
<select value={selectedMachine} onChange={(event) => setSelectedMachine(event.target.value)}>
|
||||||
{machines.map((machine) => (
|
{machines.map((machine) => (
|
||||||
<option key={machine.code} value={machine.code}>
|
<option key={machine.code} value={machine.code}>
|
||||||
@@ -240,16 +276,16 @@ function App() {
|
|||||||
<span className={`status-dot status-dot--${selectedMachineInfo.status}`} />
|
<span className={`status-dot status-dot--${selectedMachineInfo.status}`} />
|
||||||
<div>
|
<div>
|
||||||
<strong>{selectedMachineInfo.status}</strong>
|
<strong>{selectedMachineInfo.status}</strong>
|
||||||
<small>{selectedMachineInfo.powered_on ? "Powered ON" : "Powered OFF"}</small>
|
<small>{machinePowerLabel}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="empty">No machine returned by the backend.</p>
|
<p className="empty">{t("No machine returned by the backend.", "Aucune machine retournee par le backend.")}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="numeric-grid">
|
<div className="numeric-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Cycle time sec</span>
|
<span>{t("Cycle time sec", "Temps cycle sec")}</span>
|
||||||
<input
|
<input
|
||||||
min="1"
|
min="1"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
@@ -259,7 +295,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Burst count</span>
|
<span>{t("Burst count", "Nombre cycles")}</span>
|
||||||
<input
|
<input
|
||||||
min="1"
|
min="1"
|
||||||
max="100"
|
max="100"
|
||||||
@@ -274,10 +310,10 @@ function App() {
|
|||||||
<article className="panel panel--scenarios">
|
<article className="panel panel--scenarios">
|
||||||
<div className="panel__header">
|
<div className="panel__header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Cases</p>
|
<p className="eyebrow">{t("Cases", "Cas")}</p>
|
||||||
<h2>Scenario Launcher</h2>
|
<h2>{t("Scenario Launcher", "Lanceur scenarios")}</h2>
|
||||||
</div>
|
</div>
|
||||||
{loading ? <span className="busy">Sending...</span> : null}
|
{loading ? <span className="busy">{t("Sending...", "Envoi...")}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="scenario-grid">
|
<div className="scenario-grid">
|
||||||
@@ -289,8 +325,8 @@ function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => runScenario(scenario.key)}
|
onClick={() => runScenario(scenario.key)}
|
||||||
>
|
>
|
||||||
<strong>{scenario.title}</strong>
|
<strong>{tt(scenario.title)}</strong>
|
||||||
<span>{scenario.description}</span>
|
<span>{tt(scenario.description)}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -299,21 +335,21 @@ function App() {
|
|||||||
<article className="panel">
|
<article className="panel">
|
||||||
<div className="panel__header">
|
<div className="panel__header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Manual</p>
|
<p className="eyebrow">{t("Manual", "Manuel")}</p>
|
||||||
<h2>Raw Passive Input</h2>
|
<h2>{t("Raw Passive Input", "Entree passive brute")}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form className="raw-form" onSubmit={sendRawInput}>
|
<form className="raw-form" onSubmit={sendRawInput}>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Input name</span>
|
<span>{t("Input name", "Nom entree")}</span>
|
||||||
<input value={rawInputName} onChange={(event) => setRawInputName(event.target.value)} />
|
<input value={rawInputName} onChange={(event) => setRawInputName(event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Input value</span>
|
<span>{t("Input value", "Valeur entree")}</span>
|
||||||
<input value={rawInputValue} onChange={(event) => setRawInputValue(event.target.value)} />
|
<input value={rawInputValue} onChange={(event) => setRawInputValue(event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<button className="primary-button" disabled={loading || !selectedMachine} type="submit">
|
<button className="primary-button" disabled={loading || !selectedMachine} type="submit">
|
||||||
Send raw input
|
{t("Send raw input", "Envoyer entree brute")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
@@ -321,15 +357,15 @@ function App() {
|
|||||||
<article className="panel panel--log">
|
<article className="panel panel--log">
|
||||||
<div className="panel__header">
|
<div className="panel__header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Audit</p>
|
<p className="eyebrow">{t("Audit", "Audit")}</p>
|
||||||
<h2>Simulation Log</h2>
|
<h2>{t("Simulation Log", "Journal simulation")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button className="ghost-button" type="button" onClick={() => setLogs([])}>
|
<button className="ghost-button" type="button" onClick={() => setLogs([])}>
|
||||||
Clear
|
{t("Clear", "Effacer")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="log-list">
|
<div className="log-list">
|
||||||
{logs.length === 0 ? <p className="empty">No simulator event sent yet.</p> : null}
|
{logs.length === 0 ? <p className="empty">{t("No simulator event sent yet.", "Aucun evenement simulateur envoye.")}</p> : null}
|
||||||
{logs.map((log) => (
|
{logs.map((log) => (
|
||||||
<details className={`log-entry log-entry--${log.status}`} key={log.id} open={log.status === "error"}>
|
<details className={`log-entry log-entry--${log.status}`} key={log.id} open={log.status === "error"}>
|
||||||
<summary>
|
<summary>
|
||||||
|
|||||||
@@ -96,6 +96,29 @@ h2 {
|
|||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero__side {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-select {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid rgba(10, 47, 90, 0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
box-shadow: 0 12px 30px rgba(10, 47, 90, 0.08);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-select span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.connection-card {
|
.connection-card {
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -355,6 +378,10 @@ pre {
|
|||||||
.connection-card {
|
.connection-card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero__side {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user