设置页内核参数加 hosts 行,铅笔进弹窗编辑(顶层 hosts: 键, config.go:454,值单个或数组 parseHosts config.go:1131)。弹窗里随便改、 保存才一次性提交——写配置有几秒的校验重载,逐行失焦提交会把等待塞进 每次输入。没动过的行原样写回,数组不被重整形。 订阅和规则组新增时查缓存路径撞名:路径由名字净化而来,「订阅 A」和 「订阅/A」是两个名字、同一个文件,名字查重挡不住,会互相覆盖缓存。 纯函数在 shared 带测试,页面提交前拦。 连接页表头挪进滚动容器加 sticky:.scroller 声明 overflow-y 后按规范 连带拿到横向滚动上下文,表头留在外面时窄屏横向滚动两者脱节。表头 40px 的窗口偏移由 overscan 8 行吸收,不配 scrollMargin。 GEO 单文件下载先借内核的混合端口出去再退直连——内核自己更新 GEO 走 的是它的分流,Patchbay 直连 GitHub 不一定通。ECharts 拆独立 chunk, 业务代码改动不再让这 500KB 跟着换 hash。
478 lines
13 KiB
Vue
478 lines
13 KiB
Vue
<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 { toast, toastError } from '../toast'
|
||
import { kernel } from '../runtime'
|
||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||
import IconClose from '~icons/ri/close-line'
|
||
|
||
const snapshot = ref<ConnectionSnapshot | null>(null)
|
||
/**
|
||
* 轮询取数的失败,成功时自己清掉。这是持续状态所以留在页内;
|
||
* 断开动作的失败是一次性事件,走右上角轻提示。
|
||
*/
|
||
const pollFailure = ref('')
|
||
/** 表格里的持续时间按这一刻算。跟着轮询节拍走,不单独开秒表 */
|
||
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]
|
||
try {
|
||
await api.closeConnection(id)
|
||
await refresh()
|
||
} catch (error) {
|
||
toastError(error)
|
||
} finally {
|
||
closing.value = closing.value.filter((item) => item !== id)
|
||
}
|
||
}
|
||
|
||
async function closeAllConfirmed() {
|
||
askAll.value = false
|
||
if (closingAll.value) return
|
||
closingAll.value = true
|
||
try {
|
||
await api.closeAllConnections()
|
||
await refresh()
|
||
toast('已断开所有连接')
|
||
} catch (error) {
|
||
toastError(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="pollFailure" class="failure">
|
||
<strong class="failure-text">{{ pollFailure }}</strong>
|
||
<button type="button" class="failure-x" title="关闭提示" @click="pollFailure = ''">
|
||
<IconClose />
|
||
</button>
|
||
</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">
|
||
<!-- 表头在滚动容器里而不是外面:.scroller 声明 overflow-y 后按规范会连带
|
||
拿到自己的横向滚动上下文,表头留在外面的话窄屏横向滚动时两者会脱节。
|
||
竖直方向靠 sticky 钉住。 -->
|
||
<div ref="scrollEl" class="scroller">
|
||
<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 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 {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--s-3);
|
||
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);
|
||
}
|
||
|
||
.failure-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.failure-x {
|
||
flex: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 24px;
|
||
height: 24px;
|
||
border: none;
|
||
border-radius: var(--r-sm);
|
||
background: none;
|
||
color: var(--c-danger);
|
||
cursor: pointer;
|
||
}
|
||
.failure-x:hover {
|
||
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
|
||
}
|
||
|
||
.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);
|
||
/* 只负责圆角裁切;横竖两个方向的滚动都归 .scroller 一个容器管 */
|
||
overflow: hidden;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
/* 表头随内容横向滚动(同一个容器),竖直方向 sticky 钉在顶上。
|
||
行是绝对定位的,要 z-index 才能压住;背景不透明才不会透出滚过的行 */
|
||
.thead {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1;
|
||
height: 40px;
|
||
border-bottom: 1px solid var(--c-border);
|
||
background: var(--c-surface);
|
||
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: auto;
|
||
/* 表格最长不顶满一屏:留出头部和汇总条的位置,连接少时随内容缩短 */
|
||
max-height: calc(100vh - 20rem);
|
||
/* 表头占 40px,虚拟滚动的可见窗口按纯行高算会偏这 40px——overscan 8 行
|
||
(384px)足以盖住,不必给虚拟器配 scrollMargin */
|
||
}
|
||
|
||
.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>
|