public inbox for yew-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH yew-comp 0/3] improve task/log viewer
@ 2026-03-30 12:53 Dominik Csapak
  2026-03-30 12:53 ` [PATCH yew-comp 1/3] task (viewer): add task download button Dominik Csapak
                   ` (2 more replies)
  0 siblings, 3 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-03-30 12:53 UTC (permalink / raw)
  To: yew-devel

1/3 introduces a download button (like in PVE/PBS) to the task viewer
2/3 is just import cleanup
3/3 reformats task log lines with '\r' in them (see commit message for
details why this is useful/necessary)

Dominik Csapak (3):
  task (viewer): add task download button
  log-view: reorganize imports
  log-view: improve display of lines with '\r'

 src/lib.rs         |  21 ++++++++
 src/log_view.rs    |  48 ++++++++++++------
 src/task_viewer.rs | 118 +++++++++++++++++++++++++++++++++++++++++----
 src/tasks.rs       |   6 +++
 4 files changed, 169 insertions(+), 24 deletions(-)

-- 
2.47.3





^ permalink raw reply	[flat|nested] 11+ messages in thread

* [PATCH yew-comp 1/3] task (viewer): add task download button
  2026-03-30 12:53 [PATCH yew-comp 0/3] improve task/log viewer Dominik Csapak
@ 2026-03-30 12:53 ` Dominik Csapak
  2026-03-30 13:36   ` Shannon Sterz
  2026-03-31 11:14   ` Maximiliano Sandoval
  2026-03-30 12:53 ` [PATCH yew-comp 2/3] log-view: reorganize imports Dominik Csapak
  2026-03-30 12:53 ` [RFC PATCH yew-comp 3/3] log-view: improve display of lines with '\r' Dominik Csapak
  2 siblings, 2 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-03-30 12:53 UTC (permalink / raw)
  To: yew-devel

similar to what we already have in the ExtJS gui. Currently the default
is to construct a data URL from the downloaded json in the UI, since not
all APIs support the 'download=1' parameter (and some can't be easily
extended).

The download api can be enabled with a setting though, so if at some
point all consumers use that, we can drop the compatibility code path.

Adds also a small helper to create a temporary 'a' element to click.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
Longer explanation which api calls do not support the 'download'
parameter: on PDM, we tunnel the api call to the remote, but this is
autogenerated code that expects json output. With the 'download'
parameter, the content type gets set to text/plain, which confuses the
client.

To fix it, we'd either have to rewrite the auto-code generation to
handle this case, or expose the underlying client and doit manually, or
rewrite the remote task api call to to something similar what we do here
in the ui, collecting json output and returning a final log txt file.

Since having this download button is useful anyway, and implementing any
of these solutions would take way longer than doing this here, I opted
for doing that now, and fixing the backend later.

 src/lib.rs         |  21 ++++++++
 src/log_view.rs    |   7 +--
 src/task_viewer.rs | 118 +++++++++++++++++++++++++++++++++++++++++----
 src/tasks.rs       |   6 +++
 4 files changed, 140 insertions(+), 12 deletions(-)

diff --git a/src/lib.rs b/src/lib.rs
index 62aa571..65dae68 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -234,6 +234,8 @@ pub mod utils;
 mod xtermjs;
 pub use xtermjs::{ConsoleType, ProxmoxXTermJs, XTermJs};
 
+use anyhow::{format_err, Error};
+
 use pwt::gettext_noop;
 use pwt::state::{LanguageInfo, TextDirection};
 
@@ -271,6 +273,25 @@ mod panic_wrapper {
     }
 }
 
+/// Helper to download the given 'source' url via a simulated click on a hidden 'a' element.
+pub fn download_as_file(source: &str, file_name: &str) -> Result<(), Error> {
+    let el = gloo_utils::document()
+        .create_element("a")
+        .map_err(|_| format_err!("unable to create element 'a'"))?;
+    let html = el
+        .dyn_into::<web_sys::HtmlElement>()
+        .map_err(|_| format_err!("unable to cast to html element"))?;
+    html.set_attribute("href", source)
+        .map_err(|_| format_err!("unable to set href attribute"))?;
+    html.set_attribute("target", "_blank")
+        .map_err(|_| format_err!("unable to set target attribute"))?;
+    html.set_attribute("download", file_name)
+        .map_err(|_| format_err!("unable to set download attribute"))?;
+    html.click();
+    html.remove();
+    Ok(())
+}
+
 pub fn store_csrf_token(crsf_token: &str) {
     if let Some(store) = pwt::state::session_storage() {
         if store.set_item("CSRFToken", crsf_token).is_err() {
diff --git a/src/log_view.rs b/src/log_view.rs
index 20f8f77..46bc758 100644
--- a/src/log_view.rs
+++ b/src/log_view.rs
@@ -37,10 +37,11 @@ const MAX_PHYSICAL: f64 = 17_000_000.0;
 const DEFAULT_LINE_HEIGHT: u64 = 18;
 
 #[derive(Deserialize)]
-struct LogEntry {
+/// a single Log entry line
+pub struct LogEntry {
     #[allow(dead_code)]
-    n: u64,
-    t: String,
+    pub n: u64,
+    pub t: String,
 }
 
 pub struct LogPage {
diff --git a/src/task_viewer.rs b/src/task_viewer.rs
index e78454e..decf721 100644
--- a/src/task_viewer.rs
+++ b/src/task_viewer.rs
@@ -1,6 +1,6 @@
 use std::rc::Rc;
 
-use serde_json::Value;
+use serde_json::{json, Value};
 
 use gloo_timers::callback::Timeout;
 
@@ -9,15 +9,19 @@ use yew::prelude::*;
 use yew::virtual_dom::{Key, VComp, VNode};
 
 use pwt::state::Loader;
-use pwt::widget::{Button, Column, Dialog, TabBarItem, TabPanel, Toolbar};
+use pwt::widget::{AlertDialog, Button, Column, Container, Dialog, TabBarItem, TabPanel, Toolbar};
 use pwt::{prelude::*, AsyncPool};
 
+use crate::common_api_types::ProxmoxUpid;
+use crate::log_view::LogEntry;
 use crate::percent_encoding::percent_encode_component;
 use crate::utils::{format_duration_human, format_upid, render_epoch};
-use crate::{KVGrid, KVGridRow, LogView};
+use crate::{download_as_file, KVGrid, KVGridRow, LogView};
 
 use pwt_macros::builder;
 
+use proxmox_time::epoch_to_rfc3339_utc;
+
 #[derive(Properties, PartialEq, Clone)]
 #[builder]
 pub struct TaskViewer {
@@ -39,6 +43,12 @@ pub struct TaskViewer {
     #[builder(IntoPropValue, into_prop_value)]
     /// The base url for
     pub base_url: AttrValue,
+
+    #[prop_or(false)]
+    #[builder(IntoPropValue, into_prop_value)]
+    /// Use the 'download' API parameter for downloading task logs.
+    /// By default the task log will be downloaded as json, and converted to a data URL.
+    pub download_api: bool,
 }
 
 impl TaskViewer {
@@ -55,6 +65,7 @@ pub enum Msg {
     DataChange,
     Reload,
     StopTask,
+    DownloadErr(Option<String>),
 }
 
 pub struct PwtTaskViewer {
@@ -63,6 +74,7 @@ pub struct PwtTaskViewer {
     active: bool,
     endtime: Option<i64>,
     async_pool: AsyncPool,
+    download_err: Option<String>,
 }
 
 impl Component for PwtTaskViewer {
@@ -99,6 +111,7 @@ impl Component for PwtTaskViewer {
             active: props.endtime.is_none(),
             endtime: props.endtime,
             async_pool: AsyncPool::new(),
+            download_err: None,
         }
     }
 
@@ -138,12 +151,23 @@ impl Component for PwtTaskViewer {
                 }
                 true
             }
+            Msg::DownloadErr(err) => {
+                self.download_err = err;
+                true
+            }
         }
     }
 
     fn view(&self, ctx: &Context<Self>) -> Html {
         let props = ctx.props();
 
+        if let Some(err) = &self.download_err {
+            return AlertDialog::new(err)
+                .title(tr!("Download Error"))
+                .on_close(ctx.link().callback(|_| Msg::DownloadErr(None)))
+                .into();
+        }
+
         let panel = self.loader.render(|data| {
             TabPanel::new()
                 .class("pwt-flex-fit")
@@ -273,18 +297,94 @@ impl PwtTaskViewer {
         let active = self.active;
         let link = ctx.link();
 
-        let toolbar = Toolbar::new().class("pwt-border-bottom").with_child(
-            Button::new(tr!("Stop"))
-                .disabled(!active)
-                .onclick(link.callback(|_| Msg::StopTask)),
-        );
-
         let url = format!(
             "{}/{}/log",
             props.base_url,
             percent_encode_component(&task_id),
         );
 
+        let filename = match props.task_id.parse::<ProxmoxUpid>() {
+            Ok(upid) => {
+                format!(
+                    "task-{}-{}-{}.log",
+                    upid.node,
+                    upid.worker_type,
+                    epoch_to_rfc3339_utc(upid.starttime).unwrap_or(upid.starttime.to_string())
+                )
+            }
+            Err(_) => format!("task-{}.log", props.task_id),
+        };
+
+        let download_btn: Html = if props.download_api {
+            Container::from_tag("a")
+                .attribute("disabled", active.then_some(""))
+                .attribute("target", "_blank")
+                .attribute("href", format!("/api2/extjs{url}?download=1"))
+                .attribute("filename", filename)
+                .with_child(
+                    Button::new(tr!("Download"))
+                        .disabled(active)
+                        .icon_class("fa fa-download"),
+                )
+                .into()
+        } else {
+            Button::new(tr!("Download"))
+                .icon_class("fa fa-download")
+                .disabled(active)
+                .on_activate({
+                    let url = url.clone();
+                    let link = link.clone();
+                    let async_pool = self.async_pool.clone();
+                    move |_| {
+                        let url = url.clone();
+                        let filename = filename.clone();
+                        let link = link.clone();
+                        async_pool.spawn(async move {
+                            let param = json!({
+                                "start": 0,
+                                "limit": 0,
+                            });
+                            let resp =
+                                crate::http_get_full::<Vec<LogEntry>>(url, Some(param)).await;
+                            match resp {
+                                Ok(resp) => {
+                                    let string = resp
+                                        .data
+                                        .into_iter()
+                                        .map(|entry| entry.t)
+                                        .collect::<Vec<_>>()
+                                        .join("\n");
+
+                                    if let Err(err) = download_as_file(
+                                        &format!(
+                                            "data:text/plain;charset=utf-8,{}",
+                                            percent_encode_component(&string)
+                                        ),
+                                        &filename,
+                                    ) {
+                                        link.send_message(Msg::DownloadErr(Some(err.to_string())))
+                                    }
+                                }
+                                Err(err) => {
+                                    link.send_message(Msg::DownloadErr(Some(err.to_string())))
+                                }
+                            }
+                        });
+                    }
+                })
+                .into()
+        };
+
+        let toolbar = Toolbar::new()
+            .class("pwt-border-bottom")
+            .with_child(
+                Button::new(tr!("Stop"))
+                    .disabled(!active)
+                    .onclick(link.callback(|_| Msg::StopTask)),
+            )
+            .with_flex_spacer()
+            .with_child(download_btn);
+
         Column::new()
             .class("pwt-flex-fit")
             .with_child(toolbar)
diff --git a/src/tasks.rs b/src/tasks.rs
index b49de98..fb3ed39 100644
--- a/src/tasks.rs
+++ b/src/tasks.rs
@@ -78,6 +78,11 @@ pub struct Tasks {
     #[prop_or_default]
     /// An optional column configuration that overwrites the default one.
     pub columns: Option<Rc<Vec<DataTableHeader<TaskListItem>>>>,
+
+    #[prop_or(false)]
+    #[builder(IntoPropValue, into_prop_value)]
+    /// Use the 'download' API parameter for downloading task logs.
+    pub download_api: bool,
 }
 
 impl Default for Tasks {
@@ -493,6 +498,7 @@ impl LoadableComponent for ProxmoxTasks {
         match view_state {
             ViewDialog::TaskViewer => {
                 let mut dialog = TaskViewer::new(&*selected_key)
+                    .download_api(props.download_api)
                     .endtime(selected_item.endtime)
                     .on_close(ctx.link().change_view_callback(|_| None));
                 if let Some(base_url) = &props.base_url {
-- 
2.47.3





^ permalink raw reply	[flat|nested] 11+ messages in thread

* [PATCH yew-comp 2/3] log-view: reorganize imports
  2026-03-30 12:53 [PATCH yew-comp 0/3] improve task/log viewer Dominik Csapak
  2026-03-30 12:53 ` [PATCH yew-comp 1/3] task (viewer): add task download button Dominik Csapak
