all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH datacenter-manager/proxmox-geojson-data/yew-widget-toolkit/yew-widget-toolkit-assets v3 00/11] add a new map widget for custom views
@ 2026-05-22  8:33 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
                   ` (13 more replies)
  0 siblings, 14 replies; 19+ messages in thread
From: Dominik Csapak @ 2026-05-22  8:33 UTC (permalink / raw)
  To: pdm-devel

This series let's users add location info to remotes (longitude,latitude) and
makes it possible to show them on a map in a custom view.

This map is interactive, so it let's you zoom/pan/etc. (with touch controls too)

It also adds new status info per remote to the resources status api call
so we can show some sensible info on the map (success/warn/error).

For now this is very basic only, but we can extend that in the future by e.g.,
adding a health call for each remote and including this info here.

The source map data is from naturalearthdata.com (data is in the public domain)
and is converted to geojson with 'ogr2ogr' (small script is included)

I used the lowest resolution data which results in a ~350K json (~130K zipped)
which should work in most scenarios (e.g. cpu perf on mid-tier mobiles)

Locations are now taken from PVE/PBS themselves and are cached.
I chose 24 hours as default max-age for the cache for now, but we might
want to increase that to something even higher (maybe a month?).
There is also currently no mechanism that updates the location in the background
automatically, but this can be done in the next version or as a followup
if we can agree on a sensible interval for that. (I'd suggest once a week
or something like that?)

The api call falls always back to the entry in the cache (regardless of age)
if the fetching returns an error, so that the map is always filled with
data if we have it.

This is done since we can assume that most servers won't move around so once
we have the location, this won't change.

If there is a differing opinion or suggestion on any of that, please do tell.

NOTE: the proxmox-geojson-data does not contain the sources yet since it's
too big to send as a patch, so one has to do a 'make natural-earth' there and
commit the shx/shp files once.

changes from v2:
* split the world map out to own repository
* use location stored in pve/pbs instead in the pdm remotes
  (this accounts for most of the changes)
* some smaller fixes, e.g. the map will now zoom to the points if the
  points list changes from empty to non-empty

changes from v1:
* don't include world-map.json file in commit
* fixed an issue with the wrong property name for the remote location in the ui

proxmox-yew-widget-toolkit:

Dominik Csapak (3):
  js-helper: add client-to-svg-coordinate conversion helper
  widget: charts: add interactive Map with zoom/pan and clustering
  widget: charts: add WorldMap with GeoJSON rendering

 Cargo.toml                         |   1 +
 js-helper-module.js                |   7 +
 src/lib.rs                         |   5 +
 src/widget/charts/map/map_point.rs | 116 ++++++
 src/widget/charts/map/mod.rs       | 553 +++++++++++++++++++++++++++++
 src/widget/charts/map/zoom_info.rs | 193 ++++++++++
 src/widget/charts/mod.rs           |   9 +
 src/widget/charts/world_map.rs     | 219 ++++++++++++
 8 files changed, 1103 insertions(+)
 create mode 100644 src/widget/charts/map/map_point.rs
 create mode 100644 src/widget/charts/map/mod.rs
 create mode 100644 src/widget/charts/map/zoom_info.rs
 create mode 100644 src/widget/charts/world_map.rs


proxmox-yew-widget-toolkit-assets:

Dominik Csapak (1):
  charts: add necessary classes for Map

 scss/_charts.scss | 64 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 64 insertions(+)


proxmox-geojson-data:

Dominik Csapak (1):
  initial commit


proxmox-datacenter-manager:

Dominik Csapak (6):
  server: pbs client: add node_config method
  lib/api: add 'location-info' api call with cached information
  lib/api: add new 'remote-list' info to the resource status
  server: serve geojson worldmap
  ui: views: refactor required api call info into struct
  ui: views: add map component

 debian/control                                |   1 +
 lib/pdm-api-types/src/lib.rs                  |  34 ++
 lib/pdm-api-types/src/resource.rs             |  51 +++
 lib/pdm-api-types/src/views.rs                |   2 +
 server/src/api/resources.rs                   | 159 +++++--
 server/src/bin/proxmox-datacenter-api/main.rs |   1 +
 server/src/lib.rs                             |   1 +
 server/src/location_cache.rs                  | 160 +++++++
 server/src/pbs_client.rs                      |   6 +
 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                      |  76 +++-
 ui/src/dashboard/view/row_view.rs             |   1 +
 16 files changed, 863 insertions(+), 45 deletions(-)
 create mode 100644 server/src/location_cache.rs
 create mode 100644 ui/src/dashboard/map.rs


Summary over all repositories:
  25 files changed, 2030 insertions(+), 45 deletions(-)

-- 
Generated by git-murpp 0.8.1




^ permalink raw reply	[flat|nested] 19+ messages in thread

* [PATCH yew-widget-toolkit v3 1/3] js-helper: add client-to-svg-coordinate conversion helper
  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 ` 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
                   ` (12 subsequent siblings)
  13 siblings, 0 replies; 19+ messages in thread
From: Dominik Csapak @ 2026-05-22  8:33 UTC (permalink / raw)
  To: pdm-devel

Doing this in rust would mean we have to expose additional web-sys
features or doing the binding ourselves. Also since error handling for
the necessary code would be longer than the actual two line javascript
code, just have a short helper here.

This will be used in the upcoming Map widget to convert from pointer
position to svg coordinates.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 js-helper-module.js | 7 +++++++
 src/lib.rs          | 5 +++++
 2 files changed, 12 insertions(+)

diff --git a/js-helper-module.js b/js-helper-module.js
index 838c987..53291f3 100644
--- a/js-helper-module.js
+++ b/js-helper-module.js
@@ -31,6 +31,12 @@ function toggle_popover(popover) {
     popover.togglePopover();
 }
 
+function client_to_svg_coords(svg, x, y) {
+    const pt = new DOMPoint(x, y);
+    const svgPt = pt.matrixTransform(svg.getScreenCTM().inverse());
+    return [svgPt.x, svgPt.y];
+}
+
 export {
     test_alert,
     show_dialog,
@@ -39,4 +45,5 @@ export {
     hide_popover,
     show_popover,
     toggle_popover,
+    client_to_svg_coords,
 };
diff --git a/src/lib.rs b/src/lib.rs
index d3ac6ea..bd51b0d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -297,6 +297,7 @@ extern "C" {
     pub fn show_popover(popover: web_sys::Node);
     pub fn hide_popover(popover: web_sys::Node);
     pub fn toggle_popover(popover: web_sys::Node);
+    pub fn client_to_svg_coords(svg: &web_sys::Node, x: f64, y: f64) -> Vec<f64>;
 }
 
 // Create wrapper which panics if called from target_arch!=wasm32
@@ -329,6 +330,10 @@ mod panic_wrapper {
     pub fn toggle_popover(_popover: web_sys::Node) {
         unreachable!()
     }
+    /// Calculate the svg coordinates from viewport ones
+    pub fn client_to_svg_coords(_svg: &web_sys::Node, _x: f64, _y: f64) -> Vec<f64> {
+        unreachable!()
+    }
 }
 
 // some helpers
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [PATCH yew-widget-toolkit v3 2/3] widget: charts: add interactive Map with zoom/pan and clustering
  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 ` 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
                   ` (11 subsequent siblings)
  13 siblings, 1 reply; 19+ messages in thread
From: Dominik Csapak @ 2026-05-22  8:33 UTC (permalink / raw)
  To: pdm-devel

This exposes a generict Map<T> that takes a SVG element (intended to be
the background map) and draws typed Points (MapPoint<T: MapPointData>)
over it. These can implement various render functions for clustered
points.

The Map handles:

* Interaction (zooming, panning, toggling info cards on the points,
  tooltip, etc.), it also supports touch input (pinch zooming and
  panning).
* Rendering of background and points
* Clustering of points depending on the zoom level (combine points that
  are too close)

Shows an interaction panel in the top right to zoom out/in and show the
whole map.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 src/widget/charts/map/map_point.rs | 116 ++++++
 src/widget/charts/map/mod.rs       | 553 +++++++++++++++++++++++++++++
 src/widget/charts/map/zoom_info.rs | 193 ++++++++++
 src/widget/charts/mod.rs           |   6 +
 4 files changed, 868 insertions(+)
 create mode 100644 src/widget/charts/map/map_point.rs
 create mode 100644 src/widget/charts/map/mod.rs
 create mode 100644 src/widget/charts/map/zoom_info.rs

diff --git a/src/widget/charts/map/map_point.rs b/src/widget/charts/map/map_point.rs
new file mode 100644
index 0000000..d5bfedd
--- /dev/null
+++ b/src/widget/charts/map/map_point.rs
@@ -0,0 +1,116 @@
+use crate::prelude::*;
+use crate::widget::canvas::{Circle, Group};
+use crate::widget::charts::map::{Coordinates, MapPoint};
+use crate::widget::{container::span, Column};
+
+/// Contains the points rendered together on the map due to clustering,
+/// and suggested properties such as the radius (calculated from clustering)
+/// as well if the cluster is selected and where it's center is.
+#[derive(Clone)]
+pub struct PointsRenderArgs<'a, T: MapPointData> {
+    pub points: &'a [&'a MapPoint<T>],
+    pub center: Coordinates,
+    pub selected: bool,
+    pub suggested_radius: f64,
+}
+
+/// The default renderer for a map cluster.
+pub fn render_point_default<T: MapPointData>(args: &PointsRenderArgs<T>) -> Group {
+    Group::new()
+        .class("pwt-map-location")
+        .with_child(
+            Circle::new()
+                .style(
+                    "--pwt-location-radius",
+                    format!("{:.3}px", args.suggested_radius),
+                )
+                .cx(args.center.x)
+                .cy(args.center.y),
+        )
+        .with_optional_child(
+            args.selected.then_some(
+                Circle::new()
+                    .class("pwt-map-location-animated")
+                    .style(
+                        "--pwt-location-radius",
+                        format!("{:.3}px", args.suggested_radius),
+                    )
+                    .cx(args.center.x)
+                    .cy(args.center.y),
+            ),
+        )
+}
+
+/// The default info renderer for a map cluster.
+pub fn render_info_default<T: MapPointData>(args: &PointsRenderArgs<T>) -> Html {
+    Column::new()
+        .gap(1)
+        .children(
+            args.points
+                .iter()
+                .map(|point| span(point.data.render_title()).into()),
+        )
+        .into()
+}
+
+/// The default tooltip renderer for a map cluster.
+pub fn render_tooltip_default<T: MapPointData>(args: &PointsRenderArgs<T>) -> Html {
+    let count = args.points.len();
+    if count > 3 {
+        Column::new()
+            .gap(1)
+            .with_child(tr!(
+                "{0} and {1} more",
+                args.points[0].data.render_title(),
+                count - 1
+            ))
+            .into()
+    } else {
+        Column::new()
+            .gap(1)
+            .children(
+                args.points
+                    .iter()
+                    .map(|point| span(point.data.render_title()).into()),
+            )
+            .into()
+    }
+}
+
+pub trait MapPointData: PartialEq + Clone {
+    /// Get the title of the map point
+    fn render_title(&self) -> AttrValue;
+
+    /// Render the map icon for a cluster of points.
+    ///
+    /// Uses [render_point_default] by default.
+    fn render_point(args: &PointsRenderArgs<Self>) -> Group {
+        render_point_default(args)
+    }
+
+    /// Render the info box for a cluster of points.
+    ///
+    /// Uses [render_info_default] by default.
+    fn render_info(args: &PointsRenderArgs<Self>) -> Html {
+        render_info_default(args)
+    }
+
+    /// Render the tooltip for a cluster of points.
+    ///
+    /// Uses [render_tooltip_default] by default.
+    fn render_tooltip(args: &PointsRenderArgs<Self>) -> Html {
+        render_tooltip_default(args)
+    }
+}
+
+impl MapPointData for AttrValue {
+    fn render_title(&self) -> AttrValue {
+        self.clone()
+    }
+}
+
+impl MapPointData for String {
+    fn render_title(&self) -> AttrValue {
+        self.clone().into()
+    }
+}
diff --git a/src/widget/charts/map/mod.rs b/src/widget/charts/map/mod.rs
new file mode 100644
index 0000000..c55a163
--- /dev/null
+++ b/src/widget/charts/map/mod.rs
@@ -0,0 +1,553 @@
+mod map_point;
+pub use map_point::{
+    MapPointData, PointsRenderArgs, render_info_default, render_point_default,
+    render_tooltip_default,
+};
+
+mod zoom_info;
+use zoom_info::ZoomInfo;
+
+use std::marker::PhantomData;
+
+use crate::dom::align::{AlignOptions, align_to, align_to_xy};
+use crate::prelude::*;
+use crate::touch::{GestureDetector, GestureDragEvent, GesturePhase, GesturePinchZoomEvent};
+use crate::widget::canvas::{Canvas, Circle, Group};
+use crate::widget::charts::map::zoom_info::ZoomAction;
+use crate::widget::{Button, Card, Container, Row, SizeObserver, Tooltip};
+use crate::{client_to_svg_coords, css};
+use pwt_macros::{builder, widget};
+
+/// x and y coordinates to represent a position
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Coordinates {
+    pub x: f64,
+    pub y: f64,
+}
+
+/// Represents a point on the map.
+#[derive(Clone, PartialEq)]
+pub struct MapPoint<T> {
+    /// The coordinates of the point on a [Map].
+    pub coordinates: Coordinates,
+    pub data: T,
+}
+
+impl<T> MapPoint<T> {
+    /// Create a new [MapPoint] with the given data and coordinates.
+    pub fn new(coordinates: Coordinates, data: T) -> Self {
+        Self { coordinates, data }
+    }
+}
+
+/// An interactive Map that handles interaction (zooming, panning) and draws
+/// [MapPoint]s on top of the given SVG element.
+///
+/// Can handle touch and mouse input.
+#[widget(pwt=crate,comp=MapComp<T>, @element)]
+#[builder]
+#[derive(Properties, PartialEq, Clone)]
+pub struct Map<T: MapPointData + 'static = AttrValue> {
+    /// The map as an svg element
+    map: Html,
+
+    #[prop_or(1000.0)]
+    #[builder]
+    /// The width of the shown map. Used for coordinates and scaling.
+    width: f64,
+
+    #[prop_or(1000.0)]
+    #[builder]
+    /// The height of the shown map. Used for coordinates and scaling.
+    height: f64,
+
+    #[prop_or_default]
+    #[builder]
+    /// A list of points to highlight on the map.
+    points: Vec<MapPoint<T>>,
+
+    #[prop_or(30.0)]
+    #[builder]
+    /// The maximum zoom level that is allowed.
+    max_zoom_level: f64,
+
+    #[prop_or(8.0)]
+    #[builder]
+    /// The radius for info points.
+    info_point_radius: f64,
+}
+
+impl<T: MapPointData> Map<T> {
+    /// Creates a new interactive map with the given background SVG map element
+    pub fn new(map: impl Into<Html>) -> Self {
+        yew::props!(Self { map: map.into() })
+    }
+}
+
+pub enum Msg {
+    WheelZoom(ZoomAction, i32, i32),
+    ButtonZoom(ZoomAction),
+    PinchZoom(GesturePinchZoomEvent),
+    Resize(f64, f64),
+    Tooltip(Option<(usize, i32, i32)>),
+    ToggleInfo(usize),
+    Drag(GestureDragEvent),
+}
+
+#[derive(PartialEq, Clone)]
+// cache to hold the clustering of the points, can change when the zoom or the fit scale changes
+struct Cluster {
+    center: Coordinates,
+    indices: Vec<usize>,
+}
+
+pub struct MapComp<T: Clone + PartialEq> {
+    zoom: ZoomInfo,
+    pinch_start_scale: f64,
+    pinch_last_center: Coordinates,
+    fit_scale: f64,
+    svg_ref: NodeRef,
+    tooltip: Option<(usize, i32, i32)>,
+    tooltip_ref: NodeRef,
+    info_anchor_ref: NodeRef,
+    info_ref: NodeRef,
+    info_visible: Option<usize>,
+    grab_start: Option<(f64, f64)>,
+    clusters: Vec<Cluster>,
+    _phantom_data: PhantomData<T>,
+}
+
+impl<T: MapPointData + 'static> MapComp<T> {
+    fn create_tooltip(&self, args: &PointsRenderArgs<T>) -> Html {
+        Container::new()
+            .attribute("role", "tooltip")
+            .attribute("aria-live", "polite")
+            .attribute("data-show", Some(""))
+            .class("pwt-tooltip")
+            .class("pwt-tooltip-rich")
+            .with_child(T::render_tooltip(args))
+            .into_html_with_ref(self.tooltip_ref.clone())
+    }
+
+    fn create_info(&self, args: &PointsRenderArgs<T>) -> Html {
+        Card::new()
+            .class("pwt-map-info")
+            .with_child(T::render_info(args))
+            .into_html_with_ref(self.info_ref.clone())
+    }
+
+    fn cluster_points(&mut self, ctx: &Context<Self>) {
+        // simple algorithm to find overlapping points and cluster them together
+
+        let points = &ctx.props().points;
+        let mut indices: Vec<usize> = (0..ctx.props().points.len()).collect();
+
+        let effective_radius = ctx.props().info_point_radius / self.fit_scale;
+        let mut clusters = Vec::new();
+        while let Some(index) = indices.pop() {
+            let base = &points[index];
+            let mut overlapping = Vec::new();
+            let mut non_overlapping = Vec::new();
+            let base_coordinates = self.zoom.map_point(base.coordinates);
+
+            for compare_index in indices.into_iter() {
+                let p = &points[compare_index];
+                let point_coordinates = self.zoom.map_point(p.coordinates);
+                let dx = base_coordinates.x - point_coordinates.x;
+                let dy = base_coordinates.y - point_coordinates.y;
+                if dx * dx + dy * dy < (2.0 * effective_radius).powi(2) {
+                    overlapping.push(compare_index);
+                } else {
+                    non_overlapping.push(compare_index);
+                }
+            }
+            indices = non_overlapping;
+            overlapping.insert(0, index);
+
+            let mut x_center = 0.0;
+            let mut y_center = 0.0;
+            for index in overlapping.iter() {
+                let coordinates = points[*index].coordinates;
+                x_center += coordinates.x;
+                y_center += coordinates.y;
+            }
+            x_center /= overlapping.len() as f64;
+            y_center /= overlapping.len() as f64;
+
+            clusters.push(Cluster {
+                center: Coordinates {
+                    x: x_center,
+                    y: y_center,
+                },
+                indices: overlapping,
+            });
+        }
+
+        if let Some(index) = self.info_visible {
+            // either finds the correct new index to show, or resets the info if
+            // the cluster does not exist anymore
+            self.info_visible = clusters
+                .iter()
+                .position(|indices| *indices == self.clusters[index]);
+        }
+        self.clusters = clusters;
+    }
+}
+
+impl<T: MapPointData + 'static> yew::Component for MapComp<T> {
+    type Message = Msg;
+    type Properties = Map<T>;
+
+    fn create(ctx: &Context<Self>) -> Self {
+        let props = ctx.props();
+        let mut zoom = ZoomInfo::new(props.width, props.height, props.max_zoom_level);
+        zoom.zoom_to_points(
+            ctx.props().points.iter().map(|poi| poi.coordinates),
+            2.0 * props.info_point_radius,
+        );
+
+        let mut this = Self {
+            zoom,
+            pinch_start_scale: 1.0,
+            pinch_last_center: Coordinates { x: 0.0, y: 0.0 },
+            svg_ref: NodeRef::default(),
+            fit_scale: 1.0,
+            tooltip: None,
+            tooltip_ref: NodeRef::default(),
+            info_anchor_ref: NodeRef::default(),
+            info_ref: NodeRef::default(),
+            info_visible: None,
+            grab_start: None,
+            clusters: Vec::new(),
+            _phantom_data: PhantomData::<T>,
+        };
+
+        this.cluster_points(ctx);
+
+        this
+    }
+
+    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+        let props = ctx.props();
+        let width = props.width;
+        let height = props.height;
+        match msg {
+            Msg::WheelZoom(change, x, y) => {
+                let Some(svg) = self.svg_ref.get() else {
+                    return false;
+                };
+                let coords = client_to_svg_coords(&svg, x as f64, y as f64);
+                if self.zoom.update_zoom(change, coords[0], coords[1]) {
+                    self.cluster_points(ctx);
+                }
+            }
+            Msg::ButtonZoom(change) => {
+                if self.zoom.update_zoom(change, width / 2.0, height / 2.0) {
+                    self.cluster_points(ctx);
+                }
+            }
+            Msg::PinchZoom(event) => {
+                let Some(svg) = self.svg_ref.get() else {
+                    return false;
+                };
+                let coords = client_to_svg_coords(
+                    &svg,
+                    (event.point0.x + event.point1.x) as f64 / 2.0,
+                    (event.point0.y + event.point1.y) as f64 / 2.0,
+                );
+                let x = coords[0];
+                let y = coords[1];
+                match event.phase {
+                    GesturePhase::Start => {
+                        self.pinch_start_scale = self.zoom.get_zoom_level();
+                        self.pinch_last_center = Coordinates { x, y };
+                    }
+                    GesturePhase::Update => {
+                        self.zoom
+                            .move_pan(x - self.pinch_last_center.x, y - self.pinch_last_center.y);
+                        self.pinch_last_center = Coordinates { x, y };
+                        self.zoom.update_zoom(
+                            zoom_info::ZoomAction::Scale(self.pinch_start_scale * event.scale),
+                            x,
+                            y,
+                        );
+                    }
+                    GesturePhase::End => return false,
+                }
+            }
+            Msg::Drag(event) => match event.phase {
+                GesturePhase::Start => {
+                    self.grab_start = Some((event.x() as f64, event.y() as f64));
+                }
+                GesturePhase::Update => {
+                    if let Some((start_x, start_y)) = self.grab_start {
+                        let x = event.x() as f64;
+                        let y = event.y() as f64;
+                        self.zoom.move_pan(
+                            (x - start_x) / self.fit_scale,
+                            (y - start_y) / self.fit_scale,
+                        );
+                        self.grab_start = Some((x, y));
+                    }
+                }
+                GesturePhase::End => {
+                    self.grab_start = None;
+                    return false;
+                }
+            },
+            Msg::Resize(real_width, real_height) => {
+                if real_width > 0.0 && real_height > 0.0 {
+                    // use the smaller scale so the whole map fits
+                    self.fit_scale = (real_width / width).min(real_height / height);
+                    self.cluster_points(ctx);
+                }
+            }
+            Msg::Tooltip(index) => {
+                if index.is_none() && self.tooltip.is_none() {
+                    return false;
+                }
+                self.tooltip = index;
+            }
+            Msg::ToggleInfo(index) => {
+                if self.info_visible == Some(index) {
+                    self.info_visible = None;
+                } else {
+                    self.info_visible = Some(index);
+                    if let Some(cluster) = self.clusters.get(index) {
+                        self.zoom.center_point(cluster.center);
+                        self.tooltip = None;
+                    }
+                }
+            }
+        }
+        true
+    }
+
+    fn view(&self, ctx: &Context<Self>) -> Html {
+        let props = ctx.props();
+        let link = ctx.link();
+        let width = props.width;
+        let height = props.height;
+
+        let effective_radius = props.info_point_radius / self.fit_scale;
+
+        let zoom_level = self.zoom.get_zoom_level();
+        let is_zoomed = zoom_level != 1.0;
+        let fully_zoomed = zoom_level >= props.max_zoom_level;
+
+        let svg = Canvas::new()
+            .onwheel({
+                let link = link.clone();
+                move |event: WheelEvent| {
+                    // don't scroll the remaining page when scrolling in map
+                    event.prevent_default();
+                    // ignore delta mode as we zoom in/out in 10% steps later anyway, only the
+                    // direction is relevant here
+                    let (delta, x, y) = (event.delta_y(), event.client_x(), event.client_y());
+                    let action = if delta < 0.0 {
+                        ZoomAction::In
+                    } else {
+                        ZoomAction::Out
+                    };
+                    link.send_message(Msg::WheelZoom(action, x, y));
+                }
+            })
+            .style(
+                "cursor",
+                match (is_zoomed, self.grab_start.is_some()) {
+                    (true, true) => Some("grabbing"),
+                    (true, false) => Some("grab"),
+                    (false, _) => None,
+                },
+            )
+            .class("pwt-map")
+            .attribute("viewBox", format!("0 0 {width} {height}"))
+            .with_child(
+                Group::new()
+                    .with_child(props.map.clone())
+                    .style("transform", self.zoom.get_transform()),
+            );
+
+        let mut points = Group::new();
+        let mut tooltip = None;
+        let mut info = None;
+
+        for (index, cluster) in self.clusters.iter().enumerate() {
+            let len = cluster.indices.len() as f64;
+            let radius_factor = if len > 1.0 {
+                // grow logarithmically but not too big
+                1.0 + len.log2() * 0.3
+            } else {
+                1.0
+            };
+
+            let points_of_interest: Vec<_> = cluster
+                .indices
+                .iter()
+                .map(|index| &ctx.props().points[*index])
+                .collect();
+
+            let center = self.zoom.map_point(cluster.center);
+
+            // don't render points, tooltips and info boxes that are out of bounds
+            if self.zoom.is_out_of_bounds(center) {
+                continue;
+            }
+
+            let args = PointsRenderArgs {
+                points: &points_of_interest,
+                center,
+                selected: self.info_visible == Some(index),
+                suggested_radius: effective_radius * radius_factor,
+            };
+
+            let point = T::render_point(&args)
+                .onpointermove(link.callback(move |event: PointerEvent| {
+                    let x = event.client_x();
+                    let y = event.client_y();
+                    Msg::Tooltip(Some((index, x, y)))
+                }))
+                .onpointerleave(link.callback(move |_| Msg::Tooltip(None)))
+                .onclick(link.callback(move |_| Msg::ToggleInfo(index)));
+            if self.info_visible == Some(index) {
+                points.add_child(point);
+                // ref for info-box
+                points.add_child(
+                    Circle::new()
+                        .cx(center.x)
+                        .cy(center.y)
+                        .r(0)
+                        .into_html_with_ref(self.info_anchor_ref.clone()),
+                );
+                info = Some(self.create_info(&args));
+            } else {
+                points.add_child(point);
+            }
+
+            match &self.tooltip {
+                Some((tooltip_idx, _, _)) if *tooltip_idx == index => {
+                    tooltip = Some(self.create_tooltip(&args));
+                }
+                _ => {}
+            }
+        }
+
+        Container::new()
+            .with_std_props(&props.std_props)
+            .listeners(&props.listeners)
+            .class(css::Display::Block)
+            .with_child(SizeObserver::new(
+                Container::new()
+                    .class(css::Display::Flex)
+                    .class(css::JustifyContent::Center)
+                    .width("100%")
+                    .height("100%")
+                    .with_child(
+                        GestureDetector::new(
+                            svg.with_child(points)
+                                .into_html_with_ref(self.svg_ref.clone()),
+                        )
+                        .on_drag(link.callback(Msg::Drag))
+                        .on_pinch_zoom(link.callback(Msg::PinchZoom)),
+                    ),
+                {
+                    let link = link.clone();
+                    move |(_, _, width, height)| {
+                        link.send_message(Msg::Resize(width, height));
+                    }
+                },
+            ))
+            .with_optional_child(tooltip)
+            .with_optional_child(info)
+            .with_child(
+                Row::new()
+                    .gap(1)
+                    .class("pwt-map-interaction-panel")
+                    .with_child(
+                        Tooltip::new(
+                            Button::new_icon("fa fa-arrows-alt")
+                                .class(css::ColorScheme::Primary)
+                                .disabled(!is_zoomed)
+                                .on_activate(link.callback(|_| Msg::ButtonZoom(ZoomAction::Reset))),
+                        )
+                        .tip(tr!("Show whole map")),
+                    )
+                    .with_child(
+                        Tooltip::new(
+                            Button::new_icon("fa fa-minus")
+                                .class(css::ColorScheme::Primary)
+                                .disabled(!is_zoomed)
+                                .on_activate(link.callback(|_| Msg::ButtonZoom(ZoomAction::Out))),
+                        )
+                        .tip(tr!("Zoom out")),
+                    )
+                    .with_child(
+                        Tooltip::new(
+                            Button::new_icon("fa fa-plus")
+                                .class(css::ColorScheme::Primary)
+                                .disabled(fully_zoomed)
+                                .on_activate(link.callback(|_| Msg::ButtonZoom(ZoomAction::In))),
+                        )
+                        .tip(tr!("Zoom in")),
+                    ),
+            )
+            .into()
+    }
+
+    fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
+        let props = ctx.props();
+        let mut need_clustering = false;
+        if props.width != old_props.width
+            || props.height != old_props.height
+            || props.max_zoom_level != old_props.max_zoom_level
+        {
+            self.zoom = ZoomInfo::new(props.width, props.height, props.max_zoom_level);
+            need_clustering = true;
+        }
+        if props.points != old_props.points {
+            need_clustering = true;
+            if old_props.points.is_empty() && !props.points.is_empty() {
+                self.zoom.zoom_to_points(
+                    ctx.props().points.iter().map(|poi| poi.coordinates),
+                    2.0 * props.info_point_radius,
+                );
+            }
+        }
+
+        if need_clustering {
+            self.cluster_points(ctx);
+        }
+
+        true
+    }
+
+    fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
+        if let &Some((_, x, y)) = &self.tooltip
+            && let Some(el) = self.tooltip_ref.get()
+        {
+            let _ = align_to_xy(
+                el,
+                (x as f64 + 10.0, y as f64 + 10.0),
+                crate::dom::align::Point::TopStart,
+            );
+        }
+        if let (Some(_), Some(anchor), Some(el)) = (
+            self.info_visible,
+            self.info_anchor_ref.get(),
+            self.info_ref.get(),
+        ) {
+            let _ = align_to(
+                anchor,
+                el,
+                Some(
+                    AlignOptions::new(
+                        crate::dom::align::Point::Top,
+                        crate::dom::align::Point::Bottom,
+                        crate::dom::align::GrowDirection::None,
+                    )
+                    .offset(0.0, ctx.props().info_point_radius * 2.0),
+                ),
+            );
+        }
+    }
+}
diff --git a/src/widget/charts/map/zoom_info.rs b/src/widget/charts/map/zoom_info.rs
new file mode 100644
index 0000000..e1b2aef
--- /dev/null
+++ b/src/widget/charts/map/zoom_info.rs
@@ -0,0 +1,193 @@
+use crate::widget::charts::map::Coordinates;
+
+// ratio per zooming step
+const ZOOMING_RATIO: f64 = 1.1;
+
+/// Represents a zooming state
+pub struct ZoomInfo {
+    pan: (f64, f64),
+    user_scale: f64,
+    width: f64,
+    height: f64,
+    max_zoom_level: f64,
+}
+
+/// The zoom action a user can take.
+pub enum ZoomAction {
+    /// Zooms in by a fixed ratio
+    In,
+    /// Zooms out by a fixed ratio
+    Out,
+    /// Resets the zoom level to 1.0
+    Reset,
+    /// Tries to zoom to this level
+    Scale(f64),
+}
+
+impl ZoomInfo {
+    /// Creates a new ZoomInfo instance.
+    pub fn new(width: f64, height: f64, max_zoom_level: f64) -> Self {
+        Self {
+            pan: (0.0, 0.0),
+            user_scale: 1.0,
+            width,
+            height,
+            max_zoom_level,
+        }
+    }
+
+    /// Updates the zoom to or from the given center point.
+    /// Returns true if the scale changed, false otherwise.
+    pub fn update_zoom(&mut self, change: ZoomAction, center_x: f64, center_y: f64) -> bool {
+        let point_x = (center_x - self.pan.0) / self.user_scale;
+        let point_y = (center_y - self.pan.1) / self.user_scale;
+
+        let old_scale = self.user_scale;
+        self.update_scale(change);
+        if old_scale == self.user_scale {
+            return false;
+        }
+
+        self.update_pan(
+            center_x - point_x * self.user_scale,
+            center_y - point_y * self.user_scale,
+        );
+
+        true
+    }
+
+    fn update_pan(&mut self, x: f64, y: f64) {
+        self.pan = (
+            x.max(self.width - self.width * self.user_scale).min(0.0),
+            y.max(self.height - self.height * self.user_scale).min(0.0),
+        );
+    }
+
+    /// Pans the view around by the given diff
+    pub fn move_pan(&mut self, diff_x: f64, diff_y: f64) {
+        self.update_pan(self.pan.0 + diff_x, self.pan.1 + diff_y);
+    }
+
+    fn update_scale(&mut self, change: ZoomAction) {
+        self.user_scale = match change {
+            ZoomAction::In => self.user_scale * ZOOMING_RATIO,
+            ZoomAction::Out => self.user_scale / ZOOMING_RATIO,
+            ZoomAction::Reset => 1.0,
+            ZoomAction::Scale(scale) => scale,
+        }
+        .clamp(1.0, self.max_zoom_level);
+    }
+
+    /// Returns the transform string that pans and scales appropriately.
+    pub fn get_transform(&self) -> String {
+        format!(
+            "translate({:.3}px, {:.3}px) scale({:.3})",
+            self.pan.0, self.pan.1, self.user_scale
+        )
+    }
+
+    /// Maps a coordinate of the original size, to the current zoomed/panned view.
+    pub fn map_point(&self, coordinates: Coordinates) -> Coordinates {
+        let x = coordinates.x * self.user_scale + self.pan.0;
+        let y = coordinates.y * self.user_scale + self.pan.1;
+        Coordinates { x, y }
+    }
+
+    fn zoomed_size(&self) -> (f64, f64) {
+        (self.width / self.user_scale, self.height / self.user_scale)
+    }
+
+    /// Centers the view at the given point
+    pub fn center_point(&mut self, coordinates: Coordinates) {
+        let (width, height) = self.zoomed_size();
+        self.update_pan(
+            -(coordinates.x - width / 2.0) * self.user_scale,
+            -(coordinates.y - height / 2.0) * self.user_scale,
+        );
+    }
+
+    fn zoom_to_point(&mut self, coordinates: Coordinates, width: f64) {
+        self.update_scale(ZoomAction::Scale(self.width / width));
+        self.center_point(coordinates);
+    }
+
+    /// Changes the scale and pan so that all points of the list are included including the padding
+    pub fn zoom_to_points(&mut self, points: impl IntoIterator<Item = Coordinates>, padding: f64) {
+        let points = points.into_iter();
+
+        let mut x_min = f64::INFINITY;
+        let mut x_max = f64::NEG_INFINITY;
+        let mut y_min = f64::INFINITY;
+        let mut y_max = f64::NEG_INFINITY;
+
+        let mut got_points = false;
+
+        for Coordinates { x, y } in points {
+            got_points = true;
+            if x > x_max {
+                x_max = x;
+            }
+            if y > y_max {
+                y_max = y;
+            }
+            if x < x_min {
+                x_min = x;
+            }
+            if y < y_min {
+                y_min = y;
+            }
+        }
+
+        if !got_points {
+            return;
+        }
+
+        let width = x_max - x_min + 2.0 * padding;
+        let height = y_max - y_min + 2.0 * padding;
+
+        let mid_point = Coordinates {
+            x: (x_min + x_max) / 2.0,
+            y: (y_min + y_max) / 2.0,
+        };
+
+        self.zoom_to_point(mid_point, width.max(height * self.width / self.height));
+    }
+
+    /// tests if the given point is outside the current view
+    pub fn is_out_of_bounds(&self, coordinates: Coordinates) -> bool {
+        !(0.0..=self.width).contains(&coordinates.x)
+            || !(0.0..=self.height).contains(&coordinates.y)
+    }
+
+    /// Returns the current zoom level
+    pub fn get_zoom_level(&self) -> f64 {
+        self.user_scale
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::ZoomInfo;
+    use crate::widget::charts::{map::zoom_info::ZoomAction, Coordinates};
+
+    #[test]
+    fn test_zooming() {
+        let mut zoom = ZoomInfo::new(200.0, 200.0, 100.0);
+        let coordinate = Coordinates { x: 100.0, y: 100.0 };
+        assert_eq!(zoom.map_point(coordinate), coordinate);
+        zoom.update_zoom(ZoomAction::In, 50.0, 50.0);
+        assert_eq!(
+            zoom.map_point(coordinate),
+            Coordinates { x: 105.0, y: 105.0 }
+        );
+
+        assert_eq!(zoom.get_zoom_level(), 1.1);
+        zoom.update_zoom(ZoomAction::Scale(10.0), 0.0, 0.0);
+
+        assert_eq!(zoom.get_zoom_level(), 10.0);
+
+        zoom.zoom_to_point(Coordinates { x: 50.0, y: 50.0 }, 100.0);
+
+        assert_eq!(zoom.get_zoom_level(), 2.0);
+    }
+}
diff --git a/src/widget/charts/mod.rs b/src/widget/charts/mod.rs
index 42491f7..f5cd26b 100644
--- a/src/widget/charts/mod.rs
+++ b/src/widget/charts/mod.rs
@@ -1,4 +1,10 @@
 //! Chart components
 
+mod map;
+pub use map::{
+    render_info_default, render_point_default, render_tooltip_default, Coordinates, Map, MapPoint,
+    MapPointData, PointsRenderArgs,
+};
+
 mod pie;
 pub use pie::{LegendPosition, PieChart};
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [PATCH yew-widget-toolkit v3 3/3] widget: charts: add WorldMap with GeoJSON rendering
  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  8:33 ` Dominik Csapak
  2026-05-22  8:34 ` [PATCH yew-widget-toolkit-assets v3 1/1] charts: add necessary classes for Map Dominik Csapak
                   ` (10 subsequent siblings)
  13 siblings, 0 replies; 19+ messages in thread
