判定按状态码白名单而不是只认 200:探测不带鉴权,OpenAI 回 401、Anthropic 回 405 才是正常应答,说明 TLS 握手、路由、目标服务全都正常。清单和白名单照 sub2api 的 代理质量检查抄来。 失败文案不照抄 Bun:本机端口没人监听时它给的是「Unable to connect. Is the computer able to access the url?」,把矛头指向目标网址,而真正的原因在本机。 对话框留在 InboundDetail 而不是提到 RouterPage——它不写配置文件也不改内核状态, 不需要走那条 commit 链路。firstPort 挪进 shared/inbounds.ts,两个页面都要用它了。
This commit is contained in:
@@ -8,6 +8,7 @@ import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/ind
|
||||
import type { ConnectionSnapshot } from '@shared/connections.ts'
|
||||
import type { ExitProbe, HostInfo } from '@shared/host-info.ts'
|
||||
import type { GeoFileStatus } from '@shared/geo.ts'
|
||||
import type { ReachResult } from '@shared/reachability.ts'
|
||||
import { DEFAULT_TEST_URL, testUrl } from './prefs'
|
||||
|
||||
const TOKEN_KEY = 'patchbay.token'
|
||||
@@ -194,6 +195,13 @@ export const api = {
|
||||
*/
|
||||
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 等。 */
|
||||
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 { firstPort, kernelPorts, modeLabel } from './dashboard.ts'
|
||||
import { kernelPorts, modeLabel } from './dashboard.ts'
|
||||
|
||||
describe('运行模式', () => {
|
||||
it('三个已知模式有中文名', () => {
|
||||
@@ -31,20 +31,3 @@ describe('代理端口', () => {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 入站声明的端口在探测公网 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」,全部只读。
|
||||
*/
|
||||
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 { ACTIVE_BYTES, ROUTE_WINDOW_MS, bucketRates, lastRate, windowBytes } from '@shared/telemetry.ts'
|
||||
import type { RouteSample } from '@shared/telemetry.ts'
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
totals,
|
||||
traffic,
|
||||
} from '../telemetry'
|
||||
import { firstPort, kernelPorts, modeLabel } from '../dashboard'
|
||||
import { kernelPorts, modeLabel } from '../dashboard'
|
||||
import { cssColor, themeTick } from '../theme'
|
||||
import EChart from '../components/EChart.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* 两半的反馈方式必须不一样。它们长得一样的时候,用户分不清哪个动作要等、哪个不用。
|
||||
*/
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { firstPort } from '@shared/inbounds.ts'
|
||||
import type { Inbound } from '@shared/inbounds.ts'
|
||||
import {
|
||||
autoGroupName,
|
||||
@@ -29,8 +30,10 @@ import { isChainedLine, kernel, linesFrom, membersOf, providers, runtime } from
|
||||
import { mergeMemberPreview } from '../member-preview'
|
||||
import type { MemberPreview } from '../member-preview'
|
||||
import OutboundCard from '../components/OutboundCard.vue'
|
||||
import ReachDialog from '../components/ReachDialog.vue'
|
||||
import SourcePicker from '../components/SourcePicker.vue'
|
||||
import IconEdit from '~icons/ri/edit-line'
|
||||
import IconReach from '~icons/ri/test-tube-line'
|
||||
import IconDelete from '~icons/ri/delete-bin-line'
|
||||
import IconUpgrade from '~icons/ri/arrow-up-circle-line'
|
||||
import IconRight from '~icons/ri/arrow-right-s-line'
|
||||
@@ -96,6 +99,24 @@ const subMatchAt = computed(() => firstMatchIndex(props.subRules))
|
||||
/** legacy 入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删,只能升级。 */
|
||||
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('')
|
||||
watch(
|
||||
() => props.panel.groups,
|
||||
@@ -460,6 +481,9 @@ const complete = computed(
|
||||
<span class="port mono">{{ inbound.port }}</span>
|
||||
<span class="name">{{ inbound.name }}</span>
|
||||
<div class="acts">
|
||||
<button v-if="reachPort !== null" type="button" @click="reachOpen = true">
|
||||
<IconReach /> 测试
|
||||
</button>
|
||||
<template v-if="!locked">
|
||||
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
|
||||
<button type="button" class="danger" @click="emit('remove')">
|
||||
@@ -802,6 +826,15 @@ const complete = computed(
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 放在 Transition 外面:切换入站时详情整块换掉,对话框不该跟着淡入淡出 -->
|
||||
<ReachDialog
|
||||
v-if="reachPort !== null"
|
||||
:open="reachOpen"
|
||||
:name="inbound.name"
|
||||
:port="reachPort"
|
||||
@close="reachOpen = false"
|
||||
/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user