Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions examples/mockAuditEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// src/examples/mockAuditEvents.ts
import type { AuditEvent } from '@/types';

export const mockAuditEvents: AuditEvent[] = Array.from({ length: 1200 }, (_, i) => {
const types: AuditEvent['eventType'][] = [
'wallet_connected', 'trade_executed', 'rebalance_triggered',
'risk_alert', 'ai_recommendation_applied', 'settings_changed', 'export_generated',
];
const results: AuditEvent['result'][] = ['success', 'failure', 'pending'];
const eventType = types[i % types.length];
return {
id: `evt_${i}`,
timestamp: Date.now() - i * 3_600_000,
eventType,
action: `Sample action for ${eventType}`,
result: results[i % results.length],
actor: 'GABC...MOCK',
before: eventType === 'settings_changed' ? { riskTolerance: 'medium' } : undefined,
after: eventType === 'settings_changed' ? { riskTolerance: 'high' } : undefined,
};
});
79 changes: 34 additions & 45 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"dependencies": {
"@creit.tech/stellar-wallets-kit": "^2.5.0",
"@tailwindcss/forms": "^0.5.6",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.6.0",
"chart.js": "^4.4.0",
"d3": "^7.8.5",
Expand Down
148 changes: 148 additions & 0 deletions src/components/audit/AuditLogTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
'use client';

import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getPaginationRowModel,
flexRender,
createColumnHelper,
type SortingState,
} from '@tanstack/react-table';
import { useState } from 'react';
import type { AuditEvent } from '@/types';

const columnHelper = createColumnHelper<AuditEvent>();

const columns = [
columnHelper.accessor('timestamp', {
header: 'Timestamp',
cell: (info) => new Date(info.getValue()).toLocaleString(),
}),
columnHelper.accessor('eventType', {
header: 'Event Type',
}),
columnHelper.accessor('action', {
header: 'Action',
}),
columnHelper.accessor('result', {
header: 'Result',
cell: (info) => {
const result = info.getValue();
const color =
result === 'success' ? 'text-green-600' :
result === 'failure' ? 'text-red-600' : 'text-yellow-600';
return <span className={color}>{result}</span>;
},
}),
];

interface AuditLogTableProps {
events: AuditEvent[];
onSelectEvent: (event: AuditEvent) => void;
}

export function AuditLogTable({ events, onSelectEvent }: AuditLogTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);

const table = useReactTable({
data: events,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 10 } },
});

return (
<>
{/* Desktop table view */}
<div className="hidden md:block overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900/50 sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer select-none hover:text-brand-teal transition-colors"
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' ▲', desc: ' ▼' }[header.column.getIsSorted() as string] ?? ''}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{table.getRowModel().rows.map((row, i) => (
<tr
key={row.id}
onClick={() => onSelectEvent(row.original)}
className={`cursor-pointer transition-colors hover:bg-brand-teal/5 dark:hover:bg-brand-teal/10 ${
i % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50/50 dark:bg-gray-900/20'
}`}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>

{/* Mobile card view */}
<div className="md:hidden divide-y divide-gray-200 dark:divide-gray-700">
{table.getRowModel().rows.map((row) => {
const event = row.original;
const resultColor =
event.result === 'success' ? 'text-green-600' :
event.result === 'failure' ? 'text-red-600' : 'text-yellow-600';
return (
<div
key={row.id}
onClick={() => onSelectEvent(event)}
className="p-4 active:bg-gray-50 dark:active:bg-gray-700"
>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(event.timestamp).toLocaleString()}
</span>
<span className={`text-xs font-medium ${resultColor}`}>{event.result}</span>
</div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{event.eventType}</p>
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">{event.action}</p>
</div>
);
})}
</div>

{/* Pagination — shared by both views */}
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-900/30">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
Previous
</button>
<span className="text-sm text-gray-500 dark:text-gray-400">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="px-3 py-1.5 text-sm rounded border border-gray-300 dark:border-gray-600 disabled:opacity-40 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
Next
</button>
</div>
</>
);
}
97 changes: 97 additions & 0 deletions src/components/audit/AuditLogViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client';

import { useState, useMemo } from 'react';
import { AuditLogTable } from './AuditLogTable';
import type { AuditEvent, AuditEventType } from '@/types';
import { mockAuditEvents } from '../../../examples/mockAuditEvents';
import { DateRangeFilter } from './DateRangeFilter';
import { EventTypeFilter } from './EventTypeFilter';
import { EventDetailModal } from './EventDetailModal';
import { ExportButton } from './ExportButton';


export interface AuditLogFilters {
dateFrom: number | null;
dateTo: number | null;
eventTypes: AuditEventType[];
search: string;
}

const defaultFilters: AuditLogFilters = {
dateFrom: null,
dateTo: null,
eventTypes: [],
search: '',
};

export default function AuditLogViewer() {
const [filters, setFilters] = useState<AuditLogFilters>(defaultFilters);
const [selectedEvent, setSelectedEvent] = useState<AuditEvent | null>(null);

// this is a placeholder for fetching events from an API or other source, must be replaced for fetch real data
const events = mockAuditEvents;

const filteredEvents = useMemo(() => {
return events.filter((event) => {
if (filters.dateFrom && event.timestamp < filters.dateFrom) return false;
if (filters.dateTo && event.timestamp > filters.dateTo) return false;
if (filters.eventTypes.length > 0 && !filters.eventTypes.includes(event.eventType)) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
const haystack = `${event.action} ${event.eventType} ${event.result}`.toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
});
}, [events, filters]);

return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Audit Log</h2>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-500 dark:text-gray-400">
{filteredEvents.length} event{filteredEvents.length !== 1 ? 's' : ''}
</span>
<ExportButton events={filteredEvents} />
</div>
</div>

<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-end gap-4 bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm">
<DateRangeFilter
dateFrom={filters.dateFrom}
dateTo={filters.dateTo}
onChange={(dateFrom, dateTo) => setFilters((f) => ({ ...f, dateFrom, dateTo }))}
/>
<EventTypeFilter
selected={filters.eventTypes}
onChange={(eventTypes) => setFilters((f) => ({ ...f, eventTypes }))}
/>
<label className="flex flex-col text-xs text-gray-500 dark:text-gray-400 flex-1 min-w-[200px]">
Search
<input
type="text"
placeholder="Search by action, type, or result..."
value={filters.search}
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))}
className="mt-1 px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm"
/>
</label>
{(filters.dateFrom || filters.dateTo || filters.eventTypes.length > 0 || filters.search) && (
<button
onClick={() => setFilters(defaultFilters)}
className="text-sm text-gray-500 hover:text-brand-teal underline whitespace-nowrap"
>
Clear all
</button>
)}
</div>

<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<AuditLogTable events={filteredEvents} onSelectEvent={setSelectedEvent} />
<EventDetailModal event={selectedEvent} onClose={() => setSelectedEvent(null)} />
</div>
</div>
);
}
Loading