From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id E8FFE1FF142 for ; Fri, 22 May 2026 10:34:30 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id CB2081BFE; Fri, 22 May 2026 10:34:30 +0200 (CEST) From: Dominik Csapak To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager v3 6/6] ui: views: add map component Date: Fri, 22 May 2026 10:34:07 +0200 Message-ID: <20260522083412.1223719-12-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260522083412.1223719-1-d.csapak@proxmox.com> References: <20260522083412.1223719-1-d.csapak@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.049 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: CKWEQTVQVJQMXLDUZUGZCVNO4WJNAKPS X-Message-ID-Hash: CKWEQTVQVJQMXLDUZUGZCVNO4WJNAKPS 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/Trunk.toml | 5 + ui/debian/control | 1 + ui/src/dashboard/map.rs | 406 ++++++++++++++++++++++++++++++ ui/src/dashboard/mod.rs | 3 + ui/src/dashboard/view.rs | 46 +++- ui/src/dashboard/view/row_view.rs | 1 + 8 files changed, 459 insertions(+), 6 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..9905eee2 100644 --- a/lib/pdm-api-types/src/views.rs +++ b/lib/pdm-api-types/src/views.rs @@ -333,6 +333,8 @@ pub enum WidgetType { #[serde(skip_serializing_if = "Option::is_none")] remote_type: Option, }, + /// A simple map + 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 a6f427b5..fe8c6dbe 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/Trunk.toml b/ui/Trunk.toml index 48113c1d..39fdd5be 100644 --- a/ui/Trunk.toml +++ b/ui/Trunk.toml @@ -20,3 +20,8 @@ command_arguments= ["css/material-yew-style.scss", "dist/.stage/material-yew-sty stage="build" command="rust-grass" command_arguments= ["css/desktop-yew-style.scss", "dist/.stage/desktop-yew-style.css"] + +[[hooks]] +stage="build" +command="sh" +command_arguments= ["-c", "mkdir -p ./dist/.stage/geojson && cp /usr/share/proxmox-geojson-data/world-map.json ./dist/.stage/geojson/"] diff --git a/ui/debian/control b/ui/debian/control index fa7af060..322c7709 100644 --- a/ui/debian/control +++ b/ui/debian/control @@ -8,6 +8,7 @@ Build-Depends: debhelper-compat (= 13), fonts-font-awesome, librust-anyhow-1+default-dev, librust-futures-0.3+default-dev, + librust-geojson-0.24+default-dev, librust-gloo-net-0.4+default-dev, librust-gloo-timers-0.3+default-dev, librust-gloo-utils-0.2+default-dev, diff --git a/ui/src/dashboard/map.rs b/ui/src/dashboard/map.rs new file mode 100644 index 00000000..ed597968 --- /dev/null +++ b/ui/src/dashboard/map.rs @@ -0,0 +1,406 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::hash::Hash; +use std::rc::Rc; + +use anyhow::Error; +use geojson::GeoJson; +use yew::virtual_dom::{VComp, VNode}; + +use proxmox_yew_comp::Status; +use pwt::css; +use pwt::prelude::*; +use pwt::state::{Loader, SharedState, SharedStateObserver}; +use pwt::widget::canvas::Group; +use pwt::widget::charts::{ + render_point_default, render_tooltip_default, Location, MapPointData, PointsRenderArgs, + WorldMap, WorldPoint, +}; +use pwt::widget::container::span; +use pwt::widget::{error_message, ActionIcon, Column, Container, Fa, Panel, Row, Tooltip}; +use pwt_macros::{builder, widget}; + +use crate::dashboard::loading_column; +use crate::{navigate_to, LoadResult}; + +use pdm_api_types::remotes::RemoteType; +use pdm_api_types::resource::{RemoteInfo, RemoteStatus, ResourcesStatus}; +use pdm_api_types::CachedLocationInfo; +use pdm_api_types::Location as RemoteLocation; + +#[widget(comp=DashboardMapComp, @element)] +#[builder] +#[derive(Properties, PartialEq, Clone)] +pub struct DashboardMap { + status: SharedState>, + locations: SharedState, Error>>, +} + +impl DashboardMap { + pub fn new( + status: SharedState>, + locations: SharedState, Error>>, + ) -> Self { + yew::props!(Self { status, locations }) + } +} + +pub enum Msg { + MapLoaded, + DataChanged, +} + +pub struct DashboardMapComp { + loader: Loader, + points: Vec>, + _status_observer: SharedStateObserver>, + _location_observer: SharedStateObserver, Error>>, +} + +#[derive(PartialEq, Debug)] +struct UniqueRemoteLocation(RemoteLocation, String); + +// lat/long can't be NaN since the config format limits to valid values, so this is ok +impl Eq for UniqueRemoteLocation {} + +impl Hash for UniqueRemoteLocation { + fn hash(&self, state: &mut H) { + self.0.name.hash(state); + self.1.hash(state); + self.0.latitude.to_bits().hash(state); + self.0.longitude.to_bits().hash(state); + } +} + +impl DashboardMapComp { + fn calculate_points(ctx: &Context) -> Vec> { + let read_guard = ctx.props().status.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); + } + }; + + let mut unique_locations: HashMap> = HashMap::new(); + let location_guard = ctx.props().locations.read(); + + if let Some(locations) = &location_guard.data { + for (remote, remote_location) in locations { + for (nodename, node_location) in &remote_location.node_locations { + let unique_location = unique_locations + .entry(UniqueRemoteLocation(node_location.clone(), remote.clone())) + .or_default(); + + unique_location.push(nodename.clone()); + } + } + } + + let mut points = unique_locations + .into_iter() + .map(|(point, members)| { + let UniqueRemoteLocation(location, remote) = point; + + let data = match info_map.get(&remote) { + Some(&info) => info.clone(), + None => RemoteInfo { + name: remote, + ty: RemoteType::Pve, + messages: Vec::new(), + status: RemoteStatus::Unknown, + }, + }; + let data = PoiInfo::new(data, members, location.name); + WorldPoint { + location: Location::new(location.longitude, location.latitude), + data, + } + }) + .collect::>(); + + points.sort_by_key(|loc| loc.data.render_title()); + points + } +} + +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) + }, + "/geojson/world-map.json", + )) + .on_change(ctx.link().callback(|_| Msg::MapLoaded)); + loader.load(); + + let _status_observer = ctx + .props() + .status + .add_listener(ctx.link().callback(|_| Msg::DataChanged)); + + let _location_observer = ctx + .props() + .locations + .add_listener(ctx.link().callback(|_| Msg::DataChanged)); + + let points = Self::calculate_points(ctx); + + Self { + loader, + points, + _status_observer, + _location_observer, + } + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + match msg { + Msg::MapLoaded => {} + Msg::DataChanged => { + self.points = Self::calculate_points(ctx); + } + } + true + } + + fn view(&self, ctx: &Context) -> Html { + let props = ctx.props(); + let loader = self.loader.read(); + + if !props.locations.read().has_data() { + return loading_column().into(); + } + + 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 { + name: Option, + remote: RemoteInfo, + nodes: Vec, +} + +impl std::ops::Deref for PoiInfo { + type Target = RemoteInfo; + + fn deref(&self) -> &Self::Target { + &self.remote + } +} + +impl PoiInfo { + fn new(remote: RemoteInfo, nodes: Vec, name: Option) -> Self { + yew::props!(Self { + name, + remote, + nodes, + }) + } +} + +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.remote.name.clone(); + let (status, status_icon) = match props.remote.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)), + }; + let mut nodes = props.nodes.clone(); + nodes.sort(); + let (node_count, node_hint) = + match (props.remote.ty, nodes.len()) { + (RemoteType::Pve, x) if x > 0 => ( + Some(span(tr!("1 Node" | "{0} Nodes" % x, x))), + Some(Tooltip::new(Fa::new("question-circle")).rich_tip( + Column::new().children(nodes.into_iter().map(|n| span(n).into())), + )), + ), + _ => (None, None), + }; + + let extra_row = match (node_count, props.name.as_ref()) { + (None, None) => None, + (node_count, name) => Some( + Row::new() + .padding_start(1) + .padding_end(4) + .gap(1) + .class(css::FontStyle::BodySmall) + .class(css::AlignItems::Center) + .with_optional_child(name.map(span)) + .with_flex_spacer() + .with_optional_child(node_count) + .with_optional_child(node_hint), + ), + }; + Column::new() + .width(300) + .max_height(300) + .class(css::JustifyContent::Stretch) + .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.remote.name)) + .with_child( + ActionIcon::new("fa fa-chevron-right") + .on_activate(move |_| navigate_to(&link, &remote_name, None)), + ), + ) + .with_optional_child(extra_row) + .with_optional_child( + (!props.remote.messages.is_empty()).then_some( + Column::new() + .padding_top(2) + .children(props.remote.messages.iter().map(|err| { + span(err) + .padding_bottom(1) + .class(css::Overflow::Auto) + .into() + })), + ), + ) + .into() + } +} + +impl MapPointData for PoiInfo { + fn render_title(&self) -> AttrValue { + match &self.name { + Some(name) => format!("{} - {name}", self.remote.name).into(), + None => self.remote.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})")) + } + + fn render_info(args: &PointsRenderArgs) -> Html { + let mut points = args.points.to_vec(); + points.sort_by(|a, b| a.data.render_title().cmp(&b.data.render_title())); + Column::new() + .children( + points + .iter() + // insert a separator in between + .flat_map(|&point| { + [Container::from_tag("hr").into(), point.data.clone().into()] + }) + .skip(1), + ) + .into() + } + + fn render_tooltip(args: &PointsRenderArgs) -> Html { + let mut seen = HashSet::new(); + let mut unique_remotes = args + .points + .iter() + .cloned() + .filter(|point| { + let title = point.data.render_title(); + seen.insert(title) + }) + .collect::>(); + unique_remotes.sort_by(|a, b| a.data.render_title().cmp(&b.data.render_title())); + + let mut new_args = args.clone(); + new_args.points = &unique_remotes; + + render_tooltip_default(&new_args) + } +} + +/// Creates a dashboard panel with a world map +pub fn create_map_panel( + status: SharedState>, + locations: SharedState, Error>>, +) -> Panel { + Panel::new() + .with_child(DashboardMap::new(status.clone(), locations.clone()).flex(1.0)) + .with_optional_child( + status + .read() + .error + .as_ref() + .map(|err| error_message(&format!("status - {err}"))), + ) + .with_optional_child( + locations + .read() + .error + .as_ref() + .map(|err| error_message(&format!("locations - {err}"))), + ) +} 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 3ddc4910..a654d1be 100644 --- a/ui/src/dashboard/view.rs +++ b/ui/src/dashboard/view.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::rc::Rc; use anyhow::Error; @@ -24,10 +25,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; @@ -39,7 +40,7 @@ use pdm_api_types::subscription::RemoteSubscriptions; use pdm_api_types::views::{ RowWidget, TaskSummaryGrouping, ViewConfig, ViewLayout, ViewTemplate, WidgetType, }; -use pdm_api_types::TaskStatistics; +use pdm_api_types::{CachedLocationInfo, TaskStatistics}; use pdm_client::types::TopEntities; use pdm_search::{Search, SearchTerm}; @@ -84,6 +85,7 @@ pub enum LoadingResult { TopEntities(Result), TaskStatistics(Result), SubscriptionInfo(Result, Error>), + Locations(Result, Error>), All, } @@ -123,6 +125,7 @@ struct WidgetRenderArgs { subscriptions: SharedState, Error>>, top_entities: SharedState>, statistics: SharedState>, + locations: SharedState, Error>>, redraw_controller: RedrawController, } @@ -137,6 +140,7 @@ fn render_widget( subscriptions, top_entities, statistics, + locations, redraw_controller, } = render_args; @@ -173,6 +177,7 @@ fn render_widget( resource, remote_type, } => create_gauge_panel(*resource, *remote_type, status), + WidgetType::Map => create_map_panel(status, locations), WidgetType::UnknownWidget { widget_type, .. } => create_unknown_widget_panel(widget_type), }; @@ -251,7 +256,27 @@ impl ViewComp { link.send_message(Msg::LoadingResult(LoadingResult::SubscriptionInfo(res))); }; - join!(status_future, entities_future, tasks_future, subs_future); + let location_future = async { + if required.locations { + let mut params = json!({}); + // max-age for location has a sensible backend default and does not need to be + // updated as often, except if forced + if max_age == 0 { + params["max-age"] = 0.into(); + } + add_view_filter(&mut params); + let res = http_get("/resources/location-info", Some(params)).await; + link.send_message(Msg::LoadingResult(LoadingResult::Locations(res))); + } + }; + + join!( + status_future, + entities_future, + tasks_future, + subs_future, + location_future + ); link.send_message(Msg::LoadingResult(LoadingResult::All)); }); } else { @@ -266,6 +291,7 @@ struct RequiredApiCalls { status: bool, top_entities: bool, task_statistics: bool, + locations: bool, } fn required_api_calls(layout: &ViewLayout) -> RequiredApiCalls { @@ -291,6 +317,10 @@ fn required_api_calls(layout: &ViewLayout) -> RequiredApiCalls { WidgetType::ResourceTree => { // each list must do it itself } + WidgetType::Map => { + api_calls.status = true; + api_calls.locations = true; + } WidgetType::UnknownWidget { .. } => {} } } @@ -338,6 +368,7 @@ impl Component for ViewComp { top_entities: SharedState::new(LoadResult::new()), statistics: SharedState::new(LoadResult::new()), subscriptions: SharedState::new(LoadResult::new()), + locations: SharedState::new(LoadResult::new()), redraw_controller: RedrawController::new(), }, } @@ -360,6 +391,9 @@ impl Component for ViewComp { LoadingResult::SubscriptionInfo(subscriptions) => { self.render_args.subscriptions.write().update(subscriptions); } + LoadingResult::Locations(locations) => { + self.render_args.locations.write().update(locations); + } LoadingResult::All => { self.loading = false; if self.load_finished_time.is_none() { 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