404 不是 bug。内核的 /proxies 只返回全局名字表(内置出站、自有线路、策略组),
订阅线路不在里面——跟「订阅线路不能被入站直接引用」是同一条约束,我没想到它同样
适用于运行时接口。所以 /proxies/{订阅线路}/delay 一律 404,而订阅线路的卡片也一直
没有协议和延迟,因为那些数据同样查不到。
现在运行时状态从两处合成:/proxies 给内置出站和策略组,/providers/proxies 给订阅线路
(它返回的线路本来就带完整的协议和延迟历史)。测速按线路归属选接口,订阅线路走
{订阅}/{线路}/healthcheck。实测该接口对同一条线路返回 200 和 325 毫秒。
168 lines
5.7 KiB
TypeScript
168 lines
5.7 KiB
TypeScript
/**
|
||
* 后端接口的客户端。
|
||
*
|
||
* 令牌存在 localStorage 里。它等同于密码,但这个页面本来就只有能访问 127.0.0.1
|
||
* (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。
|
||
*/
|
||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||
|
||
const TOKEN_KEY = 'patchbay.token'
|
||
|
||
/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */
|
||
const TEST_URL = 'https://www.gstatic.com/generate_204'
|
||
|
||
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)
|
||
}
|
||
|
||
/** 接口报错时抛这个,界面据此决定是提示重填令牌还是显示错误内容。 */
|
||
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 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
|
||
yaml: string
|
||
revision: string
|
||
diagnostics: ConfigDiagnostic[]
|
||
}
|
||
|
||
export interface ApplySuccess {
|
||
ok: true
|
||
snapshot: ConfigSnapshot
|
||
reloaded: boolean
|
||
reloadMessage: string
|
||
}
|
||
|
||
export interface ApplyFailure {
|
||
ok: false
|
||
stage: 'stale' | 'self-check' | 'kernel-check' | 'write' | 'reload'
|
||
message: string
|
||
hint: string
|
||
restored: boolean
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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 const api = {
|
||
readConfig: () => request<ConfigSnapshot>('GET', '/api/config'),
|
||
|
||
applyConfig: (data: ConfigObject, baseRevision: string) =>
|
||
request<ApplySuccess>('PUT', '/api/config', { data, baseRevision }),
|
||
|
||
listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'),
|
||
|
||
restoreBackup: (name: string) =>
|
||
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
|
||
|
||
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
||
|
||
/** 入站类型的字段表,从内核源码提取,用来驱动表单。 */
|
||
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 q = `url=${encodeURIComponent(TEST_URL)}&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)
|
||
},
|
||
|
||
/**
|
||
* 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。
|
||
*
|
||
* 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张
|
||
* 全局名字表里,这是拿到它们类型和延迟的唯一途径。
|
||
*/
|
||
providers: () =>
|
||
request<{ providers: Record<string, { vehicleType: string; proxies: ProxyInfo[] }> }>(
|
||
'GET',
|
||
'/api/kernel/providers/proxies',
|
||
),
|
||
}
|