eneros-dashboard
eneros-dashboard provides EnerOS’s Web dashboard service, supporting PWA offline access and mobile adaptation. Based on server-side rendering + client-side hydration, with first paint < 200ms, it includes a built-in draggable visualization component library covering single-line diagrams, Load Flow heatmaps, time series charts, alarm lists, KPI cards, Agent status, and other scenarios.
Responsibilities
eneros-dashboard handles the following core responsibilities:
- Dashboard Definition:
DashboardDef declaratively describes layout, components, and data sources, supporting TOML / JSON / database storage
- Component Library: 6 built-in Widgets (single-line diagram, heatmap, time series chart, alarm list, KPI card, Agent status)
- PWA: Service Worker caching, offline access to the last 24h of data, supports Add to Home Screen
- Multi-tenant: Tenant-isolated dashboard configuration and data views, with permissions down to component granularity
- Real-time Push: Real-time data push based on SSE, automatic component refresh
- Draggable Layout: 12-grid responsive layout, supporting drag, resize, and composition
- Export: Dashboards can be exported as PDF / PNG / template JSON
- Multilingual: Chinese-English bilingual interface based on
eneros-i18n
Key Types and Interfaces
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())
}
}
Core API
| Method | Signature | Description |
|---|
DashboardServer::from_config | fn from_config(path: impl AsRef<Path>) -> Result<Self> | Load from TOML |
DashboardServer::listen | async fn listen(self, addr: &str) -> Result<()> | Start HTTP service |
WidgetRegistry::register | fn register(&mut self, widget: Box<dyn Widget>) | Register component |
WidgetRegistry::get | fn get(&self, kind: WidgetKind) -> Option<&dyn Widget> | Get component |
Widget::render | fn render(&self, ctx: &RenderContext) -> RenderResult | Render component |
Usage Examples
Start Dashboard Service
use eneros_dashboard::DashboardServer;
let server = DashboardServer::from_config("dashboard.toml")?;
server.listen("0.0.0.0:3000").await?;
Declarative Dashboard Definition (TOML)
[dashboards.main]
title = "Main Grid Monitoring"
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 }
Programmatic Dashboard Construction
use eneros_dashboard::{DashboardDef, WidgetDef, WidgetKind, DataSource, Position, Layout};
use std::time::Duration;
let dashboard = DashboardDef {
id: "main".into(),
title: "Main Grid Monitoring".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
# List all dashboards
curl http://localhost:3000/dashboards
# Get dashboard definition
curl http://localhost:3000/dashboards/main
# Create dashboard
curl -X POST http://localhost:3000/dashboards \
-H "Content-Type: application/json" \
-d '{"id":"ops","title":"Operations View","widgets":[]}'
# Real-time data push (SSE)
curl -N http://localhost:3000/dashboards/main/stream
# Export 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() }));
Component Library
| WidgetKind | Description | Data Source |
|---|
SingleLineDiagram | Single-line diagram, showing bus, branch, equipment topology | Network |
Heatmap | Heatmap, branch loading / voltage distribution | Metric |
TimeSeriesChart | Time series chart, multi-curve overlay | Metric |
AlarmList | Alarm list, filtered by severity | Alarms |
KpiCard | KPI card, single-value metric | Kpi |
AgentStatus | Agent status list | Agents |
Configuration Parameter Table
DashboardConfig Fields
| Field | Type | Default | Description |
|---|
bind | String | ”0.0.0.0:3000” | Listen address |
static_dir | String | ”/var/lib/eneros/dashboard” | Static assets directory |
pwa_enabled | bool | true | Enable PWA |
offline_cache_hours | u32 | 24 | Offline cache hours |
default_refresh_interval_ms | u64 | 5000 | Default refresh interval (milliseconds) |
max_widgets_per_dashboard | usize | 32 | Maximum components per dashboard |
Layout Fields
| Field | Type | Default | Description |
|---|
columns | u32 | 12 | Number of grid columns |
row_height | u32 | 80 | Row height (px) |
gap | u32 | 8 | Spacing (px) |
responsive | bool | true | Whether responsive |
Position Fields
| Field | Type | Description |
|---|
x | u32 | Starting column |
y | u32 | Starting row |
w | u32 | Width (columns) |
h | u32 | Height (rows) |
Test environment: 4 cores / 8GB / Ubuntu 22.04 / Rust 1.78 release build.
| Operation | Latency |
|---|
| First paint rendering (SSR) | < 200ms |
| Dashboard definition loading | < 50ms |
| Single Widget rendering | < 10ms |
| SSE data push (1000 clients) | < 1ms broadcast |
| Offline cache hit | < 5ms |
| PDF export (10 Widgets) | < 2s |
Dependencies
Own Dependencies
[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"] }
Depended On By
None (top-level application), can be deployed as a standalone binary or embedded into eneros-api.
Version and Compatibility
| eneros-dashboard Version | EnerOS Version | Key Changes |
|---|
| 0.47.x | 0.47.x | PWA offline cache + SSE real-time push |
| 0.46.x | 0.46.x | Added Agent status component |
| 0.45.x (LTS) | 0.45.x | LTS version, security updates until 2028 |