9 Commits

Author SHA1 Message Date
syntaxbullet
5d2d4bb0c6 refactor: improve type safety and remove forced casts in dashboard service 2026-01-08 21:31:40 +01:00
syntaxbullet
19206b5cc7 fix: address security review findings, implement real cache clearing, and fix lifecycle promises 2026-01-08 21:29:09 +01:00
syntaxbullet
0f6cce9b6e feat: implement administrative control panel with real-time bot actions 2026-01-08 21:19:16 +01:00
syntaxbullet
3f3a6c88e8 fix(dash): resolve test regressions, await promises, and improve TypeScript strictness 2026-01-08 21:12:41 +01:00
syntaxbullet
8253de9f73 fix(dash): address safety constraints, validation, and test quality issues 2026-01-08 21:08:47 +01:00
syntaxbullet
1251df286e feat: implement real-time dashboard updates via WebSockets 2026-01-08 21:01:33 +01:00
syntaxbullet
fff90804c0 feat(dash): Revamp dashboard UI with glassmorphism and real bot data 2026-01-08 20:58:57 +01:00
syntaxbullet
8ebaf7b4ee docs: update ticket status to In Review with implementation notes 2026-01-08 18:51:58 +01:00
syntaxbullet
17cb70ec00 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
2026-01-08 18:50:44 +01:00
33 changed files with 2190 additions and 216 deletions

1
.gitignore vendored
View File

@@ -45,4 +45,3 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
src/db/data
src/db/log
scratchpad/
tickets/

View File

@@ -8,6 +8,7 @@ import { startWebServerFromRoot } from "../web/src/server";
await AuroraClient.loadCommands();
await AuroraClient.loadEvents();
await AuroraClient.deployCommands();
await AuroraClient.setupSystemEvents();
console.log("🌐 Starting web server...");

111
bot/lib/BotClient.test.ts Normal file
View File

@@ -0,0 +1,111 @@
import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test";
import { systemEvents, EVENTS } from "@shared/lib/events";
// Mock Discord.js Client and related classes
mock.module("discord.js", () => ({
Client: class {
constructor() { }
on() { }
once() { }
login() { }
destroy() { }
removeAllListeners() { }
},
Collection: Map,
GatewayIntentBits: { Guilds: 1, MessageContent: 1, GuildMessages: 1, GuildMembers: 1 },
REST: class {
setToken() { return this; }
put() { return Promise.resolve([]); }
},
Routes: {
applicationGuildCommands: () => 'guild_route',
applicationCommands: () => 'global_route'
}
}));
// Mock loaders to avoid filesystem access during client init
mock.module("../lib/loaders/CommandLoader", () => ({
CommandLoader: class {
constructor() { }
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
}
}));
mock.module("../lib/loaders/EventLoader", () => ({
EventLoader: class {
constructor() { }
loadFromDirectory() { return Promise.resolve({ loaded: 0, skipped: 0, errors: [] }); }
}
}));
// Mock dashboard service to prevent network/db calls during event handling
mock.module("@shared/modules/economy/lootdrop.service", () => ({
lootdropService: { clearCaches: mock(async () => { }) }
}));
mock.module("@shared/modules/trade/trade.service", () => ({
tradeService: { clearSessions: mock(() => { }) }
}));
mock.module("@/modules/admin/item_wizard", () => ({
clearDraftSessions: mock(() => { })
}));
mock.module("@shared/modules/dashboard/dashboard.service", () => ({
dashboardService: {
recordEvent: mock(() => Promise.resolve())
}
}));
describe("AuroraClient System Events", () => {
let AuroraClient: any;
beforeEach(async () => {
systemEvents.removeAllListeners();
const module = await import("./BotClient");
AuroraClient = module.AuroraClient;
AuroraClient.maintenanceMode = false;
// MUST call explicitly now
await AuroraClient.setupSystemEvents();
});
/**
* Test Case: Maintenance Mode Toggle
* Requirement: Client state should update when event is received
*/
test("should toggle maintenanceMode when MAINTENANCE_MODE event is received", async () => {
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: true, reason: "Testing" });
await new Promise(resolve => setTimeout(resolve, 30));
expect(AuroraClient.maintenanceMode).toBe(true);
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: false });
await new Promise(resolve => setTimeout(resolve, 30));
expect(AuroraClient.maintenanceMode).toBe(false);
});
/**
* Test Case: Command Reload
* Requirement: loadCommands and deployCommands should be called
*/
test("should reload commands when RELOAD_COMMANDS event is received", async () => {
const loadSpy = spyOn(AuroraClient, "loadCommands").mockImplementation(() => Promise.resolve());
const deploySpy = spyOn(AuroraClient, "deployCommands").mockImplementation(() => Promise.resolve());
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
await new Promise(resolve => setTimeout(resolve, 50));
expect(loadSpy).toHaveBeenCalled();
expect(deploySpy).toHaveBeenCalled();
});
/**
* Test Case: Cache Clearance
* Requirement: Service clear methods should be triggered
*/
test("should trigger service cache clearance when CLEAR_CACHE is received", async () => {
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
const { tradeService } = await import("@shared/modules/trade/trade.service");
systemEvents.emit(EVENTS.ACTIONS.CLEAR_CACHE);
await new Promise(resolve => setTimeout(resolve, 50));
expect(lootdropService.clearCaches).toHaveBeenCalled();
expect(tradeService.clearSessions).toHaveBeenCalled();
});
});

View File

@@ -9,6 +9,7 @@ export class Client extends DiscordClient {
commands: Collection<string, Command>;
lastCommandTimestamp: number | null = null;
maintenanceMode: boolean = false;
private commandLoader: CommandLoader;
private eventLoader: EventLoader;
@@ -19,6 +20,60 @@ export class Client extends DiscordClient {
this.eventLoader = new EventLoader(this);
}
public async setupSystemEvents() {
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.on(EVENTS.ACTIONS.RELOAD_COMMANDS, async () => {
console.log("🔄 System Action: Reloading commands...");
try {
await this.loadCommands(true);
await this.deployCommands();
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: "success",
message: "Bot: Commands reloaded and redeployed",
icon: "✅"
});
} catch (error) {
console.error("Failed to reload commands:", error);
}
});
systemEvents.on(EVENTS.ACTIONS.CLEAR_CACHE, async () => {
console.log("<22> System Action: Clearing all internal caches...");
try {
// 1. Lootdrop Service
const { lootdropService } = await import("@shared/modules/economy/lootdrop.service");
await lootdropService.clearCaches();
// 2. Trade Service
const { tradeService } = await import("@shared/modules/trade/trade.service");
tradeService.clearSessions();
// 3. Item Wizard
const { clearDraftSessions } = await import("@/modules/admin/item_wizard");
clearDraftSessions();
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: "success",
message: "Bot: All internal caches and sessions cleared",
icon: "🧼"
});
} catch (error) {
console.error("Failed to clear caches:", error);
}
});
systemEvents.on(EVENTS.ACTIONS.MAINTENANCE_MODE, async (data: { enabled: boolean, reason?: string }) => {
const { enabled, reason } = data;
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
this.maintenanceMode = enabled;
});
}
async loadCommands(reload: boolean = false) {
if (reload) {
this.commands.clear();

View File

@@ -0,0 +1,74 @@
import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test";
import { getClientStats, clearStatsCache } from "./clientStats";
// Mock AuroraClient
mock.module("./BotClient", () => ({
AuroraClient: {
guilds: {
cache: {
size: 5,
},
},
ws: {
ping: 42,
},
users: {
cache: {
size: 100,
},
},
commands: {
size: 20,
},
lastCommandTimestamp: 1641481200000,
},
}));
describe("clientStats", () => {
beforeEach(() => {
clearStatsCache();
});
test("should return client stats", () => {
const stats = getClientStats();
expect(stats.guilds).toBe(5);
expect(stats.ping).toBe(42);
expect(stats.cachedUsers).toBe(100);
expect(stats.commandsRegistered).toBe(20);
expect(typeof stats.uptime).toBe("number"); // Can't mock process.uptime easily
expect(stats.lastCommandTimestamp).toBe(1641481200000);
});
test("should cache stats for 30 seconds", () => {
const stats1 = getClientStats();
const stats2 = getClientStats();
// Should return same object (cached)
expect(stats1).toBe(stats2);
});
test("should refresh cache after TTL expires", async () => {
const stats1 = getClientStats();
// Wait for cache to expire (simulate by clearing and waiting)
await new Promise(resolve => setTimeout(resolve, 35));
clearStatsCache();
const stats2 = getClientStats();
// Should be different objects (new fetch)
expect(stats1).not.toBe(stats2);
// But values should be the same (mocked client)
expect(stats1.guilds).toBe(stats2.guilds);
});
test("clearStatsCache should invalidate cache", () => {
const stats1 = getClientStats();
clearStatsCache();
const stats2 = getClientStats();
// Should be different objects
expect(stats1).not.toBe(stats2);
});
});

48
bot/lib/clientStats.ts Normal file
View File

@@ -0,0 +1,48 @@
import { AuroraClient } from "./BotClient";
import type { ClientStats } from "@shared/modules/dashboard/dashboard.types";
// Cache for client stats (30 second TTL)
let cachedStats: ClientStats | null = null;
let lastFetchTime: number = 0;
const CACHE_TTL_MS = 30 * 1000; // 30 seconds
/**
* Get Discord client statistics with caching
* Respects rate limits by caching for 30 seconds
*/
export function getClientStats(): ClientStats {
const now = Date.now();
// Return cached stats if still valid
if (cachedStats && (now - lastFetchTime) < CACHE_TTL_MS) {
return cachedStats;
}
// Fetch fresh stats
const stats: ClientStats = {
bot: {
name: AuroraClient.user?.username || "Aurora",
avatarUrl: AuroraClient.user?.displayAvatarURL() || null,
},
guilds: AuroraClient.guilds.cache.size,
ping: AuroraClient.ws.ping,
cachedUsers: AuroraClient.users.cache.size,
commandsRegistered: AuroraClient.commands.size,
uptime: process.uptime(),
lastCommandTimestamp: AuroraClient.lastCommandTimestamp,
};
// Update cache
cachedStats = stats;
lastFetchTime = now;
return stats;
}
/**
* Clear the stats cache (useful for testing)
*/
export function clearStatsCache(): void {
cachedStats = null;
lastFetchTime = 0;
}

View File

@@ -56,4 +56,28 @@ describe("CommandHandler", () => {
expect(executeError).toHaveBeenCalled();
expect(AuroraClient.lastCommandTimestamp).toBeNull();
});
test("should block execution when maintenance mode is active", async () => {
AuroraClient.maintenanceMode = true;
const executeSpy = mock(() => Promise.resolve());
AuroraClient.commands.set("maint-test", {
data: { name: "maint-test" } as any,
execute: executeSpy
} as any);
const interaction = {
commandName: "maint-test",
user: { id: "123", username: "testuser" },
reply: mock(() => Promise.resolve())
} as unknown as ChatInputCommandInteraction;
await CommandHandler.handle(interaction);
expect(executeSpy).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({
flags: expect.anything()
}));
AuroraClient.maintenanceMode = false; // Reset for other tests
});
});

View File

@@ -17,6 +17,13 @@ export class CommandHandler {
return;
}
// Check maintenance mode
if (AuroraClient.maintenanceMode) {
const errorEmbed = createErrorEmbed('The bot is currently undergoing maintenance. Please try again later.');
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
return;
}
// Ensure user exists in database
try {
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);

View File

@@ -241,3 +241,8 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
}
};
export const clearDraftSessions = () => {
draftSession.clear();
console.log("[ItemWizard] All draft item creation sessions cleared.");
};

View File

@@ -7,6 +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"),
});
const parsedEnv = envSchema.safeParse(process.env);

21
shared/lib/events.ts Normal file
View File

@@ -0,0 +1,21 @@
import { EventEmitter } from "node:events";
/**
* Global system event bus for cross-module communication.
* Used primarily for real-time dashboard updates.
*/
class SystemEventEmitter extends EventEmitter { }
export const systemEvents = new SystemEventEmitter();
export const EVENTS = {
DASHBOARD: {
STATS_UPDATE: "dashboard:stats_update",
NEW_EVENT: "dashboard:new_event",
},
ACTIONS: {
RELOAD_COMMANDS: "actions:reload_commands",
CLEAR_CACHE: "actions:clear_cache",
MAINTENANCE_MODE: "actions:maintenance_mode",
}
} as const;

View File

@@ -0,0 +1,66 @@
import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test";
import { actionService } from "./action.service";
import { systemEvents, EVENTS } from "@shared/lib/events";
import { dashboardService } from "@shared/modules/dashboard/dashboard.service";
describe("ActionService", () => {
beforeEach(() => {
// Clear any previous mock state
mock.restore();
});
/**
* Test Case: Command Reload
* Requirement: Emits event and records to dashboard
*/
test("reloadCommands should emit RELOAD_COMMANDS event and record dashboard event", async () => {
const emitSpy = spyOn(systemEvents, "emit");
const recordSpy = spyOn(dashboardService, "recordEvent").mockImplementation(() => Promise.resolve());
const result = await actionService.reloadCommands();
expect(result.success).toBe(true);
expect(emitSpy).toHaveBeenCalledWith(EVENTS.ACTIONS.RELOAD_COMMANDS);
expect(recordSpy).toHaveBeenCalledWith(expect.objectContaining({
type: "info",
message: "Admin: Triggered command reload"
}));
});
/**
* Test Case: Cache Clearance
* Requirement: Emits event and records to dashboard
*/
test("clearCache should emit CLEAR_CACHE event and record dashboard event", async () => {
const emitSpy = spyOn(systemEvents, "emit");
const recordSpy = spyOn(dashboardService, "recordEvent").mockImplementation(() => Promise.resolve());
const result = await actionService.clearCache();
expect(result.success).toBe(true);
expect(emitSpy).toHaveBeenCalledWith(EVENTS.ACTIONS.CLEAR_CACHE);
expect(recordSpy).toHaveBeenCalledWith(expect.objectContaining({
type: "info",
message: "Admin: Triggered cache clearance"
}));
});
/**
* Test Case: Maintenance Mode Toggle
* Requirement: Emits event with correct payload and records to dashboard with warning type
*/
test("toggleMaintenanceMode should emit MAINTENANCE_MODE event and record dashboard event", async () => {
const emitSpy = spyOn(systemEvents, "emit");
const recordSpy = spyOn(dashboardService, "recordEvent").mockImplementation(() => Promise.resolve());
const result = await actionService.toggleMaintenanceMode(true, "Test Reason");
expect(result.success).toBe(true);
expect(result.enabled).toBe(true);
expect(emitSpy).toHaveBeenCalledWith(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled: true, reason: "Test Reason" });
expect(recordSpy).toHaveBeenCalledWith(expect.objectContaining({
type: "warn",
message: "Admin: Maintenance mode ENABLED (Test Reason)"
}));
});
});

View File

@@ -0,0 +1,53 @@
import { systemEvents, EVENTS } from "@shared/lib/events";
import { dashboardService } from "@shared/modules/dashboard/dashboard.service";
/**
* Service to handle administrative actions triggered from the dashboard.
* These actions are broadcasted to the bot via the system event bus.
*/
export const actionService = {
/**
* Triggers a reload of all bot commands.
*/
reloadCommands: async () => {
systemEvents.emit(EVENTS.ACTIONS.RELOAD_COMMANDS);
await dashboardService.recordEvent({
type: "info",
message: "Admin: Triggered command reload",
icon: "♻️"
});
return { success: true, message: "Command reload triggered" };
},
/**
* Triggers a clearance of internal bot caches.
*/
clearCache: async () => {
systemEvents.emit(EVENTS.ACTIONS.CLEAR_CACHE);
await dashboardService.recordEvent({
type: "info",
message: "Admin: Triggered cache clearance",
icon: "🧹"
});
return { success: true, message: "Cache clearance triggered" };
},
/**
* Toggles maintenance mode for the bot.
*/
toggleMaintenanceMode: async (enabled: boolean, reason?: string) => {
systemEvents.emit(EVENTS.ACTIONS.MAINTENANCE_MODE, { enabled, reason });
await dashboardService.recordEvent({
type: enabled ? "warn" : "info",
message: `Admin: Maintenance mode ${enabled ? "ENABLED" : "DISABLED"}${reason ? ` (${reason})` : ""}`,
icon: "🛠️"
});
return { success: true, enabled, message: `Maintenance mode ${enabled ? "enabled" : "disabled"}` };
}
};

View File

@@ -0,0 +1,192 @@
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" }])),
}));
const mockQuery = {
transactions: {
findMany: mock((): Promise<any[]> => Promise.resolve([])),
},
moderationCases: {
findMany: mock((): Promise<any[]> => Promise.resolve([])),
},
};
mock.module("@shared/db/DrizzleClient", () => ({
DrizzleClient: {
select: mockSelect,
query: mockQuery,
},
}));
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" }])),
}));
});
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 = [
{
type: "DAILY_REWARD",
description: "Daily reward",
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",
}
}
}));
await dashboardService.recordEvent({
type: 'info',
message: 'Test Event',
icon: '🚀'
});
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");
}
});
});
});

