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

440 lines
12 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">
/**
* 连接页:内核当前有哪些连接,从哪一路入站进来,能断开它们。
*
* /connections 没有流式版本,只能每秒全量轮询一次。表格数据来自内核快照,
* 显示层的问题(主机名怎么摆、出站链按什么方向、持续时间多长)都在 shared/connections.ts
* 里回答,这里只管取数、状态和断开动作。
*/
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import {
chainLabel,
filterConnections,
formatDuration,
groupByInbound,
hostLabel,
ruleLabel,
sortConnections,
} from '@shared/connections.ts'
import type { ConnectionSnapshot, SortKey } from '@shared/connections.ts'
import { formatBytes } from '@shared/format.ts'
import { ApiError, api } from '../api'
import { kernel } from '../runtime'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import IconClose from '~icons/ri/close-line'
const snapshot = ref<ConnectionSnapshot | null>(null)
/** 断开动作的失败。只被下一次动作覆盖,轮询不碰它 */
const failure = ref('')
/** 轮询取数的失败。轮询自己成功时清掉 */
const pollFailure = ref('')
const shownFailure = computed(() => failure.value || pollFailure.value)
/** 表格里的持续时间按这一刻算。跟着轮询节拍走,不单独开秒表 */
const now = ref(Date.now())
const query = ref('')
const sort = ref<SortKey>('download')
/** 正在断开中的连接 id,防止连点 */
const closing = ref<string[]>([])
const closingAll = ref(false)
const askAll = ref(false)
/** 有请求在飞就不再发,内核卡住时每秒叠一个请求只会让局面更糟 */
let fetching = false
let timer: number | undefined
/**
* 轮询的取数失败和断开动作的失败分开放。共用一条通道的话,断开失败的提示
* 会被下一秒轮询成功时的清空抹掉,用户只看到那条连接还在、不知道为什么。
*/
async function refresh() {
// 内核不在线时不打这个接口:得不到数据,界面直接说原因
if (!kernel.value.online || fetching) return
fetching = true
try {
snapshot.value = await api.connections()
now.value = Date.now()
pollFailure.value = ''
} catch (error) {
pollFailure.value = error instanceof ApiError ? error.message : String(error)
} finally {
fetching = false
}
}
onMounted(() => {
void refresh()
timer = window.setInterval(() => void refresh(), 1000)
})
onUnmounted(() => {
window.clearInterval(timer)
})
const all = computed(() => snapshot.value?.connections ?? [])
const rows = computed(() =>
sortConnections(filterConnections(all.value, query.value), sort.value, now.value),
)
const uses = computed(() => groupByInbound(all.value))
/* 行高写死,estimateSize 必须和它一致,否则滚动位置算不准 */
const ROW_HEIGHT = 48
const scrollEl = ref<HTMLElement | null>(null)
const virtualizer = useVirtualizer(
computed(() => ({
count: rows.value.length,
getScrollElement: () => scrollEl.value,
estimateSize: () => ROW_HEIGHT,
overscan: 8,
})),
)
const virtualRows = computed(() => virtualizer.value.getVirtualItems())
const totalSize = computed(() => virtualizer.value.getTotalSize())
async function closeOne(id: string) {
if (closing.value.includes(id) || closingAll.value) return
closing.value = [...closing.value, id]
failure.value = ''
try {
await api.closeConnection(id)
await refresh()
} catch (error) {
failure.value = error instanceof ApiError ? error.message : String(error)
} finally {
closing.value = closing.value.filter((item) => item !== id)
}
}
async function closeAllConfirmed() {
askAll.value = false
if (closingAll.value) return
closingAll.value = true
failure.value = ''
try {
await api.closeAllConnections()
await refresh()
} catch (error) {
failure.value = error instanceof ApiError ? error.message : String(error)
} finally {
closingAll.value = false
}
}
</script>
<template>
<div class="page">
<header class="head">
<div class="sums">
<div class="sum">
<span class="sum-n">{{ all.length }}</span>
<span class="sum-k">连接</span>
</div>
<div class="sum">
<span class="sum-n">{{ formatBytes(snapshot?.uploadTotal ?? 0) }}</span>
<span class="sum-k">上传总量</span>
</div>
<div class="sum">
<span class="sum-n">{{ formatBytes(snapshot?.downloadTotal ?? 0) }}</span>
<span class="sum-k">下载总量</span>
</div>
</div>
<div class="tools">
<input v-model="query" type="search" class="search" placeholder="按主机筛选" />
<button
type="button"
class="close-all"
:disabled="all.length === 0 || closingAll"
@click="askAll = true"
>
全部断开
</button>
</div>
</header>
<div v-if="shownFailure" class="failure">
<strong>{{ shownFailure }}</strong>
</div>
<p v-if="!kernel.online" class="note">内核未连接,看不到当前有哪些连接。</p>
<p v-else-if="!snapshot" class="note">读取中</p>
<p v-else-if="all.length === 0" class="note">当前没有连接。</p>
<template v-else>
<!-- 这个项目特有的视角:每一路入站(每个容器)现在有多少条连接 -->
<div class="uses">
<span v-for="use in uses" :key="use.label" class="use">
{{ use.label }}
<strong>{{ use.count }}</strong> 条
</span>
</div>
<p v-if="rows.length === 0" class="note">没有主机匹配「{{ query.trim() }}」。</p>
<div v-else class="table">
<div class="rowgrid thead">
<span>主机</span>
<span>规则</span>
<span>出站链</span>
<span class="num">上传</span>
<button
type="button"
:class="['th num', sort === 'download' && 'is-on']"
@click="sort = 'download'"
>
下载{{ sort === 'download' ? ' ↓' : '' }}
</button>
<button
type="button"
:class="['th num', sort === 'duration' && 'is-on']"
@click="sort = 'duration'"
>
持续时间{{ sort === 'duration' ? ' ↓' : '' }}
</button>
<span>入站</span>
<span></span>
</div>
<div ref="scrollEl" class="scroller">
<div class="spacer" :style="{ height: `${totalSize}px` }">
<div
v-for="vRow in virtualRows"
:key="rows[vRow.index]?.id ?? vRow.index"
class="rowgrid row"
:style="{ transform: `translateY(${vRow.start}px)` }"
>
<template v-if="rows[vRow.index]">
<span class="cell mono" :title="hostLabel(rows[vRow.index]!.metadata)">
{{ hostLabel(rows[vRow.index]!.metadata) }}
</span>
<span class="cell mono">{{ ruleLabel(rows[vRow.index]!.rule, rows[vRow.index]!.rulePayload) }}</span>
<span class="cell mono" :title="chainLabel(rows[vRow.index]!.chains)">
{{ chainLabel(rows[vRow.index]!.chains) }}
</span>
<span class="cell num mono">{{ formatBytes(rows[vRow.index]!.upload) }}</span>
<span class="cell num mono">{{ formatBytes(rows[vRow.index]!.download) }}</span>
<span class="cell num mono">{{ formatDuration(rows[vRow.index]!.start, now) }}</span>
<span class="cell mono">{{ rows[vRow.index]!.metadata.inboundName }}</span>
<button
type="button"
class="drop"
title="断开这条连接"
:disabled="closing.includes(rows[vRow.index]!.id) || closingAll"
@click="closeOne(rows[vRow.index]!.id)"
>
<IconClose />
</button>
</template>
</div>
</div>
</div>
</div>
</template>
<ConfirmDialog
:open="askAll"
:message="`断开全部 ${String(all.length)} 条连接?正在进行的传输会中断。`"
action="全部断开"
@close="askAll = false"
@confirm="closeAllConfirmed"
/>
</div>
</template>
<style scoped>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
margin-bottom: var(--s-5);
}
.sums {
display: flex;
gap: var(--s-6);
}
.sum {
display: flex;
align-items: baseline;
gap: var(--s-2);
}
.sum-n {
font-size: var(--t-xl);
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.sum-k {
color: var(--c-text-soft);
font-size: var(--t-sm);
}
.tools {
display: flex;
align-items: center;
gap: var(--s-2);
}
.search {
width: 200px;
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
}
.close-all {
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
cursor: pointer;
white-space: nowrap;
}
.close-all:hover:not(:disabled) {
border-color: var(--c-danger);
color: var(--c-danger);
}
.close-all:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.failure {
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
border-radius: var(--r-md);
background: var(--c-danger-soft);
padding: var(--s-3) var(--s-4);
margin-bottom: var(--s-4);
font-size: var(--t-sm);
}
.note {
color: var(--c-text-faint);
font-size: var(--t-sm);
}
.uses {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
margin-bottom: var(--s-4);
}
.use {
padding: var(--s-1) var(--s-3);
border: 1px solid var(--c-border);
border-radius: 999px;
background: var(--c-surface);
font-size: var(--t-sm);
color: var(--c-text-soft);
}
.use strong {
color: var(--c-text);
font-variant-numeric: tabular-nums;
}
/* 窄屏不挤变形,横向滚动 */
.table {
border: 1px solid var(--c-border);
border-radius: var(--r-md);
background: var(--c-surface);
overflow-x: auto;
}
.rowgrid {
display: grid;
grid-template-columns:
minmax(160px, 1.5fr) minmax(120px, 1fr) minmax(160px, 1.5fr)
5rem 5rem 6.5rem minmax(90px, 0.8fr) 2rem;
align-items: center;
gap: var(--s-3);
min-width: 880px;
padding: 0 var(--s-4);
}
.thead {
height: 40px;
border-bottom: 1px solid var(--c-border);
color: var(--c-text-faint);
font-size: var(--t-xs);
}
/* 可排序的列表头。用按钮但视觉上仍是表头文字,排序中的那列才显色 */
.th {
padding: 0;
border: none;
background: none;
color: inherit;
font-size: inherit;
cursor: pointer;
text-align: right;
}
.th:hover {
color: var(--c-text);
}
.th.is-on {
color: var(--c-text);
font-weight: 600;
}
.scroller {
overflow-y: auto;
/* 表格最长不顶满一屏:留出头部和汇总条的位置,连接少时随内容缩短 */
max-height: calc(100vh - 20rem);
}
.spacer {
position: relative;
}
.row {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 48px;
border-bottom: 1px solid var(--c-border);
font-size: var(--t-sm);
}
.cell {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.drop {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: none;
border-radius: var(--r-sm);
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.drop:hover:not(:disabled) {
background: var(--c-danger-soft);
color: var(--c-danger);
}
.drop:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>