Files
mihomo-patchbay/web/views/DashboardPage.vue
T
im0o 3d7304dd22
release / release (push) Successful in 29s
入站详情可以测常用 AI 服务的可访问性
判定按状态码白名单而不是只认 200:探测不带鉴权,OpenAI 回 401、Anthropic 回 405
才是正常应答,说明 TLS 握手、路由、目标服务全都正常。清单和白名单照 sub2api 的
代理质量检查抄来。

失败文案不照抄 Bun:本机端口没人监听时它给的是「Unable to connect. Is the
computer able to access the url?」,把矛头指向目标网址,而真正的原因在本机。

对话框留在 InboundDetail 而不是提到 RouterPage——它不写配置文件也不改内核状态,
不需要走那条 commit 链路。firstPort 挪进 shared/inbounds.ts,两个页面都要用它了。
2026-08-31 03:59:08 +08:00

1075 lines
30 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, firstPort, inboundStatKey } from '@shared/inbounds.ts'
import { flagEmoji, formatBytes, formatUptime } from '@shared/format.ts'
import { ACTIVE_BYTES, ROUTE_WINDOW_MS, bucketRates, 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, refreshKernel, reloadRuntime } from '../runtime'
import {
connCount,
exitProbes,
kernelMemory,
live,
probeExit,
routeSamples,
tick,
totals,
traffic,
} from '../telemetry'
import { kernelPorts, modeLabel } from '../dashboard'
import { cssColor, themeTick } from '../theme'
import EChart from '../components/EChart.vue'
import ConfirmDialog from '../components/ConfirmDialog.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'
import IconProbe from '~icons/ri/global-line'
import IconRestart from '~icons/ri/restart-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: [flagEmoji(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 },
// 合并成 28 桶再画:3 秒一个采样直接全画,在一条百来像素宽的线上只剩噪声
data: bucketRates(samples, end - ROUTE_WINDOW_MS, end, 28),
},
],
}
}
/* ── 内核 ─────────────────────────────────────── */
const confirmRestart = ref(false)
const restarting = ref(false)
/**
* 重启内核进程。回话之后它就退了,恢复不会有任何通知,只能探——
* 每 2 秒一次,最多半分钟;到点还没回来就交还给外壳 10 秒一次的心跳。
*/
async function restartKernel() {
confirmRestart.value = false
if (restarting.value) return
restarting.value = true
try {
await api.restartKernel()
} catch {
// 内核可能在响应送达前就退出了,这不算失败,接下来看它回不回来
}
kernel.value = { online: false }
for (let i = 0; i < 15 && !kernel.value.online; i++) {
await new Promise((resolve) => setTimeout(resolve, 2_000))
await refreshKernel()
}
if (kernel.value.online) await reloadRuntime()
restarting.value = false
}
/* ── 服务器 ───────────────────────────────────── */
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
// 两段之间的间隙用底色描边做:borderWidth 在段与段的交界处各让出一半
const gap = { borderColor: cssColor('--c-surface'), borderWidth: 3 }
return {
animation: false,
series: [
{
type: 'pie',
radius: ['58%', '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'), borderRadius: 8, ...gap },
},
{
value: t.down,
name: '下载',
itemStyle: { color: cssColor('--c-accent'), borderRadius: 8, ...gap },
},
],
},
],
}
})
/* ── 概览与内核 ───────────────────────────────── */
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">
<div class="plot-area">
<p v-if="traffic.length === 0" class="empty plot-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>
</div>
<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">
<IconKernel class="sect-i" />
<h2>内核</h2>
<button
v-if="kernel.online || restarting"
type="button"
class="restart"
:disabled="restarting"
@click="confirmRestart = true"
>
<IconRestart /> {{ restarting ? '重启中' : '重启内核' }}
</button>
</header>
<div class="sect-b">
<p v-if="!kernel.online && !restarting" class="empty">内核未连接</p>
<p v-else-if="!kernel.online" class="empty">正在等待内核回来</p>
<div v-else class="gauges">
<div class="gauge">
<span class="g-k">版本</span>
<span class="g-v mono">{{ kernel.version ?? '—' }}</span>
</div>
<div class="gauge">
<span class="g-k">运行模式</span>
<span class="g-v">{{ mode }}</span>
</div>
<div class="gauge">
<span class="g-k">代理端口</span>
<span class="g-v mono ports">
<span v-for="p in ports" :key="p.name">{{ p.name }} {{ p.port }}</span>
<span v-if="ports.length === 0">—</span>
</span>
</div>
</div>
</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-id">
<span class="r-name">{{ row.inbound.name }}</span>
<span class="r-port mono">{{ row.inbound.port }}</span>
</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"
title="检测公网 IP"
@click.stop="void probeExit(row.exit.port)"
>
<IconProbe /> 检测
</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>
</div>
</div>
<ConfirmDialog
:open="confirmRestart"
message="重启内核会断开它当前的所有连接,几秒后自动恢复。"
action="重启"
@close="confirmRestart = false"
@confirm="restartKernel"
/>
</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-area {
display: flex;
flex-direction: column;
min-height: 250px;
}
.plot-empty {
margin: auto;
padding: 0;
}
.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);
}
/* ── 内核 ─────────────────────────────────────── */
.restart {
display: flex;
align-items: center;
gap: var(--s-1);
margin-left: auto;
padding: 2px var(--s-2);
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-soft);
font-size: var(--t-xs);
cursor: pointer;
}
.restart:hover:not(:disabled) {
background: var(--c-surface-sunken);
color: var(--c-danger);
}
.restart:disabled {
color: var(--c-text-faint);
cursor: default;
}
.restart svg {
width: 13px;
height: 13px;
}
/* ── 路由 ─────────────────────────────────────── */
.routes {
padding-top: var(--s-2);
padding-bottom: var(--s-2);
}
/* 名字占弹性位,走势线定宽靠右。行间不画分割线:划线会吃掉 hover 高亮的圆角 */
.route {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) 150px max-content max-content 16px;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-2);
border-radius: var(--r-sm);
cursor: pointer;
}
.route:hover {
background: var(--c-surface-sunken);
}
.r-id {
display: flex;
align-items: baseline;
gap: var(--s-2);
min-width: 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;
width: 150px;
}
.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 {
display: inline-flex;
align-items: center;
gap: var(--s-1);
padding: 1px var(--s-2);
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-soft);
font-size: var(--t-xs);
cursor: pointer;
}
.probe:hover,
.exit:hover {
background: var(--c-surface-sunken);
color: var(--c-accent-text);
}
.probe svg {
width: 13px;
height: 13px;
}
.probe-fail {
color: var(--c-danger);
}
.probe-fail:hover {
color: var(--c-danger);
}
.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;
}
</style>