View File

@@ -0,0 +1,180 @@
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users, transactions, moderationCases, inventory } from "@db/schema";
import { desc, sql, and, gte } from "drizzle-orm";
import type { RecentEvent } from "./dashboard.types";
export const dashboardService = {
/**
* Get count of active users from database
*/
getActiveUserCount: async (): Promise<number> => {
const result = await DrizzleClient
.select({ count: sql<string>`COUNT(*)` })
.from(users)
.where(sql`${users.isActive} = true`);
return Number(result[0]?.count || 0);
},
/**
* Get total user count
*/
getTotalUserCount: async (): Promise<number> => {
const result = await DrizzleClient
.select({ count: sql<string>`COUNT(*)` })
.from(users);
return Number(result[0]?.count || 0);
},
/**
* Get economy statistics
*/
getEconomyStats: async (): Promise<{
totalWealth: bigint;
avgLevel: number;
topStreak: number;
}> => {
const allUsers = await DrizzleClient.select().from(users);
const totalWealth = allUsers.reduce(
(acc: bigint, u: any) => acc + (u.balance || 0n),
0n
);
const avgLevel = allUsers.length > 0
? Math.round(
allUsers.reduce((acc: number, u: any) => acc + (u.level || 1), 0) / allUsers.length
)
: 1;
const topStreak = allUsers.reduce(
(max: number, u: any) => Math.max(max, u.dailyStreak || 0),
0
);
return { totalWealth, avgLevel, topStreak };
},
/**
* Get total items in circulation
*/
getTotalItems: async (): Promise<number> => {
const result = await DrizzleClient
.select({ total: sql<string>`COALESCE(SUM(${inventory.quantity}), 0)` })
.from(inventory);
return Number(result[0]?.total || 0);
},
/**
* Get recent transactions as events (last 24 hours)
*/
getRecentTransactions: async (limit: number = 10): Promise<RecentEvent[]> => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentTx = await DrizzleClient.query.transactions.findMany({
limit,
orderBy: [desc(transactions.createdAt)],
where: gte(transactions.createdAt, oneDayAgo),
with: {
user: true,
},
});
return recentTx.map((tx: any) => ({
type: 'info' as const,
message: `${tx.user?.username || 'Unknown'}: ${tx.description || 'Transaction'}`,
timestamp: tx.createdAt || new Date(),
icon: getTransactionIcon(tx.type),
}));
},
/**
* Get recent moderation cases as events (last 24 hours)
*/
getRecentModerationCases: async (limit: number = 10): Promise<RecentEvent[]> => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentCases = await DrizzleClient.query.moderationCases.findMany({
limit,
orderBy: [desc(moderationCases.createdAt)],
where: gte(moderationCases.createdAt, oneDayAgo),
});
return recentCases.map((modCase: any) => ({
type: modCase.type === 'warn' || modCase.type === 'ban' ? 'error' : 'info',
message: `${modCase.type.toUpperCase()}: ${modCase.username} - ${modCase.reason}`,
timestamp: modCase.createdAt || new Date(),
icon: getModerationIcon(modCase.type),
}));
},
/**
* Get combined recent events (transactions + moderation)
*/
getRecentEvents: async (limit: number = 10): Promise<RecentEvent[]> => {
const [txEvents, modEvents] = await Promise.all([
dashboardService.getRecentTransactions(limit),
dashboardService.getRecentModerationCases(limit),
]);
// Combine and sort by timestamp
const allEvents = [...txEvents, ...modEvents]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, limit);
return allEvents;
},
/**
* Records a new internal event and broadcasts it via WebSocket
*/
recordEvent: async (event: Omit<RecentEvent, 'timestamp'>): Promise<void> => {
const fullEvent: RecentEvent = {
...event,
timestamp: new Date(),
};
// Broadcast to WebSocket clients
try {
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.emit(EVENTS.DASHBOARD.NEW_EVENT, {
...fullEvent,
timestamp: (fullEvent.timestamp instanceof Date)
? fullEvent.timestamp.toISOString()
: fullEvent.timestamp
});
} catch (e) {
console.error("Failed to emit system event:", e);
}
},
};
/**
* Helper to get icon for transaction type
*/
function getTransactionIcon(type: string): string {
if (type.includes("LOOT")) return "🌠";
if (type.includes("GIFT")) return "🎁";
if (type.includes("SHOP")) return "🛒";
if (type.includes("DAILY")) return "☀️";
if (type.includes("QUEST")) return "📜";
if (type.includes("TRANSFER")) return "💸";
return "💫";
}
/**
* Helper to get icon for moderation type
*/
function getModerationIcon(type: string): string {
switch (type) {
case 'warn': return '⚠️';
case 'timeout': return '⏸️';
case 'kick': return '👢';
case 'ban': return '🔨';
case 'note': return '📝';
case 'prune': return '🧹';
default: return '🛡️';
}
}

View File

@@ -0,0 +1,76 @@
import { z } from "zod";
export const RecentEventSchema = z.object({
type: z.enum(['success', 'error', 'info', 'warn']),
message: z.string(),
timestamp: z.union([z.date(), z.string().datetime()]),
icon: z.string().optional(),
});
export type RecentEvent = z.infer<typeof RecentEventSchema>;
export const DashboardStatsSchema = z.object({
bot: z.object({
name: z.string(),
avatarUrl: z.string().nullable(),
}),
guilds: z.object({
count: z.number(),
changeFromLastMonth: z.number().optional(),
}),
users: z.object({
active: z.number(),
total: z.number(),
changePercentFromLastMonth: z.number().optional(),
}),
commands: z.object({
total: z.number(),
changePercentFromLastMonth: z.number().optional(),
}),
ping: z.object({
avg: z.number(),
changeFromLastHour: z.number().optional(),
}),
economy: z.object({
totalWealth: z.string(),
avgLevel: z.number(),
topStreak: z.number(),
}),
recentEvents: z.array(RecentEventSchema),
uptime: z.number(),
lastCommandTimestamp: z.number().nullable(),
maintenanceMode: z.boolean(),
});
export type DashboardStats = z.infer<typeof DashboardStatsSchema>;
export const ClientStatsSchema = z.object({
bot: z.object({
name: z.string(),
avatarUrl: z.string().nullable(),
}),
guilds: z.number(),
ping: z.number(),
cachedUsers: z.number(),
commandsRegistered: z.number(),
uptime: z.number(),
lastCommandTimestamp: z.number().nullable(),
});
export type ClientStats = z.infer<typeof ClientStatsSchema>;
// Action Schemas
export const MaintenanceModeSchema = z.object({
enabled: z.boolean(),
reason: z.string().optional(),
});
// WebSocket Message Schemas
export const WsMessageSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("PING") }),
z.object({ type: z.literal("PONG") }),
z.object({ type: z.literal("STATS_UPDATE"), data: DashboardStatsSchema }),
z.object({ type: z.literal("NEW_EVENT"), data: RecentEventSchema }),
]);
export type WsMessage = z.infer<typeof WsMessageSchema>;

View File

@@ -61,6 +61,14 @@ export const economyService = {
description: `Transfer from ${fromUserId}`,
});
// Record dashboard event
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: 'info',
message: `${sender.username} transferred ${amount.toLocaleString()} AU to User ID ${toUserId}`,
icon: '💸'
});
return { success: true, amount };
}, tx);
},
@@ -149,6 +157,14 @@ export const economyService = {
description: `Daily reward (Streak: ${streak})`,
});
// Record dashboard event
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
await dashboardService.recordEvent({
type: 'success',
message: `${user.username} claimed daily reward: ${totalReward.toLocaleString()} AU`,
icon: '☀️'
});
return { claimed: true, amount: totalReward, streak, nextReadyAt, isWeekly: isWeeklyCurrent, weeklyBonus: weeklyBonusAmount };
}, tx);
},

View File

@@ -163,6 +163,11 @@ class LootdropService {
return { success: false, error: "An error occurred while processing the reward." };
}
}
public async clearCaches() {
this.channelActivity.clear();
this.channelCooldowns.clear();
console.log("[LootdropService] Caches cleared via administrative action.");
}
}
export const lootdropService = new LootdropService();

View File

@@ -196,5 +196,10 @@ export const tradeService = {
});
tradeService.endSession(threadId);
},
clearSessions: () => {
sessions.clear();
console.log("[TradeService] All active trade sessions cleared.");
}
};

View File

@@ -0,0 +1,38 @@
# DASH-003: Visual Analytics & Activity Charts
**Status:** Draft
**Created:** 2026-01-08
**Tags:** dashboard, analytics, charts, frontend
## 1. Context & User Story
* **As a:** Bot Administrator
* **I want to:** View a graphical representation of bot usage over the last 24 hours.
* **So that:** I can identify peak usage times and trends in command execution.
## 2. Technical Requirements
### Data Model Changes
- [ ] No new tables.
- [ ] Requires complex aggregation queries on the `transactions` table.
### API / Interface
- [ ] `GET /api/stats/activity`: Returns an array of data points for the last 24 hours (hourly granularity).
- [ ] Response Structure: `Array<{ hour: string, commands: number, transactions: number }>`.
## 3. Constraints & Validations (CRITICAL)
- **Input Validation:** Hourly buckets must be strictly validated for the 24h window.
- **System Constraints:**
- Database query must be cached for at least 5 minutes as it involves heavy aggregation.
- Chart must be responsive and handle mobile viewports.
- **Business Logic Guardrails:**
- If no data exists for an hour, it must return 0 rather than skipping the point.
## 4. Acceptance Criteria
1. [ ] **Given** a 24-hour history of transactions, **When** the dashboard loads, **Then** a line or area chart displays the command volume over time.
2. [ ] **Given** the premium glassmorphic theme, **When** the chart is rendered, **Then** it must use the primary brand colors and gradients to match the UI.
3. [ ] **Given** a mouse hover on the chart, **When** hovering over a point, **Then** a glassmorphic tooltip shows exact counts for that hour.
## 5. Implementation Plan
- [ ] Step 1: Add an aggregation method to `dashboard.service.ts` to fetch hourly counts from the `transactions` table.
- [ ] Step 2: Create the `/api/stats/activity` endpoint.
- [ ] Step 3: Install a charting library (e.g., `recharts` or `lucide-react` compatible library).
- [ ] Step 4: Implement the `ActivityChart` component into the middle column of the dashboard.

View File

@@ -0,0 +1,53 @@
# DASH-004: Administrative Control Panel
**Status:** Done
**Created:** 2026-01-08
**Tags:** dashboard, control-panel, bot-actions, operations
## 1. Context & User Story
* **As a:** Bot Administrator
* **I want to:** Execute common maintenance tasks directly from the dashboard buttons.
* **So that:** I don't have to use terminal commands or Discord slash commands for system-level operations.
## 2. Technical Requirements
### Data Model Changes
- [ ] N/A.
### API / Interface
- [ ] `POST /api/actions/reload-commands`: Triggers the bot's command loader.
- [ ] `POST /api/actions/clear-cache`: Clears internal bot caches.
- [ ] `POST /api/actions/maintenance-mode`: Toggles a maintenance flag for the bot.
## 3. Constraints & Validations (CRITICAL)
- **Input Validation:** Standard JSON body with optional `reason` field.
- **System Constraints:**
- Actions must be idempotent where possible.
- Actions must provide a response within 10 seconds.
- **Business Logic Guardrails:**
- **SECURITY**: This endpoint MUST require high-privilege authentication (currently we have single admin assumption, but token-based check should be planned).
- Maintenance mode toggle must be logged to the event feed.
## 4. Acceptance Criteria
1. [ ] **Given** a "Quick Actions" card, **When** the "Reload Commands" button is clicked, **Then** the bot reloads its local command files and posts a "Success" event to the feed.
2. [ ] **Given** a running bot, **When** the "Clear Cache" button is pushed, **Then** the bot flushes its internal memory maps and the memory usage metric reflects the drop.
## 5. Implementation Plan
- [x] Step 1: Create an `action.service.ts` to handle the logic of triggering bot-specific functions.
- [x] Step 2: Implement the `/api/actions` route group.
- [x] Step 3: Design a "Quick Actions" card with premium styled buttons in `Dashboard.tsx`.
- [x] Step 4: Add loading states to buttons to show when an operation is "In Progress."
## Implementation Notes
Successfully implemented the Administrative Control Panel with the following changes:
- **Backend Service**: Created `shared/modules/admin/action.service.ts` to coordinate actions like reloading commands, clearing cache, and toggling maintenance mode.
- **System Bus**: Updated `shared/lib/events.ts` with new action events.
- **API Endpoints**: Added `POST /api/actions/*` routes to the web server in `web/src/server.ts`.
- **Bot Integration**:
- Updated `AuroraClient` in `bot/lib/BotClient.ts` to listen for system action events.
- Implemented `maintenanceMode` flag in `AuroraClient`.
- Updated `CommandHandler.ts` to respect maintenance mode, blocking user commands with a helpful error embed.
- **Frontend UI**:
- Created `ControlPanel.tsx` component with a premium glassmorphic design and real-time state feedback.
- Integrated `ControlPanel` into the `Dashboard.tsx` page.
- Updated `use-dashboard-stats` hook and shared types to include maintenance mode status.
- **Verification**: Created 3 new test suites covering the service, the bot listener, and the command handler enforcement. All tests passing.

View File

@@ -0,0 +1,202 @@
# DASH-001: Dashboard Real Data Integration
**Status:** In Review
**Created:** 2026-01-08
**Tags:** dashboard, api, discord-client, database, real-time
## 1. Context & User Story
* **As a:** Bot Administrator
* **I want to:** See real data on the dashboard instead of mock/hardcoded values
* **So that:** I can monitor actual bot metrics, user activity, and system health in real-time
## 2. Technical Requirements
### Data Model Changes
- [ ] No new tables required
- [ ] SQL migration required? **No** existing schema already has `users`, `transactions`, `moderationCases`, and other relevant tables
### API / Interface
#### New Dashboard Stats Service
Create a new service at `shared/modules/dashboard/dashboard.service.ts`:
```typescript
interface DashboardStats {
guilds: {
count: number;
changeFromLastMonth?: number;
};
users: {
active: number;
changePercentFromLastMonth?: number;
};
commands: {
total: number;
changePercentFromLastMonth?: number;
};
ping: {
avg: number;
changeFromLastHour?: number;
};
recentEvents: RecentEvent[];
activityOverview: ActivityDataPoint[];
}
interface RecentEvent {
type: 'success' | 'error' | 'info';
message: string;
timestamp: Date;
}
```
#### API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/stats` | Returns `DashboardStats` object |
| `GET` | `/api/stats/realtime` | WebSocket/SSE for live updates |
### Discord Client Data
The `AuroraClient` (exported from `bot/lib/BotClient.ts`) provides access to:
| Property | Data Source | Dashboard Metric |
|----------|-------------|------------------|
| `client.guilds.cache.size` | Discord.js | Total Servers |
| `client.users.cache.size` | Discord.js | Active Users (approximate) |
| `client.ws.ping` | Discord.js | Avg Ping |
| `client.commands.size` | Bot commands | Commands Registered |
| `client.lastCommandTimestamp` | Custom property | Last command run time |
### Database Data
Query from existing tables:
| Metric | Query |
|--------|-------|
| User count (registered) | `SELECT COUNT(*) FROM users WHERE is_active = true` |
| Commands executed (today) | `SELECT COUNT(*) FROM transactions WHERE type = 'COMMAND_RUN' AND created_at >= NOW() - INTERVAL '1 day'` |
| Recent moderation events | `SELECT * FROM moderation_cases ORDER BY created_at DESC LIMIT 10` |
| Recent transactions | `SELECT * FROM transactions ORDER BY created_at DESC LIMIT 10` |
> [!IMPORTANT]
> The Discord client instance (`AuroraClient`) is in the `bot` package, while the web server is in the `web` package. Need to establish cross-package communication:
> - **Option A**: Export client reference from `bot` and import in `web` (same process, simple)
> - **Option B**: IPC via shared memory or message queue (separate processes)
> - **Option C**: Internal HTTP/WebSocket between bot and web (microservice pattern)
## 3. Constraints & Validations (CRITICAL)
- **Input Validation:**
- API endpoints must not accept arbitrary query parameters
- Rate limiting on `/api/stats` to prevent abuse (max 60 requests/minute per IP)
- **System Constraints:**
- Discord API rate limits apply when fetching guild/user data
- Cache Discord data and refresh at most every 30 seconds
- Database queries should be optimized with existing indices
- API response timeout: 5 seconds maximum
- **Business Logic Guardrails:**
- Do not expose sensitive user data (only aggregates)
- Do not expose Discord tokens or internal IDs in API responses
- Activity history limited to last 24 hours to prevent performance issues
- User counts should count only registered users, not all Discord users
## 4. Acceptance Criteria
1. [ ] **Given** the dashboard is loaded, **When** the API `/api/stats` is called, **Then** it returns real guild count from Discord client
2. [ ] **Given** the bot is connected to Discord, **When** viewing the dashboard, **Then** the "Total Servers" shows actual `guilds.cache.size`
3. [ ] **Given** users are registered in the database, **When** viewing the dashboard, **Then** "Active Users" shows count from `users` table where `is_active = true`
4. [ ] **Given** the bot is running, **When** viewing the dashboard, **Then** "Avg Ping" shows actual `client.ws.ping` value
5. [ ] **Given** recent bot activity occurred, **When** viewing "Recent Events", **Then** events from `transactions` and `moderation_cases` tables are displayed
6. [ ] **Given** mock data exists in components, **When** the feature is complete, **Then** all hardcoded values in `Dashboard.tsx` are replaced with API data
## 5. Implementation Plan
### Phase 1: Data Layer & Services
- [ ] Create `shared/modules/dashboard/dashboard.service.ts` with statistics aggregation functions
- [ ] Add helper to query active user count from database
- [ ] Add helper to query recent transactions (as events)
- [ ] Add helper to query moderation cases (as events)
---
### Phase 2: Discord Client Exposure
- [ ] Create a client stats provider that exposes Discord metrics
- [ ] Implement caching layer to avoid rate limiting (30-second TTL)
- [ ] Export stats getter from `bot` package for `web` package consumption
---
### Phase 3: API Implementation
- [ ] Add `/api/stats` endpoint in `web/src/server.ts`
- [ ] Wire up `dashboard.service.ts` functions to API
- [ ] Add error handling and response formatting
- [ ] Consider adding rate limiting middleware
---
### Phase 4: Frontend Integration
- [ ] Create custom React hook `useDashboardStats()` for data fetching
- [ ] Replace hardcoded values in `Dashboard.tsx` with hook data
- [ ] Add loading states and error handling
- [ ] Implement auto-refresh (poll every 30 seconds or use SSE/WebSocket)
---
### Phase 5: Activity Overview Chart
- [ ] Query hourly command/transaction counts for last 24 hours
- [ ] Integrate charting library (e.g., Recharts, Chart.js)
- [ ] Replace "Chart Placeholder" with actual chart component
---
## Architecture Decision Required
> [!WARNING]
> **Key Decision: How should the web server access Discord client data?**
>
> The bot and web server currently run in the same process. Recommend:
> - **Short term**: Direct import of `AuroraClient` singleton in API handlers
> - **Long term**: Consider event bus or shared state manager if splitting to microservices
## Out of Scope
- User authentication/authorization for API endpoints
- Historical data beyond 24 hours
- Command execution tracking (would require new database table)
- Guild-specific analytics (separate feature)
---
## Implementation Notes
**Status:** In Review
**Implemented:** 2026-01-08
**Branch:** `feat/dashboard-real-data-integration`
**Commit:** `17cb70e`
### Files Changed
#### New Files Created (7)
1. `shared/modules/dashboard/dashboard.types.ts` - TypeScript interfaces
2. `shared/modules/dashboard/dashboard.service.ts` - Database query service
3. `shared/modules/dashboard/dashboard.service.test.ts` - Service unit tests
4. `bot/lib/clientStats.ts` - Discord client stats provider with caching
5. `bot/lib/clientStats.test.ts` - Client stats unit tests
6. `web/src/hooks/use-dashboard-stats.ts` - React hook for data fetching
7. `tickets/2026-01-08-dashboard-real-data-integration.md` - This ticket
#### Modified Files (3)
1. `web/src/server.ts` - Added `/api/stats` endpoint
2. `web/src/pages/Dashboard.tsx` - Integrated real data with loading/error states
3. `.gitignore` - Removed `tickets/` to track tickets in version control
### Test Results
```
✓ 11 tests passing
✓ TypeScript check clean (bun x tsc --noEmit)
```
### Architecture Decision
Used **Option A** (direct import) for accessing `AuroraClient` from web server, as both run in the same process. This is the simplest approach and avoids unnecessary complexity.

View File

@@ -0,0 +1,49 @@
# DASH-002: Real-time Live Updates via WebSockets
**Status:** Done
**Created:** 2026-01-08
**Tags:** dashboard, websocket, real-time, performance
## 1. Context & User Story
* **As a:** Bot Administrator
* **I want to:** See metrics and events update instantly on my screen without refreshing or waiting for polling intervals.
* **So that:** I can react immediately to errors or spikes in latency and have a dashboard that feels "alive."
## 2. Technical Requirements
### Data Model Changes
- [x] No database schema changes required.
- [x] Created `shared/lib/events.ts` for a global system event bus.
### API / Interface
- [x] Establish a WebSocket endpoint at `/ws`.
- [x] Define the message protocol:
- `STATS_UPDATE`: Server to client containing full `DashboardStats`.
- `NEW_EVENT`: Server to client when a specific event is recorded.
## 3. Constraints & Validations (CRITICAL)
- **Input Validation:** WS messages validated using JSON parsing and type checks.
- **System Constraints:**
- WebSocket broadcast interval set to 5s for metrics.
- Automatic reconnection logic handled in the frontend hook.
- **Business Logic Guardrails:**
- Events are pushed immediately as they occur via the system event bus.
## 4. Acceptance Criteria
1. [x] **Given** the dashboard is open, **When** a command is run in Discord (e.g. Daily), **Then** the "Recent Events" list updates instantly on the web UI.
2. [x] **Given** a changing network environment, **When** the bot's ping fluctuates, **Then** the "Avg Latency" card updates in real-time.
3. [x] **Given** a connection loss, **When** the network returns, **Then** the client automatically reconnects to the WS room.
## 5. Implementation Plan
- [x] Step 1: Integrate a WebSocket library into `web/src/server.ts` using Bun's native `websocket` support.
- [x] Step 2: Implement a broadcast system in `dashboard.service.ts` to push events to the WS handler using `systemEvents`.
- [x] Step 3: Create/Update `useDashboardStats` hook in the frontend to handle connection lifecycle and state merging.
- [x] Step 4: Refactor `Dashboard.tsx` state consumption to benefit from real-time updates.
## Implementation Notes
### Files Changed
- `shared/lib/events.ts`: New event bus for the system.
- `web/src/server.ts`: Added WebSocket handler and stats broadcast.
- `web/src/hooks/use-dashboard-stats.ts`: Replaced polling with WebSocket + HTTP initial load.
- `shared/modules/dashboard/dashboard.service.ts`: Added `recordEvent` helper to emit WS events.
- `shared/modules/economy/economy.service.ts`: Integrated `recordEvent` into daily claims and transfers.
- `shared/modules/dashboard/dashboard.service.test.ts`: Added unit tests for event emission.

View File

@@ -127,28 +127,46 @@ const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
.filter(dir => !dir.includes("node_modules"));
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
const result = await Bun.build({
entrypoints,
outdir,
plugins: [plugin],
minify: true,
target: "browser",
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
...cliConfig,
});
const build = async () => {
const result = await Bun.build({
entrypoints,
outdir,
plugins: [plugin],
minify: true,
target: "browser",
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
...cliConfig,
});
const outputTable = result.outputs.map(output => ({
File: path.relative(process.cwd(), output.path),
Type: output.kind,
Size: formatFileSize(output.size),
}));
console.table(outputTable);
return result;
};
const result = await build();
const end = performance.now();
const outputTable = result.outputs.map(output => ({
File: path.relative(process.cwd(), output.path),
Type: output.kind,
Size: formatFileSize(output.size),
}));
console.table(outputTable);
const buildTime = (end - start).toFixed(2);
console.log(`\n✅ Build completed in ${buildTime}ms\n`);
if ((cliConfig as any).watch) {
console.log("👀 Watching for changes...\n");
// Keep the process alive for watch mode
// Bun.build with watch:true handles the watching,
// we just need to make sure the script doesn't exit.
process.stdin.resume();
// Also, handle manual exit
process.on("SIGINT", () => {
console.log("\n👋 Stopping build watcher...");
process.exit(0);
});
}

View File

