release / release (push) Successful in 29s
判定按状态码白名单而不是只认 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,两个页面都要用它了。
1075 lines
30 KiB
Vue
1075 lines
30 KiB
Vue
<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>
|