Skip to content

Commit c5d2765

Browse files
committed
feat: add retry delivery, optimized polling, and improved activity timeline UI
1 parent 697fc3c commit c5d2765

3 files changed

Lines changed: 898 additions & 158 deletions

File tree

web/src/app/delivery-targets/target-logs.tsx

Lines changed: 298 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,226 @@
11

22

3+
// "use client"
4+
5+
// import { useEffect, useState } from "react"
6+
// import { CheckCircle2, XCircle, Clock } from "lucide-react"
7+
8+
// type Log = {
9+
// id: string
10+
// event_id: number
11+
// status: "success" | "failed"
12+
// status_code?: number
13+
// response?: string
14+
// attempt: number
15+
// created_at: string
16+
// }
17+
18+
// /* ---------------- HELPERS ---------------- */
19+
20+
21+
// async function retryEvent(eventId: number) {
22+
// try {
23+
// await fetch(
24+
// `${process.env.NEXT_PUBLIC_API_URL}/events/${eventId}/replay`,
25+
// {
26+
// method: "POST",
27+
// credentials: "include",
28+
// }
29+
// )
30+
31+
// alert("Retry triggered")
32+
// } catch {
33+
// alert("Retry failed")
34+
// }
35+
// }
36+
37+
// function groupByEvent(logs: Log[]) {
38+
// const map: Record<number, Log[]> = {}
39+
40+
// logs.forEach((log) => {
41+
// if (!map[log.event_id]) map[log.event_id] = []
42+
// map[log.event_id].push(log)
43+
// })
44+
45+
// return Object.entries(map)
46+
// .sort((a, b) => Number(b[0]) - Number(a[0])) // latest first
47+
// .map(([eventId, items]) => ({
48+
// eventId: Number(eventId),
49+
// items: items.sort((a, b) => b.attempt - a.attempt),
50+
// }))
51+
// }
52+
53+
// function parseResponse(response?: string) {
54+
// if (!response) return null
55+
56+
// try {
57+
// return JSON.parse(response)
58+
// } catch {
59+
// return response
60+
// }
61+
// }
62+
63+
// /* ---------------- COMPONENT ---------------- */
64+
65+
// export default function TargetLogs({ targetId }: { targetId: string }) {
66+
// const [logs, setLogs] = useState<Log[]>([])
67+
// const [loading, setLoading] = useState(true)
68+
69+
// useEffect(() => {
70+
// async function fetchLogs() {
71+
// try {
72+
// const res = await fetch(
73+
// `${process.env.NEXT_PUBLIC_API_URL}/delivery-targets/${targetId}/logs`,
74+
// {
75+
// credentials: "include",
76+
// }
77+
// )
78+
79+
// const data = await res.json()
80+
81+
// setLogs((prev) => {
82+
// const next = data.items || []
83+
84+
// if (JSON.stringify(prev) === JSON.stringify(next)) {
85+
// return prev
86+
// }
87+
88+
// return next
89+
// })
90+
// } catch (err) {
91+
// console.error("Failed to fetch logs", err)
92+
// } finally {
93+
// setLoading(false)
94+
// }
95+
// }
96+
97+
// // initial fetch
98+
// fetchLogs()
99+
100+
// // interval defined as const
101+
// const interval = setInterval(fetchLogs, 5000)
102+
103+
// return () => clearInterval(interval)
104+
// }, [targetId])
105+
106+
// const grouped = groupByEvent(logs)
107+
108+
// if (loading) {
109+
// return (
110+
// <div className="p-6 text-sm text-muted-foreground">
111+
// Loading logs...
112+
// </div>
113+
// )
114+
// }
115+
116+
// return (
117+
// <div className="rounded-2xl border bg-card p-5 space-y-6">
118+
119+
// <h2 className="font-semibold text-sm">Activity Timeline</h2>
120+
121+
// {grouped.length === 0 && (
122+
// <p className="text-sm text-muted-foreground">
123+
// No activity yet
124+
// </p>
125+
// )}
126+
127+
// {grouped.map((group) => (
128+
// <div key={group.eventId} className="space-y-3">
129+
130+
// {/* Event Header */}
131+
// <div className="text-xs font-medium text-muted-foreground">
132+
// Event #{group.eventId}
133+
// </div>
134+
135+
// <div className="space-y-2 border-l pl-4">
136+
137+
// {group.items.map((log) => {
138+
// const parsed = parseResponse(log.response)
139+
140+
// return (
141+
// <div key={log.id} className="flex gap-3">
142+
143+
// {/* Timeline Dot */}
144+
// <div className="mt-1">
145+
// {log.status === "success" ? (
146+
// <CheckCircle2 className="w-4 h-4 text-emerald-500" />
147+
// ) : (
148+
// <XCircle className="w-4 h-4 text-red-500" />
149+
// )}
150+
// </div>
151+
152+
// {/* Content */}
153+
// <div className="flex-1 space-y-1">
154+
155+
// <p className="text-sm font-medium">
156+
// {log.status === "success"
157+
// ? "Delivered successfully"
158+
// : "Delivery failed"}
159+
160+
// {
161+
// log.status === "failed" && (
162+
// <button
163+
// onClick={() => retryEvent(log.event_id)}
164+
// className="text-xs px-2 py-1 rounded border hover:bg-muted"
165+
// >
166+
// Retry
167+
// </button>
168+
// )
169+
// }
170+
// </p>
171+
172+
// <div className="flex gap-3 text-xs text-muted-foreground flex-wrap">
173+
174+
// <span>Attempt {log.attempt}</span>
175+
176+
// {log.status_code && (
177+
// <span>HTTP {log.status_code}</span>
178+
// )}
179+
180+
// {parsed?.duration_ms && (
181+
// <span className="flex items-center gap-1">
182+
// <Clock className="w-3 h-3" />
183+
// {parsed.duration_ms}ms
184+
// </span>
185+
// )}
186+
// </div>
187+
188+
// {/* Response preview */}
189+
// {parsed && (
190+
// <details className="text-xs mt-1">
191+
// <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
192+
// View response
193+
// </summary>
194+
195+
// <pre className="mt-2 p-2 bg-muted rounded overflow-auto text-[10px] max-h-48">
196+
// {typeof parsed === "string"
197+
// ? parsed.slice(0, 500)
198+
// : JSON.stringify(parsed, null, 2)}
199+
// </pre>
200+
// </details>
201+
// )}
202+
// </div>
203+
204+
// {/* Time */}
205+
// <div className="text-xs text-muted-foreground whitespace-nowrap">
206+
// {new Date(log.created_at).toLocaleTimeString()}
207+
// </div>
208+
// </div>
209+
// )
210+
// })}
211+
// </div>
212+
// </div>
213+
// ))}
214+
// </div>
215+
// )
216+
// }
217+
218+
219+
220+
221+
222+
223+
3224
"use client"
4225

5226
import { useEffect, useState } from "react"
@@ -26,7 +247,7 @@ function groupByEvent(logs: Log[]) {
26247
})
27248

28249
return Object.entries(map)
29-
.sort((a, b) => Number(b[0]) - Number(a[0])) // latest first
250+
.sort((a, b) => Number(b[0]) - Number(a[0]))
30251
.map(([eventId, items]) => ({
31252
eventId: Number(eventId),
32253
items: items.sort((a, b) => b.attempt - a.attempt),
@@ -48,41 +269,61 @@ function parseResponse(response?: string) {
48269
export default function TargetLogs({ targetId }: { targetId: string }) {
49270
const [logs, setLogs] = useState<Log[]>([])
50271
const [loading, setLoading] = useState(true)
272+
const [retrying, setRetrying] = useState<number | null>(null)
273+
274+
/* ---------------- FETCH ---------------- */
275+
276+
async function fetchLogs() {
277+
try {
278+
const res = await fetch(
279+
`${process.env.NEXT_PUBLIC_API_URL}/delivery-targets/${targetId}/logs`,
280+
{ credentials: "include" }
281+
)
282+
283+
const data = await res.json()
284+
285+
setLogs((prev) => {
286+
const next = data.items || []
287+
if (JSON.stringify(prev) === JSON.stringify(next)) return prev
288+
return next
289+
})
290+
} catch (err) {
291+
console.error("Failed to fetch logs", err)
292+
} finally {
293+
setLoading(false)
294+
}
295+
}
51296

52-
useEffect(() => {
53-
async function fetchLogs() {
54-
try {
55-
const res = await fetch(
56-
`${process.env.NEXT_PUBLIC_API_URL}/delivery-targets/${targetId}/logs`,
57-
{
58-
credentials: "include",
59-
}
60-
)
61-
62-
const data = await res.json()
63-
64-
setLogs((prev) => {
65-
const next = data.items || []
66-
67-
if (JSON.stringify(prev) === JSON.stringify(next)) {
68-
return prev
69-
}
70-
71-
return next
72-
})
73-
} catch (err) {
74-
console.error("Failed to fetch logs", err)
75-
} finally {
76-
setLoading(false)
77-
}
297+
/* ---------------- RETRY ---------------- */
298+
299+
async function retryEvent(eventId: number) {
300+
try {
301+
setRetrying(eventId)
302+
303+
await fetch(
304+
`${process.env.NEXT_PUBLIC_API_URL}/events/${eventId}/replay`,
305+
{
306+
method: "POST",
307+
credentials: "include",
308+
}
309+
)
310+
311+
// refresh logs instantly
312+
await fetchLogs()
313+
} catch {
314+
alert("Retry failed")
315+
} finally {
316+
setRetrying(null)
78317
}
79-
80-
// initial fetch
318+
}
319+
320+
/* ---------------- EFFECT ---------------- */
321+
322+
useEffect(() => {
81323
fetchLogs()
82-
83-
// interval defined as const
84-
const interval = setInterval(fetchLogs, 5000)
85-
324+
325+
const interval = setInterval(fetchLogs, 4000) // slightly faster
326+
86327
return () => clearInterval(interval)
87328
}, [targetId])
88329

@@ -123,7 +364,7 @@ export default function TargetLogs({ targetId }: { targetId: string }) {
123364
return (
124365
<div key={log.id} className="flex gap-3">
125366

126-
{/* Timeline Dot */}
367+
{/* Status Icon */}
127368
<div className="mt-1">
128369
{log.status === "success" ? (
129370
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
@@ -135,12 +376,29 @@ export default function TargetLogs({ targetId }: { targetId: string }) {
135376
{/* Content */}
136377
<div className="flex-1 space-y-1">
137378

138-
<p className="text-sm font-medium">
139-
{log.status === "success"
140-
? "Delivered successfully"
141-
: "Delivery failed"}
142-
</p>
379+
{/* Title + Retry */}
380+
<div className="flex items-center gap-2">
381+
382+
<p className="text-sm font-medium">
383+
{log.status === "success"
384+
? "Delivered successfully"
385+
: "Delivery failed"}
386+
</p>
387+
388+
{log.status === "failed" && (
389+
<button
390+
onClick={() => retryEvent(log.event_id)}
391+
disabled={retrying === log.event_id}
392+
className="text-xs px-2 py-1 rounded border hover:bg-muted"
393+
>
394+
{retrying === log.event_id
395+
? "Retrying..."
396+
: "Retry"}
397+
</button>
398+
)}
399+
</div>
143400

401+
{/* Meta */}
144402
<div className="flex gap-3 text-xs text-muted-foreground flex-wrap">
145403

146404
<span>Attempt {log.attempt}</span>
@@ -157,7 +415,7 @@ export default function TargetLogs({ targetId }: { targetId: string }) {
157415
)}
158416
</div>
159417

160-
{/* Response preview */}
418+
{/* Response */}
161419
{parsed && (
162420
<details className="text-xs mt-1">
163421
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">

0 commit comments

Comments
 (0)