@ 2026-03-30 12:53 ` Dominik Csapak
  2026-03-30 12:53 ` [RFC PATCH yew-comp 3/3] log-view: improve display of lines with '\r' Dominik Csapak
  2 siblings, 0 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-03-30 12:53 UTC (permalink / raw)
  To: yew-devel

into the grouping we usual use. No functional change.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 src/log_view.rs | 18 +++++++-----------
 1 file changed, 7 insertions(+), 11 deletions(-)

diff --git a/src/log_view.rs b/src/log_view.rs
index 46bc758..fdfce4c 100644
--- a/src/log_view.rs
+++ b/src/log_view.rs
@@ -3,24 +3,20 @@ use std::collections::{HashMap, HashSet};
 use std::rc::Rc;
 
 use anyhow::Error;
-
-use pwt::prelude::*;
-use pwt::props::{
-    AsClassesMut, AsCssStylesMut, ContainerBuilder, CssMarginBuilder, CssPaddingBuilder, CssStyles,
-    WidgetBuilder, WidgetStyleBuilder,
-};
-use pwt::AsyncPool;
+use gloo_timers::callback::{Interval, Timeout};
 use serde::Deserialize;
 use serde_json::json;
-
-use gloo_timers::callback::{Interval, Timeout};
-
 use yew::html::{IntoEventCallback, IntoPropValue};
 use yew::virtual_dom::{Key, VComp, VNode};
 
 use pwt::dom::DomSizeObserver;
+use pwt::prelude::*;
+use pwt::props::{
+    AsClassesMut, AsCssStylesMut, ContainerBuilder, CssMarginBuilder, CssPaddingBuilder, CssStyles,
+    WidgetBuilder, WidgetStyleBuilder,
+};
 use pwt::widget::Container;
-
+use pwt::AsyncPool;
 use pwt_macros::builder;
 
 // Note: virtual scrolling fails when log is large:
-- 
2.47.3





^ permalink raw reply	[flat|nested] 11+ messages in thread

* [RFC PATCH yew-comp 3/3] log-view: improve display of lines with '\r'
  2026-03-30 12:53 [PATCH yew-comp 0/3] improve task/log viewer Dominik Csapak
  2026-03-30 12:53 ` [PATCH yew-comp 1/3] task (viewer): add task download button Dominik Csapak
  2026-03-30 12:53 ` [PATCH yew-comp 2/3] log-view: reorganize imports Dominik Csapak
@ 2026-03-30 12:53 ` Dominik Csapak
  2 siblings, 0 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-03-30 12:53 UTC (permalink / raw)
  To: yew-devel

Some tools, like 'dd', will output '\r' as line delimiter, so it
overwrites the last line in the cli output.

Since the browser typically ignores '\r' in the DOM, this kind of output
leads to very long and unreadable lines.

In ExtJS, the problem is circumvented somewhat: it uses '.innerHTML'
which leads to the browser parsing and normalizing line endings.

This "works", in the sense that the lines will displayed separately,
but interferes with the paging algorithm that depends on the backend
line count (which counts "\n" only currently).

We can improve this in the frontend only, by only displaying the 'last
line' (everything after the last '\r') by default, and adding a hint
icon that shows the full output in a tooltip.

This fixes the issue with readability, does not interfere with the
paging algorithm, and sill provides the full output (on demand).

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
this is a pattern we don't have yet, showing inline icons in task log
I did not want to do the same thing extjs does, since it can break the
virtual scrolling.

the long-term fix is to fix the command invokations to replace \r to \n
but this does not fix already existing task logs and older PVE/PBS
servers, so having a workaround/solution in the gui is still useful
IMHO.

 src/log_view.rs | 25 +++++++++++++++++++++++--
 1 file changed, 23 insertions(+), 2 deletions(-)

diff --git a/src/log_view.rs b/src/log_view.rs
index fdfce4c..90000d4 100644
--- a/src/log_view.rs
+++ b/src/log_view.rs
@@ -9,13 +9,14 @@ use serde_json::json;
 use yew::html::{IntoEventCallback, IntoPropValue};
 use yew::virtual_dom::{Key, VComp, VNode};
 
+use pwt::css;
 use pwt::dom::DomSizeObserver;
 use pwt::prelude::*;
 use pwt::props::{
     AsClassesMut, AsCssStylesMut, ContainerBuilder, CssMarginBuilder, CssPaddingBuilder, CssStyles,
     WidgetBuilder, WidgetStyleBuilder,
 };
-use pwt::widget::Container;
+use pwt::widget::{Container, Fa, Tooltip};
 use pwt::AsyncPool;
 use pwt_macros::builder;
 
@@ -478,7 +479,27 @@ impl Component for PwtLogView {
                         let page_ref = page_ref.take().unwrap_or_default();
 
                         for item in page.lines.iter() {
-                            tag.add_child(format!("{}\n", item.t));
+                            if let Some(idx) = item.t.rfind('\r') {
+                                tag.add_child(&item.t[idx + 1..]);
+                                tag.add_child(
+                                    Tooltip::new(
+                                        Fa::new("info-circle").class(css::FontColor::Warning),
+                                    )
+                                    .rich_tip(
+                                        Container::new()
+                                            .class(css::WhiteSpace::Pre)
+                                            .with_child(tr!("Full Output:"))
+                                            .with_child(
+                                                item.t.replace("\r\n", "\n").replace("\r", "\n"),
+                                            ),
+                                    )
+                                    .padding_start(1)
+                                    .class(css::Display::Inline),
+                                );
+                            } else {
+                                tag.add_child(&item.t);
+                            }
+                            tag.add_child("\n");
                         }
 
                         let html: Html = tag.into_html_with_ref(page_ref);
-- 
2.47.3





^ permalink raw reply	[flat|nested] 11+ messages in thread

* Re: [PATCH yew-comp 1/3] task (viewer): add task download button
  2026-03-30 12:53 ` [PATCH yew-comp 1/3] task (viewer): add task download button Dominik Csapak
@ 2026-03-30 13:36   ` Shannon Sterz
  2026-03-30 13:48     ` Dominik Csapak
  2026-03-31 11:14   ` Maximiliano Sandoval
  1 sibling, 1 reply; 11+ messages in thread
From: Shannon Sterz @ 2026-03-30 13:36 UTC (permalink / raw)
  To: Dominik Csapak; +Cc: yew-devel

On Mon Mar 30, 2026 at 2:53 PM CEST, Dominik Csapak wrote:
> similar to what we already have in the ExtJS gui. Currently the default
> is to construct a data URL from the downloaded json in the UI, since not
> all APIs support the 'download=1' parameter (and some can't be easily
> extended).
>
> The download api can be enabled with a setting though, so if at some
> point all consumers use that, we can drop the compatibility code path.
>
> Adds also a small helper to create a temporary 'a' element to click.
>
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
> Longer explanation which api calls do not support the 'download'
> parameter: on PDM, we tunnel the api call to the remote, but this is
> autogenerated code that expects json output. With the 'download'
> parameter, the content type gets set to text/plain, which confuses the
> client.
>
> To fix it, we'd either have to rewrite the auto-code generation to
> handle this case, or expose the underlying client and doit manually, or
> rewrite the remote task api call to to something similar what we do here
> in the ui, collecting json output and returning a final log txt file.
>
> Since having this download button is useful anyway, and implementing any
> of these solutions would take way longer than doing this here, I opted
> for doing that now, and fixing the backend later.
>
>  src/lib.rs         |  21 ++++++++
>  src/log_view.rs    |   7 +--
>  src/task_viewer.rs | 118 +++++++++++++++++++++++++++++++++++++++++----
>  src/tasks.rs       |   6 +++
>  4 files changed, 140 insertions(+), 12 deletions(-)
>
> diff --git a/src/lib.rs b/src/lib.rs
> index 62aa571..65dae68 100644
> --- a/src/lib.rs
> +++ b/src/lib.rs
> @@ -234,6 +234,8 @@ pub mod utils;
>  mod xtermjs;
>  pub use xtermjs::{ConsoleType, ProxmoxXTermJs, XTermJs};
>
> +use anyhow::{format_err, Error};
> +
>  use pwt::gettext_noop;
>  use pwt::state::{LanguageInfo, TextDirection};
>
> @@ -271,6 +273,25 @@ mod panic_wrapper {
>      }
>  }
>
> +/// Helper to download the given 'source' url via a simulated click on a hidden 'a' element.
> +pub fn download_as_file(source: &str, file_name: &str) -> Result<(), Error> {
> +    let el = gloo_utils::document()
> +        .create_element("a")
> +        .map_err(|_| format_err!("unable to create element 'a'"))?;
> +    let html = el
> +        .dyn_into::<web_sys::HtmlElement>()
> +        .map_err(|_| format_err!("unable to cast to html element"))?;
> +    html.set_attribute("href", source)
> +        .map_err(|_| format_err!("unable to set href attribute"))?;
> +    html.set_attribute("target", "_blank")
> +        .map_err(|_| format_err!("unable to set target attribute"))?;
> +    html.set_attribute("download", file_name)
> +        .map_err(|_| format_err!("unable to set download attribute"))?;
> +    html.click();
> +    html.remove();
> +    Ok(())
> +}
> +
>  pub fn store_csrf_token(crsf_token: &str) {
>      if let Some(store) = pwt::state::session_storage() {
>          if store.set_item("CSRFToken", crsf_token).is_err() {
> diff --git a/src/log_view.rs b/src/log_view.rs
> index 20f8f77..46bc758 100644
> --- a/src/log_view.rs
> +++ b/src/log_view.rs
> @@ -37,10 +37,11 @@ const MAX_PHYSICAL: f64 = 17_000_000.0;
>  const DEFAULT_LINE_HEIGHT: u64 = 18;
>
>  #[derive(Deserialize)]
> -struct LogEntry {
> +/// a single Log entry line
> +pub struct LogEntry {
>      #[allow(dead_code)]
> -    n: u64,
> -    t: String,
> +    pub n: u64,
> +    pub t: String,
>  }
>
>  pub struct LogPage {
> diff --git a/src/task_viewer.rs b/src/task_viewer.rs
> index e78454e..decf721 100644
> --- a/src/task_viewer.rs
> +++ b/src/task_viewer.rs
> @@ -1,6 +1,6 @@
>  use std::rc::Rc;
>
> -use serde_json::Value;
> +use serde_json::{json, Value};
>
>  use gloo_timers::callback::Timeout;
>
> @@ -9,15 +9,19 @@ use yew::prelude::*;
>  use yew::virtual_dom::{Key, VComp, VNode};
>
>  use pwt::state::Loader;
> -use pwt::widget::{Button, Column, Dialog, TabBarItem, TabPanel, Toolbar};
> +use pwt::widget::{AlertDialog, Button, Column, Container, Dialog, TabBarItem, TabPanel, Toolbar};
>  use pwt::{prelude::*, AsyncPool};
>
> +use crate::common_api_types::ProxmoxUpid;
> +use crate::log_view::LogEntry;
>  use crate::percent_encoding::percent_encode_component;
>  use crate::utils::{format_duration_human, format_upid, render_epoch};
> -use crate::{KVGrid, KVGridRow, LogView};
> +use crate::{download_as_file, KVGrid, KVGridRow, LogView};
>
>  use pwt_macros::builder;
>
> +use proxmox_time::epoch_to_rfc3339_utc;
> +
>  #[derive(Properties, PartialEq, Clone)]
>  #[builder]
>  pub struct TaskViewer {
> @@ -39,6 +43,12 @@ pub struct TaskViewer {
>      #[builder(IntoPropValue, into_prop_value)]
>      /// The base url for
>      pub base_url: AttrValue,
> +
> +    #[prop_or(false)]
> +    #[builder(IntoPropValue, into_prop_value)]
> +    /// Use the 'download' API parameter for downloading task logs.
> +    /// By default the task log will be downloaded as json, and converted to a data URL.
> +    pub download_api: bool,
>  }
>
>  impl TaskViewer {
> @@ -55,6 +65,7 @@ pub enum Msg {
>      DataChange,
>      Reload,
>      StopTask,
> +    DownloadErr(Option<String>),
>  }
>
>  pub struct PwtTaskViewer {
> @@ -63,6 +74,7 @@ pub struct PwtTaskViewer {
>      active: bool,
>      endtime: Option<i64>,
>      async_pool: AsyncPool,
> +    download_err: Option<String>,
>  }
>
>  impl Component for PwtTaskViewer {
> @@ -99,6 +111,7 @@ impl Component for PwtTaskViewer {
>              active: props.endtime.is_none(),
>              endtime: props.endtime,
>              async_pool: AsyncPool::new(),
> +            download_err: None,
>          }
>      }
>
> @@ -138,12 +151,23 @@ impl Component for PwtTaskViewer {
>                  }
>                  true
>              }
> +            Msg::DownloadErr(err) => {
> +                self.download_err = err;
> +                true
> +            }
>          }
>      }
>
>      fn view(&self, ctx: &Context<Self>) -> Html {
>          let props = ctx.props();
>
> +        if let Some(err) = &self.download_err {
> +            return AlertDialog::new(err)
> +                .title(tr!("Download Error"))
> +                .on_close(ctx.link().callback(|_| Msg::DownloadErr(None)))
> +                .into();
> +        }
> +
>          let panel = self.loader.render(|data| {
>              TabPanel::new()
>                  .class("pwt-flex-fit")
> @@ -273,18 +297,94 @@ impl PwtTaskViewer {
>          let active = self.active;
>          let link = ctx.link();
>
> -        let toolbar = Toolbar::new().class("pwt-border-bottom").with_child(
> -            Button::new(tr!("Stop"))
> -                .disabled(!active)
> -                .onclick(link.callback(|_| Msg::StopTask)),
> -        );
> -
>          let url = format!(
>              "{}/{}/log",
>              props.base_url,
>              percent_encode_component(&task_id),
>          );
>
> +        let filename = match props.task_id.parse::<ProxmoxUpid>() {
> +            Ok(upid) => {
> +                format!(
> +                    "task-{}-{}-{}.log",
> +                    upid.node,
> +                    upid.worker_type,
> +                    epoch_to_rfc3339_utc(upid.starttime).unwrap_or(upid.starttime.to_string())
> +                )
> +            }
> +            Err(_) => format!("task-{}.log", props.task_id),
> +        };
> +
> +        let download_btn: Html = if props.download_api {
> +            Container::from_tag("a")
> +                .attribute("disabled", active.then_some(""))
> +                .attribute("target", "_blank")
> +                .attribute("href", format!("/api2/extjs{url}?download=1"))
> +                .attribute("filename", filename)
> +                .with_child(
> +                    Button::new(tr!("Download"))
> +                        .disabled(active)
> +                        .icon_class("fa fa-download"),
> +                )
> +                .into()

is there a reason you construct a new `<a>` element as a container here
instead of using the `download_as_file` helper in an `on_activate`
callback for the button?

> +        } else {
> +            Button::new(tr!("Download"))
> +                .icon_class("fa fa-download")
> +                .disabled(active)
> +                .on_activate({
> +                    let url = url.clone();
> +                    let link = link.clone();
> +                    let async_pool = self.async_pool.clone();
> +                    move |_| {
> +                        let url = url.clone();
> +                        let filename = filename.clone();
> +                        let link = link.clone();
> +                        async_pool.spawn(async move {
> +                            let param = json!({
> +                                "start": 0,
> +                                "limit": 0,
> +                            });
> +                            let resp =
> +                                crate::http_get_full::<Vec<LogEntry>>(url, Some(param)).await;
> +                            match resp {
> +                                Ok(resp) => {
> +                                    let string = resp
> +                                        .data
> +                                        .into_iter()
> +                                        .map(|entry| entry.t)
> +                                        .collect::<Vec<_>>()
> +                                        .join("\n");
> +
> +                                    if let Err(err) = download_as_file(
> +                                        &format!(
> +                                            "data:text/plain;charset=utf-8,{}",
> +                                            percent_encode_component(&string)
> +                                        ),
> +                                        &filename,
> +                                    ) {
> +                                        link.send_message(Msg::DownloadErr(Some(err.to_string())))
> +                                    }
> +                                }
> +                                Err(err) => {
> +                                    link.send_message(Msg::DownloadErr(Some(err.to_string())))
> +                                }
> +                            }
> +                        });
> +                    }
> +                })
> +                .into()
> +        };
> +
> +        let toolbar = Toolbar::new()
> +            .class("pwt-border-bottom")
> +            .with_child(
> +                Button::new(tr!("Stop"))
> +                    .disabled(!active)
> +                    .onclick(link.callback(|_| Msg::StopTask)),
> +            )
> +            .with_flex_spacer()
> +            .with_child(download_btn);
> +
>          Column::new()
>              .class("pwt-flex-fit")
>              .with_child(toolbar)
> diff --git a/src/tasks.rs b/src/tasks.rs
> index b49de98..fb3ed39 100644
> --- a/src/tasks.rs
> +++ b/src/tasks.rs
> @@ -78,6 +78,11 @@ pub struct Tasks {
>      #[prop_or_default]
>      /// An optional column configuration that overwrites the default one.
>      pub columns: Option<Rc<Vec<DataTableHeader<TaskListItem>>>>,
> +
> +    #[prop_or(false)]
> +    #[builder(IntoPropValue, into_prop_value)]
> +    /// Use the 'download' API parameter for downloading task logs.
> +    pub download_api: bool,
>  }
>
>  impl Default for Tasks {
> @@ -493,6 +498,7 @@ impl LoadableComponent for ProxmoxTasks {
>          match view_state {
>              ViewDialog::TaskViewer => {
>                  let mut dialog = TaskViewer::new(&*selected_key)
> +                    .download_api(props.download_api)
>                      .endtime(selected_item.endtime)
>                      .on_close(ctx.link().change_view_callback(|_| None));
>                  if let Some(base_url) = &props.base_url {





^ permalink raw reply	[flat|nested] 11+ messages in thread

* Re: [PATCH yew-comp 1/3] task (viewer): add task download button
  2026-03-30 13:36   ` Shannon Sterz
