feat(dashboard): expand stats & remove admin token auth
This commit is contained in:
@@ -7,7 +7,6 @@ DISCORD_BOT_TOKEN=your-discord-bot-token
|
||||
DISCORD_CLIENT_ID=your-discord-client-id
|
||||
DISCORD_GUILD_ID=your-discord-guild-id
|
||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||
ADMIN_TOKEN=Ffeg4hgsdfvsnyms,kmeuy64sy5y
|
||||
|
||||
VPS_USER=your-vps-user
|
||||
VPS_HOST=your-vps-ip
|
||||
|
||||
@@ -7,7 +7,7 @@ const envSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
HOST: z.string().default("127.0.0.1"),
|
||||
ADMIN_TOKEN: z.string().min(8, "ADMIN_TOKEN must be at least 8 characters"),
|
||||
ADMIN_TOKEN: z.string().min(8, "ADMIN_TOKEN must be at least 8 characters").optional(),
|
||||
});
|
||||
|
||||
const parsedEnv = envSchema.safeParse(process.env);
|
||||
|
||||
@@ -1,292 +1,99 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { dashboardService } from "./dashboard.service";
|
||||
|
||||
// Mock DrizzleClient
|
||||
const mockSelect = mock(() => ({
|
||||
from: mock(() => Promise.resolve([{ count: "5" }])),
|
||||
}));
|
||||
// Mock DrizzleClient before importing service
|
||||
const mockFindMany = mock();
|
||||
const mockLimit = mock();
|
||||
|
||||
const mockQuery = {
|
||||
transactions: {
|
||||
findMany: mock((): Promise<any[]> => Promise.resolve([])),
|
||||
},
|
||||
moderationCases: {
|
||||
findMany: mock((): Promise<any[]> => Promise.resolve([])),
|
||||
},
|
||||
// Helper to support the chained calls in getLeaderboards
|
||||
const mockChain = {
|
||||
from: () => mockChain,
|
||||
orderBy: () => mockChain,
|
||||
limit: mockLimit
|
||||
};
|
||||
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: {
|
||||
select: mockSelect,
|
||||
query: mockQuery,
|
||||
},
|
||||
select: () => mockChain,
|
||||
query: {
|
||||
lootdrops: {
|
||||
findMany: mockFindMany
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Import service after mocking
|
||||
import { dashboardService } from "./dashboard.service";
|
||||
|
||||
describe("dashboardService", () => {
|
||||
beforeEach(() => {
|
||||
mockSelect.mockClear();
|
||||
mockQuery.transactions.findMany.mockClear();
|
||||
mockQuery.moderationCases.findMany.mockClear();
|
||||
|
||||
// Reset default mock implementation
|
||||
mockSelect.mockImplementation(() => ({
|
||||
from: mock(() => Promise.resolve([{ count: "5" }])),
|
||||
}));
|
||||
mockFindMany.mockClear();
|
||||
mockLimit.mockClear();
|
||||
});
|
||||
|
||||
describe("getActiveUserCount", () => {
|
||||
test("should return active user count from database", async () => {
|
||||
mockSelect.mockImplementationOnce(() => ({
|
||||
// @ts-ignore ts(2322)
|
||||
from: mock(() => ({
|
||||
where: mock(() => Promise.resolve([{ count: "5" }])),
|
||||
})),
|
||||
}));
|
||||
|
||||
const count = await dashboardService.getActiveUserCount();
|
||||
expect(count).toBe(5);
|
||||
expect(mockSelect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return 0 when no users found", async () => {
|
||||
|
||||
mockSelect.mockImplementationOnce(() => ({
|
||||
// @ts-ignore ts(2322)
|
||||
from: mock(() => ({
|
||||
where: mock(() => Promise.resolve([{ count: "0" }])),
|
||||
})),
|
||||
}));
|
||||
|
||||
const count = await dashboardService.getActiveUserCount();
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTotalUserCount", () => {
|
||||
test("should return total user count", async () => {
|
||||
const count = await dashboardService.getTotalUserCount();
|
||||
expect(count).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRecentTransactions", () => {
|
||||
test("should return formatted transaction events", async () => {
|
||||
const mockTx = [
|
||||
describe("getActiveLootdrops", () => {
|
||||
test("should return active lootdrops when found", async () => {
|
||||
const mockDrops = [
|
||||
{
|
||||
type: "DAILY_REWARD",
|
||||
description: "Daily reward",
|
||||
messageId: "123",
|
||||
channelId: "general",
|
||||
rewardAmount: 100,
|
||||
currency: "Gold",
|
||||
createdAt: new Date(),
|
||||
user: { username: "testuser" },
|
||||
},
|
||||
] as any;
|
||||
|
||||
mockQuery.transactions.findMany.mockResolvedValueOnce(mockTx);
|
||||
|
||||
const events = await dashboardService.getRecentTransactions(10);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.type).toBe("info");
|
||||
expect(events[0]?.message).toContain("testuser");
|
||||
expect(events[0]?.icon).toBe("☀️");
|
||||
});
|
||||
|
||||
test("should handle empty transactions", async () => {
|
||||
mockQuery.transactions.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const events = await dashboardService.getRecentTransactions(10);
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRecentModerationCases", () => {
|
||||
test("should return formatted moderation events", async () => {
|
||||
const mockCases = [
|
||||
{
|
||||
type: "warn",
|
||||
username: "baduser",
|
||||
reason: "Spam",
|
||||
createdAt: new Date(),
|
||||
},
|
||||
] as any;
|
||||
|
||||
mockQuery.moderationCases.findMany.mockResolvedValueOnce(mockCases);
|
||||
|
||||
const events = await dashboardService.getRecentModerationCases(10);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.type).toBe("error");
|
||||
expect(events[0]?.message).toContain("WARN");
|
||||
expect(events[0]?.message).toContain("baduser");
|
||||
expect(events[0]?.icon).toBe("⚠️");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRecentEvents", () => {
|
||||
test("should combine and sort transactions and moderation events", async () => {
|
||||
const now = new Date();
|
||||
const earlier = new Date(now.getTime() - 1000);
|
||||
|
||||
mockQuery.transactions.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
type: "DAILY_REWARD",
|
||||
description: "Daily",
|
||||
createdAt: now,
|
||||
user: { username: "user1" },
|
||||
},
|
||||
] as unknown as any[]);
|
||||
|
||||
mockQuery.moderationCases.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
type: "warn",
|
||||
username: "user2",
|
||||
reason: "Test",
|
||||
createdAt: earlier,
|
||||
},
|
||||
] as unknown as any[]);
|
||||
|
||||
const events = await dashboardService.getRecentEvents(10);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
// Should be sorted by timestamp (newest first)
|
||||
const t0 = events[0]?.timestamp instanceof Date ? events[0].timestamp.getTime() : new Date(events[0]?.timestamp ?? 0).getTime();
|
||||
const t1 = events[1]?.timestamp instanceof Date ? events[1].timestamp.getTime() : new Date(events[1]?.timestamp ?? 0).getTime();
|
||||
expect(t0).toBeGreaterThanOrEqual(t1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordEvent", () => {
|
||||
test("should emit NEW_EVENT to systemEvents", async () => {
|
||||
const mockEmit = mock((_event: string, _data: unknown) => { });
|
||||
|
||||
mock.module("@shared/lib/events", () => ({
|
||||
systemEvents: {
|
||||
emit: mockEmit,
|
||||
},
|
||||
EVENTS: {
|
||||
DASHBOARD: {
|
||||
NEW_EVENT: "dashboard:new_event",
|
||||
}
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
claimedBy: null
|
||||
}
|
||||
}));
|
||||
];
|
||||
mockFindMany.mockResolvedValue(mockDrops);
|
||||
|
||||
await dashboardService.recordEvent({
|
||||
type: 'info',
|
||||
message: 'Test Event',
|
||||
icon: '🚀'
|
||||
});
|
||||
const result = await dashboardService.getActiveLootdrops();
|
||||
expect(result).toEqual(mockDrops);
|
||||
expect(mockFindMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockEmit).toHaveBeenCalled();
|
||||
const calls = mockEmit.mock.calls;
|
||||
if (calls.length > 0 && calls[0]) {
|
||||
expect(calls[0][0]).toBe("dashboard:new_event");
|
||||
const data = calls[0][1] as { message: string, timestamp: string };
|
||||
expect(data.message).toBe("Test Event");
|
||||
expect(data.timestamp).toBeDefined();
|
||||
// Verify it's an ISO string
|
||||
expect(() => new Date(data.timestamp).toISOString()).not.toThrow();
|
||||
} else {
|
||||
throw new Error("mockEmit was not called with expected arguments");
|
||||
}
|
||||
test("should return empty array if no active drops", async () => {
|
||||
mockFindMany.mockResolvedValue([]);
|
||||
const result = await dashboardService.getActiveLootdrops();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActivityAggregation", () => {
|
||||
test("should return exactly 24 data points representing the last 24 hours", async () => {
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours(), 0, 0, 0);
|
||||
describe("getLeaderboards", () => {
|
||||
test("should combine top levels and wealth", async () => {
|
||||
const mockTopLevels = [
|
||||
{ username: "Alice", level: 10, avatar: "a.png" },
|
||||
{ username: "Bob", level: 5, avatar: null },
|
||||
{ username: "Charlie", level: 2, avatar: "c.png" }
|
||||
];
|
||||
const mockTopWealth = [
|
||||
{ username: "Alice", balance: 1000n, avatar: "a.png" },
|
||||
{ username: "Dave", balance: 500n, avatar: "d.png" },
|
||||
{ username: "Bob", balance: 100n, avatar: null }
|
||||
];
|
||||
|
||||
mockSelect.mockImplementationOnce(() => ({
|
||||
// @ts-ignore
|
||||
from: mock(() => ({
|
||||
where: mock(() => ({
|
||||
groupBy: mock(() => ({
|
||||
orderBy: mock(() => Promise.resolve([
|
||||
{
|
||||
hour: now.toISOString(),
|
||||
transactions: "10",
|
||||
commands: "5"
|
||||
}
|
||||
]))
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
// Mock sequential calls to limit()
|
||||
// First call is topLevels, second is topWealth
|
||||
mockLimit
|
||||
.mockResolvedValueOnce(mockTopLevels)
|
||||
.mockResolvedValueOnce(mockTopWealth);
|
||||
|
||||
const activity = await dashboardService.getActivityAggregation();
|
||||
const result = await dashboardService.getLeaderboards();
|
||||
|
||||
expect(activity).toHaveLength(24);
|
||||
|
||||
// Check if the current hour matches our mock
|
||||
const currentHourData = activity.find(a => new Date(a.hour).getTime() === now.getTime());
|
||||
expect(currentHourData).toBeDefined();
|
||||
expect(currentHourData?.transactions).toBe(10);
|
||||
expect(currentHourData?.commands).toBe(5);
|
||||
|
||||
// Check if missing hours are filled with 0
|
||||
const otherHour = activity.find(a => new Date(a.hour).getTime() !== now.getTime());
|
||||
expect(otherHour?.transactions).toBe(0);
|
||||
expect(otherHour?.commands).toBe(0);
|
||||
expect(result.topLevels).toEqual(mockTopLevels);
|
||||
// Verify balance BigInt to string conversion
|
||||
expect(result.topWealth).toHaveLength(3);
|
||||
expect(result.topWealth[0]!.balance).toBe("1000");
|
||||
expect(result.topWealth[0]!.username).toBe("Alice");
|
||||
expect(result.topWealth[1]!.balance).toBe("500");
|
||||
expect(mockLimit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("should return 24 hours of zeros if database is empty", async () => {
|
||||
mockSelect.mockImplementationOnce(() => ({
|
||||
// @ts-ignore
|
||||
from: mock(() => ({
|
||||
where: mock(() => ({
|
||||
groupBy: mock(() => ({
|
||||
orderBy: mock(() => Promise.resolve([]))
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
test("should handle empty leaderboards", async () => {
|
||||
mockLimit.mockResolvedValue([]);
|
||||
|
||||
const activity = await dashboardService.getActivityAggregation();
|
||||
expect(activity).toHaveLength(24);
|
||||
expect(activity.every(a => a.transactions === 0 && a.commands === 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return 24 hours of zeros if database returns rows with null hours", async () => {
|
||||
mockSelect.mockImplementationOnce(() => ({
|
||||
// @ts-ignore
|
||||
from: mock(() => ({
|
||||
where: mock(() => ({
|
||||
groupBy: mock(() => ({
|
||||
orderBy: mock(() => Promise.resolve([{ hour: null, transactions: "10", commands: "5" }]))
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
|
||||
const activity = await dashboardService.getActivityAggregation();
|
||||
expect(activity).toHaveLength(24);
|
||||
expect(activity.every(a => a.transactions === 0 && a.commands === 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("should correctly map hours regardless of input sort order", async () => {
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours(), 0, 0, 0);
|
||||
const hourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
|
||||
mockSelect.mockImplementationOnce(() => ({
|
||||
// @ts-ignore
|
||||
from: mock(() => ({
|
||||
where: mock(() => ({
|
||||
groupBy: mock(() => ({
|
||||
orderBy: mock(() => Promise.resolve([
|
||||
{ hour: now.toISOString(), transactions: "10", commands: "5" },
|
||||
{ hour: hourAgo.toISOString(), transactions: "20", commands: "10" }
|
||||
]))
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
|
||||
const activity = await dashboardService.getActivityAggregation();
|
||||
const current = activity.find(a => a.hour === now.toISOString());
|
||||
const past = activity.find(a => a.hour === hourAgo.toISOString());
|
||||
|
||||
expect(current?.transactions).toBe(10);
|
||||
expect(past?.transactions).toBe(20);
|
||||
const result = await dashboardService.getLeaderboards();
|
||||
expect(result.topLevels).toEqual([]);
|
||||
expect(result.topWealth).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users, transactions, moderationCases, inventory, type User } from "@db/schema";
|
||||
import { users, transactions, moderationCases, inventory, lootdrops, type User } from "@db/schema";
|
||||
import { desc, sql, gte } from "drizzle-orm";
|
||||
import type { RecentEvent, ActivityData } from "./dashboard.types";
|
||||
import { TransactionType } from "@shared/lib/constants";
|
||||
@@ -201,6 +201,44 @@ export const dashboardService = {
|
||||
|
||||
return activity;
|
||||
},
|
||||
/**
|
||||
* Get active lootdrops
|
||||
*/
|
||||
getActiveLootdrops: async () => {
|
||||
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
|
||||
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
|
||||
limit: 1,
|
||||
orderBy: desc(lootdrops.createdAt)
|
||||
});
|
||||
|
||||
return activeDrops;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get leaderboards (Top 3 Levels and Wealth)
|
||||
*/
|
||||
getLeaderboards: async () => {
|
||||
const topLevels = await DrizzleClient.select({
|
||||
username: users.username,
|
||||
level: users.level,
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(desc(users.level))
|
||||
.limit(3);
|
||||
|
||||
const topWealth = await DrizzleClient.select({
|
||||
username: users.username,
|
||||
balance: users.balance,
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(desc(users.balance))
|
||||
.limit(3);
|
||||
|
||||
return {
|
||||
topLevels,
|
||||
topWealth: topWealth.map(u => ({ ...u, balance: (u.balance || 0n).toString() }))
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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