Compare commits
1
Commits
7d2a3afff0
..
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d7304dd22 |
+36
-2
@@ -1,11 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* 主机信息与公网 IP 检测。
|
* 主机信息,以及经由某一路入站发出的探测。
|
||||||
*
|
*
|
||||||
* Patchbay 服务端和 mihomo 跑在同一台机器上,所以这两件事由它代答:
|
* Patchbay 服务端和 mihomo 跑在同一台机器上,所以这几件事由它代答:
|
||||||
* - 主机指标:mihomo 的管理接口只报自己的内存,CPU、主机内存、网关这些
|
* - 主机指标:mihomo 的管理接口只报自己的内存,CPU、主机内存、网关这些
|
||||||
* 从 os 模块和 /proc 里读(解析在 shared/host-info.ts,那边有测试)
|
* 从 os 模块和 /proc 里读(解析在 shared/host-info.ts,那边有测试)
|
||||||
* - 公网 IP 检测:浏览器没法「经由某个入站端口」发请求,服务端可以——把本机
|
* - 公网 IP 检测:浏览器没法「经由某个入站端口」发请求,服务端可以——把本机
|
||||||
* 对应端口设成代理去访问 ip.sb,返回的就是那一路在外面的地址
|
* 对应端口设成代理去访问 ip.sb,返回的就是那一路在外面的地址
|
||||||
|
* - 可访问性测试:同一条路子,换成访问几个常用 AI 服务,判定在 shared/reachability.ts
|
||||||
*/
|
*/
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
parseNameservers,
|
parseNameservers,
|
||||||
} from '../shared/host-info.ts'
|
} from '../shared/host-info.ts'
|
||||||
import type { CpuTimes, ExitProbe, HostInfo } from '../shared/host-info.ts'
|
import type { CpuTimes, ExitProbe, HostInfo } from '../shared/host-info.ts'
|
||||||
|
import { describeProbeFailure, judge } from '../shared/reachability.ts'
|
||||||
|
import type { ReachResult, ReachTarget } from '../shared/reachability.ts'
|
||||||
|
|
||||||
function cpuTimesNow(): CpuTimes {
|
function cpuTimesNow(): CpuTimes {
|
||||||
let busy = 0
|
let busy = 0
|
||||||
@@ -98,3 +101,34 @@ export async function probeExit(port: number): Promise<ExitProbe> {
|
|||||||
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单个目标的探测超时,毫秒。超时的说法由服务端生成,所以只有这里需要这个数。 */
|
||||||
|
const REACH_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 经由本机某个入站端口探测一个目标能不能访问。同 probeExit,只对说 HTTP 的入站有效。
|
||||||
|
*
|
||||||
|
* 不带任何鉴权头,判定全交给 shared/reachability.ts 的 judge()。正文只读前 8 KB:
|
||||||
|
* 判定只需要认拦截页开头的标记,而目标本身可能返回很大的 JSON。
|
||||||
|
*/
|
||||||
|
export async function probeReach(port: number, target: ReachTarget): Promise<ReachResult> {
|
||||||
|
const started = Date.now()
|
||||||
|
try {
|
||||||
|
const res = await fetch(target.url, {
|
||||||
|
proxy: `http://127.0.0.1:${String(port)}`,
|
||||||
|
headers: { Accept: 'application/json,text/html,*/*' },
|
||||||
|
signal: AbortSignal.timeout(REACH_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
const body = (await res.text()).slice(0, 8192)
|
||||||
|
const { verdict, message } = judge(target, res.status, res.headers, body)
|
||||||
|
return { target: target.id, verdict, status: res.status, ms: Date.now() - started, message }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
target: target.id,
|
||||||
|
verdict: 'unreachable',
|
||||||
|
status: null,
|
||||||
|
ms: Date.now() - started,
|
||||||
|
message: describeProbeFailure(port, REACH_TIMEOUT_MS, error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+16
-1
@@ -24,7 +24,8 @@ import {
|
|||||||
undoConfig,
|
undoConfig,
|
||||||
} from './config-store.ts'
|
} from './config-store.ts'
|
||||||
import { geoStatus, syncGeoFile } from './geo.ts'
|
import { geoStatus, syncGeoFile } from './geo.ts'
|
||||||
import { hostInfo, probeExit } from './host.ts'
|
import { hostInfo, probeExit, probeReach } from './host.ts'
|
||||||
|
import { findTarget } from '../shared/reachability.ts'
|
||||||
import {
|
import {
|
||||||
kernelVersion,
|
kernelVersion,
|
||||||
proxyGet,
|
proxyGet,
|
||||||
@@ -283,6 +284,20 @@ function makeRoute(env: Env, token: string) {
|
|||||||
return json(await probeExit(port))
|
return json(await probeExit(port))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 经由某个入站端口探测一个目标能不能访问。一次一个目标,前端并发发起,
|
||||||
|
// 这样每一行各自落定,不必等最慢的那个才整表出现
|
||||||
|
if (path === '/api/host/reach' && req.method === 'GET') {
|
||||||
|
const port = Number(url.searchParams.get('port'))
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
return json({ message: 'port 参数必须是 1-65535 的端口号' }, 400)
|
||||||
|
}
|
||||||
|
const target = findTarget(url.searchParams.get('target') ?? '')
|
||||||
|
if (!target) {
|
||||||
|
return json({ message: `没有名为 ${url.searchParams.get('target') ?? ''} 的探测目标` }, 400)
|
||||||
|
}
|
||||||
|
return json(await probeReach(port, target))
|
||||||
|
}
|
||||||
|
|
||||||
// ── 内核 ────────────────────────────────────────────
|
// ── 内核 ────────────────────────────────────────────
|
||||||
if (path === '/api/kernel' && req.method === 'GET') {
|
if (path === '/api/kernel' && req.method === 'GET') {
|
||||||
return json(await kernelVersion(env))
|
return json(await kernelVersion(env))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
collectInbounds,
|
collectInbounds,
|
||||||
collectOutboundNames,
|
collectOutboundNames,
|
||||||
fallbackTarget,
|
fallbackTarget,
|
||||||
|
firstPort,
|
||||||
isUnsetOutbound,
|
isUnsetOutbound,
|
||||||
outboundLabel,
|
outboundLabel,
|
||||||
OUTBOUND_PRESETS,
|
OUTBOUND_PRESETS,
|
||||||
@@ -164,3 +165,20 @@ describe('出站预设表是读取和显示的唯一约定', () => {
|
|||||||
expect(isUnsetOutbound({ kind: 'named', name: 'AUTO' })).toBe(false)
|
expect(isUnsetOutbound({ kind: 'named', name: 'AUTO' })).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('经由入站发请求时连哪个端口', () => {
|
||||||
|
it('单个端口直接用', () => {
|
||||||
|
expect(firstPort('7890')).toBe(7890)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多端口和区间取第一个数', () => {
|
||||||
|
expect(firstPort('200,302')).toBe(200)
|
||||||
|
expect(firstPort('401-429')).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('解析不出或越界返回 null', () => {
|
||||||
|
expect(firstPort('')).toBeNull()
|
||||||
|
expect(firstPort('abc')).toBeNull()
|
||||||
|
expect(firstPort('70000')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -185,6 +185,17 @@ export function inboundStatKey(inbound: Inbound): string {
|
|||||||
* `MATCH` 匹配一切,所以第一条 MATCH 之后的规则都到不了,它就是兜底出站。
|
* `MATCH` 匹配一切,所以第一条 MATCH 之后的规则都到不了,它就是兜底出站。
|
||||||
* 没有 MATCH 时内核会直连,返回 null。
|
* 没有 MATCH 时内核会直连,返回 null。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 服务端要经由这一路入站发请求时,连本机的哪个端口。
|
||||||
|
* 命名入站的端口可以写成 "200,302" 或 "401-429",取第一个数字;解析不出返回 null。
|
||||||
|
*/
|
||||||
|
export function firstPort(port: string): number | null {
|
||||||
|
const match = /\d+/.exec(port)
|
||||||
|
if (!match) return null
|
||||||
|
const value = Number(match[0])
|
||||||
|
return value >= 1 && value <= 65535 ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
export function fallbackTarget(data: ConfigObject): string | null {
|
export function fallbackTarget(data: ConfigObject): string | null {
|
||||||
const rules = Array.isArray(data.rules) ? data.rules : []
|
const rules = Array.isArray(data.rules) ? data.rules : []
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
describeProbeFailure,
|
||||||
|
findTarget,
|
||||||
|
isChallenge,
|
||||||
|
judge,
|
||||||
|
rayId,
|
||||||
|
REACH_TARGETS,
|
||||||
|
} from './reachability.ts'
|
||||||
|
|
||||||
|
const openai = REACH_TARGETS[0]!
|
||||||
|
const gemini = REACH_TARGETS[2]!
|
||||||
|
|
||||||
|
const headers = (init: Record<string, string> = {}) => new Headers(init)
|
||||||
|
|
||||||
|
describe('REACH_TARGETS', () => {
|
||||||
|
test('标识不重名,否则接口按 id 取目标会取错', () => {
|
||||||
|
const ids = REACH_TARGETS.map((t) => t.id)
|
||||||
|
expect(new Set(ids).size).toBe(ids.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('findTarget 认不出来的标识返回 null', () => {
|
||||||
|
expect(findTarget('openai')?.label).toBe('OpenAI')
|
||||||
|
expect(findTarget('nonesuch')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('judge', () => {
|
||||||
|
test('无鉴权探测拿到 401 算可达,并说明这是预期结果', () => {
|
||||||
|
const { verdict, message } = judge(openai, 401, headers(), '')
|
||||||
|
expect(verdict).toBe('reachable')
|
||||||
|
expect(message).toBe('HTTP 401,目标可达')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('2xx 不需要解释,只报状态码', () => {
|
||||||
|
expect(judge(gemini, 200, headers(), '{}')).toEqual({
|
||||||
|
verdict: 'reachable',
|
||||||
|
message: 'HTTP 200',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('白名单之外的状态码算不通', () => {
|
||||||
|
const { verdict, message } = judge(gemini, 403, headers(), '')
|
||||||
|
expect(verdict).toBe('unreachable')
|
||||||
|
expect(message).toBe('HTTP 403,不是预期的状态码')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('429 单独说是限流,不和连不上混在一起', () => {
|
||||||
|
expect(judge(openai, 429, headers(), '').verdict).toBe('throttled')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Cloudflare 拦截优先于状态码白名单', () => {
|
||||||
|
// 429 在 openai 的白名单外,但结论要是 challenge 而不是 throttled:
|
||||||
|
// 前者要换出口 IP,后者只要等
|
||||||
|
const { verdict, message } = judge(
|
||||||
|
openai,
|
||||||
|
429,
|
||||||
|
headers({ 'cf-mitigated': 'challenge', 'cf-ray': '8f0a1b2c3d4e5f60-LAX' }),
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
expect(verdict).toBe('challenge')
|
||||||
|
expect(message).toContain('8f0a1b2c3d4e5f60-LAX')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isChallenge', () => {
|
||||||
|
test('只有 403 和 429 才可能是拦截页', () => {
|
||||||
|
const body = '<html>window._cf_chl_opt</html>'
|
||||||
|
expect(isChallenge(403, headers(), body)).toBe(true)
|
||||||
|
expect(isChallenge(429, headers(), body)).toBe(true)
|
||||||
|
expect(isChallenge(200, headers(), body)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('没有 cf-mitigated 头时认正文里的标记', () => {
|
||||||
|
expect(isChallenge(403, headers(), '<title>Just a Moment...</title>')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('普通的 403 不是拦截页', () => {
|
||||||
|
expect(isChallenge(403, headers(), '{"error":"forbidden"}')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('正文只看前 4 KB,标记在那之后不算', () => {
|
||||||
|
expect(isChallenge(403, headers(), 'x'.repeat(5000) + 'just a moment')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rayId', () => {
|
||||||
|
test('优先取响应头', () => {
|
||||||
|
expect(rayId(headers({ 'cf-ray': ' abc123-LAX ' }), 'cf-ray: zzz')).toBe('abc123-LAX')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('没有响应头时从正文里找', () => {
|
||||||
|
expect(rayId(headers(), 'Cloudflare Ray ID: <code>cf-ray: abc123</code>')).toBe('abc123')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('都没有就是空串', () => {
|
||||||
|
expect(rayId(headers(), 'nothing here')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('describeProbeFailure', () => {
|
||||||
|
test('超时说等了多久', () => {
|
||||||
|
const error = new Error('timed out')
|
||||||
|
error.name = 'TimeoutError'
|
||||||
|
expect(describeProbeFailure(7890, 10_000, error)).toBe('10 秒内没有响应')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('本机端口没人监听时指向内核,不照抄 Bun 那句指向目标网址的话', () => {
|
||||||
|
const error = Object.assign(new Error('Unable to connect. Is the computer able to access the url?'), {
|
||||||
|
code: 'ConnectionRefused',
|
||||||
|
})
|
||||||
|
expect(describeProbeFailure(7890, 10_000, error)).toBe('本机 7890 端口没有响应,内核可能没在运行')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('其余原因照原文给出,不猜', () => {
|
||||||
|
expect(describeProbeFailure(7890, 10_000, new Error('TLS handshake failed'))).toBe(
|
||||||
|
'连接失败:TLS handshake failed',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* 可访问性测试:一路入站到几个常用 AI 服务通不通。
|
||||||
|
*
|
||||||
|
* 判定方式来自 sub2api 的代理质量检查(backend/internal/service/admin_proxy.go:337):
|
||||||
|
* 发一个**不带鉴权**的 GET,按状态码白名单判可达。这些接口没有 key 一定不返回 200,
|
||||||
|
* 但只要能返回 401/405 这类业务错误,就说明 TLS 握手、路由、目标服务全都正常——
|
||||||
|
* 这正是「这一路能不能用」要问的。用 200 当唯一标准反而永远测不通。
|
||||||
|
*
|
||||||
|
* 判定逻辑放在 shared/ 而不是 server/,是因为「什么状态码算通」是这个功能的全部内容,
|
||||||
|
* 它得能被 bun test 覆盖;server/ 那边只剩下发请求。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 一个探测目标。 */
|
||||||
|
export interface ReachTarget {
|
||||||
|
/** 接口参数里用的标识 */
|
||||||
|
id: string
|
||||||
|
/** 界面上显示的服务名 */
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
/** 这些状态码都算目标可达 */
|
||||||
|
reachableStatuses: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预设目标清单,照抄 sub2api 的四家(admin_service.go:600)。
|
||||||
|
*
|
||||||
|
* Anthropic 的白名单最宽,是因为 /v1/messages 只接受 POST,用 GET 探它时不同的
|
||||||
|
* 边缘节点会回 400/404/405 中的任意一个,这几种都只说明请求到达了对面。
|
||||||
|
*/
|
||||||
|
export const REACH_TARGETS: ReachTarget[] = [
|
||||||
|
{
|
||||||
|
id: 'openai',
|
||||||
|
label: 'OpenAI',
|
||||||
|
url: 'https://api.openai.com/v1/models',
|
||||||
|
reachableStatuses: [200, 401],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'anthropic',
|
||||||
|
label: 'Anthropic',
|
||||||
|
url: 'https://api.anthropic.com/v1/messages',
|
||||||
|
reachableStatuses: [200, 400, 401, 404, 405],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini',
|
||||||
|
label: 'Gemini',
|
||||||
|
url: 'https://generativelanguage.googleapis.com/$discovery/rest?version=v1beta',
|
||||||
|
reachableStatuses: [200],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grok',
|
||||||
|
label: 'Grok',
|
||||||
|
url: 'https://api.x.ai/v1/models',
|
||||||
|
reachableStatuses: [200, 401],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function findTarget(id: string): ReachTarget | null {
|
||||||
|
return REACH_TARGETS.find((t) => t.id === id) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次探测的结论。
|
||||||
|
* reachable 通了
|
||||||
|
* challenge 网络通,但出口 IP 被 Cloudflare 拦下要人机验证——这一路发得出请求,用不了服务
|
||||||
|
* throttled 对面回了 429,通但有频控
|
||||||
|
* unreachable 没通,或者回了预期之外的状态码
|
||||||
|
*/
|
||||||
|
export type ReachVerdict = 'reachable' | 'challenge' | 'throttled' | 'unreachable'
|
||||||
|
|
||||||
|
export interface ReachResult {
|
||||||
|
target: string
|
||||||
|
verdict: ReachVerdict
|
||||||
|
/** 没拿到响应时(连接失败、超时)为 null */
|
||||||
|
status: number | null
|
||||||
|
/** 整个来回的毫秒数 */
|
||||||
|
ms: number
|
||||||
|
/** 说明这个结论怎么来的,直接显示给用户 */
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cloudflare 拦截页面里必然出现的标记之一。摘自 sub2api util/httputil/httputil.go:15。 */
|
||||||
|
const CHALLENGE_MARKERS = [
|
||||||
|
'window._cf_chl_opt',
|
||||||
|
'just a moment',
|
||||||
|
'enable javascript and cookies to continue',
|
||||||
|
'__cf_chl_',
|
||||||
|
'challenge-platform',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个响应是不是 Cloudflare 的人机验证。
|
||||||
|
*
|
||||||
|
* 只有 403 和 429 才可能是。cf-mitigated 头是 Cloudflare 自己给的明示,没有这个头时
|
||||||
|
* 退回去认拦截页面的正文标记——正文只看前 4 KB,验证页的标记都在最前面。
|
||||||
|
*/
|
||||||
|
export function isChallenge(status: number, headers: Headers, body: string): boolean {
|
||||||
|
if (status !== 403 && status !== 429) return false
|
||||||
|
if (headers.get('cf-mitigated')?.trim().toLowerCase() === 'challenge') return true
|
||||||
|
|
||||||
|
const preview = body.slice(0, 4096).toLowerCase()
|
||||||
|
return CHALLENGE_MARKERS.some((marker) => preview.includes(marker))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cloudflare 的请求编号。报障时对面要这个,所以命中拦截要把它带出来。 */
|
||||||
|
export function rayId(headers: Headers, body: string): string {
|
||||||
|
const header = headers.get('cf-ray')?.trim()
|
||||||
|
if (header) return header
|
||||||
|
return /cf-ray[:\s=]+([a-z0-9-]+)/i.exec(body.slice(0, 8192))?.[1] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把一次拿到了响应的探测判成结论。连接根本没建立时不走这里,见 server/host.ts。 */
|
||||||
|
export function judge(
|
||||||
|
target: ReachTarget,
|
||||||
|
status: number,
|
||||||
|
headers: Headers,
|
||||||
|
body: string,
|
||||||
|
): { verdict: ReachVerdict; message: string } {
|
||||||
|
if (isChallenge(status, headers, body)) {
|
||||||
|
const ray = rayId(headers, body)
|
||||||
|
return {
|
||||||
|
verdict: 'challenge',
|
||||||
|
message: `出口 IP 被 Cloudflare 拦下要人机验证${ray ? `(cf-ray ${ray})` : ''}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.reachableStatuses.includes(status)) {
|
||||||
|
return {
|
||||||
|
verdict: 'reachable',
|
||||||
|
// 白名单里的非 2xx 是无鉴权探测的预期结果,不解释一句会被当成失败
|
||||||
|
message: status >= 200 && status < 300 ? `HTTP ${status}` : `HTTP ${status},目标可达`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 429) return { verdict: 'throttled', message: 'HTTP 429,目标在限流' }
|
||||||
|
|
||||||
|
return { verdict: 'unreachable', message: `HTTP ${status},不是预期的状态码` }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把「连响应都没拿到」翻译成一句说明原因的话。
|
||||||
|
*
|
||||||
|
* 探测走的是本机的入站端口,所以连接失败有两种完全不同的来源:端口上根本没人监听
|
||||||
|
* (内核没运行),和端口通了但代理那一跳走不出去。Bun 对前者给的是
|
||||||
|
* 「Unable to connect. Is the computer able to access the url?」——这句话把矛头指向了
|
||||||
|
* 目标网址,而真正的原因在本机,照抄出来只会让人去查错方向。
|
||||||
|
*/
|
||||||
|
export function describeProbeFailure(port: number, timeoutMs: number, error: unknown): string {
|
||||||
|
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||||
|
return `${String(timeoutMs / 1000)} 秒内没有响应`
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = error instanceof Error ? (error as { code?: unknown }).code : undefined
|
||||||
|
if (code === 'ConnectionRefused') {
|
||||||
|
return `本机 ${String(port)} 端口没有响应,内核可能没在运行`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `连接失败:${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/ind
|
|||||||
import type { ConnectionSnapshot } from '@shared/connections.ts'
|
import type { ConnectionSnapshot } from '@shared/connections.ts'
|
||||||
import type { ExitProbe, HostInfo } from '@shared/host-info.ts'
|
import type { ExitProbe, HostInfo } from '@shared/host-info.ts'
|
||||||
import type { GeoFileStatus } from '@shared/geo.ts'
|
import type { GeoFileStatus } from '@shared/geo.ts'
|
||||||
|
import type { ReachResult } from '@shared/reachability.ts'
|
||||||
import { DEFAULT_TEST_URL, testUrl } from './prefs'
|
import { DEFAULT_TEST_URL, testUrl } from './prefs'
|
||||||
|
|
||||||
const TOKEN_KEY = 'patchbay.token'
|
const TOKEN_KEY = 'patchbay.token'
|
||||||
@@ -194,6 +195,13 @@ export const api = {
|
|||||||
*/
|
*/
|
||||||
probeExit: (port: number) => request<ExitProbe>('GET', `/api/host/exit?port=${String(port)}`),
|
probeExit: (port: number) => request<ExitProbe>('GET', `/api/host/exit?port=${String(port)}`),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 经由某个入站端口探测一个目标能不能访问。一次一个目标,调用方并发发起。
|
||||||
|
* 同样会发起真实的外网请求,只在用户点了测试时调。
|
||||||
|
*/
|
||||||
|
probeReach: (port: number, target: string) =>
|
||||||
|
request<ReachResult>('GET', `/api/host/reach?port=${String(port)}&target=${target}`),
|
||||||
|
|
||||||
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
|
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
|
||||||
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
|
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 可访问性测试:这一路入站到几个常用 AI 服务通不通。
|
||||||
|
*
|
||||||
|
* 每个目标一个请求、并发发起,所以每一行各自落定,不用等最慢的那个才整表出现。
|
||||||
|
* 判定不在这里——「什么状态码算通」在 shared/reachability.ts,这里只管显示。
|
||||||
|
*
|
||||||
|
* 这是纯读的运行时动作:不写配置文件、不改内核状态,所以打开就直接开测,不设确认。
|
||||||
|
*/
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { REACH_TARGETS } from '@shared/reachability.ts'
|
||||||
|
import type { ReachResult, ReachVerdict } from '@shared/reachability.ts'
|
||||||
|
import { api } from '../api'
|
||||||
|
import IconClose from '~icons/ri/close-line'
|
||||||
|
import IconRefresh from '~icons/ri/refresh-line'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
/** 经由哪一路入站测。名字只用来显示,端口才是真正走的那个口 */
|
||||||
|
name: string
|
||||||
|
port: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
type Row = { state: 'testing' } | { state: 'done'; result: ReachResult }
|
||||||
|
|
||||||
|
const rows = ref<Record<string, Row>>({})
|
||||||
|
const running = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第几轮测试。重测和关掉重开都会让它加一,上一轮迟到的响应据此丢弃——
|
||||||
|
* 不这么做的话,换了一路入站再打开,上一路的结果会落到新的表里。
|
||||||
|
*/
|
||||||
|
let round = 0
|
||||||
|
|
||||||
|
async function runAll() {
|
||||||
|
if (props.port <= 0) return
|
||||||
|
round += 1
|
||||||
|
const mine = round
|
||||||
|
const port = props.port
|
||||||
|
running.value = true
|
||||||
|
rows.value = Object.fromEntries(REACH_TARGETS.map((t) => [t.id, { state: 'testing' } as Row]))
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
REACH_TARGETS.map(async (target) => {
|
||||||
|
const result = await api.probeReach(port, target.id).catch(
|
||||||
|
(error: unknown): ReachResult => ({
|
||||||
|
target: target.id,
|
||||||
|
verdict: 'unreachable',
|
||||||
|
status: null,
|
||||||
|
ms: 0,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (round !== mine) return
|
||||||
|
rows.value = { ...rows.value, [target.id]: { state: 'done', result } }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (round === mine) running.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialog = ref<HTMLDialogElement | null>(null)
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
dialog.value?.showModal()
|
||||||
|
void runAll()
|
||||||
|
} else {
|
||||||
|
dialog.value?.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 点在遮罩上就关闭。内容区的点击 target 是 .body 里的元素,到不了 dialog 本身。 */
|
||||||
|
function onBackdrop(event: MouseEvent) {
|
||||||
|
if (event.target === dialog.value) emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERDICT_LABEL: Record<ReachVerdict, string> = {
|
||||||
|
reachable: '通过',
|
||||||
|
throttled: '限流',
|
||||||
|
challenge: '人机验证',
|
||||||
|
unreachable: '失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某个目标测完了的结果,还在测就是 null。模板里的分支只认这一个函数。 */
|
||||||
|
function resultOf(id: string): ReachResult | null {
|
||||||
|
const row = rows.value[id]
|
||||||
|
return row?.state === 'done' ? row.result : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 目标地址显示成主机名加路径,完整 URL 太长会把这一行撑开。 */
|
||||||
|
function shortUrl(url: string): string {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return parsed.host + parsed.pathname
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<dialog
|
||||||
|
ref="dialog"
|
||||||
|
class="dlg"
|
||||||
|
@click="onBackdrop"
|
||||||
|
@close="emit('close')"
|
||||||
|
@cancel.prevent="emit('close')"
|
||||||
|
>
|
||||||
|
<div class="body">
|
||||||
|
<header class="head">
|
||||||
|
<h2>
|
||||||
|
可访问性测试
|
||||||
|
<span class="via mono">{{ name }} : {{ port }}</span>
|
||||||
|
</h2>
|
||||||
|
<button type="button" class="icon" title="关闭" @click="emit('close')">
|
||||||
|
<IconClose />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul class="rows">
|
||||||
|
<li v-for="target in REACH_TARGETS" :key="target.id" class="row">
|
||||||
|
<div class="who">
|
||||||
|
<span class="label">{{ target.label }}</span>
|
||||||
|
<span class="url mono">{{ shortUrl(target.url) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="resultOf(target.id)">
|
||||||
|
<span :class="['verdict', resultOf(target.id)!.verdict]">
|
||||||
|
{{ VERDICT_LABEL[resultOf(target.id)!.verdict] }}
|
||||||
|
</span>
|
||||||
|
<span class="ms mono">{{ resultOf(target.id)!.ms }} ms</span>
|
||||||
|
<!-- 排在毫秒之后:它跨整行,写在前面会把毫秒挤到下一行去 -->
|
||||||
|
<span class="msg">{{ resultOf(target.id)!.message }}</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="waiting">测试中</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<footer class="acts">
|
||||||
|
<button type="button" class="btn" :disabled="running" @click="void runAll()">
|
||||||
|
<IconRefresh /> 重新测试
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dlg {
|
||||||
|
width: min(560px, calc(100vw - 2 * var(--s-5)));
|
||||||
|
/* 内边距放在 .body 上:dialog 自己的内边距区域点了会被当成遮罩点击 */
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: var(--c-surface);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlg::backdrop {
|
||||||
|
background: rgb(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: var(--s-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-3);
|
||||||
|
margin-bottom: var(--s-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--s-3);
|
||||||
|
font-size: var(--t-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.via {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: var(--s-1);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.icon:hover {
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
/* 服务名那列吃掉剩余宽度,结论和毫秒各自贴右:四行对齐才看得出哪个不一样 */
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1) var(--s-3);
|
||||||
|
padding: var(--s-3) 0;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 每一行是各自的 grid,列宽不共享,所以结论和毫秒都定死一个下限,
|
||||||
|
否则「通过」和「人机验证」不一样长,四行的徽章就对不齐 */
|
||||||
|
.verdict {
|
||||||
|
min-width: 4.5em;
|
||||||
|
padding: 2px var(--s-2);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.verdict.reachable {
|
||||||
|
background: var(--c-ok-soft);
|
||||||
|
color: var(--c-ok);
|
||||||
|
}
|
||||||
|
.verdict.throttled,
|
||||||
|
.verdict.challenge {
|
||||||
|
background: var(--c-warn-soft);
|
||||||
|
color: var(--c-warn);
|
||||||
|
}
|
||||||
|
.verdict.unreachable {
|
||||||
|
background: var(--c-danger-soft);
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms {
|
||||||
|
min-width: 4.5em;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 结论那一句独占第二行,长了也不会把状态和毫秒挤出去 */
|
||||||
|
.msg {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.waiting {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acts {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--s-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s-1);
|
||||||
|
padding: var(--s-2) var(--s-4);
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--c-surface);
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--c-accent);
|
||||||
|
color: var(--c-accent-text);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+1
-18
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'bun:test'
|
import { describe, expect, it } from 'bun:test'
|
||||||
import { firstPort, kernelPorts, modeLabel } from './dashboard.ts'
|
import { kernelPorts, modeLabel } from './dashboard.ts'
|
||||||
|
|
||||||
describe('运行模式', () => {
|
describe('运行模式', () => {
|
||||||
it('三个已知模式有中文名', () => {
|
it('三个已知模式有中文名', () => {
|
||||||
@@ -31,20 +31,3 @@ describe('代理端口', () => {
|
|||||||
expect(kernelPorts({ port: '7890' })).toEqual([{ name: 'port', port: 7890 }])
|
expect(kernelPorts({ port: '7890' })).toEqual([{ name: 'port', port: 7890 }])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('探测端口', () => {
|
|
||||||
it('单个端口直接用', () => {
|
|
||||||
expect(firstPort('7890')).toBe(7890)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('多端口和区间取第一个数', () => {
|
|
||||||
expect(firstPort('200,302')).toBe(200)
|
|
||||||
expect(firstPort('401-429')).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('解析不出或越界返回 null', () => {
|
|
||||||
expect(firstPort('')).toBeNull()
|
|
||||||
expect(firstPort('abc')).toBeNull()
|
|
||||||
expect(firstPort('70000')).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -38,14 +38,3 @@ export function kernelPorts(configs: Record<string, unknown>): KernelPort[] {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 入站声明的端口在探测公网 IP 时用哪个数。
|
|
||||||
* 新式入站的端口可以写 "200,302" 或 "401-429",探测取第一个数字;解析不出返回 null。
|
|
||||||
*/
|
|
||||||
export function firstPort(port: string): number | null {
|
|
||||||
const match = /\d+/.exec(port)
|
|
||||||
if (!match) return null
|
|
||||||
const value = Number(match[0])
|
|
||||||
return value >= 1 && value <= 65535 ? value : null
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* 除了「检测公网 IP」,全部只读。
|
* 除了「检测公网 IP」,全部只读。
|
||||||
*/
|
*/
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { collectInbounds, inboundStatKey } from '@shared/inbounds.ts'
|
import { collectInbounds, firstPort, inboundStatKey } from '@shared/inbounds.ts'
|
||||||
import { flagEmoji, formatBytes, formatUptime } from '@shared/format.ts'
|
import { flagEmoji, formatBytes, formatUptime } from '@shared/format.ts'
|
||||||
import { ACTIVE_BYTES, ROUTE_WINDOW_MS, bucketRates, lastRate, windowBytes } from '@shared/telemetry.ts'
|
import { ACTIVE_BYTES, ROUTE_WINDOW_MS, bucketRates, lastRate, windowBytes } from '@shared/telemetry.ts'
|
||||||
import type { RouteSample } from '@shared/telemetry.ts'
|
import type { RouteSample } from '@shared/telemetry.ts'
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
totals,
|
totals,
|
||||||
traffic,
|
traffic,
|
||||||
} from '../telemetry'
|
} from '../telemetry'
|
||||||
import { firstPort, kernelPorts, modeLabel } from '../dashboard'
|
import { kernelPorts, modeLabel } from '../dashboard'
|
||||||
import { cssColor, themeTick } from '../theme'
|
import { cssColor, themeTick } from '../theme'
|
||||||
import EChart from '../components/EChart.vue'
|
import EChart from '../components/EChart.vue'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
* 两半的反馈方式必须不一样。它们长得一样的时候,用户分不清哪个动作要等、哪个不用。
|
* 两半的反馈方式必须不一样。它们长得一样的时候,用户分不清哪个动作要等、哪个不用。
|
||||||
*/
|
*/
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import { firstPort } from '@shared/inbounds.ts'
|
||||||
import type { Inbound } from '@shared/inbounds.ts'
|
import type { Inbound } from '@shared/inbounds.ts'
|
||||||
import {
|
import {
|
||||||
autoGroupName,
|
autoGroupName,
|
||||||
@@ -29,8 +30,10 @@ import { isChainedLine, kernel, linesFrom, membersOf, providers, runtime } from
|
|||||||
import { mergeMemberPreview } from '../member-preview'
|
import { mergeMemberPreview } from '../member-preview'
|
||||||
import type { MemberPreview } from '../member-preview'
|
import type { MemberPreview } from '../member-preview'
|
||||||
import OutboundCard from '../components/OutboundCard.vue'
|
import OutboundCard from '../components/OutboundCard.vue'
|
||||||
|
import ReachDialog from '../components/ReachDialog.vue'
|
||||||
import SourcePicker from '../components/SourcePicker.vue'
|
import SourcePicker from '../components/SourcePicker.vue'
|
||||||
import IconEdit from '~icons/ri/edit-line'
|
import IconEdit from '~icons/ri/edit-line'
|
||||||
|
import IconReach from '~icons/ri/test-tube-line'
|
||||||
import IconDelete from '~icons/ri/delete-bin-line'
|
import IconDelete from '~icons/ri/delete-bin-line'
|
||||||
import IconUpgrade from '~icons/ri/arrow-up-circle-line'
|
import IconUpgrade from '~icons/ri/arrow-up-circle-line'
|
||||||
import IconRight from '~icons/ri/arrow-right-s-line'
|
import IconRight from '~icons/ri/arrow-right-s-line'
|
||||||
@@ -96,6 +99,24 @@ const subMatchAt = computed(() => firstMatchIndex(props.subRules))
|
|||||||
/** legacy 入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删,只能升级。 */
|
/** legacy 入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删,只能升级。 */
|
||||||
const locked = computed(() => props.inbound.isDefault)
|
const locked = computed(() => props.inbound.isDefault)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可访问性测试走本机的哪个端口,测不了就是 null。
|
||||||
|
*
|
||||||
|
* 服务端拿这个端口当 HTTP 代理去访问目标,所以只有说 HTTP 的入站可以测;
|
||||||
|
* 纯 socks 端口这条路走不通(同公网 IP 检测,server/host.ts)。
|
||||||
|
*/
|
||||||
|
const reachPort = computed(() =>
|
||||||
|
props.inbound.type === 'http' || props.inbound.type === 'mixed'
|
||||||
|
? firstPort(props.inbound.port)
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对话框留在这里而不是像别的对话框那样提到 RouterPage:它不写配置文件也不改内核状态,
|
||||||
|
* 不需要经过父组件那条 commit 链路,提上去只会多出一组只为传端口而存在的 props。
|
||||||
|
*/
|
||||||
|
const reachOpen = ref(false)
|
||||||
|
|
||||||
const activeTab = ref('')
|
const activeTab = ref('')
|
||||||
watch(
|
watch(
|
||||||
() => props.panel.groups,
|
() => props.panel.groups,
|
||||||
@@ -460,6 +481,9 @@ const complete = computed(
|
|||||||
<span class="port mono">{{ inbound.port }}</span>
|
<span class="port mono">{{ inbound.port }}</span>
|
||||||
<span class="name">{{ inbound.name }}</span>
|
<span class="name">{{ inbound.name }}</span>
|
||||||
<div class="acts">
|
<div class="acts">
|
||||||
|
<button v-if="reachPort !== null" type="button" @click="reachOpen = true">
|
||||||
|
<IconReach /> 测试
|
||||||
|
</button>
|
||||||
<template v-if="!locked">
|
<template v-if="!locked">
|
||||||
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
|
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
|
||||||
<button type="button" class="danger" @click="emit('remove')">
|
<button type="button" class="danger" @click="emit('remove')">
|
||||||
@@ -802,6 +826,15 @@ const complete = computed(
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<!-- 放在 Transition 外面:切换入站时详情整块换掉,对话框不该跟着淡入淡出 -->
|
||||||
|
<ReachDialog
|
||||||
|
v-if="reachPort !== null"
|
||||||
|
:open="reachOpen"
|
||||||
|
:name="inbound.name"
|
||||||
|
:port="reachPort"
|
||||||
|
@close="reachOpen = false"
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user