@ 2026-03-30 13:48     ` Dominik Csapak
       [not found]       ` <DHG6K6Z0MPF2.1RFDEDL4NQM3M@proxmox.com>
  0 siblings, 1 reply; 11+ messages in thread
From: Dominik Csapak @ 2026-03-30 13:48 UTC (permalink / raw)
  To: Shannon Sterz; +Cc: yew-devel



On 3/30/26 3:35 PM, Shannon Sterz wrote:
> On Mon Mar 30, 2026 at 2:53 PM CEST, Dominik Csapak wrote:
[snip]
>> @@ -273,18 +297,94 @@ impl PwtTaskViewer {
>>           let active = self.active;
>>           let link = ctx.link();
>>
>> -        let toolbar = Toolbar::new().class("pwt-border-bottom").with_child(
>> -            Button::new(tr!("Stop"))
>> -                .disabled(!active)
>> -                .onclick(link.callback(|_| Msg::StopTask)),
>> -        );
>> -
>>           let url = format!(
>>               "{}/{}/log",
>>               props.base_url,
>>               percent_encode_component(&task_id),
>>           );
>>
>> +        let filename = match props.task_id.parse::<ProxmoxUpid>() {
>> +            Ok(upid) => {
>> +                format!(
>> +                    "task-{}-{}-{}.log",
>> +                    upid.node,
>> +                    upid.worker_type,
>> +                    epoch_to_rfc3339_utc(upid.starttime).unwrap_or(upid.starttime.to_string())
>> +                )
>> +            }
>> +            Err(_) => format!("task-{}.log", props.task_id),
>> +        };
>> +
>> +        let download_btn: Html = if props.download_api {
>> +            Container::from_tag("a")
>> +                .attribute("disabled", active.then_some(""))
>> +                .attribute("target", "_blank")
>> +                .attribute("href", format!("/api2/extjs{url}?download=1"))
>> +                .attribute("filename", filename)
>> +                .with_child(
>> +                    Button::new(tr!("Download"))
>> +                        .disabled(active)
>> +                        .icon_class("fa fa-download"),
>> +                )
>> +                .into()
> 
> is there a reason you construct a new `<a>` element as a container here
> instead of using the `download_as_file` helper in an `on_activate`
> callback for the button?
> 

if we have a static link, there is no need to create an 'a' element
on the fly and clicking it programmatically. Since browsers use
heuristics to detect 'user-initiated' actions to determine if things
are allowed (e.g. auto-clicking on links) such helpers should be used
sparingly.

a static link is better expressed with native browser methods such as
an a element that the user clicks. (the button inside is just for
cosmetics in this case)

e.g. if one would send a message in on_activate and call the helper in
the update method, this might not work as intended since the browser
can't detect that it was user-initiated.

so my intent was to only use it when absolutely necessary.

it's also what we do in PDM to download the report (though there we
use a data url)

the idea here was also that once all task log endpoints support the
download api parameter, we can remove the button below again,
and the helper too.

do you see any advantage with using the helper in the first case too?

>> +        } else {
>> +            Button::new(tr!("Download"))
>> +                .icon_class("fa fa-download")
>> +                .disabled(active)
>> +                .on_activate({
>> +                    let url = url.clone();
>> +                    let link = link.clone();
>> +                    let async_pool = self.async_pool.clone();
>> +                    move |_| {
>> +                        let url = url.clone();
>> +                        let filename = filename.clone();
>> +                        let link = link.clone();
>> +                        async_pool.spawn(async move {
>> +                            let param = json!({
>> +                                "start": 0,
>> +                                "limit": 0,
>> +                            });
>> +                            let resp =
>> +                                crate::http_get_full::<Vec<LogEntry>>(url, Some(param)).await;
>> +                            match resp {
>> +                                Ok(resp) => {
>> +                                    let string = resp
>> +                                        .data
>> +                                        .into_iter()
>> +                                        .map(|entry| entry.t)
>> +                                        .collect::<Vec<_>>()
>> +                                        .join("\n");
>> +
>> +                                    if let Err(err) = download_as_file(
>> +                                        &format!(
>> +                                            "data:text/plain;charset=utf-8,{}",
>> +                                            percent_encode_component(&string)
>> +                                        ),
>> +                                        &filename,
>> +                                    ) {
>> +                                        link.send_message(Msg::DownloadErr(Some(err.to_string())))
>> +                                    }
>> +                                }
>> +                                Err(err) => {
>> +                                    link.send_message(Msg::DownloadErr(Some(err.to_string())))
>> +                                }
>> +                            }
>> +                        });
>> +                    }
>> +                })
>> +                .into()
>> +        };
>> +
>> +        let toolbar = Toolbar::new()
>> +            .class("pwt-border-bottom")
>> +            .with_child(
>> +                Button::new(tr!("Stop"))
>> +                    .disabled(!active)
>> +                    .onclick(link.callback(|_| Msg::StopTask)),
>> +            )
>> +            .with_flex_spacer()
>> +            .with_child(download_btn);
>> +
>>           Column::new()
>>               .class("pwt-flex-fit")
>>               .with_child(toolbar)
>> diff --git a/src/tasks.rs b/src/tasks.rs
>> index b49de98..fb3ed39 100644
>> --- a/src/tasks.rs
>> +++ b/src/tasks.rs
>> @@ -78,6 +78,11 @@ pub struct Tasks {
>>       #[prop_or_default]
>>       /// An optional column configuration that overwrites the default one.
>>       pub columns: Option<Rc<Vec<DataTableHeader<TaskListItem>>>>,
>> +
>> +    #[prop_or(false)]
>> +    #[builder(IntoPropValue, into_prop_value)]
>> +    /// Use the 'download' API parameter for downloading task logs.
>> +    pub download_api: bool,
>>   }
>>
>>   impl Default for Tasks {
>> @@ -493,6 +498,7 @@ impl LoadableComponent for ProxmoxTasks {
>>           match view_state {
>>               ViewDialog::TaskViewer => {
>>                   let mut dialog = TaskViewer::new(&*selected_key)
>> +                    .download_api(props.download_api)
>>                       .endtime(selected_item.endtime)
>>                       .on_close(ctx.link().change_view_callback(|_| None));
>>                   if let Some(base_url) = &props.base_url {
> 





^ permalink raw reply	[flat|nested] 11+ messages in thread

* Re: [PATCH yew-comp 1/3] task (viewer): add task download button
       [not found]             ` <6e6fa075-2b27-4635-8fcf-ebdcdfdad3d8@proxmox.com>
@ 2026-03-31  7:39               ` Shannon Sterz
  0 siblings, 0 replies; 11+ messages in thread
From: Shannon Sterz @ 2026-03-31  7:39 UTC (permalink / raw)
  To: Dominik Csapak; +Cc: yew-devel

On Mon Mar 30, 2026 at 4:44 PM CEST, Dominik Csapak wrote:
>
>
> On 3/30/26 4:39 PM, Shannon Sterz wrote:
>> On Mon Mar 30, 2026 at 4:23 PM CEST, Dominik Csapak wrote:
>>>
>>>
>>> On 3/30/26 4:17 PM, Shannon Sterz wrote:
>>>> On Mon Mar 30, 2026 at 3:48 PM CEST, Dominik Csapak wrote:
>>>>>
>>>>>
>>>>> On 3/30/26 3:35 PM, Shannon Sterz wrote:
>>>>>> On Mon Mar 30, 2026 at 2:53 PM CEST, Dominik Csapak wrote:
>>>>> [snip]
>>>>>>> @@ -273,18 +297,94 @@ impl PwtTaskViewer {
>>>>>>>             let active = self.active;
>>>>>>>             let link = ctx.link();
>>>>>>>
>>>>>>> -        let toolbar = Toolbar::new().class("pwt-border-bottom").with_child(
>>>>>>> -            Button::new(tr!("Stop"))
>>>>>>> -                .disabled(!active)
>>>>>>> -                .onclick(link.callback(|_| Msg::StopTask)),
>>>>>>> -        );
>>>>>>> -
>>>>>>>             let url = format!(
>>>>>>>                 "{}/{}/log",
>>>>>>>                 props.base_url,
>>>>>>>                 percent_encode_component(&task_id),
>>>>>>>             );
>>>>>>>
>>>>>>> +        let filename = match props.task_id.parse::<ProxmoxUpid>() {
>>>>>>> +            Ok(upid) => {
>>>>>>> +                format!(
>>>>>>> +                    "task-{}-{}-{}.log",
>>>>>>> +                    upid.node,
>>>>>>> +                    upid.worker_type,
>>>>>>> +                    epoch_to_rfc3339_utc(upid.starttime).unwrap_or(upid.starttime.to_string())
>>>>>>> +                )
>>>>>>> +            }
>>>>>>> +            Err(_) => format!("task-{}.log", props.task_id),
>>>>>>> +        };
>>>>>>> +
>>>>>>> +        let download_btn: Html = if props.download_api {
>>>>>>> +            Container::from_tag("a")
>>>>>>> +                .attribute("disabled", active.then_some(""))
>>>>>>> +                .attribute("target", "_blank")
>>>>>>> +                .attribute("href", format!("/api2/extjs{url}?download=1"))
>>>>>>> +                .attribute("filename", filename)
>>>>>>> +                .with_child(
>>>>>>> +                    Button::new(tr!("Download"))
>>>>>>> +                        .disabled(active)
>>>>>>> +                        .icon_class("fa fa-download"),
>>>>>>> +                )
>>>>>>> +                .into()
>>>>>>
>>>>>> is there a reason you construct a new `<a>` element as a container here
>>>>>> instead of using the `download_as_file` helper in an `on_activate`
>>>>>> callback for the button?
>>>>>>
>>>>>
>>>>> if we have a static link, there is no need to create an 'a' element
>>>>> on the fly and clicking it programmatically. Since browsers use
>>>>> heuristics to detect 'user-initiated' actions to determine if things
>>>>> are allowed (e.g. auto-clicking on links) such helpers should be used
>>>>> sparingly.
>>>>>
>>>>> a static link is better expressed with native browser methods such as
>>>>> an a element that the user clicks. (the button inside is just for
>>>>> cosmetics in this case)
>>>>>
>>>>> e.g. if one would send a message in on_activate and call the helper in
>>>>> the update method, this might not work as intended since the browser
>>>>> can't detect that it was user-initiated.
>>>>
>>>> you could just call the helper directly instead of going through a
>>>> message though, no? however, im not sure if that is picked up as
>>>> user-initiated in all browsers either.
>>>>
>>>>> so my intent was to only use it when absolutely necessary.
>>>>>
>>>>> it's also what we do in PDM to download the report (though there we
>>>>> use a data url)
>>>>>
>>>>> the idea here was also that once all task log endpoints support the
>>>>> download api parameter, we can remove the button below again,
>>>>> and the helper too.
>>>>>
>>>>> do you see any advantage with using the helper in the first case too?
>>>>
>>>> my thinking was mostly about being able to re-use certain code paths
>>>> here.
>>>>
>>>> more importantly, though, from what i can tell the html 5 spec does not
>>>> allow interactive elements, like <button>, as descendants of <a>
>>>> elements:
>>>>
>>>>> Transparent, but there must be no interactive content descendant, a
>>>>> element descendant, or descendant with the tabindex attribute
>>>>> specified.
>>>>> - https://html.spec.whatwg.org/#the-a-element
>>>>
>>>> after looking into it some more, coding download buttons for static
>>>> resources as <a> elements is the right way to go accessibility-wise. so
>>>> maybe replacing the inner `Button` component with purely aesthetic
>>>> button.
>>>>
>>>> -->8 snip 8<--
>>>
>>> Sound good in general, I'll look into that, but since none of the part
>>> of the button is really interactive, i think it can be fine for now?
>>>
>>> (it's sadly not as simple as adding some class currently, since the e.g.
>>> ripple effect has to be done manually)
>>
>> i know it's a little trickier. sadly, this is definitively forbidden
>> regardless of "real interactivity". as per spec `<button>` is always
>> "interactive content":
>>
>>> § 3.2.5.2.7 Interactive content
>>>
>>> *Interactive content* is content that is specifically intended for
>>> user interaction.
>>>      => `a` (if the `href` attribute is present), `audio` (if the controls
>>>      attribute is present), `button`, `details`, `embed`, `iframe`,
>>>      `img` (if the usemap attribute is present), `input` (if the type
>>>      attribute is not in the Hidden state), `label`, `select`,
>>>      `textarea`, `video` (if the controls attribute is present)
>>> - https://html.spec.whatwg.org/#interactive-content-2
>>
>> we also set a tabindex attribute on buttons unconditionally [1], this is
>> also explicitly forbidden for descendants of the `<a>` element.
>>
>> [1]: https://git.proxmox.com/?p=ui/proxmox-yew-widget-toolkit.git;a=blob;f=src/widget/button.rs;h=584f923c;hb=HEAD#l289
>
>
> yes it might be "forbidden", but still works for these purposes for now
> until we have a better way? Not sure if we want to block features
> just to be spec compliant when it clearly works in all relevant
> browsers, especially when I already said I'll fix it.

sorry if i misunderstood you here, i'm not saying this should be a
general blocker. i was just concerned that invalid markup might mess
with certain screen readers and other accessibility aids.

(added the list back as i accidentally dropped it along the way, sorry
for that)




^ permalink raw reply	[flat|nested] 11+ messages in thread

* Re: [PATCH yew-comp 1/3] task (viewer): add task download button
  2026-03-30 12:53 ` [PATCH yew-comp 1/3] task (viewer): add task download button Dominik Csapak
  2026-03-30 13:36   ` Shannon Sterz
@ 2026-03-31 11:14   ` Maximiliano Sandoval
  2026-03-31 12:05     ` Dominik Csapak
  1 sibling, 1 reply; 11+ messages in thread
From: Maximiliano Sandoval @ 2026-03-31 11:14 UTC (permalink / raw)
  To: Dominik Csapak; +Cc: yew-devel

Dominik Csapak <d.csapak@proxmox.com> writes:

> similar to what we already have in the ExtJS gui. Currently the default
> is to construct a data URL from the downloaded json in the UI, since not
> all APIs support the 'download=1' parameter (and some can't be easily
> extended).
>
> The download api can be enabled with a setting though, so if at some
> point all consumers use that, we can drop the compatibility code path.
>
> Adds also a small helper to create a temporary 'a' element to click.
>
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
> Longer explanation which api calls do not support the 'download'
> parameter: on PDM, we tunnel the api call to the remote, but this is
> autogenerated code that expects json output. With the 'download'
> parameter, the content type gets set to text/plain, which confuses the
> client.
>
> To fix it, we'd either have to rewrite the auto-code generation to
> handle this case, or expose the underlying client and doit manually, or
> rewrite the remote task api call to to something similar what we do here
> in the ui, collecting json output and returning a final log txt file.
>
> Since having this download button is useful anyway, and implementing any
> of these solutions would take way longer than doing this here, I opted
> for doing that now, and fixing the backend later.
>
>  src/lib.rs         |  21 ++++++++
>  src/log_view.rs    |   7 +--
>  src/task_viewer.rs | 118 +++++++++++++++++++++++++++++++++++++++++----
>  src/tasks.rs       |   6 +++
>  4 files changed, 140 insertions(+), 12 deletions(-)
>
> diff --git a/src/lib.rs b/src/lib.rs
> index 62aa571..65dae68 100644
> --- a/src/lib.rs
> +++ b/src/lib.rs
> @@ -234,6 +234,8 @@ pub mod utils;
>  mod xtermjs;
>  pub use xtermjs::{ConsoleType, ProxmoxXTermJs, XTermJs};
>  
> +use anyhow::{format_err, Error};
> +
>  use pwt::gettext_noop;
>  use pwt::state::{LanguageInfo, TextDirection};
>  
> @@ -271,6 +273,25 @@ mod panic_wrapper {
>      }
>  }
>  
> +/// Helper to download the given 'source' url via a simulated click on a hidden 'a' element.
> +pub fn download_as_file(source: &str, file_name: &str) -> Result<(), Error> {
> +    let el = gloo_utils::document()
> +        .create_element("a")
> +        .map_err(|_| format_err!("unable to create element 'a'"))?;
> +    let html = el
> +        .dyn_into::<web_sys::HtmlElement>()

Is it possible a `use wasm_bindgen::JsCast;`

is missing in this function or file? This does not compile.


> +        .map_err(|_| format_err!("unable to cast to html element"))?;
> +    html.set_attribute("href", source)
> +        .map_err(|_| format_err!("unable to set href attribute"))?;
> +    html.set_attribute("target", "_blank")
> +        .map_err(|_| format_err!("unable to set target attribute"))?;
> +    html.set_attribute("download", file_name)
> +        .map_err(|_| format_err!("unable to set download attribute"))?;
> +    html.click();
> +    html.remove();
> +    Ok(())
> +}
> +
>  pub fn store_csrf_token(crsf_token: &str) {
>      if let Some(store) = pwt::state::session_storage() {
>          if store.set_item("CSRFToken", crsf_token).is_err() {
> diff --git a/src/log_view.rs b/src/log_view.rs
> index 20f8f77..46bc758 100644
> --- a/src/log_view.rs
> +++ b/src/log_view.rs
> @@ -37,10 +37,11 @@ const MAX_PHYSICAL: f64 = 17_000_000.0;
>  const DEFAULT_LINE_HEIGHT: u64 = 18;
>  
>  #[derive(Deserialize)]
> -struct LogEntry {
> +/// a single Log entry line
> +pub struct LogEntry {
>      #[allow(dead_code)]
> -    n: u64,
> -    t: String,
> +    pub n: u64,
> +    pub t: String,
>  }
>  
>  pub struct LogPage {
> diff --git a/src/task_viewer.rs b/src/task_viewer.rs
> index e78454e..decf721 100644
> --- a/src/task_viewer.rs
> +++ b/src/task_viewer.rs
> @@ -1,6 +1,6 @@
>  use std::rc::Rc;
>  
> -use serde_json::Value;
> +use serde_json::{json, Value};
>  
>  use gloo_timers::callback::Timeout;
>  
> @@ -9,15 +9,19 @@ use yew::prelude::*;
>  use yew::virtual_dom::{Key, VComp, VNode};
>  
>  use pwt::state::Loader;
> -use pwt::widget::{Button, Column, Dialog, TabBarItem, TabPanel, Toolbar};
> +use pwt::widget::{AlertDialog, Button, Column, Container, Dialog, TabBarItem, TabPanel, Toolbar};
>  use pwt::{prelude::*, AsyncPool};
>  
> +use crate::common_api_types::ProxmoxUpid;
> +use crate::log_view::LogEntry;
>  use crate::percent_encoding::percent_encode_component;
>  use crate::utils::{format_duration_human, format_upid, render_epoch};
> -use crate::{KVGrid, KVGridRow, LogView};
> +use crate::{download_as_file, KVGrid, KVGridRow, LogView};
>  
>  use pwt_macros::builder;
>  
> +use proxmox_time::epoch_to_rfc3339_utc;
> +
>  #[derive(Properties, PartialEq, Clone)]
>  #[builder]
>  pub struct TaskViewer {
> @@ -39,6 +43,12 @@ pub struct TaskViewer {
>      #[builder(IntoPropValue, into_prop_value)]
>      /// The base url for
>      pub base_url: AttrValue,
> +
> +    #[prop_or(false)]
> +    #[builder(IntoPropValue, into_prop_value)]
> +    /// Use the 'download' API parameter for downloading task logs.
> +    /// By default the task log will be downloaded as json, and converted to a data URL.
> +    pub download_api: bool,
>  }
>  
>  impl TaskViewer {
> @@ -55,6 +65,7 @@ pub enum Msg {
>      DataChange,
>      Reload,
>      StopTask,
> +    DownloadErr(Option<String>),
>  }
>  
>  pub struct PwtTaskViewer {
> @@ -63,6 +74,7 @@ pub struct PwtTaskViewer {
>      active: bool,
>      endtime: Option<i64>,
>      async_pool: AsyncPool,
> +    download_err: Option<String>,
>  }
>  
>  impl Component for PwtTaskViewer {
> @@ -99,6 +111,7 @@ impl Component for PwtTaskViewer {
>              active: props.endtime.is_none(),
>              endtime: props.endtime,
>              async_pool: AsyncPool::new(),
> +            download_err: None,
>          }
>      }
>  
> @@ -138,12 +151,23 @@ impl Component for PwtTaskViewer {
>                  }
>                  true
>              }
> +            Msg::DownloadErr(err) => {
> +                self.download_err = err;
> +                true
> +            }
>          }
>      }
>  
>      fn view(&self, ctx: &Context<Self>) -> Html {
>          let props = ctx.props();
>  
> +        if let Some(err) = &self.download_err {
> +            return AlertDialog::new(err)
> +                .title(tr!("Download Error"))
> +                .on_close(ctx.link().callback(|_| Msg::DownloadErr(None)))
> +                .into();
> +        }
> +
>          let panel = self.loader.render(|data| {
>              TabPanel::new()
>                  .class("pwt-flex-fit")
> @@ -273,18 +297,94 @@ impl PwtTaskViewer {
>          let active = self.active;
>          let link = ctx.link();
>  
> -        let toolbar = Toolbar::new().class("pwt-border-bottom").with_child(
> -            Button::new(tr!("Stop"))
> -                .disabled(!active)
> -                .onclick(link.callback(|_| Msg::StopTask)),
> -        );
> -
>          let url = format!(
>              "{}/{}/log",
>              props.base_url,
>              percent_encode_component(&task_id),
>          );
>  
> +        let filename = match props.task_id.parse::<ProxmoxUpid>() {
> +            Ok(upid) => {
> +                format!(
> +                    "task-{}-{}-{}.log",
> +                    upid.node,
> +                    upid.worker_type,
> +                    epoch_to_rfc3339_utc(upid.starttime).unwrap_or(upid.starttime.to_string())
> +                )
> +            }
> +            Err(_) => format!("task-{}.log", props.task_id),
> +        };
> +
> +        let download_btn: Html = if props.download_api {
> +            Container::from_tag("a")
> +                .attribute("disabled", active.then_some(""))
> +                .attribute("target", "_blank")
> +                .attribute("href", format!("/api2/extjs{url}?download=1"))
> +                .attribute("filename", filename)
> +                .with_child(
> +                    Button::new(tr!("Download"))
> +                        .disabled(active)
> +                        .icon_class("fa fa-download"),
> +                )
> +                .into()
> +        } else {
> +            Button::new(tr!("Download"))
> +                .icon_class("fa fa-download")
> +                .disabled(active)
> +                .on_activate({
> +                    let url = url.clone();
> +                    let link = link.clone();
> +                    let async_pool = self.async_pool.clone();
> +                    move |_| {
> +                        let url = url.clone();
> +                        let filename = filename.clone();
> +                        let link = link.clone();
> +                        async_pool.spawn(async move {
> +                            let param = json!({
> +                                "start": 0,
> +                                "limit": 0,
> +                            });
> +                            let resp =
> +                                crate::http_get_full::<Vec<LogEntry>>(url, Some(param)).await;
> +                            match resp {
> +                                Ok(resp) => {
> +                                    let string = resp
> +                                        .data
> +                                        .into_iter()
> +                                        .map(|entry| entry.t)
> +                                        .collect::<Vec<_>>()
> +                                        .join("\n");
> +
> +                                    if let Err(err) = download_as_file(
> +                                        &format!(
> +                                            "data:text/plain;charset=utf-8,{}",
> +                                            percent_encode_component(&string)
> +                                        ),
> +                                        &filename,
> +                                    ) {
> +                                        link.send_message(Msg::DownloadErr(Some(err.to_string())))
> +                                    }
> +                                }
> +                                Err(err) => {
> +                                    link.send_message(Msg::DownloadErr(Some(err.to_string())))
> +                                }
> +                            }
> +                        });
> +                    }
> +                })
> +                .into()
> +        };
> +
> +        let toolbar = Toolbar::new()
> +            .class("pwt-border-bottom")
> +            .with_child(
> +                Button::new(tr!("Stop"))
> +                    .disabled(!active)
> +                    .onclick(link.callback(|_| Msg::StopTask)),
> +            )
> +            .with_flex_spacer()
> +            .with_child(download_btn);
> +
>          Column::new()
>              .class("pwt-flex-fit")
>              .with_child(toolbar)
> diff --git a/src/tasks.rs b/src/tasks.rs
> index b49de98..fb3ed39 100644
> --- a/src/tasks.rs
> +++ b/src/tasks.rs
> @@ -78,6 +78,11 @@ pub struct Tasks {
>      #[prop_or_default]
>      /// An optional column configuration that overwrites the default one.
>      pub columns: Option<Rc<Vec<DataTableHeader<TaskListItem>>>>,
> +
> +    #[prop_or(false)]
> +    #[builder(IntoPropValue, into_prop_value)]
> +    /// Use the 'download' API parameter for downloading task logs.
> +    pub download_api: bool,
>  }
>  
>  impl Default for Tasks {
> @@ -493,6 +498,7 @@ impl LoadableComponent for ProxmoxTasks {
>          match view_state {
>              ViewDialog::TaskViewer => {
>                  let mut dialog = TaskViewer::new(&*selected_key)
> +                    .download_api(props.download_api)
>                      .endtime(selected_item.endtime)
>                      .on_close(ctx.link().change_view_callback(|_| None));
>                  if let Some(base_url) = &props.base_url {

-- 
Maximiliano




^ permalink raw reply	[flat|nested] 11+ messages in thread

* Re: [PATCH yew-comp 1/3] task (viewer): add task download button
  2026-03-31 11:14   ` Maximiliano Sandoval
@ 2026-03-31 12:05     ` Dominik Csapak
  2026-03-31 12:14       ` Dominik Csapak
  0 siblings, 1 reply; 11+ messages in thread
From: Dominik Csapak @ 2026-03-31 12:05 UTC (permalink / raw)
  To: Maximiliano Sandoval; +Cc: yew-devel



On 3/31/26 1:13 PM, Maximiliano Sandoval wrote:
> Dominik Csapak <d.csapak@proxmox.com> writes:
> 
>> similar to what we already have in the ExtJS gui. Currently the default
>> is to construct a data URL from the downloaded json in the UI, since not
>> all APIs support the 'download=1' parameter (and some can't be easily
>> extended).
>>
>> The download api can be enabled with a setting though, so if at some
>> point all consumers use that, we can drop the compatibility code path.
>>
>> Adds also a small helper to create a temporary 'a' element to click.
>>
>> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
>> ---
>> Longer explanation which api calls do not support the 'download'
>> parameter: on PDM, we tunnel the api call to the remote, but this is
>> autogenerated code that expects json output. With the 'download'
>> parameter, the content type gets set to text/plain, which confuses the
>> client.
>>
>> To fix it, we'd either have to rewrite the auto-code generation to
>> handle this case, or expose the underlying client and doit manually, or
>> rewrite the remote task api call to to something similar what we do here
>> in the ui, collecting json output and returning a final log txt file.
>>
>> Since having this download button is useful anyway, and implementing any
>> of these solutions would take way longer than doing this here, I opted
>> for doing that now, and fixing the backend later.
>>
>>   src/lib.rs         |  21 ++++++++
>>   src/log_view.rs    |   7 +--
>>   src/task_viewer.rs | 118 +++++++++++++++++++++++++++++++++++++++++----
>>   src/tasks.rs       |   6 +++
>>   4 files changed, 140 insertions(+), 12 deletions(-)
>>
>> diff --git a/src/lib.rs b/src/lib.rs
>> index 62aa571..65dae68 100644
>> --- a/src/lib.rs
>> +++ b/src/lib.rs
>> @@ -234,6 +234,8 @@ pub mod utils;
>>   mod xtermjs;
>>   pub use xtermjs::{ConsoleType, ProxmoxXTermJs, XTermJs};
>>   
>> +use anyhow::{format_err, Error};
>> +
>>   use pwt::gettext_noop;
>>   use pwt::state::{LanguageInfo, TextDirection};
>>   
>> @@ -271,6 +273,25 @@ mod panic_wrapper {
>>       }
>>   }
>>   
>> +/// Helper to download the given 'source' url via a simulated click on a hidden 'a' element.
>> +pub fn download_as_file(source: &str, file_name: &str) -> Result<(), Error> {
>> +    let el = gloo_utils::document()
>> +        .create_element("a")
>> +        .map_err(|_| format_err!("unable to create element 'a'"))?;
>> +    let html = el
>> +        .dyn_into::<web_sys::HtmlElement>()
> 
> Is it possible a `use wasm_bindgen::JsCast;`
> 
> is missing in this function or file? This does not compile.
> 

yes that's possible. Not sure why, when I compile pdm with this as path
dep override, it compiles with no issues, but a 'make deb' fails
here...

> 
>> +        .map_err(|_| format_err!("unable to cast to html element"))?;
>> +    html.set_attribute("href", source)
>> +        .map_err(|_| format_err!("unable to set href attribute"))?;
>> +    html.set_attribute("target", "_blank")
>> +        .map_err(|_| format_err!("unable to set target attribute"))?;
>> +    html.set_attribute("download", file_name)
>> +        .map_err(|_| format_err!("unable to set download attribute"))?;
>> +    html.click();
>> +    html.remove();
>> +    Ok(())
>> +}
>> +
>>   pub fn store_csrf_token(crsf_token: &str) {
>>       if let Some(store) = pwt::state::session_storage() {
>>           if store.set_item("CSRFToken", crsf_token).is_err() {
>> diff --git a/src/log_view.rs b/src/log_view.rs
>> index 20f8f77..46bc758 100644
>> --- a/src/log_view.rs
>> +++ b/src/log_view.rs
>> @@ -37,10 +37,11 @@ const MAX_PHYSICAL: f64 = 17_000_000.0;
>>   const DEFAULT_LINE_HEIGHT: u64 = 18;
>>   
>>   #[derive(Deserialize)]
>> -struct LogEntry {
>> +/// a single Log entry line
>> +pub struct LogEntry {
>>       #[allow(dead_code)]
>> -    n: u64,
>> -    t: String,
>> +    pub n: u64,
>> +    pub t: String,
>>   }
>>   
>>   pub struct LogPage {
>> diff --git a/src/task_viewer.rs b/src/task_viewer.rs
>> index e78454e..decf721 100644
>> --- a/src/task_viewer.rs
>> +++ b/src/task_viewer.rs
>> @@ -1,6 +1,6 @@
>>   use std::rc::Rc;
>>   
>> -use serde_json::Value;
>> +use serde_json::{json, Value};
>>   
>>   use gloo_timers::callback::Timeout;
>>   
>> @@ -9,15 +9,19 @@ use yew::prelude::*;
>>   use yew::virtual_dom::{Key, VComp, VNode};
>>   
>>   use pwt::state::Loader;
>> -use pwt::widget::{Button, Column, Dialog, TabBarItem, TabPanel, Toolbar};
>> +use pwt::widget::{AlertDialog, Button, Column, Container, Dialog, TabBarItem, TabPanel, Toolbar};
>>   use pwt::{prelude::*, AsyncPool};
>>   
>> +use crate::common_api_types::ProxmoxUpid;
>> +use crate::log_view::LogEntry;
>>   use crate::percent_encoding::percent_encode_component;
>>   use crate::utils::{format_duration_human, format_upid, render_epoch};
>> -use crate::{KVGrid, KVGridRow, LogView};
>> +use crate::{download_as_file, KVGrid, KVGridRow, LogView};
>>   
>>   use pwt_macros::builder;
>>   
>> +use proxmox_time::epoch_to_rfc3339_utc;
>> +
>>   #[derive(Properties, PartialEq, Clone)]
>>   #[builder]
>>   pub struct TaskViewer {
>> @@ -39,6 +43,12 @@ pub struct TaskViewer {
>>       #[builder(IntoPropValue, into_prop_value)]
>>       /// The base url for
>>       pub base_url: AttrValue,
>> +
>> +    #[prop_or(false)]
>> +    #[builder(IntoPropValue, into_prop_value)]
>> +    /// Use the 'download' API parameter for downloading task logs.
>> +    /// By default the task log will be downloaded as json, and converted to a data URL.
>> +    pub download_api: bool,
>>   }
>>   
>>   impl TaskViewer {
>> @@ -55,6 +65,7 @@ pub enum Msg {
>>       DataChange,
>>       Reload,
>>       StopTask,
>> +    DownloadErr(Option<String>),
>>   }
>>   
>>   pub struct PwtTaskViewer {
>> @@ -63,6 +74,7 @@ pub struct PwtTaskViewer {
>>       active: bool,
>>       endtime: Option<i64>,
>>       async_pool: AsyncPool,
>> +    download_err: Option<String>,
>>   }
>>   
>>   impl Component for PwtTaskViewer {
>> @@ -99,6 +111,7 @@ impl Component for PwtTaskViewer {
>>               active: props.endtime.is_none(),
>>               endtime: props.endtime,
>>               async_pool: AsyncPool::new(),
>> +            download_err: None,
>>           }
>>       }
>>   
>> @@ -138,12 +151,23 @@ impl Component for PwtTaskViewer {
>>                   }
>>                   true
>>               }
>> +            Msg::DownloadErr(err) => {
>> +                self.download_err = err;
>> +                true
>> +            }
>>           }
>>       }
>>   
>>       fn view(&self, ctx: &Context<Self>) -> Html {
>>           let props = ctx.props();
>>   
>> +        if let Some(err) = &self.download_err {
>> +            return AlertDialog::new(err)
>> +                .title(tr!("Download Error"))
>> +                .on_close(ctx.link().callback(|_| Msg::DownloadErr(None)))
>> +                .into();
>> +        }
>> +
>>           let panel = self.loader.render(|data| {
>>               TabPanel::new()
>>                   .class("pwt-flex-fit")
>> @@ -273,18 +297,94 @@ impl PwtTaskViewer {
>>           let active = self.active;
>>           let link = ctx.link();
>>   
>> -        let toolbar = Toolbar::new().class("pwt-border-bottom").with_child(
>> -            Button::new(tr!("Stop"))
>> -                .disabled(!active)
>> -                .onclick(link.callback(|_| Msg::StopTask)),
>> -        );
>> -
>>           let url = format!(
>>               "{}/{}/log",
>>               props.base_url,
>>               percent_encode_component(&task_id),
>>           );
>>   
>> +        let filename = match props.task_id.parse::<ProxmoxUpid>() {
>> +            Ok(upid) => {
>> +                format!(
>> +                    "task-{}-{}-{}.log",
>> +                    upid.node,
>> +                    upid.worker_type,
>> +                    epoch_to_rfc3339_utc(upid.starttime).unwrap_or(upid.starttime.to_string())
>> +                )
>> +            }
>> +            Err(_) => format!("task-{}.log", props.task_id),
>> +        };
>> +
>> +        let download_btn: Html = if props.download_api {
>> +            Container::from_tag("a")
>> +                .attribute("disabled", active.then_some(""))
>> +                .attribute("target", "_blank")
>> +                .attribute("href", format!("/api2/extjs{url}?download=1"))
>> +                .attribute("filename", filename)
>> +                .with_child(
>> +                    Button::new(tr!("Download"))
>> +                        .disabled(active)
>> +                        .icon_class("fa fa-download"),
>> +                )
>> +                .into()
>> +        } else {
>> +            Button::new(tr!("Download"))
>> +                .icon_class("fa fa-download")
>> +                .disabled(active)
>> +                .on_activate({
>> +                    let url = url.clone();
>> +                    let link = link.clone();
>> +                    let async_pool = self.async_pool.clone();
>> +                    move |_| {
>> +                        let url = url.clone();
>> +                        let filename = filename.clone();
>> +                        let link = link.clone();
>> +                        async_pool.spawn(async move {
>> +                            let param = json!({
>> +                                "start": 0,
>> +                                "limit": 0,
>> +                            });
>> +                            let resp =
>> +                                crate::http_get_full::<Vec<LogEntry>>(url, Some(param)).await;
>> +                            match resp {
>> +                                Ok(resp) => {
>> +                                    let string = resp
>> +                                        .data
>> +                                        .into_iter()
>> +                                        .map(|entry| entry.t)
>> +                                        .collect::<Vec<_>>()
>> +                                        .join("\n");
>> +
>> +                                    if let Err(err) = download_as_file(
>> +                                        &format!(
>> +                                            "data:text/plain;charset=utf-8,{}",
>> +                                            percent_encode_component(&string)
>> +                                        ),
>> +                                        &filename,
>> +                                    ) {
>> +                                        link.send_message(Msg::DownloadErr(Some(err.to_string())))
>> +                                    }
>> +                                }
>> +                                Err(err) => {
>> +                                    link.send_message(Msg::DownloadErr(Some(err.to_string())))
>> +                                }
>> +                            }
>> +                        });
>> +                    }
>> +                })
>> +                .into()
>> +        };
>> +
>> +        let toolbar = Toolbar::new()
>> +            .class("pwt-border-bottom")
>> +            .with_child(
>> +                Button::new(tr!("Stop"))
>> +                    .disabled(!active)
>> +                    .onclick(link.callback(|_| Msg::StopTask)),
>> +            )
>> +            .with_flex_spacer()
>> +            .with_child(download_btn);
>> +
>>           Column::new()
>>               .class("pwt-flex-fit")
>>               .with_child(toolbar)
>> diff --git a/src/tasks.rs b/src/tasks.rs
>> index b49de98..fb3ed39 100644
>> --- a/src/tasks.rs
>> +++ b/src/tasks.rs
>> @@ -78,6 +78,11 @@ pub struct Tasks {
>>       #[prop_or_default]
>>       /// An optional column configuration that overwrites the default one.
>>       pub columns: Option<Rc<Vec<DataTableHeader<TaskListItem>>>>,
>> +
>> +    #[prop_or(false)]
>> +    #[builder(IntoPropValue, into_prop_value)]
>> +    /// Use the 'download' API parameter for downloading task logs.
>> +    pub download_api: bool,
>>   }
>>   
>>   impl Default for Tasks {
>> @@ -493,6 +498,7 @@ impl LoadableComponent for ProxmoxTasks {
>>           match view_state {
>>               ViewDialog::TaskViewer => {
>>                   let mut dialog = TaskViewer::new(&*selected_key)
>> +                    .download_api(props.download_api)
>>                       .endtime(selected_item.endtime)
>>                       .on_close(ctx.link().change_view_callback(|_| None));
>>                   if let Some(base_url) = &props.base_url {
> 





^ permalink raw reply	[flat|nested] 11+ messages in thread

* Re: [PATCH yew-comp 1/3] task (viewer): add task download button
  2026-03-31 12:05     ` Dominik Csapak
@ 2026-03-31 12:14       ` Dominik Csapak
  2026-03-31 12:55         ` Shannon Sterz
  0 siblings, 1 reply; 11+ messages in thread
From: Dominik Csapak @ 2026-03-31 12:14 UTC (permalink / raw)
  To: Maximiliano Sandoval; +Cc: yew-devel



On 3/31/26 2:05 PM, Dominik Csapak wrote:
> 
> 
> On 3/31/26 1:13 PM, Maximiliano Sandoval wrote:
>> Dominik Csapak <d.csapak@proxmox.com> writes:
>>
>>> similar to what we already have in the ExtJS gui. Currently the default
>>> is to construct a data URL from the downloaded json in the UI, since not
>>> all APIs support the 'download=1' parameter (and some can't be easily
>>> extended).
>>>
>>> The download api can be enabled with a setting though, so if at some
>>> point all consumers use that, we can drop the compatibility code path.
>>>
>>> Adds also a small helper to create a temporary 'a' element to click.
>>>
>>> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
>>> ---
>>> Longer explanation which api calls do not support the 'download'
>>> parameter: on PDM, we tunnel the api call to the remote, but this is
>>> autogenerated code that expects json output. With the 'download'
>>> parameter, the content type gets set to text/plain, which confuses the
>>> client.
>>>
>>> To fix it, we'd either have to rewrite the auto-code generation to
>>> handle this case, or expose the underlying client and doit manually, or
>>> rewrite the remote task api call to to something similar what we do here
>>> in the ui, collecting json output and returning a final log txt file.
>>>
>>> Since having this download button is useful anyway, and implementing any
>>> of these solutions would take way longer than doing this here, I opted
>>> for doing that now, and fixing the backend later.
>>>
>>>   src/lib.rs         |  21 ++++++++
>>>   src/log_view.rs    |   7 +--
>>>   src/task_viewer.rs | 118 +++++++++++++++++++++++++++++++++++++++++----
>>>   src/tasks.rs       |   6 +++
>>>   4 files changed, 140 insertions(+), 12 deletions(-)
>>>
>>> diff --git a/src/lib.rs b/src/lib.rs
>>> index 62aa571..65dae68 100644
>>> --- a/src/lib.rs
>>> +++ b/src/lib.rs
>>> @@ -234,6 +234,8 @@ pub mod utils;
>>>   mod xtermjs;
>>>   pub use xtermjs::{ConsoleType, ProxmoxXTermJs, XTermJs};
>>> +use anyhow::{format_err, Error};
>>> +
>>>   use pwt::gettext_noop;
>>>   use pwt::state::{LanguageInfo, TextDirection};
>>> @@ -271,6 +273,25 @@ mod panic_wrapper {
>>>       }
>>>   }
>>> +/// Helper to download the given 'source' url via a simulated click 
>>> on a hidden 'a' element.
>>> +pub fn download_as_file(source: &str, file_name: &str) -> Result<(), 
>>> Error> {
>>> +    let el = gloo_utils::document()
>>> +        .create_element("a")
>>> +        .map_err(|_| format_err!("unable to create element 'a'"))?;
>>> +    let html = el
>>> +        .dyn_into::<web_sys::HtmlElement>()
>>
>> Is it possible a `use wasm_bindgen::JsCast;`
>>
>> is missing in this function or file? This does not compile.
>>
> 
> yes that's possible. Not sure why, when I compile pdm with this as path
> dep override, it compiles with no issues, but a 'make deb' fails
> here...
> 


ah found it, we do:

---
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::{self, prelude::*};
---

which imports this for wasm32 only and a 'make deb' seemingly builds for 
x86 during the tests

short term solution is to remove the 'cfg(target_arch)' line, but the 
'real' fix would probably for cargo to build for the correct arch here
(not sure if that can be configured somehow)




^ permalink raw reply	[flat|nested] 11+ messages in thread

* Re: [PATCH yew-comp 1/3] task (viewer): add task download button
  2026-03-31 12:14       ` Dominik Csapak
@ 2026-03-31 12:55         ` Shannon Sterz
  0 siblings, 0 replies; 11+ messages in thread
From: Shannon Sterz @ 2026-03-31 12:55 UTC (permalink / raw)
  To: Dominik Csapak, Maximiliano Sandoval; +Cc: yew-devel

On Tue Mar 31, 2026 at 2:14 PM CEST, Dominik Csapak wrote:
>
>
> On 3/31/26 2:05 PM, Dominik Csapak wrote:
>>
>>
>> On 3/31/26 1:13 PM, Maximiliano Sandoval wrote:
>>> Dominik Csapak <d.csapak@proxmox.com> writes:
>>>
>>>> similar to what we already have in the ExtJS gui. Currently the default
>>>> is to construct a data URL from the downloaded json in the UI, since not
>>>> all APIs support the 'download=1' parameter (and some can't be easily
>>>> extended).
>>>>
>>>> The download api can be enabled with a setting though, so if at some
>>>> point all consumers use that, we can drop the compatibility code path.
>>>>
>>>> Adds also a small helper to create a temporary 'a' element to click.
>>>>
>>>> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
>>>> ---
>>>> Longer explanation which api calls do not support the 'download'
>>>> parameter: on PDM, we tunnel the api call to the remote, but this is
>>>> autogenerated code that expects json output. With the 'download'
>>>> parameter, the content type gets set to text/plain, which confuses the
>>>> client.
>>>>
>>>> To fix it, we'd either have to rewrite the auto-code generation to
>>>> handle this case, or expose the underlying client and doit manually, or
>>>> rewrite the remote task api call to to something similar what we do here
>>>> in the ui, collecting json output and returning a final log txt file.
>>>>
>>>> Since having this download button is useful anyway, and implementing any
>>>> of these solutions would take way longer than doing this here, I opted
>>>> for doing that now, and fixing the backend later.
>>>>
>>>>   src/lib.rs         |  21 ++++++++
>>>>   src/log_view.rs    |   7 +--
>>>>   src/task_viewer.rs | 118 +++++++++++++++++++++++++++++++++++++++++----
>>>>   src/tasks.rs       |   6 +++
>>>>   4 files changed, 140 insertions(+), 12 deletions(-)
>>>>
>>>> diff --git a/src/lib.rs b/src/lib.rs
>>>> index 62aa571..65dae68 100644
>>>> --- a/src/lib.rs
>>>> +++ b/src/lib.rs
>>>> @@ -234,6 +234,8 @@ pub mod utils;
>>>>   mod xtermjs;
>>>>   pub use xtermjs::{ConsoleType, ProxmoxXTermJs, XTermJs};
>>>> +use anyhow::{format_err, Error};
>>>> +
>>>>   use pwt::gettext_noop;
>>>>   use pwt::state::{LanguageInfo, TextDirection};
>>>> @@ -271,6 +273,25 @@ mod panic_wrapper {
>>>>       }
>>>>   }
>>>> +/// Helper to download the given 'source' url via a simulated click
>>>> on a hidden 'a' element.
>>>> +pub fn download_as_file(source: &str, file_name: &str) -> Result<(),
>>>> Error> {
>>>> +    let el = gloo_utils::document()
>>>> +        .create_element("a")
>>>> +        .map_err(|_| format_err!("unable to create element 'a'"))?;
>>>> +    let html = el
>>>> +        .dyn_into::<web_sys::HtmlElement>()
>>>
>>> Is it possible a `use wasm_bindgen::JsCast;`
>>>
>>> is missing in this function or file? This does not compile.
>>>
>>
>> yes that's possible. Not sure why, when I compile pdm with this as path
>> dep override, it compiles with no issues, but a 'make deb' fails
>> here...
>>
>
>
> ah found it, we do:
>
> ---
> #[cfg(target_arch = "wasm32")]
> use wasm_bindgen::{self, prelude::*};
> ---
>
> which imports this for wasm32 only and a 'make deb' seemingly builds for
> x86 during the tests
>
> short term solution is to remove the 'cfg(target_arch)' line, but the
> 'real' fix would probably for cargo to build for the correct arch here
> (not sure if that can be configured somehow)

adding:

```
[build]
target = "wasm32-unknown-unknown"
```

to `.cargo/config.toml` might be what you are looking for :)






^ permalink raw reply	[flat|nested] 11+ messages in thread

end of thread, other threads:[~2026-03-31 12:55 UTC | newest]

Thread overview: 11+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-03-30 12:53 [PATCH yew-comp 0/3] improve task/log viewer Dominik Csapak
2026-03-30 12:53 ` [PATCH yew-comp 1/3] task (viewer): add task download button Dominik Csapak
2026-03-30 13:36   ` Shannon Sterz
2026-03-30 13:48     ` Dominik Csapak
     [not found]       ` <DHG6K6Z0MPF2.1RFDEDL4NQM3M@proxmox.com>
     [not found]         ` <059e96c0-1b42-4c94-979a-6dfcf6ff5860@proxmox.com>
     [not found]           ` <DHG71AI5H1Z9.1E3IKBB4GT12K@proxmox.com>
     [not found]             ` <6e6fa075-2b27-4635-8fcf-ebdcdfdad3d8@proxmox.com>
2026-03-31  7:39               ` Shannon Sterz
2026-03-31 11:14   ` Maximiliano Sandoval
2026-03-31 12:05     ` Dominik Csapak
2026-03-31 12:14       ` Dominik Csapak
2026-03-31 12:55         ` Shannon Sterz
2026-03-30 12:53 ` [PATCH yew-comp 2/3] log-view: reorganize imports Dominik Csapak
2026-03-30 12:53 ` [RFC PATCH yew-comp 3/3] log-view: improve display of lines with '\r' Dominik Csapak

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