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
|
# Web framework
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
tower-http = { version = "0.6", features = ["cors"] }
|
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
|
||||||
|
|
@ -43,3 +43,8 @@ validator = { version = "0.20", features = ["derive"] }
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
anyhow = "1.0"
|
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 } => {
|
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 } => {
|
ReloadEvent::SourceDelete { source_id } => {
|
||||||
tracing::info!("Processing SourceDelete event for {}", 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 crate::{AppState, model::{Node, Source}};
|
||||||
use anyhow::{Context};
|
use anyhow::{Context};
|
||||||
|
use sqlx::QueryBuilder;
|
||||||
|
|
||||||
// 树节点结构体
|
// 树节点结构体
|
||||||
#[derive(Debug, Serialize, Clone)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
|
@ -200,6 +201,81 @@ pub async fn create_source(
|
||||||
Ok((StatusCode::CREATED, Json(CreateSourceRes { id: new_id })))
|
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(
|
pub async fn delete_source(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(source_id): Path<Uuid>,
|
Path(source_id): Path<Uuid>,
|
||||||
|
|
|
||||||
115
src/main.rs
115
src/main.rs
|
|
@ -11,6 +11,7 @@ mod websocket;
|
||||||
mod telemetry;
|
mod telemetry;
|
||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
use db::init_database;
|
use db::init_database;
|
||||||
use middleware::simple_logger;
|
use middleware::simple_logger;
|
||||||
use connection::ConnectionManager;
|
use connection::ConnectionManager;
|
||||||
|
|
@ -20,6 +21,7 @@ use axum::{
|
||||||
routing::{get, put},
|
routing::{get, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -93,13 +95,24 @@ async fn main() {
|
||||||
tracing::info!("Starting server at http://{}", addr);
|
tracing::info!("Starting server at http://{}", addr);
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||||
|
|
||||||
// comment fixed
|
let ui_url = format!("http://{}:{}/ui", "localhost", config.server_port);
|
||||||
let shutdown_signal = async move{
|
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()
|
tokio::signal::ctrl_c()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to install Ctrl+C handler");
|
.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...");
|
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");
|
tracing::info!("All connections closed");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -112,7 +125,7 @@ async fn main() {
|
||||||
fn build_router(state: AppState) -> Router {
|
fn build_router(state: AppState) -> Router {
|
||||||
let all_route = Router::new()
|
let all_route = Router::new()
|
||||||
.route("/api/source", get(handler::source::get_source_list).post(handler::source::create_source))
|
.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}/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/source/{source_id}/node-tree", get(handler::source::get_node_tree))
|
||||||
.route("/api/point", get(handler::point::get_point_list))
|
.route("/api/point", get(handler::point::get_point_list))
|
||||||
|
|
@ -134,6 +147,7 @@ fn build_router(state: AppState) -> Router {
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(all_route)
|
.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/public", get(websocket::public_websocket_handler))
|
||||||
.route("/ws/client/{client_id}", get(websocket::client_websocket_handler))
|
.route("/ws/client/{client_id}", get(websocket::client_websocket_handler))
|
||||||
.layer(axum::middleware::from_fn(simple_logger))
|
.layer(axum::middleware::from_fn(simple_logger))
|
||||||
|
|
@ -145,3 +159,96 @@ fn build_router(state: AppState) -> Router {
|
||||||
)
|
)
|
||||||
.with_state(state)
|
.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