@@ -1,5 +1,4 @@
import { LayoutDashboard, Settings, Activity, Server, Zap } from "lucide-react";
import { LayoutDashboard, Settings, Activity } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import {
Sidebar,
@@ -14,6 +13,7 @@ import {
SidebarFooter,
SidebarRail,
} from "@/components/ui/sidebar";
import { useDashboardStats } from "@/hooks/use-dashboard-stats";
// Menu items.
const items = [
@@ -36,37 +36,52 @@ const items = [
export function AppSidebar() {
const location = useLocation();
const { stats } = useDashboardStats();
const botName = stats?.bot?.name || "Aurora";
const botAvatar = stats?.bot?.avatarUrl;
return (
<Sidebar>
<SidebarHeader>
<Sidebar className="glass-sidebar border-r border-white/5">
<SidebarHeader className="p-4">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link to="/">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Zap className="size-4" />
<SidebarMenuButton size="lg" asChild className="hover:bg-white/5 transition-all duration-300 rounded-xl">
<Link to="/" className="flex items-center gap-3">
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-purple-600 text-primary-foreground shadow-lg shadow-primary/20 overflow-hidden border border-white/10">
{botAvatar ? (
<img src={botAvatar} alt={botName} className="size-full object-cover" />
) : (
<div className="size-full flex items-center justify-center font-bold text-lg italic">A</div>
)}
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">Aurora</span>
<span className="">v1.0.0</span>
<div className="flex flex-col gap-0 leading-none">
<span className="text-lg font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">{botName}</span>
<span className="text-[10px] uppercase tracking-widest text-primary font-bold">Admin Portal</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarContent className="px-2">
<SidebarGroup>
<SidebarGroupLabel>Application</SidebarGroupLabel>
<SidebarGroupLabel className="px-4 text-[10px] font-bold uppercase tracking-[0.2em] text-white/30 mb-2">Main Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenu className="gap-1">
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={location.pathname === item.url}>
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
<SidebarMenuButton
asChild
isActive={location.pathname === item.url}
className={`transition-all duration-200 rounded-lg px-4 py-6 ${location.pathname === item.url
? "bg-primary/10 text-primary border border-primary/20 shadow-lg shadow-primary/5"
: "hover:bg-white/5 text-white/60 hover:text-white"
}`}
>
<Link to={item.url} className="flex items-center gap-3">
<item.icon className={`size-5 ${location.pathname === item.url ? "text-primary" : ""}`} />
<span className="font-medium">{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -75,16 +90,16 @@ export function AppSidebar() {
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarFooter className="p-4 border-t border-white/5">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg">
<div className="bg-muted flex aspect-square size-8 items-center justify-center rounded-lg">
<span className="text-xs font-bold">U</span>
<SidebarMenuButton size="lg" className="hover:bg-white/5 rounded-xl transition-colors">
<div className="bg-primary/20 border border-primary/20 flex aspect-square size-10 items-center justify-center rounded-full overflow-hidden">
<span className="text-sm font-bold text-primary italic">A</span>
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">User</span>
<span className="text-xs text-muted-foreground">Admin</span>
<div className="flex flex-col gap-0.5 leading-none ml-2">
<span className="font-bold text-sm text-white/90">Administrator</span>
<span className="text-[10px] text-white/40 font-medium">Session Active</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>

View File

@@ -0,0 +1,126 @@
import { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
import { Button } from "./ui/button";
import { RefreshCw, Trash2, ShieldAlert, Loader2, Power } from "lucide-react";
/**
* Props for the ControlPanel component
*/
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.
*/
export function ControlPanel({ maintenanceMode }: ControlPanelProps) {
const [loading, setLoading] = useState<string | null>(null);
/**
* Handles triggering an administrative action via the API
*/
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,
});
if (!response.ok) throw new Error(`Action ${action} failed`);
} catch (error) {
console.error("Action Error:", error);
// Ideally we'd show a toast here
} finally {
setLoading(null);
}
};
return (
<Card className="glass border-white/5 overflow-hidden group">
<CardHeader className="relative">
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<ShieldAlert className="h-12 w-12" />
</div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
<div className="h-5 w-1 bg-primary rounded-full" />
System Controls
</CardTitle>
<CardDescription className="text-white/40">Administrative bot operations</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-2 gap-4">
{/* Reload Commands Button */}
<Button
variant="outline"
className="glass hover:bg-white/10 border-white/10 hover:border-primary/50 flex flex-col items-start gap-2 h-auto py-4 px-4 transition-all group/btn"
onClick={() => handleAction("reload-commands")}
disabled={!!loading}
>
<div className="p-2 rounded-lg bg-primary/10 text-primary group-hover/btn:scale-110 transition-transform">
{loading === "reload-commands" ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</div>
<div className="space-y-0.5 text-left">
<p className="text-sm font-bold">Reload</p>
<p className="text-[10px] text-white/30">Sync commands</p>
</div>
</Button>
{/* Clear Cache Button */}
<Button
variant="outline"
className="glass hover:bg-white/10 border-white/10 hover:border-blue-500/50 flex flex-col items-start gap-2 h-auto py-4 px-4 transition-all group/btn"
onClick={() => handleAction("clear-cache")}
disabled={!!loading}
>
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500 group-hover/btn:scale-110 transition-transform">
{loading === "clear-cache" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</div>
<div className="space-y-0.5 text-left">
<p className="text-sm font-bold">Flush</p>
<p className="text-[10px] text-white/30">Clear caches</p>
</div>
</Button>
</div>
{/* Maintenance Mode Toggle Button */}
<Button
variant="outline"
className={`glass flex items-center justify-between h-auto py-4 px-5 border-white/10 transition-all group/maint ${maintenanceMode
? 'bg-red-500/10 border-red-500/50 hover:bg-red-500/20'
: 'hover:border-yellow-500/50 hover:bg-yellow-500/5'
}`}
onClick={() => handleAction("maintenance-mode", { enabled: !maintenanceMode, reason: "Dashboard toggle" })}
disabled={!!loading}
>
<div className="flex items-center gap-4">
<div className={`p-2.5 rounded-full transition-all ${maintenanceMode ? 'bg-red-500 text-white animate-pulse shadow-[0_0_15px_rgba(239,68,68,0.4)]' : 'bg-white/5 text-white/40'
}`}>
{loading === "maintenance-mode" ? <Loader2 className="h-5 w-5 animate-spin" /> : <Power className="h-5 w-5" />}
</div>
<div className="text-left">
<p className="text-sm font-bold">Maintenance Mode</p>
<p className="text-[10px] text-white/30">
{maintenanceMode ? "Bot is currently restricted" : "Restrict bot access"}
</p>
</div>
</div>
<div className={`h-2 w-2 rounded-full ${maintenanceMode ? 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]' : 'bg-white/10'}`} />
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,132 @@
import { useState, useEffect } from "react";
interface DashboardStats {
bot: {
name: string;
avatarUrl: string | null;
};
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' | 'warn';
message: string;
timestamp: string;
icon?: string;
}>;
uptime: number;
lastCommandTimestamp: number | null;
maintenanceMode: boolean;
}
interface UseDashboardStatsResult {
stats: DashboardStats | null;
loading: boolean;
error: string | null;
}
/**
* Custom hook to fetch and auto-refresh dashboard statistics using WebSockets with HTTP fallback
*/
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();
// WebSocket setup
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws`;
let socket: WebSocket | null = null;
let reconnectTimeout: Timer | null = null;
const connect = () => {
socket = new WebSocket(wsUrl);
socket.onopen = () => {
console.log("🟢 [WS] Connected to dashboard live stream");
setError(null);
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
};
socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === "STATS_UPDATE") {
setStats(message.data);
} else if (message.type === "NEW_EVENT") {
setStats(prev => {
if (!prev) return prev;
return {
...prev,
recentEvents: [message.data, ...prev.recentEvents].slice(0, 10)
};
});
}
} catch (e) {
console.error("Error parsing WS message:", e);
}
};
socket.onclose = () => {
console.log("🟠 [WS] Connection lost. Attempting reconnect in 5s...");
reconnectTimeout = setTimeout(connect, 5000);
};
socket.onerror = (err) => {
console.error("🔴 [WS] Socket error:", err);
socket?.close();
};
};
connect();
// Cleanup on unmount
return () => {
if (socket) {
socket.onclose = null; // Prevent reconnect on intentional close
socket.close();
}
if (reconnectTimeout) clearTimeout(reconnectTimeout);
};
}, []);
return { stats, loading, error };
}

View File

@@ -4,9 +4,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aurora</title>
<title>Aurora Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>

View File

@@ -7,21 +7,28 @@ import { Separator } from "../components/ui/separator";
export function DashboardLayout() {
return (
<SidebarProvider>
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/20 blur-[120px] rounded-full animate-pulse" />
<div className="absolute bottom-[-10%] right-[-10%] w-[30%] h-[30%] bg-purple-500/10 blur-[100px] rounded-full" />
</div>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<div className="flex items-center gap-2 px-4">
{/* Breadcrumbs could go here */}
<h1 className="text-lg font-semibold">Dashboard</h1>
<SidebarInset className="bg-transparent">
<header className="flex h-16 shrink-0 items-center gap-2 px-6 backdrop-blur-md bg-background/30 border-b border-white/5 sticky top-0 z-10">
<SidebarTrigger className="-ml-1 hover:bg-white/5 transition-colors" />
<Separator orientation="vertical" className="mx-4 h-4 bg-white/10" />
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold tracking-tight text-glow">Dashboard</h1>
</div>
<div className="ml-auto flex items-center gap-4">
<div className="h-2 w-2 rounded-full bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.5)]" />
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min p-4">
<main className="flex flex-1 flex-col gap-6 p-6">
<div className="flex-1 rounded-2xl md:min-h-min">
<Outlet />
</div>
</div>
</main>
</SidebarInset>
</SidebarProvider>
);

View File

@@ -6,105 +6,169 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Activity, Server, Users, Zap } from "lucide-react";
import { useDashboardStats } from "@/hooks/use-dashboard-stats";
import { ControlPanel } from "@/components/ControlPanel";
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>
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
<p className="text-muted-foreground">Overview of your bot's activity and performance.</p>
<div className="space-y-8 animate-in fade-in duration-700">
<div className="flex flex-col gap-2">
<h2 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-white to-white/40">
{stats.bot.name} Overview
</h2>
<p className="text-white/40 font-medium">Monitoring real-time activity and core bot metrics.</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{/* Metric Cards */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Servers</CardTitle>
<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>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Users</CardTitle>
<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>
</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>
<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>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Ping</CardTitle>
<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>
</CardContent>
</Card>
{[
{ 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: "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) => (
<Card key={i} className="glass group hover:border-primary/50 transition-all duration-300 hover:scale-[1.02]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-white/50">{metric.title}</CardTitle>
<div className={`p-2 rounded-lg bg-gradient-to-br ${metric.color} bg-opacity-10 group-hover:scale-110 transition-transform duration-300`}>
<metric.icon className="h-4 w-4 text-white" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tracking-tight mb-1">{metric.value}</div>
<p className="text-xs font-medium text-white/30">{metric.label}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4">
<CardHeader>
<CardTitle>Activity Overview</CardTitle>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4 glass border-white/5">
<CardHeader className="flex flex-row items-start justify-between">
<div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
<div className="h-5 w-1 bg-primary rounded-full" />
Economy Overview
</CardTitle>
<CardDescription className="text-white/40">Global wealth and progression statistics</CardDescription>
</div>
<div className="bg-white/5 px-3 py-1.5 rounded-full border border-white/10 flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[10px] font-bold uppercase tracking-widest text-white/50">
Uptime: {Math.floor(stats.uptime / 3600)}h {Math.floor((stats.uptime % 3600) / 60)}m
</span>
</div>
</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="grid gap-8">
<div className="relative group">
<div className="absolute -inset-1 bg-gradient-to-r from-primary/20 to-purple-500/20 rounded-2xl blur opacity-0 group-hover:opacity-100 transition duration-1000"></div>
<div className="relative bg-white/5 rounded-xl p-6 border border-white/10">
<p className="text-sm font-bold uppercase tracking-wider text-white/30 mb-1">Total Distributed Wealth</p>
<p className="text-4xl font-black text-glow bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-400">
{BigInt(stats.economy.totalWealth).toLocaleString()} <span className="text-xl font-bold text-white/20">AU</span>
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
<p className="text-xs font-bold text-white/30 uppercase tracking-widest mb-1">Avg Level</p>
<p className="text-2xl font-bold">{stats.economy.avgLevel}</p>
</div>
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
<p className="text-xs font-bold text-white/30 uppercase tracking-widest mb-1">Peak Streak</p>
<p className="text-2xl font-bold">{stats.economy.topStreak} <span className="text-sm text-white/20">days</span></p>
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="col-span-3">
<CardHeader>
<CardTitle>Recent Events</CardTitle>
<CardDescription>Latest system and bot events.</CardDescription>
</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 className="col-span-3 flex flex-col gap-6">
{/* Administrative Control Panel */}
<ControlPanel maintenanceMode={stats.maintenanceMode} />
{/* Recent Events Feed */}
<Card className="glass border-white/5 overflow-hidden flex-1">
<CardHeader className="bg-white/[0.02] border-b border-white/5">
<CardTitle className="text-xl font-bold">Recent Events</CardTitle>
<CardDescription className="text-white/30">Live system activity feed</CardDescription>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y divide-white/5">
{stats.recentEvents.length === 0 ? (
<div className="p-8 text-center bg-transparent">
<p className="text-sm text-white/20 font-medium">No activity recorded</p>
</div>
) : (
stats.recentEvents.slice(0, 6).map((event, i) => (
<div key={i} className="flex items-start gap-4 p-4 hover:bg-white/[0.03] transition-colors group">
<div className={`mt-1 p-2 rounded-lg ${event.type === 'success' ? 'bg-emerald-500/10 text-emerald-500' :
event.type === 'error' ? 'bg-red-500/10 text-red-500' :
event.type === 'warn' ? 'bg-yellow-500/10 text-yellow-500' :
'bg-blue-500/10 text-blue-500'
} group-hover:scale-110 transition-transform`}>
<div className="text-lg leading-none">{event.icon}</div>
</div>
<div className="space-y-1 flex-1">
<p className="text-sm font-semibold text-white/90 leading-tight">
{event.message}
</p>
<p className="text-[10px] font-bold text-white/20 uppercase tracking-wider">
{new Date(event.timestamp).toLocaleTimeString()}
</p>
</div>
</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>
</div>
</CardContent>
</Card>
{stats.recentEvents.length > 0 && (
<button className="w-full py-3 text-[10px] font-bold uppercase tracking-[0.2em] text-white/20 hover:text-primary hover:bg-white/[0.02] transition-all border-t border-white/5">
View Event Logs
</button>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);

143
web/src/server.test.ts Normal file
View File

@@ -0,0 +1,143 @@
import { describe, test, expect, afterAll, mock } from "bun:test";
import type { WebServerInstance } from "./server";
import { createWebServer } from "./server";
interface MockBotStats {
bot: { name: string; avatarUrl: string | null };
guilds: number;
ping: number;
cachedUsers: number;
commandsRegistered: number;
uptime: number;
lastCommandTimestamp: number | null;
}
// 1. Mock DrizzleClient (dependency of dashboardService)
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 }]),
};
const mockFrom = {
from: mock(() => mockBuilder),
};
return {
DrizzleClient: {
select: mock(() => mockFrom),
query: {
transactions: { findMany: mock(() => Promise.resolve([])) },
moderationCases: { findMany: mock(() => Promise.resolve([])) },
users: {
findFirst: mock(() => Promise.resolve({ username: "test" })),
findMany: mock(() => Promise.resolve([])),
},
}
},
};
});
// 2. Mock Bot Stats Provider
mock.module("../../bot/lib/clientStats", () => ({
getClientStats: mock((): MockBotStats => ({
bot: { name: "TestBot", avatarUrl: null },
guilds: 5,
ping: 42,
cachedUsers: 100,
commandsRegistered: 10,
uptime: 3600,
lastCommandTimestamp: Date.now(),
})),
}));
// 3. System Events (No mock needed, use real events)
describe("WebServer Security & Limits", () => {
const port = 3001;
let serverInstance: WebServerInstance | null = null;
afterAll(async () => {
if (serverInstance) {
await serverInstance.stop();
}
});
test("should reject more than 10 concurrent WebSocket connections", async () => {
serverInstance = await createWebServer({ port, hostname: "localhost" });
const wsUrl = `ws://localhost:${port}/ws`;
const sockets: WebSocket[] = [];
try {
// Attempt to open 12 connections (limit is 10)
for (let i = 0; i < 12; i++) {
const ws = new WebSocket(wsUrl);
sockets.push(ws);
await new Promise(resolve => setTimeout(resolve, 5));
}
// Give connections time to settle
await new Promise(resolve => setTimeout(resolve, 800));
const pendingCount = serverInstance.server.pendingWebSockets;
expect(pendingCount).toBeLessThanOrEqual(10);
} finally {
sockets.forEach(s => {
if (s.readyState === WebSocket.OPEN || s.readyState === WebSocket.CONNECTING) {
s.close();
}
});
}
});
test("should return 200 for health check", async () => {
if (!serverInstance) {
serverInstance = await createWebServer({ port, hostname: "localhost" });
}
const response = await fetch(`http://localhost:${port}/api/health`);
expect(response.status).toBe(200);
const data = (await response.json()) as { status: string };
expect(data.status).toBe("ok");
});
describe("Administrative Actions Authorization", () => {
test("should reject 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}` }
});
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
});
expect(response.status).toBe(400);
const data = await response.json() as { error: string };
expect(data.error).toBe("Invalid payload");
});
});
});

