1217 lines
69 KiB
HTML
1217 lines
69 KiB
HTML
<!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">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 & jetzt ausführen</button>
|
||
</div>
|
||
|
||
<label class="form-label">JavaScript</label>
|
||
<textarea class="form-textarea js-editor" id="systemInitScript" placeholder="var response = http.postJson("http://localhost:9000/api/reglogin", { client: "opcua-client" }); var result = JSON.parse(response.body); global.session = result; global.token = result.token; log.info("RegLogin OK");"></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}} var response = http.postJson("http://localhost:9000/api/process", { {{payloadFields}}}); var result = JSON.parse(response.body); global.apiResult = result; {{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("http://localhost:9000/api/login", { user: "opc", password: "secret" }); var result = JSON.parse(login.body); session.token = result.token; global.session = result; 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("ns=...")</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("ns=3;s=AirConditioner_1.Temperature")); Temperatur > 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("ns=3;s=AirConditioner_1.Temperature")); var response = http.postJson("http://localhost:9000/api/check", { temperature: Temperatur }); var result = JSON.parse(response.body); opc.write("ns=3;s=AirConditioner_1.Result", 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 enc = document.getElementById('configEncryptionEnabled');
|
||
const rp = document.getElementById('restAuthEnabled');
|
||
const ru = document.getElementById('restAuthUser');
|
||
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 = {
|
||
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');
|
||
}
|
||
|
||
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 =>
|
||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||
}
|
||
|
||
function escAttr(s) {
|
||
return esc(s).replace(/`/g, '`');
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |