forked from syntaxbullet/AuroraBot-discord
feat: integrate real data into dashboard
- Created dashboard service with DB queries for users, economy, events - Added client stats provider with 30s caching for Discord metrics - Implemented /api/stats endpoint aggregating all dashboard data - Created useDashboardStats React hook with auto-refresh - Updated Dashboard.tsx to display real data with loading/error states - Added comprehensive test coverage (11 tests passing) - Replaced all mock values with live Discord and database metrics
This commit is contained in:
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/log
|
||||
scratchpad/
|
||||
tickets/
|
||||
scratchpad/
|
||||
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);
|
||||
});
|
||||
});
|
||||
44
bot/lib/clientStats.ts
Normal file
44
bot/lib/clientStats.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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 = {
|
||||
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;
|
||||
}
|
||||
153
shared/modules/dashboard/dashboard.service.test.ts
Normal file
153
shared/modules/dashboard/dashboard.service.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
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.resolve([])),
|
||||
},
|
||||
moderationCases: {
|
||||
findMany: mock(() => 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(() => ({
|
||||
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(() => ({
|
||||
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 any);
|
||||
|
||||
mockQuery.moderationCases.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
type: "warn",
|
||||
username: "user2",
|
||||
reason: "Test",
|
||||
createdAt: earlier,
|
||||
},
|
||||
] as any);
|
||||
|
||||
const events = await dashboardService.getRecentEvents(10);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
// Should be sorted by timestamp (newest first)
|
||||
expect(events[0]?.timestamp.getTime()).toBeGreaterThanOrEqual(
|
||||
events[1]?.timestamp.getTime() ?? 0
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
157
shared/modules/dashboard/dashboard.service.ts
Normal file
157
shared/modules/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||
import { users, transactions, moderationCases, inventory } from "@db/schema";
|
||||
import { desc, sql, and, gte } from "drizzle-orm";
|
||||
import type { RecentEvent } from "./dashboard.types";
|
||||
|
||||
export const dashboardService = {
|
||||
/**
|
||||
* Get count of active users from database
|
||||
*/
|
||||
getActiveUserCount: async (): Promise<number> => {
|
||||
const result = await DrizzleClient
|
||||
.select({ count: sql<string>`COUNT(*)` })
|
||||
.from(users)
|
||||
.where(sql`${users.isActive} = true`);
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get total user count
|
||||
*/
|
||||
getTotalUserCount: async (): Promise<number> => {
|
||||
const result = await DrizzleClient
|
||||
.select({ count: sql<string>`COUNT(*)` })
|
||||
.from(users);
|
||||
|
||||
return Number(result[0]?.count || 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get economy statistics
|
||||
*/
|
||||
getEconomyStats: async (): Promise<{
|
||||
totalWealth: bigint;
|
||||
avgLevel: number;
|
||||
topStreak: number;
|
||||
}> => {
|
||||
const allUsers = await DrizzleClient.select().from(users);
|
||||
|
||||
const totalWealth = allUsers.reduce(
|
||||
(acc: bigint, u: any) => acc + (u.balance || 0n),
|
||||
0n
|
||||
);
|
||||
|
||||
const avgLevel = allUsers.length > 0
|
||||
? Math.round(
|
||||
allUsers.reduce((acc: number, u: any) => acc + (u.level || 1), 0) / allUsers.length
|
||||
)
|
||||
: 1;
|
||||
|
||||
const topStreak = allUsers.reduce(
|
||||
(max: number, u: any) => Math.max(max, u.dailyStreak || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return { totalWealth, avgLevel, topStreak };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get total items in circulation
|
||||
*/
|
||||
getTotalItems: async (): Promise<number> => {
|
||||
const result = await DrizzleClient
|
||||
.select({ total: sql<string>`COALESCE(SUM(${inventory.quantity}), 0)` })
|
||||
.from(inventory);
|
||||
|
||||
return Number(result[0]?.total || 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get recent transactions as events (last 24 hours)
|
||||
*/
|
||||
getRecentTransactions: async (limit: number = 10): Promise<RecentEvent[]> => {
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const recentTx = await DrizzleClient.query.transactions.findMany({
|
||||
limit,
|
||||
orderBy: [desc(transactions.createdAt)],
|
||||
where: gte(transactions.createdAt, oneDayAgo),
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
return recentTx.map((tx: any) => ({
|
||||
type: 'info' as const,
|
||||
message: `${tx.user?.username || 'Unknown'}: ${tx.description || 'Transaction'}`,
|
||||
timestamp: tx.createdAt || new Date(),
|
||||
icon: getTransactionIcon(tx.type),
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get recent moderation cases as events (last 24 hours)
|
||||
*/
|
||||
getRecentModerationCases: async (limit: number = 10): Promise<RecentEvent[]> => {
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const recentCases = await DrizzleClient.query.moderationCases.findMany({
|
||||
limit,
|
||||
orderBy: [desc(moderationCases.createdAt)],
|
||||
where: gte(moderationCases.createdAt, oneDayAgo),
|
||||
});
|
||||
|
||||
return recentCases.map((modCase: any) => ({
|
||||
type: modCase.type === 'warn' || modCase.type === 'ban' ? 'error' : 'info',
|
||||
message: `${modCase.type.toUpperCase()}: ${modCase.username} - ${modCase.reason}`,
|
||||
timestamp: modCase.createdAt || new Date(),
|
||||
icon: getModerationIcon(modCase.type),
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get combined recent events (transactions + moderation)
|
||||
*/
|
||||
getRecentEvents: async (limit: number = 10): Promise<RecentEvent[]> => {
|
||||
const [txEvents, modEvents] = await Promise.all([
|
||||
dashboardService.getRecentTransactions(limit),
|
||||
dashboardService.getRecentModerationCases(limit),
|
||||
]);
|
||||
|
||||
// Combine and sort by timestamp
|
||||
const allEvents = [...txEvents, ...modEvents]
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||
.slice(0, limit);
|
||||
|
||||
return allEvents;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 '🛡️';
|
||||
}
|
||||
}
|
||||
41
shared/modules/dashboard/dashboard.types.ts
Normal file
41
shared/modules/dashboard/dashboard.types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface DashboardStats {
|
||||
guilds: {
|
||||
count: number;
|
||||
changeFromLastMonth?: number;
|
||||
};
|
||||
users: {
|
||||
active: number;
|
||||
total: number;
|
||||
changePercentFromLastMonth?: number;
|
||||
};
|
||||
commands: {
|
||||
total: number;
|
||||
changePercentFromLastMonth?: number;
|
||||
};
|
||||
ping: {
|
||||
avg: number;
|
||||
changeFromLastHour?: number;
|
||||
};
|
||||
economy: {
|
||||
totalWealth: string; // bigint as string for JSON
|
||||
avgLevel: number;
|
||||
topStreak: number;
|
||||
};
|
||||
recentEvents: RecentEvent[];
|
||||
}
|
||||
|
||||
export interface RecentEvent {
|
||||
type: 'success' | 'error' | 'info';
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ClientStats {
|
||||
guilds: number;
|
||||
ping: number;
|
||||
cachedUsers: number;
|
||||
commandsRegistered: number;
|
||||
uptime: number;
|
||||
lastCommandTimestamp: number | null;
|
||||
}
|
||||
168
tickets/2026-01-08-dashboard-real-data-integration.md
Normal file
168
tickets/2026-01-08-dashboard-real-data-integration.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# DASH-001: Dashboard Real Data Integration
|
||||
|
||||
**Status:** Draft
|
||||
**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)
|
||||
78
web/src/hooks/use-dashboard-stats.ts
Normal file
78
web/src/hooks/use-dashboard-stats.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface DashboardStats {
|
||||
guilds: {
|
||||
count: number;
|
||||
};
|
||||
users: {
|
||||
active: number;
|
||||
total: number;
|
||||
};
|
||||
commands: {
|
||||
total: number;
|
||||
};
|
||||
ping: {
|
||||
avg: number;
|
||||
};
|
||||
economy: {
|
||||
totalWealth: string;
|
||||
avgLevel: number;
|
||||
topStreak: number;
|
||||
};
|
||||
recentEvents: Array<{
|
||||
type: 'success' | 'error' | 'info';
|
||||
message: string;
|
||||
timestamp: string;
|
||||
icon?: string;
|
||||
}>;
|
||||
uptime: number;
|
||||
lastCommandTimestamp: number | null;
|
||||
}
|
||||
|
||||
interface UseDashboardStatsResult {
|
||||
stats: DashboardStats | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to fetch and auto-refresh dashboard statistics
|
||||
* Polls the API every 30 seconds
|
||||
*/
|
||||
export function useDashboardStats(): UseDashboardStatsResult {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stats");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch dashboard stats:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch stats");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Initial fetch
|
||||
fetchStats();
|
||||
|
||||
// Set up polling every 30 seconds
|
||||
const interval = setInterval(fetchStats, 30000);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return { stats, loading, error };
|
||||
}
|
||||
@@ -6,8 +6,49 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Activity, Server, Users, Zap } from "lucide-react";
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats";
|
||||
|
||||
export function Dashboard() {
|
||||
const { stats, loading, error } = useDashboardStats();
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
||||
<p className="text-muted-foreground">Loading dashboard data...</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Loading...</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-8 w-20 bg-muted animate-pulse rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
|
||||
<p className="text-destructive">Error loading dashboard: {error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -23,8 +64,8 @@ export function Dashboard() {
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">12</div>
|
||||
<p className="text-xs text-muted-foreground">+2 from last month</p>
|
||||
<div className="text-2xl font-bold">{stats.guilds.count}</div>
|
||||
<p className="text-xs text-muted-foreground">Active guilds</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -34,19 +75,21 @@ export function Dashboard() {
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">1,234</div>
|
||||
<p className="text-xs text-muted-foreground">+10% from last month</p>
|
||||
<div className="text-2xl font-bold">{stats.users.active.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.users.total.toLocaleString()} total registered
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Commands Run</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">Commands</CardTitle>
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">12,345</div>
|
||||
<p className="text-xs text-muted-foreground">+5% from last month</p>
|
||||
<div className="text-2xl font-bold">{stats.commands.total}</div>
|
||||
<p className="text-xs text-muted-foreground">Registered commands</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -56,8 +99,8 @@ export function Dashboard() {
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">24ms</div>
|
||||
<p className="text-xs text-muted-foreground">+2ms from last hour</p>
|
||||
<div className="text-2xl font-bold">{stats.ping.avg}ms</div>
|
||||
<p className="text-xs text-muted-foreground">WebSocket latency</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -65,11 +108,25 @@ export function Dashboard() {
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Activity Overview</CardTitle>
|
||||
<CardTitle>Economy Overview</CardTitle>
|
||||
<CardDescription>Server economy statistics</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px] w-full bg-muted/20 flex items-center justify-center border-2 border-dashed border-muted rounded-md text-muted-foreground">
|
||||
Chart Placeholder
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Total Wealth</p>
|
||||
<p className="text-2xl font-bold">{BigInt(stats.economy.totalWealth).toLocaleString()} AU</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Average Level</p>
|
||||
<p className="text-xl font-bold">{stats.economy.avgLevel}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Top Streak</p>
|
||||
<p className="text-xl font-bold">{stats.economy.topStreak} days</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -81,27 +138,30 @@ export function Dashboard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 mr-2" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">New guild joined</p>
|
||||
<p className="text-sm text-muted-foreground">2 minutes ago</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-destructive mr-2" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Error in verify command</p>
|
||||
<p className="text-sm text-muted-foreground">15 minutes ago</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 mr-2" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Bot restarted</p>
|
||||
<p className="text-sm text-muted-foreground">1 hour ago</p>
|
||||
</div>
|
||||
</div>
|
||||
{stats.recentEvents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No recent events</p>
|
||||
) : (
|
||||
stats.recentEvents.slice(0, 5).map((event, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full mr-2 ${event.type === 'success'
|
||||
? 'bg-emerald-500'
|
||||
: event.type === 'error'
|
||||
? 'bg-destructive'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
/>
|
||||
<div className="space-y-1 flex-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{event.icon} {event.message}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(event.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -62,6 +62,58 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
||||
return Response.json({ status: "ok", timestamp: Date.now() });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/stats") {
|
||||
try {
|
||||
// Import services (dynamic to avoid circular deps)
|
||||
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
||||
const { getClientStats } = await import("../../bot/lib/clientStats");
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [clientStats, activeUsers, totalUsers, economyStats, recentEvents] = await Promise.all([
|
||||
Promise.resolve(getClientStats()),
|
||||
dashboardService.getActiveUserCount(),
|
||||
dashboardService.getTotalUserCount(),
|
||||
dashboardService.getEconomyStats(),
|
||||
dashboardService.getRecentEvents(10),
|
||||
]);
|
||||
|
||||
const stats = {
|
||||
guilds: {
|
||||
count: clientStats.guilds,
|
||||
},
|
||||
users: {
|
||||
active: activeUsers,
|
||||
total: totalUsers,
|
||||
},
|
||||
commands: {
|
||||
total: clientStats.commandsRegistered,
|
||||
},
|
||||
ping: {
|
||||
avg: clientStats.ping,
|
||||
},
|
||||
economy: {
|
||||
totalWealth: economyStats.totalWealth.toString(),
|
||||
avgLevel: economyStats.avgLevel,
|
||||
topStreak: economyStats.topStreak,
|
||||
},
|
||||
recentEvents: recentEvents.map(event => ({
|
||||
...event,
|
||||
timestamp: event.timestamp.toISOString(),
|
||||
})),
|
||||
uptime: clientStats.uptime,
|
||||
lastCommandTimestamp: clientStats.lastCommandTimestamp,
|
||||
};
|
||||
|
||||
return Response.json(stats);
|
||||
} catch (error) {
|
||||
console.error("Error fetching dashboard stats:", error);
|
||||
return Response.json(
|
||||
{ error: "Failed to fetch dashboard statistics" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Static File Serving
|
||||
let pathName = url.pathname;
|
||||
if (pathName === "/") pathName = "/index.html";
|
||||
|
||||
Reference in New Issue
Block a user