Files
mihomo-patchbay/web/views/DashboardPage.vue
T
im0o 35eb463633 评审 P1 修正一批
设置:显式主题补 color-scheme,滚动条和 select 弹层这些 UA 部件不看
变量。连接:断开失败的提示与轮询取数分成两条通道,不再被下一秒的轮询
成功抹掉;排序加 id 决胜,内核快照的遍历序无序,空闲连接下载量普遍
相同,没有决胜键行会在断开按钮下面跳。仪表盘与请求:一条流断了连带
收掉同组连接;断流不等于内核没了(配置重载掐断流而 online 从未翻转),
5 秒自动重连。规则:正则类规则(DOMAIN-REGEX 等三种)按内核的
payloadCanContainComma 语义拆分与禁入结构化编辑,顶层逗号不再把正则
截一半当目标;findRuleIssues 注释里对内核校验的错误断言改正。
2026-08-11 21:14:03 +08:00

414 lines
11 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) return
// 一条断了把另一条也收掉:界面已经显示离线,不留一条看不见的活连接
ctrl.abort()
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()
}
})
// 内核从离线恢复(外壳的心跳每 10 秒探测一次)时重新接上;掉线时收掉
watch(
() => kernel.value.online,
(online) => {
stopStreams()
samples.value = []
memory.value = null
configs.value = null
if (online) {
streamFailed.value = false
startStreams()
void loadConfigs()
}
},
)
// 断流不等于内核没了:配置重载会掐断流,内核几秒内就回来、online 从未翻转。
// 稍后重试一次;再失败会重新走到这里,形成 5 秒一次的重试节奏,直到接上或离开页面
let retryTimer = 0
watch(streamFailed, (failed) => {
window.clearTimeout(retryTimer)
if (!failed) return
retryTimer = window.setTimeout(() => {
if (!kernel.value.online) return // 真离线交给上面那个 watch
streamFailed.value = false
startStreams()
void loadConfigs()
}, 5000)
})
onBeforeUnmount(() => {
stopStreams()
window.clearTimeout(retryTimer)
})
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>