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;
};
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",
title: "Power ON",
description: "Simulate machine power available.",
title: { en: "Power ON", fr: "Marche ON" },
description: { en: "Simulate machine power available.", fr: "Simuler la presence alimentation machine." },
},
{
key: "power_off",
title: "Power OFF",
description: "Close open downtime and move out of planning.",
title: { en: "Power OFF", fr: "Arret OFF" },
description: { en: "Close open downtime and move out of planning.", fr: "Fermer l'arret ouvert et sortir du planning." },
},
{
key: "cycle_completed",
title: "Single Cycle",
description: "Publish one normalized completed cycle.",
title: { en: "Single Cycle", fr: "Cycle simple" },
description: { en: "Publish one normalized completed cycle.", fr: "Publier un cycle termine normalise." },
},
{
key: "cycle_burst",
title: "Cycle Burst",
description: "Generate many cycles with the selected cycle time.",
title: { en: "Cycle Burst", fr: "Rafale cycles" },
description: { en: "Generate many cycles with the selected cycle time.", fr: "Generer plusieurs cycles avec le temps choisi." },
},
{
key: "cycle_signal_edge",
title: "Passive Edge",
description: "Toggle cycle_signal using the machine configured edge.",
title: { en: "Passive Edge", fr: "Front passif" },
description: { en: "Toggle cycle_signal using the machine configured edge.", fr: "Basculer cycle_signal selon le front configure." },
},
{
key: "debounce_noise",
title: "Debounce Noise",
description: "Send a too-fast edge that should be ignored.",
title: { en: "Debounce Noise", fr: "Bruit anti-rebond" },
description: { en: "Send a too-fast edge that should be ignored.", fr: "Envoyer un front trop rapide qui doit etre ignore." },
},
{
key: "machine_stopped",
title: "Open Stop",
description: "Force an unqualified downtime if an OF is running.",
title: { en: "Open Stop", fr: "Ouvrir arret" },
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",
title: "Recover",
description: "Close downtime by producing a new cycle.",
title: { en: "Recover", fr: "Reprise" },
description: { en: "Close downtime by producing a new cycle.", fr: "Fermer l'arret avec un nouveau cycle." },
},
{
key: "alarm_on",
title: "Alarm ON",
description: "Raise the general_alarm passive signal.",
title: { en: "Alarm ON", fr: "Alarme ON" },
description: { en: "Raise the general_alarm passive signal.", fr: "Activer le signal passif general_alarm." },
},
{
key: "alarm_off",
title: "Alarm OFF",
description: "Clear the general_alarm passive signal.",
title: { en: "Alarm OFF", fr: "Alarme OFF" },
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() {
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 [selectedMachine, setSelectedMachine] = useState("");
const [cycleTimeSec, setCycleTimeSec] = useState(20);
@@ -122,15 +133,27 @@ function App() {
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<LogEntry[]>([]);
function t(en: string, fr: string) {
return language === "fr" ? fr : en;
}
function tt(value: Translation) {
return value[language];
}
useEffect(() => {
apiRequest<Machine[]>("/api/machines")
.then((items) => {
setMachines(items);
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) {
setLogs((current) => [
{
@@ -146,7 +169,7 @@ function App() {
async function runScenario(scenario: ScenarioKey) {
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;
}
setLoading(true);
@@ -160,9 +183,10 @@ function App() {
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) {
addLog(scenario, "error", error instanceof Error ? error.message : "Unknown error");
addLog(scenario, "error", error instanceof Error ? error.message : t("Unknown error", "Erreur inconnue"));
} finally {
setLoading(false);
}
@@ -171,7 +195,7 @@ function App() {
async function sendRawInput(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
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;
}
setLoading(true);
@@ -187,45 +211,57 @@ function App() {
});
addLog(`raw:${rawInputName}`, "ok", JSON.stringify(result, null, 2));
} 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 {
setLoading(false);
}
}
const selectedMachineInfo = machines.find((machine) => machine.code === selectedMachine);
const machinePowerLabel = selectedMachineInfo?.powered_on ? t("Powered ON", "Sous tension") : t("Powered OFF", "Hors tension");
return (
<main className="shell">
<section className="hero">
<div>
<p className="eyebrow">Separate test console</p>
<h1>Passive Signal Simulator</h1>
<p className="eyebrow">{t("Separate test console", "Console de test separee")}</p>
<h1>{t("Passive Signal Simulator", "Simulateur signaux passifs")}</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.
{t(
"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>
</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">
<span>Backend API</span>
<span>{t("Backend API", "API backend")}</span>
<strong>{API_BASE_URL}</strong>
</div>
</div>
</section>
<section className="control-grid">
<article className="panel panel--machine">
<div className="panel__header">
<div>
<p className="eyebrow">Target</p>
<h2>Machine</h2>
<p className="eyebrow">{t("Target", "Cible")}</p>
<h2>{t("Machine", "Machine")}</h2>
</div>
<button className="ghost-button" type="button" onClick={() => window.location.reload()}>
Reload
{t("Reload", "Recharger")}
</button>
</div>
<label className="field">
<span>Machine code</span>
<span>{t("Machine code", "Code machine")}</span>
<select value={selectedMachine} onChange={(event) => setSelectedMachine(event.target.value)}>
{machines.map((machine) => (
<option key={machine.code} value={machine.code}>
@@ -240,16 +276,16 @@ function App() {
<span className={`status-dot status-dot--${selectedMachineInfo.status}`} />
<div>
<strong>{selectedMachineInfo.status}</strong>
<small>{selectedMachineInfo.powered_on ? "Powered ON" : "Powered OFF"}</small>
<small>{machinePowerLabel}</small>
</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">
<label className="field">
<span>Cycle time sec</span>
<span>{t("Cycle time sec", "Temps cycle sec")}</span>
<input
min="1"
step="0.1"
@@ -259,7 +295,7 @@ function App() {
/>
</label>
<label className="field">
<span>Burst count</span>
<span>{t("Burst count", "Nombre cycles")}</span>
<input
min="1"
max="100"
@@ -274,10 +310,10 @@ function App() {
<article className="panel panel--scenarios">
<div className="panel__header">
<div>
<p className="eyebrow">Cases</p>
<h2>Scenario Launcher</h2>
<p className="eyebrow">{t("Cases", "Cas")}</p>
<h2>{t("Scenario Launcher", "Lanceur scenarios")}</h2>
</div>
{loading ? <span className="busy">Sending...</span> : null}
{loading ? <span className="busy">{t("Sending...", "Envoi...")}</span> : null}
</div>
<div className="scenario-grid">
@@ -289,8 +325,8 @@ function App() {
type="button"
onClick={() => runScenario(scenario.key)}
>
<strong>{scenario.title}</strong>
<span>{scenario.description}</span>
<strong>{tt(scenario.title)}</strong>
<span>{tt(scenario.description)}</span>
</button>
))}
</div>
@@ -299,21 +335,21 @@ function App() {
<article className="panel">
<div className="panel__header">
<div>
<p className="eyebrow">Manual</p>
<h2>Raw Passive Input</h2>
<p className="eyebrow">{t("Manual", "Manuel")}</p>
<h2>{t("Raw Passive Input", "Entree passive brute")}</h2>
</div>
</div>
<form className="raw-form" onSubmit={sendRawInput}>
<label className="field">
<span>Input name</span>
<span>{t("Input name", "Nom entree")}</span>
<input value={rawInputName} onChange={(event) => setRawInputName(event.target.value)} />
</label>
<label className="field">
<span>Input value</span>
<span>{t("Input value", "Valeur entree")}</span>
<input value={rawInputValue} onChange={(event) => setRawInputValue(event.target.value)} />
</label>
<button className="primary-button" disabled={loading || !selectedMachine} type="submit">
Send raw input
{t("Send raw input", "Envoyer entree brute")}
</button>
</form>
</article>
@@ -321,15 +357,15 @@ function App() {
<article className="panel panel--log">
<div className="panel__header">
<div>
<p className="eyebrow">Audit</p>
<h2>Simulation Log</h2>
<p className="eyebrow">{t("Audit", "Audit")}</p>
<h2>{t("Simulation Log", "Journal simulation")}</h2>
</div>
<button className="ghost-button" type="button" onClick={() => setLogs([])}>
Clear
{t("Clear", "Effacer")}
</button>
</div>
<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) => (
<details className={`log-entry log-entry--${log.status}`} key={log.id} open={log.status === "error"}>
<summary>

View File

@@ -96,6 +96,29 @@ h2 {
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 {
min-width: 300px;
padding: 20px;
@@ -355,6 +378,10 @@ pre {
.connection-card {
min-width: 0;
}
.hero__side {
width: 100%;
}
}
@media (max-width: 620px) {