diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e08b30e..16bceab73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v1.5.1 - (2025-01-12) + +- filter trigger by status +- filter history by status + ## v1.5.0 - (2025-01-11) - Adjusted transaction handling for trigger life cycle events diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/history/HistoryService.java b/core/src/main/java/org/sterl/spring/persistent_tasks/history/HistoryService.java index 92012085a..afdba87c6 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/history/HistoryService.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/history/HistoryService.java @@ -96,18 +96,15 @@ public long countTriggers(TriggerKey key) { * @return the found data, looking only the last states */ public Page findTriggerState( - @Nullable TriggerKey key, Pageable page) { - + @Nullable TriggerKey key, @Nullable TriggerStatus status, Pageable page) { + page = applyDefaultSortIfNeeded(page); - if (key == null) return triggerHistoryLastStateRepository.findAll(page); - if (key.getId() == null && key.getTaskName() == null) return triggerHistoryLastStateRepository.findAll(page); - if (key.getId() == null && key.getTaskName() != null) { - return triggerHistoryLastStateRepository.findAll(key.getTaskName(), page); + if (key == null && status == null) { + return triggerHistoryLastStateRepository.findAll(page); } - return triggerHistoryLastStateRepository.findAll( - key.getId(), - key.getTaskName(), - page); + final var id = key == null ? null : key.getId(); + final var name = key == null ? null : key.getTaskName(); + return triggerHistoryLastStateRepository.findAll(id, name, status, page); } private Pageable applyDefaultSortIfNeeded(Pageable page) { diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/history/api/TriggerHistoryResource.java b/core/src/main/java/org/sterl/spring/persistent_tasks/history/api/TriggerHistoryResource.java index eb2ec1f18..18937dd8b 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/history/api/TriggerHistoryResource.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/history/api/TriggerHistoryResource.java @@ -14,6 +14,7 @@ import org.sterl.spring.persistent_tasks.api.TaskStatusHistoryOverview; import org.sterl.spring.persistent_tasks.api.Trigger; import org.sterl.spring.persistent_tasks.api.TriggerKey; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.persistent_tasks.history.HistoryService; import org.sterl.spring.persistent_tasks.history.api.HistoryConverter.FromLastTriggerStateEntity; import org.sterl.spring.persistent_tasks.history.api.HistoryConverter.FromTriggerStateDetailEntity; @@ -41,11 +42,11 @@ public List taskStatusHistory() { public PagedModel list( @RequestParam(name = "id", required = false) String id, @RequestParam(name = "taskName", required = false) String taskName, - @PageableDefault(size = 100) Pageable pageable) { + @RequestParam(name = "status", required = false) TriggerStatus status, + @PageableDefault(size = 100) Pageable page) { + var key = new TriggerKey(StringUtils.trimToNull(id), StringUtils.trimToNull(taskName)); return FromLastTriggerStateEntity.INSTANCE.toPage( // - historyService.findTriggerState( - new TriggerKey(StringUtils.trimToNull(id), StringUtils.trimToNull(taskName)), - pageable)); + historyService.findTriggerState(key, status, page)); } } diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/shared/repository/TriggerDataRepository.java b/core/src/main/java/org/sterl/spring/persistent_tasks/shared/repository/TriggerDataRepository.java index daf4d49c0..c07a81cc6 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/shared/repository/TriggerDataRepository.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/shared/repository/TriggerDataRepository.java @@ -20,9 +20,12 @@ public interface TriggerDataRepository extends JpaRepo SELECT e FROM #{#entityName} e WHERE (e.data.key.id LIKE :id% OR :id IS NULL) AND (e.data.key.taskName = :taskName OR :taskName IS NULL) + AND (e.data.status = :status OR :status IS NULL) """) Page findAll(@Param("id") String id, - @Param("taskName") String taskName, Pageable page); + @Param("taskName") String taskName, + @Param("status") TriggerStatus status, + Pageable page); @Query(""" SELECT e FROM #{#entityName} e diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/TriggerService.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/TriggerService.java index 0ddee5d26..7b8877770 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/TriggerService.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/TriggerService.java @@ -94,8 +94,9 @@ public Optional get(TriggerKey triggerKey) { } @Transactional(readOnly = true , timeout = 10) - public Page findAllTriggers(TriggerKey key, Pageable page) { - return this.readTrigger.listTriggers(key, page); + public Page findAllTriggers( + @Nullable TriggerKey key, @Nullable TriggerStatus status, Pageable page) { + return this.readTrigger.listTriggers(key, status, page); } @Transactional(readOnly = true , timeout = 10) diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResource.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResource.java index c6abdfd22..6f4c543c0 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResource.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResource.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RestController; import org.sterl.spring.persistent_tasks.api.Trigger; import org.sterl.spring.persistent_tasks.api.TriggerKey; +import org.sterl.spring.persistent_tasks.api.TriggerStatus; import org.sterl.spring.persistent_tasks.trigger.TriggerService; import org.sterl.spring.persistent_tasks.trigger.api.TriggerConverter.FromTriggerEntity; @@ -39,12 +40,12 @@ public long count() { public PagedModel list( @RequestParam(name = "id", required = false) String id, @RequestParam(name = "taskName", required = false) String taskName, + @RequestParam(name = "status", required = false) TriggerStatus status, @PageableDefault(size = 100, direction = Direction.ASC, sort = "data.runAt") Pageable pageable) { + var key = new TriggerKey(StringUtils.trimToNull(id), StringUtils.trimToNull(taskName)); return FromTriggerEntity.INSTANCE.toPage( - triggerService.findAllTriggers( - new TriggerKey(StringUtils.trimToNull(id), StringUtils.trimToNull(taskName)), - pageable)); + triggerService.findAllTriggers(key, status, pageable)); } @PostMapping("triggers/{taskName}/{id}/run-at") diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/ReadTriggerComponent.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/ReadTriggerComponent.java index 9fbdff8f1..c2a02d759 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/ReadTriggerComponent.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/component/ReadTriggerComponent.java @@ -50,10 +50,12 @@ public List findTriggersLastPingAfter(OffsetDateTime dateTime) { return triggerRepository.findTriggersLastPingAfter(dateTime); } - public Page listTriggers(TriggerKey key, Pageable page) { - if (key == null) return triggerRepository.findAll(page); - if (key.getId() == null) return listTriggers(key.toTaskId(), page); - return triggerRepository.findAll(key.getId(), key.getTaskName(), page); + public Page listTriggers(@Nullable TriggerKey key, + @Nullable TriggerStatus status, Pageable page) { + if (key == null && status == null) return triggerRepository.findAll(page); + final var id = key == null ? null : key.getId(); + final var name = key == null ? null : key.getTaskName(); + return triggerRepository.findAll(id, name, status, page); } public Page listTriggers(TaskId task, Pageable page) { diff --git a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/model/TriggerEntity.java b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/model/TriggerEntity.java index 4abcfbc85..999f28cf1 100644 --- a/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/model/TriggerEntity.java +++ b/core/src/main/java/org/sterl/spring/persistent_tasks/trigger/model/TriggerEntity.java @@ -104,6 +104,7 @@ public TriggerEntity complete(Exception e) { public TriggerEntity runAt(OffsetDateTime runAt) { data.setStatus(TriggerStatus.WAITING); data.setRunAt(runAt); + setRunningOn(null); return this; } diff --git a/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerServiceTransactionTest.java b/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerServiceTransactionTest.java index 104f01d9b..7d4987cc1 100644 --- a/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerServiceTransactionTest.java +++ b/core/src/test/java/org/sterl/spring/persistent_tasks/scheduler/SchedulerServiceTransactionTest.java @@ -161,6 +161,8 @@ void test_fail_in_transaction() throws Exception { // AND var data = persistentTaskService.getLastDetailData(trigger.key()); assertThat(data.get().getStatus()).isEqualTo(TriggerStatus.FAILED); + assertThat(triggerService.get(trigger.getKey()).get().getRunningOn()).isNull(); + assertThat(triggerService.get(trigger.getKey()).get().status()).isEqualTo(TriggerStatus.WAITING); // AND var history = historyService.findAllDetailsForKey(trigger.key()).getContent(); assertThat(history.get(0).getData().getStatus()).isEqualTo(TriggerStatus.FAILED); diff --git a/core/src/test/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResourceTest.java b/core/src/test/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResourceTest.java index b468b3ff5..57aef97f3 100644 --- a/core/src/test/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResourceTest.java +++ b/core/src/test/java/org/sterl/spring/persistent_tasks/trigger/api/TriggerResourceTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; @@ -16,12 +17,18 @@ import org.sterl.spring.persistent_tasks.AbstractSpringTest.TaskConfig.Task3; import org.sterl.spring.persistent_tasks.api.TaskId.TaskTriggerBuilder; import org.sterl.spring.persistent_tasks.api.Trigger; +import org.sterl.spring.persistent_tasks.api.TriggerKey; import org.sterl.spring.persistent_tasks.api.TriggerStatus; +import org.sterl.spring.persistent_tasks.shared.model.TriggerData; +import org.sterl.spring.persistent_tasks.trigger.model.TriggerEntity; +import org.sterl.spring.persistent_tasks.trigger.repository.TriggerRepository; class TriggerResourceTest extends AbstractSpringTest { @LocalServerPort private int port; + @Autowired + private TriggerRepository triggerRepository; private String baseUrl; private final RestTemplate template = new RestTemplate(); @@ -33,7 +40,8 @@ void setupRest() { @Test void testList() { // GIVEN - var triggerKey = triggerService.queue(TaskTriggerBuilder.newTrigger("task1").build()).getKey(); + var k1 = createStatus(new TriggerKey("1-foo", "foo"), TriggerStatus.WAITING).getKey(); + var k2 = createStatus(new TriggerKey("2-foo", "bar"), TriggerStatus.WAITING).getKey(); // WHEN var response = template.exchange( @@ -45,8 +53,11 @@ void testList() { // THEN assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody()).isNotNull(); - assertThat(response.getBody()).contains(triggerKey.getId()); - assertThat(response.getBody()).contains(triggerKey.getTaskName()); + assertThat(response.getBody()).contains(k1.getId()); + assertThat(response.getBody()).contains(k1.getTaskName()); + // AND + assertThat(response.getBody()).contains(k2.getId()); + assertThat(response.getBody()).contains(k2.getTaskName()); } @Test @@ -71,6 +82,26 @@ void testSearchById() { assertThat(response.getBody()).doesNotContain(key2.getId()); } + @Test + void testSearchByStatus() { + // GIVEN + var k1 = createStatus(new TriggerKey("1-foo", "foo"), TriggerStatus.WAITING).getKey(); + var k2 = createStatus(new TriggerKey("2-foo", "bar"), TriggerStatus.RUNNING).getKey(); + + // WHEN + var response = template.exchange( + baseUrl + "?status=" + TriggerStatus.RUNNING, + HttpMethod.GET, + null, + String.class); + + // THEN + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).contains(k2.getId()); + assertThat(response.getBody()).doesNotContain(k1.getId()); + } + @Test void testCancel() { // GIVEN @@ -115,5 +146,25 @@ void testUpdateRunAt() { assertThat(triggerService.countTriggers(TriggerStatus.WAITING)).isOne(); assertThat(response.getBody().getKey()).isEqualTo(triggerKey); } + + + private TriggerEntity createStatus(TriggerKey key, TriggerStatus status) { + final var now = OffsetDateTime.now(); + final var isCancel = status == TriggerStatus.CANCELED; + + TriggerEntity result = new TriggerEntity(); + result.setData(TriggerData + .builder() + .start(isCancel ? null : now.minusMinutes(1)) + .end(isCancel ? null : now) + .createdTime(now) + .key(key) + .status(status) + .runningDurationInMs(isCancel ? null : 600L) + .build() + ); + + return triggerRepository.save(result); + } } diff --git a/ui/src/history/history.page.tsx b/ui/src/history/history.page.tsx index 69402a40b..deb8c384b 100644 --- a/ui/src/history/history.page.tsx +++ b/ui/src/history/history.page.tsx @@ -4,27 +4,25 @@ import useAutoRefresh from "@src/shared/use-auto-refresh"; import HttpErrorView from "@src/shared/view/http-error.view"; import PageView from "@src/shared/view/page.view"; import ReloadButton from "@src/shared/view/reload-button.view"; +import TriggerStatusSelect from "@src/shared/view/triger-status-select.view"; import TriggerItemView from "@src/shared/view/trigger-list-item.view"; import TaskSelect from "@src/task/view/task-select.view"; +import { useQuery } from "crossroad"; import { useState } from "react"; import { Accordion, Col, Form, Row, Stack } from "react-bootstrap"; const HistoryPage = () => { - const [page, setPage] = useState(0); - const [taskName, setTaskName] = useState(""); - const [id, setId] = useState(""); + const [query, setQuery] = useQuery(); const triggers = useServerObject>( "/spring-tasks-api/history" ); const doReload = () => { - triggers.doGet( - "?size=10&page=" + page + "&taskName=" + taskName + "&id=" + id - ); + triggers.doGet("?size=10&" + new URLSearchParams(query).toString()); }; - useAutoRefresh(10000, doReload, [page, taskName, id]); + useAutoRefresh(10000, doReload, [query]); return ( <> @@ -33,25 +31,52 @@ const HistoryPage = () => { e.key == "Enter" - ? setId( - (e.target as HTMLInputElement).value - ) + ? setQuery((prev) => ({ + ...prev, + id: (e.target as HTMLInputElement) + .value, + })) : null } /> + + + setQuery((prev) => ({ + ...prev, + status, + })) + } + /> + - + + setQuery((prev) => ({ + ...prev, + taskName: taskName, + })) + } + /> - + setPage(p)} + onPage={(page) => + setQuery((prev) => ({ + ...prev, + page: page + "", + })) + } data={triggers.data} /> diff --git a/ui/src/shared/http-request.ts b/ui/src/shared/http-request.ts index 3824dfd4e..bed519e6f 100644 --- a/ui/src/shared/http-request.ts +++ b/ui/src/shared/http-request.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; export interface ServerObject { isLoading: boolean, @@ -36,9 +36,9 @@ export const useServerObject = (url: string, startValue?: T): ServerObject }) .finally(() => setIsLoading(false)); return () => controller.abort(); - } + }; - const doCall = (urlPart?: string, method: HttpMethod = "GET", dataToSend?: unknown) => { + const doCall = useCallback((urlPart?: string, method: HttpMethod = "GET", dataToSend?: unknown) => { return axios.request({ baseURL: url + (urlPart ?? ""), data: dataToSend, @@ -47,9 +47,10 @@ export const useServerObject = (url: string, startValue?: T): ServerObject .then((response) => setData(response.data as T)) .catch(e => steError(e)) .finally(() => setIsLoading(false)); - } + }, [url]); + return useMemo( () => ({ isLoading, data, error, doGet, doCall }), - [isLoading, data, error, doCall] + [isLoading, data, error, doCall, url] ); } \ No newline at end of file diff --git a/ui/src/shared/view/page.view.tsx b/ui/src/shared/view/page.view.tsx index 84bac91ec..1d9a96c9e 100644 --- a/ui/src/shared/view/page.view.tsx +++ b/ui/src/shared/view/page.view.tsx @@ -32,7 +32,7 @@ const PageView: React.FC = ({ data, onPage }) => { : "-"; return ( - + void; +} + +function TriggerStatusSelect({ value = "", onTaskChange }: TaskSelectProps) { + const handleTaskChange = (event: React.ChangeEvent) => { + const newTask = event.target.value ?? ""; + if (newTask !== value) { + if (onTaskChange) onTaskChange(newTask); + } + }; + + return ( + + + + + + + + + ); +} + +export default TriggerStatusSelect; diff --git a/ui/src/task/view/staus.view.tsx b/ui/src/task/view/staus.view.tsx index c2134d017..398da4bf9 100644 --- a/ui/src/task/view/staus.view.tsx +++ b/ui/src/task/view/staus.view.tsx @@ -4,18 +4,40 @@ import { Badge } from "react-bootstrap"; interface Props { status: TriggerStatus; suffix?: string; + pill?: boolean; } -const StatusView = ({ status, suffix }: Props) => { - if (status === "SUCCESS") - return Success{suffix ?? ""}; - if (status === "FAILED") - return Failed{suffix ?? ""}; - if (status === "RUNNING") return Running{suffix ?? ""}; +const StatusView = ({ status, pill = false, suffix }: Props) => { + if (status === "SUCCESS") { + return ( + + Success{suffix ?? ""} + + ); + } + if (status === "FAILED") { + return ( + + Failed{suffix ?? ""} + + ); + } + if (status === "RUNNING") { + return Running{suffix ?? ""}; + } if (status === "WAITING") - return Wating{suffix ?? ""}; - if (status === "CANCELED") - return Canceled{suffix ?? ""}; - return {status}; + return ( + + Wating{suffix ?? ""} + + ); + if (status === "CANCELED") { + return ( + + Canceled{suffix ?? ""} + + ); + } + return {status}; }; export default StatusView; diff --git a/ui/src/task/view/task-select.view.tsx b/ui/src/task/view/task-select.view.tsx index 304da1623..51d62496a 100644 --- a/ui/src/task/view/task-select.view.tsx +++ b/ui/src/task/view/task-select.view.tsx @@ -1,21 +1,20 @@ -import { useEffect, useState } from "react"; -import { Col, Form, Row, Spinner } from "react-bootstrap"; import { useServerObject } from "@src/shared/http-request"; +import { useEffect } from "react"; +import { Col, Form, Row, Spinner } from "react-bootstrap"; interface TaskSelectProps { + value?: string; onTaskChange?: (task: string) => void; // Define type for the callback } -function TaskSelect({ onTaskChange }: TaskSelectProps) { - const [selectedTask, setSelectedTask] = useState(""); +function TaskSelect({ value = "", onTaskChange }: TaskSelectProps) { const tasksState = useServerObject("/spring-tasks-api/tasks"); useEffect(tasksState.doGet, []); const handleTaskChange = (event: React.ChangeEvent) => { const newTask = event.target.value ?? ""; - if (newTask !== selectedTask) { - setSelectedTask(newTask); + if (newTask !== value) { if (onTaskChange) onTaskChange(newTask); } }; @@ -41,7 +40,7 @@ function TaskSelect({ onTaskChange }: TaskSelectProps) { diff --git a/ui/src/trigger/triggers.page.tsx b/ui/src/trigger/triggers.page.tsx index 42343937a..a88e28b8e 100644 --- a/ui/src/trigger/triggers.page.tsx +++ b/ui/src/trigger/triggers.page.tsx @@ -4,26 +4,24 @@ import useAutoRefresh from "@src/shared/use-auto-refresh"; import HttpErrorView from "@src/shared/view/http-error.view"; import PageView from "@src/shared/view/page.view"; import ReloadButton from "@src/shared/view/reload-button.view"; +import TriggerStatusSelect from "@src/shared/view/triger-status-select.view"; import TaskSelect from "@src/task/view/task-select.view"; -import { useState } from "react"; +import { useQuery } from "crossroad"; import { Accordion, Col, Form, Row, Stack } from "react-bootstrap"; import TriggerItemView from "../shared/view/trigger-list-item.view"; const TriggersPage = () => { - const [page, setPage] = useState(0); - const [taskName, setTaskName] = useState(""); - const [id, setId] = useState(""); + const [query, setQuery] = useQuery(); const triggers = useServerObject>( "/spring-tasks-api/triggers" ); - const doReload = () => { - triggers.doGet( - "?size=10&page=" + page + "&taskName=" + taskName + "&id=" + id + return triggers.doGet( + "?size=10&" + new URLSearchParams(query).toString() ); }; - - useAutoRefresh(10000, doReload, [page, taskName, id]); + console.info(query); + useAutoRefresh(10000, doReload, [query]); return ( @@ -31,22 +29,53 @@ const TriggersPage = () => { e.key == "Enter" - ? setId((e.target as HTMLInputElement).value) + ? setQuery((prev) => ({ + ...prev, + id: (e.target as HTMLInputElement).value, + })) : null } /> + + + setQuery((prev) => ({ + ...prev, + status, + })) + } + /> + - + + setQuery((prev) => ({ + ...prev, + taskName: taskName, + })) + } + /> - setPage(p)} data={triggers.data} /> + + setQuery((prev) => ({ + ...prev, + page: page + "", + })) + } + data={triggers.data} + /> { if (!data) return undefined; - if (data.status === "SUCCESS") return Success; - if (data.status === "FAILED") return Failed; - if (data.status === "RUNNING") return Running; - if (data.executionCount > 0 && data.status === "WAITING") { - return Retry; + return ( + + Retry + + ); } - if (data.status === "WAITING") return Wating; - if (data.status === "CANCELED") - return Canceled; - return {data.status}; + return ; }; export default TriggerStatusView;