shared: add independent IPv4 and IPv6 collection [CI]
This commit is contained in:
1 parent
c974a7dddb
commit
0eda0600cc
60 files changed
+699
-137
No files matched your search
@@ -22,6 +22,7 @@ npm run lint:css # 颜色只能写在 shared/theme/tokens.css,
|
||||
连别的后端(本机跑前端、连线上的):登录页左边选后端(这个页面自己所在的、保存过的,或者「添加后端」),右边都是用户名和密码;添加后端填地址、用户名、密码,登录成功才保存。跨域用不了 Cookie,所以登录时后端会返回会话令牌(`return_session`),前端存在浏览器里、之后每个请求带上,12 小时过期、退出登录即作废;用户看不到也不需要管它。后端需要放行这个页面的来源(`MoShiro__CorsOrigins`)。展示端和管理端侧栏的插头图标是同一个切换器。
|
||||
|
||||
约定:
|
||||
- 总览、线路详情、对比和管理端按需测试均可切换 IPv4 / IPv6;查询和测试结果独立。管理端 Agent 编辑页分别配置 IPv4、IPv6 地址,IPv6 留空只采 IPv4。展示端只显示地址族,不显示 IP。
|
||||
- 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 Probe 的版本和来源地址(这些只在管理端)。
|
||||
- 线路详情页:先选**哪一天**(前一天 / 后一天 / 日期选择,URL 的 `day=YYYY-MM-DD`),下面是这一天的丢包缩略图,上面有可拖动的选区(拖空白处新建、拖两边调长短、拖中间平移;URL 的 `at=起_止`,单位是从选中那天 0 点算起的分钟,可以是负数或超过 1440)。缩略图的**刻度**缺省是 48 小时(连续两天,选区可以跨零点,比如晚高峰 20:00 到凌晨 02:00),也可以切成 24 小时(只看一天;URL 的 `span=24`,选区跨零点时自动用 48 小时)。刻度下方是**选区长度**:30 分钟、1 小时、3 小时、6 小时、12 小时、整天,点一下把选区设成这么长——今天且选区到现在为止的,继续看「最近一段」(实时刷新),否则保持选区的起点往后取。刻度上每小时一根短线,48 小时刻度每 3 小时标一个钟点(24 小时刻度每 2 小时),零点标日期;鼠标移到缩略图上会标出那个位置的时间;点一下空白处或者刻度下面的钟点:离那里近的端点(左端或右端)贴过去,另一个端点不动。鼠标停在轴上或刻度上时,会动的那个端点变色,它到目标位置之间染色:扩大是橙色,收缩是灰色。往日默认是整天;「整天」是本地时区的自然日。时间的文字不写「次日」,跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。
|
||||
- 图表上方有 **EWMA 平滑**开关(关、轻 α=0.3、中 α=0.1、重 α=0.03;URL 的 `ewma=low|mid|high`):开了之后每条线画成指数加权移动平均,原始数据以淡线垫在后面,缺口处断开;只在浏览器里算,不影响汇总的指标数字。
|
||||
|
||||
@@ -13,6 +13,7 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: ()
|
||||
const [name, setName] = useState(agent.name)
|
||||
const [labels, setLabels] = useState(agent.labels.join(', '))
|
||||
const [host, setHost] = useState(agent.public_host)
|
||||
const [ipv6Host, setIpv6Host] = useState(agent.ipv6_host ?? '')
|
||||
const [visible, setVisible] = useState(agent.public_visible)
|
||||
return (
|
||||
<Dialog
|
||||
@@ -29,7 +30,7 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: ()
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
const ok = await run(
|
||||
() => api.patch(`/api/admin/agents/${agent.id}`, { name, labels: labels.split(',').map((x) => x.trim()).filter(Boolean), public_host: host, public_visible: visible }),
|
||||
() => api.patch(`/api/admin/agents/${agent.id}`, { name, labels: labels.split(',').map((x) => x.trim()).filter(Boolean), public_host: host.trim(), ipv6_host: ipv6Host.trim(), public_visible: visible }),
|
||||
'已保存',
|
||||
)
|
||||
if (ok) {
|
||||
@@ -50,10 +51,15 @@ function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: ()
|
||||
</Field>
|
||||
</div>
|
||||
<div className="wide">
|
||||
<Field label="公开地址" hint="Probe 用它连接这个 Agent:域名或 IP">
|
||||
<Field label="IPv4 地址" hint="Probe 用它测 IPv4:IPv4 地址或域名(A 记录)">
|
||||
<input className="input" value={host} onChange={(e) => setHost(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="wide">
|
||||
<Field label="IPv6 地址" hint="IPv6 地址或域名(AAAA 记录);留空只采集 IPv4">
|
||||
<input className="input" value={ipv6Host} onChange={(e) => setIpv6Host(e.target.value)} placeholder="例如 2001:db8::1 或 agent.example.com" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="wide">
|
||||
<Field label="标签" hint="逗号分隔">
|
||||
<input className="input" value={labels} onChange={(e) => setLabels(e.target.value)} />
|
||||
@@ -167,9 +173,9 @@ export function AgentDetail() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{a.probes.map((p) => (
|
||||
<tr key={p.probe_id}>
|
||||
<tr key={`${p.probe_id}~${p.ip_version}`}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{probeName(p.probe_id)}</div>
|
||||
<div style={{ fontWeight: 600 }}>{probeName(p.probe_id)} <Chip>{p.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}</Chip></div>
|
||||
<div className="mono faint">{p.probe_id}</div>
|
||||
</td>
|
||||
<td>{p.version ?? '—'}</td>
|
||||
@@ -179,7 +185,7 @@ export function AgentDetail() {
|
||||
</td>
|
||||
<td className="r">
|
||||
<span className="row" style={{ justifyContent: 'flex-end' }}>
|
||||
<Link className="btn small" to={`/tests?agent=${a.id}&probe=${p.probe_id}`}>
|
||||
<Link className="btn small" to={`/tests?agent=${a.id}&probe=${p.probe_id}&ip_version=${p.ip_version}`}>
|
||||
<FlaskConical size={14} />
|
||||
测试
|
||||
</Link>
|
||||
@@ -209,8 +215,12 @@ export function AgentDetail() {
|
||||
<dl className="kv">
|
||||
<dt>ID</dt>
|
||||
<dd className="mono">{a.id}</dd>
|
||||
<dt>公开地址</dt>
|
||||
<dt>IPv4 地址</dt>
|
||||
<dd className="mono">{a.public_host || '—'}</dd>
|
||||
<dt>IPv6 地址</dt>
|
||||
<dd className="mono">{a.ipv6_host || '未配置'}</dd>
|
||||
<dt>IPv6 监听</dt>
|
||||
<dd>{a.ipv6_supported ? '已启用' : '未启用,请升级 Agent 并配置双栈监听'}</dd>
|
||||
<dt>控制端口</dt>
|
||||
<dd className="mono">{a.probe_tcp_port}/tcp</dd>
|
||||
<dt>数据端口</dt>
|
||||
|
||||
@@ -209,7 +209,7 @@ export function Probes() {
|
||||
) : (
|
||||
<>
|
||||
<Chip tone={p.online ? 'ok' : ''}>{p.online ? '已连面板' : '未连面板'}</Chip>
|
||||
<Chip tone={p.connections.length > 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${p.connections.length} 个 Agent` : '没有连接'}</Chip>
|
||||
<Chip tone={p.connections.length > 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${new Set(p.connections.map((c) => c.agent_id)).size} 个 Agent` : '没有连接'}</Chip>
|
||||
</>
|
||||
)}
|
||||
<span className="mono faint">{p.id}</span>
|
||||
@@ -252,10 +252,11 @@ export function Probes() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.connections.map((c) => (
|
||||
<tr key={c.agent_id}>
|
||||
<tr key={`${c.agent_id}~${c.ip_version}`}>
|
||||
<td>
|
||||
<Link to={`/agents/${c.agent_id}`} style={{ fontWeight: 600 }}>
|
||||
{c.agent_name}
|
||||
<Chip>{c.ip_version === 'ipv6' ? 'IPv6' : 'IPv4'}</Chip>
|
||||
</Link>
|
||||
</td>
|
||||
<td>{c.version ?? '—'}</td>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FlaskConical, Square } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { AgentDto, Page, PlanDto, ProbeDto, TestDto } from '@shared/api/types'
|
||||
@@ -84,6 +85,7 @@ function Result({ test }: { test: TestDto }) {
|
||||
|
||||
export function Tests() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const ipVersion = ipVersionOf(params.get('ip_version'))
|
||||
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 10_000)
|
||||
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [])
|
||||
const plans = useQuery(() => api.get<Page<PlanDto>>('/api/public/catalog/plans'), [])
|
||||
@@ -92,16 +94,18 @@ export function Tests() {
|
||||
const active = (agents.data?.items ?? []).filter((a) => a.status === 'active')
|
||||
const agentId = params.get('agent') ?? active[0]?.id ?? ''
|
||||
const agent = active.find((a) => a.id === agentId)
|
||||
const connected = agent?.probes ?? []
|
||||
const connected = (agent?.probes ?? []).filter((p) => ipVersionOf(p.ip_version) === ipVersion)
|
||||
const probeId = params.get('probe') ?? connected[0]?.probe_id ?? ''
|
||||
const [planId, setPlanId] = useState('')
|
||||
const [selected, setSelected] = useState<string>()
|
||||
const line = agentId && probeId ? `${agentId}~${probeId}` : ''
|
||||
const line = agentId && probeId ? `${agentId}~${probeId}${ipVersion === 'ipv6' ? '~ipv6' : ''}` : ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!planId && plans.data?.items[0]) setPlanId(plans.data.items[0].id)
|
||||
}, [plans.data, planId])
|
||||
|
||||
useEffect(() => { setSelected(undefined) }, [line])
|
||||
|
||||
const tests = useQuery(() => (line ? api.get<Page<TestDto>>(`/api/admin/lines/${encodeURIComponent(line)}/tests`, { limit: 20 }) : Promise.resolve(undefined)), [line], 3000)
|
||||
const detail = useQuery(() => (selected ? api.get<TestDto>(`/api/public/tests/${selected}`) : Promise.resolve(undefined)), [selected, tests.data?.items[0]?.status], 3000)
|
||||
const probeName = (id: string) => probes.data?.items.find((p) => p.id === id)?.name ?? id
|
||||
@@ -118,6 +122,7 @@ export function Tests() {
|
||||
<>
|
||||
<header>
|
||||
<h1>按需测试</h1>
|
||||
<IpVersionPicker value={ipVersion} onChange={(v) => set({ ip_version: v })} />
|
||||
<span className="right">{agents.loading && !agents.data ? <Spinner /> : null}</span>
|
||||
</header>
|
||||
<ErrorNotice error={agents.error ?? plans.error ?? tests.error} />
|
||||
@@ -190,7 +195,7 @@ export function Tests() {
|
||||
) : (
|
||||
<table className="table hover">
|
||||
<tbody>
|
||||
{tests.data?.items.map((t) => (
|
||||
{tests.data?.items.filter((t) => t.agent_id === agentId && t.probe_id === probeId && ipVersionOf(t.ip_version) === ipVersion).map((t) => (
|
||||
<tr key={t.id} onClick={() => setSelected(t.id)} style={selected === t.id ? { background: 'var(--app-card-hover)' } : undefined}>
|
||||
<td>
|
||||
<Chip tone={STATUS[t.status]?.tone ?? ''}>{STATUS[t.status]?.label ?? t.status}</Chip>
|
||||
@@ -221,7 +226,7 @@ export function Tests() {
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">结果</div>
|
||||
{detail.data ? (
|
||||
{detail.data && detail.data.id === selected && ipVersionOf(detail.data.ip_version) === ipVersion ? (
|
||||
<>
|
||||
<p className="faint" style={{ marginBottom: 12 }}>
|
||||
{detail.data.plan} · {STATUS[detail.data.status]?.label ?? detail.data.status}
|
||||
|
||||
+12
-4
@@ -1,20 +1,28 @@
|
||||
import { BuildFooter } from './BuildFooter'
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { NavLink, Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom'
|
||||
import { SourceButton, ThemeToggle } from '@shared/ui'
|
||||
import { Compare } from './pages/Compare'
|
||||
import { Line } from './pages/Line'
|
||||
import { Overview } from './pages/Overview'
|
||||
|
||||
function LineRoute() {
|
||||
const { id } = useParams()
|
||||
return <Line key={id} />
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const location = useLocation()
|
||||
const ipv6 = location.pathname.endsWith('~ipv6') || new URLSearchParams(location.search).get('ip_version') === 'ipv6'
|
||||
const familyQuery = ipv6 ? '?ip_version=ipv6' : ''
|
||||
return (
|
||||
<>
|
||||
<header className="topbar">
|
||||
<div className="inner">
|
||||
<nav className="topnav">
|
||||
<NavLink to="/" end className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
<NavLink to={`/${familyQuery}`} end className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
总览
|
||||
</NavLink>
|
||||
<NavLink to="/compare" className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
<NavLink to={`/compare${familyQuery}`} className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
对比
|
||||
</NavLink>
|
||||
<a href="/admin/">管理</a>
|
||||
@@ -25,7 +33,7 @@ export function App() {
|
||||
</header>
|
||||
<Routes>
|
||||
<Route path="/" element={<Overview />} />
|
||||
<Route path="/line/:id" element={<Line />} />
|
||||
<Route path="/line/:id" element={<LineRoute />} />
|
||||
<Route path="/compare" element={<Compare />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Star } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { CompareDto, OverviewDto, OverviewLineDto } from '@shared/api/types'
|
||||
@@ -54,15 +55,16 @@ interface Cell {
|
||||
|
||||
export function Compare() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const ipVersion = ipVersionOf(params.get('ip_version'))
|
||||
const range: PresetValue = isPreset(params.get('range')) ? (params.get('range') as PresetValue) : '1d'
|
||||
const metric = METRICS.find((m) => m.id === params.get('metric')) ?? METRICS[0]
|
||||
const picked = useMemo(() => (params.get('lines') ?? '').split(',').filter(Boolean), [params])
|
||||
const picked = useMemo(() => (params.get('lines') ?? '').split(',').filter((id) => id && id.endsWith('~ipv6') === (ipVersion === 'ipv6')), [params, ipVersion])
|
||||
|
||||
const overview = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: metric.id }), [range, metric.id], 15_000)
|
||||
const overview = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: metric.id, ip_version: ipVersion }), [range, metric.id, ipVersion], 15_000)
|
||||
|
||||
// 矩阵:行是 Agent,列是 Probe;每一列内部排名(同一个 Probe 看出去,哪个 Agent 的线路好)
|
||||
const matrix = useMemo(() => {
|
||||
const lines = overview.data?.lines ?? []
|
||||
const lines = (overview.data?.lines ?? []).filter((l) => ipVersionOf(l.ip_version) === ipVersion)
|
||||
const probes = [...new Map(lines.map((l) => [l.probe_id, l.probe_name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
||||
const agents = [...new Map(lines.map((l) => [l.agent_id, l.name])).entries()].sort((a, b) => a[1].localeCompare(b[1]))
|
||||
const byProbe = new Map<string, Cell[]>()
|
||||
@@ -79,7 +81,7 @@ export function Compare() {
|
||||
byProbe.set(probeId, cells)
|
||||
}
|
||||
return { probes, agents, byProbe }
|
||||
}, [overview.data, metric])
|
||||
}, [overview.data, metric, ipVersion])
|
||||
|
||||
// 默认选每个 Probe 里最好的那条线路(最多 6 条)
|
||||
const defaults = useMemo(() => {
|
||||
@@ -123,6 +125,7 @@ export function Compare() {
|
||||
<h1>线路对比</h1>
|
||||
<p className="sub">同一个指标,在每个 Probe 看出去,哪个 Agent 的线路更好。</p>
|
||||
<div className="toolbar">
|
||||
<IpVersionPicker value={ipVersion} onChange={(v) => update({ ip_version: v, lines: null })} />
|
||||
<Segmented options={PRESETS.map((p) => ({ value: p.value, label: p.label }))} value={range} onChange={(v) => update({ range: v })} />
|
||||
<select className="select" style={{ width: 'auto' }} value={metric.id} onChange={(e) => update({ metric: e.target.value })}>
|
||||
{METRICS.map((x) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { IP_VERSIONS, lineIdFor } from '@shared/ipVersions'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { LineDto, TestDto } from '@shared/api/types'
|
||||
@@ -245,7 +246,7 @@ export function Line() {
|
||||
return (
|
||||
<main className="page">
|
||||
<div className="crumbs">
|
||||
<Link to="/" className="row">
|
||||
<Link to={id.endsWith('~ipv6') ? '/?ip_version=ipv6' : '/'} className="row">
|
||||
<ChevronLeft size={16} />
|
||||
总览
|
||||
</Link>
|
||||
@@ -256,6 +257,18 @@ export function Line() {
|
||||
{line.data?.name ?? '线路'}
|
||||
{line.data ? <span className="muted" style={{ fontWeight: 400 }}> → {line.data.probe_name}</span> : null}
|
||||
</h1>
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
{IP_VERSIONS.map((v) => {
|
||||
const query = new URLSearchParams(params)
|
||||
query.set('ip_version', v.value)
|
||||
return (
|
||||
<Link key={v.value} className={`btn small${(id.endsWith('~ipv6') ? 'ipv6' : 'ipv4') === v.value ? ' primary' : ''}`} to={`/line/${encodeURIComponent(lineIdFor(id, v.value))}?${query.toString()}`}>
|
||||
{v.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{line.data && !line.data.ipv6_available && id.endsWith('~ipv6') ? <span className="muted">此 Agent 未配置 IPv6 地址</span> : null}
|
||||
</div>
|
||||
{line.data ? (
|
||||
<div className="row wrap" style={{ marginTop: 12 }}>
|
||||
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts, type HostItem } from '../
|
||||
import { Flag, IspIcon, useNodeInfos } from '@shared/nodes'
|
||||
import { OsIcon } from '@shared/osIcon'
|
||||
import { PageError } from '../lib'
|
||||
import { IpVersionPicker, ipVersionOf } from '@shared/ipVersions'
|
||||
import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
|
||||
|
||||
const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',')
|
||||
@@ -153,6 +154,7 @@ export function Overview() {
|
||||
const infos = useNodeInfos()
|
||||
// 近多久的汇总和走势:3 / 6 / 12 小时,记在链接里(?range=6h)
|
||||
const [params, setParams] = useSearchParams()
|
||||
const ipVersion = ipVersionOf(params.get('ip_version'))
|
||||
const range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h'
|
||||
const setRange = (v: OverviewRange) =>
|
||||
setParams(
|
||||
@@ -164,12 +166,13 @@ export function Overview() {
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: METRICS }), [range], 10_000)
|
||||
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: METRICS, ip_version: ipVersion }), [range, ipVersion], 10_000)
|
||||
|
||||
const probes = useMemo(() => [...new Set((data?.lines ?? []).map((l) => l.probe_name))].sort(), [data])
|
||||
const cards = useMemo(() => {
|
||||
const byAgent = new Map<string, AgentCard>()
|
||||
for (const l of data?.lines ?? []) {
|
||||
if (ipVersionOf(l.ip_version) !== ipVersion) continue
|
||||
if (probe && l.probe_name !== probe) continue
|
||||
const card = byAgent.get(l.agent_id) ?? { id: l.agent_id, name: l.name, labels: l.labels, online: l.online, lines: [] }
|
||||
card.lines.push(l)
|
||||
@@ -190,7 +193,7 @@ export function Overview() {
|
||||
if (y === null) return -1
|
||||
return x - y
|
||||
})
|
||||
}, [data, probe, sort])
|
||||
}, [data, probe, sort, ipVersion])
|
||||
|
||||
const online = cards.filter((c) => c.online).length
|
||||
|
||||
@@ -203,6 +206,7 @@ export function Overview() {
|
||||
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
|
||||
</p>
|
||||
<div className="toolbar">
|
||||
<IpVersionPicker value={ipVersion} onChange={(v) => { const next = new URLSearchParams(params); next.set('ip_version', v); setProbe(''); setParams(next, { replace: true }) }} />
|
||||
{probes.length > 1 ? <Segmented options={[{ value: '', label: '全部 Probe' }, ...probes.map((p) => ({ value: p, label: p }))]} value={probe} onChange={setProbe} /> : null}
|
||||
<span className="right row">
|
||||
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: `近 ${r.label}` }))} value={range} onChange={setRange} />
|
||||
@@ -225,7 +229,7 @@ export function Overview() {
|
||||
{data ? (
|
||||
cards.length === 0 ? (
|
||||
<div className="card" style={{ marginTop: 24 }}>
|
||||
<Empty>还没有线路</Empty>
|
||||
<Empty>{ipVersion === 'ipv6' ? '还没有 IPv6 线路,请在管理端配置 Agent 的 IPv6 地址' : '还没有线路'}</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cards">
|
||||
|
||||
@@ -1007,12 +1007,17 @@ export interface components {
|
||||
created_at: unknown;
|
||||
/** Format: date-time */
|
||||
last_seen_at: unknown;
|
||||
/** @default */
|
||||
ipv6_host: string;
|
||||
/** @default false */
|
||||
ipv6_supported: boolean;
|
||||
};
|
||||
AgentPatchRequest: {
|
||||
name: null | string;
|
||||
labels: null | string[];
|
||||
public_host: null | string;
|
||||
public_visible: null | boolean;
|
||||
ipv6_host?: null | string;
|
||||
};
|
||||
ApiTokenDto: {
|
||||
id: string;
|
||||
@@ -1075,6 +1080,8 @@ export interface components {
|
||||
remote: null | string;
|
||||
/** Format: date-time */
|
||||
connected_since: unknown;
|
||||
/** @default ipv4 */
|
||||
ip_version: string;
|
||||
};
|
||||
CreateApiTokenRequest: {
|
||||
name: string;
|
||||
@@ -1285,6 +1292,10 @@ export interface components {
|
||||
data_since: unknown;
|
||||
kinds: string[];
|
||||
last_test: null | components["schemas"]["TestSummaryDto"];
|
||||
/** @default ipv4 */
|
||||
ip_version: string;
|
||||
/** @default false */
|
||||
ipv6_available: boolean;
|
||||
};
|
||||
LinkDto: {
|
||||
probe_id: string;
|
||||
@@ -1408,6 +1419,8 @@ export interface components {
|
||||
endpoint_changes: number | string;
|
||||
summary: components["schemas"]["SummaryDto"];
|
||||
last_test: null | components["schemas"]["TestSummaryDto"];
|
||||
/** @default ipv4 */
|
||||
ip_version: string;
|
||||
};
|
||||
PageDtoOfAgentDto: {
|
||||
items: components["schemas"]["AgentDto"][];
|
||||
@@ -1527,6 +1540,8 @@ export interface components {
|
||||
remote: null | string;
|
||||
/** Format: date-time */
|
||||
connected_since: unknown;
|
||||
/** @default ipv4 */
|
||||
ip_version: string;
|
||||
};
|
||||
ProbeDto: {
|
||||
id: string;
|
||||
@@ -1642,6 +1657,8 @@ export interface components {
|
||||
versions: {
|
||||
[key: string]: string;
|
||||
};
|
||||
/** @default ipv4 */
|
||||
ip_version: string;
|
||||
};
|
||||
SetLinkRequest: {
|
||||
enabled: null | boolean;
|
||||
@@ -1702,6 +1719,8 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
finished_at: unknown;
|
||||
result: null | components["schemas"]["TestResultDto"];
|
||||
/** @default ipv4 */
|
||||
ip_version: string;
|
||||
};
|
||||
TestResultDto: {
|
||||
metrics: {
|
||||
@@ -1762,6 +1781,7 @@ export interface operations {
|
||||
metrics?: string;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
ip_version?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -2191,7 +2211,9 @@ export interface operations {
|
||||
};
|
||||
live_stream: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
ip_version?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Segmented } from './ui'
|
||||
|
||||
export type IpVersion = 'ipv4' | 'ipv6'
|
||||
export const ipVersionOf = (value: string | null | undefined): IpVersion => value === 'ipv6' ? 'ipv6' : 'ipv4'
|
||||
export const IP_VERSIONS: { value: IpVersion; label: string }[] = [{ value: 'ipv4', label: 'IPv4' }, { value: 'ipv6', label: 'IPv6' }]
|
||||
export const lineIdFor = (id: string, version: IpVersion): string => id.replace(/~ipv6$/, '') + (version === 'ipv6' ? '~ipv6' : '')
|
||||
|
||||
export function IpVersionPicker({ value, onChange }: { value: IpVersion; onChange: (value: IpVersion) => void }) {
|
||||
return <Segmented options={IP_VERSIONS} value={value} onChange={onChange} />
|
||||
}
|
||||
Reference in new issue
Block a user