opcua-service/target/classes/web/web-interface.html
2026-05-15 06:25:32 +02:00

1264 lines
72 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#007aff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)">
<title>OPC UA Control</title>
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css">
<style>
:root {
--bg-primary: #f2f2f7;
--bg-secondary: #ffffff;
--bg-tertiary: #f2f2f7;
--text-primary: #1c1c1e;
--text-secondary: #8e8e93;
--border-color: rgba(60,60,67,0.18);
--blue: #007aff;
--green: #34c759;
--red: #ff3b30;
--orange: #ff9500;
--shadow: 0 1px 3px rgba(0,0,0,0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #000000;
--bg-secondary: #1c1c1e;
--bg-tertiary: #2c2c2e;
--text-primary: #ffffff;
--text-secondary: #98989d;
--border-color: rgba(84,84,88,0.48);
--shadow: 0 1px 3px rgba(0,0,0,0.3);
}
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', Arial, sans-serif; background: var(--bg-primary); color: var(--text-primary); }
.header { background: var(--blue); color: white; padding: max(env(safe-area-inset-top), 20px) 20px 20px 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); position: sticky; top: 0; z-index: 100; }
.header h1 { font-size: clamp(20px, 5vw, 28px); font-weight: 700; margin-bottom: 8px; display:flex; align-items:center; gap:10px; }
.app-logo { width: 36px; height: 36px; border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.20); flex-shrink: 0; }
.status-badge { display: inline-flex; align-items: center; gap: 6px; background: rgba(255,255,255,0.2); padding: 6px 12px; border-radius: 20px; font-size: 13px; font-weight: 600; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--red); }
.status-dot.connected { background: var(--green); animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
.toolbar { background: var(--bg-secondary); padding: 12px 20px; border-bottom: 0.5px solid var(--border-color); display: flex; gap: 8px; overflow-x: auto; -webkit-overflow-scrolling: touch; align-items: center; }
.toolbar::-webkit-scrollbar { display: none; }
.btn { background: var(--blue); color: white; border: none; padding: 10px 16px; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; white-space: nowrap; touch-action: manipulation; }
.btn:active { transform: scale(0.96); }
.btn-success { background: var(--green); }
.btn-danger { background: var(--red); }
.btn-secondary { background: var(--text-secondary); }
.btn-small { padding: 6px 12px; font-size: 13px; border-radius: 8px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.twin-status { font-size: 12px; color: var(--text-secondary); white-space: nowrap; }
.container { max-width: 1600px; margin: 0 auto; padding: clamp(12px, 2vw, 20px); padding-bottom: max(env(safe-area-inset-bottom), 16px); }
.grid { display: grid; grid-template-columns: 1fr; gap: 16px; }
@media (min-width: 768px) { .grid { grid-template-columns: 350px 1fr; } }
@media (min-width: 1024px) { .grid { grid-template-columns: 400px 1fr; } }
.card { background: var(--bg-secondary); border-radius: 12px; margin-bottom: 16px; box-shadow: var(--shadow); overflow: hidden; }
.card-header { padding: 16px; border-bottom: 0.5px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.card-title { font-size: 17px; font-weight: 600; }
.tree-container { max-height: 70vh; overflow-y: auto; -webkit-overflow-scrolling: touch; }
@media (min-width: 768px) { .tree-container { max-height: 600px; } }
.tree-node { padding: 10px 16px; border-bottom: 0.5px solid var(--border-color); cursor: pointer; user-select: none; }
.tree-node:active { background: var(--bg-tertiary); }
.tree-node-content { display: flex; align-items: center; gap: 8px; }
.tree-expand { width: 24px; height: 24px; border-radius: 50%; background: var(--bg-tertiary); display: flex; align-items: center; justify-content: center; font-size: 14px; transition: transform 0.2s; flex-shrink: 0; cursor: pointer; }
.tree-expand.expanded { transform: rotate(90deg); }
.tree-expand.loading-spin { animation: spin 0.7s linear infinite; }
.tree-children { padding-left: 28px; display: none; }
.tree-children.show { display: block; }
.value-badge { margin-left: 8px; padding: 2px 8px; background: var(--blue); color: white; border-radius: 6px; font-size: 11px; font-weight: 600; font-family: 'SF Mono', Monaco, monospace; }
.table-container { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { width: 100%; border-collapse: collapse; font-size: clamp(13px, 2vw, 15px); }
thead { background: var(--bg-tertiary); position: sticky; top: 0; z-index: 1; }
th { padding: 12px 8px; text-align: left; font-weight: 600; font-size: 13px; color: var(--text-secondary); text-transform: uppercase; border-bottom: 0.5px solid var(--border-color); white-space: nowrap; }
td { padding: 12px 8px; border-bottom: 0.5px solid var(--border-color); }
.value-cell { font-family: 'SF Mono', Monaco, 'Courier New', monospace; font-size: 14px; font-weight: 500; background: var(--bg-tertiary); padding: 6px 10px; border-radius: 6px; display: inline-block; cursor: pointer; transition: background 0.3s; }
.value-cell.updating { animation: highlight 0.5s; }
@keyframes highlight { 0%, 100% { background: var(--bg-tertiary); } 50% { background: var(--orange); } }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; }
.modal.active { display: flex; align-items: flex-end; justify-content: center; }
@media (min-width: 768px) { .modal.active { align-items: center; } }
.modal-content { background: var(--bg-secondary); border-radius: 12px 12px 0 0; padding: 24px; max-width: 600px; width: 100%; max-height: 85vh; overflow-y: auto; }
.modal-content.configuration-content { max-width: 980px; }
@media (min-width: 768px) { .modal-content { border-radius: 12px; } }
.modal-header { font-size: 20px; font-weight: 700; margin-bottom: 20px; }
.configuration-content .modal-header { font-size: 22px; margin-bottom: 14px; }
.config-pane { border: 1px solid var(--border-color); border-radius: 12px; margin-bottom: 16px; background: var(--bg-secondary); overflow: hidden; }
.config-pane-title { padding: 12px 14px; font-weight: 700; border-bottom: 1px solid var(--border-color); background: var(--bg-tertiary); }
.config-pane-body { padding: 14px; }
.config-checkbox-row { display:flex; gap:18px; flex-wrap:wrap; align-items:center; margin: 10px 0 12px 0; }
.config-path { font-size:12px; color:var(--text-secondary); font-family:'SF Mono', Monaco, monospace; margin: 8px 0 10px 0; word-break: break-all; }
.config-actions { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
.configuration-content .CodeMirror { min-height: 280px; }
.form-group { margin-bottom: 16px; }
.form-label { display: block; margin-bottom: 8px; font-size: 13px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; }
.form-input, .form-select, .form-textarea { width: 100%; padding: 12px; border: 0.5px solid var(--border-color); border-radius: 10px; font-size: 16px; background: var(--bg-tertiary); color: var(--text-primary); transition: all 0.2s; }
.form-input:focus, .form-select:focus, .form-textarea:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 4px rgba(0,122,255,0.1); }
.form-textarea { min-height: 120px; font-family: 'SF Mono', Monaco, monospace; resize: vertical; }
.badge { display: inline-block; padding: 4px 8px; background: var(--blue); color: white; border-radius: 6px; font-size: 12px; font-weight: 600; }
.badge-success { background: var(--green); }
.badge-warning { background: var(--orange); }
.btn-delete { background: none; border: none; color: var(--red); font-size: 20px; cursor: pointer; padding: 4px 8px; border-radius: 6px; }
.empty-state { text-align: center; padding: 40px 20px; color: var(--text-secondary); }
.empty-icon { font-size: 48px; margin-bottom: 12px; }
.spinner { width: 16px; height: 16px; border: 2px solid var(--bg-tertiary); border-top: 2px solid var(--blue); border-radius: 50%; animation: spin 0.8s linear infinite; display: inline-block; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } }
.list-item { padding: 12px 16px; border-bottom: 0.5px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; }
.list-item:last-child { border-bottom: none; }
.code-helper { border: 1px solid var(--border-color); background: var(--bg-tertiary); border-radius: 10px; padding: 10px; margin-bottom: 12px; }
.code-helper-list { max-height: 180px; overflow: auto; border: 1px solid var(--border-color); border-radius: 8px; padding: 8px; background: var(--bg-secondary); margin: 8px 0; }
.code-helper-row { display: grid; grid-template-columns: 24px minmax(120px, 1fr) minmax(140px, 1.3fr); gap: 8px; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--border-color); }
.code-helper-row:last-child { border-bottom: none; }
.code-helper-alias { width: 100%; padding: 6px 8px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-tertiary); color: var(--text-primary); }
.CodeMirror { min-height: 220px; border-radius: 10px; font-size: 14px; font-family: 'SF Mono', Monaco, monospace; }
.template-manager-grid { display:grid; grid-template-columns:minmax(180px,0.8fr) minmax(260px,1.6fr); gap:12px; }
.template-list { min-height:220px; border:1px solid var(--border-color); border-radius:10px; background:var(--bg-secondary); overflow:auto; }
.template-list-item { padding:10px 12px; border-bottom:1px solid var(--border-color); cursor:pointer; display:flex; justify-content:space-between; gap:8px; }
.template-list-item.active { background:rgba(0,122,255,0.16); }
@media(max-width:800px){ .template-manager-grid { grid-template-columns:1fr; } }
</style>
</head>
<body>
<div class="header">
<h1><img src="/assets/opcua-client-logo.svg" class="app-logo" alt=""> OPC UA Control</h1>
<div class="status-badge">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Disconnected</span>
</div>
</div>
<div class="toolbar">
<button class="btn btn-success" onclick="loadRoot()" id="loadTreeBtn">🌳 Load Tree</button>
<button class="btn" onclick="exportTwin()">⬇ Export JSON</button>
<button class="btn" onclick="openConfigurationModal()">⚙ Configuration</button>
<button class="btn btn-secondary" onclick="window.location.href='/help'">📘 Hilfe / REST API</button>
<button class="btn btn-secondary" onclick="window.location.href='/api/openapi.json'">🧭 OpenAPI</button>
<button class="btn" onclick="toggleAutoRefresh()"><span id="refreshLabel">▶ Live</span></button>
<span class="twin-status" id="twinStatus"></span>
</div>
<div class="container">
<div class="grid">
<!-- Tree -->
<div class="card">
<div class="card-header">
<div class="card-title">OPC UA Tree</div>
<div id="treeInfo" style="font-size:12px;color:var(--text-secondary)"></div>
</div>
<div class="tree-container">
<div id="treeView">
<div class="empty-state">
<div class="empty-icon">🌳</div>
<div>Click "Load Tree"</div>
</div>
</div>
</div>
</div>
<!-- Right column -->
<div>
<div class="card">
<div class="card-header">
<div class="card-title">Monitored Nodes</div>
<span class="badge" id="nodeCount">0</span>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>NodeID</th>
<th>NS</th>
<th>Type</th>
<th>Value</th>
<th>Actions</th>
<th></th>
</tr>
</thead>
<tbody id="nodesTableBody">
<tr><td colspan="7"><div class="empty-state"><div class="empty-icon">📊</div><div>No nodes</div></div></td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">Actions</div>
<span class="badge badge-success" id="actionCount">0</span>
</div>
<div id="actionsList">
<div class="empty-state"><div class="empty-icon"></div><div>No actions</div></div>
</div>
</div>
</div>
</div>
</div>
<!-- Configuration Modal -->
<div class="modal" id="systemInitModal">
<div class="modal-content configuration-content">
<div class="modal-header">Configuration</div>
<div class="config-pane">
<div class="config-pane-title">OPC UA / Ports</div>
<div class="config-pane-body">
<div class="form-group">
<label class="form-label">OPC UA Endpoint</label>
<input type="text" class="form-input" id="configEndpoint" placeholder="opc.tcp://server:4840">
</div>
<div class="config-checkbox-row">
<label style="display:flex;gap:8px;align-items:center">
<input type="checkbox" id="configHttpEnabled"> HTTP aktivieren
</label>
</div>
<div class="form-group">
<label class="form-label">HTTP Port</label>
<input type="number" class="form-input" id="configHttpPort" min="1" max="65535" placeholder="8081">
</div>
<div class="config-checkbox-row">
<label style="display:flex;gap:8px;align-items:center">
<input type="checkbox" id="configHttpsEnabled"> HTTPS aktivieren
</label>
</div>
<div class="form-group">
<label class="form-label">HTTPS Port</label>
<input type="number" class="form-input" id="configHttpsPort" min="1" max="65535" placeholder="8443">
</div>
<div style="font-size:12px;color:var(--text-secondary);line-height:1.4">
Es wird keine feste URL gespeichert. Die Web/API-Adresse wird automatisch aus dem Port gebildet. Port-Änderungen wirken nach Neustart des Webservers/Programms.
</div>
</div>
</div>
<div class="config-pane">
<div class="config-pane-title">Portable Config / Verschlüsselung / REST Auth</div>
<div class="config-pane-body">
<div style="font-size:13px;color:var(--text-secondary);line-height:1.45;margin-bottom:8px">
Alle Definitionen werden im Unterordner <b>config</b> neben der Anwendung gespeichert. Optional können Actions, Templates und System Init verschlüsselt werden.
</div>
<div class="config-path" id="configDirectoryLabel">config</div>
<div class="config-path" id="settingsFileLabel">config/settings.json</div>
<div class="config-checkbox-row">
<label style="display:flex;gap:8px;align-items:center">
<input type="checkbox" id="configEncryptionEnabled"> Config-Dateien verschlüsseln
</label>
</div>
<div class="form-group">
<label class="form-label">Config-Passwort</label>
<input type="password" class="form-input" id="configEncryptionPassword" placeholder="Passwort für AES-GCM Verschlüsselung">
</div>
<div class="config-checkbox-row">
<label style="display:flex;gap:8px;align-items:center">
<input type="checkbox" id="restAuthEnabled"> REST-API Authentifizierung aktivieren
</label>
</div>
<div class="form-group">
<label class="form-label">REST Benutzer</label>
<input type="text" class="form-input" id="restAuthUser" placeholder="admin">
</div>
<div class="form-group">
<label class="form-label">REST Passwort</label>
<input type="password" class="form-input" id="restAuthPassword" placeholder="Leer lassen = bestehendes Passwort behalten">
</div>
<div id="securityInfo" style="font-size:12px;color:var(--text-secondary);margin-top:8px"></div>
</div>
</div>
<div class="config-pane">
<div class="config-pane-title">System Init / RegLogin</div>
<div class="config-pane-body">
<div style="font-size:13px;color:var(--text-secondary);line-height:1.45;margin-bottom:8px">
Dieses globale Initial-Script wird dauerhaft gespeichert und beim Start/Connect geladen.
Im Service-Modus läuft es nach erfolgreichem OPC-Connect/Reconnect, wenn <b>Nach OPC Connect</b> aktiv ist.
</div>
<div class="config-path" id="systemInitPathLabel">config/system-init-script.json</div>
<div class="config-checkbox-row">
<label style="display:flex;gap:8px;align-items:center">
<input type="checkbox" id="systemInitEnabled"> Aktiv
</label>
<label style="display:flex;gap:8px;align-items:center">
<input type="checkbox" id="systemInitRunAtProgramStart"> Bei Programmstart
</label>
<label style="display:flex;gap:8px;align-items:center">
<input type="checkbox" id="systemInitRunAfterConnect"> Nach OPC Connect/Reconnect
</label>
</div>
<div class="config-actions">
<button type="button" class="btn" onclick="insertRegLoginTemplate()">RegLogin Template einfügen</button>
<button type="button" class="btn btn-success" onclick="saveSystemInit(true)">Speichern &amp; jetzt ausführen</button>
</div>
<label class="form-label">JavaScript</label>
<textarea class="form-textarea js-editor" id="systemInitScript" placeholder="var response = http.postJson(&quot;http://localhost:9000/api/reglogin&quot;, { client: &quot;opcua-client&quot; });&#10;var result = JSON.parse(response.body);&#10;global.session = result;&#10;global.token = result.token;&#10;log.info(&quot;RegLogin OK&quot;);"></textarea>
<div id="systemInitInfo" style="font-size:12px;color:var(--text-secondary);margin-top:10px"></div>
</div>
</div>
<div class="config-pane">
<div class="config-pane-title">Script Templates / Button-Vorlagen</div>
<div class="config-pane-body">
<div style="font-size:13px;color:var(--text-secondary);line-height:1.45;margin-bottom:8px">
GET, POST und SET sind nur Standard-Templates. Du kannst sie ändern, deaktivieren oder beliebig viele eigene Templates anlegen. Für jedes aktive Template wird im Dialog <b>Create New Action</b> automatisch ein Button erzeugt.
</div>
<div class="config-path" id="scriptTemplatePathLabel">config/script-templates.json</div>
<div class="template-manager-grid">
<div>
<div id="scriptTemplateList" class="template-list"></div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
<button type="button" class="btn btn-small" onclick="newScriptTemplate()">Neu</button>
<button type="button" class="btn btn-small btn-secondary" onclick="deleteScriptTemplate()">Löschen</button>
<button type="button" class="btn btn-small" onclick="resetScriptTemplates()">GET/POST/SET Reset</button>
</div>
</div>
<div>
<label class="form-label">Name / Button-Text</label>
<input type="text" class="form-input" id="scriptTemplateName" placeholder="z. B. POST an MES">
<label style="display:flex;gap:8px;align-items:center;margin:10px 0">
<input type="checkbox" id="scriptTemplateEnabled" checked> Aktiv / Button anzeigen
</label>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">
<button type="button" class="btn btn-small" onclick="insertTemplatePlaceholders()">Platzhalter einfügen</button>
<button type="button" class="btn btn-small btn-success" onclick="saveScriptTemplates()">Templates speichern</button>
</div>
<label class="form-label">JavaScript Template</label>
<textarea class="form-textarea js-editor" id="scriptTemplateCode" placeholder="{{readNumberVars}}&#10;var response = http.postJson(&quot;http://localhost:9000/api/process&quot;, {&#10;{{payloadFields}}});&#10;var result = JSON.parse(response.body);&#10;global.apiResult = result;&#10;{{opcWritesFromResult}}"></textarea>
<div style="font-size:12px;color:var(--text-secondary);margin-top:8px;line-height:1.45">
Platzhalter: <code>{{readNumberVars}}</code>, <code>{{readTextVars}}</code>, <code>{{payloadFields}}</code>, <code>{{opcWritesFromResult}}</code>, <code>{{selectedNodesComment}}</code>, <code>{{firstNodeIdJs}}</code>, <code>{{firstAlias}}</code>
</div>
</div>
</div>
</div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
<button class="btn" style="min-width:110px" onclick="saveConfiguration()">Save</button>
<button class="btn btn-secondary" style="min-width:110px" onclick="closeSystemInitModal()">Cancel</button>
</div>
</div>
</div>
<!-- Action Modal -->
<div class="modal" id="actionModal">
<div class="modal-content">
<div class="modal-header">Create Action</div>
<div class="form-group">
<label class="form-label">Name</label>
<input type="text" class="form-input" id="actionName" placeholder="Temperature Alert">
</div>
<div class="form-group">
<label class="form-label">Trigger Type</label>
<select class="form-select" id="triggerType" onchange="updateTriggerFields()">
<option value="ON_CHANGE">On Change</option>
<option value="ON_EVEN">On Even Number</option>
<option value="ON_ODD">On Odd Number</option>
<option value="ON_TRUE">On True</option>
<option value="ON_FALSE">On False</option>
<option value="ON_VALUE">On Specific Value</option>
<option value="ON_GREATER_THAN">On Greater Than</option>
<option value="ON_LESS_THAN">On Less Than</option>
<option value="ON_INTERVAL">On Time Interval</option>
</select>
</div>
<div class="form-group" id="triggerValueGroup" style="display:none">
<label class="form-label">Trigger Value</label>
<input type="text" class="form-input" id="triggerValue" placeholder="e.g., 100">
</div>
<div class="form-group" id="intervalGroup" style="display:none">
<label class="form-label">Interval (ms)</label>
<input type="number" class="form-input" id="intervalMs" value="1000">
</div>
<div class="form-group">
<label class="form-label" style="display:flex;gap:8px;align-items:center">
<input type="checkbox" id="initEnabled" onchange="toggleInitConfig()">
Initial-Call / Session aktivieren
</label>
<div id="initConfig" style="display:none;margin-top:10px">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:6px">Läuft beim Programmstart und nach Connect-Klick. Geeignet für RegLogin, Token, Session-ID und globale API-Ergebnisse.</div>
<textarea class="form-textarea js-editor" id="initScript" placeholder="var login = http.postJson(&quot;http://localhost:9000/api/login&quot;, { user: &quot;opc&quot;, password: &quot;secret&quot; });&#10;var result = JSON.parse(login.body);&#10;session.token = result.token;&#10;global.session = result;&#10;global.lastLogin = result;"></textarea>
</div>
</div>
<div class="form-group">
<label class="form-label" style="display:flex;gap:8px;align-items:center">
<input type="checkbox" id="conditionEnabled" onchange="toggleConditionConfig()">
Bedingung aktivieren
</label>
<div id="conditionConfig" style="display:none;margin-top:10px">
<label class="form-label">NodeID-Codebausteine einfügen</label>
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:6px">Die Liste erzeugt nur Code. Beim Speichern ist das Script vollständig eigenständig, z. B. <code>var Temperatur = opc.read(&quot;ns=...&quot;)</code>.</div>
<div id="conditionNodeList" class="code-helper-list"></div>
<label class="form-label" style="margin-top:10px">Condition JavaScript (muss true/false zurückgeben)</label>
<textarea class="form-textarea js-editor" id="conditionScript" placeholder="var Temperatur = Number(opc.read(&quot;ns=3;s=AirConditioner_1.Temperature&quot;));&#10;Temperatur &gt; 70"></textarea>
</div>
</div>
<div class="form-group">
<label class="form-label">NodeID-Codegenerator fuer Hauptscript</label>
<div class="code-helper">
<div style="font-size:12px;color:var(--text-secondary)">Checkboxen erzeugen nur feste Codezeilen. Es werden keine Runtime-Variablen aus Checkboxen gespeichert.</div>
<div id="actionNodeCodeList" class="code-helper-list"></div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button type="button" class="btn btn-small" onclick="insertCheckedNodeSnippets('actionNodeCodeList','scriptCode','readNumber')">var Number einfuegen</button>
<button type="button" class="btn btn-small" onclick="insertCheckedNodeSnippets('actionNodeCodeList','scriptCode','read')">var Text einfuegen</button>
<button type="button" class="btn btn-small" onclick="insertCheckedNodeSnippets('actionNodeCodeList','scriptCode','write','result.value')">opc.write einfuegen</button>
</div>
<div id="actionTemplateButtons" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px"></div>
</div>
<label class="form-label">JavaScript Code</label>
<textarea class="form-textarea js-editor" id="scriptCode" placeholder="var Temperatur = Number(opc.read(&quot;ns=3;s=AirConditioner_1.Temperature&quot;));&#10;var response = http.postJson(&quot;http://localhost:9000/api/check&quot;, { temperature: Temperatur });&#10;var result = JSON.parse(response.body);&#10;opc.write(&quot;ns=3;s=AirConditioner_1.Result&quot;, result.value);"></textarea>
</div>
<div style="display:flex;gap:8px">
<button class="btn" style="flex:1" onclick="saveAction()">Save</button>
<button class="btn btn-secondary" style="flex:1" onclick="closeModal()">Cancel</button>
</div>
</div>
</div>
<!-- Edit Value Modal -->
<div class="modal" id="editValueModal">
<div class="modal-content">
<div class="modal-header">Edit Value</div>
<div class="form-group">
<label class="form-label">Node ID</label>
<input type="text" class="form-input" id="editNodeId" readonly>
</div>
<div class="form-group">
<label class="form-label">New Value</label>
<input type="text" class="form-input" id="editValue">
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-success" style="flex:1" onclick="writeValue()">Write</button>
<button class="btn btn-secondary" style="flex:1" onclick="closeEditValueModal()">Cancel</button>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/javascript/javascript.min.js"></script>
<script>
const API = window.location.origin + '/api';
let nodes = [], actions = [], systemInitConfig = {}, scriptTemplates = [], securityConfig = {}, selectedTemplateIndex = -1, isConnected = false, currentNodeForAction = null, autoRefreshInterval = null;
let restAuth = { enabled: false, user: 'admin', password: sessionStorage.getItem('opcuaRestPassword') || '' };
const jsEditors = {};
function buildAuthHeaders() {
if (!restAuth.enabled || !restAuth.password) return {};
return { 'Authorization': 'Basic ' + btoa((restAuth.user || 'admin') + ':' + restAuth.password) };
}
async function apiFetch(path, options = {}) {
options.headers = Object.assign({}, options.headers || {}, buildAuthHeaders());
let response = await fetch(API + path, options);
if (response.status === 401) {
const pw = prompt('REST Passwort für Benutzer ' + (restAuth.user || 'admin') + ':');
if (pw === null) return response;
restAuth.password = pw;
sessionStorage.setItem('opcuaRestPassword', pw);
options.headers = Object.assign({}, options.headers || {}, buildAuthHeaders());
response = await fetch(API + path, options);
}
return response;
}
async function loadAuthStatus() {
try {
const d = await (await fetch(API + '/auth/status')).json();
restAuth.enabled = !!d.restAuthEnabled;
restAuth.user = d.restAuthUser || 'admin';
} catch(e) {}
}
// ── Status polling ────────────────────────────────────────────────────
setInterval(checkStatus, 5000);
loadAuthStatus().then(() => { checkStatus(); loadActions(); loadSystemInit(); loadScriptTemplates(); });
checkStatus();
setInterval(pollTwinStatus, 4000);
async function checkStatus() {
try {
const d = await (await apiFetch('/status')).json();
isConnected = d.connected;
document.getElementById('statusDot').classList.toggle('connected', isConnected);
document.getElementById('statusText').textContent = isConnected ? 'Connected' : 'Disconnected';
} catch(e) { isConnected = false; }
}
async function pollTwinStatus() {
try {
const d = await (await apiFetch('/twin-status')).json();
const el = document.getElementById('twinStatus');
if (d.ready) {
el.textContent = '✅ Export bereit (' + d.nodeCount + ' Nodes)';
} else if (d.building) {
el.textContent = '⏳ Export wird gebaut…';
} else {
el.textContent = '';
}
} catch(e) {}
}
// ── Tree: lazy browse ─────────────────────────────────────────────────
async function loadRoot() {
if (!isConnected) { alert('Not connected'); return; }
const btn = document.getElementById('loadTreeBtn');
btn.disabled = true;
const container = document.getElementById('treeView');
container.innerHTML = '<div style="padding:20px;text-align:center"><span class="spinner"></span> Loading…</div>';
try {
const d = await browseNode(null);
container.innerHTML = '';
container.appendChild(buildUl(d.children));
document.getElementById('treeInfo').textContent = d.children.length + ' root nodes';
} catch(e) {
container.innerHTML = '<div class="empty-state"><div>Error: ' + e.message + '</div></div>';
} finally {
btn.disabled = false;
}
}
async function browseNode(nodeId) {
const r = await apiFetch('/browse-tree', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(nodeId ? {nodeId} : {})
});
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Browse failed');
return d;
}
function buildUl(children) {
const ul = document.createElement('div');
for (const n of children) {
const wrap = document.createElement('div');
const row = document.createElement('div');
row.className = 'tree-node';
const content = document.createElement('div');
content.className = 'tree-node-content';
// Expand button
const expand = document.createElement('div');
expand.className = 'tree-expand';
expand.textContent = '';
expand.onclick = async (e) => {
e.stopPropagation();
const childContainer = wrap.querySelector('.tree-children');
if (childContainer.classList.contains('show')) {
childContainer.classList.remove('show');
expand.classList.remove('expanded');
return;
}
// Lazy load children
if (childContainer.children.length === 0) {
expand.classList.add('loading-spin');
expand.textContent = '○';
try {
const d = await browseNode(n.nodeId);
if (d.children.length > 0) {
childContainer.appendChild(buildUl(d.children));
} else {
childContainer.innerHTML = '<div style="padding:8px 16px;font-size:12px;color:var(--text-secondary)">No children</div>';
}
} catch(err) {
childContainer.innerHTML = '<div style="padding:8px;color:var(--red)">Error loading</div>';
}
expand.classList.remove('loading-spin');
expand.textContent = '';
}
childContainer.classList.add('show');
expand.classList.add('expanded');
};
const isVar = n.nodeClass === 'Variable';
const icon = isVar ? '📊' : (n.nodeClass === 'View' ? '👁' : '📁');
const label = document.createElement('span');
label.style.flex = '1';
label.innerHTML = `<span style="margin-right:6px">${icon}</span><b>${esc(n.displayName || n.browseName)}</b><span style="font-size:11px;color:var(--text-secondary);margin-left:6px">${n.nodeClass || ''}</span>`;
// Value badge for Variables
if (isVar && n.value != null) {
const badge = document.createElement('span');
badge.className = 'value-badge';
badge.textContent = n.value;
label.appendChild(badge);
}
// Click to add to monitoring
label.onclick = () => addNode(n.nodeId, n.displayName || n.browseName);
label.style.cursor = 'pointer';
content.appendChild(expand);
content.appendChild(label);
row.appendChild(content);
const childContainer = document.createElement('div');
childContainer.className = 'tree-children';
wrap.appendChild(row);
wrap.appendChild(childContainer);
ul.appendChild(wrap);
}
return ul;
}
// ── Monitoring table ──────────────────────────────────────────────────
function addNode(nodeId, name) {
if (nodes.find(n => n.id === nodeId)) { alert('Already added'); return; }
const m = nodeId.match(/ns=(\d+);([a-z])=/i);
nodes.push({
id: nodeId, name,
ns: m ? m[1] : '?',
type: m ? (m[2].toLowerCase() === 's' ? 'String' : 'UInteger') : '?',
value: '…'
});
renderTable();
readValue(nodeId);
}
async function readValue(nodeId) {
try {
const d = await (await apiFetch('/read', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({nodeId})
})).json();
if (d.success) {
const n = nodes.find(x => x.id === nodeId);
if (n) {
const cell = document.querySelector(`[data-node-id="${nodeId}"] .value-cell`);
if (cell && n.value !== d.value) {
cell.classList.add('updating');
setTimeout(() => cell.classList.remove('updating'), 500);
}
n.value = d.value;
renderTable();
}
}
} catch(e) {}
}
function renderTable() {
const tb = document.getElementById('nodesTableBody');
document.getElementById('nodeCount').textContent = nodes.length;
if (nodes.length === 0) {
tb.innerHTML = '<tr><td colspan="7"><div class="empty-state"><div class="empty-icon">📊</div><div>No nodes — click a tree node to add</div></div></td></tr>';
return;
}
tb.innerHTML = nodes.map((n, i) => `
<tr data-node-id="${esc(n.id)}">
<td style="font-weight:600">${esc(n.name)}</td>
<td style="font-size:12px;color:var(--text-secondary)">${esc(n.id)}</td>
<td><span class="badge">${n.ns}</span></td>
<td><span class="badge badge-warning">${n.type}</span></td>
<td><span class="value-cell" onclick="editVal('${esc(n.id)}','${esc(n.value)}')">${esc(n.value)}</span></td>
<td><button class="btn btn-small" onclick="openActions('${esc(n.id)}')">Actions</button></td>
<td><button class="btn-delete" onclick="removeNode(${i})">×</button></td>
</tr>`).join('');
}
function removeNode(idx) {
if (confirm('Remove ' + nodes[idx].name + '?')) { nodes.splice(idx, 1); renderTable(); }
}
function toggleAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
document.getElementById('refreshLabel').textContent = '▶ Live';
} else {
autoRefreshInterval = setInterval(() => nodes.forEach(n => readValue(n.id)), 2000);
document.getElementById('refreshLabel').textContent = '⏹ Stop';
nodes.forEach(n => readValue(n.id));
}
}
// ── Export ────────────────────────────────────────────────────────────
function exportTwin() {
window.open(API + '/export', '_blank');
}
// ── Write value ───────────────────────────────────────────────────────
function editVal(nodeId, val) {
document.getElementById('editNodeId').value = nodeId;
document.getElementById('editValue').value = val;
document.getElementById('editValueModal').classList.add('active');
}
function closeEditValueModal() { document.getElementById('editValueModal').classList.remove('active'); }
async function writeValue() {
const nodeId = document.getElementById('editNodeId').value;
const value = document.getElementById('editValue').value;
try {
const d = await (await apiFetch('/write', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({nodeId, value})
})).json();
if (d.success) {
const n = nodes.find(x => x.id === nodeId);
if (n) { n.value = value; renderTable(); }
closeEditValueModal();
} else alert('Write failed');
} catch(e) { alert('Error: ' + e.message); }
}
// ── System Init / RegLogin ──────────────────────────────────────────────
async function loadSecurityConfig() {
try {
const d = await (await apiFetch('/config/security')).json();
if (d.success) {
securityConfig = d;
const dir = document.getElementById('configDirectoryLabel');
const settings = document.getElementById('settingsFileLabel');
if (dir) dir.textContent = d.configDirectory || 'config';
if (settings) settings.textContent = d.settingsFile || 'config/settings.json';
const endpoint = document.getElementById('configEndpoint');
const he = document.getElementById('configHttpEnabled');
const hp = document.getElementById('configHttpPort');
const hse = document.getElementById('configHttpsEnabled');
const hsp = document.getElementById('configHttpsPort');
const enc = document.getElementById('configEncryptionEnabled');
const rp = document.getElementById('restAuthEnabled');
const ru = document.getElementById('restAuthUser');
if (endpoint) endpoint.value = d.endpoint || '';
if (he) he.checked = d.httpEnabled !== false;
if (hp) hp.value = d.httpPort || 8081;
if (hse) hse.checked = !!d.httpsEnabled;
if (hsp) hsp.value = d.httpsPort || 8443;
if (enc) enc.checked = !!d.configEncryptionEnabled;
if (rp) rp.checked = !!d.restAuthEnabled;
if (ru) ru.value = d.restAuthUser || 'admin';
const info = document.getElementById('securityInfo');
if (info) info.textContent = 'REST Passwort gesetzt: ' + (d.restAuthPasswordSet ? 'ja' : 'nein') + ' · Config Passwort gesetzt: ' + (d.configEncryptionPasswordSet ? 'ja' : 'nein');
}
} catch(e) {
const info = document.getElementById('securityInfo');
if (info) info.textContent = 'Security-Config konnte nicht geladen werden: ' + e.message;
}
}
async function saveSecurityConfig() {
const payload = {
endpoint: document.getElementById('configEndpoint')?.value || '',
httpEnabled: document.getElementById('configHttpEnabled')?.checked !== false,
httpPort: Number(document.getElementById('configHttpPort')?.value || 8081),
httpsEnabled: document.getElementById('configHttpsEnabled')?.checked || false,
httpsPort: Number(document.getElementById('configHttpsPort')?.value || 8443),
configEncryptionEnabled: document.getElementById('configEncryptionEnabled')?.checked || false,
configEncryptionPassword: document.getElementById('configEncryptionPassword')?.value || '',
restAuthEnabled: document.getElementById('restAuthEnabled')?.checked || false,
restAuthUser: document.getElementById('restAuthUser')?.value || 'admin',
restAuthPassword: document.getElementById('restAuthPassword')?.value || ''
};
const d = await (await apiFetch('/config/security', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
})).json();
if (!d.success) throw new Error(d.error || 'Security speichern fehlgeschlagen');
securityConfig = d;
restAuth.enabled = !!d.restAuthEnabled && !!d.restAuthPasswordSet;
restAuth.user = d.restAuthUser || 'admin';
const pw = payload.restAuthPassword;
if (pw) {
restAuth.password = pw;
sessionStorage.setItem('opcuaRestPassword', pw);
}
document.getElementById('restAuthPassword').value = '';
document.getElementById('configDirectoryLabel').textContent = d.configDirectory || 'config';
document.getElementById('settingsFileLabel').textContent = d.settingsFile || 'config/settings.json';
document.getElementById('securityInfo').textContent = 'Gespeichert. REST Auth: ' + (restAuth.enabled ? 'aktiv' : 'aus') + ' · HTTP Port: ' + (d.httpPort || payload.httpPort) + ' (Port-Änderung nach Neustart wirksam)';
}
async function loadSystemInit() {
try {
const d = await (await apiFetch('/system-init')).json();
if (d.success) systemInitConfig = Object.assign({}, d.config || {}, {file: d.file});
} catch(e) {}
}
async function openConfigurationModal() {
await loadAuthStatus();
await loadSecurityConfig();
await loadSystemInit();
await loadScriptTemplates();
document.getElementById('systemInitEnabled').checked = !!systemInitConfig.enabled;
document.getElementById('systemInitRunAtProgramStart').checked = !!systemInitConfig.runAtProgramStart;
document.getElementById('systemInitRunAfterConnect').checked = systemInitConfig.runAfterConnect !== false;
setEditorValue('systemInitScript', systemInitConfig.script || '');
document.getElementById('systemInitPathLabel').textContent = systemInitConfig.file || 'config/system-init-script.json';
document.getElementById('systemInitInfo').textContent = systemInitConfig.updatedAt ? ('Zuletzt gespeichert: ' + new Date(systemInitConfig.updatedAt).toLocaleString()) : '';
document.getElementById('systemInitModal').classList.add('active');
ensureEditor('systemInitScript');
ensureEditor('scriptTemplateCode');
renderScriptTemplateList();
setTimeout(() => {
if (jsEditors.systemInitScript) jsEditors.systemInitScript.refresh();
if (jsEditors.scriptTemplateCode) jsEditors.scriptTemplateCode.refresh();
}, 50);
}
function insertRegLoginTemplate() {
insertAtCursor('systemInitScript', `var response = http.postJson("http://localhost:9000/api/reglogin", {
client: "opcua-client",
user: "opc",
password: "secret"
});
if (response.status < 200 || response.status >= 300) {
throw "RegLogin failed: HTTP " + response.status + " / " + response.body;
}
var result = JSON.parse(response.body);
global.session = result;
global.token = result.token;
global.apiResult = result;
log.info("RegLogin OK");
`);
}
async function saveConfiguration() {
try { await saveSecurityConfig(); } catch(e) { alert('Security speichern Fehler: ' + e.message); return; }
await saveSystemInit(false);
await saveScriptTemplates(false);
closeSystemInitModal();
}
function closeSystemInitModal() {
document.getElementById('systemInitModal').classList.remove('active');
}
async function saveSystemInit(runAfterSave) {
const payload = {
enabled: document.getElementById('systemInitEnabled').checked,
runAtProgramStart: document.getElementById('systemInitRunAtProgramStart').checked,
runAfterConnect: document.getElementById('systemInitRunAfterConnect').checked,
script: getEditorValue('systemInitScript')
};
try {
const d = await (await apiFetch('/system-init', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
})).json();
if (!d.success) throw new Error(d.error || 'Save failed');
systemInitConfig = Object.assign({}, d.config || payload, {file: d.file});
const file = d.file || 'config/system-init-script.json';
document.getElementById('systemInitPathLabel').textContent = file;
document.getElementById('systemInitInfo').textContent = 'Gespeichert in: ' + file;
if (runAfterSave) await runSystemInit();
} catch(e) { alert('System Init Fehler: ' + e.message); }
}
async function runSystemInit() {
try {
const d = await (await apiFetch('/system-init/run', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({reason: 'manual-ui', force: true})
})).json();
if (!d.success && !d.skipped) throw new Error(d.error || 'Run failed');
document.getElementById('systemInitInfo').textContent = d.skipped
? ('Nicht ausgeführt: ' + (d.message || 'skipped'))
: ('Ausgeführt. Globals: ' + JSON.stringify(d.globals || {}));
} catch(e) { alert('System Init Run Fehler: ' + e.message); }
}
// ── Script Templates ───────────────────────────────────────────────────
async function loadScriptTemplates() {
try {
const d = await (await apiFetch('/script/templates')).json();
if (d.success) {
scriptTemplates = d.templates || [];
const path = document.getElementById('scriptTemplatePathLabel');
if (path) path.textContent = d.file || 'config/script-templates.json';
}
} catch(e) {}
}
function commitSelectedTemplate() {
if (selectedTemplateIndex < 0 || selectedTemplateIndex >= scriptTemplates.length) return;
const t = scriptTemplates[selectedTemplateIndex];
const nameEl = document.getElementById('scriptTemplateName');
const enabledEl = document.getElementById('scriptTemplateEnabled');
if (nameEl) t.name = nameEl.value || 'Template';
if (enabledEl) t.enabled = !!enabledEl.checked;
t.script = getEditorValue('scriptTemplateCode');
t.updatedAt = Date.now();
}
function renderScriptTemplateList() {
const list = document.getElementById('scriptTemplateList');
if (!list) return;
if (!scriptTemplates.length) {
list.innerHTML = '<div style="padding:12px;color:var(--text-secondary)">Keine Templates vorhanden.</div>';
selectedTemplateIndex = -1;
setTemplateForm(null);
return;
}
if (selectedTemplateIndex < 0 || selectedTemplateIndex >= scriptTemplates.length) selectedTemplateIndex = 0;
list.innerHTML = scriptTemplates.map((t, i) => `
<div class="template-list-item ${i === selectedTemplateIndex ? 'active' : ''}" onclick="selectScriptTemplate(${i})">
<span>${t.enabled === false ? '○' : '●'} ${esc(t.name || 'Template')}</span>
<span style="color:var(--text-secondary);font-size:11px">${t.builtIn ? 'Standard' : 'Eigen'}</span>
</div>`).join('');
setTemplateForm(scriptTemplates[selectedTemplateIndex]);
}
function setTemplateForm(t) {
const nameEl = document.getElementById('scriptTemplateName');
const enabledEl = document.getElementById('scriptTemplateEnabled');
if (nameEl) nameEl.value = t ? (t.name || '') : '';
if (enabledEl) enabledEl.checked = !t || t.enabled !== false;
setEditorValue('scriptTemplateCode', t ? (t.script || '') : '');
}
function selectScriptTemplate(index) {
commitSelectedTemplate();
selectedTemplateIndex = index;
renderScriptTemplateList();
if (jsEditors.scriptTemplateCode) setTimeout(() => jsEditors.scriptTemplateCode.refresh(), 20);
}
function newScriptTemplate() {
commitSelectedTemplate();
scriptTemplates.push({
id: '', name: 'Neues Template', enabled: true, builtIn: false, updatedAt: Date.now(),
script: `// Eigenes Template\n// Die Platzhalter werden beim Einfügen durch feste NodeID-Codezeilen ersetzt.\n{{readNumberVars}}\n\nvar response = http.postJson("http://localhost:9000/api/process", {\n{{payloadFields}}});\n\nvar result = JSON.parse(response.body);\nglobal.apiResult = result;\n{{opcWritesFromResult}}\n`
});
selectedTemplateIndex = scriptTemplates.length - 1;
renderScriptTemplateList();
}
function deleteScriptTemplate() {
if (selectedTemplateIndex < 0) return;
if (!confirm('Template löschen?')) return;
scriptTemplates.splice(selectedTemplateIndex, 1);
selectedTemplateIndex = Math.min(selectedTemplateIndex, scriptTemplates.length - 1);
renderScriptTemplateList();
}
async function resetScriptTemplates() {
if (!confirm('Standard GET/POST/SET wiederherstellen? Eigene Template-Änderungen werden ersetzt.')) return;
try {
const d = await (await apiFetch('/script/templates', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({resetDefaults: true})
})).json();
if (!d.success) throw new Error(d.error || 'Reset failed');
scriptTemplates = d.templates || [];
selectedTemplateIndex = 0;
renderScriptTemplateList();
renderActionTemplateButtons();
} catch(e) { alert('Template Reset Fehler: ' + e.message); }
}
async function saveScriptTemplates(showMessage = true) {
commitSelectedTemplate();
try {
const d = await (await apiFetch('/script/templates', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({templates: scriptTemplates})
})).json();
if (!d.success) throw new Error(d.error || 'Save failed');
scriptTemplates = d.templates || [];
renderScriptTemplateList();
renderActionTemplateButtons();
if (showMessage) alert('Templates gespeichert');
} catch(e) { alert('Templates speichern Fehler: ' + e.message); }
}
function insertTemplatePlaceholders() {
insertAtCursor('scriptTemplateCode', `\n// Platzhalter:\n{{readNumberVars}}\n{{payloadFields}}\n{{opcWritesFromResult}}\n`);
}
async function renderActionTemplateButtons() {
const box = document.getElementById('actionTemplateButtons');
if (!box) return;
if (!scriptTemplates.length) await loadScriptTemplates();
const active = scriptTemplates.filter(t => t.enabled !== false);
if (!active.length) {
box.innerHTML = '<span style="font-size:12px;color:var(--text-secondary)">Keine aktiven Templates. Unter Configuration → Script Templates anlegen.</span>';
return;
}
box.innerHTML = active.map(t => `<button type="button" class="btn btn-small" onclick="insertUserTemplate('${escAttr(t.id || t.name)}')">${esc(t.name || 'Template')}</button>`).join('');
}
async function insertUserTemplate(templateId) {
const selected = getCheckedNodeSpecs('actionNodeCodeList');
try {
const d = await (await apiFetch('/script/template-snippet', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({templateId, nodes: selected})
})).json();
if (!d.success) throw new Error(d.error || 'Template failed');
insertAtCursor('scriptCode', d.script);
} catch(e) { alert('Template Fehler: ' + e.message); }
}
// ── Actions ───────────────────────────────────────────────────────────
async function openActions(nodeId) {
currentNodeForAction = nodeId;
await loadScriptTemplates();
renderConditionNodeList();
renderNodeCodeList('actionNodeCodeList');
renderActionTemplateButtons();
document.getElementById('actionModal').classList.add('active');
['initScript','conditionScript','scriptCode'].forEach(ensureEditor);
}
function closeModal() { document.getElementById('actionModal').classList.remove('active'); }
function updateTriggerFields() {
const t = document.getElementById('triggerType').value;
document.getElementById('triggerValueGroup').style.display =
['ON_VALUE','ON_GREATER_THAN','ON_LESS_THAN'].includes(t) ? 'block' : 'none';
document.getElementById('intervalGroup').style.display =
t === 'ON_INTERVAL' ? 'block' : 'none';
}
function toggleConditionConfig() {
document.getElementById('conditionConfig').style.display =
document.getElementById('conditionEnabled').checked ? 'block' : 'none';
}
function toggleInitConfig() {
document.getElementById('initConfig').style.display =
document.getElementById('initEnabled').checked ? 'block' : 'none';
}
function renderConditionNodeList() {
renderNodeCodeList('conditionNodeList');
const box = document.getElementById('conditionNodeList');
if (box && nodes.length) {
const buttons = document.createElement('div');
buttons.style.display = 'flex';
buttons.style.gap = '6px';
buttons.style.flexWrap = 'wrap';
buttons.style.marginTop = '8px';
buttons.innerHTML = `
<button type="button" class="btn btn-small" onclick="insertCheckedNodeSnippets('conditionNodeList','conditionScript','readNumber')">var Number in Bedingung</button>
<button type="button" class="btn btn-small" onclick="insertCheckedNodeSnippets('conditionNodeList','conditionScript','read')">var Text in Bedingung</button>`;
box.appendChild(buttons);
}
}
function renderNodeCodeList(containerId) {
const box = document.getElementById(containerId);
if (!box) return;
if (!nodes.length) {
box.innerHTML = '<div style="font-size:12px;color:var(--text-secondary)">Keine ueberwachten Nodes vorhanden.</div>';
return;
}
box.innerHTML = nodes.map((n, i) => {
const alias = safeAlias(n.name || n.id);
return `<label class="code-helper-row">
<input type="checkbox" data-node-id="${esc(n.id)}" checked>
<input class="code-helper-alias" value="${esc(alias)}" title="Variablenname im erzeugten Script">
<span style="font-size:11px;color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis">${esc(n.id)}</span>
</label>`;
}).join('');
}
function getCheckedNodeSpecs(containerId) {
const box = document.getElementById(containerId);
if (!box) return [];
return [...box.querySelectorAll('input[type="checkbox"][data-node-id]:checked')].map(cb => {
const row = cb.closest('.code-helper-row');
const aliasInput = row ? row.querySelector('.code-helper-alias') : null;
return { nodeId: cb.dataset.nodeId, alias: aliasInput ? aliasInput.value : undefined };
});
}
async function insertCheckedNodeSnippets(containerId, targetId, mode, writeExpression) {
const selected = getCheckedNodeSpecs(containerId);
if (!selected.length) { alert('Keine Node markiert'); return; }
try {
const d = await (await apiFetch('/script/snippet', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({mode, nodes: selected, writeExpression})
})).json();
if (!d.success) throw new Error(d.error || 'Snippet failed');
insertAtCursor(targetId, d.script);
} catch(e) {
alert('Snippet Fehler: ' + e.message);
}
}
function selectedConditionNodes() {
// Keinen Runtime-Zustand aus Checkboxen speichern. Die NodeIDs stehen explizit im Script.
return {ids: [], aliases: {}};
}
function insertNodeSnippet(btn) {
const target = btn.dataset.target;
const nodeId = btn.dataset.nodeId;
const alias = safeAlias(btn.dataset.alias || nodeId);
let snippet;
if (btn.dataset.mode === 'readNumber') {
snippet = `var ${alias} = Number(opc.read(${JSON.stringify(nodeId)}));\n`;
} else if (btn.dataset.mode === 'write') {
snippet = `opc.write(${JSON.stringify(nodeId)}, /* value */);\n`;
} else {
snippet = `var ${alias} = opc.read(${JSON.stringify(nodeId)});\n`;
}
insertAtCursor(target, snippet);
}
function ensureEditor(id) {
const el = document.getElementById(id);
if (!el || jsEditors[id]) return jsEditors[id];
if (window.CodeMirror) {
jsEditors[id] = CodeMirror.fromTextArea(el, {
mode: 'javascript', theme: 'material-darker', lineNumbers: true,
lineWrapping: false, indentUnit: 2, tabSize: 2
});
}
return jsEditors[id];
}
function getEditorValue(id) {
return jsEditors[id] ? jsEditors[id].getValue() : (document.getElementById(id)?.value || '');
}
function setEditorValue(id, value) {
if (jsEditors[id]) jsEditors[id].setValue(value || '');
else { const el = document.getElementById(id); if (el) el.value = value || ''; }
}
function insertAtCursor(elementId, text) {
const cm = jsEditors[elementId];
if (cm) {
cm.replaceSelection(text);
cm.focus();
return;
}
const el = document.getElementById(elementId);
if (!el) return;
const start = el.selectionStart ?? el.value.length;
const end = el.selectionEnd ?? el.value.length;
el.value = el.value.slice(0, start) + text + el.value.slice(end);
const pos = start + text.length;
el.focus();
el.setSelectionRange(pos, pos);
}
async function loadActions() {
try {
const d = await (await apiFetch('/actions')).json();
if (d.success) {
actions = d.actions || [];
renderActions();
}
} catch(e) {}
}
async function saveAction() {
const a = {
nodeId: currentNodeForAction,
name: document.getElementById('actionName').value,
trigger: document.getElementById('triggerType').value,
value: document.getElementById('triggerValue').value,
interval: document.getElementById('intervalMs').value,
initEnabled: document.getElementById('initEnabled').checked,
initScript: getEditorValue('initScript'),
conditionEnabled: document.getElementById('conditionEnabled').checked,
conditionScript: getEditorValue('conditionScript'),
conditionNodeIds: [],
nodeAliases: {},
script: getEditorValue('scriptCode')
};
if (!a.name || !a.script) { alert('Name and Script required!'); return; }
try {
const d = await (await apiFetch('/actions', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(a)
})).json();
if (!d.success) throw new Error(d.error || 'Save action failed');
await loadActions();
closeModal();
document.getElementById('actionName').value = '';
setEditorValue('scriptCode', '');
setEditorValue('initScript', '');
document.getElementById('initEnabled').checked = false;
toggleInitConfig();
setEditorValue('conditionScript', '');
document.getElementById('conditionEnabled').checked = false;
toggleConditionConfig();
} catch(e) { alert('Error: ' + e.message); }
}
function renderActions() {
const al = document.getElementById('actionsList');
document.getElementById('actionCount').textContent = actions.length;
if (actions.length === 0) {
al.innerHTML = '<div class="empty-state"><div class="empty-icon">⚡</div><div>No actions</div></div>';
return;
}
al.innerHTML = actions.map((a, i) => `
<div class="list-item">
<div>
<div style="font-weight:600">${esc(a.actionName || a.name)}</div>
<div style="font-size:12px;color:var(--text-secondary);margin-top:4px">
${esc(a.triggerType || a.trigger)} · ${esc(a.nodeId || '')}
${a.initEnabled ? ' · init/session' : ''}
${a.conditionEnabled ? ' · condition' : ''}
</div>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-small" onclick="testAction(${i})">Test</button>
<button class="btn-delete" onclick="deleteAction(${i})">×</button>
</div>
</div>`).join('');
}
async function testAction(i) {
const a = actions[i];
try {
const d = await (await apiFetch('/actions/test', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({nodeId: a.nodeId, actionName: a.actionName || a.name, value: 'TEST'})
})).json();
if (!d.success) throw new Error(d.error || 'Test failed');
} catch(e) { alert('Error: ' + e.message); }
}
async function deleteAction(i) {
const a = actions[i];
if (!confirm('Delete: ' + (a.actionName || a.name) + '?')) return;
try {
const d = await (await apiFetch('/actions/delete', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({nodeId: a.nodeId, actionName: a.actionName || a.name})
})).json();
if (!d.success) throw new Error(d.error || 'Delete failed');
await loadActions();
} catch(e) { alert('Error: ' + e.message); }
}
function safeAlias(s) {
s = (s || 'node').toString().replace(/[^A-Za-z0-9_$]/g, '_');
return /^[A-Za-z_$]/.test(s) ? s : 'n_' + s;
}
function esc(s) {
if (!s) return '';
return s.toString().replace(/[&<>"']/g, m =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
}
function escAttr(s) {
return esc(s).replace(/`/g, '&#096;');
}
</script>
</body>
</html>