opcua-service/main/resources/web/web-interface.html
2026-05-11 19:40:18 +02:00

636 lines
34 KiB
HTML
Raw 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>
<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); }&#10;warn('Value: ' + currentValue);&#10;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 =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
}
</script>
</body>
</html>