diff --git a/frontend/src/pages/JobDetailPage.tsx b/frontend/src/pages/JobDetailPage.tsx index 5b55a3e..30fd07c 100644 --- a/frontend/src/pages/JobDetailPage.tsx +++ b/frontend/src/pages/JobDetailPage.tsx @@ -10,6 +10,40 @@ import { EmptyState } from "../components/EmptyState"; import { StatusBadge } from "../components/StatusBadge"; import type { Job, JobEvent } from "../types"; +type TimelineEvent = JobEvent & { + firstId: number; + lastId: number; + repeatCount: number; +}; + +function buildTimeline(events: JobEvent[]) { + const grouped: TimelineEvent[] = []; + + [...events] + .sort((a, b) => a.id - b.id) + .forEach((event) => { + const previous = grouped.at(-1); + const isSameRun = + previous && + previous.type === event.type && + previous.attempt === event.attempt && + previous.worker_id === event.worker_id; + + if (isSameRun) { + previous.lastId = event.id; + previous.repeatCount += 1; + previous.created_at = event.created_at; + previous.message = event.message; + previous.data = event.data; + return; + } + + grouped.push({ ...event, firstId: event.id, lastId: event.id, repeatCount: 1 }); + }); + + return grouped.reverse(); +} + export function JobDetailPage() { const { jobId } = useParams(); const [job, setJob] = useState(null); @@ -28,7 +62,7 @@ export function JobDetailPage() { return () => window.clearInterval(id); }, [refresh]); - const sortedEvents = useMemo(() => [...events].sort((a, b) => b.id - a.id), [events]); + const timelineEvents = useMemo(() => buildTimeline(events), [events]); async function retry() { if (!job) return; @@ -54,7 +88,9 @@ export function JobDetailPage() {
Job detail -

{job.type}

+

+ {job.type} +

@@ -83,69 +119,79 @@ export function JobDetailPage() {
Locked by - {job.locked_by ?? "-"} + {job.locked_by ?? "-"}
-
+

Metadata

-
-
- ID - {job.id} - +
+
+ ID + {job.id}
-
- Available - +
+ Available
-
- Locked until - +
+ Locked until
-
- Finished - +
+ Finished
-
- Idempotency - {job.idempotency_key ?? "-"} - +
+ Idempotency + {job.idempotency_key ?? "-"}
-
-

Payload

+
+
+
+

Payload

+
+
{JSON.stringify(job.payload, null, 2)}
+
+
+
+

Result

+
+
{JSON.stringify(job.result ?? null, null, 2)}
+
+ {job.last_error &&

{job.last_error}

}
-
{JSON.stringify(job.payload, null, 2)}
-
-

Result

-
-
{JSON.stringify(job.result ?? null, null, 2)}
- {job.last_error &&

{job.last_error}

}
-
+

Event Timeline

-
- {sortedEvents.map((event) => ( -
- {eventTitle(event.type)} - {event.worker_id ?? `attempt ${event.attempt}`} - -
+
+ {timelineEvents.map((event) => ( +
+
))} - {!sortedEvents.length && } + {!timelineEvents.length && }
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3a1add7..d746ed5 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -357,7 +357,7 @@ label { .stats-grid { display: grid; gap: 12px; - grid-template-columns: repeat(4, minmax(160px, 1fr)); + grid-template-columns: repeat(3, minmax(160px, 1fr)); } .stat-card { @@ -442,6 +442,16 @@ label { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.detail-grid { + display: grid; + gap: 16px; + grid-template-columns: minmax(280px, 0.85fr) minmax(0, 1.15fr); +} + +.detail-wide { + grid-column: 1 / -1; +} + .circle-grid { display: grid; gap: 12px; @@ -807,6 +817,141 @@ label { padding: 9px; } +.detail-fields, +.json-stack, +.timeline-list { + display: grid; + gap: 10px; +} + +.detail-field { + background: var(--panel-raised); + border: 1px solid var(--border); + border-radius: 6px; + display: grid; + gap: 6px; + min-width: 0; + padding: 10px; +} + +.detail-field > span { + color: var(--muted); + font-size: 12px; + font-weight: 900; + text-transform: uppercase; +} + +.code-token { + max-width: 100%; + min-width: 0; + padding: 6px 8px; +} + +.code-token.wrap { + line-height: 1.45; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; + word-break: break-word; +} + +.json-block { + max-height: 320px; + overflow: auto; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.error-block { + background: color-mix(in srgb, var(--red) 10%, var(--panel-raised)); + border: 1px solid color-mix(in srgb, var(--red) 34%, var(--border)); + border-radius: 6px; + margin: 0; + padding: 10px; +} + +.timeline-item { + display: grid; + gap: 10px; + grid-template-columns: 18px minmax(0, 1fr); + position: relative; +} + +.timeline-item::before { + background: var(--border); + bottom: -10px; + content: ""; + left: 8px; + position: absolute; + top: 18px; + width: 1px; +} + +.timeline-item:last-child::before { + display: none; +} + +.timeline-dot { + background: var(--cyan); + border: 3px solid var(--panel); + border-radius: 999px; + box-shadow: 0 0 0 1px var(--border); + display: block; + height: 16px; + margin-top: 10px; + position: relative; + width: 16px; + z-index: 1; +} + +.timeline-card { + background: var(--panel-raised); + border: 1px solid var(--border); + border-radius: 6px; + display: grid; + gap: 7px; + min-width: 0; + padding: 10px; +} + +.timeline-heading, +.timeline-meta { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.timeline-heading { + justify-content: space-between; +} + +.timeline-meta, +.timeline-card p { + color: var(--muted); + font-size: 12px; +} + +.timeline-meta > span, +.timeline-card p { + overflow-wrap: anywhere; +} + +.timeline-card p { + margin: 0; +} + +.timeline-count { + background: var(--subtle); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--ink); + font-size: 11px; + font-weight: 900; + padding: 3px 8px; +} + .toolbar, .filter-grid { display: grid; @@ -1223,6 +1368,7 @@ code { } .dashboard-grid, + .detail-grid, .stats-grid { grid-template-columns: 1fr 1fr; } @@ -1321,9 +1467,11 @@ code { } .dashboard-grid, + .detail-grid, .stats-grid, .circle-grid, .filter-grid, + .preset-grid, .toolbar, .compact-row, .dot-chart-row {