From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 810FC1FF15C for ; Fri, 31 Oct 2025 13:48:09 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id E3EE91185C; Fri, 31 Oct 2025 13:48:41 +0100 (CET) From: Dominik Csapak To: pdm-devel@lists.proxmox.com Date: Fri, 31 Oct 2025 13:44:04 +0100 Message-ID: <20251031124822.2739685-22-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20251031124822.2739685-1-d.csapak@proxmox.com> References: <20251031124822.2739685-1-d.csapak@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.171 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_2 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_4 0.1 random spam to be learned in bayes PROLO_LEO1 0.1 Meta Catches all Leo drug variations so far SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pdm-devel] [PATCH datacenter-manager v3 21/21] ui: dashboard: enable editing view X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" This adds a mechanism to edit a view, namely it adds an edit button (pencil) in the status row. When in 'edit mode' one can: * drag the panels around * delete panels * add new panels * set the 'flex' value of panels * add a new row at the end * delete a whole row There is currently no mechanism to persistently save the result, but that's only a case of wiring the 'on_update_layout' callback to e.g. a backend api call. The drag&drop works with desktop and touchscreens, but on touchscreens, there is no 'drag item' shown currently. The menu structure for adding new items could probably be improved, but that should not be a big issue. For handling the 'editing overlay' of the panels, there is a new 'RowElement' component that just abstracts that away to have a less code in the RowView component. Signed-off-by: Dominik Csapak --- ui/Cargo.toml | 2 +- ui/css/pdm.scss | 4 + ui/src/dashboard/status_row.rs | 29 +- ui/src/dashboard/view.rs | 92 ++++--- ui/src/dashboard/view/row_element.rs | 130 +++++++++ ui/src/dashboard/view/row_view.rs | 389 +++++++++++++++++++++++++-- 6 files changed, 596 insertions(+), 50 deletions(-) create mode 100644 ui/src/dashboard/view/row_element.rs diff --git a/ui/Cargo.toml b/ui/Cargo.toml index cccb914e..b93ad79a 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -23,7 +23,7 @@ serde_json = "1.0" wasm-bindgen = "0.2.92" wasm-bindgen-futures = "0.4" wasm-logger = "0.2" -web-sys = { version = "0.3", features = ["Location"] } +web-sys = { version = "0.3", features = ["Location", "DataTransfer"] } yew = { version = "0.21", features = ["csr"] } yew-router = { version = "0.18" } diff --git a/ui/css/pdm.scss b/ui/css/pdm.scss index 92182a47..71cd4b05 100644 --- a/ui/css/pdm.scss +++ b/ui/css/pdm.scss @@ -120,3 +120,7 @@ background-color: var(--pwt-color-background); } } + +.dragging-item { + opacity: 0.5; +} diff --git a/ui/src/dashboard/status_row.rs b/ui/src/dashboard/status_row.rs index 0855b123..72ca195c 100644 --- a/ui/src/dashboard/status_row.rs +++ b/ui/src/dashboard/status_row.rs @@ -6,12 +6,13 @@ use pwt::{ css::AlignItems, widget::{ActionIcon, Container, Row, Tooltip}, }; -use pwt_macros::widget; +use pwt_macros::{builder, widget}; use proxmox_yew_comp::utils::render_epoch; #[widget(comp=PdmDashboardStatusRow)] #[derive(Properties, PartialEq, Clone)] +#[builder] pub struct DashboardStatusRow { last_refresh: Option, reload_interval_s: u32, @@ -19,6 +20,11 @@ pub struct DashboardStatusRow { on_reload: Callback, on_settings_click: Callback<()>, + + #[builder_cb(Into, into, Option>)] + #[prop_or_default] + /// An optional callback to show and toggle an edit button + on_edit_toggle: Option>, } impl DashboardStatusRow { @@ -40,12 +46,14 @@ impl DashboardStatusRow { pub enum Msg { /// The bool denotes if the reload comes from the click or the timer. Reload(bool), + Edit(bool), } #[doc(hidden)] pub struct PdmDashboardStatusRow { _interval: Interval, loading: bool, + edit: bool, } impl PdmDashboardStatusRow { @@ -70,6 +78,7 @@ impl Component for PdmDashboardStatusRow { Self { _interval: Self::create_interval(ctx), loading: false, + edit: false, } } @@ -81,6 +90,10 @@ impl Component for PdmDashboardStatusRow { self.loading = true; true } + Msg::Edit(edit) => { + self.edit = edit; + true + } } } @@ -121,6 +134,20 @@ impl Component for PdmDashboardStatusRow { None => tr!("Now refreshing"), })) .with_flex_spacer() + .with_optional_child(props.on_edit_toggle.clone().map(|on_edit_toggle| { + let (icon, tooltip, new_value) = if self.edit { + ("fa fa-check", tr!("Finish Editing"), false) + } else { + ("fa fa-pencil", tr!("Edit"), true) + }; + Tooltip::new(ActionIcon::new(icon).tabindex(0).on_activate({ + ctx.link().callback(move |_| { + on_edit_toggle.emit(new_value); + Msg::Edit(new_value) + }) + })) + .tip(tooltip) + })) .with_child( Tooltip::new( ActionIcon::new("fa fa-cogs") diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs index fb46064c..5239878d 100644 --- a/ui/src/dashboard/view.rs +++ b/ui/src/dashboard/view.rs @@ -20,9 +20,7 @@ use crate::dashboard::refresh_config_edit::{ }; use crate::dashboard::tasks::get_task_options; use crate::dashboard::types::RowWidget; -use crate::dashboard::types::{ - LeaderboardType, TaskSummaryGrouping, ViewLayout, ViewTemplate, WidgetType, -}; +use crate::dashboard::types::{TaskSummaryGrouping, ViewLayout, ViewTemplate, WidgetType}; use crate::dashboard::{ create_guest_panel, create_node_panel, create_pbs_datastores_panel, create_refresh_config_edit_window, create_remote_panel, create_sdn_panel, @@ -41,6 +39,8 @@ use pdm_client::types::TopEntities; mod row_view; pub use row_view::RowView; +mod row_element; + #[derive(Properties, PartialEq)] pub struct View { view: AttrValue, @@ -74,6 +74,8 @@ pub enum Msg { Reload(bool), // force ConfigWindow(bool), // show UpdateConfig(RefreshConfig), + EditMode(bool), + LayoutUpdate(ViewLayout), } struct ViewComp { @@ -92,6 +94,8 @@ struct ViewComp { load_finished_time: Option, show_config_window: bool, show_create_wizard: Option, + + edit_mode: bool, } fn render_widget( @@ -271,6 +275,8 @@ impl Component for ViewComp { loading: true, show_config_window: false, show_create_wizard: None, + + edit_mode: false, } } @@ -326,6 +332,15 @@ impl Component for ViewComp { self.show_config_window = false; } + Msg::EditMode(edit) => { + self.edit_mode = edit; + } + Msg::LayoutUpdate(view_layout) => { + // FIXME: update backend layout + if let Some(template) = &mut self.template.data { + template.layout = view_layout; + } + } } true } @@ -345,46 +360,56 @@ impl Component for ViewComp { } let mut view = Column::new().class(css::FlexFit).with_child( Container::new() - .class("pwt-content-spacer-padding") + .padding(4) .class("pwt-content-spacer-colors") .class("pwt-default-colors") - .with_child(DashboardStatusRow::new( - self.load_finished_time, - self.refresh_config - .refresh_interval - .unwrap_or(DEFAULT_REFRESH_INTERVAL_S), - ctx.link().callback(Msg::Reload), - ctx.link().callback(|_| Msg::ConfigWindow(true)), - )), + .with_child( + DashboardStatusRow::new( + self.load_finished_time, + self.refresh_config + .refresh_interval + .unwrap_or(DEFAULT_REFRESH_INTERVAL_S), + ctx.link().callback(Msg::Reload), + ctx.link().callback(|_| Msg::ConfigWindow(true)), + ) + .on_edit_toggle(ctx.link().callback(Msg::EditMode)), + ), ); if !has_sub_panel(self.template.data.as_ref()) { view.add_child( Row::new() - .class("pwt-content-spacer") - .with_child(create_subscription_panel(self.subscriptions.clone())), + .padding_x(4) + .padding_bottom(4) + .padding_top(0) + .class("pwt-content-spacer-colors") + .with_child(create_subscription_panel(self.subscriptions.clone()).flex(1.0)), ); } match self.template.data.as_ref().map(|template| &template.layout) { Some(ViewLayout::Rows { rows }) => { - view.add_child(RowView::new(rows.clone(), { - let link = ctx.link().clone(); - let status = self.status.clone(); - let subscriptions = self.subscriptions.clone(); - let top_entities = self.top_entities.clone(); - let statistics = self.statistics.clone(); - let refresh_config = self.refresh_config.clone(); - move |widget: &RowWidget| { - render_widget( - link.clone(), - widget, - status.clone(), - subscriptions.clone(), - top_entities.clone(), - statistics.clone(), - refresh_config.clone(), - ) - } - })); + view.add_child( + RowView::new(rows.clone(), { + let link = ctx.link().clone(); + let status = self.status.clone(); + let subscriptions = self.subscriptions.clone(); + let top_entities = self.top_entities.clone(); + let statistics = self.statistics.clone(); + let refresh_config = self.refresh_config.clone(); + move |widget: &RowWidget| { + render_widget( + link.clone(), + widget, + status.clone(), + subscriptions.clone(), + top_entities.clone(), + statistics.clone(), + refresh_config.clone(), + ) + } + }) + .edit_mode(self.edit_mode) + .on_update_layout(ctx.link().callback(Msg::LayoutUpdate)), + ); } None => {} } @@ -485,6 +510,7 @@ async fn load_template() -> Result { \"leaderboard-type\": \"node-memory\" } ], + [], [ { \"flex\": 5.0, diff --git a/ui/src/dashboard/view/row_element.rs b/ui/src/dashboard/view/row_element.rs new file mode 100644 index 00000000..d242195c --- /dev/null +++ b/ui/src/dashboard/view/row_element.rs @@ -0,0 +1,130 @@ +use yew::html::IntoEventCallback; + +use pwt::css; +use pwt::prelude::*; +use pwt::props::RenderFn; +use pwt::widget::{ActionIcon, Card, Fa, Panel, Row}; +use pwt_macros::{builder, widget}; + +use crate::dashboard::types::RowWidget; + +#[widget(comp=RowElementComp, @element)] +#[derive(PartialEq, Properties, Clone)] +#[builder] +pub struct RowElement { + item: RowWidget, + widget_renderer: RenderFn, + + #[builder] + #[prop_or_default] + edit_mode: bool, + + #[builder] + #[prop_or_default] + is_dragging: bool, + + #[builder_cb(IntoEventCallback, into_event_callback, ())] + #[prop_or_default] + on_remove: Option>, + + #[builder_cb(IntoEventCallback, into_event_callback, u32)] + #[prop_or_default] + on_flex_change: Option>, +} + +impl RowElement { + pub fn new(item: RowWidget, widget_renderer: impl Into>) -> Self { + let widget_renderer = widget_renderer.into(); + yew::props!(Self { + item, + widget_renderer + }) + } +} + +pub enum Msg { + FlexReduce, + FlexIncrease, +} + +pub struct RowElementComp {} + +impl Component for RowElementComp { + type Message = Msg; + type Properties = RowElement; + + fn create(_ctx: &Context) -> Self { + Self {} + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + let props = ctx.props(); + let flex = props.item.flex.unwrap_or(1.0) as u32; + match msg { + Msg::FlexReduce => { + if let Some(on_flex_change) = &props.on_flex_change { + on_flex_change.emit(flex.saturating_sub(1)) + } + } + Msg::FlexIncrease => { + if let Some(on_flex_change) = &props.on_flex_change { + on_flex_change.emit(flex.saturating_add(1)) + } + } + } + + true + } + + fn view(&self, ctx: &Context) -> Html { + let props = ctx.props(); + let widget = props.widget_renderer.apply(&props.item); + + let edit_overlay = Card::new() + .padding(2) + .style("z-index", "10") + .class(css::AlignItems::Center) + .with_child(Fa::new("bars").style("cursor", "grab").padding_end(1)) + .with_child(tr!("Flex")) + .with_child( + ActionIcon::new("fa fa-minus") + .on_activate(ctx.link().callback(|_| Msg::FlexReduce)), + ) + .with_child(props.item.flex.unwrap_or(1.0) as u32) + .with_child( + ActionIcon::new("fa fa-plus") + .on_activate(ctx.link().callback(|_| Msg::FlexIncrease)), + ) + .with_child(ActionIcon::new("fa fa-times").on_activate({ + let on_remove = props.on_remove.clone(); + move |_| { + if let Some(on_remove) = &on_remove { + on_remove.emit(()); + } + } + })); + + Panel::new() + .with_std_props(&props.std_props) + .listeners(&props.listeners) + .border(true) + .class(props.is_dragging.then_some("dragging-item")) + .attribute("draggable", if props.edit_mode { "true" } else { "false" }) + .style("position", "relative") + .with_child(widget) + .with_optional_child( + props.edit_mode.then_some( + Row::new() + .gap(2) + .class(css::Display::Flex) + .class(css::AlignItems::Start) + .class(css::JustifyContent::End) + .key("overlay") + .style("position", "absolute") + .style("inset", "0") + .with_child(edit_overlay), + ), + ) + .into() + } +} diff --git a/ui/src/dashboard/view/row_view.rs b/ui/src/dashboard/view/row_view.rs index 69300327..adf7733f 100644 --- a/ui/src/dashboard/view/row_view.rs +++ b/ui/src/dashboard/view/row_view.rs @@ -1,21 +1,37 @@ use std::collections::HashMap; use std::rc::Rc; +use gloo_timers::callback::Timeout; +use wasm_bindgen::JsCast; +use web_sys::Element; +use yew::html::IntoEventCallback; use yew::virtual_dom::{VComp, VNode}; use pwt::css; use pwt::prelude::*; use pwt::props::RenderFn; -use pwt::widget::{Column, Container, Panel, Row}; +use pwt::widget::menu::{Menu, MenuButton, MenuItem}; +use pwt::widget::{Button, Column, Container, Row}; use pwt_macros::builder; -use crate::dashboard::types::RowWidget; +use crate::dashboard::types::{RowWidget, ViewLayout, WidgetType}; +use crate::dashboard::view::row_element::RowElement; + +use pdm_api_types::remotes::RemoteType; #[derive(Properties, PartialEq)] #[builder] pub struct RowView { rows: Vec>, widget_renderer: RenderFn, + + #[prop_or_default] + #[builder] + edit_mode: bool, + + #[prop_or_default] + #[builder_cb(IntoEventCallback, into_event_callback, ViewLayout)] + on_update_layout: Option>, } impl RowView { @@ -33,6 +49,21 @@ impl From for VNode { } } +pub enum DragMsg { + Start(Position), + End, + Enter(Position), + EnterDebounced(Position), +} +pub enum Msg { + DragEvent(DragMsg), + AddRow, + RemoveRow(usize), // idx + EditFlex(Position, u32), + AddWidget(Position, WidgetType), + RemoveWidget(Position), +} + #[derive(Clone, Copy, Debug, PartialEq)] /// Represents the position of a widget in a row view pub struct Position { @@ -42,6 +73,12 @@ pub struct Position { pub struct RowViewComp { current_layout: Vec>, + new_layout: Option>>, + dragging: Option, // index of item + dragging_target: Option, // index of item + drag_timeout: Option, + + next_row_indices: HashMap, // for saving the max index for new widgets } fn extract_row_layout(rows: &Vec>) -> Vec> { @@ -65,7 +102,7 @@ fn extract_row_layout(rows: &Vec>) -> Vec) -> Self { @@ -75,14 +112,109 @@ impl Component for RowViewComp { for (row_idx, row) in current_layout.iter().enumerate() { next_row_indices.insert(row_idx, row.len()); } - Self { current_layout } + Self { + new_layout: None, + current_layout, + dragging: None, + dragging_target: None, + drag_timeout: None, + next_row_indices, + } + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + match msg { + Msg::RemoveRow(idx) => { + self.current_layout.remove(idx); + } + Msg::AddRow => { + self.current_layout.push(Vec::new()); + } + Msg::DragEvent(drag_msg) => { + match drag_msg { + DragMsg::Start(coords) => { + self.dragging = Some(coords); + self.dragging_target = Some(coords); + } + DragMsg::End => { + self.dragging = None; + self.dragging_target = None; + if let Some(layout) = self.new_layout.take() { + self.current_layout = layout; + } + } + DragMsg::Enter(coords) => { + let link = ctx.link().clone(); + self.drag_timeout = Some(Timeout::new(100, move || { + link.send_message(Msg::DragEvent(DragMsg::EnterDebounced(coords))); + })); + } + DragMsg::EnterDebounced(coords) => { + // FIXME: only change when item is in correct place, + // e.g. when the mouse position is such that it's inside the space + // where the item would land + if let Some(source_coords) = self.dragging { + let mut new_layout = self.current_layout.clone(); + let item = new_layout[source_coords.row].remove(source_coords.item); + let target_idx = new_layout[coords.row].len().min(coords.item); + new_layout[coords.row].insert(target_idx, item); + self.new_layout = Some(new_layout); + } + self.dragging_target = Some(coords); + } + } + } + Msg::EditFlex(coords, flex) => { + self.current_layout[coords.row][coords.item].1.flex = Some(flex as f32); + } + Msg::AddWidget(coords, widget_type) => { + let next_idx = *self.next_row_indices.get(&coords.row).unwrap_or(&0); + self.next_row_indices + .insert(coords.row, next_idx.saturating_add(1)); + self.current_layout[coords.row].insert( + coords.item, + ( + Position { + row: coords.row, + item: next_idx, + }, + RowWidget { + flex: None, + title: None, + r#type: widget_type, + }, + ), + ); + } + Msg::RemoveWidget(coords) => { + self.current_layout[coords.row].remove(coords.item); + } + } + true } fn changed(&mut self, ctx: &Context, old_props: &Self::Properties) -> bool { let props = ctx.props(); - + if !props.edit_mode && old_props.edit_mode { + if let Some(on_update_layout) = &props.on_update_layout { + let rows = self + .current_layout + .iter() + .map(|row| row.iter().map(|(_, item)| item.clone()).collect()) + .collect(); + on_update_layout.emit(ViewLayout::Rows { rows }); + } + } if props.rows != old_props.rows { - self.current_layout = extract_row_layout(&props.rows); + let new_layout = extract_row_layout(&props.rows); + if new_layout != self.current_layout { + self.current_layout = new_layout; + } + if !props.edit_mode { + self.dragging = None; + self.dragging_target = None; + self.drag_timeout = None; + } } true @@ -90,8 +222,11 @@ impl Component for RowViewComp { fn view(&self, ctx: &Context) -> Html { let props = ctx.props(); - let mut view = Column::new(); - let layout = &self.current_layout; + let mut view = Column::new().onpointerup( + (self.dragging.is_some() && props.edit_mode) + .then_some(ctx.link().callback(|_| Msg::DragEvent(DragMsg::End))), + ); + let layout = self.new_layout.as_ref().unwrap_or(&self.current_layout); let mut row = Row::new() .padding_x(2) .class("pwt-content-spacer-colors") @@ -104,7 +239,7 @@ impl Component for RowViewComp { .sum(); let gaps_ratio = 1.0; //items.len().saturating_sub(1) as f32 / items.len() as f32; - for (_item_idx, (coords, item)) in items.iter().enumerate() { + for (item_idx, (coords, item)) in items.iter().enumerate() { let flex = item.flex.unwrap_or(1.0); let flex_ratio = 95.0 * (flex.max(1.0)) / flex_sum; // we have to subtract the gaps too @@ -112,27 +247,251 @@ impl Component for RowViewComp { "{} {} calc({}% - calc({} * var(--pwt-spacer-4)))", flex, flex, flex_ratio, gaps_ratio ); + let current_coords = Position { + row: row_idx, + item: item_idx, + }; - let widget = props.widget_renderer.apply(&item); - let row_element = Panel::new() - .border(true) + let row_element = RowElement::new(item.clone(), props.widget_renderer.clone()) .margin_x(2) .margin_bottom(4) + .edit_mode(props.edit_mode) + .is_dragging(self.dragging_target == Some(current_coords)) .key(format!("item-{}-{}", coords.row, coords.item)) .style("flex", flex_style) - .with_child(widget); - + .style("touch-action", props.edit_mode.then_some("none")) + .on_remove( + ctx.link() + .callback(move |_| Msg::RemoveWidget(current_coords)), + ) + .on_flex_change( + ctx.link() + .callback(move |flex| Msg::EditFlex(current_coords, flex)), + ) + .ondragstart(ctx.link().callback(move |event: DragEvent| { + let data = event.data_transfer().unwrap(); + let _ = data.clear_data(); + let _ = data.set_data("", ""); + Msg::DragEvent(DragMsg::Start(current_coords)) + })) + .onpointerdown(props.edit_mode.then_some(ctx.link().callback( + move |event: PointerEvent| { + // we need to release the pointer capture to trigger pointer events + // on other elements + if let Some(target) = event + .target() + .and_then(|target| target.dyn_into::().ok()) + { + let _ = target.release_pointer_capture(event.pointer_id()); + } + Msg::DragEvent(DragMsg::Start(current_coords)) + }, + ))) + .ondragend(ctx.link().callback(|_| Msg::DragEvent(DragMsg::End))) + // necessary for drop event to trigger + .ondragover(|event: DragEvent| event.prevent_default()) + .ondragenter( + ctx.link() + .callback(move |_| Msg::DragEvent(DragMsg::Enter(current_coords))), + ) + .onpointerenter( + (self.dragging.is_some() && props.edit_mode).then_some( + ctx.link() + .callback(move |_| Msg::DragEvent(DragMsg::Enter(current_coords))), + ), + ) + .ondrop(ctx.link().callback(|event: DragEvent| { + event.prevent_default(); + Msg::DragEvent(DragMsg::End) + })); row.add_child(row_element); } + if props.edit_mode { + let drop_coords = Position { + row: row_idx, + item: items.len().saturating_sub(1), + }; + row.add_child( + Container::new() + .key(format!("row-add-{}", row_idx)) + .style("flex", "1 1 100%") + .margin_x(2) + .margin_bottom(4) + .padding_bottom(4) + .border_bottom(true) + .ondragenter( + ctx.link() + .callback(move |_| Msg::DragEvent(DragMsg::Enter(drop_coords))), + ) + .onpointerenter( + (self.dragging.is_some() && props.edit_mode) + .then_some(ctx.link().callback(move |_| { + Msg::DragEvent(DragMsg::Enter(drop_coords)) + })), + ) + // necessary for drop event to trigger + .ondragover(|event: DragEvent| event.prevent_default()) + .ondrop(ctx.link().callback(|event: DragEvent| { + event.prevent_default(); + Msg::DragEvent(DragMsg::End) + })) + .with_child( + Row::new() + .gap(2) + .with_child( + MenuButton::new(tr!("Add Widget")) + .class(css::ColorScheme::Primary) + .show_arrow(true) + .icon_class("fa fa-plus-circle") + .menu(create_menu( + ctx, + Position { + row: row_idx, + item: items.len(), + }, + )), + ) + .with_child( + Button::new(tr!("Remove Row")) + .icon_class("fa fa-times") + .class(css::ColorScheme::Error) + .on_activate( + ctx.link().callback(move |_| Msg::RemoveRow(row_idx)), + ), + ), + ), + ); + } row.add_child( Container::new() .key(format!("spacer-{row_idx}")) .style("flex", "1 1 100%"), ); } - + if props.edit_mode { + row.add_child( + Container::new() + .key("add-row") + .padding_x(2) + .style("flex", "1 1 100%") + .with_child( + Button::new(tr!("Add Row")) + .class(css::ColorScheme::Secondary) + .icon_class("fa fa-plus-circle") + .on_activate(ctx.link().callback(|_| Msg::AddRow)), + ), + ); + } view.add_child(row); view.into() } } + +fn create_menu(ctx: &yew::Context, new_coords: Position) -> Menu { + let create_callback = |widget: WidgetType| { + ctx.link() + .callback(move |_| Msg::AddWidget(new_coords, widget.clone())) + }; + Menu::new() + .with_item( + MenuItem::new(tr!("Remote Panel")) + .on_select(create_callback(WidgetType::Remotes { show_wizard: true })), + ) + .with_item( + MenuItem::new(tr!("Node Panels")).menu( + Menu::new() + .with_item( + MenuItem::new(tr!("All Nodes")) + .on_select(create_callback(WidgetType::Nodes { remote_type: None })), + ) + .with_item(MenuItem::new(tr!("PBS Nodes")).on_select(create_callback( + WidgetType::Nodes { + remote_type: Some(RemoteType::Pbs), + }, + ))) + .with_item(MenuItem::new(tr!("PVE Nodes")).on_select(create_callback( + WidgetType::Nodes { + remote_type: Some(RemoteType::Pve), + }, + ))), + ), + ) + .with_item( + MenuItem::new(tr!("Guest Panels")).menu( + Menu::new() + .with_item( + MenuItem::new(tr!("All Guests")) + .on_select(create_callback(WidgetType::Guests { guest_type: None })), + ) + .with_item( + MenuItem::new(tr!("Virtual Machines")).on_select(create_callback( + WidgetType::Guests { + guest_type: Some(crate::pve::GuestType::Qemu), + }, + )), + ) + .with_item( + MenuItem::new(tr!("Linux Container")).on_select(create_callback( + WidgetType::Guests { + guest_type: Some(crate::pve::GuestType::Lxc), + }, + )), + ), + ), + ) + .with_item( + MenuItem::new(tr!("Subscription Panel")) + .on_select(create_callback(WidgetType::Subscription)), + ) + .with_item( + MenuItem::new(tr!("PBS Datastores")) + .on_select(create_callback(WidgetType::PbsDatastores)), + ) + .with_item( + MenuItem::new(tr!("Leaderboards")).menu( + Menu::new() + .with_item( + MenuItem::new(tr!("Guests with Highest CPU Usage")).on_select( + create_callback(WidgetType::Leaderboard { + leaderboard_type: + crate::dashboard::types::LeaderboardType::GuestCpu, + }), + ), + ) + .with_item( + MenuItem::new(tr!("Nodes With the Hightest CPU Usagge)")).on_select( + create_callback(WidgetType::Leaderboard { + leaderboard_type: crate::dashboard::types::LeaderboardType::NodeCpu, + }), + ), + ) + .with_item( + MenuItem::new(tr!("Nodes With the Highest Memory Usage")).on_select( + create_callback(WidgetType::Leaderboard { + leaderboard_type: + crate::dashboard::types::LeaderboardType::NodeMemory, + }), + ), + ), + ), + ) + .with_item( + MenuItem::new(tr!("Task Summaries")).menu( + Menu::new() + .with_item(MenuItem::new(tr!("Task Summary by Category")).on_select( + create_callback(WidgetType::TaskSummary { + grouping: crate::dashboard::types::TaskSummaryGrouping::Category, + }), + )) + .with_item( + MenuItem::new(tr!("Task Summary Sorted by Failed Tasks")).on_select( + create_callback(WidgetType::TaskSummary { + grouping: crate::dashboard::types::TaskSummaryGrouping::Remote, + }), + ), + ), + ), + ) + .with_item(MenuItem::new(tr!("SDN Panel")).on_select(create_callback(WidgetType::Sdn))) +} -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel