Skip to content

Commit 9c44b45

Browse files
Add transaction activity logs with expandable timeline view
Co-authored-by: joaquim.verges <[email protected]>
1 parent 8def4f3 commit 9c44b45

File tree

3 files changed

+235
-15
lines changed

3 files changed

+235
-15
lines changed

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/analytics.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ export async function getTransactionsChart({
122122

123123
// TODO - need to handle this error state, like we do with the connect charts
124124
throw new Error(
125-
`Error fetching transactions chart data: ${response.status} ${response.statusText} - ${await response.text().catch(() => "Unknown error")}`,
125+
`Error fetching transactions chart data: ${response.status} ${
126+
response.statusText
127+
} - ${await response.text().catch(() => "Unknown error")}`,
126128
);
127129
}
128130

@@ -192,11 +194,87 @@ export async function getSingleTransaction({
192194

193195
// TODO - need to handle this error state, like we do with the connect charts
194196
throw new Error(
195-
`Error fetching single transaction data: ${response.status} ${response.statusText} - ${await response.text().catch(() => "Unknown error")}`,
197+
`Error fetching single transaction data: ${response.status} ${
198+
response.statusText
199+
} - ${await response.text().catch(() => "Unknown error")}`,
196200
);
197201
}
198202

199203
const data = (await response.json()).result as TransactionsResponse;
200204

201205
return data.transactions[0];
202206
}
207+
208+
// Activity log types
209+
export type ActivityLogEntry = {
210+
id: string;
211+
transactionId: string;
212+
batchIndex: number;
213+
eventType: string;
214+
stageName: string;
215+
executorName: string;
216+
notificationId: string;
217+
payload: Record<string, unknown> | string | number | boolean | null;
218+
timestamp: string;
219+
createdAt: string;
220+
};
221+
222+
type ActivityLogsResponse = {
223+
result: {
224+
activityLogs: ActivityLogEntry[];
225+
transaction: {
226+
id: string;
227+
batchIndex: number;
228+
clientId: string;
229+
};
230+
pagination: {
231+
totalCount: number;
232+
page: number;
233+
limit: number;
234+
};
235+
};
236+
};
237+
238+
export async function getTransactionActivityLogs({
239+
teamId,
240+
clientId,
241+
transactionId,
242+
}: {
243+
teamId: string;
244+
clientId: string;
245+
transactionId: string;
246+
}): Promise<ActivityLogEntry[]> {
247+
const authToken = await getAuthToken();
248+
249+
const response = await fetch(
250+
`${NEXT_PUBLIC_ENGINE_CLOUD_URL}/v1/transactions/activity-logs?transactionId=${transactionId}`,
251+
{
252+
headers: {
253+
Authorization: `Bearer ${authToken}`,
254+
"Content-Type": "application/json",
255+
"x-client-id": clientId,
256+
"x-team-id": teamId,
257+
},
258+
method: "GET",
259+
},
260+
);
261+
262+
if (!response.ok) {
263+
if (response.status === 401) {
264+
return [];
265+
}
266+
267+
// Don't throw on 404 - activity logs might not exist for all transactions
268+
if (response.status === 404) {
269+
return [];
270+
}
271+
272+
console.error(
273+
`Error fetching activity logs: ${response.status} ${response.statusText}`,
274+
);
275+
return [];
276+
}
277+
278+
const data = (await response.json()) as ActivityLogsResponse;
279+
return data.result.activityLogs;
280+
}

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/page.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { notFound, redirect } from "next/navigation";
33
import { getAuthToken } from "@/api/auth-token";
44
import { getProject } from "@/api/projects";
55
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
6-
import { getSingleTransaction } from "../../lib/analytics";
6+
import {
7+
getSingleTransaction,
8+
getTransactionActivityLogs,
9+
} from "../../lib/analytics";
710
import { TransactionDetailsUI } from "./transaction-details-ui";
811

912
export default async function TransactionPage({
@@ -26,11 +29,18 @@ export default async function TransactionPage({
2629
redirect(`/team/${team_slug}`);
2730
}
2831

29-
const transactionData = await getSingleTransaction({
30-
clientId: project.publishableKey,
31-
teamId: project.teamId,
32-
transactionId: id,
33-
});
32+
const [transactionData, activityLogs] = await Promise.all([
33+
getSingleTransaction({
34+
clientId: project.publishableKey,
35+
teamId: project.teamId,
36+
transactionId: id,
37+
}),
38+
getTransactionActivityLogs({
39+
clientId: project.publishableKey,
40+
teamId: project.teamId,
41+
transactionId: id,
42+
}),
43+
]);
3444

3545
const client = getClientThirdwebClient({
3646
jwt: authToken,
@@ -48,6 +58,7 @@ export default async function TransactionPage({
4858
project={project}
4959
teamSlug={team_slug}
5060
transaction={transactionData}
61+
activityLogs={activityLogs}
5162
/>
5263
</div>
5364
);

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/tx/[id]/transaction-details-ui.tsx

Lines changed: 138 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"use client";
22

33
import { format, formatDistanceToNowStrict } from "date-fns";
4-
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
4+
import { ExternalLink, Info, ChevronDown, ChevronRight } from "lucide-react";
55
import Link from "next/link";
6+
import { useState } from "react";
67
import { hexToNumber, isHex, type ThirdwebClient, toEther } from "thirdweb";
78
import type { Project } from "@/api/projects";
89
import { WalletAddress } from "@/components/blocks/wallet-address";
@@ -16,15 +17,18 @@ import { useAllChainsData } from "@/hooks/chains/allChains";
1617
import { ChainIconClient } from "@/icons/ChainIcon";
1718
import { statusDetails } from "../../analytics/tx-table/tx-table-ui";
1819
import type { Transaction } from "../../analytics/tx-table/types";
20+
import { type ActivityLogEntry } from "../../lib/analytics";
1921

2022
export function TransactionDetailsUI({
2123
transaction,
2224
client,
25+
activityLogs,
2326
}: {
2427
transaction: Transaction;
2528
teamSlug: string;
2629
client: ThirdwebClient;
2730
project: Project;
31+
activityLogs: ActivityLogEntry[];
2832
}) {
2933
const { idToChain } = useAllChainsData();
3034

@@ -45,8 +49,8 @@ export function TransactionDetailsUI({
4549
executionResult && "error" in executionResult
4650
? executionResult.error.message
4751
: executionResult && "revertData" in executionResult
48-
? executionResult.revertData?.revertReason
49-
: null;
52+
? executionResult.revertData?.revertReason
53+
: null;
5054
const errorDetails =
5155
executionResult && "error" in executionResult
5256
? executionResult.error
@@ -68,12 +72,18 @@ export function TransactionDetailsUI({
6872
// Gas information
6973
const gasUsed =
7074
executionResult && "actualGasUsed" in executionResult
71-
? `${isHex(executionResult.actualGasUsed) ? hexToNumber(executionResult.actualGasUsed) : executionResult.actualGasUsed}`
75+
? `${
76+
isHex(executionResult.actualGasUsed)
77+
? hexToNumber(executionResult.actualGasUsed)
78+
: executionResult.actualGasUsed
79+
}`
7280
: "N/A";
7381

7482
const gasCost =
7583
executionResult && "actualGasCost" in executionResult
76-
? `${toEther(BigInt(executionResult.actualGasCost || "0"))} ${chain?.nativeCurrency.symbol || ""}`
84+
? `${toEther(BigInt(executionResult.actualGasCost || "0"))} ${
85+
chain?.nativeCurrency.symbol || ""
86+
}`
7787
: "N/A";
7888

7989
return (
@@ -156,7 +166,10 @@ export function TransactionDetailsUI({
156166
rel="noopener noreferrer"
157167
target="_blank"
158168
>
159-
{`${transactionHash.slice(0, 8)}...${transactionHash.slice(-6)}`}{" "}
169+
{`${transactionHash.slice(
170+
0,
171+
8,
172+
)}...${transactionHash.slice(-6)}`}{" "}
160173
<ExternalLinkIcon className="size-4 text-muted-foreground" />
161174
</Link>
162175
</Button>
@@ -165,7 +178,10 @@ export function TransactionDetailsUI({
165178
className="font-mono text-muted-foreground text-sm"
166179
copyIconPosition="left"
167180
textToCopy={transactionHash}
168-
textToShow={`${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`}
181+
textToShow={`${transactionHash.slice(
182+
0,
183+
6,
184+
)}...${transactionHash.slice(-4)}`}
169185
tooltip="Copy transaction hash"
170186
variant="ghost"
171187
/>
@@ -347,7 +363,122 @@ export function TransactionDetailsUI({
347363
)}
348364
</CardContent>
349365
</Card>
366+
367+
{/* Activity Log Card */}
368+
<ActivityLogCard activityLogs={activityLogs} />
350369
</div>
351370
</>
352371
);
353372
}
373+
374+
// Activity Log Timeline Component
375+
function ActivityLogCard({
376+
activityLogs,
377+
}: { activityLogs: ActivityLogEntry[] }) {
378+
return (
379+
<Card>
380+
<CardHeader>
381+
<CardTitle className="text-lg">Activity Log</CardTitle>
382+
</CardHeader>
383+
<CardContent>
384+
{activityLogs.length === 0 ? (
385+
<p className="text-muted-foreground text-sm">
386+
No activity logs available for this transaction
387+
</p>
388+
) : (
389+
<div className="space-y-4">
390+
{activityLogs.map((log, index) => (
391+
<ActivityLogEntry
392+
key={log.id}
393+
log={log}
394+
isLast={index === activityLogs.length - 1}
395+
/>
396+
))}
397+
</div>
398+
)}
399+
</CardContent>
400+
</Card>
401+
);
402+
}
403+
404+
function ActivityLogEntry({
405+
log,
406+
isLast,
407+
}: { log: ActivityLogEntry; isLast: boolean }) {
408+
const [isExpanded, setIsExpanded] = useState(false);
409+
410+
return (
411+
<div className="relative">
412+
{/* Timeline line */}
413+
{!isLast && (
414+
<div className="absolute left-4 top-8 h-full w-0.5 bg-border" />
415+
)}
416+
417+
<div className="flex items-start gap-4">
418+
{/* Timeline dot */}
419+
<div className="relative flex h-8 w-8 items-center justify-center rounded-full bg-muted">
420+
<div className="h-3 w-3 rounded-full bg-primary" />
421+
</div>
422+
423+
{/* Content */}
424+
<div className="flex-1 min-w-0">
425+
<button
426+
onClick={() => setIsExpanded(!isExpanded)}
427+
className="flex w-full items-center justify-between py-2 text-left hover:bg-muted/50 rounded-md px-2 -ml-2"
428+
>
429+
<div className="flex items-center gap-2">
430+
<span className="font-medium text-sm">{log.stageName}</span>
431+
<span className="text-muted-foreground text-xs">
432+
{formatDistanceToNowStrict(new Date(log.timestamp), {
433+
addSuffix: true,
434+
})}
435+
</span>
436+
</div>
437+
{isExpanded ? (
438+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
439+
) : (
440+
<ChevronRight className="h-4 w-4 text-muted-foreground" />
441+
)}
442+
</button>
443+
444+
{isExpanded && (
445+
<div className="mt-2 space-y-3 px-2">
446+
<div className="grid grid-cols-2 gap-4 text-sm">
447+
<div>
448+
<div className="text-muted-foreground">Event Type</div>
449+
<div className="font-mono">{log.eventType}</div>
450+
</div>
451+
<div>
452+
<div className="text-muted-foreground">Executor</div>
453+
<div className="font-mono">{log.executorName}</div>
454+
</div>
455+
<div>
456+
<div className="text-muted-foreground">Batch Index</div>
457+
<div className="font-mono">{log.batchIndex}</div>
458+
</div>
459+
<div>
460+
<div className="text-muted-foreground">Timestamp</div>
461+
<div className="font-mono text-xs">
462+
{format(new Date(log.timestamp), "PP pp z")}
463+
</div>
464+
</div>
465+
</div>
466+
467+
{log.payload && (
468+
<div>
469+
<div className="text-muted-foreground text-sm mb-2">
470+
Payload
471+
</div>
472+
<CodeClient
473+
code={JSON.stringify(log.payload, null, 2)}
474+
lang="json"
475+
/>
476+
</div>
477+
)}
478+
</div>
479+
)}
480+
</div>
481+
</div>
482+
</div>
483+
);
484+
}

0 commit comments

Comments
 (0)