Add cron worker controls and align superuser specs

This commit is contained in:
Jeppe B
2026-07-13 19:47:08 +02:00
parent 9e27380a10
commit bbe4ad938a
7 changed files with 442 additions and 62 deletions
@@ -1,45 +1,78 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, 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 {
deployCronWorkers,
listCronRuns,
listCronTasks,
listCronWorkers,
runCronTask,
updateCronTask,
} from "@/services/superuserCron.js";
const { t, locale } = useI18n();
const RUN_POLL_INTERVAL_MS = 2500;
const RUN_POLL_ATTEMPTS = 6;
const IN_PROGRESS_STATUSES = new Set(["queued", "running"]);
const tasks = ref([]);
const runs = ref([]);
const workers = ref([]);
const workerSummary = ref({ total: 0, running: 0, stale: 0 });
const workerDeployment = ref(null);
const summary = ref({ total: 0, enabled: 0, due: 0 });
const intervalDrafts = ref({});
const loading = ref(false);
const runsLoading = ref(false);
const workersLoading = ref(false);
const errorMessage = ref("");
const queuedMessage = ref("");
const busyTaskId = ref(null);
const savingTaskId = ref(null);
const deployingWorker = ref(false);
const pollTimeouts = new Set();
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 canManage = computed(
() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_cron_manage")
);
const canDeployWorkers = computed(
() => canManage.value && (SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_coolify_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;
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 workerTargetLabel = computed(
() =>
workerDeployment.value?.target?.coolify_service_uuid ||
workerDeployment.value?.target?.coolify_resource_uuid ||
workerDeployment.value?.target?.id ||
t("cron.empty_value")
);
const hasWorkerDeploymentTarget = computed(
() =>
Boolean(workerDeployment.value?.target?.id) ||
Boolean(workerDeployment.value?.target?.coolify_service_uuid) ||
Boolean(workerDeployment.value?.target?.coolify_resource_uuid)
);
const workerDeploymentActionLabel = computed(() =>
hasWorkerDeploymentTarget.value ? t("cron.workers.update_deployment") : t("cron.workers.deploy_to_coolify")
);
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");
const parseError = (error) =>
error?.response?.data?.data?.message || error?.response?.data?.message || error?.message || t("cron.errors.generic");
function seedIntervalDrafts(nextTasks) {
const nextDrafts = {};
@@ -53,7 +86,11 @@ 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 };
summary.value = data.summary || {
total: nextTasks.length,
enabled: enabledTasks.value.length,
due: dueTasks.value.length,
};
seedIntervalDrafts(nextTasks);
}
@@ -67,11 +104,23 @@ async function loadRuns() {
}
}
async function loadWorkers() {
workersLoading.value = true;
try {
const data = responseData(await listCronWorkers(), {});
workers.value = Array.isArray(data.workers) ? data.workers : [];
workerSummary.value = data.summary || { total: workers.value.length, running: 0, stale: 0 };
workerDeployment.value = data.deployment || null;
} finally {
workersLoading.value = false;
}
}
async function refreshCron() {
loading.value = true;
errorMessage.value = "";
try {
await Promise.all([loadTasks(), loadRuns()]);
await Promise.all([loadTasks(), loadRuns(), loadWorkers()]);
} catch (error) {
errorMessage.value = parseError(error);
} finally {
@@ -82,12 +131,15 @@ async function refreshCron() {
async function runTask(task) {
busyTaskId.value = task.id;
errorMessage.value = "";
queuedMessage.value = "";
try {
const data = responseData(await runCronTask(task.id, { force: false }), null);
if (data) {
runs.value = [data, ...runs.value].slice(0, 25);
queuedMessage.value = t("cron.messages.queued", { task: task.name || task.id });
}
await loadTasks();
await Promise.all([loadTasks(), loadRuns()]);
void pollQueuedRun(data);
} catch (error) {
errorMessage.value = parseError(error);
} finally {
@@ -95,6 +147,54 @@ async function runTask(task) {
}
}
async function deployWorkers() {
deployingWorker.value = true;
errorMessage.value = "";
queuedMessage.value = "";
const updatingDeployment = hasWorkerDeploymentTarget.value;
try {
await deployCronWorkers({});
await loadWorkers();
queuedMessage.value = t(
updatingDeployment ? "cron.messages.worker_update_queued" : "cron.messages.worker_deploy_queued"
);
} catch (error) {
errorMessage.value = parseError(error);
} finally {
deployingWorker.value = false;
}
}
function waitForPollDelay() {
return new Promise((resolve) => {
const timeout = window.setTimeout(() => {
pollTimeouts.delete(timeout);
resolve();
}, RUN_POLL_INTERVAL_MS);
pollTimeouts.add(timeout);
});
}
async function pollQueuedRun(run) {
const runId = run?.id;
if (!runId || !IN_PROGRESS_STATUSES.has(String(run?.status || ""))) {
return;
}
try {
for (let attempt = 0; attempt < RUN_POLL_ATTEMPTS; attempt += 1) {
await waitForPollDelay();
await Promise.all([loadTasks(), loadRuns()]);
const currentRun = runs.value.find((candidate) => Number(candidate.id) === Number(runId));
if (!currentRun || !IN_PROGRESS_STATUSES.has(String(currentRun.status || ""))) {
return;
}
}
} catch (error) {
errorMessage.value = parseError(error);
}
}
async function toggleTask(task) {
savingTaskId.value = task.id;
errorMessage.value = "";
@@ -123,12 +223,15 @@ async function saveInterval(task) {
savingTaskId.value = task.id;
errorMessage.value = "";
try {
const data = responseData(await updateCronTask(task.id, {
schedule: {
type: "interval",
seconds,
},
}), {});
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;
@@ -214,12 +317,24 @@ function statusClass(status) {
if (status === "running") {
return "is-info";
}
if (status === "queued") {
return "is-warning";
}
return "is-light";
}
function runStartedLabel(run) {
return formatDate(run.started_at || run.scheduled_for || run.created_at);
}
onMounted(() => {
void refreshCron();
});
onBeforeUnmount(() => {
pollTimeouts.forEach((timeout) => window.clearTimeout(timeout));
pollTimeouts.clear();
});
</script>
<template>
@@ -247,7 +362,13 @@ onMounted(() => {
<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">
<button
class="button is-light"
type="button"
:class="{ 'is-loading': loading }"
@click="refreshCron"
data-testid="cron-refresh"
>
{{ t("cron.actions.refresh") }}
</button>
</div>
@@ -255,6 +376,9 @@ onMounted(() => {
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="cron-error">
{{ errorMessage }}
</div>
<div v-if="queuedMessage" class="notification is-info is-light" data-testid="cron-run-queued">
{{ queuedMessage }}
</div>
<div class="table-container cron-table-container">
<table class="table is-fullwidth is-hoverable cron-table" data-testid="cron-task-table">
@@ -348,10 +472,85 @@ onMounted(() => {
</table>
</div>
<section class="cron-worker-panel" data-testid="cron-worker-panel">
<div class="cron-section-heading">
<h2>{{ t("cron.workers.title") }}</h2>
<button
class="button is-small is-light"
type="button"
:disabled="!canDeployWorkers || deployingWorker"
:class="{ 'is-loading': deployingWorker }"
@click="deployWorkers"
data-testid="cron-worker-deploy"
>
{{ workerDeploymentActionLabel }}
</button>
</div>
<div class="cron-stats" aria-live="polite">
<div class="cron-stat" data-testid="cron-worker-total">
<span>{{ t("cron.workers.total") }}</span>
<strong>{{ workerSummary.total ?? workers.length }}</strong>
</div>
<div class="cron-stat" data-testid="cron-worker-running">
<span>{{ t("cron.workers.running") }}</span>
<strong>{{ workerSummary.running ?? 0 }}</strong>
</div>
<div class="cron-stat" data-testid="cron-worker-stale">
<span>{{ t("cron.workers.stale") }}</span>
<strong>{{ workerSummary.stale ?? 0 }}</strong>
</div>
<div class="cron-stat" data-testid="cron-worker-target">
<span>{{ t("cron.workers.target") }}</span>
<strong>{{ workerTargetLabel }}</strong>
</div>
</div>
<div class="table-container cron-table-container">
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>{{ t("cron.workers.name") }}</th>
<th>{{ t("cron.workers.source") }}</th>
<th>{{ t("cron.workers.status") }}</th>
<th>{{ t("cron.workers.heartbeat") }}</th>
<th>{{ t("cron.workers.last_run_count") }}</th>
</tr>
</thead>
<tbody>
<tr v-if="workersLoading && workers.length === 0">
<td colspan="5">{{ t("cron.workers.loading") }}</td>
</tr>
<tr v-else-if="workers.length === 0">
<td colspan="5">{{ t("cron.workers.empty") }}</td>
</tr>
<tr v-for="worker in workers" :key="worker.worker_id">
<td>
<strong>{{ worker.name || worker.worker_id }}</strong>
<small>{{ worker.hostname || t("cron.empty_value") }}</small>
</td>
<td>{{ worker.source }}</td>
<td>
<span class="tag" :class="statusClass(worker.status)">
{{ statusLabel(worker.status) }}
</span>
<small v-if="worker.stale">{{ t("cron.workers.stale") }}</small>
</td>
<td>{{ formatDate(worker.last_heartbeat_at) }}</td>
<td>{{ worker.last_run_count ?? 0 }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<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">
<button
class="button is-small is-light"
type="button"
:class="{ 'is-loading': runsLoading }"
@click="loadRuns"
>
{{ t("cron.actions.refresh") }}
</button>
</div>
@@ -376,7 +575,7 @@ onMounted(() => {
<td>
<span class="tag" :class="statusClass(run.status)">{{ statusLabel(run.status) }}</span>
</td>
<td>{{ formatDate(run.started_at) }}</td>
<td>{{ runStartedLabel(run) }}</td>
<td>{{ formatDuration(run.duration_ms) }}</td>
</tr>
</tbody>
@@ -427,6 +626,7 @@ onMounted(() => {
display: block;
font-size: 1rem;
margin-top: 0.25rem;
overflow-wrap: anywhere;
}
.cron-table-container {
@@ -456,7 +656,8 @@ onMounted(() => {
white-space: nowrap;
}
.cron-history h2 {
.cron-history h2,
.cron-worker-panel h2 {
font-size: 1.1rem;
font-weight: 700;
margin: 0;