Compare commits
13 Commits
a207d511be
...
cf4c28e1df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf4c28e1df | ||
|
|
39e405afde | ||
|
|
6763e3c543 | ||
|
|
11e07a0068 | ||
|
|
5d2d4bb0c6 | ||
|
|
19206b5cc7 | ||
|
|
0f6cce9b6e | ||
|
|
3f3a6c88e8 | ||
|
|
8253de9f73 | ||
|
|
1251df286e | ||
|
|
fff90804c0 | ||
|
|
8ebaf7b4ee | ||
|
|
17cb70ec00 |
@@ -7,6 +7,7 @@ DISCORD_BOT_TOKEN=your-discord-bot-token
|
|||||||
DISCORD_CLIENT_ID=your-discord-client-id
|
DISCORD_CLIENT_ID=your-discord-client-id
|
||||||
DISCORD_GUILD_ID=your-discord-guild-id
|
DISCORD_GUILD_ID=your-discord-guild-id
|
||||||
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
DATABASE_URL=postgres://aurora:aurora@db:5432/aurora
|
||||||
|
ADMIN_TOKEN=Ffeg4hgsdfvsnyms,kmeuy64sy5y
|
||||||
|
|
||||||
VPS_USER=your-vps-user
|
VPS_USER=your-vps-user
|
||||||
VPS_HOST=your-vps-ip
|
VPS_HOST=your-vps-ip
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,5 +44,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
|
|
||||||
src/db/data
|
src/db/data
|
||||||
src/db/log
|
src/db/log
|
||||||
scratchpad/
|
scratchpad/
|
||||||
tickets/
|
|
||||||
@@ -8,6 +8,7 @@ import { startWebServerFromRoot } from "../web/src/server";
|
|||||||
await AuroraClient.loadCommands();
|
await AuroraClient.loadCommands();
|
||||||
await AuroraClient.loadEvents();
|
await AuroraClient.loadEvents();
|
||||||
await AuroraClient.deployCommands();
|
await AuroraClient.deployCommands();
|
||||||
|
await AuroraClient.setupSystemEvents();
|
||||||
|
|
||||||
console.log("🌐 Starting web server...");
|
console.log("🌐 Starting web server...");
|
||||||
|
|
||||||
|
|||||||
111
bot/lib/BotClient.test.ts
Normal file
111
bot/lib/BotClient.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ export class Client extends DiscordClient {
|
|||||||
|
|
||||||
commands: Collection<string, Command>;
|
commands: Collection<string, Command>;
|
||||||
lastCommandTimestamp: number | null = null;
|
lastCommandTimestamp: number | null = null;
|
||||||
|
maintenanceMode: boolean = false;
|
||||||
private commandLoader: CommandLoader;
|
private commandLoader: CommandLoader;
|
||||||
private eventLoader: EventLoader;
|
private eventLoader: EventLoader;
|
||||||
|
|
||||||
@@ -19,6 +20,60 @@ export class Client extends DiscordClient {
|
|||||||
this.eventLoader = new EventLoader(this);
|
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) {
|
async loadCommands(reload: boolean = false) {
|
||||||
if (reload) {
|
if (reload) {
|
||||||
this.commands.clear();
|
this.commands.clear();
|
||||||
|
|||||||
74
bot/lib/clientStats.test.ts
Normal file
74
bot/lib/clientStats.test.ts
Normal 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
48
bot/lib/clientStats.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -56,4 +56,28 @@ describe("CommandHandler", () => {
|
|||||||
expect(executeError).toHaveBeenCalled();
|
expect(executeError).toHaveBeenCalled();
|
||||||
expect(AuroraClient.lastCommandTimestamp).toBeNull();
|
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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
|||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles slash command execution
|
* Handles slash command execution
|
||||||
@@ -17,6 +17,13 @@ export class CommandHandler {
|
|||||||
return;
|
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
|
// Ensure user exists in database
|
||||||
try {
|
try {
|
||||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
|
|||||||
@@ -241,3 +241,8 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearDraftSessions = () => {
|
||||||
|
draftSession.clear();
|
||||||
|
console.log("[ItemWizard] All draft item creation sessions cleared.");
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ import {
|
|||||||
bigserial,
|
bigserial,
|
||||||
check
|
check
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
import { relations, sql } from 'drizzle-orm';
|
import { relations, sql, type InferSelectModel } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export type User = InferSelectModel<typeof users>;
|
||||||
|
export type Transaction = InferSelectModel<typeof transactions>;
|
||||||
|
export type ModerationCase = InferSelectModel<typeof moderationCases>;
|
||||||
|
export type Item = InferSelectModel<typeof items>;
|
||||||
|
export type Inventory = InferSelectModel<typeof inventory>;
|
||||||
|
|
||||||
// --- TABLES ---
|
// --- TABLES ---
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const envSchema = z.object({
|
|||||||
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
DATABASE_URL: z.string().min(1, "Database URL is required"),
|
||||||
PORT: z.coerce.number().default(3000),
|
PORT: z.coerce.number().default(3000),
|
||||||
HOST: z.string().default("127.0.0.1"),
|
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);
|
const parsedEnv = envSchema.safeParse(process.env);
|
||||||
|
|||||||
21
shared/lib/events.ts
Normal file
21
shared/lib/events.ts
Normal 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;
|
||||||
66
shared/modules/admin/action.service.test.ts
Normal file
66
shared/modules/admin/action.service.test.ts
Normal 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)"
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
53
shared/modules/admin/action.service.ts
Normal file
53
shared/modules/admin/action.service.ts
Normal 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"}` };
|
||||||
|
}
|
||||||
|
};
|
||||||
292
shared/modules/dashboard/dashboard.service.test.ts
Normal file
292
shared/modules/dashboard/dashboard.service.test.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
mockSelect.mockImplementationOnce(() => ({
|
||||||
|
// @ts-ignore
|
||||||
|
from: mock(() => ({
|
||||||
|
where: mock(() => ({
|
||||||
|
groupBy: mock(() => ({
|
||||||
|
orderBy: mock(() => Promise.resolve([
|
||||||
|
{
|
||||||
|
hour: now.toISOString(),
|
||||||
|
transactions: "10",
|
||||||
|
commands: "5"
|
||||||
|
}
|
||||||
|
]))
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
const activity = await dashboardService.getActivityAggregation();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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([]))
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
232
shared/modules/dashboard/dashboard.service.ts
Normal file
232
shared/modules/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
|
import { users, transactions, moderationCases, inventory, 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";
|
||||||
|
|
||||||
|
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: User) => acc + (u.balance || 0n),
|
||||||
|
0n
|
||||||
|
);
|
||||||
|
|
||||||
|
const avgLevel = allUsers.length > 0
|
||||||
|
? Math.round(
|
||||||
|
allUsers.reduce((acc: number, u: User) => acc + (u.level || 1), 0) / allUsers.length
|
||||||
|
)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
const topStreak = allUsers.reduce(
|
||||||
|
(max: number, u: User) => 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) => ({
|
||||||
|
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) => ({
|
||||||
|
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 as string),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hourly activity aggregation for the last 24 hours
|
||||||
|
*/
|
||||||
|
getActivityAggregation: async (): Promise<ActivityData[]> => {
|
||||||
|
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Postgres aggregation
|
||||||
|
// We treat everything as a transaction.
|
||||||
|
// We treat everything except TRANSFER_IN as a 'command' (to avoid double counting transfers)
|
||||||
|
const result = await DrizzleClient
|
||||||
|
.select({
|
||||||
|
hour: sql<string>`date_trunc('hour', ${transactions.createdAt})`,
|
||||||
|
transactions: sql<string>`COUNT(*)`,
|
||||||
|
commands: sql<string>`COUNT(*) FILTER (WHERE ${transactions.type} != ${TransactionType.TRANSFER_IN})`
|
||||||
|
})
|
||||||
|
.from(transactions)
|
||||||
|
.where(gte(transactions.createdAt, twentyFourHoursAgo))
|
||||||
|
.groupBy(sql`1`)
|
||||||
|
.orderBy(sql`1`);
|
||||||
|
|
||||||
|
// Map into a record for easy lookups
|
||||||
|
const dataMap = new Map<string, { commands: number, transactions: number }>();
|
||||||
|
result.forEach(row => {
|
||||||
|
if (!row.hour) return;
|
||||||
|
const dateStr = new Date(row.hour).toISOString();
|
||||||
|
dataMap.set(dateStr, {
|
||||||
|
commands: Number(row.commands),
|
||||||
|
transactions: Number(row.transactions)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate the last 24 hours of data
|
||||||
|
const activity: ActivityData[] = [];
|
||||||
|
const current = new Date();
|
||||||
|
current.setHours(current.getHours(), 0, 0, 0);
|
||||||
|
|
||||||
|
for (let i = 23; i >= 0; i--) {
|
||||||
|
const h = new Date(current.getTime() - i * 60 * 60 * 1000);
|
||||||
|
const iso = h.toISOString();
|
||||||
|
const existing = dataMap.get(iso);
|
||||||
|
|
||||||
|
activity.push({
|
||||||
|
hour: iso,
|
||||||
|
commands: existing?.commands || 0,
|
||||||
|
transactions: existing?.transactions || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return activity;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 '🛡️';
|
||||||
|
}
|
||||||
|
}
|
||||||
83
shared/modules/dashboard/dashboard.types.ts
Normal file
83
shared/modules/dashboard/dashboard.types.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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>;
|
||||||
|
export const ActivityDataSchema = z.object({
|
||||||
|
hour: z.string(),
|
||||||
|
commands: z.number(),
|
||||||
|
transactions: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ActivityData = z.infer<typeof ActivityDataSchema>;
|
||||||
@@ -61,6 +61,14 @@ export const economyService = {
|
|||||||
description: `Transfer from ${fromUserId}`,
|
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 };
|
return { success: true, amount };
|
||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
@@ -149,6 +157,14 @@ export const economyService = {
|
|||||||
description: `Daily reward (Streak: ${streak})`,
|
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 };
|
return { claimed: true, amount: totalReward, streak, nextReadyAt, isWeekly: isWeeklyCurrent, weeklyBonus: weeklyBonusAmount };
|
||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -163,6 +163,11 @@ class LootdropService {
|
|||||||
return { success: false, error: "An error occurred while processing the reward." };
|
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();
|
export const lootdropService = new LootdropService();
|
||||||
|
|||||||
@@ -196,5 +196,10 @@ export const tradeService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tradeService.endSession(threadId);
|
tradeService.endSession(threadId);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSessions: () => {
|
||||||
|
sessions.clear();
|
||||||
|
console.log("[TradeService] All active trade sessions cleared.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
61
tickets/2026-01-08-dashboard-activity-charts.md
Normal file
61
tickets/2026-01-08-dashboard-activity-charts.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# DASH-003: Visual Analytics & Activity Charts
|
||||||
|
|
||||||
|
**Status:** Done
|
||||||
|
**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
|
||||||
|
- [x] No new tables.
|
||||||
|
- [x] Requires complex aggregation queries on the `transactions` table.
|
||||||
|
|
||||||
|
### API / Interface
|
||||||
|
- [x] `GET /api/stats/activity`: Returns an array of data points for the last 24 hours (hourly granularity).
|
||||||
|
- [x] 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. [x] **Given** a 24-hour history of transactions, **When** the dashboard loads, **Then** a line or area chart displays the command volume over time.
|
||||||
|
2. [x] **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. [x] **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
|
||||||
|
- [x] Step 1: Add an aggregation method to `dashboard.service.ts` to fetch hourly counts from the `transactions` table.
|
||||||
|
- [x] Step 2: Create the `/api/stats/activity` endpoint.
|
||||||
|
- [x] Step 3: Install a charting library (`recharts`).
|
||||||
|
- [x] Step 4: Implement the `ActivityChart` component into the middle column of the dashboard.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
Implemented a comprehensive activity analytics system for the Aurora dashboard:
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
- **Service Layer**: Added `getActivityAggregation` to `dashboard.service.ts`. It performs a hourly aggregation on the `transactions` table using Postgres `date_trunc` and `FILTER` clauses to differentiate between "commands" and "total transactions". Missing hours in the 24h window are automatically filled with zero-values.
|
||||||
|
- **API**: Implemented `GET /api/stats/activity` in `web/src/server.ts` with a 5-minute in-memory cache to maintain server performance.
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
- **Library**: Added `recharts` for high-performance SVG charting.
|
||||||
|
- **Hooks**: Created `use-activity-stats.ts` to manage the lifecycle and polling of analytics data.
|
||||||
|
- **Components**: Developed `ActivityChart.tsx` featuring:
|
||||||
|
- Premium glassmorphic styling (backdrop blur, subtle borders).
|
||||||
|
- Responsive `AreaChart` with brand-matching gradients.
|
||||||
|
- Custom glassmorphic tooltip with precise data point values.
|
||||||
|
- Smooth entry animations.
|
||||||
|
- **Integration**: Placed the new analytics card prominently in the `Dashboard.tsx` layout.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- **Unit Tests**: Added comprehensive test cases to `dashboard.service.test.ts` verifying the 24-point guaranteed response and correct data mapping.
|
||||||
|
- **Type Safety**: Passed `bun x tsc --noEmit` with zero errors.
|
||||||
|
- **Runtime**: All tests passing.
|
||||||
53
tickets/2026-01-08-dashboard-control-panel.md
Normal file
53
tickets/2026-01-08-dashboard-control-panel.md
Normal 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.
|
||||||
202
tickets/2026-01-08-dashboard-real-data-integration.md
Normal file
202
tickets/2026-01-08-dashboard-real-data-integration.md
Normal 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.
|
||||||
49
tickets/2026-01-08-real-time-dashboard-updates.md
Normal file
49
tickets/2026-01-08-real-time-dashboard-updates.md
Normal 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.
|
||||||
60
web/build.ts
60
web/build.ts
@@ -127,28 +127,46 @@ const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
|
|||||||
.filter(dir => !dir.includes("node_modules"));
|
.filter(dir => !dir.includes("node_modules"));
|
||||||
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
|
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
|
||||||
|
|
||||||
const result = await Bun.build({
|
const build = async () => {
|
||||||
entrypoints,
|
const result = await Bun.build({
|
||||||
outdir,
|
entrypoints,
|
||||||
plugins: [plugin],
|
outdir,
|
||||||
minify: true,
|
plugins: [plugin],
|
||||||
target: "browser",
|
minify: true,
|
||||||
sourcemap: "linked",
|
target: "browser",
|
||||||
define: {
|
sourcemap: "linked",
|
||||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
define: {
|
||||||
},
|
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||||
...cliConfig,
|
},
|
||||||
});
|
...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 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);
|
const buildTime = (end - start).toFixed(2);
|
||||||
|
|
||||||
console.log(`\n✅ Build completed in ${buildTime}ms\n`);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
79
web/bun.lock
79
web/bun.lock
@@ -18,6 +18,7 @@
|
|||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
|
"recharts": "^3.6.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -122,14 +123,40 @@
|
|||||||
|
|
||||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||||
|
|
||||||
|
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||||
|
|
||||||
|
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||||
|
|
||||||
|
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||||
|
|
||||||
|
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||||
|
|
||||||
|
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||||
|
|
||||||
|
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||||
|
|
||||||
|
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||||
|
|
||||||
|
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
|
||||||
|
|
||||||
|
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||||
|
|
||||||
|
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
|
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||||
|
|
||||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||||
|
|
||||||
"bun": ["bun@1.3.5", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.5", "@oven/bun-darwin-x64": "1.3.5", "@oven/bun-darwin-x64-baseline": "1.3.5", "@oven/bun-linux-aarch64": "1.3.5", "@oven/bun-linux-aarch64-musl": "1.3.5", "@oven/bun-linux-x64": "1.3.5", "@oven/bun-linux-x64-baseline": "1.3.5", "@oven/bun-linux-x64-musl": "1.3.5", "@oven/bun-linux-x64-musl-baseline": "1.3.5", "@oven/bun-windows-x64": "1.3.5", "@oven/bun-windows-x64-baseline": "1.3.5" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-c1YHIGUfgvYPJmLug5QiLzNWlX2Dg7X/67JWu1Va+AmMXNXzC/KQn2lgQ7rD+n1u1UqDpJMowVGGxTNpbPydNw=="],
|
"bun": ["bun@1.3.5", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.5", "@oven/bun-darwin-x64": "1.3.5", "@oven/bun-darwin-x64-baseline": "1.3.5", "@oven/bun-linux-aarch64": "1.3.5", "@oven/bun-linux-aarch64-musl": "1.3.5", "@oven/bun-linux-x64": "1.3.5", "@oven/bun-linux-x64-baseline": "1.3.5", "@oven/bun-linux-x64-musl": "1.3.5", "@oven/bun-linux-x64-musl-baseline": "1.3.5", "@oven/bun-windows-x64": "1.3.5", "@oven/bun-windows-x64-baseline": "1.3.5" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-c1YHIGUfgvYPJmLug5QiLzNWlX2Dg7X/67JWu1Va+AmMXNXzC/KQn2lgQ7rD+n1u1UqDpJMowVGGxTNpbPydNw=="],
|
||||||
@@ -146,16 +173,52 @@
|
|||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||||
|
|
||||||
|
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||||
|
|
||||||
|
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||||
|
|
||||||
|
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
|
||||||
|
|
||||||
|
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||||
|
|
||||||
|
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||||
|
|
||||||
|
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||||
|
|
||||||
|
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||||
|
|
||||||
|
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||||
|
|
||||||
|
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||||
|
|
||||||
|
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||||
|
|
||||||
|
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||||
|
|
||||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
|
|
||||||
|
"es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="],
|
||||||
|
|
||||||
|
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||||
|
|
||||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||||
|
|
||||||
|
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||||
|
|
||||||
|
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||||
|
|
||||||
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
|
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
|
||||||
|
|
||||||
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
||||||
|
|
||||||
|
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
||||||
|
|
||||||
|
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
|
||||||
|
|
||||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||||
|
|
||||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||||
@@ -166,6 +229,14 @@
|
|||||||
|
|
||||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
|
"recharts": ["recharts@3.6.0", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg=="],
|
||||||
|
|
||||||
|
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
|
||||||
|
|
||||||
|
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||||
|
|
||||||
|
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||||
@@ -174,6 +245,8 @@
|
|||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
|
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||||
@@ -184,6 +257,10 @@
|
|||||||
|
|
||||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||||
|
|
||||||
|
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||||
|
|
||||||
|
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
@@ -197,5 +274,7 @@
|
|||||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||||
|
|
||||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@reduxjs/toolkit/immer": ["immer@11.1.3", "", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
|
"recharts": "^3.6.0",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
125
web/src/components/ActivityChart.tsx
Normal file
125
web/src/components/ActivityChart.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { ActivityData } from '../hooks/use-activity-stats';
|
||||||
|
|
||||||
|
interface ActivityChartProps {
|
||||||
|
data: ActivityData[];
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass p-3 rounded-lg border border-white/10 text-sm shadow-xl animate-in fade-in zoom-in duration-200">
|
||||||
|
<p className="font-semibold text-white/90 border-b border-white/10 pb-1 mb-2">
|
||||||
|
{data.displayTime}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-primary font-medium">Commands</span>
|
||||||
|
<span className="font-mono">{payload[0].value}</span>
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-[var(--chart-2)] font-medium">Transactions</span>
|
||||||
|
<span className="font-mono">{payload[1].value}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ActivityChart: React.FC<ActivityChartProps> = ({ data, loading }) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[300px] flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||||
|
<p className="text-muted-foreground animate-pulse text-sm">Aggregating stats...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format hour for XAxis (e.g., "HH:00")
|
||||||
|
const chartData = data.map(item => ({
|
||||||
|
...item,
|
||||||
|
displayTime: new Date(item.hour).getHours() + ':00'
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[300px] mt-4">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorCommands" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-primary)" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-primary)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="colorTransactions" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--chart-2)" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="var(--chart-2)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
stroke="rgba(255,255,255,0.05)"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="hour"
|
||||||
|
fontSize={10}
|
||||||
|
tickFormatter={(str) => {
|
||||||
|
const date = new Date(str);
|
||||||
|
return date.getHours() % 4 === 0 ? `${date.getHours()}:00` : '';
|
||||||
|
}}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
minTickGap={30}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
fontSize={10}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
width={40}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="commands"
|
||||||
|
stroke="var(--color-primary)"
|
||||||
|
strokeWidth={2}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorCommands)"
|
||||||
|
animationDuration={1500}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="transactions"
|
||||||
|
stroke="var(--chart-2)"
|
||||||
|
strokeWidth={2}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorTransactions)"
|
||||||
|
animationDuration={1500}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
|
import { LayoutDashboard, Settings, Activity } from "lucide-react";
|
||||||
import { LayoutDashboard, Settings, Activity, Server, Zap } from "lucide-react";
|
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import { useDashboardStats } from "@/hooks/use-dashboard-stats";
|
||||||
|
|
||||||
// Menu items.
|
// Menu items.
|
||||||
const items = [
|
const items = [
|
||||||
@@ -36,37 +36,52 @@ const items = [
|
|||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { stats } = useDashboardStats();
|
||||||
|
|
||||||
|
const botName = stats?.bot?.name || "Aurora";
|
||||||
|
const botAvatar = stats?.bot?.avatarUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar className="glass-sidebar border-r border-white/5">
|
||||||
<SidebarHeader>
|
<SidebarHeader className="p-4">
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton size="lg" asChild>
|
<SidebarMenuButton size="lg" asChild className="hover:bg-white/5 transition-all duration-300 rounded-xl">
|
||||||
<Link to="/">
|
<Link to="/" className="flex items-center gap-3">
|
||||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
<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">
|
||||||
<Zap className="size-4" />
|
{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>
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
<div className="flex flex-col gap-0 leading-none">
|
||||||
<span className="font-semibold">Aurora</span>
|
<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="">v1.0.0</span>
|
<span className="text-[10px] uppercase tracking-widest text-primary font-bold">Admin Portal</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent className="px-2">
|
||||||
<SidebarGroup>
|
<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>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu className="gap-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton asChild isActive={location.pathname === item.url}>
|
<SidebarMenuButton
|
||||||
<Link to={item.url}>
|
asChild
|
||||||
<item.icon />
|
isActive={location.pathname === item.url}
|
||||||
<span>{item.title}</span>
|
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>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
@@ -75,16 +90,16 @@ export function AppSidebar() {
|
|||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter className="p-4 border-t border-white/5">
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton size="lg">
|
<SidebarMenuButton size="lg" className="hover:bg-white/5 rounded-xl transition-colors">
|
||||||
<div className="bg-muted flex aspect-square size-8 items-center justify-center rounded-lg">
|
<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-xs font-bold">U</span>
|
<span className="text-sm font-bold text-primary italic">A</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
<div className="flex flex-col gap-0.5 leading-none ml-2">
|
||||||
<span className="font-semibold">User</span>
|
<span className="font-bold text-sm text-white/90">Administrator</span>
|
||||||
<span className="text-xs text-muted-foreground">Admin</span>
|
<span className="text-[10px] text-white/40 font-medium">Session Active</span>
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
126
web/src/components/ControlPanel.tsx
Normal file
126
web/src/components/ControlPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
web/src/hooks/use-activity-stats.ts
Normal file
55
web/src/hooks/use-activity-stats.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export interface ActivityData {
|
||||||
|
hour: string;
|
||||||
|
commands: number;
|
||||||
|
transactions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseActivityStatsResult {
|
||||||
|
data: ActivityData[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to fetch hourly activity data for charts.
|
||||||
|
* Data is cached on the server for 5 minutes.
|
||||||
|
*/
|
||||||
|
export function useActivityStats(): UseActivityStatsResult {
|
||||||
|
const [data, setData] = useState<ActivityData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
const jsonData = await response.json();
|
||||||
|
setData(jsonData);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch activity stats:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to fetch activity");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchActivity();
|
||||||
|
|
||||||
|
// Refresh every 5 minutes to match server cache
|
||||||
|
const interval = setInterval(fetchActivity, 5 * 60 * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data, loading, error, refresh: fetchActivity };
|
||||||
|
}
|
||||||
132
web/src/hooks/use-dashboard-stats.ts
Normal file
132
web/src/hooks/use-dashboard-stats.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -7,21 +7,28 @@ import { Separator } from "../components/ui/separator";
|
|||||||
export function DashboardLayout() {
|
export function DashboardLayout() {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<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 />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset className="bg-transparent">
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
<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" />
|
<SidebarTrigger className="-ml-1 hover:bg-white/5 transition-colors" />
|
||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
<Separator orientation="vertical" className="mx-4 h-4 bg-white/10" />
|
||||||
<div className="flex items-center gap-2 px-4">
|
<div className="flex items-center gap-2">
|
||||||
{/* Breadcrumbs could go here */}
|
<h1 className="text-lg font-semibold tracking-tight text-glow">Dashboard</h1>
|
||||||
<h1 className="text-lg font-semibold">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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
<main className="flex flex-1 flex-col gap-6 p-6">
|
||||||
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min p-4">
|
<div className="flex-1 rounded-2xl md:min-h-min">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,105 +6,186 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Activity, Server, Users, Zap } from "lucide-react";
|
import { Activity, Server, Users, Zap } from "lucide-react";
|
||||||
|
import { useDashboardStats } from "@/hooks/use-dashboard-stats";
|
||||||
|
import { useActivityStats } from "@/hooks/use-activity-stats";
|
||||||
|
import { ControlPanel } from "@/components/ControlPanel";
|
||||||
|
import { ActivityChart } from "@/components/ActivityChart";
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
|
const { stats, loading, error } = useDashboardStats();
|
||||||
|
const { data: activityData, loading: activityLoading } = useActivityStats();
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8 animate-in fade-in duration-700">
|
||||||
<div>
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
<h2 className="text-4xl font-extrabold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white via-white to-white/40">
|
||||||
<p className="text-muted-foreground">Overview of your bot's activity and performance.</p>
|
{stats.bot.name} Overview
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/40 font-medium">Monitoring real-time activity and core bot metrics.</p>
|
||||||
</div>
|
</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 */}
|
{/* Metric Cards */}
|
||||||
<Card>
|
{[
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
{ title: "Active Users", value: stats.users.active.toLocaleString(), label: `${stats.users.total.toLocaleString()} total registered`, icon: Users, color: "from-purple-500 to-pink-500" },
|
||||||
<CardTitle className="text-sm font-medium">Total Servers</CardTitle>
|
{ title: "Commands registered", value: stats.commands.total, label: "Total system capabilities", icon: Zap, color: "from-yellow-500 to-orange-500" },
|
||||||
<Server className="h-4 w-4 text-muted-foreground" />
|
{ title: "Avg Latency", value: `${stats.ping.avg}ms`, label: "WebSocket heartbeat", icon: Activity, color: "from-emerald-500 to-teal-500" },
|
||||||
</CardHeader>
|
].map((metric, i) => (
|
||||||
<CardContent>
|
<Card key={i} className="glass group hover:border-primary/50 transition-all duration-300 hover:scale-[1.02]">
|
||||||
<div className="text-2xl font-bold">12</div>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<p className="text-xs text-muted-foreground">+2 from last month</p>
|
<CardTitle className="text-xs font-bold uppercase tracking-widest text-white/50">{metric.title}</CardTitle>
|
||||||
</CardContent>
|
<div className={`p-2 rounded-lg bg-gradient-to-br ${metric.color} bg-opacity-10 group-hover:scale-110 transition-transform duration-300`}>
|
||||||
</Card>
|
<metric.icon className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
<Card>
|
</CardHeader>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardContent>
|
||||||
<CardTitle className="text-sm font-medium">Active Users</CardTitle>
|
<div className="text-3xl font-bold tracking-tight mb-1">{metric.value}</div>
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<p className="text-xs font-medium text-white/30">{metric.label}</p>
|
||||||
</CardHeader>
|
</CardContent>
|
||||||
<CardContent>
|
</Card>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
{/* Activity Chart Section */}
|
||||||
<Card className="col-span-4">
|
<Card className="glass border-white/5 overflow-hidden">
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle>Activity Overview</CardTitle>
|
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<div className="h-5 w-1 bg-emerald-500 rounded-full" />
|
||||||
|
Live Activity Analytics
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-white/40 font-medium tracking-tight">Hourly command and transaction volume across the network (last 24h)</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActivityChart data={activityData} loading={activityLoading} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<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">
|
<div className="grid gap-8">
|
||||||
Chart Placeholder
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="col-span-3">
|
<div className="col-span-3 flex flex-col gap-6">
|
||||||
<CardHeader>
|
{/* Administrative Control Panel */}
|
||||||
<CardTitle>Recent Events</CardTitle>
|
<ControlPanel maintenanceMode={stats.maintenanceMode} />
|
||||||
<CardDescription>Latest system and bot events.</CardDescription>
|
|
||||||
</CardHeader>
|
{/* Recent Events Feed */}
|
||||||
<CardContent>
|
<Card className="glass border-white/5 overflow-hidden flex-1">
|
||||||
<div className="space-y-4">
|
<CardHeader className="bg-white/[0.02] border-b border-white/5">
|
||||||
<div className="flex items-center">
|
<CardTitle className="text-xl font-bold">Recent Events</CardTitle>
|
||||||
<div className="w-2 h-2 rounded-full bg-emerald-500 mr-2" />
|
<CardDescription className="text-white/30">Live system activity feed</CardDescription>
|
||||||
<div className="space-y-1">
|
</CardHeader>
|
||||||
<p className="text-sm font-medium leading-none">New guild joined</p>
|
<CardContent className="p-0">
|
||||||
<p className="text-sm text-muted-foreground">2 minutes ago</p>
|
<div className="divide-y divide-white/5">
|
||||||
</div>
|
{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>
|
||||||
<div className="flex items-center">
|
{stats.recentEvents.length > 0 && (
|
||||||
<div className="w-2 h-2 rounded-full bg-destructive mr-2" />
|
<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">
|
||||||
<div className="space-y-1">
|
View Event Logs
|
||||||
<p className="text-sm font-medium leading-none">Error in verify command</p>
|
</button>
|
||||||
<p className="text-sm text-muted-foreground">15 minutes ago</p>
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<div className="flex items-center">
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
143
web/src/server.test.ts
Normal file
143
web/src/server.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,17 +51,139 @@ 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;
|
||||||
|
|
||||||
|
// Cache for activity stats (heavy aggregation)
|
||||||
|
let activityPromise: Promise<import("@shared/modules/dashboard/dashboard.types").ActivityData[]> | null = null;
|
||||||
|
let lastActivityFetch: number = 0;
|
||||||
|
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
const server = serve({
|
const server = serve({
|
||||||
port,
|
port,
|
||||||
hostname,
|
hostname,
|
||||||
async fetch(req) {
|
async fetch(req, server) {
|
||||||
const url = new URL(req.url);
|
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
|
// API routes
|
||||||
if (url.pathname === "/api/health") {
|
if (url.pathname === "/api/health") {
|
||||||
return Response.json({ status: "ok", timestamp: Date.now() });
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if (activityPromise && (now - lastActivityFetch < ACTIVITY_CACHE_TTL)) {
|
||||||
|
const data = await activityPromise;
|
||||||
|
return Response.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, trigger a new fetch (deduplicated by the promise)
|
||||||
|
if (!activityPromise || (now - lastActivityFetch >= ACTIVITY_CACHE_TTL)) {
|
||||||
|
activityPromise = (async () => {
|
||||||
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||||
|
return await dashboardService.getActivityAggregation();
|
||||||
|
})();
|
||||||
|
lastActivityFetch = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activity = await activityPromise;
|
||||||
|
return Response.json(activity);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching activity stats:", error);
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Failed to fetch activity 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
|
// Static File Serving
|
||||||
let pathName = url.pathname;
|
let pathName = url.pathname;
|
||||||
if (pathName === "/") pathName = "/index.html";
|
if (pathName === "/") pathName = "/index.html";
|
||||||
@@ -77,24 +199,155 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
|
|
||||||
const fileRef = Bun.file(safePath);
|
const fileRef = Bun.file(safePath);
|
||||||
if (await fileRef.exists()) {
|
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);
|
return new Response(fileRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SPA Fallback: Serve index.html for unknown non-file routes
|
// 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 parts = pathName.split("/");
|
||||||
const lastPart = parts[parts.length - 1];
|
const lastPart = parts[parts.length - 1];
|
||||||
if (lastPart?.includes(".")) {
|
|
||||||
|
// If it's a direct request for a missing file (has dot), return 404
|
||||||
|
// EXCEPT for index.html which is our fallback entry point
|
||||||
|
if (lastPart?.includes(".") && lastPart !== "index.html") {
|
||||||
return new Response("Not Found", { status: 404 });
|
return new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(Bun.file(join(distDir, "index.html")));
|
const indexFile = Bun.file(join(distDir, "index.html"));
|
||||||
|
if (!(await indexFile.exists())) {
|
||||||
|
if (isDev) {
|
||||||
|
return new Response("<html><body><h1>🛠️ Dashboard is building...</h1><p>Please refresh in a few seconds. The bundler is currently generating the static assets.</p><script>setTimeout(() => location.reload(), 2000);</script></body></html>", {
|
||||||
|
status: 503,
|
||||||
|
headers: { "Content-Type": "text/html" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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>`);
|
||||||
|
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,
|
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}`;
|
const url = `http://${hostname}:${port}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -104,6 +357,9 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
if (buildProcess) {
|
if (buildProcess) {
|
||||||
buildProcess.kill();
|
buildProcess.kill();
|
||||||
}
|
}
|
||||||
|
if (statsBroadcastInterval) {
|
||||||
|
clearInterval(statsBroadcastInterval);
|
||||||
|
}
|
||||||
server.stop(true);
|
server.stop(true);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,79 +42,65 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 1rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.12 0.02 260);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.98 0.01 260);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(0.16 0.03 260 / 0.5);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.98 0.01 260);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(0.14 0.02 260 / 0.8);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.98 0.01 260);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.65 0.18 250);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(1 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.25 0.04 260);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.98 0.01 260);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.2 0.03 260 / 0.6);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.7 0.02 260);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.3 0.05 250 / 0.4);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.98 0.01 260);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.6 0.18 25);
|
||||||
--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);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 5%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.65 0.18 250 / 50%);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.6 0.18 250);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.7 0.15 160);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.8 0.12 80);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.6 0.2 300);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.6 0.25 20);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.14 0.02 260 / 0.6);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.98 0.01 260);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.65 0.18 250);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(1 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(1 0 0 / 5%);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.98 0.01 260);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 8%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.65 0.18 250 / 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user