chenpei 4e89b57798 feat(upload): optimize upload UI and performance
- Expand batch upload list area and optimize preview image layout
- Improve upload speed tracking and display (server-side & client-side)
- Reduce network detection log noise
- Optimize Docker build process (caching, env validation)
- Add shadow environment deployment support
2026-01-28 15:07:50 +08:00

341 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 { NextResponse } from 'next/server'
import os from 'os'
import { exec } from 'child_process'
import { promisify } from 'util'
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'
const execAsync = promisify(exec)
// 性能监控 - 快速统计接口(用于悬停卡片)
// GET /api/performance/quick
export const dynamic = 'force-dynamic'
export const revalidate = 0
// 获取CPU使用率(正确的方法:需要两次采样)
async function getCpuUsage(): Promise<number> {
try {
// 方法1:使用 /proc/stat 两次采样计算CPU使用率(最准确)
const getCpuStats = async () => {
const { stdout } = await execAsync("cat /proc/stat | grep '^cpu '")
const parts = stdout.trim().split(/\s+/)
return {
user: parseInt(parts[1]) || 0,
nice: parseInt(parts[2]) || 0,
system: parseInt(parts[3]) || 0,
idle: parseInt(parts[4]) || 0,
iowait: parseInt(parts[5]) || 0,
irq: parseInt(parts[6]) || 0,
softirq: parseInt(parts[7]) || 0,
}
}
const stats1 = await getCpuStats()
await new Promise(resolve => setTimeout(resolve, 1000)) // 等待1秒
const stats2 = await getCpuStats()
const total1 = stats1.user + stats1.nice + stats1.system + stats1.idle + stats1.iowait + stats1.irq + stats1.softirq
const total2 = stats2.user + stats2.nice + stats2.system + stats2.idle + stats2.iowait + stats2.irq + stats2.softirq
const totalDiff = total2 - total1
if (totalDiff > 0) {
const idleDiff = stats2.idle - stats1.idle
const usedDiff = totalDiff - idleDiff
const cpuUsage = (usedDiff / totalDiff) * 100
return Math.min(Math.max(Math.round(cpuUsage), 0), 100)
}
} catch (error) {
console.warn('[PerformanceQuick] Failed to get CPU usage from /proc/stat:', error)
}
try {
// 方法2:尝试使用 top 命令(批处理模式,更准确)
const { stdout } = await execAsync("top -bn2 -d1 | grep 'Cpu(s)' | tail -1 | awk '{print $2}' | sed 's/%us,//'", { timeout: 3000 })
const cpuUsage = parseFloat(stdout.trim())
if (!isNaN(cpuUsage) && cpuUsage >= 0 && cpuUsage <= 100) {
return Math.round(cpuUsage)
}
} catch (error) {
console.warn('[PerformanceQuick] Failed to get CPU usage from top:', error)
}
try {
// 方法3:使用 vmstat(如果可用)
const { stdout } = await execAsync("vmstat 1 2 | tail -1 | awk '{print 100-$15}'", { timeout: 3000 })
const cpuUsage = parseFloat(stdout.trim())
if (!isNaN(cpuUsage) && cpuUsage >= 0 && cpuUsage <= 100) {
return Math.round(cpuUsage)
}
} catch (error) {
console.warn('[PerformanceQuick] Failed to get CPU usage from vmstat:', 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() {
try {
// 1. 系统性能(先用 Node.js os 模块,后续可接 TrueNAS)
const loadAvg = os.loadavg() // [1min, 5min, 15min]
const totalMem = os.totalmem()
const freeMem = os.freemem()
const usedMem = totalMem - freeMem
const memPercentage = totalMem > 0 ? Math.round((usedMem / totalMem) * 100) : 0
// 获取更准确的CPU使用率
const cpuUsage = await getCpuUsage()
const cpu = {
usage: cpuUsage,
loadAverage: loadAvg,
}
const memory = {
used: Math.round(usedMem / (1024 * 1024)), // MB
total: Math.round(totalMem / (1024 * 1024)), // MB
percentage: memPercentage,
}
// 获取网络带宽使用情况
const networkSpeed = await getNetworkSpeed()
const network = {
uploadSpeed: networkSpeed?.uploadSpeed || 0, // 字节/秒
downloadSpeed: networkSpeed?.downloadSpeed || 0, // 字节/秒
interface: networkSpeed?.interface || 'unknown', // 使用的网卡
}
// 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小时无活动 → 不再展示(避免长期误导)
uploadSessions.forEach((session, uploadId) => {
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 isTooOld = inactiveMs >= MAX_SHOW_THRESHOLD
// 只展示:未完成 + 未超过“最大无活动时间”
// 注意:即使不是“活跃”,也会展示(并返回 inactiveMs),用于解释“队列里有任务但网速很低”的情况
if (progress < 100) {
if (!isTooOld) {
uploadUserIds.add(session.userId)
uploadIds.add(uploadId)
uploadProcessing.push({
uploadId,
fileName: session.fileName,
progress,
userId: session.userId,
currentSpeed: (session as any).currentSpeed || 0, // 上传速度(字节/秒,估算)
uploadedBytes: (session as any).uploadedBytes || 0,
lastActivityAt,
inactiveMs,
isActive,
})
}
}
})
// 批量查询用户信息
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 uploadProcessingWithUsers = uploadProcessing.map((task) => {
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 uploadQueue = {
processing: uploadProcessingWithUsers,
waiting: uploadWaiting,
total: uploadProcessingWithUsers.length + uploadWaiting.length,
}
// 3. 转码队列(基于 transcodeQueue)
const status = transcodeQueue.getStatus()
const progressObj: Record<string, number> = {}
const stageObj: Record<string, string> = {}
// Map 无法直接序列化,转成普通对象
status.progress.forEach((value, key) => {
progressObj[key] = value
})
status.stages.forEach((value, key) => {
stageObj[key] = value
})
// 查询转码队列中的素材信息(文件名、类型、上传人等)
const processingMaterialIds = status.processingIds
const waitingMaterialIds = status.waiting
const allMaterialIds = [...processingMaterialIds, ...waitingMaterialIds]
const materialInfoMap = new Map<string, any>()
if (allMaterialIds.length > 0) {
try {
const materials = await prisma.material.findMany({
where: {
id: { in: allMaterialIds },
deletedAt: null,
} as any,
select: {
id: true,
zipFileName: true,
materialType: true,
authorName: true,
user: {
select: {
username: true,
},
},
} as any,
})
materials.forEach((m: any) => {
materialInfoMap.set(m.id, {
fileName: m.zipFileName || '未知文件',
materialType: m.materialType || '未知类型',
authorName: m.authorName || m.user?.username || '未知用户',
})
})
} catch (error) {
console.error('[PerformanceQuick] Failed to fetch material info:', error)
}
}
const transcodeQueueData = {
processing: processingMaterialIds.map((id) => {
const info = materialInfoMap.get(id) || {
fileName: '未知文件',
materialType: '未知类型',
authorName: '未知用户',
}
return {
materialId: id,
fileName: info.fileName,
materialType: info.materialType,
authorName: info.authorName,
progress: progressObj[id] ?? null,
stage: stageObj[id] ?? null,
}
}),
waiting: waitingMaterialIds.map((id, index) => {
const info = materialInfoMap.get(id) || {
fileName: '未知文件',
materialType: '未知类型',
authorName: '未知用户',
}
return {
materialId: id,
fileName: info.fileName,
materialType: info.materialType,
authorName: info.authorName,
queuePosition: index + 1,
}
}),
total: status.queueLength,
maxConcurrent: status.maxConcurrent,
}
return NextResponse.json({
cpu,
memory,
network,
uploadQueue,
transcodeQueue: transcodeQueueData,
})
} catch (error) {
console.error('[PerformanceQuick] 获取性能数据失败:', error)
return NextResponse.json(
{ error: '获取性能数据失败' },
{ status: 500 }
)
}
}