View File

@@ -51,17 +51,96 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
}
}
// Configuration constants
const MAX_CONNECTIONS = 10;
const MAX_PAYLOAD_BYTES = 16384; // 16KB
const IDLE_TIMEOUT_SECONDS = 60;
// Interval for broadcasting stats to all connected WS clients
let statsBroadcastInterval: Timer | undefined;
const server = serve({
port,
hostname,
async fetch(req) {
async fetch(req, server) {
const url = new URL(req.url);
// Upgrade to WebSocket
if (url.pathname === "/ws") {
// Security Check: limit concurrent connections
const currentConnections = server.pendingWebSockets;
if (currentConnections >= MAX_CONNECTIONS) {
console.warn(`⚠️ [WS] Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`);
return new Response("Connection limit reached", { status: 429 });
}
const success = server.upgrade(req);
if (success) return undefined;
return new Response("WebSocket upgrade failed", { status: 400 });
}
// API routes
if (url.pathname === "/api/health") {
return Response.json({ status: "ok", timestamp: Date.now() });
}
if (url.pathname === "/api/stats") {
try {
const stats = await getFullDashboardStats();
return Response.json(stats);
} catch (error) {
console.error("Error fetching dashboard stats:", error);
return Response.json(
{ error: "Failed to fetch dashboard statistics" },
{ status: 500 }
);
}
}
// 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");
if (url.pathname === "/api/actions/reload-commands") {
const result = await actionService.reloadCommands();
return Response.json(result);
}
if (url.pathname === "/api/actions/clear-cache") {
const result = await actionService.clearCache();
return Response.json(result);
}
if (url.pathname === "/api/actions/maintenance-mode") {
const rawBody = await req.json();
const parsed = MaintenanceModeSchema.safeParse(rawBody);
if (!parsed.success) {
return Response.json({ error: "Invalid payload", issues: parsed.error.issues }, { status: 400 });
}
const result = await actionService.toggleMaintenanceMode(parsed.data.enabled, parsed.data.reason);
return Response.json(result);
}
} catch (error) {
console.error("Error executing administrative action:", error);
return Response.json(
{ error: "Failed to execute administrative action" },
{ status: 500 }
);
}
}
// Static File Serving
let pathName = url.pathname;
if (pathName === "/") pathName = "/index.html";
@@ -77,24 +156,142 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
const fileRef = Bun.file(safePath);
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>`);
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
return new Response(fileRef);
}
// SPA Fallback: Serve index.html for unknown non-file routes
// If the path looks like a file (has extension), return 404
// Otherwise serve index.html
const parts = pathName.split("/");
const lastPart = parts[parts.length - 1];
if (lastPart?.includes(".")) {
return new Response("Not Found", { status: 404 });
}
return new Response(Bun.file(join(distDir, "index.html")));
const indexFile = Bun.file(join(distDir, "index.html"));
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>`);
return new Response(indexHtml, { headers: { "Content-Type": "text/html" } });
},
websocket: {
open(ws) {
ws.subscribe("dashboard");
console.log(`🔌 [WS] Client connected. Total: ${server.pendingWebSockets}`);
// Send initial stats
getFullDashboardStats().then(stats => {
ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats }));
});
// Start broadcast interval if this is the first client
if (!statsBroadcastInterval) {
statsBroadcastInterval = setInterval(async () => {
try {
const stats = await getFullDashboardStats();
server.publish("dashboard", JSON.stringify({ type: "STATS_UPDATE", data: stats }));
} catch (error) {
console.error("Error in stats broadcast:", error);
}
}, 5000);
}
},
async message(ws, message) {
try {
const messageStr = message.toString();
// Defense-in-depth: redundant length check before parsing
if (messageStr.length > MAX_PAYLOAD_BYTES) {
console.error("❌ [WS] Payload exceeded maximum limit");
return;
}
const rawData = JSON.parse(messageStr);
const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types");
const parsed = WsMessageSchema.safeParse(rawData);
if (!parsed.success) {
console.error("❌ [WS] Invalid message format:", parsed.error.issues);
return;
}
if (parsed.data.type === "PING") {
ws.send(JSON.stringify({ type: "PONG" }));
}
} catch (e) {
console.error("❌ [WS] Failed to handle message:", e instanceof Error ? e.message : "Malformed JSON");
}
},
close(ws) {
ws.unsubscribe("dashboard");
console.log(`🔌 [WS] Client disconnected. Total remaining: ${server.pendingWebSockets}`);
// Stop broadcast interval if no clients left
if (server.pendingWebSockets === 0 && statsBroadcastInterval) {
clearInterval(statsBroadcastInterval);
statsBroadcastInterval = undefined;
}
},
maxPayloadLength: MAX_PAYLOAD_BYTES,
idleTimeout: IDLE_TIMEOUT_SECONDS,
},
development: isDev,
});
/**
* Helper to fetch full dashboard stats object.
* Unified for both HTTP API and WebSocket broadcasts.
*/
async function getFullDashboardStats() {
// 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),
]);
return {
bot: clientStats.bot,
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 instanceof Date ? event.timestamp.toISOString() : event.timestamp,
})),
uptime: clientStats.uptime,
lastCommandTimestamp: clientStats.lastCommandTimestamp,
maintenanceMode: (await import("../../bot/lib/BotClient")).AuroraClient.maintenanceMode,
};
}
// Listen for real-time events from the system bus
const { systemEvents, EVENTS } = await import("@shared/lib/events");
systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => {
server.publish("dashboard", JSON.stringify({ type: "NEW_EVENT", data: event }));
});
const url = `http://${hostname}:${port}`;
return {
@@ -104,6 +301,9 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
if (buildProcess) {
buildProcess.kill();
}
if (statsBroadcastInterval) {
clearInterval(statsBroadcastInterval);
}
server.stop(true);
},
};

