From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 12E6A1FF136 for ; Mon, 04 May 2026 14:45:30 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id E4C5620CCB; Mon, 4 May 2026 14:45:29 +0200 (CEST) From: Dominik Csapak 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 Message-ID: <20260504124515.2956574-9-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260504124515.2956574-1-d.csapak@proxmox.com> References: <20260504124515.2956574-1-d.csapak@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.050 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: GYBMAZ4DYEKWASSYIB7IC5CF3XOJLYTF X-Message-ID-Hash: GYBMAZ4DYEKWASSYIB7IC5CF3XOJLYTF X-MailFrom: d.csapak@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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, }, + #[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>, +} + +impl DashboardMap { + pub fn new(data: SharedState>) -> Self { + yew::props!(Self { data }) + } +} + +pub enum Msg { + MapLoaded, + DataChanged, + RemoteListChanged(RemoteList), +} + +pub struct DashboardMapComp { + loader: Loader, + points: Vec>, + remote_list: RemoteList, + _remote_list_ctx_handle: ContextHandle, + _data_observer: SharedStateObserver>, +} + +impl DashboardMapComp { + fn calculate_points(ctx: &Context, remote_list: &RemoteList) -> Vec> { + 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 { + 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, 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) -> 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 for VNode { + fn from(val: PoiInfo) -> Self { + let comp = VComp::new::(Rc::new(val), None); + VNode::from(comp) + } +} + +struct PoiInfoComp {} + +impl Component for PoiInfoComp { + type Message = (); + type Properties = PoiInfo; + + fn create(_ctx: &Context) -> Self { + Self {} + } + + fn view(&self, ctx: &Context) -> 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) -> 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) -> 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>) -> 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, 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