diff --git a/.woodpecker/main-fastlane.yml b/.woodpecker/main-fastlane.yml index bcd4079..ce989e2 100644 --- a/.woodpecker/main-fastlane.yml +++ b/.woodpecker/main-fastlane.yml @@ -11,6 +11,8 @@ clone: when: - event: push branch: main + - event: pull_request_closed + branch: main concurrency: 1 @@ -19,6 +21,7 @@ steps: image: bash environment: NUGET_PACKAGES: /home/justme/.nuget/packages + GITEA_BASE: http://127.0.0.1:3100 ZOT_REGISTRY: registry.oeo.one ZOT_USERNAME: justme ZOT_SECRET: diff --git a/ci/main-fastlane.py b/ci/main-fastlane.py index 0fe6fdb..f3347f9 100644 --- a/ci/main-fastlane.py +++ b/ci/main-fastlane.py @@ -35,6 +35,9 @@ IGNORED_PARTS = {".git", ".release", "artifacts", "bin", "obj", "TestResults", " def read_changed_files() -> list[str]: raw = os.environ.get("CI_PIPELINE_FILES") + if os.environ.get("FASTLANE_FILES_FROM_PR_API") == "true": + value = json.loads(raw or "[]") + return [str(path).replace("\\", "/") for path in value] if raw: try: value = json.loads(raw) diff --git a/ci/release-linux.sh b/ci/release-linux.sh index 6ad5f63..f29ad97 100644 --- a/ci/release-linux.sh +++ b/ci/release-linux.sh @@ -12,8 +12,9 @@ if [[ -n "$tag" ]]; then } version="${tag#v}" else - [[ "${CI_PIPELINE_EVENT:-}" == push && "${CI_COMMIT_BRANCH:-}" == main ]] || { - echo "untagged image builds are restricted to pushes on main" >&2 + [[ ("${CI_PIPELINE_EVENT:-}" == push && "${CI_COMMIT_BRANCH:-}" == main) || \ + ("${CI_PIPELINE_EVENT:-}" == pull_request_closed && "${CI_COMMIT_BRANCH:-}" == main && "${FASTLANE_MERGED_PR:-}" == true) ]] || { + echo "untagged image builds are restricted to main pushes and merged PRs" >&2 exit 2 } version=main diff --git a/ci/release-main-images.sh b/ci/release-main-images.sh index 047eedc..6188ab9 100644 --- a/ci/release-main-images.sh +++ b/ci/release-main-images.sh @@ -2,15 +2,6 @@ # Build and push only the linux/amd64 OCI images affected by a main push. set -euo pipefail -[[ "${CI_PIPELINE_EVENT:-}" == push && "${CI_COMMIT_BRANCH:-}" == main ]] || { - echo "fast-lane images can only be published from a push to main" >&2 - exit 2 -} -[[ -n "${ZOT_USERNAME:-}" && -n "${ZOT_SECRET:-}" ]] || { - echo "Zot credentials are required" >&2 - exit 2 -} - root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" registry="${ZOT_REGISTRY:-registry.oeo.one}" work="$root/.release/main-fastlane" @@ -20,6 +11,38 @@ export HOME="$buildah_home" export BUILDAH_HOME="$buildah_home" export REGISTRY_AUTH_FILE="$work/zot-auth.json" +if [[ "${CI_PIPELINE_EVENT:-}" == pull_request_closed ]]; then + [[ "${CI_COMMIT_TARGET_BRANCH:-}" == main && -n "${CI_COMMIT_PULL_REQUEST:-}" ]] || { + echo "ignoring closed pull request outside main" >&2 + exit 0 + } + gitea_base="${GITEA_BASE:-http://127.0.0.1:3100}" + api="$gitea_base/api/v1/repos/justme/MyMonitor" + pr_json="$(curl --fail-with-body --silent --show-error "$api/pulls/$CI_COMMIT_PULL_REQUEST")" + read -r merged base_branch <<< "$(python3 -c 'import json,sys; p=json.load(sys.stdin); print(str(p.get("merged", False)).lower(), p.get("base", {}).get("ref", ""))' <<< "$pr_json")" + if [[ "$merged" != true || "$base_branch" != main ]]; then + echo "pull request $CI_COMMIT_PULL_REQUEST was closed without merging to main; skipping images" + exit 0 + fi + + export FASTLANE_MERGED_PR=true + export FASTLANE_FILES_FROM_PR_API=true + export CI_PIPELINE_FILES="$(curl --fail-with-body --silent --show-error \ + "$api/pulls/$CI_COMMIT_PULL_REQUEST/files?limit=500" \ + | python3 -c 'import json,sys; print(json.dumps([name for item in json.load(sys.stdin) for name in (item["filename"], item.get("previous_filename")) if name]))')" + git fetch "$gitea_base/justme/MyMonitor.git" main + git checkout --detach FETCH_HEAD + export CI_COMMIT_BRANCH=main +elif [[ "${CI_PIPELINE_EVENT:-}" != push || "${CI_COMMIT_BRANCH:-}" != main ]]; then + echo "fast-lane images can only be published from a main push or a merged PR into main" >&2 + exit 2 +fi + +[[ -n "${ZOT_USERNAME:-}" && -n "${ZOT_SECRET:-}" ]] || { + echo "Zot credentials are required" >&2 + exit 2 +} + targets="$(python3 "$root/ci/main-fastlane.py")" if [[ -z "$targets" ]]; then echo "no runtime image changes detected; skipping container builds" diff --git a/docs/deployment.md b/docs/deployment.md index 660a0d9..e8f524d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -38,7 +38,7 @@ Compose 将容器端口只绑定到宿主机 `127.0.0.1:8080`,适合由同机 Woodpecker 在 `vMAJOR.MINOR.PATCH` tag 上,由 Windows runner 使用 AotAnywhere 并行构建 Agent/Probe 的 Linux glibc、Linux musl、macOS 和 Windows 产物。Agent 包还包含一次性注册工具 `fleet-enroll`。Linux 归档带有安装脚本和 systemd/OpenWrt 单元;Windows 产物为 `.zip`,Linux/macOS 产物为 `.tar.gz`。Linux glibc 的 Agent 与 Probe 二进制还会打包为 OCI 镜像,支持 `linux/amd64` 和 `linux/arm64`;musl、macOS、Windows 仍通过对应平台归档分发。所有平台产物和 OCI 镜像发布成功后,CI 会创建对应的 Gitea Release,并附上压缩包和 SHA-256 校验文件。macOS 产物目前未签名/公证。安装说明见 [`../backend/agent/README.md`](../backend/agent/README.md) 与 [`../backend/probe/README.md`](../backend/probe/README.md)。 -推送到 `main` 会触发 Linux/amd64 快车道。CI 根据变更文件所在项目及 `.csproj` 的传递引用,只构建受影响的 Backend、Agent 或 Probe 镜像;Agent 镜像也包含 `fleet-enroll`。例如只改 Probe 时只构建 Probe;改动 `Monitor.Protocol` 等被多个程序引用的库时,会构建所有受影响镜像。结果推送到对应镜像的 `main` 和 `main-amd64` 标签,不改写 release 版本和 `latest`。只改 Web、文档或部署配置时不会构建容器。完整多架构和跨平台构建仍由版本 tag 流水线负责。 +推送到 `main` 或 feature PR 合入 `main` 后会触发 Linux/amd64 快车道;关闭但未合入的 PR 会跳过。CI 根据变更文件所在项目及 `.csproj` 的传递引用,只构建受影响的 Backend、Agent 或 Probe 镜像;Agent 镜像也包含 `fleet-enroll`。例如只改 Probe 时只构建 Probe;改动 `Monitor.Protocol` 等被多个程序引用的库时,会构建所有受影响镜像。结果推送到对应镜像的 `main` 和 `main-amd64` 标签,不改写 release 版本和 `latest`。只改 Web、文档或部署配置时,流程会正常完成但不会构建容器。完整多架构和跨平台构建仍由版本 tag 流水线负责。 Agent 和 Probe 镜像分别发布为 `registry.oeo.one/monitor/monitor-agent:` 与 `registry.oeo.one/monitor/monitor-probe:`,包含 amd64、arm64 多架构索引;也可指定 `:-amd64` 或 `:-arm64`。稳定版本同时更新 `latest`。镜像以非 root 用户(UID/GID 1654)运行,默认读取 `/etc/monitor/agent.json` 或 `/etc/monitor/probe.json`。运行容器时挂载对应配置目录;Probe 还会在该目录写入 `agents.json`,因此目录需可由 1654 用户写入。Agent、Probe 需使用宿主网络以保持节点端口和测量流量行为,并按需添加 `NET_RAW` 能力以执行 ICMP 测量: diff --git a/web/README.md b/web/README.md index 20b9fe0..d15f158 100644 --- a/web/README.md +++ b/web/README.md @@ -23,5 +23,8 @@ npm run lint:css # 颜色只能写在 shared/theme/tokens.css, 约定: - 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 Probe 的版本和来源地址(这些只在管理端)。 -- 时间范围(15 分钟 … 7 天)只在线路详情页;总览是卡片,Agent 主机状态通过 SSE(`/api/public/live?topics=hosts`)实时更新,其余每 10 秒刷新。 +- 线路详情页:先选**哪一天**(前一天 / 后一天 / 日期选择,URL 的 `day=YYYY-MM-DD`),下面是这一天的丢包缩略图,上面有可拖动的选区(拖空白处新建、拖两边调长短、拖中间平移;URL 的 `at=起_止`,单位是从选中那天 0 点算起的分钟,可以是负数或超过 1440)。缩略图的**刻度**缺省是 48 小时(连续两天,选区可以跨零点,比如晚高峰 20:00 到凌晨 02:00),也可以切成 24 小时(只看一天;URL 的 `span=24`,选区跨零点时自动用 48 小时)。刻度下方是**选区长度**:30 分钟、1 小时、3 小时、6 小时、12 小时、整天,点一下把选区设成这么长——今天且选区到现在为止的,继续看「最近一段」(实时刷新),否则保持选区的起点往后取。刻度上每小时一根短线,48 小时刻度每 3 小时标一个钟点(24 小时刻度每 2 小时),零点标日期;鼠标移到缩略图上会标出那个位置的时间;点一下空白处或者刻度下面的钟点:离那里近的端点(左端或右端)贴过去,另一个端点不动。鼠标停在轴上或刻度上时,会动的那个端点变色,它到目标位置之间染色:扩大是橙色,收缩是灰色。往日默认是整天;「整天」是本地时区的自然日。时间的文字不写「次日」,跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。 +- 图表上方有 **EWMA 平滑**开关(关、轻 α=0.3、中 α=0.1、重 α=0.03;URL 的 `ewma=low|mid|high`):开了之后每条线画成指数加权移动平均,原始数据以淡线垫在后面,缺口处断开;只在浏览器里算,不影响汇总的指标数字。 +- 图表在数据刷新(轮询、实时)时原地更新,不重建;拖选区时只在本地更新,松手才取数。 +- 总览是卡片,Agent 主机状态通过 SSE(`/api/public/live?topics=hosts`)实时更新,其余每 10 秒刷新。 - 生产部署时,把 `display/dist` 放在站点根、`admin/dist` 放在 `/admin/` 下(同源,Cookie 和 CSRF 才能工作)。 diff --git a/web/display/src/Minimap.tsx b/web/display/src/Minimap.tsx new file mode 100644 index 0000000..6c43aca --- /dev/null +++ b/web/display/src/Minimap.tsx @@ -0,0 +1,258 @@ +import { useEffect, useMemo, useRef, useState, type PointerEvent } from 'react' +import { LoadingBar } from '@shared/ui' +import { fmtSpan } from './ranges' + +const MIN = 60_000 +/** 选区最短 5 分钟。 */ +const MIN_LEN = 5 +const EDGE_PX = 9 + +interface Props { + /** 时间轴的起点(毫秒)。 */ + domainStart: number + /** 时间轴的长度(分钟):1440 或 2880(连续 48 小时,选区可以跨零点)。 */ + domainMin: number + t: number[] + /** 丢包(缩略图画它)。 */ + values: (number | null)[] + fromMs: number + toMs: number + onSelect: (fromMs: number, toMs: number) => void + /** 缩略图的数据正在加载。 */ + loading?: boolean +} + +type Mode = 'l' | 'r' | 'move' | 'new' +interface Drag { + mode: Mode + anchor: number + from: number + to: number + moved: boolean +} + +const hourOf = (ms: number) => new Date(ms).getHours() +const dateOf = (ms: number) => `${new Date(ms).getMonth() + 1}/${new Date(ms).getDate()}` +const clockOf = (ms: number) => `${String(new Date(ms).getHours()).padStart(2, '0')}:${String(new Date(ms).getMinutes()).padStart(2, '0')}` + +/** + * 连续 48 小时的缩略图(丢包),上面有一块可拖动的选区: + * 拖空白处新建,拖两边调长短,拖中间整体平移;选区可以跨过零点(晚高峰常常是 20:00 到凌晨 02:00)。 + * 点一下空白处或者刻度下面的钟点:离那里近的端点贴过去,另一个端点不动;鼠标移上去会标出这个位置的时间。 + * 拖动过程中只在这里本地更新,松手才通知外面(外面再去取数),所以不会闪。 + */ +export function Minimap({ domainStart, domainMin, t, values, fromMs, toMs, onSelect, loading = false }: Props) { + const box = useRef(null) + const drag = useRef(null) + const latest = useRef<[number, number] | null>(null) + const [live, setLive] = useState<[number, number] | null>(null) + const [held, setHeld] = useState<[number, number] | null>(null) + const [hover, setHover] = useState(null) + + const propFrom = Math.round((fromMs - domainStart) / MIN) + const propTo = Math.round((toMs - domainStart) / MIN) + // 松手之后到外面的状态更新完成之前,选区停在松手的位置,不跳回去 + useEffect(() => { + if (held && propFrom === held[0] && propTo === held[1]) setHeld(null) + }, [propFrom, propTo, held]) + useEffect(() => { + if (!held) return + const id = window.setTimeout(() => setHeld(null), 4000) + return () => window.clearTimeout(id) + }, [held]) + + const shown = live ?? held ?? [propFrom, propTo] + const from = Math.max(0, shown[0]) + const to = Math.min(domainMin, shown[1]) + + const path = useMemo(() => { + let max = 5 + for (const v of values) if (v !== null && v !== undefined && v > max) max = v + const points: { x: number; v: number }[] = [] + for (let i = 0; i < t.length; i++) { + const v = values[i] + const x = (t[i] - domainStart) / (domainMin * MIN) + if (v !== null && v !== undefined && x >= 0 && x <= 1) points.push({ x, v }) + } + if (points.length < 2) return '' + const first = points[0].x * 1000 + const last = points[points.length - 1].x * 1000 + return `M${first.toFixed(1)},60 ` + points.map((p) => `L${(p.x * 1000).toFixed(1)},${(58 - (Math.min(p.v, max) / max) * 54).toFixed(1)}`).join(' ') + ` L${last.toFixed(1)},60 Z` + }, [t, values, domainStart, domainMin]) + + // 有数据的时间段:其余(还没有数据的更早时间、中间的缺口、还没到的未来)画成灰色斜纹 + const covered = useMemo(() => { + const step = t.length > 1 ? t[1] - t[0] : 2 * MIN + const segments: { x: number; w: number }[] = [] + let start = -1 + let last = -1 + const flush = () => { + if (start >= 0) segments.push({ x: ((start - domainStart) / (domainMin * MIN)) * 1000, w: Math.max(2, ((last + step - start) / (domainMin * MIN)) * 1000) }) + start = -1 + } + for (let i = 0; i < t.length; i++) { + if (values[i] === null || values[i] === undefined) continue + if (start >= 0 && t[i] - last > step * 2.5) flush() + if (start < 0) start = t[i] + last = t[i] + } + flush() + return segments + }, [t, values, domainStart, domainMin]) + + // 刻度:每小时一个小短线;24 小时刻度每 2 小时、48 小时刻度每 3 小时标一个钟点;零点标日期 + const ticks = useMemo(() => { + const labelEvery = domainMin <= 1440 ? 2 : 3 + const list: { hour: number; frac: number; label: string | null; midnight: boolean }[] = [] + for (let h = 0; h <= domainMin / 60; h += 1) { + const ms = domainStart + h * 3_600_000 + const hour = hourOf(ms) + const labelled = h % labelEvery === 0 || hour === 0 + list.push({ hour: h, frac: h / (domainMin / 60), label: !labelled ? null : hour === 0 ? dateOf(ms) : `${hour}时`, midnight: hour === 0 }) + } + return list + }, [domainStart, domainMin]) + + const minuteAt = (e: PointerEvent) => { + const rect = box.current!.getBoundingClientRect() + return Math.min(domainMin, Math.max(0, ((e.clientX - rect.left) / rect.width) * domainMin)) + } + const clamp = (a: number, b: number): [number, number] => { + let lo = Math.round(Math.min(a, b)) + let hi = Math.round(Math.max(a, b)) + if (hi - lo < MIN_LEN) hi = Math.min(domainMin, lo + MIN_LEN) + if (hi - lo < MIN_LEN) lo = hi - MIN_LEN + return [Math.max(0, lo), Math.min(domainMin, hi)] + } + + const down = (e: PointerEvent) => { + if (e.button !== 0) return + const rect = box.current!.getBoundingClientRect() + const pxPerMin = rect.width / domainMin + const at = minuteAt(e) + let mode: Mode = 'new' + if (Math.abs(at - from) * pxPerMin <= EDGE_PX) mode = 'l' + else if (Math.abs(at - to) * pxPerMin <= EDGE_PX) mode = 'r' + else if (at > from && at < to) mode = 'move' + drag.current = { mode, anchor: at, from, to, moved: false } + latest.current = null + e.currentTarget.setPointerCapture(e.pointerId) + e.preventDefault() + } + const move = (e: PointerEvent) => { + const d = drag.current + if (!d) return + const at = minuteAt(e) + setHover(null) + if (!d.moved && Math.abs(at - d.anchor) * (box.current!.getBoundingClientRect().width / domainMin) < 3) return + d.moved = true + let next: [number, number] + if (d.mode === 'new') next = clamp(d.anchor, at) + else if (d.mode === 'l') next = clamp(at, d.to) + else if (d.mode === 'r') next = clamp(d.from, at) + else { + const len = d.to - d.from + const start = Math.min(domainMin - len, Math.max(0, d.from + (at - d.anchor))) + next = [Math.round(start), Math.round(start + len)] + } + latest.current = next + setLive(next) + } + const up = () => { + const d = drag.current + const result = latest.current + drag.current = null + latest.current = null + setLive(null) + if (d?.moved && result && (result[0] !== d.from || result[1] !== d.to)) { + setHeld(result) + onSelect(domainStart + result[0] * MIN, domainStart + result[1] * MIN) + } else if (d && !d.moved && (d.mode === 'new' || d.mode === 'move')) { + // 只是点了一下:离点的位置近的那个端点贴过去,另一个端点不动 + snapTo(d.anchor, d.from, d.to) + } + } + /** 哪个端点离 `minute` 近,就把哪个端点移到那里(点在选区外面也一样),另一端不动。 */ + const predict = (minute: number, a = from, b = to): { next: [number, number]; side: 'l' | 'r' } => { + const at = Math.round(minute) + return Math.abs(at - a) <= Math.abs(at - b) ? { next: clamp(at, b), side: 'l' } : { next: clamp(a, at), side: 'r' } + } + const snapTo = (minute: number, a = from, b = to) => { + const { next } = predict(minute, a, b) + if (next[0] === a && next[1] === b) return + setHeld(next) + onSelect(domainStart + next[0] * MIN, domainStart + next[1] * MIN) + } + // 点下去会动的是哪个端点:从那个端点到目标位置之间染色(扩大是一种颜色,收缩是另一种),那个端点也变色 + const target = hover !== null && !live && !drag.current ? predict(hover) : null + const sweep = target + ? target.side === 'l' + ? { lo: Math.min(from, target.next[0]), hi: Math.max(from, target.next[0]), grow: target.next[0] < from, changed: target.next[0] !== from } + : { lo: Math.min(to, target.next[1]), hi: Math.max(to, target.next[1]), grow: target.next[1] > to, changed: target.next[1] !== to } + : null + const jumpTo = (tickHour: number) => snapTo(tickHour * 60) + const hoverMove = (e: PointerEvent) => { + if (drag.current) return + setHover(minuteAt(e)) + } + + const length = to - from + + return ( +
+
{ move(e); hoverMove(e) }} onPointerLeave={() => setHover(null)} onPointerUp={up} onPointerCancel={up} role="slider" aria-label="拖动选择时间段" aria-valuemin={0} aria-valuemax={domainMin} aria-valuenow={from} tabIndex={0}> + + + {ticks + .filter((x) => x.midnight) + .map((x) => ( +
+ ))} + {sweep?.changed ?
: null} +
+ + +
+ {hover !== null && !live ? ( + <> +
+
+ {dateOf(domainStart + hover * MIN)} {clockOf(domainStart + hover * MIN)} +
+ + ) : null} +
+ {ticks.map((x, i) => ( + + ))} +
+
+
+ {fmtSpan(domainStart + from * MIN, domainStart + to * MIN)} + · {length >= 60 ? `${Math.floor(length / 60)} 小时${length % 60 ? ` ${length % 60} 分` : ''}` : `${length} 分钟`} +
+
+ ) +} diff --git a/web/display/src/display.css b/web/display/src/display.css index 8f0edb5..d5ece56 100644 --- a/web/display/src/display.css +++ b/web/display/src/display.css @@ -276,14 +276,28 @@ .line-link { display: flex; + flex-wrap: wrap; align-items: center; - gap: 8px; + gap: 4px 8px; + min-width: 0; padding: 6px 10px; border-radius: var(--r-control); background: var(--app-card-inset); color: var(--app-ink); } +.line-link > .muted { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.line-link > .scores { + flex-wrap: wrap; + margin-left: auto; +} + .line-link:hover { text-decoration: none; background: var(--app-card-hover); @@ -407,6 +421,37 @@ flex-direction: column; } +.day-pick { + position: relative; + display: inline-flex; + align-items: center; + height: 36px; + padding: 0 16px; + border: 0; + border-radius: var(--r-control); + background: var(--app-card-inset); + color: var(--app-ink); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.day-pick:hover { + background: var(--app-card-hover); +} + +.day-pick input { + position: absolute; + left: 0; + bottom: 0; + width: 1px; + height: 1px; + padding: 0; + border: 0; + opacity: 0; + pointer-events: none; +} + /* ── 对比:每个 Probe 的最佳线路 + 矩阵 ── */ .winners { margin-top: 24px; @@ -460,3 +505,255 @@ .cell svg { color: var(--app-ok); } + +/* ── 线路详情:指标平铺成一格一格 ── */ +.tiles { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); +} + +.tiles > .stat, +.tiles > .tile { + padding: 14px 16px; + border-radius: var(--r-card); + background: var(--app-card); +} + +.tiles > .tile { + display: flex; + flex-direction: column; + gap: 8px; + grid-column: 1 / -1; +} + +.tiles > .tile .k { + font-size: 12px; + color: var(--app-ink-faint); +} + +.tiles .stat .v { + font-size: 22px; +} + +.tiles > .tile .scores { + justify-content: flex-start; +} + +/* ── 一天的缩略图和可拖动的选区 ── */ +.minimap { + --pick: var(--chart-1); + margin-top: 12px; +} + +.minimap .track { + position: relative; + height: 60px; + margin-bottom: 18px; + border-radius: var(--r-control); + /* 没有数据的地方:灰色斜纹 */ + background: repeating-linear-gradient(135deg, var(--app-card-inset) 0 5px, var(--app-card-hover) 5px 10px); + cursor: crosshair; + touch-action: none; + user-select: none; +} + +.minimap svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border-radius: var(--r-control); + overflow: hidden; +} + +.minimap svg path { + fill: var(--app-bad); + opacity: 0.55; +} + +.minimap svg rect.has { + fill: var(--app-card); +} + +.minimap .sel { + position: absolute; + top: 0; + bottom: 0; + border: 2px solid var(--pick); + border-radius: var(--r-control); + background: color-mix(in srgb, var(--pick) 16%, transparent); + cursor: grab; +} + +.minimap .sel .h { + position: absolute; + top: 12px; + bottom: 12px; + width: 6px; + border-radius: 3px; + background: var(--pick); + cursor: ew-resize; +} + +.minimap .sel .h.l { + left: -4px; +} + +.minimap .sel .h.r { + right: -4px; +} + +.minimap .ticks { + position: absolute; + left: 0; + right: 0; + top: 100%; + height: 18px; +} + +.minimap .tick { + position: absolute; + top: 0; + min-width: 16px; + height: 18px; + padding: 7px 2px 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--app-ink-faint); + font: inherit; + font-size: 11px; + line-height: 1; + cursor: pointer; + transform: translateX(-50%); +} + +/* 刻度线:每小时一根,标了钟点的更长 */ +.minimap .tick::before { + content: ''; + position: absolute; + left: 50%; + top: 0; + width: 1px; + height: 4px; + background: var(--app-ink-faint); + opacity: 0.6; +} + +.minimap .tick.minor::before { + height: 3px; + opacity: 0.35; +} + +.minimap .tick:not(.minor)::before { + height: 6px; +} + +.minimap .tick:hover:not(:disabled) { + background: var(--app-card-hover); + color: var(--app-ink); +} + +.minimap .tick:disabled { + cursor: default; +} + +.minimap .tick.date { + font-weight: 600; + color: var(--app-ink-muted); +} + +.minimap .guide { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--app-ink-muted); + opacity: 0.7; + pointer-events: none; +} + +.minimap .guide-label { + position: absolute; + top: 4px; + padding: 2px 6px; + border-radius: 6px; + background: var(--app-ink); + color: var(--app-card); + font-size: 11px; + white-space: nowrap; + pointer-events: none; +} + +.minimap .readout { + font-size: 12px; + color: var(--app-ink-muted); +} + +.day-pick { + user-select: none; +} + +.minimap .midnight { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--app-ink-faint); + opacity: 0.6; + pointer-events: none; +} + + +/* ── 线路详情的图表:按 UDP、TCP、评分分组,宽图占满一行 ── */ +.chart-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.chart-section h2 { + margin: 8px 4px 0; + font-size: 14px; + font-weight: 600; + color: var(--app-ink-muted); +} + +.chart-section .chart-card.wide { + grid-column: 1 / -1; +} + +.minimap .loading-bar { + z-index: 2; + border-radius: var(--r-control) var(--r-control) 0 0; +} + +/* 点下去会动的端点:它到目标位置之间染色(扩大 = 强调色,收缩 = 灰色),端点本身也变色 */ +.minimap .sweep { + position: absolute; + top: 0; + bottom: 0; + pointer-events: none; + z-index: 1; +} + +.minimap .sweep.grow { + background: color-mix(in srgb, var(--chart-2) 32%, transparent); +} + +.minimap .sweep.cut { + background: color-mix(in srgb, var(--app-ink) 18%, transparent); +} + +.minimap .sel .h { + transition: background-color 0.12s ease; +} + +.minimap .sel .h.will.grow { + background: var(--chart-2); +} + +.minimap .sel .h.will.cut { + background: var(--app-ink-muted); +} diff --git a/web/display/src/pages/Compare.tsx b/web/display/src/pages/Compare.tsx index 58ece2f..362b3f4 100644 --- a/web/display/src/pages/Compare.tsx +++ b/web/display/src/pages/Compare.tsx @@ -20,9 +20,9 @@ interface MetricInfo { const METRICS: MetricInfo[] = [ { id: 'rtt.p50', label: '延迟 p50', unit: 'ms', better: 'lower' }, { id: 'rtt.p95', label: '延迟 p95', unit: 'ms', better: 'lower' }, - { id: 'loss.total', label: '丢包', unit: '%', better: 'lower' }, - { id: 'loss.up', label: '上行丢包', unit: '%', better: 'lower' }, - { id: 'loss.down', label: '下行丢包', unit: '%', better: 'lower' }, + { id: 'loss.total', label: 'UDP 丢包', unit: '%', better: 'lower' }, + { id: 'loss.up', label: 'UDP 上行丢包', unit: '%', better: 'lower' }, + { id: 'loss.down', label: 'UDP 下行丢包', unit: '%', better: 'lower' }, { id: 'jitter.up.p95', label: '上行抖动', unit: 'ms', better: 'lower' }, { id: 'jitter.down.p95', label: '下行抖动', unit: 'ms', better: 'lower' }, { id: 'tcp.rtt.p50', label: 'TCP RTT p50', unit: 'ms', better: 'lower' }, diff --git a/web/display/src/pages/Line.tsx b/web/display/src/pages/Line.tsx index 3d23a8b..3b8e401 100644 --- a/web/display/src/pages/Line.tsx +++ b/web/display/src/pages/Line.tsx @@ -2,14 +2,18 @@ import { ChevronLeft } from 'lucide-react' import { useMemo } from 'react' import { Link, useParams, useSearchParams } from 'react-router-dom' import { api } from '@shared/api/client' -import type { LineDto, SeriesDto, TestDto } from '@shared/api/types' +import type { LineDto, TestDto } from '@shared/api/types' import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart' import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format' import { fmtBytes, useHosts } from '../live' import { useQuery } from '@shared/hooks' -import { Chip, Dot, Spinner, Stat } from '@shared/ui' +import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui' import { PageError, ScoreChips } from '../lib' -import { availablePresets, defaultPreset, monthsWithData, PRESETS, RangePicker, resolveRange, SLICES, type PresetValue, type RangeState, type SliceValue } from '../ranges' +import { Minimap } from '../Minimap' +import { readSpec, usePeriod, usePeriodSeries, writeSpec } from '../periods' +import { dayBounds, dayKey, RangePicker, WINDOWS, type WindowValue } from '../ranges' + +const MIN = 60_000 function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' { if (value === null || value === undefined) return '' @@ -23,70 +27,215 @@ interface HostSeries { values: Record } +/** 指标卡片:好坏的分界(越低越好)和显示格式。 */ +const TILES: { label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string }[] = [ + { label: 'UDP 延迟 p50', key: 'rtt.p50', fmt: fmtMs }, + { label: 'UDP 延迟 p95', key: 'rtt.p95', fmt: fmtMs }, + { label: 'UDP 丢包', key: 'loss.total', fmt: fmtPct, good: 0.5, poor: 3 }, + { label: 'UDP 上行丢包', key: 'loss.up', fmt: fmtPct, good: 0.5, poor: 3 }, + { label: 'UDP 下行丢包', key: 'loss.down', fmt: fmtPct, good: 0.5, poor: 3 }, + { label: 'UDP 最长连续丢包', key: 'loss.run_max', fmt: fmtNum, unit: '包' }, + { label: 'UDP 上行抖动 p95', key: 'jitter.up.p95', fmt: fmtMs }, + { label: 'UDP 下行抖动 p95', key: 'jitter.down.p95', fmt: fmtMs }, + { label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs }, + { label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs }, + { label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs }, + { label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 }, +] + +interface ChartDef { + id: string + title: string + unit: string + zero?: boolean + fill?: boolean + series: { key: string; label: string; color: string; dash?: number[] }[] + group: 'udp' | 'tcp' | 'score' + /** 占满一行。 */ + wide?: boolean +} + +const GROUPS = [ + { id: 'udp', title: 'UDP(往返探测)' }, + { id: 'tcp', title: 'TCP' }, + { id: 'score', title: '评分' }, +] as const + +const CHARTS: ChartDef[] = [ + { id: 'rtt', title: '延迟', group: 'udp', wide: true, unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] }, + { id: 'loss', title: '丢包', group: 'udp', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] }, + { id: 'jitter', title: '抖动(p95)', group: 'udp', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] }, + { id: 'tcp', title: '往返时延(RTT)', group: 'tcp', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] }, + { id: 'retrans', title: '重传率', group: 'tcp', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] }, + { id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] }, +] + +/** EWMA 平滑的力度:系数越小越平滑(s = α·x + (1−α)·s)。 */ +const EWMA = [ + { value: 'off', label: '关', alpha: 0 }, + { value: 'low', label: '轻', alpha: 0.3 }, + { value: 'mid', label: '中', alpha: 0.1 }, + { value: 'high', label: '重', alpha: 0.03 }, +] as const +type EwmaValue = (typeof EWMA)[number]['value'] + +/** 指数加权移动平均;缺口(没有数据)处断开,缺口后重新开始,不会把缺口两边连在一起。 */ +function ewma(values: (number | null)[], alpha: number): (number | null)[] { + let state: number | null = null + return values.map((v) => { + if (v === null || v === undefined) { + state = null + return null + } + state = state === null ? v : alpha * v + (1 - alpha) * state + return state + }) +} + +/** 主机状态:实时数据(SSE)每隔几秒来一次,只重绘这一块,不影响上面的图表。 */ +function HostCard({ agentId, range }: { agentId: string | undefined; range: string | null }) { + const history = useQuery( + () => (agentId && range ? api.get(`/api/public/agents/${agentId}/host`, { range }) : Promise.resolve(undefined)), + [agentId, range], + 30_000, + ) + const { hosts } = useHosts() + const host = agentId ? hosts.get(agentId)?.host : undefined + const data = history.data + const charts = useMemo( + () => + data + ? { + usage: [ + { label: 'CPU', values: data.values['cpu'] ?? [], color: '--chart-1' }, + { label: '内存', values: data.values['mem'] ?? [], color: '--chart-2' }, + { label: '磁盘', values: data.values['disk'] ?? [], color: '--chart-4' }, + ], + speed: [ + { label: '上行', values: data.values['tx_bps'] ?? [], color: '--chart-2' }, + { label: '下行', values: data.values['rx_bps'] ?? [], color: '--chart-1' }, + ], + } + : undefined, + [data], + ) + if (!host && !data) return null + return ( +
+
主机状态
+ {host ? ( +
+ + + + +
+ ) : null} + {data && charts ? ( +
+ + +
+ ) : null} +
+ ) +} + export function Line() { const { id = '' } = useParams() const [params, setParams] = useSearchParams() const line = useQuery(() => api.get(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000) + const ready = Boolean(line.data) const sinceMs = line.data?.data_since ? Date.parse(line.data.data_since) : null const nowMs = useMemo(() => Date.now(), [line.data]) - const spanMs = sinceMs === null ? null : nowMs - sinceMs + const todayStart = dayBounds(nowMs).start - // 时间范围:只提供有数据的档位;URL 里的值如果超出了现有的数据就退回到默认 - const wanted = params.get('range') - const available = availablePresets(spanMs) - const preset: PresetValue = PRESETS.some((x) => x.value === wanted) && available.some((x) => x.value === wanted) ? (wanted as PresetValue) : defaultPreset(spanMs) - const sliceParam = params.get('slice') - const state: RangeState = { - preset, - month: params.get('month'), - slice: SLICES.some((x) => x.value === sliceParam) ? (sliceParam as SliceValue) : 'all', - page: Math.max(0, Number(params.get('page') ?? 0) || 0), - } - const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs]) - const resolved = resolveRange(state, months, nowMs) - const rangeKey = JSON.stringify(resolved.query) - const setRange = (next: RangeState) => { - const q = new URLSearchParams() - if (next.month) { - q.set('month', next.month) - if (next.slice !== 'all') q.set('slice', next.slice) - if (next.page > 0) q.set('page', String(next.page)) - } else { - q.set('range', next.preset) - } + const spec = readSpec(params) + const period = usePeriod(id, ready, sinceMs, nowMs, spec) + const series = usePeriodSeries(id, ready, period) + + const update = (fn: (q: URLSearchParams) => void) => { + const q = new URLSearchParams(params) + fn(q) setParams(q, { replace: true }) } + const dayParam = (dayStart: number) => (dayStart === todayStart ? null : dayKey(dayStart)) + const changeDay = (next: { dayStart: number; window: WindowValue | null }) => + update((q) => writeSpec(q, { ...readSpec(q), day: dayParam(next.dayStart), range: next.window, at: null })) + const selectRange = (fromMs: number, toMs: number) => + update((q) => { + const a = Math.round((fromMs - period.dayStart) / MIN) + const b = Math.round((toMs - period.dayStart) / MIN) + writeSpec(q, { ...readSpec(q), day: dayParam(period.dayStart), range: null, at: `${a}_${b}` }) + }) + /** 点了一档时长。看的是最新的(今天、选区到现在)就继续看「最近一段」;否则保持选区的起点,往后取这么长。 */ + const pickLength = (w: WindowValue) => + update((q) => { + const base = { ...readSpec(q), day: dayParam(period.dayStart) } + const info = WINDOWS.find((x) => x.value === w)! + if (w === 'day' || (period.isToday && (period.live || period.toMs >= nowMs - 2 * MIN))) { + writeSpec(q, { ...base, range: w, at: null }) + return + } + const start = Math.min(period.fromMs, nowMs - info.ms) + const a = Math.round((start - period.dayStart) / MIN) + writeSpec(q, { ...base, range: null, at: `${a}_${a + info.ms / MIN}` }) + }) + const setScale = (scale: 24 | 48) => + update((q) => { + const current = readSpec(q) + // 换回 24 小时:跨零点的选区放不下了,回到默认 + writeSpec(q, { ...current, span: scale === 24 ? '24' : null, at: scale === 24 && period.domainMin > 1440 ? null : current.at }) + }) - const series = useQuery( - () => (line.data ? api.get(`/api/public/lines/${encodeURIComponent(id)}/series`, { ...resolved.query, preset: 'udp,tcp,scores' }) : Promise.resolve(undefined)), - [id, rangeKey, Boolean(line.data)], - state.month ? undefined : 10_000, - ) const agentId = line.data?.agent_id - const hostHistory = useQuery( - () => (agentId && !state.month ? api.get(`/api/public/agents/${agentId}/host`, { range: resolved.hostRange }) : Promise.resolve(undefined)), - [agentId, resolved.hostRange, Boolean(state.month)], - 30_000, - ) - const { hosts } = useHosts() - const host = agentId ? hosts.get(agentId)?.host : undefined const lastTestId = line.data?.last_test?.id const test = useQuery(() => (lastTestId ? api.get(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId]) + const smooth = EWMA.find((x) => x.value === params.get('ewma')) ?? EWMA[0] const s = series.data - const col = (key: string): (number | null)[] => s?.values[key] ?? [] const m = s?.summary.metrics ?? {} const gaps = useMemo(() => (s?.gaps ?? []).map((g) => ({ from: Date.parse(g.from), to: Date.parse(g.to) })), [s]) - const t = s?.t ?? [] - const chart = (title: string, unit: string, list: ChartSeries[], zero = true) => - s && list.some((x) => x.values.some((v) => v !== null)) ? ( -
-

{title}

- + // 图表只在数据变化时更新;别的状态(加载中、实时推送)变化不会让它们闪 + const charts = useMemo( + () => + s + ? GROUPS.map((group) => { + const cards = CHARTS.filter((def) => def.group === group.id).map((def) => { + const raw = def.series.map((x) => ({ x, values: s.values[x.key] ?? [] })) + // 开了 EWMA:原始数据画成淡线垫在后面,平滑后的线画在上面 + const list: ChartSeries[] = + smooth.alpha > 0 + ? [ + ...raw.map(({ x, values }) => ({ label: `${x.label}(原始)`, values, color: x.color, faint: true })), + ...raw.map(({ x, values }) => ({ label: x.label, values: ewma(values, smooth.alpha), color: x.color, dash: x.dash, fill: def.fill })), + ] + : raw.map(({ x, values }) => ({ label: x.label, values, color: x.color, dash: x.dash, fill: def.fill })) + if (!list.some((x) => x.values.some((v) => v !== null))) return null + return { def, node: } + }) + return { group, cards: cards.filter((c): c is NonNullable => c !== null) } + }).filter((g) => g.cards.length > 0) + : [], + [s, gaps, smooth.alpha], + ) + const loadingNow = !ready || series.busy + const chartSections = charts.map(({ group, cards }) => ( +
+

{group.title}

+
+ {cards.map(({ def, node }) => ( +
+ +

{def.title}

+ {node} +
+ ))}
- ) : null +
+ )) const result = test.data?.result const peak = result?.metrics?.['tcp.bulk.peak.max'] @@ -112,108 +261,91 @@ export function Line() { {line.data.labels.map((x) => ( {x} ))} + {series.loading && !s ? : null}
) : null} -
- - - {resolved.label} - {series.loading && !s ? : null} - +
+ 1440 ? 48 : 24} + onScale={setScale} + sinceMs={sinceMs} + nowMs={nowMs} + spanMs={period.spanMs} + > + +
+ {!s && (loadingNow || !line.error) && !series.error ? ( + // 还没有数据:先摆出占位,每块上面有进度条 +
+
+ {Array.from({ length: 12 }, (_, i) => ( +
+ +
+ ))} +
+
+ +
+
+ {[0, 1].map((i) => ( +
+ +
+ ))} +
+
+ ) : null} + {s ? (
-
-
-
- - - - - - -
+
+
+ + 评分
-
-
- - - - - - -
- {s.summary.coverage < 0.95 ?

这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。

: null} + {TILES.map((t) => ( + + ))}
+

+ 样本数:UDP {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])} +

