From ce10efa9edb8f02b79155259d8134070481066ad Mon Sep 17 00:00:00 2001 From: Julian Haupt Date: Tue, 16 Jun 2026 20:10:02 +0200 Subject: [PATCH] cockpit: add operation log auditing --- public/locales/de-DE/translation.json | 52 +++ public/locales/en-US/translation.json | 52 +++ src/api/resources/history.js | 48 ++- src/api/resources/history.test.js | 49 ++- src/components/GoTo.jsx | 1 + src/components/GoTo.test.jsx | 7 + src/components/Header.jsx | 9 + src/css/style.css | 53 +++ src/index.jsx | 2 + src/pages/OperationLog.jsx | 531 ++++++++++++++++++++++++++ src/pages/OperationLog.test.jsx | 184 +++++++++ src/state.js | 6 + src/state.test.js | 2 + 13 files changed, 993 insertions(+), 3 deletions(-) create mode 100644 src/pages/OperationLog.jsx create mode 100644 src/pages/OperationLog.test.jsx 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 ( +
    +
    +
    +

    {t("operation_log.title")}

    + +
    + +
    + + +
    + ); +}; + +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 ( +
    + + + + + + + + + + + + + {groups.map((group) => ( + + ))} + +
    {t("operation_log.timestamp")}{t("operation_log.user")}{t("operation_log.operation")}{t("operation_log.entity")}{t("operation_log.annotation")}{t("common.action")}
    + {list.value?.hasMore ? ( + + ) : ( + {t("tasks.no-more-items")} + )} +
    + ); + }} + /> + +
    +