feat(web): add inline point chart panel
This commit is contained in:
parent
bf548161a6
commit
920e37f759
|
|
@ -309,6 +309,21 @@ impl ConnectionManager {
|
||||||
self.point_monitor_data.read().await
|
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) {
|
async fn start_heartbeat_task(&self, source_id: Uuid) {
|
||||||
let manager = self.clone();
|
let manager = self.clone();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,21 @@ pub struct PointWithMonitor {
|
||||||
pub point_monitor: Option<crate::telemetry::PointMonitorInfo>,
|
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(
|
pub async fn get_point_list(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<GetPointListQuery>,
|
Query(query): Query<GetPointListQuery>,
|
||||||
|
|
@ -75,6 +90,40 @@ pub async fn get_point(
|
||||||
Ok(Json(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.
|
/// Request payload for updating editable point fields.
|
||||||
#[derive(Deserialize, Validate)]
|
#[derive(Deserialize, Validate)]
|
||||||
|
|
@ -463,3 +512,14 @@ pub async fn batch_set_point_value(
|
||||||
.map_err(|e| ApiErr::Internal(e, None))?;
|
.map_err(|e| ApiErr::Internal(e, None))?;
|
||||||
Ok(Json(result))
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ fn build_router(state: AppState) -> Router {
|
||||||
axum::routing::post(handler::point::batch_create_points)
|
axum::routing::post(handler::point::batch_create_points)
|
||||||
.delete(handler::point::batch_delete_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/{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/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))
|
.route("/api/tag", get(handler::tag::get_tag_list).post(handler::tag::create_tag))
|
||||||
|
|
|
||||||
253
web/app.js
253
web/app.js
|
|
@ -9,6 +9,9 @@ const state = {
|
||||||
pointsPage: 1,
|
pointsPage: 1,
|
||||||
pointsPageSize: 100,
|
pointsPageSize: 100,
|
||||||
pointsTotal: 0,
|
pointsTotal: 0,
|
||||||
|
chartPointId: null,
|
||||||
|
chartPointName: '',
|
||||||
|
chartData: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = (id) => document.getElementById(id);
|
const el = (id) => document.getElementById(id);
|
||||||
|
|
@ -36,6 +39,10 @@ const pointsPageInfo = el('pointsPageInfo');
|
||||||
const openSourceFormBtn = el('openSourceForm');
|
const openSourceFormBtn = el('openSourceForm');
|
||||||
const sourceModal = el('sourceModal');
|
const sourceModal = el('sourceModal');
|
||||||
const closeSourceModalBtn = el('closeSourceModal');
|
const closeSourceModalBtn = el('closeSourceModal');
|
||||||
|
const chartCanvas = el('chartCanvas');
|
||||||
|
const chartTitle = el('chartTitle');
|
||||||
|
const chartSummary = el('chartSummary');
|
||||||
|
const refreshChartBtn = el('refreshChart');
|
||||||
|
|
||||||
function setStatus(text) {
|
function setStatus(text) {
|
||||||
statusText.textContent = text;
|
statusText.textContent = text;
|
||||||
|
|
@ -64,7 +71,9 @@ function renderSources() {
|
||||||
if (state.selectedSourceId === source.id) {
|
if (state.selectedSourceId === source.id) {
|
||||||
item.classList.add('selected');
|
item.classList.add('selected');
|
||||||
}
|
}
|
||||||
item.onclick = () => selectSource(source.id);
|
item.onclick = () => {
|
||||||
|
selectSource(source.id).catch((err) => setStatus(err.message));
|
||||||
|
};
|
||||||
|
|
||||||
const statusBadge = document.createElement('span');
|
const statusBadge = document.createElement('span');
|
||||||
statusBadge.className = `badge ${source.is_connected ? '' : 'offline'}`;
|
statusBadge.className = `badge ${source.is_connected ? '' : 'offline'}`;
|
||||||
|
|
@ -86,10 +95,12 @@ function renderSources() {
|
||||||
selectPointsBtn.textContent = '选入 Points';
|
selectPointsBtn.textContent = '选入 Points';
|
||||||
selectPointsBtn.onclick = (e) => {
|
selectPointsBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
selectSource(source.id).then(() => {
|
selectSource(source.id)
|
||||||
pointModal.classList.remove('hidden');
|
.then(() => {
|
||||||
loadTree();
|
pointModal.classList.remove('hidden');
|
||||||
});
|
return loadTree();
|
||||||
|
})
|
||||||
|
.catch((err) => setStatus(err.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
const editBtn = document.createElement('button');
|
const editBtn = document.createElement('button');
|
||||||
|
|
@ -105,7 +116,11 @@ function renderSources() {
|
||||||
reconnectBtn.className = 'secondary';
|
reconnectBtn.className = 'secondary';
|
||||||
reconnectBtn.onclick = async (e) => {
|
reconnectBtn.onclick = async (e) => {
|
||||||
e.stopPropagation();
|
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');
|
const deleteBtn = document.createElement('button');
|
||||||
|
|
@ -113,7 +128,7 @@ function renderSources() {
|
||||||
deleteBtn.className = 'danger';
|
deleteBtn.className = 'danger';
|
||||||
deleteBtn.onclick = (e) => {
|
deleteBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deleteSource(source.id);
|
deleteSource(source.id).catch((err) => setStatus(err.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
actionRow.appendChild(selectPointsBtn);
|
actionRow.appendChild(selectPointsBtn);
|
||||||
|
|
@ -153,7 +168,7 @@ function resetSourceForm() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSources() {
|
async function loadSources() {
|
||||||
setStatus('加载 Source...');
|
setStatus('加载数据源...');
|
||||||
const data = await apiFetch('/api/source');
|
const data = await apiFetch('/api/source');
|
||||||
state.sources = data || [];
|
state.sources = data || [];
|
||||||
renderSources();
|
renderSources();
|
||||||
|
|
@ -172,7 +187,7 @@ async function selectSource(sourceId) {
|
||||||
|
|
||||||
async function loadTree() {
|
async function loadTree() {
|
||||||
if (!state.selectedSourceId) {
|
if (!state.selectedSourceId) {
|
||||||
nodeTree.innerHTML = '<div class="muted">请选择 Source</div>';
|
nodeTree.innerHTML = '<div class="muted">请选择数据源</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus('加载节点树...');
|
setStatus('加载节点树...');
|
||||||
|
|
@ -191,6 +206,7 @@ function renderNode(node) {
|
||||||
if (node.children && node.children.length) {
|
if (node.children && node.children.length) {
|
||||||
summary.classList.add('has-children');
|
summary.classList.add('has-children');
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkbox = document.createElement('input');
|
const checkbox = document.createElement('input');
|
||||||
checkbox.type = 'checkbox';
|
checkbox.type = 'checkbox';
|
||||||
checkbox.checked = state.selectedNodeIds.has(node.id);
|
checkbox.checked = state.selectedNodeIds.has(node.id);
|
||||||
|
|
@ -221,8 +237,7 @@ function toggleNode(node, checked) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSelectedNodes() {
|
function renderSelectedNodes() {
|
||||||
const count = state.selectedNodeIds.size;
|
selectedCount.textContent = `已选中 ${state.selectedNodeIds.size} 个节点`;
|
||||||
selectedCount.textContent = `已选 ${count} 个节点`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPoints() {
|
async function createPoints() {
|
||||||
|
|
@ -268,6 +283,21 @@ async function reconnectSource(sourceId, sourceName) {
|
||||||
setStatus(`${sourceName || 'Source'} 重连完成`);
|
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) {
|
async function deletePoint(pointId) {
|
||||||
if (!confirm('确认删除该 Point?')) return;
|
if (!confirm('确认删除该 Point?')) return;
|
||||||
await apiFetch(`/api/point/${pointId}`, { method: 'DELETE' });
|
await apiFetch(`/api/point/${pointId}`, { method: 'DELETE' });
|
||||||
|
|
@ -281,10 +311,12 @@ async function loadPoints() {
|
||||||
const pageSize = state.pointsPageSize;
|
const pageSize = state.pointsPageSize;
|
||||||
const data = await apiFetch(`/api/point?page=${page}&page_size=${pageSize}${sourceQuery}`);
|
const data = await apiFetch(`/api/point?page=${page}&page_size=${pageSize}${sourceQuery}`);
|
||||||
const items = data && data.data ? data.data : [];
|
const items = data && data.data ? data.data : [];
|
||||||
|
|
||||||
state.pointsTotal = data && typeof data.total === 'number' ? data.total : items.length;
|
state.pointsTotal = data && typeof data.total === 'number' ? data.total : items.length;
|
||||||
state.points.clear();
|
state.points.clear();
|
||||||
state.pointEls.clear();
|
state.pointEls.clear();
|
||||||
pointList.innerHTML = '';
|
pointList.innerHTML = '';
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
pointList.innerHTML = '<tr><td colspan="5" class="empty-state">暂无 Points</td></tr>';
|
pointList.innerHTML = '<tr><td colspan="5" class="empty-state">暂无 Points</td></tr>';
|
||||||
pointsPageInfo.textContent = `${state.pointsPage} / 1`;
|
pointsPageInfo.textContent = `${state.pointsPage} / 1`;
|
||||||
|
|
@ -293,12 +325,19 @@ async function loadPoints() {
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const point = item.point || item;
|
const point = item.point || item;
|
||||||
const monitor = item.point_monitor || null;
|
const monitor = item.point_monitor || null;
|
||||||
state.points.set(point.id, { point, monitor });
|
state.points.set(point.id, { point, monitor });
|
||||||
|
|
||||||
const tr = document.createElement('tr');
|
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');
|
const tdName = document.createElement('td');
|
||||||
tdName.innerHTML = `<div class="point-name">${point.name}</div><div class="point-id">${point.node_id}</div>`;
|
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);
|
tdTime.appendChild(ts);
|
||||||
|
|
||||||
const tdAction = document.createElement('td');
|
const tdAction = document.createElement('td');
|
||||||
|
|
||||||
const deleteBtn = document.createElement('button');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.className = 'danger';
|
deleteBtn.className = 'danger';
|
||||||
deleteBtn.textContent = '×';
|
deleteBtn.textContent = '×';
|
||||||
deleteBtn.title = '删除';
|
deleteBtn.title = '删除';
|
||||||
deleteBtn.style.cssText = 'width:22px;height:22px;padding:0;font-size:14px;';
|
deleteBtn.style.cssText = 'width:22px;height:22px;padding:0;font-size:14px;';
|
||||||
deleteBtn.onclick = (e) => { e.stopPropagation(); deletePoint(point.id); };
|
deleteBtn.onclick = (e) => {
|
||||||
tdAction.appendChild(deleteBtn);
|
e.stopPropagation();
|
||||||
|
deletePoint(point.id).catch((err) => setStatus(err.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
tdAction.append(deleteBtn);
|
||||||
tr.append(tdName, tdValue, tdQuality, tdTime, tdAction);
|
tr.append(tdName, tdValue, tdQuality, tdTime, tdAction);
|
||||||
pointList.appendChild(tr);
|
pointList.appendChild(tr);
|
||||||
|
|
||||||
state.pointEls.set(point.id, { box: tr, value, qualityBadge, ts });
|
state.pointEls.set(point.id, { box: tr, value, qualityBadge, ts });
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
||||||
pointsPageInfo.textContent = `${state.pointsPage} / ${totalPages}`;
|
pointsPageInfo.textContent = `${state.pointsPage} / ${totalPages}`;
|
||||||
prevPointsBtn.disabled = state.pointsPage <= 1;
|
prevPointsBtn.disabled = state.pointsPage <= 1;
|
||||||
|
|
@ -350,6 +394,7 @@ async function saveSource(event) {
|
||||||
endpoint: sourceEndpoint.value.trim(),
|
endpoint: sourceEndpoint.value.trim(),
|
||||||
enabled: sourceEnabled.checked,
|
enabled: sourceEnabled.checked,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!payload.name || !payload.endpoint) return;
|
if (!payload.name || !payload.endpoint) return;
|
||||||
|
|
||||||
const id = sourceIdInput.value;
|
const id = sourceIdInput.value;
|
||||||
|
|
@ -394,7 +439,7 @@ function startPointSocket() {
|
||||||
handlePointUpdate(payload.data);
|
handlePointUpdate(payload.data);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore invalid payloads
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
|
@ -404,14 +449,19 @@ function startPointSocket() {
|
||||||
|
|
||||||
function handlePointUpdate(data) {
|
function handlePointUpdate(data) {
|
||||||
if (!data || !data.point_id) return;
|
if (!data || !data.point_id) return;
|
||||||
const entry = state.pointEls.get(data.point_id);
|
|
||||||
if (!entry) return;
|
|
||||||
|
|
||||||
const quality = (data.quality || 'unknown').toLowerCase();
|
const entry = state.pointEls.get(data.point_id);
|
||||||
entry.qualityBadge.className = `badge quality-${quality}`;
|
if (entry) {
|
||||||
entry.qualityBadge.textContent = quality.toUpperCase();
|
const quality = (data.quality || 'unknown').toLowerCase();
|
||||||
entry.value.textContent = formatValue(data);
|
entry.qualityBadge.className = `badge quality-${quality}`;
|
||||||
entry.ts.textContent = data.timestamp || '';
|
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) {
|
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);
|
sourceReset.addEventListener('click', resetSourceForm);
|
||||||
browseNodesBtn.addEventListener('click', browseNodes);
|
browseNodesBtn.addEventListener('click', () => {
|
||||||
refreshTreeBtn.addEventListener('click', loadTree);
|
browseNodes().catch((err) => setStatus(err.message));
|
||||||
createPointsBtn.addEventListener('click', createPoints);
|
});
|
||||||
|
refreshTreeBtn.addEventListener('click', () => {
|
||||||
|
loadTree().catch((err) => setStatus(err.message));
|
||||||
|
});
|
||||||
|
createPointsBtn.addEventListener('click', () => {
|
||||||
|
createPoints().catch((err) => setStatus(err.message));
|
||||||
|
});
|
||||||
closeModalBtn.addEventListener('click', () => {
|
closeModalBtn.addEventListener('click', () => {
|
||||||
pointModal.classList.add('hidden');
|
pointModal.classList.add('hidden');
|
||||||
});
|
});
|
||||||
|
|
@ -496,21 +691,25 @@ openSourceFormBtn.addEventListener('click', () => {
|
||||||
closeSourceModalBtn.addEventListener('click', () => {
|
closeSourceModalBtn.addEventListener('click', () => {
|
||||||
sourceModal.classList.add('hidden');
|
sourceModal.classList.add('hidden');
|
||||||
});
|
});
|
||||||
|
refreshChartBtn.addEventListener('click', () => {
|
||||||
|
loadPointHistory().catch((err) => setStatus(err.message));
|
||||||
|
});
|
||||||
prevPointsBtn.addEventListener('click', () => {
|
prevPointsBtn.addEventListener('click', () => {
|
||||||
if (state.pointsPage > 1) {
|
if (state.pointsPage > 1) {
|
||||||
state.pointsPage -= 1;
|
state.pointsPage -= 1;
|
||||||
loadPoints();
|
loadPoints().catch((err) => setStatus(err.message));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
nextPointsBtn.addEventListener('click', () => {
|
nextPointsBtn.addEventListener('click', () => {
|
||||||
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
||||||
if (state.pointsPage < totalPages) {
|
if (state.pointsPage < totalPages) {
|
||||||
state.pointsPage += 1;
|
state.pointsPage += 1;
|
||||||
loadPoints();
|
loadPoints().catch((err) => setStatus(err.message));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
loadSources().catch((err) => setStatus(err.message));
|
loadSources().catch((err) => setStatus(err.message));
|
||||||
loadPoints().catch((err) => setStatus(err.message));
|
loadPoints().catch((err) => setStatus(err.message));
|
||||||
|
renderChart();
|
||||||
startLogs();
|
startLogs();
|
||||||
startPointSocket();
|
startPointSocket();
|
||||||
|
|
|
||||||
|
|
@ -46,19 +46,30 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel bottom">
|
<section class="panel bottom-left">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>实时日志</h2>
|
<h2>实时日志</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="log" id="logView"></div>
|
<div class="log" id="logView"></div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
<div class="modal hidden" id="pointModal">
|
<div class="modal hidden" id="pointModal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
<h3>选择节点创建 Points</h3>
|
<h3>选择节点创建 Points</h3>
|
||||||
<button class="secondary" id="closeModal">✕</button>
|
<button class="secondary" id="closeModal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button id="browseNodes">浏览并同步节点</button>
|
<button id="browseNodes">浏览并同步节点</button>
|
||||||
|
|
@ -66,7 +77,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="tree" id="nodeTree"></div>
|
<div class="tree" id="nodeTree"></div>
|
||||||
<div class="modal-foot">
|
<div class="modal-foot">
|
||||||
<div class="muted" id="selectedCount">已选 0 个节点</div>
|
<div class="muted" id="selectedCount">已选中 0 个节点</div>
|
||||||
<button id="createPoints">创建 Points</button>
|
<button id="createPoints">创建 Points</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -76,7 +87,7 @@
|
||||||
<div class="modal-content modal-sm">
|
<div class="modal-content modal-sm">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
<h3>Source 配置</h3>
|
<h3>Source 配置</h3>
|
||||||
<button class="secondary" id="closeSourceModal">✕</button>
|
<button class="secondary" id="closeSourceModal">×</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="sourceForm" class="form">
|
<form id="sourceForm" class="form">
|
||||||
<input type="hidden" id="sourceId" />
|
<input type="hidden" id="sourceId" />
|
||||||
|
|
|
||||||
|
|
@ -57,17 +57,23 @@ body {
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 340px 1fr;
|
grid-template-columns: 340px minmax(0, 3fr) minmax(0, 2fr);
|
||||||
grid-template-rows: 1fr 1fr;
|
grid-template-rows: 1fr 380px;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
height: calc(100vh - var(--topbar-h));
|
height: calc(100vh - var(--topbar-h));
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel.top-left { grid-column: 1; grid-row: 1; }
|
.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 {
|
.panel.bottom-left {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / 3;
|
||||||
|
grid-row: 2;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.bottom-right {
|
||||||
|
grid-column: 3;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -265,6 +271,10 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
background: var(--accent-bg);
|
background: var(--accent-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr.active {
|
||||||
|
background: rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.point-name {
|
.point-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|
@ -496,6 +506,33 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
align-items: center;
|
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 {
|
.modal-content .tree {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
@ -517,11 +554,12 @@ button.danger:hover { background: var(--danger-hover); }
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.grid {
|
.grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
body { height: auto; overflow: auto; }
|
body { height: auto; overflow: auto; }
|
||||||
.panel.top-left { min-height: 200px; }
|
.panel.top-left { min-height: 200px; }
|
||||||
.panel.top-right { min-height: 300px; }
|
.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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue