From 19c465e2a6fd639fe5a5143a031e80089dfac0bb Mon Sep 17 00:00:00 2001 From: Julian Haupt Date: Tue, 16 Jun 2026 20:29:50 +0200 Subject: [PATCH] cockpit: add cleanup view --- public/locales/de-DE/translation.json | 33 +++ public/locales/en-US/translation.json | 33 +++ src/api/engine_rest.jsx | 2 + src/api/resources/cleanup.js | 98 +++++++ src/api/resources/cleanup.test.js | 124 ++++++++ src/components/GoTo.jsx | 1 + src/components/GoTo.test.jsx | 7 + src/components/Header.jsx | 6 + src/css/style.css | 59 ++++ src/index.jsx | 2 + src/pages/Cleanup.jsx | 394 ++++++++++++++++++++++++++ src/pages/Cleanup.test.jsx | 168 +++++++++++ src/state.js | 16 ++ src/state.test.js | 3 + 14 files changed, 946 insertions(+) create mode 100644 src/api/resources/cleanup.js create mode 100644 src/api/resources/cleanup.test.js create mode 100644 src/pages/Cleanup.jsx create mode 100644 src/pages/Cleanup.test.jsx diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json index 5f7d6a9..806d99a 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", + "cleanup": "Cleanup", "admin": "Admin", "help": "Hilfe", "account": "Konto", @@ -77,6 +78,7 @@ "decisions": "Entscheidungen", "deployments": "Deployments", "migrations": "Migrationen", + "cleanup": "Cleanup", "admin": "Admin", "admin-users": "Admin – Benutzer", "admin-groups": "Admin – Gruppen", @@ -559,6 +561,37 @@ "without-tenant-id": "Ohne Mandanten-ID" } }, + "cleanup": { + "title": "Cleanup", + "subtitle": "History Cleanup überwachen, bereinigbare Daten prüfen und die History Time To Live anpassen.", + "refresh": "Aktualisieren", + "run-now": "Cleanup jetzt starten", + "configuration": "Konfiguration", + "enabled": "Aktiviert", + "strategy": "Strategie", + "batch-window": "Batch-Fenster", + "parallelism": "Parallelität", + "deleted-data": "Gelöschte Daten in den letzten {{days}} Tagen", + "jobs": "Cleanup-Jobs", + "no-jobs": "Es sind keine Cleanup-Jobs geplant.", + "due-date": "Fälligkeitsdatum", + "retries": "Retries", + "exception": "Exception", + "process-definitions": "Bereinigbare Prozessdefinitionen", + "decision-definitions": "Bereinigbare Entscheidungsdefinitionen", + "batches": "Bereinigbare Batches", + "no-cleanable-data": "Keine bereinigbaren Daten gemeldet.", + "version": "Version", + "finished": "Beendet", + "cleanable": "Bereinigbar", + "ttl": "History TTL", + "save-ttl": "TTL speichern", + "metrics": { + "process-instances": "Prozessinstanzen", + "decision-instances": "Entscheidungsinstanzen", + "batch-operations": "Batch-Operationen" + } + }, "admin": { "users": "Benutzer", "groups": "Gruppen", diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index ac20fbe..7c63e59 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -39,6 +39,7 @@ "deployments": "Deployments", "batches": "Batches", "migrations": "Migrations", + "cleanup": "Cleanup", "admin": "Admin", "help": "Help", "account": "Account", @@ -77,6 +78,7 @@ "decisions": "Decisions", "deployments": "Deployments", "migrations": "Migrations", + "cleanup": "Cleanup", "admin": "Admin", "admin-users": "Admin – Users", "admin-groups": "Admin – Groups", @@ -559,6 +561,37 @@ "without-tenant-id": "Without Tenant ID" } }, + "cleanup": { + "title": "Cleanup", + "subtitle": "Monitor history cleanup, review cleanable data, and adjust history time to live.", + "refresh": "Refresh", + "run-now": "Run cleanup now", + "configuration": "Configuration", + "enabled": "Enabled", + "strategy": "Strategy", + "batch-window": "Batch window", + "parallelism": "Parallelism", + "deleted-data": "Deleted data in the last {{days}} days", + "jobs": "Cleanup jobs", + "no-jobs": "No cleanup jobs are scheduled.", + "due-date": "Due date", + "retries": "Retries", + "exception": "Exception", + "process-definitions": "Cleanable process definitions", + "decision-definitions": "Cleanable decision definitions", + "batches": "Cleanable batches", + "no-cleanable-data": "No cleanable data reported.", + "version": "Version", + "finished": "Finished", + "cleanable": "Cleanable", + "ttl": "History TTL", + "save-ttl": "Save TTL", + "metrics": { + "process-instances": "Process instances", + "decision-instances": "Decision instances", + "batch-operations": "Batch operations" + } + }, "admin": { "users": "Users", "groups": "Groups", diff --git a/src/api/engine_rest.jsx b/src/api/engine_rest.jsx index b1df2be..6887238 100644 --- a/src/api/engine_rest.jsx +++ b/src/api/engine_rest.jsx @@ -4,6 +4,7 @@ import { } from "./helper.jsx"; import auth from "./resources/auth.js"; import batch from "./resources/batch.js"; +import cleanup from "./resources/cleanup.js"; import engine from "./resources/engine.js"; import user from "./resources/user.js"; import group from "./resources/group.js"; @@ -23,6 +24,7 @@ const engine_rest = { auth, authorization, batch, + cleanup, decision, deployment, engine, diff --git a/src/api/resources/cleanup.js b/src/api/resources/cleanup.js new file mode 100644 index 0000000..a776a9c --- /dev/null +++ b/src/api/resources/cleanup.js @@ -0,0 +1,98 @@ +import { GET, POST, PUT } from "../helper.jsx"; + +const metric_url = (name, params = {}) => { + const query = new URLSearchParams(params).toString(); + return `/metrics/${name}/sum${query ? `?${query}` : ""}`; +}; + +const get_configuration = (state) => + GET("/history/cleanup/configuration", state, state.api.cleanup.configuration); + +const get_jobs = (state) => + GET("/history/cleanup/jobs", state, state.api.cleanup.jobs); + +const run_cleanup = (state, immediately_due = false) => + POST( + `/history/cleanup?immediatelyDue=${immediately_due}`, + {}, + state, + state.api.cleanup.run, + ); + +const get_cleanable_process_definitions = (state) => + GET( + "/history/process-definition/cleanable-process-instance-report", + state, + state.api.cleanup.cleanable.process_definitions, + ); + +const get_cleanable_decision_definitions = (state) => + GET( + "/history/decision-definition/cleanable-decision-instance-report", + state, + state.api.cleanup.cleanable.decision_definitions, + ); + +const get_cleanable_batches = (state) => + GET( + "/history/batch/cleanable-batch-report", + state, + state.api.cleanup.cleanable.batches, + ); + +const get_removed_process_instances = (state, params = {}) => + GET( + metric_url("history-cleanup-removed-process-instances", params), + state, + state.api.cleanup.metrics.process_instances, + ); + +const get_removed_decision_instances = (state, params = {}) => + GET( + metric_url("history-cleanup-removed-decision-instances", params), + state, + state.api.cleanup.metrics.decision_instances, + ); + +const get_removed_batch_operations = (state, params = {}) => + GET( + metric_url("history-cleanup-removed-batch-operations", params), + state, + state.api.cleanup.metrics.batch_operations, + ); + +const set_process_definition_ttl = (state, id, historyTimeToLive) => + PUT( + `/process-definition/${id}/history-time-to-live`, + { historyTimeToLive }, + state, + state.api.cleanup.update_ttl, + ); + +const set_decision_definition_ttl = (state, id, historyTimeToLive) => + PUT( + `/decision-definition/${id}/history-time-to-live`, + { historyTimeToLive }, + state, + state.api.cleanup.update_ttl, + ); + +const cleanup = { + configuration: get_configuration, + jobs: get_jobs, + run: run_cleanup, + cleanable: { + process_definitions: get_cleanable_process_definitions, + decision_definitions: get_cleanable_decision_definitions, + batches: get_cleanable_batches, + }, + metrics: { + process_instances: get_removed_process_instances, + decision_instances: get_removed_decision_instances, + batch_operations: get_removed_batch_operations, + }, + set_process_definition_ttl, + set_decision_definition_ttl, +}; + +export default cleanup; diff --git a/src/api/resources/cleanup.test.js b/src/api/resources/cleanup.test.js new file mode 100644 index 0000000..e18e9dd --- /dev/null +++ b/src/api/resources/cleanup.test.js @@ -0,0 +1,124 @@ +import { describe, it, vi, beforeEach } from "vitest"; + +vi.mock("../helper.jsx", () => ({ + GET: vi.fn(), + POST: vi.fn(), + PUT: vi.fn(), +})); + +import { GET, POST, PUT } from "../helper.jsx"; +import { create_mock_state, expect_api_call } from "../../test/helpers.js"; +import cleanup from "./cleanup.js"; + +describe("api/resources/cleanup", () => { + let state; + + beforeEach(() => { + state = create_mock_state(); + }); + + it("configuration() GETs history cleanup configuration", () => { + cleanup.configuration(state); + expect_api_call(GET, { + url: "/history/cleanup/configuration", + state, + signal: state.api.cleanup.configuration, + }); + }); + + it("jobs() GETs history cleanup jobs", () => { + cleanup.jobs(state); + expect_api_call(GET, { + url: "/history/cleanup/jobs", + state, + signal: state.api.cleanup.jobs, + }); + }); + + it("run() POSTs the cleanup trigger", () => { + cleanup.run(state, true); + expect_api_call(POST, { + url: "/history/cleanup?immediatelyDue=true", + body: {}, + state, + signal: state.api.cleanup.run, + }); + }); + + it("cleanable.process_definitions() GETs the cleanable process report", () => { + cleanup.cleanable.process_definitions(state); + expect_api_call(GET, { + url: "/history/process-definition/cleanable-process-instance-report", + state, + signal: state.api.cleanup.cleanable.process_definitions, + }); + }); + + it("cleanable.decision_definitions() GETs the cleanable decision report", () => { + cleanup.cleanable.decision_definitions(state); + expect_api_call(GET, { + url: "/history/decision-definition/cleanable-decision-instance-report", + state, + signal: state.api.cleanup.cleanable.decision_definitions, + }); + }); + + it("cleanable.batches() GETs the cleanable batch report", () => { + cleanup.cleanable.batches(state); + expect_api_call(GET, { + url: "/history/batch/cleanable-batch-report", + state, + signal: state.api.cleanup.cleanable.batches, + }); + }); + + it("metrics.process_instances() GETs the metric sum", () => { + cleanup.metrics.process_instances(state, { + startDate: "2026-05-17T00:00:00.000+0000", + endDate: "2026-06-16T00:00:00.000+0000", + }); + expect_api_call(GET, { + url: "/metrics/history-cleanup-removed-process-instances/sum?startDate=2026-05-17T00%3A00%3A00.000%2B0000&endDate=2026-06-16T00%3A00%3A00.000%2B0000", + state, + signal: state.api.cleanup.metrics.process_instances, + }); + }); + + it("metrics.decision_instances() GETs the metric sum", () => { + cleanup.metrics.decision_instances(state); + expect_api_call(GET, { + url: "/metrics/history-cleanup-removed-decision-instances/sum", + state, + signal: state.api.cleanup.metrics.decision_instances, + }); + }); + + it("metrics.batch_operations() GETs the metric sum", () => { + cleanup.metrics.batch_operations(state); + expect_api_call(GET, { + url: "/metrics/history-cleanup-removed-batch-operations/sum", + state, + signal: state.api.cleanup.metrics.batch_operations, + }); + }); + + it("set_process_definition_ttl() PUTs historyTimeToLive", () => { + cleanup.set_process_definition_ttl(state, "process:1", 30); + expect_api_call(PUT, { + url: "/process-definition/process:1/history-time-to-live", + body: { historyTimeToLive: 30 }, + state, + signal: state.api.cleanup.update_ttl, + }); + }); + + it("set_decision_definition_ttl() PUTs nullable historyTimeToLive", () => { + cleanup.set_decision_definition_ttl(state, "decision:1", null); + expect_api_call(PUT, { + url: "/decision-definition/decision:1/history-time-to-live", + body: { historyTimeToLive: null }, + state, + signal: state.api.cleanup.update_ttl, + }); + }); +}); diff --git a/src/components/GoTo.jsx b/src/components/GoTo.jsx index 87dde84..9f158c0 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.cleanup", href: "/cleanup" }, { 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..460e5f3 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, "cleanup"); + expect( + Array.from(container.querySelectorAll(".goto-item")).map((a) => + a.getAttribute("href"), + ), + ).toContain("/cleanup"); }); it("navigates and closes when a result is clicked", () => { diff --git a/src/components/Header.jsx b/src/components/Header.jsx index ce108ea..e378532 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -74,6 +74,7 @@ export function Header() { @@ -146,6 +147,11 @@ export function Header() { {t("nav.migrations")} +
  • + + {t("nav.cleanup")} + +
  • {t("nav.admin")} diff --git a/src/css/style.css b/src/css/style.css index 59fa993..b373a75 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -828,6 +828,65 @@ text-align: center; } + .cleanup { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--spacing-2); + height: 100%; + padding: var(--spacing-2); + } + .cleanup > header { + display: flex; + justify-content: space-between; + align-items: start; + gap: var(--spacing-2); + } + .cleanup > header p { + color: var(--text-2); + margin: 0; + } + .cleanup > section, + .cleanup > section > section { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + } + .cleanup table { + width: 100%; + } + .cleanup :where(td, th) { + vertical-align: top; + } + .cleanup-summary, + .cleanup-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: var(--spacing-1); + } + .cleanup-summary dt, + .cleanup-metrics dt { + color: var(--text-2); + font-size: var(--small-font-size); + } + .cleanup-summary dd, + .cleanup-metrics dd { + margin: 0; + font-weight: 600; + } + .cleanup-ttl { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-1); + } + .cleanup-ttl input { + width: 7rem; + } + .cleanup-ttl button { + margin: 0; + } + #admin-page { display: flex; flex: 1; diff --git a/src/index.jsx b/src/index.jsx index 075d533..28bf9f4 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 { CleanupPage } from "./pages/Cleanup.jsx"; import { NotFound } from "./pages/_404.jsx"; import { AccountPage } from "./pages/Account.jsx"; @@ -90,6 +91,7 @@ const Routing = () => { component={DeploymentsPage} /> + date.toISOString().replace("Z", "+0000"); + +const metric_params = () => { + const end = new Date(), + start = new Date(end); + start.setDate(start.getDate() - METRIC_WINDOW_DAYS); + return { + startDate: camunda_date(start), + endDate: camunda_date(end), + }; +}; + +const load_cleanup = (state) => { + const params = metric_params(); + void engine_rest.cleanup.configuration(state); + void engine_rest.cleanup.jobs(state); + void engine_rest.cleanup.cleanable.process_definitions(state); + void engine_rest.cleanup.cleanable.decision_definitions(state); + void engine_rest.cleanup.cleanable.batches(state); + void engine_rest.cleanup.metrics.process_instances(state, params); + void engine_rest.cleanup.metrics.decision_instances(state, params); + void engine_rest.cleanup.metrics.batch_operations(state, params); +}; + +const num = (value) => (value ?? 0).toLocaleString(); + +const nullable = (value) => value ?? "—"; + +const metric_value = (signal) => signal.value?.data?.result ?? 0; + +const CleanupPage = () => { + const state = useContext(AppState), + [t] = useTranslation(); + + useEffect(() => { + load_cleanup(state); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const run_cleanup = async () => { + await engine_rest.cleanup.run(state, true); + load_cleanup(state); + }; + + return ( +
    +
    +
    +

    {t("cleanup.title")}

    +

    {t("cleanup.subtitle")}

    +
    +
    + + +
    +
    + + + + + +
    + ); +}; + +const ConfigurationPanel = () => { + const state = useContext(AppState), + [t] = useTranslation(), + signal = state.api.cleanup.configuration; + + return ( +
    +

    {t("cleanup.configuration")}

    + { + const config = signal.value.data; + return ( +
    +
    {t("cleanup.enabled")}
    +
    + {config.historyCleanupEnabled + ? t("common.yes") + : t("common.no")} +
    +
    {t("cleanup.strategy")}
    +
    {nullable(config.historyCleanupStrategy)}
    +
    {t("cleanup.batch-window")}
    +
    + {nullable(config.historyCleanupBatchWindowStartTime)} -{" "} + {nullable(config.historyCleanupBatchWindowEndTime)} +
    +
    {t("cleanup.parallelism")}
    +
    {nullable(config.historyCleanupDegreeOfParallelism)}
    +
    + ); + }} + /> +
    + ); +}; + +const MetricsPanel = () => { + const state = useContext(AppState), + [t] = useTranslation(), + metrics = state.api.cleanup.metrics; + + return ( +
    +

    {t("cleanup.deleted-data", { days: METRIC_WINDOW_DAYS })}

    + ( +
    +
    {t("cleanup.metrics.process-instances")}
    +
    {num(metric_value(metrics.process_instances))}
    +
    {t("cleanup.metrics.decision-instances")}
    +
    {num(metric_value(metrics.decision_instances))}
    +
    {t("cleanup.metrics.batch-operations")}
    +
    {num(metric_value(metrics.batch_operations))}
    +
    + )} + /> +
    + ); +}; + +const CleanupJobs = () => { + const state = useContext(AppState), + [t] = useTranslation(), + signal = state.api.cleanup.jobs; + + return ( +
    +

    {t("cleanup.jobs")}

    + { + const jobs = signal.value.data ?? []; + if (jobs.length === 0) + return

    {t("cleanup.no-jobs")}

    ; + return ( + + + + + + + + + + + {jobs.map((job) => ( + + + + + + + ))} + +
    {t("common.id")}{t("cleanup.due-date")}{t("cleanup.retries")}{t("cleanup.exception")}
    {job.id}{nullable(job.dueDate)}{nullable(job.retries)}{nullable(job.exceptionMessage)}
    + ); + }} + /> +
    + ); +}; + +const CleanableData = () => ( +
    + + + +
    +); + +const CleanableProcessDefinitions = () => { + const state = useContext(AppState), + [t] = useTranslation(), + signal = state.api.cleanup.cleanable.process_definitions; + + return ( +
    +

    {t("cleanup.process-definitions")}

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

    {t("cleanup.no-cleanable-data")}

    ; + return ( + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
    {t("common.name")}{t("common.key")}{t("cleanup.version")}{t("cleanup.finished")}{t("cleanup.cleanable")}{t("cleanup.ttl")}
    + + {row.processDefinitionName ?? + row.processDefinitionKey ?? + row.processDefinitionId} + + {row.processDefinitionKey}{row.processDefinitionVersion}{num(row.finishedProcessInstanceCount)}{num(row.cleanableProcessInstanceCount)} + + engine_rest.cleanup.set_process_definition_ttl( + state, + id, + ttl, + ) + } + /> +
    + ); + }} + /> +
    + ); +}; + +const CleanableDecisionDefinitions = () => { + const state = useContext(AppState), + [t] = useTranslation(), + signal = state.api.cleanup.cleanable.decision_definitions; + + return ( +
    +

    {t("cleanup.decision-definitions")}

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

    {t("cleanup.no-cleanable-data")}

    ; + return ( + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
    {t("common.name")}{t("common.key")}{t("cleanup.version")}{t("cleanup.finished")}{t("cleanup.cleanable")}{t("cleanup.ttl")}
    + + {row.decisionDefinitionName ?? + row.decisionDefinitionKey ?? + row.decisionDefinitionId} + + {row.decisionDefinitionKey}{row.decisionDefinitionVersion}{num(row.finishedDecisionInstanceCount)}{num(row.cleanableDecisionInstanceCount)} + + engine_rest.cleanup.set_decision_definition_ttl( + state, + id, + ttl, + ) + } + /> +
    + ); + }} + /> +
    + ); +}; + +const CleanableBatches = () => { + const state = useContext(AppState), + [t] = useTranslation(), + signal = state.api.cleanup.cleanable.batches; + + return ( +
    +

    {t("cleanup.batches")}

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

    {t("cleanup.no-cleanable-data")}

    ; + return ( + + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
    {t("common.type")}{t("cleanup.finished")}{t("cleanup.cleanable")}{t("cleanup.ttl")}
    {row.batchType}{num(row.finishedBatchCount)}{num(row.cleanableBatchCount)}{nullable(row.historyTimeToLive)}
    + ); + }} + /> +
    + ); +}; + +const TtlEditor = ({ id, value, on_save }) => { + const state = useContext(AppState), + [t] = useTranslation(), + ttl = useSignal(value ?? ""); + + const submit = async (event) => { + event.preventDefault(); + const next = ttl.value === "" ? null : Number(ttl.value); + await on_save(id, next); + load_cleanup(state); + }; + + return ( +
    + (ttl.value = event.currentTarget.value)} + /> + +
    + ); +}; + +export { CleanupPage }; diff --git a/src/pages/Cleanup.test.jsx b/src/pages/Cleanup.test.jsx new file mode 100644 index 0000000..58eea0e --- /dev/null +++ b/src/pages/Cleanup.test.jsx @@ -0,0 +1,168 @@ +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) }; +}); + +import { AppState } from "../state.js"; +import engine_rest from "../api/engine_rest.jsx"; +import { CleanupPage } from "./Cleanup.jsx"; +import { create_mock_state, signal_response } from "../test/helpers.js"; + +const renderPage = (state) => + render(h(AppState.Provider, { value: state }, h(CleanupPage, {}))); + +const populate_cleanup = (state) => { + signal_response(state.api.cleanup.configuration, { + historyCleanupEnabled: true, + historyCleanupStrategy: "removalTimeBased", + historyCleanupBatchWindowStartTime: "22:00", + historyCleanupBatchWindowEndTime: "06:00", + historyCleanupDegreeOfParallelism: 2, + }); + signal_response(state.api.cleanup.jobs, [ + { id: "job-1", dueDate: "2026-06-16T22:00:00.000+0000", retries: 3 }, + ]); + signal_response(state.api.cleanup.cleanable.process_definitions, [ + { + processDefinitionId: "invoice:1", + processDefinitionKey: "invoice", + processDefinitionName: "Invoice", + processDefinitionVersion: 1, + historyTimeToLive: 30, + finishedProcessInstanceCount: 10, + cleanableProcessInstanceCount: 4, + }, + ]); + signal_response(state.api.cleanup.cleanable.decision_definitions, [ + { + decisionDefinitionId: "decision:1", + decisionDefinitionKey: "approval", + decisionDefinitionName: "Approval", + decisionDefinitionVersion: 2, + historyTimeToLive: 7, + finishedDecisionInstanceCount: 8, + cleanableDecisionInstanceCount: 5, + }, + ]); + signal_response(state.api.cleanup.cleanable.batches, [ + { + batchType: "instance-modification", + historyTimeToLive: 5, + finishedBatchCount: 6, + cleanableBatchCount: 2, + }, + ]); + signal_response(state.api.cleanup.metrics.process_instances, { result: 11 }); + signal_response(state.api.cleanup.metrics.decision_instances, { result: 12 }); + signal_response(state.api.cleanup.metrics.batch_operations, { result: 13 }); +}; + +describe("CleanupPage", () => { + let state; + + beforeEach(() => { + state = create_mock_state(); + }); + + afterEach(cleanup); + + it("fetches cleanup status, reports, jobs, and metrics on mount", () => { + renderPage(state); + + expect(engine_rest.cleanup.configuration.mock.lastCall[0]).toBe(state); + expect(engine_rest.cleanup.jobs.mock.lastCall[0]).toBe(state); + expect( + engine_rest.cleanup.cleanable.process_definitions.mock.lastCall[0], + ).toBe(state); + expect( + engine_rest.cleanup.cleanable.decision_definitions.mock.lastCall[0], + ).toBe(state); + expect(engine_rest.cleanup.cleanable.batches.mock.lastCall[0]).toBe(state); + expect(engine_rest.cleanup.metrics.process_instances).toHaveBeenCalled(); + expect(engine_rest.cleanup.metrics.decision_instances).toHaveBeenCalled(); + expect(engine_rest.cleanup.metrics.batch_operations).toHaveBeenCalled(); + }); + + it("renders configuration, cleanup jobs, cleanable data, and metrics", () => { + populate_cleanup(state); + + const { getByText } = renderPage(state); + + expect(getByText("removalTimeBased")).toBeTruthy(); + expect(getByText("job-1")).toBeTruthy(); + expect(getByText("Invoice")).toBeTruthy(); + expect(getByText("Approval")).toBeTruthy(); + expect(getByText("instance-modification")).toBeTruthy(); + expect(getByText("11")).toBeTruthy(); + expect(getByText("12")).toBeTruthy(); + expect(getByText("13")).toBeTruthy(); + }); + + it("updates process definition history time to live", async () => { + populate_cleanup(state); + engine_rest.cleanup.set_process_definition_ttl.mockResolvedValue(undefined); + + const { container, getAllByText } = renderPage(state); + const input = container.querySelectorAll(".cleanup-ttl input")[0]; + fireEvent.input(input, { target: { value: "60" } }); + fireEvent.click(getAllByText("cleanup.save-ttl")[0]); + + await waitFor(() => + expect(engine_rest.cleanup.set_process_definition_ttl).toHaveBeenCalled(), + ); + const call = engine_rest.cleanup.set_process_definition_ttl.mock.lastCall; + expect(call[0]).toBe(state); + expect(call[1]).toBe("invoice:1"); + expect(call[2]).toBe(60); + }); + + it("updates decision definition history time to live", async () => { + populate_cleanup(state); + engine_rest.cleanup.set_decision_definition_ttl.mockResolvedValue( + undefined, + ); + + const { container, getAllByText } = renderPage(state); + const input = container.querySelectorAll(".cleanup-ttl input")[1]; + fireEvent.input(input, { target: { value: "" } }); + fireEvent.click(getAllByText("cleanup.save-ttl")[1]); + + await waitFor(() => + expect( + engine_rest.cleanup.set_decision_definition_ttl, + ).toHaveBeenCalled(), + ); + const call = engine_rest.cleanup.set_decision_definition_ttl.mock.lastCall; + expect(call[0]).toBe(state); + expect(call[1]).toBe("decision:1"); + expect(call[2]).toBeNull(); + }); + + it("triggers cleanup immediately", async () => { + populate_cleanup(state); + engine_rest.cleanup.run.mockResolvedValue(undefined); + + const { getByText } = renderPage(state); + fireEvent.click(getByText("cleanup.run-now")); + + await waitFor(() => expect(engine_rest.cleanup.run).toHaveBeenCalled()); + const call = engine_rest.cleanup.run.mock.lastCall; + expect(call[0]).toBe(state); + expect(call[1]).toBe(true); + }); +}); diff --git a/src/state.js b/src/state.js index f63ad2e..48ac3cb 100644 --- a/src/state.js +++ b/src/state.js @@ -84,6 +84,22 @@ const createAppState = () => { update: signal(null), saved_filters: signal(null), }, + cleanup: { + configuration: signal(null), + jobs: signal(null), + run: signal(null), + update_ttl: signal(null), + cleanable: { + process_definitions: signal(null), + decision_definitions: signal(null), + batches: signal(null), + }, + metrics: { + process_instances: signal(null), + decision_instances: signal(null), + batch_operations: signal(null), + }, + }, tenant: { list: signal(null), by_member: signal(null), diff --git a/src/state.test.js b/src/state.test.js index c6b9968..8fce02d 100644 --- a/src/state.test.js +++ b/src/state.test.js @@ -57,6 +57,9 @@ describe("state", () => { expect(api.authorization.all.value).toBeNull(); expect(api.batch.list.value).toBeNull(); expect(api.batch.one.value).toBeNull(); + expect(api.cleanup.configuration.value).toBeNull(); + expect(api.cleanup.cleanable.process_definitions.value).toBeNull(); + expect(api.cleanup.metrics.process_instances.value).toBeNull(); }); }); });