From: Dominik Csapak @ 2026-05-22  8:33 UTC (permalink / raw)
  To: pdm-devel

This builds on the new Map<T> widget by taking in GeoJSON data,
projecting the lines/polygons to the svgs coordinate system and
providing that to the Map.

In addition it exposes WorldPoint, which is the same as MapPoint, but
instead of Coordinates it uses Location which can be simply converted by
projecting it to the internal coordinate system.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 Cargo.toml                     |   1 +
 src/widget/charts/mod.rs       |   3 +
 src/widget/charts/world_map.rs | 219 +++++++++++++++++++++++++++++++++
 3 files changed, 223 insertions(+)
 create mode 100644 src/widget/charts/world_map.rs

diff --git a/Cargo.toml b/Cargo.toml
index 125867e..6c24f64 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -70,5 +70,6 @@ wasm-bindgen-futures = "0.4"
 url = "2.1"
 percent-encoding = "2.1"
 gettext = "0.4"
+geojson = "0.24"
 
 pwt-macros = { version = "0.5.2", path = "pwt-macros" }
diff --git a/src/widget/charts/mod.rs b/src/widget/charts/mod.rs
index f5cd26b..3c95150 100644
--- a/src/widget/charts/mod.rs
+++ b/src/widget/charts/mod.rs
@@ -8,3 +8,6 @@ pub use map::{
 
 mod pie;
 pub use pie::{LegendPosition, PieChart};
+
+mod world_map;
+pub use world_map::{Location, WorldMap, WorldPoint};
diff --git a/src/widget/charts/world_map.rs b/src/widget/charts/world_map.rs
new file mode 100644
index 0000000..b1b29c9
--- /dev/null
+++ b/src/widget/charts/world_map.rs
@@ -0,0 +1,219 @@
+use std::marker::PhantomData;
+use std::rc::Rc;
+
+use geojson::{GeoJson, Geometry};
+use yew::prelude::*;
+
+use crate::prelude::*;
+use crate::widget::canvas::Path;
+use crate::widget::charts::{Coordinates, Map, MapPoint, MapPointData};
+use pwt_macros::{builder, widget};
+
+// the constant size of the svg viewport. also use to project lon/lat to svg coordinates
+const WIDTH: f64 = 3600.0; // use 10 units per longitude
+const WIDTH_RATIO: f64 = 1.65; // use a ratio that looks a bit more like regular maps
+const HEIGHT: f64 = WIDTH / WIDTH_RATIO;
+
+/// Represents a Location in the world using the geographic coordinate system.
+#[derive(Clone, Copy, PartialEq)]
+pub struct Location {
+    pub longitude: f64,
+    pub latitude: f64,
+}
+
+impl Location {
+    /// Create a new location from longitude and latitude
+    pub fn new(longitude: f64, latitude: f64) -> Self {
+        Self {
+            longitude,
+            latitude,
+        }
+    }
+}
+
+impl From<Location> for Coordinates {
+    fn from(value: Location) -> Self {
+        project(value)
+    }
+}
+
+/// Holds a location and arbitrary data that implements [MapPointData]
+///
+/// Can be converted into a [MapPoint] (its location will be projected to
+/// a coordinate system useful for [WorldMap])
+#[derive(Clone, PartialEq)]
+pub struct WorldPoint<T: MapPointData> {
+    pub location: Location,
+    pub data: T,
+}
+
+impl<T: MapPointData> From<WorldPoint<T>> for MapPoint<T> {
+    fn from(value: WorldPoint<T>) -> Self {
+        MapPoint {
+            coordinates: value.location.into(),
+            data: value.data,
+        }
+    }
+}
+
+/// A world map using GeoJSON data to draw SVG lines and polygons.
+#[widget(pwt=crate,comp=WorldMapComp<T>, @element)]
+#[builder]
+#[derive(Properties, PartialEq, Clone)]
+pub struct WorldMap<T: MapPointData + 'static> {
+    #[prop_or_default]
+    /// A list of points to highlight on the map.
+    points: Vec<MapPoint<T>>,
+
+    map_data: Rc<GeoJson>,
+}
+
+impl<T: MapPointData> WorldMap<T> {
+    /// Creates a new WorldMap, takes the necessary GeoJson as Rc to not copy data unnecessarily
+    /// around
+    pub fn new(map_data: Rc<GeoJson>) -> Self {
+        yew::props!(Self { map_data })
+    }
+
+    /// Set the points of the map. Converts the List into a list of [MapPoint].
+    pub fn set_points(&mut self, points: impl Into<Vec<WorldPoint<T>>>) {
+        self.points = points
+            .into()
+            .into_iter()
+            .map(|point| point.into())
+            .collect();
+    }
+
+    /// Builder style method to set the points of the map. Converts the List into a list of [MapPoint].
+    pub fn points(mut self, points: impl Into<Vec<WorldPoint<T>>>) -> Self {
+        self.set_points(points);
+        self
+    }
+}
+
+pub struct WorldMapComp<T: MapPointData> {
+    path: String,
+    _phantom_data: PhantomData<T>,
+}
+
+fn calculate_path(geojson: &GeoJson) -> String {
+    let mut paths = Vec::new();
+    match geojson {
+        GeoJson::Geometry(geometry) => {
+            paths.append(&mut parse_geometry(geometry));
+        }
+        GeoJson::Feature(feature) => {
+            if let Some(geometry) = &feature.geometry {
+                let mut new_paths = parse_geometry(geometry);
+                paths.append(&mut new_paths);
+            }
+        }
+        GeoJson::FeatureCollection(feature_collection) => {
+            for f in feature_collection {
+                if let Some(geometry) = &f.geometry {
+                    let mut new_paths = parse_geometry(geometry);
+                    paths.append(&mut new_paths);
+                }
+            }
+        }
+    }
+    paths.join(" ")
+}
+
+impl<T: MapPointData + 'static> yew::Component for WorldMapComp<T> {
+    type Message = ();
+    type Properties = WorldMap<T>;
+
+    fn create(ctx: &Context<Self>) -> Self {
+        let path = calculate_path(&ctx.props().map_data);
+        Self {
+            path,
+            _phantom_data: PhantomData::<T>,
+        }
+    }
+
+    fn view(&self, ctx: &Context<Self>) -> Html {
+        let props = ctx.props();
+
+        Map::new(
+            Path::new()
+                .d(self.path.clone())
+                .style("vector-effect", "non-scaling-stroke")
+                .style("stroke-width", "0.2px")
+                .style("fill", "var(--pwt-color-neutral)")
+                .style("stroke", "var(--pwt-color-on-neutral)"),
+        )
+        .with_std_props(&props.std_props)
+        .listeners(&props.listeners)
+        .style("background-color", "var(--pwt-color-surface)")
+        .width(WIDTH)
+        .height(HEIGHT)
+        .points(props.points.clone())
+        .into()
+    }
+
+    fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
+        let props = ctx.props();
+        if props.map_data != old_props.map_data {
+            self.path = calculate_path(&props.map_data);
+        }
+        true
+    }
+}
+
+// equirectangular projection
+//
+// assume each degree takes the same amount of space in both dimensions
+fn project(location: Location) -> Coordinates {
+    Coordinates {
+        x: (location.longitude + 180.0) * (WIDTH / 360.0),
+        y: (90.0 - location.latitude) * (HEIGHT / 180.0),
+    }
+}
+
+fn ring_to_path(coordinates: &[Vec<f64>]) -> String {
+    let mut path = line_to_path(coordinates);
+    path.push('Z');
+    path
+}
+
+fn line_to_path(coordinates: &[Vec<f64>]) -> String {
+    let mut path = String::new();
+    let mut prefix = "M";
+    for list in coordinates {
+        if list.len() < 2 {
+            continue;
+        }
+        let Coordinates { x, y } = project(Location::new(list[0], list[1]));
+        path.push_str(&format!("{prefix}{:.2},{:.2}", x, y));
+        prefix = "L";
+    }
+    path
+}
+
+fn parse_geometry(geometry: &Geometry) -> Vec<String> {
+    let mut paths = Vec::new();
+
+    match &geometry.value {
+        geojson::Value::Polygon(polygon) => {
+            paths.append(&mut polygon.iter().map(|ring| ring_to_path(ring)).collect());
+        }
+        geojson::Value::MultiPolygon(items) => {
+            for poly in items {
+                paths.append(&mut poly.iter().map(|ring| ring_to_path(ring)).collect())
+            }
+        }
+        geojson::Value::LineString(line) => paths.push(line_to_path(line)),
+        geojson::Value::MultiLineString(line) => {
+            paths.append(&mut line.iter().map(|ring| line_to_path(ring)).collect())
+        }
+        geojson::Value::GeometryCollection(items) => {
+            for geom in items {
+                paths.append(&mut parse_geometry(geom));
+            }
+        }
+        geojson::Value::Point(_) => {}
+        geojson::Value::MultiPoint(_) => {}
+    }
+    paths
+}
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [PATCH yew-widget-toolkit-assets v3 1/1] charts: add necessary classes for Map
  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
                   ` (2 preceding siblings ...)
  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 ` Dominik Csapak
  2026-05-22  8:34 ` [PATCH proxmox-geojson-data v3 1/1] initial commit Dominik Csapak
                   ` (9 subsequent siblings)
  13 siblings, 0 replies; 19+ messages in thread
