From 23aa28fbea7d863660e4ead2e319f8d096dc2f12 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Fri, 14 Aug 2026 14:05:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=A2=E9=98=85=E8=AF=A6=E6=83=85=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E7=AB=8B=E5=88=BB=E6=A3=80=E6=9F=A5=E7=BA=BF=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 走内核的 provider healthcheck:逐条 URLTest,测通与否和延迟一起记进历史 (adapter/provider/healthcheck.go:123-189)。用的是订阅自己的 health-check.url——这个接口不收 url 参数,设置页那个测速地址传不进去。 它是同步的,测完全部线路才回话,一批 10 条、每条最多 5 秒 (healthcheck.go:132、181、201),线路多又都测不通时远超透传默认的 15 秒,所以 proxyGet 的超时改成可传,健康检查这条路径放到 120 秒。 --- server/kernel.ts | 9 ++++-- server/routes.ts | 10 +++++- web/api.ts | 13 ++++++++ web/views/ConfigPage.vue | 27 ++++++++++++++++ web/views/SubscriptionDetail.vue | 54 +++++++++++++++++++++++++++++++- 5 files changed, 109 insertions(+), 4 deletions(-) diff --git a/server/kernel.ts b/server/kernel.ts index d6f2c87..76a3ab6 100644 --- a/server/kernel.ts +++ b/server/kernel.ts @@ -184,8 +184,13 @@ export async function updateGeoDatabases(env: Env): Promise { * 持续吐 JSON 的分块流(内核源码 hub/route/server.go:380-383),给它们设超时等于 * 每隔一会儿就把连接掐断一次。 */ -export async function proxyGet(env: Env, path: string, stream = false): Promise { - return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(15_000) }) +export async function proxyGet( + env: Env, + path: string, + stream = false, + timeoutMs = 15_000, +): Promise { + return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(timeoutMs) }) } /** 透传一个写操作。目前只有断开连接和刷新订阅两处。 */ diff --git a/server/routes.ts b/server/routes.ts index 59f65fe..76b79b4 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -366,7 +366,15 @@ function makeRoute(env: Env, token: string) { if (!allowed) { return json({ message: `不允许访问该路径:${target}` }, 403) } - const upstream = await proxyGet(env, target + url.search, STREAM_PATHS.has(target)) + // 健康检查是同步的:内核测完才回话,一批 10 条、每条最多 5 秒 + // (内核源码 adapter/provider/healthcheck.go:132、181、201), + // 整条订阅测且线路多又都测不通时会远超默认的 15 秒 + const upstream = await proxyGet( + env, + target + url.search, + STREAM_PATHS.has(target), + HEALTHCHECK_PATH.test(target) ? 120_000 : undefined, + ) return new Response(upstream.body, { status: upstream.status, headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' }, diff --git a/web/api.ts b/web/api.ts index 5f62ec4..affc31c 100644 --- a/web/api.ts +++ b/web/api.ts @@ -310,6 +310,19 @@ export const api = { refreshProvider: (name: string) => request('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`), + /** + * 让内核立刻对一个订阅的全部线路做一次健康检查:逐条 URLTest,测通与否和延迟一起 + * 记进历史(内核源码 adapter/provider/healthcheck.go:123-189)。 + * + * 用的是这个订阅 `health-check.url` 里的地址而不是设置页的测速地址——内核这个接口 + * 不收 url 参数。它是同步的,测完全部线路才回话,所以可能要等上几十秒。 + */ + checkProviderLines: (name: string) => + request( + 'GET', + `/api/kernel/providers/proxies/${encodeURIComponent(name)}/healthcheck`, + ), + /** * 每个规则组的运行时状态:装了多少条规则、上次什么时候更新。 * 配置文件里只有声明,这两个数字只有运行中的内核知道。 diff --git a/web/views/ConfigPage.vue b/web/views/ConfigPage.vue index a84d0d9..632fd41 100644 --- a/web/views/ConfigPage.vue +++ b/web/views/ConfigPage.vue @@ -50,6 +50,8 @@ const formEditing = ref(null) const removing = ref(null) /** 正在等内核重新拉取的订阅名 */ const refreshing = ref(null) +/** 正在等内核测完线路的订阅名 */ +const checking = ref(null) /** * 每个订阅的运行时数据:当前线路、上次更新时间。 @@ -247,6 +249,29 @@ async function refresh() { } } +/** + * 让内核立刻测一遍选中订阅的线路。运行时动作,不写配置文件,所以不走 commit()。 + * + * 内核是测完全部线路才回话的,线路多又都测不通时要等上几十秒,期间按钮转着。 + */ +async function check() { + const target = selected.value + if (!target || checking.value !== null) return + checking.value = target.name + try { + await api.checkProviderLines(target.name) + await reloadProviders() + toast(`已检查 ${target.name} 的线路`) + } catch (error) { + toast(error instanceof ApiError ? error.message : String(error), { + kind: 'error', + hint: '该操作仅修改内核运行状态,配置文件未被修改。', + }) + } finally { + checking.value = null + } +} + const refreshingAll = ref(false) /** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */ @@ -309,10 +334,12 @@ async function refreshAll() { :info="providerInfo[selected.name]" :online="kernel.online" :refreshing="refreshing === selected.name" + :checking="checking === selected.name" :saving="saving" @edit="openEdit" @remove="askRemove" @refresh="refresh" + @check="check" @toggle-health-check="toggleHealthCheck" /> diff --git a/web/views/SubscriptionDetail.vue b/web/views/SubscriptionDetail.vue index 66f67b3..7834d2b 100644 --- a/web/views/SubscriptionDetail.vue +++ b/web/views/SubscriptionDetail.vue @@ -17,6 +17,8 @@ import IconDelete from '~icons/ri/delete-bin-line' import IconRefresh from '~icons/ri/refresh-line' import IconOffline from '~icons/ri/cloud-off-line' import IconPulse from '~icons/ri/pulse-line' +import IconSync from '~icons/ri/loop-right-line' +import IconLoad from '~icons/ri/loader-4-line' const props = defineProps<{ subscription: Subscription @@ -25,6 +27,8 @@ const props = defineProps<{ online: boolean /** 正在等内核重新拉取 */ refreshing: boolean + /** 正在等内核测完这个订阅的线路 */ + checking: boolean /** 配置正在保存 */ saving: boolean }>() @@ -34,6 +38,8 @@ const emit = defineEmits<{ remove: [] /** 让内核立刻重新拉取。运行时动作,不写配置文件 */ refresh: [] + /** 让内核立刻测一遍这个订阅的线路。运行时动作,不写配置文件 */ + check: [] /** 开关健康检查。写配置文件,走保存链路 */ toggleHealthCheck: [] }>() @@ -86,7 +92,19 @@ function delayClass(ms: number | null): string {
-

线路

+

+ 线路 + +

内核未连接

这个订阅现在没有线路

@@ -195,6 +213,8 @@ function delayClass(ms: number | null): string { } h3 { + display: flex; + align-items: center; font-size: var(--t-sm); font-weight: 600; color: var(--c-text); @@ -202,6 +222,38 @@ h3 { margin-bottom: var(--s-3); } +.check { + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; + width: 22px; + height: 22px; + border: none; + border-radius: var(--r-sm); + background: none; + color: var(--c-text-faint); + cursor: pointer; +} +.check:hover:not(:disabled) { + background: var(--c-surface-sunken); + color: var(--c-text); +} +.check:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.spin { + animation: spin 0.9s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + .hint { display: flex; align-items: center;