Skip to content

Commit 5e2dc5d

Browse files
committed
feat(audit-trail): register routes and enhance layout for result view
1 parent a8d51b0 commit 5e2dc5d

7 files changed

Lines changed: 335 additions & 7 deletions

File tree

apps/explorer/src/components/ui/PageHeader.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type PageHeaderType =
2525
| 'Package'
2626
| 'Identity'
2727
| 'Notarization'
28+
| 'Audit Trail'
2829
| 'Abstract Account';
2930

3031
export interface PageHeaderProps {
@@ -87,7 +88,7 @@ export function PageHeader({
8788
<>
8889
{type && (
8990
<div className="flex flex-row items-center gap-xxs">
90-
<span className="text-headline-sm text-iota-neutral-10 dark:text-iota-neutral-92">
91+
<span className="dark:text-iota-neutral-92 text-headline-sm text-iota-neutral-10">
9192
{type}
9293
</span>
9394
{status && (
@@ -103,7 +104,7 @@ export function PageHeader({
103104
</div>
104105
)}
105106
{title && (
106-
<div className="flex items-center gap-xxs text-iota-neutral-40 dark:text-iota-neutral-60">
107+
<div className="dark:text-iota-neutral-60 flex items-center gap-xxs text-iota-neutral-40">
107108
<span
108109
className="break-all text-body-ds-lg"
109110
data-testid="heading-object-id"
@@ -124,7 +125,7 @@ export function PageHeader({
124125
{isLoadingSubtitle ? (
125126
<Placeholder width="w-48" />
126127
) : subtitle ? (
127-
<span className="truncate text-body-md text-iota-neutral-40 dark:text-iota-neutral-60">
128+
<span className="dark:text-iota-neutral-60 truncate text-body-md text-iota-neutral-40">
128129
{subtitle}
129130
</span>
130131
) : null}

apps/explorer/src/components/ui/SideBySidePanels.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// Copyright (c) 2026 IOTA Stiftung
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { Panel, PanelGroup } from 'react-resizable-panels';
54
import { ErrorBoundary } from '~/components';
5+
import { clsx } from 'clsx';
6+
import { Panel, PanelGroup } from 'react-resizable-panels';
67

78
/**
89
* The props for the SideBySidePanels component.
@@ -16,16 +17,31 @@ interface SideBySidePanelsProps {
1617
* The component for the second panel.
1718
*/
1819
secondPanel: React.ReactNode;
20+
/**
21+
* The desired width distribution between panels on md+ screens.
22+
*/
23+
ratio?: '50-50' | '66-34';
1924
}
2025

26+
const RATIO_CLASSES = {
27+
'50-50': { first: 'md:w-1/2', second: 'md:w-1/2' },
28+
'66-34': { first: 'md:w-4/6', second: 'md:w-2/6' },
29+
};
30+
2131
/**
2232
* Component that displays two panels side by side, already wrapped in ErrorBoundary.
2333
*/
24-
export function SideBySidePanels({ firstPanel, secondPanel }: SideBySidePanelsProps) {
34+
export function SideBySidePanels({
35+
firstPanel,
36+
secondPanel,
37+
ratio = '50-50',
38+
}: SideBySidePanelsProps) {
39+
const classes = RATIO_CLASSES[ratio];
40+
2541
return (
2642
<ErrorBoundary>
2743
<div className="flex flex-col gap-md md:flex-row">
28-
<div className="flex w-full flex-1 md:w-1/2">
44+
<div className={clsx('flex w-full flex-1', classes.first)}>
2945
<div className="panel-bg flex w-full flex-col rounded-xl border border-transparent p-md--rs">
3046
<PanelGroup direction="horizontal">
3147
<Panel>
@@ -34,7 +50,7 @@ export function SideBySidePanels({ firstPanel, secondPanel }: SideBySidePanelsPr
3450
</PanelGroup>
3551
</div>
3652
</div>
37-
<div className="flex w-full md:w-1/2">
53+
<div className={clsx('flex w-full', classes.second)}>
3854
<div className="panel-bg flex w-full flex-col rounded-xl border border-transparent p-md--rs">
3955
<PanelGroup direction="horizontal">
4056
<Panel>
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// TODO: use '@iota/audit-trail/web' after publish
5+
import { type AuditTrailHandle, type OnChainAuditTrail, type Record } from '@iota/audit-trail';
6+
import { useInfiniteQuery, useQuery, type UseQueryResult } from '@tanstack/react-query';
7+
import { useMemo } from 'react';
8+
import { useAuditTrailClient } from '~/contexts';
9+
10+
// NOTE: These are placeholder types based on the specification.
11+
// They will be replaced with the actual types from '@iota/audit-trail'.
12+
13+
type ListPageResponse = {
14+
records: Record[];
15+
cursor?: bigint;
16+
};
17+
18+
/**
19+
* A React hook that resolves an Object ID to its corresponding Audit Trail document on chain.
20+
*
21+
* @param {string} objectId - The Object ID to resolve.
22+
* @returns a Audit Trail document on chain.
23+
*/
24+
export function useResolveOnChainAuditTrail(objectId: string): UseQueryResult<OnChainAuditTrail> {
25+
const auditTrailClient = useAuditTrailClient();
26+
return useQuery({
27+
queryKey: ['resolve-audit-trail', objectId],
28+
queryFn: async () => auditTrailClient?.trail(objectId).get(),
29+
enabled: !!auditTrailClient,
30+
});
31+
}
32+
33+
/**
34+
* A React hook that resolves an Object ID to its corresponding Audit Trail document on chain.
35+
*
36+
* @param {string} objectId - The Object ID to resolve.
37+
* @returns a Audit Trail document on chain.
38+
*/
39+
export function useResolveAuditTrailHandle(objectId: string): UseQueryResult<AuditTrailHandle> {
40+
const auditTrailClient = useAuditTrailClient();
41+
return useQuery({
42+
queryKey: ['resolve-audit-trail-handle', objectId],
43+
queryFn: async () => auditTrailClient?.trail(objectId),
44+
enabled: !!auditTrailClient,
45+
});
46+
}
47+
48+
type UsePaginatedAuditTrailRecordsParams = {
49+
objectId: string;
50+
auditTrail: AuditTrailHandle | null;
51+
pageSize: number;
52+
};
53+
54+
export function usePaginatedAuditTrailRecords({
55+
objectId,
56+
auditTrail,
57+
pageSize,
58+
}: UsePaginatedAuditTrailRecordsParams) {
59+
const { data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isLoading } =
60+
useInfiniteQuery<ListPageResponse>({
61+
queryKey: ['paginatedRecords', objectId, auditTrail, pageSize],
62+
queryFn: async ({ pageParam = 0n }) => {
63+
if (!auditTrail) {
64+
return { records: [], cursor: undefined };
65+
}
66+
67+
const response = await auditTrail.records().listPage(pageParam as bigint, pageSize);
68+
69+
// This is a temporary hack because the wasm bindings do not return the correct type.
70+
// This should be removed once the bindings are fixed.
71+
return response as unknown as ListPageResponse;
72+
},
73+
initialPageParam: 0n,
74+
getNextPageParam: (lastPage) => lastPage.cursor,
75+
enabled: Boolean(auditTrail),
76+
});
77+
78+
const records = useMemo(() => data?.pages.flatMap((page) => page.records) ?? [], [data]);
79+
80+
return {
81+
records,
82+
error,
83+
fetchNextPage,
84+
hasNextPage,
85+
isFetching,
86+
isFetchingNextPage,
87+
isLoading,
88+
};
89+
}

apps/explorer/src/pages/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { ValidatorPageResult } from './validators/Validators';
1818
import { Layout } from '~/components';
1919
import { IdentityResult } from './trust-framework/identity-result/IdentityResult';
2020
import { NotarizationResult } from './trust-framework/notarization-result/NotarizationResult';
21+
import { AuditTrailResult } from './trust-framework/audit-trail-result/AuditTrailResult';
2122

2223
interface RedirectWithIdProps {
2324
base: string;
@@ -50,6 +51,7 @@ export const router = sentryCreateBrowserRouter([
5051
{ path: 'validator/:id', element: <ValidatorDetails /> },
5152
{ path: 'identity/:id', element: <IdentityResult /> },
5253
{ path: 'notarization/:id', element: <NotarizationResult /> },
54+
{ path: 'audit-trail/:id', element: <AuditTrailResult /> },
5355
],
5456
},
5557
{
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { InfoBox, InfoBoxStyle, InfoBoxType } from '@iota/apps-ui-kit';
5+
import { AddressAlias, useCopyToClipboard, useGetObjectOrPastObject } from '@iota/core';
6+
import { PageHeader, PageLayout } from '~/components';
7+
import { onCopySuccess } from '~/lib';
8+
import { useAuditTrailPkgId } from '~/contexts';
9+
import { useMemo } from 'react';
10+
import { Warning } from '@iota/apps-ui-icons';
11+
import {
12+
getAuditTrailRecordsSize,
13+
getAuditTrailType,
14+
MetadataBuilder,
15+
} from '../headerMetadataHelper';
16+
import {
17+
useResolveAuditTrailHandle,
18+
useResolveOnChainAuditTrail,
19+
} from '~/hooks/useResolveAuditTrail';
20+
import { TransactionsView } from '../common/TransactionsView';
21+
22+
interface AuditTrailContentProps {
23+
objectId: string;
24+
}
25+
26+
export function AuditTrailContent({ objectId }: AuditTrailContentProps) {
27+
const { data: objectResult, isPending: isObjectPending } = useGetObjectOrPastObject(objectId);
28+
const { data: auditTrailObject, isPending: isAuditTrailObjectPending } =
29+
useResolveOnChainAuditTrail(objectId);
30+
const { data: auditTrailHandle, isPending: isAuditTrailHandlePending } =
31+
useResolveAuditTrailHandle(objectId);
32+
33+
const copyToClipboard = useCopyToClipboard(onCopySuccess);
34+
const iotaAuditTrailPackage = useAuditTrailPkgId();
35+
36+
const isPending = useMemo(
37+
() => isAuditTrailObjectPending || isObjectPending || isAuditTrailHandlePending,
38+
[isAuditTrailObjectPending, isObjectPending, isAuditTrailHandlePending],
39+
);
40+
if (isPending) {
41+
return <PageLayout loading loadingText="Loading Audit Trail Object..." content={[]} />;
42+
}
43+
44+
if (auditTrailObject == null || auditTrailHandle == null) {
45+
return (
46+
<PageLayout
47+
content={
48+
<InfoBox
49+
title="Error resolving Audit Trail"
50+
supportingText={`Could not resolve Audit Trail ${objectId} in the current network.`}
51+
icon={<Warning />}
52+
type={InfoBoxType.Error}
53+
style={InfoBoxStyle.Elevated}
54+
/>
55+
}
56+
/>
57+
);
58+
}
59+
60+
if (objectResult == null) {
61+
return (
62+
<PageLayout
63+
content={
64+
<InfoBox
65+
title="Error fetching Object"
66+
supportingText={`Could not fetch Object ID ${objectId} from the current network.`}
67+
icon={<Warning />}
68+
type={InfoBoxType.Error}
69+
style={InfoBoxStyle.Elevated}
70+
/>
71+
}
72+
/>
73+
);
74+
}
75+
76+
if (iotaAuditTrailPackage == null) {
77+
// The activation of this branch is a symptom of Notarization WASM Web module not loaded.
78+
return (
79+
<PageLayout
80+
content={
81+
<InfoBox
82+
title="Error loading official Audit Trail package"
83+
supportingText="Could not load package ID from Audit Trail client."
84+
icon={<Warning />}
85+
type={InfoBoxType.Error}
86+
style={InfoBoxStyle.Elevated}
87+
/>
88+
}
89+
/>
90+
);
91+
}
92+
93+
return (
94+
<PageLayout
95+
content={
96+
<div className="flex flex-col gap-y-2xl">
97+
<PageHeader
98+
type="Audit Trail"
99+
title={
100+
<AddressAlias
101+
address={objectId || ''}
102+
onCopy={() => copyToClipboard(objectId || '')}
103+
/>
104+
}
105+
showCopyButton={false}
106+
metaItems={MetadataBuilder.create()
107+
.addItem(getAuditTrailType(objectResult.data!, iotaAuditTrailPackage))
108+
.addItem(getAuditTrailRecordsSize(auditTrailObject))
109+
.build()}
110+
/>
111+
<TransactionsView objectId={objectId} />
112+
</div>
113+
}
114+
/>
115+
);
116+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { InfoBox, InfoBoxStyle, InfoBoxType } from '@iota/apps-ui-kit';
5+
import { useParams } from 'react-router-dom';
6+
import { PageLayout } from '~/components';
7+
import { Warning } from '@iota/apps-ui-icons';
8+
import { AuditTrailContent } from './AuditTrailContent';
9+
10+
export function AuditTrailResult() {
11+
const { id: auditTrailId } = useParams();
12+
13+
if (auditTrailId == null) {
14+
return (
15+
<PageLayout
16+
content={
17+
<InfoBox
18+
title="Audit Trail not implemented yet!"
19+
supportingText="Wait for the Audit Trail implementation."
20+
icon={<Warning />}
21+
type={InfoBoxType.Error}
22+
style={InfoBoxStyle.Elevated}
23+
/>
24+
}
25+
/>
26+
);
27+
}
28+
29+
return <AuditTrailContent objectId={auditTrailId} />;
30+
}

0 commit comments

Comments
 (0)