仪表盘细节修整,内核卡片搬左列并加重启
- 曲线空态和图共用一个定高容器,数据到来不再挤下面的内容 - 路由行去掉行间分割线:它会吃掉 hover 高亮的圆角 - 每路走势线先合并成 28 桶再画。3 秒一个采样直接全画,在一条 百来像素宽的线上只剩噪声;没采集的桶画断口,不画假零 - 环形图两段加圆角,交界用底色描边让出间隙 - 内核卡片从右列搬到服务器和路由之间(左列本来就矮一截),加 「重启内核」:POST /restart 后内核自我 exec、PID 不变,恢复没有 任何通知,只能每 2 秒探一次,半分钟探不到交还给外壳心跳
This commit is contained in:
+150
-53
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user