const state = { sources: [], selectedSourceId: null, tree: [], selectedNodeIds: new Set(), logSource: null, points: new Map(), pointEls: new Map(), pointsPage: 1, pointsPageSize: 100, pointsTotal: 0, }; const el = (id) => document.getElementById(id); const statusText = el('statusText'); const sourceForm = el('sourceForm'); const sourceIdInput = el('sourceId'); const sourceName = el('sourceName'); const sourceEndpoint = el('sourceEndpoint'); const sourceEnabled = el('sourceEnabled'); const sourceSubmit = el('sourceSubmit'); const sourceReset = el('sourceReset'); const sourceList = el('sourceList'); const nodeTree = el('nodeTree'); const browseNodesBtn = el('browseNodes'); const refreshTreeBtn = el('refreshTree'); const createPointsBtn = el('createPoints'); const logView = el('logView'); const pointList = el('pointList'); const pointModal = el('pointModal'); const closeModalBtn = el('closeModal'); const selectedCount = el('selectedCount'); const prevPointsBtn = el('prevPoints'); const nextPointsBtn = el('nextPoints'); const pointsPageInfo = el('pointsPageInfo'); const openSourceFormBtn = el('openSourceForm'); const sourceModal = el('sourceModal'); const closeSourceModalBtn = el('closeSourceModal'); function setStatus(text) { statusText.textContent = text; } async function apiFetch(url, options = {}) { const res = await fetch(url, { headers: { 'Content-Type': 'application/json', }, ...options, }); if (!res.ok) { const err = await res.text(); throw new Error(err || res.statusText); } if (res.status === 204) return null; return res.json(); } function renderSources() { sourceList.innerHTML = ''; state.sources.forEach((source) => { const item = document.createElement('div'); item.className = 'list-item'; if (state.selectedSourceId === source.id) { item.classList.add('selected'); } item.onclick = () => selectSource(source.id); const statusBadge = document.createElement('span'); statusBadge.className = `badge ${source.is_connected ? '' : 'offline'}`; statusBadge.textContent = source.is_connected ? '在线' : '离线'; const titleRow = document.createElement('div'); titleRow.className = 'row'; titleRow.innerHTML = `${source.name}`; titleRow.appendChild(statusBadge); const endpoint = document.createElement('div'); endpoint.className = 'muted'; endpoint.textContent = source.endpoint; const actionRow = document.createElement('div'); actionRow.className = 'row'; const selectPointsBtn = document.createElement('button'); selectPointsBtn.textContent = '选入 Points'; selectPointsBtn.onclick = (e) => { e.stopPropagation(); selectSource(source.id).then(() => { pointModal.classList.remove('hidden'); loadTree(); }); }; const editBtn = document.createElement('button'); editBtn.textContent = '编辑'; editBtn.className = 'secondary'; editBtn.onclick = (e) => { e.stopPropagation(); fillSourceForm(source); }; const deleteBtn = document.createElement('button'); deleteBtn.textContent = '删除'; deleteBtn.className = 'danger'; deleteBtn.onclick = (e) => { e.stopPropagation(); deleteSource(source.id); }; actionRow.appendChild(selectPointsBtn); actionRow.appendChild(editBtn); actionRow.appendChild(deleteBtn); item.appendChild(titleRow); item.appendChild(endpoint); if (source.last_error) { const err = document.createElement('div'); err.style.color = 'var(--danger)'; err.textContent = source.last_error; item.appendChild(err); } item.appendChild(actionRow); sourceList.appendChild(item); }); } function fillSourceForm(source) { sourceIdInput.value = source.id; sourceName.value = source.name || ''; sourceEndpoint.value = source.endpoint || ''; sourceEnabled.checked = !!source.enabled; sourceSubmit.textContent = '保存'; sourceModal.classList.remove('hidden'); } function resetSourceForm() { sourceIdInput.value = ''; sourceName.value = ''; sourceEndpoint.value = ''; sourceEnabled.checked = true; sourceSubmit.textContent = '保存'; } async function loadSources() { setStatus('加载 Source...'); const data = await apiFetch('/api/source'); state.sources = data || []; renderSources(); setStatus('Ready'); } async function selectSource(sourceId) { state.selectedSourceId = sourceId; state.selectedNodeIds.clear(); state.pointsPage = 1; renderSelectedNodes(); await loadPoints(); await loadTree(); } async function loadTree() { if (!state.selectedSourceId) { nodeTree.innerHTML = '
请选择 Source
'; return; } setStatus('加载节点树...'); const data = await apiFetch(`/api/source/${state.selectedSourceId}/node-tree`); state.tree = data || []; nodeTree.innerHTML = ''; state.tree.forEach((node) => nodeTree.appendChild(renderNode(node))); setStatus('Ready'); } function renderNode(node) { const details = document.createElement('details'); details.open = false; const summary = document.createElement('summary'); if (node.children && node.children.length) { summary.classList.add('has-children'); } const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = state.selectedNodeIds.has(node.id); checkbox.onchange = () => toggleNode(node, checkbox.checked); const label = document.createElement('span'); label.className = 'node-label'; label.textContent = `${node.display_name || node.browse_name} (${node.node_class})`; summary.appendChild(checkbox); summary.appendChild(label); details.appendChild(summary); if (node.children && node.children.length) { node.children.forEach((child) => details.appendChild(renderNode(child))); } return details; } function toggleNode(node, checked) { if (checked) { state.selectedNodeIds.add(node.id); } else { state.selectedNodeIds.delete(node.id); } renderSelectedNodes(); } function renderSelectedNodes() { const count = state.selectedNodeIds.size; selectedCount.textContent = `已选 ${count} 个节点`; } async function createPoints() { if (!state.selectedNodeIds.size) return; setStatus('创建 Points...'); await apiFetch('/api/point/batch', { method: 'POST', body: JSON.stringify({ node_ids: Array.from(state.selectedNodeIds) }), }); setStatus('Points 创建完成'); state.selectedNodeIds.clear(); renderSelectedNodes(); pointModal.classList.add('hidden'); await loadPoints(); } async function browseNodes() { if (!state.selectedSourceId) return; setStatus('浏览节点中...'); await apiFetch(`/api/source/${state.selectedSourceId}/browse`, { method: 'POST', }); await loadTree(); } async function deleteSource(sourceId) { if (!confirm('确认删除该 Source?')) return; await apiFetch(`/api/source/${sourceId}`, { method: 'DELETE' }); if (state.selectedSourceId === sourceId) { state.selectedSourceId = null; nodeTree.innerHTML = ''; } await loadSources(); } async function deletePoint(pointId) { if (!confirm('确认删除该 Point?')) return; await apiFetch(`/api/point/${pointId}`, { method: 'DELETE' }); await loadPoints(); } async function loadPoints() { setStatus('加载 Points...'); const sourceQuery = state.selectedSourceId ? `&source_id=${state.selectedSourceId}` : ''; const page = state.pointsPage; const pageSize = state.pointsPageSize; const data = await apiFetch(`/api/point?page=${page}&page_size=${pageSize}${sourceQuery}`); const items = data && data.data ? data.data : []; state.pointsTotal = data && typeof data.total === 'number' ? data.total : items.length; state.points.clear(); state.pointEls.clear(); pointList.innerHTML = ''; if (!items.length) { pointList.textContent = '暂无 Points'; pointsPageInfo.textContent = `第 ${state.pointsPage} 页 / 共 1 页`; prevPointsBtn.disabled = true; nextPointsBtn.disabled = true; setStatus('Ready'); return; } items.forEach((item) => { const point = item.point || item; const monitor = item.point_monitor || null; state.points.set(point.id, { point, monitor }); const box = document.createElement('div'); box.className = 'list-item'; const row = document.createElement('div'); row.className = 'row'; row.innerHTML = `${point.name}`; const quality = monitor ? (monitor.quality || 'unknown').toLowerCase() : 'unknown'; const qualityBadge = document.createElement('span'); qualityBadge.className = `badge quality-${quality}`; qualityBadge.textContent = quality.toUpperCase(); row.appendChild(qualityBadge); const deleteBtn = document.createElement('button'); deleteBtn.className = 'danger'; deleteBtn.textContent = '删除'; deleteBtn.onclick = () => deletePoint(point.id); row.appendChild(deleteBtn); const valueRow = document.createElement('div'); valueRow.className = 'row'; const value = document.createElement('div'); value.className = 'value'; value.textContent = formatValue(monitor); const ts = document.createElement('div'); ts.className = 'muted'; ts.textContent = monitor && monitor.timestamp ? monitor.timestamp : ''; valueRow.appendChild(value); valueRow.appendChild(ts); const meta = document.createElement('div'); meta.className = 'muted'; meta.textContent = `${point.id} / node: ${point.node_id}`; box.appendChild(row); box.appendChild(valueRow); box.appendChild(meta); pointList.appendChild(box); state.pointEls.set(point.id, { box, value, qualityBadge, ts }); }); const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize)); pointsPageInfo.textContent = `第 ${state.pointsPage} 页 / 共 ${totalPages} 页`; prevPointsBtn.disabled = state.pointsPage <= 1; nextPointsBtn.disabled = state.pointsPage >= totalPages; setStatus('Ready'); } async function saveSource(event) { event.preventDefault(); const payload = { name: sourceName.value.trim(), endpoint: sourceEndpoint.value.trim(), enabled: sourceEnabled.checked, }; if (!payload.name || !payload.endpoint) return; const id = sourceIdInput.value; if (id) { await apiFetch(`/api/source/${id}`, { method: 'PUT', body: JSON.stringify(payload), }); } else { await apiFetch('/api/source', { method: 'POST', body: JSON.stringify(payload), }); } resetSourceForm(); sourceModal.classList.add('hidden'); await loadSources(); } function startLogs() { if (state.logSource) state.logSource.close(); const es = new EventSource('/api/logs/stream'); state.logSource = es; es.addEventListener('log', (event) => { const data = JSON.parse(event.data); data.lines.forEach((line) => appendLog(line)); }); es.addEventListener('error', () => { appendLog('[log stream error]'); }); } function startPointSocket() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const ws = new WebSocket(`${protocol}://${location.host}/ws/public`); ws.onmessage = (event) => { try { const payload = JSON.parse(event.data); if (payload.type === 'PointNewValue' || payload.type === 'point_new_value') { handlePointUpdate(payload.data); } } catch { // ignore } }; ws.onclose = () => { setTimeout(startPointSocket, 2000); }; } function handlePointUpdate(data) { if (!data || !data.point_id) return; const entry = state.pointEls.get(data.point_id); if (!entry) return; const quality = (data.quality || 'unknown').toLowerCase(); entry.qualityBadge.className = `badge quality-${quality}`; entry.qualityBadge.textContent = quality.toUpperCase(); entry.value.textContent = formatValue(data); entry.ts.textContent = data.timestamp || ''; } function formatValue(monitor) { if (!monitor) return '--'; if (monitor.value_text) return monitor.value_text; if (monitor.value === null || monitor.value === undefined) return '--'; if (typeof monitor.value === 'string') return monitor.value; try { return JSON.stringify(monitor.value); } catch { return String(monitor.value); } } function appendLog(line) { const atBottom = logView.scrollTop + logView.clientHeight >= logView.scrollHeight - 10; const div = document.createElement('div'); div.className = 'log-line'; const parsed = parseLogLine(line); if (parsed) { const levelRaw = (parsed.level || '').toString(); const level = levelRaw.toLowerCase(); if (level) div.classList.add(`level-${level}`); const levelSpan = document.createElement('span'); levelSpan.className = 'level'; levelSpan.textContent = levelRaw || 'LOG'; const timeSpan = document.createElement('span'); timeSpan.className = 'muted'; timeSpan.textContent = parsed.timestamp ? ` ${parsed.timestamp}` : ''; const targetSpan = document.createElement('span'); targetSpan.className = 'muted'; targetSpan.textContent = parsed.target ? ` ${parsed.target}` : ''; const msgSpan = document.createElement('span'); msgSpan.className = 'message'; msgSpan.textContent = (parsed.fields && parsed.fields.message) || parsed.message || parsed.msg || line; div.appendChild(levelSpan); if (timeSpan.textContent) div.appendChild(timeSpan); if (targetSpan.textContent) div.appendChild(targetSpan); div.appendChild(msgSpan); } else { div.textContent = line; } logView.appendChild(div); if (atBottom) { logView.scrollTop = logView.scrollHeight; } } function parseLogLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null; try { return JSON.parse(trimmed); } catch { return null; } } sourceForm.addEventListener('submit', saveSource); sourceReset.addEventListener('click', resetSourceForm); browseNodesBtn.addEventListener('click', browseNodes); refreshTreeBtn.addEventListener('click', loadTree); createPointsBtn.addEventListener('click', createPoints); closeModalBtn.addEventListener('click', () => { pointModal.classList.add('hidden'); }); openSourceFormBtn.addEventListener('click', () => { resetSourceForm(); sourceModal.classList.remove('hidden'); }); closeSourceModalBtn.addEventListener('click', () => { sourceModal.classList.add('hidden'); }); prevPointsBtn.addEventListener('click', () => { if (state.pointsPage > 1) { state.pointsPage -= 1; loadPoints(); } }); nextPointsBtn.addEventListener('click', () => { const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize)); if (state.pointsPage < totalPages) { state.pointsPage += 1; loadPoints(); } }); loadSources().catch((err) => setStatus(err.message)); loadPoints().catch((err) => setStatus(err.message)); startLogs(); startPointSocket();