const state = { sources: [], selectedSourceId: null, tree: [], selectedNodeIds: new Set(), logSource: null, points: new Map(), pointEls: new Map(), pointsPage: 1, pointsPageSize: 100, pointsTotal: 0, chartPointId: null, chartPointName: '', chartData: [], }; 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'); const chartCanvas = el('chartCanvas'); const chartTitle = el('chartTitle'); const chartSummary = el('chartSummary'); const refreshChartBtn = el('refreshChart'); const openApiDocBtn = el('openApiDoc'); const apiDocDrawer = el('apiDocDrawer'); const closeApiDocBtn = el('closeApiDoc'); const apiDocContent = el('apiDocContent'); const apiDocToc = el('apiDocToc'); let apiDocLoaded = false; function setStatus(text) { statusText.textContent = text; } function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function renderInlineMarkdown(text) { let html = escapeHtml(text); html = html.replace(/`([^`\n]+)`/g, '$1'); html = html.replace(/\*\*([^*\n]+)\*\*/g, '$1'); html = html.replace(/\*([^*\n]+)\*/g, '$1'); return html; } function slugifyHeading(text, used) { const base = String(text || '') .toLowerCase() .replace(/<[^>]+>/g, '') .replace(/[^\w\u4e00-\u9fa5-]+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') || 'section'; let slug = base; let index = 2; while (used.has(slug)) { slug = `${base}-${index}`; index += 1; } used.add(slug); return slug; } function renderMarkdown(markdown) { const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n'); const blocks = []; const toc = []; const usedHeadingIds = new Set(); let paragraph = []; let listType = null; let listItems = []; let codeFence = false; let codeLines = []; function flushParagraph() { if (!paragraph.length) return; blocks.push(`

${renderInlineMarkdown(paragraph.join(' '))}

`); paragraph = []; } function flushList() { if (!listItems.length) return; const tag = listType || 'ul'; blocks.push(`<${tag}>${listItems.map((item) => `
  • ${renderInlineMarkdown(item)}
  • `).join('')}`); listItems = []; listType = null; } function flushCodeFence() { if (!codeFence) return; blocks.push(`
    ${escapeHtml(codeLines.join('\n'))}
    `); codeFence = false; codeLines = []; } for (const line of lines) { if (line.trim().startsWith('```')) { flushParagraph(); flushList(); if (codeFence) { flushCodeFence(); } else { codeFence = true; } continue; } if (codeFence) { codeLines.push(line); continue; } const trimmed = line.trim(); if (!trimmed) { flushParagraph(); flushList(); continue; } if (/^---+$/.test(trimmed)) { flushParagraph(); flushList(); blocks.push('
    '); continue; } const heading = trimmed.match(/^(#{1,4})\s+(.*)$/); if (heading) { flushParagraph(); flushList(); const level = heading[1].length; const title = heading[2].trim(); const id = slugifyHeading(title, usedHeadingIds); toc.push({ level, title, id }); blocks.push(`${renderInlineMarkdown(title)}`); continue; } const quote = trimmed.match(/^>\s?(.*)$/); if (quote) { flushParagraph(); flushList(); blocks.push(`
    ${renderInlineMarkdown(quote[1])}
    `); continue; } const unordered = trimmed.match(/^[-*]\s+(.*)$/); if (unordered) { flushParagraph(); if (listType && listType !== 'ul') flushList(); listType = 'ul'; listItems.push(unordered[1]); continue; } const ordered = trimmed.match(/^\d+\.\s+(.*)$/); if (ordered) { flushParagraph(); if (listType && listType !== 'ol') flushList(); listType = 'ol'; listItems.push(ordered[1]); continue; } flushList(); paragraph.push(trimmed); } flushParagraph(); flushList(); flushCodeFence(); return { html: blocks.join(''), toc }; } function renderApiDocToc(items) { if (!apiDocToc) return; if (!items.length) { apiDocToc.innerHTML = '
    无目录
    '; return; } apiDocToc.innerHTML = items .map((item) => `${escapeHtml(item.title)}`) .join(''); apiDocToc.querySelectorAll('a').forEach((link) => { link.addEventListener('click', (event) => { event.preventDefault(); const targetId = link.getAttribute('href').slice(1); const target = apiDocContent.querySelector(`#${CSS.escape(targetId)}`); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); }); } async function loadApiDoc() { apiDocContent.innerHTML = '

    加载中...

    '; if (apiDocToc) { apiDocToc.innerHTML = '
    加载中...
    '; } const response = await fetch('/api/docs/api-md'); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const markdown = await response.text(); const rendered = renderMarkdown(markdown); apiDocContent.innerHTML = rendered.html; renderApiDocToc(rendered.toc); apiDocLoaded = true; } async function openApiDocDrawer() { apiDocDrawer.classList.remove('hidden'); if (!apiDocLoaded) { try { await loadApiDoc(); } catch (err) { apiDocContent.innerHTML = `

    加载 API.md 失败

    ${escapeHtml(err.message || 'unknown error')}
    `; if (apiDocToc) { apiDocToc.innerHTML = '
    目录加载失败
    '; } } } } function closeApiDocDrawer() { apiDocDrawer.classList.add('hidden'); } 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).catch((err) => setStatus(err.message)); }; 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'); return loadTree(); }) .catch((err) => setStatus(err.message)); }; const editBtn = document.createElement('button'); editBtn.textContent = '编辑'; editBtn.className = 'secondary'; editBtn.onclick = (e) => { e.stopPropagation(); fillSourceForm(source); }; const reconnectBtn = document.createElement('button'); reconnectBtn.textContent = '重连'; reconnectBtn.className = 'secondary'; reconnectBtn.onclick = async (e) => { e.stopPropagation(); try { await reconnectSource(source.id, source.name); } catch (err) { setStatus(err.message); } }; const deleteBtn = document.createElement('button'); deleteBtn.textContent = '删除'; deleteBtn.className = 'danger'; deleteBtn.onclick = (e) => { e.stopPropagation(); deleteSource(source.id).catch((err) => setStatus(err.message)); }; actionRow.appendChild(selectPointsBtn); actionRow.appendChild(editBtn); actionRow.appendChild(reconnectBtn); 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('加载数据源...'); 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; renderSources(); renderSelectedNodes(); await loadPoints(); await loadTree(); } async function loadTree() { if (!state.selectedSourceId) { nodeTree.innerHTML = '
    请选择数据源
    '; 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() { selectedCount.textContent = `已选中 ${state.selectedNodeIds.size} 个节点`; } 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 reconnectSource(sourceId, sourceName) { setStatus(`正在重连 ${sourceName || 'Source'}...`); await apiFetch(`/api/source/${sourceId}/reconnect`, { method: 'POST' }); await loadSources(); if (state.selectedSourceId === sourceId) { await loadPoints(); } setStatus(`${sourceName || 'Source'} 重连完成`); } async function openChart(pointId, pointName) { state.chartPointId = pointId; state.chartPointName = pointName || '测点'; chartTitle.textContent = `${state.chartPointName} 曲线`; chartSummary.textContent = '加载中...'; await loadPointHistory(pointId); } async function loadPointHistory(pointId = state.chartPointId) { if (!pointId) return; const items = await apiFetch(`/api/point/${pointId}/history?limit=120`); state.chartData = (items || []).map(normalizeChartItem).filter(Boolean); renderChart(); } 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.innerHTML = '暂无 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 tr = document.createElement('tr'); if (state.chartPointId === point.id) { tr.classList.add('active'); } tr.onclick = () => { openChart(point.id, point.name).catch((err) => setStatus(err.message)); }; const tdName = document.createElement('td'); tdName.innerHTML = `
    ${point.name}
    ${point.node_id}
    `; const tdValue = document.createElement('td'); const value = document.createElement('span'); value.className = 'point-value'; value.textContent = formatValue(monitor); tdValue.appendChild(value); const quality = monitor ? (monitor.quality || 'unknown').toLowerCase() : 'unknown'; const tdQuality = document.createElement('td'); const qualityBadge = document.createElement('span'); qualityBadge.className = `badge quality-${quality}`; qualityBadge.textContent = quality.toUpperCase(); tdQuality.appendChild(qualityBadge); const tdTime = document.createElement('td'); const ts = document.createElement('span'); ts.className = 'muted'; ts.textContent = monitor && monitor.timestamp ? monitor.timestamp : '--'; tdTime.appendChild(ts); const tdAction = document.createElement('td'); const deleteBtn = document.createElement('button'); deleteBtn.className = 'danger'; deleteBtn.textContent = '×'; deleteBtn.title = '删除'; deleteBtn.style.cssText = 'width:22px;height:22px;padding:0;font-size:14px;'; deleteBtn.onclick = (e) => { e.stopPropagation(); deletePoint(point.id).catch((err) => setStatus(err.message)); }; tdAction.append(deleteBtn); tr.append(tdName, tdValue, tdQuality, tdTime, tdAction); pointList.appendChild(tr); state.pointEls.set(point.id, { box: tr, 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 invalid payloads } }; ws.onclose = () => { setTimeout(startPointSocket, 2000); }; } function handlePointUpdate(data) { if (!data || !data.point_id) return; const entry = state.pointEls.get(data.point_id); if (entry) { 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 || ''; } if (state.chartPointId === data.point_id) { appendChartPoint(data); } } 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; } } function normalizeChartItem(item) { if (!item) return null; return { timestamp: item.timestamp || '', quality: (item.quality || 'unknown').toLowerCase(), valueText: item.value_text || formatValue(item), valueNumber: getNumericValue(item), }; } function getNumericValue(item) { if (typeof item.value_number === 'number' && Number.isFinite(item.value_number)) { return item.value_number; } if (typeof item.value === 'number' && Number.isFinite(item.value)) { return item.value; } if (typeof item.value === 'boolean') { return item.value ? 1 : 0; } if (typeof item.value_text === 'string') { const parsed = Number(item.value_text); if (Number.isFinite(parsed)) return parsed; } return null; } function appendChartPoint(item) { const normalized = normalizeChartItem(item); if (!normalized) return; const last = state.chartData[state.chartData.length - 1]; if (last && last.timestamp === normalized.timestamp && last.valueText === normalized.valueText) { return; } state.chartData.push(normalized); if (state.chartData.length > 120) { state.chartData = state.chartData.slice(-120); } renderChart(); } function renderChart() { const ctx = chartCanvas.getContext('2d'); const width = chartCanvas.width; const height = chartCanvas.height; ctx.clearRect(0, 0, width, height); if (!state.chartPointId) { ctx.fillStyle = '#94a3b8'; ctx.font = '14px Segoe UI'; ctx.fillText('Click a point row to view its chart', 24, 40); chartSummary.textContent = '点击上方点位表中的某一行查看曲线'; return; } const numericPoints = state.chartData.filter((item) => typeof item.valueNumber === 'number'); if (!numericPoints.length) { ctx.fillStyle = '#94a3b8'; ctx.font = '14px Segoe UI'; ctx.fillText('No numeric history available for charting', 24, 40); chartSummary.textContent = state.chartData.length ? `Recent ${state.chartData.length} samples are non-numeric` : 'No history data'; return; } const padding = { top: 20, right: 20, bottom: 36, left: 52 }; const plotWidth = width - padding.left - padding.right; const plotHeight = height - padding.top - padding.bottom; const values = numericPoints.map((item) => item.valueNumber); let min = Math.min(...values); let max = Math.max(...values); if (min === max) { const delta = min === 0 ? 1 : Math.abs(min) * 0.1; min -= delta; max += delta; } ctx.strokeStyle = '#cbd5e1'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(padding.left, padding.top); ctx.lineTo(padding.left, height - padding.bottom); ctx.lineTo(width - padding.right, height - padding.bottom); ctx.stroke(); ctx.fillStyle = '#64748b'; ctx.font = '12px Segoe UI'; ctx.fillText(max.toFixed(2), 8, padding.top + 4); ctx.fillText(min.toFixed(2), 8, height - padding.bottom); for (let i = 0; i <= 4; i += 1) { const y = padding.top + (plotHeight / 4) * i; ctx.strokeStyle = '#e2e8f0'; ctx.beginPath(); ctx.moveTo(padding.left, y); ctx.lineTo(width - padding.right, y); ctx.stroke(); } ctx.strokeStyle = '#2563eb'; ctx.lineWidth = 2; ctx.beginPath(); numericPoints.forEach((item, index) => { const x = padding.left + (plotWidth * index) / Math.max(1, numericPoints.length - 1); const y = padding.top + ((max - item.valueNumber) / (max - min)) * plotHeight; if (index === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }); ctx.stroke(); ctx.fillStyle = '#2563eb'; numericPoints.forEach((item, index) => { const x = padding.left + (plotWidth * index) / Math.max(1, numericPoints.length - 1); const y = padding.top + ((max - item.valueNumber) / (max - min)) * plotHeight; ctx.beginPath(); ctx.arc(x, y, 2.5, 0, Math.PI * 2); ctx.fill(); }); const firstLabel = numericPoints[0].timestamp || '--'; const lastLabel = numericPoints[numericPoints.length - 1].timestamp || '--'; ctx.fillStyle = '#64748b'; ctx.font = '11px Segoe UI'; ctx.fillText(firstLabel, padding.left, height - 12); const lastWidth = ctx.measureText(lastLabel).width; ctx.fillText(lastLabel, width - padding.right - lastWidth, height - 12); const latest = numericPoints[numericPoints.length - 1]; chartSummary.textContent = `Latest ${numericPoints.length} points, current value ${latest.valueText || latest.valueNumber}`; } sourceForm.addEventListener('submit', (event) => { saveSource(event).catch((err) => setStatus(err.message)); }); sourceReset.addEventListener('click', resetSourceForm); browseNodesBtn.addEventListener('click', () => { browseNodes().catch((err) => setStatus(err.message)); }); refreshTreeBtn.addEventListener('click', () => { loadTree().catch((err) => setStatus(err.message)); }); createPointsBtn.addEventListener('click', () => { createPoints().catch((err) => setStatus(err.message)); }); closeModalBtn.addEventListener('click', () => { pointModal.classList.add('hidden'); }); openSourceFormBtn.addEventListener('click', () => { resetSourceForm(); sourceModal.classList.remove('hidden'); }); closeSourceModalBtn.addEventListener('click', () => { sourceModal.classList.add('hidden'); }); if (openApiDocBtn) { openApiDocBtn.addEventListener('click', () => { openApiDocDrawer().catch((err) => setStatus(err.message)); }); } if (closeApiDocBtn) { closeApiDocBtn.addEventListener('click', closeApiDocDrawer); } if (apiDocDrawer) { apiDocDrawer.addEventListener('click', (event) => { if (event.target === apiDocDrawer) { closeApiDocDrawer(); } }); } document.addEventListener('keydown', (event) => { if (apiDocDrawer && event.key === 'Escape' && !apiDocDrawer.classList.contains('hidden')) { closeApiDocDrawer(); } }); refreshChartBtn.addEventListener('click', () => { loadPointHistory().catch((err) => setStatus(err.message)); }); prevPointsBtn.addEventListener('click', () => { if (state.pointsPage > 1) { state.pointsPage -= 1; loadPoints().catch((err) => setStatus(err.message)); } }); nextPointsBtn.addEventListener('click', () => { const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize)); if (state.pointsPage < totalPages) { state.pointsPage += 1; loadPoints().catch((err) => setStatus(err.message)); } }); loadSources().catch((err) => setStatus(err.message)); loadPoints().catch((err) => setStatus(err.message)); renderChart(); startLogs(); startPointSocket();