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