public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager 4/4] ui: views: add map component
Date: Mon,  4 May 2026 14:44:55 +0200	[thread overview]
Message-ID: <20260504124515.2956574-9-d.csapak@proxmox.com> (raw)
In-Reply-To: <20260504124515.2956574-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    |   2 +
 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, 294 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 c1885828..d300d74e 100644
--- a/lib/pdm-api-types/src/views.rs
+++ b/lib/pdm-api-types/src/views.rs
@@ -312,6 +312,8 @@ pub enum WidgetType {
         #[serde(skip_serializing_if = "Option::is_none")]
         remote_type: Option<RemoteType>,
     },
+    #[serde(rename_all = "kebab-case")]
+    Map,
 }
 
 #[derive(Serialize, Deserialize, PartialEq, Clone, Copy)]
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index 460e247e..752e685d 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 81810664..de2a59fe 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -22,10 +22,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;
@@ -171,6 +171,7 @@ fn render_widget(
             resource,
             remote_type,
         } => create_gauge_panel(*resource, *remote_type, status),
+        WidgetType::Map => create_map_panel(status),
     };
 
     if let Some(title) = &item.title {
@@ -273,6 +274,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





  parent reply	other threads:[~2026-05-04 12:45 UTC|newest]

Thread overview: 13+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-05-04 12:44 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
2026-05-04 12:44 ` [PATCH yew-widget-toolkit 1/3] js-helper: add client-to-svg-coordinate conversion helper Dominik Csapak
2026-05-04 12:44 ` [PATCH yew-widget-toolkit 2/3] widget: charts: add interactive Map with zoom/pan and clustering Dominik Csapak
2026-05-04 12:44 ` [PATCH yew-widget-toolkit 3/3] widget: charts: add WorldMap with GeoJSON rendering Dominik Csapak
2026-05-04 12:44 ` [PATCH yew-widget-toolkit-assets 1/1] charts: add necessary classes for Map Dominik Csapak
2026-05-04 12:44 ` [PATCH datacenter-manager 1/4] lib/api/ui: add location property to remote config Dominik Csapak
2026-05-04 12:44 ` [PATCH datacenter-manager 2/4] lib/api: add new 'remote-list' info to the resource status Dominik Csapak
2026-05-04 12:44 ` Dominik Csapak [this message]
2026-05-04 12:49 ` [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
2026-05-05  8:28   ` Thomas Lamprecht
2026-05-05  8:39     ` Dominik Csapak
2026-05-05  8:51       ` Thomas Lamprecht
2026-05-05  7:38 ` superseded: " Dominik Csapak

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=20260504124515.2956574-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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal