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:
Radosław Gierwiało
2025-12-02 23:17:19 +01:00
parent 08845704cf
commit 1051cc6754
5 changed files with 608 additions and 14 deletions

View File

@@ -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"

View File

@@ -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);

View 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;

View File

@@ -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 };