feat: integrate real data into dashboard
- Created dashboard service with DB queries for users, economy, events - Added client stats provider with 30s caching for Discord metrics - Implemented /api/stats endpoint aggregating all dashboard data - Created useDashboardStats React hook with auto-refresh - Updated Dashboard.tsx to display real data with loading/error states - Added comprehensive test coverage (11 tests passing) - Replaced all mock values with live Discord and database metrics
This commit is contained in:
78
web/src/hooks/use-dashboard-stats.ts
Normal file
78
web/src/hooks/use-dashboard-stats.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface DashboardStats {
|
||||
guilds: {
|
||||
count: number;
|
||||
};
|
||||
users: {
|
||||
active: number;
|
||||
total: number;
|
||||
};
|
||||
commands: {
|
||||
total: number;
|
||||
};
|
||||
ping: {
|
||||
avg: number;
|
||||
};
|
||||
economy: {
|
||||
totalWealth: string;
|
||||
avgLevel: number;
|
||||
topStreak: number;
|
||||
};
|
||||
recentEvents: Array<{
|
||||
type: 'success' | 'error' | 'info';
|
||||
message: string;
|
||||
timestamp: string;
|
||||
icon?: string;
|
||||
}>;
|
||||
uptime: number;
|
||||
lastCommandTimestamp: number | null;
|
||||
}
|
||||
|
||||
interface UseDashboardStatsResult {
|
||||
stats: DashboardStats | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to fetch and auto-refresh dashboard statistics
|
||||
* Polls the API every 30 seconds
|
||||
*/
|
||||
export function useDashboardStats(): UseDashboardStatsResult {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stats");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch dashboard stats:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch stats");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Initial fetch
|
||||
fetchStats();
|
||||
|
||||
// Set up polling every 30 seconds
|
||||
const interval = setInterval(fetchStats, 30000);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return { stats, loading, error };
|
||||
}
|
||||
@@ -6,8 +6,49 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Activity, Server, Users, Zap } from "lucide-react";
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats";
|
||||
|
||||
export function Dashboard() {
|
||||
const { stats, loading, error } = useDashboardStats();
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
||||
<p className="text-muted-foreground">Loading dashboard data...</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Loading...</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-8 w-20 bg-muted animate-pulse rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
||||
<p className="text-destructive">Error loading dashboard: {error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -23,8 +64,8 @@ export function Dashboard() {
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">12</div>
|
||||
<p className="text-xs text-muted-foreground">+2 from last month</p>
|
||||
<div className="text-2xl font-bold">{stats.guilds.count}</div>
|
||||
<p className="text-xs text-muted-foreground">Active guilds</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -34,19 +75,21 @@ export function Dashboard() {
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">1,234</div>
|
||||
<p className="text-xs text-muted-foreground">+10% from last month</p>
|
||||
<div className="text-2xl font-bold">{stats.users.active.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.users.total.toLocaleString()} total registered
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Commands Run</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">Commands</CardTitle>
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">12,345</div>
|
||||
<p className="text-xs text-muted-foreground">+5% from last month</p>
|
||||
<div className="text-2xl font-bold">{stats.commands.total}</div>
|
||||
<p className="text-xs text-muted-foreground">Registered commands</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -56,8 +99,8 @@ export function Dashboard() {
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">24ms</div>
|
||||
<p className="text-xs text-muted-foreground">+2ms from last hour</p>
|
||||
<div className="text-2xl font-bold">{stats.ping.avg}ms</div>
|
||||
<p className="text-xs text-muted-foreground">WebSocket latency</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -65,11 +108,25 @@ export function Dashboard() {
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Activity Overview</CardTitle>
|
||||
<CardTitle>Economy Overview</CardTitle>
|
||||
<CardDescription>Server economy statistics</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px] w-full bg-muted/20 flex items-center justify-center border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
||||
Chart Placeholder
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Total Wealth</p>
|
||||
<p className="text-2xl font-bold">{BigInt(stats.economy.totalWealth).toLocaleString()} AU</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Average Level</p>
|
||||
<p className="text-xl font-bold">{stats.economy.avgLevel}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Top Streak</p>
|
||||
<p className="text-xl font-bold">{stats.economy.topStreak} days</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -81,27 +138,30 @@ export function Dashboard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 mr-2" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">New guild joined</p>
|
||||
<p className="text-sm text-muted-foreground">2 minutes ago</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-destructive mr-2" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Error in verify command</p>
|
||||
<p className="text-sm text-muted-foreground">15 minutes ago</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 mr-2" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Bot restarted</p>
|
||||
<p className="text-sm text-muted-foreground">1 hour ago</p>
|
||||
</div>
|
||||
</div>
|
||||
{stats.recentEvents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No recent events</p>
|
||||
) : (
|
||||
stats.recentEvents.slice(0, 5).map((event, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full mr-2 ${event.type === 'success'
|
||||
? 'bg-emerald-500'
|
||||
: event.type === 'error'
|
||||
? 'bg-destructive'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
/>
|
||||
<div className="space-y-1 flex-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{event.icon} {event.message}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(event.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -62,6 +62,58 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
return Response.json({ status: "ok", timestamp: Date.now() });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/stats") {
|
||||
try {
|
||||
// Import services (dynamic to avoid circular deps)
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
const { getClientStats } = await import("../../bot/lib/clientStats");
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [clientStats, activeUsers, totalUsers, economyStats, recentEvents] = await Promise.all([
|
||||
Promise.resolve(getClientStats()),
|
||||
dashboardService.getActiveUserCount(),
|
||||
dashboardService.getTotalUserCount(),
|
||||
dashboardService.getEconomyStats(),
|
||||
dashboardService.getRecentEvents(10),
|
||||
]);
|
||||
|
||||
const stats = {
|
||||
guilds: {
|
||||
count: clientStats.guilds,
|
||||
},
|
||||
users: {
|
||||
active: activeUsers,
|
||||
total: totalUsers,
|
||||
},
|
||||
commands: {
|
||||
total: clientStats.commandsRegistered,
|
||||
},
|
||||
ping: {
|
||||
avg: clientStats.ping,
|
||||
},
|
||||
economy: {
|
||||
totalWealth: economyStats.totalWealth.toString(),
|
||||
avgLevel: economyStats.avgLevel,
|
||||
topStreak: economyStats.topStreak,
|
||||
},
|
||||
recentEvents: recentEvents.map(event => ({
|
||||
...event,
|
||||
timestamp: event.timestamp.toISOString(),
|
||||
})),
|
||||
uptime: clientStats.uptime,
|
||||
lastCommandTimestamp: clientStats.lastCommandTimestamp,
|
||||
};
|
||||
|
||||
return Response.json(stats);
|
||||
} catch (error) {
|
||||
console.error("Error fetching dashboard stats:", error);
|
||||
return Response.json(
|
||||
{ error: "Failed to fetch dashboard statistics" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Static File Serving
|
||||
let pathName = url.pathname;
|
||||
if (pathName === "/") pathName = "/index.html";
|
||||
|
||||
Reference in New Issue
Block a user