diff --git a/server/kernel.ts b/server/kernel.ts index dc485af..54168cb 100644 --- a/server/kernel.ts +++ b/server/kernel.ts @@ -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 { + 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 请求。 * diff --git a/server/routes.ts b/server/routes.ts index 7880431..e42a6e8 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -12,7 +12,7 @@ import listenerFields from '../shared/listener-fields.json' with { type: 'json' } import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.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 type { ConfigObject } from '../vendor/config-editor/index.ts' import type { Env } from './env.ts' @@ -214,6 +214,14 @@ function makeRoute(env: Env, token: string) { : 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')) { const target = path.slice('/api/kernel'.length) const rule = WRITABLE.find((w) => w.method === req.method && w.pattern.test(target)) diff --git a/shared/telemetry.test.ts b/shared/telemetry.test.ts index 26e156d..aaec569 100644 --- a/shared/telemetry.test.ts +++ b/shared/telemetry.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import { + bucketRates, lastRate, pushWindow, 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', () => { const samples: RouteSample[] = [ { t: 0, ms: 3_000, up: 100, down: 100 }, diff --git a/shared/telemetry.ts b/shared/telemetry.ts index 5181525..81934a2 100644 --- a/shared/telemetry.ts +++ b/shared/telemetry.ts @@ -90,3 +90,36 @@ export function lastRate(samples: RouteSample[]): { up: number; down: number } | if (!last || last.ms <= 0) return null 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) +} diff --git a/web/api.ts b/web/api.ts index 34fafc0..0fd8f7e 100644 --- a/web/api.ts +++ b/web/api.ts @@ -164,6 +164,12 @@ export const api = { /** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */ kernelConfigs: () => request>('GET', '/api/kernel/configs'), + /** + * 重启内核进程。所有连接会断开,几秒后自动恢复; + * 策略组的选中靠配置里的 profile.store-selected 找回来。 + */ + restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'), + /** * 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。 * diff --git a/web/views/DashboardPage.vue b/web/views/DashboardPage.vue index 68efb43..10d9c09 100644 --- a/web/views/DashboardPage.vue +++ b/web/views/DashboardPage.vue @@ -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(null) @@ -225,12 +254,14 @@ const donutOption = computed(() => { 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(() => { 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 ?? {}))
-

- {{ kernel.online ? '正在等待第一份流量数据' : '内核未连接' }} -

- +
+

+ {{ kernel.online ? '正在等待第一份流量数据' : '内核未连接' }} +

+ +
累计上传
@@ -379,6 +420,43 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
+
+
+ +

内核

+ +
+
+

内核未连接

+

正在等待内核回来

+
+
+ 版本 + {{ kernel.version ?? '—' }} +
+
+ 运行模式 + {{ mode }} +
+
+ 代理端口 + + {{ p.name }} {{ p.port }} + — + +
+
+
+
+
@@ -398,8 +476,10 @@ const ports = computed(() => kernelPorts(configs.value ?? {})) @keydown.enter="emit('open-inbound', row.inbound.id)" > - {{ row.inbound.name }} - {{ row.inbound.port }} + + {{ row.inbound.name }} + {{ row.inbound.port }} + @@ -515,34 +595,16 @@ const ports = computed(() => kernelPorts(configs.value ?? {}))
-
-
- -

内核

-
-
-

内核未连接

-
-
-
版本
-
{{ kernel.version ?? '—' }}
-
-
-
运行模式
-
{{ mode }}
-
-
-
代理端口
-
- {{ p.name }} {{ p.port }} - — -
-
-
-
-
+ + @@ -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; }