diff --git a/frontend/src/pages/JobsPage.tsx b/frontend/src/pages/JobsPage.tsx
index cc49702..1c30a9c 100644
--- a/frontend/src/pages/JobsPage.tsx
+++ b/frontend/src/pages/JobsPage.tsx
@@ -1,4 +1,4 @@
-import { ChevronLeft, ChevronRight, Eye, Plus, RotateCcw } from "lucide-react";
+import { Check, ChevronLeft, ChevronRight, Eye, Plus, RotateCcw, X } from "lucide-react";
import { FormEvent, useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
@@ -35,6 +35,33 @@ function demoPayload(type: string) {
return { sleep_seconds: 1 };
}
+function attemptState(job: Job, attemptNumber: number) {
+ if (attemptNumber > job.attempts) return "empty";
+ if (job.status === "succeeded") return attemptNumber === job.attempts ? "succeeded" : "failed";
+ if (job.status === "running" && attemptNumber === job.attempts) return "running";
+ return "failed";
+}
+
+function AttemptMeter({ job }: { job: Job }) {
+ return (
+
+ {Array.from({ length: job.max_attempts }, (_, index) => {
+ const attemptNumber = index + 1;
+ const state = attemptState(job, attemptNumber);
+ return (
+
+ {state === "succeeded" && }
+ {state === "failed" && }
+
+ );
+ })}
+ {/*
+ {job.attempts}/{job.max_attempts}
+ */}
+
+ );
+}
+
export function JobsPage() {
const [jobs, setJobs] = useState([]);
const [totalJobs, setTotalJobs] = useState(0);
@@ -162,40 +189,6 @@ export function JobsPage() {
setStatusFilter(value as JobStatus | "all")} />
setTypeFilter(event.target.value)} />
-
-
- Showing {firstItem}-{lastItem} of {totalJobs}
-
-
- setPageSize(Number(value))} />
-
-
-
-
-
@@ -220,7 +213,7 @@ export function JobsPage() {
| {job.priority} |
- {job.attempts}/{job.max_attempts}
+
|
@@ -239,9 +232,10 @@ export function JobsPage() {
- Page {currentPage} of {totalPages}
+ Showing {firstItem}-{lastItem} of {totalJobs}
+ setPageSize(Number(value))} />
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index d19cb85..cf96f74 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -1134,15 +1134,6 @@ label {
padding-top: 12px;
}
-.pagination-bar.top {
- border-bottom: 1px solid var(--border);
- border-top: 0;
- margin-bottom: 12px;
- margin-top: 0;
- padding-bottom: 12px;
- padding-top: 0;
-}
-
.pagination-controls {
align-items: center;
display: flex;
@@ -1168,6 +1159,53 @@ label {
width: 74px;
}
+.attempt-meter {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ min-width: 128px;
+}
+
+.attempt-square {
+ align-items: center;
+ background: var(--panel-raised);
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ color: var(--muted);
+ display: inline-flex;
+ height: 22px;
+ justify-content: center;
+ width: 22px;
+}
+
+.attempt-square.succeeded {
+ background: var(--green-soft);
+ border-color: color-mix(in srgb, var(--green) 42%, var(--border));
+ color: var(--green);
+}
+
+.attempt-square.failed {
+ background: var(--red-soft);
+ border-color: color-mix(in srgb, var(--red) 42%, var(--border));
+ color: var(--red);
+}
+
+.attempt-square.running {
+ animation: attempt-running 960ms ease-in-out infinite alternate;
+ background: var(--orange-soft);
+ border-color: color-mix(in srgb, var(--orange) 42%, var(--border));
+ color: var(--orange);
+}
+
+.attempt-count {
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 900;
+ margin-left: 3px;
+ white-space: nowrap;
+}
+
.event-feed-meta {
align-items: center;
display: flex;
@@ -1746,6 +1784,16 @@ code {
}
}
+@keyframes attempt-running {
+ from {
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--orange) 18%, transparent);
+ }
+
+ to {
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--orange) 4%, transparent);
+ }
+}
+
@media (prefers-reduced-motion: reduce) {
*,
*::before,
|