Add simulator language selector

This commit is contained in:
masterdev
2026-06-17 11:25:54 +01:00
parent 77eac77050
commit e35a46ca03
2 changed files with 120 additions and 57 deletions

View File

@@ -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,28 +211,40 @@ 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="connection-card"> <div className="hero__side">
<span>Backend API</span> <label className="language-select">
<strong>{API_BASE_URL}</strong> <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">
<span>{t("Backend API", "API backend")}</span>
<strong>{API_BASE_URL}</strong>
</div>
</div> </div>
</section> </section>
@@ -216,16 +252,16 @@ function App() {
<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>

View File

@@ -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) {