Files
mihomo-patchbay/web/api.ts
T
im0o 23aa28fbea 订阅详情可以立刻检查线路
走内核的 provider healthcheck:逐条 URLTest,测通与否和延迟一起记进历史
(adapter/provider/healthcheck.go:123-189)。用的是订阅自己的
health-check.url——这个接口不收 url 参数,设置页那个测速地址传不进去。

它是同步的,测完全部线路才回话,一批 10 条、每条最多 5 秒
(healthcheck.go:132、181、201),线路多又都测不通时远超透传默认的
15 秒,所以 proxyGet 的超时改成可传,健康检查这条路径放到 120 秒。
2026-08-14 14:05:24 +08:00

352 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 后端接口的客户端。
*
* 令牌存在 localStorage 里。它等同于密码,但这个页面本来就只有能访问 127.0.0.1
* (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。
*/
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
import type { ConnectionSnapshot } from '@shared/connections.ts'
import type { ExitProbe, HostInfo } from '@shared/host-info.ts'
import type { GeoFileStatus } from '@shared/geo.ts'
import { DEFAULT_TEST_URL, testUrl } from './prefs'
const TOKEN_KEY = 'patchbay.token'
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
}
/** 一个订阅的运行时状态。updatedAt 是上次拉取成功的时刻,没拉过是 Go 的零值时间。 */
export interface ProviderInfo {
vehicleType: string
updatedAt?: string
proxies: ProxyInfo[]
}
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 interface RuleProviderInfo {
/** domain / ipcidr / classical */
behavior: string
/** yaml / text / mrs。内核始终序列化它(provider.go:37 无 omitempty),inline 类型给的是空串 */
format?: string
name: string
/** 当前装了多少条规则 */
ruleCount: number
/** HTTP / File / Inline */
vehicleType: string
/** RFC3339。还没拉取过时是零值(0001-01-01) */
updatedAt: string
}
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[]; redo: number }>('GET', '/api/config/backups'),
/** 撤回最近一次改动:应用最新备份并消费掉它,撤回前的状态进重做栈。 */
undoConfig: () => request<ApplySuccess>('POST', '/api/config/undo'),
/** 重做最近一次被撤回的改动。新的正常保存会清空重做栈。 */
redoConfig: () => request<ApplySuccess>('POST', '/api/config/redo'),
/** 配置文件的原文,给「下载当前配置」用。 */
configRaw: async (): Promise<string> => {
const res = await fetch('/api/config/raw', {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!res.ok) {
const text = await res.text()
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {}
throw new ApiError(res.status, payload)
}
return res.text()
},
/**
* 用上传的 YAML 替换当前配置:override 整份替换,compatible 只搬
* Patchbay 接管的段落。走服务端完整的保护链路(校验、备份、重载、失败回退)。
*/
importConfig: (content: string, mode: 'override' | 'compatible') =>
request<ApplySuccess>('POST', '/api/config/import', { content, mode }),
restoreBackup: (name: string) =>
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
/** 一条历史记录的对比原文:这份备份(改动前)和它之后的状态(改动后)。 */
backupDiff: (name: string) =>
request<{ before: string; after: string }>(
'GET',
`/api/config/backups/${encodeURIComponent(name)}/diff`,
),
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
/**
* 服务端所在主机的指标:CPU、内存、负载、内网地址、网关。
* 内核的管理接口只报自己的内存,这些只有跑在同一台机器上的 Patchbay 服务端答得出。
*/
host: () => request<HostInfo>('GET', '/api/host'),
/**
* 经由某个入站端口探测公网 IP(服务端拿本机对应端口当代理去访问 ip.sb)。
* 会发起真实的外网请求,只在用户点了检测时调。
*/
probeExit: (port: number) => request<ExitProbe>('GET', `/api/host/exit?port=${String(port)}`),
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
/**
* 重启内核进程。所有连接会断开,几秒后自动恢复;
* 策略组的选中靠配置里的 profile.store-selected 找回来。
*/
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
/** 让内核重新加载磁盘上的配置文件。给 SSH 手改过配置的场景用。 */
reloadKernelConfig: () => request<{ ok: true }>('POST', '/api/kernel/reload'),
/**
* 让内核更新 GEO 数据库。同步接口,下载完才回话,可能要等上几十秒。
* 内核只有全量更新这一个口子,没变化的文件靠内容哈希跳过重写。
*/
updateGeo: () => request<{ ok: true }>('POST', '/api/kernel/geo'),
/** 四类 GEO 文件的本地状态:大小、更新时间、生效中的下载地址。 */
geoStatus: () => request<{ files: GeoFileStatus[] }>('GET', '/api/geo'),
/**
* 让服务端下载单个 GEO 文件(内核的更新接口是全量的,单文件只能自己下)。
* 内核缓存着旧数据,重启后才换用新文件。
*/
syncGeoFile: (configKey: string) =>
request<{ ok: true }>('POST', '/api/geo/sync', { key: configKey }),
/**
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
*
* 不能用上面的 request():它会把整个响应读完,而对永不结束的流来说那等于挂死。
* 调用方拿到响应体后用 web/stream.ts 的 readLines() 逐行读;中止 signal 即断流。
*/
openStream: async (path: string, signal: AbortSignal): Promise<ReadableStream<Uint8Array>> => {
const res = await fetch(`/api/kernel${path}`, {
headers: { Authorization: `Bearer ${getToken()}` },
signal,
})
if (!res.ok) {
const text = await res.text()
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {}
throw new ApiError(res.status, payload)
}
if (!res.body) throw new Error('响应没有内容')
return res.body
},
/**
* 把上传的线路/规则清单写进内核工作目录的 providers/ 下。
* path 必须是 providerCachePath / ruleProviderPath 生成的形状,服务端有白名单校验。
*/
uploadProviderFile: (path: string, content: string) =>
request<{ ok: true }>('POST', '/api/provider-files', { path, content }),
/** 入站类型的字段表,从内核源码提取,用来驱动表单。 */
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 target = testUrl.value.trim() || DEFAULT_TEST_URL
const q = `url=${encodeURIComponent(target)}&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'),
/**
* 让内核立刻重新拉取某个订阅。
*
* 运行时动作,不写配置文件,所以不走 applyConfig 那条校验重载链路。成功时内核回
* 204 空响应,拉取失败回 503 带错误消息(内核源码 hub/route/provider.go:52-60)。
*/
refreshProvider: (name: string) =>
request<unknown>('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`),
/**
* 让内核立刻对一个订阅的全部线路做一次健康检查:逐条 URLTest,测通与否和延迟一起
* 记进历史(内核源码 adapter/provider/healthcheck.go:123-189)。
*
* 用的是这个订阅 `health-check.url` 里的地址而不是设置页的测速地址——内核这个接口
* 不收 url 参数。它是同步的,测完全部线路才回话,所以可能要等上几十秒。
*/
checkProviderLines: (name: string) =>
request<unknown>(
'GET',
`/api/kernel/providers/proxies/${encodeURIComponent(name)}/healthcheck`,
),
/**
* 每个规则组的运行时状态:装了多少条规则、上次什么时候更新。
* 配置文件里只有声明,这两个数字只有运行中的内核知道。
*/
ruleProviders: () =>
request<{ providers: Record<string, RuleProviderInfo> }>('GET', '/api/kernel/providers/rules'),
/**
* 让内核立刻重新拉取某个规则组。
* 这是运行时动作,不写配置文件,所以不走落盘那条保护链路。
*/
updateRuleProvider: (name: string) =>
request<Record<string, never>>(
'PUT',
`/api/kernel/providers/rules/${encodeURIComponent(name)}`,
),
/**
* 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。
*
* 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张
* 全局名字表里,这是拿到它们类型和延迟的唯一途径。
*/
providers: () =>
request<{ providers: Record<string, ProviderInfo> }>('GET', '/api/kernel/providers/proxies'),
}