diff --git a/README.md b/README.md index 735a660..90d1f92 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ * 开箱即用,多种安装方式,提供可视化 WebUi 界面 * 多主播录制/上传,24X7 无人值守运行,高自定义元信息 * 录制同时抓取弹幕,输出 XML 弹幕文件 +* 正在录制的直播间可在 WebUI 内直接预览:复用正在写盘的那一路流(不另外拉流),带封面、实时弹幕与写盘码率曲线,支持多路同屏监视 * 作为自动化流程中的命令行工具封装使用 * 边录边传 0 落盘 diff --git a/app/(app)/page.module.scss b/app/(app)/page.module.scss index 642aead..d48d1e4 100644 --- a/app/(app)/page.module.scss +++ b/app/(app)/page.module.scss @@ -136,6 +136,12 @@ margin-left: 6px; color: var(--semi-color-text-1); } +/* 区块头右侧:码率图开关 + 「直播管理 →」 */ +.secTools { + display: inline-flex; + align-items: center; + gap: 14px; +} .secLink { font-size: 13px; font-weight: 600; diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index f06d367..eab0cdf 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -8,6 +8,7 @@ import { } from '@/app/lib/use-dashboard' import { formatVersion } from '@/app/lib/status' import StreamerCard from '../ui/StreamerCard' +import { CardRateSwitch } from '../ui/LiveRateChart' import EventTimeline from '../ui/EventTimeline' import BackgroundSetter from './components/BackgroundSetter' import styles from './page.module.scss' @@ -123,9 +124,12 @@ export default function Home() { 进行中{live.length} - - 直播管理 → - + + {live.length > 0 ? : null} + + 直播管理 → + + {d.streamersFailed ? ( 主播列表加载失败,无法显示卡片。 diff --git a/app/(app)/streamers/page.module.scss b/app/(app)/streamers/page.module.scss index c8386be..58e2b96 100644 --- a/app/(app)/streamers/page.module.scss +++ b/app/(app)/streamers/page.module.scss @@ -68,6 +68,11 @@ color: var(--semi-color-primary); font-weight: 600; } +/* 工具栏「码率图」开关(网格视图) */ +.rateSwitch { + margin-right: 4px; +} + .toolbarSpacer { flex: 1; } diff --git a/app/(app)/streamers/page.tsx b/app/(app)/streamers/page.tsx index 69be4df..1fe63e1 100644 --- a/app/(app)/streamers/page.tsx +++ b/app/(app)/streamers/page.tsx @@ -41,6 +41,7 @@ import { SLOW_REFRESH_MS, } from '@/app/lib/use-dashboard' import StreamerCard, { LiveAvatar } from '@/app/ui/StreamerCard' +import { CardRateSwitch } from '@/app/ui/LiveRateChart' import PageHeader from '../components/PageHeader' import styles from './page.module.scss' @@ -309,6 +310,7 @@ export default function StreamersPage() { ))} + {layout === 'grid' ? : null}
)} + {rateChart ? ( + + ) : null}
{info?.title || '\u00a0'}
diff --git a/app/ui/LivePreview.tsx b/app/ui/LivePreview.tsx index 1cb5acf..8f0a495 100644 --- a/app/ui/LivePreview.tsx +++ b/app/ui/LivePreview.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import dynamic from 'next/dynamic' import { Button, Modal, Switch, Tag, Toast, Tooltip, Typography } from '@douyinfe/semi-ui' -import { IconPlay, IconRefresh } from '@douyinfe/semi-icons' +import { IconChevronDown, IconPlay, IconRefresh } from '@douyinfe/semi-icons' import type { ButtonProps } from '@douyinfe/semi-ui/lib/es/button' import { LiveStreamerEntity } from '@/app/lib/api-streamer' import { platformName } from '@/app/lib/status' @@ -19,6 +19,7 @@ import { } from '@/app/lib/use-dashboard' import { useBoolPref } from '@/app/lib/use-local-pref' import { type DanmakuFeed, useDanmakuFeed } from '@/app/lib/danmaku-feed' +import { LiveRateChart, LiveRateSummary, MODAL_RATE_WINDOW_MS } from './LiveRateChart' import styles from './live-preview.module.scss' const Players = dynamic(() => import('@/app/ui/Player'), { ssr: false }) @@ -28,6 +29,8 @@ const MAX_AUTO_RECONNECT = 3 const RECONNECT_DELAY_MS = 2000 /** 弹层弹幕开关记在本地,默认开;监视器另有自己的开关(默认关) */ const MODAL_DANMAKU_KEY = 'biliup.preview.danmaku' +/** 弹层底部的码率折线默认展开,折叠状态记在本地 */ +const MODAL_RATE_CHART_KEY = 'biliup.preview.rateChart' type Phase = 'connecting' | 'playing' | 'reconnecting' | 'ended' | 'error' @@ -330,6 +333,7 @@ export function LivePreviewModal({ const [danmakuPref, setDanmakuPref] = useBoolPref(MODAL_DANMAKU_KEY, true) const danmakuOn = visible && danmakuAvailable && danmakuPref const danmakuFeed = useDanmakuFeed([streamer.id], danmakuOn) + const [chartOpen, setChartOpen] = useBoolPref(MODAL_RATE_CHART_KEY, true) const transport = usePreviewTransport() const notifiedRef = useRef(false) useEffect(() => { @@ -396,6 +400,25 @@ export function LivePreviewModal({ {visible ? ( ) : null} + {/* 写盘速率折线:每秒轮询瘦端点 /v1/live-rates,最近 3 分钟;折叠时不轮询、不加载 uPlot */} +
+ + {visible && chartOpen ? ( +
+ +
+ ) : null} +
{transport === 'direct' diff --git a/app/ui/LiveRateChart.tsx b/app/ui/LiveRateChart.tsx new file mode 100644 index 0000000..4188f0e --- /dev/null +++ b/app/ui/LiveRateChart.tsx @@ -0,0 +1,111 @@ +'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 ( + + + + 码率图 + + setOn(!!v)} aria-label="码率图" /> + + + ) +} + +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 ( +
+ +
+ ) +} + +/** 弹层折线标题里的「当前 / 峰值 / 均值」,与图共用同一份采样与同一个单位。 */ +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 ( + + — + + ) + } + 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 ( + + + 当前 {current === null ? '—' : formatInUnit(current, unit)} + + + 峰值 {formatInUnit(series.max, unit)} + + + 均值 {formatInUnit(avg, unit)} + + + ) +} diff --git a/app/ui/RateChart.tsx b/app/ui/RateChart.tsx new file mode 100644 index 0000000..06ca4da --- /dev/null +++ b/app/ui/RateChart.tsx @@ -0,0 +1,264 @@ +'use client' +import React, { useEffect, useMemo, useRef } from 'react' +import uPlot from 'uplot' +import 'uplot/dist/uPlot.min.css' +import { formatInUnit, rateUnit, readRateSeries, useLiveRates, type RateSeries } from '@/app/lib/use-live-rates' +import styles from './rate-chart.module.scss' + +/** + * 写盘速率折线(uPlot)。本模块整体懒加载(消费方用 `next/dynamic` 引入),uPlot 与它的 CSS + * 只在第一次真正要画图时才下载,不进首屏。 + * + * 两种形态: + * - `full`:弹层底部的折线,带时间轴与 Y 轴(单位按窗口最大值自动选 KB/s / MB/s),hover 读数; + * - `sparkline`:卡片 / 监视器小窗里的一条线,不画坐标轴,hover 显示数值与时刻。 + * + * 数据来自 `useLiveRates()` 共享的采样历史;uPlot 实例在 effect 里创建 / 销毁,渲染期不碰 ref。 + * 配色在每次绘制时从 Semi 的 CSS 变量读取,切换深浅色不用重建实例。uPlot 本身没有动画, + * 因此 `prefers-reduced-motion` 下没有需要关的东西;样式里也不加过渡。 + * 没有任何采样(未录制 / 断流 / 下载器不计字节)时只显示「—」占位,不画空图。 + */ + +export type RateChartVariant = 'full' | 'sparkline' + +interface Props { + /** 直播间 id */ + id: number + /** 展示的时间窗口(毫秒) */ + windowMs: number + variant?: RateChartVariant + /** 图高(CSS px);宽随容器 */ + height?: number + className?: string + /** 可访问名称前缀 */ + label?: string + /** 叠在封面 / 画面上:读数条放图区内部,占位「—」不画虚线框 */ + overlay?: boolean +} + +const FONT = '11px system-ui, -apple-system, "Segoe UI", sans-serif' + +/** 读 Semi 主题变量。`--semi-green-5` 是 `r,g,b` 三元组,方便拼半透明填充。 */ +function themeColor(name: string, fallback: string): string { + if (typeof document === 'undefined') return fallback + const v = getComputedStyle(document.body).getPropertyValue(name).trim() + return v || fallback +} +const lineColor = () => `rgba(${themeColor('--semi-green-5', '59,179,70')}, 1)` +const fillColor = () => `rgba(${themeColor('--semi-green-5', '59,179,70')}, 0.16)` +const gridColor = () => themeColor('--semi-color-border', 'rgba(28,31,35,0.08)') +const axisColor = () => themeColor('--semi-color-text-2', 'rgba(28,31,35,0.62)') + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n) +} +function hhmmss(sec: number): string { + const d = new Date(sec * 1000) + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}` +} + +function seriesMax(u: uPlot): number { + let max = 0 + for (const v of u.data[1] ?? []) if (v !== null && v !== undefined && v > max) max = v + return max +} + +/** Y 轴从 0 起,顶上留 15% 余量;全 0 时给 1 免得刻度退化 */ +function yRange(u: uPlot): uPlot.Range.MinMax { + const max = seriesMax(u) + return [0, max > 0 ? max * 1.15 : 1] +} + +/** 横轴始终是「最新一点往前 windowSec 秒」:只有几个点时曲线从右端往左长,不会先铺满再压缩 */ +function xRange(windowSec: number) { + return (u: uPlot): uPlot.Range.MinMax => { + const xs = u.data[0] + const end = xs && xs.length > 0 ? xs[xs.length - 1] : Date.now() / 1000 + return [end - windowSec, end] + } +} + +function buildOptions(variant: RateChartVariant, width: number, height: number, windowMs: number): uPlot.Options { + const spark = variant === 'sparkline' + return { + width, + height, + // 图例用自己的读数条替代;uPlot 的表格图例会撑开高度 + legend: { show: false }, + // 只允许 hover 读数,不做框选缩放(缩放对实时滚动窗口没有意义) + cursor: { + x: !spark, + y: false, + drag: { x: false, y: false, setScale: false }, + points: { show: true, size: spark ? 6 : 8, fill: lineColor, stroke: lineColor }, + lock: false, + }, + select: { show: false, left: 0, top: 0, width: 0, height: 0 }, + scales: { + x: { time: true, range: xRange(windowMs / 1000) }, + y: { range: yRange }, + }, + series: [ + {}, + { + label: '写盘速率', + stroke: lineColor, + fill: spark ? fillColor : undefined, + width: spark ? 1.5 : 2, + // 断口(null)不连线:录制中断的那段就是空白 + spanGaps: false, + points: { show: false }, + }, + ], + axes: spark + ? [{ show: false }, { show: false }] + : [ + { + stroke: axisColor, + grid: { stroke: gridColor, width: 1 }, + ticks: { stroke: gridColor, width: 1, size: 4 }, + font: FONT, + size: 22, + gap: 4, + values: (_u, splits) => splits.map(hhmmss), + }, + { + stroke: axisColor, + grid: { stroke: gridColor, width: 1 }, + ticks: { show: false }, + font: FONT, + size: 58, + gap: 6, + // 四格等分、0 在底 + splits: (_u, _axisIdx, _scaleMin, scaleMax) => [0, 1, 2, 3, 4].map((k) => (scaleMax / 4) * k), + values: (u, splits) => { + const unit = rateUnit(seriesMax(u)) + return splits.map((s) => (s === 0 ? '0' : formatInUnit(s, unit))) + }, + }, + ], + } +} + +/** + * 真正持有 uPlot 实例的部分;外层已确认窗口内有采样才挂它。 + * 创建 / 销毁与数据更新分成两个 effect:StrictMode 双次执行时第一份实例在清理里销毁, + * 不会留下多余的 canvas。 + */ +function Plot({ + id, + windowMs, + variant, + height, + series, + overlay, +}: { + id: number + windowMs: number + variant: RateChartVariant + height: number + series: RateSeries + overlay: boolean +}) { + const hostRef = useRef(null) + const readoutRef = useRef(null) + const plotRef = useRef(null) + + useEffect(() => { + const host = hostRef.current + const readout = readoutRef.current + if (!host) return + const width = Math.max(1, Math.floor(host.clientWidth)) + const opts = buildOptions(variant, width, height, windowMs) + // hover 读数:直接写 DOM,不走 React 状态(mousemove 频率高,不值得重渲染) + opts.hooks = { + setCursor: [ + (u) => { + if (!readout) return + const idx = u.cursor.idx + if (idx === null || idx === undefined) { + readout.hidden = true + return + } + const x = u.data[0][idx] + const y = u.data[1][idx] + const text = y === null || y === undefined ? '—' : formatInUnit(y, rateUnit(seriesMax(u))) + readout.textContent = `${hhmmss(x)} · ${text}` + readout.hidden = false + // 跟着光标左右走,不出图区 + const plotLeft = u.bbox.left / (window.devicePixelRatio || 1) + const left = plotLeft + Math.min(Math.max(0, u.cursor.left ?? 0), Math.max(0, u.bbox.width / (window.devicePixelRatio || 1) - readout.offsetWidth)) + readout.style.transform = `translateX(${Math.round(left)}px)` + }, + ], + } + // 初始数据直接从共享历史读(import 异步、props 可能已经过时),随后的更新走下面那个 effect + const initial = readRateSeries(id, windowMs, Date.now()) + const plot = new uPlot(opts, [initial.xs, initial.ys], host) + plotRef.current = plot + const ro = new ResizeObserver(() => { + const w = Math.floor(host.clientWidth) + if (w > 0 && w !== plot.width) plot.setSize({ width: w, height }) + }) + ro.observe(host) + return () => { + ro.disconnect() + plot.destroy() + plotRef.current = null + if (readout) readout.hidden = true + } + }, [id, windowMs, variant, height]) + + // 新一帧到达:只换数据,不重建实例 + useEffect(() => { + plotRef.current?.setData([series.xs, series.ys]) + }, [series]) + + return ( +
+
+ ) +} + +export default function RateChart({ id, windowMs, variant = 'full', height, className, label, overlay = false }: Props) { + const snap = useLiveRates() + // 环形缓冲只随快照版本变化,快照引用就是重新读序列的触发键;`receivedAt` 让渲染期的读取保持纯粹 + const series = useMemo(() => readRateSeries(id, windowMs, snap.receivedAt), [id, windowMs, snap]) + const h = height ?? (variant === 'sparkline' ? 36 : 160) + const unit = rateUnit(series.max) + const wrapCls = [styles.chart, className].filter(Boolean).join(' ') + + // 房间不在最近一帧里(停录 / 下播 / 接口不可用)或窗口内从没有过采样(yt-dlp 等不计字节)→ 只给「—」 + const recording = snap.latest.has(id) + if (!recording || series.latest === null) { + return ( +
+ — +
+ ) + } + + const current = formatInUnit(series.latest, unit) + return ( +
+ +
+ ) +} diff --git a/app/ui/StreamerCard.tsx b/app/ui/StreamerCard.tsx index 18404bb..52161df 100644 --- a/app/ui/StreamerCard.tsx +++ b/app/ui/StreamerCard.tsx @@ -12,6 +12,7 @@ import { canPreview, } from '@/app/lib/use-dashboard' import { LivePreviewButton, LivePreviewModal } from './LivePreview' +import { LiveRateChart, SPARK_RATE_WINDOW_MS, useCardRateChart } from './LiveRateChart' import styles from './streamer-card.module.scss' export interface StreamerCardProps { @@ -54,10 +55,16 @@ export function LiveCover({ src, alt, onPreview, + overlay, + onStateChange, }: { src: string alt: string onPreview?: () => void + /** 叠在封面底部的一层(码率 sparkline);点击会冒泡到封面本身的 onClick */ + overlay?: React.ReactNode + /** 加载结果回调:父组件据此决定 overlay 放不放得下(加载失败整块消失) */ + onStateChange?: (state: 'ok' | 'error') => void }) { const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading') if (state === 'error') return null @@ -87,8 +94,14 @@ export function LiveCover({ fill sizes="(max-width: 600px) 100vw, 400px" loading="lazy" - onLoad={() => setState('ok')} - onError={() => setState('error')} + onLoad={() => { + setState('ok') + onStateChange?.('ok') + }} + onError={() => { + setState('error') + onStateChange?.('error') + }} /> {clickable ? ( ) : null} + {overlay ?
{overlay}
: null} ) } @@ -156,6 +170,23 @@ export default function StreamerCard({ const avatarSrc = live ? liveImageUrl(streamer.id, 'avatar', streamer.live_avatar_url) : null const rate = live ? formatRate(streamer.live_bytes_per_sec) : null + // 码率 sparkline(页面工具栏「码率图」开关,默认关):有封面就叠在封面底部,封面没有 / 加载失败就放名称行下 + const [rateChartOn] = useCardRateChart() + const [coverFailed, setCoverFailed] = useState(false) + const coverShown = !!coverSrc && !coverFailed + const sparkline = + live && rateChartOn ? ( + + ) : null + const plat = platformName(streamer.url) const platColor = PLAT_COLORS[plat] ?? DEFAULT_PLAT_COLOR @@ -203,6 +234,8 @@ export default function StreamerCard({ src={coverSrc} alt={`${name} 的直播间封面`} onPreview={previewable ? () => setPreviewOpen(true) : undefined} + overlay={coverShown ? sparkline : null} + onStateChange={(s) => setCoverFailed(s === 'error')} /> ) : null} @@ -231,6 +264,9 @@ export default function StreamerCard({ ) : null}
+ {/* 开了码率图但没有封面可叠:sparkline 放这一行 */} + {sparkline && !coverShown ? sparkline : null} + {/* 标题行:始终渲染(空字符串占位),保证所有卡片等高、标题基线对齐 */}
{info?.title || ''} diff --git a/app/ui/live-monitor.module.scss b/app/ui/live-monitor.module.scss index f88b24f..5637471 100644 --- a/app/ui/live-monitor.module.scss +++ b/app/ui/live-monitor.module.scss @@ -155,6 +155,12 @@ color: rgba(255, 255, 255, 0.85); } +/* 小窗下的 60 s 码率 sparkline(工具栏「码率图」开关,默认关) */ +.tileSpark { + padding: 6px 12px 0; + box-sizing: content-box; +} + .tileFoot { padding: 7px 12px 9px; font-size: 12px; diff --git a/app/ui/live-preview.module.scss b/app/ui/live-preview.module.scss index d5f0c0c..bb2d50a 100644 --- a/app/ui/live-preview.module.scss +++ b/app/ui/live-preview.module.scss @@ -126,6 +126,62 @@ padding: 10px 24px 14px; } +/* ===== 弹层底部:写盘速率折线(可折叠) ===== */ +.rateSection { + border-top: 1px solid var(--semi-color-border); +} +.rateHead { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 24px; + border: 0; + background: transparent; + color: var(--semi-color-text-1); + font: inherit; + font-size: 12.5px; + text-align: left; + cursor: pointer; +} +.rateHead:hover { + background: var(--semi-color-fill-0); +} +.rateHead:focus-visible { + outline: 2px solid var(--semi-color-primary); + outline-offset: -2px; +} +.rateTitle { + font-weight: 600; + white-space: nowrap; + margin-right: auto; +} +.rateChevron { + flex-shrink: 0; + color: var(--semi-color-text-2); + transition: transform 0.15s ease; +} +.rateSection[data-open='false'] .rateChevron { + transform: rotate(-90deg); +} +.rateBody { + padding: 4px 24px 8px; +} +@media (max-width: 600px) { + .rateHead { + flex-wrap: wrap; + padding: 8px 16px; + } + .rateBody { + padding: 4px 12px 8px; + } +} +@media (prefers-reduced-motion: reduce) { + .rateChevron { + transition: none; + } +} + /* disabled 的按钮不响应鼠标,包一层让 Tooltip 仍能弹出 */ .disabledWrap { display: inline-flex; diff --git a/app/ui/live-rate-chart.module.scss b/app/ui/live-rate-chart.module.scss new file mode 100644 index 0000000..ffbddff --- /dev/null +++ b/app/ui/live-rate-chart.module.scss @@ -0,0 +1,31 @@ +/* 码率折线的懒加载壳与统计条 */ + +.slot { + position: relative; + width: 100%; + min-width: 0; +} + +.switchRow { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.summary { + display: inline-flex; + align-items: baseline; + gap: 12px; + font-size: 12px; + color: var(--semi-color-text-2); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.summary b { + font-weight: 600; + color: var(--semi-color-text-0); +} +.summary[data-state='empty'] { + color: var(--semi-color-text-3); +} diff --git a/app/ui/rate-chart.module.scss b/app/ui/rate-chart.module.scss new file mode 100644 index 0000000..1d43553 --- /dev/null +++ b/app/ui/rate-chart.module.scss @@ -0,0 +1,74 @@ +/* 写盘速率折线(uPlot):弹层折线 + 卡片 / 监视器 sparkline 共用。不加过渡动画。 */ + +.chart { + position: relative; + width: 100%; + min-width: 0; +} + +/* uPlot 挂载点:宽随容器、高由组件内联指定。不裁剪,读数条要能浮在 sparkline 上方 */ +.plotHost { + position: relative; + width: 100%; +} +.plotHost :global(.uplot) { + font-family: inherit; +} +/* 光标线与点用主题色 */ +.plotHost :global(.u-cursor-x) { + border-right: 1px dashed var(--semi-color-text-3); +} + +/* hover 读数:贴在图区顶部,随光标横向移动 */ +.readout { + position: absolute; + top: 2px; + left: 0; + z-index: 2; + padding: 1px 6px; + border-radius: 4px; + font-size: 11px; + line-height: 16px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + color: var(--semi-color-text-0); + background: var(--semi-color-bg-3); + border: 1px solid var(--semi-color-border); + box-shadow: 0 1px 4px rgba(var(--semi-grey-9), 0.12); + pointer-events: none; +} +.plotHost[data-variant='sparkline'] .readout { + top: -20px; +} +/* 叠在封面上时读数条放图区内部(封面容器会裁掉外面的部分) */ +.plotHost[data-overlay] .readout { + top: 2px; +} + +/* 无采样:只显示「—」 */ +.chart[data-state='empty'], +.chart[data-state='error'] { + display: flex; + align-items: center; + justify-content: center; + color: var(--semi-color-text-3); + font-variant-numeric: tabular-nums; +} +.chart[data-variant='full'][data-state='empty'], +.chart[data-variant='full'][data-state='error'] { + border: 1px dashed var(--semi-color-border); + border-radius: 8px; + font-size: 13px; +} +.chart[data-variant='sparkline'][data-state='empty'], +.chart[data-variant='sparkline'][data-state='error'] { + font-size: 12px; +} +/* 叠在封面上的占位:白字、不加边框 */ +.chart[data-overlay][data-state='empty'], +.chart[data-overlay][data-state='error'] { + color: rgba(255, 255, 255, 0.75); +} +.placeholder { + user-select: none; +} diff --git a/app/ui/streamer-card.module.scss b/app/ui/streamer-card.module.scss index 3060434..f3dde06 100644 --- a/app/ui/streamer-card.module.scss +++ b/app/ui/streamer-card.module.scss @@ -258,6 +258,25 @@ align-self: center; } +/* 直播中:最近 60 秒写盘速率 sparkline(无坐标轴,hover 显示数值);没有封面可叠时放名称行下 */ +.spark { + margin: 2px 0 6px; +} + +/* 叠在封面底部的 sparkline:深色渐变垫底让绿线在任何封面上都可读;点击仍冒泡到封面打开预览 */ +.coverOverlay { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 10px 6px 2px; + background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.35) 60%, rgba(0, 0, 0, 0)); + pointer-events: none; +} +.coverOverlay > * { + pointer-events: auto; +} + /* 未开播:最近录制时间 */ .recInfo { font-size: 12px; diff --git a/crates/biliup-cli/src/server/api/live_rates.rs b/crates/biliup-cli/src/server/api/live_rates.rs new file mode 100644 index 0000000..74fc501 --- /dev/null +++ b/crates/biliup-cli/src/server/api/live_rates.rs @@ -0,0 +1,436 @@ +//! 正在录制的直播间的写盘速率,供前端画码率曲线: +//! +//! - `GET /v1/ws/live-rates`:WebSocket,连上后每秒推一帧(JSON 数组),前端首选。 +//! WebSocket 不占浏览器对同一主机 HTTP/1.1 的六个并发连接(监视器 4 路视频 + 1 条弹幕 SSE +//! 已经用掉五个,再挂一条 SSE 会把列表轮询饿死),与日志页的 `/v1/ws/logs` 是同一套机制。 +//! - `GET /v1/live-rates`:同一帧的一次性 HTTP 版本,供 WebSocket 连不上(反代没放行 upgrade)时 +//! 每秒轮询回退,也方便 curl 核对。 +//! +//! `/v1/streamers` 每次要查全表、拼齐每个主播的全部字段,10 s 一次的节奏不适合调快; +//! 这里只在内存里扫一遍 worker,对每个 `Working` 的房间读一次 [`RateMeter`] 的窗口均值—— +//! 与 `/v1/streamers` 的 `live_bytes_per_sec` 是同一个数,同一秒读到的值相同。 +//! 不落库、不新开采样任务、服务端不存历史:曲线的历史由浏览器自己累积(最近 3 分钟)。 +//! +//! 两个路由都注册在 `router()` 里,`--auth` 时与 `/v1/streamers` 同一道登录校验。 +//! +//! [`RateMeter`]: crate::server::common::throughput::RateMeter + +use crate::server::api::ws::websocket_origin_allowed; +use crate::server::core::download_manager::DownloadManager; +use crate::server::infrastructure::context::{Worker, WorkerStatus}; +use axum::Json; +use axum::extract::State; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio::time::{MissedTickBehavior, interval}; +use tracing::debug; + +/// WebSocket 推送间隔;后端 `RateMeter` 本身 1 s 采一次,再快没有新信息。 +pub const PUSH_INTERVAL: Duration = Duration::from_secs(1); +/// 进程内同时允许的码率 WebSocket 连接数(每个标签页一条)。 +pub const MAX_RATE_CONNECTIONS: usize = 16; + +fn rate_connection_limit() -> &'static Arc { + static LIMIT: OnceLock> = OnceLock::new(); + LIMIT.get_or_init(|| Arc::new(Semaphore::new(MAX_RATE_CONNECTIONS))) +} + +fn acquire_rate_permit(limiter: Arc) -> Option { + limiter.try_acquire_owned().ok() +} + +/// 一个正在录制的直播间在某一秒的写盘速率。 +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct LiveRateFrame { + /// 直播间 id(`livestreamers.id`,与 `/v1/streamers` 一致) + pub id: i64, + /// 最近一个滑动窗口内的写盘速率(字节/秒);尚无采样或下载器不经本进程写盘时为 `null` + pub bytes_per_sec: Option, + /// 服务端读数的时刻,Unix 毫秒;同一次响应里所有房间相同 + pub ts: i64, +} + +/// 从 worker 列表里挑出正在录制的房间,按 id 升序给出各自的速率。 +pub fn live_rate_frames(workers: &[Arc], ts: i64) -> Vec { + let mut frames: Vec = workers + .iter() + .filter_map(|worker| { + let status = worker.downloader_status.read().unwrap(); + match &*status { + WorkerStatus::Working(task) => Some(LiveRateFrame { + id: worker.id(), + bytes_per_sec: task.bytes_per_sec(), + ts, + }), + _ => None, + } + }) + .collect(); + frames.sort_by_key(|frame| frame.id); + frames +} + +/// 当前这一秒的一帧:所有录制中房间的速率。 +async fn current_frames(managers: &DownloadManager) -> Vec { + let workers = managers.get_rooms().await; + live_rate_frames(&workers, chrono::Utc::now().timestamp_millis()) +} + +/// `GET /v1/live-rates` +pub async fn get_live_rates(State(managers): State>) -> Response { + let mut response = Json(current_frames(&managers).await).into_response(); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +/// `GET /v1/ws/live-rates`:WebSocket,连上后立刻发一帧,之后每秒一帧(与 `/v1/live-rates` 同一形状的 +/// JSON 数组)。客户端不需要发任何东西;Ping 回 Pong,Close 即结束。Origin 校验与连接数上限 +/// 与 `/v1/ws/logs` 同款。 +pub async fn ws_live_rates( + ws: WebSocketUpgrade, + State(managers): State>, + headers: HeaderMap, +) -> Response { + if !websocket_origin_allowed(&headers) { + return (StatusCode::FORBIDDEN, "WebSocket Origin 不受信任").into_response(); + } + let Some(permit) = acquire_rate_permit(rate_connection_limit().clone()) else { + return ( + StatusCode::TOO_MANY_REQUESTS, + format!("码率推送连接数已达上限(进程内最多 {MAX_RATE_CONNECTIONS} 条)"), + ) + .into_response(); + }; + ws.on_upgrade(move |socket| async move { + let _permit = permit; + push_live_rates(socket, managers).await; + }) +} + +async fn push_live_rates(mut ws: WebSocket, managers: Arc) { + let mut tick = interval(PUSH_INTERVAL); + tick.set_missed_tick_behavior(MissedTickBehavior::Skip); + debug!("开始码率推送"); + loop { + tokio::select! { + maybe_msg = ws.recv() => { + match maybe_msg { + Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, + Some(Ok(Message::Ping(payload))) => { + if ws.send(Message::Pong(payload)).await.is_err() { + break; + } + } + Some(Ok(_)) => {} + } + } + _ = tick.tick() => { + let frames = current_frames(&managers).await; + let Ok(json) = serde_json::to_string(&frames) else { continue }; + if ws.send(Message::Text(json.into())).await.is_err() { + break; + } + } + } + } + let _ = ws.send(Message::Close(None)).await; + debug!("码率推送结束"); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::common::download::DownloadTask; + use crate::server::config::Config; + use crate::server::core::downloader::{DownloaderRuntime, DownloaderType}; + use crate::server::infrastructure::connection_pool::ConnectionManager; + use crate::server::infrastructure::context::Stage; + use crate::server::infrastructure::models::live_streamer::LiveStreamer; + use biliup::downloader::live::{DownloaderHint, LiveStream}; + use std::collections::HashMap; + use std::sync::RwLock; + use std::time::{Duration, Instant}; + + fn streamer(id: i64) -> LiveStreamer { + LiveStreamer { + id, + url: format!("https://live.bilibili.com/{id}"), + remark: format!("room {id}"), + filename_prefix: None, + time_range: None, + upload_streamers_id: None, + format: None, + override_cfg: None, + preprocessor: None, + segment_processor: None, + downloaded_processor: None, + postprocessor: None, + opt_args: None, + excluded_keywords: None, + } + } + + fn live_stream() -> LiveStream { + LiveStream { + name: "room".into(), + url: "https://live.bilibili.com/1".into(), + title: "t".into(), + date: chrono::Utc::now(), + live_cover_url: String::new(), + avatar_url: None, + raw_stream_url: "https://cdn.example/live.flv".into(), + platform: "bilibili".into(), + stream_headers: HashMap::new(), + suffix: "flv".into(), + danmaku: None, + downloader_hint: DownloaderHint::StreamGears, + runtime_options: None, + } + } + + fn idle_worker(id: i64) -> Arc { + Arc::new(Worker::new( + streamer(id), + None, + Arc::new(RwLock::new(Config::default())), + Default::default(), + )) + } + + async fn working_worker( + id: i64, + downloader: DownloaderType, + ) -> (Arc, Arc) { + let worker = idle_worker(id); + let task = Arc::new(DownloadTask::new( + DownloaderRuntime::from_type(downloader), + &live_stream(), + )); + worker + .change_status(Stage::Download, WorkerStatus::Working(task.clone())) + .await; + (worker, task) + } + + /// 只报告正在录制的房间;刚开录、还没有两次采样时给 `null` 而不是 0。 + #[tokio::test] + async fn only_recording_rooms_are_reported_sorted_by_id() { + let (working_b, _) = working_worker(7, DownloaderType::StreamGears).await; + let (working_a, _) = working_worker(3, DownloaderType::StreamGears).await; + let workers = vec![idle_worker(1), working_b, working_a, idle_worker(9)]; + + let frames = live_rate_frames(&workers, 1_700_000_000_123); + assert_eq!( + frames, + vec![ + LiveRateFrame { + id: 3, + bytes_per_sec: None, + ts: 1_700_000_000_123, + }, + LiveRateFrame { + id: 7, + bytes_per_sec: None, + ts: 1_700_000_000_123, + }, + ] + ); + } + + /// 曲线上的点与卡片数字必须是同一个数:都来自同一个 `RateMeter` 的同一次读数。 + #[tokio::test] + async fn the_rate_is_the_same_number_the_streamer_list_reports() { + let (worker, task) = working_worker(1, DownloaderType::StreamGears).await; + let meter = task.rate_meter(); + let t0 = Instant::now() - Duration::from_secs(2); + meter.sample_at(t0); + meter.counter().add(2_000_000); + meter.sample_at(t0 + Duration::from_secs(2)); + + let frames = live_rate_frames(&[worker], 0); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].bytes_per_sec, Some(1_000_000)); + assert_eq!(frames[0].bytes_per_sec, task.bytes_per_sec()); + } + + /// 字节不经过本进程的下载器(边录边传 / yt-dlp)与列表一样报 `null`。 + #[tokio::test] + async fn downloaders_without_a_counter_report_null() { + let (worker, task) = working_worker(1, DownloaderType::YtDlp).await; + task.rate_meter().counter().add(4096); + task.rate_meter().sample(); + let frames = live_rate_frames(&[worker], 0); + assert_eq!(frames[0].bytes_per_sec, None); + } + + /// 前端按这三个键读:`id` / `bytes_per_sec` / `ts`。 + #[test] + fn the_frame_serialises_to_the_shape_the_frontend_reads() { + let json = serde_json::to_value(LiveRateFrame { + id: 5, + bytes_per_sec: Some(655_360), + ts: 1_700_000_000_000, + }) + .unwrap(); + assert_eq!( + json, + serde_json::json!({ "id": 5, "bytes_per_sec": 655_360, "ts": 1_700_000_000_000_i64 }) + ); + let json = serde_json::to_value(LiveRateFrame { + id: 5, + bytes_per_sec: None, + ts: 0, + }) + .unwrap(); + assert_eq!(json["bytes_per_sec"], serde_json::Value::Null); + } + + /// 手写一个最小的 WebSocket 客户端(握手 + 读服务端未掩码的帧),不为测试引新依赖。 + mod ws_client { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpStream; + + pub async fn handshake(addr: &str, path: &str, origin: &str) -> (TcpStream, u16) { + let mut stream = TcpStream::connect(addr).await.unwrap(); + let request = format!( + "GET {path} HTTP/1.1\r\nHost: {addr}\r\nOrigin: {origin}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n" + ); + stream.write_all(request.as_bytes()).await.unwrap(); + // 逐字节读到响应头结束 + let mut head = Vec::new(); + let mut byte = [0u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).await.unwrap(); + head.push(byte[0]); + } + let status: u16 = std::str::from_utf8(&head) + .unwrap() + .split_whitespace() + .nth(1) + .unwrap() + .parse() + .unwrap(); + (stream, status) + } + + /// 读一帧文本(服务端发出的帧不带掩码);Close 帧返回 None。 + pub async fn read_text(stream: &mut TcpStream) -> Option { + let mut header = [0u8; 2]; + stream.read_exact(&mut header).await.unwrap(); + let opcode = header[0] & 0x0f; + let mut len = (header[1] & 0x7f) as u64; + if len == 126 { + let mut ext = [0u8; 2]; + stream.read_exact(&mut ext).await.unwrap(); + len = u16::from_be_bytes(ext) as u64; + } else if len == 127 { + let mut ext = [0u8; 8]; + stream.read_exact(&mut ext).await.unwrap(); + len = u64::from_be_bytes(ext); + } + let mut payload = vec![0u8; len as usize]; + stream.read_exact(&mut payload).await.unwrap(); + match opcode { + 0x1 => Some(String::from_utf8(payload).unwrap()), + 0x8 => None, + other => panic!("unexpected opcode {other}"), + } + } + } + + async fn serve(managers: Arc) -> String { + use axum::Router; + use axum::routing::get; + let app = Router::new() + .route("/v1/ws/live-rates", get(ws_live_rates)) + .route("/v1/live-rates", get(get_live_rates)) + .with_state(managers); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + addr + } + + /// WebSocket:连上立刻一帧、之后每秒一帧,形状与 HTTP 版一致;Origin 与 Host 不同源时 403。 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_websocket_pushes_one_frame_per_second_in_the_same_shape() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("data.sqlite3"); + let pool = ConnectionManager::new_pool(db.to_str().unwrap()) + .await + .unwrap(); + let managers = Arc::new(DownloadManager::new(1, 0, pool)); + let addr = serve(managers).await; + + let (_stream, status) = + ws_client::handshake(&addr, "/v1/ws/live-rates", "https://attacker.example").await; + assert_eq!(status, 403, "Origin 与 Host 不同源应被拒绝"); + + let (mut stream, status) = + ws_client::handshake(&addr, "/v1/ws/live-rates", &format!("http://{addr}")).await; + assert_eq!(status, 101); + let t0 = Instant::now(); + let first = ws_client::read_text(&mut stream).await.unwrap(); + assert!(t0.elapsed() < Duration::from_millis(500), "第一帧应立刻到"); + let frames: Vec = serde_json::from_str(&first).unwrap(); + assert!(frames.is_empty(), "没有录制中的房间时是空数组"); + let second = ws_client::read_text(&mut stream).await.unwrap(); + let elapsed = t0.elapsed(); + assert!( + elapsed >= Duration::from_millis(800) && elapsed < Duration::from_millis(1800), + "第二帧应在约 1 s 后到,实际 {elapsed:?}" + ); + let _: Vec = serde_json::from_str(&second).unwrap(); + } + + /// 码率 WebSocket 连接数有上限,断开后释放。 + /// 用独立的信号量而不是进程级那个:同一测试二进制里的 WebSocket 测试会并发占着一个许可。 + #[test] + fn websocket_rate_connections_are_bounded() { + let limiter = Arc::new(Semaphore::new(MAX_RATE_CONNECTIONS)); + let permits: Vec<_> = (0..MAX_RATE_CONNECTIONS) + .map(|_| acquire_rate_permit(limiter.clone()).unwrap()) + .collect(); + assert!(acquire_rate_permit(limiter.clone()).is_none()); + drop(permits); + assert!(acquire_rate_permit(limiter).is_some()); + } + + /// 处理函数:空列表也是合法响应(`[]`),且禁止缓存——每秒都要拿到新读数。 + #[tokio::test] + async fn the_endpoint_returns_an_uncacheable_json_array() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("data.sqlite3"); + let pool = ConnectionManager::new_pool(db.to_str().unwrap()) + .await + .unwrap(); + let managers = Arc::new(DownloadManager::new(1, 0, pool)); + + let response = get_live_rates(State(managers)).await; + assert_eq!(response.status(), axum::http::StatusCode::OK); + assert_eq!( + response.headers().get(header::CACHE_CONTROL).unwrap(), + "no-store" + ); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap(), + "application/json" + ); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"[]"); + } +} diff --git a/crates/biliup-cli/src/server/api/mod.rs b/crates/biliup-cli/src/server/api/mod.rs index 2e6714a..f1b7678 100644 --- a/crates/biliup-cli/src/server/api/mod.rs +++ b/crates/biliup-cli/src/server/api/mod.rs @@ -8,6 +8,8 @@ pub mod endpoints; pub mod live_media; /// 直播预览:把正在录制的流旁路给页面内播放器 pub mod live_preview; +/// 录制中直播间的写盘速率(每秒轮询的瘦端点,供码率曲线) +pub mod live_rates; /// 单页应用静态文件处理 pub mod spa; pub mod ws; diff --git a/crates/biliup-cli/src/server/api/ws.rs b/crates/biliup-cli/src/server/api/ws.rs index 78dd29a..3aa8a01 100644 --- a/crates/biliup-cli/src/server/api/ws.rs +++ b/crates/biliup-cli/src/server/api/ws.rs @@ -48,7 +48,8 @@ fn acquire_log_permit(limiter: Arc) -> Option { limiter.try_acquire_owned().ok() } -fn websocket_origin_allowed(headers: &HeaderMap) -> bool { +/// WebSocket 握手的 Origin 校验:与 Host 同源,或是本地开发前端。日志与码率两个 WS 共用。 +pub(crate) fn websocket_origin_allowed(headers: &HeaderMap) -> bool { let Some(origin) = headers .get(header::ORIGIN) .and_then(|value| value.to_str().ok()) diff --git a/crates/biliup-cli/src/server/common/download.rs b/crates/biliup-cli/src/server/common/download.rs index 627b72d..486f8fb 100644 --- a/crates/biliup-cli/src/server/common/download.rs +++ b/crates/biliup-cli/src/server/common/download.rs @@ -232,6 +232,12 @@ impl DownloadTask { } } + /// 本任务的写盘速率表(测试里用来喂采样)。 + #[cfg(test)] + pub(crate) fn rate_meter(&self) -> &RateMeter { + &self.meter + } + /// 当前直播间的封面与头像地址。 pub fn live_media(&self) -> LiveMedia { self.media.read().unwrap().clone() diff --git a/crates/biliup-cli/src/server/common/throughput.rs b/crates/biliup-cli/src/server/common/throughput.rs index 747ef51..0099764 100644 --- a/crates/biliup-cli/src/server/common/throughput.rs +++ b/crates/biliup-cli/src/server/common/throughput.rs @@ -46,7 +46,7 @@ impl RateMeter { self.sample_at(Instant::now()); } - fn sample_at(&self, now: Instant) { + pub(crate) fn sample_at(&self, now: Instant) { let total = self.counter.total(); let mut samples = self.samples.lock().unwrap(); samples.push_back((now, total)); diff --git a/crates/biliup-cli/src/server/router.rs b/crates/biliup-cli/src/server/router.rs index 4c95474..c6afd48 100644 --- a/crates/biliup-cli/src/server/router.rs +++ b/crates/biliup-cli/src/server/router.rs @@ -13,6 +13,7 @@ use crate::server::api::live_media::{get_live_avatar, get_live_cover}; use crate::server::api::live_preview::{ get_live_danmaku, get_live_danmaku_multi, get_live_stream, get_live_url, }; +use crate::server::api::live_rates::{get_live_rates, ws_live_rates}; use crate::server::infrastructure::service_register::ServiceRegister; use axum::Router; use axum::body::Body; @@ -47,6 +48,10 @@ pub fn router(service_register: ServiceRegister) -> Router<()> { // 预览播放器的实时弹幕(SSE),同样在登录校验之内;监视器多路复用一条 .route("/v1/streamers/{id}/danmaku", get(get_live_danmaku)) .route("/v1/danmaku", get(get_live_danmaku_multi)) + // 录制中各房间的写盘速率(只读内存):WebSocket 每秒推一帧画码率曲线,HTTP 版供回退 / curl; + // 不要为此调快 /v1/streamers + .route("/v1/ws/live-rates", get(ws_live_rates)) + .route("/v1/live-rates", get(get_live_rates)) // 配置管理路由 .route( "/v1/configuration", diff --git a/package-lock.json b/package-lock.json index b01395d..bbf874c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "react": "^19.3.0", "react-dom": "^19.3.0", "react-json-tree": "^0.20.0", - "swr": "^2.5.1" + "swr": "^2.5.1", + "uplot": "^1.6.32" }, "devDependencies": { "@types/node": "^26.6.2", @@ -9373,6 +9374,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 2822a4f..cd4d46c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "react": "^19.3.0", "react-dom": "^19.3.0", "react-json-tree": "^0.20.0", - "swr": "^2.5.1" + "swr": "^2.5.1", + "uplot": "^1.6.32" }, "devDependencies": { "@types/node": "^26.6.2",