diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json
index 5f7d6a9..b91cb22 100644
--- a/public/locales/de-DE/translation.json
+++ b/public/locales/de-DE/translation.json
@@ -39,6 +39,7 @@
"deployments": "Deployments",
"batches": "Stapelverarbeitung",
"migrations": "Migrationen",
+ "operation-log": "Operationsprotokoll",
"admin": "Admin",
"help": "Hilfe",
"account": "Konto",
@@ -77,6 +78,7 @@
"decisions": "Entscheidungen",
"deployments": "Deployments",
"migrations": "Migrationen",
+ "operation-log": "Operationsprotokoll",
"admin": "Admin",
"admin-users": "Admin – Benutzer",
"admin-groups": "Admin – Gruppen",
@@ -761,6 +763,56 @@
"view-batch": "Batch anzeigen"
}
},
+ "operation_log": {
+ "title": "Operationsprotokoll",
+ "refresh": "Aktualisieren",
+ "matching-entries": "{{count}} passende Einträge",
+ "empty": "Keine Operationen entsprechen diesem Filter.",
+ "timestamp": "Zeitstempel",
+ "user": "Benutzer",
+ "operation": "Operation",
+ "entity": "Entität",
+ "annotation": "Anmerkung",
+ "annotate": "Anmerken",
+ "annotation-title": "Anmerkung bearbeiten",
+ "clear-annotation": "Anmerkung entfernen",
+ "details": "Eintragsdetails",
+ "property": "Eigenschaft",
+ "original-value": "Ursprünglicher Wert",
+ "new-value": "Neuer Wert",
+ "category": "Kategorie",
+ "apply-filters": "Filter anwenden",
+ "clear-filters": "Filter zurücksetzen",
+ "filters": {
+ "user": "Benutzer-ID",
+ "operation": "Operationstyp",
+ "after": "Nach",
+ "before": "Vor"
+ },
+ "sort": {
+ "timestamp": "Zeitstempel",
+ "operationId": "Operations-ID",
+ "userId": "Benutzer-ID",
+ "operationType": "Operationstyp",
+ "entityType": "Entitätstyp",
+ "category": "Kategorie"
+ },
+ "filter_keys": {
+ "userId": "userId",
+ "operationType": "operationType",
+ "entityType": "entityType",
+ "category": "category",
+ "timestampAfter": "timestampAfter",
+ "timestampBefore": "timestampBefore",
+ "processInstanceId": "processInstanceId",
+ "processDefinitionId": "processDefinitionId",
+ "taskId": "taskId",
+ "operationId": "operationId"
+ },
+ "filter": {
+ "manage_title": "Operationsprotokoll-Filter verwalten"
+ }
+ },
"list_filter": {
"saved_filter": "Filter",
"all": "Alle",
diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json
index ac20fbe..0f3bef8 100644
--- a/public/locales/en-US/translation.json
+++ b/public/locales/en-US/translation.json
@@ -41,6 +41,7 @@
"migrations": "Migrations",
"admin": "Admin",
"help": "Help",
+ "operation-log": "Operation Log",
"account": "Account",
"logout": "Logout",
"menu": "Menu",
@@ -77,6 +78,7 @@
"decisions": "Decisions",
"deployments": "Deployments",
"migrations": "Migrations",
+ "operation-log": "Operation Log",
"admin": "Admin",
"admin-users": "Admin – Users",
"admin-groups": "Admin – Groups",
@@ -761,6 +763,56 @@
"view-batch": "View batch"
}
},
+ "operation_log": {
+ "title": "Operation Log",
+ "refresh": "Refresh",
+ "matching-entries": "{{count}} matching entries",
+ "empty": "No operations match this filter.",
+ "timestamp": "Timestamp",
+ "user": "User",
+ "operation": "Operation",
+ "entity": "Entity",
+ "annotation": "Annotation",
+ "annotate": "Annotate",
+ "annotation-title": "Edit annotation",
+ "clear-annotation": "Clear annotation",
+ "details": "Entry details",
+ "property": "Property",
+ "original-value": "Original value",
+ "new-value": "New value",
+ "category": "Category",
+ "apply-filters": "Apply filters",
+ "clear-filters": "Clear filters",
+ "filters": {
+ "user": "User ID",
+ "operation": "Operation type",
+ "after": "After",
+ "before": "Before"
+ },
+ "sort": {
+ "timestamp": "Timestamp",
+ "operationId": "Operation ID",
+ "userId": "User ID",
+ "operationType": "Operation type",
+ "entityType": "Entity type",
+ "category": "Category"
+ },
+ "filter_keys": {
+ "userId": "userId",
+ "operationType": "operationType",
+ "entityType": "entityType",
+ "category": "category",
+ "timestampAfter": "timestampAfter",
+ "timestampBefore": "timestampBefore",
+ "processInstanceId": "processInstanceId",
+ "processDefinitionId": "processDefinitionId",
+ "taskId": "taskId",
+ "operationId": "operationId"
+ },
+ "filter": {
+ "manage_title": "Manage operation-log filters"
+ }
+ },
"list_filter": {
"saved_filter": "Filter",
"all": "All",
diff --git a/src/api/resources/history.js b/src/api/resources/history.js
index 6bc1013..52088f5 100644
--- a/src/api/resources/history.js
+++ b/src/api/resources/history.js
@@ -1,6 +1,7 @@
-import { GET, PAGINATED_GET } from '../helper.jsx'
+import { GET, PAGINATED_GET, PUT } from '../helper.jsx'
const INSTANCE_PAGE_SIZE = 20
+const OPERATION_LOG_PAGE_SIZE = 30
const instance_url = (definition_id, params = {}, { unfinished = false } = {}) => {
const merged = {
@@ -55,6 +56,43 @@ const get_historic_called_instances = (state, instance_id) =>
const get_user_operation = (state, execution_id) =>
GET(`/history/user-operation?processInstanceId=${execution_id}`, state, state.api.history.user_operation)
+const operation_log_url = (params = {}) => {
+ const merged = {
+ sortBy: 'timestamp',
+ sortOrder: 'desc',
+ ...params,
+ }
+ return new URLSearchParams(merged).toString()
+}
+
+const get_operation_log = (state, params = {}, firstResult = 0) =>
+ PAGINATED_GET(
+ `/history/user-operation?${operation_log_url(params)}`,
+ state,
+ state.api.history.operation_log.list,
+ firstResult,
+ OPERATION_LOG_PAGE_SIZE,
+ )
+
+const get_operation_log_count = (state, params = {}) =>
+ GET(`/history/user-operation/count?${operation_log_url(params)}`, state, state.api.history.operation_log.count)
+
+const set_operation_log_annotation = (state, operation_id, annotation) =>
+ PUT(
+ `/history/user-operation/${operation_id}/set-annotation`,
+ { annotation },
+ state,
+ state.api.history.operation_log.update,
+ )
+
+const clear_operation_log_annotation = (state, operation_id) =>
+ PUT(
+ `/history/user-operation/${operation_id}/clear-annotation`,
+ {},
+ state,
+ state.api.history.operation_log.update,
+ )
+
const history = {
process_instance: {
all: get_process_instances,
@@ -72,7 +110,13 @@ const history = {
task: {
by_process_instance: get_historic_tasks_by_instance,
},
+ operation_log: {
+ all: get_operation_log,
+ count: get_operation_log_count,
+ set_annotation: set_operation_log_annotation,
+ clear_annotation: clear_operation_log_annotation,
+ },
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..909349e 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(),
+ PUT: vi.fn(),
}));
-import { GET, PAGINATED_GET } from "../helper.jsx";
+import { GET, PAGINATED_GET, PUT } from "../helper.jsx";
import { create_mock_state, expect_api_call } from "../../test/helpers.js";
import history from "./history.js";
@@ -101,6 +102,52 @@ describe("api/resources/history", () => {
});
});
+ it("operation_log.all() PAGINATED_GETs the global operation log", () => {
+ history.operation_log.all(
+ state,
+ { userId: "demo", operationType: "ModifyProcessInstance" },
+ 30,
+ );
+ expect_api_call(PAGINATED_GET, {
+ url: "/history/user-operation?sortBy=timestamp&sortOrder=desc&userId=demo&operationType=ModifyProcessInstance",
+ state,
+ signal: state.api.history.operation_log.list,
+ });
+ expect(PAGINATED_GET.mock.lastCall[3]).toBe(30);
+ expect(PAGINATED_GET.mock.lastCall[4]).toBe(30);
+ });
+
+ it("operation_log.count() GETs the matching operation count", () => {
+ history.operation_log.count(state, {
+ timestampAfter: "2026-06-01T00:00:00.000+0000",
+ });
+ expect_api_call(GET, {
+ url: "/history/user-operation/count?sortBy=timestamp&sortOrder=desc×tampAfter=2026-06-01T00%3A00%3A00.000%2B0000",
+ state,
+ signal: state.api.history.operation_log.count,
+ });
+ });
+
+ it("operation_log.set_annotation() PUTs the annotation body", () => {
+ history.operation_log.set_annotation(state, "op1", "Reviewed");
+ expect_api_call(PUT, {
+ url: "/history/user-operation/op1/set-annotation",
+ body: { annotation: "Reviewed" },
+ state,
+ signal: state.api.history.operation_log.update,
+ });
+ });
+
+ it("operation_log.clear_annotation() PUTs to the clear endpoint", () => {
+ history.operation_log.clear_annotation(state, "op1");
+ expect_api_call(PUT, {
+ url: "/history/user-operation/op1/clear-annotation",
+ body: {},
+ state,
+ signal: state.api.history.operation_log.update,
+ });
+ });
+
it("task.by_process_instance() GETs historic tasks filtered by instance", () => {
history.task.by_process_instance(state, "inst-1");
expect_api_call(GET, {
diff --git a/src/components/GoTo.jsx b/src/components/GoTo.jsx
index 87dde84..5eb359a 100644
--- a/src/components/GoTo.jsx
+++ b/src/components/GoTo.jsx
@@ -30,6 +30,7 @@ const PAGES = [
{ nameKey: "goto.pages.decisions", href: "/decisions" },
{ nameKey: "goto.pages.deployments", href: "/deployments" },
{ nameKey: "goto.pages.migrations", href: "/migrations" },
+ { nameKey: "goto.pages.operation-log", href: "/operation-log" },
{ nameKey: "goto.pages.admin", href: "/admin" },
{ nameKey: "goto.pages.admin-users", href: "/admin/users" },
{ nameKey: "goto.pages.admin-groups", href: "/admin/groups" },
diff --git a/src/components/GoTo.test.jsx b/src/components/GoTo.test.jsx
index 2ddf3d2..9f9f730 100644
--- a/src/components/GoTo.test.jsx
+++ b/src/components/GoTo.test.jsx
@@ -53,6 +53,13 @@ describe("GoTo", () => {
a.getAttribute("href"),
),
).not.toContain("/tasks");
+
+ type(input, "operation-log");
+ expect(
+ Array.from(container.querySelectorAll(".goto-item")).map((a) =>
+ a.getAttribute("href"),
+ ),
+ ).toContain("/operation-log");
});
it("navigates and closes when a result is clicked", () => {
diff --git a/src/components/Header.jsx b/src/components/Header.jsx
index ce108ea..0762b39 100644
--- a/src/components/Header.jsx
+++ b/src/components/Header.jsx
@@ -74,6 +74,7 @@ export function Header() {
@@ -146,6 +147,14 @@ export function Header() {
{t("nav.migrations")}
+
+
+ {t("nav.operation-log")}
+
+
{t("nav.admin")}
diff --git a/src/css/style.css b/src/css/style.css
index 59fa993..c3b2454 100644
--- a/src/css/style.css
+++ b/src/css/style.css
@@ -828,6 +828,59 @@
text-align: center;
}
+ .operation-log {
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-2);
+ height: 100%;
+ padding: var(--spacing-2);
+ }
+ .operation-log > header {
+ display: flex;
+ justify-content: space-between;
+ align-items: start;
+ gap: var(--spacing-2);
+ }
+ .operation-log > header button {
+ margin: 0;
+ }
+ .operation-log-filter {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
+ gap: var(--spacing-1);
+ align-items: end;
+ }
+ .operation-log-filter label,
+ .operation-log-annotation label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+ .operation-log table {
+ width: 100%;
+ }
+ .operation-log td {
+ vertical-align: top;
+ }
+ .operation-log details {
+ padding: var(--spacing-1) 0;
+ }
+ .operation-log details table {
+ background: var(--background-2);
+ margin-top: var(--spacing-1);
+ }
+ .operation-log-entities {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+ .operation-log-annotation textarea {
+ box-sizing: border-box;
+ min-height: 8rem;
+ width: min(28rem, 100%);
+ }
+
#admin-page {
display: flex;
flex: 1;
diff --git a/src/index.jsx b/src/index.jsx
index 075d533..b9335a9 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -15,6 +15,7 @@ import { MigrationsPage } from "./pages/Migrations.jsx";
import { AdminPage } from "./pages/Admin.jsx";
import { DeploymentsPage } from "./pages/Deployments.jsx";
import { BatchesPage } from "./pages/Batches.jsx";
+import { OperationLogPage } from "./pages/OperationLog.jsx";
import { NotFound } from "./pages/_404.jsx";
import { AccountPage } from "./pages/Account.jsx";
@@ -90,6 +91,7 @@ const Routing = () => {
component={DeploymentsPage}
/>
+
{
+ if (!id || id === "all") return null;
+ return (signal.value?.data ?? []).find((f) => f.id === id) ?? null;
+};
+
+const timestamp_param = (value, edge) => {
+ if (!value) return value;
+ if (/(Z|[+-]\d{2}:?\d{2})$/.test(value)) return value;
+ if (value.includes("T")) {
+ if (value.includes(".")) return `${value}+0000`;
+ if (/T\d{2}:\d{2}:\d{2}$/.test(value)) return `${value}.000+0000`;
+ return `${value}:00.000+0000`;
+ }
+ return edge === "before"
+ ? `${value}T23:59:59.999+0000`
+ : `${value}T00:00:00.000+0000`;
+};
+
+const params_from_query = (state, query) => {
+ const { saved_filter_id, sortBy, sortOrder, criteria } =
+ parse_list_query(query),
+ saved = find_saved(
+ state.api.history.operation_log.saved_filters,
+ saved_filter_id,
+ ),
+ params = {
+ ...(saved?.query ?? {}),
+ ...criteria,
+ ...(sortBy ? { sortBy } : {}),
+ ...(sortOrder ? { sortOrder } : {}),
+ };
+
+ if (params.timestampAfter)
+ params.timestampAfter = timestamp_param(params.timestampAfter, "after");
+ if (params.timestampBefore)
+ params.timestampBefore = timestamp_param(params.timestampBefore, "before");
+
+ return params;
+};
+
+const load_operation_log = (state, query, firstResult = 0) => {
+ const params = params_from_query(state, query);
+ void engine_rest.history.operation_log.all(state, params, firstResult);
+ if (firstResult === 0)
+ void engine_rest.history.operation_log.count(state, params);
+};
+
+const group_operations = (entries = []) => {
+ const groups = new Map();
+ entries.forEach((entry) => {
+ const key = entry.operationId ?? entry.id;
+ if (!groups.has(key)) {
+ groups.set(key, {
+ operationId: key,
+ userId: entry.userId,
+ timestamp: entry.timestamp,
+ annotation: entry.annotation,
+ entries: [],
+ });
+ }
+ const group = groups.get(key);
+ group.entries.push(entry);
+ group.userId = group.userId ?? entry.userId;
+ group.timestamp = group.timestamp ?? entry.timestamp;
+ group.annotation = group.annotation ?? entry.annotation;
+ });
+ return Array.from(groups.values());
+};
+
+const unique_join = (entries, key) =>
+ [...new Set(entries.map((entry) => entry[key]).filter(Boolean))].join(", ") ||
+ "—";
+
+const OperationLogPage = () => {
+ const state = useContext(AppState),
+ { query } = useRoute(),
+ [t] = useTranslation();
+
+ useEffect(() => {
+ hydrate_signal(
+ RESOURCE_TYPE,
+ state.api.history.operation_log.saved_filters,
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ load_operation_log(state, query);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [JSON.stringify(query)]);
+
+ if (query.filters === "manage") return ;
+
+ return (
+
+
+
+
+
+ );
+};
+
+const OperationLogCount = () => {
+ const state = useContext(AppState),
+ [t] = useTranslation();
+ return (
+ (
+
+ {t("operation_log.matching-entries", {
+ count:
+ state.api.history.operation_log.count.value?.data?.count ?? 0,
+ })}
+
+ )}
+ on_load={{t("common.loading")}
}
+ />
+ );
+};
+
+const OperationLogFilters = () => {
+ const state = useContext(AppState),
+ { query } = useRoute(),
+ { route } = useLocation(),
+ [t] = useTranslation(),
+ parsed = parse_list_query(query),
+ list_current = {
+ saved_filter_id: parsed.saved_filter_id,
+ sortBy: parsed.sortBy ?? DEFAULTS.sortBy,
+ sortOrder: parsed.sortOrder ?? DEFAULTS.sortOrder,
+ criteria: parsed.criteria,
+ },
+ apply_patch = (patch) =>
+ route(write_list_query(window.location.href, patch), true),
+ criteria = parsed.criteria;
+
+ const submit = (event) => {
+ event.preventDefault();
+ const form = event.currentTarget;
+ apply_patch({
+ criteria: {
+ userId: form.elements.userId.value,
+ operationType: form.elements.operationType.value,
+ timestampAfter: form.elements.timestampAfter.value,
+ timestampBefore: form.elements.timestampBefore.value,
+ },
+ });
+ };
+
+ return (
+ <>
+
+ route(with_manage(), false)}
+ />
+ >
+ );
+};
+
+const OperationLogTable = () => {
+ const state = useContext(AppState),
+ { query } = useRoute(),
+ [t] = useTranslation(),
+ list = state.api.history.operation_log.list,
+ edit_open = useSignal(false),
+ editing = useSignal(null),
+ annotation = useSignal("");
+
+ const open_annotation = (group) => {
+ editing.value = group;
+ annotation.value = group.annotation ?? "";
+ edit_open.value = true;
+ };
+
+ const reload = () => load_operation_log(state, query);
+
+ const submit_annotation = async (event) => {
+ event.preventDefault();
+ await engine_rest.history.operation_log.set_annotation(
+ state,
+ editing.value.operationId,
+ annotation.value,
+ );
+ edit_open.value = false;
+ reload();
+ };
+
+ const clear_annotation = async () => {
+ await engine_rest.history.operation_log.clear_annotation(
+ state,
+ editing.value.operationId,
+ );
+ edit_open.value = false;
+ reload();
+ };
+
+ const load_more = () =>
+ load_operation_log(state, query, list.value?.data?.length ?? 0);
+
+ return (
+ <>
+ {
+ const groups = group_operations(list.value?.data ?? []);
+ if (groups.length === 0)
+ return {t("operation_log.empty")}
;
+
+ return (
+
+
+
+
+ | {t("operation_log.timestamp")} |
+ {t("operation_log.user")} |
+ {t("operation_log.operation")} |
+ {t("operation_log.entity")} |
+ {t("operation_log.annotation")} |
+ {t("common.action")} |
+
+
+
+ {groups.map((group) => (
+
+ ))}
+
+
+ {list.value?.hasMore ? (
+
+ ) : (
+
{t("tasks.no-more-items")}
+ )}
+
+ );
+ }}
+ />
+
+ >
+ );
+};
+
+const OperationGroupRow = ({ group, on_annotate }) => {
+ const [t] = useTranslation(),
+ timestamp = group.timestamp ? new Date(group.timestamp) : null;
+
+ return (
+ <>
+
+ |
+ {timestamp ? (
+
+ ) : (
+ "—"
+ )}
+ |
+ {group.userId ?? "—"} |
+ {unique_join(group.entries, "operationType")} |
+
+
+ |
+ {group.annotation ?? "—"} |
+
+
+ |
+
+
+
+
+ {t("operation_log.details")}
+
+
+
+ | {t("operation_log.property")} |
+ {t("operation_log.original-value")} |
+ {t("operation_log.new-value")} |
+ {t("operation_log.entity")} |
+ {t("operation_log.category")} |
+
+
+
+ {group.entries.map((entry, index) => (
+
+ | {entry.property ?? "—"} |
+ {entry.orgValue ?? "—"} |
+ {entry.newValue ?? "—"} |
+ {entry.entityType ?? "—"} |
+ {entry.category ?? "—"} |
+
+ ))}
+
+
+
+ |
+
+ >
+ );
+};
+
+const EntityLinks = ({ entries }) => {
+ const entry = entries.find((item) => item.processInstanceId) ?? entries[0],
+ task = entries.find((item) => item.taskId),
+ processDefinitionId = entry?.processDefinitionId,
+ processInstanceId = entry?.processInstanceId;
+
+ return (
+
+ {processInstanceId && processDefinitionId ? (
+
+ {processInstanceId}
+
+ ) : processDefinitionId ? (
+ {processDefinitionId}
+ ) : (
+ "—"
+ )}
+ {task?.taskId ? (
+ {task.taskId}
+ ) : null}
+
+ );
+};
+
+const OperationLogManage = () => {
+ const state = useContext(AppState),
+ { route } = useLocation(),
+ [t] = useTranslation(),
+ refresh = () =>
+ hydrate_signal(
+ RESOURCE_TYPE,
+ state.api.history.operation_log.saved_filters,
+ );
+
+ return (
+
+ {
+ create_saved_filter(RESOURCE_TYPE, filter);
+ refresh();
+ }}
+ on_update={(id, filter) => {
+ update_saved_filter(RESOURCE_TYPE, id, filter);
+ refresh();
+ }}
+ on_delete={(id) => {
+ delete_saved_filter(RESOURCE_TYPE, id);
+ refresh();
+ }}
+ on_close={() => route(without_manage(), true)}
+ build_share_link={(filter) =>
+ filter_share_link(window.location.href, filter)
+ }
+ />
+
+ );
+};
+
+export { OperationLogPage };
diff --git a/src/pages/OperationLog.test.jsx b/src/pages/OperationLog.test.jsx
new file mode 100644
index 0000000..eb7fcb8
--- /dev/null
+++ b/src/pages/OperationLog.test.jsx
@@ -0,0 +1,184 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { h } from "preact";
+import { render, cleanup, fireEvent, waitFor } from "@testing-library/preact";
+
+vi.mock("../api/engine_rest.jsx", async (importOriginal) => {
+ const actual = await importOriginal();
+ const spyify = (o) =>
+ Object.fromEntries(
+ Object.entries(o).map(([k, v]) => [
+ k,
+ typeof v === "function"
+ ? vi.fn()
+ : v && typeof v === "object"
+ ? spyify(v)
+ : v,
+ ]),
+ );
+ return { ...actual, default: spyify(actual.default) };
+});
+
+const route = vi.fn();
+let mockQuery = {};
+
+vi.mock("preact-iso", () => ({
+ useRoute: () => ({ query: mockQuery }),
+ useLocation: () => ({ route, path: "/operation-log" }),
+}));
+
+import { AppState } from "../state.js";
+import engine_rest from "../api/engine_rest.jsx";
+import { OperationLogPage } from "./OperationLog.jsx";
+import { create_mock_state, signal_response } from "../test/helpers.js";
+
+const renderPage = (state) =>
+ render(h(AppState.Provider, { value: state }, h(OperationLogPage, {})));
+
+const sample_entries = [
+ {
+ id: "entry-1",
+ operationId: "operation-1",
+ timestamp: "2026-06-01T10:15:00.000+0000",
+ userId: "demo",
+ operationType: "ModifyProcessInstance",
+ entityType: "ProcessInstance",
+ category: "Operator",
+ property: "nrOfInstances",
+ orgValue: "1",
+ newValue: "2",
+ processDefinitionId: "invoice:1",
+ processInstanceId: "pi-1",
+ annotation: "Reviewed",
+ },
+ {
+ id: "entry-2",
+ operationId: "operation-1",
+ timestamp: "2026-06-01T10:15:00.000+0000",
+ userId: "demo",
+ operationType: "ModifyProcessInstance",
+ entityType: "Task",
+ category: "Operator",
+ property: "assignee",
+ orgValue: "sales",
+ newValue: "ops",
+ taskId: "task-1",
+ },
+];
+
+describe("OperationLogPage", () => {
+ let state;
+
+ beforeEach(() => {
+ state = create_mock_state();
+ mockQuery = {};
+ route.mockClear();
+ });
+
+ afterEach(cleanup);
+
+ it("fetches operation log entries and their count on mount", () => {
+ renderPage(state);
+ const all_call = engine_rest.history.operation_log.all.mock.lastCall,
+ count_call = engine_rest.history.operation_log.count.mock.lastCall;
+
+ expect(all_call[0]).toBe(state);
+ expect(all_call[1]).toEqual({});
+ expect(all_call[2]).toBe(0);
+ expect(count_call[0]).toBe(state);
+ expect(count_call[1]).toEqual({});
+ });
+
+ it("translates list query filters into operation-log API parameters", () => {
+ mockQuery = {
+ "q.userId": "demo",
+ "q.timestampAfter": "2026-06-01T10:30",
+ "q.timestampBefore": "2026-06-01T10:30:45",
+ sortBy: "userId",
+ sortOrder: "asc",
+ };
+
+ renderPage(state);
+
+ const [called_state, params, first_result] =
+ engine_rest.history.operation_log.all.mock.lastCall;
+
+ expect(called_state).toBe(state);
+ expect(params).toEqual({
+ userId: "demo",
+ timestampAfter: "2026-06-01T10:30:00.000+0000",
+ timestampBefore: "2026-06-01T10:30:45.000+0000",
+ sortBy: "userId",
+ sortOrder: "asc",
+ });
+ expect(first_result).toBe(0);
+ });
+
+ it("renders grouped operation rows and their details", () => {
+ signal_response(state.api.history.operation_log.list, sample_entries);
+ signal_response(state.api.history.operation_log.count, { count: 2 });
+
+ const { getByText, container } = renderPage(state);
+
+ expect(getByText("ModifyProcessInstance")).toBeTruthy();
+ expect(getByText("Reviewed")).toBeTruthy();
+ expect(getByText("nrOfInstances")).toBeTruthy();
+ expect(getByText("assignee")).toBeTruthy();
+ expect(
+ container.querySelector(
+ '.operation-log-entities a[href="/processes/invoice:1/instances/pi-1/vars?history=true"]',
+ ),
+ ).toBeTruthy();
+ expect(
+ container.querySelector(
+ '.operation-log-entities a[href="/tasks/task-1/form"]',
+ ),
+ ).toBeTruthy();
+ });
+
+ it("sets an annotation for an operation group", async () => {
+ signal_response(state.api.history.operation_log.list, sample_entries);
+ engine_rest.history.operation_log.set_annotation.mockResolvedValue(
+ undefined,
+ );
+
+ const { getByText, container } = renderPage(state);
+
+ fireEvent.click(getByText("operation_log.annotate"));
+ fireEvent.input(container.querySelector("textarea"), {
+ target: { value: "Checked by ops" },
+ });
+ fireEvent.click(getByText("common.save"));
+
+ await waitFor(() =>
+ expect(
+ engine_rest.history.operation_log.set_annotation,
+ ).toHaveBeenCalled(),
+ );
+ const call = engine_rest.history.operation_log.set_annotation.mock.lastCall;
+ expect(call[0]).toBe(state);
+ expect(call[1]).toBe("operation-1");
+ expect(call[2]).toBe("Checked by ops");
+ });
+
+ it("clears an annotation for an operation group", async () => {
+ signal_response(state.api.history.operation_log.list, sample_entries);
+ engine_rest.history.operation_log.clear_annotation.mockResolvedValue(
+ undefined,
+ );
+
+ const { getByText } = renderPage(state);
+
+ fireEvent.click(getByText("operation_log.annotate"));
+ fireEvent.click(getByText("operation_log.clear-annotation"));
+
+ await waitFor(() =>
+ expect(
+ engine_rest.history.operation_log.clear_annotation,
+ ).toHaveBeenCalled(),
+ );
+ const call =
+ engine_rest.history.operation_log.clear_annotation.mock.lastCall;
+ expect(call[0]).toBe(state);
+ expect(call[1]).toBe("operation-1");
+ });
+});
diff --git a/src/state.js b/src/state.js
index f63ad2e..2fe4aed 100644
--- a/src/state.js
+++ b/src/state.js
@@ -171,6 +171,12 @@ const createAppState = () => {
by_process_definition: signal(null),
by_process_instance: signal(null),
},
+ operation_log: {
+ list: signal(null),
+ count: signal(null),
+ update: signal(null),
+ saved_filters: signal(null),
+ },
task: {
by_process_instance: signal(null),
},
diff --git a/src/state.test.js b/src/state.test.js
index c6b9968..fccdb40 100644
--- a/src/state.test.js
+++ b/src/state.test.js
@@ -54,6 +54,8 @@ describe("state", () => {
expect(api.process.definition.list.value).toBeNull();
expect(api.process.instance.one.value).toBeNull();
expect(api.task.comment.list.value).toBeNull();
+ expect(api.history.operation_log.list.value).toBeNull();
+ expect(api.history.operation_log.count.value).toBeNull();
expect(api.authorization.all.value).toBeNull();
expect(api.batch.list.value).toBeNull();
expect(api.batch.one.value).toBeNull();