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(())
}
}
#[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,
}
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_config | fn from_config(path: impl AsRef<Path>) -> Result<Self> | 从 TOML 加载 |
DashboardServer::listen | async fn listen(self, addr: &str) -> Result<()> | 启动 HTTP 服务 |
WidgetRegistry::register | fn register(&mut self, widget: Box<dyn Widget>) | 注册组件 |
WidgetRegistry::get | fn get(&self, kind: WidgetKind) -> Option<&dyn Widget> | 获取组件 |
Widget::render | fn 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
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 |
KpiCard | KPI 卡片,单值指标 | Kpi |
AgentStatus | Agent 状态列表 | Agents |
配置参数表
DashboardConfig 字段
| 字段 | 类型 | 默认值 | 说明 |
|---|
bind | String | ”0.0.0.0:3000” | 监听地址 |
static_dir | String | ”/var/lib/eneros/dashboard” | 静态资源目录 |
pwa_enabled | bool | true | 启用 PWA |
offline_cache_hours | u32 | 24 | 离线缓存小时数 |
default_refresh_interval_ms | u64 | 5000 | 默认刷新间隔(毫秒) |
max_widgets_per_dashboard | usize | 32 | 单仪表盘最大组件数 |
Layout 字段
| 字段 | 类型 | 默认值 | 说明 |
|---|
columns | u32 | 12 | 栅格列数 |
row_height | u32 | 80 | 行高(px) |
gap | u32 | 8 | 间距(px) |
responsive | bool | true | 是否响应式 |
Position 字段
| 字段 | 类型 | 说明 |
|---|
x | u32 | 起始列 |
y | u32 | 起始行 |
w | u32 | 宽度(列数) |
h | u32 | 高度(行数) |
性能指标
测试环境: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.x | 0.47.x | PWA 离线缓存 + SSE 实时推送 |
| 0.46.x | 0.46.x | 新增 Agent 状态组件 |
| 0.45.x (LTS) | 0.45.x | LTS 版本,至 2028 年安全更新 |
相关文档