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 v3 6/6] ui: views: add map component
Date: Fri, 22 May 2026 10:34:07 +0200	[thread overview]
Message-ID: <20260522083412.1223719-12-d.csapak@proxmox.com> (raw)
In-Reply-To: <20260522083412.1223719-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/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<RemoteType>,
     },
+    /// 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<LoadResult<ResourcesStatus, Error>>,
+    locations: SharedState<LoadResult<HashMap<String, CachedLocationInfo>, Error>>,
+}
+
+impl DashboardMap {
+    pub fn new(
+        status: SharedState<LoadResult<ResourcesStatus, Error>>,
+        locations: SharedState<LoadResult<HashMap<String, CachedLocationInfo>, Error>>,
+    ) -> Self {
+        yew::props!(Self { status, locations })
+    }
+}
+
+pub enum Msg {
+    MapLoaded,
+    DataChanged,
+}
+
+pub struct DashboardMapComp {
+    loader: Loader<GeoJson>,
+    points: Vec<WorldPoint<PoiInfo>>,
+    _status_observer: SharedStateObserver<LoadResult<ResourcesStatus, Error>>,
+    _location_observer: SharedStateObserver<LoadResult<HashMap<String, CachedLocationInfo>, 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<H: std::hash::Hasher>(&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<Self>) -> Vec<WorldPoint<PoiInfo>> {
+        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<UniqueRemoteLocation, Vec<String>> = 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::<Vec<_>>();
+
+        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>) -> 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<Self>, msg: Self::Message) -> bool {
+        match msg {
+            Msg::MapLoaded => {}
+            Msg::DataChanged => {
+                self.points = Self::calculate_points(ctx);
+            }
+        }
+        true
+    }
+
+    fn view(&self, ctx: &Context<Self>) -> 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<String>,
+    remote: RemoteInfo,
+    nodes: Vec<String>,
+}
+
+impl std::ops::Deref for PoiInfo {
+    type Target = RemoteInfo;
+
+    fn deref(&self) -> &Self::Target {
+        &self.remote
+    }
+}
+
+impl PoiInfo {
+    fn new(remote: RemoteInfo, nodes: Vec<String>, name: Option<String>) -> Self {
+        yew::props!(Self {
+            name,
+            remote,
+            nodes,
+        })
+    }
+}
+
+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.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<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})"))
+    }
+
+    fn render_info(args: &PointsRenderArgs<Self>) -> 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<Self>) -> 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::<Vec<_>>();
+        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<LoadResult<ResourcesStatus, Error>>,
+    locations: SharedState<LoadResult<HashMap<String, CachedLocationInfo>, 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<pdm_client::types::TopEntities, proxmox_client::Error>),
     TaskStatistics(Result<TaskStatistics, Error>),
     SubscriptionInfo(Result<Vec<RemoteSubscriptions>, Error>),
+    Locations(Result<HashMap<String, CachedLocationInfo>, Error>),
     All,
 }
 
@@ -123,6 +125,7 @@ struct WidgetRenderArgs {
     subscriptions: SharedState<LoadResult<Vec<RemoteSubscriptions>, Error>>,
     top_entities: SharedState<LoadResult<TopEntities, proxmox_client::Error>>,
     statistics: SharedState<LoadResult<TaskStatistics, Error>>,
+    locations: SharedState<LoadResult<HashMap<String, CachedLocationInfo>, 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<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-22  8:34 UTC|newest]

Thread overview: 19+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-05-22  8:33 [PATCH datacenter-manager/proxmox-geojson-data/yew-widget-toolkit/yew-widget-toolkit-assets v3 00/11] add a new map widget for custom views Dominik Csapak
2026-05-22  8:33 ` [PATCH yew-widget-toolkit v3 1/3] js-helper: add client-to-svg-coordinate conversion helper Dominik Csapak
2026-05-22  8:33 ` [PATCH yew-widget-toolkit v3 2/3] widget: charts: add interactive Map with zoom/pan and clustering Dominik Csapak
2026-05-22 13:30   ` Shannon Sterz
2026-05-22  8:33 ` [PATCH yew-widget-toolkit v3 3/3] widget: charts: add WorldMap with GeoJSON rendering Dominik Csapak
2026-05-22  8:34 ` [PATCH yew-widget-toolkit-assets v3 1/1] charts: add necessary classes for Map Dominik Csapak
2026-05-22  8:34 ` [PATCH proxmox-geojson-data v3 1/1] initial commit Dominik Csapak
2026-05-22 13:30   ` Shannon Sterz
2026-05-22  8:34 ` [PATCH datacenter-manager v3 1/6] server: pbs client: add node_config method Dominik Csapak
2026-05-22  8:34 ` [PATCH datacenter-manager v3 2/6] lib/api: add 'location-info' api call with cached information Dominik Csapak
2026-05-22 13:30   ` Shannon Sterz
2026-05-22  8:34 ` [PATCH datacenter-manager v3 3/6] lib/api: add new 'remote-list' info to the resource status Dominik Csapak
2026-05-22  8:34 ` [PATCH datacenter-manager v3 4/6] server: serve geojson worldmap Dominik Csapak
2026-05-22  8:34 ` [PATCH datacenter-manager v3 5/6] ui: views: refactor required api call info into struct Dominik Csapak
2026-05-22  8:34 ` Dominik Csapak [this message]
2026-05-22 13:30   ` [PATCH datacenter-manager v3 6/6] ui: views: add map component Shannon Sterz
2026-05-22  9:38 ` [PATCH datacenter-manager/proxmox-geojson-data/yew-widget-toolkit/yew-widget-toolkit-assets v3 00/11] add a new map widget for custom views Thomas Lamprecht
2026-05-22 13:33 ` Shannon Sterz
2026-05-24  2:31 ` applied: " Thomas Lamprecht

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=20260522083412.1223719-12-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