Show verified cron worker cadence (#230)
Expose responsive worker cadence proof in the Cron operations dashboard.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# Automatic cron execution visual comparison
|
||||
|
||||
The Cron workers panel now exposes machine-verifiable scheduler cadence.
|
||||
The summary reports how many active workers have maintained the required
|
||||
once-per-minute cadence, and each worker row shows its latest loop gap and
|
||||
consecutive qualifying loops.
|
||||
|
||||
A worker is verified only after two consecutive loops, with no gap above
|
||||
60 seconds, while the worker is running and its latest observation is no more
|
||||
than 60 seconds old.
|
||||
|
||||
## Mobile
|
||||
|
||||
- [Before](before-mobile.png)
|
||||
- [After](after-mobile.png)
|
||||
|
||||
## Tablet
|
||||
|
||||
- [Before](before-tablet.png)
|
||||
- [After](after-tablet.png)
|
||||
|
||||
## Desktop
|
||||
|
||||
- [Before](before-desktop.png)
|
||||
- [After](after-desktop.png)
|
||||
|
||||
The updated state tags use explicit foreground colors so success and warning
|
||||
labels remain readable against their backgrounds.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -2191,6 +2191,11 @@
|
||||
"title": "Cron tasks",
|
||||
"workers": {
|
||||
"api_target": "API target",
|
||||
"cadence_detail": "Last gap {gap}s across {loops} consecutive loops",
|
||||
"cadence_pending": "Awaiting proof",
|
||||
"cadence_verified": "Verified",
|
||||
"cadence_waiting": "Waiting for two scheduler loops",
|
||||
"cadence_workers": "{verified}/{total} active workers verified",
|
||||
"deploy": "Deploy worker",
|
||||
"deploy_to_coolify": "Deploy to Coolify",
|
||||
"deployment": "Deployment",
|
||||
@@ -2201,6 +2206,7 @@
|
||||
"last_run_count": "Last run count",
|
||||
"latest_deployment": "Latest deployment",
|
||||
"loading": "Loading cron workers...",
|
||||
"minute_proof": "Once-per-minute proof",
|
||||
"name": "Worker",
|
||||
"repair_deployment": "Repair Coolify deployment",
|
||||
"running": "@:{'cron.status.running'}",
|
||||
|
||||
@@ -79,6 +79,11 @@
|
||||
"title": "Cron tasks",
|
||||
"workers": {
|
||||
"api_target": "API target",
|
||||
"cadence_detail": "Last gap {gap}s across {loops} consecutive loops",
|
||||
"cadence_pending": "Awaiting proof",
|
||||
"cadence_verified": "Verified",
|
||||
"cadence_waiting": "Waiting for two scheduler loops",
|
||||
"cadence_workers": "{verified}/{total} active workers verified",
|
||||
"deploy": "Deploy worker",
|
||||
"deploy_to_coolify": "Deploy to Coolify",
|
||||
"deployment": "Deployment",
|
||||
@@ -89,6 +94,7 @@
|
||||
"last_run_count": "Last run count",
|
||||
"latest_deployment": "Latest deployment",
|
||||
"loading": "Loading cron workers...",
|
||||
"minute_proof": "Once-per-minute proof",
|
||||
"name": "Worker",
|
||||
"repair_deployment": "Repair Coolify deployment",
|
||||
"running": "@:{'cron.status.running'}",
|
||||
|
||||
@@ -98,10 +98,21 @@ const workerDeploymentActionLabel = computed(() => {
|
||||
});
|
||||
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 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 minuteCadenceProof = computed(() => {
|
||||
const activeWorkers = workers.value.filter((worker) => worker.status === "running" && !worker.stale);
|
||||
const verifiedWorkers = activeWorkers.filter((worker) => worker.minute_cadence?.verified);
|
||||
return {
|
||||
active: activeWorkers.length,
|
||||
verified: verifiedWorkers.length,
|
||||
complete: activeWorkers.length > 0 && verifiedWorkers.length === activeWorkers.length,
|
||||
};
|
||||
});
|
||||
const selectedWorkerChannel = computed(
|
||||
() =>
|
||||
workerChannels.value.find((channel) => Number(channel.id) === Number(selectedWorkerChannelId.value)) ||
|
||||
@@ -109,10 +120,7 @@ const selectedWorkerChannel = computed(
|
||||
null
|
||||
);
|
||||
const workerApiTargetLabel = computed(
|
||||
() =>
|
||||
workerStatus.value?.api_target?.id ||
|
||||
workerDeployment.value?.api_target?.id ||
|
||||
t("cron.empty_value")
|
||||
() => workerStatus.value?.api_target?.id || workerDeployment.value?.api_target?.id || t("cron.empty_value")
|
||||
);
|
||||
|
||||
const responseData = (response, fallback) => response?.data?.data ?? fallback;
|
||||
@@ -454,6 +462,17 @@ function deploymentLabel(deployment) {
|
||||
});
|
||||
}
|
||||
|
||||
function cadenceDetail(worker) {
|
||||
const proof = worker?.minute_cadence;
|
||||
if (!proof || proof.last_gap_seconds === null || proof.last_gap_seconds === undefined) {
|
||||
return t("cron.workers.cadence_waiting");
|
||||
}
|
||||
return t("cron.workers.cadence_detail", {
|
||||
gap: proof.last_gap_seconds,
|
||||
loops: proof.consecutive_loops || 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function changeWorkerChannel() {
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
@@ -645,12 +664,7 @@ onBeforeUnmount(() => {
|
||||
</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)"
|
||||
>
|
||||
<span v-for="issue in workerIssues" :key="issue.code || issue.message" class="tag" :class="issueClass(issue)">
|
||||
{{ issue.message }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -675,6 +689,24 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="cron-stat" data-testid="cron-worker-minute-proof">
|
||||
<span>{{ t("cron.workers.minute_proof") }}</span>
|
||||
<strong>
|
||||
<span class="tag cron-cadence-tag" :class="minuteCadenceProof.complete ? 'is-success' : 'is-warning'">
|
||||
{{
|
||||
minuteCadenceProof.complete ? t("cron.workers.cadence_verified") : t("cron.workers.cadence_pending")
|
||||
}}
|
||||
</span>
|
||||
<small>
|
||||
{{
|
||||
t("cron.workers.cadence_workers", {
|
||||
verified: minuteCadenceProof.verified,
|
||||
total: minuteCadenceProof.active,
|
||||
})
|
||||
}}
|
||||
</small>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="cron-stat" data-testid="cron-worker-target">
|
||||
<span>{{ t("cron.workers.target") }}</span>
|
||||
<strong>{{ workerTargetLabel }}</strong>
|
||||
@@ -700,7 +732,9 @@ onBeforeUnmount(() => {
|
||||
<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>
|
||||
<span class="tag" :class="statusClass(deployment.status)">{{ statusLabel(deployment.status) }}</span>
|
||||
</td>
|
||||
<td>{{ formatDate(deployment.started_at || deployment.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -714,20 +748,17 @@ onBeforeUnmount(() => {
|
||||
<th>{{ t("cron.workers.source") }}</th>
|
||||
<th>{{ t("cron.workers.status") }}</th>
|
||||
<th>{{ t("cron.workers.heartbeat") }}</th>
|
||||
<th>{{ t("cron.workers.minute_proof") }}</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>
|
||||
<td colspan="6">{{ 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 colspan="6">
|
||||
{{ hasWorkerDeploymentTarget ? t("cron.workers.empty_with_target") : t("cron.workers.empty") }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="worker in workers" :key="worker.worker_id">
|
||||
@@ -743,6 +774,19 @@ onBeforeUnmount(() => {
|
||||
<small v-if="worker.stale">{{ t("cron.workers.stale") }}</small>
|
||||
</td>
|
||||
<td>{{ formatDate(worker.last_heartbeat_at) }}</td>
|
||||
<td :data-testid="`cron-worker-cadence-${worker.worker_id}`">
|
||||
<span
|
||||
class="tag cron-cadence-tag"
|
||||
:class="worker.minute_cadence?.verified ? 'is-success' : 'is-warning'"
|
||||
>
|
||||
{{
|
||||
worker.minute_cadence?.verified
|
||||
? t("cron.workers.cadence_verified")
|
||||
: t("cron.workers.cadence_pending")
|
||||
}}
|
||||
</span>
|
||||
<small>{{ cadenceDetail(worker) }}</small>
|
||||
</td>
|
||||
<td>{{ worker.last_run_count ?? 0 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -824,7 +868,9 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.cron-stat span,
|
||||
.cron-table small {
|
||||
.cron-table small,
|
||||
.cron-worker-panel td small,
|
||||
.cron-stat small {
|
||||
color: #667085;
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
@@ -864,6 +910,16 @@ onBeforeUnmount(() => {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.cron-cadence-tag.is-success {
|
||||
background: #a7f3d0;
|
||||
color: #064e3b;
|
||||
}
|
||||
|
||||
.cron-cadence-tag.is-warning {
|
||||
background: #fef3c7;
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
.cron-table-container {
|
||||
border: 1px solid #d7dde5;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -152,6 +152,16 @@ test.describe("Superuser cron operations", () => {
|
||||
status: "running",
|
||||
stale: false,
|
||||
last_heartbeat_at: "2026-07-09 12:01:30",
|
||||
last_loop_started_at: "2026-07-09 12:01:30",
|
||||
last_loop_finished_at: "2026-07-09 12:01:31",
|
||||
last_loop_gap_seconds: 15,
|
||||
consecutive_minute_loops: 5,
|
||||
minute_cadence: {
|
||||
verified: true,
|
||||
maximum_gap_seconds: 60,
|
||||
last_gap_seconds: 15,
|
||||
consecutive_loops: 5,
|
||||
},
|
||||
last_run_count: 1,
|
||||
commit_sha: "abcdef1234567890",
|
||||
release_channel_id: 1,
|
||||
@@ -351,8 +361,26 @@ test.describe("Superuser cron operations", () => {
|
||||
"Process e-conomic transfer queue"
|
||||
);
|
||||
await expect(page.getByTestId("cron-total-tasks")).toContainText("2");
|
||||
const visualVariant = process.env.CRON_VISUAL_VARIANT;
|
||||
const visualDirectory = process.env.CRON_VISUAL_DIR;
|
||||
if (visualVariant && visualDirectory) {
|
||||
const device = testInfo.project.name.replace(/^chromium-/, "");
|
||||
const workerPanel = page.getByTestId("cron-worker-panel");
|
||||
await expect(workerPanel).toContainText("release-stable-cron-worker");
|
||||
await workerPanel.screenshot({
|
||||
path: `${visualDirectory}/${visualVariant}-${device}.png`,
|
||||
});
|
||||
if (visualVariant === "before") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await expect(page.getByTestId("cron-worker-panel")).toContainText("release-stable-cron-worker");
|
||||
await expect(page.getByTestId("cron-worker-minute-proof")).toContainText("Verified");
|
||||
await expect(page.getByTestId("cron-worker-minute-proof")).toContainText("1/1 active workers verified");
|
||||
await expect(page.getByTestId("cron-worker-cadence-release-stable-cron-worker")).toContainText(
|
||||
"Last gap 15s across 5 consecutive loops"
|
||||
);
|
||||
if (!/mobile/i.test(testInfo.project.name)) {
|
||||
await expect(page.getByTestId("cron-worker-panel")).toContainText("release-stable-cron-worker");
|
||||
await expect(page.getByTestId("cron-worker-running")).toContainText("1");
|
||||
await expect(page.getByTestId("cron-worker-target")).toContainText("cron-worker-uuid");
|
||||
await expect(page.getByTestId("cron-worker-deploy")).toHaveText("Update Coolify deployment");
|
||||
|
||||
Reference in New Issue
Block a user