Skip to content

Commit aa715c0

Browse files
authored
Merge pull request #1064 from JerryOJJ/feat/1014-timelock-queue-admin
feat(admin): add timelock queue viewer (#1014)
2 parents c9ef026 + 2d9eb76 commit aa715c0

7 files changed

Lines changed: 807 additions & 14 deletions

File tree

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
import { useEffect, useState } from 'react';
2+
import { Clock3, FileSearch, History, Loader2, XCircle } from 'lucide-react';
3+
import { Button } from '@/components/ui/button';
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogDescription,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
} from '@/components/ui/dialog';
12+
import { useTimelockActions } from '@/hooks/useTimelockActions';
13+
import { useStellarWallet } from '@/hooks/useStellarWallet';
14+
import type { TimelockAction } from '@/services/admin.service';
15+
16+
const PANEL_CLASS =
17+
'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8';
18+
19+
function timestamp(value?: string): string {
20+
if (!value) return '—';
21+
const date = new Date(value);
22+
return Number.isNaN(date.getTime())
23+
? value
24+
: date.toLocaleString(undefined, {
25+
dateStyle: 'medium',
26+
timeStyle: 'short',
27+
});
28+
}
29+
30+
function remainingTime(eta: string, now: number): string {
31+
const etaMs = new Date(eta).getTime();
32+
if (Number.isNaN(etaMs)) return 'Unavailable';
33+
const seconds = Math.max(0, Math.floor((etaMs - now) / 1000));
34+
if (seconds === 0) return 'Ready';
35+
const days = Math.floor(seconds / 86_400);
36+
const hours = Math.floor((seconds % 86_400) / 3_600);
37+
const minutes = Math.floor((seconds % 3_600) / 60);
38+
const remainder = seconds % 60;
39+
const clock = [hours, minutes, remainder]
40+
.map(part => String(part).padStart(2, '0'))
41+
.join(':');
42+
return days > 0 ? `${days}d ${clock}` : clock;
43+
}
44+
45+
function paramsSummary(params: unknown): string {
46+
if (params === null || typeof params !== 'object')
47+
return String(params ?? 'No parameters');
48+
const entries = Object.entries(params as Record<string, unknown>);
49+
if (entries.length === 0) return 'No parameters';
50+
const summary = entries
51+
.slice(0, 2)
52+
.map(
53+
([key, value]) =>
54+
`${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`
55+
)
56+
.join(' · ');
57+
return entries.length > 2
58+
? `${summary} · +${entries.length - 2} more`
59+
: summary;
60+
}
61+
62+
function isInsideCancellationWindow(
63+
action: TimelockAction,
64+
now: number
65+
): boolean {
66+
if (!action.cancellable || action.status !== 'pending') return false;
67+
if (!action.cancellationDeadline) return true;
68+
const deadline = new Date(action.cancellationDeadline).getTime();
69+
return !Number.isNaN(deadline) && now <= deadline;
70+
}
71+
72+
function ActionStatus({ status }: { status: TimelockAction['status'] }) {
73+
const styles = {
74+
pending: 'border-amber-300/25 bg-amber-300/10 text-amber-200',
75+
executed: 'border-emerald-300/25 bg-emerald-300/10 text-emerald-200',
76+
cancelled: 'border-red-300/25 bg-red-300/10 text-red-200',
77+
};
78+
return (
79+
<span
80+
className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold capitalize ${styles[status]}`}
81+
>
82+
{status}
83+
</span>
84+
);
85+
}
86+
87+
function JsonParams({ params }: { params: unknown }) {
88+
return (
89+
<pre className="max-h-[50vh] overflow-auto rounded-lg border border-white/10 bg-black/20 p-4 text-left font-mono text-xs leading-5 text-white/80">
90+
{JSON.stringify(params, null, 2)}
91+
</pre>
92+
);
93+
}
94+
95+
export default function TimelockQueuePanel({ isAdmin }: { isAdmin: boolean }) {
96+
const { address, isConnected } = useStellarWallet();
97+
const { pending, history, cancel, enabled } = useTimelockActions(
98+
isAdmin,
99+
address,
100+
isConnected
101+
);
102+
const [selectedAction, setSelectedAction] = useState<TimelockAction | null>(
103+
null
104+
);
105+
const [now, setNow] = useState(Date.now());
106+
107+
useEffect(() => {
108+
if (!pending.data?.length) return;
109+
const interval = window.setInterval(() => setNow(Date.now()), 1000);
110+
return () => window.clearInterval(interval);
111+
}, [pending.data?.length]);
112+
113+
if (!enabled) return null;
114+
115+
return (
116+
<section className={PANEL_CLASS} data-testid="timelock-queue-panel">
117+
<div className="flex items-start justify-between gap-4">
118+
<div>
119+
<div className="flex items-center gap-2 text-amber-300">
120+
<Clock3 className="size-5" aria-hidden="true" />
121+
<span className="text-xs font-bold uppercase tracking-[0.22em]">
122+
Protocol safety
123+
</span>
124+
</div>
125+
<h2 className="mt-2 font-grotesque text-2xl font-black">
126+
Timelock queue
127+
</h2>
128+
<p className="mt-2 text-sm leading-6 text-white/55">
129+
Review scheduled protocol actions and their execution status.
130+
</p>
131+
</div>
132+
</div>
133+
134+
<div className="mt-7">
135+
<h3 className="mb-3 font-grotesque text-lg font-bold">
136+
Pending actions
137+
</h3>
138+
{pending.isLoading && (
139+
<p role="status" className="text-sm text-white/50">
140+
Loading queued actions…
141+
</p>
142+
)}
143+
{pending.isError && (
144+
<p
145+
role="alert"
146+
className="rounded-lg border border-red-300/20 bg-red-300/5 p-4 text-sm text-red-100"
147+
>
148+
The timelock queue could not be loaded. Retry from the
149+
dashboard refresh.
150+
</p>
151+
)}
152+
{!pending.isLoading &&
153+
!pending.isError &&
154+
(pending.data?.length ?? 0) === 0 && (
155+
<p className="rounded-lg border border-dashed border-white/10 px-4 py-8 text-center text-sm text-white/40">
156+
No protocol actions are waiting in the queue.
157+
</p>
158+
)}
159+
{!pending.isLoading &&
160+
!pending.isError &&
161+
(pending.data?.length ?? 0) > 0 && (
162+
<div className="overflow-x-auto rounded-lg border border-white/10">
163+
<table className="w-full min-w-[760px] border-collapse text-left text-sm">
164+
<thead className="bg-white/[0.04] text-xs uppercase text-white/45">
165+
<tr>
166+
<th className="px-4 py-3 font-semibold">
167+
Action
168+
</th>
169+
<th className="px-4 py-3 font-semibold">
170+
Parameters
171+
</th>
172+
<th className="px-4 py-3 font-semibold">ETA</th>
173+
<th className="px-4 py-3 font-semibold">
174+
Status
175+
</th>
176+
<th className="px-4 py-3 text-right font-semibold">
177+
Manage
178+
</th>
179+
</tr>
180+
</thead>
181+
<tbody className="divide-y divide-white/10">
182+
{pending.data?.map(action => {
183+
const canCancel =
184+
isAdmin &&
185+
isInsideCancellationWindow(action, now);
186+
return (
187+
<tr
188+
key={action.id}
189+
data-testid={`timelock-action-${action.id}`}
190+
>
191+
<td className="px-4 py-4 font-semibold text-white/85">
192+
{action.type}
193+
</td>
194+
<td
195+
className="max-w-sm truncate px-4 py-4 text-white/55"
196+
title={paramsSummary(action.params)}
197+
>
198+
{paramsSummary(action.params)}
199+
</td>
200+
<td
201+
className="whitespace-nowrap px-4 py-4 font-mono text-amber-200"
202+
data-testid={`timelock-eta-${action.id}`}
203+
>
204+
{remainingTime(action.eta, now)}
205+
</td>
206+
<td className="px-4 py-4">
207+
<ActionStatus
208+
status={
209+
new Date(action.eta).getTime() <=
210+
now
211+
? 'pending'
212+
: action.status
213+
}
214+
/>
215+
</td>
216+
<td className="px-4 py-4">
217+
<div className="flex justify-end gap-2">
218+
<Button
219+
type="button"
220+
variant="outline"
221+
size="sm"
222+
onClick={() =>
223+
setSelectedAction(action)
224+
}
225+
aria-label={`View ${action.type} details`}
226+
className="border-white/15 bg-white/5 text-white hover:bg-white/10"
227+
>
228+
<FileSearch aria-hidden="true" />
229+
</Button>
230+
<Button
231+
type="button"
232+
variant="outline"
233+
size="sm"
234+
disabled={
235+
!canCancel || cancel.isPending
236+
}
237+
onClick={() =>
238+
setSelectedAction(action)
239+
}
240+
data-testid={`timelock-cancel-${action.id}`}
241+
className="border-red-300/20 bg-red-300/5 text-red-200 hover:bg-red-300/10"
242+
>
243+
{cancel.isPending &&
244+
cancel.variables === action.id ? (
245+
<Loader2
246+
className="animate-spin"
247+
aria-hidden="true"
248+
/>
249+
) : (
250+
<XCircle aria-hidden="true" />
251+
)}
252+
Cancel
253+
</Button>
254+
</div>
255+
</td>
256+
</tr>
257+
);
258+
})}
259+
</tbody>
260+
</table>
261+
</div>
262+
)}
263+
</div>
264+
265+
<div className="mt-9 border-t border-white/10 pt-7">
266+
<div className="mb-3 flex items-center gap-2">
267+
<History className="size-4 text-white/40" aria-hidden="true" />
268+
<h3 className="font-grotesque text-lg font-bold">
269+
Action history
270+
</h3>
271+
</div>
272+
{history.isLoading && (
273+
<p role="status" className="text-sm text-white/45">
274+
Loading action history…
275+
</p>
276+
)}
277+
{history.isError && (
278+
<p role="alert" className="text-sm text-red-200">
279+
Action history could not be loaded.
280+
</p>
281+
)}
282+
{!history.isLoading &&
283+
!history.isError &&
284+
(history.data?.length ?? 0) === 0 && (
285+
<p className="rounded-lg border border-dashed border-white/10 px-4 py-6 text-center text-sm text-white/35">
286+
Executed and cancelled actions will appear here.
287+
</p>
288+
)}
289+
{!history.isLoading &&
290+
!history.isError &&
291+
(history.data?.length ?? 0) > 0 && (
292+
<ul
293+
className="divide-y divide-white/10 rounded-lg border border-white/10"
294+
aria-label="Timelock action history"
295+
>
296+
{history.data?.map(action => (
297+
<li
298+
key={action.id}
299+
className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
300+
>
301+
<div className="min-w-0">
302+
<p className="font-semibold text-white/85">
303+
{action.type}
304+
</p>
305+
<p className="truncate text-xs text-white/40">
306+
{paramsSummary(action.params)}
307+
</p>
308+
</div>
309+
<div className="flex items-center gap-3">
310+
<ActionStatus status={action.status} />
311+
<time
312+
className="text-xs text-white/45"
313+
dateTime={
314+
action.executedAt ??
315+
action.cancelledAt ??
316+
action.eta
317+
}
318+
>
319+
{timestamp(
320+
action.executedAt ??
321+
action.cancelledAt ??
322+
action.eta
323+
)}
324+
</time>
325+
</div>
326+
</li>
327+
))}
328+
</ul>
329+
)}
330+
</div>
331+
332+
<Dialog
333+
open={selectedAction !== null}
334+
onOpenChange={open => !open && setSelectedAction(null)}
335+
>
336+
<DialogContent className="border-white/10 bg-[#091728] text-white sm:max-w-2xl">
337+
<DialogHeader>
338+
<DialogTitle className="font-grotesque text-xl font-bold">
339+
{selectedAction?.type ?? 'Action details'}
340+
</DialogTitle>
341+
<DialogDescription className="text-white/50">
342+
Full queued action parameters and timing.
343+
</DialogDescription>
344+
</DialogHeader>
345+
{selectedAction && (
346+
<div className="space-y-4">
347+
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-white/55">
348+
<span>Queued {timestamp(selectedAction.queuedAt)}</span>
349+
<span>ETA {timestamp(selectedAction.eta)}</span>
350+
</div>
351+
<JsonParams params={selectedAction.params} />
352+
</div>
353+
)}
354+
<DialogFooter>
355+
{selectedAction &&
356+
isAdmin &&
357+
isInsideCancellationWindow(selectedAction, now) && (
358+
<Button
359+
type="button"
360+
variant="outline"
361+
disabled={cancel.isPending}
362+
onClick={() =>
363+
cancel.mutate(selectedAction.id, {
364+
onSuccess: () => setSelectedAction(null),
365+
})
366+
}
367+
className="border-red-300/20 bg-red-300/5 text-red-200 hover:bg-red-300/10"
368+
>
369+
{cancel.isPending ? (
370+
<Loader2
371+
className="animate-spin"
372+
aria-hidden="true"
373+
/>
374+
) : (
375+
<XCircle aria-hidden="true" />
376+
)}
377+
{cancel.isPending
378+
? 'Cancelling…'
379+
: 'Cancel queued action'}
380+
</Button>
381+
)}
382+
<Button
383+
type="button"
384+
variant="outline"
385+
onClick={() => setSelectedAction(null)}
386+
className="border-white/15 bg-white/5 text-white"
387+
>
388+
Close
389+
</Button>
390+
</DialogFooter>
391+
</DialogContent>
392+
</Dialog>
393+
</section>
394+
);
395+
}

0 commit comments

Comments
 (0)