From 920e37f759f5fd788331b37c124dc2f608a621c1 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 20 Mar 2026 10:54:20 +0800 Subject: [PATCH] feat(web): add inline point chart panel --- src/connection.rs | 15 +++ src/handler/point.rs | 60 ++++++++++ src/main.rs | 1 + web/app.js | 253 ++++++++++++++++++++++++++++++++++++++----- web/index.html | 19 +++- web/styles.css | 52 +++++++-- 6 files changed, 362 insertions(+), 38 deletions(-) diff --git a/src/connection.rs b/src/connection.rs index 27a4f9a..5463bd5 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -309,6 +309,21 @@ impl ConnectionManager { self.point_monitor_data.read().await } + pub async fn get_point_history(&self, point_id: Uuid, limit: usize) -> Vec { + if limit == 0 { + return Vec::new(); + } + + let history_data = self.point_history_data.read().await; + history_data + .get(&point_id) + .map(|ring| { + let skip = ring.len().saturating_sub(limit); + ring.iter().skip(skip).cloned().collect() + }) + .unwrap_or_default() + } + async fn start_heartbeat_task(&self, source_id: Uuid) { let manager = self.clone(); diff --git a/src/handler/point.rs b/src/handler/point.rs index 4146e4b..739175d 100644 --- a/src/handler/point.rs +++ b/src/handler/point.rs @@ -27,6 +27,21 @@ pub struct PointWithMonitor { pub point_monitor: Option, } +#[derive(Deserialize, Validate)] +pub struct GetPointHistoryQuery { + pub limit: Option, +} + +#[derive(Serialize)] +pub struct PointHistoryItem { + #[serde(serialize_with = "crate::util::datetime::option_utc_to_local_str")] + pub timestamp: Option>, + pub quality: crate::telemetry::PointQuality, + pub value: Option, + pub value_text: Option, + pub value_number: Option, +} + pub async fn get_point_list( State(state): State, Query(query): Query, @@ -75,6 +90,40 @@ pub async fn get_point( Ok(Json(point)) } +pub async fn get_point_history( + State(state): State, + Path(point_id): Path, + Query(query): Query, +) -> Result { + let pool = &state.pool; + let point = crate::service::get_point_by_id(pool, point_id).await?; + if point.is_none() { + return Err(ApiErr::NotFound("Point not found".to_string(), None)); + } + + let limit = query.limit.unwrap_or(120).clamp(1, 1000); + let history = state + .connection_manager + .get_point_history(point_id, limit) + .await; + + let items: Vec = history + .into_iter() + .map(|item| { + let value_number = monitor_value_to_number(&item); + PointHistoryItem { + timestamp: item.timestamp, + quality: item.quality, + value_number, + value: item.value, + value_text: item.value_text, + } + }) + .collect(); + + Ok(Json(items)) +} + /// Request payload for updating editable point fields. #[derive(Deserialize, Validate)] @@ -463,3 +512,14 @@ pub async fn batch_set_point_value( .map_err(|e| ApiErr::Internal(e, None))?; Ok(Json(result)) } + +fn monitor_value_to_number(item: &crate::telemetry::PointMonitorInfo) -> Option { + match item.value.as_ref()? { + crate::telemetry::DataValue::Int(v) => Some(*v as f64), + crate::telemetry::DataValue::UInt(v) => Some(*v as f64), + crate::telemetry::DataValue::Float(v) => Some(*v), + crate::telemetry::DataValue::Bool(v) => Some(if *v { 1.0 } else { 0.0 }), + crate::telemetry::DataValue::Text(v) => v.parse::().ok(), + _ => None, + } +} diff --git a/src/main.rs b/src/main.rs index 524922e..d3e0ba0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -139,6 +139,7 @@ fn build_router(state: AppState) -> Router { axum::routing::post(handler::point::batch_create_points) .delete(handler::point::batch_delete_points), ) + .route("/api/point/{point_id}/history", get(handler::point::get_point_history)) .route("/api/point/{point_id}", get(handler::point::get_point).put(handler::point::update_point).delete(handler::point::delete_point)) .route("/api/point/batch/set-tags", put(handler::point::batch_set_point_tags)) .route("/api/tag", get(handler::tag::get_tag_list).post(handler::tag::create_tag)) diff --git a/web/app.js b/web/app.js index 45658de..89e29f6 100644 --- a/web/app.js +++ b/web/app.js @@ -9,6 +9,9 @@ const state = { pointsPage: 1, pointsPageSize: 100, pointsTotal: 0, + chartPointId: null, + chartPointName: '', + chartData: [], }; const el = (id) => document.getElementById(id); @@ -36,6 +39,10 @@ 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; @@ -64,7 +71,9 @@ function renderSources() { if (state.selectedSourceId === source.id) { item.classList.add('selected'); } - item.onclick = () => selectSource(source.id); + item.onclick = () => { + selectSource(source.id).catch((err) => setStatus(err.message)); + }; const statusBadge = document.createElement('span'); statusBadge.className = `badge ${source.is_connected ? '' : 'offline'}`; @@ -86,10 +95,12 @@ function renderSources() { selectPointsBtn.textContent = '选入 Points'; selectPointsBtn.onclick = (e) => { e.stopPropagation(); - selectSource(source.id).then(() => { - pointModal.classList.remove('hidden'); - loadTree(); - }); + selectSource(source.id) + .then(() => { + pointModal.classList.remove('hidden'); + return loadTree(); + }) + .catch((err) => setStatus(err.message)); }; const editBtn = document.createElement('button'); @@ -105,7 +116,11 @@ function renderSources() { reconnectBtn.className = 'secondary'; reconnectBtn.onclick = async (e) => { e.stopPropagation(); - await reconnectSource(source.id, source.name); + try { + await reconnectSource(source.id, source.name); + } catch (err) { + setStatus(err.message); + } }; const deleteBtn = document.createElement('button'); @@ -113,7 +128,7 @@ function renderSources() { deleteBtn.className = 'danger'; deleteBtn.onclick = (e) => { e.stopPropagation(); - deleteSource(source.id); + deleteSource(source.id).catch((err) => setStatus(err.message)); }; actionRow.appendChild(selectPointsBtn); @@ -153,7 +168,7 @@ function resetSourceForm() { } async function loadSources() { - setStatus('加载 Source...'); + setStatus('加载数据源...'); const data = await apiFetch('/api/source'); state.sources = data || []; renderSources(); @@ -172,7 +187,7 @@ async function selectSource(sourceId) { async function loadTree() { if (!state.selectedSourceId) { - nodeTree.innerHTML = '
请选择 Source
'; + nodeTree.innerHTML = '
请选择数据源
'; return; } setStatus('加载节点树...'); @@ -191,6 +206,7 @@ function renderNode(node) { 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); @@ -221,8 +237,7 @@ function toggleNode(node, checked) { } function renderSelectedNodes() { - const count = state.selectedNodeIds.size; - selectedCount.textContent = `已选 ${count} 个节点`; + selectedCount.textContent = `已选中 ${state.selectedNodeIds.size} 个节点`; } async function createPoints() { @@ -268,6 +283,21 @@ async function reconnectSource(sourceId, sourceName) { 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' }); @@ -281,10 +311,12 @@ async function loadPoints() { 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 = '暂无 Points'; pointsPageInfo.textContent = `${state.pointsPage} / 1`; @@ -293,12 +325,19 @@ async function loadPoints() { 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 = `
${point.name}
${point.node_id}
`; @@ -323,19 +362,24 @@ async function loadPoints() { 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); + 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; @@ -350,6 +394,7 @@ async function saveSource(event) { endpoint: sourceEndpoint.value.trim(), enabled: sourceEnabled.checked, }; + if (!payload.name || !payload.endpoint) return; const id = sourceIdInput.value; @@ -394,7 +439,7 @@ function startPointSocket() { handlePointUpdate(payload.data); } } catch { - // ignore + // ignore invalid payloads } }; ws.onclose = () => { @@ -404,14 +449,19 @@ function startPointSocket() { 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 || ''; + 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) { @@ -481,11 +531,156 @@ function parseLogLine(line) { } } -sourceForm.addEventListener('submit', saveSource); +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); -refreshTreeBtn.addEventListener('click', loadTree); -createPointsBtn.addEventListener('click', createPoints); +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'); }); @@ -496,21 +691,25 @@ openSourceFormBtn.addEventListener('click', () => { 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(); + 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(); + loadPoints().catch((err) => setStatus(err.message)); } }); loadSources().catch((err) => setStatus(err.message)); loadPoints().catch((err) => setStatus(err.message)); +renderChart(); startLogs(); startPointSocket(); diff --git a/web/index.html b/web/index.html index 462b0ab..47d5ed0 100644 --- a/web/index.html +++ b/web/index.html @@ -46,19 +46,30 @@ -
+

实时日志

+ +
+
+

测点曲线

+ +
+
+
点击上方点位表中的某一行查看曲线
+ +
+