From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager v2 4/4] ui: views: add map component
Date: Tue, 5 May 2026 09:31:59 +0200 [thread overview]
Message-ID: <20260505073203.398548-9-d.csapak@proxmox.com> (raw)
In-Reply-To: <20260505073203.398548-1-d.csapak@proxmox.com>
this uses the WorldMap from yew widget toolkit together with the added
world-map.json to show a world map on a custom view. This shows the
remotes which have a location in the config and their status.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
lib/pdm-api-types/src/views.rs | 3 +
ui/Cargo.toml | 1 +
ui/src/dashboard/map.rs | 281 ++++++++++++++++++++++++++++++
ui/src/dashboard/mod.rs | 3 +
ui/src/dashboard/view.rs | 10 +-
ui/src/dashboard/view/row_view.rs | 1 +
6 files changed, 295 insertions(+), 4 deletions(-)
create mode 100644 ui/src/dashboard/map.rs
diff --git a/lib/pdm-api-types/src/views.rs b/lib/pdm-api-types/src/views.rs
index 3e215d06..1bab0170 100644
--- a/lib/pdm-api-types/src/views.rs
+++ b/lib/pdm-api-types/src/views.rs
@@ -333,6 +333,9 @@ pub enum WidgetType {
#[serde(skip_serializing_if = "Option::is_none")]
remote_type: Option<RemoteType>,
},
+ /// A simple map
+ #[serde(rename_all = "kebab-case")]
+ Map,
#[serde(untagged)]
#[serde(rename_all = "kebab-case")]
/// Catches all widgets for unknown types.
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index 90b9e1e4..00139749 100644
--- a/ui/Cargo.toml
+++ b/ui/Cargo.toml
@@ -11,6 +11,7 @@ resolver = "2"
[dependencies]
anyhow = "1.0"
futures = "0.3"
+geojson = "0.24"
gloo-net = "0.4"
gloo-timers = "0.3"
gloo-utils = "0.2"
diff --git a/ui/src/dashboard/map.rs b/ui/src/dashboard/map.rs
new file mode 100644
index 00000000..9d7c24bd
--- /dev/null
+++ b/ui/src/dashboard/map.rs
@@ -0,0 +1,281 @@
+use std::collections::HashMap;
+use std::rc::Rc;
+
+use anyhow::Error;
+use geojson::GeoJson;
+
+use proxmox_yew_comp::Status;
+use pwt::css;
+use pwt::prelude::*;
+use pwt::state::SharedStateObserver;
+use pwt::state::{Loader, SharedState};
+use pwt::widget::canvas::Group;
+use pwt::widget::charts::{
+ render_point_default, Location, MapPointData, PointsRenderArgs, WorldMap, WorldPoint,
+};
+use pwt::widget::container::span;
+use pwt::widget::Container;
+use pwt::widget::{error_message, ActionIcon, Column, Fa, Panel, Row};
+use pwt_macros::{builder, widget};
+use yew::virtual_dom::{VComp, VNode};
+
+use crate::dashboard::loading_column;
+use crate::{navigate_to, LoadResult, RemoteList};
+
+use pdm_api_types::resource::{RemoteInfo, RemoteStatus, ResourcesStatus};
+
+#[widget(comp=DashboardMapComp, @element)]
+#[builder]
+#[derive(Properties, PartialEq, Clone)]
+pub struct DashboardMap {
+ data: SharedState<LoadResult<ResourcesStatus, Error>>,
+}
+
+impl DashboardMap {
+ pub fn new(data: SharedState<LoadResult<ResourcesStatus, Error>>) -> Self {
+ yew::props!(Self { data })
+ }
+}
+
+pub enum Msg {
+ MapLoaded,
+ DataChanged,
+ RemoteListChanged(RemoteList),
+}
+
+pub struct DashboardMapComp {
+ loader: Loader<GeoJson>,
+ points: Vec<WorldPoint<PoiInfo>>,
+ remote_list: RemoteList,
+ _remote_list_ctx_handle: ContextHandle<RemoteList>,
+ _data_observer: SharedStateObserver<LoadResult<ResourcesStatus, Error>>,
+}
+
+impl DashboardMapComp {
+ fn calculate_points(ctx: &Context<Self>, remote_list: &RemoteList) -> Vec<WorldPoint<PoiInfo>> {
+ let read_guard = ctx.props().data.read();
+
+ let mut info_map = HashMap::new();
+ if let Some(data) = &read_guard.data {
+ for remote in &data.remote_list {
+ info_map.insert(remote.name.clone(), remote);
+ }
+ };
+
+ remote_list
+ .iter()
+ .filter_map(|remote| {
+ remote.location.as_ref().map(|location| {
+ let location = Location::new(location.longitude, location.latitude);
+ let info = match info_map.get(&remote.id) {
+ Some(&info) => info.clone(),
+ None => RemoteInfo {
+ name: remote.id.clone(),
+ messages: Vec::new(),
+ status: RemoteStatus::Unknown,
+ },
+ };
+ WorldPoint {
+ location,
+ data: PoiInfo::new(info),
+ }
+ })
+ })
+ .collect()
+ }
+}
+
+impl yew::Component for DashboardMapComp {
+ type Message = Msg;
+ type Properties = DashboardMap;
+
+ fn create(ctx: &Context<Self>) -> Self {
+ let loader = Loader::new()
+ .loader((
+ |url: AttrValue| async move {
+ let json = gloo_net::http::Request::get(&url).send().await?;
+ let geo_json = GeoJson::from_json_value(json.json().await?)?;
+ Ok(geo_json)
+ },
+ "/world-map.json",
+ ))
+ .on_change(ctx.link().callback(|_| Msg::MapLoaded));
+ loader.load();
+
+ let _data_observer = ctx
+ .props()
+ .data
+ .add_listener(ctx.link().callback(|_| Msg::DataChanged));
+
+ let (remote_list, _remote_list_ctx_handle) = ctx
+ .link()
+ .context(ctx.link().callback(Msg::RemoteListChanged))
+ .expect("no remote list context");
+ let points = Self::calculate_points(ctx, &remote_list); // todo
+
+ Self {
+ loader,
+ remote_list,
+ points,
+ _remote_list_ctx_handle,
+ _data_observer,
+ }
+ }
+
+ fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Msg::MapLoaded => {}
+ Msg::DataChanged => {
+ self.points = Self::calculate_points(ctx, &self.remote_list);
+ }
+ Msg::RemoteListChanged(remote_list) => {
+ self.points = Self::calculate_points(ctx, &remote_list);
+ self.remote_list = remote_list;
+ }
+ }
+ true
+ }
+
+ fn view(&self, ctx: &Context<Self>) -> Html {
+ let props = ctx.props();
+ let loader = self.loader.read();
+
+ let geojson = match &loader.data {
+ Some(Ok(geojson)) => Rc::clone(geojson),
+ Some(Err(err)) => return error_message(&err.to_string()).into(),
+ _ => return loading_column().into(),
+ };
+
+ WorldMap::new(geojson)
+ .with_std_props(&props.std_props)
+ .listeners(&props.listeners)
+ .points(self.points.clone())
+ .into()
+ }
+}
+
+#[derive(Clone, PartialEq, Properties)]
+struct PoiInfo {
+ info: RemoteInfo,
+}
+
+impl std::ops::Deref for PoiInfo {
+ type Target = RemoteInfo;
+
+ fn deref(&self) -> &Self::Target {
+ &self.info
+ }
+}
+
+impl PoiInfo {
+ fn new(info: RemoteInfo) -> Self {
+ yew::props!(Self { info })
+ }
+}
+
+impl From<PoiInfo> for VNode {
+ fn from(val: PoiInfo) -> Self {
+ let comp = VComp::new::<PoiInfoComp>(Rc::new(val), None);
+ VNode::from(comp)
+ }
+}
+
+struct PoiInfoComp {}
+
+impl Component for PoiInfoComp {
+ type Message = ();
+ type Properties = PoiInfo;
+
+ fn create(_ctx: &Context<Self>) -> Self {
+ Self {}
+ }
+
+ fn view(&self, ctx: &Context<Self>) -> Html {
+ let props = ctx.props();
+
+ let link = ctx.link().clone();
+ let remote_name = props.info.name.clone();
+ let (status, status_icon) = match props.info.status {
+ RemoteStatus::Good => (tr!("Good"), Fa::from(Status::Success)),
+ RemoteStatus::Warning => (tr!("Warning"), Fa::from(Status::Warning)),
+ RemoteStatus::Error => (tr!("Error"), Fa::from(Status::Error)),
+ RemoteStatus::Unknown => (tr!("Unknown"), Fa::from(Status::Unknown)),
+ };
+ Column::new()
+ .width(300)
+ .max_height(300)
+ .class(css::JustifyContent::Stretch)
+ .gap(2)
+ .padding(1)
+ .with_child(
+ Row::new()
+ .gap(1)
+ .class(css::AlignItems::Center)
+ .with_child(status_icon)
+ .with_child(span(&status))
+ .with_flex_spacer()
+ .with_child(span(&props.info.name))
+ .with_child(
+ ActionIcon::new("fa fa-chevron-right")
+ .on_activate(move |_| navigate_to(&link, &remote_name, None)),
+ ),
+ )
+ .with_optional_child((!props.info.messages.is_empty()).then_some(
+ Container::new().children(props.info.messages.iter().map(|err| {
+ span(err)
+ .padding_bottom(1)
+ .class(css::Overflow::Auto)
+ .into()
+ })),
+ ))
+ .into()
+ }
+}
+
+impl MapPointData for PoiInfo {
+ fn render_title(&self) -> AttrValue {
+ self.info.name.clone().into()
+ }
+
+ fn render_point(args: &PointsRenderArgs<Self>) -> Group {
+ let mut worst = RemoteStatus::Good;
+
+ for poi in args.points {
+ match (&poi.data.status, &worst) {
+ (RemoteStatus::Error, _) => worst = RemoteStatus::Error,
+ (RemoteStatus::Warning, RemoteStatus::Good | RemoteStatus::Unknown) => {
+ worst = RemoteStatus::Warning
+ }
+ (RemoteStatus::Unknown, RemoteStatus::Good) => worst = RemoteStatus::Unknown,
+ _ => {}
+ }
+ }
+
+ let mut args = args.clone();
+ let txt = match worst {
+ RemoteStatus::Good => "success",
+ RemoteStatus::Warning => "warning",
+ RemoteStatus::Error => "error",
+ RemoteStatus::Unknown => {
+ // animate the not yet loaded remotes
+ args.selected = true;
+ "primary"
+ }
+ };
+ render_point_default(&args).style(
+ "--pwt-location-color",
+ format!("var(--pwt-color-{txt}) ! important"),
+ )
+ }
+
+ fn render_info(args: &PointsRenderArgs<Self>) -> Html {
+ Column::new()
+ .children(args.points.iter().map(|&point| point.data.clone().into()))
+ .into()
+ }
+}
+
+/// Creates a dashboard panel with a world map
+pub fn create_map_panel(status: SharedState<LoadResult<ResourcesStatus, Error>>) -> Panel {
+ Panel::new().with_child(DashboardMap::new(status).flex(1.0))
+}
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 194000c2..fdf08c43 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -20,6 +20,9 @@ pub use gauge_panel::create_gauge_panel;
mod guest_panel;
pub use guest_panel::create_guest_panel;
+mod map;
+pub use map::create_map_panel;
+
mod node_status_panel;
use node_status_panel::create_node_panel;
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index bdf92bf6..63d4e5ef 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -24,10 +24,10 @@ use crate::dashboard::refresh_config_edit::{
use crate::dashboard::subscription_info::create_subscriptions_dialog;
use crate::dashboard::tasks::get_task_options;
use crate::dashboard::{
- create_gauge_panel, create_guest_panel, create_node_panel, create_pbs_datastores_panel,
- create_refresh_config_edit_window, create_remote_panel, create_resource_tree, create_sdn_panel,
- create_subscription_panel, create_task_summary_panel, create_top_entities_panel,
- DashboardStatusRow,
+ create_gauge_panel, create_guest_panel, create_map_panel, create_node_panel,
+ create_pbs_datastores_panel, create_refresh_config_edit_window, create_remote_panel,
+ create_resource_tree, create_sdn_panel, create_subscription_panel, create_task_summary_panel,
+ create_top_entities_panel, DashboardStatusRow,
};
use crate::remotes::AddWizard;
use crate::widget::RedrawController;
@@ -173,6 +173,7 @@ fn render_widget(
resource,
remote_type,
} => create_gauge_panel(*resource, *remote_type, status),
+ WidgetType::Map => create_map_panel(status),
WidgetType::UnknownWidget { widget_type, .. } => create_unknown_widget_panel(widget_type),
};
@@ -276,6 +277,7 @@ fn required_api_calls(layout: &ViewLayout) -> (bool, bool, bool) {
| WidgetType::Remotes { .. }
| WidgetType::Sdn
| WidgetType::PbsDatastores
+ | WidgetType::Map
| WidgetType::NodeResourceGauge { .. } => {
status = true;
}
diff --git a/ui/src/dashboard/view/row_view.rs b/ui/src/dashboard/view/row_view.rs
index 3c5428ae..a9696317 100644
--- a/ui/src/dashboard/view/row_view.rs
+++ b/ui/src/dashboard/view/row_view.rs
@@ -667,6 +667,7 @@ fn create_menu(ctx: &yew::Context<RowViewComp>, new_coords: Position) -> Menu {
),
)
.with_item(MenuItem::new(tr!("SDN Panel")).on_select(create_callback(WidgetType::Sdn)))
+ .with_item(MenuItem::new(tr!("Map")).on_select(create_callback(WidgetType::Map)))
.with_item(
MenuItem::new(tr!("Resource Tree"))
.on_select(create_callback(WidgetType::ResourceTree)),
--
2.47.3
prev parent reply other threads:[~2026-05-05 7:32 UTC|newest]
Thread overview: 13+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-05-05 7:31 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets v2 0/8] add a new map widget for custom views Dominik Csapak
2026-05-05 7:31 ` [PATCH yew-widget-toolkit v2 1/3] js-helper: add client-to-svg-coordinate conversion helper Dominik Csapak
2026-05-05 7:31 ` [PATCH yew-widget-toolkit v2 2/3] widget: charts: add interactive Map with zoom/pan and clustering Dominik Csapak
2026-05-05 7:31 ` [PATCH yew-widget-toolkit v2 3/3] widget: charts: add WorldMap with GeoJSON rendering Dominik Csapak
2026-05-05 7:31 ` [PATCH yew-widget-toolkit-assets v2 1/1] charts: add necessary classes for Map Dominik Csapak
2026-05-05 7:31 ` [PATCH datacenter-manager v2 1/4] lib/api/ui: add location property to remote config Dominik Csapak
2026-05-05 8:26 ` Thomas Lamprecht
2026-05-05 8:36 ` Dominik Csapak
2026-05-05 8:44 ` Thomas Lamprecht
2026-05-05 8:46 ` Dominik Csapak
2026-05-05 7:31 ` [PATCH datacenter-manager v2 2/4] lib/api: add new 'remote-list' info to the resource status Dominik Csapak
2026-05-05 7:31 ` [PATCH datacenter-manager v2 3/4] ui: add world map geojson update script Dominik Csapak
2026-05-05 7:31 ` Dominik Csapak [this message]
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260505073203.398548-9-d.csapak@proxmox.com \
--to=d.csapak@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox