636 lines
34 KiB
HTML
636 lines
34 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>
|
||
<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; }
|
||
.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; }
|
||
@media (min-width: 768px) { .modal-content { border-radius: 12px; } }
|
||
.modal-header { font-size: 20px; font-weight: 700; margin-bottom: 20px; }
|
||
.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; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<h1>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="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>
|
||
|
||
<!-- 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="conditionEnabled" onchange="toggleConditionConfig()">
|
||
Bedingung über markierte NodeIDs aktivieren
|
||
</label>
|
||
<div id="conditionConfig" style="display:none;margin-top:10px">
|
||
<label class="form-label">NodeIDs als Script-Variablen übernehmen</label>
|
||
<div id="conditionNodeList" style="max-height:140px;overflow:auto;border:1px solid var(--border);border-radius:8px;padding:8px;background:#fff"></div>
|
||
<label class="form-label" style="margin-top:10px">Condition JavaScript (muss true/false zurückgeben)</label>
|
||
<textarea class="form-textarea" id="conditionScript" placeholder="Number(values.Temperature) > 70 && Humidity < 65"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">JavaScript Code</label>
|
||
<textarea class="form-textarea" id="scriptCode" placeholder="function warn(msg){ console.log(msg); } warn('Value: ' + currentValue); opc.write(nodes.OutputNode, true);"></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>
|
||
const API = window.location.origin + '/api';
|
||
let nodes = [], actions = [], isConnected = false, currentNodeForAction = null, autoRefreshInterval = null;
|
||
|
||
// ── Status polling ────────────────────────────────────────────────────
|
||
setInterval(checkStatus, 5000);
|
||
checkStatus();
|
||
setInterval(pollTwinStatus, 4000);
|
||
loadActions();
|
||
|
||
async function checkStatus() {
|
||
try {
|
||
const d = await (await fetch(API + '/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 fetch(API + '/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 fetch(API + '/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 fetch(API + '/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 fetch(API + '/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); }
|
||
}
|
||
|
||
// ── Actions ───────────────────────────────────────────────────────────
|
||
function openActions(nodeId) {
|
||
currentNodeForAction = nodeId;
|
||
renderConditionNodeList();
|
||
document.getElementById('actionModal').classList.add('active');
|
||
}
|
||
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 renderConditionNodeList() {
|
||
const box = document.getElementById('conditionNodeList');
|
||
if (!nodes.length) {
|
||
box.innerHTML = '<div style="font-size:12px;color:var(--text-secondary)">Keine überwachten Nodes vorhanden.</div>';
|
||
return;
|
||
}
|
||
box.innerHTML = nodes.map(n => {
|
||
const checked = n.id === currentNodeForAction ? 'checked' : '';
|
||
const alias = safeAlias(n.name || n.id);
|
||
return `<label style="display:flex;gap:8px;align-items:center;padding:4px 0">
|
||
<input type="checkbox" class="cond-node" value="${esc(n.id)}" data-alias="${esc(alias)}" ${checked}>
|
||
<span><b>${esc(alias)}</b> = <span style="font-size:11px;color:var(--text-secondary)">${esc(n.id)}</span></span>
|
||
</label>`;
|
||
}).join('');
|
||
}
|
||
|
||
function selectedConditionNodes() {
|
||
const ids = [], aliases = {};
|
||
document.querySelectorAll('.cond-node:checked').forEach(cb => {
|
||
ids.push(cb.value);
|
||
aliases[cb.value] = cb.dataset.alias || safeAlias(cb.value);
|
||
});
|
||
return {ids, aliases};
|
||
}
|
||
|
||
async function loadActions() {
|
||
try {
|
||
const d = await (await fetch(API + '/actions')).json();
|
||
if (d.success) {
|
||
actions = d.actions || [];
|
||
renderActions();
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
async function saveAction() {
|
||
const selected = selectedConditionNodes();
|
||
const a = {
|
||
nodeId: currentNodeForAction,
|
||
name: document.getElementById('actionName').value,
|
||
trigger: document.getElementById('triggerType').value,
|
||
value: document.getElementById('triggerValue').value,
|
||
interval: document.getElementById('intervalMs').value,
|
||
conditionEnabled: document.getElementById('conditionEnabled').checked,
|
||
conditionScript: document.getElementById('conditionScript').value,
|
||
conditionNodeIds: selected.ids,
|
||
nodeAliases: selected.aliases,
|
||
script: document.getElementById('scriptCode').value
|
||
};
|
||
if (!a.name || !a.script) { alert('Name and Script required!'); return; }
|
||
try {
|
||
const d = await (await fetch(API + '/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 = '';
|
||
document.getElementById('scriptCode').value = '';
|
||
document.getElementById('conditionScript').value = '';
|
||
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.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 fetch(API + '/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 fetch(API + '/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]));
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |