连接页:每秒轮询的连接表格,能按入站看、能断开
/connections 没有流式版本,只能全量轮询;断开是运行时动作,不走 落盘那条保护链路。两个容易做反的地方都收在 shared/connections.ts: chains 数组第一个是最终出口,显示要倒过来;没有连接时 connections 字段是 null 而不是空数组。虚拟滚动用 @tanstack/vue-virtual,ADR 0006 留的两个例外之一,它只有 virtual-core 一个运行时依赖。
This commit is contained in:
+191
-172
@@ -1,172 +1,191 @@
|
||||
/**
|
||||
* 后端接口的客户端。
|
||||
*
|
||||
* 令牌存在 localStorage 里。它等同于密码,但这个页面本来就只有能访问 127.0.0.1
|
||||
* (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。
|
||||
*/
|
||||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
|
||||
const TOKEN_KEY = 'patchbay.token'
|
||||
|
||||
/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */
|
||||
const TEST_URL = 'https://www.gstatic.com/generate_204'
|
||||
|
||||
export function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? ''
|
||||
}
|
||||
|
||||
export function setToken(value: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, value.trim())
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口报错时抛这个。
|
||||
*
|
||||
* 服务端的错误响应统一是 { message, hint? }:message 说发生了什么,hint 说下一步该干嘛。
|
||||
* 界面直接用这两个字段,不需要认得每个端点各自的错误形状。
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly payload: Record<string, unknown>,
|
||||
) {
|
||||
super(
|
||||
typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: `请求失败(HTTP ${String(status)})`,
|
||||
)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
|
||||
/** 服务端给的下一步建议,没有就是空串。 */
|
||||
get hint(): string {
|
||||
return typeof this.payload.hint === 'string' ? this.payload.hint : ''
|
||||
}
|
||||
|
||||
get unauthorized(): boolean {
|
||||
return this.status === 401
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await res.text()
|
||||
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {}
|
||||
if (!res.ok) throw new ApiError(res.status, payload)
|
||||
return payload as T
|
||||
}
|
||||
|
||||
export interface ConfigSnapshot {
|
||||
data: ConfigObject
|
||||
revision: string
|
||||
diagnostics: ConfigDiagnostic[]
|
||||
}
|
||||
|
||||
export interface ApplySuccess {
|
||||
ok: true
|
||||
snapshot: ConfigSnapshot
|
||||
reloaded: boolean
|
||||
reloadMessage: string
|
||||
}
|
||||
|
||||
export interface ProxyInfo {
|
||||
/** 出站的名字。/proxies 用键名标识,provider 里的线路则带在对象上 */
|
||||
name: string
|
||||
/** 协议类型(Vmess、Vless…)或策略组类型(Selector、URLTest…) */
|
||||
type: string
|
||||
/** 策略组当前选中的成员。不是策略组就没有这个字段 */
|
||||
now?: string
|
||||
/** 策略组的成员列表 */
|
||||
all?: string[]
|
||||
/** 延迟测试记录,最后一条是最近一次。delay 为 0 表示测失败 */
|
||||
history?: { delay: number }[]
|
||||
}
|
||||
|
||||
/** 最近一次延迟。没测过或测失败都返回 null。 */
|
||||
export function latestDelay(info: ProxyInfo | undefined): number | null {
|
||||
const last = info?.history?.at(-1)?.delay
|
||||
return last === undefined || last === 0 ? null : last
|
||||
}
|
||||
|
||||
export interface FieldSpec {
|
||||
key: string
|
||||
type: 'string' | 'number' | 'boolean' | 'string[]' | 'number[]' | 'object' | 'object[]'
|
||||
required: boolean
|
||||
default: unknown
|
||||
enum: (string | number)[] | null
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface ListenerFields {
|
||||
common: FieldSpec[]
|
||||
types: Record<string, { note: string; fields: FieldSpec[] }>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
readConfig: () => request<ConfigSnapshot>('GET', '/api/config'),
|
||||
|
||||
applyConfig: (data: ConfigObject, baseRevision: string) =>
|
||||
request<ApplySuccess>('PUT', '/api/config', { data, baseRevision }),
|
||||
|
||||
listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'),
|
||||
|
||||
restoreBackup: (name: string) =>
|
||||
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
|
||||
|
||||
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
||||
|
||||
/** 入站类型的字段表,从内核源码提取,用来驱动表单。 */
|
||||
listenerFields: () => request<ListenerFields>('GET', '/api/meta/listener-fields'),
|
||||
|
||||
/**
|
||||
* 所有出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。
|
||||
* 这些配置文件里都没有,只能问运行中的内核。
|
||||
*/
|
||||
proxies: () => request<{ proxies: Record<string, ProxyInfo> }>('GET', '/api/kernel/proxies'),
|
||||
|
||||
/**
|
||||
* 切换某个策略组当前选中哪个成员。
|
||||
* 这是运行时状态,不写配置文件,立刻生效——metacubexd 里点一条线路做的就是这件事。
|
||||
*/
|
||||
selectProxy: (group: string, member: string) =>
|
||||
request<{ ok: true }>('PUT', `/api/kernel/proxies/${encodeURIComponent(group)}`, {
|
||||
name: member,
|
||||
}),
|
||||
|
||||
/**
|
||||
* 测一条出站的延迟。
|
||||
*
|
||||
* 走哪个接口取决于它在不在内核的全局名字表里:策略组和自有线路在表里,用 /proxies;
|
||||
* 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。
|
||||
*/
|
||||
testLine: (name: string, provider?: string, timeout = 5000) => {
|
||||
const q = `url=${encodeURIComponent(TEST_URL)}&timeout=${String(timeout)}`
|
||||
const path = provider
|
||||
? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}`
|
||||
: `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}`
|
||||
return request<{ delay: number }>('GET', path)
|
||||
},
|
||||
|
||||
/**
|
||||
* 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。
|
||||
*
|
||||
* 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张
|
||||
* 全局名字表里,这是拿到它们类型和延迟的唯一途径。
|
||||
*/
|
||||
providers: () =>
|
||||
request<{ providers: Record<string, { vehicleType: string; proxies: ProxyInfo[] }> }>(
|
||||
'GET',
|
||||
'/api/kernel/providers/proxies',
|
||||
),
|
||||
}
|
||||
/**
|
||||
* 后端接口的客户端。
|
||||
*
|
||||
* 令牌存在 localStorage 里。它等同于密码,但这个页面本来就只有能访问 127.0.0.1
|
||||
* (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。
|
||||
*/
|
||||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import type { ConnectionSnapshot } from '@shared/connections.ts'
|
||||
|
||||
const TOKEN_KEY = 'patchbay.token'
|
||||
|
||||
/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */
|
||||
const TEST_URL = 'https://www.gstatic.com/generate_204'
|
||||
|
||||
export function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? ''
|
||||
}
|
||||
|
||||
export function setToken(value: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, value.trim())
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 接口报错时抛这个。
|
||||
*
|
||||
* 服务端的错误响应统一是 { message, hint? }:message 说发生了什么,hint 说下一步该干嘛。
|
||||
* 界面直接用这两个字段,不需要认得每个端点各自的错误形状。
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly payload: Record<string, unknown>,
|
||||
) {
|
||||
super(
|
||||
typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: `请求失败(HTTP ${String(status)})`,
|
||||
)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
|
||||
/** 服务端给的下一步建议,没有就是空串。 */
|
||||
get hint(): string {
|
||||
return typeof this.payload.hint === 'string' ? this.payload.hint : ''
|
||||
}
|
||||
|
||||
get unauthorized(): boolean {
|
||||
return this.status === 401
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await res.text()
|
||||
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {}
|
||||
if (!res.ok) throw new ApiError(res.status, payload)
|
||||
return payload as T
|
||||
}
|
||||
|
||||
export interface ConfigSnapshot {
|
||||
data: ConfigObject
|
||||
revision: string
|
||||
diagnostics: ConfigDiagnostic[]
|
||||
}
|
||||
|
||||
export interface ApplySuccess {
|
||||
ok: true
|
||||
snapshot: ConfigSnapshot
|
||||
reloaded: boolean
|
||||
reloadMessage: string
|
||||
}
|
||||
|
||||
export interface ProxyInfo {
|
||||
/** 出站的名字。/proxies 用键名标识,provider 里的线路则带在对象上 */
|
||||
name: string
|
||||
/** 协议类型(Vmess、Vless…)或策略组类型(Selector、URLTest…) */
|
||||
type: string
|
||||
/** 策略组当前选中的成员。不是策略组就没有这个字段 */
|
||||
now?: string
|
||||
/** 策略组的成员列表 */
|
||||
all?: string[]
|
||||
/** 延迟测试记录,最后一条是最近一次。delay 为 0 表示测失败 */
|
||||
history?: { delay: number }[]
|
||||
}
|
||||
|
||||
/** 最近一次延迟。没测过或测失败都返回 null。 */
|
||||
export function latestDelay(info: ProxyInfo | undefined): number | null {
|
||||
const last = info?.history?.at(-1)?.delay
|
||||
return last === undefined || last === 0 ? null : last
|
||||
}
|
||||
|
||||
export interface FieldSpec {
|
||||
key: string
|
||||
type: 'string' | 'number' | 'boolean' | 'string[]' | 'number[]' | 'object' | 'object[]'
|
||||
required: boolean
|
||||
default: unknown
|
||||
enum: (string | number)[] | null
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface ListenerFields {
|
||||
common: FieldSpec[]
|
||||
types: Record<string, { note: string; fields: FieldSpec[] }>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
readConfig: () => request<ConfigSnapshot>('GET', '/api/config'),
|
||||
|
||||
applyConfig: (data: ConfigObject, baseRevision: string) =>
|
||||
request<ApplySuccess>('PUT', '/api/config', { data, baseRevision }),
|
||||
|
||||
listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'),
|
||||
|
||||
restoreBackup: (name: string) =>
|
||||
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
|
||||
|
||||
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
||||
|
||||
/** 入站类型的字段表,从内核源码提取,用来驱动表单。 */
|
||||
listenerFields: () => request<ListenerFields>('GET', '/api/meta/listener-fields'),
|
||||
|
||||
/**
|
||||
* 所有出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。
|
||||
* 这些配置文件里都没有,只能问运行中的内核。
|
||||
*/
|
||||
proxies: () => request<{ proxies: Record<string, ProxyInfo> }>('GET', '/api/kernel/proxies'),
|
||||
|
||||
/**
|
||||
* 切换某个策略组当前选中哪个成员。
|
||||
* 这是运行时状态,不写配置文件,立刻生效——metacubexd 里点一条线路做的就是这件事。
|
||||
*/
|
||||
selectProxy: (group: string, member: string) =>
|
||||
request<{ ok: true }>('PUT', `/api/kernel/proxies/${encodeURIComponent(group)}`, {
|
||||
name: member,
|
||||
}),
|
||||
|
||||
/**
|
||||
* 测一条出站的延迟。
|
||||
*
|
||||
* 走哪个接口取决于它在不在内核的全局名字表里:策略组和自有线路在表里,用 /proxies;
|
||||
* 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。
|
||||
*/
|
||||
testLine: (name: string, provider?: string, timeout = 5000) => {
|
||||
const q = `url=${encodeURIComponent(TEST_URL)}&timeout=${String(timeout)}`
|
||||
const path = provider
|
||||
? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}`
|
||||
: `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}`
|
||||
return request<{ delay: number }>('GET', path)
|
||||
},
|
||||
|
||||
/**
|
||||
* 当前所有连接的快照。
|
||||
*
|
||||
* 这个接口没有流式版本(流式的是 /traffic、/logs 那几个),只能全量轮询。
|
||||
* 没有连接时 connections 字段是 null 而不是空数组。
|
||||
*/
|
||||
connections: () => request<ConnectionSnapshot>('GET', '/api/kernel/connections'),
|
||||
|
||||
/** 断开某一条连接。运行时动作,不写配置文件。 */
|
||||
closeConnection: (id: string) =>
|
||||
request<{ message?: string }>(
|
||||
'DELETE',
|
||||
`/api/kernel/connections/${encodeURIComponent(id)}`,
|
||||
),
|
||||
|
||||
/** 断开所有连接。 */
|
||||
closeAllConnections: () => request<{ message?: string }>('DELETE', '/api/kernel/connections'),
|
||||
|
||||
/**
|
||||
* 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。
|
||||
*
|
||||
* 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张
|
||||
* 全局名字表里,这是拿到它们类型和延迟的唯一途径。
|
||||
*/
|
||||
providers: () =>
|
||||
request<{ providers: Record<string, { vehicleType: string; proxies: ProxyInfo[] }> }>(
|
||||
'GET',
|
||||
'/api/kernel/providers/proxies',
|
||||
),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
<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 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()
|
||||
failure.value = ''
|
||||
} catch (error) {
|
||||
failure.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="failure" class="failure">
|
||||
<strong>{{ failure }}</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>
|
||||
Reference in New Issue
Block a user