去掉转换入口,加测延迟,改掉刷新时丢状态的毛病
最要紧的是第三条:之前每次改完都会先切到「读取中」,页面被拆掉重建, 选中的那一路入站随之丢失,改个设置还得重新找回原来的位置。现在只有首次加载显示占位, 之后都是原地更新。切换策略组还做成了乐观更新——那一步不写配置文件, 没必要让点击等一个往返,真失败了随后的刷新会纠正回来。 测延迟按钮让内核并发测整个策略组。测不通是关于线路的结论而不是 Patchbay 出了问题, 所以全部超时用中性提示,不弹红框。
This commit is contained in:
+7
-1
@@ -16,6 +16,12 @@ import { tokenFromRequest, tokenMatches } from './auth.ts'
|
|||||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
import type { Env } from './env.ts'
|
import type { Env } from './env.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 延迟测试。名字里可能有斜杠和中文,所以按前缀匹配而不是列进上面那个精确白名单。
|
||||||
|
* 它是 GET 但会真的发起网络连接,属于「读」里比较重的一类,单独列出来提醒这一点。
|
||||||
|
*/
|
||||||
|
const DELAY_PATH = /^\/(proxies|group)\/.+\/delay$/
|
||||||
|
|
||||||
/** 允许透传的管理接口路径。白名单而不是黑名单——多开一个口子要有意为之。 */
|
/** 允许透传的管理接口路径。白名单而不是黑名单——多开一个口子要有意为之。 */
|
||||||
const READABLE_KERNEL_PATHS = new Set([
|
const READABLE_KERNEL_PATHS = new Set([
|
||||||
'/version',
|
'/version',
|
||||||
@@ -118,7 +124,7 @@ export function createRouter(env: Env, token: string) {
|
|||||||
|
|
||||||
if (path.startsWith('/api/kernel/') && req.method === 'GET') {
|
if (path.startsWith('/api/kernel/') && req.method === 'GET') {
|
||||||
const target = path.slice('/api/kernel'.length)
|
const target = path.slice('/api/kernel'.length)
|
||||||
if (!READABLE_KERNEL_PATHS.has(target)) {
|
if (!READABLE_KERNEL_PATHS.has(target) && !DELAY_PATH.test(target)) {
|
||||||
return json({ error: `不允许透传这个路径:${target}` }, 403)
|
return json({ error: `不允许透传这个路径:${target}` }, 403)
|
||||||
}
|
}
|
||||||
const upstream = await proxyGet(env, target + url.search)
|
const upstream = await proxyGet(env, target + url.search)
|
||||||
|
|||||||
+25
-4
@@ -15,11 +15,19 @@ const providerLines = ref<string[]>([])
|
|||||||
const runtime = ref<Record<string, ProxyInfo>>({})
|
const runtime = ref<Record<string, ProxyInfo>>({})
|
||||||
const needsToken = ref(!getToken())
|
const needsToken = ref(!getToken())
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const loading = ref(false)
|
const booted = ref(false)
|
||||||
|
const refreshing = ref(false)
|
||||||
const active = ref('router')
|
const active = ref('router')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新读一遍状态。
|
||||||
|
*
|
||||||
|
* 关键在于**不能把页面拆掉重建**:之前每次刷新都会先切到「读取中」,
|
||||||
|
* 于是路由页被卸载、选中的那一路入站丢失,改完一个设置还要重新找回原来的位置。
|
||||||
|
* 所以只有首次加载显示占位,之后都是原地更新。
|
||||||
|
*/
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
refreshing.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
try {
|
try {
|
||||||
const [config, status] = await Promise.all([api.readConfig(), api.kernel()])
|
const [config, status] = await Promise.all([api.readConfig(), api.kernel()])
|
||||||
@@ -37,7 +45,8 @@ async function load() {
|
|||||||
loadError.value = error instanceof Error ? error.message : String(error)
|
loadError.value = error instanceof Error ? error.message : String(error)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
refreshing.value = false
|
||||||
|
booted.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +71,17 @@ async function readRuntime(): Promise<Record<string, ProxyInfo>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换策略组时先在本地改掉,不等内核回话。
|
||||||
|
*
|
||||||
|
* 这一步不写配置文件,失败率极低;而等一个往返再更新会让点击感觉发木。
|
||||||
|
* 真失败了随后的刷新会把状态纠正回来。
|
||||||
|
*/
|
||||||
|
function optimisticSelect(group: string, member: string) {
|
||||||
|
const info = runtime.value[group]
|
||||||
|
if (info) info.now = member
|
||||||
|
}
|
||||||
|
|
||||||
function submitToken(value: string) {
|
function submitToken(value: string) {
|
||||||
setToken(value)
|
setToken(value)
|
||||||
void load()
|
void load()
|
||||||
@@ -119,7 +139,7 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<p v-if="loading" class="note">读取中</p>
|
<p v-if="!booted && refreshing" class="note">读取中</p>
|
||||||
|
|
||||||
<div v-else-if="loadError" class="alert">
|
<div v-else-if="loadError" class="alert">
|
||||||
<h2>读不到配置</h2>
|
<h2>读不到配置</h2>
|
||||||
@@ -144,6 +164,7 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
|||||||
:provider-lines="providerLines"
|
:provider-lines="providerLines"
|
||||||
:runtime="runtime"
|
:runtime="runtime"
|
||||||
@changed="load"
|
@changed="load"
|
||||||
|
@optimistic-select="optimisticSelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-else class="note">
|
<div v-else class="note">
|
||||||
|
|||||||
+17
@@ -8,6 +8,9 @@ import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/ind
|
|||||||
|
|
||||||
const TOKEN_KEY = 'patchbay.token'
|
const TOKEN_KEY = 'patchbay.token'
|
||||||
|
|
||||||
|
/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */
|
||||||
|
const TEST_URL = 'https://www.gstatic.com/generate_204'
|
||||||
|
|
||||||
export function getToken(): string {
|
export function getToken(): string {
|
||||||
return localStorage.getItem(TOKEN_KEY) ?? ''
|
return localStorage.getItem(TOKEN_KEY) ?? ''
|
||||||
}
|
}
|
||||||
@@ -134,6 +137,20 @@ export const api = {
|
|||||||
name: member,
|
name: member,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** 测一条线路的延迟。返回的是这一次的结果,同时也会被记进内核的历史里。 */
|
||||||
|
testLine: (name: string, url = TEST_URL, timeout = 5000) =>
|
||||||
|
request<{ delay: number }>(
|
||||||
|
'GET',
|
||||||
|
`/api/kernel/proxies/${encodeURIComponent(name)}/delay?url=${encodeURIComponent(url)}&timeout=${String(timeout)}`,
|
||||||
|
),
|
||||||
|
|
||||||
|
/** 测一整个策略组,内核会并发测所有成员。返回每个成员的延迟。 */
|
||||||
|
testGroup: (group: string, url = TEST_URL, timeout = 5000) =>
|
||||||
|
request<Record<string, number>>(
|
||||||
|
'GET',
|
||||||
|
`/api/kernel/group/${encodeURIComponent(group)}/delay?url=${encodeURIComponent(url)}&timeout=${String(timeout)}`,
|
||||||
|
),
|
||||||
|
|
||||||
/** 订阅里当前有哪些线路。配置文件给不出这个答案,只能问运行中的内核。 */
|
/** 订阅里当前有哪些线路。配置文件给不出这个答案,只能问运行中的内核。 */
|
||||||
providers: () =>
|
providers: () =>
|
||||||
request<{ providers: Record<string, { vehicleType: string; proxies: { name: string }[] }> }>(
|
request<{ providers: Record<string, { vehicleType: string; proxies: { name: string }[] }> }>(
|
||||||
|
|||||||
+38
-37
@@ -6,7 +6,7 @@ import type { ProxyInfo } from '../api'
|
|||||||
import IconOffline from '~icons/ri/cloud-off-line'
|
import IconOffline from '~icons/ri/cloud-off-line'
|
||||||
import IconEdit from '~icons/ri/edit-line'
|
import IconEdit from '~icons/ri/edit-line'
|
||||||
import IconDelete from '~icons/ri/delete-bin-line'
|
import IconDelete from '~icons/ri/delete-bin-line'
|
||||||
import IconConvert from '~icons/ri/swap-line'
|
import IconFlash from '~icons/ri/flashlight-line'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
inbound: Inbound
|
inbound: Inbound
|
||||||
@@ -19,14 +19,16 @@ const props = defineProps<{
|
|||||||
runtime: Record<string, ProxyInfo>
|
runtime: Record<string, ProxyInfo>
|
||||||
kernelOnline: boolean
|
kernelOnline: boolean
|
||||||
saving: boolean
|
saving: boolean
|
||||||
|
/** 正在测延迟 */
|
||||||
|
testing: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
pick: [mode: OutboundMode]
|
pick: [mode: OutboundMode]
|
||||||
edit: []
|
edit: []
|
||||||
remove: []
|
remove: []
|
||||||
convert: []
|
|
||||||
select: [group: string, member: string]
|
select: [group: string, member: string]
|
||||||
|
test: [group: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,7 +177,18 @@ function delayClass(ms: number | null): string {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<template v-if="switchable">
|
<template v-if="switchable">
|
||||||
<h4>{{ switchable.group }} 当前选中</h4>
|
<div class="sub-head">
|
||||||
|
<h4>{{ switchable.group }} 当前选中</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flash"
|
||||||
|
:disabled="testing"
|
||||||
|
:title="`测试 ${switchable.group} 里所有线路的延迟`"
|
||||||
|
@click="emit('test', switchable.group)"
|
||||||
|
>
|
||||||
|
<IconFlash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<button
|
<button
|
||||||
v-for="m in switchable.members"
|
v-for="m in switchable.members"
|
||||||
@@ -198,15 +211,9 @@ function delayClass(ms: number | null): string {
|
|||||||
<section class="pick">
|
<section class="pick">
|
||||||
<h3>出站</h3>
|
<h3>出站</h3>
|
||||||
|
|
||||||
<div v-if="locked" class="convert">
|
<p v-if="locked" class="aside-note">
|
||||||
<p>
|
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,出站固定是 RULE
|
||||||
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,出站固定是
|
</p>
|
||||||
<span class="mono">RULE</span>,下面这些都不能选。
|
|
||||||
</p>
|
|
||||||
<button type="button" :disabled="saving" @click="emit('convert')">
|
|
||||||
<IconConvert /> 转成命名入站
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!locked" class="grid">
|
<div v-if="!locked" class="grid">
|
||||||
<button
|
<button
|
||||||
@@ -373,43 +380,37 @@ function delayClass(ms: number | null): string {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.convert {
|
.is-dim {
|
||||||
display: flex;
|
opacity: 0.6;
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--s-3);
|
|
||||||
padding: var(--s-3);
|
|
||||||
border: 1px solid var(--c-border);
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
background: var(--c-surface-sunken);
|
|
||||||
margin-bottom: var(--s-4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.convert p {
|
.sub-head {
|
||||||
color: var(--c-text-soft);
|
|
||||||
font-size: var(--t-sm);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.convert button {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: var(--s-2);
|
gap: var(--s-2);
|
||||||
padding: var(--s-2) var(--s-3);
|
}
|
||||||
border: 1px solid var(--c-border-strong);
|
|
||||||
|
.flash {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: 1px solid transparent;
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
background: var(--c-surface);
|
background: none;
|
||||||
font-size: var(--t-sm);
|
color: var(--c-text-faint);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.convert button:hover:not(:disabled) {
|
.flash:hover:not(:disabled) {
|
||||||
border-color: var(--c-accent);
|
background: var(--c-accent-soft);
|
||||||
color: var(--c-accent-text);
|
color: var(--c-accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-dim {
|
.flash:disabled {
|
||||||
opacity: 0.6;
|
cursor: progress;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pick {
|
.pick {
|
||||||
|
|||||||
+27
-35
@@ -23,7 +23,7 @@ const props = defineProps<{
|
|||||||
runtime: Record<string, ProxyInfo>
|
runtime: Record<string, ProxyInfo>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ changed: [] }>()
|
const emit = defineEmits<{ changed: []; optimisticSelect: [group: string, member: string] }>()
|
||||||
|
|
||||||
const inbounds = computed(() => collectInbounds(props.data))
|
const inbounds = computed(() => collectInbounds(props.data))
|
||||||
const outbounds = computed(() => collectOutboundNames(props.data))
|
const outbounds = computed(() => collectOutboundNames(props.data))
|
||||||
@@ -35,8 +35,7 @@ const done = ref('')
|
|||||||
const fields = ref<ListenerFields | null>(null)
|
const fields = ref<ListenerFields | null>(null)
|
||||||
const formOpen = ref(false)
|
const formOpen = ref(false)
|
||||||
const formEditing = ref<Inbound | null>(null)
|
const formEditing = ref<Inbound | null>(null)
|
||||||
/** 正在把哪个老式端口字段转成命名入站。null 表示不是在做转换 */
|
const testing = ref(false)
|
||||||
const convertingKey = ref<string | null>(null)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -116,30 +115,13 @@ async function pickOutbound(mode: OutboundMode) {
|
|||||||
|
|
||||||
function openAdd() {
|
function openAdd() {
|
||||||
formEditing.value = null
|
formEditing.value = null
|
||||||
convertingKey.value = null
|
|
||||||
formOpen.value = true
|
formOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 把一个老式顶层端口转成命名入站。
|
|
||||||
*
|
|
||||||
* 转完它才有 proxy 字段,才能单独指定出站。表单预填端口、类型和它当前实际绑定的地址
|
|
||||||
* (老式端口的地址是内核从 allow-lan 和 bind-address 推出来的,转换后要显式写进 listen,
|
|
||||||
* 否则改 allow-lan 会连带影响这个端口)。名字留空,让用户自己起一个——沿用 mixed-port
|
|
||||||
* 当名字很难看。
|
|
||||||
*/
|
|
||||||
function openConvert() {
|
|
||||||
const target = selected.value
|
|
||||||
if (!target?.legacyKey) return
|
|
||||||
formEditing.value = { ...target, name: '' }
|
|
||||||
convertingKey.value = target.legacyKey
|
|
||||||
formOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit() {
|
function openEdit() {
|
||||||
if (!selected.value || selected.value.isDefault) return
|
if (!selected.value || selected.value.isDefault) return
|
||||||
formEditing.value = selected.value
|
formEditing.value = selected.value
|
||||||
convertingKey.value = null
|
|
||||||
formOpen.value = true
|
formOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,41 +129,50 @@ async function submitForm(entry: ConfigObject) {
|
|||||||
const next = clone()
|
const next = clone()
|
||||||
const listeners = Array.isArray(next.listeners) ? (next.listeners as ConfigObject[]) : []
|
const listeners = Array.isArray(next.listeners) ? (next.listeners as ConfigObject[]) : []
|
||||||
const editingIndex = formEditing.value?.listenerIndex ?? null
|
const editingIndex = formEditing.value?.listenerIndex ?? null
|
||||||
const converting = convertingKey.value
|
|
||||||
|
|
||||||
if (editingIndex === null) listeners.push(entry)
|
if (editingIndex === null) listeners.push(entry)
|
||||||
else listeners[editingIndex] = entry
|
else listeners[editingIndex] = entry
|
||||||
next.listeners = listeners
|
next.listeners = listeners
|
||||||
|
|
||||||
// 转换时把老式端口字段去掉,否则同一个端口会被声明两次,内核起不来
|
|
||||||
if (converting) delete next[converting]
|
|
||||||
|
|
||||||
formOpen.value = false
|
formOpen.value = false
|
||||||
convertingKey.value = null
|
await commit(next, editingIndex === null ? `新增了 ${String(entry.name)}` : '改动已保存')
|
||||||
const what = converting
|
|
||||||
? `${converting} 转成了命名入站 ${String(entry.name)}`
|
|
||||||
: editingIndex === null
|
|
||||||
? `新增了 ${String(entry.name)}`
|
|
||||||
: '改动已保存'
|
|
||||||
await commit(next, what)
|
|
||||||
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
|
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 切换策略组当前选中谁。运行时动作,不写配置文件,所以不走那条落盘链路。 */
|
/** 切换策略组当前选中谁。运行时动作,不写配置文件,所以不走那条落盘链路。 */
|
||||||
async function selectMember(group: string, member: string) {
|
async function selectMember(group: string, member: string) {
|
||||||
if (saving.value) return
|
if (saving.value) return
|
||||||
saving.value = true
|
// 先本地改掉,别让点击等一个往返。失败了随后的刷新会纠正回来。
|
||||||
|
emit('optimisticSelect', group, member)
|
||||||
failure.value = null
|
failure.value = null
|
||||||
done.value = ''
|
done.value = ''
|
||||||
try {
|
try {
|
||||||
await api.selectProxy(group, member)
|
await api.selectProxy(group, member)
|
||||||
done.value = `${group} 切到了 ${member}`
|
|
||||||
emit('changed')
|
emit('changed')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof ApiError ? String(error.payload.error ?? error.message) : String(error)
|
const message = error instanceof ApiError ? String(error.payload.error ?? error.message) : String(error)
|
||||||
failure.value = { message, hint: '这一步只改内核的运行状态,配置文件没有被动过。' }
|
failure.value = { message, hint: '这一步只改内核的运行状态,配置文件没有被动过。' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 让内核并发测一个策略组里所有成员的延迟,测完刷新一次拿到新数字。 */
|
||||||
|
async function testGroup(group: string) {
|
||||||
|
if (testing.value) return
|
||||||
|
testing.value = true
|
||||||
|
failure.value = null
|
||||||
|
done.value = ''
|
||||||
|
try {
|
||||||
|
const result = await api.testGroup(group)
|
||||||
|
const ok = Object.values(result).filter((ms) => ms > 0).length
|
||||||
|
done.value = `${group} 测完了,${ok} / ${Object.keys(result).length} 条通`
|
||||||
|
emit('changed')
|
||||||
|
} catch (error) {
|
||||||
|
// 测不通是关于线路的结论,不是 Patchbay 出了问题,所以用中性提示而不是错误框。
|
||||||
|
// 内核在全部超时时回 504,单条测失败回 503,两种都属于「测出来了,结果不好」。
|
||||||
|
const raw = error instanceof ApiError ? String(error.payload.message ?? error.message) : String(error)
|
||||||
|
done.value = raw.includes('timeout') ? `${group} 里的线路全部超时` : `测试没能完成:${raw}`
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
testing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,8 +256,9 @@ async function removeSelected() {
|
|||||||
@pick="pickOutbound"
|
@pick="pickOutbound"
|
||||||
@edit="openEdit"
|
@edit="openEdit"
|
||||||
@remove="removeSelected"
|
@remove="removeSelected"
|
||||||
@convert="openConvert"
|
|
||||||
@select="selectMember"
|
@select="selectMember"
|
||||||
|
@test="testGroup"
|
||||||
|
:testing="testing"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user