's'
This commit is contained in:
@@ -149,12 +149,12 @@ function resolvePreload(): string {
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 920,
|
||||
height: 860,
|
||||
minWidth: 640,
|
||||
width: 1020,
|
||||
height: 720,
|
||||
minWidth: 720,
|
||||
minHeight: 560,
|
||||
show: false,
|
||||
backgroundColor: '#f0f0f0',
|
||||
backgroundColor: '#14161c',
|
||||
title: '直播录制助手',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>直播录制助手</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,22 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { apiUrl } from './api'
|
||||
import NetworkDashboard from './NetworkDashboard'
|
||||
import ProxySettings from './ProxySettings'
|
||||
import SystemMetrics from './SystemMetrics'
|
||||
import { UI_DROPDOWN_YOUTUBE_ONLY } from './constants'
|
||||
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from './scriptUtils'
|
||||
|
||||
type Entry = { script: string; label: string; pm2: string }
|
||||
|
||||
type NavId = 'record' | 'network' | 'proxy' | 'system'
|
||||
|
||||
const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [
|
||||
{ id: 'record', label: '录制', hint: '任务与推流控制' },
|
||||
{ id: 'network', label: '网络', hint: '连通与测速' },
|
||||
{ id: 'proxy', label: '代理', hint: '仅录制进程' },
|
||||
{ id: 'system', label: '系统', hint: '负载与网速' },
|
||||
]
|
||||
|
||||
type StatusVariant =
|
||||
| 'loading'
|
||||
| 'online'
|
||||
@@ -49,6 +61,8 @@ export default function App() {
|
||||
const [saveYoutubeLoading, setSaveYoutubeLoading] = useState(false)
|
||||
const [saveUrlLoading, setSaveUrlLoading] = useState(false)
|
||||
|
||||
const [nav, setNav] = useState<NavId>('record')
|
||||
|
||||
const logRef = useRef<HTMLPreElement>(null)
|
||||
|
||||
const scrollLog = useCallback(() => {
|
||||
@@ -249,18 +263,26 @@ export default function App() {
|
||||
|
||||
if (bootError) {
|
||||
return (
|
||||
<div className="app-frame">
|
||||
<div className="app-chrome">
|
||||
<div className="app-chrome__title">直播录制助手</div>
|
||||
</div>
|
||||
<div className="app-body app-body--padded">
|
||||
<div className="error-card" role="alert">
|
||||
<div className="error-card__icon" aria-hidden>
|
||||
!
|
||||
</div>
|
||||
<div className="app-frame app-frame--verge">
|
||||
<aside className="app-sidebar" aria-label="主导航">
|
||||
<div className="app-sidebar__brand">
|
||||
<div className="app-sidebar__logo" aria-hidden />
|
||||
<div>
|
||||
<h2 className="error-card__title">无法启动</h2>
|
||||
<pre className="error-card__body">{bootError}</pre>
|
||||
<div className="app-sidebar__app-name">直播录制助手</div>
|
||||
<div className="app-sidebar__app-tag">本地控制台</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="app-workspace">
|
||||
<div className="app-workspace__inner app-workspace__inner--center">
|
||||
<div className="error-card" role="alert">
|
||||
<div className="error-card__icon" aria-hidden>
|
||||
!
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="error-card__title">无法启动</h2>
|
||||
<pre className="error-card__body">{bootError}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,14 +290,73 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-frame">
|
||||
<header className="app-chrome">
|
||||
<div className="app-chrome__title">直播录制助手</div>
|
||||
<p className="app-chrome__subtitle">打开即可用,无需单独安装运行环境</p>
|
||||
</header>
|
||||
const navMeta = NAV_ITEMS.find((n) => n.id === nav)
|
||||
|
||||
<div className="app-body">
|
||||
return (
|
||||
<div className="app-frame app-frame--verge">
|
||||
<aside className="app-sidebar" aria-label="主导航">
|
||||
<div className="app-sidebar__brand">
|
||||
<div className="app-sidebar__logo" aria-hidden />
|
||||
<div>
|
||||
<div className="app-sidebar__app-name">直播录制助手</div>
|
||||
<div className="app-sidebar__app-tag">懒人包 · 本地</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="app-sidebar__nav">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`app-sidebar__link ${nav === item.id ? 'is-active' : ''}`}
|
||||
onClick={() => setNav(item.id)}
|
||||
title={item.hint}
|
||||
>
|
||||
<span className="app-sidebar__link-label">{item.label}</span>
|
||||
{nav === item.id && <span className="app-sidebar__link-glow" aria-hidden />}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="app-workspace">
|
||||
<header className="app-workspace__header">
|
||||
<div>
|
||||
<h1 className="app-workspace__title">{navMeta?.label ?? ''}</h1>
|
||||
<p className="app-workspace__subtitle">{navMeta?.hint}</p>
|
||||
</div>
|
||||
{nav === 'record' && (
|
||||
<div
|
||||
id="statusIndicator"
|
||||
className="status-pill"
|
||||
data-variant={statusMeta.variant}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="status-pill__dot" aria-hidden />
|
||||
{statusMeta.line}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="app-workspace__scroll">
|
||||
{nav === 'network' && (
|
||||
<div className="app-panel">
|
||||
<NetworkDashboard />
|
||||
</div>
|
||||
)}
|
||||
{nav === 'proxy' && (
|
||||
<div className="app-panel">
|
||||
<ProxySettings />
|
||||
</div>
|
||||
)}
|
||||
{nav === 'system' && (
|
||||
<div className="app-panel">
|
||||
<SystemMetrics />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nav === 'record' && (
|
||||
<div className="app-body app-body--record">
|
||||
<div className="app-toolbar">
|
||||
<div className="app-toolbar__left">
|
||||
<label className="field-label" htmlFor="processSelect">
|
||||
@@ -296,16 +377,6 @@ export default function App() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
id="statusIndicator"
|
||||
className="status-pill"
|
||||
data-variant={statusMeta.variant}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="status-pill__dot" aria-hidden />
|
||||
{statusMeta.line}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{initialLoad && (
|
||||
@@ -474,6 +545,9 @@ export default function App() {
|
||||
</section>
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
204
electron/src/renderer/src/NetworkDashboard.tsx
Normal file
204
electron/src/renderer/src/NetworkDashboard.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { apiUrl } from './api'
|
||||
|
||||
type SiteInfo = {
|
||||
host: string
|
||||
reachable: boolean
|
||||
latency_ms: number
|
||||
error: string | null
|
||||
resolved_ip: string | null
|
||||
country: string | null
|
||||
country_code: string | null
|
||||
geo_error: string | null
|
||||
}
|
||||
|
||||
type SpeedBlock = {
|
||||
label: string
|
||||
download_mbps: number | null
|
||||
download_error: string | null
|
||||
download_bytes_sampled: number
|
||||
upload_mbps: number | null
|
||||
upload_error: string | null
|
||||
upload_target: string | null
|
||||
}
|
||||
|
||||
type NetworkPayload = {
|
||||
google: SiteInfo
|
||||
douyin: SiteInfo
|
||||
routes: { different: boolean | null; note: string }
|
||||
speed: { overseas: SpeedBlock; domestic: SpeedBlock }
|
||||
meta?: { at: number; download_cap_bytes: number; upload_bytes: number }
|
||||
error?: string
|
||||
}
|
||||
|
||||
function fmtMbps(v: number | null | undefined): string {
|
||||
if (v == null || Number.isNaN(v)) return '—'
|
||||
return `${v.toFixed(2)} Mbps`
|
||||
}
|
||||
|
||||
function SiteRow({ title, site }: { title: string; site: SiteInfo }) {
|
||||
const ok = site.reachable
|
||||
return (
|
||||
<div className="net-site">
|
||||
<div className="net-site__head">
|
||||
<span className="net-site__title">{title}</span>
|
||||
<span className={`net-badge ${ok ? 'net-badge--ok' : 'net-badge--bad'}`}>
|
||||
{ok ? '可访问' : '不可访问'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="net-site__grid">
|
||||
<span className="net-k">延迟</span>
|
||||
<span className="net-v">{site.latency_ms != null ? `${site.latency_ms.toFixed(0)} ms` : '—'}</span>
|
||||
<span className="net-k">解析 IP</span>
|
||||
<span className="net-v net-v--mono">{site.resolved_ip ?? '—'}</span>
|
||||
<span className="net-k">地区(参考)</span>
|
||||
<span className="net-v">
|
||||
{site.country ?? '—'}
|
||||
{site.country_code ? ` (${site.country_code})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{(site.error || site.geo_error) && (
|
||||
<p className="net-site__err">
|
||||
{[site.error, site.geo_error].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NetworkDashboard() {
|
||||
const [data, setData] = useState<NetworkPayload | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const res = await fetch(apiUrl('/network_status'))
|
||||
const json = (await res.json()) as NetworkPayload & { error?: string }
|
||||
if (!res.ok) {
|
||||
setErr(json.error || `请求失败 ${res.status}`)
|
||||
setData(null)
|
||||
return
|
||||
}
|
||||
setData(json)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setData(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => void load(), 45000)
|
||||
return () => clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<section className="card card--network" aria-labelledby="net-dash-title">
|
||||
<div className="net-dash__header">
|
||||
<div>
|
||||
<h2 className="card__title" id="net-dash-title">
|
||||
网络仪表盘
|
||||
</h2>
|
||||
<p className="card__desc net-dash__lead">
|
||||
检测 Google(偏海外)与抖音(偏国内)是否通畅、解析出口是否像两条线,并估算上下行(采样约 0.6MB
|
||||
下行 / 256KB 上行)。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary net-dash__refresh" disabled={loading} onClick={() => void load()}>
|
||||
{loading ? '检测中…' : '重新检测'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<p className="net-dash__error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && !data && !err && <div className="net-dash__loading">正在检测网络…</div>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="net-site-row">
|
||||
<SiteRow title="Google(海外参考)" site={data.google} />
|
||||
<SiteRow title="抖音(国内参考)" site={data.douyin} />
|
||||
</div>
|
||||
|
||||
<div className="net-routes">
|
||||
<span className="net-routes__label">线路是否像「两条线」</span>
|
||||
<span
|
||||
className={`net-routes__pill ${
|
||||
data.routes.different === true
|
||||
? 'net-routes__pill--yes'
|
||||
: data.routes.different === false
|
||||
? 'net-routes__pill--no'
|
||||
: 'net-routes__pill--unk'
|
||||
}`}
|
||||
>
|
||||
{data.routes.different === true
|
||||
? '是(解析国家不同)'
|
||||
: data.routes.different === false
|
||||
? '否或不确定'
|
||||
: '未知'}
|
||||
</span>
|
||||
<p className="net-routes__note">{data.routes.note}</p>
|
||||
</div>
|
||||
|
||||
<div className="net-speed">
|
||||
<h3 className="net-speed__title">网速(估算)</h3>
|
||||
<div className="net-speed__grid">
|
||||
<div className="net-speed__col">
|
||||
<div className="net-speed__head">海外</div>
|
||||
<div className="net-metric">
|
||||
<span className="net-metric__k">下行</span>
|
||||
<span className="net-metric__v">{fmtMbps(data.speed.overseas.download_mbps)}</span>
|
||||
</div>
|
||||
<div className="net-metric">
|
||||
<span className="net-metric__k">上行</span>
|
||||
<span className="net-metric__v">{fmtMbps(data.speed.overseas.upload_mbps)}</span>
|
||||
</div>
|
||||
{(data.speed.overseas.download_error || data.speed.overseas.upload_error) && (
|
||||
<p className="net-speed__hint">
|
||||
{[data.speed.overseas.download_error, data.speed.overseas.upload_error]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="net-speed__col">
|
||||
<div className="net-speed__head">国内</div>
|
||||
<div className="net-metric">
|
||||
<span className="net-metric__k">下行</span>
|
||||
<span className="net-metric__v">{fmtMbps(data.speed.domestic.download_mbps)}</span>
|
||||
</div>
|
||||
<div className="net-metric">
|
||||
<span className="net-metric__k">上行</span>
|
||||
<span className="net-metric__v">{fmtMbps(data.speed.domestic.upload_mbps)}</span>
|
||||
</div>
|
||||
{(data.speed.domestic.download_error || data.speed.domestic.upload_error) && (
|
||||
<p className="net-speed__hint">
|
||||
{[data.speed.domestic.download_error, data.speed.domestic.upload_error]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="net-speed__foot">
|
||||
上行若失败多为网站不接受测试流量,不代表您无法上传直播;海外项走 Google / httpbin,国内项走抖音首页流与门户
|
||||
POST。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
112
electron/src/renderer/src/ProxySettings.tsx
Normal file
112
electron/src/renderer/src/ProxySettings.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { apiUrl } from './api'
|
||||
|
||||
type ProxyCfg = { enabled: boolean; http: string; socks: string }
|
||||
|
||||
export default function ProxySettings() {
|
||||
const [cfg, setCfg] = useState<ProxyCfg>({ enabled: false, http: '', socks: '' })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const res = await fetch(apiUrl('/proxy_config'))
|
||||
const data = (await res.json()) as ProxyCfg
|
||||
if (!res.ok) throw new Error('读取失败')
|
||||
setCfg({
|
||||
enabled: !!data.enabled,
|
||||
http: data.http || '',
|
||||
socks: data.socks || '',
|
||||
})
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const res = await fetch(apiUrl('/proxy_config'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg),
|
||||
})
|
||||
if (!res.ok) throw new Error('保存失败')
|
||||
setMsg('已保存。重启录制任务后生效。')
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card card--proxy" aria-labelledby="proxy-settings-title">
|
||||
<h2 className="card__title" id="proxy-settings-title">
|
||||
录制专用代理(不改系统网络)
|
||||
</h2>
|
||||
<p className="card__desc">
|
||||
仅对本软件拉起的 FFmpeg / 录制进程生效,不会修改 Windows
|
||||
系统代理。若你使用 Clash Verge 等,请在本机开启「混合端口」后,把地址填成与之一致(常见为 7890)。
|
||||
YouTube RTMP 推流若仍失败,可能需要代理软件开启 TUN 或自行调整规则。
|
||||
</p>
|
||||
{loading ? (
|
||||
<p className="net-dash__loading">正在读取配置…</p>
|
||||
) : (
|
||||
<div className="proxy-form">
|
||||
<label className="proxy-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cfg.enabled}
|
||||
onChange={(e) => setCfg((c) => ({ ...c, enabled: e.target.checked }))}
|
||||
/>
|
||||
<span>启用仅录制进程使用的代理</span>
|
||||
</label>
|
||||
<label className="field-label" htmlFor="proxyHttp">
|
||||
HTTP 代理(供 FFmpeg -http_proxy 等)
|
||||
</label>
|
||||
<input
|
||||
id="proxyHttp"
|
||||
className="proxy-input"
|
||||
type="text"
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
value={cfg.http}
|
||||
onChange={(e) => setCfg((c) => ({ ...c, http: e.target.value }))}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<label className="field-label" htmlFor="proxySocks">
|
||||
SOCKS5(写入 ALL_PROXY,部分连接可能走此)
|
||||
</label>
|
||||
<input
|
||||
id="proxySocks"
|
||||
className="proxy-input"
|
||||
type="text"
|
||||
placeholder="socks5://127.0.0.1:7890"
|
||||
value={cfg.socks}
|
||||
onChange={(e) => setCfg((c) => ({ ...c, socks: e.target.value }))}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<div className="btn-row">
|
||||
<button type="button" className="btn btn-primary" disabled={saving} onClick={() => void save()}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void load()}>
|
||||
重新读取
|
||||
</button>
|
||||
</div>
|
||||
{msg && <p className="proxy-form__msg">{msg}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
144
electron/src/renderer/src/SystemMetrics.tsx
Normal file
144
electron/src/renderer/src/SystemMetrics.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { apiUrl } from './api'
|
||||
|
||||
type Metrics = {
|
||||
ts?: number
|
||||
cpu_percent?: number | null
|
||||
cpu_count_logical?: number
|
||||
memory?: { percent?: number; used?: number; total?: number; available?: number }
|
||||
swap?: { percent?: number; used?: number; total?: number }
|
||||
disks?: Array<{
|
||||
mountpoint?: string
|
||||
percent?: number
|
||||
free?: number
|
||||
total?: number
|
||||
}>
|
||||
network?: { up_mbps?: number | null; down_mbps?: number | null }
|
||||
ffmpeg_processes?: Array<{ pid?: number; name?: string; rss?: number }>
|
||||
error?: string
|
||||
}
|
||||
|
||||
function fmtBytes(n: number | undefined): string {
|
||||
if (n == null || Number.isNaN(n)) return '—'
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
|
||||
return `${(n / 1024 ** 3).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
export default function SystemMetrics() {
|
||||
const [data, setData] = useState<Metrics | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(apiUrl('/system_metrics'))
|
||||
const json = (await res.json()) as Metrics
|
||||
if (!res.ok) {
|
||||
setErr(json.error || `HTTP ${res.status}`)
|
||||
return
|
||||
}
|
||||
setErr(null)
|
||||
setData(json)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
const id = window.setInterval(() => void load(), 2000)
|
||||
return () => clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
const m = data?.memory
|
||||
const net = data?.network
|
||||
|
||||
return (
|
||||
<section className="card card--metrics" aria-labelledby="sys-metrics-title">
|
||||
<h2 className="card__title" id="sys-metrics-title">
|
||||
本机负载与网速
|
||||
</h2>
|
||||
<p className="card__desc">
|
||||
网速为全机所有网卡合计的估算值(首次刷新可能无速率,约 2 秒后出现)。下方列出当前检测到的
|
||||
ffmpeg 进程。
|
||||
</p>
|
||||
{err && (
|
||||
<p className="net-dash__error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
{!data && !err && <p className="net-dash__loading">正在读取…</p>}
|
||||
{data && (
|
||||
<div className="metrics-grid">
|
||||
<div className="metrics-block">
|
||||
<div className="metrics-block__title">CPU</div>
|
||||
<div className="metrics-big">
|
||||
{data.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'}
|
||||
</div>
|
||||
<div className="metrics-sub">逻辑处理器 {data.cpu_count_logical ?? '—'} 个</div>
|
||||
</div>
|
||||
<div className="metrics-block">
|
||||
<div className="metrics-block__title">内存</div>
|
||||
<div className="metrics-big">{m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'}</div>
|
||||
<div className="metrics-sub">
|
||||
{fmtBytes(m?.used)} / {fmtBytes(m?.total)}(可用 {fmtBytes(m?.available)})
|
||||
</div>
|
||||
</div>
|
||||
<div className="metrics-block">
|
||||
<div className="metrics-block__title">全机网速</div>
|
||||
<div className="metrics-rate">
|
||||
<span>↑ {net?.up_mbps != null ? `${net.up_mbps.toFixed(2)} Mbps` : '—'}</span>
|
||||
<span>↓ {net?.down_mbps != null ? `${net.down_mbps.toFixed(2)} Mbps` : '—'}</span>
|
||||
</div>
|
||||
<div className="metrics-sub">含所有网卡收发合计</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data?.swap && data.swap.total != null && data.swap.total > 0 && (
|
||||
<div className="metrics-swap">
|
||||
交换分区 {data.swap.percent != null ? `${data.swap.percent.toFixed(0)}%` : '—'} ·{' '}
|
||||
{fmtBytes(data.swap.used)} / {fmtBytes(data.swap.total)}
|
||||
</div>
|
||||
)}
|
||||
{data?.disks && data.disks.length > 0 && (
|
||||
<div className="metrics-disks">
|
||||
<div className="metrics-disks__title">磁盘空间</div>
|
||||
<ul className="metrics-disk-list">
|
||||
{data.disks.slice(0, 8).map((d) => (
|
||||
<li key={d.mountpoint}>
|
||||
<span className="metrics-disk-list__mp">{d.mountpoint}</span>
|
||||
<span>
|
||||
{d.percent != null ? `${d.percent.toFixed(0)}%` : '—'} 已用 · 剩余 {fmtBytes(d.free)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{data?.ffmpeg_processes && data.ffmpeg_processes.length > 0 && (
|
||||
<div className="metrics-ff">
|
||||
<div className="metrics-ff__title">ffmpeg 进程</div>
|
||||
<table className="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PID</th>
|
||||
<th>名称</th>
|
||||
<th>内存</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.ffmpeg_processes.map((p) => (
|
||||
<tr key={p.pid}>
|
||||
<td>{p.pid}</td>
|
||||
<td>{p.name}</td>
|
||||
<td>{fmtBytes(p.rss)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user