Files
mihomo-patchbay/web/api.ts
T
im0o 6b208847a2 修掉审查查出的五个问题
保存失败会丢掉整张表单:对话框在 await commit 之前就关了,重开是空的。改成只有成功才关。

错误响应原本有两种形状({error} 和 {message,hint}),前端只读 message,于是服务端
写好的中文提示一律退化成「请求失败(HTTP 4xx)」。统一成 { message, hint? }。

路由处理器没有兜底:请求体不是合法 JSON、配置文件运行中被删,都会返回一个没有响应体
的 500。这条链路的立意是出事时把用户救回来,而出事时给的偏偏是最没信息量的一句话。

延迟测试失败被写进 done,而 done 的样式是成功绿。拆出中性的 notice——测不通是关于
线路的结论,既不是操作失败也不是成功。

刷新原本只有一个动词,测一条线路的延迟会导致整份配置被重读、重新解析、跑一遍引用校验,
外加四个往返。拆成 saved 和 runtime-changed 两个事件,运行时动作只重读运行时状态。
2026-08-10 14:29:46 +08:00

182 lines
6.0 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)
}
/**
* 接口报错时抛这个。
*
* 服务端的错误响应统一是 { 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
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',
),
}