diff --git a/public/locales/de-DE/translation.json b/public/locales/de-DE/translation.json index 5f7d6a9..d41e6f5 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", + "reports": "Reports", "admin": "Admin", "help": "Hilfe", "account": "Konto", @@ -77,6 +78,7 @@ "decisions": "Entscheidungen", "deployments": "Deployments", "migrations": "Migrationen", + "reports": "Reports", "admin": "Admin", "admin-users": "Admin – Benutzer", "admin-groups": "Admin – Gruppen", @@ -559,6 +561,46 @@ "without-tenant-id": "Ohne Mandanten-ID" } }, + "reports": { + "title": "Reports", + "subtitle": "Abgeschlossene Prozess- und Aufgabenhistorie mit Cockpit Enterprise Reports auswerten.", + "refresh": "Aktualisieren", + "run": "Report ausführen", + "export-csv": "CSV exportieren", + "export-json": "JSON exportieren", + "no-results": "Für die gewählten Filter wurden keine Report-Zeilen gefunden.", + "started-after": "Gestartet nach", + "started-before": "Gestartet vor", + "completed-after": "Abgeschlossen nach", + "completed-before": "Abgeschlossen vor", + "minimum": "Minimum", + "average": "Durchschnitt", + "maximum": "Maximum", + "count": "Anzahl", + "period": { + "title": "Zeitraum", + "month": "Monat", + "quarter": "Quartal", + "month-value": "Monat {{month}}", + "quarter-value": "Quartal {{quarter}}" + }, + "process": { + "title": "Prozessinstanz-Dauer-Report", + "period": "Aggregation", + "keys": "Prozessdefinitionsschlüssel" + }, + "task": { + "title": "Report für abgeschlossene Aufgaben", + "type": "Report-Typ", + "count": "Abgeschlossene Aufgaben", + "duration": "Aufgabendauer", + "period": "Aggregation", + "group-by": "Gruppieren nach", + "process-definition": "Prozessdefinition", + "task-name": "Aufgabenname", + "group": "Gruppe" + } + }, "admin": { "users": "Benutzer", "groups": "Gruppen", diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index ac20fbe..5efc2df 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", + "reports": "Reports", "admin": "Admin", "help": "Help", "account": "Account", @@ -77,6 +78,7 @@ "decisions": "Decisions", "deployments": "Deployments", "migrations": "Migrations", + "reports": "Reports", "admin": "Admin", "admin-users": "Admin – Users", "admin-groups": "Admin – Groups", @@ -559,6 +561,46 @@ "without-tenant-id": "Without Tenant ID" } }, + "reports": { + "title": "Reports", + "subtitle": "Analyze completed process and task history with Cockpit Enterprise reports.", + "refresh": "Refresh", + "run": "Run report", + "export-csv": "Export CSV", + "export-json": "Export JSON", + "no-results": "No report rows found for the selected filters.", + "started-after": "Started after", + "started-before": "Started before", + "completed-after": "Completed after", + "completed-before": "Completed before", + "minimum": "Minimum", + "average": "Average", + "maximum": "Maximum", + "count": "Count", + "period": { + "title": "Period", + "month": "Month", + "quarter": "Quarter", + "month-value": "Month {{month}}", + "quarter-value": "Quarter {{quarter}}" + }, + "process": { + "title": "Process Instance Duration Report", + "period": "Aggregation", + "keys": "Process definition keys" + }, + "task": { + "title": "Completed Task Instance Report", + "type": "Report type", + "count": "Completed tasks", + "duration": "Task duration", + "period": "Aggregation", + "group-by": "Group by", + "process-definition": "Process definition", + "task-name": "Task name", + "group": "Group" + } + }, "admin": { "users": "Users", "groups": "Groups", diff --git a/src/api/engine_rest.jsx b/src/api/engine_rest.jsx index b1df2be..59df385 100644 --- a/src/api/engine_rest.jsx +++ b/src/api/engine_rest.jsx @@ -14,6 +14,7 @@ import deployment from "./resources/deployment.js"; import history from "./resources/history.js"; import job_definition from "./resources/job_definition.js"; import migration from "./resources/migration.js"; +import report from "./resources/report.js"; import task from "./resources/task.js"; import authorization from "./resources/authorization.js"; import decision from "./resources/decision.js"; @@ -33,6 +34,7 @@ const engine_rest = { migration, process_definition, process_instance, + report, task, tenant, user, diff --git a/src/api/resources/report.js b/src/api/resources/report.js new file mode 100644 index 0000000..5e4ca28 --- /dev/null +++ b/src/api/resources/report.js @@ -0,0 +1,38 @@ +import { GET } from "../helper.jsx"; + +const compact_params = (params) => + Object.fromEntries( + Object.entries(params).filter( + ([, value]) => value !== undefined && value !== null && value !== "", + ), + ); + +const report_url = (path, params = {}) => { + const query = new URLSearchParams(compact_params(params)).toString(); + return `${path}${query ? `?${query}` : ""}`; +}; + +const get_process_instance_duration = (state, params = {}) => + GET( + report_url("/history/process-instance/report", { + reportType: "duration", + periodUnit: "month", + ...params, + }), + state, + state.api.report.process_duration, + ); + +const get_task_report = (state, params = {}) => + GET( + report_url("/history/task/report", params), + state, + state.api.report.task, + ); + +const report = { + process_instance_duration: get_process_instance_duration, + task: get_task_report, +}; + +export default report; diff --git a/src/api/resources/report.test.js b/src/api/resources/report.test.js new file mode 100644 index 0000000..bd8f69f --- /dev/null +++ b/src/api/resources/report.test.js @@ -0,0 +1,65 @@ +import { describe, it, vi, beforeEach } from "vitest"; + +vi.mock("../helper.jsx", () => ({ + GET: vi.fn(), +})); + +import { GET } from "../helper.jsx"; +import { create_mock_state, expect_api_call } from "../../test/helpers.js"; +import report from "./report.js"; + +describe("api/resources/report", () => { + let state; + + beforeEach(() => { + state = create_mock_state(); + }); + + it("process_instance_duration() GETs the duration report with defaults", () => { + report.process_instance_duration(state); + expect_api_call(GET, { + url: "/history/process-instance/report?reportType=duration&periodUnit=month", + state, + signal: state.api.report.process_duration, + }); + }); + + it("process_instance_duration() appends filters", () => { + report.process_instance_duration(state, { + periodUnit: "quarter", + processDefinitionKeyIn: "invoice", + startedAfter: "2026-01-01T00:00:00.000+0000", + startedBefore: "2026-12-31T23:59:59.999+0000", + }); + expect_api_call(GET, { + url: "/history/process-instance/report?reportType=duration&periodUnit=quarter&processDefinitionKeyIn=invoice&startedAfter=2026-01-01T00%3A00%3A00.000%2B0000&startedBefore=2026-12-31T23%3A59%3A59.999%2B0000", + state, + signal: state.api.report.process_duration, + }); + }); + + it("task() GETs the historic task count report", () => { + report.task(state, { + reportType: "count", + groupBy: "processDefinition", + completedAfter: "2026-01-01T00:00:00.000+0000", + }); + expect_api_call(GET, { + url: "/history/task/report?reportType=count&groupBy=processDefinition&completedAfter=2026-01-01T00%3A00%3A00.000%2B0000", + state, + signal: state.api.report.task, + }); + }); + + it("task() GETs the historic task duration report", () => { + report.task(state, { + reportType: "duration", + periodUnit: "quarter", + }); + expect_api_call(GET, { + url: "/history/task/report?reportType=duration&periodUnit=quarter", + state, + signal: state.api.report.task, + }); + }); +}); diff --git a/src/components/GoTo.jsx b/src/components/GoTo.jsx index 87dde84..bdd79f4 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.reports", href: "/reports" }, { 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..c4d1caa 100644 --- a/src/components/GoTo.test.jsx +++ b/src/components/GoTo.test.jsx @@ -46,6 +46,12 @@ describe("GoTo", () => { const items = Array.from(container.querySelectorAll(".goto-item")); const hrefs = items.map((a) => a.getAttribute("href")); expect(hrefs).toContain("/tasks"); + type(input, "reports"); + expect( + Array.from(container.querySelectorAll(".goto-item")).map((a) => + a.getAttribute("href"), + ), + ).toContain("/reports"); // A non-matching query should yield no page entries. type(input, "zzzznotarealpage"); expect( diff --git a/src/components/Header.jsx b/src/components/Header.jsx index ce108ea..e772438 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -67,6 +67,7 @@ export function Header() {
  • {t("nav.deployments")}
  • {t("nav.batches")}
  • {t("nav.migrations")}
  • +
  • {t("nav.reports")}
  • {t("nav.admin")}
  • @@ -146,6 +147,11 @@ export function Header() { {t("nav.migrations")} +
  • + + {t("nav.reports")} + +
  • {t("nav.admin")} diff --git a/src/components/Header.test.jsx b/src/components/Header.test.jsx index a5f18b3..63fe8ae 100644 --- a/src/components/Header.test.jsx +++ b/src/components/Header.test.jsx @@ -61,7 +61,7 @@ describe("Header", () => { a.getAttribute("href"), ); // Logo (/), tasks, processes, decisions, deployments, batches, - // migrations, admin. + // migrations, reports, admin. expect(hrefs).toEqual([ "/", "/tasks", @@ -70,6 +70,7 @@ describe("Header", () => { "/deployments", "/batches", "/migrations", + "/reports", "/admin", ]); }); diff --git a/src/css/style.css b/src/css/style.css index 59fa993..e60302d 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -828,6 +828,57 @@ text-align: center; } + .reports { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--spacing-2); + height: 100%; + padding: var(--spacing-2); + } + .reports > header, + .reports > section > header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: start; + gap: var(--spacing-2); + } + .reports > header p { + color: var(--text-2); + margin: 0; + } + .reports > section { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + } + .reports-filter { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--spacing-1); + } + .reports-filter label { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 12rem; + } + .reports-filter input, + .reports-filter select { + width: 100%; + } + .reports-filter button { + margin: 0; + } + .reports table { + width: 100%; + } + .reports :where(td, th) { + vertical-align: top; + } + #admin-page { display: flex; flex: 1; diff --git a/src/index.jsx b/src/index.jsx index 075d533..7ba7272 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 { ReportsPage } from "./pages/Reports.jsx"; import { NotFound } from "./pages/_404.jsx"; import { AccountPage } from "./pages/Account.jsx"; @@ -90,6 +91,7 @@ const Routing = () => { component={DeploymentsPage} /> + new Date().toISOString().slice(0, 10); + +const start_of_year = () => { + const now = new Date(); + return `${now.getFullYear()}-01-01`; +}; + +const date_param = (value, end = false) => + value ? `${value}T${end ? "23:59:59.999" : "00:00:00.000"}+0000` : null; + +const process_report_params = (form) => ({ + periodUnit: form.period_unit.value, + processDefinitionKeyIn: form.process_definition_keys.value, + startedAfter: date_param(form.started_after.value), + startedBefore: date_param(form.started_before.value, true), +}); + +const task_report_params = (form) => ({ + reportType: form.report_type.value, + periodUnit: + form.report_type.value === "duration" ? form.period_unit.value : null, + groupBy: form.report_type.value === "count" ? form.group_by.value : null, + completedAfter: date_param(form.completed_after.value), + completedBefore: date_param(form.completed_before.value, true), +}); + +const load_reports = (state, process_form, task_form) => { + void engine_rest.report.process_instance_duration( + state, + process_report_params(process_form), + ); + void engine_rest.report.task(state, task_report_params(task_form)); +}; + +const num = (value) => (value ?? 0).toLocaleString(); + +const format_duration = (value) => { + if (value === null || value === undefined) return "-"; + const total_seconds = Math.round(Number(value) / 1000), + days = Math.floor(total_seconds / 86400), + hours = Math.floor((total_seconds % 86400) / 3600), + minutes = Math.floor((total_seconds % 3600) / 60), + seconds = total_seconds % 60; + + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +}; + +const csv_escape = (value) => { + const text = value === null || value === undefined ? "" : String(value); + return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +}; + +const download_text = (filename, type, content) => { + const url = URL.createObjectURL(new Blob([content], { type })), + link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +}; + +const export_json = (filename, rows) => + download_text( + filename, + "application/json", + JSON.stringify(rows ?? [], null, 2), + ); + +const export_csv = (filename, rows, columns) => { + const csv = [ + columns.map(({ label }) => csv_escape(label)).join(","), + ...(rows ?? []).map((row) => + columns.map(({ value }) => csv_escape(value(row))).join(","), + ), + ].join("\n"); + download_text(filename, "text/csv", csv); +}; + +const ReportsPage = () => { + const state = useContext(AppState), + [t] = useTranslation(), + process_form = { + period_unit: useSignal("month"), + process_definition_keys: useSignal(""), + started_after: useSignal(start_of_year()), + started_before: useSignal(today()), + }, + task_form = { + report_type: useSignal("count"), + period_unit: useSignal("month"), + group_by: useSignal("processDefinition"), + completed_after: useSignal(start_of_year()), + completed_before: useSignal(today()), + }; + + useEffect(() => { + load_reports(state, process_form, task_form); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
    +
    +
    +

    {t("reports.title")}

    +

    {t("reports.subtitle")}

    +
    + +
    + + + +
    + ); +}; + +const ProcessDurationReport = ({ form }) => { + const state = useContext(AppState), + [t] = useTranslation(), + signal = state.api.report.process_duration, + run = (event) => { + event.preventDefault(); + void engine_rest.report.process_instance_duration( + state, + process_report_params(form), + ); + }; + + return ( +
    +
    +

    {t("reports.process.title")}

    + +
    +
    + + + + + +
    + } + /> +
    + ); +}; + +const TaskReport = ({ form }) => { + const state = useContext(AppState), + [t] = useTranslation(), + signal = state.api.report.task, + run = (event) => { + event.preventDefault(); + void engine_rest.report.task(state, task_report_params(form)); + }; + + return ( +
    +
    +

    {t("reports.task.title")}

    + +
    +
    + + {form.report_type.value === "duration" ? ( + + ) : ( + + )} + + + +
    + + form.report_type.value === "duration" ? ( + + ) : ( + + ) + } + /> +
    + ); +}; + +const ExportButtons = ({ filename, signal, columns }) => { + const [t] = useTranslation(), + rows = signal.value?.data ?? [], + disabled = rows.length === 0; + + return ( +
    + + +
    + ); +}; + +const DurationTable = ({ rows }) => { + const [t] = useTranslation(); + if (rows.length === 0) + return

    {t("reports.no-results")}

    ; + + return ( + + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
    {t("reports.period.title")}{t("reports.minimum")}{t("reports.average")}{t("reports.maximum")}
    {format_period(row, t)}{format_duration(row.minimum)}{format_duration(row.average)}{format_duration(row.maximum)}
    + ); +}; + +const TaskCountTable = ({ rows }) => { + const [t] = useTranslation(); + if (rows.length === 0) + return

    {t("reports.no-results")}

    ; + + return ( + + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
    {t("reports.task.group")}{t("reports.task.process-definition")}{t("common.key")}{t("reports.count")}
    + {row.taskName ?? + row.taskDefinitionKey ?? + row.processDefinitionName ?? + "-"} + {row.processDefinitionName ?? row.processDefinitionId ?? "-"}{row.processDefinitionKey ?? "-"}{num(row.count)}
    + ); +}; + +const format_period = (row, t) => { + const unit = String(row.periodUnit ?? "").toLowerCase(); + if (unit === "quarter") + return t("reports.period.quarter-value", { quarter: row.period }); + return t("reports.period.month-value", { month: row.period }); +}; + +const duration_columns = (t) => [ + { label: t("reports.period.title"), value: (row) => format_period(row, t) }, + { label: t("reports.minimum"), value: (row) => row.minimum }, + { label: t("reports.average"), value: (row) => row.average }, + { label: t("reports.maximum"), value: (row) => row.maximum }, +]; + +const task_count_columns = (t) => [ + { + label: t("reports.task.group"), + value: (row) => row.taskName ?? row.taskDefinitionKey ?? "", + }, + { + label: t("reports.task.process-definition"), + value: (row) => row.processDefinitionName ?? row.processDefinitionId ?? "", + }, + { label: t("common.key"), value: (row) => row.processDefinitionKey ?? "" }, + { label: t("reports.count"), value: (row) => row.count }, +]; + +export { ReportsPage }; diff --git a/src/pages/Reports.test.jsx b/src/pages/Reports.test.jsx new file mode 100644 index 0000000..3ea6fea --- /dev/null +++ b/src/pages/Reports.test.jsx @@ -0,0 +1,121 @@ +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 { ReportsPage } from "./Reports.jsx"; +import { create_mock_state, signal_response } from "../test/helpers.js"; + +const renderPage = (state) => + render(h(AppState.Provider, { value: state }, h(ReportsPage, {}))); + +const populate_reports = (state) => { + signal_response(state.api.report.process_duration, [ + { period: 1, periodUnit: "MONTH", minimum: 1000, average: 2000, maximum: 3000 }, + ]); + signal_response(state.api.report.task, [ + { + taskName: "Approve invoice", + taskDefinitionKey: "approve", + processDefinitionId: "invoice:1", + processDefinitionKey: "invoice", + processDefinitionName: "Invoice", + count: 42, + }, + ]); +}; + +describe("ReportsPage", () => { + let state; + + beforeEach(() => { + state = create_mock_state(); + }); + + afterEach(cleanup); + + it("fetches process duration and task reports on mount", () => { + renderPage(state); + + expect(engine_rest.report.process_instance_duration).toHaveBeenCalled(); + expect(engine_rest.report.task).toHaveBeenCalled(); + expect(engine_rest.report.process_instance_duration.mock.lastCall[0]).toBe( + state, + ); + expect(engine_rest.report.task.mock.lastCall[0]).toBe(state); + expect(engine_rest.report.task.mock.lastCall[1]).toMatchObject({ + reportType: "count", + groupBy: "processDefinition", + }); + }); + + it("renders duration and completed task report rows", () => { + populate_reports(state); + + const { getByText } = renderPage(state); + + expect(getByText("1s")).toBeTruthy(); + expect(getByText("2s")).toBeTruthy(); + expect(getByText("3s")).toBeTruthy(); + expect(getByText("Approve invoice")).toBeTruthy(); + expect(getByText("Invoice")).toBeTruthy(); + expect(getByText("42")).toBeTruthy(); + }); + + it("runs the process duration report with filters", async () => { + populate_reports(state); + + const { container, getAllByText } = renderPage(state); + const process_section = container.querySelector(".reports > section"); + const inputs = process_section.querySelectorAll("input"); + fireEvent.input(inputs[0], { target: { value: "invoice" } }); + fireEvent.input(inputs[1], { target: { value: "2026-01-01" } }); + fireEvent.input(inputs[2], { target: { value: "2026-06-16" } }); + fireEvent.submit(getAllByText("reports.run")[0].closest("form")); + + await waitFor(() => + expect(engine_rest.report.process_instance_duration).toHaveBeenCalled(), + ); + const params = + engine_rest.report.process_instance_duration.mock.lastCall[1]; + expect(params).toMatchObject({ + processDefinitionKeyIn: "invoice", + startedAfter: "2026-01-01T00:00:00.000+0000", + startedBefore: "2026-06-16T23:59:59.999+0000", + }); + }); + + it("runs the task duration report with period aggregation", async () => { + populate_reports(state); + + const { container, getAllByText } = renderPage(state); + const task_section = container.querySelectorAll(".reports > section")[1]; + fireEvent.change(task_section.querySelector("select"), { + target: { value: "duration" }, + }); + fireEvent.submit(getAllByText("reports.run")[1].closest("form")); + + await waitFor(() => expect(engine_rest.report.task).toHaveBeenCalled()); + expect(engine_rest.report.task.mock.lastCall[1]).toMatchObject({ + reportType: "duration", + periodUnit: "month", + }); + }); +}); diff --git a/src/state.js b/src/state.js index f63ad2e..c218711 100644 --- a/src/state.js +++ b/src/state.js @@ -84,6 +84,10 @@ const createAppState = () => { update: signal(null), saved_filters: signal(null), }, + report: { + process_duration: signal(null), + task: signal(null), + }, tenant: { list: signal(null), by_member: signal(null), diff --git a/src/state.test.js b/src/state.test.js index c6b9968..4327958 100644 --- a/src/state.test.js +++ b/src/state.test.js @@ -57,6 +57,8 @@ describe("state", () => { expect(api.authorization.all.value).toBeNull(); expect(api.batch.list.value).toBeNull(); expect(api.batch.one.value).toBeNull(); + expect(api.report.process_duration.value).toBeNull(); + expect(api.report.task.value).toBeNull(); }); }); });