From: Dominik Csapak @ 2026-05-22  8:34 UTC (permalink / raw)
  To: pdm-devel

these classes are used by the new Map<T> widget.

The map contains several widget that need some styling:
* map info: we need to set the z-index and reset transition in case this
  is set somewhere.

* the map itself: needs to disable touch actions and set it's width and
  height to 100%

* the map-location: is a circle with a color specified via the
  'pwt-location-color' variable (so it's easier to set in a nested
  element)

* the interaction-panel: is placed on the top right

* the location-pulse: a small animation that represents loading and
  highlights a selected element.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 scss/_charts.scss | 64 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 64 insertions(+)

diff --git a/scss/_charts.scss b/scss/_charts.scss
index 0f5e87e..30c527c 100644
--- a/scss/_charts.scss
+++ b/scss/_charts.scss
@@ -12,3 +12,67 @@
 .pwt-legend-color {
     color: var(--pwt-legend-color)
 }
+
+/// Class `pwt-map-info`
+///
+/// This is used for the info panel when a point is clicked on a map
+/// Transition is reset so it doesn't inherit it.
+.pwt-map-info {
+    transition: 0s;
+    z-index: 100;
+}
+
+/// Class `pwt-map`
+///
+/// This is used for the base SVG element of the map
+.pwt-map {
+    width: 100%;
+    height: 100%;
+    touch-action: none;
+    display: block;
+}
+
+/// Class `pwt-map-location`
+///
+/// This is used for a location displayed on a `Map`
+/// It uses the `--pwt-location-color` so it can be easily
+/// overwritten via css.
+.pwt-map-location {
+    --pwt-location-color: var(--pwt-color-primary);
+    cursor: pointer;
+
+    & > circle {
+        fill: color-mix(in lab, var(--pwt-location-color), transparent 80%);
+        stroke: var(--pwt-location-color);
+        stroke-width: 2px;
+        r: calc(var(--pwt-location-radius) - 2px);
+        vector-effect: non-scaling-stroke;
+    }
+}
+
+/// Class `pwt-map-interaction-panel`
+///
+/// Is used for positioning the panel containing the interactive elements of
+/// a map
+.pwt-map-interaction-panel {
+    position: absolute;
+    inset: var(--pwt-spacer-2) var(--pwt-spacer-2) auto auto;
+}
+
+/// Class `pwt-map-location-animated`
+///
+/// This is used for animating a selected Location
+.pwt-map-location-animated {
+    animation: pwt-location-pulse 1.2s infinite;
+}
+
+@keyframes pwt-location-pulse {
+    from {
+        r: var(--pwt-location-radius);
+        opacity:100%;
+    }
+    to {
+        r: calc(var(--pwt-location-radius) * 1.5);
+        opacity:0%;
+    }
+}
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [PATCH proxmox-geojson-data v3 1/1] initial commit
  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
                   ` (3 preceding siblings ...)
  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 ` 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
                   ` (8 subsequent siblings)
  13 siblings, 1 reply; 19+ messages in thread
From: Dominik Csapak @ 2026-05-22  8:34 UTC (permalink / raw)
  To: pdm-devel

includes a make target to download the source shp/shx file and the
make targets for building the geojson file and package.

Suggested-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 Makefile             | 92 ++++++++++++++++++++++++++++++++++++++++++++
 debian/changelog     |  5 +++
 debian/control       | 16 ++++++++
 debian/copyright     | 29 ++++++++++++++
 debian/rules         |  4 ++
 debian/source/format |  1 +
 6 files changed, 147 insertions(+)
 create mode 100644 Makefile
 create mode 100644 debian/changelog
 create mode 100644 debian/control
 create mode 100644 debian/copyright
 create mode 100755 debian/rules
 create mode 100644 debian/source/format

diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..8a5cf9d
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,92 @@
+include /usr/share/dpkg/default.mk
+
+PACKAGE=proxmox-geojson-data
+
+BUILDDIR ?= $(PACKAGE)-$(DEB_VERSION_UPSTREAM)
+ORIG_SRC_TAR=$(PACKAGE)_$(DEB_VERSION_UPSTREAM).orig.tar.gz
+
+DEB=$(PACKAGE)_$(DEB_VERSION)_all.deb
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+
+DESTDIR =
+PREFIX = /usr
+OUTDIR = $(PREFIX)/share/$(PACKAGE)
+
+all: world-map.json
+
+NE_REF   := v5.1.2
+NE_DEST  := assets
+NE_BASE  := https://raw.githubusercontent.com/nvkelso/natural-earth-vector/$(NE_REF)
+NE_FILE  := ne_110m_admin_0_countries
+NE_FILES := \
+    $(NE_FILE).shp \
+    $(NE_FILE).shx \
+
+NE_TARGETS := $(addprefix $(NE_DEST)/,$(NE_FILES))
+
+SQL_COMMAND := "SELECT 'land' AS kind, ST_Union(geometry) AS geometry FROM $(NE_FILE) UNION ALL SELECT 'borders' AS kind, ST_Collect(ST_Boundary(geometry)) AS geometry FROM $(NE_FILE)"
+
+.PHONY: natural-earth natural-earth-clean
+natural-earth: $(NE_TARGETS)
+
+$(NE_DEST)/%: | $(NE_DEST)
+	curl -fsSL --retry 3 -o $@ $(NE_BASE)/110m_cultural/$*
+
+$(NE_DEST):
+	mkdir -p $@
+
+natural-earth-clean:
+	rm -f $(NE_TARGETS)
+
+world-map.json: $(NE_TARGETS)
+	ogr2ogr -f "GeoJSON" world-map.json $(NE_DEST)/ne_110m_admin_0_countries.shp -dialect sqlite -sql $(SQL_COMMAND) -lco COORDINATE_PRECISION=4 -nlt MULTILINESTRING
+
+install: world-map.json
+	install -dm0755 $(DESTDIR)$(OUTDIR)
+	install -m0644 world-map.json $(DESTDIR)$(OUTDIR)
+
+
+$(BUILDDIR):
+	rm -rf $@ $@.tmp
+	mkdir -p $@.tmp/
+	cp -a debian/ assets/ Makefile $@.tmp/
+	echo "git clone git://git.proxmox.com/git/$(PACKAGE).git\\ngit checkout $$(git rev-parse HEAD)" \
+	    > $@.tmp/debian/SOURCE
+	mv $@.tmp $@
+
+$(ORIG_SRC_TAR): $(BUILDDIR)
+	tar czf $(ORIG_SRC_TAR) --exclude="$(BUILDDIR)/debian" $(BUILDDIR)
+
+.PHONY: deb
+deb: $(DEB)
+$(DEB): $(BUILDDIR)
+	cd $(BUILDDIR)/; dpkg-buildpackage -b -uc -us
+	lintian $(DEB)
+	@echo $(DEB)
+
+.PHONY: dsc
+dsc: $(BUILDDIR)
+	rm -rf $(DSC) $(BUILDDIR)
+	$(MAKE) $(DSC)
+	lintian $(DSC)
+
+$(DSC): $(BUILDDIR) $(ORIG_SRC_TAR)
+	cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d
+
+sbuild: $(DSC)
+	sbuild $(DSC)
+
+.PHONY: upload
+upload: UPLOAD_DIST ?= $(DEB_DISTRIBUTION)
+upload: $(DEB)
+	tar cf - $(DEB) |ssh -X repoman@repo.proxmox.com -- upload --product pdm --dist $(UPLOAD_DIST) --arch $(DEB_HOST_ARCH)
+
+.PHONY: clean distclean
+distclean: clean
+clean:
+	rm -rf $(PACKAGE)-[0-9]*/
+	rm -f *.deb *.changes *.dsc *.tar.* *.buildinfo *.build
+
+.PHONY: dinstall
+dinstall: deb
+	dpkg -i $(DEB)
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..12664a9
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,5 @@
+proxmox-geojson-data (0.1) trixie; urgency=medium
+
+  * Initial release.
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 21 May 2026 09:46:18 +0200
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..9840882
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,16 @@
+Source: proxmox-geojson-data
+Section: admin
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Rules-Requires-Root: no
+Build-Depends: debhelper-compat (= 13),
+               gdal-bin,
+Standards-Version: 4.7.2
+Homepage: https://www.proxmox.com
+
+Package: proxmox-geojson-data
+Architecture: all
+Depends: ${misc:Depends},
+Description: GeoJSON data for Proxmox products
+ Includes GeoJSON used by Proxmox products, such as a worldmap for Proxmox
+ Datacenter Manager. Original data sourced from https://www.naturalearthdata.com/
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..4199756
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,29 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Source: https://git.proxmox.com/?p=proxmox-geojson-data.git;a=summary
+Upstream-Name: proxmox-geojson-data
+
+Files:
+ assets/*
+Copyright: No copyright claimed. Released into the public domain by the authors.
+License: public-domain
+ These files are in the public domain.
+ Free vector and raster map data @ naturalearthdata.com.
+
+Files:
+ debian/*
+ Makefile
+Copyright:
+ 2026 Proxmox Support Team <support@proxmox.com>
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License along
+ with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..2d33f6a
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,4 @@
+#!/usr/bin/make -f
+
+%:
+	dh $@
diff --git a/debian/source/format b/debian/source/format
new file mode 100644
index 0000000..89ae9db
--- /dev/null
+++ b/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [PATCH datacenter-manager v3 1/6] server: pbs client: add node_config method
  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
                   ` (4 preceding siblings ...)
  2026-05-22  8:34 ` [PATCH proxmox-geojson-data v3 1/1] initial commit Dominik Csapak
@ 2026-05-22  8:34 ` 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
                   ` (7 subsequent siblings)
  13 siblings, 0 replies; 19+ messages in thread
From: Dominik Csapak @ 2026-05-22  8:34 UTC (permalink / raw)
  To: pdm-devel

we need that for getting the location data out of the config of PBS
remotes.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 server/src/pbs_client.rs | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/server/src/pbs_client.rs b/server/src/pbs_client.rs
index c3025091..1b4d0fce 100644
--- a/server/src/pbs_client.rs
+++ b/server/src/pbs_client.rs
@@ -297,6 +297,12 @@ impl PbsClient {
         Ok(self.0.post_without_body(path).await?.expect_json()?.data)
     }
 
+    /// Return the node config of the Proxmox Backup Server instance
+    pub async fn node_config(&self) -> Result<pbs_api_types::NodeConfig, Error> {
+        let path = "/api2/extjs/nodes/localhost/config";
+        Ok(self.0.get(path).await?.expect_json()?.data)
+    }
+
     /// Return the datastore status
     pub async fn datastore_status(
         &self,
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [PATCH datacenter-manager v3 2/6] lib/api: add 'location-info' api call with cached information
  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
                   ` (5 preceding siblings ...)
  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 ` 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
                   ` (6 subsequent siblings)
  13 siblings, 1 reply; 19+ messages in thread
