Files
mihomo-patchbay/web/api.ts
T
im0o 5cdc183190 去掉转换入口,加测延迟,改掉刷新时丢状态的毛病
最要紧的是第三条:之前每次改完都会先切到「读取中」,页面被拆掉重建,
选中的那一路入站随之丢失,改个设置还得重新找回原来的位置。现在只有首次加载显示占位,
之后都是原地更新。切换策略组还做成了乐观更新——那一步不写配置文件,
没必要让点击等一个往返,真失败了随后的刷新会纠正回来。

测延迟按钮让内核并发测整个策略组。测不通是关于线路的结论而不是 Patchbay 出了问题,
所以全部超时用中性提示,不弹红框。
2026-08-10 12:53:56 +08:00

161 lines
5.3 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'
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 : `请求失败(${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 {
/** 协议类型(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,
}),
/** 测一条线路的延迟。返回的是这一次的结果,同时也会被记进内核的历史里。 */
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: () =>
request<{ providers: Record<string, { vehicleType: string; proxies: { name: string }[] }> }>(
'GET',
'/api/kernel/providers/proxies',
),
}