feat: add page
This commit is contained in:
parent
1374abe550
commit
efed6aa816
File diff suppressed because it is too large
Load Diff
|
|
@ -9,7 +9,7 @@ tokio = { version = "1.49", features = ["full"] }
|
|||
|
||||
# Web framework
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
|
||||
|
|
@ -43,3 +43,8 @@ validator = { version = "0.20", features = ["derive"] }
|
|||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
tray-icon = "0.15"
|
||||
winit = "0.30"
|
||||
webbrowser = "0.8"
|
||||
|
|
|
|||
|
|
@ -48,7 +48,10 @@ impl EventManager {
|
|||
}
|
||||
}
|
||||
ReloadEvent::SourceUpdate { source_id } => {
|
||||
tracing::info!("SourceUpdate event for {}: not implemented yet", source_id);
|
||||
tracing::info!("Processing SourceUpdate event for {}", source_id);
|
||||
if let Err(e) = connection_manager_clone.reconnect(&pool, source_id).await {
|
||||
tracing::error!("Failed to reconnect source {}: {}", source_id, e);
|
||||
}
|
||||
}
|
||||
ReloadEvent::SourceDelete { source_id } => {
|
||||
tracing::info!("Processing SourceDelete event for {}", source_id);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use crate::util::response::ApiErr;
|
|||
|
||||
use crate::{AppState, model::{Node, Source}};
|
||||
use anyhow::{Context};
|
||||
use sqlx::QueryBuilder;
|
||||
|
||||
// 树节点结构体
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
|
|
@ -200,6 +201,81 @@ pub async fn create_source(
|
|||
Ok((StatusCode::CREATED, Json(CreateSourceRes { id: new_id })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct UpdateSourceReq {
|
||||
pub name: Option<String>,
|
||||
pub endpoint: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub security_policy: Option<String>,
|
||||
pub security_mode: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_source(
|
||||
State(state): State<AppState>,
|
||||
Path(source_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateSourceReq>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
payload.validate()?;
|
||||
|
||||
if payload.name.is_none()
|
||||
&& payload.endpoint.is_none()
|
||||
&& payload.enabled.is_none()
|
||||
&& payload.security_policy.is_none()
|
||||
&& payload.security_mode.is_none()
|
||||
&& payload.username.is_none()
|
||||
&& payload.password.is_none()
|
||||
{
|
||||
return Ok(Json(serde_json::json!({"ok_msg": "No fields to update"})));
|
||||
}
|
||||
|
||||
let pool = &state.pool;
|
||||
|
||||
let exists = sqlx::query("SELECT 1 FROM source WHERE id = $1")
|
||||
.bind(source_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.is_some();
|
||||
if !exists {
|
||||
return Err(ApiErr::NotFound(format!("Source with id {} not found", source_id), None));
|
||||
}
|
||||
|
||||
let mut qb = QueryBuilder::new("UPDATE source SET ");
|
||||
let mut sep = qb.separated(", ");
|
||||
|
||||
if let Some(name) = &payload.name {
|
||||
sep.push("name = ").push_bind(name);
|
||||
}
|
||||
if let Some(endpoint) = &payload.endpoint {
|
||||
sep.push("endpoint = ").push_bind(endpoint);
|
||||
}
|
||||
if let Some(enabled) = payload.enabled {
|
||||
sep.push("enabled = ").push_bind(enabled);
|
||||
}
|
||||
if let Some(security_policy) = &payload.security_policy {
|
||||
sep.push("security_policy = ").push_bind(security_policy);
|
||||
}
|
||||
if let Some(security_mode) = &payload.security_mode {
|
||||
sep.push("security_mode = ").push_bind(security_mode);
|
||||
}
|
||||
if let Some(username) = &payload.username {
|
||||
sep.push("username = ").push_bind(username);
|
||||
}
|
||||
if let Some(password) = &payload.password {
|
||||
sep.push("password = ").push_bind(password);
|
||||
}
|
||||
|
||||
sep.push("updated_at = NOW()");
|
||||
|
||||
qb.push(" WHERE id = ").push_bind(source_id);
|
||||
qb.build().execute(pool).await?;
|
||||
|
||||
let _ = state.event_manager.send(crate::event::ReloadEvent::SourceUpdate { source_id });
|
||||
|
||||
Ok(Json(serde_json::json!({"ok_msg": "Source updated successfully"})))
|
||||
}
|
||||
|
||||
pub async fn delete_source(
|
||||
State(state): State<AppState>,
|
||||
Path(source_id): Path<Uuid>,
|
||||
|
|
|
|||
115
src/main.rs
115
src/main.rs
|
|
@ -11,6 +11,7 @@ mod websocket;
|
|||
mod telemetry;
|
||||
use config::AppConfig;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::services::ServeDir;
|
||||
use db::init_database;
|
||||
use middleware::simple_logger;
|
||||
use connection::ConnectionManager;
|
||||
|
|
@ -20,6 +21,7 @@ use axum::{
|
|||
routing::{get, put},
|
||||
Router,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -93,13 +95,24 @@ async fn main() {
|
|||
tracing::info!("Starting server at http://{}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
|
||||
// comment fixed
|
||||
let shutdown_signal = async move{
|
||||
let ui_url = format!("http://{}:{}/ui", "localhost", config.server_port);
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
let shutdown_tx_ctrl = shutdown_tx.clone();
|
||||
let rt_handle = tokio::runtime::Handle::current();
|
||||
init_tray(ui_url, shutdown_tx.clone(), rt_handle);
|
||||
|
||||
let connection_manager_for_shutdown = connection_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to install Ctrl+C handler");
|
||||
let _ = shutdown_tx_ctrl.send(()).await;
|
||||
});
|
||||
|
||||
let shutdown_signal = async move{
|
||||
let _ = shutdown_rx.recv().await;
|
||||
tracing::info!("Received shutdown signal, closing all connections...");
|
||||
connection_manager.disconnect_all().await;
|
||||
connection_manager_for_shutdown.disconnect_all().await;
|
||||
tracing::info!("All connections closed");
|
||||
};
|
||||
|
||||
|
|
@ -112,7 +125,7 @@ async fn main() {
|
|||
fn build_router(state: AppState) -> Router {
|
||||
let all_route = Router::new()
|
||||
.route("/api/source", get(handler::source::get_source_list).post(handler::source::create_source))
|
||||
.route("/api/source/{source_id}", axum::routing::delete(handler::source::delete_source))
|
||||
.route("/api/source/{source_id}", axum::routing::delete(handler::source::delete_source).put(handler::source::update_source))
|
||||
.route("/api/source/{source_id}/browse", axum::routing::post(handler::source::browse_and_save_nodes))
|
||||
.route("/api/source/{source_id}/node-tree", get(handler::source::get_node_tree))
|
||||
.route("/api/point", get(handler::point::get_point_list))
|
||||
|
|
@ -134,6 +147,7 @@ fn build_router(state: AppState) -> Router {
|
|||
|
||||
Router::new()
|
||||
.merge(all_route)
|
||||
.nest_service("/ui", ServeDir::new("web").append_index_html_on_directories(true))
|
||||
.route("/ws/public", get(websocket::public_websocket_handler))
|
||||
.route("/ws/client/{client_id}", get(websocket::client_websocket_handler))
|
||||
.layer(axum::middleware::from_fn(simple_logger))
|
||||
|
|
@ -145,3 +159,96 @@ fn build_router(state: AppState) -> Router {
|
|||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn init_tray(ui_url: String, shutdown_tx: mpsc::Sender<()>, rt_handle: tokio::runtime::Handle) {
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = tray::run_tray(ui_url, shutdown_tx, rt_handle) {
|
||||
tracing::warn!("Tray init failed: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn init_tray(_ui_url: String, _shutdown_tx: mpsc::Sender<()>, _rt_handle: tokio::runtime::Handle) {}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod tray {
|
||||
use std::error::Error;
|
||||
use tokio::sync::mpsc;
|
||||
use tray_icon::{
|
||||
menu::{Menu, MenuEvent, MenuItem},
|
||||
Icon, TrayIconBuilder,
|
||||
};
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||
use winit::platform::windows::EventLoopBuilderExtWindows;
|
||||
|
||||
pub fn run_tray(
|
||||
ui_url: String,
|
||||
shutdown_tx: mpsc::Sender<()>,
|
||||
rt_handle: tokio::runtime::Handle,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let mut builder = EventLoop::builder();
|
||||
builder.with_any_thread(true);
|
||||
let event_loop = builder.build()?;
|
||||
|
||||
let menu = Menu::new();
|
||||
let open_item = MenuItem::new("Open UI", true, None);
|
||||
let exit_item = MenuItem::new("Exit", true, None);
|
||||
menu.append(&open_item)?;
|
||||
menu.append(&exit_item)?;
|
||||
|
||||
let icon = Icon::from_rgba(vec![0, 120, 212, 255], 1, 1)?;
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.with_tooltip("PLC Control")
|
||||
.with_menu(Box::new(menu))
|
||||
.with_icon(icon)
|
||||
.build()?;
|
||||
|
||||
let menu_rx = MenuEvent::receiver();
|
||||
let mut app = TrayApp {
|
||||
menu_rx,
|
||||
open_id: open_item.id().clone(),
|
||||
exit_id: exit_item.id().clone(),
|
||||
ui_url,
|
||||
shutdown_tx,
|
||||
rt_handle,
|
||||
};
|
||||
|
||||
event_loop.run_app(&mut app).map_err(|e| e.into())
|
||||
}
|
||||
|
||||
struct TrayApp {
|
||||
menu_rx: &'static tray_icon::menu::MenuEventReceiver,
|
||||
open_id: tray_icon::menu::MenuId,
|
||||
exit_id: tray_icon::menu::MenuId,
|
||||
ui_url: String,
|
||||
shutdown_tx: mpsc::Sender<()>,
|
||||
rt_handle: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for TrayApp {
|
||||
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {}
|
||||
|
||||
fn window_event(
|
||||
&mut self,
|
||||
_event_loop: &ActiveEventLoop,
|
||||
_window_id: winit::window::WindowId,
|
||||
_event: winit::event::WindowEvent,
|
||||
) {}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
event_loop.set_control_flow(ControlFlow::Wait);
|
||||
while let Ok(menu_event) = self.menu_rx.try_recv() {
|
||||
if menu_event.id == self.open_id {
|
||||
let _ = webbrowser::open(&self.ui_url);
|
||||
}
|
||||
if menu_event.id == self.exit_id {
|
||||
let _ = self.rt_handle.block_on(self.shutdown_tx.send(()));
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,499 @@
|
|||
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 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(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;
|
||||
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 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.textContent = '暂无 Points';
|
||||
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 box = document.createElement('div');
|
||||
box.className = 'list-item';
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
row.innerHTML = `<strong>${point.name}</strong>`;
|
||||
|
||||
const quality = monitor ? (monitor.quality || 'unknown').toLowerCase() : 'unknown';
|
||||
const qualityBadge = document.createElement('span');
|
||||
qualityBadge.className = `badge quality-${quality}`;
|
||||
qualityBadge.textContent = quality.toUpperCase();
|
||||
row.appendChild(qualityBadge);
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'danger';
|
||||
deleteBtn.textContent = '删除';
|
||||
deleteBtn.onclick = () => deletePoint(point.id);
|
||||
row.appendChild(deleteBtn);
|
||||
|
||||
const valueRow = document.createElement('div');
|
||||
valueRow.className = 'row';
|
||||
const value = document.createElement('div');
|
||||
value.className = 'value';
|
||||
value.textContent = formatValue(monitor);
|
||||
const ts = document.createElement('div');
|
||||
ts.className = 'muted';
|
||||
ts.textContent = monitor && monitor.timestamp ? monitor.timestamp : '';
|
||||
valueRow.appendChild(value);
|
||||
valueRow.appendChild(ts);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'muted';
|
||||
meta.textContent = `${point.id} / node: ${point.node_id}`;
|
||||
|
||||
box.appendChild(row);
|
||||
box.appendChild(valueRow);
|
||||
box.appendChild(meta);
|
||||
pointList.appendChild(box);
|
||||
|
||||
state.pointEls.set(point.id, { box, 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();
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PLC Control UI</title>
|
||||
<link rel="stylesheet" href="/ui/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="title">PLC Control</div>
|
||||
<div class="status" id="statusText">Ready</div>
|
||||
</header>
|
||||
|
||||
<main class="grid">
|
||||
<section class="panel top-left">
|
||||
<div class="row">
|
||||
<h2>Sources</h2>
|
||||
<button id="openSourceForm">新增 Source</button>
|
||||
</div>
|
||||
<div class="list" id="sourceList"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel top-right">
|
||||
<h2>Points</h2>
|
||||
<div class="list" id="pointList"></div>
|
||||
<div class="pager">
|
||||
<button class="secondary" id="prevPoints">上一页</button>
|
||||
<div class="muted" id="pointsPageInfo">第 1 页</div>
|
||||
<button class="secondary" id="nextPoints">下一页</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel bottom">
|
||||
<h2>实时日志</h2>
|
||||
<div class="log" id="logView"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="modal hidden" id="pointModal">
|
||||
<div class="modal-content">
|
||||
<div class="row">
|
||||
<h3>选择节点创建 Points</h3>
|
||||
<button class="secondary" id="closeModal">关闭</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button id="browseNodes">浏览并同步节点</button>
|
||||
<button id="refreshTree">刷新树</button>
|
||||
</div>
|
||||
<div class="tree" id="nodeTree"></div>
|
||||
<div class="actions">
|
||||
<div class="muted" id="selectedCount">已选 0 个节点</div>
|
||||
<button id="createPoints">创建 Points</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal hidden" id="sourceModal">
|
||||
<div class="modal-content">
|
||||
<div class="row">
|
||||
<h3>Source 配置</h3>
|
||||
<button class="secondary" id="closeSourceModal">关闭</button>
|
||||
</div>
|
||||
<form id="sourceForm" class="form">
|
||||
<input type="hidden" id="sourceId" />
|
||||
<label>
|
||||
名称
|
||||
<input id="sourceName" required />
|
||||
</label>
|
||||
<label>
|
||||
Endpoint
|
||||
<input id="sourceEndpoint" placeholder="opc.tcp://host:port" required />
|
||||
</label>
|
||||
<label class="row">
|
||||
<input type="checkbox" id="sourceEnabled" checked />
|
||||
<span>启用</span>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit" id="sourceSubmit">保存</button>
|
||||
<button type="button" id="sourceReset">清空</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/ui/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
:root {
|
||||
--bg: #f5f7fb;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f0f3f8;
|
||||
--text: #1f2a37;
|
||||
--muted: #6b7a90;
|
||||
--accent: #1f6feb;
|
||||
--accent-2: #22c55e;
|
||||
--danger: #ef4444;
|
||||
--border: #d5dbe6;
|
||||
--shadow: rgba(12, 22, 34, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background: radial-gradient(circle at top, #ffffff 0%, #eef2f8 55%);
|
||||
font-size: 12px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(90deg, #ffffff, #f2f5fa);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 3fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel.top-left {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
.panel.top-right {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
.panel.bottom {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 10px 30px var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 40vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h2, h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.form input[type="text"],
|
||||
.form input[type="url"],
|
||||
.form input[type="password"],
|
||||
.form input[type="number"],
|
||||
.form input:not([type]) {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form .row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--danger);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow-y: auto;
|
||||
max-height: none;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-item.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px rgba(31, 111, 235, 0.2);
|
||||
}
|
||||
|
||||
.list-item .row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(31, 111, 235, 0.14);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge.offline {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.badge.quality-good { background: rgba(34, 197, 94, 0.16); color: #16a34a; }
|
||||
.badge.quality-bad { background: rgba(239, 68, 68, 0.16); color: #dc2626; }
|
||||
.badge.quality-uncertain { background: rgba(245, 158, 11, 0.16); color: #d97706; }
|
||||
.badge.quality-unknown { background: rgba(148, 163, 184, 0.2); color: #64748b; }
|
||||
|
||||
.value {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tree {
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.tree details {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.tree summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tree summary.has-children::before {
|
||||
content: "▸";
|
||||
color: var(--muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.tree details[open] > summary.has-children::before {
|
||||
content: "▾";
|
||||
}
|
||||
|
||||
.tree summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tree .node-label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.subpanel {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log {
|
||||
background: #0b1117;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px;
|
||||
font-family: "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 11px;
|
||||
color: #c9d7e8;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-line .level {
|
||||
font-weight: 700;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.log-line .message {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.log-line .muted {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.log-line.level-trace .level { color: #94a3b8; }
|
||||
.log-line.level-debug .level { color: #38bdf8; }
|
||||
.log-line.level-info .level { color: #22c55e; }
|
||||
.log-line.level-warn .level { color: #f59e0b; }
|
||||
.log-line.level-error .level { color: #ef4444; }
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5, 11, 20, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: min(920px, 96vw);
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 16px;
|
||||
box-shadow: 0 20px 60px var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-content .tree {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
background: var(--panel-2);
|
||||
max-height: 55vh;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.panel {
|
||||
height: auto;
|
||||
}
|
||||
.list {
|
||||
max-height: none;
|
||||
}
|
||||
.log {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue