* [pdm-devel] [PATCH datacenter-manager] ui: dashboard: add task summary
@ 2025-01-23 15:10 Dominik Csapak
2025-01-29 18:18 ` Thomas Lamprecht
0 siblings, 1 reply; 2+ messages in thread
From: Dominik Csapak @ 2025-01-23 15:10 UTC (permalink / raw)
To: pdm-devel
similar to what we have in PBS, show some categories of tasks with
their status counts. When clicking on a count, a filtered view opens
that only shows those tasks.
This also refactors the option remote task rendering from the running
tasks list.
It's already prepared that one can provide a given timeframe, currently
it's hardcoded to 24 hours (like the stastic panels too)
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
ui/src/dashboard/mod.rs | 22 +-
ui/src/dashboard/tasks.rs | 423 ++++++++++++++++++++++++++++++++++++++
ui/src/tasks.rs | 19 +-
ui/src/top_nav_bar.rs | 20 +-
4 files changed, 462 insertions(+), 22 deletions(-)
create mode 100644 ui/src/dashboard/tasks.rs
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index ea0cf5e..754f4af 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -26,6 +26,9 @@ pub use top_entities::TopEntities;
mod subscription_info;
pub use subscription_info::SubscriptionInfo;
+mod tasks;
+use tasks::TaskSummary;
+
#[derive(Properties, PartialEq)]
pub struct Dashboard {
#[prop_or(60)]
@@ -260,7 +263,7 @@ impl Component for PdmDashboard {
}
}
- fn view(&self, _ctx: &yew::Context<Self>) -> yew::Html {
+ fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
let (remote_icon, remote_text) = match (self.status.failed_remotes, self.status.remotes) {
(0, 0) => (Status::Warning.to_fa_icon(), tr!("No remotes configured.")),
(0, _) => (
@@ -290,7 +293,7 @@ impl Component for PdmDashboard {
.with_tool(
Button::new(tr!("Add"))
.icon_class("fa fa-plus-circle")
- .onclick(_ctx.link().callback(|_| Msg::CreateWizard(true))),
+ .onclick(ctx.link().callback(|_| Msg::CreateWizard(true))),
)
.with_child(
Column::new()
@@ -392,7 +395,6 @@ impl Component for PdmDashboard {
.class("pwt-content-spacer")
.class("pwt-flex-direction-row")
.class("pwt-align-content-start")
- .class(pwt::css::Flex::Fill)
.style("padding-top", "0")
.class(FlexWrap::Wrap)
//.min_height(175)
@@ -414,6 +416,18 @@ impl Component for PdmDashboard {
tr!("Memory usage"),
self.top_entities.as_ref().map(|e| &e.node_memory),
)),
+ )
+ .with_child(
+ Container::new()
+ .class("pwt-content-spacer")
+ .class("pwt-flex-direction-row")
+ .class("pwt-align-content-start")
+ .style("padding-top", "0")
+ .class(pwt::css::Flex::Fill)
+ .class(FlexWrap::Wrap)
+ .with_child(TaskSummary::new())
+ // invisible for the content-spacer
+ .with_child(Container::new().style("display", "none")),
);
Panel::new()
@@ -423,7 +437,7 @@ impl Component for PdmDashboard {
.with_optional_child(
self.show_wizard.then_some(
AddWizard::new(pdm_api_types::remotes::RemoteType::Pve)
- .on_close(_ctx.link().callback(|_| Msg::CreateWizard(false)))
+ .on_close(ctx.link().callback(|_| Msg::CreateWizard(false)))
.on_submit(move |ctx| {
crate::remotes::create_remote(
ctx,
diff --git a/ui/src/dashboard/tasks.rs b/ui/src/dashboard/tasks.rs
new file mode 100644
index 0000000..90f86c6
--- /dev/null
+++ b/ui/src/dashboard/tasks.rs
@@ -0,0 +1,423 @@
+use std::collections::HashMap;
+use std::rc::Rc;
+
+use js_sys::Date;
+use serde_json::json;
+use yew::virtual_dom::{Key, VComp, VNode};
+
+use proxmox_yew_comp::common_api_types::{TaskListItem, TaskStatusClass};
+use proxmox_yew_comp::utils::{format_duration_human, render_epoch};
+use proxmox_yew_comp::{
+ http_get, LoadableComponent, LoadableComponentContext, LoadableComponentLink,
+ LoadableComponentMaster, Status, TaskViewer,
+};
+use pwt::css;
+use pwt::prelude::*;
+use pwt::props::ExtractPrimaryKey;
+use pwt::state::Store;
+use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
+use pwt::widget::{ActionIcon, Container, Dialog, Fa, Panel, Row, Tooltip};
+use pwt_macros::builder;
+
+use pdm_api_types::RemoteUpid;
+
+use crate::tasks::format_optional_remote_upid;
+
+#[derive(Clone, Eq, PartialEq, Ord, PartialOrd)]
+struct TaskSummaryItem {
+ prio: usize, // sort order
+ task_type: String,
+ title: String,
+ error_count: u64,
+ warning_count: u64,
+ ok_count: u64,
+}
+
+impl TaskSummaryItem {
+ fn new(task_type: impl Into<String>, title: impl Into<String>, prio: usize) -> Self {
+ TaskSummaryItem {
+ task_type: task_type.into(),
+ title: title.into(),
+ prio,
+ error_count: 0,
+ warning_count: 0,
+ ok_count: 0,
+ }
+ }
+}
+
+impl ExtractPrimaryKey for TaskSummaryItem {
+ fn extract_key(&self) -> Key {
+ Key::from(self.task_type.clone())
+ }
+}
+
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct TaskSummary {
+ #[builder]
+ /// How much
+ #[prop_or(24)]
+ amount_hours: u64,
+}
+
+impl TaskSummary {
+ pub fn new() -> Self {
+ yew::props!(Self {})
+ }
+}
+
+pub enum Msg {
+ DataChange(Vec<TaskListItem>),
+}
+
+#[derive(PartialEq)]
+pub enum ViewState {
+ ShowFilteredTasks((String, TaskStatusClass)),
+ ShowTask((RemoteUpid, Option<i64>)), // endtime
+}
+
+#[doc(hidden)]
+pub struct ProxmoxTaskSummary {
+ store: Store<TaskSummaryItem>,
+ task_store: Store<TaskListItem>,
+}
+
+fn map_worker_type(worker_type: &str) -> &str {
+ match worker_type {
+ task_type if task_type.contains("migrate") => "migrate",
+ task_type if task_type.starts_with("qm") => "qm",
+ task_type if task_type.starts_with("vz") && task_type != "vzdump" => "vz",
+ task_type if task_type.starts_with("ceph") => "ceph",
+ task_type if task_type.starts_with("ha") => "ha",
+ other => other,
+ }
+}
+
+fn get_type_title(task_type: &str) -> String {
+ match task_type {
+ "migrate" => tr!("Guest Migrations"),
+ "qm" => tr!("Virtual Machine related Tasks"),
+ "vz" => tr!("Container related Tasks"),
+ "ceph" => tr!("Ceph related Tasks"),
+ "vzdump" => tr!("Backup Tasks"),
+ "ha" => tr!("HA related Tasks"),
+ other => tr!("Worker Type: {0}", other),
+ }
+}
+
+fn extract_task_summary(data: &[TaskListItem]) -> Vec<TaskSummaryItem> {
+ let mut map: HashMap<String, TaskSummaryItem> = HashMap::new();
+
+ let mut prio = 0;
+ let mut insert_type = |task_type: &str| {
+ prio += 1;
+ map.insert(
+ task_type.to_string(),
+ TaskSummaryItem::new(task_type, get_type_title(task_type), prio),
+ );
+ };
+
+ insert_type("migrate");
+ insert_type("qm");
+ insert_type("vz");
+ insert_type("ceph");
+ insert_type("vzdump");
+ insert_type("ha");
+
+ for task in data {
+ let status: TaskStatusClass = match &task.status {
+ Some(status) => status.into(),
+ None => continue,
+ };
+
+ let task_type = map_worker_type(&task.worker_type);
+
+ let entry = match map.get_mut(task_type) {
+ Some(entry) => entry,
+ None => continue,
+ };
+
+ match status {
+ TaskStatusClass::Ok => entry.ok_count += 1,
+ TaskStatusClass::Warning => entry.warning_count += 1,
+ TaskStatusClass::Error => entry.error_count += 1,
+ }
+ }
+
+ let mut list: Vec<TaskSummaryItem> = map.into_values().collect();
+ list.sort();
+ list
+}
+
+fn render_counter(
+ link: LoadableComponentLink<ProxmoxTaskSummary>,
+ count: u64,
+ task_type: String,
+ task_class: TaskStatusClass,
+) -> Html {
+ let (icon_class, icon_scheme) = match task_class {
+ TaskStatusClass::Ok => ("fa-check", css::ColorScheme::Success),
+ TaskStatusClass::Warning => ("fa-exclamation-triangle", css::ColorScheme::Warning),
+ TaskStatusClass::Error => ("fa-times-circle", css::ColorScheme::Error),
+ };
+ let action = ActionIcon::new(classes!("fa", icon_class))
+ .margin_end(1)
+ .class((count > 0).then_some(icon_scheme))
+ .disabled(count == 0)
+ .on_activate(link.change_view_callback(move |_| {
+ ViewState::ShowFilteredTasks((task_type.clone(), task_class))
+ }));
+
+ Container::from_tag("span")
+ .with_child(action)
+ .with_child(count)
+ .into()
+}
+
+impl ProxmoxTaskSummary {
+ fn task_summary_columns(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> Rc<Vec<DataTableHeader<TaskSummaryItem>>> {
+ Rc::new(vec![
+ DataTableColumn::new("")
+ .flex(1)
+ .get_property(|item: &TaskSummaryItem| &item.title)
+ .into(),
+ DataTableColumn::new("")
+ .width("100px")
+ .render({
+ let link = ctx.link().clone();
+ move |item: &TaskSummaryItem| {
+ render_counter(
+ link.clone(),
+ item.error_count,
+ item.task_type.clone(),
+ TaskStatusClass::Error,
+ )
+ }
+ })
+ .into(),
+ DataTableColumn::new("")
+ .width("100px")
+ .render({
+ let link = ctx.link().clone();
+ move |item: &TaskSummaryItem| {
+ render_counter(
+ link.clone(),
+ item.warning_count,
+ item.task_type.clone(),
+ TaskStatusClass::Warning,
+ )
+ }
+ })
+ .into(),
+ DataTableColumn::new("")
+ .width("100px")
+ .render({
+ let link = ctx.link().clone();
+ move |item: &TaskSummaryItem| {
+ render_counter(
+ link.clone(),
+ item.ok_count,
+ item.task_type.clone(),
+ TaskStatusClass::Ok,
+ )
+ }
+ })
+ .into(),
+ ])
+ }
+}
+
+impl LoadableComponent for ProxmoxTaskSummary {
+ type Properties = TaskSummary;
+ type Message = Msg;
+ type ViewState = ViewState;
+
+ fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+ let store = Store::new();
+ store.set_data(extract_task_summary(&[]));
+ Self {
+ store,
+ task_store: Store::new(),
+ }
+ }
+
+ fn load(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), anyhow::Error>>>> {
+ let link = ctx.link();
+ let amount_hours = ctx.props().amount_hours;
+ Box::pin(async move {
+ let since = (Date::now() / 1000.0) as u64 - (amount_hours * 60 * 60);
+
+ // TODO replace with pdm client call
+ let params = Some(json!({
+ "since": since,
+ }));
+
+ let res: Vec<_> = http_get("/remote-tasks/list", params).await?;
+ link.send_message(Msg::DataChange(res));
+ Ok(())
+ })
+ }
+
+ fn update(&mut self, _ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Msg::DataChange(data) => {
+ self.store.set_data(extract_task_summary(&data));
+ self.task_store.set_data(data);
+ true
+ }
+ }
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
+ let columns = self.task_summary_columns(ctx);
+ let grid = DataTable::new(columns, self.store.clone())
+ .class(pwt::css::FlexFit)
+ .striped(false)
+ .borderless(true)
+ .hover(true)
+ .show_header(false);
+
+ let title: Html = Row::new()
+ .class(css::AlignItems::Center)
+ .gap(2)
+ .with_child(Fa::new("list-alt"))
+ .with_child(tr!("Task Summary"))
+ .into();
+
+ Panel::new()
+ .class(css::FlexFit)
+ .title(title)
+ .with_child(Container::new().padding(2).with_child(grid))
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<Html> {
+ match view_state {
+ ViewState::ShowFilteredTasks((task_type, task_status)) => {
+ let task_type = task_type.clone();
+ let task_status = *task_status;
+ let status_text = match task_status {
+ TaskStatusClass::Ok => tr!("OK"),
+ TaskStatusClass::Warning => tr!("Warning"),
+ TaskStatusClass::Error => tr!("Error"),
+ };
+ let title = format!("{} - {}", get_type_title(&task_type), status_text);
+ self.task_store.set_filter(move |task: &TaskListItem| {
+ let status: TaskStatusClass = match &task.status {
+ Some(status) => status.into(),
+ None => return false,
+ };
+
+ let worker_type = map_worker_type(&task.worker_type);
+ if task_type != worker_type {
+ return false;
+ }
+
+ match (task_status, status) {
+ (TaskStatusClass::Ok, TaskStatusClass::Ok) => {}
+ (TaskStatusClass::Warning, TaskStatusClass::Warning) => {}
+ (TaskStatusClass::Error, TaskStatusClass::Error) => {}
+ _ => return false,
+ }
+
+ true
+ });
+ Some(
+ Dialog::new(title)
+ .min_width(800)
+ .resizable(true)
+ .on_close(ctx.link().change_view_callback(|_| None))
+ .with_child(
+ DataTable::new(filtered_tasks_columns(ctx), self.task_store.clone())
+ .min_height(600)
+ .class(pwt::css::FlexFit),
+ )
+ .into(),
+ )
+ }
+ ViewState::ShowTask((remote_upid, endtime)) => {
+ // TODO PBS
+ let base_url = format!("/pve/remotes/{}/tasks", remote_upid.remote());
+ Some(
+ TaskViewer::new(remote_upid.to_string())
+ .endtime(endtime)
+ .base_url(base_url)
+ .on_close(ctx.link().change_view_callback(|_| None))
+ .into(),
+ )
+ }
+ }
+ }
+}
+
+fn filtered_tasks_columns(
+ ctx: &LoadableComponentContext<ProxmoxTaskSummary>,
+) -> Rc<Vec<DataTableHeader<TaskListItem>>> {
+ Rc::new(vec![
+ DataTableColumn::new(tr!("Task"))
+ .flex(1)
+ .render(|item: &TaskListItem| format_optional_remote_upid(&item.upid).into())
+ .into(),
+ DataTableColumn::new(tr!("Start Time"))
+ .width("200px")
+ .render(|item: &TaskListItem| render_epoch(item.starttime).into())
+ .into(),
+ DataTableColumn::new(tr!("Duration"))
+ .render(|item: &TaskListItem| {
+ let duration = match item.endtime {
+ Some(endtime) => endtime - item.starttime,
+ None => return html! {"-"},
+ };
+ html! {format_duration_human(duration as f64)}
+ })
+ .into(),
+ DataTableColumn::new(tr!("Status"))
+ .justify("center")
+ .render(|item: &TaskListItem| {
+ let text = item.status.as_deref().unwrap_or("");
+ let icon = match text.into() {
+ TaskStatusClass::Ok => Status::Success,
+ TaskStatusClass::Warning => Status::Warning,
+ TaskStatusClass::Error => Status::Error,
+ };
+ icon.to_fa_icon().into()
+ })
+ .into(),
+ DataTableColumn::new(tr!("Action"))
+ .justify("center")
+ .render({
+ let link = ctx.link().clone();
+ move |item: &TaskListItem| {
+ let upid = item.upid.clone();
+ let endtime = item.endtime;
+ let icon = ActionIcon::new("fa fa-chevron-right").on_activate(
+ link.change_view_callback(move |_| {
+ upid.parse()
+ .map(|upid| ViewState::ShowTask((upid, endtime)))
+ .ok()
+ }),
+ );
+ Tooltip::new(icon).tip(tr!("Open Task")).into()
+ }
+ })
+ .into(),
+ ])
+}
+
+impl From<TaskSummary> for VNode {
+ fn from(val: TaskSummary) -> Self {
+ let comp = VComp::new::<LoadableComponentMaster<ProxmoxTaskSummary>>(Rc::new(val), None);
+ VNode::from(comp)
+ }
+}
diff --git a/ui/src/tasks.rs b/ui/src/tasks.rs
index 6aa202a..a02a7f4 100644
--- a/ui/src/tasks.rs
+++ b/ui/src/tasks.rs
@@ -1,6 +1,9 @@
-use proxmox_yew_comp::utils::register_task_description;
+use proxmox_yew_comp::utils::{format_task_description, format_upid, register_task_description};
use pwt::tr;
+use pdm_api_types::RemoteUpid;
+use pdm_client::types::PveUpid;
+
pub fn register_pve_tasks() {
register_task_description("qmstart", ("VM", tr!("Start")));
register_task_description("acmedeactivate", ("ACME Account", tr!("Deactivate")));
@@ -99,3 +102,17 @@ pub fn register_pve_tasks() {
register_task_description("zfscreate", (tr!("ZFS Storage"), tr!("Create")));
register_task_description("zfsremove", ("ZFS Pool", tr!("Remove")));
}
+
+/// Format a UPID that is either [`RemoteUpid`] or a [`UPID`]
+/// If it's a [`RemoteUpid`], prefixes it with the remote name
+pub fn format_optional_remote_upid(upid: &str) -> String {
+ if let Ok(remote_upid) = upid.parse::<RemoteUpid>() {
+ let description = match remote_upid.upid.parse::<PveUpid>() {
+ Ok(upid) => format_task_description(&upid.worker_type, upid.worker_id.as_deref()),
+ Err(_) => format_upid(&remote_upid.upid),
+ };
+ format!("{} - {}", remote_upid.remote(), description)
+ } else {
+ format_upid(&upid)
+ }
+}
diff --git a/ui/src/top_nav_bar.rs b/ui/src/top_nav_bar.rs
index 07b3b23..38ab503 100644
--- a/ui/src/top_nav_bar.rs
+++ b/ui/src/top_nav_bar.rs
@@ -13,15 +13,15 @@ use pwt::state::{Loader, Theme, ThemeObserver};
use pwt::widget::{Button, Container, Row, ThemeModeSelector, Tooltip};
use proxmox_yew_comp::common_api_types::TaskListItem;
-use proxmox_yew_comp::utils::{format_task_description, format_upid, set_location_href};
+use proxmox_yew_comp::utils::set_location_href;
use proxmox_yew_comp::RunningTasksButton;
use proxmox_yew_comp::{http_get, HelpButton, LanguageDialog, TaskViewer, ThemeDialog};
use pwt_macros::builder;
use pdm_api_types::RemoteUpid;
-use pdm_client::types::PveUpid;
+use crate::tasks::format_optional_remote_upid;
use crate::widget::SearchBox;
#[derive(Deserialize)]
@@ -206,21 +206,7 @@ impl Component for PdmTopNavBar {
set_location_href("#/remotes/tasks");
}),
])
- .render(|item: &TaskListItem| {
- if let Ok(remote_upid) = (&item.upid).parse::<RemoteUpid>() {
- let description = match remote_upid.upid.parse::<PveUpid>() {
- Ok(upid) => format_task_description(
- &upid.worker_type,
- upid.worker_id.as_deref(),
- ),
- Err(_) => format_upid(&remote_upid.upid),
- };
- format!("{} - {}", remote_upid.remote(), description)
- } else {
- format_upid(&item.upid)
- }
- .into()
- }),
+ .render(|item: &TaskListItem| format_optional_remote_upid(&item.upid).into()),
);
button_group.add_child(
--
2.39.5
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 2+ messages in thread
* Re: [pdm-devel] [PATCH datacenter-manager] ui: dashboard: add task summary
2025-01-23 15:10 [pdm-devel] [PATCH datacenter-manager] ui: dashboard: add task summary Dominik Csapak
@ 2025-01-29 18:18 ` Thomas Lamprecht
0 siblings, 0 replies; 2+ messages in thread
From: Thomas Lamprecht @ 2025-01-29 18:18 UTC (permalink / raw)
To: Proxmox Datacenter Manager development discussion, Dominik Csapak
Am 23.01.25 um 16:10 schrieb Dominik Csapak:
> similar to what we have in PBS, show some categories of tasks with
> their status counts. When clicking on a count, a filtered view opens
> that only shows those tasks.
>
> This also refactors the option remote task rendering from the running
> tasks list.
>
> It's already prepared that one can provide a given timeframe, currently
> it's hardcoded to 24 hours (like the stastic panels too)
>
Maybe mention the timeframe in parentheses, like e.g.: Task Summary (Last 24h)
I think it might be good to either allow selecting the grouping, i.e. not only
by task-type(-group) like you do now but also per-remote to be able to quickly
determine if a specific remote node/cluster has recent task errors.
As variant of that could also be showing both groupings at the same time, i.e.,
two panels beside each other; would make better use of the space here and require
fewer eyes/head "travel distance" between the label column and the action buttons,
especially on wider browser windows.
Then some other things, which might be also improved on PBS side when applicable
(I did not check the status quo there to closely though):
- The task list window that opens when clicking on one of the action buttons
could do well with providing the task filter bar, maybe with fields that do
not make sense for a specific view hidden though (like the task result type
is preselected anyway, the remote would be preselected for a per-remote
grouping, maybe there are others)
Allowing sorting there might be also nice; we do not show the sort status
at all; albeit they seem sorted descending by their start-time. That would
IMO always make sense, even if we, whyever, do not allow sorting here.
- Show the remote as dedicated column, at least in intra-remote groupings like
the hard coded current one.
- Allowing some sort of go-back flow when opening a specific task log from the
task list window would be nice. Now, one always needs to reopen the list again,
which is a bit of a nuisance.
- tooltips for the error/warning/... icons might be nice, albeit definitively
very fine polishing.
- You do not send an explicit task count limit, so the default of 50 is used
for both the calculation of error/warning/ok from the card and the window.
For the dashboard calculation it might be actually worth to pre-calculate
them in the backend and query them from a cache there, as with thousands of
active remotes (nodes) it might become rather unfeasible of doing all in the
front end.
For the window and the task list we could use paging, as we do not care for
newer tasks that finished since we opened the window it should not be that
hard, one basically just needs to set `until` to the oldest entry (but not
lower, as then one might miss tasks that stopped at the same time) and filter
out any existing. Alternatively one could also add a parameter to the API
to make it handle the UPID as sort of until/since "cursor" (the terminology
that sd-journal uses IIRC), avoiding issues with a lot of tasks that stopped
at exactly the same time.
- With lots of tasks the window grows quite high, this is actually not a
critic, probably more something I need to get accustomed too; but noting
it in case you/others also noticed this. Maybe a _bit_ more headroom above
and below might be good, or some relatively high max-height for those with
displays rotated such that they are taller than wider; but OTOH it's also
good to use the available space.
That's all for now, mostly higher level stuff, albeit I can take a look at
the code too if you want – but IMO the overall (architectural) design is more
important, and with rust one can more safely focus on that, so I did not
bother too much with that (yet) here.
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
^ permalink raw reply [flat|nested] 2+ messages in thread
end of thread, other threads:[~2025-01-29 18:19 UTC | newest]
Thread overview: 2+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-01-23 15:10 [pdm-devel] [PATCH datacenter-manager] ui: dashboard: add task summary Dominik Csapak
2025-01-29 18:18 ` Thomas Lamprecht
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox