跳到主内容

eneros-dashboard

Crate 索引

eneros-dashboard

eneros-dashboard 提供 EnerOS 的 Web 仪表盘服务,支持 PWA 离线访问与移动端自适应。基于服务端渲染 + 客户端水合,首屏 < 200ms,内置可拖拽的可视化组件库,覆盖单线图、潮流热力图、时序曲线、告警列表、KPI 卡片、Agent 状态等场景。

职责描述

eneros-dashboard 承担以下核心职责:

  • 仪表盘定义DashboardDef 声明式描述布局、组件、数据源,支持 TOML / JSON / 数据库存储
  • 组件库:6 种内置 Widget(单线图、热力图、时序曲线、告警列表、KPI 卡片、Agent 状态)
  • PWA:Service Worker 缓存,离线可查看最近 24h 数据,支持 Add to Home Screen
  • 多租户:按租户隔离仪表盘配置与数据视图,权限到组件粒度
  • 实时推送:基于 SSE 推送实时数据,自动刷新组件
  • 可拖拽布局:12 栅格响应式布局,支持拖拽、缩放、组合
  • 导出:仪表盘可导出为 PDF / PNG / 模板 JSON
  • 多语言:基于 eneros-i18n 提供中英双语界面

关键类型与接口

DashboardServer

use axum::Router;
use eneros_api::ApiClient;

pub struct DashboardServer {
    router: Router,
    widget_registry: WidgetRegistry,
    api_client: ApiClient,
    config: DashboardConfig,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DashboardConfig {
    pub bind: String,
    pub static_dir: String,
    pub pwa_enabled: bool,
    pub offline_cache_hours: u32,
    pub default_refresh_interval_ms: u64,
    pub max_widgets_per_dashboard: usize,
}

impl Default for DashboardConfig {
    fn default() -> Self {
        Self {
            bind: "0.0.0.0:3000".into(),
            static_dir: "/var/lib/eneros/dashboard".into(),
            pwa_enabled: true,
            offline_cache_hours: 24,
            default_refresh_interval_ms: 5000,
            max_widgets_per_dashboard: 32,
        }
    }
}

impl DashboardServer {
    pub fn from_config(path: impl AsRef<Path>) -> Result<Self> {
        let text = std::fs::read_to_string(path.as_ref())?;
        let config: DashboardConfig = toml::from_str(&text)?;
        Ok(Self {
            router: Self::build_router(),
            widget_registry: WidgetRegistry::default(),
            api_client: ApiClient::builder().build()?,
            config,
        })
    }

    fn build_router() -> Router {
        Router::new()
            .route("/", get(index_page))
            .route("/dashboards", get(list_dashboards).post(create_dashboard))
            .route("/dashboards/:id", get(get_dashboard).put(update_dashboard).delete(delete_dashboard))
            .route("/dashboards/:id/stream", get(stream_dashboard))
            .route("/dashboards/:id/export", get(export_dashboard))
            .route("/widgets", get(list_widgets))
            .route("/static/*path", get(serve_static))
            .route("/sw.js", get(service_worker))
            .route("/manifest.json", get(manifest))
    }

