Skip to main content

eneros-dashboard

Crate Index

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(())
    }
}

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())
    }
}

Core API

MethodSignatureDescription
DashboardServer::from_configfn from_config(path: impl AsRef<Path>) -> Result<Self>Load from TOML
DashboardServer::listenasync fn listen(self, addr: &str) -> Result<()>Start HTTP service
WidgetRegistry::registerfn register(&mut self, widget: Box<dyn Widget>)Register component
WidgetRegistry::getfn get(&self, kind: WidgetKind) -> Option<&dyn Widget>Get component
Widget::renderfn render(&self, ctx: &RenderContext) -> RenderResultRender 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

Custom 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() }));

Component Library

WidgetKindDescriptionData Source
SingleLineDiagramSingle-line diagram, showing bus, branch, equipment topologyNetwork
HeatmapHeatmap, branch loading / voltage distributionMetric
TimeSeriesChartTime series chart, multi-curve overlayMetric
AlarmListAlarm list, filtered by severityAlarms
KpiCardKPI card, single-value metricKpi
AgentStatusAgent status listAgents

Configuration Parameter Table

DashboardConfig Fields

FieldTypeDefaultDescription
bindString”0.0.0.0:3000”Listen address
static_dirString”/var/lib/eneros/dashboard”Static assets directory
pwa_enabledbooltrueEnable PWA
offline_cache_hoursu3224Offline cache hours
default_refresh_interval_msu645000Default refresh interval (milliseconds)
max_widgets_per_dashboardusize32Maximum components per dashboard

Layout Fields

FieldTypeDefaultDescription
columnsu3212Number of grid columns
row_heightu3280Row height (px)
gapu328Spacing (px)
responsivebooltrueWhether responsive

Position Fields

FieldTypeDescription
xu32Starting column
yu32Starting row
wu32Width (columns)
hu32Height (rows)

Performance Metrics

Test environment: 4 cores / 8GB / Ubuntu 22.04 / Rust 1.78 release build.

OperationLatency
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 VersionEnerOS VersionKey Changes
0.47.x0.47.xPWA offline cache + SSE real-time push
0.46.x0.46.xAdded Agent status component
0.45.x (LTS)0.45.xLTS version, security updates until 2028