shared: add independent IPv4 and IPv6 collection [CI]
ci/woodpecker/pr/verify Pipeline was successful
ci/woodpecker/pr/web-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-07 11:44:01 +08:00
1 parent c974a7dddb
commit 0eda0600cc
60 files changed
+699 -137

No files matched your search

+1
View File
@@ -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`):开了之后每条线画成指数加权移动平均,原始数据以淡线垫在后面,缺口处断开;只在浏览器里算,不影响汇总的指标数字。
+16 -6
View File
@@ -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>
+3 -2
View File
@@ -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>
+9 -4
View File
@@ -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
View File
@@ -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>
+7 -4
View File
@@ -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) => (
+14 -1
View File
@@ -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'} />
+7 -3
View File
@@ -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">
+23 -1
View File
@@ -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;
+10
View File
@@ -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} />
}