Files
mihomo-patchbay/web/views/DashboardPage.vue
T
im0o eec5b791c8 仪表盘重做:ECharts 曲线、常驻遥测、路由行直达详情
采集从仪表盘组件挪进模块级单例(web/telemetry.ts),流量曲线和每路
吞吐的历史跨页面留存——跟着组件走的话切一次页面就清零一次。每路吞吐
从 /connections 快照按连接逐条求差再按 inboundName 聚合:先求和再求差
会在连接关闭时算出负数。

图表换 ECharts(按需注册):手写 SVG 折线做不出平滑曲线和时间轴,
颜色在运行时从 CSS 变量取,跟随主题切换重取。

路由列表每行带走势、速率和可选的公网 IP 检测,点击跳路由页并选中
那一路(外壳传 focus 给 RouterPage 消费)。
2026-08-11 22:26:36 +08:00

962 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 仪表盘:打开 Patchbay 第一眼看到的页面,现在什么状况。
*
* 页面本身只负责摆数据。采集在 web/telemetry.ts 的常驻单例里——流量曲线和每路
* 吞吐的历史跨页面留存,切走再切回来不清零。主机指标(CPU、内存、网关)来自
* Patchbay 自己的服务端,只在本页打开期间轮询:它没有历史,不需要常驻。
*
* 除了「检测公网 IP」,全部只读。
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { collectInbounds, inboundStatKey } from '@shared/inbounds.ts'
import { formatBytes, formatUptime } from '@shared/format.ts'
import { ACTIVE_BYTES, ROUTE_WINDOW_MS, lastRate, windowBytes } from '@shared/telemetry.ts'
import type { RouteSample } from '@shared/telemetry.ts'
import type { HostInfo } from '@shared/host-info.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import type { EChartsCoreOption } from 'echarts/core'
import { api } from '../api'
import { kernel, providerLines } from '../runtime'
import {
connCount,
exitProbes,
kernelMemory,
live,
probeExit,
routeSamples,
tick,
totals,
traffic,
} from '../telemetry'
import { firstPort, kernelPorts, modeLabel } from '../dashboard'
import { cssColor, themeTick } from '../theme'
import EChart from '../components/EChart.vue'
import IconNet from '~icons/ri/line-chart-line'
import IconServer from '~icons/ri/server-line'
import IconRoute from '~icons/ri/route-line'
import IconDonut from '~icons/ri/pie-chart-2-line'
import IconLan from '~icons/ri/router-line'
import IconKernel from '~icons/ri/terminal-box-line'
import IconOverview from '~icons/ri/layout-grid-line'
import IconGo from '~icons/ri/arrow-right-s-line'
const props = defineProps<{ data: ConfigObject }>()
const emit = defineEmits<{ 'open-inbound': [id: string]; 'open-router': [] }>()
/* ── 网络 ─────────────────────────────────────── */
const RANGES = [
{ label: '实时', ms: 60_000 },
{ label: '5 分钟', ms: 5 * 60_000 },
{ label: '30 分钟', ms: 30 * 60_000 },
] as const
const rangeMs = ref<number>(RANGES[0].ms)
/** 当前速率。断流期间是 null——显示破折号,而不是把最后一秒冻在屏幕上。 */
const current = computed(() => (live.value ? (traffic.value.at(-1) ?? null) : null))
const rate = (n: number): string => `${formatBytes(n)}/s`
const trafficOption = computed<EChartsCoreOption>(() => {
void themeTick.value
const end = traffic.value.at(-1)?.t ?? Date.now()
const start = end - rangeMs.value
// 只把窗口内的点交给图表,多留两秒让曲线画到左缘之外不断头
const points = traffic.value.filter((p) => p.t >= start - 2_000)
const faint = cssColor('--c-text-faint')
const border = cssColor('--c-border')
const accent = cssColor('--c-accent')
const line = (color: string, data: Array<[number, number]>) => ({
type: 'line',
smooth: 0.4,
symbol: 'none',
sampling: 'lttb',
lineStyle: { width: 1.5, color },
itemStyle: { color },
data,
})
return {
animation: false,
grid: { left: 4, right: 4, top: 8, bottom: 0, containLabel: true },
tooltip: {
trigger: 'axis',
backgroundColor: cssColor('--c-surface'),
borderColor: border,
textStyle: { color: cssColor('--c-text'), fontSize: 12 },
valueFormatter: (value: unknown) => (typeof value === 'number' ? rate(value) : '—'),
},
xAxis: {
type: 'time',
min: start,
max: end,
axisLine: { lineStyle: { color: border } },
axisTick: { show: false },
axisLabel: { color: faint, fontSize: 11, hideOverlap: true },
splitLine: { show: false },
},
yAxis: {
type: 'value',
min: 0,
axisLabel: {
color: faint,
fontSize: 11,
formatter: (value: number) => (value === 0 ? '0' : rate(value)),
},
splitLine: { lineStyle: { color: border, opacity: 0.6 } },
},
series: [
{ name: '上行', ...line(faint, traffic.value.length ? points.map((p) => [p.t, p.up]) : []) },
{ name: '下行', ...line(accent, traffic.value.length ? points.map((p) => [p.t, p.down]) : []) },
],
}
})
/* ── 路由 ─────────────────────────────────────── */
/** 公网 IP 那一格的状态机。probeable 之外的入站不给按钮:服务端只能经 HTTP 代理发请求。 */
type ExitCell =
| { kind: 'unsupported' }
| { kind: 'idle'; port: number }
| { kind: 'loading' }
| { kind: 'ok'; port: number; text: string; title: string }
| { kind: 'fail'; port: number; title: string }
function exitCell(probeable: boolean, port: number | null): ExitCell {
if (!probeable || port === null) return { kind: 'unsupported' }
const probe = exitProbes.value[port]
if (!probe) return { kind: 'idle', port }
if (probe.state === 'loading') return { kind: 'loading' }
const result = probe.result
if (result?.ok) {
return {
kind: 'ok',
port,
text: [result.countryCode, result.ip].filter(Boolean).join(' ') || '未知地址',
title: `${result.country ?? ''} 往返 ${String(result.ms ?? '?')} ms,点击重测`,
}
}
return { kind: 'fail', port, title: result?.message ?? '探测失败' }
}
const SHOWN_ROUTES = 6
const routeRows = computed(() => {
void themeTick.value
const now = tick.value
const accent = cssColor('--c-accent')
const rows = collectInbounds(props.data).map((inbound, index) => {
const samples = routeSamples.value[inboundStatKey(inbound)] ?? []
const bytes = windowBytes(samples, now - ROUTE_WINDOW_MS)
const port = firstPort(inbound.port)
const probeable = port !== null && (inbound.type === 'http' || inbound.type === 'mixed')
return {
inbound,
index,
bytes,
active: bytes > ACTIVE_BYTES,
rate: lastRate(samples),
spark: sparkOption(samples, now, accent),
exit: exitCell(probeable, port),
}
})
// 忙的排前面好找,同样闲的保持配置里的顺序
return rows.sort((a, b) => b.bytes - a.bytes || a.index - b.index)
})
const shownRoutes = computed(() => routeRows.value.slice(0, SHOWN_ROUTES))
const hiddenCount = computed(() => Math.max(0, routeRows.value.length - SHOWN_ROUTES))
const activeCount = computed(() => routeRows.value.filter((r) => r.active).length)
function sparkOption(samples: RouteSample[], end: number, color: string): EChartsCoreOption {
return {
animation: false,
grid: { left: 0, right: 0, top: 3, bottom: 3 },
xAxis: { type: 'time', min: end - ROUTE_WINDOW_MS, max: end, show: false },
yAxis: { type: 'value', min: 0, show: false },
series: [
{
type: 'line',
smooth: 0.4,
symbol: 'none',
silent: true,
lineStyle: { width: 1, color },
areaStyle: { color, opacity: 0.08 },
data: samples.map((s) => [s.t, ((s.up + s.down) / Math.max(s.ms, 1)) * 1000]),
},
],
}
}
/* ── 服务器 ───────────────────────────────────── */
const host = ref<HostInfo | null>(null)
const hostFailed = ref(false)
let hostTimer = 0
async function loadHost() {
try {
host.value = await api.host()
hostFailed.value = false
} catch {
hostFailed.value = true
}
}
onMounted(() => {
void loadHost()
hostTimer = window.setInterval(() => void loadHost(), 5_000)
})
onBeforeUnmount(() => window.clearInterval(hostTimer))
const memUsedRatio = computed(() => {
const h = host.value
if (!h || h.memTotal <= 0) return null
return Math.min(100, Math.max(0, (1 - h.memAvailable / h.memTotal) * 100))
})
const pct = (n: number): string => `${String(Math.round(n))}%`
/* ── 流量统计 ─────────────────────────────────── */
const donutOption = computed<EChartsCoreOption>(() => {
void themeTick.value
const t = totals.value
const empty = !t || t.up + t.down === 0
return {
animation: false,
series: [
{
type: 'pie',
radius: ['64%', '86%'],
label: { show: false },
labelLine: { show: false },
emphasis: { scale: false },
silent: true,
data: empty
? [{ value: 1, itemStyle: { color: cssColor('--c-surface-sunken') } }]
: [
{ value: t.up, name: '上传', itemStyle: { color: cssColor('--c-text-faint') } },
{ value: t.down, name: '下载', itemStyle: { color: cssColor('--c-accent') } },
],
},
],
}
})
/* ── 概览与内核 ───────────────────────────────── */
const configs = ref<Record<string, unknown> | null>(null)
async function loadConfigs() {
configs.value = await api.kernelConfigs().catch(() => null)
}
onMounted(() => {
if (kernel.value.online) void loadConfigs()
})
watch(
() => kernel.value.online,
(online) => {
configs.value = null
if (online) void loadConfigs()
},
)
const inboundCount = computed(() => collectInbounds(props.data).length)
const groupCount = computed(() =>
Array.isArray(props.data['proxy-groups']) ? (props.data['proxy-groups'] as unknown[]).length : 0,
)
const ruleCount = computed(() =>
Array.isArray(props.data.rules) ? (props.data.rules as unknown[]).length : 0,
)
const mode = computed(() => modeLabel(configs.value?.mode))
const ports = computed(() => kernelPorts(configs.value ?? {}))
</script>
<template>
<div class="page">
<section class="sect">
<header class="sect-h">
<IconNet class="sect-i" />
<h2>网络</h2>
<div class="h-right">
<div v-if="current" class="now mono">
<span>↑ {{ rate(current.up) }}</span>
<span class="now-down">↓ {{ rate(current.down) }}</span>
</div>
<div class="ranges">
<button
v-for="r in RANGES"
:key="r.ms"
type="button"
:class="['range', rangeMs === r.ms && 'is-on']"
@click="rangeMs = r.ms"
>
{{ r.label }}
</button>
</div>
</div>
</header>
<div class="sect-b">
<p v-if="traffic.length === 0" class="empty">
{{ kernel.online ? '正在等待第一份流量数据' : '内核未连接' }}
</p>
<template v-else>
<EChart class="plot" :option="trafficOption" />
<div class="legend">
<span class="key"><span class="sw sw-up" aria-hidden="true"></span>上行</span>
<span class="key"><span class="sw sw-down" aria-hidden="true"></span>下行</span>
<span v-if="!live && kernel.online" class="stale">数据流已中断,正在重连</span>
<span v-else-if="!live" class="stale">内核未连接,显示的是断开前的历史</span>
</div>
</template>
<dl class="strip">
<div class="stat">
<dt>累计上传</dt>
<dd>{{ totals ? formatBytes(totals.up) : '—' }}</dd>
</div>
<div class="stat">
<dt>累计下载</dt>
<dd>{{ totals ? formatBytes(totals.down) : '—' }}</dd>
</div>
<div class="stat">
<dt>内核内存</dt>
<dd>{{ kernelMemory !== null ? formatBytes(kernelMemory) : '—' }}</dd>
</div>
<div class="stat">
<dt>连接</dt>
<dd>{{ connCount ?? '—' }}</dd>
</div>
</dl>
</div>
</section>
<div class="cols">
<div class="col">
<section class="sect">
<header class="sect-h">
<IconServer class="sect-i" />
<h2>服务器</h2>
<span v-if="host" class="sect-m mono">{{ host.platform }} · {{ host.arch }}</span>
</header>
<div class="sect-b">
<p v-if="hostFailed" class="empty">主机信息不可用</p>
<template v-else-if="host">
<div class="gauges">
<div class="gauge">
<span class="g-k">CPU 使用率</span>
<span class="g-v">{{ host.cpuUsage !== null ? pct(host.cpuUsage) : '—' }}</span>
<span class="bar" aria-hidden="true">
<span class="bar-fill" :style="{ width: pct(host.cpuUsage ?? 0) }"></span>
</span>
</div>
<div class="gauge">
<span class="g-k">内存使用率</span>
<span class="g-v">{{ memUsedRatio !== null ? pct(memUsedRatio) : '—' }}</span>
<span class="bar" aria-hidden="true">
<span class="bar-fill" :style="{ width: pct(memUsedRatio ?? 0) }"></span>
</span>
</div>
<div class="gauge">
<span class="g-k">系统负载</span>
<span class="g-v">{{ host.load1 !== null ? host.load1.toFixed(2) : '—' }}</span>
</div>
<div class="gauge">
<span class="g-k">运行时间</span>
<span class="g-v">{{ formatUptime(host.uptimeSeconds) }}</span>
</div>
</div>
<p class="meta mono">
{{ host.cpuCount }} 核 · 内存 {{ formatBytes(host.memTotal) }} · 可用
{{ formatBytes(host.memAvailable) }}
</p>
</template>
<p v-else class="empty">正在读取主机信息</p>
</div>
</section>
<section class="sect">
<header class="sect-h">
<IconRoute class="sect-i" />
<h2>路由</h2>
<span class="sect-m">{{ activeCount }} 路活跃</span>
</header>
<div class="sect-b routes">
<p v-if="shownRoutes.length === 0" class="empty">还没有任何入站。</p>
<div
v-for="row in shownRoutes"
:key="row.inbound.id"
class="route"
role="button"
tabindex="0"
:title="`打开 ${row.inbound.name} 的详情`"
@click="emit('open-inbound', row.inbound.id)"
@keydown.enter="emit('open-inbound', row.inbound.id)"
>
<span :class="['dot', row.active ? 'dot-on' : 'dot-idle']" aria-hidden="true"></span>
<span class="r-name">{{ row.inbound.name }}</span>
<span class="r-port mono">{{ row.inbound.port }}</span>
<EChart class="spark" :option="row.spark" />
<span class="r-rate mono">
<template v-if="row.rate">↑ {{ rate(row.rate.up) }} ↓ {{ rate(row.rate.down) }}</template>
<template v-else>—</template>
</span>
<span class="r-exit">
<button
v-if="row.exit.kind === 'idle'"
type="button"
class="probe"
@click.stop="void probeExit(row.exit.port)"
>
检测公网 IP
</button>
<span v-else-if="row.exit.kind === 'loading'" class="probe-wait">检测中</span>
<button
v-else-if="row.exit.kind === 'ok'"
type="button"
class="exit mono"
:title="row.exit.title"
@click.stop="void probeExit(row.exit.port, true)"
>
{{ row.exit.text }}
</button>
<button
v-else-if="row.exit.kind === 'fail'"
type="button"
class="probe probe-fail"
:title="row.exit.title"
@click.stop="void probeExit(row.exit.port, true)"
>
检测失败
</button>
<span v-else class="probe-none">—</span>
</span>
<IconGo class="r-go" aria-hidden="true" />
</div>
<button v-if="hiddenCount > 0" type="button" class="more" @click="emit('open-router')">
还有 {{ hiddenCount }} 路,打开路由页查看
</button>
</div>
</section>
</div>
<div class="col">
<section class="sect">
<header class="sect-h">
<IconDonut class="sect-i" />
<h2>流量统计</h2>
</header>
<div class="sect-b donut-wrap">
<EChart class="donut" :option="donutOption" />
<dl class="donut-kv">
<div class="row">
<dt><span class="sw sw-up" aria-hidden="true"></span>累计上传</dt>
<dd class="mono">{{ totals ? formatBytes(totals.up) : '—' }}</dd>
</div>
<div class="row">
<dt><span class="sw sw-down" aria-hidden="true"></span>累计下载</dt>
<dd class="mono">{{ totals ? formatBytes(totals.down) : '—' }}</dd>
</div>
</dl>
</div>
</section>
<section class="sect">
<header class="sect-h">
<IconLan class="sect-i" />
<h2>内网</h2>
</header>
<div class="sect-b">
<dl class="kv">
<div v-for="addr in host?.lanAddresses ?? []" :key="addr.iface + addr.address" class="row">
<dt>{{ addr.iface }}</dt>
<dd class="mono">{{ addr.address }}</dd>
</div>
<div class="row">
<dt>网关</dt>
<dd class="mono">{{ host?.gateway ?? '—' }}</dd>
</div>
<div class="row">
<dt>DNS</dt>
<dd class="mono dns">{{ host?.nameservers.length ? host.nameservers.join('、') : '—' }}</dd>
</div>
</dl>
</div>
</section>
<section class="sect">
<header class="sect-h">
<IconOverview class="sect-i" />
<h2>概览</h2>
</header>
<div class="sect-b">
<dl class="kv">
<div class="row">
<dt>入站</dt>
<dd>{{ inboundCount }} 路</dd>
</div>
<div class="row">
<dt>策略组</dt>
<dd>{{ groupCount }} 个</dd>
</div>
<div class="row">
<dt>订阅线路</dt>
<dd>{{ providerLines.length }} 条</dd>
</div>
<div class="row">
<dt>规则</dt>
<dd>{{ ruleCount }} 条</dd>
</div>
</dl>
</div>
</section>
<section class="sect">
<header class="sect-h">
<IconKernel class="sect-i" />
<h2>内核</h2>
</header>
<div class="sect-b">
<p v-if="!kernel.online" class="empty">内核未连接</p>
<dl v-else class="kv">
<div class="row">
<dt>版本</dt>
<dd class="mono">{{ kernel.version ?? '—' }}</dd>
</div>
<div class="row">
<dt>运行模式</dt>
<dd>{{ mode }}</dd>
</div>
<div class="row">
<dt>代理端口</dt>
<dd class="mono ports">
<span v-for="p in ports" :key="p.name">{{ p.name }} {{ p.port }}</span>
<span v-if="ports.length === 0">—</span>
</dd>
</div>
</dl>
</div>
</section>
</div>
</div>
</div>
</template>
<style scoped>
.page {
container-type: inline-size;
display: flex;
flex-direction: column;
gap: var(--s-5);
}
/* ── 区块骨架:图标标题一行,内容一格。整页只有这一种容器 ── */
.sect {
border: 1px solid var(--c-border);
border-radius: var(--r-md);
background: var(--c-surface);
}
.sect-h {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-3) var(--s-4);
border-bottom: 1px solid var(--c-border);
}
.sect-h h2 {
font-size: var(--t-base);
font-weight: 600;
}
.sect-i {
flex: none;
width: 16px;
height: 16px;
color: var(--c-accent);
}
/* 标题行右侧的辅助信息 */
.sect-m {
margin-left: auto;
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.sect-b {
padding: var(--s-4);
}
.cols {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 3fr);
gap: var(--s-5);
align-items: start;
}
@container (max-width: 900px) {
.cols {
grid-template-columns: minmax(0, 1fr);
}
}
.col {
display: flex;
flex-direction: column;
gap: var(--s-5);
min-width: 0;
}
.empty {
color: var(--c-text-faint);
font-size: var(--t-sm);
padding: var(--s-2) 0;
}
/* ── 网络 ─────────────────────────────────────── */
.h-right {
display: flex;
align-items: center;
gap: var(--s-4);
margin-left: auto;
}
.now {
display: flex;
gap: var(--s-4);
font-size: var(--t-sm);
color: var(--c-text-soft);
font-variant-numeric: tabular-nums;
}
.now-down {
color: var(--c-accent-text);
}
.ranges {
display: flex;
gap: var(--s-1);
}
.range {
padding: 2px var(--s-3);
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-soft);
font-size: var(--t-xs);
cursor: pointer;
}
.range:hover {
background: var(--c-surface-sunken);
color: var(--c-text);
}
.range.is-on {
background: var(--c-accent-soft);
border-color: color-mix(in srgb, var(--c-accent) 25%, transparent);
color: var(--c-accent-text);
}
.plot {
height: 220px;
}
.legend {
display: flex;
align-items: center;
gap: var(--s-4);
margin-top: var(--s-2);
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.key {
display: flex;
align-items: center;
gap: var(--s-2);
}
.sw {
display: inline-block;
width: 14px;
height: 2px;
border-radius: 1px;
}
.sw-up {
background: var(--c-text-faint);
}
.sw-down {
background: var(--c-accent);
}
.stale {
margin-left: auto;
color: var(--c-text-faint);
font-size: var(--t-xs);
}
.strip {
display: flex;
flex-wrap: wrap;
gap: var(--s-2) var(--s-6);
margin: var(--s-4) 0 0;
padding-top: var(--s-3);
border-top: 1px solid var(--c-border);
}
.stat {
display: flex;
align-items: baseline;
gap: var(--s-2);
}
.stat dt {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.stat dd {
margin: 0;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* ── 服务器 ───────────────────────────────────── */
.gauges {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--s-4) var(--s-5);
}
.gauge {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: var(--s-1) var(--s-2);
}
.g-k {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.g-v {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.bar {
grid-column: 1 / -1;
height: 4px;
border-radius: 2px;
background: var(--c-surface-sunken);
overflow: hidden;
}
.bar-fill {
display: block;
height: 100%;
border-radius: 2px;
background: var(--c-accent);
transition: width 0.3s ease;
}
.meta {
margin-top: var(--s-4);
padding-top: var(--s-3);
border-top: 1px solid var(--c-border);
color: var(--c-text-faint);
font-size: var(--t-xs);
}
/* ── 路由 ─────────────────────────────────────── */
.routes {
padding-top: var(--s-2);
padding-bottom: var(--s-2);
}
.route {
display: grid;
grid-template-columns: 8px minmax(72px, max-content) max-content minmax(48px, 1fr) max-content max-content 16px;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-1);
border-radius: var(--r-sm);
cursor: pointer;
}
.route:hover {
background: var(--c-surface-sunken);
}
.route + .route {
border-top: 1px solid var(--c-border);
border-radius: 0;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.dot-on {
background: var(--c-ok);
}
.dot-idle {
background: var(--c-text-faint);
opacity: 0.5;
}
.r-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.r-port {
color: var(--c-text-faint);
font-size: var(--t-xs);
}
.spark {
height: 26px;
min-width: 48px;
}
.r-rate {
font-size: var(--t-xs);
color: var(--c-text-soft);
font-variant-numeric: tabular-nums;
text-align: right;
min-width: 132px;
}
.r-exit {
min-width: 88px;
display: flex;
justify-content: flex-end;
}
.probe,
.exit {
padding: 1px var(--s-2);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-xs);
cursor: pointer;
}
.probe:hover,
.exit:hover {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.exit {
border-color: transparent;
color: var(--c-text-soft);
}
.probe-fail {
color: var(--c-danger);
border-color: color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
}
.probe-wait,
.probe-none {
color: var(--c-text-faint);
font-size: var(--t-xs);
}
.r-go {
width: 16px;
height: 16px;
color: var(--c-text-faint);
}
.more {
display: block;
width: 100%;
margin-top: var(--s-2);
padding: var(--s-2);
border: none;
border-top: 1px solid var(--c-border);
background: none;
color: var(--c-text-soft);
font-size: var(--t-sm);
cursor: pointer;
text-align: center;
}
.more:hover {
color: var(--c-accent-text);
}
/* ── 流量统计 ─────────────────────────────────── */
.donut-wrap {
display: flex;
align-items: center;
gap: var(--s-5);
}
.donut {
flex: none;
width: 112px;
height: 112px;
}
.donut-kv {
flex: 1;
margin: 0;
min-width: 0;
}
.donut-kv .row dt {
display: flex;
align-items: center;
gap: var(--s-2);
}
/* ── 键值行(内网 / 概览 / 内核)──────────────── */
.kv {
margin: 0;
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--s-4);
padding: var(--s-2) 0;
}
.row + .row {
border-top: 1px solid var(--c-border);
}
.row dt {
color: var(--c-text-soft);
font-size: var(--t-sm);
white-space: nowrap;
}
.row dd {
margin: 0;
font-variant-numeric: tabular-nums;
text-align: right;
}
.dns {
overflow-wrap: anywhere;
}
.ports {
display: flex;
gap: var(--s-3);
flex-wrap: wrap;
justify-content: flex-end;
}
</style>