Add superuser cron operations UI
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user