Add cron worker controls and align superuser specs
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user