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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.