forked from syntaxbullet/AuroraBot-discord
feat(dashboard): expand stats & remove admin token auth
This commit is contained in:
@@ -10,14 +10,6 @@ interface ControlPanelProps {
|
||||
maintenanceMode: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AURORA_ENV?: {
|
||||
ADMIN_TOKEN: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ControlPanel component provides quick administrative actions for the bot.
|
||||
* Integrated with the premium glassmorphic theme.
|
||||
@@ -31,12 +23,10 @@ export function ControlPanel({ maintenanceMode }: ControlPanelProps) {
|
||||
const handleAction = async (action: string, payload?: Record<string, unknown>) => {
|
||||
setLoading(action);
|
||||
try {
|
||||
const token = window.AURORA_ENV?.ADMIN_TOKEN;
|
||||
const response = await fetch(`/api/actions/${action}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
|
||||
@@ -25,12 +25,7 @@ export function useActivityStats(): UseActivityStatsResult {
|
||||
const fetchActivity = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = (window as any).AURORA_ENV?.ADMIN_TOKEN;
|
||||
const response = await fetch("/api/stats/activity", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const response = await fetch("/api/stats/activity");
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const jsonData = await response.json();
|
||||
setData(jsonData);
|
||||
|
||||
@@ -22,6 +22,17 @@ interface DashboardStats {
|
||||
totalWealth: string;
|
||||
avgLevel: number;
|
||||
topStreak: number;
|
||||
totalItems: number;
|
||||
};
|
||||
activeLootdrops: Array<{
|
||||
rewardAmount: number;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
}>;
|
||||
leaderboards: {
|
||||
topLevels: Array<{ username: string; level: number | null }>;
|
||||
topWealth: Array<{ username: string; balance: string }>;
|
||||
};
|
||||
recentEvents: Array<{
|
||||
type: 'success' | 'error' | 'info' | 'warn';
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Activity, Server, Users, Zap } from "lucide-react";
|
||||
import { Activity, Server, Users, Zap, Package, Trophy, Coins } from "lucide-react";
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats";
|
||||
import { useActivityStats } from "@/hooks/use-activity-stats";
|
||||
import { ControlPanel } from "@/components/ControlPanel";
|
||||
@@ -62,10 +62,11 @@ export function Dashboard() {
|
||||
<p className="text-white/40 font-medium">Monitoring real-time activity and core bot metrics.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Metric Cards */}
|
||||
{[
|
||||
{ title: "Active Users", value: stats.users.active.toLocaleString(), label: `${stats.users.total.toLocaleString()} total registered`, icon: Users, color: "from-purple-500 to-pink-500" },
|
||||
{ title: "Items in Circulation", value: stats.economy.totalItems.toLocaleString(), label: "Total items owned", icon: Package, color: "from-blue-500 to-cyan-500" },
|
||||
{ title: "Commands registered", value: stats.commands.total, label: "Total system capabilities", icon: Zap, color: "from-yellow-500 to-orange-500" },
|
||||
{ title: "Avg Latency", value: `${stats.ping.avg}ms`, label: "WebSocket heartbeat", icon: Activity, color: "from-emerald-500 to-teal-500" },
|
||||
].map((metric, i) => (
|
||||
@@ -144,6 +145,34 @@ export function Dashboard() {
|
||||
{/* Administrative Control Panel */}
|
||||
<ControlPanel maintenanceMode={stats.maintenanceMode} />
|
||||
|
||||
{/* Active Lootdrop Alert */}
|
||||
{stats.activeLootdrops && stats.activeLootdrops.length > 0 && (
|
||||
<Card className="bg-gradient-to-br from-red-500/10 to-orange-500/10 border-red-500/20 overflow-hidden relative">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10">
|
||||
<Zap className="w-24 h-24 text-red-500" />
|
||||
</div>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg font-bold text-red-400 flex items-center gap-2">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
||||
</span>
|
||||
ACTIVE LOOTDROP
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-2xl font-black text-white/90">
|
||||
{stats.activeLootdrops[0]?.rewardAmount} {stats.activeLootdrops[0]?.currency}
|
||||
</p>
|
||||
<p className="text-xs font-medium text-white/50">
|
||||
Expires <span className="text-red-300">{stats.activeLootdrops[0]?.expiresAt ? new Date(stats.activeLootdrops[0].expiresAt).toLocaleTimeString() : 'Never'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent Events Feed */}
|
||||
<Card className="glass border-white/5 overflow-hidden flex-1">
|
||||
<CardHeader className="bg-white/[0.02] border-b border-white/5">
|
||||
@@ -187,6 +216,63 @@ export function Dashboard() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leaderboards Section */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card className="glass border-white/5">
|
||||
<CardHeader className="flex flex-row items-center gap-4">
|
||||
<div className="p-2 bg-yellow-500/10 rounded-lg">
|
||||
<Trophy className="w-5 h-5 text-yellow-500" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold">Top Levels</CardTitle>
|
||||
<CardDescription className="text-white/40">Highest ranked users</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{stats.leaderboards?.topLevels.map((user, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${i === 0 ? 'bg-yellow-500/20 text-yellow-500' : i === 1 ? 'bg-gray-400/20 text-gray-400' : i === 2 ? 'bg-orange-700/20 text-orange-700' : 'bg-white/5 text-white/40'}`}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<span className="font-semibold">{user.username}</span>
|
||||
</div>
|
||||
<span className="font-mono text-sm text-white/60">Lvl {user.level}</span>
|
||||
</div>
|
||||
)) || <p className="text-white/20 text-sm">No data available</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="glass border-white/5">
|
||||
<CardHeader className="flex flex-row items-center gap-4">
|
||||
<div className="p-2 bg-emerald-500/10 rounded-lg">
|
||||
<Coins className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold">Richest Users</CardTitle>
|
||||
<CardDescription className="text-white/40">Highest net worth</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{stats.leaderboards?.topWealth.map((user, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${i === 0 ? 'bg-yellow-500/20 text-yellow-500' : i === 1 ? 'bg-gray-400/20 text-gray-400' : i === 2 ? 'bg-orange-700/20 text-orange-700' : 'bg-white/5 text-white/40'}`}>
|
||||
{i + 1}
|
||||
</div>
|
||||
<span className="font-semibold">{user.username}</span>
|
||||
</div>
|
||||
<span className="font-mono text-sm text-white/60">{BigInt(user.balance).toLocaleString()} AU</span>
|
||||
</div>
|
||||
)) || <p className="text-white/20 text-sm">No data available</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ mock.module("@shared/db/DrizzleClient", () => {
|
||||
const mockBuilder = {
|
||||
where: mock(() => Promise.resolve([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }])),
|
||||
then: (onfulfilled: any) => onfulfilled([{ count: "5", balance: 1000n, level: 5, dailyStreak: 2 }]),
|
||||
orderBy: mock(() => mockBuilder), // Chainable
|
||||
limit: mock(() => Promise.resolve([])), // Terminal
|
||||
};
|
||||
|
||||
const mockFrom = {
|
||||
@@ -33,6 +35,7 @@ mock.module("@shared/db/DrizzleClient", () => {
|
||||
findFirst: mock(() => Promise.resolve({ username: "test" })),
|
||||
findMany: mock(() => Promise.resolve([])),
|
||||
},
|
||||
lootdrops: { findMany: mock(() => Promise.resolve([])) },
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -100,37 +103,20 @@ describe("WebServer Security & Limits", () => {
|
||||
expect(data.status).toBe("ok");
|
||||
});
|
||||
|
||||
describe("Administrative Actions Authorization", () => {
|
||||
test("should reject administrative actions without token", async () => {
|
||||
describe("Administrative Actions", () => {
|
||||
test("should allow administrative actions without token", async () => {
|
||||
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
|
||||
method: "POST"
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should reject administrative actions with invalid token", async () => {
|
||||
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
|
||||
method: "POST",
|
||||
headers: { "Authorization": "Bearer wrong-token" }
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
test("should accept administrative actions with valid token", async () => {
|
||||
const { env } = await import("@shared/lib/env");
|
||||
const response = await fetch(`http://localhost:${port}/api/actions/reload-commands`, {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${env.ADMIN_TOKEN}` }
|
||||
});
|
||||
// Should be 200 (OK) or 500 (if underlying service fails, but NOT 401)
|
||||
expect(response.status).not.toBe(401);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should reject maintenance mode with invalid payload", async () => {
|
||||
const { env } = await import("@shared/lib/env");
|
||||
const response = await fetch(`http://localhost:${port}/api/actions/maintenance-mode`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${env.ADMIN_TOKEN}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ not_enabled: true }) // Wrong field
|
||||
|
||||
@@ -104,14 +104,6 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
|
||||
if (url.pathname === "/api/stats/activity") {
|
||||
try {
|
||||
// Security Check: Token-based authentication
|
||||
const { env } = await import("@shared/lib/env");
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (authHeader !== `Bearer ${env.ADMIN_TOKEN}`) {
|
||||
console.warn(`⚠️ [API] Unauthorized activity analytics access attempt from ${req.headers.get("x-forwarded-for") || "unknown"}`);
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// If we have a valid cache, return it
|
||||
@@ -143,14 +135,6 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
// Administrative Actions
|
||||
if (url.pathname.startsWith("/api/actions/") && req.method === "POST") {
|
||||
try {
|
||||
// Security Check: Token-based authentication
|
||||
const { env } = await import("@shared/lib/env");
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (authHeader !== `Bearer ${env.ADMIN_TOKEN}`) {
|
||||
console.warn(`⚠️ [API] Unauthorized administrative action attempt from ${req.headers.get("x-forwarded-for") || "unknown"}`);
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { actionService } = await import("@shared/modules/admin/action.service");
|
||||
const { MaintenanceModeSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
||||
|
||||
@@ -201,10 +185,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
if (await fileRef.exists()) {
|
||||
// If serving index.html, inject env vars for frontend
|
||||
if (pathName === "/index.html") {
|
||||
let html = await fileRef.text();
|
||||
const { env } = await import("@shared/lib/env");
|
||||
const envScript = `<script>window.AURORA_ENV = { ADMIN_TOKEN: "${env.ADMIN_TOKEN}" };</script>`;
|
||||
html = html.replace("</head>", `${envScript}</head>`);
|
||||
const html = await fileRef.text();
|
||||
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
||||
}
|
||||
return new Response(fileRef);
|
||||
@@ -231,10 +212,7 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
return new Response("Dashboard Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
let indexHtml = await indexFile.text();
|
||||
const { env: sharedEnv } = await import("@shared/lib/env");
|
||||
const script = `<script>window.AURORA_ENV = { ADMIN_TOKEN: "${sharedEnv.ADMIN_TOKEN}" };</script>`;
|
||||
indexHtml = indexHtml.replace("</head>", `${script}</head>`);
|
||||
const indexHtml = await indexFile.text();
|
||||
return new Response(indexHtml, { headers: { "Content-Type": "text/html" } });
|
||||
},
|
||||
|
||||
@@ -312,15 +290,43 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
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([
|
||||
// Fetch all data in parallel with error isolation
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve(getClientStats()),
|
||||
dashboardService.getActiveUserCount(),
|
||||
dashboardService.getTotalUserCount(),
|
||||
dashboardService.getEconomyStats(),
|
||||
dashboardService.getRecentEvents(10),
|
||||
dashboardService.getTotalItems(),
|
||||
dashboardService.getActiveLootdrops(),
|
||||
dashboardService.getLeaderboards(),
|
||||
]);
|
||||
|
||||
// Helper to unwrap result or return default
|
||||
const unwrap = <T>(result: PromiseSettledResult<T>, defaultValue: T, name: string): T => {
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
console.error(`Failed to fetch ${name}:`, result.reason);
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
const clientStats = unwrap(results[0], {
|
||||
bot: { name: 'Aurora', avatarUrl: null },
|
||||
guilds: 0,
|
||||
commandsRegistered: 0,
|
||||
cachedUsers: 0,
|
||||
ping: 0,
|
||||
uptime: 0,
|
||||
lastCommandTimestamp: null
|
||||
}, 'clientStats');
|
||||
|
||||
const activeUsers = unwrap(results[1], 0, 'activeUsers');
|
||||
const totalUsers = unwrap(results[2], 0, 'totalUsers');
|
||||
const economyStats = unwrap(results[3], { totalWealth: 0n, avgLevel: 0, topStreak: 0 }, 'economyStats');
|
||||
const recentEvents = unwrap(results[4], [], 'recentEvents');
|
||||
const totalItems = unwrap(results[5], 0, 'totalItems');
|
||||
const activeLootdrops = unwrap(results[6], [], 'activeLootdrops');
|
||||
const leaderboards = unwrap(results[7], { topLevels: [], topWealth: [] }, 'leaderboards');
|
||||
|
||||
return {
|
||||
bot: clientStats.bot,
|
||||
guilds: { count: clientStats.guilds },
|
||||
@@ -331,11 +337,20 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
totalWealth: economyStats.totalWealth.toString(),
|
||||
avgLevel: economyStats.avgLevel,
|
||||
topStreak: economyStats.topStreak,
|
||||
totalItems,
|
||||
},
|
||||
recentEvents: recentEvents.map(event => ({
|
||||
...event,
|
||||
timestamp: event.timestamp instanceof Date ? event.timestamp.toISOString() : event.timestamp,
|
||||
})),
|
||||
activeLootdrops: activeLootdrops.map(drop => ({
|
||||
rewardAmount: drop.rewardAmount,
|
||||
currency: drop.currency,
|
||||
createdAt: drop.createdAt.toISOString(),
|
||||
expiresAt: drop.expiresAt ? drop.expiresAt.toISOString() : null,
|
||||
// Explicitly excluding channelId/messageId to prevent sniping
|
||||
})),
|
||||
leaderboards,
|
||||
uptime: clientStats.uptime,
|
||||
lastCommandTimestamp: clientStats.lastCommandTimestamp,
|
||||
maintenanceMode: (await import("../../bot/lib/BotClient")).AuroraClient.maintenanceMode,
|
||||
|
||||
Reference in New Issue
Block a user