From: Dominik Csapak @ 2026-05-22  8:34 UTC (permalink / raw)
  To: pdm-devel

PVE and PBS remotes can have a location configured, and we want to query
that, so add a cache for this location info and an API call to query it.

Modeled after the other resource api calls (status/subscription) but
lets the location info be optional since this is a relatively new
feature.

The biggest difference is that we try to get a location at all costs, so
if there is no new enough location in the cache we try to update it.

If that fails (e.g. because the remote is offline) we retry the cache
with unlimited 'max-age'. Otherwise there could be a situation where the
location info is so old that offline remotes won't be shown.

Refactor some permission checks from the subscription api call so we can
reuse that.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-api-types/src/lib.rs |  34 ++++++++
 server/src/api/resources.rs  | 120 +++++++++++++++++++++-----
 server/src/lib.rs            |   1 +
 server/src/location_cache.rs | 160 +++++++++++++++++++++++++++++++++++
 4 files changed, 294 insertions(+), 21 deletions(-)
 create mode 100644 server/src/location_cache.rs

diff --git a/lib/pdm-api-types/src/lib.rs b/lib/pdm-api-types/src/lib.rs
index dab0f240..0cef8de9 100644
--- a/lib/pdm-api-types/src/lib.rs
+++ b/lib/pdm-api-types/src/lib.rs
@@ -519,3 +519,37 @@ pub const TASKLOG_DOWNLOAD_PARAM_SCHEMA: Schema = proxmox_schema::BooleanSchema:
 )
 .default(false)
 .schema();
