From de7601cdcd72389b5112b4104981594766894a6a Mon Sep 17 00:00:00 2001 From: Julian Haupt Date: Tue, 16 Jun 2026 20:50:04 +0200 Subject: [PATCH] cockpit: add process instance restart --- public/locales/de-DE/translation.json | 24 ++ public/locales/en-US/translation.json | 24 ++ src/api/resources/process_definition.js | 18 ++ src/api/resources/process_definition.test.js | 28 ++ src/css/components.css | 37 +++ src/pages/Processes.jsx | 321 ++++++++++++++++++- src/pages/Processes.test.jsx | 88 ++++- src/state.js | 1 + src/state.test.js | 1 + 9 files changed, 528 insertions(+), 14 deletions(-) diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json index 5f7d6a9..f186714 100644 --- a/public/locales/de-DE/translation.json +++ b/public/locales/de-DE/translation.json @@ -284,6 +284,7 @@ "instances": "Instanzen", "incidents": "Inzidente", "called-definitions": "Aufgerufene Definitionen", + "restart": "Restart", "jobs": "Jobs", "variables": "Variablen", "instance-incidents": "Instanz-Inzidente", @@ -296,6 +297,7 @@ "instances": "Instanzen", "incidents": "Inzidente", "called-definitions": "Aufgerufene Definitionen", + "restart": "Restart", "jobs": "Jobs" }, "diagram-maximize": "Diagramm maximieren", @@ -347,6 +349,28 @@ "suspend": "Anhalten", "change-priority": "Job-Priorität ändern" }, + "restart": { + "start-point": "Startpunkt", + "instruction": "Instruktion", + "default-start": "Standard-Start-Event", + "start-before": "Vor Aktivität starten", + "start-after": "Nach Aktivität starten", + "start-transition": "Transition starten", + "activity-id": "Aktivitäts-ID", + "transition-id": "Transition-ID", + "options": "Optionen", + "async": "Asynchroner Batch", + "initial-variables": "Initiale Variablen", + "without-business-key": "Ohne Geschäftsschlüssel", + "skip-custom-listeners": "Custom Listener überspringen", + "skip-io-mappings": "IO-Mappings überspringen", + "execute": "Neu starten", + "execute-async": "Asynchron neu starten", + "batch-created": "Restart-Batch erstellt:", + "success": "Prozessinstanzen neu gestartet.", + "no-instances": "Keine abgeschlossenen Prozessinstanzen gefunden.", + "end-time": "Endzeit" + }, "sort": { "name": "Name", "key": "Schlüssel", diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index ac20fbe..90cb331 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -284,6 +284,7 @@ "instances": "Instances", "incidents": "Incidents", "called-definitions": "Called Definitions", + "restart": "Restart", "jobs": "Jobs", "variables": "Variables", "instance-incidents": "Instance Incidents", @@ -296,6 +297,7 @@ "instances": "Instances", "incidents": "Incidents", "called-definitions": "Called Definitions", + "restart": "Restart", "jobs": "Jobs" }, "diagram-maximize": "Maximize diagram", @@ -347,6 +349,28 @@ "suspend": "Suspend", "change-priority": "Change Overriding Job Priority" }, + "restart": { + "start-point": "Start point", + "instruction": "Instruction", + "default-start": "Default start event", + "start-before": "Start before activity", + "start-after": "Start after activity", + "start-transition": "Start transition", + "activity-id": "Activity ID", + "transition-id": "Transition ID", + "options": "Options", + "async": "Async batch", + "initial-variables": "Initial variables", + "without-business-key": "Without business key", + "skip-custom-listeners": "Skip custom listeners", + "skip-io-mappings": "Skip IO mappings", + "execute": "Restart", + "execute-async": "Restart async", + "batch-created": "Restart batch created:", + "success": "Process instances restarted.", + "no-instances": "No completed process instances found.", + "end-time": "End Time" + }, "sort": { "name": "Name", "key": "Key", diff --git a/src/api/resources/process_definition.js b/src/api/resources/process_definition.js index 0ec18ac..29f4f07 100644 --- a/src/api/resources/process_definition.js +++ b/src/api/resources/process_definition.js @@ -90,6 +90,22 @@ export const start_process_submit_form = (state, id, body = {}) => export const get_activity_instance_statistics = (state, id) => GET(`/process-definition/${id}/statistics`, state, state.api.process.definition.activity_instance_statistics) +const restart_process_instances = (state, id, body) => + POST( + `/process-definition/${id}/restart`, + body, + state, + state.api.process.definition.restart, + ) + +const restart_process_instances_async = (state, id, body) => + POST( + `/process-definition/${id}/restart-async`, + body, + state, + state.api.process.definition.restart, + ) + const suspend_process_definition = (state, id) => PUT( `/process-definition/${id}/suspended`, @@ -127,6 +143,8 @@ const process_definition = { rendered_start_form: get_rendered_start_form, submit_form: start_process_submit_form, activity_instance_statistics: get_activity_instance_statistics, + restart: restart_process_instances, + restart_async: restart_process_instances_async, suspend: suspend_process_definition, activate: activate_process_definition, remove: delete_process_definition, diff --git a/src/api/resources/process_definition.test.js b/src/api/resources/process_definition.test.js index fec6547..ede635c 100644 --- a/src/api/resources/process_definition.test.js +++ b/src/api/resources/process_definition.test.js @@ -155,6 +155,34 @@ describe("api/resources/process_definition", () => { }); }); + it("restart() POSTs the restart payload", () => { + const body = { + instructions: [{ type: "startBeforeActivity", activityId: "task" }], + processInstanceIds: ["inst-1"], + }; + process_definition.restart(state, "def-1", body); + expect_api_call(POST, { + url: "/process-definition/def-1/restart", + body, + state, + signal: state.api.process.definition.restart, + }); + }); + + it("restart_async() POSTs the async restart payload", () => { + const body = { + processInstanceIds: ["inst-1", "inst-2"], + initialVariables: true, + }; + process_definition.restart_async(state, "def-1", body); + expect_api_call(POST, { + url: "/process-definition/def-1/restart-async", + body, + state, + signal: state.api.process.definition.restart, + }); + }); + it("suspend() PUTs suspended=true", () => { process_definition.suspend(state, "def-1"); expect_api_call(PUT, { diff --git a/src/css/components.css b/src/css/components.css index 5bed3e4..a213568 100644 --- a/src/css/components.css +++ b/src/css/components.css @@ -570,6 +570,43 @@ main#processes { color: var(--text); } +#processes .restart { + display: flex; + flex-direction: column; + gap: var(--spacing-2); +} + +#processes .restart form { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--spacing-2); +} + +#processes .restart fieldset { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-1); + margin: 0; +} + +#processes .restart label { + display: flex; + align-items: center; + gap: 0.4rem; +} + +#processes .restart label:has(> select), +#processes .restart label:has(> input[type="text"]) { + flex-direction: column; + align-items: start; +} + +#processes .restart button { + margin: 0; +} + /* bpmn-js's brand badge defaults to inline-flow at the canvas root, which can push sibling columns sideways. Pin it to the viewport's bottom-right. */ .bjs-powered-by { diff --git a/src/pages/Processes.jsx b/src/pages/Processes.jsx index d6ac79e..9b463a2 100644 --- a/src/pages/Processes.jsx +++ b/src/pages/Processes.jsx @@ -277,6 +277,7 @@ const ProcessSubNav = () => { panel="called_definitions" label={t("processes.subnav.called-definitions")} /> + @@ -686,7 +687,7 @@ const DefinitionsEmpty = () => { {t("processes.empty.how-to")} @@ -928,8 +929,7 @@ const InstanceDetails = () => { params: { selection_id, definition_id, panel }, query, } = useRoute(), - history_mode = query.history === "true", - [t] = useTranslation(); + history_mode = query.history === "true"; if (selection_id) { if ( @@ -1005,6 +1005,302 @@ const ProcessInstance = ({ id, startTime, state, businessKey }) => { ); }; +const restart_instruction = (type, target) => { + if (type === "default") return null; + const value = target.trim(); + if (!value) return null; + return type === "startTransition" + ? { type, transitionId: value } + : { type, activityId: value }; +}; + +const restart_body = (selected, form) => { + const instruction = restart_instruction( + form.instruction_type.value, + form.instruction_target.value, + ); + return { + ...(instruction ? { instructions: [instruction] } : {}), + processInstanceIds: [...selected], + initialVariables: form.initial_variables.value, + skipCustomListeners: form.skip_custom_listeners.value, + skipIoMappings: form.skip_io_mappings.value, + withoutBusinessKey: form.without_business_key.value, + }; +}; + +const RestartProcessInstances = () => { + const state = useContext(AppState), + { params } = useRoute(), + [t] = useTranslation(), + selected = useSignal(new Set()), + form = { + instruction_type: useSignal("default"), + instruction_target: useSignal(""), + async: useSignal(true), + initial_variables: useSignal(false), + skip_custom_listeners: useSignal(false), + skip_io_mappings: useSignal(false), + without_business_key: useSignal(false), + }, + list = state.api.process.instance.list, + restart = state.api.process.definition.restart; + + useEffect(() => { + restart.value = null; + selected.value = new Set(); + void engine_rest.history.process_instance.all( + state, + params.definition_id, + { finished: true, sortBy: "endTime", sortOrder: "desc" }, + 0, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params.definition_id]); + + const rows = list.value?.data ?? [], + needs_target = form.instruction_type.value !== "default", + has_target = form.instruction_target.value.trim().length > 0, + can_restart = + selected.value.size > 0 && (!needs_target || has_target); + + const toggle_one = (id) => { + const next = new Set(selected.value); + if (next.has(id)) next.delete(id); + else next.add(id); + selected.value = next; + }; + + const toggle_all = () => { + if (selected.value.size === rows.length && rows.length > 0) { + selected.value = new Set(); + } else { + selected.value = new Set(rows.map((row) => row.id)); + } + }; + + const submit = async (event) => { + event.preventDefault(); + if (!can_restart) return; + const body = restart_body(selected.value, form); + if (form.async.value) { + await engine_rest.process_definition.restart_async( + state, + params.definition_id, + body, + ); + } else { + await engine_rest.process_definition.restart( + state, + params.definition_id, + body, + ); + } + selected.value = new Set(); + }; + + return ( +
+
+
+ {t("processes.restart.start-point")} + + {needs_target ? ( + + ) : null} +
+ +
+ {t("processes.restart.options")} + + + + + +
+ + +
+ + null} + on_success={() => { + const result = restart.value?.data; + return result?.id ? ( +

+ {t("processes.restart.batch-created")}{" "} + {result.id} +

+ ) : ( +

{t("processes.restart.success")}

+ ); + }} + /> + + + rows.length === 0 ? ( +

{t("processes.restart.no-instances")}

+ ) : ( +
+ + + + + + + + + + + + + {rows.map((instance) => ( + + + + + + + + + ))} + +
+ 0 && selected.value.size === rows.length + } + onChange={toggle_all} + /> + {t("common.id")}{t("processes.start-time")}{t("processes.restart.end-time")}{t("common.state")}{t("processes.business-key")}
+ toggle_one(instance.id)} + /> + {instance.id.substring(0, 8)} + {instance.startTime ? ( + + ) : ( + "—" + )} + + {instance.endTime ? ( + + ) : ( + "—" + )} + {instance.state}{instance.businessKey}
+
+ ) + } + /> +
+ ); +}; + const InstanceVariables = () => { const state = useContext(AppState), { params, query } = useRoute(), @@ -1046,7 +1342,7 @@ const InstanceVariables = () => { ).map( // eslint-disable-next-line react/jsx-key ([name, { type, value }]) => ( - + {name} {type} {value} @@ -1056,7 +1352,7 @@ const InstanceVariables = () => { : state.api.process.instance.variables.value.data.map( // eslint-disable-next-line react/jsx-key ({ name, type, value }) => ( - + {name} {type} {value} @@ -1464,13 +1760,6 @@ const JobDefinitions = () => { ); }; -const BackToListBtn = ({ url, title, className }) => ( - - - - -); - const DefinitionsManage = () => { const state = useContext(AppState), { route } = useLocation(), @@ -1557,10 +1846,16 @@ const process_definition_tabs = [ pos: 2, Component: CalledProcessDefinitions, }, + { + nameKey: "processes.tabs.restart", + id: "restart", + pos: 3, + Component: RestartProcessInstances, + }, { nameKey: "processes.tabs.jobs", id: "jobs", - pos: 3, + pos: 4, Component: JobDefinitions, }, ]; diff --git a/src/pages/Processes.test.jsx b/src/pages/Processes.test.jsx index 60c4b48..4d6bc41 100644 --- a/src/pages/Processes.test.jsx +++ b/src/pages/Processes.test.jsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { h } from "preact"; -import { render, cleanup, fireEvent } from "@testing-library/preact"; +import { render, cleanup, fireEvent, waitFor } from "@testing-library/preact"; // Spy all engine_rest API functions but keep RequestState/RESPONSE_STATE real. vi.mock("../api/engine_rest.jsx", async (importOriginal) => { @@ -338,6 +338,92 @@ describe("ProcessesPage — definition tabs", () => { expect(link.getAttribute("href")).toBe("/processes/called:1"); }); + it("restart tab fetches completed historic instances", () => { + mockParams = { definition_id: "proc:1", panel: "restart" }; + renderPage(state); + expect(engine_rest.history.process_instance.all).toHaveBeenCalled(); + const call = engine_rest.history.process_instance.all.mock.lastCall; + expect(call[0]).toBe(state); + expect(call[1]).toBe("proc:1"); + expect(call[2]).toMatchObject({ + finished: true, + sortBy: "endTime", + sortOrder: "desc", + }); + }); + + it("restart tab creates an async restart payload from the selected row", async () => { + mockParams = { definition_id: "proc:1", panel: "restart" }; + signal_response(state.api.process.instance.list, [ + { + id: "hist-1", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T01:00:00Z", + state: "COMPLETED", + businessKey: "BK-1", + }, + ]); + engine_rest.process_definition.restart_async.mockResolvedValue(undefined); + + const { container, getByText } = renderPage(state); + const row_box = container.querySelector('tbody input[type="checkbox"]'), + option_boxes = container.querySelectorAll( + '.restart form input[type="checkbox"]', + ); + fireEvent.click(row_box); + fireEvent.change(container.querySelector(".restart select"), { + target: { value: "startAfterActivity" }, + }); + fireEvent.input(container.querySelector('.restart input[type="text"]'), { + target: { value: "UserTask_1" }, + }); + fireEvent.click(option_boxes[1]); + fireEvent.click(getByText("processes.restart.execute-async")); + + await waitFor(() => + expect(engine_rest.process_definition.restart_async).toHaveBeenCalled(), + ); + const call = engine_rest.process_definition.restart_async.mock.lastCall; + expect(call[0]).toBe(state); + expect(call[1]).toBe("proc:1"); + expect(call[2]).toMatchObject({ + instructions: [ + { type: "startAfterActivity", activityId: "UserTask_1" }, + ], + processInstanceIds: ["hist-1"], + initialVariables: true, + }); + }); + + it("restart tab can execute a synchronous default-start restart", async () => { + mockParams = { definition_id: "proc:1", panel: "restart" }; + signal_response(state.api.process.instance.list, [ + { + id: "hist-2", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-01T01:00:00Z", + state: "COMPLETED", + }, + ]); + engine_rest.process_definition.restart.mockResolvedValue(undefined); + + const { container, getByText } = renderPage(state); + const row_box = container.querySelector('tbody input[type="checkbox"]'), + option_boxes = container.querySelectorAll( + '.restart form input[type="checkbox"]', + ); + fireEvent.click(row_box); + fireEvent.click(option_boxes[0]); + fireEvent.click(getByText("processes.restart.execute")); + + await waitFor(() => + expect(engine_rest.process_definition.restart).toHaveBeenCalled(), + ); + const body = engine_rest.process_definition.restart.mock.lastCall[2]; + expect(body.processInstanceIds).toEqual(["hist-2"]); + expect(body.instructions).toBeUndefined(); + }); + it("jobs tab fetches and renders job definitions", () => { mockParams = { definition_id: "proc:1", panel: "jobs" }; signal_response(state.api.job_definition.all.by_process_definition, [ diff --git a/src/state.js b/src/state.js index f63ad2e..e599bf0 100644 --- a/src/state.js +++ b/src/state.js @@ -109,6 +109,7 @@ const createAppState = () => { deployed_start_form: signal(null), rendered_form: signal(null), activity_instance_statistics: signal(null), + restart: signal(null), suspend: signal(null), remove: signal(null), saved_filters: signal(null), diff --git a/src/state.test.js b/src/state.test.js index c6b9968..0bdf983 100644 --- a/src/state.test.js +++ b/src/state.test.js @@ -52,6 +52,7 @@ describe("state", () => { it("mirrors the REST resource structure under api.*", () => { const { api } = createAppState(); expect(api.process.definition.list.value).toBeNull(); + expect(api.process.definition.restart.value).toBeNull(); expect(api.process.instance.one.value).toBeNull(); expect(api.task.comment.list.value).toBeNull(); expect(api.authorization.all.value).toBeNull();