node-red-contrib-me-vplc 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +42 -0
  2. package/me-vplc.html +64 -0
  3. package/me-vplc.js +603 -0
  4. package/package.json +26 -0
  5. package/project/README.md +1052 -0
  6. package/project/START_ME_VPLC.cmd +176 -0
  7. package/project/backend/active_project.json +3 -0
  8. package/project/backend/app.py +839 -0
  9. package/project/backend/connector_runtime.py +585 -0
  10. package/project/backend/requirements.txt +3 -0
  11. package/project/backend/st_compiler.py +1415 -0
  12. package/project/frontend/index.html +12 -0
  13. package/project/frontend/package.json +18 -0
  14. package/project/frontend/src/App.jsx +631 -0
  15. package/project/frontend/src/style.css +964 -0
  16. package/project/frontend/vite.config.js +14 -0
  17. package/wheelhouse/Flask_Cors-4.0.1-py2.py3-none-any.whl +0 -0
  18. package/wheelhouse/blinker-1.9.0-py3-none-any.whl +0 -0
  19. package/wheelhouse/click-8.3.3-py3-none-any.whl +0 -0
  20. package/wheelhouse/colorama-0.4.6-py2.py3-none-any.whl +0 -0
  21. package/wheelhouse/flask-3.0.3-py3-none-any.whl +0 -0
  22. package/wheelhouse/itsdangerous-2.2.0-py3-none-any.whl +0 -0
  23. package/wheelhouse/jinja2-3.1.6-py3-none-any.whl +0 -0
  24. package/wheelhouse/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
  25. package/wheelhouse/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
  26. package/wheelhouse/markupsafe-3.0.3-cp310-cp310-win_amd64.whl +0 -0
  27. package/wheelhouse/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
  28. package/wheelhouse/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
  29. package/wheelhouse/markupsafe-3.0.3-cp311-cp311-win_amd64.whl +0 -0
  30. package/wheelhouse/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
  31. package/wheelhouse/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
  32. package/wheelhouse/markupsafe-3.0.3-cp312-cp312-win_amd64.whl +0 -0
  33. package/wheelhouse/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
  34. package/wheelhouse/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
  35. package/wheelhouse/markupsafe-3.0.3-cp313-cp313-win_amd64.whl +0 -0
  36. package/wheelhouse/pymodbus-3.6.9-py3-none-any.whl +0 -0
  37. package/wheelhouse/werkzeug-3.1.8-py3-none-any.whl +0 -0
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="de">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>ME vPLC</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/App.jsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "me-vplc-frontend",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@vitejs/plugin-react": "latest",
13
+ "vite": "latest",
14
+ "react": "latest",
15
+ "react-dom": "latest"
16
+ },
17
+ "devDependencies": {}
18
+ }
@@ -0,0 +1,631 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import "./style.css";
4
+
5
+ const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:5000";
6
+ const PHASES = ["READ", "EXECUTE", "WRITE", "SYNC"];
7
+
8
+ function defaultStatus() {
9
+ return {
10
+ active_project: "Original",
11
+ projects: [],
12
+ running: false,
13
+ cycle_time_ms: 200,
14
+ auto_cycle_enabled: false,
15
+ real_cycle_avg_ms: 0,
16
+ suggested_cycle_time_ms: 200,
17
+ cycle_count: 0,
18
+ last_cycle_ms: 0,
19
+ phases: {},
20
+ imported_st_project: null,
21
+ imported_connector: null,
22
+ can_start: false,
23
+ st_compile_ok: false,
24
+ st_compile_message: "Kein ST-Projekt importiert.",
25
+ last_error: null,
26
+ connector_status: null,
27
+ stats_window: {
28
+ window_size: 100,
29
+ last_sync_cycle: 0,
30
+ last_sync_time: null,
31
+ current_window_count: 0,
32
+ next_sync_cycle: 100,
33
+ },
34
+ };
35
+ }
36
+
37
+ function formatMs(value) {
38
+ const number = Number(value || 0);
39
+ return Number.isFinite(number) ? number.toFixed(3) : "0.000";
40
+ }
41
+
42
+ function NavButton({ active, onClick, children }) {
43
+ return (
44
+ <button className={active ? "nav-button active" : "nav-button"} onClick={onClick}>
45
+ {children}
46
+ </button>
47
+ );
48
+ }
49
+
50
+ function EditorModal({ editor, onClose, onSelectFile, onChangeContent, onSave }) {
51
+ const [filterText, setFilterText] = useState("");
52
+
53
+ if (!editor.open) return null;
54
+
55
+ const activeFile = editor.files[editor.activeIndex] || { path: "", content: "" };
56
+ const activeContent = activeFile.content || "";
57
+ const lineCount = activeContent.length ? activeContent.split(/\r\n|\r|\n/).length : 0;
58
+ const charCount = activeContent.length;
59
+ const filteredFiles = editor.files
60
+ .map((file, index) => ({ ...file, index }))
61
+ .filter((file) => file.path.toLowerCase().includes(filterText.toLowerCase()));
62
+
63
+ function getFileName(path) {
64
+ const normalized = String(path || "").replace(/\\/g, "/");
65
+ const parts = normalized.split("/");
66
+ return parts[parts.length - 1] || normalized || "Unbenannte Datei";
67
+ }
68
+
69
+ function insertTab(event) {
70
+ if (event.key !== "Tab") return;
71
+ event.preventDefault();
72
+ const textarea = event.currentTarget;
73
+ const start = textarea.selectionStart;
74
+ const end = textarea.selectionEnd;
75
+ const next = `${activeContent.substring(0, start)} ${activeContent.substring(end)}`;
76
+ onChangeContent(next);
77
+ window.requestAnimationFrame(() => {
78
+ textarea.selectionStart = textarea.selectionEnd = start + 2;
79
+ });
80
+ }
81
+
82
+ return (
83
+ <div className="modal-backdrop" role="presentation">
84
+ <section className="modal-card editor-modal" role="dialog" aria-modal="true" aria-label={editor.title}>
85
+ <div className="modal-header editor-header">
86
+ <div className="editor-title-block">
87
+ <h2>{editor.title}</h2>
88
+ <p>{editor.filename}</p>
89
+ </div>
90
+ <div className="editor-header-actions">
91
+ <button onClick={onClose}>Abbrechen</button>
92
+ <button className="primary" onClick={onSave} disabled={editor.saving || !editor.files.length}>
93
+ {editor.saving ? "Speichern..." : "Speichern"}
94
+ </button>
95
+ <button className="modal-close" onClick={onClose}>Schließen</button>
96
+ </div>
97
+ </div>
98
+
99
+ {editor.error && <div className="modal-error">{editor.error}</div>}
100
+
101
+ <div className="editor-toolbar">
102
+ <label className="editor-search">
103
+ <span>Dateien suchen</span>
104
+ <input
105
+ value={filterText}
106
+ onChange={(event) => setFilterText(event.target.value)}
107
+ placeholder="Dateiname filtern..."
108
+ />
109
+ </label>
110
+ <div className="editor-meta">
111
+ <span>{editor.files.length} Datei(en)</span>
112
+ <span>{lineCount} Zeile(n)</span>
113
+ <span>{charCount} Zeichen</span>
114
+ </div>
115
+ </div>
116
+
117
+ <div className="editor-layout">
118
+ <aside className="editor-file-panel" aria-label="Dateien">
119
+ <div className="editor-file-panel-title">Projektdateien</div>
120
+ <div className="editor-file-list">
121
+ {filteredFiles.map((file) => (
122
+ <button
123
+ key={`${file.index}-${file.path}`}
124
+ className={file.index === editor.activeIndex ? "editor-file active" : "editor-file"}
125
+ onClick={() => onSelectFile(file.index)}
126
+ title={file.path}
127
+ >
128
+ <span className="editor-file-name">{getFileName(file.path)}</span>
129
+ <span className="editor-file-path">{file.path}</span>
130
+ </button>
131
+ ))}
132
+ {!filteredFiles.length && <div className="editor-empty">Keine Datei gefunden.</div>}
133
+ </div>
134
+ </aside>
135
+
136
+ <div className="editor-main">
137
+ <div className="editor-path">
138
+ <span>{activeFile.path || "Keine Datei ausgewählt"}</span>
139
+ </div>
140
+ <textarea
141
+ spellCheck="false"
142
+ value={activeContent}
143
+ onKeyDown={insertTab}
144
+ onChange={(event) => onChangeContent(event.target.value)}
145
+ />
146
+ </div>
147
+ </div>
148
+
149
+ <div className="modal-actions editor-footer">
150
+ <span>Bearbeitung ist nur bei PLC STOP möglich. Änderungen werden nach dem Speichern neu geladen.</span>
151
+ <div>
152
+ <button onClick={onClose}>Abbrechen</button>
153
+ <button className="primary" onClick={onSave} disabled={editor.saving || !editor.files.length}>
154
+ {editor.saving ? "Speichern..." : "Speichern"}
155
+ </button>
156
+ </div>
157
+ </div>
158
+ </section>
159
+ </div>
160
+ );
161
+ }
162
+
163
+ function App() {
164
+ const [status, setStatus] = useState(defaultStatus());
165
+ const [activePage, setActivePage] = useState("control");
166
+ const [cycleTime, setCycleTime] = useState(200);
167
+ const [cycleInputDirty, setCycleInputDirty] = useState(false);
168
+ const [cycleInputFocused, setCycleInputFocused] = useState(false);
169
+ const [editor, setEditor] = useState({
170
+ open: false,
171
+ kind: null,
172
+ title: "",
173
+ filename: "",
174
+ files: [],
175
+ activeIndex: 0,
176
+ saving: false,
177
+ error: null,
178
+ });
179
+ const stInputRef = useRef(null);
180
+ const connectorInputRef = useRef(null);
181
+
182
+ function updateStatus(data, syncCycleInput = true) {
183
+ setStatus(data);
184
+ if (syncCycleInput && !cycleInputDirty && !cycleInputFocused) {
185
+ setCycleTime(data.cycle_time_ms);
186
+ }
187
+ }
188
+
189
+ async function request(path, options = {}, syncCycleInput = true) {
190
+ const response = await fetch(`${API_BASE}${path}`, options);
191
+ const data = await response.json();
192
+ if (!response.ok) {
193
+ if (data && typeof data === "object" && data.cycle_time_ms) {
194
+ updateStatus(data, syncCycleInput);
195
+ }
196
+ throw new Error(data.error || data.message || data.st_compile_message || "Request fehlgeschlagen");
197
+ }
198
+ updateStatus(data, syncCycleInput);
199
+ return data;
200
+ }
201
+
202
+ async function loadStatus() {
203
+ try {
204
+ const response = await fetch(`${API_BASE}/api/status`);
205
+ const data = await response.json();
206
+ updateStatus(data, true);
207
+ } catch (error) {
208
+ // Backend derzeit nicht erreichbar; UI versucht den Status zyklisch erneut zu laden.
209
+ }
210
+ }
211
+
212
+ useEffect(() => {
213
+ loadStatus();
214
+ const timer = window.setInterval(loadStatus, 1000);
215
+ return () => window.clearInterval(timer);
216
+ }, [cycleInputDirty, cycleInputFocused]);
217
+
218
+ async function startPlc() {
219
+ try {
220
+ await request("/api/start", { method: "POST" });
221
+ } catch (error) {
222
+ // Start-Fehler werden im Backend-Status abgelegt.
223
+ }
224
+ }
225
+
226
+ async function stopPlc() {
227
+ await request("/api/stop", { method: "POST" });
228
+ }
229
+
230
+ async function applyCycleTime() {
231
+ if (status.running) return;
232
+ const data = await request(
233
+ "/api/cycle-time",
234
+ {
235
+ method: "POST",
236
+ headers: { "Content-Type": "application/json" },
237
+ body: JSON.stringify({ cycle_time_ms: Number(cycleTime) }),
238
+ },
239
+ false
240
+ );
241
+ setCycleInputDirty(false);
242
+ setCycleTime(data.cycle_time_ms);
243
+ }
244
+
245
+ async function toggleAutoCycle(event) {
246
+ if (status.running) return;
247
+ const enabled = event.target.checked;
248
+ const data = await request(
249
+ "/api/auto-cycle",
250
+ {
251
+ method: "POST",
252
+ headers: { "Content-Type": "application/json" },
253
+ body: JSON.stringify({ enabled }),
254
+ },
255
+ false
256
+ );
257
+ setCycleInputDirty(false);
258
+ setCycleTime(data.cycle_time_ms);
259
+ }
260
+
261
+ async function resetStats() {
262
+ if (status.running) return;
263
+ await request("/api/reset-stats", { method: "POST" });
264
+ }
265
+
266
+ async function selectProject(event) {
267
+ if (status.running) return;
268
+ const project = event.target.value;
269
+ try {
270
+ const data = await request(
271
+ "/api/projects/select",
272
+ {
273
+ method: "POST",
274
+ headers: { "Content-Type": "application/json" },
275
+ body: JSON.stringify({ project }),
276
+ },
277
+ false
278
+ );
279
+ setCycleInputDirty(false);
280
+ setCycleTime(data.cycle_time_ms);
281
+ } catch (error) {
282
+ // Projektwechsel-Fehler werden über den Status bzw. im Browser-Request sichtbar.
283
+ }
284
+ }
285
+
286
+ async function uploadFile(endpoint, file) {
287
+ if (status.running) return;
288
+ if (!file) return;
289
+ const formData = new FormData();
290
+ formData.append("file", file);
291
+ await request(endpoint, {
292
+ method: "POST",
293
+ body: formData,
294
+ });
295
+ }
296
+
297
+ async function openEditor(kind) {
298
+ if (status.running) return;
299
+ const isConnector = kind === "connector";
300
+ const endpoint = isConnector ? "/api/editor/connector" : "/api/editor/st-project";
301
+ setEditor({
302
+ open: true,
303
+ kind,
304
+ title: isConnector ? "PLC Connector bearbeiten" : "ST Projekt bearbeiten",
305
+ filename: "",
306
+ files: [],
307
+ activeIndex: 0,
308
+ saving: false,
309
+ error: null,
310
+ });
311
+ try {
312
+ const response = await fetch(`${API_BASE}${endpoint}`);
313
+ const data = await response.json();
314
+ if (!response.ok) throw new Error(data.error || "Editor konnte nicht geöffnet werden.");
315
+ setEditor((current) => ({
316
+ ...current,
317
+ filename: data.filename || "",
318
+ files: data.files || [],
319
+ activeIndex: 0,
320
+ error: null,
321
+ }));
322
+ } catch (error) {
323
+ setEditor((current) => ({ ...current, error: error.message || String(error) }));
324
+ }
325
+ }
326
+
327
+ function closeEditor() {
328
+ setEditor({
329
+ open: false,
330
+ kind: null,
331
+ title: "",
332
+ filename: "",
333
+ files: [],
334
+ activeIndex: 0,
335
+ saving: false,
336
+ error: null,
337
+ });
338
+ }
339
+
340
+ function selectEditorFile(index) {
341
+ setEditor((current) => ({ ...current, activeIndex: index }));
342
+ }
343
+
344
+ function changeEditorContent(content) {
345
+ setEditor((current) => ({
346
+ ...current,
347
+ files: current.files.map((file, index) => (
348
+ index === current.activeIndex ? { ...file, content } : file
349
+ )),
350
+ }));
351
+ }
352
+
353
+ async function saveEditor() {
354
+ const isConnector = editor.kind === "connector";
355
+ const endpoint = isConnector ? "/api/editor/connector" : "/api/editor/st-project";
356
+ setEditor((current) => ({ ...current, saving: true, error: null }));
357
+ try {
358
+ const data = await request(
359
+ endpoint,
360
+ {
361
+ method: "POST",
362
+ headers: { "Content-Type": "application/json" },
363
+ body: JSON.stringify({ files: editor.files }),
364
+ },
365
+ false
366
+ );
367
+ setCycleInputDirty(false);
368
+ updateStatus(data, false);
369
+ closeEditor();
370
+ } catch (error) {
371
+ setEditor((current) => ({
372
+ ...current,
373
+ saving: false,
374
+ error: error.message || String(error),
375
+ }));
376
+ }
377
+ }
378
+
379
+ const canEdit = !status.running;
380
+
381
+ return (
382
+ <main className="app">
383
+ <header className="header">
384
+ <div>
385
+ <h1>ME vPLC</h1>
386
+ </div>
387
+ <span className={status.running ? "status running" : "status stopped"}>
388
+ {status.running ? "RUNNING" : "STOPPED"}
389
+ </span>
390
+ </header>
391
+
392
+ <section className="project-toolbar" aria-label="Projekt Auswahl">
393
+ <div>
394
+ <span>Projekt</span>
395
+ <strong>{status.active_project || "Original"}</strong>
396
+ </div>
397
+ <label>
398
+ <span>Projekt auswählen</span>
399
+ <select value={status.active_project || "Original"} onChange={selectProject} disabled={status.running}>
400
+ {(status.projects || []).map((project) => (
401
+ <option key={project} value={project}>{project}</option>
402
+ ))}
403
+ </select>
404
+ </label>
405
+ </section>
406
+
407
+ <nav className="page-nav" aria-label="ME vPLC Navigation">
408
+ <NavButton active={activePage === "control"} onClick={() => setActivePage("control")}>
409
+ PLC Control
410
+ </NavButton>
411
+ <NavButton active={activePage === "connector"} onClick={() => setActivePage("connector")}>
412
+ PLC Connector
413
+ </NavButton>
414
+ <NavButton active={activePage === "cycle"} onClick={() => setActivePage("cycle")}>
415
+ PLC Monitoring
416
+ </NavButton>
417
+ </nav>
418
+
419
+ {activePage === "control" && (
420
+ <section className="page-grid control-page">
421
+ <section className="card controls-card">
422
+ <div className="section-title-row">
423
+ <h2>PLC Control</h2>
424
+ <span>{status.running ? "PLC läuft" : "PLC gestoppt"}</span>
425
+ </div>
426
+
427
+ <div className="control-actions">
428
+ <button
429
+ className="primary"
430
+ onClick={startPlc}
431
+ disabled={status.running || !status.can_start}
432
+ title={!status.can_start ? "Start nicht möglich: Kein ST-Projekt und kein Connector importiert." : "PLC starten"}
433
+ >
434
+ Start
435
+ </button>
436
+ <button className="danger" onClick={stopPlc} disabled={!status.running}>Stop</button>
437
+ <button onClick={resetStats} disabled={status.running}>Statistik zurücksetzen</button>
438
+ </div>
439
+
440
+ <div className="cycle-settings">
441
+ <label className="cycle-control">
442
+ <span>PLC Cycle Time [ms]</span>
443
+ <input
444
+ type="number"
445
+ min="1"
446
+ max="60000"
447
+ value={cycleTime}
448
+ disabled={status.running || status.auto_cycle_enabled}
449
+ onFocus={() => setCycleInputFocused(true)}
450
+ onBlur={() => setCycleInputFocused(false)}
451
+ onChange={(event) => {
452
+ setCycleTime(event.target.value);
453
+ setCycleInputDirty(true);
454
+ }}
455
+ />
456
+ </label>
457
+ <button onClick={applyCycleTime} disabled={status.running || status.auto_cycle_enabled}>Übernehmen</button>
458
+ <label className="togglebar" title="Stellt die Zykluszeit automatisch anhand von READ.avg + EXECUTE.avg + WRITE.avg plus Reserve ein.">
459
+ <input
460
+ type="checkbox"
461
+ checked={Boolean(status.auto_cycle_enabled)}
462
+ disabled={status.running}
463
+ onChange={toggleAutoCycle}
464
+ />
465
+ <span className="toggle-slider" />
466
+ <span className="toggle-text">Auto-Zyklus</span>
467
+ </label>
468
+ </div>
469
+ </section>
470
+ </section>
471
+ )}
472
+
473
+ {activePage === "connector" && (
474
+ <section className="page-grid">
475
+ <section className="card imports">
476
+ <input
477
+ ref={connectorInputRef}
478
+ type="file"
479
+ hidden
480
+ accept=".json,.zip,.txt"
481
+ onChange={(event) => uploadFile("/api/import/connector", event.target.files?.[0])}
482
+ />
483
+ <div className="section-title-row full-row">
484
+ <h2>PLC Connector</h2>
485
+ <button onClick={() => connectorInputRef.current?.click()} disabled={status.running}>Import Connector</button>
486
+ </div>
487
+ <div className="import-info full-row">
488
+ <span>Projekt:</span><strong>{status.active_project || "-"}</strong>
489
+ <span>PLC Connector:</span>
490
+ {status.imported_connector ? (
491
+ <button
492
+ className="text-link"
493
+ onClick={() => openEditor("connector")}
494
+ disabled={!canEdit}
495
+ title={canEdit ? "PLC Connector bearbeiten" : "Bearbeiten nur bei PLC STOP möglich"}
496
+ >
497
+ {status.imported_connector}
498
+ </button>
499
+ ) : (
500
+ <span>-</span>
501
+ )}
502
+ </div>
503
+ {status.connector_status ? (
504
+ <div className="connector-info">
505
+ <span>Modbus: {status.connector_status.endpoint}</span>
506
+ <span>Status: {status.connector_status.connected ? "verbunden" : "offline"}</span>
507
+ <span>READ Variablen: {status.connector_status.read_variables}</span>
508
+ <span>WRITE Variablen: {status.connector_status.write_variables}</span>
509
+ <span>READ Requests letzter Zyklus: {status.connector_status.read_requests}</span>
510
+ <span>READ Requests gesamt: {status.connector_status.total_read_requests ?? 0}</span>
511
+ <span>WRITE Requests letzter Zyklus: {status.connector_status.write_requests}</span>
512
+ <span>WRITE Requests gesamt: {status.connector_status.total_write_requests ?? 0}</span>
513
+ <span>WRITE übersprungen letzter Zyklus: {status.connector_status.skipped_writes ?? 0}</span>
514
+ <span>WRITE Resync: {status.connector_status.write_resync_ms ?? 0} ms</span>
515
+ <span>Letzter WRITE Grund: {status.connector_status.last_write_reason || "-"}</span>
516
+ {status.connector_status.last_error && <span className="connector-error">{status.connector_status.last_error}</span>}
517
+ </div>
518
+ ) : (
519
+ <div className="empty-state full-row">Kein PLC Connector geladen.</div>
520
+ )}
521
+ </section>
522
+ </section>
523
+ )}
524
+
525
+ {activePage === "cycle" && (
526
+ <section className="page-grid">
527
+ <section className="card imports">
528
+ <input
529
+ ref={stInputRef}
530
+ type="file"
531
+ hidden
532
+ accept=".st,.zip,.json,.txt"
533
+ onChange={(event) => uploadFile("/api/import/st-project", event.target.files?.[0])}
534
+ />
535
+ <div className="section-title-row full-row">
536
+ <h2>PLC Monitoring</h2>
537
+ <button onClick={() => stInputRef.current?.click()} disabled={status.running}>Import ST Projekt</button>
538
+ </div>
539
+ <div className="import-info full-row">
540
+ <span>Projekt:</span><strong>{status.active_project || "-"}</strong>
541
+ <span>ST Projekt:</span>
542
+ {status.imported_st_project ? (
543
+ <button
544
+ className="text-link"
545
+ onClick={() => openEditor("st-project")}
546
+ disabled={!canEdit}
547
+ title={canEdit ? "ST Projekt bearbeiten" : "Bearbeiten nur bei PLC STOP möglich"}
548
+ >
549
+ {status.imported_st_project}
550
+ </button>
551
+ ) : (
552
+ <span>-</span>
553
+ )}
554
+ </div>
555
+ </section>
556
+
557
+ <section className="card summary">
558
+ <div>
559
+ <span>Zyklen</span>
560
+ <strong>{status.cycle_count}</strong>
561
+ </div>
562
+ <div>
563
+ <span>Letzter Zyklus</span>
564
+ <strong>{formatMs(status.last_cycle_ms)} ms</strong>
565
+ </div>
566
+ <div>
567
+ <span>Soll-Zyklus</span>
568
+ <strong>{status.cycle_time_ms} ms</strong>
569
+ </div>
570
+ <div>
571
+ <span>Ø reale PLC-Zeit</span>
572
+ <strong>{formatMs(status.real_cycle_avg_ms)} ms</strong>
573
+ <small>READ + EXECUTE + WRITE</small>
574
+ </div>
575
+ <div>
576
+ <span>Letzte Synchronisierung</span>
577
+ <strong>{status.stats_window?.last_sync_cycle || 0}</strong>
578
+ <small>
579
+ {status.stats_window?.last_sync_time
580
+ ? `Marker bei Zyklus ${status.stats_window.last_sync_cycle} um ${status.stats_window.last_sync_time}`
581
+ : `${status.stats_window?.current_window_count || 0}/${status.stats_window?.window_size || 100} Zyklen im Live-Fenster`}
582
+ </small>
583
+ </div>
584
+ </section>
585
+
586
+ <section className="card phases">
587
+ <div className="section-title-row full-row">
588
+ <h2>PLC Phasen</h2>
589
+ <small className="phase-window-note">Min/Avg/Max werden live aus den letzten 100 Zyklen berechnet.</small>
590
+ </div>
591
+ <table>
592
+ <thead>
593
+ <tr>
594
+ <th>Phase</th>
595
+ <th>Last [ms]</th>
596
+ <th>Min [ms]</th>
597
+ <th>Avg [ms]</th>
598
+ <th>Max [ms]</th>
599
+ </tr>
600
+ </thead>
601
+ <tbody>
602
+ {PHASES.map((phase) => {
603
+ const item = status.phases?.[phase] || {};
604
+ return (
605
+ <tr key={phase}>
606
+ <td>{phase}</td>
607
+ <td>{formatMs(item.last_ms)}</td>
608
+ <td>{formatMs(item.min_ms)}</td>
609
+ <td>{formatMs(item.avg_ms)}</td>
610
+ <td>{formatMs(item.max_ms)}</td>
611
+ </tr>
612
+ );
613
+ })}
614
+ </tbody>
615
+ </table>
616
+ </section>
617
+ </section>
618
+ )}
619
+
620
+ <EditorModal
621
+ editor={editor}
622
+ onClose={closeEditor}
623
+ onSelectFile={selectEditorFile}
624
+ onChangeContent={changeEditorContent}
625
+ onSave={saveEditor}
626
+ />
627
+ </main>
628
+ );
629
+ }
630
+
631
+ createRoot(document.getElementById("root")).render(<App />);