public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
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


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