侧栏动作排撤掉重启图标,内核状态(圆点加版本号)本身变成按钮:悬停 换层显示「重启内核」,不在线时只当状态看。行高压成 1 修掉圆点和等宽 字体不居中的问题。 四个页面的一次性反馈(保存、删除、更新订阅、断开连接、测延迟结论) 全部改走 toast,commit() 改返回成败给「失败不关对话框」用;持续状态 (连接页轮询失败、请求页日志流失败)留在页内,会自动消失的提示装不下 需要一直可见的信息。toast 加 info 类别给中性结论用。 GEO 每行的更新改成单独下载那一个文件:内核只有全量更新的口子,单文件 只能服务端自己下(POST /api/geo/sync),代价是只有形状校验(MaxMind 末尾标记、geodata 首字节,shared/geo.ts 带测试)且内核重启后才换用新 文件;「更新全部」文字按钮保留内核的全量口子,有完整校验和热加载。 上传两行的按钮改「选择或拖放文件」,投放区拖入高亮,恢复策略挪到 恢复行下方。
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
/**
|
||
* 后端接口的客户端。
|
||
*
|
||
* 令牌存在 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。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`),
|
||
|
||
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)}`),
|
||
|
||
/**
|
||
* 每个规则组的运行时状态:装了多少条规则、上次什么时候更新。
|
||
* 配置文件里只有声明,这两个数字只有运行中的内核知道。
|
||
*/
|
||
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'),
|
||
}
|