919 lines
29 KiB
Vue
919 lines
29 KiB
Vue
<script setup>
|
|
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 WORKER_POLL_INTERVAL_MS = 5000;
|
|
const WORKER_POLL_ATTEMPTS = 24;
|
|
const IN_PROGRESS_STATUSES = new Set(["queued", "running"]);
|
|
const WORKER_PENDING_STATES = new Set(["deploying", "waiting_for_heartbeat"]);
|
|
|
|
const tasks = ref([]);
|
|
const runs = ref([]);
|
|
const workers = ref([]);
|
|
const workerStatus = ref(null);
|
|
const workerChannels = ref([]);
|
|
const workerSummary = ref({ total: 0, running: 0, stale: 0 });
|
|
const workerDeployment = ref(null);
|
|
const selectedWorkerChannelId = 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 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
|
|
);
|
|
});
|
|
const workerTargetLabel = computed(
|
|
() =>
|
|
workerStatus.value?.cron_target?.coolify_service_uuid ||
|
|
workerStatus.value?.cron_target?.coolify_resource_uuid ||
|
|
workerStatus.value?.cron_target?.id ||
|
|
workerDeployment.value?.target?.coolify_service_uuid ||
|
|
workerDeployment.value?.target?.coolify_resource_uuid ||
|
|
workerDeployment.value?.target?.id ||
|
|
t("cron.empty_value")
|
|
);
|
|
const hasWorkerDeploymentTarget = computed(
|
|
() =>
|
|
Boolean(workerStatus.value?.cron_target?.id) ||
|
|
Boolean(workerDeployment.value?.target?.id) ||
|
|
Boolean(workerStatus.value?.cron_target?.coolify_service_uuid) ||
|
|
Boolean(workerDeployment.value?.target?.coolify_service_uuid) ||
|
|
Boolean(workerDeployment.value?.target?.coolify_resource_uuid)
|
|
);
|
|
const workerDeploymentAction = computed(
|
|
() =>
|
|
workerStatus.value?.deployment?.action ||
|
|
workerDeployment.value?.action ||
|
|
(hasWorkerDeploymentTarget.value ? "update" : "create")
|
|
);
|
|
const workerDeploymentCanDeploy = computed(() => {
|
|
const canDeploy = workerStatus.value?.deployment?.can_deploy ?? workerDeployment.value?.can_deploy;
|
|
return canDeploy === undefined || canDeploy === null ? true : Boolean(canDeploy);
|
|
});
|
|
const workerDeploymentActionLabel = computed(() => {
|
|
if (workerDeploymentAction.value === "repair") {
|
|
return t("cron.workers.repair_deployment");
|
|
}
|
|
if (workerDeploymentAction.value === "update") {
|
|
return t("cron.workers.update_deployment");
|
|
}
|
|
return t("cron.workers.deploy_to_coolify");
|
|
});
|
|
const workerState = computed(() => workerStatus.value?.state || workerDeployment.value?.state || "unknown");
|
|
const workerIssues = computed(() => (Array.isArray(workerStatus.value?.issues) ? workerStatus.value.issues : []));
|
|
const workerLatestDeployment = computed(() => workerStatus.value?.latest_deployment || workerDeployment.value?.latest_deployment || null);
|
|
const workerRecentDeployments = computed(() =>
|
|
Array.isArray(workerStatus.value?.recent_deployments) ? workerStatus.value.recent_deployments : []
|
|
);
|
|
const selectedWorkerChannel = computed(
|
|
() =>
|
|
workerChannels.value.find((channel) => Number(channel.id) === Number(selectedWorkerChannelId.value)) ||
|
|
workerStatus.value?.channel ||
|
|
null
|
|
);
|
|
const workerApiTargetLabel = computed(
|
|
() =>
|
|
workerStatus.value?.api_target?.id ||
|
|
workerDeployment.value?.api_target?.id ||
|
|
t("cron.empty_value")
|
|
);
|
|
|
|
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 loadWorkers() {
|
|
workersLoading.value = true;
|
|
try {
|
|
const data = responseData(
|
|
await listCronWorkers({
|
|
channelId: selectedWorkerChannelId.value,
|
|
includeProvider: true,
|
|
}),
|
|
{}
|
|
);
|
|
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;
|
|
workerStatus.value = data || null;
|
|
workerChannels.value = Array.isArray(data.channels) ? data.channels : workerChannels.value;
|
|
if (!selectedWorkerChannelId.value && data.channel?.id) {
|
|
selectedWorkerChannelId.value = data.channel.id;
|
|
}
|
|
} finally {
|
|
workersLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function refreshCron() {
|
|
loading.value = true;
|
|
errorMessage.value = "";
|
|
try {
|
|
await Promise.all([loadTasks(), loadRuns(), loadWorkers()]);
|
|
} catch (error) {
|
|
errorMessage.value = parseError(error);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
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 Promise.all([loadTasks(), loadRuns()]);
|
|
void pollQueuedRun(data);
|
|
} catch (error) {
|
|
errorMessage.value = parseError(error);
|
|
} finally {
|
|
busyTaskId.value = null;
|
|
}
|
|
}
|
|
|
|
async function deployWorkers() {
|
|
deployingWorker.value = true;
|
|
errorMessage.value = "";
|
|
queuedMessage.value = "";
|
|
const updatingDeployment = hasWorkerDeploymentTarget.value;
|
|
try {
|
|
const channelId = selectedWorkerChannelId.value || workerStatus.value?.channel?.id || null;
|
|
const data = responseData(
|
|
await deployCronWorkers({
|
|
...(channelId ? { channel_id: channelId } : {}),
|
|
}),
|
|
{}
|
|
);
|
|
if (data.worker_status) {
|
|
applyWorkerStatus(data.worker_status);
|
|
}
|
|
await loadWorkers();
|
|
const deploymentId = data.deployment?.id || data.applied?.[0]?.deployment_id || null;
|
|
queuedMessage.value = updatingDeployment
|
|
? t("cron.messages.worker_update_queued", { id: deploymentId || t("cron.empty_value") })
|
|
: t("cron.messages.worker_deploy_queued", { id: deploymentId || t("cron.empty_value") });
|
|
void pollWorkerDeployment();
|
|
} catch (error) {
|
|
errorMessage.value = parseError(error);
|
|
} finally {
|
|
deployingWorker.value = false;
|
|
}
|
|
}
|
|
|
|
function applyWorkerStatus(data) {
|
|
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;
|
|
workerStatus.value = data || null;
|
|
workerChannels.value = Array.isArray(data.channels) ? data.channels : workerChannels.value;
|
|
if (!selectedWorkerChannelId.value && data.channel?.id) {
|
|
selectedWorkerChannelId.value = data.channel.id;
|
|
}
|
|
}
|
|
|
|
async function pollWorkerDeployment() {
|
|
try {
|
|
for (let attempt = 0; attempt < WORKER_POLL_ATTEMPTS; attempt += 1) {
|
|
await waitForPollDelay(WORKER_POLL_INTERVAL_MS);
|
|
await loadWorkers();
|
|
if (!WORKER_PENDING_STATES.has(workerState.value)) {
|
|
return;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
errorMessage.value = parseError(error);
|
|
}
|
|
}
|
|
|
|
function waitForPollDelay(delay = RUN_POLL_INTERVAL_MS) {
|
|
return new Promise((resolve) => {
|
|
const timeout = window.setTimeout(() => {
|
|
pollTimeouts.delete(timeout);
|
|
resolve();
|
|
}, delay);
|
|
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 = "";
|
|
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) {
|
|
if (status === "deployed") {
|
|
return t("cron.status.deployed");
|
|
}
|
|
if (status === "deploying") {
|
|
return t("cron.status.deploying");
|
|
}
|
|
if (status === "failed") {
|
|
return t("cron.status.failed");
|
|
}
|
|
if (status === "healthy") {
|
|
return t("cron.status.healthy");
|
|
}
|
|
if (status === "degraded") {
|
|
return t("cron.status.degraded");
|
|
}
|
|
if (status === "needs_deploy") {
|
|
return t("cron.status.needs_deploy");
|
|
}
|
|
if (status === "queued") {
|
|
return t("cron.status.queued");
|
|
}
|
|
if (status === "running") {
|
|
return t("cron.status.running");
|
|
}
|
|
if (status === "skipped") {
|
|
return t("cron.status.skipped");
|
|
}
|
|
if (status === "succeeded") {
|
|
return t("cron.status.succeeded");
|
|
}
|
|
if (status === "timed_out") {
|
|
return t("cron.status.timed_out");
|
|
}
|
|
if (status === "waiting_for_heartbeat") {
|
|
return t("cron.status.waiting_for_heartbeat");
|
|
}
|
|
return status || t("cron.status.unknown");
|
|
}
|
|
|
|
function statusClass(status) {
|
|
if (status === "succeeded" || status === "healthy" || status === "deployed") {
|
|
return "is-success";
|
|
}
|
|
if (status === "failed" || status === "timed_out") {
|
|
return "is-danger";
|
|
}
|
|
if (status === "running" || status === "deploying" || status === "waiting_for_heartbeat") {
|
|
return "is-info";
|
|
}
|
|
if (status === "queued" || status === "degraded" || status === "needs_deploy") {
|
|
return "is-warning";
|
|
}
|
|
return "is-light";
|
|
}
|
|
|
|
function issueClass(issue) {
|
|
if (issue?.severity === "danger") {
|
|
return "is-danger";
|
|
}
|
|
if (issue?.severity === "warning") {
|
|
return "is-warning";
|
|
}
|
|
return "is-info";
|
|
}
|
|
|
|
function deploymentLabel(deployment) {
|
|
if (!deployment) {
|
|
return t("cron.empty_value");
|
|
}
|
|
const id = deployment.provider_operation_id || deployment.id || t("cron.empty_value");
|
|
return t("cron.workers.deployment_label", {
|
|
id,
|
|
status: statusLabel(deployment.status),
|
|
});
|
|
}
|
|
|
|
async function changeWorkerChannel() {
|
|
errorMessage.value = "";
|
|
try {
|
|
await loadWorkers();
|
|
} catch (error) {
|
|
errorMessage.value = parseError(error);
|
|
}
|
|
}
|
|
|
|
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>
|
|
<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 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">
|
|
<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-worker-panel" data-testid="cron-worker-panel">
|
|
<div class="cron-section-heading">
|
|
<div>
|
|
<h2>{{ t("cron.workers.title") }}</h2>
|
|
<small data-testid="cron-worker-channel">
|
|
{{ selectedWorkerChannel?.slug || t("cron.empty_value") }}
|
|
</small>
|
|
</div>
|
|
<div class="cron-worker-controls">
|
|
<select
|
|
v-if="workerChannels.length > 1"
|
|
v-model.number="selectedWorkerChannelId"
|
|
class="select is-small"
|
|
data-testid="cron-worker-channel-select"
|
|
@change="changeWorkerChannel"
|
|
>
|
|
<option v-for="channel in workerChannels" :key="channel.id" :value="channel.id">
|
|
{{ channel.slug }}
|
|
</option>
|
|
</select>
|
|
<button
|
|
class="button is-small is-light"
|
|
type="button"
|
|
:disabled="!canDeployWorkers || deployingWorker || !workerDeploymentCanDeploy"
|
|
:class="{ 'is-loading': deployingWorker }"
|
|
@click="deployWorkers"
|
|
data-testid="cron-worker-deploy"
|
|
>
|
|
{{ workerDeploymentActionLabel }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-if="workerIssues.length > 0" class="cron-worker-issues" data-testid="cron-worker-issues">
|
|
<span
|
|
v-for="issue in workerIssues"
|
|
:key="issue.code || issue.message"
|
|
class="tag"
|
|
:class="issueClass(issue)"
|
|
>
|
|
{{ issue.message }}
|
|
</span>
|
|
</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-state">
|
|
<span>{{ t("cron.workers.state") }}</span>
|
|
<strong>
|
|
<span class="tag" :class="statusClass(workerState)">
|
|
{{ statusLabel(workerState) }}
|
|
</span>
|
|
</strong>
|
|
</div>
|
|
<div class="cron-stat" data-testid="cron-worker-target">
|
|
<span>{{ t("cron.workers.target") }}</span>
|
|
<strong>{{ workerTargetLabel }}</strong>
|
|
</div>
|
|
<div class="cron-stat" data-testid="cron-worker-api-target">
|
|
<span>{{ t("cron.workers.api_target") }}</span>
|
|
<strong>{{ workerApiTargetLabel }}</strong>
|
|
</div>
|
|
<div class="cron-stat" data-testid="cron-worker-latest-deployment">
|
|
<span>{{ t("cron.workers.latest_deployment") }}</span>
|
|
<strong>{{ deploymentLabel(workerLatestDeployment) }}</strong>
|
|
</div>
|
|
</div>
|
|
<div v-if="workerRecentDeployments.length > 0" class="cron-worker-deployments">
|
|
<table class="table is-fullwidth is-striped is-narrow" data-testid="cron-worker-deployments">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ t("cron.workers.deployment") }}</th>
|
|
<th>{{ t("cron.history.status") }}</th>
|
|
<th>{{ t("cron.history.started") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="deployment in workerRecentDeployments" :key="deployment.id">
|
|
<td>{{ deployment.provider_operation_id || deployment.id }}</td>
|
|
<td><span class="tag" :class="statusClass(deployment.status)">{{ statusLabel(deployment.status) }}</span></td>
|
|
<td>{{ formatDate(deployment.started_at || deployment.created_at) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</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">
|
|
{{
|
|
hasWorkerDeploymentTarget
|
|
? t("cron.workers.empty_with_target")
|
|
: 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"
|
|
>
|
|
{{ 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>{{ runStartedLabel(run) }}</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;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.cron-section-heading small {
|
|
color: #667085;
|
|
display: block;
|
|
font-size: 0.8rem;
|
|
margin-top: 0.2rem;
|
|
}
|
|
|
|
.cron-worker-controls,
|
|
.cron-worker-issues {
|
|
align-items: center;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.cron-worker-controls select {
|
|
min-width: 8rem;
|
|
}
|
|
|
|
.cron-worker-issues {
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.cron-worker-deployments {
|
|
margin: 0.75rem 0;
|
|
}
|
|
|
|
.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,
|
|
.cron-worker-panel 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>
|