From 780dd6b5b1542b56ccf996a342ce2f830316ac08 Mon Sep 17 00:00:00 2001 From: Julian Haupt Date: Tue, 16 Jun 2026 21:14:22 +0200 Subject: [PATCH] cockpit: add historic instance delete --- public/locales/de-DE/translation.json | 13 +- public/locales/en-US/translation.json | 13 +- src/api/resources/history.js | 8 +- src/api/resources/history.test.js | 17 ++- src/css/components.css | 43 ++++++ src/pages/Processes.jsx | 204 ++++++++++++++++++++++---- src/pages/Processes.test.jsx | 83 +++++++++++ src/state.js | 1 + src/state.test.js | 1 + 9 files changed, 350 insertions(+), 33 deletions(-) diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json index 5f7d6a9..925a3e9 100644 --- a/public/locales/de-DE/translation.json +++ b/public/locales/de-DE/translation.json @@ -388,7 +388,18 @@ "startedAfter": "startedAfter", "finished": "finished" }, - "manage_title": "Prozessinstanz-Filter verwalten" + "manage_title": "Prozessinstanz-Filter verwalten", + "historic-delete": { + "open": "Historie löschen", + "title": "Historische Prozessinstanzen löschen", + "reason": "Löschgrund", + "confirm": "Historie löschen", + "select-all": "Alle historischen Instanzen auswählen", + "count": "{{count}} ausgewählt", + "batch-created": "Batch erstellt:", + "view-batch": "Batch anzeigen", + "success": "Historien-Lösch-Batch gestartet." + } }, "history-mode-na": "Live-Daten – der Verlaufsmodus gilt hier nicht.", "filter": { diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index ac20fbe..afe291d 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -388,7 +388,18 @@ "startedAfter": "startedAfter", "finished": "finished" }, - "manage_title": "Manage process-instance filters" + "manage_title": "Manage process-instance filters", + "historic-delete": { + "open": "Delete history", + "title": "Delete historic process instances", + "reason": "Delete reason", + "confirm": "Delete history", + "select-all": "Select all historic instances", + "count": "{{count}} selected", + "batch-created": "Batch created:", + "view-batch": "View batch", + "success": "History delete batch started." + } }, "history-mode-na": "Live data — history mode does not apply here.", "filter": { diff --git a/src/api/resources/history.js b/src/api/resources/history.js index 6bc1013..8871dd9 100644 --- a/src/api/resources/history.js +++ b/src/api/resources/history.js @@ -1,4 +1,4 @@ -import { GET, PAGINATED_GET } from '../helper.jsx' +import { GET, PAGINATED_GET, POST } from '../helper.jsx' const INSTANCE_PAGE_SIZE = 20 @@ -49,6 +49,9 @@ const get_historic_tasks_by_instance = (state, instance_id) => const get_historic_called_instances = (state, instance_id) => GET(`/history/process-instance?superProcessInstanceId=${instance_id}`, state, state.api.history.process_instance.called) +const delete_historic_process_instances_async = (state, body) => + POST('/history/process-instance/delete', body, state, state.api.history.process_instance.delete_async) + /** * Task History */ @@ -61,6 +64,7 @@ const history = { one: get_process_instance, all_unfinished: get_process_instances_unfinished, called: get_historic_called_instances, + delete_async: delete_historic_process_instances_async, }, incident: { by_process_definition: get_incidents_by_process_definition, @@ -75,4 +79,4 @@ const history = { get_user_operation } -export default history \ No newline at end of file +export default history diff --git a/src/api/resources/history.test.js b/src/api/resources/history.test.js index 12970fd..1175c60 100644 --- a/src/api/resources/history.test.js +++ b/src/api/resources/history.test.js @@ -3,9 +3,10 @@ import { describe, it, vi, beforeEach, expect } from "vitest"; vi.mock("../helper.jsx", () => ({ GET: vi.fn(), PAGINATED_GET: vi.fn(), + POST: vi.fn(), })); -import { GET, PAGINATED_GET } from "../helper.jsx"; +import { GET, PAGINATED_GET, POST } from "../helper.jsx"; import { create_mock_state, expect_api_call } from "../../test/helpers.js"; import history from "./history.js"; @@ -118,4 +119,18 @@ describe("api/resources/history", () => { signal: state.api.history.process_instance.called, }); }); + + it("process_instance.delete_async() POSTs selected historic instances", () => { + const body = { + historicProcessInstanceIds: ["hist-1", "hist-2"], + deleteReason: "retention", + }; + history.process_instance.delete_async(state, body); + expect_api_call(POST, { + url: "/history/process-instance/delete", + body, + state, + signal: state.api.history.process_instance.delete_async, + }); + }); }); diff --git a/src/css/components.css b/src/css/components.css index 5bed3e4..f946155 100644 --- a/src/css/components.css +++ b/src/css/components.css @@ -869,6 +869,49 @@ div.accordion { color: var(--text-2); } +/* historic process instance bulk actions */ + +.historic-instance-delete { + margin-bottom: var(--spacing-2); + padding: var(--spacing-1); + border: var(--border); + border-radius: var(--border-radius); + background: var(--background-1); +} + +.historic-instance-delete fieldset { + display: grid; + grid-template-columns: minmax(14rem, 1fr) max-content; + gap: var(--spacing-1); + align-items: end; +} + +.historic-instance-delete legend { + grid-column: 1 / -1; +} + +.historic-instance-delete label { + display: flex; + flex-direction: column; + gap: var(--spacing-half); +} + +.historic-instance-delete .button-group { + display: flex; + gap: var(--spacing-1); +} + +.historic-instance-delete .info-box { + grid-column: 1 / -1; + margin-top: var(--spacing-1); +} + +@media (max-width: 720px) { + .historic-instance-delete fieldset { + grid-template-columns: 1fr; + } +} + /* task detail cards */ .task-cards { diff --git a/src/pages/Processes.jsx b/src/pages/Processes.jsx index d6ac79e..03217f3 100644 --- a/src/pages/Processes.jsx +++ b/src/pages/Processes.jsx @@ -61,6 +61,16 @@ const instance_params_from_query = (state, query) => { }; }; +const historic_process_instance_delete_body = (form, historicProcessInstanceIds) => { + const data = new FormData(form), + deleteReason = String(data.get("deleteReason") ?? "").trim(); + + return { + historicProcessInstanceIds, + ...(deleteReason ? { deleteReason } : {}), + }; +}; + const SORT_OPTIONS = [ { key: "name", nameKey: "processes.sort.name" }, { key: "key", nameKey: "processes.sort.key" }, @@ -686,7 +696,7 @@ const DefinitionsEmpty = () => { {t("processes.empty.how-to")} @@ -817,7 +827,10 @@ const Instances = () => { { route } = useLocation(), [t] = useTranslation(), history_mode = query.history === "true", - list = state.api.process.instance.list; + list = state.api.process.instance.list, + delete_result = state.api.history.process_instance.delete_async, + selected = useSignal(new Set()), + delete_open = useSignal(false); useEffect(() => { hydrate_signal( @@ -857,6 +870,9 @@ const Instances = () => { .join("&"); useEffect(() => { + selected.value = new Set(); + delete_open.value = false; + delete_result.value = null; if (!params.selection_id) fetch_page(0); // eslint-disable-next-line react-hooks/exhaustive-deps }, [params.definition_id, params.selection_id, history_mode, criteria_signature]); @@ -879,6 +895,51 @@ const Instances = () => { return ; } + const rows = list.value?.data ?? []; + const selected_ids = [...selected.value]; + const has_selection = selected_ids.length > 0; + const all_selected = + rows.length > 0 && rows.every((row) => selected.value.has(row.id)); + const delete_loading = delete_result.value?.status === RESPONSE_STATE.LOADING; + + const toggle_instance = (id) => { + const next = new Set(selected.value); + if (next.has(id)) next.delete(id); + else next.add(id); + selected.value = next; + }; + + const toggle_all_instances = () => { + const next = new Set(selected.value); + if (all_selected) rows.forEach((row) => next.delete(row.id)); + else rows.forEach((row) => next.add(row.id)); + selected.value = next; + }; + + const open_delete = () => { + if (!has_selection) return; + delete_result.value = null; + delete_open.value = true; + }; + + const cancel_delete = () => { + delete_open.value = false; + delete_result.value = null; + }; + + const submit_delete = async (event) => { + event.preventDefault(); + if (!has_selection || delete_loading) return; + + const body = historic_process_instance_delete_body( + event.currentTarget, + selected_ids, + ); + await engine_rest.history.process_instance.delete_async(state, body); + selected.value = new Set(); + fetch_page(0); + }; + return !params?.selection_id ? ( <> { on_change={apply_patch} on_manage={open_manage} /> + {history_mode ? ( +
+
+ + {has_selection ? ( + + {t("processes.instance.historic-delete.count", { + count: selected_ids.length, + })} + + ) : null} +
+
+ ) : null} + {delete_open.value ? ( +
+
+ {t("processes.instance.historic-delete.title")} + +
+ + +
+
+ null} + on_success={() => { + const batch = delete_result.value?.data; + return batch?.id ? ( +

+ {t("processes.instance.historic-delete.batch-created")}{" "} + + {t("processes.instance.historic-delete.view-batch")} + +

+ ) : ( +

+ {t("processes.instance.historic-delete.success")} +

+ ); + }} + /> + + ) : null}
+ {history_mode ? ( + + ) : null} @@ -900,7 +1030,11 @@ const Instances = () => { - +
+ + {t("common.id")} {t("processes.start-time")} {t("common.state")}
@@ -917,9 +1051,17 @@ const Instances = () => { ); }; -const InstanceTableRows = () => +const InstanceTableRows = ({ selectable = false, selected, on_toggle }) => useContext(AppState).api.process.instance.list.value?.data?.map( - (instance) => , + (instance) => ( + on_toggle?.(instance.id)} + {...instance} + /> + ), ) ?? null; const InstanceDetails = () => { @@ -928,8 +1070,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 ( @@ -983,10 +1124,28 @@ const InstanceDetailsDescription = () => { ); }; -const ProcessInstance = ({ id, startTime, state, businessKey }) => { +const ProcessInstance = ({ + id, + startTime, + state, + businessKey, + selectable = false, + checked = false, + on_toggle, +}) => { const { params, query } = useRoute(); return ( - + + {selectable ? ( + e.stopPropagation()}> + + + ) : null} { ? !history_mode ? Object.entries( state.api.process.instance.variables.value.data, - ).map( - // eslint-disable-next-line react/jsx-key - ([name, { type, value }]) => ( - - {name} - {type} - {value} - - ), - ) + ).map(([name, { type, value }]) => ( + + {name} + {type} + {value} + + )) : state.api.process.instance.variables.value.data.map( - // eslint-disable-next-line react/jsx-key ({ name, type, value }) => ( - + {name} {type} {value} @@ -1464,13 +1619,6 @@ const JobDefinitions = () => { ); }; -const BackToListBtn = ({ url, title, className }) => ( - - - - -); - const DefinitionsManage = () => { const state = useContext(AppState), { route } = useLocation(), diff --git a/src/pages/Processes.test.jsx b/src/pages/Processes.test.jsx index 60c4b48..0e384cd 100644 --- a/src/pages/Processes.test.jsx +++ b/src/pages/Processes.test.jsx @@ -304,6 +304,89 @@ describe("ProcessesPage — definition tabs", () => { expect(getByText("BK-1")).toBeTruthy(); }); + it("instances tab hides historic delete in live mode", () => { + mockParams = { definition_id: "proc:1", panel: "instances" }; + signal_response(state.api.process.instance.list, [ + { + id: "abcdef1234567890", + startTime: "2024-01-01T00:00:00Z", + state: "ACTIVE", + businessKey: "BK-1", + }, + ]); + const { container, queryByText } = renderPage(state); + expect(queryByText("processes.instance.historic-delete.open")).toBeNull(); + expect(container.querySelector('tbody input[type="checkbox"]')).toBeNull(); + }); + + it("instances tab disables historic delete until an instance is selected", () => { + mockParams = { definition_id: "proc:1", panel: "instances" }; + mockQuery = { history: "true" }; + signal_response(state.api.process.instance.list, [ + { + id: "abcdef1234567890", + startTime: "2024-01-01T00:00:00Z", + state: "COMPLETED", + businessKey: "BK-1", + }, + ]); + const { container, getByText } = renderPage(state); + const open_delete = getByText("processes.instance.historic-delete.open"); + expect(open_delete.disabled).toBe(true); + fireEvent.click(container.querySelector('tbody input[type="checkbox"]')); + expect(open_delete.disabled).toBe(false); + }); + + it("instances tab sends selected historic instances to the async delete endpoint", async () => { + mockParams = { definition_id: "proc:1", panel: "instances" }; + mockQuery = { history: "true" }; + signal_response(state.api.process.instance.list, [ + { + id: "abcdef1234567890", + startTime: "2024-01-01T00:00:00Z", + state: "COMPLETED", + businessKey: "BK-1", + }, + { + id: "fedcba0987654321", + startTime: "2024-01-02T00:00:00Z", + state: "COMPLETED", + businessKey: "BK-2", + }, + ]); + engine_rest.history.process_instance.delete_async.mockImplementationOnce( + async (next_state) => { + signal_response(next_state.api.history.process_instance.delete_async, { + id: "batch-1", + }); + }, + ); + + const { container, getByText } = renderPage(state); + fireEvent.click(container.querySelector('tbody input[type="checkbox"]')); + fireEvent.click(getByText("processes.instance.historic-delete.open")); + fireEvent.input(container.querySelector('input[name="deleteReason"]'), { + target: { value: "retention" }, + }); + fireEvent.submit(container.querySelector(".historic-instance-delete")); + + await Promise.resolve(); + await Promise.resolve(); + + expect(engine_rest.history.process_instance.delete_async).toHaveBeenCalled(); + const call = engine_rest.history.process_instance.delete_async.mock.lastCall; + expect(call[0]).toBe(state); + expect(call[1]).toEqual({ + historicProcessInstanceIds: ["abcdef1234567890"], + deleteReason: "retention", + }); + expect( + getByText("processes.instance.historic-delete.view-batch").getAttribute( + "href", + ), + ).toBe("/batches/batch-1"); + }); + it("incidents tab fetches and renders definition incidents", () => { mockParams = { definition_id: "proc:1", panel: "incidents" }; signal_response(state.api.history.incident.by_process_definition, [ diff --git a/src/state.js b/src/state.js index f63ad2e..6b7c88e 100644 --- a/src/state.js +++ b/src/state.js @@ -176,6 +176,7 @@ const createAppState = () => { }, process_instance: { called: signal(null), + delete_async: signal(null), }, user_operation: signal(null), }, diff --git a/src/state.test.js b/src/state.test.js index c6b9968..5287422 100644 --- a/src/state.test.js +++ b/src/state.test.js @@ -53,6 +53,7 @@ describe("state", () => { const { api } = createAppState(); expect(api.process.definition.list.value).toBeNull(); expect(api.process.instance.one.value).toBeNull(); + expect(api.history.process_instance.delete_async.value).toBeNull(); expect(api.task.comment.list.value).toBeNull(); expect(api.authorization.all.value).toBeNull(); expect(api.batch.list.value).toBeNull();