From: "Lukas Wagner" <l.wagner@proxmox.com>
To: "Proxmox Datacenter Manager development discussion"
<pdm-devel@lists.proxmox.com>,
"Stefan Hanreich" <s.hanreich@proxmox.com>
Subject: Re: [pdm-devel] [PATCH proxmox-datacenter-manager 3/5] ui: sdn: evpn: add zone status panel
Date: Tue, 18 Nov 2025 15:12:55 +0100 [thread overview]
Message-ID: <DEBVRZFOFXB5.9P36O8JX48ON@proxmox.com> (raw)
In-Reply-To: <20251107085934.118815-7-s.hanreich@proxmox.com>
some notes inline, apart from this:
Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>
On Fri Nov 7, 2025 at 9:59 AM CET, Stefan Hanreich wrote:
> This panel shows the status of the IP-VRF of an EVPN zone. It lists
> the routing table of a given node, which can be selected via the
> dropdown in the panel. It will be used in the remote tree of the EVPN
> view to display detailed information about the selected EVPN zone.
>
> Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
> ---
> ui/src/sdn/evpn/mod.rs | 33 +++++
> ui/src/sdn/evpn/zone_status.rs | 261 +++++++++++++++++++++++++++++++++
> 2 files changed, 294 insertions(+)
> create mode 100644 ui/src/sdn/evpn/zone_status.rs
>
> diff --git a/ui/src/sdn/evpn/mod.rs b/ui/src/sdn/evpn/mod.rs
> index 1948ecf..6c919ba 100644
> --- a/ui/src/sdn/evpn/mod.rs
> +++ b/ui/src/sdn/evpn/mod.rs
> @@ -13,6 +13,9 @@ pub use add_vnet::AddVnetWindow;
> mod add_zone;
> pub use add_zone::AddZoneWindow;
>
> +mod zone_status;
> +pub use zone_status::ZoneStatusTable;
> +
> #[derive(Clone, Copy, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)]
> pub struct EvpnRouteTarget {
> asn: u32,
> @@ -39,3 +42,33 @@ impl std::fmt::Display for EvpnRouteTarget {
> write!(f, "{}:{}", self.asn, self.vni)
> }
> }
> +
> +#[derive(Debug, Clone, PartialEq, Default)]
> +#[repr(transparent)]
> +pub struct NodeList(Vec<String>);
> +
> +impl std::ops::Deref for NodeList {
> + type Target = Vec<String>;
> +
> + fn deref(&self) -> &Self::Target {
> + &self.0
> + }
> +}
> +
> +impl std::str::FromStr for NodeList {
> + type Err = anyhow::Error;
> +
> + fn from_str(value: &str) -> Result<Self, Self::Err> {
> + if value.is_empty() {
> + anyhow::bail!("node list cannot be an empty string");
> + }
> +
> + Ok(Self(value.split(",").map(String::from).collect()))
> + }
> +}
> +
> +impl FromIterator<String> for NodeList {
> + fn from_iter<I: IntoIterator<Item = String>>(iter: I) -> Self {
> + Self(iter.into_iter().collect())
> + }
> +}
> diff --git a/ui/src/sdn/evpn/zone_status.rs b/ui/src/sdn/evpn/zone_status.rs
> new file mode 100644
> index 0000000..a78fd7d
> --- /dev/null
> +++ b/ui/src/sdn/evpn/zone_status.rs
> @@ -0,0 +1,261 @@
> +use std::cmp::Ordering;
> +use std::future::Future;
> +use std::pin::Pin;
> +use std::rc::Rc;
> +
> +use anyhow::{Context, Error};
> +use proxmox_yew_comp::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
> +use pwt::props::{EventSubscriber, ExtractPrimaryKey};
> +use yew::virtual_dom::{Key, VComp, VNode};
> +use yew::{html, AttrValue, Properties};
nit: include ordering (std -> 3rd party -> proxmox -> crate level)
> +
> +use pdm_client::types::SdnZoneIpVrf;
> +use pwt::props::{ContainerBuilder, FieldBuilder, WidgetBuilder, WidgetStyleBuilder};
> +use pwt::state::Store;
> +use pwt::tr;
> +use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
> +use pwt::widget::form::Combobox;
> +use pwt::widget::{error_message, Button, Column, InputPanel, Toolbar};
> +
> +use crate::pdm_client;
> +use crate::sdn::evpn::NodeList;
> +
> +#[derive(Clone, PartialEq, Properties, Default)]
> +pub struct ZoneStatusTable {
> + remote: String,
> + zone: String,
> + nodes: Option<NodeList>,
> +}
> +
> +impl ZoneStatusTable {
> + pub fn new(remote: String, zone: String, nodes: Option<NodeList>) -> Self {
> + yew::props!(Self {
> + zone,
> + remote,
> + nodes
> + })
> + }
> +}
> +
> +impl From<ZoneStatusTable> for VNode {
> + fn from(value: ZoneStatusTable) -> Self {
> + let comp = VComp::new::<LoadableComponentMaster<ZoneStatusComponent>>(Rc::new(value), None);
> + VNode::from(comp)
> + }
> +}
> +
> +#[derive(Clone, PartialEq)]
> +#[repr(transparent)]
> +pub struct IpVrfEntry(pub SdnZoneIpVrf);
> +
> +impl From<SdnZoneIpVrf> for IpVrfEntry {
> + fn from(value: SdnZoneIpVrf) -> Self {
> + Self(value)
> + }
> +}
> +
> +impl ExtractPrimaryKey for IpVrfEntry {
> + fn extract_key(&self) -> Key {
> + Key::from(self.0.ip.as_str())
> + }
> +}
> +
> +fn default_sorter(a: &IpVrfEntry, b: &IpVrfEntry) -> Ordering {
> + (&a.0.ip, &a.0.metric).cmp(&(&b.0.ip, &b.0.metric))
> +}
> +
> +pub struct ZoneStatusComponent {
> + store: Store<IpVrfEntry>,
> + columns: Rc<Vec<DataTableHeader<IpVrfEntry>>>,
> + nodes: Option<Rc<Vec<AttrValue>>>,
> + selected_node: Option<AttrValue>,
> + error_msg: Option<String>,
> + vrf_loading: bool,
> +}
> +
> +impl ZoneStatusComponent {
> + fn columns() -> Rc<Vec<DataTableHeader<IpVrfEntry>>> {
> + Rc::new(vec![
> + DataTableColumn::new(tr!("Destination"))
> + .get_property(|entry: &IpVrfEntry| &entry.0.ip)
> + .into(),
> + DataTableColumn::new(tr!("Nexthops"))
> + .render(|entry: &IpVrfEntry| {
> + let mut column = Column::new();
> +
> + for nexthop in &entry.0.nexthops {
> + column.add_child(html! { <div>{ nexthop }</div> });
> + }
> +
> + column.into()
> + })
> + .into(),
> + DataTableColumn::new(tr!("Protocol"))
> + .get_property(|entry: &IpVrfEntry| &entry.0.protocol)
> + .into(),
> + DataTableColumn::new(tr!("Metric"))
> + .get_property(|entry: &IpVrfEntry| &entry.0.metric)
> + .into(),
> + ])
> + }
> +}
> +
> +#[derive(Debug)]
> +pub enum ZoneStatusComponentMsg {
> + NodeSelected(Option<String>),
> + NodeListLoaded(Rc<Vec<AttrValue>>),
> + ZoneStatusLoaded(Result<Vec<SdnZoneIpVrf>, Error>),
> +}
nit: ZoneStatusComponentMsg and ZoneStatusComponent can probably be
private?
> +
> +impl LoadableComponent for ZoneStatusComponent {
> + type Message = ZoneStatusComponentMsg;
> + type Properties = ZoneStatusTable;
> + type ViewState = ();
> +
> + fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
> + Self {
> + store: Store::new(),
> + columns: Self::columns(),
> + selected_node: None,
> + nodes: Default::default(),
> + error_msg: None,
> + vrf_loading: false,
> + }
> + }
> +
> + fn load(
> + &self,
> + ctx: &proxmox_yew_comp::LoadableComponentContext<Self>,
> + ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
> + let link = ctx.link().clone();
> + let props = ctx.props().clone();
> +
> + Box::pin(async move {
> + let node_list = if let Some(nodes) = props.nodes {
> + nodes.iter().cloned().map(AttrValue::from).collect()
> + } else {
> + pdm_client()
> + .pve_list_nodes(&props.remote)
> + .await?
> + .into_iter()
> + .map(|node_index| AttrValue::from(node_index.node))
> + .collect()
> + };
> +
> + link.send_message(Self::Message::NodeListLoaded(Rc::new(node_list)));
> +
> + Ok(())
> + })
> + }
> +
> + fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
> + match msg {
> + Self::Message::NodeListLoaded(node_list) => {
> + let selected_node = node_list.iter().next().cloned();
> +
> + self.nodes = Some(node_list);
> +
> + if let Some(node) = selected_node {
> + ctx.link()
> + .send_message(Self::Message::NodeSelected(Some(node.to_string())));
> + }
> + }
> + Self::Message::NodeSelected(node_name) => {
> + if let Some(node_name) = node_name {
> + self.vrf_loading = true;
> + self.selected_node = Some(node_name.clone().into());
> +
> + let link = ctx.link().clone();
> + let props = ctx.props().clone();
> +
> + ctx.link().spawn(async move {
> + let status_result = pdm_client()
> + .pve_sdn_zone_get_ip_vrf(&props.remote, &node_name, &props.zone)
> + .await;
> +
> + link.send_message(Self::Message::ZoneStatusLoaded(
> + status_result.with_context(|| "could not load zone status".to_string()),
> + ));
> + });
> + }
> + }
> + Self::Message::ZoneStatusLoaded(zone_status_result) => {
> + self.vrf_loading = false;
> +
> + match zone_status_result {
> + Ok(zone_status) => {
> + self.store
> + .write()
> + .set_data(zone_status.into_iter().map(IpVrfEntry::from).collect());
> +
> + self.store.set_sorter(default_sorter);
> +
> + self.error_msg = None;
> + }
> + Err(error) => {
> + self.store.write().clear();
> + self.error_msg = Some(format!("{error:?}"));
> + }
> + }
> + }
> + }
> +
> + true
> + }
> +
> + fn main_view(&self, ctx: &proxmox_yew_comp::LoadableComponentContext<Self>) -> yew::Html {
> + let selected_node = self.selected_node.clone();
> +
> + let toolbar = Toolbar::new()
> + .class("pwt-w-100")
> + .class("pwt-overflow-hidden")
> + .class("pwt-border-bottom")
> + .with_child(
> + InputPanel::new().with_field(
> + tr!("Node"),
> + Combobox::new()
> + .min_width(100)
> + .required(true)
> + .value(self.selected_node.clone())
> + .items(self.nodes.clone().unwrap_or_default())
> + .on_change(
> + ctx.link()
> + .callback(|node| Self::Message::NodeSelected(Some(node))),
> + ),
> + ),
> + )
> + .with_flex_spacer()
> + .with_child(Button::refresh(ctx.loading() || self.vrf_loading).onclick(
> + ctx.link().callback(move |_| {
> + Self::Message::NodeSelected(selected_node.as_ref().map(ToString::to_string))
> + }),
> + ));
> +
> + let table =
> + DataTable::new(self.columns.clone(), self.store.clone()).class(pwt::css::FlexFit);
> +
> + let mut column = Column::new()
> + .class(pwt::css::FlexFit)
> + .with_child(toolbar)
> + .with_child(table);
> +
> + if let Some(msg) = &self.error_msg {
> + column.add_child(error_message(msg));
> + }
> +
> + column.into()
> + }
> +
> + fn changed(
> + &mut self,
> + ctx: &LoadableComponentContext<Self>,
> + _old_props: &Self::Properties,
> + ) -> bool {
> + self.selected_node = None;
> + self.nodes = None;
> +
> + ctx.link().send_reload();
> +
> + true
> + }
> +}
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
next prev parent reply other threads:[~2025-11-18 14:12 UTC|newest]
Thread overview: 15+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-11-07 8:59 [pdm-devel] [PATCH proxmox{, -datacenter-manager} 0/8] Integration of IP-VRF and MAC-VRF status to EVPN panel Stefan Hanreich
2025-11-07 8:59 ` [pdm-devel] [PATCH proxmox 1/3] pve-api-types: add zone / vnet status reporting endpoints Stefan Hanreich
2025-11-07 8:59 ` [pdm-devel] [PATCH proxmox 2/3] pve-api-types: generate ip-vrf / mac-vrf endpoints Stefan Hanreich
2025-11-07 8:59 ` [pdm-devel] [PATCH proxmox 3/3] pve-api-types: regenerate Stefan Hanreich
2025-11-07 8:59 ` [pdm-devel] [PATCH proxmox-datacenter-manager 1/5] server: api: sdn: add ip-vrf endpoint Stefan Hanreich
2025-11-07 8:59 ` [pdm-devel] [PATCH proxmox-datacenter-manager 2/5] server: api: sdn: add mac-vrf endpoint Stefan Hanreich
2025-11-07 8:59 ` [pdm-devel] [PATCH proxmox-datacenter-manager 3/5] ui: sdn: evpn: add zone status panel Stefan Hanreich
2025-11-18 14:12 ` Lukas Wagner [this message]
2025-11-07 8:59 ` [pdm-devel] [PATCH proxmox-datacenter-manager 4/5] ui: sdn: evpn: add vnet " Stefan Hanreich
2025-11-18 14:12 ` Lukas Wagner
2025-11-07 8:59 ` [pdm-devel] [PATCH proxmox-datacenter-manager 5/5] sdn: evpn: add detail panel to the evpn panel Stefan Hanreich
2025-11-18 14:13 ` Lukas Wagner
2025-11-19 12:16 ` Stefan Hanreich
2025-11-12 16:18 ` [pdm-devel] [PATCH proxmox{, -datacenter-manager} 0/8] Integration of IP-VRF and MAC-VRF status to EVPN panel Hannes Duerr
2025-11-18 14:14 ` Lukas Wagner
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=DEBVRZFOFXB5.9P36O8JX48ON@proxmox.com \
--to=l.wagner@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
--cc=s.hanreich@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