    pub async fn listen(self, addr: &str) -> Result<()> {
        let listener = tokio::net::TcpListener::bind(addr).await?;
        axum::serve(listener, self.router).await?;
        Ok(())
    }
}

DashboardDef / WidgetDef

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DashboardDef {
    pub id: DashboardId,
    pub title: String,
    pub layout: Layout,
    pub widgets: Vec<WidgetDef>,
    pub refresh_interval: std::time::Duration,
    pub tenant: Option<String>,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DashboardId(pub String);

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Layout {
    pub columns: u32,
    pub row_height: u32,
    pub gap: u32,
    pub responsive: bool,
}

impl Default for Layout {
    fn default() -> Self {
        Self {
            columns: 12,
            row_height: 80,
            gap: 8,
            responsive: true,
        }
    }
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct WidgetDef {
    pub id: String,
    pub kind: WidgetKind,
    pub data_source: DataSource,
    pub position: Position,
    pub config: serde_json::Value,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum WidgetKind {
    SingleLineDiagram,
    Heatmap,
    TimeSeriesChart,
    AlarmList,
    KpiCard,
    AgentStatus,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum DataSource {
    Network { network: String },
    Metric { metric: String },
    Alarms { severity: Option<String> },
    Agents { agent_type: Option<String> },
    PowerFlow { network: String },
    Kpi { name: String },
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Position {
    pub x: u32,
    pub y: u32,
    pub w: u32,
    pub h: u32,
}

WidgetRegistry

pub struct WidgetRegistry {
    widgets: HashMap<String, Box<dyn Widget>>,
}

pub trait Widget: Send + Sync {
    fn kind(&self) -> WidgetKind;
    fn render(&self, ctx: &RenderContext) -> RenderResult;
    fn default_config(&self) -> serde_json::Value;
}

pub struct RenderContext<'a> {
    pub data: &'a serde_json::Value,
    pub locale: &'a str,
    pub theme: Theme,
}

#[derive(Debug, Clone, Copy)]
pub enum Theme {
    Light,
    Dark,
}

pub struct RenderResult {
    pub html: String,
    pub scripts: Vec<String>,
    pub styles: Vec<String>,
}

impl Default for WidgetRegistry {
    fn default() -> Self {
        let mut registry = Self { widgets: HashMap::new() };
        registry.register(Box::new(SingleLineDiagramWidget));
        registry.register(Box::new(HeatmapWidget));
        registry.register(Box::new(TimeSeriesChartWidget));
        registry.register(Box::new(AlarmListWidget));
        registry.register(Box::new(KpiCardWidget));
        registry.register(Box::new(AgentStatusWidget));
        registry
    }
}

impl WidgetRegistry {
    pub fn register(&mut self, widget: Box<dyn Widget>) {
        let key = format!("{:?}", widget.kind());
        self.widgets.insert(key, widget);
    }

    pub fn get(&self, kind: WidgetKind) -> Option<&dyn Widget> {
        let key = format!("{:?}", kind);
        self.widgets.get(&key).map(|b| b.as_ref())
    }
}

核心 API

方法签名说明
DashboardServer::from_configfn from_config(path: impl AsRef<Path>) -> Result<Self>从 TOML 加载
DashboardServer::listenasync fn listen(self, addr: &str) -> Result<()>启动 HTTP 服务
WidgetRegistry::registerfn register(&mut self, widget: Box<dyn Widget>)注册组件
WidgetRegistry::getfn get(&self, kind: WidgetKind) -> Option<&dyn Widget>获取组件
Widget::renderfn render(&self, ctx: &RenderContext) -> RenderResult渲染组件

使用示例

启动仪表盘服务

use eneros_dashboard::DashboardServer;

let server = DashboardServer::from_config("dashboard.toml")?;
server.listen("0.0.0.0:3000").await?;

声明式仪表盘定义(TOML)

[dashboards.main]
title = "主网监控"
refresh_interval = "5s"

[dashboards.main.layout]
columns = 12
row_height = 80
gap = 8
responsive = true

[[dashboards.main.widgets]]
id = "sld"
kind = "SingleLineDiagram"
data_source = { network = "net_8f3a2b" }
position = { x = 0, y = 0, w = 12, h = 8 }
config = { show_voltage = true, show_flow = true }

[[dashboards.main.widgets]]
id = "loading"
kind = "Heatmap"
data_source = { metric = "branch_loading" }
position = { x = 0, y = 8, w = 6, h = 4 }
config = { color_scale = "viridis" }

[[dashboards.main.widgets]]
id = "voltage_trend"
kind = "TimeSeriesChart"
data_source = { metric = "voltage_pu" }
position = { x = 6, y = 8, w = 6, h = 4 }
config = { y_axis_label = "Voltage (pu)", window = "1h" }

[[dashboards.main.widgets]]
id = "alarms"
kind = "AlarmList"
data_source = { severity = "critical" }
position = { x = 0, y = 12, w = 4, h = 6 }

[[dashboards.main.widgets]]
id = "kpi_loss"
kind = "KpiCard"
data_source = { Kpi = { name = "total_loss_mw" } }
position = { x = 4, y = 12, w = 4, h = 3 }
config = { unit = "MW", threshold = 5.0 }

[[dashboards.main.widgets]]
id = "agents"
kind = "AgentStatus"
data_source = { Agents = { agent_type = "dispatch" } }
position = { x = 8, y = 12, w = 4, h = 6 }

编程式构造仪表盘

use eneros_dashboard::{DashboardDef, WidgetDef, WidgetKind, DataSource, Position, Layout};
use std::time::Duration;

let dashboard = DashboardDef {
    id: "main".into(),
    title: "主网监控".into(),
    layout: Layout::default(),
    refresh_interval: Duration::from_secs(5),
    widgets: vec![
        WidgetDef {
            id: "sld".into(),
            kind: WidgetKind::SingleLineDiagram,
            data_source: DataSource::Network { network: "net_8f3a2b".into() },
            position: Position { x: 0, y: 0, w: 12, h: 8 },
            config: serde_json::json!({"show_voltage": true, "show_flow": true}),
        },
        WidgetDef {
            id: "loading".into(),
            kind: WidgetKind::Heatmap,
            data_source: DataSource::Metric { metric: "branch_loading".into() },
            position: Position { x: 0, y: 8, w: 6, h: 4 },
            config: serde_json::json!({"color_scale": "viridis"}),
        },
    ],
    tenant: None,
    created_at: chrono::Utc::now(),
    updated_at: chrono::Utc::now(),
};

REST API

# 列出所有仪表盘
curl http://localhost:3000/dashboards

# 获取仪表盘定义
curl http://localhost:3000/dashboards/main

# 创建仪表盘
curl -X POST http://localhost:3000/dashboards \
  -H "Content-Type: application/json" \
  -d '{"id":"ops","title":"运维视图","widgets":[]}'

# 实时数据推送(SSE)
curl -N http://localhost:3000/dashboards/main/stream

# 导出 PDF
curl http://localhost:3000/dashboards/main/export?format=pdf -o dashboard.pdf

自定义 Widget

use eneros_dashboard::{Widget, WidgetKind, RenderContext, RenderResult};

struct GaugeWidget {
    name: String,
}

impl Widget for GaugeWidget {
    fn kind(&self) -> WidgetKind {
        WidgetKind::KpiCard
    }

    fn render(&self, ctx: &RenderContext) -> RenderResult {
        let value = ctx.data["value"].as_f64().unwrap_or(0.0);
        let label = ctx.data["label"].as_str().unwrap_or("");
        let html = format!(
            "<div class='gauge'><span class='value'>{:.2}</span><span class='label'>{}</span></div>",
            value, label
        );
        RenderResult {
            html,
            scripts: vec!["gauge.js".into()],
            styles: vec!["gauge.css".into()],
        }
    }

    fn default_config(&self) -> serde_json::Value {
        serde_json::json!({"unit": "", "min": 0, "max": 100})
    }
}

let mut registry = WidgetRegistry::default();
registry.register(Box::new(GaugeWidget { name: "frequency".into() }));

组件库

WidgetKind说明数据源
SingleLineDiagram单线图,显示母线、支路、设备拓扑Network
Heatmap热力图,支路负载率 / 电压分布Metric
TimeSeriesChart时序曲线,多曲线叠加Metric
AlarmList告警列表,按严重级别筛选Alarms
KpiCardKPI 卡片,单值指标Kpi
AgentStatusAgent 状态列表Agents

配置参数表

DashboardConfig 字段

字段类型默认值说明
bindString”0.0.0.0:3000”监听地址
static_dirString”/var/lib/eneros/dashboard”静态资源目录
pwa_enabledbooltrue启用 PWA
offline_cache_hoursu3224离线缓存小时数
default_refresh_interval_msu645000默认刷新间隔(毫秒)
max_widgets_per_dashboardusize32单仪表盘最大组件数

Layout 字段

字段类型默认值说明
columnsu3212栅格列数
row_heightu3280行高(px)
gapu328间距(px)
responsivebooltrue是否响应式

Position 字段

字段类型说明
xu32起始列
yu32起始行
wu32宽度(列数)
hu32高度(行数)

性能指标

测试环境:4 核 / 8GB / Ubuntu 22.04 / Rust 1.78 release build。

操作延迟
首屏渲染(SSR)< 200ms
仪表盘定义加载< 50ms
单 Widget 渲染< 10ms
SSE 数据推送(1000 客户端)< 1ms 广播
离线缓存命中< 5ms
PDF 导出(10 个 Widget)< 2s

依赖关系

自身依赖

[dependencies]
eneros-core = { path = "../core", version = "0.47.0" }
eneros-api = { path = "../api", version = "0.47.0" }
eneros-topology = { path = "../topology", version = "0.47.0" }
eneros-timeseries = { path = "../timeseries", version = "0.47.0" }
eneros-i18n = { path = "../i18n", version = "0.47.0" }
axum = { version = "0.7", features = ["ws"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
tracing = "0.1"
chrono = { version = "0.4", features = ["serde"] }

被依赖

无(顶层应用),可作为独立二进制部署或嵌入到 eneros-api 中。

版本与兼容性

eneros-dashboard 版本EnerOS 版本关键变更
0.47.x0.47.xPWA 离线缓存 + SSE 实时推送
0.46.x0.46.x新增 Agent 状态组件
0.45.x (LTS)0.45.xLTS 版本,至 2028 年安全更新

相关文档