Add superuser cron operations UI

This commit is contained in:
Jeppe B
2026-07-09 11:12:02 +02:00
parent 85be8d2ce7
commit 1326a40033
7 changed files with 811 additions and 0 deletions
@@ -463,6 +463,17 @@ const items = computed<NavigationItemProps[]>(() => [
},
],
},
// 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"),
+68
View File
@@ -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'}",
@@ -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"
}
}
+7
View File
@@ -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',
+22
View File
@@ -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,
});
@@ -0,0 +1,466 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
import {
listCronRuns,
listCronTasks,
runCronTask,
updateCronTask,
} from "@/services/superuserCron.js";
const { t, locale } = useI18n();
const tasks = ref([]);
const runs = ref([]);
const summary = ref({ total: 0, enabled: 0, due: 0 });
const intervalDrafts = ref({});
const loading = ref(false);
const runsLoading = ref(false);
const errorMessage = ref("");
const busyTaskId = ref(null);
const savingTaskId = ref(null);
const canView = computed(() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_cron_view"));
const canRun = computed(() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("SUPERUSER_RUN_CRON"));
const canManage = computed(() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_cron_manage"));
const enabledTasks = computed(() => tasks.value.filter((task) => task.enabled));
const dueTasks = computed(() => enabledTasks.value.filter((task) => task.due));
const nextTask = computed(() => {
return [...enabledTasks.value]
.filter((task) => task.next_run_at)
.sort((left, right) => Date.parse(left.next_run_at) - Date.parse(right.next_run_at))[0] || null;
});
const responseData = (response, fallback) => response?.data?.data ?? fallback;
const parseError = (error) => error?.response?.data?.data?.message
|| error?.response?.data?.message
|| error?.message
|| t("cron.errors.generic");
function seedIntervalDrafts(nextTasks) {
const nextDrafts = {};
nextTasks.forEach((task) => {
nextDrafts[task.id] = Number(task?.schedule?.seconds || task?.default_schedule?.seconds || 60);
});
intervalDrafts.value = nextDrafts;
}
async function loadTasks() {
const data = responseData(await listCronTasks(), {});
const nextTasks = Array.isArray(data.tasks) ? data.tasks : [];
tasks.value = nextTasks;
summary.value = data.summary || { total: nextTasks.length, enabled: enabledTasks.value.length, due: dueTasks.value.length };
seedIntervalDrafts(nextTasks);
}
async function loadRuns() {
runsLoading.value = true;
try {
const data = responseData(await listCronRuns({ limit: 25 }), {});
runs.value = Array.isArray(data.runs) ? data.runs : [];
} finally {
runsLoading.value = false;
}
}
async function refreshCron() {
loading.value = true;
errorMessage.value = "";
try {
await Promise.all([loadTasks(), loadRuns()]);
} catch (error) {
errorMessage.value = parseError(error);
} finally {
loading.value = false;
}
}
async function runTask(task) {
busyTaskId.value = task.id;
errorMessage.value = "";
try {
const data = responseData(await runCronTask(task.id, { force: false }), null);
if (data) {
runs.value = [data, ...runs.value].slice(0, 25);
}
await loadTasks();
} catch (error) {
errorMessage.value = parseError(error);
} finally {
busyTaskId.value = null;
}
}
async function toggleTask(task) {
savingTaskId.value = task.id;
errorMessage.value = "";
try {
const data = responseData(await updateCronTask(task.id, { enabled: !task.enabled }), {});
if (Array.isArray(data.tasks)) {
tasks.value = data.tasks;
summary.value = data.summary || summary.value;
seedIntervalDrafts(data.tasks);
} else {
await loadTasks();
}
} catch (error) {
errorMessage.value = parseError(error);
} finally {
savingTaskId.value = null;
}
}
async function saveInterval(task) {
const seconds = Math.max(30, Number.parseInt(intervalDrafts.value[task.id], 10) || task?.schedule?.seconds || 60);
intervalDrafts.value = {
...intervalDrafts.value,
[task.id]: seconds,
};
savingTaskId.value = task.id;
errorMessage.value = "";
try {
const data = responseData(await updateCronTask(task.id, {
schedule: {
type: "interval",
seconds,
},
}), {});
if (Array.isArray(data.tasks)) {
tasks.value = data.tasks;
summary.value = data.summary || summary.value;
seedIntervalDrafts(data.tasks);
} else {
await loadTasks();
}
} catch (error) {
errorMessage.value = parseError(error);
} finally {
savingTaskId.value = null;
}
}
function formatDate(value) {
if (!value) {
return t("cron.empty_value");
}
const timestamp = Date.parse(String(value).replace(" ", "T"));
if (!Number.isFinite(timestamp)) {
return String(value);
}
return new Intl.DateTimeFormat(locale.value, {
dateStyle: "short",
timeStyle: "medium",
}).format(new Date(timestamp));
}
function formatDuration(milliseconds) {
const value = Number(milliseconds || 0);
if (!Number.isFinite(value) || value <= 0) {
return t("cron.empty_value");
}
if (value < 1000) {
return t("cron.duration.ms", { value });
}
return t("cron.duration.seconds", { value: (value / 1000).toFixed(1) });
}
function scheduleLabel(task) {
const seconds = Number(task?.schedule?.seconds || 0);
if (seconds <= 0) {
return t("cron.empty_value");
}
if (seconds % 3600 === 0) {
return t("cron.interval.hours", { value: seconds / 3600 });
}
if (seconds % 60 === 0) {
return t("cron.interval.minutes", { value: seconds / 60 });
}
return t("cron.interval.seconds", { value: seconds });
}
function statusLabel(status) {
const key = `cron.status.${status || "unknown"}`;
const translated = t(key);
return translated === key ? (status || t("cron.status.unknown")) : translated;
}
function statusClass(status) {
if (status === "succeeded") {
return "is-success";
}
if (status === "failed" || status === "timed_out") {
return "is-danger";
}
if (status === "running") {
return "is-info";
}
return "is-light";
}
onMounted(() => {
void refreshCron();
});
</script>
<template>
<RestrictedPageWrapper :hasPermission="canView">
<SuperUserDashboardNavigation />
<PageTitle :title="t('cron.title')" :subtitle="t('cron.subtitle')" />
<section class="cron-page" data-testid="superuser-cron-page">
<div class="cron-toolbar">
<div class="cron-stats" aria-live="polite">
<div class="cron-stat" data-testid="cron-total-tasks">
<span>{{ t("cron.summary.total") }}</span>
<strong>{{ summary.total ?? tasks.length }}</strong>
</div>
<div class="cron-stat" data-testid="cron-enabled-tasks">
<span>{{ t("cron.summary.enabled") }}</span>
<strong>{{ summary.enabled ?? enabledTasks.length }}</strong>
</div>
<div class="cron-stat" data-testid="cron-due-tasks">
<span>{{ t("cron.summary.due") }}</span>
<strong>{{ summary.due ?? dueTasks.length }}</strong>
</div>
<div class="cron-stat" data-testid="cron-next-task">
<span>{{ t("cron.summary.next") }}</span>
<strong>{{ nextTask ? formatDate(nextTask.next_run_at) : t("cron.empty_value") }}</strong>
</div>
</div>
<button class="button is-light" type="button" :class="{ 'is-loading': loading }" @click="refreshCron" data-testid="cron-refresh">
{{ t("cron.actions.refresh") }}
</button>
</div>
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="cron-error">
{{ errorMessage }}
</div>
<div class="table-container cron-table-container">
<table class="table is-fullwidth is-hoverable cron-table" data-testid="cron-task-table">
<thead>
<tr>
<th>{{ t("cron.table.task") }}</th>
<th>{{ t("cron.table.module") }}</th>
<th>{{ t("cron.table.schedule") }}</th>
<th>{{ t("cron.table.next_run") }}</th>
<th>{{ t("cron.table.estimate") }}</th>
<th>{{ t("cron.table.status") }}</th>
<th>{{ t("cron.table.enabled") }}</th>
<th>{{ t("cron.table.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loading && tasks.length === 0">
<td colspan="8">{{ t("cron.loading") }}</td>
</tr>
<tr v-else-if="tasks.length === 0">
<td colspan="8">{{ t("cron.empty") }}</td>
</tr>
<tr v-for="task in tasks" :key="task.id" :data-testid="`cron-task-row-${task.id}`">
<td>
<strong>{{ task.name }}</strong>
<small>{{ task.description }}</small>
</td>
<td>{{ task.module }}</td>
<td>
<div class="cron-interval-control">
<input
v-model.number="intervalDrafts[task.id]"
class="input is-small"
type="number"
min="30"
step="30"
:disabled="!canManage || savingTaskId === task.id"
:aria-label="t('cron.actions.edit_interval')"
:data-testid="`cron-task-interval-${task.id}`"
/>
<button
class="button is-small is-light"
type="button"
:disabled="!canManage || savingTaskId === task.id"
:class="{ 'is-loading': savingTaskId === task.id }"
@click="saveInterval(task)"
:data-testid="`cron-task-save-${task.id}`"
>
{{ t("cron.actions.save") }}
</button>
</div>
<small>{{ scheduleLabel(task) }}</small>
</td>
<td>
<span>{{ formatDate(task.next_run_at) }}</span>
<small v-if="task.due">{{ t("cron.states.due_now") }}</small>
</td>
<td>{{ formatDuration(task.estimated_duration_ms) }}</td>
<td>
<span class="tag" :class="statusClass(task.last_status)">
{{ statusLabel(task.last_status) }}
</span>
<small v-if="task.last_error">{{ task.last_error }}</small>
</td>
<td>
<label class="cron-toggle">
<input
type="checkbox"
:checked="task.enabled"
:disabled="!canManage || savingTaskId === task.id"
@change="toggleTask(task)"
:data-testid="`cron-task-toggle-${task.id}`"
/>
<span>{{ task.enabled ? t("cron.states.enabled") : t("cron.states.disabled") }}</span>
</label>
</td>
<td>
<button
class="button is-small is-primary"
type="button"
:disabled="!canRun || busyTaskId === task.id"
:class="{ 'is-loading': busyTaskId === task.id }"
@click="runTask(task)"
:data-testid="`cron-task-run-${task.id}`"
>
{{ t("cron.actions.run_now") }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<section class="cron-history" data-testid="cron-run-history">
<div class="cron-section-heading">
<h2>{{ t("cron.history.title") }}</h2>
<button class="button is-small is-light" type="button" :class="{ 'is-loading': runsLoading }" @click="loadRuns">
{{ t("cron.actions.refresh") }}
</button>
</div>
<div class="table-container">
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>{{ t("cron.history.task") }}</th>
<th>{{ t("cron.history.source") }}</th>
<th>{{ t("cron.history.status") }}</th>
<th>{{ t("cron.history.started") }}</th>
<th>{{ t("cron.history.duration") }}</th>
</tr>
</thead>
<tbody>
<tr v-if="runs.length === 0">
<td colspan="5">{{ t("cron.history.empty") }}</td>
</tr>
<tr v-for="run in runs" :key="run.id || `${run.task_id}-${run.started_at}`">
<td>{{ run.task_id }}</td>
<td>{{ run.source }}</td>
<td>
<span class="tag" :class="statusClass(run.status)">{{ statusLabel(run.status) }}</span>
</td>
<td>{{ formatDate(run.started_at) }}</td>
<td>{{ formatDuration(run.duration_ms) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</section>
</RestrictedPageWrapper>
</template>
<style scoped>
.cron-page {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0 1rem 2rem;
}
.cron-toolbar,
.cron-section-heading {
align-items: center;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.cron-stats {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(4, minmax(8rem, 1fr));
width: 100%;
}
.cron-stat {
border: 1px solid #d7dde5;
border-radius: 6px;
padding: 0.75rem;
}
.cron-stat span,
.cron-table small {
color: #667085;
display: block;
font-size: 0.8rem;
}
.cron-stat strong {
display: block;
font-size: 1rem;
margin-top: 0.25rem;
}
.cron-table-container {
border: 1px solid #d7dde5;
border-radius: 6px;
}
.cron-table td {
vertical-align: middle;
}
.cron-interval-control {
align-items: center;
display: flex;
gap: 0.5rem;
min-width: 12rem;
}
.cron-interval-control .input {
max-width: 6rem;
}
.cron-toggle {
align-items: center;
display: inline-flex;
gap: 0.4rem;
white-space: nowrap;
}
.cron-history h2 {
font-size: 1.1rem;
font-weight: 700;
margin: 0;
}
@media (max-width: 900px) {
.cron-toolbar,
.cron-section-heading {
align-items: stretch;
flex-direction: column;
}
.cron-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.cron-stats {
grid-template-columns: 1fr;
}
}
</style>
+167
View File
@@ -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<Record<string, unknown>> = [];
const runPayloads: Array<Record<string, unknown>> = [];
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<string, unknown>);
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<string, unknown>;
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 },
});
});
});