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

+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>
)