chenpei e864fc3249 fix: 系统性能面板改回浅色,并纠正内存和队列口径
内存改读应用容器上限,不再把整机 90% 当成素材服务快满了。
转码队列改看素材状态和租约,上传停住的会话单独标出。
2026-08-17 13:35:09 +08:00

355 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import os from 'os'
import { getAllSessions } from '@/lib/upload-sessions-redis'
import { transcodeQueue } from '@/lib/video-transcode-queue'
import { prisma } from '@/lib/prisma'
import { getNetworkSpeed } from '@/lib/network-bandwidth-monitor'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission } from '@/lib/department-permission'
import {
classifyTranscodeMaterial,
classifyUploadSession,
readProcessMemory,
} from '@/lib/system-performance'
const RATE_LIMIT_WINDOW_MS = 60 * 1000
const RATE_LIMIT_MAX_REQUESTS = 40
const MAX_VISIBLE_UPLOAD_SESSIONS = 100
const requestCounts = new Map<string, { count: number; resetAt: number }>()
function consumeRateLimit(userId: string): number | null {
const now = Date.now()
const current = requestCounts.get(userId)
if (!current || current.resetAt <= now) {
requestCounts.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
return null
}
if (current.count >= RATE_LIMIT_MAX_REQUESTS) {
return Math.max(1, Math.ceil((current.resetAt - now) / 1000))
}
current.count += 1
return null
}
// 性能监控 - 快速统计接口(用于悬停卡片)
// GET /api/performance/quick
export const dynamic = 'force-dynamic'
export const revalidate = 0
// 获取CPU使用率(正确的方法:需要两次采样)
async function getCpuUsage(): Promise<number> {
try {
const snapshot = () => os.cpus().map((cpu) => cpu.times)
const before = snapshot()
await new Promise((resolve) => setTimeout(resolve, 200))
const after = snapshot()
let totalDiff = 0
let idleDiff = 0
for (let index = 0; index < Math.min(before.length, after.length); index += 1) {
const previous = before[index]
const current = after[index]
if (!previous || !current) {
continue
}
const previousTotal =
previous.user + previous.nice + previous.sys + previous.idle + previous.irq
const currentTotal =
current.user + current.nice + current.sys + current.idle + current.irq
totalDiff += Math.max(0, currentTotal - previousTotal)
idleDiff += Math.max(0, current.idle - previous.idle)
}
if (totalDiff > 0) {
const usedDiff = Math.max(0, totalDiff - idleDiff)
const cpuUsage = (usedDiff / totalDiff) * 100
return Math.min(Math.max(Math.round(cpuUsage), 0), 100)
}
} catch (error) {
console.warn('[PerformanceQuick] Failed to sample CPU usage:', error)
}
// 降级方案:使用负载平均值估算(不准确,但总比没有好)
const loadAvg = os.loadavg()[0]
const cpuCount = os.cpus().length
// 负载平均值反映系统负载,但不等同于CPU使用率
// 负载平均值 = CPU核心数时,表示CPU满载
// 这里做一个粗略估算:负载平均值 / CPU核心数 * 100,但限制在100%以内
const estimatedUsage = Math.min(Math.round((loadAvg / cpuCount) * 100), 100)
console.warn(`[PerformanceQuick] Using loadavg estimation: load=${loadAvg}, cores=${cpuCount}, estimated=${estimatedUsage}%`)
return estimatedUsage
}
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const user = session.user as any
if (!hasSystemAdminPermission(user.role, Boolean(user.isSuperAdmin))) {
return NextResponse.json({ error: '仅系统管理员可查看性能数据' }, { status: 403 })
}
const rateLimitKey = `${String(user.id || 'unknown')}:${request.headers.get('x-forwarded-for') || ''}`
const retryAfter = consumeRateLimit(rateLimitKey)
if (retryAfter) {
return NextResponse.json(
{ error: '请求过于频繁,请稍后再试' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
)
}
const loadAvg = os.loadavg()
const cpuCores = Math.max(1, os.cpus().length)
const [cpuUsage, memoryStats] = await Promise.all([getCpuUsage(), readProcessMemory()])
const cpu = {
usage: cpuUsage,
loadAverage: loadAvg,
cores: cpuCores,
}
const memory = {
used: Math.round(memoryStats.usedBytes / (1024 * 1024)),
total: Math.round(memoryStats.totalBytes / (1024 * 1024)),
percentage: memoryStats.percentage,
usedBytes: memoryStats.usedBytes,
totalBytes: memoryStats.totalBytes,
source: memoryStats.source,
hostUsedBytes: memoryStats.hostUsedBytes,
hostTotalBytes: memoryStats.hostTotalBytes,
hostPercentage: memoryStats.hostPercentage,
}
const networkSpeed = await getNetworkSpeed()
const network = {
uploadSpeed: networkSpeed?.uploadSpeed || 0,
downloadSpeed: networkSpeed?.downloadSpeed || 0,
interface: networkSpeed?.interface || 'unknown',
scope: 'app-container' as const,
}
// 2. 上传队列(从 Redis 获取,只显示真正活跃的上传)
const uploadProcessing: any[] = []
const uploadWaiting: any[] = []
const uploadUserIds = new Set<string>()
const uploadIds = new Set<string>()
// 从 Redis 获取所有上传会话
const uploadSessions = await getAllSessions()
// 只显示真正进行中的上传(进度 < 100% 且最近有活动)
const now = Date.now()
// 说明:
// - lastActivityAt:每个 chunk 上传完成后更新(更能代表“是否真的在上传”)
// - createdAt:只能代表会话创建时间,不能代表持续活跃
const ACTIVE_THRESHOLD = 5 * 60 * 1000 // 5分钟内有活动 → 认为“活跃”
const MAX_SHOW_THRESHOLD = 60 * 60 * 1000 // 超过1小时无活动 → 不再展示(避免长期误导)
Array.from(uploadSessions.entries())
.sort(([, left], [, right]) => {
const leftActivity = left.lastActivityAt || left.createdAt || 0
const rightActivity = right.lastActivityAt || right.createdAt || 0
return rightActivity - leftActivity
})
.slice(0, MAX_VISIBLE_UPLOAD_SESSIONS)
.forEach(([uploadId, session]) => {
const completedChunks = session.chunks.size
const totalChunks = session.totalChunks || 1
const progress = Math.min(
100,
Math.round((completedChunks / totalChunks) * 100)
)
const lastActivityAt = (session as any).lastActivityAt || session.createdAt || 0
const inactiveMs = lastActivityAt > 0 ? now - lastActivityAt : Number.POSITIVE_INFINITY
const isActive = inactiveMs >= 0 && inactiveMs < ACTIVE_THRESHOLD
const bucket = classifyUploadSession({
progress,
inactiveMs,
activeThresholdMs: ACTIVE_THRESHOLD,
maxShowThresholdMs: MAX_SHOW_THRESHOLD,
})
if (bucket === 'hidden') {
return
}
uploadUserIds.add(session.userId)
uploadIds.add(uploadId)
const item = {
uploadId,
fileName: session.fileName,
fileSize: session.fileSize || 0,
progress,
userId: session.userId,
currentSpeed: (session as any).currentSpeed || 0,
uploadedBytes: (session as any).uploadedBytes || 0,
lastActivityAt,
inactiveMs,
isActive,
}
if (bucket === 'processing') {
uploadProcessing.push(item)
} else {
uploadWaiting.push(item)
}
})
// 批量查询用户信息
const userInfoMap = new Map<string, { username: string; realName?: string }>()
if (uploadUserIds.size > 0) {
try {
const users = await prisma.user.findMany({
where: {
id: { in: Array.from(uploadUserIds) },
} as any,
select: {
id: true,
username: true,
realName: true,
} as any,
})
users.forEach((user: any) => {
userInfoMap.set(user.id, {
username: user.username || '未知',
realName: user.realName || undefined,
})
})
} catch (error) {
console.error('[PerformanceQuick] Failed to fetch user info:', error)
}
}
// 尝试通过uploadId查询已创建的material记录(获取materialType)
const uploadMaterialInfoMap = new Map<string, { materialType: string }>()
if (uploadIds.size > 0) {
try {
const materials = await prisma.material.findMany({
where: {
uploadId: { in: Array.from(uploadIds) },
deletedAt: null,
} as any,
select: {
uploadId: true,
materialType: true,
} as any,
})
materials.forEach((m: any) => {
if (m.uploadId) {
uploadMaterialInfoMap.set(m.uploadId, {
materialType: m.materialType || '未知类型',
})
}
})
} catch (error) {
console.error('[PerformanceQuick] Failed to fetch upload material info:', error)
}
}
const decorateUploadTask = (task: any) => {
const userInfo = userInfoMap.get(task.userId) || { username: '未知用户' }
const materialInfo = uploadMaterialInfoMap.get(task.uploadId) || { materialType: '未知类型' }
return {
...task,
username: userInfo.realName || userInfo.username || '未知用户',
materialType: materialInfo.materialType,
}
}
const uploadProcessingWithUsers = uploadProcessing.map(decorateUploadTask)
const uploadWaitingWithUsers = uploadWaiting.map(decorateUploadTask)
const uploadQueue = {
processing: uploadProcessingWithUsers,
waiting: uploadWaitingWithUsers,
total: uploadProcessingWithUsers.length + uploadWaitingWithUsers.length,
}
const status = transcodeQueue.getStatus()
const progressObj: Record<string, number> = {}
const stageObj: Record<string, string> = {}
status.progress.forEach((value, key) => {
progressObj[key] = value
})
status.stages.forEach((value, key) => {
stageObj[key] = value
})
const transcodeMaterials = await prisma.material.findMany({
where: {
deletedAt: null,
videoProcessingStatus: { in: ['pending', 'processing'] },
},
select: {
id: true,
zipFileName: true,
materialType: true,
authorName: true,
videoProcessingStatus: true,
transcodeLeaseExpiresAt: true,
user: {
select: {
username: true,
},
},
},
orderBy: { updatedAt: 'asc' },
take: 80,
})
const transcodeNow = new Date()
const transcodeProcessing: any[] = []
const transcodeWaiting: any[] = []
transcodeMaterials.forEach((material) => {
const bucket = classifyTranscodeMaterial({
status: material.videoProcessingStatus,
leaseExpiresAt: material.transcodeLeaseExpiresAt,
now: transcodeNow,
})
if (bucket === 'hidden') return
const item = {
materialId: material.id,
fileName: material.zipFileName || '未知文件',
materialType: material.materialType || '未知类型',
authorName: material.authorName || material.user?.username || '未知用户',
progress: progressObj[material.id] ?? null,
stage: stageObj[material.id] ?? null,
}
if (bucket === 'processing') {
transcodeProcessing.push(item)
} else {
transcodeWaiting.push({
...item,
queuePosition: transcodeWaiting.length + 1,
})
}
})
const transcodeQueueData = {
processing: transcodeProcessing,
waiting: transcodeWaiting,
total: transcodeProcessing.length + transcodeWaiting.length,
maxConcurrent: status.maxConcurrent,
}
return NextResponse.json({
cpu,
memory,
network,
uploadQueue,
transcodeQueue: transcodeQueueData,
})
} catch (error) {
console.error('[PerformanceQuick] 获取性能数据失败:', error)
return NextResponse.json(
{ error: '获取性能数据失败' },
{ status: 500 }
)
}
}