feat(web): acrylic theme, node flags and icons, build info

Acrylic cards and topbar over a blurred background, country flags, ISP and
OS icons, per-node version tags, a build info footer and an admin versions
tab. Overview cards drop the sparkline and widen the hover area.
This commit is contained in:
justme committed 2026-10-06 21:22:04 +08:00
1 parent 1e5c660d05
commit 83f54978d2
29 files changed
+1310 -143

No files matched your search

+5 -3
View File
@@ -1,3 +1,4 @@
import { BuildFooter } from './BuildFooter'
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
import { SourceButton, ThemeToggle } from '@shared/ui'
import { Compare } from './pages/Compare'
@@ -9,9 +10,6 @@ export function App() {
<>
<header className="topbar">
<div className="inner">
<NavLink to="/" className="brand">
MyMonitor
</NavLink>
<nav className="topnav">
<NavLink to="/" end className={({ isActive }) => (isActive ? 'active' : '')}>
总览
@@ -31,6 +29,10 @@ export function App() {
<Route path="/compare" element={<Compare />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<footer className="site-foot">
国家和运营商由 <a href="https://ip.007995.xyz/" target="_blank" rel="noreferrer">ip.007995.xyz</a> 查询
<BuildFooter />
</footer>
</>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { api } from '@shared/api/client'
import type { AboutDto, BuildDto } from '@shared/api/types'
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
import { useQuery } from '@shared/hooks'
/** 「0.1.1 · v0.1.1-5-gabc123 · 2026-10-06 20:30」:版本、tag(和版本一样就不重复写)、构建时间。 */
function buildText(b: { version: string; tag?: string | null; built_at?: string | null }): string {
const parts = [b.version]
if (b.tag && b.tag.replace(/^v/, '') !== b.version) parts.push(b.tag)
const at = fmtBuiltAt(b.built_at)
if (at) parts.push(at)
return parts.join(' · ')
}
/**
* 页面底部的构建信息:后端和前端的版本、tag、构建时间。要管理员在设置里打开「site.show_build」才显示;
* 关着的时候(或者后端太旧没有这个接口)什么也不画,前端自己的信息也不单独露出来。
*/
export function BuildFooter() {
const about = useQuery(() => api.get<AboutDto>('/api/public/about'), [], 5 * 60_000)
if (!about.data?.show_build || !about.data.backend) return null
const backend: BuildDto = about.data.backend
return (
<div className="build-info">
<span>后端 {buildText(backend)}</span>
<span>前端 {buildText({ version: frontendBuild.version, tag: frontendBuild.tag, built_at: frontendBuild.builtAt })}</span>
</div>
)
}
+16 -60
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState, type MouseEvent } from 'react'
import { useRef, useState, type MouseEvent } from 'react'
import { api } from '@shared/api/client'
import type { SeriesDto } from '@shared/api/types'
import { fmtMs, fmtPct } from '@shared/format'
@@ -30,9 +30,7 @@ interface StripProps {
/** 每 5 分钟一格:这一格的丢包(重传)率、延迟。 */
t: number[]
badSeries: (number | null)[]
rttSeries: (number | null)[]
ready: boolean
loading: boolean
/** 提示里这一格的「丢包」叫什么。 */
tipBad: string
/** 没有数据时占位的格数,和每格长度的说明(「每 5 分钟」)。 */
@@ -41,42 +39,13 @@ interface StripProps {
hoursText: string
/** 悬停的那一格(UDP 和 TCP 共用);`null` = 没有。 */
hover: number | null
onHover: (i: number | null) => void
}
/**
* 一排小格(每 5 分钟一格,按这 5 分钟的丢包率着色:绿 / 黄 / 红 / 灰 = 没有数据)
* 加一条延迟的小走势(只标纵轴最大值),尽量轻。
* 尽量轻。
*/
function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, rttSeries, ready, loading, cells, stepText, hoursText, hover, onHover }: StripProps) {
const count = ready ? badSeries.length : 0
const onSparkMove = (e: MouseEvent<HTMLDivElement>) => {
if (count < 2) return
const rect = e.currentTarget.getBoundingClientRect()
onHover(Math.min(count - 1, Math.max(0, Math.round(((e.clientX - rect.left) / rect.width) * (count - 1)))))
}
// 纵轴贴着数据走:从最小值到最大值(上下各留一点),而不是从 0 开始——延迟本来就在一个窄范围里波动,从 0 画会成一条平线
const spark = useMemo(() => {
const points = rttSeries.map((v, i) => ({ v, i })).filter((p): p is { v: number; i: number } => p.v !== null && p.v !== undefined)
if (points.length < 2) return null
const lo = Math.min(...points.map((p) => p.v))
const hi = Math.max(...points.map((p) => p.v))
const mean = points.reduce((sum, p) => sum + p.v, 0) / points.length
const pad = Math.max((hi - lo) * 0.15, mean * 0.03, 1)
const top = hi + pad
const bottom = Math.max(0, lo - pad)
const n = rttSeries.length - 1
const W = 300
const H = 30
const x = (i: number) => (i / n) * W
const y = (v: number) => H - ((v - bottom) / (top - bottom)) * H
const line = points.map((p, k) => `${k === 0 ? 'M' : 'L'}${x(p.i).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ')
const area = `${line} L${x(points[points.length - 1].i).toFixed(1)},${H} L${x(points[0].i).toFixed(1)},${H} Z`
const fmt = (v: number) => (v >= 100 ? String(Math.round(v)) : v.toFixed(1))
const dots = rttSeries.map((v) => (v === null || v === undefined ? null : y(v) / H))
return { line, area, hi: fmt(hi), lo: fmt(lo), W, H, dots }
}, [rttSeries])
function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, ready, cells, stepText, hoursText, hover }: StripProps) {
return (
<div className="udp-strip">
<div className="udp-head">
@@ -89,29 +58,9 @@ function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, rttSeries,
</div>
<div className="bars" role="img" aria-label={`近 ${hoursText}每 ${stepText}的${badLabel}情况`}>
{(ready ? badSeries : Array.from({ length: cells }, () => null)).map((v, i) => (
<span key={t[i] ?? i} className={`bar ${ready ? levelOf(v) : 'none'}${hover === i ? ' on' : ''}`} onMouseEnter={() => ready && onHover(i)} />
<span key={t[i] ?? i} className={`bar ${ready ? levelOf(v) : 'none'}${hover === i ? ' on' : ''}`} />
))}
</div>
<div className="spark" onMouseMove={onSparkMove}>
{spark ? (
<>
<svg viewBox={`0 0 ${spark.W} ${spark.H}`} preserveAspectRatio="none" aria-hidden="true">
<path className="area" d={spark.area} />
<path className="line" d={spark.line} vectorEffect="non-scaling-stroke" />
</svg>
{hover !== null && spark.dots[hover] !== null && spark.dots[hover] !== undefined ? (
<>
<i className="guide" style={{ left: `${(hover / (count - 1)) * 100}%` }} />
<i className="dot" style={{ left: `${(hover / (count - 1)) * 100}%`, top: `${(spark.dots[hover] as number) * 100}%` }} />
</>
) : null}
<span className="ymax num">{spark.hi}</span>
<span className="ymin num">{spark.lo}</span>
</>
) : (
<span className="faint empty">{loading ? '加载中…' : '没有数据'}</span>
)}
</div>
</div>
)
}
@@ -123,7 +72,7 @@ interface Props {
m: Record<string, number | null | undefined>
}
/** 一条线路近一段时间的 UDP 和 TCP:各是一排小格加一条延迟走势,共用一次请求。 */
/** 一条线路近一段时间的 UDP 和 TCP:各是一排小格,共用一次请求。 */
export function LineStrips({ lineId, range, m }: Props) {
const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0]
const series = useQuery(
@@ -139,10 +88,18 @@ export function LineStrips({ lineId, range, m }: Props) {
const last = useRef<number | null>(null) // 提示收回去的动画期间,内容还要留着
if (hover !== null) last.current = hover
const shown = hover ?? last.current
const common = { t, ready: Boolean(s), loading: series.loading, cells: Math.round((info.hours * 60) / info.stepMin), stepText: `${info.stepMin} 分钟`, hoursText: `${info.hours} 小时`, hover, onHover: setHover }
// 整块线路区域都能触发:按鼠标横向位置对应到小格(UDP 和 TCP 的小格一样宽,量第一排就行),不用精确指到小格上
const box = useRef<HTMLDivElement>(null)
const onMove = (e: MouseEvent<HTMLDivElement>) => {
const bars = box.current?.querySelector('.bars')
if (!s || t.length < 1 || !bars) return
const r = bars.getBoundingClientRect()
setHover(Math.min(t.length - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * t.length))))
}
const common = { t, ready: Boolean(s), cells: Math.round((info.hours * 60) / info.stepMin), stepText: `${info.stepMin} 分钟`, hoursText: `${info.hours} 小时`, hover }
const at = shown !== null && t[shown] !== undefined ? shown : null
return (
<div className="strips" onMouseLeave={() => setHover(null)}>
<div className="strips" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
<Strip
{...common}
rttLabel="UDP 延迟 p50"
@@ -151,10 +108,9 @@ export function LineStrips({ lineId, range, m }: Props) {
bad={m['loss.total']}
badHint={`上行丢包 ${fmtPct(m['loss.up'])} 下行丢包 ${fmtPct(m['loss.down'])}`}
badSeries={col('loss.total')}
rttSeries={col('rtt.p50')}
tipBad="丢包"
/>
<Strip {...common} rttLabel="TCP RTT p50" rtt={m['tcp.rtt.p50']} badLabel="重传" bad={m['tcp.retrans']} badSeries={col('tcp.retrans')} rttSeries={col('tcp.rtt.p50')} tipBad="重传" />
<Strip {...common} rttLabel="TCP RTT p50" rtt={m['tcp.rtt.p50']} badLabel="重传" bad={m['tcp.retrans']} badSeries={col('tcp.retrans')} tipBad="重传" />
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
{at !== null ? (
<>
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

+257 -68
View File
@@ -862,54 +862,6 @@
opacity: 1;
}
.spark {
position: relative;
height: 30px;
}
.spark svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.spark .area {
fill: color-mix(in srgb, var(--app-accent) 12%, transparent);
}
.spark .line {
fill: none;
stroke: var(--app-accent);
stroke-width: 1.2;
}
.spark .ymax,
.spark .ymin {
position: absolute;
left: 2px;
font-size: 9px;
line-height: 1;
color: var(--app-ink-faint);
}
.spark .ymax {
top: 0;
}
.spark .ymin {
bottom: 0;
}
.spark .empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 11px;
}
/* UDP 和 TCP 两段之间留一点空 */
.strips {
display: flex;
@@ -923,26 +875,6 @@
outline-offset: 1px;
}
.spark .guide {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--app-ink-faint);
pointer-events: none;
}
.spark .dot {
position: absolute;
width: 7px;
height: 7px;
margin: -3.5px 0 0 -3.5px;
border-radius: 50%;
background: var(--app-accent);
box-shadow: 0 0 0 2px var(--app-card);
pointer-events: none;
}
.strips {
position: relative;
}
@@ -1038,3 +970,260 @@
display: none;
}
}
.site-foot {
max-width: 900px;
margin: 48px auto 24px;
padding: 0 16px;
font-size: 11px;
text-align: center;
color: var(--app-ink-faint);
}
.site-foot a {
color: inherit;
text-decoration: underline;
}
/* Agent 卡片:国旗开头,名字下面是「运营商 / 系统」两行(左边名字右边内容,和下面的流量、网速一个风格) */
/* 亚克力:整张卡片半透明 + 背景模糊,细亮边,大一点的投影;标题栏是稍深的一条,底下垫放大模糊的国旗 */
/* 背景图固定在最底下,自己模糊一点(边缘放大一点,免得模糊后露出白边);上面盖一层页面底色,亮色盖得浅一点,暗色盖得深 */
body::before {
content: '';
position: fixed;
inset: -24px;
z-index: -1;
background-image:
linear-gradient(color-mix(in srgb, var(--app-bg) var(--bg-veil), transparent), color-mix(in srgb, var(--app-bg) var(--bg-veil), transparent)),
url('./assets/bg.jpg');
background-size: cover;
background-position: center;
filter: blur(var(--bg-blur));
pointer-events: none;
}
:root {
--bg-veil: 50%;
--bg-blur: 10px;
}
:root[data-theme="dark"] {
--bg-veil: 82%;
}
.agent-card {
background: color-mix(in srgb, var(--app-card) 76%, transparent);
backdrop-filter: blur(28px) saturate(1.6);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
0 14px 32px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
}
.agent-head {
position: relative;
isolation: isolate;
margin: -20px -20px 0;
padding: 16px 20px;
border-radius: var(--r-card) var(--r-card) 0 0;
background: color-mix(in srgb, var(--app-ink) 5%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--app-ink) 8%, transparent);
}
.agent-head .flag {
flex: none;
width: 24px;
height: 18px;
border-radius: 3px;
object-fit: cover;
box-shadow: 0 0 0 1px var(--app-outline-soft);
}
.agent-head .flag.ph {
display: inline-block;
background: var(--app-card-inset);
}
.facts {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.fact {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.fact .k {
color: var(--app-ink-faint);
}
.fact .v {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--app-ink);
}
.fact .v .isp {
flex: none;
width: 16px;
height: 16px;
object-fit: contain;
}
.fact .v .isp rect {
fill: var(--app-ink-faint);
}
.fact .v .isp text {
fill: var(--isp-ink);
font-size: 10px;
font-weight: 700;
}
.fact .v .isp-broadnet rect {
fill: var(--isp-broadnet);
}
/* 线路行:国旗在 Probe 名字前面,运营商图标在后面 */
.line-name {
display: inline-flex;
align-items: center;
gap: 6px;
}
.line-name .flag {
flex: none;
width: 18px;
height: 13.5px;
border-radius: 2px;
object-fit: cover;
box-shadow: 0 0 0 1px var(--app-outline-soft);
}
.line-top .isp {
flex: none;
width: 16px;
height: 16px;
object-fit: contain;
}
.line-top .isp rect {
fill: var(--app-ink-faint);
}
.line-top .isp text {
fill: var(--isp-ink);
font-size: 10px;
font-weight: 700;
}
.line-top .isp-broadnet rect {
fill: var(--isp-broadnet);
}
/* 亚克力卡片里的线路块:不用实色,只叠一层淡淡的墨色和细描边,透出卡片本身 */
.agent-card .line-link {
background: color-mix(in srgb, var(--app-ink) 4%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 7%, transparent);
}
.agent-card .line-link:hover {
background: color-mix(in srgb, var(--app-ink) 7%, transparent);
}
/* 线路详情、对比等页面的卡片和指标块:同样的亚克力,一层压一层 */
.card:not(.agent-card),
.tiles > .stat,
.tiles > .tile {
background: color-mix(in srgb, var(--app-card) 76%, transparent);
backdrop-filter: blur(28px) saturate(1.6);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
0 14px 32px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
}
/* 小的指标块浮得低一点:阴影收小 */
.tiles > .stat {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 8%, transparent),
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
0 6px 14px -8px color-mix(in srgb, var(--app-shadow) 40%, transparent);
}
.topbar {
background: color-mix(in srgb, var(--app-card) 62%, transparent);
backdrop-filter: blur(20px) saturate(1.5);
border-bottom-color: color-mix(in srgb, var(--app-ink) 7%, transparent);
box-shadow: 0 4px 14px -8px color-mix(in srgb, var(--app-shadow) 28%, transparent);
}
/* 时间轴:同样的亚克力。有数据的段是半透明的亮底,没数据的段是淡淡的斜纹,整条有细描边和投影 */
.minimap .track {
background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--app-card) 55%, var(--app-idle)) 0 5px, color-mix(in srgb, var(--app-card) 70%, var(--app-idle)) 5px 10px);
backdrop-filter: blur(20px) saturate(1.6);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
0 10px 24px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
}
.minimap svg rect.has {
fill: color-mix(in srgb, var(--app-card) 80%, transparent);
}
/* 直接落在背景图上的字(页面标题、分组名、说明):加一圈和页面底色一样的柔光,免得被图案吃掉 */
body {
text-shadow: 0 0 8px color-mix(in srgb, var(--app-bg) 90%, transparent), 0 0 2px color-mix(in srgb, var(--app-bg) 70%, transparent);
}
.card,
.pop,
.tiles > .stat,
.tiles > .tile,
.topbar,
button,
input {
text-shadow: none;
}
/* 时间轴整条做成圆角:轨道、里面的图、选区用同一个半径 */
.minimap .track,
.minimap svg,
.minimap .sel {
border-radius: 18px;
}
/* 版本、tag:等宽的小字 */
.fact .v.mono-tag,
.probe-tag {
font-family: var(--font-mono);
font-size: 11px;
}
.probe-tag {
padding: 0 6px;
border-radius: 6px;
background: color-mix(in srgb, var(--app-ink) 6%, transparent);
color: var(--app-ink-muted);
}
.build-info {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 16px;
margin-top: 4px;
font-family: var(--font-mono);
font-size: 11px;
}
+10
View File
@@ -5,6 +5,7 @@ import { api } from '@shared/api/client'
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 { NodeTag, useNodeInfos } from '@shared/nodes'
import { fmtBytes, useHosts } from '../live'
import { useQuery } from '@shared/hooks'
import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui'
@@ -146,6 +147,7 @@ export function Line() {
const [params, setParams] = useSearchParams()
const line = useQuery(() => api.get<LineDto>(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000)
const infos = useNodeInfos()
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])
@@ -258,6 +260,14 @@ export function Line() {
<div className="row wrap" style={{ marginTop: 12 }}>
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
<span className="muted">{line.data.online ? (line.data.probe_connected ? '在线' : 'Agent 在线,Probe 未连接') : 'Agent 离线'}</span>
<span className="row" style={{ gap: 6 }}>
<span className="faint">Agent</span>
<NodeTag info={infos.get(line.data.agent_id)} />
</span>
<span className="row" style={{ gap: 6 }}>
<span className="faint">Probe</span>
<NodeTag info={infos.get(line.data.probe_id)} />
</span>
{line.data.labels.map((x) => (
<Chip key={x}>{x}</Chip>
))}
+49 -5
View File
@@ -1,11 +1,13 @@
import { useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
import type { OverviewDto, OverviewLineDto } from '@shared/api/types'
import type { NodeInfoDto, OverviewDto, OverviewLineDto } from '@shared/api/types'
import { fmtAgo } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Dot, Empty, Segmented, Spinner } from '@shared/ui'
import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts, type HostItem } from '../live'
import { Flag, IspIcon, useNodeInfos } from '@shared/nodes'
import { OsIcon } from '@shared/osIcon'
import { PageError } from '../lib'
import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
@@ -36,17 +38,49 @@ function Meter({ label, value, detail }: { label: string; value: number | null;
)
}
function Card({ agent, host, range }: { agent: AgentCard; host: HostItem | undefined; range: OverviewRange }) {
function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem | undefined; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
const h = host?.host ?? null
const stale = !agent.online || !host || host.stale
const info = infos.get(agent.id)
return (
<div className={`card agent-card ${agent.online ? '' : 'offline'}`}>
<div className="agent-head">
<Dot tone={agent.online ? 'ok' : 'bad'} />
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
{info?.country ? <Flag code={info.country} /> : <Dot tone={agent.online ? 'ok' : 'bad'} />}
<span className="agent-name">{agent.name}</span>
<Chip tone={agent.online ? 'ok' : 'bad'}>{agent.online ? '在线' : '离线'}</Chip>
{!agent.online && host?.last_seen_at ? <span className="faint">{fmtAgo(host.last_seen_at)}</span> : null}
</div>
{info?.isp_name || info?.os_name || info?.tag || info?.version ? (
<div className="facts">
{info.isp_name ? (
<div className="fact">
<span className="k">运营商</span>
<span className="v">
{info.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
{info.isp_name}
</span>
</div>
) : null}
{info.os_name ? (
<div className="fact">
<span className="k">系统</span>
<span className="v">
<OsIcon os={info.os} name={info.os_name} />
{[info.os_name, info.arch].filter(Boolean).join(' / ')}
</span>
</div>
) : null}
{info.tag || info.version ? (
<div className="fact">
<span className="k">版本</span>
<span className="v mono-tag" title={info.version ? `版本 ${info.version}` : undefined}>
{info.tag || info.version}
</span>
</div>
) : null}
</div>
) : null}
{agent.labels.length > 0 ? (
<div className="line-meta">
{agent.labels.map((x) => (
@@ -61,7 +95,16 @@ function Card({ agent, host, range }: { agent: AgentCard; host: HostItem | undef
return (
<Link key={l.id} to={`/line/${encodeURIComponent(l.id)}`} className="line-link">
<div className="line-top">
<span className="line-name">→ {l.probe_name}</span>
<span className="line-name">
→ {infos.get(l.probe_id)?.country ? <Flag code={infos.get(l.probe_id)!.country!} /> : null}
{l.probe_name}
</span>
{infos.get(l.probe_id)?.isp_key ? <IspIcon ispKey={infos.get(l.probe_id)!.isp_key!} name={infos.get(l.probe_id)!.isp_name} /> : null}
{infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version ? (
<span className="probe-tag" title="Probe 版本">
{infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version}
</span>
) : null}
<span className="faint">近 {OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>
{!l.probe_connected && l.online ? <Chip tone="warn">Probe 未连接</Chip> : null}
</div>
@@ -108,6 +151,7 @@ export function Overview() {
const [probe, setProbe] = useState<string>('')
const [sort, setSort] = useState<SortKey>('name')
const { hosts, live } = useHosts()
const infos = useNodeInfos()
// 近多久的汇总和走势:3 / 6 / 12 小时,记在链接里(?range=6h)
const [params, setParams] = useSearchParams()
const range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h'
@@ -187,7 +231,7 @@ export function Overview() {
) : (
<div className="cards">
{cards.map((c) => (
<Card key={c.id} agent={c} host={hosts.get(c.id)} range={range} />
<Card key={c.id} agent={c} host={hosts.get(c.id)} range={range} infos={infos} />
))}
</div>
)