diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json
index 5f7d6a9..99e9b9e 100644
--- a/public/locales/de-DE/translation.json
+++ b/public/locales/de-DE/translation.json
@@ -324,7 +324,10 @@
"cause-process-instance-id": "Verursachende Prozessinstanz-ID",
"root-cause-process-instance-id": "Ursprüngliche Prozessinstanz-ID",
"annotation": "Anmerkung",
- "configuration": "Konfiguration"
+ "configuration": "Konfiguration",
+ "annotation-placeholder": "Anmerkung hinzufügen",
+ "save-annotation": "Speichern",
+ "clear-annotation": "Leeren"
},
"user-tasks": {
"owner": "Besitzer",
diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json
index ac20fbe..e50e46f 100644
--- a/public/locales/en-US/translation.json
+++ b/public/locales/en-US/translation.json
@@ -324,7 +324,10 @@
"cause-process-instance-id": "Cause Process Instance ID",
"root-cause-process-instance-id": "Root Cause Process Instance ID",
"annotation": "Annotation",
- "configuration": "Configuration"
+ "configuration": "Configuration",
+ "annotation-placeholder": "Add annotation",
+ "save-annotation": "Save",
+ "clear-annotation": "Clear"
},
"user-tasks": {
"owner": "Owner",
diff --git a/public/locales/es-ES/translation.json b/public/locales/es-ES/translation.json
index 121792c..41691cf 100644
--- a/public/locales/es-ES/translation.json
+++ b/public/locales/es-ES/translation.json
@@ -291,7 +291,10 @@
"cause-process-instance-id": "ID de instancia de proceso causa",
"root-cause-process-instance-id": "ID de instancia de proceso causa raíz",
"annotation": "Anotación",
- "configuration": "Configuración"
+ "configuration": "Configuración",
+ "annotation-placeholder": "Añadir anotación",
+ "save-annotation": "Guardar",
+ "clear-annotation": "Borrar"
},
"user-tasks": {
"owner": "Propietario",
diff --git a/public/locales/fr-FR/translation.json b/public/locales/fr-FR/translation.json
index 43e715f..44ffd23 100644
--- a/public/locales/fr-FR/translation.json
+++ b/public/locales/fr-FR/translation.json
@@ -291,7 +291,10 @@
"cause-process-instance-id": "ID d'instance de processus cause",
"root-cause-process-instance-id": "ID d'instance de processus cause racine",
"annotation": "Annotation",
- "configuration": "Configuration"
+ "configuration": "Configuration",
+ "annotation-placeholder": "Ajouter une annotation",
+ "save-annotation": "Enregistrer",
+ "clear-annotation": "Effacer"
},
"user-tasks": {
"owner": "Propriétaire",
diff --git a/public/locales/nl-NL/translation.json b/public/locales/nl-NL/translation.json
index a27f3a1..7f50375 100644
--- a/public/locales/nl-NL/translation.json
+++ b/public/locales/nl-NL/translation.json
@@ -291,7 +291,10 @@
"cause-process-instance-id": "Oorzaak procesinstantie-ID",
"root-cause-process-instance-id": "Hoofdoorzaak procesinstantie-ID",
"annotation": "Annotatie",
- "configuration": "Configuratie"
+ "configuration": "Configuratie",
+ "annotation-placeholder": "Annotatie toevoegen",
+ "save-annotation": "Opslaan",
+ "clear-annotation": "Wissen"
},
"user-tasks": {
"owner": "Eigenaar",
diff --git a/src/api/engine_rest.jsx b/src/api/engine_rest.jsx
index b1df2be..ed97d71 100644
--- a/src/api/engine_rest.jsx
+++ b/src/api/engine_rest.jsx
@@ -12,6 +12,7 @@ import process_definition from "./resources/process_definition.js";
import process_instance from "./resources/process_instance.js";
import deployment from "./resources/deployment.js";
import history from "./resources/history.js";
+import incident from "./resources/incident.js";
import job_definition from "./resources/job_definition.js";
import migration from "./resources/migration.js";
import task from "./resources/task.js";
@@ -29,6 +30,7 @@ const engine_rest = {
filter,
group,
history,
+ incident,
job_definition,
migration,
process_definition,
diff --git a/src/api/resources/incident.js b/src/api/resources/incident.js
new file mode 100644
index 0000000..854a114
--- /dev/null
+++ b/src/api/resources/incident.js
@@ -0,0 +1,40 @@
+import { DELETE, GET, PUT } from "../helper.jsx";
+
+const get_incidents_by_process_definition = (state, definition_id) =>
+ GET(
+ `/incident?processDefinitionId=${definition_id}`,
+ state,
+ state.api.incident.by_process_definition,
+ );
+
+const get_incidents_by_process_instance = (state, instance_id) =>
+ GET(
+ `/incident?processInstanceId=${instance_id}`,
+ state,
+ state.api.incident.by_process_instance,
+ );
+
+const set_annotation = (state, id, annotation) =>
+ PUT(
+ `/incident/${id}/annotation`,
+ { annotation },
+ state,
+ state.api.incident.annotation,
+ );
+
+const clear_annotation = (state, id) =>
+ DELETE(
+ `/incident/${id}/annotation`,
+ null,
+ state,
+ state.api.incident.annotation,
+ );
+
+const incident = {
+ by_process_definition: get_incidents_by_process_definition,
+ by_process_instance: get_incidents_by_process_instance,
+ set_annotation,
+ clear_annotation,
+};
+
+export default incident;
diff --git a/src/api/resources/incident.test.js b/src/api/resources/incident.test.js
new file mode 100644
index 0000000..a12cf87
--- /dev/null
+++ b/src/api/resources/incident.test.js
@@ -0,0 +1,56 @@
+import { describe, it, vi, beforeEach } from "vitest";
+
+vi.mock("../helper.jsx", () => ({
+ DELETE: vi.fn(),
+ GET: vi.fn(),
+ PUT: vi.fn(),
+}));
+
+import { DELETE, GET, PUT } from "../helper.jsx";
+import { create_mock_state, expect_api_call } from "../../test/helpers.js";
+import incident from "./incident.js";
+
+describe("api/resources/incident", () => {
+ let state;
+ beforeEach(() => {
+ state = create_mock_state();
+ });
+
+ it("by_process_definition() GETs runtime incidents filtered by definition", () => {
+ incident.by_process_definition(state, "def-1");
+ expect_api_call(GET, {
+ url: "/incident?processDefinitionId=def-1",
+ state,
+ signal: state.api.incident.by_process_definition,
+ });
+ });
+
+ it("by_process_instance() GETs runtime incidents filtered by instance", () => {
+ incident.by_process_instance(state, "inst-1");
+ expect_api_call(GET, {
+ url: "/incident?processInstanceId=inst-1",
+ state,
+ signal: state.api.incident.by_process_instance,
+ });
+ });
+
+ it("set_annotation() PUTs the annotation body", () => {
+ incident.set_annotation(state, "inc-1", "Checked by ops");
+ expect_api_call(PUT, {
+ url: "/incident/inc-1/annotation",
+ body: { annotation: "Checked by ops" },
+ state,
+ signal: state.api.incident.annotation,
+ });
+ });
+
+ it("clear_annotation() DELETEs the annotation", () => {
+ incident.clear_annotation(state, "inc-1");
+ expect_api_call(DELETE, {
+ url: "/incident/inc-1/annotation",
+ body: null,
+ state,
+ signal: state.api.incident.annotation,
+ });
+ });
+});
diff --git a/src/css/components.css b/src/css/components.css
index 5bed3e4..b9e1c7c 100644
--- a/src/css/components.css
+++ b/src/css/components.css
@@ -533,6 +533,23 @@ main#processes {
max-width: 40%;
}
+#processes form.incident-annotation {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-1);
+ margin: 0;
+}
+
+#processes form.incident-annotation input {
+ min-width: 14rem;
+}
+
+#processes form.incident-annotation .button-group {
+ grid-column: auto;
+ justify-content: flex-start;
+ flex-wrap: nowrap;
+}
+
/* Definition / instance metadata renders as a
grid. */
#processes dl {
display: grid;
diff --git a/src/pages/Processes.jsx b/src/pages/Processes.jsx
index d6ac79e..5e5fc4b 100644
--- a/src/pages/Processes.jsx
+++ b/src/pages/Processes.jsx
@@ -167,6 +167,9 @@ const ProcessesPage = () => {
state.api.process.instance.list.value = null;
state.api.process.instance.one.value = null;
state.api.process.instance.activity_instances.value = null;
+ state.api.incident.by_process_definition.value = null;
+ state.api.incident.by_process_instance.value = null;
+ state.api.incident.annotation.value = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.definition_id]);
@@ -686,7 +689,7 @@ const DefinitionsEmpty = () => {
{t("processes.empty.how-to")}
@@ -928,8 +931,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 +1007,83 @@ const ProcessInstance = ({ id, startTime, state, businessKey }) => {
);
};
+const refetch_definition_incidents = (state, definition_id, history_mode) =>
+ history_mode
+ ? engine_rest.history.incident.by_process_definition(state, definition_id)
+ : engine_rest.incident.by_process_definition(state, definition_id);
+
+const refetch_instance_incidents = (state, instance_id, history_mode) =>
+ history_mode
+ ? engine_rest.history.incident.by_process_instance(state, instance_id)
+ : engine_rest.incident.by_process_instance(state, instance_id);
+
+const IncidentAnnotationActions = ({ incident, readonly, on_refresh }) => {
+ const state = useContext(AppState),
+ [t] = useTranslation(),
+ annotation = useSignal(incident.annotation ?? ""),
+ saving = useSignal(false);
+
+ useEffect(() => {
+ annotation.value = incident.annotation ?? "";
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [incident.id, incident.annotation]);
+
+ const submit = async (event) => {
+ event.preventDefault();
+ saving.value = true;
+ try {
+ await engine_rest.incident.set_annotation(
+ state,
+ incident.id,
+ annotation.value,
+ );
+ await on_refresh?.();
+ } finally {
+ saving.value = false;
+ }
+ },
+ clear = async () => {
+ saving.value = true;
+ try {
+ await engine_rest.incident.clear_annotation(state, incident.id);
+ annotation.value = "";
+ await on_refresh?.();
+ } finally {
+ saving.value = false;
+ }
+ };
+
+ if (readonly) return annotation.value || "—";
+
+ return (
+
+ );
+};
+
const InstanceVariables = () => {
const state = useContext(AppState),
{ params, query } = useRoute(),
@@ -1044,9 +1123,8 @@ const InstanceVariables = () => {
? Object.entries(
state.api.process.instance.variables.value.data,
).map(
- // eslint-disable-next-line react/jsx-key
([name, { type, value }]) => (
-
+
| {name} |
{type} |
{value} |
@@ -1054,9 +1132,8 @@ const InstanceVariables = () => {
),
)
: state.api.process.instance.variables.value.data.map(
- // eslint-disable-next-line react/jsx-key
({ name, type, value }) => (
-
+
| {name} |
{type} |
{value} |
@@ -1074,16 +1151,13 @@ const InstanceIncidents = () => {
const state = useContext(AppState),
{ params, query } = useRoute(),
history_mode = query.history === "true",
- [t] = useTranslation();
+ [t] = useTranslation(),
+ incidents = history_mode
+ ? state.api.history.incident.by_process_instance
+ : state.api.incident.by_process_instance;
- // The incident endpoint is history-only in Camunda 7, but timestamp filters
- // applied via history mode (e.g. closed instance window) can change the
- // returned set — re-fetch on toggle.
useEffect(() => {
- void engine_rest.history.incident.by_process_instance(
- state,
- params.selection_id,
- );
+ refetch_instance_incidents(state, params.selection_id, history_mode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.selection_id, history_mode]);
@@ -1102,16 +1176,16 @@ const InstanceIncidents = () => {
{t("processes.incidents.root-cause-process-instance-id")} |
{t("common.type")} |
{t("processes.incidents.annotation")} |
- {t("common.action")} |
- {state.api.history.incident.by_process_instance.value?.data?.map(
+ {incidents.value?.data?.map(
// eslint-disable-next-line react/jsx-key
({
id,
incidentMessage,
processInstanceId,
+ incidentTimestamp,
createTime,
activityId,
failedActivityId,
@@ -1126,8 +1200,10 @@ const InstanceIncidents = () => {
- |
{activityId} |
@@ -1139,7 +1215,19 @@ const InstanceIncidents = () => {
{incidentType} |
- {annotation} |
+
+
+ refetch_instance_incidents(
+ state,
+ params.selection_id,
+ history_mode,
+ )
+ }
+ />
+ |
),
)}
@@ -1311,16 +1399,20 @@ const CalledProcessInstances = () => {
const Incidents = () => {
const state = useContext(AppState),
- { definition_id } = useRoute(),
- [t] = useTranslation();
+ {
+ params: { definition_id },
+ query,
+ } = useRoute(),
+ history_mode = query.history === "true",
+ [t] = useTranslation(),
+ incidents = history_mode
+ ? state.api.history.incident.by_process_definition
+ : state.api.incident.by_process_definition;
useEffect(() => {
- void engine_rest.history.incident.by_process_definition(
- state,
- definition_id,
- );
+ refetch_definition_incidents(state, definition_id, history_mode);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [definition_id]);
+ }, [definition_id, history_mode]);
/** @namespace instance.incidentMessage **/
/** @namespace instance.incidentType **/
@@ -1332,18 +1424,30 @@ const Incidents = () => {
{t("processes.incidents.message")} |
{t("common.type")} |
{t("processes.incidents.configuration")} |
+ {t("processes.incidents.annotation")} |
- {state.api.history.incident.by_process_definition.value?.data?.map(
- (incident) => (
-
- | {incident.incidentMessage} |
- {incident.incidentType} |
- {incident.configuration} |
-
- ),
- )}
+ {incidents.value?.data?.map((incident) => (
+
+ | {incident.incidentMessage} |
+ {incident.incidentType} |
+ {incident.configuration} |
+
+
+ refetch_definition_incidents(
+ state,
+ definition_id,
+ history_mode,
+ )
+ }
+ />
+ |
+
+ ))}
@@ -1464,13 +1568,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..9060940 100644
--- a/src/pages/Processes.test.jsx
+++ b/src/pages/Processes.test.jsx
@@ -282,7 +282,8 @@ describe("ProcessesPage — definition tabs", () => {
mockQuery = { history: "true", "q.finished": "true" };
renderPage(state);
expect(engine_rest.history.process_instance.all).toHaveBeenCalled();
- const params_arg = engine_rest.history.process_instance.all.mock.lastCall[2];
+ const params_arg =
+ engine_rest.history.process_instance.all.mock.lastCall[2];
expect(params_arg.finished).toBe("true");
});
@@ -304,22 +305,93 @@ describe("ProcessesPage — definition tabs", () => {
expect(getByText("BK-1")).toBeTruthy();
});
- it("incidents tab fetches and renders definition incidents", () => {
+ it("incidents tab fetches and renders live definition incidents", () => {
mockParams = { definition_id: "proc:1", panel: "incidents" };
- signal_response(state.api.history.incident.by_process_definition, [
+ signal_response(state.api.incident.by_process_definition, [
{
id: "inc1",
incidentMessage: "boom",
incidentType: "failedJob",
configuration: "cfg",
+ annotation: "watching",
},
]);
- const { getByText } = renderPage(state);
+ const { getByDisplayValue, getByText } = renderPage(state);
+ expect(engine_rest.incident.by_process_definition).toHaveBeenCalled();
+ expect(getByText("boom")).toBeTruthy();
+ expect(getByText("cfg")).toBeTruthy();
+ expect(getByDisplayValue("watching")).toBeTruthy();
+ });
+
+ it("incidents tab uses history incidents read-only in history mode", () => {
+ mockParams = { definition_id: "proc:1", panel: "incidents" };
+ mockQuery = { history: "true" };
+ signal_response(state.api.history.incident.by_process_definition, [
+ {
+ id: "inc1",
+ incidentMessage: "historic boom",
+ incidentType: "failedJob",
+ configuration: "cfg",
+ annotation: "historic note",
+ },
+ ]);
+ const { getByText, queryByText } = renderPage(state);
expect(
engine_rest.history.incident.by_process_definition,
).toHaveBeenCalled();
- expect(getByText("boom")).toBeTruthy();
- expect(getByText("cfg")).toBeTruthy();
+ expect(getByText("historic boom")).toBeTruthy();
+ expect(getByText("historic note")).toBeTruthy();
+ expect(queryByText("processes.incidents.save-annotation")).toBeNull();
+ });
+
+ it("incidents tab saves an annotation for a live incident", async () => {
+ mockParams = { definition_id: "proc:1", panel: "incidents" };
+ engine_rest.incident.set_annotation.mockResolvedValue(undefined);
+ signal_response(state.api.incident.by_process_definition, [
+ {
+ id: "inc1",
+ incidentMessage: "boom",
+ incidentType: "failedJob",
+ configuration: "cfg",
+ annotation: "",
+ },
+ ]);
+ const { getByLabelText, getByText } = renderPage(state);
+ fireEvent.input(getByLabelText("processes.incidents.annotation inc1"), {
+ target: { value: "Checked by ops" },
+ });
+ fireEvent.submit(
+ getByText("processes.incidents.save-annotation").closest("form"),
+ );
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(engine_rest.incident.set_annotation).toHaveBeenCalled();
+ expect(engine_rest.incident.set_annotation.mock.lastCall[0]).toBe(state);
+ expect(engine_rest.incident.set_annotation.mock.lastCall[1]).toBe("inc1");
+ expect(engine_rest.incident.set_annotation.mock.lastCall[2]).toBe(
+ "Checked by ops",
+ );
+ });
+
+ it("incidents tab clears an annotation for a live incident", async () => {
+ mockParams = { definition_id: "proc:1", panel: "incidents" };
+ engine_rest.incident.clear_annotation.mockResolvedValue(undefined);
+ signal_response(state.api.incident.by_process_definition, [
+ {
+ id: "inc1",
+ incidentMessage: "boom",
+ incidentType: "failedJob",
+ configuration: "cfg",
+ annotation: "old note",
+ },
+ ]);
+ const { getByText } = renderPage(state);
+ fireEvent.click(getByText("processes.incidents.clear-annotation"));
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(engine_rest.incident.clear_annotation).toHaveBeenCalled();
+ expect(engine_rest.incident.clear_annotation.mock.lastCall[0]).toBe(state);
+ expect(engine_rest.incident.clear_annotation.mock.lastCall[1]).toBe("inc1");
});
it("called-definitions tab fetches and renders called definitions", () => {
@@ -423,28 +495,54 @@ describe("ProcessesPage — instance details", () => {
expect(getByText("amount")).toBeTruthy();
});
- it("instance-incidents sub-panel fetches and renders incidents", () => {
+ it("instance-incidents sub-panel fetches and renders live incidents", () => {
mockParams = {
definition_id: "proc:1",
panel: "instances",
selection_id: "inst-9999",
sub_panel: "instance_incidents",
};
- signal_response(state.api.history.incident.by_process_instance, [
+ signal_response(state.api.incident.by_process_instance, [
{
id: "ii1",
incidentMessage: "instance boom",
processInstanceId: "inst-9999",
- createTime: "2024-01-01T00:00:00Z",
+ incidentTimestamp: "2024-01-01T00:00:00Z",
activityId: "act1",
incidentType: "failedJob",
},
]);
const { getByText } = renderPage(state);
- expect(engine_rest.history.incident.by_process_instance).toHaveBeenCalled();
+ expect(engine_rest.incident.by_process_instance).toHaveBeenCalled();
expect(getByText("instance boom")).toBeTruthy();
});
+ it("instance-incidents sub-panel uses historic incidents read-only in history mode", () => {
+ mockParams = {
+ definition_id: "proc:1",
+ panel: "instances",
+ selection_id: "inst-9999",
+ sub_panel: "instance_incidents",
+ };
+ mockQuery = { history: "true" };
+ signal_response(state.api.history.incident.by_process_instance, [
+ {
+ id: "ii1",
+ incidentMessage: "historic instance boom",
+ processInstanceId: "inst-9999",
+ createTime: "2024-01-01T00:00:00Z",
+ activityId: "act1",
+ incidentType: "failedJob",
+ annotation: "historic note",
+ },
+ ]);
+ const { getByText, queryByText } = renderPage(state);
+ expect(engine_rest.history.incident.by_process_instance).toHaveBeenCalled();
+ expect(getByText("historic instance boom")).toBeTruthy();
+ expect(getByText("historic note")).toBeTruthy();
+ expect(queryByText("processes.incidents.save-annotation")).toBeNull();
+ });
+
it("called-instances sub-panel fetches and renders called instances", () => {
mockParams = {
definition_id: "proc:1",
diff --git a/src/state.js b/src/state.js
index f63ad2e..87662af 100644
--- a/src/state.js
+++ b/src/state.js
@@ -77,6 +77,11 @@ const createAppState = () => {
validation: signal(null),
execution: signal(null),
},
+ incident: {
+ by_process_definition: signal(null),
+ by_process_instance: signal(null),
+ annotation: signal(null),
+ },
batch: {
list: signal(null),
one: signal(null),