Squash of recent dev work: return_session login, backend switcher, line detail timeline, overview strips, node platform/country/ISP info with flags and icons, build info (version, tag, time) behind site.show_build, and the acrylic display theme.
193 lines
6.7 KiB
TypeScript
193 lines
6.7 KiB
TypeScript
/**
|
|
* 数据源:前端默认和后端同源(Cookie 会话)。也可以指到别的后端(如本机跑前端、连线上的 `https://mo.oeo.one`):
|
|
* 地址和令牌存在浏览器里(每个前端各存一份令牌),也可以用构建时的 `VITE_API_BASE` 给个默认地址。
|
|
* 跨域时不带 Cookie,只用 `Authorization: Bearer <API 令牌>`;后端要在 `Monitor__CorsOrigins` 里放行这个前端的来源。
|
|
*/
|
|
|
|
const BASE_KEY = 'monitor.api_base'
|
|
|
|
let app = 'app'
|
|
|
|
/** 每个前端启动时调用一次,令牌按前端分开存(展示端用只读令牌,管理端用管理令牌)。 */
|
|
export function configureSource(name: string) {
|
|
app = name
|
|
}
|
|
|
|
function read(key: string): string {
|
|
try {
|
|
return localStorage.getItem(key) ?? ''
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function write(key: string, value: string) {
|
|
try {
|
|
if (value) localStorage.setItem(key, value)
|
|
else localStorage.removeItem(key)
|
|
} catch {
|
|
// 浏览器禁用存储时只是不记住
|
|
}
|
|
}
|
|
|
|
export function normalizeBase(input: string): string {
|
|
const text = input.trim().replace(/\/+$/, '')
|
|
if (!text) return ''
|
|
return /^https?:\/\//i.test(text) ? text : `https://${text}`
|
|
}
|
|
|
|
/** 后端地址(不带末尾斜杠);空串 = 同源。 */
|
|
export function getBase(): string {
|
|
return normalizeBase(read(BASE_KEY) || (import.meta.env.VITE_API_BASE as string | undefined) || '')
|
|
}
|
|
|
|
export interface SavedSource {
|
|
base: string
|
|
/** 在这个后端登录之后拿到的会话(只存在浏览器里);空 = 还没登录。 */
|
|
token: string
|
|
}
|
|
|
|
// v2:以前保存的是手填的管理 Token,现在是登录后拿到的会话,旧的不再使用
|
|
const listKey = () => `monitor.sources.v2.${app}`
|
|
|
|
function readList(): SavedSource[] {
|
|
try {
|
|
const parsed = JSON.parse(read(listKey()) || '[]') as unknown
|
|
return Array.isArray(parsed) ? (parsed as SavedSource[]).filter((x) => typeof x?.base === 'string' && x.base !== '') : []
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
/** 保存过的后端(每个前端各存各的登录会话);当前连接的那个一定在里面。不含这个页面自己所在的后端。 */
|
|
export function listSources(): SavedSource[] {
|
|
const list = readList()
|
|
const active = getBase()
|
|
if (active && !list.some((x) => x.base === active)) list.push({ base: active, token: '' })
|
|
return list
|
|
}
|
|
|
|
function writeList(list: SavedSource[]) {
|
|
write(listKey(), JSON.stringify(list))
|
|
}
|
|
|
|
/** 当前连接的后端的登录会话;这个页面自己所在的后端用 Cookie,没有。 */
|
|
export function getToken(): string {
|
|
const active = getBase()
|
|
return active ? (readList().find((x) => x.base === active)?.token ?? '') : ''
|
|
}
|
|
|
|
/** 保存(或更新)一个后端和它的登录会话,并连过去。地址留空 = 这个页面自己所在的后端。 */
|
|
export function setSource(base: string, token: string) {
|
|
const next = normalizeBase(base)
|
|
if (next) writeList([...readList().filter((x) => x.base !== next), { base: next, token }])
|
|
write(BASE_KEY, next)
|
|
}
|
|
|
|
/** 切换到已经保存过的后端(空串 = 这个页面自己所在的后端)。 */
|
|
export function activateSource(base: string) {
|
|
write(BASE_KEY, normalizeBase(base))
|
|
}
|
|
|
|
/** 忘掉一个保存过的后端;正连着的话回到这个页面自己所在的后端。 */
|
|
export function removeSource(base: string) {
|
|
writeList(readList().filter((x) => x.base !== base))
|
|
if (getBase() === base) write(BASE_KEY, '')
|
|
}
|
|
|
|
/** 只改当前连接的后端的登录会话(过期、退出时清掉)。 */
|
|
export function setToken(token: string) {
|
|
const active = getBase()
|
|
if (!active) return
|
|
writeList([...readList().filter((x) => x.base !== active), { base: active, token }])
|
|
}
|
|
|
|
export const isRemote = () => getBase() !== ''
|
|
|
|
/** 拼出请求地址:同源时原样返回。 */
|
|
export function resolveUrl(path: string): string {
|
|
return getBase() + path
|
|
}
|
|
|
|
export function authHeaders(): Record<string, string> {
|
|
const token = getToken()
|
|
return isRemote() && token ? { Authorization: `Bearer ${token}` } : {}
|
|
}
|
|
|
|
/**
|
|
* 当前后端连不连得上:只看有没有响应(跨域时拿不到内容也没关系,所以用 no-cors);网络不通、域名解析失败、连接被拒才算失败。
|
|
*/
|
|
export async function pingBackend(): Promise<boolean> {
|
|
try {
|
|
await fetch(resolveUrl('/healthz'), { mode: 'no-cors', cache: 'no-store' })
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export interface LiveSource {
|
|
onopen: (() => void) | null
|
|
onerror: (() => void) | null
|
|
addEventListener: (type: string, listener: (event: MessageEvent<string>) => void) => void
|
|
close: () => void
|
|
}
|
|
|
|
/**
|
|
* SSE 订阅。同源用浏览器的 EventSource;跨域时 EventSource 带不了 Authorization 头,
|
|
* 所以用 fetch 读流并自己解析,断开后 3 秒重连。
|
|
*/
|
|
export function openLive(path: string): LiveSource {
|
|
if (!isRemote()) return new EventSource(path) as unknown as LiveSource
|
|
|
|
const listeners = new Map<string, ((event: MessageEvent<string>) => void)[]>()
|
|
const source: LiveSource = {
|
|
onopen: null,
|
|
onerror: null,
|
|
addEventListener: (type, listener) => listeners.set(type, [...(listeners.get(type) ?? []), listener]),
|
|
close: () => {
|
|
closed = true
|
|
controller?.abort()
|
|
},
|
|
}
|
|
let closed = false
|
|
let controller: AbortController | undefined
|
|
|
|
const run = async () => {
|
|
while (!closed) {
|
|
controller = new AbortController()
|
|
try {
|
|
const response = await fetch(resolveUrl(path), { headers: { Accept: 'text/event-stream', ...authHeaders() }, signal: controller.signal })
|
|
if (!response.ok || !response.body) throw new Error(String(response.status))
|
|
source.onopen?.()
|
|
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
|
|
let buffer = ''
|
|
for (;;) {
|
|
const { value, done } = await reader.read()
|
|
if (done) break
|
|
buffer += value
|
|
let at: number
|
|
while ((at = buffer.search(/\r?\n\r?\n/)) >= 0) {
|
|
const block = buffer.slice(0, at)
|
|
buffer = buffer.slice(at).replace(/^\r?\n\r?\n/, '')
|
|
let type = 'message'
|
|
const data: string[] = []
|
|
for (const line of block.split(/\r?\n/)) {
|
|
if (line.startsWith('event:')) type = line.slice(6).trim()
|
|
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''))
|
|
}
|
|
if (data.length > 0) for (const l of listeners.get(type) ?? []) l({ data: data.join('\n') } as MessageEvent<string>)
|
|
}
|
|
}
|
|
} catch {
|
|
// 下面统一报错并重连
|
|
}
|
|
if (closed) return
|
|
source.onerror?.()
|
|
await new Promise((resolve) => setTimeout(resolve, 3000))
|
|
}
|
|
}
|
|
void run()
|
|
return source
|
|
}
|