From: Maximiliano Sandoval <m.sandoval@proxmox.com>
To: Dominik Csapak <d.csapak@proxmox.com>
Cc: yew-devel@lists.proxmox.com
Subject: Re: [PATCH yew-comp 1/3] task (viewer): add task download button
Date: Tue, 31 Mar 2026 13:14:29 +0200 [thread overview]
Message-ID: <s8oqzp0qmi2.fsf@toolbox> (raw)
In-Reply-To: <20260330125320.507302-2-d.csapak@proxmox.com> (Dominik Csapak's message of "Mon, 30 Mar 2026 14:53:06 +0200")
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
next prev parent reply other threads:[~2026-03-31 11:14 UTC|newest]
Thread overview: 11+ messages / expand[flat|nested] mbox.gz Atom feed top
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 [this message]
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
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=s8oqzp0qmi2.fsf@toolbox \
--to=m.sandoval@proxmox.com \
--cc=d.csapak@proxmox.com \
--cc=yew-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox