feat: M4-01 — enhance admin dashboard with real metrics + caching
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 40s
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 40s
- Query real data: today's AI calls, cost, import count, failed tasks, active users, upcoming expirations - Add Redis caching (TTL 120s) for dashboard stats - Add POST /admin-api/dashboard/refresh endpoint - Fix ignoreDeprecations in tsconfig (ts-jest incompatible) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
783df02a26
commit
fb1c6fd216
@ -1,11 +1,12 @@
|
|||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, HttpCode, HttpStatus, UseGuards } from '@nestjs/common';
|
||||||
import { AdminDashboardService } from './admin-dashboard.service';
|
import { AdminDashboardService } from './admin-dashboard.service';
|
||||||
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
|
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
|
||||||
|
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
|
||||||
|
|
||||||
@ApiTags('admin-dashboard')
|
@ApiTags('admin-dashboard')
|
||||||
@Controller('admin-api/dashboard')
|
@Controller('admin-api/dashboard')
|
||||||
@UseGuards(AdminAuthGuard)
|
@UseGuards(AdminAuthGuard, AdminRolesGuard)
|
||||||
export class AdminDashboardController {
|
export class AdminDashboardController {
|
||||||
constructor(private readonly adminDashboardService: AdminDashboardService) {}
|
constructor(private readonly adminDashboardService: AdminDashboardService) {}
|
||||||
|
|
||||||
@ -15,4 +16,12 @@ export class AdminDashboardController {
|
|||||||
async getStats() {
|
async getStats() {
|
||||||
return this.adminDashboardService.getStats();
|
return this.adminDashboardService.getStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: '刷新仪表盘缓存' })
|
||||||
|
async refresh() {
|
||||||
|
return this.adminDashboardService.refreshCache();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AdminDashboardController } from './admin-dashboard.controller';
|
import { AdminDashboardController } from './admin-dashboard.controller';
|
||||||
import { AdminDashboardService } from './admin-dashboard.service';
|
import { AdminDashboardService } from './admin-dashboard.service';
|
||||||
import { AdminAuthModule } from '../admin-auth/admin-auth.module';
|
import { AdminAuthModule } from '../admin-auth/admin-auth.module';
|
||||||
|
import { RedisModule } from '../../infrastructure/redis/redis.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AdminAuthModule],
|
imports: [AdminAuthModule, RedisModule],
|
||||||
controllers: [AdminDashboardController],
|
controllers: [AdminDashboardController],
|
||||||
providers: [AdminDashboardService],
|
providers: [AdminDashboardService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,35 +1,77 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Optional } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||||
|
|
||||||
|
const CACHE_KEY = 'admin:dashboard:stats';
|
||||||
|
const CACHE_TTL = 120; // 2 min
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminDashboardService {
|
export class AdminDashboardService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Optional() private readonly redis?: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async getStats() {
|
async getStats() {
|
||||||
|
// Try cache first
|
||||||
|
if (this.redis) {
|
||||||
|
try {
|
||||||
|
const cached = await this.redis.get(CACHE_KEY);
|
||||||
|
if (cached) return JSON.parse(cached);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||||
const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1);
|
const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const weekAgo = new Date(Date.now() - 7 * 86400000);
|
||||||
|
|
||||||
try {
|
const [
|
||||||
const [totalUsers, newUsersToday, totalKnowledgeBases, totalFiles] = await Promise.all([
|
totalUsers, newUsersToday, activeUsersToday,
|
||||||
this.prisma.user.count({ where: { deletedAt: null } }).catch(() => 0),
|
totalKnowledgeBases, newKbsToday,
|
||||||
this.prisma.user.count({ where: { createdAt: { gte: today, lt: tomorrow }, deletedAt: null } }).catch(() => 0),
|
totalFiles, totalStorageBytes,
|
||||||
this.prisma.knowledgeBase.count({ where: { deletedAt: null } }).catch(() => 0),
|
todayImportCount, failedImportCount,
|
||||||
this.prisma.uploadedFile.count().catch(() => 0),
|
todayAiCalls, todayAiCost,
|
||||||
]);
|
failedTasks,
|
||||||
|
upcomingExpirations,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.prisma.user.count({ where: { deletedAt: null } }).catch(() => 0),
|
||||||
|
this.prisma.user.count({ where: { createdAt: { gte: today, lt: tomorrow }, deletedAt: null } }).catch(() => 0),
|
||||||
|
this.prisma.user.count({ where: { lastLoginAt: { gte: today, lt: tomorrow }, deletedAt: null } }).catch(() => 0),
|
||||||
|
this.prisma.knowledgeBase.count({ where: { deletedAt: null } }).catch(() => 0),
|
||||||
|
this.prisma.knowledgeBase.count({ where: { createdAt: { gte: today, lt: tomorrow }, deletedAt: null } }).catch(() => 0),
|
||||||
|
this.prisma.uploadedFile.count().catch(() => 0),
|
||||||
|
this.prisma.uploadedFile.aggregate({ _sum: { sizeBytes: true } }).then(r => Number(r._sum.sizeBytes ?? 0)).catch(() => 0),
|
||||||
|
this.prisma.documentImport.count({ where: { createdAt: { gte: today, lt: tomorrow } } }).catch(() => 0),
|
||||||
|
this.prisma.documentImport.count({ where: { status: 'failed' } }).catch(() => 0),
|
||||||
|
this.prisma.aiUsageLog.count({ where: { createdAt: { gte: today, lt: tomorrow } } }).catch(() => 0),
|
||||||
|
this.prisma.costDailySummary.aggregate({ where: { date: { gte: today, lt: tomorrow } }, _sum: { cost: true } }).then(r => Number(r._sum.cost ?? 0)).catch(() => 0),
|
||||||
|
this.prisma.taskLog.count({ where: { status: 'failed', createdAt: { gte: weekAgo } } }).catch(() => 0),
|
||||||
|
this.prisma.secretRecord.count({ where: { expiresAt: { lte: new Date(Date.now() + 30 * 86400000), gt: new Date() } } }).catch(() => 0),
|
||||||
|
]);
|
||||||
|
|
||||||
// Skip AI stats and activity tables that might not exist
|
const result = {
|
||||||
const totalAiCallsToday = 0;
|
totalUsers, newUsersToday, activeUsersToday,
|
||||||
const activeUsersToday = 0;
|
totalKnowledgeBases, newKbsToday,
|
||||||
const newKbsToday = 0;
|
totalFiles, totalStorageBytes,
|
||||||
|
todayImportCount, failedImportCount,
|
||||||
|
todayAiCalls, todayAiCost: Math.round(todayAiCost * 100) / 100,
|
||||||
|
failedTasks,
|
||||||
|
upcomingExpirations,
|
||||||
|
serverSummary: null, // filled by server monitor
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
// Cache result
|
||||||
totalUsers, newUsersToday, activeUsersToday,
|
if (this.redis) {
|
||||||
totalKnowledgeBases, newKbsToday, totalAiCallsToday,
|
try { await this.redis.set(CACHE_KEY, JSON.stringify(result), CACHE_TTL); } catch {}
|
||||||
totalFiles, totalStorageBytes: 0,
|
|
||||||
userTrend: [], aiCallTrend: [],
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { totalUsers: 0, newUsersToday: 0, activeUsersToday: 0, totalKnowledgeBases: 0, newKbsToday: 0, totalAiCallsToday: 0, totalFiles: 0, totalStorageBytes: 0, userTrend: [], aiCallTrend: [] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshCache() {
|
||||||
|
if (this.redis) {
|
||||||
|
try { await this.redis.del(CACHE_KEY); } catch {}
|
||||||
|
}
|
||||||
|
return this.getStats();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,6 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"ignoreDeprecations": "6.0",
|
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user