chore: initial project import
This commit is contained in:
commit
26620707a0
347 files changed
+56172
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { authHeaders, isRemote, resolveUrl } from './source'
|
||||
|
||||
/** 后端 API 客户端:默认同源请求(开发时由 Vite 代理到后端),Cookie 会话,修改类请求带 CSRF 令牌;指到别的后端时用 Bearer 令牌(见 source.ts)。 */
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly code: string
|
||||
readonly detail: string
|
||||
/** 字段级的校验错误:字段名 → 信息。 */
|
||||
readonly errors: Record<string, string[]>
|
||||
|
||||
constructor(status: number, code: string, detail: string, errors: Record<string, string[]> = {}) {
|
||||
super(detail || code)
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.detail = detail
|
||||
this.errors = errors
|
||||
}
|
||||
}
|
||||
|
||||
let csrfToken: string | null = null
|
||||
|
||||
export function setCsrf(token: string | null) {
|
||||
csrfToken = token
|
||||
}
|
||||
|
||||
export type Query = Record<string, string | number | boolean | null | undefined>
|
||||
|
||||
export function withQuery(path: string, query?: Query): string {
|
||||
if (!query) return path
|
||||
const params = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== null && v !== '') params.set(k, String(v))
|
||||
}
|
||||
const text = params.toString()
|
||||
return text ? `${path}?${text}` : path
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() }
|
||||
if (body !== undefined) headers['Content-Type'] = method === 'PATCH' ? 'application/merge-patch+json' : 'application/json'
|
||||
if (method !== 'GET' && csrfToken) headers['X-CSRF-Token'] = csrfToken
|
||||
const response = await fetch(resolveUrl(path), {
|
||||
method,
|
||||
headers,
|
||||
credentials: isRemote() ? 'omit' : 'include',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
...init,
|
||||
})
|
||||
if (response.status === 204) return undefined as T
|
||||
const text = await response.text()
|
||||
const data = text ? (JSON.parse(text) as unknown) : undefined
|
||||
if (!response.ok) {
|
||||
const problem = (data ?? {}) as { code?: string; detail?: string; title?: string; errors?: Record<string, string[]> | null }
|
||||
throw new ApiError(response.status, problem.code ?? 'error', problem.detail ?? problem.title ?? response.statusText, problem.errors ?? {})
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, query?: Query, init?: RequestInit) => request<T>('GET', withQuery(path, query), undefined, init),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
|
||||
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
|
||||
del: <T>(path: string) => request<T>('DELETE', path),
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 数据源:前端默认和后端同源(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 function getToken(): string {
|
||||
return read(`monitor.api_token.${app}`)
|
||||
}
|
||||
|
||||
export function setSource(base: string, token: string) {
|
||||
write(BASE_KEY, normalizeBase(base))
|
||||
write(`monitor.api_token.${app}`, token.trim())
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
write(`monitor.api_token.${app}`, token.trim())
|
||||
}
|
||||
|
||||
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}` } : {}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { components } from './schema'
|
||||
|
||||
type S = components['schemas']
|
||||
|
||||
/** 生成的类型把整数写成 number | string(后端的 JSON 序列化允许字符串):这里统一成 number。 */
|
||||
type NarrowValue<V> = unknown extends V ? string : number extends V ? Exclude<V, string> : V extends readonly (infer U)[] ? NarrowValue<U>[] : V extends object ? Narrow<V> : V
|
||||
type Narrow<T> = { [K in keyof T]: NarrowValue<T[K]> }
|
||||
|
||||
export type AgentDto = Narrow<S['AgentDto']>
|
||||
export type ConnectedProbeDto = Narrow<S['ConnectedProbeDto']>
|
||||
export type ProbeDto = Narrow<S['ProbeDto']>
|
||||
export type ProbeConnectionDto = Narrow<S['ProbeConnectionDto']>
|
||||
export type ProbeIdentityDto = Narrow<S['ProbeIdentityDto']>
|
||||
export type LineDto = Narrow<S['LineDto']>
|
||||
export type OverviewDto = Narrow<S['OverviewDto']>
|
||||
export type OverviewLineDto = Narrow<S['OverviewLineDto']>
|
||||
export type SeriesDto = Narrow<S['SeriesDto']>
|
||||
export type CompareDto = Narrow<S['CompareDto']>
|
||||
export type SummaryDto = Narrow<S['SummaryDto']>
|
||||
export type TaskDto = Narrow<S['TaskDto']>
|
||||
export type TestDto = Narrow<S['TestDto']>
|
||||
export type TestResultDto = Narrow<S['TestResultDto']>
|
||||
export type ScoreDto = Narrow<S['ScoreDto']>
|
||||
export type PlanDto = Narrow<S['PlanDto']>
|
||||
export type KindDto = Narrow<S['KindDto']>
|
||||
export type ParamDto = Narrow<S['ParamDto']>
|
||||
export type MetricDto = Narrow<S['MetricDto']>
|
||||
export type JoinTokenDto = Narrow<S['JoinTokenDto']>
|
||||
export type CreatedJoinTokenDto = Narrow<S['CreatedJoinTokenDto']>
|
||||
export type ApiTokenDto = Narrow<S['ApiTokenDto']>
|
||||
export type CreatedApiTokenDto = Narrow<S['CreatedApiTokenDto']>
|
||||
export type SettingDto = Narrow<S['SettingDto']>
|
||||
export type UserDto = Narrow<S['UserDto']>
|
||||
export type EventDto = Narrow<S['EventDto']>
|
||||
export type GapDto = Narrow<S['GapDto']>
|
||||
export type RecomputeDto = Narrow<S['RecomputeDto']>
|
||||
export type RawChunkMetaDto = Narrow<S['RawChunkMetaDto']>
|
||||
export type LoginResponse = Narrow<S['LoginResponse']>
|
||||
export type MeDto = Narrow<S['MeDto']>
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[]
|
||||
next_cursor?: string | null
|
||||
}
|
||||
|
||||
/** 后端数字有时按 integer|string 声明:统一成 number。 */
|
||||
export const n = (v: number | string | null | undefined): number => (v === null || v === undefined ? 0 : Number(v))
|
||||
Reference in new issue
Block a user