946 lines
27 KiB
JavaScript
946 lines
27 KiB
JavaScript
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, '<code>$1</code>');
|
||
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||
html = html.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
|
||
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(`<p>${renderInlineMarkdown(paragraph.join(' '))}</p>`);
|
||
paragraph = [];
|
||
}
|
||
|
||
function flushList() {
|
||
if (!listItems.length) return;
|
||
const tag = listType || 'ul';
|
||
blocks.push(`<${tag}>${listItems.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join('')}</${tag}>`);
|
||
listItems = [];
|
||
listType = null;
|
||
}
|
||
|
||
function flushCodeFence() {
|
||
if (!codeFence) return;
|
||
blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
|
||
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('<hr>');
|
||
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(`<h${level} id="${id}">${renderInlineMarkdown(title)}</h${level}>`);
|
||
continue;
|
||
}
|
||
|
||
const quote = trimmed.match(/^>\s?(.*)$/);
|
||
if (quote) {
|
||
flushParagraph();
|
||
flushList();
|
||
blocks.push(`<blockquote>${renderInlineMarkdown(quote[1])}</blockquote>`);
|
||
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 = '<div class="muted">无目录</div>';
|
||
return;
|
||
}
|
||
|
||
apiDocToc.innerHTML = items
|
||
.map((item) => `<a class="doc-toc-item level-${item.level}" href="#${item.id}">${escapeHtml(item.title)}</a>`)
|
||
.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 = '<p class="muted">加载中...</p>';
|
||
if (apiDocToc) {
|
||
apiDocToc.innerHTML = '<div class="muted">加载中...</div>';
|
||
}
|
||
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 = `<p style="color: var(--danger);">加载 API.md 失败</p><pre><code>${escapeHtml(err.message || 'unknown error')}</code></pre>`;
|
||
if (apiDocToc) {
|
||
apiDocToc.innerHTML = '<div class="muted">目录加载失败</div>';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 = `<strong>${source.name}</strong>`;
|
||
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 = '<div class="muted">请选择数据源</div>';
|
||
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 = '<tr><td colspan="5" class="empty-state">暂无 Points</td></tr>';
|
||
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 = `<div class="point-name">${point.name}</div><div class="point-id">${point.node_id}</div>`;
|
||
|
||
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();
|