plc_control/web/app.js

716 lines
22 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,
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');
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).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');
});
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();