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

- 曲线空态和图共用一个定高容器,数据到来不再挤下面的内容
- 路由行去掉行间分割线:它会吃掉 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
+19
View File
@@ -141,6 +141,25 @@ export async function selectProxy(
} }
} }
/**
* 让内核重启进程(POST /restart,内核源码 hub/route/restart.go:20-43)。
*
* 内核先回 {"status":"ok"} 再自我 exec。Linux 上用的是 syscall.Exec,PID 不变,
* systemd 托管不受影响;策略组的选中靠 profile.store-selected 恢复。
*/
export async function restartKernel(env: Env): Promise<ValidateResult> {
try {
const res = await callApi(env, '/restart', {
method: 'POST',
signal: AbortSignal.timeout(10_000),
})
if (res.ok) return { ok: true, message: '' }
return { ok: false, message: (await res.text()) || `管理接口返回 ${String(res.status)}` }
} catch (error) {
return { ok: false, message: `连不上管理接口:${String(error)}` }
}
}
/** /**
* 透传一个管理接口的 GET 请求。 * 透传一个管理接口的 GET 请求。
* *
+9 -1
View File
@@ -12,7 +12,7 @@
import listenerFields from '../shared/listener-fields.json' with { type: 'json' } import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts' import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
import { hostInfo, probeExit } from './host.ts' import { hostInfo, probeExit } from './host.ts'
import { kernelVersion, proxyGet, proxyWrite, selectProxy } from './kernel.ts' import { kernelVersion, proxyGet, proxyWrite, restartKernel, selectProxy } from './kernel.ts'
import { tokenFromRequest, tokenMatches } from './auth.ts' import { tokenFromRequest, tokenMatches } from './auth.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts'
import type { Env } from './env.ts' import type { Env } from './env.ts'
@@ -214,6 +214,14 @@ function makeRoute(env: Env, token: string) {
: json({ message: upstream.message || '内核拒绝了这次切换' }, 422) : json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
} }
// 重启内核进程。运行时动作,不碰配置文件;和其它写操作一样单独点名,不开通用口子
if (path === '/api/kernel/restart' && req.method === 'POST') {
const upstream = await restartKernel(env)
return upstream.ok
? json({ ok: true })
: json({ message: upstream.message || '内核拒绝了重启请求' }, 502)
}
if (path.startsWith('/api/kernel/') && (req.method === 'PUT' || req.method === 'DELETE')) { if (path.startsWith('/api/kernel/') && (req.method === 'PUT' || req.method === 'DELETE')) {
const target = path.slice('/api/kernel'.length) const target = path.slice('/api/kernel'.length)
const rule = WRITABLE.find((w) => w.method === req.method && w.pattern.test(target)) const rule = WRITABLE.find((w) => w.method === req.method && w.pattern.test(target))
+31
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { import {
bucketRates,
lastRate, lastRate,
pushWindow, pushWindow,
routeDeltas, routeDeltas,
@@ -79,6 +80,36 @@ describe('routeDeltas', () => {
}) })
}) })
describe('bucketRates', () => {
test('样本按桶平均,桶的时间戳在桶中央', () => {
const samples: RouteSample[] = [
{ t: 1_000, ms: 1_000, up: 100, down: 0 },
{ t: 2_000, ms: 1_000, up: 300, down: 0 },
{ t: 6_000, ms: 1_000, up: 50, down: 50 },
]
// 0-10s 分两桶:前桶 400 字节 / 2 秒 = 200 B/s,后桶 100 字节 / 1 秒 = 100 B/s
expect(bucketRates(samples, 0, 10_000, 2)).toEqual([
[2_500, 200],
[7_500, 100],
])
})
test('开头没数据的桶被裁掉,中间的留成 null', () => {
const samples: RouteSample[] = [
{ t: 3_000, ms: 1_000, up: 100, down: 0 },
{ t: 9_000, ms: 1_000, up: 100, down: 0 },
]
const points = bucketRates(samples, 0, 10_000, 5)
expect(points).toHaveLength(4)
expect(points[0]).toEqual([3_000, 100])
expect(points[1]![1]).toBeNull()
})
test('没有任何样本返回空数组', () => {
expect(bucketRates([], 0, 10_000, 5)).toEqual([])
})
})
describe('windowBytes / lastRate', () => { describe('windowBytes / lastRate', () => {
const samples: RouteSample[] = [ const samples: RouteSample[] = [
{ t: 0, ms: 3_000, up: 100, down: 100 }, { t: 0, ms: 3_000, up: 100, down: 100 },
+33
View File
@@ -90,3 +90,36 @@ export function lastRate(samples: RouteSample[]): { up: number; down: number } |
if (!last || last.ms <= 0) return null if (!last || last.ms <= 0) return null
return { up: (last.up / last.ms) * 1000, down: (last.down / last.ms) * 1000 } return { up: (last.up / last.ms) * 1000, down: (last.down / last.ms) * 1000 }
} }
/**
* 把窗口内的样本合并成固定数量的桶,每桶是平均速率(字节每秒)。
*
* 走势线要的是形状,不是每一次采样:3 秒一个点、5 分钟窗口是一百个点,
* 全画在一条几十像素宽的线上只剩噪声。没有采样的桶是 null(「没在采集」,
* 不是「速率为 0」——零样本是采集层补出来的真零),开头的 null 直接裁掉,
* 采集是从中途开始的,不该画出一段假的静止期。
*/
export function bucketRates(
samples: RouteSample[],
start: number,
end: number,
buckets: number,
): Array<[number, number | null]> {
if (end <= start || buckets <= 0) return []
const width = (end - start) / buckets
const sums = Array.from({ length: buckets }, () => ({ bytes: 0, ms: 0 }))
for (const s of samples) {
if (s.t < start || s.t > end) continue
const i = Math.min(buckets - 1, Math.floor((s.t - start) / width))
sums[i]!.bytes += s.up + s.down
sums[i]!.ms += s.ms
}
const points = sums.map(
(b, i): [number, number | null] => [
Math.round(start + (i + 0.5) * width),
b.ms > 0 ? (b.bytes / b.ms) * 1000 : null,
],
)
const first = points.findIndex(([, v]) => v !== null)
return first === -1 ? [] : points.slice(first)
}
+6
View File
@@ -164,6 +164,12 @@ export const api = {
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */ /** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'), kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
/**
* 重启内核进程。所有连接会断开,几秒后自动恢复;
* 策略组的选中靠配置里的 profile.store-selected 找回来。
*/
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
/** /**
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。 * 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
* *
+150 -53
View File
@@ -11,13 +11,13 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { collectInbounds, inboundStatKey } from '@shared/inbounds.ts' import { collectInbounds, inboundStatKey } from '@shared/inbounds.ts'
import { formatBytes, formatUptime } from '@shared/format.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 { RouteSample } from '@shared/telemetry.ts'
import type { HostInfo } from '@shared/host-info.ts' import type { HostInfo } from '@shared/host-info.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts' import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import type { EChartsCoreOption } from 'echarts/core' import type { EChartsCoreOption } from 'echarts/core'
import { api } from '../api' import { api } from '../api'
import { kernel, providerLines } from '../runtime' import { kernel, providerLines, refreshKernel, reloadRuntime } from '../runtime'
import { import {
connCount, connCount,
exitProbes, exitProbes,
@@ -32,6 +32,7 @@ import {
import { firstPort, kernelPorts, modeLabel } from '../dashboard' import { firstPort, kernelPorts, modeLabel } from '../dashboard'
import { cssColor, themeTick } from '../theme' import { cssColor, themeTick } from '../theme'
import EChart from '../components/EChart.vue' import EChart from '../components/EChart.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import IconNet from '~icons/ri/line-chart-line' import IconNet from '~icons/ri/line-chart-line'
import IconServer from '~icons/ri/server-line' import IconServer from '~icons/ri/server-line'
import IconRoute from '~icons/ri/route-line' import IconRoute from '~icons/ri/route-line'
@@ -184,12 +185,40 @@ function sparkOption(samples: RouteSample[], end: number, color: string): EChart
silent: true, silent: true,
lineStyle: { width: 1, color }, lineStyle: { width: 1, color },
areaStyle: { color, opacity: 0.08 }, 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) const host = ref<HostInfo | null>(null)
@@ -225,12 +254,14 @@ const donutOption = computed<EChartsCoreOption>(() => {
void themeTick.value void themeTick.value
const t = totals.value const t = totals.value
const empty = !t || t.up + t.down === 0 const empty = !t || t.up + t.down === 0
// 两段之间的间隙用底色描边做:borderWidth 在段与段的交界处各让出一半
const gap = { borderColor: cssColor('--c-surface'), borderWidth: 3 }
return { return {
animation: false, animation: false,
series: [ series: [
{ {
type: 'pie', type: 'pie',
radius: ['64%', '86%'], radius: ['58%', '86%'],
label: { show: false }, label: { show: false },
labelLine: { show: false }, labelLine: { show: false },
emphasis: { scale: false }, emphasis: { scale: false },
@@ -238,8 +269,16 @@ const donutOption = computed<EChartsCoreOption>(() => {
data: empty data: empty
? [{ value: 1, itemStyle: { color: cssColor('--c-surface-sunken') } }] ? [{ 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> </div>
</header> </header>
<div class="sect-b"> <div class="sect-b">
<p v-if="traffic.length === 0" class="empty"> <div class="plot-area">
{{ kernel.online ? '正在等待第一份流量数据' : '内核未连接' }} <p v-if="traffic.length === 0" class="empty plot-empty">
</p> {{ kernel.online ? '正在等待第一份流量数据' : '内核未连接' }}
<template v-else> </p>
<EChart class="plot" :option="trafficOption" /> <template v-else>
<div class="legend"> <EChart class="plot" :option="trafficOption" />
<span class="key"><span class="sw sw-up" aria-hidden="true"></span>上行</span> <div class="legend">
<span class="key"><span class="sw sw-down" aria-hidden="true"></span>下行</span> <span class="key"><span class="sw sw-up" aria-hidden="true"></span>上行</span>
<span v-if="!live && kernel.online" class="stale">数据流已中断,正在重连</span> <span class="key"><span class="sw sw-down" aria-hidden="true"></span>下行</span>
<span v-else-if="!live" class="stale">内核未连接,显示的是断开前的历史</span> <span v-if="!live && kernel.online" class="stale">数据流已中断,正在重连</span>
</div> <span v-else-if="!live" class="stale">内核未连接,显示的是断开前的历史</span>
</template> </div>
</template>
</div>
<dl class="strip"> <dl class="strip">
<div class="stat"> <div class="stat">
<dt>累计上传</dt> <dt>累计上传</dt>
@@ -379,6 +420,43 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
</div> </div>
</section> </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"> <section class="sect">
<header class="sect-h"> <header class="sect-h">
<IconRoute class="sect-i" /> <IconRoute class="sect-i" />
@@ -398,8 +476,10 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
@keydown.enter="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="['dot', row.active ? 'dot-on' : 'dot-idle']" aria-hidden="true"></span>
<span class="r-name">{{ row.inbound.name }}</span> <span class="r-id">
<span class="r-port mono">{{ row.inbound.port }}</span> <span class="r-name">{{ row.inbound.name }}</span>
<span class="r-port mono">{{ row.inbound.port }}</span>
</span>
<EChart class="spark" :option="row.spark" /> <EChart class="spark" :option="row.spark" />
<span class="r-rate mono"> <span class="r-rate mono">
<template v-if="row.rate">↑ {{ rate(row.rate.up) }} ↓ {{ rate(row.rate.down) }}</template> <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> </div>
</section> </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> </div>
<ConfirmDialog
:open="confirmRestart"
message="重启内核会断开它当前的所有连接,几秒后自动恢复。"
action="重启"
@close="confirmRestart = false"
@confirm="restartKernel"
/>
</div> </div>
</template> </template>
@@ -662,6 +724,18 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
color: var(--c-accent-text); color: var(--c-accent-text);
} }
/* 空态和「图 + 图例」共用一个定高容器,数据到来时下面的内容不跳位 */
.plot-area {
display: flex;
flex-direction: column;
min-height: 250px;
}
.plot-empty {
margin: auto;
padding: 0;
}
.plot { .plot {
height: 220px; height: 220px;
} }
@@ -774,27 +848,51 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
font-size: var(--t-xs); 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 { .routes {
padding-top: var(--s-2); padding-top: var(--s-2);
padding-bottom: var(--s-2); padding-bottom: var(--s-2);
} }
/* 名字占弹性位,走势线定宽靠右。行间不画分割线:划线会吃掉 hover 高亮的圆角 */
.route { .route {
display: grid; 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; align-items: center;
gap: var(--s-3); gap: var(--s-3);
padding: var(--s-2) var(--s-1); padding: var(--s-2) var(--s-2);
border-radius: var(--r-sm); border-radius: var(--r-sm);
cursor: pointer; cursor: pointer;
} }
.route:hover { .route:hover {
background: var(--c-surface-sunken); background: var(--c-surface-sunken);
} }
.route + .route {
border-top: 1px solid var(--c-border); .r-id {
border-radius: 0; display: flex;
align-items: baseline;
gap: var(--s-2);
min-width: 0;
} }
.dot { .dot {
@@ -823,7 +921,7 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
.spark { .spark {
height: 26px; height: 26px;
min-width: 48px; width: 150px;
} }
.r-rate { .r-rate {
@@ -956,6 +1054,5 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
display: flex; display: flex;
gap: var(--s-3); gap: var(--s-3);
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end;
} }
</style> </style>