diff --git a/src/app/dashboard/activity-log/page.tsx b/src/app/dashboard/activity-log/page.tsx new file mode 100644 index 0000000..1169196 --- /dev/null +++ b/src/app/dashboard/activity-log/page.tsx @@ -0,0 +1,13 @@ +import { requireAuth } from '@/lib/auth/server'; +import { ActivityLogTable } from '@/components/activityLog/ActivityLogTable'; + +export default async function ActivityLogPage() { + await requireAuth(); + + return ( +
+

Activity Log

+ +
+ ); +} diff --git a/src/components/activityLog/ActivityLogTable.tsx b/src/components/activityLog/ActivityLogTable.tsx new file mode 100644 index 0000000..476b8c8 --- /dev/null +++ b/src/components/activityLog/ActivityLogTable.tsx @@ -0,0 +1,229 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { getActivityLogAction, getGlobalActivityLogAction } from '@/lib/actions/activityLog'; +import type { ActivityLog, ActivityLogFilter, ActionType } from '@/lib/schemas/activityLog'; +import { ACTION_TYPE_LABELS, actionTypeSchema } from '@/lib/schemas/activityLog'; +import { Button } from '@/components/ui/Button'; + +const PAGE_SIZE = 10; + +interface ActivityLogTableProps { + serverId?: string; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +} + +function parseDetails(raw: string): string { + try { + const obj = JSON.parse(raw) as Record; + if (obj.file && Array.isArray(obj.changed_keys)) { + return `Updated ${obj.file} β€” changed: ${(obj.changed_keys as string[]).join(', ')}`; + } + if (obj.service) { + return `Service: ${obj.service}`; + } + if (obj.driver_count !== undefined && obj.race_count !== undefined) { + return `${obj.driver_count} drivers, ${obj.race_count} races`; + } + return raw; + } catch { + return raw; + } +} + +const ACTION_BADGE: Record = { + config_update: 'bg-blue-bg text-blue', + server_start: 'bg-green-bg text-green', + server_stop: 'bg-red-bg text-red', + server_restart: 'bg-yellow-bg text-yellow', + leaderboard_update: 'bg-overlay text-muted' +}; + +export function ActivityLogTable({ serverId }: ActivityLogTableProps) { + const [logs, setLogs] = useState([]); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const [filterAction, setFilterAction] = useState(''); + const [filterStartDate, setFilterStartDate] = useState(''); + const [filterEndDate, setFilterEndDate] = useState(''); + + const [appliedFilter, setAppliedFilter] = useState({}); + + const fetchLogs = useCallback( + async (currentPage: number, filter: ActivityLogFilter, reset: boolean) => { + setIsLoading(true); + setError(null); + const params = { ...filter, page: currentPage, page_size: PAGE_SIZE }; + const result = serverId + ? await getActivityLogAction(serverId, params) + : await getGlobalActivityLogAction(params); + setIsLoading(false); + if (!result.success) { + setError(result.message); + return; + } + setHasMore(result.data.length === PAGE_SIZE); + setLogs((prev) => (reset ? result.data : [...prev, ...result.data])); + }, + [serverId] + ); + + useEffect(() => { + fetchLogs(1, {}, true); + }, [fetchLogs]); + + const applyFilters = () => { + const filter: ActivityLogFilter = {}; + if (filterAction) filter.action = filterAction as ActionType; + if (filterStartDate) filter.start_date = new Date(filterStartDate).toISOString(); + if (filterEndDate) filter.end_date = new Date(filterEndDate).toISOString(); + setAppliedFilter(filter); + setPage(1); + setLogs([]); + fetchLogs(1, filter, true); + }; + + const clearFilters = () => { + setFilterAction(''); + setFilterStartDate(''); + setFilterEndDate(''); + setAppliedFilter({}); + setPage(1); + setLogs([]); + fetchLogs(1, {}, true); + }; + + const loadMore = () => { + const next = page + 1; + setPage(next); + fetchLogs(next, appliedFilter, false); + }; + + return ( +
+
+
+ + +
+
+ + setFilterStartDate(e.target.value)} + className="form-input w-36 text-sm" + /> +
+
+ + setFilterEndDate(e.target.value)} + className="form-input w-36 text-sm" + /> +
+ + +
+ + {error && ( +

{error}

+ )} + +
+ {logs.length === 0 && !isLoading ? ( +
+
πŸ“‹
+

No activity logged yet.

+
+ ) : ( +
+ + + + + + + + + + + {logs.map((log) => ( + + + + + + + ))} + +
+ Time + + User + + Action + + Details +
+ {formatDate(log.createdAt)} + + {log.username} + + + {ACTION_TYPE_LABELS[log.action] ?? log.action} + + + {parseDetails(log.details)} +
+
+ )} + + {isLoading && ( +
Loading…
+ )} + + {hasMore && !isLoading && logs.length > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/components/server/ServerCard.tsx b/src/components/server/ServerCard.tsx index 66ccea5..7286b9a 100755 --- a/src/components/server/ServerCard.tsx +++ b/src/components/server/ServerCard.tsx @@ -77,10 +77,15 @@ export function ServerCard({ server }: ServerCardProps) { -
+
{serviceStatusToString(server.status)} + {server.lastActivity && ( + + by {server.lastActivity.username} + + )}
diff --git a/src/components/server/ServerConfigurationTabs.tsx b/src/components/server/ServerConfigurationTabs.tsx index 338a8dc..a8f1229 100755 --- a/src/components/server/ServerConfigurationTabs.tsx +++ b/src/components/server/ServerConfigurationTabs.tsx @@ -8,6 +8,7 @@ import { EventRulesEditor } from '@/components/configuration/EventRulesEditor'; import { ServerSettingsEditor } from '@/components/configuration/ServerSettingsEditor'; import { StatisticsDashboard } from '@/components/statistics/StatisticsDashboard'; import { LeaderboardManager } from '@/components/leaderboard/LeaderboardManager'; +import { ActivityLogTable } from '@/components/activityLog/ActivityLogTable'; import { useState } from 'react'; import { StateHistoryStats } from '@/lib/schemas'; import { Leaderboard } from '@/lib/schemas/leaderboard'; @@ -26,7 +27,8 @@ const tabs = [ { id: ServerTab.event, name: 'Event Config', icon: '🏁' }, { id: ServerTab.eventRules, name: 'Event Rules', icon: 'πŸ“‹' }, { id: ServerTab.settings, name: 'Server Settings', icon: 'πŸ”§' }, - { id: ServerTab.leaderboard, name: 'Leaderboard', icon: 'πŸ†' } + { id: ServerTab.leaderboard, name: 'Leaderboard', icon: 'πŸ†' }, + { id: ServerTab.activityLog, name: 'History', icon: 'πŸ“‹' } ]; const configTabs = new Set([ @@ -138,6 +140,9 @@ export function ServerConfigurationTabs({ case ServerTab.leaderboard: return ; + case ServerTab.activityLog: + return ; + default: return (
diff --git a/src/components/ui/Sidebar.tsx b/src/components/ui/Sidebar.tsx index d651e68..72a02af 100644 --- a/src/components/ui/Sidebar.tsx +++ b/src/components/ui/Sidebar.tsx @@ -44,10 +44,25 @@ function UsersIcon({ active }: { active: boolean }) { ); } +function ActivityLogIcon({ active }: { active: boolean }) { + return ( + + + + ); +} + export function Sidebar({ showUsers }: SidebarProps) { const pathname = usePathname(); const isServers = pathname === '/dashboard' || pathname.startsWith('/dashboard/server'); const isUsers = pathname.startsWith('/dashboard/membership'); + const isActivityLog = pathname.startsWith('/dashboard/activity-log'); const navItems = [ { @@ -56,6 +71,12 @@ export function Sidebar({ showUsers }: SidebarProps) { active: isServers, icon: }, + { + href: '/dashboard/activity-log', + label: 'Activity Log', + active: isActivityLog, + icon: + }, ...(showUsers ? [ { diff --git a/src/lib/actions/activityLog.ts b/src/lib/actions/activityLog.ts new file mode 100644 index 0000000..efb14e3 --- /dev/null +++ b/src/lib/actions/activityLog.ts @@ -0,0 +1,36 @@ +'use server'; + +import { requireAuth } from '@/lib/auth/server'; +import { getActivityLog, getGlobalActivityLog } from '@/lib/api/server/activityLog'; +import type { ActivityLog, ActivityLogFilter } from '@/lib/schemas/activityLog'; + +export async function getActivityLogAction( + serverId: string, + filter?: ActivityLogFilter +): Promise<{ success: true; data: ActivityLog[] } | { success: false; message: string }> { + try { + const session = await requireAuth(); + const data = await getActivityLog(session.token!, serverId, filter); + return { success: true, data }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to fetch activity log' + }; + } +} + +export async function getGlobalActivityLogAction( + filter?: ActivityLogFilter +): Promise<{ success: true; data: ActivityLog[] } | { success: false; message: string }> { + try { + const session = await requireAuth(); + const data = await getGlobalActivityLog(session.token!, filter); + return { success: true, data }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to fetch activity log' + }; + } +} diff --git a/src/lib/api/server/activityLog.ts b/src/lib/api/server/activityLog.ts new file mode 100644 index 0000000..9a5c629 --- /dev/null +++ b/src/lib/api/server/activityLog.ts @@ -0,0 +1,39 @@ +import { fetchServerAPI } from './base'; +import { activityLogSchema, type ActivityLog, type ActivityLogFilter } from '@/lib/schemas/activityLog'; + +function buildQuery(filter?: ActivityLogFilter): string { + if (!filter) return ''; + const params = new URLSearchParams(); + if (filter.page !== undefined) params.set('page', String(filter.page)); + if (filter.page_size !== undefined) params.set('page_size', String(filter.page_size)); + if (filter.sort_by) params.set('sort_by', filter.sort_by); + if (filter.sort_desc !== undefined) params.set('sort_desc', String(filter.sort_desc)); + if (filter.user_id) params.set('user_id', filter.user_id); + if (filter.action) params.set('action', filter.action); + if (filter.start_date) params.set('start_date', filter.start_date); + if (filter.end_date) params.set('end_date', filter.end_date); + const str = params.toString(); + return str ? `?${str}` : ''; +} + +export async function getActivityLog( + token: string, + serverId: string, + filter?: ActivityLogFilter +): Promise { + const query = buildQuery(filter); + const response = await fetchServerAPI( + `/server/${serverId}/activity-log${query}`, + token + ); + return activityLogSchema.array().parse(response.data); +} + +export async function getGlobalActivityLog( + token: string, + filter?: ActivityLogFilter +): Promise { + const query = buildQuery(filter); + const response = await fetchServerAPI(`/activity-log${query}`, token); + return activityLogSchema.array().parse(response.data); +} diff --git a/src/lib/schemas/activityLog.ts b/src/lib/schemas/activityLog.ts new file mode 100644 index 0000000..a692524 --- /dev/null +++ b/src/lib/schemas/activityLog.ts @@ -0,0 +1,52 @@ +import * as z from 'zod'; + +export const actionTypeSchema = z.enum([ + 'config_update', + 'server_start', + 'server_stop', + 'server_restart', + 'leaderboard_update' +]); + +export type ActionType = z.infer; + +export const activityLogSchema = z.object({ + id: z.string(), + serverId: z.string(), + userId: z.string(), + username: z.string(), + action: actionTypeSchema, + details: z.string(), + createdAt: z.string() +}); + +export type ActivityLog = z.infer; + +export const lastActivityInfoSchema = z.object({ + updatedAt: z.string(), + username: z.string(), + action: actionTypeSchema +}); + +export type LastActivityInfo = z.infer; + +export const activityLogFilterSchema = z.object({ + page: z.number().optional(), + page_size: z.number().optional(), + sort_by: z.string().optional(), + sort_desc: z.boolean().optional(), + user_id: z.string().optional(), + action: actionTypeSchema.optional(), + start_date: z.string().optional(), + end_date: z.string().optional() +}); + +export type ActivityLogFilter = z.infer; + +export const ACTION_TYPE_LABELS: Record = { + config_update: 'Config Update', + server_start: 'Server Start', + server_stop: 'Server Stop', + server_restart: 'Server Restart', + leaderboard_update: 'Leaderboard Update' +}; diff --git a/src/lib/schemas/config.ts b/src/lib/schemas/config.ts index a5c1cd5..7046ad5 100755 --- a/src/lib/schemas/config.ts +++ b/src/lib/schemas/config.ts @@ -16,7 +16,8 @@ export enum ServerTab { event = 'event', eventRules = 'eventRules', settings = 'settings', - leaderboard = 'leaderboard' + leaderboard = 'leaderboard', + activityLog = 'activityLog' } export const serverTabSchema = z.enum(ServerTab); diff --git a/src/lib/schemas/index.ts b/src/lib/schemas/index.ts index fd9100e..b6011e1 100755 --- a/src/lib/schemas/index.ts +++ b/src/lib/schemas/index.ts @@ -4,3 +4,4 @@ export * from './config'; export * from './statistics'; export * from './lookups'; export * from './leaderboard'; +export * from './activityLog'; diff --git a/src/lib/schemas/server.ts b/src/lib/schemas/server.ts index 6191979..5408c32 100755 --- a/src/lib/schemas/server.ts +++ b/src/lib/schemas/server.ts @@ -1,4 +1,5 @@ import * as z from 'zod'; +import { lastActivityInfoSchema } from './activityLog'; export enum ServiceStatus { Unknown, @@ -57,7 +58,8 @@ export const serverSchema = z.object({ id: z.uuid(), name: z.string().min(1), status: z.enum(ServiceStatus), - state: stateSchema.optional().nullable() + state: stateSchema.optional().nullable(), + lastActivity: lastActivityInfoSchema.optional().nullable() }); export type Server = z.infer;