plc_control/web/app.js

517 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 reconnectBtn = document.createElement('button');
reconnectBtn.textContent = '重连';
reconnectBtn.className = 'secondary';
reconnectBtn.onclick = async (e) => {
e.stopPropagation();
await reconnectSource(source.id, source.name);
};
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(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('加载 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;
renderSources();
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 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 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');
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); };
tdAction.appendChild(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
}
};
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();