267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
'use client'
|
||
|
||
import { formatHumanBytes, formatHumanSpeed, formatTransferProgressSummary } from '@/lib/human-readable-transfer'
|
||
|
||
type UploadTask = {
|
||
uploadId?: string
|
||
fileName?: string
|
||
username?: string
|
||
materialType?: string
|
||
progress?: number
|
||
currentSpeed?: number
|
||
uploadedBytes?: number
|
||
fileSize?: number
|
||
isActive?: boolean
|
||
inactiveMs?: number
|
||
}
|
||
|
||
type TranscodeTask = {
|
||
materialId?: string
|
||
fileName?: string
|
||
materialType?: string
|
||
authorName?: string
|
||
stage?: string | null
|
||
progress?: number | null
|
||
queuePosition?: number
|
||
}
|
||
|
||
export type SystemPerformanceData = {
|
||
cpu?: { usage: number; loadAverage: number[]; cores?: number }
|
||
memory?: {
|
||
used: number
|
||
total: number
|
||
percentage: number
|
||
usedBytes?: number
|
||
totalBytes?: number
|
||
source?: 'container' | 'host'
|
||
hostUsedBytes?: number
|
||
hostTotalBytes?: number
|
||
hostPercentage?: number
|
||
}
|
||
network?: { uploadSpeed: number; downloadSpeed: number }
|
||
uploadQueue?: { total: number; processing: UploadTask[]; waiting: UploadTask[] }
|
||
transcodeQueue?: { total: number; maxConcurrent: number; processing: TranscodeTask[]; waiting: TranscodeTask[] }
|
||
}
|
||
|
||
const STAGE_LABEL: Record<string, string> = {
|
||
preview: '预览视频',
|
||
clip: '片段生成',
|
||
cover: '封面提取',
|
||
}
|
||
|
||
function formatAgo(ms: number): string {
|
||
if (!Number.isFinite(ms) || ms < 0) return '--'
|
||
const sec = Math.floor(ms / 1000)
|
||
if (sec < 60) return `${sec} 秒`
|
||
const min = Math.floor(sec / 60)
|
||
if (min < 60) return `${min} 分钟`
|
||
const hr = Math.floor(min / 60)
|
||
return `${hr} 小时 ${min % 60} 分钟`
|
||
}
|
||
|
||
function Meter({ value, warn = false }: { value: number; warn?: boolean }) {
|
||
const width = Math.min(Math.max(value, 0), 100)
|
||
return (
|
||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[#e7e8ea]">
|
||
<div
|
||
className={`h-full rounded-full ${warn ? 'bg-[#8a3b32]' : 'bg-[#1a1c1d]'}`}
|
||
style={{ width: `${width}%` }}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function QueueSummary({
|
||
processing,
|
||
waiting,
|
||
capacity,
|
||
}: {
|
||
processing: number
|
||
waiting: number
|
||
capacity?: number
|
||
}) {
|
||
if (processing === 0 && waiting === 0) {
|
||
return <span className="text-[#6b7075]">空闲</span>
|
||
}
|
||
const processingText =
|
||
typeof capacity === 'number' && capacity > 0 ? `进行中 ${processing} / ${capacity}` : `进行中 ${processing}`
|
||
return (
|
||
<span>
|
||
{processingText}
|
||
{waiting > 0 ? ` · 停住 ${waiting}` : ''}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
export default function SystemPerformanceCard({
|
||
data,
|
||
loading,
|
||
error,
|
||
}: {
|
||
data: SystemPerformanceData | null
|
||
loading: boolean
|
||
error: string | null
|
||
}) {
|
||
const cpu = data?.cpu
|
||
const memory = data?.memory
|
||
const memoryBytes =
|
||
typeof memory?.usedBytes === 'number' && typeof memory?.totalBytes === 'number'
|
||
? { used: memory.usedBytes, total: memory.totalBytes }
|
||
: memory
|
||
? { used: memory.used * 1024 * 1024, total: memory.total * 1024 * 1024 }
|
||
: null
|
||
const memoryWarn = (memory?.percentage ?? 0) >= 85
|
||
const uploadProcessing = data?.uploadQueue?.processing || []
|
||
const uploadWaiting = data?.uploadQueue?.waiting || []
|
||
const transcodeProcessing = data?.transcodeQueue?.processing || []
|
||
const transcodeWaiting = data?.transcodeQueue?.waiting || []
|
||
|
||
return (
|
||
<div
|
||
id="system-performance-panel"
|
||
role="status"
|
||
aria-live="polite"
|
||
className="absolute right-0 z-50 mt-2 w-[22rem] rounded-xl border border-[#e7e8ea] bg-white p-4 text-[#1a1c1d]"
|
||
>
|
||
<div className="mb-3 flex items-baseline justify-between gap-3">
|
||
<span className="text-[13px] font-semibold tracking-tight">系统性能</span>
|
||
<span className="text-[11px] text-[#6b7075]">
|
||
{error ? error : !data && loading ? '正在读取' : '应用容器此刻'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div>
|
||
<div className="mb-1 flex justify-between text-[13px]">
|
||
<span>CPU</span>
|
||
<span>{cpu ? `${cpu.usage}%` : '--'}</span>
|
||
</div>
|
||
<Meter value={cpu?.usage ?? 0} />
|
||
<div className="mt-1 flex justify-between text-[11px] text-[#6b7075]">
|
||
<span>{cpu?.cores ? `${cpu.cores} 核 · 1/5/15 分钟负载` : '1/5/15 分钟负载'}</span>
|
||
<span>{cpu?.loadAverage?.length ? cpu.loadAverage.map((value) => value.toFixed(2)).join(' / ') : '--'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="mb-1 flex justify-between text-[13px]">
|
||
<span>{memory?.source === 'host' ? '内存 · 整机' : '内存'}</span>
|
||
<span>
|
||
{memoryBytes
|
||
? `${formatHumanBytes(memoryBytes.used)} / ${formatHumanBytes(memoryBytes.total)}(${memory?.percentage}%)`
|
||
: '--'}
|
||
</span>
|
||
</div>
|
||
<Meter value={memory?.percentage ?? 0} warn={memoryWarn} />
|
||
{memory?.source === 'container' && typeof memory.hostPercentage === 'number' ? (
|
||
<div className="mt-1 text-[11px] text-[#6b7075]">
|
||
整台机器大约用了 {memory.hostPercentage}%
|
||
{typeof memory.hostUsedBytes === 'number' && typeof memory.hostTotalBytes === 'number'
|
||
? `(${formatHumanBytes(memory.hostUsedBytes)} / ${formatHumanBytes(memory.hostTotalBytes)})`
|
||
: ''}
|
||
,主要是存储机自己在用
|
||
</div>
|
||
) : null}
|
||
{memoryWarn ? (
|
||
<div className="mt-1 text-[11px] text-[#8a3b32]">这个应用自己的内存已经偏高,继续堆任务容易变慢</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div>
|
||
<div className="mb-1 flex justify-between text-[13px]">
|
||
<span>进出流量</span>
|
||
<span className="text-[11px] text-[#6b7075]">本应用,不是整台机器</span>
|
||
</div>
|
||
<div className="space-y-1 text-[12px] text-[#6b7075]">
|
||
<div className="flex justify-between">
|
||
<span>进来(别人上传)</span>
|
||
<span>{data?.network ? formatHumanSpeed(data.network.uploadSpeed) : '--'}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>出去(别人下载)</span>
|
||
<span>{data?.network ? formatHumanSpeed(data.network.downloadSpeed) : '--'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 space-y-3 border-t border-[#e7e8ea] pt-3">
|
||
<div>
|
||
<div className="mb-1 flex justify-between text-[13px]">
|
||
<span>上传</span>
|
||
<QueueSummary processing={uploadProcessing.length} waiting={uploadWaiting.length} />
|
||
</div>
|
||
<div className="max-h-32 overflow-y-auto">
|
||
{uploadProcessing.map((task, index) => {
|
||
const uploadedBytes = task.uploadedBytes || (task.fileSize || 0) * ((task.progress || 0) / 100)
|
||
const speedText = task.currentSpeed
|
||
? formatTransferProgressSummary({
|
||
speedBytesPerSecond: task.currentSpeed,
|
||
loadedBytes: uploadedBytes,
|
||
totalBytes: task.fileSize,
|
||
})
|
||
: '速度还在统计'
|
||
return (
|
||
<div key={task.uploadId || `upload-p-${index}`} className="mt-1.5 rounded-md bg-[#f7f7f8] px-2.5 py-2 text-[12px] text-[#6b7075]">
|
||
<div className="truncate font-medium text-[#1a1c1d]">{task.fileName || '未命名文件'}</div>
|
||
<div className="mt-1 flex justify-between">
|
||
<span>{task.username || '未知用户'}</span>
|
||
<span>{task.materialType || '未知类型'}</span>
|
||
</div>
|
||
<div className="mt-1">{task.progress ?? 0}% · {speedText}</div>
|
||
</div>
|
||
)
|
||
})}
|
||
{uploadWaiting.map((task, index) => (
|
||
<div key={task.uploadId || `upload-w-${index}`} className="mt-1.5 rounded-md bg-[#f7f7f8] px-2.5 py-2 text-[12px] text-[#6b7075]">
|
||
<div className="truncate font-medium text-[#1a1c1d]">{task.fileName || '未命名文件'}</div>
|
||
<div className="mt-1 flex justify-between">
|
||
<span>{task.username || '未知用户'}</span>
|
||
<span>{task.materialType || '未知类型'}</span>
|
||
</div>
|
||
<div className="mt-1">已经停住 {formatAgo(task.inactiveMs || 0)}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="mb-1 flex justify-between text-[13px]">
|
||
<span>转码</span>
|
||
<QueueSummary
|
||
processing={transcodeProcessing.length}
|
||
waiting={transcodeWaiting.length}
|
||
capacity={data?.transcodeQueue?.maxConcurrent}
|
||
/>
|
||
</div>
|
||
<div className="max-h-32 overflow-y-auto">
|
||
{transcodeProcessing.map((task, index) => (
|
||
<div key={task.materialId || `transcode-p-${index}`} className="mt-1.5 rounded-md bg-[#f7f7f8] px-2.5 py-2 text-[12px] text-[#6b7075]">
|
||
<div className="truncate font-medium text-[#1a1c1d]">{task.fileName || '未命名文件'}</div>
|
||
<div className="mt-1 flex justify-between">
|
||
<span>
|
||
{task.materialType || '未知类型'}
|
||
{task.stage ? ` · ${STAGE_LABEL[task.stage] || task.stage}` : ''}
|
||
</span>
|
||
<span>{task.progress ?? 0}%</span>
|
||
</div>
|
||
<div className="mt-1">上传人 {task.authorName || '未知用户'}</div>
|
||
</div>
|
||
))}
|
||
{transcodeWaiting.map((task, index) => (
|
||
<div key={task.materialId || `transcode-w-${index}`} className="mt-1.5 rounded-md bg-[#f7f7f8] px-2.5 py-2 text-[12px] text-[#6b7075]">
|
||
<div className="truncate font-medium text-[#1a1c1d]">{task.fileName || '未命名文件'}</div>
|
||
<div className="mt-1 flex justify-between">
|
||
<span>{task.materialType || '未知类型'}</span>
|
||
<span>排队 #{task.queuePosition || index + 1}</span>
|
||
</div>
|
||
<div className="mt-1">上传人 {task.authorName || '未知用户'}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|