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 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.