+ {s.summary.coverage < 0.95 ?

这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。

: null} -
- {chart('延迟(UDP 往返)', 'ms', [ - { label: 'p50', values: col('rtt.p50'), color: '--chart-1' }, - { label: 'p95', values: col('rtt.p95'), color: '--chart-2' }, - { label: 'p99', values: col('rtt.p99'), color: '--chart-3' }, - ])} - {chart('丢包', '%', [ - { label: '上行', values: col('loss.up'), color: '--chart-2' }, - { label: '下行', values: col('loss.down'), color: '--chart-3' }, - { label: '合计', values: col('loss.total'), color: '--chart-1', dash: [4, 3] }, - ])} - {chart('抖动(p95)', 'ms', [ - { label: '上行', values: col('jitter.up.p95'), color: '--chart-2' }, - { label: '下行', values: col('jitter.down.p95'), color: '--chart-1' }, - ])} - {chart('TCP 往返', 'ms', [ - { label: 'p50', values: col('tcp.rtt.p50'), color: '--chart-1' }, - { label: 'p95', values: col('tcp.rtt.p95'), color: '--chart-2' }, - { label: '最大', values: col('tcp.rtt.max'), color: '--chart-3' }, - ])} - {chart('TCP 重传', '%', [{ label: '重传率', values: col('tcp.retrans'), color: '--chart-3', fill: true }])} - {chart('综合评分', '', [ - { label: 'REALITY', values: col('score.reality'), color: '--chart-1' }, - { label: 'Hy2', values: col('score.hy2'), color: '--chart-2' }, - { label: 'MC', values: col('score.mc'), color: '--chart-4' }, - { label: '综合', values: col('score.base'), color: '--chart-5', dash: [4, 3] }, - ])} +
+ + EWMA 平滑 + + + options={EWMA.map((x) => ({ value: x.value, label: x.label }))} + value={smooth.value} + onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))} + />
+ {chartSections} - {host || hostHistory.data ? ( -
-
主机状态
- {host ? ( -
- - - - -
- ) : null} - {hostHistory.data ? ( -
- - -
- ) : null} -
- ) : null} + {test.data && result ? (
diff --git a/web/display/src/periods.ts b/web/display/src/periods.ts new file mode 100644 index 0000000..cd503c2 --- /dev/null +++ b/web/display/src/periods.ts @@ -0,0 +1,182 @@ +import { useMemo } from 'react' +import { api } from '@shared/api/client' +import type { SeriesDto } from '@shared/api/types' +import { useQuery } from '@shared/hooks' +import { availableWindows, dayBounds, dayKey, defaultWindow, fmtDayLabel, fmtSpan, isWindow, parseDay, WINDOWS, type WindowValue } from './ranges' + +const MIN = 60_000 +const HOUR = 60 * MIN + +/** URL 里的一段时间:哪一天、快捷档位、或者拖出来的起止分钟(从当天 0 点算)。 */ +export interface PeriodSpec { + day: string | null + range: string | null + at: string | null + /** 缩略图的刻度:缺省连续 48 小时(可以跨零点);`24` = 只看一天。 */ + span: string | null +} + +export function readSpec(params: URLSearchParams): PeriodSpec { + return { day: params.get('day'), range: params.get('range'), at: params.get('at'), span: params.get('span') } +} + +export function writeSpec(params: URLSearchParams, spec: PeriodSpec) { + for (const [key, value] of [['day', spec.day], ['range', spec.range], ['at', spec.at], ['span', spec.span]] as const) { + if (value) params.set(key, value) + else params.delete(key) + } +} + +/** `at=起_止`:从选中那天 0 点算起的分钟数,可以是负的(前一天晚上)或超过 1440(跨到后一天);晚高峰常常跨零点。 */ +export function parseAt(at: string | null): [number, number] | null { + const m = /^(-?\d{1,5})_(-?\d{1,5})$/.exec(at ?? '') + if (!m) return null + const a = Number(m[1]) + const b = Number(m[2]) + return b > a && a >= -DOMAIN_MIN && b <= 2 * DOMAIN_MIN && b - a <= 2 * DOMAIN_MIN ? [a, b] : null +} + +/** 缩略图的时间轴:连续的 48 小时,选区可以跨零点。 */ +export const DOMAIN_MIN = 1440 +export const DOMAIN_SPAN = 2 * DOMAIN_MIN + +export const fmtMinute = (minutes: number) => `${String(Math.floor(minutes / 60) % 24).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}` + +const fmtClock = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) + +export interface Period { + dayStart: number + isToday: boolean + /** 这一天里的数据概览(缩略图用)。 */ + overview: { t: number[]; loss: (number | null)[]; rtt: (number | null)[] } + /** 缩略图时间轴的起点(毫秒):往日是这一天 0 点起的 48 小时;今天是昨天 0 点起的 48 小时。 */ + domainStart: number + /** 缩略图的刻度长度(分钟):1440 或 2880。 */ + domainMin: number + spanMs: number | null + dataFirst: number | null + available: WindowValue[] + /** 当前选中的快捷档位;拖出来的区间是 `null`。 */ + win: WindowValue | null + /** 选中的区间(毫秒):缩略图上的选区、也是对比时对齐的起点。 */ + fromMs: number + toMs: number + query: Record + hostRange: string | null + label: string + shortLabel: string + /** 缩略图的数据正在加载(不含定时刷新)。 */ + busy: boolean + /** 今天、且是「最近一段」:会跟着最新数据刷新。 */ + live: boolean +} + +/** + * 把「哪一天 + 看多长或拖出的区间」变成这一段的取数参数和概览。 + * 今天的快捷档位看最近一段(实时);往日的快捷档位从这一天第一条记录所在的那一分钟起算。 + */ +export function usePeriod(lineId: string, ready: boolean, sinceMs: number | null, nowMs: number, spec: PeriodSpec): Period { + const todayStart = dayBounds(nowMs).start + const firstDay = sinceMs === null ? todayStart : dayBounds(sinceMs).start + const dayStart = Math.min(Math.max(parseDay(spec.day) ?? todayStart, firstDay), todayStart) + const isToday = dayStart === todayStart + const bounds = dayBounds(dayStart) + const dayEnd = isToday ? nowMs : bounds.end + + // 缩略图的数据:连续 48 小时(选区可以跨零点);这一天自己的数据范围和档位从中截取 + // 刻度:缺省连续 48 小时(选区可以跨零点);用户选了 24 小时就只看这一天,除非选区本身跨了零点 + const wantedAt = parseAt(spec.at) + const wide = spec.span !== '24' || (wantedAt !== null && (wantedAt[1] > DOMAIN_MIN || wantedAt[0] < 0)) + const domainMin = wide ? DOMAIN_SPAN : DOMAIN_MIN + const before = (isToday && wide) || (wantedAt !== null && wantedAt[0] < 0) ? DOMAIN_MIN : 0 + const domainStart = dayStart - before * MIN + const domainEnd = Math.min(domainStart + domainMin * MIN, nowMs) + const probe = useQuery( + () => + ready + ? api.get(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { + from: new Date(Math.max(domainStart, sinceMs === null ? domainStart : dayBounds(sinceMs).start)).toISOString(), + to: new Date(domainEnd).toISOString(), + step: wide ? '2m' : '1m', + preset: 'udp', + metrics: 'rtt.p50,loss.total', + }) + : Promise.resolve(undefined), + [lineId, domainStart, domainMin, ready, isToday ? Math.floor(nowMs / MIN) : 0], + ) + const overview = useMemo(() => { + const t = probe.data?.t ?? [] + return { t, loss: probe.data?.values['loss.total'] ?? [], rtt: probe.data?.values['rtt.p50'] ?? [] } + }, [probe.data]) + + // 这一天自己的数据:第一条和最后一条记录 + let firstIdx = -1 + let lastIdx = -1 + for (let i = 0; i < overview.t.length; i++) { + if (overview.t[i] < dayStart || overview.t[i] >= dayEnd) continue + if (overview.rtt[i] === null || overview.rtt[i] === undefined) continue + if (firstIdx < 0) firstIdx = i + lastIdx = i + } + const stepMs = overview.t.length > 1 ? overview.t[1] - overview.t[0] : MIN + const dataFirst = firstIdx >= 0 ? overview.t[firstIdx] : null + // 哪些长度可选:选区可以跨到前一天/后一天,所以看的是「从这里往前(今天)或往后(往日)一共有多少数据」,不是这一天自己有多少 + const availSpan = isToday ? (sinceMs === null ? null : nowMs - sinceMs) : dataFirst === null ? null : nowMs - dataFirst + const available = availableWindows(availSpan) + + const dayLabel = fmtDayLabel(dayStart, nowMs) + const at = wantedAt + const wanted = isWindow(spec.range) && available.includes(spec.range) ? spec.range : null + const win: WindowValue | null = at ? null : (wanted ?? defaultWindow(isToday, available)) + + let fromMs: number + let toMs: number + let query: Record + let hostRange: string | null = null + let label: string + let live = false + if (at) { + fromMs = bounds.start + at[0] * MIN + toMs = Math.min(bounds.start + at[1] * MIN, nowMs) + if (toMs <= fromMs) toMs = fromMs + MIN + query = { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString() } + label = fmtSpan(fromMs, toMs) + } else if (win === 'day') { + fromMs = bounds.start + toMs = dayEnd + query = { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString() } + hostRange = isToday ? '24h' : null + label = `${dayLabel} 全天` + } else { + const info = WINDOWS.find((w) => w.value === win)! + if (isToday) { + // 今天:最近一段,以最新的数据为终点 + const lastMs = lastIdx >= 0 ? overview.t[lastIdx] + stepMs : nowMs + toMs = Math.min(nowMs, lastMs) + fromMs = toMs - info.ms + query = { range: info.value, anchor: 'latest' } + hostRange = info.ms <= HOUR ? '1h' : info.value + label = `${dayLabel} 最近 ${info.label}` + live = true + } else { + // 往日:从这一天第一条记录所在的那一分钟起 + fromMs = Math.floor((dataFirst ?? bounds.start) / MIN) * MIN + toMs = Math.min(fromMs + info.ms, bounds.end) + query = { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString() } + label = `${dayLabel} ${fmtClock(fromMs)} 起 ${info.label}` + } + } + const shortLabel = fmtSpan(fromMs, toMs, true) + return { dayStart, isToday, domainStart, domainMin, overview, spanMs: availSpan, dataFirst, available, win, fromMs, toMs, query, hostRange, label, shortLabel, busy: probe.busy, live } +} + +/** 这一段的序列;今天的「最近一段」会定时刷新。 */ +export function usePeriodSeries(lineId: string, ready: boolean, period: Period) { + return useQuery( + () => (ready ? api.get(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, preset: 'udp,tcp,scores' }) : Promise.resolve(undefined)), + [lineId, JSON.stringify(period.query), ready], + period.isToday ? (period.win === 'day' ? 60_000 : 10_000) : undefined, + ) +} + +export { dayKey } diff --git a/web/display/src/ranges.tsx b/web/display/src/ranges.tsx index af305dd..2821593 100644 --- a/web/display/src/ranges.tsx +++ b/web/display/src/ranges.tsx @@ -1,11 +1,12 @@ import { ChevronLeft, ChevronRight } from 'lucide-react' -import { useMemo } from 'react' +import { useRef, type ReactNode } from 'react' import { Segmented } from '@shared/ui' const MIN = 60_000 -const DAY = 24 * 60 * MIN +const HOUR = 60 * MIN +const DAY = 24 * HOUR -/** 线路详情页的时间范围:只提供这条线路“有数据”的档位。 */ +/** 对比页的时间范围:最近一段。 */ export const PRESETS = [ { value: '5m', label: '5 分钟', ms: 5 * MIN }, { value: '15m', label: '15 分钟', ms: 15 * MIN }, @@ -19,172 +20,161 @@ export const PRESETS = [ export type PresetValue = (typeof PRESETS)[number]['value'] -/** 进入某个月之后的切片:整月,或者从月初起按固定长度翻页。 */ -export const SLICES = [ - { value: 'all', label: '整月', ms: 0 }, - { value: '14d', label: '14 天', ms: 14 * DAY }, - { value: '7d', label: '7 天', ms: 7 * DAY }, - { value: '3d', label: '3 天', ms: 3 * DAY }, - { value: '1d', label: '1 天', ms: DAY }, +/** 线路详情页:先选哪一天,再选这一天里看多长。`day` = 整个自然日。 */ +export const WINDOWS = [ + { value: '30m', label: '30 分钟', ms: 30 * MIN }, + { value: '1h', label: '1 小时', ms: HOUR }, + { value: '3h', label: '3 小时', ms: 3 * HOUR }, + { value: '6h', label: '6 小时', ms: 6 * HOUR }, + { value: '12h', label: '12 小时', ms: 12 * HOUR }, + { value: 'day', label: '整天', ms: DAY }, ] as const -export type SliceValue = (typeof SLICES)[number]['value'] +export type WindowValue = (typeof WINDOWS)[number]['value'] -/** 数据不足整档时允许 10% 的误差:29 分钟的数据算得上“30 分钟”。至少保留最小的一档。 */ -const TOLERANCE = 0.9 - -export function availablePresets(spanMs: number | null) { - if (spanMs === null) return [PRESETS[0]] - const list = PRESETS.filter((p) => spanMs >= p.ms * TOLERANCE) - return list.length > 0 ? list : [PRESETS[0]] -} - -/** 默认选“有数据的最大档”,但最长到 1 天(再长的范围图会很挤,用户可以自己点)。 */ -export function defaultPreset(spanMs: number | null): PresetValue { - const list = availablePresets(spanMs).filter((p) => p.ms <= DAY) - return list[list.length - 1]?.value ?? '5m' -} - -export interface MonthInfo { - key: string // YYYY-MM - label: string - start: number - end: number // 不含 -} +export const isWindow = (v: string | null): v is WindowValue => WINDOWS.some((w) => w.value === v) const pad = (n: number) => String(n).padStart(2, '0') -export function monthOf(ms: number): MonthInfo { +/** 本地时区的 YYYY-MM-DD。 */ +export function dayKey(ms: number): string { const d = new Date(ms) - const start = new Date(d.getFullYear(), d.getMonth(), 1).getTime() - const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime() - return { key: `${d.getFullYear()}-${pad(d.getMonth() + 1)}`, label: `${d.getFullYear()} 年 ${d.getMonth() + 1} 月`, start, end } + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` } -/** 从有数据的第一个月到当前月,新的在前。 */ -export function monthsWithData(sinceMs: number | null, nowMs: number): MonthInfo[] { - if (sinceMs === null) return [] - const months: MonthInfo[] = [] - let cursor = monthOf(sinceMs) - const last = monthOf(nowMs) - while (cursor.start <= last.start) { - months.push(cursor) - cursor = monthOf(cursor.end) - } - return months.reverse() +export function parseDay(key: string | null): number | null { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key ?? '') + if (!m) return null + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) + return Number.isNaN(d.getTime()) ? null : d.getTime() } -export interface RangeState { - /** 没有 month 就是“最近一段”。 */ - preset: PresetValue - month: string | null - slice: SliceValue - page: number -} - -export interface ResolvedRange { - query: { range?: string; from?: string; to?: string; anchor?: string } - /** 主机历史只支持 ≥ 1 小时的 range,这里给一个对应的值。 */ - hostRange: string - label: string -} - -const fmtDay = (ms: number) => { +/** 本地时区这一天的 [起点, 终点)。 */ +export function dayBounds(ms: number): { start: number; end: number } { const d = new Date(ms) - return `${d.getMonth() + 1}/${d.getDate()}` + return { start: new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(), end: new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime() } } -export function resolveRange(state: RangeState, months: MonthInfo[], nowMs: number): ResolvedRange { - if (!state.month) { - const hostRange = state.preset === '5m' || state.preset === '15m' || state.preset === '30m' ? '1h' : state.preset - return { query: { range: state.preset, anchor: 'latest' }, hostRange, label: PRESETS.find((p) => p.value === state.preset)?.label ?? state.preset } - } - const month = months.find((m) => m.key === state.month) ?? monthOf(nowMs) - const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0] - const end = Math.min(month.end, nowMs) - let from = month.start - let to = end - if (slice.ms > 0) { - from = month.start + state.page * slice.ms - to = Math.min(from + slice.ms, end) - } - const spanDays = Math.max(1, Math.round((to - from) / DAY)) - return { - query: { from: new Date(from).toISOString(), to: new Date(to).toISOString() }, - hostRange: spanDays >= 30 ? '30d' : `${Math.max(1, spanDays)}d`, - label: slice.ms > 0 ? `${month.label} ${fmtDay(from)} – ${fmtDay(to - 1)}` : month.label, - } +const clock = (ms: number) => `${pad(new Date(ms).getHours())}:${pad(new Date(ms).getMinutes())}` +const fullDay = (ms: number) => `${new Date(ms).getFullYear()}年${new Date(ms).getMonth() + 1}月${new Date(ms).getDate()}日` +const shortDay = (ms: number) => `${new Date(ms).getMonth() + 1}月${new Date(ms).getDate()}日` + +/** 一段时间的文字:同一天写一次日期(2026年10月5日 21:06–23:00),跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。 */ +export function fmtSpan(fromMs: number, toMs: number, short = false): string { + const day = short ? shortDay : fullDay + const end = Math.max(fromMs, toMs - 1) + // 正好到次日 0 点的写成 24:00 + return dayKey(fromMs) === dayKey(end) ? `${day(fromMs)} ${clock(fromMs)}–${clock(toMs) === '00:00' ? '24:00' : clock(toMs)}` : `${day(fromMs)} ${clock(fromMs)} – ${day(end)} ${clock(toMs)}` } -/** 一个月里按固定长度能翻几页。 */ -function pagesIn(month: MonthInfo, sliceMs: number, nowMs: number, sinceMs: number | null): { first: number; last: number } { - const start = Math.max(month.start, sinceMs === null ? month.start : sinceMs - (sinceMs % DAY)) - const end = Math.min(month.end, nowMs) - return { first: Math.max(0, Math.floor((start - month.start) / sliceMs)), last: Math.max(0, Math.ceil((end - month.start) / sliceMs) - 1) } +export const fmtDayLabel = (startMs: number, nowMs: number) => { + const d = new Date(startMs) + const today = dayKey(startMs) === dayKey(nowMs) + return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日${today ? '(今天)' : ''}` +} + +/** 数据不足整档时允许 10% 的误差:29 分钟的数据算得上“30 分钟”。 */ +const TOLERANCE = 0.9 + +/** 这一天里有多长的数据(毫秒),决定哪些档位可选;`null` = 这一天没有数据。 */ +export function availableWindows(spanMs: number | null): WindowValue[] { + if (spanMs === null) return [] + const list = WINDOWS.filter((w) => w.value === 'day' || spanMs >= w.ms * TOLERANCE).map((w) => w.value) + return list.length > 0 ? list : ['day'] +} + +/** 默认:今天看最近 1 小时,往日看整天。 */ +export function defaultWindow(isToday: boolean, available: WindowValue[]): WindowValue { + if (!isToday) return 'day' + return available.includes('1h') ? '1h' : (available.filter((w) => w !== 'day').pop() ?? 'day') } interface Props { - state: RangeState - onChange: (next: RangeState) => void + /** 选中那天的 0 点(本地时区)。 */ + dayStart: number + /** 选区(毫秒):长度正好等于某一档时,那一档高亮。 */ + fromMs: number + toMs: number + /** 换了一天(选区回到默认)。 */ + onChange: (next: { dayStart: number; window: WindowValue | null }) => void + /** 点了一档时长:30 分钟、1 小时、3 小时、6 小时、12 小时、整天。 */ + onWindow: (window: WindowValue) => void + /** 缩略图的刻度:24 小时(一天)或 48 小时(连续两天,可以跨零点)。 */ + scale: 24 | 48 + onScale: (scale: 24 | 48) => void + /** 这条线路最早有数据的时间;`null` = 没有数据。 */ sinceMs: number | null nowMs: number + /** 这一天里数据的跨度(毫秒);未知或没有数据是 `null`。 */ + spanMs: number | null + /** 放在日期和档位下面(缩略图)。 */ + children?: ReactNode } -export function RangePicker({ state, onChange, sinceMs, nowMs }: Props) { - const spanMs = sinceMs === null ? null : nowMs - sinceMs - const presets = useMemo(() => availablePresets(spanMs), [spanMs]) - const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs]) - const month = state.month ? months.find((m) => m.key === state.month) : undefined - const monthLength = month ? Math.min(month.end, nowMs) - Math.max(month.start, sinceMs ?? month.start) : 0 - const slices = SLICES.filter((s) => s.ms === 0 || monthLength >= s.ms * TOLERANCE) - const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0] - const pages = month && slice.ms > 0 ? pagesIn(month, slice.ms, nowMs, sinceMs) : undefined +export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale, onScale, sinceMs, nowMs, spanMs, children }: Props) { + const picker = useRef(null) + const today = dayBounds(nowMs).start + const firstDay = sinceMs === null ? today : dayBounds(sinceMs).start + const available = availableWindows(spanMs) + const length = toMs - fromMs + const active: WindowValue = WINDOWS.find((w) => Math.abs(length - w.ms) < 1.5 * MIN)?.value ?? ('' as WindowValue) + const step = (days: number) => { + const d = new Date(dayStart) + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + days).getTime() + } return (
- { - const ok = presets.some((x) => x.value === p.value) - return { value: p.value, label: p.label, disabled: !ok, title: ok ? undefined : '这条线路的数据还没有这么长' } - })} - value={state.month ? ('' as PresetValue) : state.preset} - onChange={(v) => onChange({ ...state, preset: v, month: null, page: 0 })} - /> - {months.length > 0 ? ( - + + + + + + {dayStart !== today ? ( + ) : null}
- {month ? ( -
- ({ value: s.value, label: s.label }))} value={state.slice} onChange={(v) => onChange({ ...state, slice: v, page: 0 })} /> - {pages ? ( - - - - - ) : null} -
- ) : null} + {children} +
+ { + const ok = available.includes(w.value) + return { value: w.value, label: w.label, disabled: !ok, title: ok ? undefined : '这一天的数据没有这么长' } + })} + value={active} + onChange={onWindow} + /> + + options={[ + { value: '24', label: '24 小时刻度', title: '缩略图显示一天' }, + { value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' }, + ]} + value={String(scale) as '24' | '48'} + onChange={(v) => onScale(v === '48' ? 48 : 24)} + /> +
) } diff --git a/web/shared/charts/TimeChart.tsx b/web/shared/charts/TimeChart.tsx index 0cbb91c..e761fc8 100644 --- a/web/shared/charts/TimeChart.tsx +++ b/web/shared/charts/TimeChart.tsx @@ -9,6 +9,8 @@ export interface ChartSeries { color: string dash?: number[] fill?: boolean + /** 原始数据的淡线(开了 EWMA 平滑时垫在平滑线后面):画细、画淡,图例和提示框里不出现。 */ + faint?: boolean } interface Props { @@ -22,14 +24,33 @@ interface Props { zero?: boolean /** 缺口:这些时间段用底色标出。 */ gaps?: { from: number; to: number }[] + /** 为真时 `t` 是「从起点算起的毫秒数」而不是 Unix 毫秒:横轴显示 0:00、0:15…,用来把两段时间叠在一起比。 */ + offset?: boolean +} + +/** 偏移量(毫秒)→ h:mm:ss / m:ss。 */ +export function fmtOffset(ms: number): string { + const total = Math.round(ms / 1000) + const h = Math.floor(total / 3600) + const m = Math.floor((total % 3600) / 60) + const sec = total % 60 + const two = (n: number) => String(n).padStart(2, '0') + return h > 0 ? `${h}:${two(m)}${sec ? ':' + two(sec) : ''}` : `${m}:${two(sec)}` } function cssVar(name: string): string { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() } +/** 毫秒到 1 秒以上换成秒(30000ms → 30s),坐标轴和提示框都不会出现一长串数字。 */ +function withUnit(v: number, unit: string, digits?: number): string { + if (unit === 'ms' && Math.abs(v) >= 1000) return `${digits === undefined ? +(v / 1000).toFixed(1) : (v / 1000).toFixed(digits)}s` + return `${v}${unit}` +} + function formatValue(v: number | null | undefined, unit: string): string { if (v === null || v === undefined) return '—' + if (unit === 'ms' && Math.abs(v) >= 1000) return withUnit(v, unit, 2) const text = Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2) return `${text}${unit}` } @@ -40,12 +61,17 @@ function formatValue(v: number | null | undefined, unit: string): string { * - 在图上拖动选一段放大预览,双击或点「还原」恢复;数据刷新时保持放大的区间; * - 鼠标悬停显示这个时刻每条线的值。 */ -export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [] }: Props) { +export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [], offset = false }: Props) { const host = useRef(null) const tip = useRef(null) const plot = useRef(null) const zoom = useRef<{ min: number; max: number } | null>(null) const [zoomed, setZoomed] = useState(false) + // 数据变了(轮询、实时)只更新数据,不重建图:重建会闪一下,还会丢掉鼠标位置 + const latest = useRef({ t, series, gaps }) + latest.current = { t, series, gaps } + const structure = JSON.stringify([series.map((x) => [x.label, x.color, x.dash, x.fill, x.faint]), unit, zero, offset, height]) + const dataOf = (): AlignedData => [latest.current.t.map((v) => v / 1000), ...latest.current.series.map((x) => x.values.map((v) => (v === null ? null : v)))] const reset = useCallback(() => { zoom.current = null @@ -70,12 +96,12 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap cursor: { drag: { x: true, y: false, setScale: false }, points: { size: 6 } }, select: { show: true, left: 0, top: 0, width: 0, height: 0 }, scales: { - x: { time: true, range: (_u, min, max) => (zoom.current ? [zoom.current.min, zoom.current.max] : [min, max]) }, + x: { time: !offset, range: (_u, min, max) => (zoom.current ? [zoom.current.min, zoom.current.max] : [min, max]) }, y: { range: (_u, min, max) => [zero ? Math.min(0, min) : min, max <= min ? min + 1 : max + (max - min) * 0.08] }, }, axes: [ - { stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { stroke: grid }, font }, - { stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font, size: 56, values: (_u, ticks) => ticks.map((v) => `${v}${unit}`) }, + { stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { stroke: grid }, font, ...(offset ? { values: (_u: uPlot, ticks: number[]) => ticks.map((v) => fmtOffset(v * 1000)) } : {}) }, + { stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font, size: 56, values: (_u, ticks) => ticks.map((v) => withUnit(v, unit)) }, ], series: [ {}, @@ -83,8 +109,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap const color = cssVar(s.color) return { label: s.label, - stroke: color, - width: 1.8, + stroke: s.faint ? color + '55' : color, + width: s.faint ? 1 : 1.8, dash: s.dash, fill: s.fill ? color + '22' : undefined, spanGaps: false, @@ -120,8 +146,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap return } const when = new Date(u.data[0][idx] * 1000) - const lines = [when.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })] - for (let i = 0; i < series.length; i++) lines.push(`${series[i].label} ${formatValue(u.data[i + 1][idx], unit)}`) + const lines = [offset ? `起点后 ${fmtOffset(u.data[0][idx] * 1000)}` : when.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })] + for (let i = 0; i < latest.current.series.length; i++) if (!latest.current.series[i].faint) lines.push(`${latest.current.series[i].label} ${formatValue(u.data[i + 1][idx], unit)}`) box.textContent = lines.join('\n') box.style.display = 'block' const width = box.offsetWidth @@ -135,7 +161,7 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap const { ctx } = u ctx.save() ctx.fillStyle = shade - for (const g of gaps) { + for (const g of latest.current.gaps) { const x0 = Math.max(u.bbox.left, u.valToPos(g.from / 1000, 'x', true)) const x1 = Math.min(u.bbox.left + u.bbox.width, u.valToPos(g.to / 1000, 'x', true)) if (x1 > x0) ctx.fillRect(x0, u.bbox.top, x1 - x0, u.bbox.height) @@ -145,8 +171,7 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap ], }, } - const data: AlignedData = [t.map((v) => v / 1000), ...series.map((s) => s.values.map((v) => (v === null ? null : v)))] - plot.current = new uPlot(options, data, el) + plot.current = new uPlot(options, dataOf(), el) // 把提示框放进绘图区,这样它的位置和光标是同一个坐标系 if (tip.current) plot.current.over.appendChild(tip.current) } @@ -161,12 +186,21 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap plot.current?.destroy() plot.current = null } - }, [t, series, height, unit, zero, gaps, reset]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [structure, reset]) + + useEffect(() => { + const u = plot.current + if (!u) return + u.setData(dataOf(), !zoom.current) + u.redraw(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [t, series, gaps]) return (
- {series.map((s) => ( + {series.filter((s) => !s.faint).map((s) => ( {s.label} diff --git a/web/shared/hooks.ts b/web/shared/hooks.ts index 4f74059..79e9a0b 100644 --- a/web/shared/hooks.ts +++ b/web/shared/hooks.ts @@ -5,6 +5,8 @@ export interface QueryState { data: T | undefined error: ApiError | Error | undefined loading: boolean + /** 正在加载,而且不是定时刷新引起的(第一次加载、换了条件):用来显示进度条,定时刷新不打扰用户。 */ + busy: boolean reload: () => void } @@ -14,17 +16,23 @@ export function useQuery(fetcher: () => Promise, deps: unknown[], interval const [error, setError] = useState() const [loading, setLoading] = useState(true) const [tick, setTick] = useState(0) + const [silent, setSilent] = useState(false) + const lastTick = useRef(0) const fetcherRef = useRef(fetcher) fetcherRef.current = fetcher useEffect(() => { let alive = true + // 只有 tick 变了(定时刷新、reload)才算静默刷新;条件变了是用户能感觉到的加载 + setSilent(lastTick.current !== tick) + lastTick.current = tick setLoading(true) fetcherRef .current() .then((value) => { if (!alive) return - setData(value) + // 轮询取到一模一样的数据时保持原来的对象,依赖它的图表不会白白重建(那是闪烁的来源) + setData((previous) => (previous !== undefined && JSON.stringify(previous) === JSON.stringify(value) ? previous : value)) setError(undefined) }) .catch((e: unknown) => { @@ -46,7 +54,7 @@ export function useQuery(fetcher: () => Promise, deps: unknown[], interval }, [intervalMs]) const reload = useCallback(() => setTick((t) => t + 1), []) - return { data, error, loading, reload } + return { data, error, loading, busy: loading && !silent, reload } } export type Theme = 'light' | 'dark' diff --git a/web/shared/theme/base.css b/web/shared/theme/base.css index 51d267a..320999f 100644 --- a/web/shared/theme/base.css +++ b/web/shared/theme/base.css @@ -635,3 +635,68 @@ pre.code { margin-right: 6px; vertical-align: middle; } + +/* ── 组件上的加载进度条:贴着卡片的上边缘,流动的渐变 ── */ +.has-loading { + position: relative; + overflow: hidden; +} + +.loading-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + overflow: hidden; + pointer-events: none; +} + +.loading-bar i { + display: block; + width: 40%; + height: 100%; + border-radius: 3px; + background: var(--chart-1); + animation: loading-bar-slide 1.1s ease-in-out infinite; +} + +@keyframes loading-bar-slide { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(250%); + } +} + +/* 还没有数据时的占位 */ +.skeleton { + position: relative; + overflow: hidden; + min-height: 72px; + border-radius: var(--r-card); + background: var(--app-card); +} + +.skeleton.chart { + min-height: 260px; +} + +.skeleton::after { + content: ''; + position: absolute; + inset: 16px 16px auto; + height: 12px; + width: 40%; + border-radius: 6px; + background: var(--app-card-inset); +} + +@media (prefers-reduced-motion: reduce) { + .loading-bar i { + animation: none; + width: 100%; + } +} diff --git a/web/shared/ui/index.tsx b/web/shared/ui/index.tsx index 0a9637b..f8c2e75 100644 --- a/web/shared/ui/index.tsx +++ b/web/shared/ui/index.tsx @@ -218,6 +218,18 @@ export function ThemeToggle() { ) } +/** + * 组件上的加载进度条:放在卡片(`position: relative`)里,贴着上边缘。 + * 数据在加载时流动,不加载时不占地方。 + */ +export function LoadingBar({ active }: { active: boolean }) { + return active ? ( +
+ +
+ ) : null +} + /** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 API 令牌,后端要放行这个前端的来源)。 */ export function SourceButton({ tokenLabel = '只读令牌(站点私有时才需要)' }: { tokenLabel?: string }) { const [open, setOpen] = useState(false)