From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager v3 6/6] ui: views: add map component
Date: Fri, 22 May 2026 10:34:07 +0200 [thread overview]
Message-ID: <20260522083412.1223719-12-d.csapak@proxmox.com> (raw)
In-Reply-To: <20260522083412.1223719-1-d.csapak@proxmox.com>
this uses the WorldMap from yew widget toolkit together with the added
world-map.json to show a world map on a custom view. This shows the
remotes which have a location in the config and their status.
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
lib/pdm-api-types/src/views.rs | 2 +
ui/Cargo.toml | 1 +
ui/Trunk.toml | 5 +
ui/debian/control | 1 +
ui/src/dashboard/map.rs | 406 ++++++++++++++++++++++++++++++
ui/src/dashboard/mod.rs | 3 +
ui/src/dashboard/view.rs | 46 +++-
ui/src/dashboard/view/row_view.rs | 1 +
8 files changed, 459 insertions(+), 6 deletions(-)
create mode 100644 ui/src/dashboard/map.rs
diff --git a/lib/pdm-api-types/src/views.rs b/lib/pdm-api-types/src/views.rs
index 3e215d06..9905eee2 100644
--- a/lib/pdm-api-types/src/views.rs
+++ b/lib/pdm-api-types/src/views.rs
@@ -333,6 +333,8 @@ pub enum WidgetType {
#[serde(skip_serializing_if = "Option::is_none")]
remote_type: Option<RemoteType>,
},
+ /// A simple map
+ Map,
#[serde(untagged)]
#[serde(rename_all = "kebab-case")]
/// Catches all widgets for unknown types.
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index a6f427b5..fe8c6dbe 100644
--- a/ui/Cargo.toml
+++ b/ui/Cargo.toml
@@ -11,6 +11,7 @@ resolver = "2"
[dependencies]
anyhow = "1.0"
futures = "0.3"
+geojson = "0.24"
gloo-net = "0.4"
gloo-timers = "0.3"
gloo-utils = "0.2"
diff --git a/ui/Trunk.toml b/ui/Trunk.toml
index 48113c1d..39fdd5be 100644
--- a/ui/Trunk.toml
+++ b/ui/Trunk.toml
@@ -20,3 +20,8 @@ command_arguments= ["css/material-yew-style.scss", "dist/.stage/material-yew-sty
stage="build"
command="rust-grass"
command_arguments= ["css/desktop-yew-style.scss", "dist/.stage/desktop-yew-style.css"]
+
+[[hooks]]
+stage="build"
+command="sh"
+command_arguments= ["-c", "mkdir -p ./dist/.stage/geojson && cp /usr/share/proxmox-geojson-data/world-map.json ./dist/.stage/geojson/"]
diff --git a/ui/debian/control b/ui/debian/control
index fa7af060..322c7709 100644
--- a/ui/debian/control
+++ b/ui/debian/control
@@ -8,6 +8,7 @@ Build-Depends: debhelper-compat (= 13),
fonts-font-awesome,
librust-anyhow-1+default-dev,
librust-futures-0.3+default-dev,
+ librust-geojson-0.24+default-dev,
librust-gloo-net-0.4+default-dev,
librust-gloo-timers-0.3+default-dev,
librust-gloo-utils-0.2+default-dev,
diff --git a/ui/src/dashboard/map.rs b/ui/src/dashboard/map.rs
new file mode 100644
index 00000000..ed597968
--- /dev/null
+++ b/ui/src/dashboard/map.rs
@@ -0,0 +1,406 @@
+use std::collections::HashMap;
+use std::collections::HashSet;
+use std::hash::Hash;
+use std::rc::Rc;
+
+use anyhow::Error;
+use geojson::GeoJson;
+use yew::virtual_dom::{VComp, VNode};
+
+use proxmox_yew_comp::Status;
+use pwt::css;
+use pwt::prelude::*;
+use pwt::state::{Loader, SharedState, SharedStateObserver};
+use pwt::widget::canvas::Group;
+use pwt::widget::charts::{
+ render_point_default, render_tooltip_default, Location, MapPointData, PointsRenderArgs,
+ WorldMap, WorldPoint,
+};
+use pwt::widget::container::span;
+use pwt::widget::{error_message, ActionIcon, Column, Container, Fa, Panel, Row, Tooltip};
+use pwt_macros::{builder, widget};
+
+use crate::dashboard::loading_column;
+use crate::{navigate_to, LoadResult};
+
+use pdm_api_types::remotes::RemoteType;
+use pdm_api_types::resource::{RemoteInfo, RemoteStatus, ResourcesStatus};
+use pdm_api_types::CachedLocationInfo;
+use pdm_api_types::Location as RemoteLocation;
+
+#[widget(comp=DashboardMapComp, @element)]
+#[builder]
+#[derive(Properties, PartialEq, Clone)]
+pub struct DashboardMap {
+ status: SharedState<LoadResult<ResourcesStatus, Error>>,
+ locations: SharedState<LoadResult<HashMap<String, CachedLocationInfo>, Error>>,
+}
+
+impl DashboardMap {
+ pub fn new(
+ status: SharedState<LoadResult<ResourcesStatus, Error>>,
+ locations: SharedState<LoadResult<HashMap<String, CachedLocationInfo>, Error>>,
+ ) -> Self {
+ yew::props!(Self { status, locations })
+ }
+}
+
+pub enum Msg {
+ MapLoaded,
+ DataChanged,
+}
+
+pub struct DashboardMapComp {
+ loader: Loader<GeoJson>,
+ points: Vec<WorldPoint<PoiInfo>>,
+ _status_observer: SharedStateObserver<LoadResult<ResourcesStatus, Error>>,
+ _location_observer: SharedStateObserver<LoadResult<HashMap<String, CachedLocationInfo>, Error>>,
+}
+
+#[derive(PartialEq, Debug)]
+struct UniqueRemoteLocation(RemoteLocation, String);
+
+// lat/long can't be NaN since the config format limits to valid values, so this is ok
+impl Eq for UniqueRemoteLocation {}
+
+impl Hash for UniqueRemoteLocation {
+ fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+ self.0.name.hash(state);
+ self.1.hash(state);
+ self.0.latitude.to_bits().hash(state);
+ self.0.longitude.to_bits().hash(state);
+ }
+}
+
+impl DashboardMapComp {
+ fn calculate_points(ctx: &Context<Self>) -> Vec<WorldPoint<PoiInfo>> {
+ let read_guard = ctx.props().status.read();
+
+ let mut info_map = HashMap::new();
+ if let Some(data) = &read_guard.data {
+ for remote in &data.remote_list {
+ info_map.insert(remote.name.clone(), remote);
+ }
+ };
+
+ let mut unique_locations: HashMap<UniqueRemoteLocation, Vec<String>> = HashMap::new();
+ let location_guard = ctx.props().locations.read();
+
+ if let Some(locations) = &location_guard.data {
+ for (remote, remote_location) in locations {
+ for (nodename, node_location) in &remote_location.node_locations {
+ let unique_location = unique_locations
+ .entry(UniqueRemoteLocation(node_location.clone(), remote.clone()))
+ .or_default();
+
+ unique_location.push(nodename.clone());
+ }
+ }
+ }
+
+ let mut points = unique_locations
+ .into_iter()
+ .map(|(point, members)| {
+ let UniqueRemoteLocation(location, remote) = point;
+
+ let data = match info_map.get(&remote) {
+ Some(&info) => info.clone(),
+ None => RemoteInfo {
+ name: remote,
+ ty: RemoteType::Pve,
+ messages: Vec::new(),
+ status: RemoteStatus::Unknown,
+ },
+ };
+ let data = PoiInfo::new(data, members, location.name);
+ WorldPoint {
+ location: Location::new(location.longitude, location.latitude),
+ data,
+ }
+ })
+ .collect::<Vec<_>>();
+
+ points.sort_by_key(|loc| loc.data.render_title());
+ points
+ }
+}
+
+impl yew::Component for DashboardMapComp {
+ type Message = Msg;
+ type Properties = DashboardMap;
+
+ fn create(ctx: &Context<Self>) -> Self {
+ let loader = Loader::new()
+ .loader((
+ |url: AttrValue| async move {
+ let json = gloo_net::http::Request::get(&url).send().await?;
+ let geo_json = GeoJson::from_json_value(json.json().await?)?;
+ Ok(geo_json)
+ },
+ "/geojson/world-map.json",
+ ))
+ .on_change(ctx.link().callback(|_| Msg::MapLoaded));
+ loader.load();
+
+ let _status_observer = ctx
+ .props()
+ .status
+ .add_listener(ctx.link().callback(|_| Msg::DataChanged));
+
+ let _location_observer = ctx
+ .props()
+ .locations
+ .add_listener(ctx.link().callback(|_| Msg::DataChanged));
+
+ let points = Self::calculate_points(ctx);
+
+ Self {
+ loader,
+ points,
+ _status_observer,
+ _location_observer,
+ }
+ }
+
+ fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Msg::MapLoaded => {}
+ Msg::DataChanged => {
+ self.points = Self::calculate_points(ctx);
+ }
+ }
+ true
+ }
+
+ fn view(&self, ctx: &Context<Self>) -> Html {
+ let props = ctx.props();
+ let loader = self.loader.read();
+
+ if !props.locations.read().has_data() {
+ return loading_column().into();
+ }
+
+ let geojson = match &loader.data {
+ Some(Ok(geojson)) => Rc::clone(geojson),
+ Some(Err(err)) => return error_message(&err.to_string()).into(),
+ _ => return loading_column().into(),
+ };
+
+ WorldMap::new(geojson)
+ .with_std_props(&props.std_props)
+ .listeners(&props.listeners)
+ .points(self.points.clone())
+ .into()
+ }
+}
+
+#[derive(Clone, PartialEq, Properties)]
+struct PoiInfo {
+ name: Option<String>,
+ remote: RemoteInfo,
+ nodes: Vec<String>,
+}
+
+impl std::ops::Deref for PoiInfo {
+ type Target = RemoteInfo;
+
+ fn deref(&self) -> &Self::Target {
+ &self.remote
+ }
+}
+
+impl PoiInfo {
+ fn new(remote: RemoteInfo, nodes: Vec<String>, name: Option<String>) -> Self {
+ yew::props!(Self {
+ name,
+ remote,
+ nodes,
+ })
+ }
+}
+
+impl From<PoiInfo> for VNode {
+ fn from(val: PoiInfo) -> Self {
+ let comp = VComp::new::<PoiInfoComp>(Rc::new(val), None);
+ VNode::from(comp)
+ }
+}
+
+struct PoiInfoComp {}
+
+impl Component for PoiInfoComp {
+ type Message = ();
+ type Properties = PoiInfo;
+
+ fn create(_ctx: &Context<Self>) -> Self {
+ Self {}
+ }
+
+ fn view(&self, ctx: &Context<Self>) -> Html {
+ let props = ctx.props();
+
+ let link = ctx.link().clone();
+ let remote_name = props.remote.name.clone();
+ let (status, status_icon) = match props.remote.status {
+ RemoteStatus::Good => (tr!("Good"), Fa::from(Status::Success)),
+ RemoteStatus::Warning => (tr!("Warning"), Fa::from(Status::Warning)),
+ RemoteStatus::Error => (tr!("Error"), Fa::from(Status::Error)),
+ RemoteStatus::Unknown => (tr!("Unknown"), Fa::from(Status::Unknown)),
+ };
+ let mut nodes = props.nodes.clone();
+ nodes.sort();
+ let (node_count, node_hint) =
+ match (props.remote.ty, nodes.len()) {
+ (RemoteType::Pve, x) if x > 0 => (
+ Some(span(tr!("1 Node" | "{0} Nodes" % x, x))),
+ Some(Tooltip::new(Fa::new("question-circle")).rich_tip(
+ Column::new().children(nodes.into_iter().map(|n| span(n).into())),
+ )),
+ ),
+ _ => (None, None),
+ };
+
+ let extra_row = match (node_count, props.name.as_ref()) {
+ (None, None) => None,
+ (node_count, name) => Some(
+ Row::new()
+ .padding_start(1)
+ .padding_end(4)
+ .gap(1)
+ .class(css::FontStyle::BodySmall)
+ .class(css::AlignItems::Center)
+ .with_optional_child(name.map(span))
+ .with_flex_spacer()
+ .with_optional_child(node_count)
+ .with_optional_child(node_hint),
+ ),
+ };
+ Column::new()
+ .width(300)
+ .max_height(300)
+ .class(css::JustifyContent::Stretch)
+ .padding(1)
+ .with_child(
+ Row::new()
+ .gap(1)
+ .class(css::AlignItems::Center)
+ .with_child(status_icon)
+ .with_child(span(&status))
+ .with_flex_spacer()
+ .with_child(span(&props.remote.name))
+ .with_child(
+ ActionIcon::new("fa fa-chevron-right")
+ .on_activate(move |_| navigate_to(&link, &remote_name, None)),
+ ),
+ )
+ .with_optional_child(extra_row)
+ .with_optional_child(
+ (!props.remote.messages.is_empty()).then_some(
+ Column::new()
+ .padding_top(2)
+ .children(props.remote.messages.iter().map(|err| {
+ span(err)
+ .padding_bottom(1)
+ .class(css::Overflow::Auto)
+ .into()
+ })),
+ ),
+ )
+ .into()
+ }
+}
+
+impl MapPointData for PoiInfo {
+ fn render_title(&self) -> AttrValue {
+ match &self.name {
+ Some(name) => format!("{} - {name}", self.remote.name).into(),
+ None => self.remote.name.clone().into(),
+ }
+ }
+
+ fn render_point(args: &PointsRenderArgs<Self>) -> Group {
+ let mut worst = RemoteStatus::Good;
+
+ for poi in args.points {
+ match (&poi.data.status, &worst) {
+ (RemoteStatus::Error, _) => worst = RemoteStatus::Error,
+ (RemoteStatus::Warning, RemoteStatus::Good | RemoteStatus::Unknown) => {
+ worst = RemoteStatus::Warning
+ }
+ (RemoteStatus::Unknown, RemoteStatus::Good) => worst = RemoteStatus::Unknown,
+ _ => {}
+ }
+ }
+
+ let mut args = args.clone();
+ let txt = match worst {
+ RemoteStatus::Good => "success",
+ RemoteStatus::Warning => "warning",
+ RemoteStatus::Error => "error",
+ RemoteStatus::Unknown => {
+ // animate the not yet loaded remotes
+ args.selected = true;
+ "primary"
+ }
+ };
+ render_point_default(&args).style("--pwt-location-color", format!("var(--pwt-color-{txt})"))
+ }
+
+ fn render_info(args: &PointsRenderArgs<Self>) -> Html {
+ let mut points = args.points.to_vec();
+ points.sort_by(|a, b| a.data.render_title().cmp(&b.data.render_title()));
+ Column::new()
+ .children(
+ points
+ .iter()
+ // insert a separator in between
+ .flat_map(|&point| {
+ [Container::from_tag("hr").into(), point.data.clone().into()]
+ })
+ .skip(1),
+ )
+ .into()
+ }
+
+ fn render_tooltip(args: &PointsRenderArgs<Self>) -> Html {
+ let mut seen = HashSet::new();
+ let mut unique_remotes = args
+ .points
+ .iter()
+ .cloned()
+ .filter(|point| {
+ let title = point.data.render_title();
+ seen.insert(title)
+ })
+ .collect::<Vec<_>>();
+ unique_remotes.sort_by(|a, b| a.data.render_title().cmp(&b.data.render_title()));
+
+ let mut new_args = args.clone();
+ new_args.points = &unique_remotes;
+
+ render_tooltip_default(&new_args)
+ }
+}
+
+/// Creates a dashboard panel with a world map
+pub fn create_map_panel(
+ status: SharedState<LoadResult<ResourcesStatus, Error>>,
+ locations: SharedState<LoadResult<HashMap<String, CachedLocationInfo>, Error>>,
+) -> Panel {
+ Panel::new()
+ .with_child(DashboardMap::new(status.clone(), locations.clone()).flex(1.0))
+ .with_optional_child(
+ status
+ .read()
+ .error
+ .as_ref()
+ .map(|err| error_message(&format!("status - {err}"))),
+ )
+ .with_optional_child(
+ locations
+ .read()
+ .error
+ .as_ref()
+ .map(|err| error_message(&format!("locations - {err}"))),
+ )
+}
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 194000c2..fdf08c43 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -20,6 +20,9 @@ pub use gauge_panel::create_gauge_panel;
mod guest_panel;
pub use guest_panel::create_guest_panel;
+mod map;
+pub use map::create_map_panel;
+
mod node_status_panel;
use node_status_panel::create_node_panel;
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index 3ddc4910..a654d1be 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -1,3 +1,4 @@
+use std::collections::HashMap;
use std::rc::Rc;
use anyhow::Error;
@@ -24,10 +25,10 @@ use crate::dashboard::refresh_config_edit::{
use crate::dashboard::subscription_info::create_subscriptions_dialog;
use crate::dashboard::tasks::get_task_options;
use crate::dashboard::{
- create_gauge_panel, create_guest_panel, create_node_panel, create_pbs_datastores_panel,
- create_refresh_config_edit_window, create_remote_panel, create_resource_tree, create_sdn_panel,
- create_subscription_panel, create_task_summary_panel, create_top_entities_panel,
- DashboardStatusRow,
+ create_gauge_panel, create_guest_panel, create_map_panel, create_node_panel,
+ create_pbs_datastores_panel, create_refresh_config_edit_window, create_remote_panel,
+ create_resource_tree, create_sdn_panel, create_subscription_panel, create_task_summary_panel,
+ create_top_entities_panel, DashboardStatusRow,
};
use crate::remotes::AddWizard;
use crate::widget::RedrawController;
@@ -39,7 +40,7 @@ use pdm_api_types::subscription::RemoteSubscriptions;
use pdm_api_types::views::{
RowWidget, TaskSummaryGrouping, ViewConfig, ViewLayout, ViewTemplate, WidgetType,
};
-use pdm_api_types::TaskStatistics;
+use pdm_api_types::{CachedLocationInfo, TaskStatistics};
use pdm_client::types::TopEntities;
use pdm_search::{Search, SearchTerm};
@@ -84,6 +85,7 @@ pub enum LoadingResult {
TopEntities(Result<pdm_client::types::TopEntities, proxmox_client::Error>),
TaskStatistics(Result<TaskStatistics, Error>),
SubscriptionInfo(Result<Vec<RemoteSubscriptions>, Error>),
+ Locations(Result<HashMap<String, CachedLocationInfo>, Error>),
All,
}
@@ -123,6 +125,7 @@ struct WidgetRenderArgs {
subscriptions: SharedState<LoadResult<Vec<RemoteSubscriptions>, Error>>,
top_entities: SharedState<LoadResult<TopEntities, proxmox_client::Error>>,
statistics: SharedState<LoadResult<TaskStatistics, Error>>,
+ locations: SharedState<LoadResult<HashMap<String, CachedLocationInfo>, Error>>,
redraw_controller: RedrawController,
}
@@ -137,6 +140,7 @@ fn render_widget(
subscriptions,
top_entities,
statistics,
+ locations,
redraw_controller,
} = render_args;
@@ -173,6 +177,7 @@ fn render_widget(
resource,
remote_type,
} => create_gauge_panel(*resource, *remote_type, status),
+ WidgetType::Map => create_map_panel(status, locations),
WidgetType::UnknownWidget { widget_type, .. } => create_unknown_widget_panel(widget_type),
};
@@ -251,7 +256,27 @@ impl ViewComp {
link.send_message(Msg::LoadingResult(LoadingResult::SubscriptionInfo(res)));
};
- join!(status_future, entities_future, tasks_future, subs_future);
+ let location_future = async {
+ if required.locations {
+ let mut params = json!({});
+ // max-age for location has a sensible backend default and does not need to be
+ // updated as often, except if forced
+ if max_age == 0 {
+ params["max-age"] = 0.into();
+ }
+ add_view_filter(&mut params);
+ let res = http_get("/resources/location-info", Some(params)).await;
+ link.send_message(Msg::LoadingResult(LoadingResult::Locations(res)));
+ }
+ };
+
+ join!(
+ status_future,
+ entities_future,
+ tasks_future,
+ subs_future,
+ location_future
+ );
link.send_message(Msg::LoadingResult(LoadingResult::All));
});
} else {
@@ -266,6 +291,7 @@ struct RequiredApiCalls {
status: bool,
top_entities: bool,
task_statistics: bool,
+ locations: bool,
}
fn required_api_calls(layout: &ViewLayout) -> RequiredApiCalls {
@@ -291,6 +317,10 @@ fn required_api_calls(layout: &ViewLayout) -> RequiredApiCalls {
WidgetType::ResourceTree => {
// each list must do it itself
}
+ WidgetType::Map => {
+ api_calls.status = true;
+ api_calls.locations = true;
+ }
WidgetType::UnknownWidget { .. } => {}
}
}
@@ -338,6 +368,7 @@ impl Component for ViewComp {
top_entities: SharedState::new(LoadResult::new()),
statistics: SharedState::new(LoadResult::new()),
subscriptions: SharedState::new(LoadResult::new()),
+ locations: SharedState::new(LoadResult::new()),
redraw_controller: RedrawController::new(),
},
}
@@ -360,6 +391,9 @@ impl Component for ViewComp {
LoadingResult::SubscriptionInfo(subscriptions) => {
self.render_args.subscriptions.write().update(subscriptions);
}
+ LoadingResult::Locations(locations) => {
+ self.render_args.locations.write().update(locations);
+ }
LoadingResult::All => {
self.loading = false;
if self.load_finished_time.is_none() {
diff --git a/ui/src/dashboard/view/row_view.rs b/ui/src/dashboard/view/row_view.rs
index 3c5428ae..a9696317 100644
--- a/ui/src/dashboard/view/row_view.rs
+++ b/ui/src/dashboard/view/row_view.rs
@@ -667,6 +667,7 @@ fn create_menu(ctx: &yew::Context<RowViewComp>, new_coords: Position) -> Menu {
),
)
.with_item(MenuItem::new(tr!("SDN Panel")).on_select(create_callback(WidgetType::Sdn)))
+ .with_item(MenuItem::new(tr!("Map")).on_select(create_callback(WidgetType::Map)))
.with_item(
MenuItem::new(tr!("Resource Tree"))
.on_select(create_callback(WidgetType::ResourceTree)),
--
2.47.3
next prev parent reply other threads:[~2026-05-22 8:34 UTC|newest]
Thread overview: 19+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-05-22 8:33 [PATCH datacenter-manager/proxmox-geojson-data/yew-widget-toolkit/yew-widget-toolkit-assets v3 00/11] add a new map widget for custom views Dominik Csapak
2026-05-22 8:33 ` [PATCH yew-widget-toolkit v3 1/3] js-helper: add client-to-svg-coordinate conversion helper Dominik Csapak
2026-05-22 8:33 ` [PATCH yew-widget-toolkit v3 2/3] widget: charts: add interactive Map with zoom/pan and clustering Dominik Csapak
2026-05-22 13:30 ` Shannon Sterz
2026-05-22 8:33 ` [PATCH yew-widget-toolkit v3 3/3] widget: charts: add WorldMap with GeoJSON rendering Dominik Csapak
2026-05-22 8:34 ` [PATCH yew-widget-toolkit-assets v3 1/1] charts: add necessary classes for Map Dominik Csapak
2026-05-22 8:34 ` [PATCH proxmox-geojson-data v3 1/1] initial commit Dominik Csapak
2026-05-22 13:30 ` Shannon Sterz
2026-05-22 8:34 ` [PATCH datacenter-manager v3 1/6] server: pbs client: add node_config method Dominik Csapak
2026-05-22 8:34 ` [PATCH datacenter-manager v3 2/6] lib/api: add 'location-info' api call with cached information Dominik Csapak
2026-05-22 13:30 ` Shannon Sterz
2026-05-22 8:34 ` [PATCH datacenter-manager v3 3/6] lib/api: add new 'remote-list' info to the resource status Dominik Csapak
2026-05-22 8:34 ` [PATCH datacenter-manager v3 4/6] server: serve geojson worldmap Dominik Csapak
2026-05-22 8:34 ` [PATCH datacenter-manager v3 5/6] ui: views: refactor required api call info into struct Dominik Csapak
2026-05-22 8:34 ` Dominik Csapak [this message]
2026-05-22 13:30 ` [PATCH datacenter-manager v3 6/6] ui: views: add map component Shannon Sterz
2026-05-22 9:38 ` [PATCH datacenter-manager/proxmox-geojson-data/yew-widget-toolkit/yew-widget-toolkit-assets v3 00/11] add a new map widget for custom views Thomas Lamprecht
2026-05-22 13:33 ` Shannon Sterz
2026-05-24 2:31 ` applied: " Thomas Lamprecht
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260522083412.1223719-12-d.csapak@proxmox.com \
--to=d.csapak@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox