From ea19c521750d46687f28363486e016ce80662deb Mon Sep 17 00:00:00 2001 From: Julian Haupt Date: Tue, 16 Jun 2026 21:21:53 +0200 Subject: [PATCH] cockpit: add decision instance history --- public/locales/de-DE/translation.json | 9 +++ public/locales/en-US/translation.json | 9 +++ src/api/resources/history.js | 19 ++++- src/api/resources/history.test.js | 11 +++ src/pages/Decisions.jsx | 107 +++++++++++++++++++++++++- src/pages/Decisions.test.jsx | 63 ++++++++++++++- src/state.js | 3 + src/state.test.js | 1 + 8 files changed, 216 insertions(+), 6 deletions(-) diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json index 5f7d6a9..2454b32 100644 --- a/public/locales/de-DE/translation.json +++ b/public/locales/de-DE/translation.json @@ -425,6 +425,15 @@ "latestVersion": "latestVersion", "decisionRequirementsDefinitionKey": "decisionRequirementsDefinitionKey" }, + "instances": { + "title": "Decision-Instanzen", + "refresh": "Aktualisieren", + "empty": "Keine Decision-Instanzen gefunden.", + "id": "Decision-Instanz-ID", + "evaluation-time": "Auswertungszeit", + "process-instance": "Prozessinstanz", + "activity": "Aktivität" + }, "filter": { "manage_title": "Decision-Filter verwalten" } diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index ac20fbe..750c3e1 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -425,6 +425,15 @@ "latestVersion": "latestVersion", "decisionRequirementsDefinitionKey": "decisionRequirementsDefinitionKey" }, + "instances": { + "title": "Decision Instances", + "refresh": "Refresh", + "empty": "No decision instances found.", + "id": "Decision Instance ID", + "evaluation-time": "Evaluation Time", + "process-instance": "Process Instance", + "activity": "Activity" + }, "filter": { "manage_title": "Manage decision-definition filters" } diff --git a/src/api/resources/history.js b/src/api/resources/history.js index 6bc1013..199bc17 100644 --- a/src/api/resources/history.js +++ b/src/api/resources/history.js @@ -1,6 +1,7 @@ import { GET, PAGINATED_GET } from '../helper.jsx' const INSTANCE_PAGE_SIZE = 20 +const DECISION_INSTANCE_PAGE_SIZE = 20 const instance_url = (definition_id, params = {}, { unfinished = false } = {}) => { const merged = { @@ -49,6 +50,19 @@ 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 get_decision_instances_by_definition = (state, definition_id, firstResult = 0) => + PAGINATED_GET( + `/history/decision-instance?${new URLSearchParams({ + decisionDefinitionId: definition_id, + sortBy: 'evaluationTime', + sortOrder: 'desc', + }).toString()}`, + state, + state.api.history.decision_instance.list, + firstResult, + DECISION_INSTANCE_PAGE_SIZE, + ) + /** * Task History */ @@ -72,7 +86,10 @@ const history = { task: { by_process_instance: get_historic_tasks_by_instance, }, + decision_instance: { + by_decision_definition: get_decision_instances_by_definition, + }, 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..bea8a7a 100644 --- a/src/api/resources/history.test.js +++ b/src/api/resources/history.test.js @@ -118,4 +118,15 @@ describe("api/resources/history", () => { signal: state.api.history.process_instance.called, }); }); + + it("decision_instance.by_decision_definition() PAGINATED_GETs historic decision instances", () => { + history.decision_instance.by_decision_definition(state, "decision-1", 20); + expect_api_call(PAGINATED_GET, { + url: "/history/decision-instance?decisionDefinitionId=decision-1&sortBy=evaluationTime&sortOrder=desc", + state, + signal: state.api.history.decision_instance.list, + }); + expect(PAGINATED_GET.mock.lastCall[3]).toBe(20); + expect(PAGINATED_GET.mock.lastCall[4]).toBe(20); + }); }); diff --git a/src/pages/Decisions.jsx b/src/pages/Decisions.jsx index 9a73c30..f0f5b2a 100644 --- a/src/pages/Decisions.jsx +++ b/src/pages/Decisions.jsx @@ -61,9 +61,18 @@ const load_decisions = (state, query) => { void engine_rest.decision.get_decision_definitions(state, params) } +const load_decision_instances = (state, decision_id, firstResult = 0) => { + if (!decision_id) return + void engine_rest.history.decision_instance.by_decision_definition( + state, + decision_id, + firstResult, + ) +} + const DecisionsPage = () => { const state = useContext(AppState), - { api: { decision: { definition, dmn } } } = state, + { api: { decision: { definition, dmn }, history: { decision_instance } } } = state, { params: { decision_id }, query } = useRoute() useEffect(() => { @@ -80,12 +89,14 @@ const DecisionsPage = () => { if (decision_id) { void engine_rest.decision.get_decision_definition(state, decision_id) void engine_rest.decision.get_dmn_xml(state, decision_id) + load_decision_instances(state, decision_id) } // Clear stale per-decision data so navigating between decisions doesn't - // render the previous decision's metadata or DMN diagram briefly. + // render the previous decision's metadata, DMN diagram or instances briefly. return () => { definition.value = null dmn.value = null + decision_instance.list.value = null } // eslint-disable-next-line react-hooks/exhaustive-deps }, [decision_id]) @@ -182,7 +193,6 @@ const DecisionDetails = () => { const { id, key, name, version, versionTag, tenantId, deploymentId, decisionRequirementsDefinitionId, historyTimeToLive, - resource } = definition.value.data return
@@ -217,9 +227,100 @@ const DecisionDetails = () => { signal={dmn} on_nothing={() =>

{t("decisions.select-diagram")}

} on_success={() => } /> + + {decision_id ? : null}
} +const DecisionInstances = ({ decision_id }) => { + const state = useContext(AppState), + [t] = useTranslation(), + list = state.api.history.decision_instance.list + + const load_more = () => + load_decision_instances(state, decision_id, list.value?.data?.length ?? 0) + + const process_instance_link = (instance) => + instance.processDefinitionId && instance.processInstanceId + ? `/processes/${instance.processDefinitionId}/instances/${instance.processInstanceId}/vars?history=true` + : null + + const formatted_time = (value) => + value ? new Date(Date.parse(value)).toLocaleString() : '—' + + return ( +
+
+

{t("decisions.instances.title")}

+ +
+ { + const rows = list.value?.data ?? [] + if (rows.length === 0) { + return

{t("decisions.instances.empty")}

+ } + return ( + <> + + + + + + + + + + + + {rows.map((instance) => { + const process_link = process_instance_link(instance) + return ( + + + + + + + + ) + })} + +
{t("decisions.instances.id")}{t("decisions.instances.evaluation-time")}{t("decisions.instances.process-instance")}{t("decisions.instances.activity")}{t("processes.tenant-id")}
{instance.id?.substring(0, 8)} + + + {process_link ? ( + + {instance.processInstanceId.substring(0, 8)} + + ) : ( + instance.processInstanceId ?? '—' + )} + {instance.activityId ?? '—'}{instance.tenantId ?? '—'}
+ {list.value?.hasMore === true ? ( + + ) : list.value?.hasMore === false ? ( + {t("tasks.no-more-items")} + ) : null} + + ) + }} + /> +
+ ) +} + const DecisionsManage = () => { const state = useContext(AppState), { route } = useLocation(), diff --git a/src/pages/Decisions.test.jsx b/src/pages/Decisions.test.jsx index 9ac0a45..e3bc07b 100644 --- a/src/pages/Decisions.test.jsx +++ b/src/pages/Decisions.test.jsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { h } from "preact"; -import { render, cleanup } from "@testing-library/preact"; +import { render, cleanup, fireEvent } from "@testing-library/preact"; // Spy all engine_rest API functions but keep RequestState/RESPONSE_STATE real. vi.mock("../api/engine_rest.jsx", async (importOriginal) => { @@ -60,11 +60,14 @@ describe("DecisionsPage", () => { expect(link.getAttribute("href")).toBe("/decisions/d1"); }); - it("fetches the selected decision + DMN xml when a decision_id is in the route", () => { + it("fetches the selected decision, DMN xml and decision instances when a decision_id is in the route", () => { mockParams = { decision_id: "d1" }; renderPage(state); expect(engine_rest.decision.get_decision_definition).toHaveBeenCalled(); expect(engine_rest.decision.get_dmn_xml).toHaveBeenCalled(); + expect( + engine_rest.history.decision_instance.by_decision_definition, + ).toHaveBeenCalled(); }); it("renders the DMN viewer with the fetched xml", () => { @@ -79,4 +82,60 @@ describe("DecisionsPage", () => { const { getByTestId } = renderPage(state); expect(getByTestId("dmn-viewer").textContent).toBe("xml"); }); + + it("renders decision instances for the selected decision", () => { + mockParams = { decision_id: "d1" }; + signal_response(state.api.decision.definition, { + id: "d1", + key: "risk", + name: "Risk", + version: 1, + }); + signal_response(state.api.decision.dmn, { dmnXml: "xml" }); + signal_response(state.api.history.decision_instance.list, [ + { + id: "decision-instance-1", + evaluationTime: "2024-01-01T10:00:00Z", + processDefinitionId: "proc:1", + processInstanceId: "abcdef1234567890", + activityId: "BusinessRuleTask_1", + tenantId: "tenant-a", + }, + ]); + + const { getByText } = renderPage(state); + expect(getByText("decision")).toBeTruthy(); + const process = getByText("abcdef12"); + expect(process.getAttribute("href")).toBe( + "/processes/proc:1/instances/abcdef1234567890/vars?history=true", + ); + expect(getByText("BusinessRuleTask_1")).toBeTruthy(); + expect(getByText("tenant-a")).toBeTruthy(); + }); + + it("loads more decision instances from the current result length", () => { + mockParams = { decision_id: "d1" }; + signal_response(state.api.decision.definition, { + id: "d1", + key: "risk", + name: "Risk", + version: 1, + }); + signal_response(state.api.decision.dmn, { dmnXml: "xml" }); + signal_response(state.api.history.decision_instance.list, [ + { id: "di-1", evaluationTime: "2024-01-01T10:00:00Z" }, + { id: "di-2", evaluationTime: "2024-01-02T10:00:00Z" }, + ]); + state.api.history.decision_instance.list.value = { + ...state.api.history.decision_instance.list.value, + hasMore: true, + }; + + const { getByText } = renderPage(state); + fireEvent.click(getByText("tasks.load-more")); + expect( + engine_rest.history.decision_instance.by_decision_definition.mock + .lastCall[2], + ).toBe(2); + }); }); diff --git a/src/state.js b/src/state.js index f63ad2e..8aa0cc1 100644 --- a/src/state.js +++ b/src/state.js @@ -177,6 +177,9 @@ const createAppState = () => { process_instance: { called: signal(null), }, + decision_instance: { + list: signal(null), + }, user_operation: signal(null), }, job_definition: { diff --git a/src/state.test.js b/src/state.test.js index c6b9968..aed8076 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.decision_instance.list.value).toBeNull(); expect(api.task.comment.list.value).toBeNull(); expect(api.authorization.all.value).toBeNull(); expect(api.batch.list.value).toBeNull();