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();