+
+#[api(
+    properties: {
+        latitude: {
+            type: Number,
+            minimum: -90.0,
+            maximum: 90.0,
+        },
+        longitude: {
+            type: Number,
+            minimum: -180.0,
+            maximum: 180.0,
+        },
+    },
+)]
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+/// Represents a physical location
+pub struct Location {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    /// An optional short name/description of the location.
+    pub name: Option<String>,
+    /// The latitude of the location
+    pub latitude: f64,
+    /// The longitude of the location
+    pub longitude: f64,
+}
+
+#[derive(Clone, PartialEq, Serialize, Deserialize)]
+/// Contains (cached) location information about a remote.
+pub struct CachedLocationInfo {
+    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
+    /// The locations of the individual nodes (if not all the same).
+    pub node_locations: HashMap<String, Location>,
+}
diff --git a/server/src/api/resources.rs b/server/src/api/resources.rs
index 1a6a23d9..9ec1ce89 100644
--- a/server/src/api/resources.rs
+++ b/server/src/api/resources.rs
@@ -18,7 +18,7 @@ use pdm_api_types::resource::{
 use pdm_api_types::subscription::{
     NodeSubscriptionInfo, RemoteSubscriptionState, RemoteSubscriptions, SubscriptionLevel,
 };
-use pdm_api_types::{Authid, PRIV_RESOURCE_AUDIT, VIEW_ID_SCHEMA};
+use pdm_api_types::{Authid, CachedLocationInfo, PRIV_RESOURCE_AUDIT, VIEW_ID_SCHEMA};
 use pdm_search::{Search, SearchTerm};
 use proxmox_access_control::CachedUserInfo;
 use proxmox_router::{
@@ -41,6 +41,10 @@ pub const ROUTER: Router = Router::new()
 #[sortable]
 const SUBDIRS: SubdirMap = &sorted!([
     ("list", &Router::new().get(&API_METHOD_GET_RESOURCES)),
+    (
+        "location-info",
+        &Router::new().get(&API_METHOD_GET_LOCATION_INFO)
+    ),
     ("status", &Router::new().get(&API_METHOD_GET_STATUS)),
     (
         "top-entities",
@@ -214,6 +218,29 @@ impl From<RemoteWithResources> for RemoteResources {
     }
 }
 
+fn check_remote_priv(user_info: &CachedUserInfo, auth_id: &Authid, remote: &str) -> bool {
+    user_info
+        .check_privs(auth_id, &["resource", remote], PRIV_RESOURCE_AUDIT, false)
+        .is_ok()
+}
+
+/// When returning true, all remotes are allowed and no per-remote permission check should be
+/// necessary
+fn check_all_remotes_allowed(
+    user_info: &CachedUserInfo,
+    auth_id: &Authid,
+    view: Option<&str>,
+) -> Result<bool, Error> {
+    Ok(if let Some(view) = view {
+        user_info.check_privs(auth_id, &["view", view], PRIV_RESOURCE_AUDIT, false)?;
+        false
+    } else {
+        user_info
+            .check_privs(auth_id, &["resource"], PRIV_RESOURCE_AUDIT, false)
+            .is_ok()
+    })
+}
+
 #[api(
     // FIXME:: see list-like API calls in resource routers, we probably want more fine-grained
     // checks..
@@ -641,34 +668,16 @@ pub async fn get_subscription_status(
         .parse()?;
     let user_info = CachedUserInfo::new()?;
 
-    let allow_all = if let Some(view) = &view {
-        user_info.check_privs(&auth_id, &["view", view], PRIV_RESOURCE_AUDIT, false)?;
-        false
-    } else {
-        user_info
-            .check_privs(&auth_id, &["resource"], PRIV_RESOURCE_AUDIT, false)
-            .is_ok()
-    };
+    let allow_all = check_all_remotes_allowed(&user_info, &auth_id, view.as_deref())?;
 
     let view = views::get_optional_view(view.as_deref())?;
 
-    let check_priv = |remote_name: &str| -> bool {
-        user_info
-            .check_privs(
-                &auth_id,
-                &["resource", remote_name],
-                PRIV_RESOURCE_AUDIT,
-                false,
-            )
-            .is_ok()
-    };
-
     for (remote_name, remote) in remotes_config {
         if let Some(view) = &view {
             if view.can_skip_remote(&remote_name) {
                 continue;
             }
-        } else if !allow_all && !check_priv(&remote_name) {
+        } else if !allow_all && !check_remote_priv(&user_info, &auth_id, &remote_name) {
             continue;
         }
 
@@ -1340,6 +1349,75 @@ fn map_pbs_datastore_status(
     }
 }
 
+#[api(
+    // FIXME:: see list-like API calls in resource routers, we probably want more fine-grained
+    // checks..
+    access: {
+        permission: &Permission::Anybody,
+    },
+    input: {
+        properties: {
+            "max-age": {
+                description: "Maximum age (in seconds) of cached remote resources. If remote is not \
+reachable or returns an error for the location, the last value from the cache will be returned in \
+any case",
+                default: 24*60*60,
+                optional: true,
+            },
+            view: {
+                schema: VIEW_ID_SCHEMA,
+                optional: true,
+            },
+        }
+    },
+)]
+/// Get the location info of the selected view (or all remotes)
+async fn get_location_info(
+    max_age: u64,
+    view: Option<String>,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<HashMap<String, CachedLocationInfo>, Error> {
+    let (remotes_config, _) = pdm_config::remotes::config()?;
+
+    let mut futures = Vec::new();
+
+    let auth_id = rpcenv
+        .get_auth_id()
+        .context("no authid available")?
+        .parse()?;
+    let user_info = CachedUserInfo::new()?;
+
+    let allow_all = check_all_remotes_allowed(&user_info, &auth_id, view.as_deref())?;
+
+    let view = views::get_optional_view(view.as_deref())?;
+
+    for (remote_name, remote) in remotes_config {
+        if let Some(view) = &view {
+            if view.can_skip_remote(&remote_name) {
+                continue;
+            }
+        } else if !allow_all && !check_remote_priv(&user_info, &auth_id, &remote_name) {
+            continue;
+        }
+
+        let future = async move {
+            match crate::location_cache::get_location_info_for_remote(&remote, max_age).await {
+                Ok(Some(info)) => Some((remote_name, info)),
+                Ok(None) => None,
+                Err(err) => {
+                    log::debug!("error on getting location data from cache: {err}");
+                    None
+                }
+            }
+        };
+
+        futures.push(future);
+    }
+
+    let res = join_all(futures).await;
+    Ok(res.into_iter().flatten().collect())
+}
+
 #[cfg(test)]
 mod tests {
     use crate::api::resources::is_remotes_only;
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 89ab3035..941f99f6 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -7,6 +7,7 @@ pub mod auth;
 pub mod context;
 pub mod env;
 pub mod jobstate;
+pub mod location_cache;
 pub mod metric_collection;
 pub mod namespaced_cache;
 pub mod parallel_fetcher;
diff --git a/server/src/location_cache.rs b/server/src/location_cache.rs
new file mode 100644
index 00000000..cce33721
--- /dev/null
+++ b/server/src/location_cache.rs
@@ -0,0 +1,160 @@
+use std::collections::HashMap;
+
+use anyhow::Error;
+use futures::future::join_all;
+
+use proxmox_schema::PropertyString;
+
+use pdm_api_types::remotes::{Remote, RemoteType};
+use pdm_api_types::{CachedLocationInfo, Location};
+use pve_api_types::{NodeConfigLocation, NodeConfigProperty};
+use serde::Deserialize;
+
+use crate::{api_cache, connection};
+
+const LOCATION_STATE_CACHE_KEY: &str = "location-state";
+
+/// Get the location info from a remote, flow is as follows:
+/// * try to get from the cache with `max_age` parameter
+/// * if that fails, try to get updated location value
+/// * if that fails try the cache again with `max_age` set to `u64::MAX`
+///
+/// This is done so we can get a location even if it was not cached within the last
+/// day and the remote is e.g. offline or not reachable.
+pub async fn get_location_info_for_remote(
+    remote: &Remote,
+    max_age: u64,
+) -> Result<Option<CachedLocationInfo>, Error> {
+    if let Some(cached) = get_cached_location_info(&remote.id, max_age).await? {
+        Ok(Some(cached))
+    } else {
+        let location_info = match fetch_remote_location_info(remote).await {
+            Ok(info) => info,
+            Err(err) => {
+                log::debug!(
+                    "error getting location info for {}, falling back to cache: {err}",
+                    remote.id
+                );
+
+                // last resort, if we can't get the location from here, we can't get any
+                get_cached_location_info(&remote.id, u64::MAX).await?
+            }
+        };
+        let info = match location_info {
+            Some(info) => info,
+            None => return Ok(None),
+        };
+        let now = proxmox_time::epoch_i64();
+
+        if let Some(existing_state) =
+            update_cached_location_info(&remote.id, info.clone(), now).await?
+        {
+            // Somebody else updated the cache while we performed the API request,
+            // return the more recent data instead of the data we just fetched.
+            return Ok(Some(existing_state));
+        }
+        Ok(Some(info))
+    }
+}
+
+async fn get_cached_location_info(
+    remote: &str,
+    max_age: u64,
+) -> Result<Option<CachedLocationInfo>, Error> {
+    let cache = api_cache::read_remote(remote).await?;
+    let location_state = cache
+        .get_with_max_age(LOCATION_STATE_CACHE_KEY, max_age as i64)
+        .await
+        .inspect_err(|err| log::error!("could not read location-state from API cache: {err}"))
+        .ok()
+        .flatten();
+
+    Ok(location_state)
+}
+
+async fn update_cached_location_info(
+    remote: &str,
+    info: CachedLocationInfo,
+    now: i64,
+) -> Result<Option<CachedLocationInfo>, Error> {
+    let cache = api_cache::write_remote(remote).await?;
+
+    Ok(cache
+        .set_if_newer_with_timestamp(LOCATION_STATE_CACHE_KEY, info, now)
+        .await?)
+}
+
+#[derive(Deserialize)]
+struct DataCenterOptions {
+    location: Option<PropertyString<Location>>,
+}
+
+async fn fetch_remote_location_info(remote: &Remote) -> Result<Option<CachedLocationInfo>, Error> {
+    match remote.ty {
+        RemoteType::Pve => {
+            let client = connection::make_pve_client(remote)?;
+
+            // first, get datacenter location
+            let cluster_options: DataCenterOptions =
+                serde_json::from_value(client.cluster_options().await?)?;
+            let location = cluster_options.location.map(|loc| loc.into_inner());
+
+            // then get the individual node locations
+            let mut node_locations = HashMap::new();
+            let nodes = client.list_nodes().await?;
+            let mut futures = Vec::with_capacity(nodes.len());
+            for node in nodes.iter() {
+                let future = client.node_config(&node.node, Some(NodeConfigProperty::Location));
+                futures.push(async move { (node.node.clone(), future.await) });
+            }
+
+            for (node_name, remote_info) in join_all(futures).await {
+                let mut node_location = None;
+                let remote_info = remote_info?;
+                if let Some(location) = remote_info.location {
+                    if let Ok(location) = location.parse::<PropertyString<NodeConfigLocation>>() {
+                        let location = location.into_inner();
+                        node_location = Some(Location {
+                            name: location.name,
+                            latitude: location.latitude,
+                            longitude: location.longitude,
+                        });
+                    }
+                }
+
+                match (node_location, &location) {
+                    (Some(location), _) => {
+                        node_locations.insert(node_name, location);
+                    }
+                    (None, Some(location)) => {
+                        node_locations.insert(node_name, location.clone());
+                    }
+                    _ => {}
+                }
+            }
+            if node_locations.is_empty() {
+                Ok(None)
+            } else {
+                Ok(Some(CachedLocationInfo { node_locations }))
+            }
+        }
+        RemoteType::Pbs => {
+            let client = connection::make_pbs_client(remote)?;
+            let loc = client.node_config().await?.location.map(|location| {
+                let location = location.into_inner();
+                let mut node_locations = HashMap::new();
+                node_locations.insert(
+                    "localhost".to_string(),
+                    Location {
+                        name: location.name,
+                        latitude: location.latitude,
+                        longitude: location.longitude,
+                    },
+                );
+                CachedLocationInfo { node_locations }
+            });
+
+            Ok(loc)
+        }
+    }
+}
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [PATCH datacenter-manager v3 3/6] lib/api: add new 'remote-list' info to the resource status
  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
                   ` (6 preceding siblings ...)
  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  8:34 ` Dominik Csapak
  2026-05-22  8:34 ` [PATCH datacenter-manager v3 4/6] server: serve geojson worldmap Dominik Csapak
                   ` (5 subsequent siblings)
  13 siblings, 0 replies; 19+ messages in thread
From: Dominik Csapak @ 2026-05-22  8:34 UTC (permalink / raw)
  To: pdm-devel

this will be used to show more status information for each remote
on the views. Put it there since we have many infos to aggregate a
status and we have to iterate over the remotes anyway here.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-api-types/src/resource.rs | 51 +++++++++++++++++++++++++++++++
 server/src/api/resources.rs       | 39 ++++++++++++++++++++---
 2 files changed, 85 insertions(+), 5 deletions(-)

diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
index 99a86364..120dcc03 100644
--- a/lib/pdm-api-types/src/resource.rs
+++ b/lib/pdm-api-types/src/resource.rs
@@ -701,6 +701,12 @@ pub struct CpuStatistics {
             items: {
                 type: FailedRemote,
             },
+        },
+        "remote-list": {
+            type: Array,
+            items: {
+                type: RemoteInfo,
+            },
         }
     }
 )]
@@ -741,6 +747,51 @@ pub struct ResourcesStatus {
     /// List of the failed remotes including type and error
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     pub failed_remotes_list: Vec<FailedRemote>,
+
+    /// List of remote info
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    #[serde(rename = "remote-list")]
+    pub remote_list: Vec<RemoteInfo>,
+}
+
+#[api]
+#[derive(Clone, PartialEq, Serialize, Deserialize, Default)]
+/// Basic remote status
+pub enum RemoteStatus {
+    /// Remote is healthy and reachable
+    Good,
+    /// Remote has at least one (non-fatal) issue
+    Warning,
+    /// Remote can't be reached or has a fatal error
+    Error,
+    #[default]
+    /// Unknown status of a remote
+    Unknown,
+}
+
+#[api(
+    properties: {
+        messages: {
+            type: Array,
+            items: {
+                description: "A warning or error message",
+                type: String,
+            },
+        },
+    },
+)]
+#[derive(Clone, PartialEq, Serialize, Deserialize, Default)]
+/// Basic information about a remote
+pub struct RemoteInfo {
+    /// The name of the remote
+    pub name: String,
+    /// The type of remote
+    pub ty: RemoteType,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    /// The error or warning messages when the state is not good.
+    pub messages: Vec<String>,
+    /// The overall status of the remote
+    pub status: RemoteStatus,
 }
 
 #[api]
diff --git a/server/src/api/resources.rs b/server/src/api/resources.rs
index 9ec1ce89..e21fa90d 100644
--- a/server/src/api/resources.rs
+++ b/server/src/api/resources.rs
@@ -12,8 +12,8 @@ use pdm_api_types::remotes::{Remote, RemoteType};
 use pdm_api_types::resource::{
     FailedRemote, NetworkFabricResource, NetworkZoneResource, PbsDatastoreResource,
     PbsNodeResource, PveLxcResource, PveNetworkResource, PveNodeResource, PveQemuResource,
-    PveStorageResource, RemoteResources, Resource, ResourceType, ResourcesStatus, SdnStatus,
-    TopEntities, PBS_DATASTORE_HIGH_USAGE_THRESHOLD,
+    PveStorageResource, RemoteInfo, RemoteResources, RemoteStatus, Resource, ResourceType,
+    ResourcesStatus, SdnStatus, TopEntities, PBS_DATASTORE_HIGH_USAGE_THRESHOLD,
 };
 use pdm_api_types::subscription::{
     NodeSubscriptionInfo, RemoteSubscriptionState, RemoteSubscriptions, SubscriptionLevel,
@@ -497,16 +497,27 @@ pub async fn get_status(
     let mut counts = ResourcesStatus::default();
     let mut pve_cpu_allocated = 0.0;
     for remote_with_resources in remotes_with_resources {
-        if let Some(err) = remote_with_resources.error {
+        if let Some(err) = &remote_with_resources.error {
             counts.failed_remotes += 1;
             counts.failed_remotes_list.push(FailedRemote {
-                name: remote_with_resources.remote_name,
+                name: remote_with_resources.remote_name.clone(),
                 error: err.to_string(),
                 remote_type: remote_with_resources.remote.ty,
             });
         } else {
             counts.remotes += 1;
         }
+
+        let mut remote_status = if remote_with_resources.error.is_some() {
+            RemoteStatus::Error
+        } else {
+            RemoteStatus::Good
+        };
+        let mut remote_messages = match remote_with_resources.error {
+            Some(error) => vec![error],
+            None => Vec::new(),
+        };
+
         let mut seen_storages = HashSet::new();
         for resource in remote_with_resources.resources {
             match resource {
@@ -543,7 +554,13 @@ pub async fn get_status(
                 Resource::PveNode(r) => {
                     match r.status.as_str() {
                         "online" => counts.pve_nodes.online += 1,
-                        "offline" => counts.pve_nodes.offline += 1,
+                        "offline" => {
+                            if remote_status == RemoteStatus::Good {
+                                remote_status = RemoteStatus::Warning;
+                            }
+                            remote_messages.push(format!("Node '{}' is offline", r.node));
+                            counts.pve_nodes.offline += 1
+                        }
                         _ => counts.pve_nodes.unknown += 1,
                     }
                     counts.pve_cpu_stats.used += r.cpu * r.maxcpu;
@@ -560,6 +577,11 @@ pub async fn get_status(
                                 counts.sdn_zones.available += 1;
                             }
                             SdnStatus::Error => {
+                                if remote_status == RemoteStatus::Good {
+                                    remote_status = RemoteStatus::Warning;
+                                }
+                                remote_messages
+                                    .push(format!("SDN zone '{}' has an error", zone.network));
                                 counts.sdn_zones.error += 1;
                             }
                             SdnStatus::Pending => {
@@ -613,6 +635,13 @@ pub async fn get_status(
                 }
             }
         }
+
+        counts.remote_list.push(RemoteInfo {
+            name: remote_with_resources.remote_name,
+            ty: remote_with_resources.remote.ty,
+            status: remote_status,
+            messages: remote_messages,
+        });
     }
 
     counts.pve_cpu_stats.allocated = Some(pve_cpu_allocated);
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [PATCH datacenter-manager v3 4/6] server: serve geojson worldmap
  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
                   ` (7 preceding siblings ...)
  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 ` Dominik Csapak
  2026-05-22  8:34 ` [PATCH datacenter-manager v3 5/6] ui: views: refactor required api call info into struct Dominik Csapak
                   ` (4 subsequent siblings)
  13 siblings, 0 replies; 19+ messages in thread
From: Dominik Csapak @ 2026-05-22  8:34 UTC (permalink / raw)
  To: pdm-devel

this will be used by the ui to show a world map with remote information

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 debian/control                                | 1 +
 server/src/bin/proxmox-datacenter-api/main.rs | 1 +
 2 files changed, 2 insertions(+)

diff --git a/debian/control b/debian/control
index 154622a2..c0bdb0ae 100644
--- a/debian/control
+++ b/debian/control
@@ -163,6 +163,7 @@ Depends: libproxmox-acme-plugins,
          proxmox-datacenter-manager-docs,
          proxmox-mini-journalreader,
          proxmox-termproxy,
+         proxmox-geojson-data,
          pve-xtermjs,
          ${misc:Depends},
          ${shlibs:Depends},
diff --git a/server/src/bin/proxmox-datacenter-api/main.rs b/server/src/bin/proxmox-datacenter-api/main.rs
index 524a4b51..ee146f50 100644
--- a/server/src/bin/proxmox-datacenter-api/main.rs
+++ b/server/src/bin/proxmox-datacenter-api/main.rs
@@ -168,6 +168,7 @@ async fn run(debug: bool) -> Result<(), Error> {
             ("xtermjs", "/usr/share/pve-xtermjs"),
             ("locale", "/usr/share/pdm-i18n"),
             ("docs", "/usr/share/doc/proxmox-datacenter-manager/html"),
+            ("geojson", "/usr/share/proxmox-geojson-data"),
         ])
         .formatted_router(&["api2"], &server::api::ROUTER)
         // FIXME: disabled for testing on pure debian
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [PATCH datacenter-manager v3 5/6] ui: views: refactor required api call info into struct
  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
                   ` (8 preceding siblings ...)
  2026-05-22  8:34 ` [PATCH datacenter-manager v3 4/6] server: serve geojson worldmap Dominik Csapak
@ 2026-05-22  8:34 ` Dominik Csapak
  2026-05-22  8:34 ` [PATCH datacenter-manager v3 6/6] ui: views: add map component Dominik Csapak
                   ` (3 subsequent siblings)
  13 siblings, 0 replies; 19+ messages in thread
From: Dominik Csapak @ 2026-05-22  8:34 UTC (permalink / raw)
  To: pdm-devel

Returning multiple bools is very confusing and error prone, so wrap them
in a local struct that has properly named fields.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/dashboard/view.rs | 30 +++++++++++++++++-------------
 1 file changed, 17 insertions(+), 13 deletions(-)

diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index bdf92bf6..3ddc4910 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -198,7 +198,7 @@ impl ViewComp {
         if let Some(data) = self.template.data.as_ref() {
             let link = ctx.link().clone();
             let (_, since) = get_task_options(self.refresh_config.task_last_hours);
-            let (status, top_entities, tasks) = required_api_calls(&data.layout);
+            let required = required_api_calls(&data.layout);
 
             self.loading = true;
             let view = ctx.props().view.clone();
@@ -209,7 +209,7 @@ impl ViewComp {
                     }
                 };
                 let status_future = async {
-                    if status {
+                    if required.status {
                         let mut params = json!({
                             "max-age": max_age,
                         });
@@ -220,7 +220,7 @@ impl ViewComp {
                 };
 
                 let entities_future = async {
-                    if top_entities {
+                    if required.top_entities {
                         let client: pdm_client::PdmClient<Rc<proxmox_yew_comp::HttpClientWasm>> =
                             pdm_client();
                         let res = client
@@ -231,7 +231,7 @@ impl ViewComp {
                 };
 
                 let tasks_future = async {
-                    if tasks {
+                    if required.task_statistics {
                         let mut params = json!({
                             "since": since,
                             "limit": 0,
@@ -261,11 +261,15 @@ impl ViewComp {
     }
 }
 
-// returns which api calls are required: status, top_entities, task statistics
-fn required_api_calls(layout: &ViewLayout) -> (bool, bool, bool) {
-    let mut status = false;
-    let mut top_entities = false;
-    let mut task_statistics = false;
+#[derive(Default)]
+struct RequiredApiCalls {
+    status: bool,
+    top_entities: bool,
+    task_statistics: bool,
+}
+
+fn required_api_calls(layout: &ViewLayout) -> RequiredApiCalls {
+    let mut api_calls = RequiredApiCalls::default();
     match layout {
         ViewLayout::Rows { rows } => {
             for row in rows {
@@ -277,13 +281,13 @@ fn required_api_calls(layout: &ViewLayout) -> (bool, bool, bool) {
                         | WidgetType::Sdn
                         | WidgetType::PbsDatastores
                         | WidgetType::NodeResourceGauge { .. } => {
-                            status = true;
+                            api_calls.status = true;
                         }
                         WidgetType::Subscription => {
                             // panel does it itself, it's always required anyway
                         }
-                        WidgetType::Leaderboard { .. } => top_entities = true,
-                        WidgetType::TaskSummary { .. } => task_statistics = true,
+                        WidgetType::Leaderboard { .. } => api_calls.top_entities = true,
+                        WidgetType::TaskSummary { .. } => api_calls.task_statistics = true,
                         WidgetType::ResourceTree => {
                             // each list must do it itself
                         }
@@ -294,7 +298,7 @@ fn required_api_calls(layout: &ViewLayout) -> (bool, bool, bool) {
         }
     }
 
-    (status, top_entities, task_statistics)
+    api_calls
 }
 
 impl Component for ViewComp {
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 19+ messages in thread

* [PATCH datacenter-manager v3 6/6] ui: views: add map component
  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
                   ` (9 preceding siblings ...)
  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
  2026-05-22 13:30   ` 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
                   ` (2 subsequent siblings)
  13 siblings, 1 reply; 19+ messages in thread
From: Dominik Csapak @ 2026-05-22  8:34 UTC (permalink / raw)
  To: pdm-devel

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





^ permalink raw reply related	[flat|nested] 19+ messages in thread

* Re: [PATCH datacenter-manager/proxmox-geojson-data/yew-widget-toolkit/yew-widget-toolkit-assets v3 00/11] add a new map widget for custom views
  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
                   ` (10 preceding siblings ...)
  2026-05-22  8:34 ` [PATCH datacenter-manager v3 6/6] ui: views: add map component Dominik Csapak
@ 2026-05-22  9:38 ` Thomas Lamprecht
  2026-05-22 13:33 ` Shannon Sterz
  2026-05-24  2:31 ` applied: " Thomas Lamprecht
  13 siblings, 0 replies; 19+ messages in thread
From: Thomas Lamprecht @ 2026-05-22  9:38 UTC (permalink / raw)
  To: Dominik Csapak, pdm-devel

Am 22.05.26 um 10:34 schrieb Dominik Csapak:
> Locations are now taken from PVE/PBS themselves and are cached.
> I chose 24 hours as default max-age for the cache for now, but we might
> want to increase that to something even higher (maybe a month?).
> There is also currently no mechanism that updates the location in the background
> automatically, but this can be done in the next version or as a followup
> if we can agree on a sensible interval for that. (I'd suggest once a week
> or something like that?)

I mean, that data is in fast to parse "hot" configs tracked by pmxcfs, so
I'd not bother to much with an overly long caching policy, even "just" an
hour would be fine here, especially as we can also pull other data from
those configs while at it, at least for PVE (descriptions, acme domains,
...?). OTOH, this is probably something where one wants like 10s or less
if one actually is in process to set that up initially and after that,
weeks. months or just opportunistically (if that config needs to be
fetched for something else) in between would be fine.

> If there is a differing opinion or suggestion on any of that, please do tell.

Fine for the MVP for now. A force-refresh for a remote or all remotes is
basically the only thing needed that gives admin an escape hatch. As IMO,
one normally detects that it's wrong when checking that map, so one fixes
the data and rechecks the map, if it then needs 24h or 1h or 1 week doesn't
matter, as that's all to slow for that operation. With a force reload we
can make the period of the reload matter way less.

Anyway, I'll try to look at this in detail today, starting out with
proxmox-geojson-data package.




^ permalink raw reply	[flat|nested] 19+ messages in thread

* Re: [PATCH yew-widget-toolkit v3 2/3] widget: charts: add interactive Map with zoom/pan and clustering
  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
  0 siblings, 0 replies; 19+ messages in thread
From: Shannon Sterz @ 2026-05-22 13:30 UTC (permalink / raw)
  To: Dominik Csapak, pdm-devel

On Fri May 22, 2026 at 10:33 AM CEST, Dominik Csapak wrote:
> This exposes a generict Map<T> that takes a SVG element (intended to be
> the background map) and draws typed Points (MapPoint<T: MapPointData>)
> over it. These can implement various render functions for clustered
> points.
>
> The Map handles:
>
> * Interaction (zooming, panning, toggling info cards on the points,
>   tooltip, etc.), it also supports touch input (pinch zooming and
>   panning).
> * Rendering of background and points
> * Clustering of points depending on the zoom level (combine points that
>   are too close)
>
> Shows an interaction panel in the top right to zoom out/in and show the
> whole map.
>
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
>  src/widget/charts/map/map_point.rs | 116 ++++++
>  src/widget/charts/map/mod.rs       | 553 +++++++++++++++++++++++++++++
>  src/widget/charts/map/zoom_info.rs | 193 ++++++++++
>  src/widget/charts/mod.rs           |   6 +
>  4 files changed, 868 insertions(+)
>  create mode 100644 src/widget/charts/map/map_point.rs
>  create mode 100644 src/widget/charts/map/mod.rs
>  create mode 100644 src/widget/charts/map/zoom_info.rs
>
> diff --git a/src/widget/charts/map/map_point.rs b/src/widget/charts/map/map_point.rs
> new file mode 100644
> index 0000000..d5bfedd
> --- /dev/null
> +++ b/src/widget/charts/map/map_point.rs
> @@ -0,0 +1,116 @@
> +use crate::prelude::*;
> +use crate::widget::canvas::{Circle, Group};
> +use crate::widget::charts::map::{Coordinates, MapPoint};
> +use crate::widget::{container::span, Column};
> +

-->8 snip 8<--

> +
> +pub trait MapPointData: PartialEq + Clone {

nit: i think this public trait could use a doc comment.

> +    /// Get the title of the map point
> +    fn render_title(&self) -> AttrValue;
> +
> +    /// Render the map icon for a cluster of points.
> +    ///
> +    /// Uses [render_point_default] by default.
> +    fn render_point(args: &PointsRenderArgs<Self>) -> Group {
> +        render_point_default(args)
> +    }
> +
> +    /// Render the info box for a cluster of points.
> +    ///
> +    /// Uses [render_info_default] by default.
> +    fn render_info(args: &PointsRenderArgs<Self>) -> Html {
> +        render_info_default(args)
> +    }
> +
> +    /// Render the tooltip for a cluster of points.
> +    ///
> +    /// Uses [render_tooltip_default] by default.
> +    fn render_tooltip(args: &PointsRenderArgs<Self>) -> Html {
> +        render_tooltip_default(args)
> +    }
> +}
> +
> +impl MapPointData for AttrValue {
> +    fn render_title(&self) -> AttrValue {
> +        self.clone()
> +    }
> +}
> +
> +impl MapPointData for String {
> +    fn render_title(&self) -> AttrValue {
> +        self.clone().into()
> +    }
> +}
> diff --git a/src/widget/charts/map/mod.rs b/src/widget/charts/map/mod.rs
> new file mode 100644
> index 0000000..c55a163
> --- /dev/null
> +++ b/src/widget/charts/map/mod.rs
> @@ -0,0 +1,553 @@
> +mod map_point;
> +pub use map_point::{
> +    MapPointData, PointsRenderArgs, render_info_default, render_point_default,
> +    render_tooltip_default,
> +};
> +
> +mod zoom_info;
> +use zoom_info::ZoomInfo;
> +
> +use std::marker::PhantomData;

nit: should be first dependency if we follow our usual order

> +
> +use crate::dom::align::{AlignOptions, align_to, align_to_xy};
> +use crate::prelude::*;
> +use crate::touch::{GestureDetector, GestureDragEvent, GesturePhase, GesturePinchZoomEvent};
> +use crate::widget::canvas::{Canvas, Circle, Group};
> +use crate::widget::charts::map::zoom_info::ZoomAction;
> +use crate::widget::{Button, Card, Container, Row, SizeObserver, Tooltip};
> +use crate::{client_to_svg_coords, css};
> +use pwt_macros::{builder, widget};
> +
> +/// x and y coordinates to represent a position
> +#[derive(Debug, Clone, Copy, PartialEq)]
> +pub struct Coordinates {
> +    pub x: f64,
> +    pub y: f64,
> +}
> +
> +/// Represents a point on the map.
> +#[derive(Clone, PartialEq)]
> +pub struct MapPoint<T> {
> +    /// The coordinates of the point on a [Map].
> +    pub coordinates: Coordinates,
> +    pub data: T,
> +}
> +
> +impl<T> MapPoint<T> {
> +    /// Create a new [MapPoint] with the given data and coordinates.
> +    pub fn new(coordinates: Coordinates, data: T) -> Self {
> +        Self { coordinates, data }
> +    }
> +}
> +
> +/// An interactive Map that handles interaction (zooming, panning) and draws
> +/// [MapPoint]s on top of the given SVG element.
> +///
> +/// Can handle touch and mouse input.
> +#[widget(pwt=crate,comp=MapComp<T>, @element)]
> +#[builder]
> +#[derive(Properties, PartialEq, Clone)]
> +pub struct Map<T: MapPointData + 'static = AttrValue> {
> +    /// The map as an svg element

nit: SVG is an abbreviation here and should be capitalized, also missing
period here

> +    map: Html,
> +
> +    #[prop_or(1000.0)]
> +    #[builder]
> +    /// The width of the shown map. Used for coordinates and scaling.
> +    width: f64,
> +
> +    #[prop_or(1000.0)]
> +    #[builder]
> +    /// The height of the shown map. Used for coordinates and scaling.
> +    height: f64,
> +
> +    #[prop_or_default]
> +    #[builder]
> +    /// A list of points to highlight on the map.
> +    points: Vec<MapPoint<T>>,
> +
> +    #[prop_or(30.0)]
> +    #[builder]
> +    /// The maximum zoom level that is allowed.
> +    max_zoom_level: f64,
> +
> +    #[prop_or(8.0)]
> +    #[builder]
> +    /// The radius for info points.
> +    info_point_radius: f64,
> +}
> +

-->8 snip 8<--

> +impl<T: MapPointData + 'static> MapComp<T> {
> +    fn create_tooltip(&self, args: &PointsRenderArgs<T>) -> Html {
> +        Container::new()
> +            .attribute("role", "tooltip")
> +            .attribute("aria-live", "polite")
> +            .attribute("data-show", Some(""))
> +            .class("pwt-tooltip")
> +            .class("pwt-tooltip-rich")
> +            .with_child(T::render_tooltip(args))
> +            .into_html_with_ref(self.tooltip_ref.clone())
> +    }
> +
> +    fn create_info(&self, args: &PointsRenderArgs<T>) -> Html {
> +        Card::new()
> +            .class("pwt-map-info")
> +            .with_child(T::render_info(args))
> +            .into_html_with_ref(self.info_ref.clone())
> +    }
> +
> +    fn cluster_points(&mut self, ctx: &Context<Self>) {
> +        // simple algorithm to find overlapping points and cluster them together
> +
> +        let points = &ctx.props().points;
> +        let mut indices: Vec<usize> = (0..ctx.props().points.len()).collect();
> +
> +        let effective_radius = ctx.props().info_point_radius / self.fit_scale;
> +        let mut clusters = Vec::new();
> +        while let Some(index) = indices.pop() {
> +            let base = &points[index];
> +            let mut overlapping = Vec::new();
> +            let mut non_overlapping = Vec::new();
> +            let base_coordinates = self.zoom.map_point(base.coordinates);
> +
> +            for compare_index in indices.into_iter() {
> +                let p = &points[compare_index];
> +                let point_coordinates = self.zoom.map_point(p.coordinates);
> +                let dx = base_coordinates.x - point_coordinates.x;
> +                let dy = base_coordinates.y - point_coordinates.y;
> +                if dx * dx + dy * dy < (2.0 * effective_radius).powi(2) {
> +                    overlapping.push(compare_index);
> +                } else {
> +                    non_overlapping.push(compare_index);
> +                }
> +            }
> +            indices = non_overlapping;
> +            overlapping.insert(0, index);
> +
> +            let mut x_center = 0.0;
> +            let mut y_center = 0.0;
> +            for index in overlapping.iter() {
> +                let coordinates = points[*index].coordinates;
> +                x_center += coordinates.x;
> +                y_center += coordinates.y;
> +            }
> +            x_center /= overlapping.len() as f64;
> +            y_center /= overlapping.len() as f64;

imo this loop could be merged into the above one, making this a little
bit more efficient.

generally this could probably be made more efficient, but i didn't
really find a performance issue and this code is legible and decoupled
enough that we can easily clean it up if it really becomes a problem
down the line.

> +
> +            clusters.push(Cluster {
> +                center: Coordinates {
> +                    x: x_center,
> +                    y: y_center,
> +                },
> +                indices: overlapping,
> +            });
> +        }
> +
> +        if let Some(index) = self.info_visible {
> +            // either finds the correct new index to show, or resets the info if
> +            // the cluster does not exist anymore
> +            self.info_visible = clusters
> +                .iter()
> +                .position(|indices| *indices == self.clusters[index]);
> +        }
> +        self.clusters = clusters;
> +    }
> +}
> +

-->8 snip 8<--

> +    fn view(&self, ctx: &Context<Self>) -> Html {
> +        let props = ctx.props();
> +        let link = ctx.link();
> +        let width = props.width;
> +        let height = props.height;
> +
> +        let effective_radius = props.info_point_radius / self.fit_scale;
> +
> +        let zoom_level = self.zoom.get_zoom_level();
> +        let is_zoomed = zoom_level != 1.0;
> +        let fully_zoomed = zoom_level >= props.max_zoom_level;
> +
> +        let svg = Canvas::new()
> +            .onwheel({
> +                let link = link.clone();
> +                move |event: WheelEvent| {
> +                    // don't scroll the remaining page when scrolling in map
> +                    event.prevent_default();
> +                    // ignore delta mode as we zoom in/out in 10% steps later anyway, only the
> +                    // direction is relevant here
> +                    let (delta, x, y) = (event.delta_y(), event.client_x(), event.client_y());
> +                    let action = if delta < 0.0 {
> +                        ZoomAction::In
> +                    } else {
> +                        ZoomAction::Out
> +                    };
> +                    link.send_message(Msg::WheelZoom(action, x, y));
> +                }
> +            })
> +            .style(
> +                "cursor",
> +                match (is_zoomed, self.grab_start.is_some()) {
> +                    (true, true) => Some("grabbing"),
> +                    (true, false) => Some("grab"),
> +                    (false, _) => None,

should this last one be `Some("gab")`? for me the cursor does not reset
from grabbing back to grab if let go (it does eventually when i hover
over something else in the map).

> +                },
> +            )
> +            .class("pwt-map")
> +            .attribute("viewBox", format!("0 0 {width} {height}"))
> +            .with_child(
> +                Group::new()
> +                    .with_child(props.map.clone())
> +                    .style("transform", self.zoom.get_transform()),
> +            );
> +
> +        let mut points = Group::new();
> +        let mut tooltip = None;
> +        let mut info = None;
> +

-->8 snip 8<--

> +    /// Changes the scale and pan so that all points of the list are included including the padding
> +    pub fn zoom_to_points(&mut self, points: impl IntoIterator<Item = Coordinates>, padding: f64) {
> +        let points = points.into_iter();
> +
> +        let mut x_min = f64::INFINITY;
> +        let mut x_max = f64::NEG_INFINITY;
> +        let mut y_min = f64::INFINITY;
> +        let mut y_max = f64::NEG_INFINITY;
> +
> +        let mut got_points = false;

nit: could do something like this instead that feels a little nicer to
me, but no hard feelings:

    let mut points = points.into_iter().peekable();

    if points.peek().is_none() {
        return;
    }

> +
> +        for Coordinates { x, y } in points {
> +            got_points = true;
> +            if x > x_max {
> +                x_max = x;
> +            }
> +            if y > y_max {
> +                y_max = y;
> +            }
> +            if x < x_min {
> +                x_min = x;
> +            }
> +            if y < y_min {
> +                y_min = y;
> +            }
> +        }
> +
> +        if !got_points {
> +            return;
> +        }
> +
> +        let width = x_max - x_min + 2.0 * padding;
> +        let height = y_max - y_min + 2.0 * padding;
> +
> +        let mid_point = Coordinates {
> +            x: (x_min + x_max) / 2.0,
> +            y: (y_min + y_max) / 2.0,
> +        };
> +
> +        self.zoom_to_point(mid_point, width.max(height * self.width / self.height));
> +    }

-->8 snip 8<--




^ permalink raw reply	[flat|nested] 19+ messages in thread

* Re: [PATCH proxmox-geojson-data v3 1/1] initial commit
  2026-05-22  8:34 ` [PATCH proxmox-geojson-data v3 1/1] initial commit Dominik Csapak
@ 2026-05-22 13:30   ` Shannon Sterz
  0 siblings, 0 replies; 19+ messages in thread
From: Shannon Sterz @ 2026-05-22 13:30 UTC (permalink / raw)
  To: Dominik Csapak, pdm-devel

On Fri May 22, 2026 at 10:34 AM CEST, Dominik Csapak wrote:
> includes a make target to download the source shp/shx file and the
> make targets for building the geojson file and package.
>
> Suggested-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
>  Makefile             | 92 ++++++++++++++++++++++++++++++++++++++++++++
>  debian/changelog     |  5 +++
>  debian/control       | 16 ++++++++
>  debian/copyright     | 29 ++++++++++++++
>  debian/rules         |  4 ++
>  debian/source/format |  1 +
>  6 files changed, 147 insertions(+)
>  create mode 100644 Makefile
>  create mode 100644 debian/changelog
>  create mode 100644 debian/control
>  create mode 100644 debian/copyright
>  create mode 100755 debian/rules
>  create mode 100644 debian/source/format
>
> diff --git a/Makefile b/Makefile
> new file mode 100644
> index 0000000..8a5cf9d
> --- /dev/null
> +++ b/Makefile
> @@ -0,0 +1,92 @@
> +include /usr/share/dpkg/default.mk
> +
> +PACKAGE=proxmox-geojson-data
> +
> +BUILDDIR ?= $(PACKAGE)-$(DEB_VERSION_UPSTREAM)
> +ORIG_SRC_TAR=$(PACKAGE)_$(DEB_VERSION_UPSTREAM).orig.tar.gz
> +
> +DEB=$(PACKAGE)_$(DEB_VERSION)_all.deb
> +DSC=$(PACKAGE)_$(DEB_VERSION).dsc
> +
> +DESTDIR =
> +PREFIX = /usr
> +OUTDIR = $(PREFIX)/share/$(PACKAGE)
> +

this is missing a `.PHONY: all`

> +all: world-map.json
> +
> +NE_REF   := v5.1.2
> +NE_DEST  := assets
> +NE_BASE  := https://raw.githubusercontent.com/nvkelso/natural-earth-vector/$(NE_REF)
> +NE_FILE  := ne_110m_admin_0_countries
> +NE_FILES := \
> +    $(NE_FILE).shp \
> +    $(NE_FILE).shx \
> +
> +NE_TARGETS := $(addprefix $(NE_DEST)/,$(NE_FILES))
> +
> +SQL_COMMAND := "SELECT 'land' AS kind, ST_Union(geometry) AS geometry FROM $(NE_FILE) UNION ALL SELECT 'borders' AS kind, ST_Collect(ST_Boundary(geometry)) AS geometry FROM $(NE_FILE)"
> +
> +.PHONY: natural-earth natural-earth-clean
> +natural-earth: $(NE_TARGETS)
> +
> +$(NE_DEST)/%: | $(NE_DEST)
> +	curl -fsSL --retry 3 -o $@ $(NE_BASE)/110m_cultural/$*
> +
> +$(NE_DEST):
> +	mkdir -p $@
> +
> +natural-earth-clean:
> +	rm -f $(NE_TARGETS)
> +
> +world-map.json: $(NE_TARGETS)
> +	ogr2ogr -f "GeoJSON" world-map.json $(NE_DEST)/ne_110m_admin_0_countries.shp -dialect sqlite -sql $(SQL_COMMAND) -lco COORDINATE_PRECISION=4 -nlt MULTILINESTRING
> +
> +install: world-map.json
> +	install -dm0755 $(DESTDIR)$(OUTDIR)
> +	install -m0644 world-map.json $(DESTDIR)$(OUTDIR)
> +
> +
> +$(BUILDDIR):

`$(NE_DEST)` should be a prerequisite here; otherwise building this
without running `all` first will fail on a git-clean repository

> +	rm -rf $@ $@.tmp
> +	mkdir -p $@.tmp/
> +	cp -a debian/ assets/ Makefile $@.tmp/

maybe `assets/` should be `$(NE_DEST)`

> +	echo "git clone git://git.proxmox.com/git/$(PACKAGE).git\\ngit checkout $$(git rev-parse HEAD)" \
> +	    > $@.tmp/debian/SOURCE
> +	mv $@.tmp $@

-->8 snip 8<--




^ permalink raw reply	[flat|nested] 19+ messages in thread

* Re: [PATCH datacenter-manager v3 2/6] lib/api: add 'location-info' api call with cached information
  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
  0 siblings, 0 replies; 19+ messages in thread
From: Shannon Sterz @ 2026-05-22 13:30 UTC (permalink / raw)
  To: Dominik Csapak, pdm-devel

On Fri May 22, 2026 at 10:34 AM CEST, Dominik Csapak wrote:
> PVE and PBS remotes can have a location configured, and we want to query
> that, so add a cache for this location info and an API call to query it.
>
> Modeled after the other resource api calls (status/subscription) but
> lets the location info be optional since this is a relatively new
> feature.
>
> The biggest difference is that we try to get a location at all costs, so
> if there is no new enough location in the cache we try to update it.
>
> If that fails (e.g. because the remote is offline) we retry the cache
> with unlimited 'max-age'. Otherwise there could be a situation where the
> location info is so old that offline remotes won't be shown.
>
> Refactor some permission checks from the subscription api call so we can
> reuse that.
>
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
>  lib/pdm-api-types/src/lib.rs |  34 ++++++++
>  server/src/api/resources.rs  | 120 +++++++++++++++++++++-----
>  server/src/lib.rs            |   1 +
>  server/src/location_cache.rs | 160 +++++++++++++++++++++++++++++++++++
>  4 files changed, 294 insertions(+), 21 deletions(-)
>  create mode 100644 server/src/location_cache.rs

-->8 snip 8<--

> diff --git a/server/src/api/resources.rs b/server/src/api/resources.rs
> index 1a6a23d9..9ec1ce89 100644
> --- a/server/src/api/resources.rs
> +++ b/server/src/api/resources.rs
> @@ -18,7 +18,7 @@ use pdm_api_types::resource::{
>  use pdm_api_types::subscription::{
>      NodeSubscriptionInfo, RemoteSubscriptionState, RemoteSubscriptions, SubscriptionLevel,
>  };
> -use pdm_api_types::{Authid, PRIV_RESOURCE_AUDIT, VIEW_ID_SCHEMA};
> +use pdm_api_types::{Authid, CachedLocationInfo, PRIV_RESOURCE_AUDIT, VIEW_ID_SCHEMA};
>  use pdm_search::{Search, SearchTerm};
>  use proxmox_access_control::CachedUserInfo;
>  use proxmox_router::{
> @@ -41,6 +41,10 @@ pub const ROUTER: Router = Router::new()
>  #[sortable]
>  const SUBDIRS: SubdirMap = &sorted!([
>      ("list", &Router::new().get(&API_METHOD_GET_RESOURCES)),
> +    (
> +        "location-info",
> +        &Router::new().get(&API_METHOD_GET_LOCATION_INFO)
> +    ),
>      ("status", &Router::new().get(&API_METHOD_GET_STATUS)),
>      (
>          "top-entities",
> @@ -214,6 +218,29 @@ impl From<RemoteWithResources> for RemoteResources {
>      }
>  }
>
> +fn check_remote_priv(user_info: &CachedUserInfo, auth_id: &Authid, remote: &str) -> bool {
> +    user_info
> +        .check_privs(auth_id, &["resource", remote], PRIV_RESOURCE_AUDIT, false)
> +        .is_ok()
> +}
> +
> +/// When returning true, all remotes are allowed and no per-remote permission check should be
> +/// necessary
> +fn check_all_remotes_allowed(
> +    user_info: &CachedUserInfo,
> +    auth_id: &Authid,
> +    view: Option<&str>,
> +) -> Result<bool, Error> {
> +    Ok(if let Some(view) = view {
> +        user_info.check_privs(auth_id, &["view", view], PRIV_RESOURCE_AUDIT, false)?;
> +        false
> +    } else {
> +        user_info
> +            .check_privs(auth_id, &["resource"], PRIV_RESOURCE_AUDIT, false)
> +            .is_ok()
> +    })
> +}
> +

i wonder if these two sould eventually also take a `privilege` parameter
and maybe be moved to a more central place, but nothing that needs to be
done with this series.

maybe even a helper that combines the `if` statements below as well, as
they are almost identical.

>  #[api(
>      // FIXME:: see list-like API calls in resource routers, we probably want more fine-grained
>      // checks..
> @@ -641,34 +668,16 @@ pub async fn get_subscription_status(
>          .parse()?;
>      let user_info = CachedUserInfo::new()?;
>
> -    let allow_all = if let Some(view) = &view {
> -        user_info.check_privs(&auth_id, &["view", view], PRIV_RESOURCE_AUDIT, false)?;
> -        false
> -    } else {
> -        user_info
> -            .check_privs(&auth_id, &["resource"], PRIV_RESOURCE_AUDIT, false)
> -            .is_ok()
> -    };
> +    let allow_all = check_all_remotes_allowed(&user_info, &auth_id, view.as_deref())?;
>
>      let view = views::get_optional_view(view.as_deref())?;
>
> -    let check_priv = |remote_name: &str| -> bool {
> -        user_info
> -            .check_privs(
> -                &auth_id,
> -                &["resource", remote_name],
> -                PRIV_RESOURCE_AUDIT,
> -                false,
> -            )
> -            .is_ok()
> -    };
> -
>      for (remote_name, remote) in remotes_config {
>          if let Some(view) = &view {
>              if view.can_skip_remote(&remote_name) {
>                  continue;
>              }
> -        } else if !allow_all && !check_priv(&remote_name) {
> +        } else if !allow_all && !check_remote_priv(&user_info, &auth_id, &remote_name) {
>              continue;
>          }

^ these if statements here

-->8 snip 8<--





^ permalink raw reply	[flat|nested] 19+ messages in thread

* Re: [PATCH datacenter-manager v3 6/6] ui: views: add map component
  2026-05-22  8:34 ` [PATCH datacenter-manager v3 6/6] ui: views: add map component Dominik Csapak
@ 2026-05-22 13:30   ` Shannon Sterz
  0 siblings, 0 replies; 19+ messages in thread
From: Shannon Sterz @ 2026-05-22 13:30 UTC (permalink / raw)
  To: Dominik Csapak, pdm-devel

On Fri May 22, 2026 at 10:34 AM CEST, Dominik Csapak wrote:
> 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>

-->8 snip 8<--

> @@ -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
> +                );

imo it might worth revisiting the approach i suggest for this last year
before we shipped the views [1]. otherwise, this will just create more
and more churn as we go on. i'll see that i can get around to this after
the release.

[1]: https://lore.proxmox.com/pdm-devel/DDQH4208RAP8.28R3YO54Z8UX2@proxmox.com/

-->8 snip 8<--




^ permalink raw reply	[flat|nested] 19+ messages in thread

* Re: [PATCH datacenter-manager/proxmox-geojson-data/yew-widget-toolkit/yew-widget-toolkit-assets v3 00/11] add a new map widget for custom views
  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
                   ` (11 preceding siblings ...)
  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
  13 siblings, 0 replies; 19+ messages in thread
From: Shannon Sterz @ 2026-05-22 13:33 UTC (permalink / raw)
  To: Dominik Csapak, pdm-devel

On Fri May 22, 2026 at 10:33 AM CEST, Dominik Csapak wrote:
> This series let's users add location info to remotes (longitude,latitude) and
> makes it possible to show them on a map in a custom view.
>
> This map is interactive, so it let's you zoom/pan/etc. (with touch controls too)
>
> It also adds new status info per remote to the resources status api call
> so we can show some sensible info on the map (success/warn/error).
>
> For now this is very basic only, but we can extend that in the future by e.g.,
> adding a health call for each remote and including this info here.
>
> The source map data is from naturalearthdata.com (data is in the public domain)
> and is converted to geojson with 'ogr2ogr' (small script is included)

-->8 snip 8<--

 some general feedback for this series:

generally this worked as intended on my end. the map could benefit from
some more gestures in my opinion though, such as:

* doubl tap to zoom: useful as an accessibility guide
* double-tap and drag to zoom: very useful for one handed control

both of these would need extra support in the gesture controller from
what i can tell. also since for now that map is only really used in a
desktop context, imo this is not a big problem, but would be nice
follow-ups.

note that the drag to zoom gesture did not work for me when trying to
test this by pressing SHIFT+drag in the Chromium mobile dev tools.

one thing i found somewhat irretating is that clicking a remote on the
map triggers a map info card in the top center of the browser window
(only in firefox, chrome renders this card on top of the clicked
cluster). that card is sticky and even clicking somewhere on or outside
the map won't dismiss it. the only way i found it's possible to dismiss
this, is by zooming in enough on the map to make it go away eventually.
imo that can be irritating as the card will overlay other widgets in a
view. especially when several resources cluster this card can become
fairly long.

another thing that might make sense, is mentioning where the map data is
from. most other map widgets im aware of do this with a small note in
the bottom right [1,2]. this could also come in handy if someone claims
that we assert the "correct" boundaries for a country. which could be
problematic in some cases, such as the india-pakistan-china border
region. all of these countries have different and overlapping claims to
the region [3].

hope the somewhat nitpicky review is alright. except for the map info
issue, nothing here is something id consider a blocker, most of it can
easily be cleaned up or improved in (trivial) follow-ups. so consider
this:

Tested-by: Shannon Sterz <s.sterz@proxmox.com>
Reviewed-by: Shannon Sterz <s.sterz@proxmox.com>

[1]: compare, grafana's implementation:
https://play.grafana.org/d/panel-geomap/geomap-examples
[2]: compare google map's implementation: https://www.google.com/maps/
[3]: https://en.wikipedia.org/wiki/Kashmir_conflict





^ permalink raw reply	[flat|nested] 19+ messages in thread

* applied: [PATCH datacenter-manager/proxmox-geojson-data/yew-widget-toolkit/yew-widget-toolkit-assets v3 00/11] add a new map widget for custom views
  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
                   ` (12 preceding siblings ...)
  2026-05-22 13:33 ` Shannon Sterz
@ 2026-05-24  2:31 ` Thomas Lamprecht
  13 siblings, 0 replies; 19+ messages in thread
From: Thomas Lamprecht @ 2026-05-24  2:31 UTC (permalink / raw)
  To: pdm-devel, Dominik Csapak

On Fri, 22 May 2026 10:33:56 +0200, Dominik Csapak wrote:
> This series let's users add location info to remotes (longitude,latitude) and
> makes it possible to show them on a map in a custom view.
> 
> This map is interactive, so it let's you zoom/pan/etc. (with touch controls too)
> 
> It also adds new status info per remote to the resources status api call
> so we can show some sensible info on the map (success/warn/error).
> 
> [...]

Applied, thanks!

I made a few changes on top while applying:

- yew-widget-toolkit: keep the map info card stable during interaction and
  fix its dismissal and cursor reset, re-cluster after a pinch zoom while
  skipping no-op zooms, guard the client-to-SVG coordinate helper against a
  missing CTM, namespace the location CSS custom properties, and give the
  segmented button a group role and aria-label.

- yew-widget-toolkit-assets: namespace the Map location custom properties to
  match the toolkit change.

- datacenter-manager: keep cached locations for unreachable remotes and
  tolerate unreachable PVE nodes, cap the map info card height, follow the
  renamed location color property, switch the server over to the now typed
  pve-api-types cluster options and location, and add end-user documentation
  for the available custom-view widgets and the new map widget.

Related, outside this series: I factored out dedicated node location types
and a typed cluster options return in pve-api-types, backed by a new
pve-node-location property-string format in pve-common that pve-cluster and
pve-manager reference, which is what the datacenter-manager server now
consumes.

Some feedback for a possible follow-up, nothing blocking as this is already
applied:

- The rendered map looks noticeably coarser than the 1:110m source material
  it is generated from, so it would be good to double-check that we are not
  losing detail somewhere along the way (fine if zoomed out, but might be
  nice to add full details when zooming closer).

- One or two more zoom levels would be great for the user experience: with
  several remotes located in the same city the current maximum zoom cannot
  really tell them apart.

[proxmox-yew-widget-toolkit]:

[1/3] js-helper: add client-to-svg-coordinate conversion helper
      commit: 835cd497f9fbd4dfa1831e3b5f1ce98b9ebdf913
[2/3] widget: charts: add interactive Map with zoom/pan and clustering
      commit: 742ffe78d874f028c5ed68dbaaf18b9d9ca84b64
[3/3] widget: charts: add WorldMap with GeoJSON rendering
      commit: 7f360d03129480f38adb87ee946c4209f39f38ee

[proxmox-datacenter-manager]:

[1/6] server: pbs client: add node_config method
      commit: 17df8309266332245386f3a6de70b3733897d227
[2/6] lib/api: add 'location-info' api call with cached information
      commit: 9a8ef4bfe73e6cc9fa6712aae69f3a1ff9d90a67
[3/6] lib/api: add new 'remote-list' info to the resource status
      commit: b20879665fbdcbc9765f2451bea4db8434a09317
[4/6] server: serve geojson worldmap
      commit: 39b36565cee582f2aace228603c9128b0f8e7ba9
[5/6] ui: views: refactor required api call info into struct
      commit: 2da1b4833b4d99efea5e616da56bc918179b2056
[6/6] ui: views: add map component
      commit: 6173037e8faf0ce5236bf2b01f54136979f5cdfe

[proxmox-geojson-data]:

[1/1] initial commit
      commit: be241be54abf5928876f3777c7d6967e74d2f7c9

[yew-widget-toolkit-assets]:

[1/1] charts: add necessary classes for Map
      commit: cff62b10facac4778a688491b95653b91a18b7f2




^ permalink raw reply	[flat|nested] 19+ messages in thread

end of thread, other threads:[~2026-05-24  2:32 UTC | newest]

Thread overview: 19+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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 ` [PATCH datacenter-manager v3 6/6] ui: views: add map component Dominik Csapak
2026-05-22 13:30   ` 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

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