Files
mihomo-patchbay/web/api.ts
T
im0o 0eef680fba 配置页:订阅的增删改和立刻更新
写配置的纯逻辑在 shared/providers.ts。删除前 subscriptionReferences 同时查
直接 use 和经订阅副本(共用缓存文件 path)的间接引用,有被引用的就拦下并列出
策略组。订阅不允许改名:名字被 use: 和副本引用,同步所有引用不如删了重建。

页面还没挂进 App.vue 的导航,由集成者统一加。
2026-08-11 18:50:13 +08:00

186 lines
6.4 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
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 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)
},
/**
* 让内核立刻重新拉取某个订阅。
*
* 运行时动作,不写配置文件,所以不走 applyConfig 那条校验重载链路。成功时内核回
* 204 空响应,拉取失败回 503 带错误消息(内核源码 hub/route/provider.go:52-60)。
*/
refreshProvider: (name: string) =>
request<unknown>('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`),
/**
* 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。
*
* 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张
* 全局名字表里,这是拿到它们类型和延迟的唯一途径。
*/
providers: () =>
request<{ providers: Record<string, ProviderInfo> }>('GET', '/api/kernel/providers/proxies'),
}