feat(frontend): show job attempt meter

This commit is contained in:
2026-06-21 09:41:03 +03:30
parent 496b18fc22
commit 1616493655
2 changed files with 88 additions and 46 deletions

View File

@@ -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 (
<div className="attempt-meter" aria-label={`${job.attempts} of ${job.max_attempts} attempts used`}>
{Array.from({ length: job.max_attempts }, (_, index) => {
const attemptNumber = index + 1;
const state = attemptState(job, attemptNumber);
return (
<span className={`attempt-square ${state}`} key={attemptNumber} title={`Attempt ${attemptNumber}: ${state}`}>
{state === "succeeded" && <Check size={12} strokeWidth={3} />}
{state === "failed" && <X size={12} strokeWidth={3} />}
</span>
);
})}
{/* <span className="attempt-count">
{job.attempts}/{job.max_attempts}
</span> */}
</div>
);
}
export function JobsPage() {
const [jobs, setJobs] = useState<Job[]>([]);
const [totalJobs, setTotalJobs] = useState(0);
@@ -162,40 +189,6 @@ export function JobsPage() {
<SelectField value={statusFilter} options={statusOptions} onChange={(value) => setStatusFilter(value as JobStatus | "all")} />
<input placeholder="Filter by type" value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)} />
</div>
<div className="pagination-bar top">
<span>
Showing {firstItem}-{lastItem} of {totalJobs}
</span>
<div className="pagination-controls">
<SelectField value={String(pageSize)} options={pageSizeOptions} onChange={(value) => setPageSize(Number(value))} />
<button className="icon-button" disabled={currentPage <= 1} type="button" aria-label="previous page" onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}>
<ChevronLeft size={18} />
</button>
<label className="page-number-field">
Page
<input
max={totalPages}
min={1}
type="number"
value={currentPage}
onChange={(event) => {
const value = Number(event.target.value);
if (!Number.isNaN(value)) setCurrentPage(Math.min(totalPages, Math.max(1, value)));
}}
/>
<span>of {totalPages}</span>
</label>
<button
className="icon-button"
disabled={currentPage >= totalPages}
type="button"
aria-label="next page"
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
>
<ChevronRight size={18} />
</button>
</div>
</div>
<div className="table-wrap">
<table>
<thead>
@@ -220,7 +213,7 @@ export function JobsPage() {
</td>
<td>{job.priority}</td>
<td>
{job.attempts}/{job.max_attempts}
<AttemptMeter job={job} />
</td>
<td>
<DateTime value={job.available_at} />
@@ -239,9 +232,10 @@ export function JobsPage() {
</div>
<div className="pagination-bar">
<span>
Page {currentPage} of {totalPages}
Showing {firstItem}-{lastItem} of {totalJobs}
</span>
<div className="pagination-controls">
<SelectField value={String(pageSize)} options={pageSizeOptions} onChange={(value) => setPageSize(Number(value))} />
<button className="secondary-button" disabled={currentPage <= 1} type="button" onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}>
<ChevronLeft size={16} /> Previous
</button>

View File

@@ -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,