Show verified cron worker cadence (#230)

Expose responsive worker cadence proof in the Cron operations dashboard.
This commit is contained in:
Jeppe B
2026-07-27 18:16:12 +02:00
committed by GitHub
parent fd31609cb3
commit cbdca71e3f
11 changed files with 145 additions and 21 deletions
@@ -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

+6
View File
@@ -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;
+29 -1
View File
@@ -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");