* feat(api): GET /v1/live-rates 录制中房间的每秒写盘速率瘦端点
/v1/streamers 每次查全表、拼齐全部字段,10 s 一次的节奏不适合调快;码率曲线需要 1 s 粒度,
新加一个只读内存的瘦端点:扫一遍 worker,对每个 Working 的房间读一次阶段一 RateMeter 的窗口均值,
返回 [{id, bytes_per_sec, ts}]。与 /v1/streamers 的 live_bytes_per_sec 同源同值;不新增采样任务、
服务端不存历史、Cache-Control: no-store;注册在 router() 内,--auth 时同一道登录校验。
Part of #1708
* feat(ui): 实时码率数据层与 uPlot 折线组件
- app/lib/use-live-rates.ts:页面内唯一的一份采样源。有订阅者时每秒拉一次 /v1/live-rates,
全部卸载 / 标签页隐藏即停;历史放模块级环形缓冲(每房间最近 3 分钟),组件卸载不清,
切走再切回曲线还在;帧里没有的房间补 null 成断口。useSyncExternalStore 订阅,无新帧不重渲染。
- app/ui/RateChart.tsx:uPlot 折线,full(带时间轴 / Y 轴,单位按窗口最大值自动选 KB/s / MB/s,
Y 从 0 起)与 sparkline(无坐标轴,hover 读数)两种形态;实例在 effect 里建 / 销毁,渲染期不碰 ref,
StrictMode 双次 effect 不留多余 canvas;配色每次绘制时读 Semi CSS 变量,深浅色切换不重建。
无采样 / 未录制只显示「—」占位,不画空图。
- app/ui/LiveRateChart.tsx:next/dynamic 懒加载入口 + 当前 / 峰值 / 均值统计条;
uPlot 与其 CSS 打进独立 chunk(约 23 KB gzip),首屏不含。
- 新依赖 uplot ^1.6.32(本 PR 唯一新依赖)。
Part of #1708
* feat(ui): 三处接入码率折线——弹层可折叠折线、卡片与监视器 sparkline
- 卡片弹层底部新增「写盘速率 · 最近 3 分钟」可折叠区(默认展开,折叠态记 localStorage
biliup.preview.rateChart):标题行带当前 / 峰值 / 均值,展开时才轮询与加载 uPlot。
- 控制台 / 直播管理页录制中卡片:名称行下一条 60 s sparkline(28 px,hover 显示时刻与数值)。
- 历史记录页「实时监视」:工具栏新增「码率图」开关(默认关,记 biliup.monitor.rateChart),
开启后每路小窗下方一条 60 s sparkline;所有小窗共用同一次每秒请求。
Part of #1708
* style(api): live_rates 测试按 rustfmt 换行
* fix(ui): sparkline 的 hover 读数浮在折线上方,不再被容器裁掉
* docs(readme): 功能列表补充直播预览(封面 / 播放器 / 弹幕 / 码率曲线)
#1708 三个阶段至此完整,在 README 的功能列表里加一条说明。
Closes #1708
* feat(api): GET /v1/ws/live-rates 用 WebSocket 每秒推送码率帧
review 1(ForgQi):复用现有的 WebSocket 机制推送而不是每秒轮询。WebSocket 不占浏览器对同一主机
HTTP/1.1 的六个并发连接(监视器 4 路视频 + 1 条弹幕 SSE 已用掉五个,这正是当初不用 SSE 的原因),
与日志页 /v1/ws/logs 同款:Origin 与 Host 同源校验、进程内连接数上限(16)、Ping 回 Pong、Close 即止。
连上立刻发一帧,之后每秒一帧,形状与 GET /v1/live-rates 相同;HTTP 版保留给 WebSocket 连不上时回退与 curl 核对。
两个路由都在 router() 内,--auth 下同一道登录校验。单测用手写的最小 WebSocket 客户端走完握手 + 两帧,不引新依赖。
* feat(ui): 码率数据改走 WebSocket;卡片 sparkline 加开关并叠在封面上
review 1(ForgQi):
- use-live-rates.ts 首选一条 WebSocket /v1/ws/live-rates(服务端每秒推一帧,所有图表共用),
一帧都没收到就断开(反代没放行 upgrade、401)时回退到每秒 GET /v1/live-rates 轮询;中途断开退避重连,
标签页隐藏时断开、可见时重连;快照新增 transport: 'ws' | 'poll'。
- 控制台「进行中」区块头与直播管理页工具栏新增「码率图」开关(默认关,记 localStorage biliup.cards.rateChart,
两页共用);开启后录制中卡片的 sparkline 叠在封面底部(深色渐变垫底、读数条在图区内;点击仍冒泡到封面打开预览),
封面没有 / 加载失败时退到名称行下方。
* test(api): 码率 WebSocket 连接上限测试改用独立信号量
CI 上与同一二进制里并发跑的 WebSocket 推送测试争用进程级信号量(后者占着一个许可),
上限测试拿不满 16 个而失败;改为像 ws.rs 的日志连接测试那样传入独立的 Semaphore。
112 lines
3.7 KiB
TypeScript
112 lines
3.7 KiB
TypeScript
'use client'
|
|
import React, { useMemo } from 'react'
|
|
import dynamic from 'next/dynamic'
|
|
import { Switch, Tooltip, Typography } from '@douyinfe/semi-ui'
|
|
import type { RateChartVariant } from './RateChart'
|
|
import { formatInUnit, rateUnit, readRateSeries, useLiveRates } from '@/app/lib/use-live-rates'
|
|
import { useBoolPref } from '@/app/lib/use-local-pref'
|
|
import styles from './live-rate-chart.module.scss'
|
|
|
|
/**
|
|
* 码率折线的懒加载入口。uPlot(约 22 KB gzip)与 `RateChart` 打在独立 chunk 里,
|
|
* 只有页面上真的出现了一张图才下载;首屏包不含它。
|
|
*/
|
|
const RateChart = dynamic(() => import('./RateChart'), { ssr: false, loading: () => null })
|
|
|
|
/** 弹层折线的窗口:与浏览器内保留的历史一致 */
|
|
export const MODAL_RATE_WINDOW_MS = 3 * 60 * 1000
|
|
/** 卡片 / 监视器 sparkline 的窗口 */
|
|
export const SPARK_RATE_WINDOW_MS = 60 * 1000
|
|
|
|
/** 控制台 / 直播管理页卡片 sparkline 的开关(默认关),两页共用一个偏好 */
|
|
export const CARD_RATE_CHART_KEY = 'biliup.cards.rateChart'
|
|
|
|
export function useCardRateChart(): [boolean, (v: boolean) => void] {
|
|
return useBoolPref(CARD_RATE_CHART_KEY, false)
|
|
}
|
|
|
|
/** 工具栏里的「码率图」开关:开启后录制中卡片的封面上叠一条最近 60 秒的写盘速率折线。 */
|
|
export function CardRateSwitch({ className }: { className?: string }) {
|
|
const [on, setOn] = useCardRateChart()
|
|
const { Text } = Typography
|
|
return (
|
|
<Tooltip content="开启后,录制中卡片的封面上叠加最近 60 秒的写盘速率折线(一条 WebSocket 每秒推一帧,所有卡片共用)">
|
|
<span className={[styles.switchRow, className].filter(Boolean).join(' ')}>
|
|
<Text type="tertiary" size="small">
|
|
码率图
|
|
</Text>
|
|
<Switch size="small" checked={on} onChange={(v) => setOn(!!v)} aria-label="码率图" />
|
|
</span>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
|
|
export function LiveRateChart({
|
|
id,
|
|
windowMs,
|
|
variant = 'full',
|
|
height,
|
|
label,
|
|
className,
|
|
overlay = false,
|
|
}: {
|
|
id: number
|
|
windowMs: number
|
|
variant?: RateChartVariant
|
|
height?: number
|
|
label?: string
|
|
className?: string
|
|
/** 叠在封面 / 画面上:读数条放在图区内部而不是上方 */
|
|
overlay?: boolean
|
|
}) {
|
|
const h = height ?? (variant === 'sparkline' ? 36 : 160)
|
|
// 外层定高:chunk 到达前后布局不跳
|
|
return (
|
|
<div
|
|
className={[styles.slot, className].filter(Boolean).join(' ')}
|
|
style={{ height: h }}
|
|
data-variant={variant}
|
|
data-overlay={overlay || undefined}
|
|
>
|
|
<RateChart id={id} windowMs={windowMs} variant={variant} height={h} label={label} overlay={overlay} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 弹层折线标题里的「当前 / 峰值 / 均值」,与图共用同一份采样与同一个单位。 */
|
|
export function LiveRateSummary({ id, windowMs }: { id: number; windowMs: number }) {
|
|
const snap = useLiveRates()
|
|
const series = useMemo(() => readRateSeries(id, windowMs, snap.receivedAt), [id, windowMs, snap])
|
|
const current = snap.latest.get(id) ?? null
|
|
if (!snap.latest.has(id) || series.latest === null) {
|
|
return (
|
|
<span className={styles.summary} data-state="empty">
|
|
—
|
|
</span>
|
|
)
|
|
}
|
|
const unit = rateUnit(series.max)
|
|
let sum = 0
|
|
let n = 0
|
|
for (const v of series.ys) {
|
|
if (v !== null) {
|
|
sum += v
|
|
n += 1
|
|
}
|
|
}
|
|
const avg = n > 0 ? sum / n : 0
|
|
return (
|
|
<span className={styles.summary} data-state="ready">
|
|
<span>
|
|
当前 <b>{current === null ? '—' : formatInUnit(current, unit)}</b>
|
|
</span>
|
|
<span>
|
|
峰值 <b>{formatInUnit(series.max, unit)}</b>
|
|
</span>
|
|
<span>
|
|
均值 <b>{formatInUnit(avg, unit)}</b>
|
|
</span>
|
|
</span>
|
|
)
|
|
}
|