From 718d3fb64c4b423d31cb60675e5715f9334bac18 Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:52:39 +0100 Subject: [PATCH 01/13] fix: ./filter-slider not found --- dashboard/src/components/tasks.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/components/tasks.tsx b/dashboard/src/components/tasks.tsx index 3b1cb0c..197a96c 100644 --- a/dashboard/src/components/tasks.tsx +++ b/dashboard/src/components/tasks.tsx @@ -6,11 +6,11 @@ import TimeIcon from '@rsuite/icons/Time'; import { Col, Grid, HStack, List, Pagination, Panel, Text, VStack } from "rsuite" import { TaskTag } from "./tag"; import { Task } from "./task"; +import { FilterSlider } from "./filter-slider"; import * as dayjs from "dayjs" import * as s from "./tasks.module.css"; import { useTask, useTasks, useTasksCount } from "../hooks/useTasks"; -import { FilterSlider } from "./filter-slider"; const FILTERS: { label: string; value: string | null; color: string }[] = [ { label: "All", value: null, color: "#6b7280"}, From 8ee8e4c35a4e219f0b72e9aca36fd92fbfff6894 Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:53:07 +0100 Subject: [PATCH 02/13] feat: task grouping --- Cargo.lock | 83 ++++++++++- dashboard/src/components/task.tsx | 63 ++++---- dashboard/src/services/api.tsx | 1 + vicky/Cargo.toml | 1 + .../down.sql | 2 + .../2025-12-12-125521-0000_task_groups/up.sql | 2 + vicky/src/bin/vicky/tasks.rs | 40 ++--- vicky/src/lib/database/entities/task.rs | 137 +++++------------- vicky/src/lib/database/schema.rs | 1 + vicky/src/lib/vicky/scheduler.rs | 124 ++++++++-------- vickyctl/src/cli.rs | 2 + vickyctl/src/tasks.rs | 5 + 12 files changed, 248 insertions(+), 213 deletions(-) create mode 100644 vicky/migrations/2025-12-12-125521-0000_task_groups/down.sql create mode 100644 vicky/migrations/2025-12-12-125521-0000_task_groups/up.sql diff --git a/Cargo.lock b/Cargo.lock index 1cf840e..c3deec5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -557,6 +557,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bon" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" +dependencies = [ + "darling 0.21.3", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "bstr" version = "1.11.3" @@ -911,8 +936,18 @@ version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.10", + "darling_macro 0.20.10", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -929,13 +964,38 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core", + "darling_core 0.20.10", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn", ] @@ -986,7 +1046,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn", @@ -1118,7 +1178,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b" dependencies = [ - "darling", + "darling 0.20.10", "either", "heck", "proc-macro2", @@ -2738,6 +2798,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "prettyplease" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.93" @@ -3501,7 +3571,7 @@ version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn", @@ -4400,6 +4470,7 @@ version = "0.1.0" dependencies = [ "async-trait", "aws-sdk-s3", + "bon", "chrono", "clap", "delegate", diff --git a/dashboard/src/components/task.tsx b/dashboard/src/components/task.tsx index 48068e2..a021b94 100644 --- a/dashboard/src/components/task.tsx +++ b/dashboard/src/components/task.tsx @@ -38,39 +38,48 @@ const Task = (props: TaskProps) => { return ( - - - - {task.display_name} - + + + + + {task.display_name} + + + + + + {dayjs.unix(task.created_at).toNow(true)} ago + {duration != null ? — : null} + {duration != null ? {duration}s : null} + + {task.locks.length ? ( + + {task.locks.map(lock => { + return ( + + {lock.name} + + ) + })} + + ) : null} + + + + + { + task.group != null ? + Group: {task.group} + : null + } {needsValidation ? ( - + Confirm ) : null} - - - - - {dayjs.unix(task.created_at).toNow(true)} ago - {duration != null ? — : null} - {duration != null ? {duration}s : null} - - {task.locks.length ? ( - - {task.locks.map(lock => { - return ( - - {lock.name} - - ) - })} - - ) : null} - - + {confirmError ? {confirmError} : null} diff --git a/dashboard/src/services/api.tsx b/dashboard/src/services/api.tsx index c68e175..9d13969 100644 --- a/dashboard/src/services/api.tsx +++ b/dashboard/src/services/api.tsx @@ -16,6 +16,7 @@ type ITask = { created_at: number, claimed_at: number | null, finished_at: number | null, + group: string | null } type IUser = { diff --git a/vicky/Cargo.toml b/vicky/Cargo.toml index 60342a8..60743da 100644 --- a/vicky/Cargo.toml +++ b/vicky/Cargo.toml @@ -30,6 +30,7 @@ diesel_migrations = { version = "2.2.0", features = ["postgres"] } chrono = { version= "0.4.39", features=["serde"] } delegate = "0.13.5" strum = { version = "0.27.2", features = ["derive"] } +bon = "3.8" [[bin]] name = "vicky" diff --git a/vicky/migrations/2025-12-12-125521-0000_task_groups/down.sql b/vicky/migrations/2025-12-12-125521-0000_task_groups/down.sql new file mode 100644 index 0000000..e296bc2 --- /dev/null +++ b/vicky/migrations/2025-12-12-125521-0000_task_groups/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE tasks + DROP "group"; \ No newline at end of file diff --git a/vicky/migrations/2025-12-12-125521-0000_task_groups/up.sql b/vicky/migrations/2025-12-12-125521-0000_task_groups/up.sql new file mode 100644 index 0000000..36b6cb8 --- /dev/null +++ b/vicky/migrations/2025-12-12-125521-0000_task_groups/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE tasks + ADD COLUMN "group" VARCHAR; \ No newline at end of file diff --git a/vicky/src/bin/vicky/tasks.rs b/vicky/src/bin/vicky/tasks.rs index f1a73d5..3df9089 100644 --- a/vicky/src/bin/vicky/tasks.rs +++ b/vicky/src/bin/vicky/tasks.rs @@ -29,6 +29,7 @@ pub struct RoTaskNew { flake_ref: FlakeRef, locks: Vec, features: Vec, + group: Option, } #[derive(Debug, PartialEq, Serialize, Deserialize)] @@ -297,35 +298,36 @@ pub async fn tasks_add( global_events: &State>, _machine: MachineGuard, ) -> Result, AppError> { - let task_uuid = Uuid::new_v4(); let status = if task.needs_confirmation { TaskStatus::NeedsUserValidation } else { TaskStatus::New }; + let task = task.into_inner(); + let task = Task::builder() - .with_id(task_uuid) - .with_status(status) - .with_display_name(&task.display_name) - .with_flake(&task.flake_ref.flake) - .with_flake_args(task.flake_ref.args.clone()) - .with_locks(task.locks.clone()) - .requires_features(task.features.clone()) + .status(status) + .display_name(task.display_name) + .flake(task.flake_ref.flake) + .flake_args(task.flake_ref.args) + .locks(task.locks) + .requires_features(task.features) + .maybe_group(task.group) .build(); let Ok(task) = task else { return Err(AppError::HttpError(Status::Conflict)); }; - db.put_task(task).await?; - global_events.send(GlobalEvent::TaskAdd)?; - let ro_task = RoTask { - id: task_uuid, + id: task.id, status, }; + db.put_task(task).await?; + global_events.send(GlobalEvent::TaskAdd)?; + Ok(Json(ro_task)) } @@ -361,9 +363,9 @@ mod tests { #[test] fn add_new_conflicting_task() { let task = Task::builder() - .with_display_name("Test 1") - .with_read_lock("mauz") - .with_write_lock("mauz") + .display_name("Test 1") + .read_lock("mauz") + .write_lock("mauz") .build(); assert!(task.is_err()); } @@ -371,10 +373,10 @@ mod tests { #[test] fn add_new_not_conflicting_task() { let task = Task::builder() - .with_display_name("Test 1") - .with_read_lock("mauz") - .with_read_lock("mauz") - .with_write_lock("delete_everything") + .display_name("Test 1") + .read_lock("mauz") + .read_lock("mauz") + .write_lock("delete_everything") .build(); assert!(task.is_ok()) } diff --git a/vicky/src/lib/database/entities/task.rs b/vicky/src/lib/database/entities/task.rs index 2f02ab2..f7b6869 100644 --- a/vicky/src/lib/database/entities/task.rs +++ b/vicky/src/lib/database/entities/task.rs @@ -1,10 +1,10 @@ +use bon::Builder; use crate::database::entities::lock::db_impl::DbLock; use crate::database::entities::lock::Lock; use crate::database::entities::task::db_impl::DbTask; use chrono::naive::serde::ts_seconds; use chrono::naive::serde::ts_seconds_option; use chrono::{NaiveDateTime, Utc}; -use delegate::delegate; use diesel::{AsExpression, FromSqlRow}; use itertools::Itertools; use serde::{Deserialize, Serialize}; @@ -35,20 +35,41 @@ pub struct FlakeRef { pub args: Vec, } -#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +impl FlakeRef { + pub fn empty() -> Self { + FlakeRef { + flake: "".to_string(), + args: vec![], + } + } +} + +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Builder)] +#[builder(finish_fn(name = _build_unchecked))] +#[builder(derive(Debug, Clone))] +#[builder(on(String, into))] pub struct Task { - pub id: Uuid, - pub display_name: String, - pub status: TaskStatus, + #[builder(field)] pub locks: Vec, + #[builder(field = FlakeRef::empty())] pub flake_ref: FlakeRef, + #[builder(field)] pub features: Vec, + + #[builder(default = Uuid::new_v4())] + pub id: Uuid, + #[builder(default = "Task")] + pub display_name: String, + #[builder(default = TaskStatus::New)] + pub status: TaskStatus, #[serde(with = "ts_seconds")] + #[builder(skip = Utc::now().naive_utc())] pub created_at: NaiveDateTime, #[serde(with = "ts_seconds_option")] pub claimed_at: Option, #[serde(with = "ts_seconds_option")] pub finished_at: Option, + pub group: Option, } impl AsRef for Task { @@ -57,88 +78,33 @@ impl AsRef for Task { } } -impl Task { - pub fn builder() -> TaskBuilder { - TaskBuilder::default() - } -} - -impl TryFrom for Task { - type Error = TaskBuilder; - - fn try_from(value: TaskBuilder) -> Result { - value.build() - } -} - -#[derive(Clone, Debug)] -pub struct TaskBuilder { - id: Option, - display_name: Option, - status: TaskStatus, - locks: Vec, - flake_ref: FlakeRef, - features: Vec, -} - -impl Default for TaskBuilder { - fn default() -> Self { - TaskBuilder { - id: None, - display_name: None, - status: TaskStatus::New, - locks: Vec::new(), - flake_ref: FlakeRef { - flake: "".to_string(), - args: Vec::new(), - }, - features: Vec::new(), - } - } -} - -impl TaskBuilder { - pub fn with_id(mut self, id: Uuid) -> Self { - self.id = Some(id); - self - } - - pub fn with_display_name>(mut self, display_name: S) -> Self { - self.display_name = Some(display_name.into()); - self - } - - pub fn with_status(mut self, status: TaskStatus) -> Self { - self.status = status; - self - } - - pub fn with_read_lock>(mut self, name: S) -> Self { +impl TaskBuilder { + pub fn read_lock>(mut self, name: S) -> Self { self.locks.push(Lock::read(name)); self } - pub fn with_write_lock>(mut self, name: S) -> Self { + pub fn write_lock>(mut self, name: S) -> Self { self.locks.push(Lock::write(name)); self } - pub fn with_locks(mut self, locks: Vec) -> Self { + pub fn locks(mut self, locks: Vec) -> Self { self.locks = locks; self } - pub fn with_flake>(mut self, flake_uri: S) -> Self { + pub fn flake>(mut self, flake_uri: S) -> Self { self.flake_ref.flake = flake_uri.into(); self } - pub fn with_flake_arg>(mut self, flake_arg: S) -> Self { + pub fn flake_arg>(mut self, flake_arg: S) -> Self { self.flake_ref.args.push(flake_arg.into()); self } - pub fn with_flake_args(mut self, args: Vec) -> Self { + pub fn flake_args(mut self, args: Vec) -> Self { self.flake_ref.args = args; self } @@ -153,31 +119,15 @@ impl TaskBuilder { self } - delegate! { - to self { - #[field] - pub fn id(&self) -> Option; - #[field] - #[expr($.as_ref())] - pub fn display_name(&self) -> Option<&String>; - #[field] - pub fn status(&self) -> TaskStatus; - #[field(&)] - pub fn locks(&self) -> &[Lock]; - #[field(&)] - pub fn flake_ref(&self) -> &FlakeRef; - #[field(&)] - pub fn features(&self) -> &[String]; - } - } - pub fn check_lock_conflict(&self) -> bool { self.locks .iter() .tuple_combinations() .any(|(a, b)| a.is_conflicting(b)) } +} +impl TaskBuilder { #[allow(clippy::result_large_err)] pub fn build(self) -> Result { if self.check_lock_conflict() { @@ -199,20 +149,6 @@ impl TaskBuilder { Err(builder) => panic!("TaskBuilder::build() failed while building: {builder:?}"), } } - - fn _build_unchecked(self) -> Task { - Task { - id: self.id.unwrap_or_else(Uuid::new_v4), - display_name: self.display_name.unwrap_or_else(|| "Task".to_string()), - features: self.features, - status: self.status, - locks: self.locks, - flake_ref: self.flake_ref, - created_at: Utc::now().naive_utc(), - claimed_at: None, - finished_at: None, - } - } } impl From<(DbTask, Vec)> for Task { @@ -231,6 +167,7 @@ impl From<(DbTask, Vec)> for Task { created_at: task.created_at, claimed_at: task.claimed_at, finished_at: task.finished_at, + group: task.group, } } } @@ -294,6 +231,7 @@ pub mod db_impl { pub created_at: NaiveDateTime, pub claimed_at: Option, pub finished_at: Option, + pub group: Option, } impl Display for TaskStatus { @@ -338,6 +276,7 @@ pub mod db_impl { created_at: task.created_at, claimed_at: task.claimed_at, finished_at: task.finished_at, + group: task.group, } } } diff --git a/vicky/src/lib/database/schema.rs b/vicky/src/lib/database/schema.rs index 772fc65..b7bbcd0 100644 --- a/vicky/src/lib/database/schema.rs +++ b/vicky/src/lib/database/schema.rs @@ -28,6 +28,7 @@ diesel::table! { created_at -> Timestamp, claimed_at -> Nullable, finished_at -> Nullable, + group -> Nullable, } } diff --git a/vicky/src/lib/vicky/scheduler.rs b/vicky/src/lib/vicky/scheduler.rs index ff38805..e5b6243 100644 --- a/vicky/src/lib/vicky/scheduler.rs +++ b/vicky/src/lib/vicky/scheduler.rs @@ -126,12 +126,12 @@ mod tests { fn scheduler_creation_no_constraints() { let tasks = vec![ Task::builder() - .with_display_name("Test 1") - .with_status(TaskStatus::Running) + .display_name("Test 1") + .status(TaskStatus::Running) .build_expect(), Task::builder() - .with_display_name("Test 2") - .with_status(TaskStatus::Running) + .display_name("Test 2") + .status(TaskStatus::Running) .build_expect(), ]; @@ -142,14 +142,14 @@ mod tests { fn scheduler_creation_multiple_read_constraints() { let tasks = vec![ Task::builder() - .with_display_name("Test 1") - .with_status(TaskStatus::Running) - .with_read_lock("foo 1") + .display_name("Test 1") + .status(TaskStatus::Running) + .read_lock("foo 1") .build_expect(), Task::builder() - .with_display_name("Test 2") - .with_status(TaskStatus::Running) - .with_read_lock("foo 1") + .display_name("Test 2") + .status(TaskStatus::Running) + .read_lock("foo 1") .build_expect(), ]; @@ -160,14 +160,14 @@ mod tests { fn scheduler_creation_single_write_constraints() { let tasks = vec![ Task::builder() - .with_display_name("Test 1") - .with_status(TaskStatus::Running) - .with_write_lock("foo1") + .display_name("Test 1") + .status(TaskStatus::Running) + .write_lock("foo1") .build_expect(), Task::builder() - .with_display_name("Test 2") - .with_status(TaskStatus::Running) - .with_write_lock("foo2") + .display_name("Test 2") + .status(TaskStatus::Running) + .write_lock("foo2") .build_expect(), ]; @@ -178,14 +178,14 @@ mod tests { fn scheduler_creation_multiple_write_constraints() { let tasks = vec![ Task::builder() - .with_display_name("Test 1") - .with_status(TaskStatus::Running) - .with_write_lock("foo1") + .display_name("Test 1") + .status(TaskStatus::Running) + .write_lock("foo1") .build_expect(), Task::builder() - .with_display_name("Test 2") - .with_status(TaskStatus::Running) - .with_write_lock("foo1") + .display_name("Test 2") + .status(TaskStatus::Running) + .write_lock("foo1") .build_expect(), ]; @@ -197,14 +197,14 @@ mod tests { fn scheduler_no_new_task() { let tasks = vec![ Task::builder() - .with_display_name("Test 1") - .with_status(TaskStatus::Running) - .with_write_lock("foo1") + .display_name("Test 1") + .status(TaskStatus::Running) + .write_lock("foo1") .build_expect(), Task::builder() - .with_display_name("Test 2") - .with_status(TaskStatus::New) - .with_write_lock("foo1") + .display_name("Test 2") + .status(TaskStatus::New) + .write_lock("foo1") .build_expect(), ]; @@ -217,13 +217,13 @@ mod tests { fn scheduler_no_new_task_with_feature() { let tasks = vec![ Task::builder() - .with_display_name("Test 1") - .with_status(TaskStatus::New) + .display_name("Test 1") + .status(TaskStatus::New) .requires_feature("huge_cpu") .build_expect(), Task::builder() - .with_display_name("Test 2") - .with_status(TaskStatus::New) + .display_name("Test 2") + .status(TaskStatus::New) .requires_feature("huge_cpu") .build_expect(), ]; @@ -237,13 +237,13 @@ mod tests { fn scheduler_new_task_with_specific_feature() { let tasks = vec![ Task::builder() - .with_display_name("Test 1") - .with_status(TaskStatus::New) + .display_name("Test 1") + .status(TaskStatus::New) .requires_feature("huge_cpu") .build_expect(), Task::builder() - .with_display_name("Test 2") - .with_status(TaskStatus::New) + .display_name("Test 2") + .status(TaskStatus::New) .requires_feature("huge_cpu") .build_expect(), ]; @@ -258,14 +258,14 @@ mod tests { fn scheduler_new_task() { let tasks = vec![ Task::builder() - .with_display_name("Test 1") - .with_status(TaskStatus::Running) - .with_write_lock("foo1") + .display_name("Test 1") + .status(TaskStatus::Running) + .write_lock("foo1") .build_expect(), Task::builder() - .with_display_name("Test 2") - .with_status(TaskStatus::New) - .with_write_lock("foo2") + .display_name("Test 2") + .status(TaskStatus::New) + .write_lock("foo2") .build_expect(), ]; @@ -278,14 +278,14 @@ mod tests { fn scheduler_new_task_ro() { let tasks = vec![ Task::builder() - .with_display_name("Test 1") - .with_status(TaskStatus::Running) - .with_read_lock("foo1") + .display_name("Test 1") + .status(TaskStatus::Running) + .read_lock("foo1") .build_expect(), Task::builder() - .with_display_name("Test 2") - .with_status(TaskStatus::New) - .with_read_lock("foo1") + .display_name("Test 2") + .status(TaskStatus::New) + .read_lock("foo1") .build_expect(), ]; @@ -298,14 +298,14 @@ mod tests { fn scheduler_new_task_rw_ro() { let tasks = vec![ Task::builder() - .with_display_name("Test 1") - .with_status(TaskStatus::Running) - .with_write_lock("foo1") + .display_name("Test 1") + .status(TaskStatus::Running) + .write_lock("foo1") .build_expect(), Task::builder() - .with_display_name("Test 2") - .with_status(TaskStatus::New) - .with_read_lock("foo1") + .display_name("Test 2") + .status(TaskStatus::New) + .read_lock("foo1") .build_expect(), ]; @@ -317,8 +317,8 @@ mod tests { #[test] fn schedule_with_poisoned_lock() { let tasks = vec![Task::builder() - .with_display_name("I need to do something") - .with_write_lock("Entire Prod Cluster") + .display_name("I need to do something") + .write_lock("Entire Prod Cluster") .build_expect()]; let mut poisoned_lock = Lock::write("Entire Prod Cluster"); poisoned_lock.poison(&Uuid::new_v4()); @@ -333,12 +333,12 @@ mod tests { fn schedule_different_tasks_with_poisoned_lock() { let tasks = vec![ Task::builder() - .with_display_name("I need to do something") - .with_write_lock("Entire Prod Cluster") + .display_name("I need to do something") + .write_lock("Entire Prod Cluster") .build_expect(), Task::builder() - .with_display_name("I need to test something") - .with_write_lock("Entire Staging Cluster") + .display_name("I need to test something") + .write_lock("Entire Staging Cluster") .build_expect(), ]; let mut poisoned_lock = Lock::write("Entire Prod Cluster"); @@ -356,8 +356,8 @@ mod tests { #[test] fn schedule_different_tasks_with_poisoned_lock_ro() { let tasks = vec![Task::builder() - .with_display_name("I need to do something") - .with_read_lock("Entire Prod Cluster") + .display_name("I need to do something") + .read_lock("Entire Prod Cluster") .build_expect()]; let mut poisoned_lock = Lock::read("Entire Prod Cluster"); poisoned_lock.poison(&Uuid::new_v4()); diff --git a/vickyctl/src/cli.rs b/vickyctl/src/cli.rs index c7434f3..b6907c5 100644 --- a/vickyctl/src/cli.rs +++ b/vickyctl/src/cli.rs @@ -30,6 +30,8 @@ pub struct TaskData { pub flake_arg: Vec, #[clap(long)] pub features: Vec, + #[clap(short, long)] + pub group: Option, #[clap(long)] pub needs_confirmation: bool, } diff --git a/vickyctl/src/tasks.rs b/vickyctl/src/tasks.rs index 6c5ba8a..46fb76b 100644 --- a/vickyctl/src/tasks.rs +++ b/vickyctl/src/tasks.rs @@ -60,6 +60,7 @@ impl TaskData { "locks": locks, "features": self.features, "needs_confirmation": self.needs_confirmation, + "group": self.group, }) } } @@ -247,6 +248,7 @@ mod tests { flake_url: "".to_string(), flake_arg: vec![], features: vec![], + group: None, needs_confirmation: false, }; @@ -259,6 +261,7 @@ mod tests { }, "features": [], "needs_confirmation": false, + "group": null, }); assert_eq!(data.to_json(), should_be); @@ -282,6 +285,7 @@ mod tests { "huge_cpu".to_string(), "gigantonormous_gpu".to_string(), ], + group: None, needs_confirmation: true, }; @@ -307,6 +311,7 @@ mod tests { }, "features": [ "feat1", "big_cpu", "huge_cpu", "gigantonormous_gpu" ], "needs_confirmation": true, + "group": null, }); assert_eq!(data.to_json(), should_be); From 33a8891128352d6c5ae5314632458816faf43a0a Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:31:12 +0100 Subject: [PATCH 03/13] feat: filter tasks by group --- vicky/src/lib/database/entities/mod.rs | 4 ++-- vicky/src/lib/database/entities/task.rs | 20 +++++++++++--------- vicky/src/lib/query/mod.rs | 7 +++++++ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/vicky/src/lib/database/entities/mod.rs b/vicky/src/lib/database/entities/mod.rs index eddc54d..0ff1915 100644 --- a/vicky/src/lib/database/entities/mod.rs +++ b/vicky/src/lib/database/entities/mod.rs @@ -26,10 +26,10 @@ impl Database { #[through(TaskDatabase)] to conn { pub async fn count_all_tasks(&self, task_status: Option) -> Result; - pub async fn get_all_tasks_filtered( + pub async fn get_all_tasks_filtered + Send + 'static>( &self, task_status: Option, - filter_params: Option, + filters: F, ) -> Result, VickyError>; pub async fn get_all_tasks(&self) -> Result, VickyError>; pub async fn get_task(&self, task_id: Uuid) -> Result, VickyError>; diff --git a/vicky/src/lib/database/entities/task.rs b/vicky/src/lib/database/entities/task.rs index f7b6869..5c9acce 100644 --- a/vicky/src/lib/database/entities/task.rs +++ b/vicky/src/lib/database/entities/task.rs @@ -283,10 +283,10 @@ pub mod db_impl { pub trait TaskDatabase { fn count_all_tasks(&mut self, task_status: Option) -> Result; - fn get_all_tasks_filtered( + fn get_all_tasks_filtered>( &mut self, task_status: Option, - filter_params: Option, + filters: F, ) -> Result, VickyError>; fn get_all_tasks(&mut self) -> Result, VickyError>; fn get_task(&mut self, task_id: Uuid) -> Result, VickyError>; @@ -309,26 +309,28 @@ pub mod db_impl { Ok(tasks_count) } - fn get_all_tasks_filtered( + fn get_all_tasks_filtered>( &mut self, task_status: Option, - filter_params: Option, + filters: F, ) -> Result, VickyError> { + let filters = filters.into(); + let mut db_tasks_build = tasks::table.into_boxed(); if let Some(task_status) = task_status { db_tasks_build = db_tasks_build.filter(tasks::status.eq(task_status)) } - let limit = filter_params.clone().and_then(|x| x.limit); - let offset = filter_params.clone().and_then(|x| x.offset); - - if let Some(r_limit) = limit { + if let Some(r_limit) = filters.limit { db_tasks_build = db_tasks_build.limit(r_limit) } - if let Some(r_offset) = offset { + if let Some(r_offset) = filters.offset { db_tasks_build = db_tasks_build.offset(r_offset) } + if let Some(group) = filters.group { + db_tasks_build = db_tasks_build.filter(tasks::group.eq(group)) + } let db_tasks = db_tasks_build .order(tasks::created_at.desc()) diff --git a/vicky/src/lib/query/mod.rs b/vicky/src/lib/query/mod.rs index 0f11d8b..0862116 100644 --- a/vicky/src/lib/query/mod.rs +++ b/vicky/src/lib/query/mod.rs @@ -4,4 +4,11 @@ use rocket::FromForm; pub struct FilterParams { pub limit: Option, pub offset: Option, + pub group: Option, } + +impl From> for FilterParams { + fn from(value: Option) -> Self { + value.unwrap_or_default() + } +} \ No newline at end of file From e51b37b98e28101502ed5778cb0e54cb540f7ffe Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:36:48 +0100 Subject: [PATCH 04/13] feat: CLI task grouping --- vickyctl/src/cli.rs | 3 +++ vickyctl/src/tasks.rs | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/vickyctl/src/cli.rs b/vickyctl/src/cli.rs index b6907c5..de3db69 100644 --- a/vickyctl/src/cli.rs +++ b/vickyctl/src/cli.rs @@ -60,6 +60,9 @@ pub struct TaskArgs { pub struct TasksArgs { #[command(flatten)] pub ctx: AppContext, + /// By which task group to filter + #[clap(short, long)] + pub group: Option, } #[derive(Args, Debug)] diff --git a/vickyctl/src/tasks.rs b/vickyctl/src/tasks.rs index 46fb76b..7ea1f08 100644 --- a/vickyctl/src/tasks.rs +++ b/vickyctl/src/tasks.rs @@ -25,9 +25,15 @@ pub fn show_tasks(tasks_args: &TasksArgs) -> Result<(), Error> { humanize::ensure_jless("tasks")?; } + let query: &[(_, _)] = match &tasks_args.group { + Some(group) => &[("group", group)], + None => &[], + }; + let client = prepare_client(&tasks_args.ctx)?; let request = client .get(format!("{}/api/v1/tasks", tasks_args.ctx.vicky_url)) + .query(query) .build()?; let response = client.execute(request)?.error_for_status()?; From 2effeed60c3c653aaac334d71d4eb7c78ec8af4a Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:22:14 +0100 Subject: [PATCH 05/13] feat: use consts to set Task State strings --- vicky/src/lib/database/entities/task.rs | 28 +++++++++++++++---------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/vicky/src/lib/database/entities/task.rs b/vicky/src/lib/database/entities/task.rs index 5c9acce..6300663 100644 --- a/vicky/src/lib/database/entities/task.rs +++ b/vicky/src/lib/database/entities/task.rs @@ -1,7 +1,7 @@ -use bon::Builder; use crate::database::entities::lock::db_impl::DbLock; use crate::database::entities::lock::Lock; use crate::database::entities::task::db_impl::DbTask; +use bon::Builder; use chrono::naive::serde::ts_seconds; use chrono::naive::serde::ts_seconds_option; use chrono::{NaiveDateTime, Utc}; @@ -234,15 +234,21 @@ pub mod db_impl { pub group: Option, } + pub const STATE_NEEDS_USER_VALIDATION_STR: &str = "NEEDS_USER_VALIDATION"; + pub const STATE_NEW_STR: &str = "NEW"; + pub const STATE_RUNNING_STR: &str = "RUNNING"; + pub const STATE_FINISHED_SUCCESS_STR: &str = "FINISHED::SUCCESS"; + pub const STATE_FINISHED_ERROR_STR: &str = "FINISHED::ERROR"; + impl Display for TaskStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let str = match self { - TaskStatus::NeedsUserValidation => "NEEDS_USER_VALIDATION", - TaskStatus::New => "NEW", - TaskStatus::Running => "RUNNING", + TaskStatus::NeedsUserValidation => STATE_NEEDS_USER_VALIDATION_STR, + TaskStatus::New => STATE_NEW_STR, + TaskStatus::Running => STATE_RUNNING_STR, TaskStatus::Finished(r) => match r { - TaskResult::Success => "FINISHED::SUCCESS", - TaskResult::Error => "FINISHED::ERROR", + TaskResult::Success => STATE_FINISHED_SUCCESS_STR, + TaskResult::Error => STATE_FINISHED_ERROR_STR, }, }; write!(f, "{str}") @@ -254,11 +260,11 @@ pub mod db_impl { fn try_from(str: &str) -> Result { match str { - "NEEDS_USER_VALIDATION" => Ok(TaskStatus::NeedsUserValidation), - "NEW" => Ok(TaskStatus::New), - "RUNNING" => Ok(TaskStatus::Running), - "FINISHED::SUCCESS" => Ok(TaskStatus::Finished(TaskResult::Success)), - "FINISHED::ERROR" => Ok(TaskStatus::Finished(TaskResult::Error)), + STATE_NEEDS_USER_VALIDATION_STR => Ok(TaskStatus::NeedsUserValidation), + STATE_NEW_STR => Ok(TaskStatus::New), + STATE_RUNNING_STR => Ok(TaskStatus::Running), + STATE_FINISHED_SUCCESS_STR => Ok(TaskStatus::Finished(TaskResult::Success)), + STATE_FINISHED_ERROR_STR => Ok(TaskStatus::Finished(TaskResult::Error)), _ => Err("Could not deserialize to TaskStatus"), } } From ba9152a80a595087d2af2adbcb20f686f89850bf Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:23:30 +0100 Subject: [PATCH 06/13] feat: filter task count --- vicky/src/bin/vicky/tasks.rs | 5 +++-- vicky/src/lib/database/entities/mod.rs | 6 +++++- vicky/src/lib/database/entities/task.rs | 17 +++++++++++++++-- vicky/src/lib/query/mod.rs | 2 +- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/vicky/src/bin/vicky/tasks.rs b/vicky/src/bin/vicky/tasks.rs index 3df9089..a4c4f9e 100644 --- a/vicky/src/bin/vicky/tasks.rs +++ b/vicky/src/bin/vicky/tasks.rs @@ -58,18 +58,19 @@ pub struct Count { count: i64, } -#[get("/count?")] +#[get("/count?&")] pub async fn tasks_count( db: Database, _auth: AnyAuthGuard, status: Option, + filter_params: Option, ) -> Result, AppError> { let task_status: Option = status .as_deref() .map(TaskStatus::try_from) .transpose() .map_err(|_| AppError::HttpError(Status::BadRequest))?; - let tasks_count = db.count_all_tasks(task_status).await?; + let tasks_count = db.count_all_tasks(task_status, filter_params).await?; let c: Count = Count { count: tasks_count }; Ok(Json(c)) } diff --git a/vicky/src/lib/database/entities/mod.rs b/vicky/src/lib/database/entities/mod.rs index 0ff1915..ad70682 100644 --- a/vicky/src/lib/database/entities/mod.rs +++ b/vicky/src/lib/database/entities/mod.rs @@ -25,7 +25,11 @@ impl Database { #[expr(self.run(move |conn| $).await)] #[through(TaskDatabase)] to conn { - pub async fn count_all_tasks(&self, task_status: Option) -> Result; + pub async fn count_all_tasks + Send + 'static>( + &self, + task_status: Option, + filters: F, + ) -> Result; pub async fn get_all_tasks_filtered + Send + 'static>( &self, task_status: Option, diff --git a/vicky/src/lib/database/entities/task.rs b/vicky/src/lib/database/entities/task.rs index 6300663..ce4deab 100644 --- a/vicky/src/lib/database/entities/task.rs +++ b/vicky/src/lib/database/entities/task.rs @@ -288,7 +288,11 @@ pub mod db_impl { } pub trait TaskDatabase { - fn count_all_tasks(&mut self, task_status: Option) -> Result; + fn count_all_tasks>( + &mut self, + task_status: Option, + filters: F, + ) -> Result; fn get_all_tasks_filtered>( &mut self, task_status: Option, @@ -303,13 +307,22 @@ pub mod db_impl { } impl TaskDatabase for diesel::pg::PgConnection { - fn count_all_tasks(&mut self, task_status: Option) -> Result { + fn count_all_tasks>( + &mut self, + task_status: Option, + filters: F, + ) -> Result { + let filters = filters.into(); let mut tasks_count_b = tasks::table.into_boxed(); if let Some(task_status) = task_status { tasks_count_b = tasks_count_b.filter(tasks::status.eq(task_status)) } + if let Some(group) = filters.group { + tasks_count_b = tasks_count_b.filter(tasks::group.eq(group)) + } + let tasks_count: i64 = tasks_count_b.count().first(self)?; Ok(tasks_count) diff --git a/vicky/src/lib/query/mod.rs b/vicky/src/lib/query/mod.rs index 0862116..056d247 100644 --- a/vicky/src/lib/query/mod.rs +++ b/vicky/src/lib/query/mod.rs @@ -11,4 +11,4 @@ impl From> for FilterParams { fn from(value: Option) -> Self { value.unwrap_or_default() } -} \ No newline at end of file +} From f170611305e02cc6c1b6d97a445230afc5ebb4b0 Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:28:58 +0100 Subject: [PATCH 07/13] fix: event callback dependency issue --- dashboard/src/hooks/useEventSource.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/dashboard/src/hooks/useEventSource.tsx b/dashboard/src/hooks/useEventSource.tsx index d7b9cc6..e9582eb 100644 --- a/dashboard/src/hooks/useEventSource.tsx +++ b/dashboard/src/hooks/useEventSource.tsx @@ -1,5 +1,5 @@ import { EventSourceMessage, fetchEventSource } from "@microsoft/fetch-event-source"; -import { MutableRefObject, Ref, RefCallback, useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useAuth } from "react-oidc-context" export type LogEvent = String; @@ -19,11 +19,16 @@ const useEventSource = (url: string, callback: (evt: string) => void, allowStart const auth = useAuth(); + const callbackRef = useRef(callback); + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + const openEventSource = useRef | null>(null); const onMessage = useCallback((evt: EventSourceMessage) => { const x = evt.data; - return callback(x); - }, [callback]) + return callbackRef.current(x); + }, []) useEffect(() => { if (!allowStart || openEventSource.current != null || !auth.user) { @@ -80,4 +85,4 @@ export { useEventSource, useLogStream, useEventSourceJSON -} \ No newline at end of file +} From 4c122011c3892065391f6c6cdbb46929aad3150b Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:45:59 +0100 Subject: [PATCH 08/13] feat: improve api state updates and add task groups --- dashboard/src/components/tasks.tsx | 10 +- dashboard/src/hooks/useTaskGroups.tsx | 48 ++++++++ dashboard/src/hooks/useTasks.tsx | 38 +++--- dashboard/src/services/api.tsx | 161 ++++++++++++++------------ 4 files changed, 166 insertions(+), 91 deletions(-) create mode 100644 dashboard/src/hooks/useTaskGroups.tsx diff --git a/dashboard/src/components/tasks.tsx b/dashboard/src/components/tasks.tsx index 197a96c..e5a3839 100644 --- a/dashboard/src/components/tasks.tsx +++ b/dashboard/src/components/tasks.tsx @@ -28,12 +28,18 @@ const Tasks = () => { const [filter, setFilter] = useState(null); const [page, setPage] = useState(1); + const groups = useTaskGroups(); + const NUM_PER_PAGE = 10; - const tasks = useTasks(filter, NUM_PER_PAGE, (page - 1) * NUM_PER_PAGE); - const tasksCount = useTasksCount(filter); + const tasks = useTasks(filter, null, NUM_PER_PAGE, (page - 1) * NUM_PER_PAGE); + const tasksCount = useTasksCount(status, null); const task = useTask(taskId); + useEffect(() => { + setPage(1); + }, [status, group]); + return ( diff --git a/dashboard/src/hooks/useTaskGroups.tsx b/dashboard/src/hooks/useTaskGroups.tsx new file mode 100644 index 0000000..7f959a1 --- /dev/null +++ b/dashboard/src/hooks/useTaskGroups.tsx @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useState } from "react"; +import { ITask, useAPI } from "../services/api"; +import { GlobalEvent, useEventSourceJSON } from "./useEventSource"; + +const GROUP_FETCH_LIMIT = 400; + +const extractGroups = (tasks: ITask[]) => { + const unique = new Set(); + tasks.forEach((task) => { + if (task.group) { + unique.add(task.group); + } + }); + return Array.from(unique).sort((a, b) => a.localeCompare(b)); +}; + +const useTaskGroups = () => { + const api = useAPI(); + const [groups, setGroups] = useState([]); + + const refreshGroups = useCallback(() => { + api.getTasks({ limit: GROUP_FETCH_LIMIT }).then((tasks) => setGroups(extractGroups(tasks))); + }, [api]); + + const eventCallback = useCallback((evt: GlobalEvent) => { + switch (evt.type) { + case "TaskAdd": + case "TaskUpdate": { + refreshGroups(); + break; + } + default: + break; + } + }, [refreshGroups]); + + useEffect(() => { + refreshGroups(); + }, [refreshGroups]); + + useEventSourceJSON(`/api/events`, eventCallback); + + return groups; +}; + +export { + useTaskGroups, +} diff --git a/dashboard/src/hooks/useTasks.tsx b/dashboard/src/hooks/useTasks.tsx index d8c2b51..b56982b 100644 --- a/dashboard/src/hooks/useTasks.tsx +++ b/dashboard/src/hooks/useTasks.tsx @@ -1,39 +1,43 @@ import { useCallback, useEffect, useState } from "react" import { ITask, useAPI } from "../services/api" -import { GlobalEvent, TaskUpdateEvent, useEventSource, useEventSourceJSON } from "./useEventSource"; +import { GlobalEvent, TaskUpdateEvent, useEventSourceJSON } from "./useEventSource"; -const useTasksCount = (filter: string | null) => { +const useTasksCount = (status: string | null, group: string | null) => { const api = useAPI(); const [tasksCount, setTasksCount] = useState(null) - + + const refreshCount = useCallback(() => { + api.getTasksCount({ status, group }).then((r) => setTasksCount(r.count)); + }, [api, status, group]); + const eventCallback = useCallback((evt: GlobalEvent) => { switch (evt.type) { case "TaskAdd": { - api.getTasksCount(filter).then((r) => setTasksCount(r.count)); + refreshCount(); break; } default: { break; } } - }, [api]) + }, [refreshCount]) useEventSourceJSON(`/api/events`, eventCallback) useEffect(() => { - api.getTasksCount(filter).then((r) => setTasksCount(r.count)); - }, [filter]) + refreshCount(); + }, [refreshCount]) return tasksCount; } -const useTasks = (filter: string | null, limit?: number, offset?: number) => { +const useTasks = (status: string | null, group: string | null, limit?: number, offset?: number) => { const api = useAPI(); const [tasks, setTasks] = useState(null) - - const fetchTasks = async () => { - api.getTasks(filter, limit, offset).then((tasks) => setTasks(tasks)); - } + + const fetchTasks = useCallback(() => { + api.getTasks({ status, group, limit, offset }).then((tasks) => setTasks(tasks)); + }, [api, status, group, limit, offset]); const eventCallback = useCallback((evt: GlobalEvent) => { switch (evt.type) { @@ -65,13 +69,13 @@ const useTasks = (filter: string | null, limit?: number, offset?: number) => { break; } } - }, [api]) + }, [api, fetchTasks]) useEventSourceJSON(`/api/events`, eventCallback) useEffect(() => { fetchTasks() - }, [filter, limit, offset]) + }, [fetchTasks]) return tasks; } @@ -94,7 +98,7 @@ const useTask = (id?: string | null) => { break; } } - }, [id]) + }, [api, id]) useEffect(() => { if (!id) { @@ -102,7 +106,7 @@ const useTask = (id?: string | null) => { } api.getTask(id).then((task) => setTask(task)); - }, [id]) + }, [api, id]) useEventSourceJSON(`/api/events`, eventCallback) @@ -114,4 +118,4 @@ export { useTasksCount, useTasks, useTask -} \ No newline at end of file +} diff --git a/dashboard/src/services/api.tsx b/dashboard/src/services/api.tsx index 9d13969..73ef158 100644 --- a/dashboard/src/services/api.tsx +++ b/dashboard/src/services/api.tsx @@ -1,6 +1,5 @@ -import axios, { Axios } from "axios" -import { useMemo } from "react" -import { useAuth } from "react-oidc-context" +import {useMemo} from "react" +import {useAuth} from "react-oidc-context" type ITask = { id: string, @@ -31,95 +30,112 @@ type IWebConfig = { const BASE_URL = "/api" +type FilterParams = { + status?: string | null; + group?: string | null; + limit?: number; + offset?: number; +} + const useAPI = () => { const auth = useAuth(); - const fetchJSON = async (url: string, init?: RequestInit) => { - const authToken = auth.user?.access_token; + return useMemo(() => { + const fetchJSON = async (url: string, init?: RequestInit) => { + const authToken = auth.user?.access_token; - if(!authToken) { - throw Error("Using useAPI without an authenticated user is not possible") - } + if (!authToken) { + throw Error("Using useAPI without an authenticated user is not possible"); + } - const response = await fetch( - url, - { + const response = await fetch(url, { method: init?.method ?? "GET", body: init?.body, headers: { - "Authorization": `Bearer ${authToken}`, + Authorization: `Bearer ${authToken}`, ...init?.headers, }, - } - ); - - if (!response.ok) { - throw Error(`Request to "${response.url}" failed with status ${response.status}`); - } - - const text = await response.text(); - if (!text) { - return null; - } - - return JSON.parse(text); - } - - const getTasks = (filter: string | null, limit?: number, offset?: number): Promise => { - const urlParams = new URLSearchParams(); - if (filter) { - urlParams.set("status", filter) - } - if (limit) { - urlParams.set("limit", limit.toString()) - } - if (offset) { - urlParams.set("offset", offset.toString()) - } - - return fetchJSON(`${BASE_URL}/tasks?${urlParams.toString()}`); - } - - const getTasksCount = (filter: string | null): Promise<{count: number}> => { - return fetchJSON(`${BASE_URL}/tasks/count${filter ? `?status=${filter}` : ''}`); - } - - const getTask = (id: string): Promise => { - return fetchJSON(`${BASE_URL}/tasks/${id}`); - } + }); - const confirmTask = (id: string): Promise => { - return fetchJSON(`${BASE_URL}/tasks/${id}/confirm`, { - method: "POST", - }); - } + if (!response.ok) { + throw Error(`Request to "${response.url}" failed with status ${response.status}`); + } - const getTaskLogs = (id: string) => { - return fetchJSON(`${BASE_URL}/tasks/${id}/logs`); - } + const text = await response.text(); + if (!text) { + return null; + } - const getUser = (): Promise => { - return fetchJSON(`${BASE_URL}/user`); - } - - - return { - getTasks, - getTasksCount, - getTask, - confirmTask, - getTaskLogs, - getUser, - } + return JSON.parse(text); + }; -} + const buildTaskParams = ({status, group, limit, offset}: FilterParams) => { + const urlParams = new URLSearchParams(); + if (status) { + urlParams.set("status", status); + } + if (group) { + urlParams.set("group", group); + } + if (limit !== undefined) { + urlParams.set("limit", limit.toString()); + } + if (offset !== undefined) { + urlParams.set("offset", offset.toString()); + } + return urlParams; + }; + + const getTasks = (filter: FilterParams = {}): Promise => { + const urlParams = buildTaskParams(filter); + const params = urlParams.toString(); + const query = params ? `?${params}` : ""; + + return fetchJSON(`${BASE_URL}/tasks${query}`); + }; + + const getTasksCount = (filter: FilterParams = {}): Promise<{ count: number }> => { + const urlParams = buildTaskParams(filter); + const params = urlParams.toString(); + const query = params ? `?${params}` : ""; + return fetchJSON(`${BASE_URL}/tasks/count${query}`); + }; + + const getTask = (id: string): Promise => { + return fetchJSON(`${BASE_URL}/tasks/${id}`); + }; + + const confirmTask = (id: string): Promise => { + return fetchJSON(`${BASE_URL}/tasks/${id}/confirm`, { + method: "POST", + }); + }; + + const getTaskLogs = (id: string) => { + return fetchJSON(`${BASE_URL}/tasks/${id}/logs`); + }; + + const getUser = (): Promise => { + return fetchJSON(`${BASE_URL}/user`); + }; + + return { + getTasks, + getTasksCount, + getTask, + confirmTask, + getTaskLogs, + getUser, + }; + }, [auth.user?.access_token]); +}; const useUnauthenticatedAPI = () => { const fetchJSON = async (url: string) => { return fetch( - url, + url, ).then(x => x.json()); } @@ -138,4 +154,5 @@ export { ITask, IUser, IWebConfig, + FilterParams, } From d47333729d62fa2b6fe975245d43542e7215cbd3 Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:47:22 +0100 Subject: [PATCH 09/13] fix: unify border radius --- dashboard/src/components/filter-slider.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/components/filter-slider.module.css b/dashboard/src/components/filter-slider.module.css index 1236584..a2c9fa1 100644 --- a/dashboard/src/components/filter-slider.module.css +++ b/dashboard/src/components/filter-slider.module.css @@ -64,7 +64,7 @@ z-index: 10; background: var(--rs-bg-well); border: 1px solid var(--rs-border-primary); - border-radius: 7px; + border-radius: 5px; box-shadow: var(--rs-shadow-2); padding: 6px 0; transform-origin: top center; From 8025ee72ca71acedf549e719368657473825f9c8 Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:01:10 +0100 Subject: [PATCH 10/13] feat: UI filter by task group --- .../src/components/group-filter.module.css | 142 +++++++++++++++++ dashboard/src/components/group-filter.tsx | 146 ++++++++++++++++++ dashboard/src/components/tasks.module.css | 14 +- dashboard/src/components/tasks.tsx | 30 ++-- 4 files changed, 320 insertions(+), 12 deletions(-) create mode 100644 dashboard/src/components/group-filter.module.css create mode 100644 dashboard/src/components/group-filter.tsx diff --git a/dashboard/src/components/group-filter.module.css b/dashboard/src/components/group-filter.module.css new file mode 100644 index 0000000..686111b --- /dev/null +++ b/dashboard/src/components/group-filter.module.css @@ -0,0 +1,142 @@ +.Wrapper { + position: relative; + display: flex; + align-items: center; +} + +.Trigger { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + border-radius: 5px; + border: 1px solid var(--rs-border-primary); + background: var(--rs-bg-well); + color: var(--rs-text-primary); + cursor: pointer; + transition: border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease; +} + +.Trigger:hover { + border-color: var(--rs-color-primary); + box-shadow: 0 10px 22px rgba(0, 0, 0, 0.35); + transform: translateY(-1px); +} + +.TriggerOpen { + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.75); +} + +.TriggerActive { + color: var(--rs-color-primary); + border-color: var(--rs-color-primary); +} + +.TriggerText { + font-size: 0.9rem; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.Popover { + position: absolute; + top: calc(100% + 8px); + left: 0; + background: var(--rs-bg-well); + border: 1px solid var(--rs-border-primary); + border-radius: 5px; + box-shadow: var(--rs-shadow-2); + padding: 10px; + z-index: 10; +} + +.SearchRow { + display: flex; + align-items: center; + gap: 6px; +} + +.SearchInput { + flex: 1; + padding: 8px 10px; + border-radius: 5px; + border: 1px solid var(--rs-border-primary); + background: var(--rs-input-bg, var(--rs-bg-well)); + color: var(--rs-text-primary); +} + +.SearchInput:focus { + outline: 1px solid var(--rs-color-primary); + border-color: var(--rs-color-primary); +} + +.ClearButton { + border: none; + background: none; + color: var(--rs-text-secondary); + cursor: pointer; + padding: 6px 8px; + border-radius: 5px; + transition: color 140ms ease, background-color 140ms ease; +} + +.ClearButton:hover { + color: var(--rs-color-primary); + background: var(--rs-list-hover-bg); +} + +.GroupList { + margin-top: 8px; + border: 1px solid var(--rs-border-primary); + border-radius: 5px; + max-height: 220px; + overflow-y: auto; + background: rgba(255, 255, 255, 0.02); +} + +.Option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 9px 10px; + border: none; + background: transparent; + color: var(--rs-text-primary); + cursor: pointer; + text-align: left; + transition: background-color 140ms ease, color 140ms ease; +} + +.Option:hover { + background: var(--rs-list-hover-bg); +} + +.OptionActive { + background: var(--rs-list-hover-bg); + color: var(--rs-color-primary); + font-weight: 600; +} + +.OptionLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.EmptyState { + padding: 12px; + text-align: center; + color: var(--rs-text-secondary); + font-size: 0.9rem; + display: flex; + flex-direction: column; + gap: 4px; +} + +.HintText { + font-size: 0.82rem; + color: var(--rs-text-tertiary); +} diff --git a/dashboard/src/components/group-filter.tsx b/dashboard/src/components/group-filter.tsx new file mode 100644 index 0000000..8b1a5a5 --- /dev/null +++ b/dashboard/src/components/group-filter.tsx @@ -0,0 +1,146 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { KeyboardEvent as ReactKeyboardEvent } from "react"; +import PeoplesIcon from "@rsuite/icons/Peoples"; + +import * as s from "./group-filter.module.css"; +import {Button} from "rsuite"; + +type GroupFilterProps = { + groups: string[]; + value: string | null; + onChange: (group: string | null) => void; +}; + +export const GroupFilter = ({ groups, value, onChange }: GroupFilterProps) => { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const wrapperRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (!open) { + return; + } + const onClickOutside = (evt: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(evt.target as Node)) { + setOpen(false); + } + }; + const onKey = (evt: KeyboardEvent) => { + if (evt.key === "Escape") { + setOpen(false); + } + }; + document.addEventListener("mousedown", onClickOutside); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onClickOutside); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + useEffect(() => { + if (open) { + // if input is not in the dom yet, this crashes without setTimeout + setTimeout(() => inputRef.current?.focus(), 0); + } + }, [open]); + + const normalizedGroups = useMemo(() => { + const uniq = new Set(); + groups.forEach((g) => { + if (g) { + uniq.add(g); + } + }); + return Array.from(uniq).sort((a, b) => a.localeCompare(b)); + }, [groups]); + + const filteredGroups = useMemo(() => { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) { + return normalizedGroups; + } + return normalizedGroups.filter((g) => g.toLowerCase().includes(trimmed)); + }, [normalizedGroups, query]); + + const handleSelect = (group: string | null) => { + onChange(group); + setOpen(false); + setQuery(""); + }; + + const handleEnter = (evt: ReactKeyboardEvent) => { + if (evt.key === "Enter") { + evt.preventDefault(); + const trimmed = query.trim(); + handleSelect(trimmed || null); + } + }; + + return ( + + setOpen((o) => !o)} + > + + {value ?? "Groups"} + + {open ? ( + + + setQuery(evt.target.value)} + onKeyDown={handleEnter} + /> + {value ? ( + handleSelect(null)} + aria-label="Clear group filter" + > + Clear + + ) : null} + + + handleSelect(null)} + > + All groups + + {filteredGroups.map((groupName) => ( + handleSelect(groupName)} + > + {groupName} + + ))} + {filteredGroups.length === 0 ? ( + + No matching groups + {query.trim() ? ( + Press Enter to use "{query.trim()}" anyways + ) : null} + + ) : null} + + + ) : null} + + ); +}; diff --git a/dashboard/src/components/tasks.module.css b/dashboard/src/components/tasks.module.css index e8a253f..b0cadb9 100644 --- a/dashboard/src/components/tasks.module.css +++ b/dashboard/src/components/tasks.module.css @@ -29,11 +29,21 @@ .TasksPanel { position: relative; - overflow-x: hidden; - overflow-y: visible; + overflow: visible; /* because the popover needs some space outside */ min-height: 340px; /* because the dropdown needs some space */ } +.HeaderRow { + flex-wrap: wrap; + gap: 12px; +} + +.Filters { + display: flex; + align-items: center; + gap: 10px; +} + .Pagination { padding: 1em 0; diff --git a/dashboard/src/components/tasks.tsx b/dashboard/src/components/tasks.tsx index e5a3839..497b235 100644 --- a/dashboard/src/components/tasks.tsx +++ b/dashboard/src/components/tasks.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState } from "react"; +import { Fragment, useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom" import CalendarIcon from '@rsuite/icons/Calendar'; import TimeIcon from '@rsuite/icons/Time'; @@ -11,6 +11,8 @@ import * as dayjs from "dayjs" import * as s from "./tasks.module.css"; import { useTask, useTasks, useTasksCount } from "../hooks/useTasks"; +import { useTaskGroups } from "../hooks/useTaskGroups"; +import { GroupFilter } from "./group-filter"; const FILTERS: { label: string; value: string | null; color: string }[] = [ { label: "All", value: null, color: "#6b7280"}, @@ -25,15 +27,16 @@ const Tasks = () => { const { taskId } = useParams(); - const [filter, setFilter] = useState(null); + const [status, setStatus] = useState(null); + const [group, setGroup] = useState(null); const [page, setPage] = useState(1); const groups = useTaskGroups(); const NUM_PER_PAGE = 10; - const tasks = useTasks(filter, null, NUM_PER_PAGE, (page - 1) * NUM_PER_PAGE); - const tasksCount = useTasksCount(status, null); + const tasks = useTasks(status, group, NUM_PER_PAGE, (page - 1) * NUM_PER_PAGE); + const tasksCount = useTasksCount(status, group); const task = useTask(taskId); useEffect(() => { @@ -44,13 +47,20 @@ const Tasks = () => { - + Tasks - + + + + From 0ddfee0c12a7d68b7f6f71abe3e7985238b3f325 Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:06:16 +0100 Subject: [PATCH 11/13] fix: remove justifySelf from Button --- dashboard/src/components/task.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/components/task.tsx b/dashboard/src/components/task.tsx index a021b94..4390d5f 100644 --- a/dashboard/src/components/task.tsx +++ b/dashboard/src/components/task.tsx @@ -74,7 +74,7 @@ const Task = (props: TaskProps) => { : null } {needsValidation ? ( - + Confirm ) : null} From 132a91717ead836bb89c655a3c80894e5857572a Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:19:34 +0100 Subject: [PATCH 12/13] fix: unbind event state --- dashboard/src/hooks/useEventSource.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/dashboard/src/hooks/useEventSource.tsx b/dashboard/src/hooks/useEventSource.tsx index e9582eb..38eeabe 100644 --- a/dashboard/src/hooks/useEventSource.tsx +++ b/dashboard/src/hooks/useEventSource.tsx @@ -24,22 +24,21 @@ const useEventSource = (url: string, callback: (evt: string) => void, allowStart callbackRef.current = callback; }, [callback]); - const openEventSource = useRef | null>(null); const onMessage = useCallback((evt: EventSourceMessage) => { const x = evt.data; return callbackRef.current(x); }, []) useEffect(() => { - if (!allowStart || openEventSource.current != null || !auth.user) { + if (!allowStart || !auth.user) { return; } const controller = new AbortController() - let urlWithParam = params ? `${url}?start=${params.start}` : url + const urlWithParam = params ? `${url}?start=${params.start}` : url; - openEventSource.current = fetchEventSource( + fetchEventSource( urlWithParam, { openWhenHidden: true, @@ -53,9 +52,8 @@ const useEventSource = (url: string, callback: (evt: string) => void, allowStart return () => { controller.abort() - openEventSource.current = null; } - }, [url, allowStart, auth.user, onMessage]) + }, [url, allowStart, auth.user, onMessage, params?.start]) } From 481256f05f7c0138917947a1780283351ca6053c Mon Sep 17 00:00:00 2001 From: willow <52585984+Kek5chen@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:20:11 +0100 Subject: [PATCH 13/13] fix: use borderImageSource instead of borderImage --- dashboard/src/components/filter-slider.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/dashboard/src/components/filter-slider.tsx b/dashboard/src/components/filter-slider.tsx index 0c9f8cf..8e61458 100644 --- a/dashboard/src/components/filter-slider.tsx +++ b/dashboard/src/components/filter-slider.tsx @@ -83,8 +83,6 @@ export const FilterSlider = ({ options, value, onChange }: FilterSliderProps) => }; const borderOverlayStyle = useMemo(() => { - const maxIndex = Math.max(1, options.length - 1); - const pos = Math.max(0, Math.min(1, (selectedIndex - dragOffset) / maxIndex)); const nextIdx = Math.max(0, Math.min(colors.length - 1, Math.ceil(selectedIndex - dragOffset))); const prevIdx = Math.max(0, Math.min(colors.length - 1, Math.floor(selectedIndex - dragOffset))); const fallbackColor = colors[0] ?? "#6b7280"; @@ -102,12 +100,11 @@ export const FilterSlider = ({ options, value, onChange }: FilterSliderProps) => } return { - borderImage: `${gradient} 1`, + borderImageSource: gradient, borderImageSlice: 1, opacity: 1, - backgroundPosition: `${pos * 100}% 50%`, }; - }, [hovering, dragState, selectedIndex, dragOffset, options.length, colors]); + }, [hovering, dragState, selectedIndex, dragOffset, colors]); return (