+ 样本数:UDP {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}
+
- {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/pages/Overview.tsx b/web/display/src/pages/Overview.tsx
index 405d9ab..e20562a 100644
--- a/web/display/src/pages/Overview.tsx
+++ b/web/display/src/pages/Overview.tsx
@@ -1,16 +1,19 @@
import { useMemo, useState } from 'react'
-import { Link } from 'react-router-dom'
+import { Link, useSearchParams } from 'react-router-dom'
import { api } from '@shared/api/client'
-import type { OverviewDto, OverviewLineDto } from '@shared/api/types'
-import { fmtAgo, fmtMs, fmtPct } from '@shared/format'
+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 { PageError, ScoreChips } from '../lib'
+import { Flag, IspIcon, useNodeInfos } from '@shared/nodes'
+import { OsIcon } from '@shared/osIcon'
+import { PageError } from '../lib'
+import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
-const METRICS = ['rtt.p50', 'loss.total', 'jitter.up.p95', 'jitter.down.p95', 'score.base', 'score.reality', 'score.hy2', 'score.mc'].join(',')
+const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',')
-type SortKey = 'name' | 'rtt' | 'score'
+type SortKey = 'name' | 'rtt' | 'loss'
interface AgentCard {
id: string
@@ -20,12 +23,6 @@ interface AgentCard {
lines: OverviewLineDto[]
}
-function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' {
- if (value === null || value === undefined) return ''
- if (value <= good) return 'ok'
- if (value >= poor) return 'bad'
- return 'warn'
-}
function Meter({ label, value, detail }: { label: string; value: number | null; detail?: string }) {
const level = value === null ? '' : value >= 90 ? 'bad' : value >= 75 ? 'warn' : 'ok'
@@ -41,17 +38,49 @@ function Meter({ label, value, detail }: { label: string; value: number | null;
)
}
-function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined }) {
+function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem | undefined; range: OverviewRange; infos: Map
}) {
const h = host?.host ?? null
const stale = !agent.online || !host || host.stale
+ const info = infos.get(agent.id)
return (
-
+ {/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
+ {info?.country ? : }
{agent.name}
{agent.online ? '在线' : '离线'}
{!agent.online && host?.last_seen_at ? {fmtAgo(host.last_seen_at)} : null}
+ {info?.isp_name || info?.os_name || info?.tag || info?.version ? (
+
+ {info.isp_name ? (
+
+ 运营商
+
+ {info.isp_key ? : null}
+ {info.isp_name}
+
+
+ ) : null}
+ {info.os_name ? (
+
+ 系统
+
+
+ {[info.os_name, info.arch].filter(Boolean).join(' / ')}
+
+
+ ) : null}
+ {info.tag || info.version ? (
+
+ 版本
+
+ {info.tag || info.version}
+
+
+ ) : null}
+
+ ) : null}
{agent.labels.length > 0 ? (
{agent.labels.map((x) => (
@@ -65,12 +94,21 @@ function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined })
const m = l.summary.metrics
return (
-
→ {l.probe_name}
-
- {!l.probe_connected && l.online ?
Probe 未连接 : null}
-
{fmtMs(m['rtt.p50'])}
-
{fmtPct(m['loss.total'])}
-
+
+
+ → {infos.get(l.probe_id)?.country ? : null}
+ {l.probe_name}
+
+ {infos.get(l.probe_id)?.isp_key ? : null}
+ {infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version ? (
+
+ {infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version}
+
+ ) : null}
+ 近 {OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}
+ {!l.probe_connected && l.online ? Probe 未连接 : null}
+
+
)
})}
@@ -113,7 +151,21 @@ export function Overview() {
const [probe, setProbe] = useState
('')
const [sort, setSort] = useState('name')
const { hosts, live } = useHosts()
- const { data, error, loading } = useQuery(() => api.get('/api/public/overview', { range: '15m', metrics: METRICS }), [], 10_000)
+ 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'
+ const setRange = (v: OverviewRange) =>
+ setParams(
+ (prev) => {
+ const next = new URLSearchParams(prev)
+ if (v === '3h') next.delete('range')
+ else next.set('range', v)
+ return next
+ },
+ { replace: true },
+ )
+ const { data, error, loading } = useQuery(() => api.get('/api/public/overview', { range, metrics: METRICS }), [range], 10_000)
const probes = useMemo(() => [...new Set((data?.lines ?? []).map((l) => l.probe_name))].sort(), [data])
const cards = useMemo(() => {
@@ -131,13 +183,13 @@ export function Overview() {
return [...byAgent.values()].sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1
if (sort === 'name') return a.name.localeCompare(b.name)
- const key = sort === 'rtt' ? 'rtt.p50' : 'score.base'
- const x = best(a, key, sort === 'rtt' ? Math.min : Math.max)
- const y = best(b, key, sort === 'rtt' ? Math.min : Math.max)
+ const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
+ const x = best(a, key, Math.min)
+ const y = best(b, key, Math.min)
if (x === null && y === null) return a.name.localeCompare(b.name)
if (x === null) return 1
if (y === null) return -1
- return sort === 'rtt' ? x - y : y - x
+ return x - y
})
}, [data, probe, sort])
@@ -146,7 +198,7 @@ export function Overview() {
return (
- 线路质量
+ 总览
{data ? `${online} / ${cards.length} 台在线` : '加载中…'}
@@ -154,11 +206,12 @@ export function Overview() {
{probes.length > 1 ? ({ value: p, label: p }))]} value={probe} onChange={setProbe} /> : null}
+ options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: `近 ${r.label}` }))} value={range} onChange={setRange} />
{cards.map((c) => (
-
+
))}
)
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/display/vite.config.ts b/web/display/vite.config.ts
index 8add9de..5ee65d5 100644
--- a/web/display/vite.config.ts
+++ b/web/display/vite.config.ts
@@ -1,20 +1,25 @@
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
+import { buildDefines } from '../build-info.mjs'
// 开发时把 /api 转发给后端,地址取自环境变量 BACKEND_URL,或 web/.env.local 里的 BACKEND_URL=(见 web/.env.example):
// BACKEND_URL=https://mo.oeo.one npm run dev:display
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '')
- const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:5000').replace(/\/+$/, '')
+ const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:29181').replace(/\/+$/, '')
return {
base: '/',
+ define: buildDefines(mode === 'development'),
plugins: [react()],
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
server: {
port: 5173,
+ strictPort: true,
fs: { allow: ['..'] },
proxy: {
+ // 开发时管理端是另一个 Vite 服务(5175);和线上一样用同源的 /admin/ 访问它
+ '/admin': { target: 'http://localhost:5175', changeOrigin: true, ws: true },
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
'/healthz': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
},
diff --git a/web/package-lock.json b/web/package-lock.json
index 3d541e2..af31271 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -10,7 +10,11 @@
"workspaces": [
"display",
"admin"
- ]
+ ],
+ "dependencies": {
+ "flag-icons": "^7.5.0",
+ "simple-icons": "^16.34.0"
+ }
},
"admin": {
"name": "monitor-admin",
@@ -998,6 +1002,12 @@
"node": ">=8"
}
},
+ "node_modules/flag-icons": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
+ "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
+ "license": "MIT"
+ },
"node_modules/flat-cache": {
"version": "6.1.23",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz",
@@ -2089,6 +2099,25 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/simple-icons": {
+ "version": "16.34.0",
+ "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.34.0.tgz",
+ "integrity": "sha512-UTxKn0PVspk84B2hHF3Dqiqht1yDjJmDc/t4cO7PyCQxCMk6OjEu95pnpGjhLaGeQ4qBrGASbefTIe/+czrX7g==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/simple-icons"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/simple-icons"
+ }
+ ],
+ "license": "CC0-1.0",
+ "engines": {
+ "node": ">=0.12.18"
+ }
+ },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
diff --git a/web/package.json b/web/package.json
index 774a719..1e27293 100644
--- a/web/package.json
+++ b/web/package.json
@@ -2,7 +2,10 @@
"name": "monitor-web",
"private": true,
"version": "0.1.0",
- "workspaces": ["display", "admin"],
+ "workspaces": [
+ "display",
+ "admin"
+ ],
"scripts": {
"dev": "node dev.mjs",
"dev:display": "npm run dev -w display",
@@ -10,5 +13,9 @@
"build": "npm run build -w display && npm run build -w admin",
"gen:api": "npx -y openapi-typescript ../backend/openapi/v1.json -o shared/api/schema.ts",
"lint:css": "npm run lint:css -w display && npm run lint:css -w admin"
+ },
+ "dependencies": {
+ "flag-icons": "^7.5.0",
+ "simple-icons": "^16.34.0"
}
}
diff --git a/web/shared/api/client.ts b/web/shared/api/client.ts
index a32692a..78035d7 100644
--- a/web/shared/api/client.ts
+++ b/web/shared/api/client.ts
@@ -57,6 +57,28 @@ async function request(method: string, path: string, body?: unknown, init?: R
return data as T
}
+/**
+ * 在另一个后端上用用户名和密码登录:不设 Cookie(跨域用不了),把会话令牌放在响应里返回(`return_session`),
+ * 调用方存进浏览器,之后每个请求带上。
+ */
+export async function loginTo(base: string, username: string, password: string): Promise {
+ const response = await fetch(`${base}/api/admin/auth/login`, {
+ method: 'POST',
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
+ credentials: 'omit',
+ body: JSON.stringify({ username, password, return_session: true }),
+ })
+ const text = await response.text()
+ const data = text ? (JSON.parse(text) as unknown) : undefined
+ if (!response.ok) {
+ const problem = (data ?? {}) as { code?: string; detail?: string; title?: string }
+ throw new ApiError(response.status, problem.code ?? 'error', problem.detail ?? problem.title ?? response.statusText)
+ }
+ const token = (data as { session_token?: string | null } | undefined)?.session_token
+ if (!token) throw new ApiError(502, 'no_session', '这个后端的版本还不支持跨域登录,请先升级后端')
+ return token
+}
+
export const api = {
get: (path: string, query?: Query, init?: RequestInit) => request('GET', withQuery(path, query), undefined, init),
post: (path: string, body?: unknown) => request('POST', path, body ?? {}),
diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts
index 906018e..4a5da8b 100644
--- a/web/shared/api/schema.ts
+++ b/web/shared/api/schema.ts
@@ -174,6 +174,40 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/public/nodes": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Display info of the visible agents and probes: system, architecture, country and ISP (never an address) */
+ get: operations["nodes_list"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/public/about": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** The backend's version, tag and build time, if the site shows build info */
+ get: operations["about_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/public/catalog/kinds": {
parameters: {
query?: never;
@@ -442,7 +476,7 @@ export interface paths {
get?: never;
put?: never;
post?: never;
- /** Revoke a join token */
+ /** Revoke a join token; with purge=true delete the record for good */
delete: operations["join_tokens_revoke"];
options?: never;
head?: never;
@@ -765,6 +799,23 @@ export interface paths {
patch: operations["users_update"];
trace?: never;
};
+ "/api/admin/build-info": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Version, tag, commit and build time of the backend and every agent and probe */
+ get: operations["build_info_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/healthz": {
parameters: {
query?: never;
@@ -854,6 +905,10 @@ export interface paths {
export type webhooks = Record;
export interface components {
schemas: {
+ AboutDto: {
+ show_build: boolean;
+ backend: null | components["schemas"]["BuildDto"];
+ };
AgentDto: {
id: string;
name: string;
@@ -915,6 +970,16 @@ export interface components {
tcp_port: number | string;
spki: string;
};
+ BuildDto: {
+ version: string;
+ tag: null | string;
+ commit: null | string;
+ built_at: null | string;
+ };
+ BuildInfoDto: {
+ backend: components["schemas"]["BuildDto"];
+ nodes: components["schemas"]["NodeBuildDto"][];
+ };
ChangePasswordRequest: {
password: string;
};
@@ -986,7 +1051,7 @@ export interface components {
CreatedJoinTokenDto: {
token: components["schemas"]["JoinTokenDto"];
plaintext: string;
- install_command: string;
+ install_commands: components["schemas"]["InstallCommandsDto"];
};
DimensionDto: {
id: string;
@@ -1112,6 +1177,11 @@ export interface components {
[key: string]: (null | number | string)[];
};
};
+ InstallCommandsDto: {
+ linux: string;
+ macos: string;
+ windows: string;
+ };
JoinTokenDto: {
id: string;
note: null | string;
@@ -1164,6 +1234,7 @@ export interface components {
LoginRequest: {
username: string;
password: string;
+ return_session: null | boolean;
};
LoginResponse: {
user_id: string;
@@ -1171,6 +1242,7 @@ export interface components {
csrf_token: string;
/** Format: date-time */
expires_at: unknown;
+ session_token: null | string;
};
MeDto: {
kind: string;
@@ -1202,6 +1274,29 @@ export interface components {
version: number | string;
needs: string[];
};
+ NodeBuildDto: {
+ id: string;
+ kind: string;
+ os: null | string;
+ os_name: null | string;
+ arch: null | string;
+ version: null | string;
+ tag: null | string;
+ commit: null | string;
+ built_at: null | string;
+ };
+ NodeInfoDto: {
+ id: string;
+ kind: string;
+ os: null | string;
+ os_name: null | string;
+ arch: null | string;
+ country: null | string;
+ isp_key: null | string;
+ isp_name: null | string;
+ version?: null | string;
+ tag?: null | string;
+ };
OverviewDto: {
range: string;
/** Format: date-time */
@@ -1259,6 +1354,10 @@ export interface components {
items: components["schemas"]["ModelDto"][];
next_cursor: null | string;
};
+ PageDtoOfNodeInfoDto: {
+ items: components["schemas"]["NodeInfoDto"][];
+ next_cursor: null | string;
+ };
PageDtoOfPlanDto: {
items: components["schemas"]["PlanDto"][];
next_cursor: null | string;
@@ -2126,6 +2225,64 @@ export interface operations {
};
};
};
+ nodes_list: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PageDtoOfNodeInfoDto"];
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ };
+ };
+ about_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["AboutDto"];
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ };
+ };
catalog_kinds: {
parameters: {
query?: never;
@@ -2902,7 +3059,9 @@ export interface operations {
};
join_tokens_revoke: {
parameters: {
- query?: never;
+ query?: {
+ purge?: boolean;
+ };
header?: never;
path: {
id: string;
@@ -4361,6 +4520,44 @@ export interface operations {
};
};
};
+ build_info_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["BuildInfoDto"];
+ };
+ };
+ /** @description Unauthorized */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/problem+json": components["schemas"]["ProblemDto"];
+ };
+ };
+ };
+ };
health_get: {
parameters: {
query?: never;
diff --git a/web/shared/api/source.ts b/web/shared/api/source.ts
index 15c33ea..4dc6a13 100644
--- a/web/shared/api/source.ts
+++ b/web/shared/api/source.ts
@@ -41,17 +41,65 @@ export function getBase(): string {
return normalizeBase(read(BASE_KEY) || (import.meta.env.VITE_API_BASE as string | undefined) || '')
}
+export interface SavedSource {
+ base: string
+ /** 在这个后端登录之后拿到的会话(只存在浏览器里);空 = 还没登录。 */
+ token: string
+}
+
+// v2:以前保存的是手填的管理 Token,现在是登录后拿到的会话,旧的不再使用
+const listKey = () => `monitor.sources.v2.${app}`
+
+function readList(): SavedSource[] {
+ try {
+ const parsed = JSON.parse(read(listKey()) || '[]') as unknown
+ return Array.isArray(parsed) ? (parsed as SavedSource[]).filter((x) => typeof x?.base === 'string' && x.base !== '') : []
+ } catch {
+ return []
+ }
+}
+
+/** 保存过的后端(每个前端各存各的登录会话);当前连接的那个一定在里面。不含这个页面自己所在的后端。 */
+export function listSources(): SavedSource[] {
+ const list = readList()
+ const active = getBase()
+ if (active && !list.some((x) => x.base === active)) list.push({ base: active, token: '' })
+ return list
+}
+
+function writeList(list: SavedSource[]) {
+ write(listKey(), JSON.stringify(list))
+}
+
+/** 当前连接的后端的登录会话;这个页面自己所在的后端用 Cookie,没有。 */
export function getToken(): string {
- return read(`monitor.api_token.${app}`)
+ const active = getBase()
+ return active ? (readList().find((x) => x.base === active)?.token ?? '') : ''
}
+/** 保存(或更新)一个后端和它的登录会话,并连过去。地址留空 = 这个页面自己所在的后端。 */
export function setSource(base: string, token: string) {
- write(BASE_KEY, normalizeBase(base))
- write(`monitor.api_token.${app}`, token.trim())
+ const next = normalizeBase(base)
+ if (next) writeList([...readList().filter((x) => x.base !== next), { base: next, token }])
+ write(BASE_KEY, next)
}
+/** 切换到已经保存过的后端(空串 = 这个页面自己所在的后端)。 */
+export function activateSource(base: string) {
+ write(BASE_KEY, normalizeBase(base))
+}
+
+/** 忘掉一个保存过的后端;正连着的话回到这个页面自己所在的后端。 */
+export function removeSource(base: string) {
+ writeList(readList().filter((x) => x.base !== base))
+ if (getBase() === base) write(BASE_KEY, '')
+}
+
+/** 只改当前连接的后端的登录会话(过期、退出时清掉)。 */
export function setToken(token: string) {
- write(`monitor.api_token.${app}`, token.trim())
+ const active = getBase()
+ if (!active) return
+ writeList([...readList().filter((x) => x.base !== active), { base: active, token }])
}
export const isRemote = () => getBase() !== ''
@@ -66,6 +114,18 @@ export function authHeaders(): Record {
return isRemote() && token ? { Authorization: `Bearer ${token}` } : {}
}
+/**
+ * 当前后端连不连得上:只看有没有响应(跨域时拿不到内容也没关系,所以用 no-cors);网络不通、域名解析失败、连接被拒才算失败。
+ */
+export async function pingBackend(): Promise {
+ try {
+ await fetch(resolveUrl('/healthz'), { mode: 'no-cors', cache: 'no-store' })
+ return true
+ } catch {
+ return false
+ }
+}
+
export interface LiveSource {
onopen: (() => void) | null
onerror: (() => void) | null
diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts
index e4ee9ca..97e91f1 100644
--- a/web/shared/api/types.ts
+++ b/web/shared/api/types.ts
@@ -12,6 +12,11 @@ export type ProbeDto = Narrow
export type ProbeConnectionDto = Narrow
export type ProbeIdentityDto = Narrow
export type LineDto = Narrow
+export type NodeInfoDto = Narrow
+export type AboutDto = Narrow
+export type BuildDto = Narrow
+export type BuildInfoDto = Narrow
+export type NodeBuildDto = Narrow
export type OverviewDto = Narrow
export type OverviewLineDto = Narrow
export type SeriesDto = Narrow
diff --git a/web/shared/assets/isp/mobile.svg b/web/shared/assets/isp/mobile.svg
new file mode 100644
index 0000000..1c9be77
--- /dev/null
+++ b/web/shared/assets/isp/mobile.svg
@@ -0,0 +1,67 @@
+
+
+
+
diff --git a/web/shared/assets/isp/telecom.svg b/web/shared/assets/isp/telecom.svg
new file mode 100644
index 0000000..39e3ce4
--- /dev/null
+++ b/web/shared/assets/isp/telecom.svg
@@ -0,0 +1,142 @@
+
+
+
+
diff --git a/web/shared/assets/isp/unicom.svg b/web/shared/assets/isp/unicom.svg
new file mode 100644
index 0000000..4b78655
--- /dev/null
+++ b/web/shared/assets/isp/unicom.svg
@@ -0,0 +1,78 @@
+
+
+
diff --git a/web/shared/buildInfo.ts b/web/shared/buildInfo.ts
new file mode 100644
index 0000000..82ee57c
--- /dev/null
+++ b/web/shared/buildInfo.ts
@@ -0,0 +1,21 @@
+/** 前端自己的构建信息(构建时由 Vite 写进来,见 web/build-info.mjs)。 */
+declare const __APP_VERSION__: string
+declare const __APP_TAG__: string
+declare const __APP_COMMIT__: string
+declare const __APP_BUILT_AT__: string
+
+export const frontendBuild = {
+ version: __APP_VERSION__,
+ tag: __APP_TAG__,
+ commit: __APP_COMMIT__,
+ builtAt: __APP_BUILT_AT__,
+}
+
+/** 构建时间显示成本地时间「2026-10-06 20:30」;没有(开发服务器)就是空。 */
+export function fmtBuiltAt(iso: string | null | undefined): string {
+ if (!iso) return ''
+ const d = new Date(iso)
+ if (Number.isNaN(d.getTime())) return iso
+ const p = (n: number) => String(n).padStart(2, '0')
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
+}
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/i18n.ts b/web/shared/i18n.ts
index 504e1d7..3f1047c 100644
--- a/web/shared/i18n.ts
+++ b/web/shared/i18n.ts
@@ -143,6 +143,7 @@ export function translateCode(code: string): string | undefined {
const SETTINGS: Record = {
'site.public': '匿名访客能不能读展示页的数据(关闭时只有登录的管理员能看)',
+ 'site.show_build': '展示页底部和节点卡片上显示后端、前端、Agent、Probe 的版本、tag 和构建时间',
'retention.raw_days': '持续任务的原始数据块保留多少天',
'retention.test_raw_days': '按需测试的原始数据块保留多少天',
'retention.metric_10s_hours': '10 秒精度的指标保留多少小时',
diff --git a/web/shared/nodes.tsx b/web/shared/nodes.tsx
new file mode 100644
index 0000000..c57e87d
--- /dev/null
+++ b/web/shared/nodes.tsx
@@ -0,0 +1,104 @@
+import { useEffect, useMemo, useState } from 'react'
+import { api } from './api/client'
+import type { NodeInfoDto, Page } from './api/types'
+import { useQuery } from './hooks'
+import { OsIcon } from './osIcon'
+import mobileIcon from './assets/isp/mobile.svg?url'
+import telecomIcon from './assets/isp/telecom.svg?url'
+import unicomIcon from './assets/isp/unicom.svg?url'
+
+/**
+ * 节点(Agent、Probe)的展示信息:国旗、运营商、系统和架构。
+ * 后端只给国家代码、运营商名、系统名和架构——没有 IP,所以这里也画不出 IP。
+ */
+
+/** 所有国旗的 SVG,按需加载:用到哪个国家才会下载哪个文件。 */
+const flagLoaders = import.meta.glob('../node_modules/flag-icons/flags/4x3/*.svg', { query: '?url', import: 'default' })
+
+const regionNames = typeof Intl !== 'undefined' && 'DisplayNames' in Intl ? new Intl.DisplayNames(['zh-CN'], { type: 'region' }) : undefined
+
+export function regionName(code: string): string {
+ try {
+ return regionNames?.of(code.toUpperCase()) ?? code
+ } catch {
+ return code
+ }
+}
+
+export function Flag({ code }: { code: string }) {
+ const [url, setUrl] = useState()
+ useEffect(() => {
+ const load = flagLoaders[`../node_modules/flag-icons/flags/4x3/${code.toLowerCase()}.svg`]
+ if (!load) {
+ setUrl(undefined)
+ return
+ }
+ let alive = true
+ void load().then((u) => alive && setUrl(u))
+ return () => {
+ alive = false
+ }
+ }, [code])
+ const name = regionName(code)
+ if (!url) return
+ return
+}
+
+/**
+ * 运营商的小图标:三大运营商用它们各自的标志(只取标志,不带文字),广电没有现成的文件,用品牌色方块加一个字代替;
+ * 别的运营商没有图标,只显示名字。这些标志是各公司的商标,只用来标明「这是哪家运营商」。
+ */
+const ISP_ICON: Record = { mobile: mobileIcon, telecom: telecomIcon, unicom: unicomIcon }
+const ISP_GLYPH: Record = { broadnet: '广' }
+
+export function IspIcon({ ispKey, name }: { ispKey: string; name?: string | null }) {
+ const icon = ISP_ICON[ispKey]
+ if (icon) return
+ const glyph = ISP_GLYPH[ispKey]
+ if (!glyph) return null
+ return (
+
+ )
+}
+
+/** 节点的展示信息,按节点 id 查:`useNodeInfos().get(id)`。每分钟刷新一次。 */
+export function useNodeInfos(): Map {
+ const { data } = useQuery(() => api.get>('/api/public/nodes'), [], 60_000)
+ return useMemo(() => new Map((data?.items ?? []).map((n) => [n.id, n])), [data])
+}
+
+/** 系统和架构:「Debian 12 · arm64」。 */
+export const platformText = (info: NodeInfoDto | undefined) => [info?.os_name, info?.arch].filter(Boolean).join(' · ')
+
+interface TagProps {
+ info: NodeInfoDto | undefined
+ /** 不写系统和架构,只留国旗和运营商。 */
+ geoOnly?: boolean
+}
+
+/** 国旗 + 运营商(图标,或者别的运营商的名字)+ 系统和架构。什么都没有就什么也不画。 */
+export function NodeTag({ info, geoOnly }: TagProps) {
+ if (!info) return null
+ const platform = geoOnly ? '' : platformText(info)
+ if (!info.country && !info.isp_name && !platform) return null
+ const title = [info.country ? regionName(info.country) : null, info.isp_name, platformText(info)].filter(Boolean).join(' · ')
+ return (
+
+ {info.country ? : null}
+ {info.isp_key ? : null}
+ {info.isp_key === 'other' && info.isp_name ? {info.isp_name} : null}
+ {platform ? (
+
+
+ {platform}
+
+ ) : null}
+
+ )
+}
diff --git a/web/shared/osIcon.tsx b/web/shared/osIcon.tsx
new file mode 100644
index 0000000..5d3fbee
--- /dev/null
+++ b/web/shared/osIcon.tsx
@@ -0,0 +1,69 @@
+import {
+ siAlmalinux,
+ siAlpinelinux,
+ siApple,
+ siArchlinux,
+ siCentos,
+ siDebian,
+ siFedora,
+ siFreebsd,
+ siKalilinux,
+ siLinux,
+ siLinuxmint,
+ siNixos,
+ siOpensuse,
+ siOpenwrt,
+ siRaspberrypi,
+ siRedhat,
+ siRockylinux,
+ siUbuntu,
+} from 'simple-icons'
+
+interface Glyph {
+ /** 24×24 的图形路径。 */
+ path: string
+ /** 品牌色(6 位十六进制);黑色的图标不填,用文字颜色。 */
+ color?: string
+}
+
+const fromSimple = (icon: { path: string; hex: string }, useBrandColor = true): Glyph => ({ path: icon.path, color: useBrandColor ? icon.hex : undefined })
+
+/** Windows 的四个窗格(simple-icons 没有收,自己画的最简形状)。 */
+const WINDOWS: Glyph = { path: 'M2 4.6 10.4 3.4v7.8H2zM11.6 3.2 22 1.8v9.4H11.6zM2 12.8h8.4v7.8L2 19.4zM11.6 12.8H22v9.4l-10.4-1.4z', color: '0078D4' }
+
+/** 按系统名挑图标:先看 Windows、macOS,再按发行版名字找,认不出的 Linux 用企鹅。 */
+function pickGlyph(os: string | null | undefined, name: string | null | undefined): Glyph | undefined {
+ if (os === 'windows') return WINDOWS
+ if (os === 'macos') return fromSimple(siApple, false)
+ const n = (name ?? '').toLowerCase()
+ if (n.includes('debian')) return fromSimple(siDebian)
+ if (n.includes('ubuntu')) return fromSimple(siUbuntu)
+ if (n.includes('alpine')) return fromSimple(siAlpinelinux)
+ if (n.includes('openwrt')) return fromSimple(siOpenwrt)
+ if (n.includes('raspbian') || n.includes('raspberry')) return fromSimple(siRaspberrypi)
+ if (n.includes('kali')) return fromSimple(siKalilinux)
+ if (n.includes('mint')) return fromSimple(siLinuxmint)
+ if (n.includes('rocky')) return fromSimple(siRockylinux)
+ if (n.includes('alma')) return fromSimple(siAlmalinux, false)
+ if (n.includes('centos')) return fromSimple(siCentos)
+ if (n.includes('fedora')) return fromSimple(siFedora)
+ if (n.includes('red hat') || n.includes('rhel')) return fromSimple(siRedhat)
+ if (n.includes('arch')) return fromSimple(siArchlinux)
+ if (n.includes('suse')) return fromSimple(siOpensuse)
+ if (n.includes('nixos')) return fromSimple(siNixos)
+ if (n.includes('freebsd')) return fromSimple(siFreebsd)
+ if (os === 'linux') return fromSimple(siLinux, false)
+ return undefined
+}
+
+/** 系统的小图标(Debian、Ubuntu、Windows、macOS……)。认不出的系统不画。 */
+export function OsIcon({ os, name }: { os: string | null | undefined; name: string | null | undefined }) {
+ const glyph = pickGlyph(os, name)
+ if (!glyph) return null
+ return (
+
+ )
+}
diff --git a/web/shared/theme/base.css b/web/shared/theme/base.css
index 51d267a..070a0df 100644
--- a/web/shared/theme/base.css
+++ b/web/shared/theme/base.css
@@ -451,7 +451,8 @@ textarea {
.scrim {
position: fixed;
inset: 0;
- background: color-mix(in srgb, var(--app-scrim), transparent 60%);
+ background: color-mix(in srgb, var(--app-scrim), transparent 55%);
+ backdrop-filter: blur(3px);
display: grid;
place-items: center;
z-index: 50;
@@ -465,7 +466,11 @@ textarea {
width: min(560px, 100%);
max-height: calc(100vh - 32px);
overflow: auto;
- box-shadow: 0 8px 32px color-mix(in srgb, var(--app-shadow), transparent 80%);
+ border: 1px solid var(--app-outline-soft);
+ /* 近处一圈细的、远处一大片柔的:边缘清楚,又不会糊成一团 */
+ box-shadow:
+ 0 1px 2px color-mix(in srgb, var(--app-shadow), transparent 80%),
+ 0 12px 40px color-mix(in srgb, var(--app-shadow), transparent 72%);
}
.dialog h2 {
@@ -635,3 +640,370 @@ 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%;
+ }
+}
+
+/* ── 带标签切换的命令框 ── */
+.cmd-tabs {
+ border-radius: var(--r-control);
+ background: var(--app-card-inset);
+ overflow: hidden;
+}
+
+.cmd-tablist {
+ display: flex;
+ gap: 4px;
+ padding: 0 12px;
+ border-bottom: 1px solid var(--app-outline);
+}
+
+.cmd-tablist button {
+ position: relative;
+ padding: 10px 10px 9px;
+ border: 0;
+ background: transparent;
+ color: var(--app-ink-faint);
+ font: inherit;
+ font-size: 13px;
+ cursor: pointer;
+}
+
+.cmd-tablist button:hover {
+ color: var(--app-ink);
+}
+
+.cmd-tablist button.on {
+ color: var(--app-ink);
+ font-weight: 600;
+}
+
+.cmd-tablist button.on::after {
+ content: '';
+ position: absolute;
+ left: 10px;
+ right: 10px;
+ bottom: -1px;
+ height: 2px;
+ border-radius: 1px;
+ background: var(--app-ink);
+}
+
+.cmd-body {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 12px 12px 12px 14px;
+}
+
+.cmd-body code {
+ flex: 1;
+ min-width: 0;
+ font-family: var(--font-mono, ui-monospace, monospace);
+ font-size: 12.5px;
+ line-height: 1.6;
+ word-break: break-all;
+ user-select: all;
+}
+
+.cmd-copy {
+ flex: none;
+ display: inline-grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--app-ink-faint);
+ cursor: pointer;
+}
+
+.cmd-copy:hover {
+ background: var(--app-card-hover);
+ color: var(--app-ink);
+}
+
+/* ── 数据源列表:连接中的高亮,点一行就切换 ── */
+.src-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.src-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.src-pick {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ border: 1px solid var(--app-outline-soft);
+ border-radius: var(--r-control);
+ background: var(--app-card-inset);
+ color: var(--app-ink);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.src-pick:hover {
+ background: var(--app-card-hover);
+}
+
+.src-row.on .src-pick,
+.src-pick.on {
+ border-color: var(--app-accent);
+ background: var(--app-accent-container);
+ color: var(--app-on-accent-container);
+}
+
+.src-dot {
+ flex: none;
+ width: 10px;
+ height: 10px;
+ border-radius: 5px;
+ border: 2px solid var(--app-ink-faint);
+}
+
+.src-row.on .src-dot,
+.src-pick.on .src-dot {
+ border-color: var(--app-accent);
+ background: var(--app-accent);
+}
+
+.src-text {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.src-text b {
+ font-weight: 600;
+}
+
+.src-text small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--app-ink-muted);
+ font-size: 12px;
+}
+
+/* ── 数据源状态条:放在用户信息上方,一眼看到连的是哪个后端、连得上没有 ── */
+.src-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ min-width: 0;
+ padding: 8px 12px;
+ border: 1px solid var(--app-outline-soft);
+ border-radius: var(--r-control);
+ background: var(--app-card);
+ color: var(--app-ink);
+ font: inherit;
+ font-size: 12px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.src-status:hover {
+ background: var(--app-card-hover);
+}
+
+.src-status .st-dot {
+ flex: none;
+ width: 8px;
+ height: 8px;
+ border-radius: 4px;
+ background: var(--app-idle);
+}
+
+.src-status.ok .st-dot {
+ background: var(--app-ok);
+}
+
+.src-status.bad .st-dot {
+ background: var(--app-bad);
+}
+
+.src-status .st-host {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 600;
+}
+
+.src-status .st {
+ flex: none;
+ color: var(--app-ink-muted);
+}
+
+.src-status.bad .st {
+ color: var(--app-bad);
+}
+
+/* ── 节点的国旗、运营商图标、系统和架构 ── */
+.node-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ min-width: 0;
+ vertical-align: middle;
+}
+
+.node-tag .flag {
+ flex: none;
+ width: 18px;
+ height: 13.5px;
+ border-radius: 2px;
+ object-fit: cover;
+ box-shadow: 0 0 0 1px var(--app-outline-soft);
+}
+
+.node-tag .flag.ph {
+ display: inline-block;
+ background: var(--app-card-inset);
+}
+
+.node-tag .isp {
+ flex: none;
+ width: 15px;
+ height: 15px;
+}
+
+/* 三大运营商的标志:留出一点空,比例保持 */
+.node-tag .isp.logo {
+ width: 17px;
+ height: 17px;
+ object-fit: contain;
+}
+
+.node-tag .isp rect {
+ fill: var(--app-ink-faint);
+}
+
+.node-tag .isp text {
+ fill: var(--isp-ink);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.node-tag .isp-mobile rect {
+ fill: var(--isp-mobile);
+}
+
+.node-tag .isp-telecom rect {
+ fill: var(--isp-telecom);
+}
+
+.node-tag .isp-unicom rect {
+ fill: var(--isp-unicom);
+}
+
+.node-tag .isp-broadnet rect {
+ fill: var(--isp-broadnet);
+}
+
+.node-tag .isp-name,
+.node-tag .plat {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 11px;
+ color: var(--app-ink-faint);
+}
+
+/* 系统的小图标:有品牌色的用品牌色(和文字色混一点,亮暗两个主题里都看得清),没有的跟文字走 */
+.os-icon {
+ flex: none;
+ width: 14px;
+ height: 14px;
+}
+
+.os-icon path.brand {
+ fill: color-mix(in srgb, var(--os-c) 72%, var(--app-ink));
+}
+
+.os-icon path.plain {
+ fill: currentColor;
+}
+
+.node-tag .plat {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
diff --git a/web/shared/theme/tokens.css b/web/shared/theme/tokens.css
index 5a4b190..a9e76ae 100644
--- a/web/shared/theme/tokens.css
+++ b/web/shared/theme/tokens.css
@@ -3,6 +3,13 @@
* 颜色只在这个文件里出现;其他样式一律引用令牌(stylelint 会拦住 hex / rgb / 具名色)。
*/
:root {
+ /* 运营商小图标的底色(自己画的简化图标,不是官方 Logo) */
+ --isp-mobile: #1c9be0;
+ --isp-telecom: #1459a8;
+ --isp-unicom: #d8261f;
+ --isp-broadnet: #de6a1b;
+ --isp-ink: #ffffff;
+
color-scheme: light;
--app-bg: #f4f2ed;
@@ -59,6 +66,10 @@
:root[data-theme="dark"] {
color-scheme: dark;
+ /* 暗色下的阴影和遮罩要用纯黑,不然和深色背景差不多,看不出层次 */
+ --app-shadow: #000000;
+ --app-scrim: #000000;
+
--app-bg: #181714;
--app-card: #211f1b;
--app-card-inset: #2b2924;
diff --git a/web/shared/ui/index.tsx b/web/shared/ui/index.tsx
index 0a9637b..a25484c 100644
--- a/web/shared/ui/index.tsx
+++ b/web/shared/ui/index.tsx
@@ -1,8 +1,9 @@
-import { Check, Copy, Moon, Plug, Sun } from 'lucide-react'
-import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
-import { ApiError } from '../api/client'
-import { getBase, getToken, normalizeBase, setSource } from '../api/source'
-import { useTheme } from '../hooks'
+import { Check, Copy, Moon, Plug, Plus, Sun, X } from 'lucide-react'
+import { createPortal } from 'react-dom'
+import { Component, createContext, useCallback, useContext, useEffect, useRef, useState, type ErrorInfo, type ReactNode } from 'react'
+import { ApiError, loginTo } from '../api/client'
+import { activateSource, getBase, listSources, normalizeBase, pingBackend, removeSource, setSource } from '../api/source'
+import { useQuery, useTheme } from '../hooks'
import { translateCode, translateField, translateMessage } from '../i18n'
/** 连接式分组(单选):组内小圆角、外角大圆角,选中段变胶囊。 */
@@ -64,14 +65,17 @@ export function Dialog({ title, onClose, children, actions }: { title: string; o
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
- return (
+ // 渲染到 body 上:对话框是 position: fixed 的,如果放在带 backdrop-filter / transform 的祖先里(比如展示端的顶栏),
+ // 它会以那个祖先为定位基准,被挤到顶栏里、一半在屏幕外
+ return createPortal(
e.target === e.currentTarget && onClose()}>
{title}
{children}
{actions ?
{actions}
: null}
-
+ ,
+ document.body,
)
}
@@ -218,49 +222,276 @@ export function ThemeToggle() {
)
}
-/** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 API 令牌,后端要放行这个前端的来源)。 */
-export function SourceButton({ tokenLabel = '只读令牌(站点私有时才需要)' }: { tokenLabel?: string }) {
- const [open, setOpen] = useState(false)
- const [base, setBase] = useState('')
- const [token, setToken] = useState('')
- const remote = getBase() !== ''
- const show = () => {
- setBase(getBase())
- setToken(getToken())
- setOpen(true)
- }
- const apply = (nextBase: string, nextToken: string) => {
- setSource(nextBase, nextToken)
- location.reload()
+/**
+ * 组件上的加载进度条:放在卡片(`position: relative`)里,贴着上边缘。
+ * 数据在加载时流动,不加载时不占地方。
+ */
+export function LoadingBar({ active }: { active: boolean }) {
+ return active ? (
+
+
+
+ ) : null
+}
+
+export interface CommandTab {
+ id: string
+ label: string
+ command: string
+}
+
+/** 按浏览器所在的系统挑一个默认的标签(Windows / macOS / 其他当 Linux)。 */
+export function guessPlatform(): 'windows' | 'macos' | 'linux' {
+ const text = `${navigator.platform} ${navigator.userAgent}`.toLowerCase()
+ return text.includes('win') ? 'windows' : text.includes('mac') ? 'macos' : 'linux'
+}
+
+/**
+ * 带标签切换的命令框(curl / npm / bun 那样):上面一排下划线标签,下面是等宽的命令,右侧一个复制图标。
+ * 记住上次选的标签(只是浏览器里的小方便)。
+ */
+export function CommandTabs({ tabs, initial, storageKey }: { tabs: CommandTab[]; initial?: string; storageKey?: string }) {
+ const [active, setActive] = useState(() => {
+ let saved: string | null = null
+ try {
+ saved = storageKey ? localStorage.getItem(storageKey) : null
+ } catch {
+ // 浏览器禁用存储时只是不记住
+ }
+ return [saved, initial].find((id) => id && tabs.some((t) => t.id === id)) ?? tabs[0]?.id ?? ''
+ })
+ const [done, setDone] = useState(false)
+ const tab = tabs.find((t) => t.id === active) ?? tabs[0]
+ if (!tab) return null
+ const pick = (id: string) => {
+ setActive(id)
+ try {
+ if (storageKey) localStorage.setItem(storageKey, id)
+ } catch {
+ // 同上
+ }
}
+ return (
+
+
+ {tabs.map((t) => (
+
+ ))}
+
+
+ {tab.command}
+
+
+
+ )
+}
+
+/**
+ * 页面出错时显示提示,而不是整页白屏:说明出了什么错,可以重试(重新渲染这一块)或刷新整个页面。
+ * 常见原因是前端和后端版本对不上(后端还没升级到新接口)。
+ */
+export class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
+ state: { error: Error | null } = { error: null }
+
+ static getDerivedStateFromError(error: Error) {
+ return { error }
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ console.error(error, info.componentStack)
+ }
+
+ render() {
+ const { error } = this.state
+ if (!error) return this.props.children
+ return (
+
+
+
+
页面出错了
+
这一页没能显示出来。如果刚升级过,多半是前端和后端的版本对不上:先把后端升级到对应的版本,再刷新。
+
+
{error.message}
+
+
+
+
+
+
+ )
+ }
+}
+
+/** 地址的简短写法:去掉协议,留 host(和端口)。 */
+const shortHost = (base: string) => base.replace(/^https?:\/\//i, '')
+
+/**
+ * 数据源:默认连这个页面所在的后端(同源);也可以保存几个别的后端(比如本机跑前端、连线上的),在列表里点一下就切换。
+ * 跨域时不用 Cookie,改用 API 令牌,所以每个后端各存一个令牌(只在浏览器里),后端需要放行这个页面的来源(`Monitor__CorsOrigins`)。
+ */
+export function SourceButton({ variant = 'icon' }: { variant?: 'icon' | 'status' }) {
+ const [open, setOpen] = useState(false)
+ const [adding, setAdding] = useState(listSources().length === 0)
+ const [base, setBase] = useState('')
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [, refresh] = useState(0)
+ const active = getBase()
+ const saved = listSources()
+ const switchTo = (next: string) => {
+ if (next !== active) {
+ activateSource(next)
+ location.reload()
+ }
+ }
+ // 状态条用:每 15 秒问一下当前后端还连不连得上
+ const health = useQuery(() => (variant === 'status' ? pingBackend() : Promise.resolve(undefined)), [variant, active], variant === 'status' ? 15_000 : undefined)
+ const state = health.data === false ? 'bad' : health.data === undefined ? 'wait' : 'ok'
+ const rows = saved.map((x) => ({ base: x.base, label: shortHost(x.base), detail: x.token ? '已登录' : '未登录' }))
+ // 连接中的排在最前面
+ rows.sort((a, b) => Number(b.base === active) - Number(a.base === active))
return (
<>
-
+ {variant === 'status' ? (
+
+ ) : (
+
+ )}
{open ? (
) : null}