View File

@@ -42,79 +42,65 @@
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--radius: 1rem;
--background: oklch(0.12 0.02 260);
--foreground: oklch(0.98 0.01 260);
--card: oklch(0.16 0.03 260 / 0.5);
--card-foreground: oklch(0.98 0.01 260);
--popover: oklch(0.14 0.02 260 / 0.8);
--popover-foreground: oklch(0.98 0.01 260);
--primary: oklch(0.65 0.18 250);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.25 0.04 260);
--secondary-foreground: oklch(0.98 0.01 260);
--muted: oklch(0.2 0.03 260 / 0.6);
--muted-foreground: oklch(0.7 0.02 260);
--accent: oklch(0.3 0.05 250 / 0.4);
--accent-foreground: oklch(0.98 0.01 260);
--destructive: oklch(0.6 0.18 25);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--input: oklch(1 0 0 / 5%);
--ring: oklch(0.65 0.18 250 / 50%);
--chart-1: oklch(0.6 0.18 250);
--chart-2: oklch(0.7 0.15 160);
--chart-3: oklch(0.8 0.12 80);
--chart-4: oklch(0.6 0.2 300);
--chart-5: oklch(0.6 0.25 20);
--sidebar: oklch(0.14 0.02 260 / 0.6);
--sidebar-foreground: oklch(0.98 0.01 260);
--sidebar-primary: oklch(0.65 0.18 250);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(1 0 0 / 5%);
--sidebar-accent-foreground: oklch(0.98 0.01 260);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.65 0.18 250 / 50%);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground selection:bg-primary/30;
font-family: 'Outfit', 'Inter', system-ui, sans-serif;
background-image:
radial-gradient(at 0% 0%, oklch(0.25 0.1 260 / 0.15) 0px, transparent 50%),
radial-gradient(at 100% 0%, oklch(0.35 0.12 300 / 0.1) 0px, transparent 50%);
background-attachment: fixed;
}
}
@layer utilities {
.glass {
@apply bg-card backdrop-blur-xl border border-white/10 shadow-2xl;
}
.glass-sidebar {
@apply bg-sidebar backdrop-blur-2xl border-r border-white/5;
}
.text-glow {
text-shadow: 0 0 10px oklch(var(--primary) / 0.5);
}
}