侧栏动作排撤掉重启图标,内核状态(圆点加版本号)本身变成按钮:悬停 换层显示「重启内核」,不在线时只当状态看。行高压成 1 修掉圆点和等宽 字体不居中的问题。 四个页面的一次性反馈(保存、删除、更新订阅、断开连接、测延迟结论) 全部改走 toast,commit() 改返回成败给「失败不关对话框」用;持续状态 (连接页轮询失败、请求页日志流失败)留在页内,会自动消失的提示装不下 需要一直可见的信息。toast 加 info 类别给中性结论用。 GEO 每行的更新改成单独下载那一个文件:内核只有全量更新的口子,单文件 只能服务端自己下(POST /api/geo/sync),代价是只有形状校验(MaxMind 末尾标记、geodata 首字节,shared/geo.ts 带测试)且内核重启后才换用新 文件;「更新全部」文字按钮保留内核的全量口子,有完整校验和热加载。 上传两行的按钮改「选择或拖放文件」,投放区拖入高亮,恢复策略挪到 恢复行下方。
88 lines
3.5 KiB
TypeScript
88 lines
3.5 KiB
TypeScript
/**
|
||
* GEO 数据库文件的本地状态:多大、多久没更新了。
|
||
*
|
||
* 内核不提供这个信息,但文件就躺在它的工作目录里,和 Patchbay 服务端
|
||
* 在同一台机器上,直接 stat 就是。
|
||
*/
|
||
import { readdir, rename, stat, writeFile } from 'node:fs/promises'
|
||
import { join } from 'node:path'
|
||
import { DEFAULT_GEOX_URLS, GEO_FILES, looksLikeGeodata, looksLikeMmdb } from '../shared/geo.ts'
|
||
import type { GeoFileStatus } from '../shared/geo.ts'
|
||
import type { Env } from './env.ts'
|
||
|
||
/** 配置里写了就用配置的,否则退回内核默认。 */
|
||
function urlFor(geoxUrl: Record<string, unknown>, configKey: string): string {
|
||
const configured = geoxUrl[configKey]
|
||
return typeof configured === 'string' && configured !== ''
|
||
? configured
|
||
: (DEFAULT_GEOX_URLS[configKey] ?? '')
|
||
}
|
||
|
||
export async function geoStatus(
|
||
env: Env,
|
||
geoxUrl: Record<string, unknown>,
|
||
): Promise<GeoFileStatus[]> {
|
||
const entries = await readdir(env.mihomoHome).catch(() => [] as string[])
|
||
return Promise.all(
|
||
GEO_FILES.map(async ({ key, configKey, file }) => {
|
||
// 内核找 GeoIP.dat/GeoSite.dat 时大小写不敏感(constant/path.go:193-224),这里照做
|
||
const actual = entries.find((name) => name.toLowerCase() === file.toLowerCase())
|
||
let size: number | null = null
|
||
let updatedAt: string | null = null
|
||
if (actual) {
|
||
const info = await stat(join(env.mihomoHome, actual)).catch(() => null)
|
||
if (info) {
|
||
size = info.size
|
||
updatedAt = info.mtime.toISOString()
|
||
}
|
||
}
|
||
return { key, configKey, url: urlFor(geoxUrl, configKey), size, updatedAt }
|
||
}),
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 下载单个 GEO 文件写进内核工作目录。
|
||
*
|
||
* 内核的更新接口是全量的(POST /configs/geo,按 enable 标记更新它用到的类型),
|
||
* 单文件只能 Patchbay 自己下。代价是拿不到内核那套完整校验,只能做形状检查
|
||
* (shared/geo.ts),也不触发热加载——内核用 mmap 缓存着旧数据,重启后才换新。
|
||
*/
|
||
export async function syncGeoFile(
|
||
env: Env,
|
||
configKey: string,
|
||
geoxUrl: Record<string, unknown>,
|
||
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||
const spec = GEO_FILES.find((g) => g.configKey === configKey)
|
||
if (!spec) return { ok: false, message: `未知的 GEO 类型:${configKey}` }
|
||
const url = urlFor(geoxUrl, configKey)
|
||
|
||
let bytes: Uint8Array
|
||
try {
|
||
const res = await fetch(url, { signal: AbortSignal.timeout(300_000), redirect: 'follow' })
|
||
if (!res.ok) return { ok: false, message: `下载失败:HTTP ${String(res.status)}` }
|
||
bytes = new Uint8Array(await res.arrayBuffer())
|
||
} catch (error) {
|
||
return { ok: false, message: `下载失败:${String(error)}` }
|
||
}
|
||
|
||
const shapeOk =
|
||
configKey === 'mmdb' || configKey === 'asn' ? looksLikeMmdb(bytes) : looksLikeGeodata(bytes)
|
||
if (!shapeOk) {
|
||
return { ok: false, message: `下载到的内容不像 ${spec.key} 文件,已丢弃,本地文件没有动。` }
|
||
}
|
||
|
||
// 沿用目录里现有的大小写写法,内核就是这样找文件的
|
||
const entries = await readdir(env.mihomoHome).catch(() => [] as string[])
|
||
const actual = entries.find((name) => name.toLowerCase() === spec.file.toLowerCase()) ?? spec.file
|
||
const finalPath = join(env.mihomoHome, actual)
|
||
const tempPath = `${finalPath}.patchbay-tmp`
|
||
try {
|
||
await writeFile(tempPath, bytes)
|
||
await rename(tempPath, finalPath)
|
||
} catch (error) {
|
||
return { ok: false, message: `写入失败:${String(error)}` }
|
||
}
|
||
return { ok: true }
|
||
}
|