feat(api): add server-side pagination and sorting to event logs API
- update event logs API to support pagination and sorting via headers - modify event logs page to handle new API response structure - implement debounced search functionality for improved UX - adjust total count display to reflect actual number of logs
This commit is contained in:
@@ -200,14 +200,23 @@ class ApiClient {
|
||||
await this.client.delete(`/api/v1/whatsapp_accounts/${id}`);
|
||||
}
|
||||
|
||||
// Event Logs API
|
||||
// Event Logs API — uses RestHeadSpec native headers for server-side pagination/sorting
|
||||
async getEventLogs(params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<EventLog[]> {
|
||||
const { data } = await this.client.get<EventLog[]>("/api/v1/event_logs", {
|
||||
params,
|
||||
});
|
||||
sort?: string;
|
||||
search?: string;
|
||||
}): Promise<{ data: EventLog[]; meta: { total: number; limit: number; offset: number } }> {
|
||||
const headers: Record<string, string> = { 'X-DetailApi': 'true' };
|
||||
if (params?.sort) headers['X-Sort'] = params.sort;
|
||||
if (params?.limit) headers['X-Limit'] = String(params.limit);
|
||||
if (params?.offset !== undefined) headers['X-Offset'] = String(params.offset);
|
||||
if (params?.search) headers['X-SearchOp-Like-EventType'] = params.search;
|
||||
|
||||
const { data } = await this.client.get<{ data: EventLog[]; meta: { total: number; limit: number; offset: number } }>(
|
||||
'/api/v1/event_logs',
|
||||
{ headers },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ export interface QueryRequest {
|
||||
id?: string;
|
||||
data?: Record<string, any>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
search_columns?: string[];
|
||||
order_by?: string;
|
||||
order_dir?: 'ASC' | 'DESC';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function DashboardPage() {
|
||||
users: users?.length || 0,
|
||||
hooks: hooks?.length || 0,
|
||||
accounts: accounts?.length || 0,
|
||||
eventLogs: eventLogs?.length || 0
|
||||
eventLogs: eventLogs?.meta?.total || 0
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err);
|
||||
|
||||
@@ -1,43 +1,57 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Table,
|
||||
Badge,
|
||||
Group,
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Title,
|
||||
Text,
|
||||
Table,
|
||||
Badge,
|
||||
Group,
|
||||
Alert,
|
||||
Loader,
|
||||
Center,
|
||||
Stack,
|
||||
TextInput,
|
||||
Select,
|
||||
Pagination,
|
||||
Code,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { IconAlertCircle, IconFileText, IconSearch } from '@tabler/icons-react';
|
||||
import { listRecords } from '../lib/query';
|
||||
import { apiClient } from '../lib/api';
|
||||
import type { EventLog } from '../types';
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export default function EventLogsPage() {
|
||||
const [logs, setLogs] = useState<EventLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterType, setFilterType] = useState<string | null>(null);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// Debounce search input by 400ms
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, []);
|
||||
const timer = setTimeout(() => setDebouncedSearch(searchQuery), 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
const loadLogs = async () => {
|
||||
// Reset to page 1 on new search
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await listRecords<EventLog>('event_logs');
|
||||
setLogs(data || []);
|
||||
const result = await apiClient.getEventLogs({
|
||||
sort: '-created_at',
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: (currentPage - 1) * ITEMS_PER_PAGE,
|
||||
search: debouncedSearch || undefined,
|
||||
});
|
||||
setLogs(result.data || []);
|
||||
setTotalCount(result.meta?.total || 0);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load event logs');
|
||||
@@ -45,33 +59,13 @@ export default function EventLogsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [currentPage, debouncedSearch]);
|
||||
|
||||
const getSuccessColor = (success: boolean) => {
|
||||
return success ? 'green' : 'red';
|
||||
};
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
}, [loadLogs]);
|
||||
|
||||
// Filter logs
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
const matchesSearch = !searchQuery ||
|
||||
log.event_type.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.action?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
log.entity_type?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesType = !filterType || log.event_type === filterType;
|
||||
|
||||
return matchesSearch && matchesType;
|
||||
});
|
||||
|
||||
// Paginate
|
||||
const totalPages = Math.ceil(filteredLogs.length / itemsPerPage);
|
||||
const paginatedLogs = filteredLogs.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
// Get unique event types for filter
|
||||
const eventTypes = Array.from(new Set(logs.map(log => log.event_type)));
|
||||
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -104,29 +98,12 @@ export default function EventLogsPage() {
|
||||
|
||||
<Group mb="md">
|
||||
<TextInput
|
||||
placeholder="Search logs..."
|
||||
placeholder="Search by event type..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Filter by type"
|
||||
data={[
|
||||
{ value: '', label: 'All Types' },
|
||||
...eventTypes.map(type => ({ value: type, label: type }))
|
||||
]}
|
||||
value={filterType}
|
||||
onChange={(value) => {
|
||||
setFilterType(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
clearable
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Table highlightOnHover withTableBorder withColumnBorders>
|
||||
@@ -142,21 +119,21 @@ export default function EventLogsPage() {
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{paginatedLogs.length === 0 ? (
|
||||
{logs.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Center h={200}>
|
||||
<Stack align="center">
|
||||
<IconFileText size={48} stroke={1.5} color="gray" />
|
||||
<Text c="dimmed">
|
||||
{searchQuery || filterType ? 'No matching logs found' : 'No event logs available'}
|
||||
{debouncedSearch ? 'No matching logs found' : 'No event logs available'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
paginatedLogs.map((log) => {
|
||||
logs.map((log) => {
|
||||
let entityDisplay = log.entity_type || '-';
|
||||
if (log.entity_id) {
|
||||
entityDisplay += ` (${log.entity_id.substring(0, 8)}...)`;
|
||||
@@ -165,14 +142,10 @@ export default function EventLogsPage() {
|
||||
return (
|
||||
<Table.Tr key={log.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</Text>
|
||||
<Text size="sm">{new Date(log.created_at).toLocaleString()}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">
|
||||
{log.event_type}
|
||||
</Badge>
|
||||
<Badge variant="light">{log.event_type}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{log.action || '-'}</Text>
|
||||
@@ -184,7 +157,7 @@ export default function EventLogsPage() {
|
||||
<Text size="sm">{log.user_id ? `User ${log.user_id.substring(0, 8)}...` : '-'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={getSuccessColor(log.success)} variant="light">
|
||||
<Badge color={log.success ? 'green' : 'red'} variant="light">
|
||||
{log.success ? 'Success' : 'Failed'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
@@ -210,22 +183,16 @@ export default function EventLogsPage() {
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Group justify="center" mt="xl">
|
||||
<Pagination
|
||||
total={totalPages}
|
||||
value={currentPage}
|
||||
onChange={setCurrentPage}
|
||||
/>
|
||||
<Pagination total={totalPages} value={currentPage} onChange={setCurrentPage} />
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Showing {paginatedLogs.length} of {filteredLogs.length} logs
|
||||
Showing {logs.length} of {totalCount} logs
|
||||
</Text>
|
||||
{(searchQuery || filterType) && (
|
||||
<Text size="sm" c="dimmed">
|
||||
(filtered from {logs.length} total)
|
||||
</Text>
|
||||
{debouncedSearch && (
|
||||
<Text size="sm" c="dimmed">Filtered by: "{debouncedSearch}"</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Container>
|
||||
|
||||
Reference in New Issue
Block a user