feat(web): add inline point chart panel

This commit is contained in:
caoqianming 2026-03-20 10:54:20 +08:00
parent bf548161a6
commit 920e37f759
6 changed files with 362 additions and 38 deletions

View File

@ -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<PointMonitorInfo> {
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();

View File

@ -27,6 +27,21 @@ pub struct PointWithMonitor {
pub point_monitor: Option<crate::telemetry::PointMonitorInfo>,
}
#[derive(Deserialize, Validate)]
pub struct GetPointHistoryQuery {
pub limit: Option<usize>,
}
#[derive(Serialize)]
pub struct PointHistoryItem {
#[serde(serialize_with = "crate::util::datetime::option_utc_to_local_str")]
pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
pub quality: crate::telemetry::PointQuality,
pub value: Option<crate::telemetry::DataValue>,
pub value_text: Option<String>,
pub value_number: Option<f64>,
}
pub async fn get_point_list(
State(state): State<AppState>,
Query(query): Query<GetPointListQuery>,
@ -75,6 +90,40 @@ pub async fn get_point(
Ok(Json(point))
}
pub async fn get_point_history(
State(state): State<AppState>,
Path(point_id): Path<Uuid>,
Query(query): Query<GetPointHistoryQuery>,
) -> Result<impl IntoResponse, ApiErr> {
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<PointHistoryItem> = 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<f64> {
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::<f64>().ok(),
_ => None,
}
}

View File

@ -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))

View File

@ -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(() => {
selectSource(source.id)
.then(() => {
pointModal.classList.remove('hidden');
loadTree();
});
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();
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 = '<div class="muted">请选择 Source</div>';
nodeTree.innerHTML = '<div class="muted">请选择数据源</div>';
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 = '<tr><td colspan="5" class="empty-state">暂无 Points</td></tr>';
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 = `<div class="point-name">${point.name}</div><div class="point-id">${point.node_id}</div>`;
@ -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,9 +449,9 @@ function startPointSocket() {
function handlePointUpdate(data) {
if (!data || !data.point_id) return;
const entry = state.pointEls.get(data.point_id);
if (!entry) 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();
@ -414,6 +459,11 @@ function handlePointUpdate(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;
@ -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();

View File

@ -46,19 +46,30 @@
</div>
</section>
<section class="panel bottom">
<section class="panel bottom-left">
<div class="panel-head">
<h2>实时日志</h2>
</div>
<div class="log" id="logView"></div>
</section>
<section class="panel bottom-right">
<div class="panel-head">
<h2 id="chartTitle">测点曲线</h2>
<button class="secondary" id="refreshChart">刷新</button>
</div>
<div class="chart-panel">
<div class="muted" id="chartSummary">点击上方点位表中的某一行查看曲线</div>
<canvas id="chartCanvas" class="chart-canvas" width="820" height="320"></canvas>
</div>
</section>
</main>
<div class="modal hidden" id="pointModal">
<div class="modal-content">
<div class="modal-head">
<h3>选择节点创建 Points</h3>
<button class="secondary" id="closeModal"></button>
<button class="secondary" id="closeModal">×</button>
</div>
<div class="toolbar">
<button id="browseNodes">浏览并同步节点</button>
@ -66,7 +77,7 @@
</div>
<div class="tree" id="nodeTree"></div>
<div class="modal-foot">
<div class="muted" id="selectedCount">已选 0 个节点</div>
<div class="muted" id="selectedCount">已选 0 个节点</div>
<button id="createPoints">创建 Points</button>
</div>
</div>
@ -76,7 +87,7 @@
<div class="modal-content modal-sm">
<div class="modal-head">
<h3>Source 配置</h3>
<button class="secondary" id="closeSourceModal"></button>
<button class="secondary" id="closeSourceModal">×</button>
</div>
<form id="sourceForm" class="form">
<input type="hidden" id="sourceId" />

View File

@ -57,17 +57,23 @@ body {
.grid {
display: grid;
grid-template-columns: 340px 1fr;
grid-template-rows: 1fr 1fr;
grid-template-columns: 340px minmax(0, 3fr) minmax(0, 2fr);
grid-template-rows: 1fr 380px;
gap: 1px;
height: calc(100vh - var(--topbar-h));
}
.panel.top-left { grid-column: 1; grid-row: 1; }
.panel.top-right { grid-column: 2; grid-row: 1; }
.panel.top-right { grid-column: 2 / 4; grid-row: 1; }
.panel.bottom {
grid-column: 1 / -1;
.panel.bottom-left {
grid-column: 1 / 3;
grid-row: 2;
min-height: 0;
}
.panel.bottom-right {
grid-column: 3;
grid-row: 2;
min-height: 0;
}
@ -265,6 +271,10 @@ button.danger:hover { background: var(--danger-hover); }
background: var(--accent-bg);
}
.data-table tbody tr.active {
background: rgba(37, 99, 235, 0.1);
}
.point-name {
font-weight: 500;
color: var(--text);
@ -496,6 +506,33 @@ button.danger:hover { background: var(--danger-hover); }
align-items: center;
}
.chart-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.chart-panel {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
gap: 8px;
padding: 8px 10px 10px;
}
.chart-canvas {
width: 100%;
height: auto;
min-height: 320px;
flex: 1 1 auto;
border: 1px solid var(--border);
background:
linear-gradient(to bottom, rgba(37, 99, 235, 0.03), rgba(37, 99, 235, 0)),
var(--surface);
}
.modal-content .tree {
flex: 1;
border: 1px solid var(--border);
@ -517,11 +554,12 @@ button.danger:hover { background: var(--danger-hover); }
@media (max-width: 900px) {
.grid {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
grid-template-rows: auto 1fr auto auto;
height: auto;
}
body { height: auto; overflow: auto; }
.panel.top-left { min-height: 200px; }
.panel.top-right { min-height: 300px; }
.panel.bottom { max-height: none; min-height: 200px; }
.panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
.panel.bottom-right { grid-column: 1; grid-row: 4; min-height: 320px; }
}