From 1326a40033e3ffa8cca4bfc851f4ddbc5442a205 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Thu, 9 Jul 2026 11:12:02 +0200 Subject: [PATCH] Add superuser cron operations UI --- .../items/NavigationMenuItemsSuperUser.vue | 11 + src/i18n/generated/global-v2.json | 68 +++ src/i18n/source/global/shared/cron/index.json | 70 +++ src/router.js | 7 + src/services/superuserCron.js | 22 + .../system/CronOperations.vue | 466 ++++++++++++++++++ tests/e2e/superuser-cron.spec.ts | 167 +++++++ 7 files changed, 811 insertions(+) create mode 100644 src/i18n/source/global/shared/cron/index.json create mode 100644 src/services/superuserCron.js create mode 100644 src/views/dashboards/superUserDashboard/system/CronOperations.vue create mode 100644 tests/e2e/superuser-cron.spec.ts diff --git a/src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue b/src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue index 2cc7a714..0fee3360 100644 --- a/src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue +++ b/src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue @@ -463,6 +463,17 @@ const items = computed(() => [ }, ], }, + // System + { + label: t("cron.system_nav"), + type: "category", + children: [ + { label: t("system_status.title"), to: "/superuser" }, + { label: t("cron.nav"), to: "/superuser/system/cron" }, + { label: t("system_status.cards.database"), to: "/superuser/system/database" }, + { label: t("cron.replication"), to: "/superuser/system/replication" }, + ], + }, // Other { label: t("superuser.nav.other"), diff --git a/src/i18n/generated/global-v2.json b/src/i18n/generated/global-v2.json index df92bcf8..f530cc0b 100644 --- a/src/i18n/generated/global-v2.json +++ b/src/i18n/generated/global-v2.json @@ -2061,6 +2061,74 @@ "subtitle": "@:{'templates.generated.compat.connectivity.subtitle'}", "title": "@:{'templates.generated.compat.connectivity.title'}" }, + "cron": { + "actions": { + "edit_interval": "Edit interval", + "refresh": "Refresh", + "run_now": "Run now", + "save": "Save", + "toggle": "Toggle cron task" + }, + "duration": { + "ms": "{value} ms", + "seconds": "{value} s" + }, + "empty": "No cron tasks found.", + "empty_value": "--", + "errors": { + "generic": "Unable to load cron tasks." + }, + "history": { + "duration": "Duration", + "empty": "No cron runs found.", + "source": "Source", + "started": "Started", + "status": "Status", + "task": "Task", + "title": "Run history" + }, + "interval": { + "hours": "Every {value} h", + "minutes": "Every {value} min", + "seconds": "Every {value} s" + }, + "loading": "Loading cron tasks...", + "nav": "Cron tasks", + "replication": "Replication", + "states": { + "disabled": "Disabled", + "due_now": "Due now", + "enabled": "Enabled" + }, + "status": { + "failed": "Failed", + "queued": "Queued", + "running": "Running", + "skipped": "Skipped", + "succeeded": "Succeeded", + "timed_out": "Timed out", + "unknown": "Unknown" + }, + "subtitle": "Configure schedules, run tasks manually, and inspect recent cron execution.", + "summary": { + "due": "Due", + "enabled": "Enabled", + "next": "Next run", + "total": "Tasks" + }, + "system_nav": "System", + "table": { + "actions": "Actions", + "enabled": "Enabled", + "estimate": "Estimate", + "module": "Module", + "next_run": "Next run", + "schedule": "Schedule", + "status": "Status", + "task": "Task" + }, + "title": "Cron tasks" + }, "customer_creation": { "customer": { "benefit_credit": "@:{'templates.generated.compat.customer_creation.customer.benefit_credit'}", diff --git a/src/i18n/source/global/shared/cron/index.json b/src/i18n/source/global/shared/cron/index.json new file mode 100644 index 00000000..c8c9ebce --- /dev/null +++ b/src/i18n/source/global/shared/cron/index.json @@ -0,0 +1,70 @@ +{ + "cron": { + "actions": { + "edit_interval": "Edit interval", + "refresh": "Refresh", + "run_now": "Run now", + "save": "Save", + "toggle": "Toggle cron task" + }, + "duration": { + "ms": "{value} ms", + "seconds": "{value} s" + }, + "empty": "No cron tasks found.", + "empty_value": "--", + "errors": { + "generic": "Unable to load cron tasks." + }, + "history": { + "duration": "Duration", + "empty": "No cron runs found.", + "source": "Source", + "started": "Started", + "status": "Status", + "task": "Task", + "title": "Run history" + }, + "interval": { + "hours": "Every {value} h", + "minutes": "Every {value} min", + "seconds": "Every {value} s" + }, + "loading": "Loading cron tasks...", + "nav": "Cron tasks", + "replication": "Replication", + "states": { + "disabled": "Disabled", + "due_now": "Due now", + "enabled": "Enabled" + }, + "status": { + "failed": "Failed", + "queued": "Queued", + "running": "Running", + "skipped": "Skipped", + "succeeded": "Succeeded", + "timed_out": "Timed out", + "unknown": "Unknown" + }, + "subtitle": "Configure schedules, run tasks manually, and inspect recent cron execution.", + "summary": { + "due": "Due", + "enabled": "Enabled", + "next": "Next run", + "total": "Tasks" + }, + "system_nav": "System", + "table": { + "actions": "Actions", + "enabled": "Enabled", + "estimate": "Estimate", + "module": "Module", + "next_run": "Next run", + "schedule": "Schedule", + "status": "Status", + "task": "Task" + }, + "title": "Cron tasks" + } +} diff --git a/src/router.js b/src/router.js index 81372dd3..7756e4a2 100644 --- a/src/router.js +++ b/src/router.js @@ -83,6 +83,7 @@ const MyOrders = lazyView('@/views/dashboards/userDashboard/orders/MyOrders.vue' const NewVehicle = lazyView('@/views/dashboards/userDashboard/vehicles/NewVehicle.vue'); const MyBookings = lazyView('@/views/dashboards/userDashboard/bookings/MyBookings.vue'); const DatabaseOverview = lazyView('@/views/dashboards/superUserDashboard/system/DatabaseOverview.vue'); +const CronOperations = lazyView('@/views/dashboards/superUserDashboard/system/CronOperations.vue'); const ReplicationManagement = lazyView('@/views/dashboards/superUserDashboard/system/ReplicationManagement.vue'); const DepartmentBookings = lazyView('@/views/dashboards/departmentDashboard/modules/bookings/DepartmentBookings.vue'); const Employee = lazyView('@/views/auth/Employee.vue'); @@ -1121,6 +1122,12 @@ export const router = createRouter({ component: DatabaseOverview, meta: { middleware: superUserMiddleware } }, + { + name: 'systemcron', + path: '/superuser/system/cron', + component: CronOperations, + meta: { middleware: superUserMiddleware } + }, { name: 'systemreplication', path: '/superuser/system/replication', diff --git a/src/services/superuserCron.js b/src/services/superuserCron.js new file mode 100644 index 00000000..881b7508 --- /dev/null +++ b/src/services/superuserCron.js @@ -0,0 +1,22 @@ +import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; + +export const listCronTasks = () => + authenticatedRequest("/superuser/cron", "GET", {}); + +export const listCronRuns = ({ taskId = null, limit = 50 } = {}) => + authenticatedRequest("/superuser/cron/runs", "GET", { + ...(taskId ? { task_id: taskId } : {}), + limit, + }); + +export const runCronTask = (taskId, { force = false } = {}) => + authenticatedRequest("/superuser/cron/run", "POST", { + task_id: taskId, + force, + }); + +export const updateCronTask = (taskId, payload) => + authenticatedRequest("/superuser/cron/config", "PATCH", { + task_id: taskId, + ...payload, + }); diff --git a/src/views/dashboards/superUserDashboard/system/CronOperations.vue b/src/views/dashboards/superUserDashboard/system/CronOperations.vue new file mode 100644 index 00000000..1b892f39 --- /dev/null +++ b/src/views/dashboards/superUserDashboard/system/CronOperations.vue @@ -0,0 +1,466 @@ + + + + + diff --git a/tests/e2e/superuser-cron.spec.ts b/tests/e2e/superuser-cron.spec.ts new file mode 100644 index 00000000..2a15972c --- /dev/null +++ b/tests/e2e/superuser-cron.spec.ts @@ -0,0 +1,167 @@ +import { expect, test } from "@playwright/test"; +import { apiPathPattern, mockApi, primeMockSession } from "./support/network.js"; + +const cronTasks = [ + { + id: "economic.transfer_queue", + name: "Process e-conomic transfer queue", + description: "Processes pending e-conomic transfer queue jobs.", + module: "economic", + schedule: { type: "interval", seconds: 30 }, + default_schedule: { type: "interval", seconds: 30 }, + enabled: true, + default_enabled: true, + estimated_duration_ms: 2400, + next_run_at: "2026-07-09 12:01:00", + last_status: "succeeded", + due: true, + }, + { + id: "system.sync_logs", + name: "Sync logs", + description: "Flushes application logs.", + module: "system", + schedule: { type: "interval", seconds: 300 }, + default_schedule: { type: "interval", seconds: 300 }, + enabled: false, + default_enabled: true, + estimated_duration_ms: 900, + next_run_at: "2026-07-09 12:05:00", + last_status: "failed", + last_error: "Redis unavailable", + due: false, + }, +]; + +function cronListPayload(tasks = cronTasks) { + return { + success: true, + data: { + tasks, + summary: { + total: tasks.length, + enabled: tasks.filter((task) => task.enabled).length, + due: tasks.filter((task) => task.enabled && task.due).length, + }, + }, + meta: {}, + includes: {}, + }; +} + +test.describe("Superuser cron operations", () => { + test.beforeEach(async ({ page }) => { + await mockApi(page, { + authenticated: true, + permissions: ["superuser", "superuser_cron_view", "SUPERUSER_RUN_CRON", "superuser_cron_manage"], + }); + await primeMockSession(page, { token: "superuser-cron-token", bootPath: null }); + }); + + test("superusers can inspect, run, toggle, and reschedule cron tasks", async ({ page }) => { + const patchPayloads: Array> = []; + const runPayloads: Array> = []; + let currentTasks = cronTasks.map((task) => ({ ...task, schedule: { ...task.schedule } })); + + await page.route(apiPathPattern("/superuser/cron"), async (route) => { + if (route.request().method() !== "GET") { + await route.fallback(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(cronListPayload(currentTasks)), + }); + }); + + await page.route(apiPathPattern("/superuser/cron/runs"), async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + success: true, + data: { + runs: [ + { + id: 41, + task_id: "economic.transfer_queue", + source: "automatic", + status: "succeeded", + started_at: "2026-07-09 12:00:00", + duration_ms: 2100, + }, + ], + }, + meta: {}, + includes: {}, + }), + }); + }); + + await page.route(apiPathPattern("/superuser/cron/run"), async (route) => { + runPayloads.push(route.request().postDataJSON() as Record); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + success: true, + data: { + id: 42, + task_id: "economic.transfer_queue", + source: "manual", + status: "succeeded", + started_at: "2026-07-09 12:02:00", + duration_ms: 1800, + }, + meta: {}, + includes: {}, + }), + }); + }); + + await page.route(apiPathPattern("/superuser/cron/config"), async (route) => { + const payload = route.request().postDataJSON() as Record; + patchPayloads.push(payload); + currentTasks = currentTasks.map((task) => { + if (task.id !== payload.task_id) { + return task; + } + return { + ...task, + ...(typeof payload.enabled === "boolean" ? { enabled: payload.enabled } : {}), + ...(payload.schedule ? { schedule: payload.schedule as { type: string; seconds: number } } : {}), + }; + }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(cronListPayload(currentTasks)), + }); + }); + + await page.goto("/superuser/system/cron", { waitUntil: "domcontentloaded" }); + + await expect(page.getByTestId("superuser-cron-page")).toBeVisible(); + await expect(page.getByTestId("cron-task-row-economic.transfer_queue")).toContainText("Process e-conomic transfer queue"); + await expect(page.getByTestId("cron-total-tasks")).toContainText("2"); + + await Promise.all([ + page.waitForResponse((response) => response.url().includes("/superuser/cron/run") && response.request().method() === "POST"), + page.getByTestId("cron-task-run-economic.transfer_queue").click(), + ]); + + expect(runPayloads).toEqual([{ task_id: "economic.transfer_queue", force: false }]); + await expect(page.getByTestId("cron-run-history")).toContainText("manual"); + + await page.getByTestId("cron-task-toggle-system.sync_logs").click(); + expect(patchPayloads.at(-1)).toMatchObject({ task_id: "system.sync_logs", enabled: true }); + + await page.getByTestId("cron-task-interval-economic.transfer_queue").fill("120"); + await page.getByTestId("cron-task-save-economic.transfer_queue").click(); + expect(patchPayloads.at(-1)).toMatchObject({ + task_id: "economic.transfer_queue", + schedule: { type: "interval", seconds: 120 }, + }); + }); +});