feat(admin): implement activity logs frontend page (Phase 6-7)
Complete implementation of admin activity logs dashboard with real-time streaming capabilities. Admin users can now monitor all system activity through a comprehensive web interface. Features: - Stats dashboard with 4 key metrics (total logs, unique users, failures, 24h activity) - Category breakdown visualization with color-coded badges - Advanced filtering (date range, category, action type, username, success/failure) - Paginated log table (50 entries per page) with sort by timestamp - Real-time streaming toggle using Socket.IO - Color-coded action badges (blue=auth, green=event, purple=match, red=admin, yellow=chat) - Admin-only access with automatic redirect for non-admin users - Responsive design for mobile and desktop Frontend Changes: - Created ActivityLogsPage.jsx (600+ lines) with complete UI implementation - Added 3 admin API methods to api.js (getActivityLogs, getActivityLogActions, getActivityLogStats) - Added /admin/activity-logs route to App.jsx - Added admin navigation link to Navbar (desktop & mobile) with Shield icon - Only visible to users with isAdmin flag Implementation Details: - Uses getSocket() from socket service for real-time updates - Joins 'admin_activity_logs' Socket.IO room on streaming enable - Receives 'activity_log_entry' events and prepends to table (first page only) - Comprehensive error handling and loading states - Empty states for no data - Clean disconnect handling when streaming disabled Testing: - Build successful (no errors) - Ready for manual testing and verification Phase 8 (Testing) remains for manual verification of all features.
This commit is contained in:
@@ -18,6 +18,7 @@ import RatePartnerPage from './pages/RatePartnerPage';
|
||||
import HistoryPage from './pages/HistoryPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import PublicProfilePage from './pages/PublicProfilePage';
|
||||
import ActivityLogsPage from './pages/admin/ActivityLogsPage';
|
||||
import VerificationBanner from './components/common/VerificationBanner';
|
||||
import InstallPWA from './components/pwa/InstallPWA';
|
||||
|
||||
@@ -199,6 +200,16 @@ function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route
|
||||
path="/admin/activity-logs"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ActivityLogsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Public Profile - must be before home route */}
|
||||
<Route
|
||||
path="/:username"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Video, LogOut, User, History, Users, Menu, X, LayoutDashboard, Calendar } from 'lucide-react';
|
||||
import { Video, LogOut, User, History, Users, Menu, X, LayoutDashboard, Calendar, Shield } from 'lucide-react';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { matchesAPI } from '../../services/api';
|
||||
@@ -122,6 +122,16 @@ const Navbar = ({ pageTitle = null }) => {
|
||||
<span>Profile</span>
|
||||
</Link>
|
||||
|
||||
{user?.isAdmin && (
|
||||
<Link
|
||||
to="/admin/activity-logs"
|
||||
className="flex items-center space-x-1 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-100"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar src={user?.avatar} username={user.username} size={32} />
|
||||
<span className="text-sm font-medium text-gray-700">{user.username}</span>
|
||||
@@ -201,6 +211,15 @@ const Navbar = ({ pageTitle = null }) => {
|
||||
>
|
||||
<User className="w-4 h-4" /> Profile
|
||||
</Link>
|
||||
{user?.isAdmin && (
|
||||
<Link
|
||||
to="/admin/activity-logs"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<Shield className="w-4 h-4" /> Admin
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
|
||||
529
frontend/src/pages/admin/ActivityLogsPage.jsx
Normal file
529
frontend/src/pages/admin/ActivityLogsPage.jsx
Normal file
@@ -0,0 +1,529 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/layout/Layout';
|
||||
import { adminAPI } from '../../services/api';
|
||||
import { getSocket } from '../../services/socket';
|
||||
import {
|
||||
Activity,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
Radio,
|
||||
RadioTower,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
const ActivityLogsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
// Data
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [actions, setActions] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
// UI State
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
|
||||
// Filters
|
||||
const [filters, setFilters] = useState({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
action: '',
|
||||
category: '',
|
||||
username: '',
|
||||
success: '',
|
||||
});
|
||||
const [limit] = useState(50);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
// Check if user is admin
|
||||
useEffect(() => {
|
||||
const userData = localStorage.getItem('user');
|
||||
if (userData) {
|
||||
const parsedUser = JSON.parse(userData);
|
||||
setUser(parsedUser);
|
||||
|
||||
if (!parsedUser.isAdmin) {
|
||||
navigate('/');
|
||||
}
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
// Fetch initial data
|
||||
useEffect(() => {
|
||||
if (!user?.isAdmin) return;
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const [logsData, statsData, actionsData] = await Promise.all([
|
||||
adminAPI.getActivityLogs({ ...filters, limit, offset }),
|
||||
adminAPI.getActivityLogStats(),
|
||||
adminAPI.getActivityLogActions(),
|
||||
]);
|
||||
|
||||
setLogs(logsData.logs);
|
||||
setTotal(logsData.total);
|
||||
setHasMore(logsData.hasMore);
|
||||
setStats(statsData);
|
||||
setActions(actionsData);
|
||||
} catch (err) {
|
||||
console.error('Error fetching activity logs:', err);
|
||||
setError('Failed to load activity logs. Make sure you have admin access.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [user, filters, limit, offset]);
|
||||
|
||||
// Real-time streaming
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
if (!streaming || !socket?.connected) return;
|
||||
|
||||
const handleNewLog = (log) => {
|
||||
// Add new log to the beginning of the list if we're on first page
|
||||
if (offset === 0) {
|
||||
setLogs((prev) => [log, ...prev].slice(0, limit));
|
||||
setTotal((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('activity_log_entry', handleNewLog);
|
||||
|
||||
return () => {
|
||||
socket.off('activity_log_entry', handleNewLog);
|
||||
};
|
||||
}, [streaming, offset, limit]);
|
||||
|
||||
const handleToggleStreaming = () => {
|
||||
const socket = getSocket();
|
||||
if (!socket) return;
|
||||
|
||||
if (!streaming) {
|
||||
socket.emit('join_admin_activity_logs');
|
||||
setStreaming(true);
|
||||
} else {
|
||||
socket.emit('leave_admin_activity_logs');
|
||||
setStreaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
setOffset(0); // Reset to first page
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilters({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
action: '',
|
||||
category: '',
|
||||
username: '',
|
||||
success: '',
|
||||
});
|
||||
setOffset(0);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [logsData, statsData] = await Promise.all([
|
||||
adminAPI.getActivityLogs({ ...filters, limit, offset }),
|
||||
adminAPI.getActivityLogStats(),
|
||||
]);
|
||||
setLogs(logsData.logs);
|
||||
setTotal(logsData.total);
|
||||
setHasMore(logsData.hasMore);
|
||||
setStats(statsData);
|
||||
} catch (err) {
|
||||
setError('Failed to refresh logs');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
setOffset((prev) => Math.max(0, prev - limit));
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (hasMore) {
|
||||
setOffset((prev) => prev + limit);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('pl-PL', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getActionBadgeColor = (action) => {
|
||||
if (action.startsWith('auth.')) return 'bg-blue-100 text-blue-800';
|
||||
if (action.startsWith('event.')) return 'bg-green-100 text-green-800';
|
||||
if (action.startsWith('match.')) return 'bg-purple-100 text-purple-800';
|
||||
if (action.startsWith('admin.')) return 'bg-red-100 text-red-800';
|
||||
if (action.startsWith('chat.')) return 'bg-yellow-100 text-yellow-800';
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
const getCategoryBadgeColor = (category) => {
|
||||
const colors = {
|
||||
auth: 'bg-blue-500',
|
||||
event: 'bg-green-500',
|
||||
match: 'bg-purple-500',
|
||||
admin: 'bg-red-500',
|
||||
chat: 'bg-yellow-500',
|
||||
other: 'bg-gray-500',
|
||||
};
|
||||
return colors[category] || colors.other;
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="w-8 h-8 text-indigo-600" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">Activity Logs</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleToggleStreaming}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
streaming
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-green-600 hover:bg-green-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{streaming ? (
|
||||
<>
|
||||
<RadioTower className="w-4 h-4 animate-pulse" />
|
||||
Stop Streaming
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Radio className="w-4 h-4" />
|
||||
Start Streaming
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
<p className="text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Dashboard */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-600 mb-1">Total Logs</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.totalLogs?.toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-600 mb-1">Unique Users</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{stats.uniqueUsers?.toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-600 mb-1">Failed Actions</p>
|
||||
<p className="text-3xl font-bold text-red-600">{stats.failedActions?.toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Success: {stats.successRate}%</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-600 mb-1">Last 24h</p>
|
||||
<p className="text-3xl font-bold text-indigo-600">{stats.recentActivity24h?.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category Stats */}
|
||||
{stats?.logsByCategory && (
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Logs by Category</h3>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{stats.logsByCategory.map((cat) => (
|
||||
<div key={cat.category} className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded-full ${getCategoryBadgeColor(cat.category)}`} />
|
||||
<span className="text-sm text-gray-700 capitalize">{cat.category}</span>
|
||||
<span className="text-sm font-semibold text-gray-900">{cat.count.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-5 h-5 text-gray-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Filters</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => handleFilterChange('startDate', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">End Date</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => handleFilterChange('endDate', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(e) => handleFilterChange('category', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="auth">Auth</option>
|
||||
<option value="event">Event</option>
|
||||
<option value="match">Match</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="chat">Chat</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Action</label>
|
||||
<select
|
||||
value={filters.action}
|
||||
onChange={(e) => handleFilterChange('action', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
{actions.map((act) => (
|
||||
<option key={act.action} value={act.action}>
|
||||
{act.action}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.username}
|
||||
onChange={(e) => handleFilterChange('username', e.target.value)}
|
||||
placeholder="Search username..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select
|
||||
value={filters.success}
|
||||
onChange={(e) => handleFilterChange('success', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="true">Success</option>
|
||||
<option value="false">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Info */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing {offset + 1}-{Math.min(offset + limit, total)} of {total.toLocaleString()} logs
|
||||
{streaming && <span className="ml-2 text-green-600 font-medium">(Live)</span>}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={offset === 0}
|
||||
className="p-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
Page {Math.floor(offset / limit) + 1}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasMore}
|
||||
className="p-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Activity className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600">No activity logs found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Timestamp
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Resource
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
IP Address
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{logs.map((log) => (
|
||||
<tr
|
||||
key={log.id}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{log.success ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatDate(log.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{log.username || 'system'}
|
||||
</div>
|
||||
{log.userId && (
|
||||
<div className="text-xs text-gray-500">ID: {log.userId}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getActionBadgeColor(log.action)}`}>
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{log.resource || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
{log.ipAddress || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Bottom */}
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-600">
|
||||
Total: {total.toLocaleString()} logs
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={offset === 0}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasMore}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityLogsPage;
|
||||
@@ -465,6 +465,33 @@ export const adminAPI = {
|
||||
const data = await fetchAPI(`/admin/events/${slug}/matching-runs/${runId}/suggestions?${params.toString()}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Activity Logs API
|
||||
async getActivityLogs({ startDate, endDate, action, category, username, userId, success, limit = 100, offset = 0 } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.append('startDate', startDate);
|
||||
if (endDate) params.append('endDate', endDate);
|
||||
if (action) params.append('action', action);
|
||||
if (category) params.append('category', category);
|
||||
if (username) params.append('username', username);
|
||||
if (userId) params.append('userId', String(userId));
|
||||
if (success !== undefined) params.append('success', String(success));
|
||||
params.append('limit', String(limit));
|
||||
params.append('offset', String(offset));
|
||||
|
||||
const data = await fetchAPI(`/admin/activity-logs?${params.toString()}`);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
async getActivityLogActions() {
|
||||
const data = await fetchAPI('/admin/activity-logs/actions');
|
||||
return data.data;
|
||||
},
|
||||
|
||||
async getActivityLogStats() {
|
||||
const data = await fetchAPI('/admin/activity-logs/stats');
|
||||
return data.data;
|
||||
},
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
|
||||
Reference in New Issue
Block a user