Files
mihomo-patchbay/web/views/DashboardPage.vue
T
im0o 97c3943403 新增仪表盘页:流量曲线、流量与内存数字、配置概览、内核状态
流量曲线手画 SVG polyline 而不引图表库:60 个点两条线,一个图表库几百 KB,
决策记录 0006 留的例外用在这里不划算。/traffic 和 /memory 是永不结束的流,
api.request() 会把整个响应读完等于挂死,所以新增 api.openStream() 返回响应体,
交给 web/stream.ts 的 readLines 逐行读,离开页面中止 fetch 断流。
导航入口要在 App.vue 里挂,按约定留给集成者统一改。
2026-08-11 18:36:00 +08:00

394 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 仪表盘:打开 Patchbay 第一眼看到的页面,现在什么状况。
*
* 全部只读。流量和内存是内核的流式接口(每秒一行 JSON),离开页面时中止;
* 概览的数字数自配置文件。两边来源不同:配置读得到不代表内核在跑,反过来也
* 一样,所以内核未连接时概览照常显示,只有流量、内存和内核那栏换说法。
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { collectInbounds } from '@shared/inbounds.ts'
import { formatBytes } from '@shared/format.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { api } from '../api'
import { readLines } from '../stream'
import { kernel, providerLines } from '../runtime'
import { chartPeak, kernelPorts, linePoints, modeLabel, pushSample } from '../dashboard'
import type { TrafficSample } from '../dashboard'
const props = defineProps<{ data: ConfigObject }>()
const samples = ref<TrafficSample[]>([])
const memory = ref<number | null>(null)
const configs = ref<Record<string, unknown> | null>(null)
/** 流读失败或中途断掉(比如页面开着时内核停了)。流量和内存整块换说法。 */
const streamFailed = ref(false)
const unreachable = computed(() => !kernel.value.online || streamFailed.value)
let abort: AbortController | null = null
/** 流里的数字字段。缺了、不是数字都按 0 算,第一行经常全是 0,那不是故障。 */
const num = (value: unknown): number =>
typeof value === 'number' && Number.isFinite(value) ? value : 0
async function consume(
path: string,
ctrl: AbortController,
onLine: (payload: Record<string, unknown>) => void,
) {
const body = await api.openStream(path, ctrl.signal)
for await (const line of readLines(body)) {
onLine(JSON.parse(line) as Record<string, unknown>)
}
}
function startStreams() {
const ctrl = new AbortController()
abort = ctrl
const run = (path: string, onLine: (payload: Record<string, unknown>) => void) =>
void consume(path, ctrl, onLine)
.catch(() => {})
// 正常结束也是断了:这两个流本身永不结束
.finally(() => {
if (!ctrl.signal.aborted) streamFailed.value = true
})
run('/traffic', (p) => {
samples.value = pushSample(samples.value, {
up: num(p.up),
down: num(p.down),
upTotal: num(p.upTotal),
downTotal: num(p.downTotal),
})
})
run('/memory', (p) => {
memory.value = num(p.inuse)
})
}
function stopStreams() {
abort?.abort()
abort = null
}
async function loadConfigs() {
configs.value = await api.kernelConfigs().catch(() => null)
}
onMounted(() => {
if (kernel.value.online) {
streamFailed.value = false
startStreams()
void loadConfigs()
}
})
// 内核从离线恢复(比如路由页保存配置触发的重载)时重新接上;掉线时收掉
watch(
() => kernel.value.online,
(online) => {
stopStreams()
samples.value = []
memory.value = null
configs.value = null
if (online) {
streamFailed.value = false
startStreams()
void loadConfigs()
}
},
)
onBeforeUnmount(stopStreams)
const latest = computed(() => samples.value.at(-1) ?? null)
/** 当前值还没到时显示破折号,不是 0——0 是个真值,破折号是没数据。 */
const current = computed(() => ({
up: latest.value ? `${formatBytes(latest.value.up)}/s` : '—',
down: latest.value ? `${formatBytes(latest.value.down)}/s` : '—',
upTotal: latest.value ? formatBytes(latest.value.upTotal) : '—',
downTotal: latest.value ? formatBytes(latest.value.downTotal) : '—',
memory: memory.value !== null ? formatBytes(memory.value) : '—',
}))
// 曲线的作图区域。viewBox 上下各留 6px,峰值的描边不顶边、底线描边不被裁掉
const CHART_W = 600
const CHART_H = 112
const peak = computed(() => chartPeak(samples.value))
const upPoints = computed(() =>
linePoints(samples.value.map((s) => s.up), peak.value, CHART_W, CHART_H),
)
const downPoints = computed(() =>
linePoints(samples.value.map((s) => s.down), peak.value, CHART_W, CHART_H),
)
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">
<template v-if="!unreachable">
<div class="sums">
<div class="sum">
<span class="sum-n">{{ current.up }}</span>
<span class="sum-k">上行</span>
</div>
<div class="sum">
<span class="sum-n">{{ current.down }}</span>
<span class="sum-k">下行</span>
</div>
<div class="sum">
<span class="sum-n">{{ current.upTotal }}</span>
<span class="sum-k">累计上传</span>
</div>
<div class="sum">
<span class="sum-n">{{ current.downTotal }}</span>
<span class="sum-k">累计下载</span>
</div>
<div class="sum">
<span class="sum-n">{{ current.memory }}</span>
<span class="sum-k">内存</span>
</div>
</div>
<div class="chart">
<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>
</div>
<!-- preserveAspectRatio=none 让曲线跟随容器宽度,描边宽度由 vector-effect 保住 -->
<svg
class="plot"
:viewBox="`0 0 ${CHART_W} ${CHART_H + 12}`"
preserveAspectRatio="none"
role="img"
aria-label="最近 60 秒的上行下行流量"
>
<g transform="translate(0, 6)">
<line class="axis" x1="0" :x2="CHART_W" :y1="CHART_H" :y2="CHART_H" />
<polyline v-if="samples.length" class="ln ln-up" :points="upPoints" />
<polyline v-if="samples.length" class="ln ln-down" :points="downPoints" />
</g>
</svg>
</div>
</template>
<p v-else class="offline">内核未连接</p>
<div class="split">
<section class="panel">
<h2 class="panel-t">概览</h2>
<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>
</section>
<section class="panel">
<h2 class="panel-t">内核</h2>
<p v-if="unreachable" class="panel-off">内核未连接</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>
</section>
</div>
</div>
</template>
<style scoped>
.sums {
display: flex;
flex-wrap: wrap;
gap: var(--s-6);
margin-bottom: var(--s-5);
}
.sum {
display: flex;
align-items: baseline;
gap: var(--s-2);
}
.sum-n {
font-size: var(--t-2xl);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.sum-k {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.chart {
margin-bottom: var(--s-6);
}
.legend {
display: flex;
gap: var(--s-4);
margin-bottom: var(--s-2);
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.key {
display: flex;
align-items: center;
gap: var(--s-2);
}
.sw {
width: 14px;
height: 2px;
border-radius: 1px;
}
.sw-up {
background: var(--c-text-faint);
}
.sw-down {
background: var(--c-accent);
}
.plot {
display: block;
width: 100%;
height: 140px;
}
.axis {
stroke: var(--c-border);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.ln {
fill: none;
stroke-width: 1.5;
stroke-linejoin: round;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
/* 下行是主视角,用唯一的强调色;上行压成中性色,不和任何状态色沾边 */
.ln-up {
stroke: var(--c-text-faint);
}
.ln-down {
stroke: var(--c-accent);
}
.offline {
color: var(--c-text-faint);
font-size: var(--t-sm);
margin-bottom: var(--s-6);
}
.split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s-5);
align-items: start;
}
@container (max-width: 900px) {
.split {
grid-template-columns: 1fr;
}
}
.page {
container-type: inline-size;
}
.panel {
border: 1px solid var(--c-border);
border-radius: var(--r-md);
background: var(--c-surface);
padding: var(--s-4) var(--s-5);
}
.panel-t {
font-size: var(--t-base);
font-weight: 600;
margin-bottom: var(--s-3);
}
.panel-off {
color: var(--c-text-faint);
font-size: var(--t-sm);
padding: var(--s-2) 0;
}
.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);
}
.row dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
.ports {
display: flex;
gap: var(--s-4);
}
</style>