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.
- package/README.md +42 -0
- package/me-vplc.html +64 -0
- package/me-vplc.js +603 -0
- package/package.json +26 -0
- package/project/README.md +1052 -0
- package/project/START_ME_VPLC.cmd +176 -0
- package/project/backend/active_project.json +3 -0
- package/project/backend/app.py +839 -0
- package/project/backend/connector_runtime.py +585 -0
- package/project/backend/requirements.txt +3 -0
- package/project/backend/st_compiler.py +1415 -0
- package/project/frontend/index.html +12 -0
- package/project/frontend/package.json +18 -0
- package/project/frontend/src/App.jsx +631 -0
- package/project/frontend/src/style.css +964 -0
- package/project/frontend/vite.config.js +14 -0
- package/wheelhouse/Flask_Cors-4.0.1-py2.py3-none-any.whl +0 -0
- package/wheelhouse/blinker-1.9.0-py3-none-any.whl +0 -0
- package/wheelhouse/click-8.3.3-py3-none-any.whl +0 -0
- package/wheelhouse/colorama-0.4.6-py2.py3-none-any.whl +0 -0
- package/wheelhouse/flask-3.0.3-py3-none-any.whl +0 -0
- package/wheelhouse/itsdangerous-2.2.0-py3-none-any.whl +0 -0
- package/wheelhouse/jinja2-3.1.6-py3-none-any.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp310-cp310-win_amd64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp311-cp311-win_amd64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp312-cp312-win_amd64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl +0 -0
- package/wheelhouse/markupsafe-3.0.3-cp313-cp313-win_amd64.whl +0 -0
- package/wheelhouse/pymodbus-3.6.9-py3-none-any.whl +0 -0
- 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 />);
|