500 lines
15 KiB
JavaScript
500 lines
15 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,
|
|
};
|
|
|
|
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 = `<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');
|
|
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 = '<div class="muted">请选择 Source</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() {
|
|
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 = `<strong>${point.name}</strong>`;
|
|
|
|
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();
|