仪表盘细节修整,内核卡片搬左列并加重启

- 曲线空态和图共用一个定高容器,数据到来不再挤下面的内容
- 路由行去掉行间分割线:它会吃掉 hover 高亮的圆角
- 每路走势线先合并成 28 桶再画。3 秒一个采样直接全画,在一条
  百来像素宽的线上只剩噪声;没采集的桶画断口,不画假零
- 环形图两段加圆角,交界用底色描边让出间隙
- 内核卡片从右列搬到服务器和路由之间(左列本来就矮一截),加
  「重启内核」:POST /restart 后内核自我 exec、PID 不变,恢复没有
  任何通知,只能每 2 秒探一次,半分钟探不到交还给外壳心跳
This commit is contained in:
2026-08-11 22:42:40 +08:00
parent eec5b791c8
commit 07a43b5a56
6 changed files with 248 additions and 54 deletions
+6
View File
@@ -164,6 +164,12 @@ export const api = {
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
/**
* 重启内核进程。所有连接会断开,几秒后自动恢复;
* 策略组的选中靠配置里的 profile.store-selected 找回来。
*/
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
/**
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
*
+150 -53
View File
@@ -11,13 +11,13 @@
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 { 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 } from '../runtime'
import { kernel, providerLines, refreshKernel, reloadRuntime } from '../runtime'
import {
connCount,
exitProbes,
@@ -32,6 +32,7 @@ import {
import { firstPort, 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'
@@ -184,12 +185,40 @@ function sparkOption(samples: RouteSample[], end: number, color: string): EChart
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]),
// 合并成 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)
@@ -225,12 +254,14 @@ 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: ['64%', '86%'],
radius: ['58%', '86%'],
label: { show: false },
labelLine: { show: false },
emphasis: { scale: false },
@@ -238,8 +269,16 @@ const donutOption = computed<EChartsCoreOption>(() => {
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') } },
{
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 },
},
],
},
],
@@ -302,18 +341,20 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
</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>
<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>
@@ -379,6 +420,43 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
</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"
>
{{ 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" />
@@ -398,8 +476,10 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
@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>
<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>
@@ -515,34 +595,16 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
</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>
<ConfirmDialog
:open="confirmRestart"
message="重启内核会断开它当前的所有连接,几秒后自动恢复。"
action="重启"
@close="confirmRestart = false"
@confirm="restartKernel"
/>
</div>
</template>
@@ -662,6 +724,18 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
color: var(--c-accent-text);
}
/* 空态和「图 + 图例」共用一个定高容器,数据到来时下面的内容不跳位 */
.plot-area {
display: flex;
flex-direction: column;
min-height: 250px;
}
.plot-empty {
margin: auto;
padding: 0;
}
.plot {
height: 220px;
}
@@ -774,27 +848,51 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
font-size: var(--t-xs);
}
/* ── 内核 ─────────────────────────────────────── */
.restart {
margin-left: auto;
padding: 2px var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
color: var(--c-text-soft);
font-size: var(--t-xs);
cursor: pointer;
}
.restart:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--c-danger) 45%, var(--c-border));
color: var(--c-danger);
}
.restart:disabled {
color: var(--c-text-faint);
cursor: default;
}
/* ── 路由 ─────────────────────────────────────── */
.routes {
padding-top: var(--s-2);
padding-bottom: var(--s-2);
}
/* 名字占弹性位,走势线定宽靠右。行间不画分割线:划线会吃掉 hover 高亮的圆角 */
.route {
display: grid;
grid-template-columns: 8px minmax(72px, max-content) max-content minmax(48px, 1fr) max-content max-content 16px;
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-1);
padding: var(--s-2) var(--s-2);
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;
.r-id {
display: flex;
align-items: baseline;
gap: var(--s-2);
min-width: 0;
}
.dot {
@@ -823,7 +921,7 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
.spark {
height: 26px;
min-width: 48px;
width: 150px;
}
.r-rate {
@@ -956,6 +1054,5 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
display: flex;
gap: var(--s-3);
flex-wrap: wrap;
justify-content: flex-end;
}
</style>