chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 3fc4e5ca53
347 files changed
+56172

No files matched your search

+65
View File
@@ -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
+132
View File
@@ -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
}
+47
View File
@@ -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))
+189
View File
@@ -0,0 +1,189 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import uPlot, { type AlignedData, type Options } from 'uplot'
import 'uplot/dist/uPlot.min.css'
export interface ChartSeries {
label: string
values: (number | null)[]
/** CSS 变量名,例如 --chart-1。 */
color: string
dash?: number[]
fill?: boolean
}
interface Props {
/** 时间轴,Unix 毫秒。 */
t: number[]
series: ChartSeries[]
height?: number
/** Y 轴的单位后缀(ms、%)。 */
unit?: string
/** 为真时 Y 轴从 0 开始。 */
zero?: boolean
/** 缺口:这些时间段用底色标出。 */
gaps?: { from: number; to: number }[]
}
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
function formatValue(v: number | null | undefined, unit: string): string {
if (v === null || v === undefined) return '—'
const text = Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2)
return `${text}${unit}`
}
/**
* uPlot 的封装:
* - 跟随主题(明暗切换时重绘)和容器宽度,空值断线;
* - 在图上拖动选一段放大预览,双击或点「还原」恢复;数据刷新时保持放大的区间;
* - 鼠标悬停显示这个时刻每条线的值。
*/
export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [] }: Props) {
const host = useRef<HTMLDivElement>(null)
const tip = useRef<HTMLDivElement>(null)
const plot = useRef<uPlot | null>(null)
const zoom = useRef<{ min: number; max: number } | null>(null)
const [zoomed, setZoomed] = useState(false)
const reset = useCallback(() => {
zoom.current = null
setZoomed(false)
const u = plot.current
if (u && u.data[0].length > 0) u.setScale('x', { min: u.data[0][0], max: u.data[0][u.data[0].length - 1] })
}, [])
useEffect(() => {
const el = host.current
if (!el) return
const build = () => {
plot.current?.destroy()
const axis = cssVar('--chart-axis')
const grid = cssVar('--chart-grid')
const font = '12px ' + (cssVar('--font-sans') || 'sans-serif')
const shade = cssVar('--app-warn-container')
const options: Options = {
width: el.clientWidth || 600,
height,
legend: { show: false },
cursor: { drag: { x: true, y: false, setScale: false }, points: { size: 6 } },
select: { show: true, left: 0, top: 0, width: 0, height: 0 },
scales: {
x: { time: true, range: (_u, min, max) => (zoom.current ? [zoom.current.min, zoom.current.max] : [min, max]) },
y: { range: (_u, min, max) => [zero ? Math.min(0, min) : min, max <= min ? min + 1 : max + (max - min) * 0.08] },
},
axes: [
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { stroke: grid }, font },
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font, size: 56, values: (_u, ticks) => ticks.map((v) => `${v}${unit}`) },
],
series: [
{},
...series.map((s) => {
const color = cssVar(s.color)
return {
label: s.label,
stroke: color,
width: 1.8,
dash: s.dash,
fill: s.fill ? color + '22' : undefined,
spanGaps: false,
points: { show: false },
}
}),
],
hooks: {
ready: [
(u) => {
u.over.addEventListener('dblclick', reset)
},
],
setSelect: [
(u) => {
if (u.select.width < 6) return
const min = u.posToVal(u.select.left, 'x')
const max = u.posToVal(u.select.left + u.select.width, 'x')
if (max - min < 5) return
zoom.current = { min, max }
setZoomed(true)
u.setSelect({ left: 0, top: 0, width: 0, height: 0 }, false)
u.setScale('x', { min, max })
},
],
setCursor: [
(u) => {
const box = tip.current
if (!box) return
const idx = u.cursor.idx
if (idx === null || idx === undefined || (u.cursor.left ?? -1) < 0) {
box.style.display = 'none'
return
}
const when = new Date(u.data[0][idx] * 1000)
const lines = [when.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })]
for (let i = 0; i < series.length; i++) lines.push(`${series[i].label} ${formatValue(u.data[i + 1][idx], unit)}`)
box.textContent = lines.join('\n')
box.style.display = 'block'
const width = box.offsetWidth
const left = (u.cursor.left ?? 0) + 14
box.style.left = `${left + width > u.over.clientWidth ? Math.max(0, (u.cursor.left ?? 0) - width - 14) : left}px`
box.style.top = '8px'
},
],
drawClear: [
(u) => {
const { ctx } = u
ctx.save()
ctx.fillStyle = shade
for (const g of gaps) {
const x0 = Math.max(u.bbox.left, u.valToPos(g.from / 1000, 'x', true))
const x1 = Math.min(u.bbox.left + u.bbox.width, u.valToPos(g.to / 1000, 'x', true))
if (x1 > x0) ctx.fillRect(x0, u.bbox.top, x1 - x0, u.bbox.height)
}
ctx.restore()
},
],
},
}
const data: AlignedData = [t.map((v) => v / 1000), ...series.map((s) => s.values.map((v) => (v === null ? null : v)))]
plot.current = new uPlot(options, data, el)
// 把提示框放进绘图区,这样它的位置和光标是同一个坐标系
if (tip.current) plot.current.over.appendChild(tip.current)
}
build()
const resize = new ResizeObserver(() => plot.current?.setSize({ width: el.clientWidth, height }))
resize.observe(el)
const theme = new MutationObserver(build)
theme.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
return () => {
resize.disconnect()
theme.disconnect()
plot.current?.destroy()
plot.current = null
}
}, [t, series, height, unit, zero, gaps, reset])
return (
<div className="chart-wrap">
<div className="chart-legend">
{series.map((s) => (
<span key={s.label}>
<i style={{ background: `var(${s.color})` }} />
{s.label}
</span>
))}
<span className="chart-hint">
{zoomed ? (
<button type="button" className="btn small text" onClick={reset}>
还原缩放
</button>
) : (
'拖动选择一段放大,双击还原'
)}
</span>
</div>
<div ref={host} className="chart" />
<div ref={tip} className="chart-tip" />
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
export function fmtMs(v: number | null | undefined, digits = 1): string {
if (v === null || v === undefined || Number.isNaN(v)) return '—'
if (v >= 1000) return `${(v / 1000).toFixed(2)} s`
return `${v.toFixed(v >= 100 ? 0 : digits)} ms`
}
export function fmtPct(v: number | null | undefined, digits = 2): string {
if (v === null || v === undefined || Number.isNaN(v)) return '—'
return `${v.toFixed(v >= 10 ? 1 : digits)}%`
}
export function fmtMbps(v: number | null | undefined): string {
if (v === null || v === undefined || Number.isNaN(v)) return '—'
return v >= 100 ? `${v.toFixed(0)} Mbit/s` : `${v.toFixed(1)} Mbit/s`
}
export function fmtNum(v: number | null | undefined, digits = 0): string {
if (v === null || v === undefined || Number.isNaN(v)) return '—'
return v.toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
export function fmtTime(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
export function fmtAgo(iso: string | null | undefined): string {
if (!iso) return '—'
const seconds = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000)
if (seconds < 60) return `${Math.floor(seconds)} 秒前`
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟前`
if (seconds < 86400) return `${Math.floor(seconds / 3600)} 小时前`
return `${Math.floor(seconds / 86400)} 天前`
}
export function fmtDuration(ms: number): string {
const s = Math.floor(ms / 1000)
if (s < 60) return `${s} 秒`
if (s < 3600) return `${Math.floor(s / 60)} 分钟`
if (s < 86400) return `${Math.floor(s / 3600)} 小时 ${Math.floor((s % 3600) / 60)} 分钟`
return `${Math.floor(s / 86400)} 天 ${Math.floor((s % 86400) / 3600)} 小时`
}
/** 评分 → 状态色(和后端模型的分档一致:≥85 好,≥60 一般,其余差)。 */
export function scoreTone(score: number | null | undefined): 'ok' | 'warn' | 'bad' | '' {
if (score === null || score === undefined) return ''
if (score >= 85) return 'ok'
if (score >= 60) return 'warn'
return 'bad'
}
+79
View File
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ApiError } from './api/client'
export interface QueryState<T> {
data: T | undefined
error: ApiError | Error | undefined
loading: boolean
reload: () => void
}
/** 取数:依赖变化时重新取;可选轮询。重新取数时保留上一次的数据,避免界面闪烁。 */
export function useQuery<T>(fetcher: () => Promise<T>, deps: unknown[], intervalMs?: number): QueryState<T> {
const [data, setData] = useState<T>()
const [error, setError] = useState<ApiError | Error>()
const [loading, setLoading] = useState(true)
const [tick, setTick] = useState(0)
const fetcherRef = useRef(fetcher)
fetcherRef.current = fetcher
useEffect(() => {
let alive = true
setLoading(true)
fetcherRef
.current()
.then((value) => {
if (!alive) return
setData(value)
setError(undefined)
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e : new Error(String(e)))
})
.finally(() => {
if (alive) setLoading(false)
})
return () => {
alive = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, tick])
useEffect(() => {
if (!intervalMs) return
const id = window.setInterval(() => setTick((t) => t + 1), intervalMs)
return () => window.clearInterval(id)
}, [intervalMs])
const reload = useCallback(() => setTick((t) => t + 1), [])
return { data, error, loading, reload }
}
export type Theme = 'light' | 'dark'
export function useTheme(): [Theme, () => void] {
const [theme, setTheme] = useState<Theme>(() => (document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'))
const toggle = useCallback(() => {
const next: Theme = theme === 'dark' ? 'light' : 'dark'
document.documentElement.dataset.theme = next
try {
localStorage.setItem('monitor-theme', next)
} catch {
// 存储不可用时只在本次会话生效
}
setTheme(next)
}, [theme])
return [theme, toggle]
}
/** 首屏之前调用:按保存的选择或系统偏好设置主题。 */
export function applyStoredTheme() {
let theme: string | null = null
try {
theme = localStorage.getItem('monitor-theme')
} catch {
// 忽略
}
const dark = theme ? theme === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
}
+162
View File
@@ -0,0 +1,162 @@
/**
* 后端的错误信息是英文(API 契约就是英文):前端在这里把常见的错误码、字段名和校验信息翻成中文。
* 没有收录的信息原样显示,不会丢。
*/
const CODES: Record<string, string> = {
validation_failed: '填写的内容有问题',
budget_exceeded: '超出了这台节点本地设定的带宽或包速率上限',
unauthorized: '用户名或密码不对,或者登录已经失效',
csrf_failed: '登录状态已经过期,请刷新页面重新登录',
forbidden: '没有权限做这件事',
site_private: '站点未公开:请管理员登录,或在管理端把站点设为公开',
not_found: '没有找到',
agent_not_found: '没有找到这个 Agent',
probe_not_found: '没有找到这个 Probe',
probe_revoked: '这个 Probe 已经被吊销,不能再换密钥,请新建一个 Probe',
probe_required: '有多个 Probe,请指定具体的线路',
task_not_found: '没有找到这个任务',
test_not_found: '没有找到这次测试',
chunk_not_found: '没有找到这个原始数据块',
token_not_found: '没有找到这个令牌',
unknown_kind: '不认识这种采集方法',
unknown_plan: '不认识这个测试方案',
name_conflict: '名称已经被使用,换一个',
probe_offline: '这个 Probe 现在没有连着这个 Agent',
test_busy: '这条线路上已经有测试在运行',
agent_not_active: '这个 Agent 还没有启用',
agent_offline: 'Agent 不在线',
raw_expired: '这份原始数据已经超过保留期限',
join_token_expired: '加入令牌已经过期',
precondition_failed: '这条记录刚被别人改过,请刷新后再改',
payload_too_large: '提交的内容太大',
range_too_large: '选的时间范围太大,请缩短或增大步长',
invalid_step: '步长不合法',
invalid_range: '时间范围不合法',
unknown_metric: '不认识这个指标',
unknown_preset: '不认识这个指标分组',
too_many_metrics: '一次选的指标太多',
too_many_requests: '请求太频繁,请稍后再试',
too_many_streams: '实时连接太多',
storage_unavailable: '存储暂时不可用,请稍后再试',
fleet_unsupported: '这个 Agent 不支持这项功能',
fleet_join_token_invalid: '加入令牌无效',
fleet_node_disabled: '这个节点已被停用',
internal_error: '服务器出错了,请稍后再试',
method_not_allowed: '不支持这个操作',
unsupported_media_type: '提交的格式不对',
}
const FIELDS: Record<string, string> = {
name: '名称',
username: '用户名',
password: '密码',
note: '备注',
labels: '标签',
public_host: '公开地址',
public_visible: '展示页可见',
max_uses: '可用次数',
expires_in_hours: '有效期',
scope: '类型',
kind: '采集方法',
target: '目标',
schedule: '调度',
agent_id: 'Agent',
base_task_id: '要覆盖的模板',
plan: '方案',
params: '参数',
range: '时间范围',
from: '开始时间',
to: '结束时间',
step: '步长',
lines: '线路',
metric: '指标',
metrics: '指标',
cursor: '分页游标',
anchor: '时间锚点',
tz: '时区',
}
const MESSAGES: [RegExp, (m: RegExpMatchArray) => string][] = [
[/^is required$/, () => '不能为空'],
[/^must be 1-64 characters( of letters, digits|: letters \(any language\), digits), '\.', '_' or '-'$/, () => '只能是 1–64 个字、字母、数字、点、下划线或连字符(可以用中文)'],
[/^must be 3-32 characters of letters, digits, '\.', '_' or '-'$/, () => '只能是 3–32 个字母、数字、点、下划线或连字符'],
[/^must be 1-64 characters$/, () => '长度需要 1–64 个字符'],
[/^must be (\d+)-(\d+) characters$/, (m) => `长度需要 ${m[1]}–${m[2]} 个字符`],
[/^must be at most (\d+) characters$/, (m) => `最多 ${m[1]} 个字符`],
[/^at most (\d+) labels of (\d+)-(\d+) characters$/, (m) => `最多 ${m[1]} 个标签,每个 ${m[2]}–${m[3]} 个字符`],
[/^must be a host name or IP address$/, () => '必须是域名或 IP 地址'],
[/^must be an IP address with an optional port$/, () => '必须是 IP 地址,端口可选'],
[/^must be between (-?[\d.]+) and (-?[\d.]+)$/, (m) => `必须在 ${m[1]} 到 ${m[2]} 之间`],
[/^must be an integer$/, () => '必须是整数'],
[/^must be a number$/, () => '必须是数字'],
[/^must be a boolean$/, () => '必须是 是 / 否'],
[/^must be one of: (.*)$/, (m) => `只能是:${m[1]}`],
[/^is not a parameter of this kind$/, () => '这种采集方法没有这个参数'],
[/^must be line or target$/, () => '只能是线路任务或目标任务'],
[/^must be read or admin$/, () => '只能是 read(只读)或 admin(全部权限)'],
[/^must be now or latest$/, () => '只能是 now 或 latest'],
[/^is not a valid cursor$/, () => '不是有效的分页游标'],
[/^is mutually exclusive with from and to$/, () => '不能和开始、结束时间同时使用'],
[/^only UTC is supported at the moment$/, () => '目前只支持 UTC'],
[/^line tasks have no target$/, () => '线路任务没有目标'],
[/^agent_id is required for target tasks$/, () => '目标任务必须指定 Agent'],
[/^per-agent line tasks must be overrides \(base_task_id\)$/, () => '针对某个 Agent 的线路任务必须是覆盖(要选一个模板)'],
[/^must reference a line template and requires agent_id$/, () => '必须引用一个线路模板,并且要指定 Agent'],
[/^an override must use the same kind as its template$/, () => '覆盖必须和它的模板是同一种采集方法'],
[/^once tasks are created through the tests API$/, () => '一次性测试请在「按需测试」里发起'],
[/^unknown kind (.+)$/, (m) => `不认识采集方法 ${m[1]}`],
[/^unknown plan (.+)$/, (m) => `不认识测试方案 ${m[1]}`],
[/^a probe named (.+) already exists$/, (m) => `已经有一个叫 ${m[1]} 的 Probe`],
[/^probe (\S+) has no session with this agent$/, () => '这个 Probe 现在没有连着这个 Agent'],
[/^probe (\S+) is not connected to this agent$/, () => '这个 Probe 现在没有连着这个 Agent'],
[/^this probe has no session with this agent$/, () => '这个 Probe 现在没有连着这个 Agent'],
[/^the probe has no session with this agent$/, () => '这个 Probe 现在没有连着这个 Agent'],
[/^another test is already running on this line$/, () => '这条线路上已经有测试在运行'],
[/^agent is offline$/, () => 'Agent 不在线'],
[/^agent is not active$/, () => 'Agent 还没有启用'],
[/^agent not found$/, () => '没有找到这个 Agent'],
[/^probe not found$/, () => '没有找到这个 Probe'],
[/^line not found$/, () => '没有找到这条线路'],
[/^there is no active probe$/, () => '现在没有可用的 Probe'],
[/^several probes exist.*$/, () => '有多个 Probe,请指定具体的线路'],
[/^a revoked probe cannot get a new key.*$/, () => '已吊销的 Probe 不能换密钥,请新建一个 Probe'],
[/^this agent does not support (\S+), which the (\S+) plan needs$/, (m) => `这个 Agent 不支持 ${m[1]},而 ${m[2]} 方案需要它`],
[/^the agent did not accept the test$/, () => 'Agent 没有接受这次测试'],
[/^a valid X-CSRF-Token header is required$/, () => '登录状态已经过期,请刷新页面重新登录'],
]
export function translateMessage(message: string): string {
const text = message.trim()
for (const [pattern, render] of MESSAGES) {
const match = text.match(pattern)
if (match) return render(match)
}
return text
}
export const translateField = (field: string): string => FIELDS[field] ?? field
export function translateCode(code: string): string | undefined {
return CODES[code]
}
const SETTINGS: Record<string, string> = {
'site.public': '匿名访客能不能读展示页的数据(关闭时只有登录的管理员能看)',
'retention.raw_days': '持续任务的原始数据块保留多少天',
'retention.test_raw_days': '按需测试的原始数据块保留多少天',
'retention.metric_10s_hours': '10 秒精度的指标保留多少小时',
'retention.metric_1m_days': '1 分钟精度的指标保留多少天',
'retention.metric_5m_days': '5 分钟精度的指标保留多少天',
'retention.events_days': '事件保留多少天',
'retention.host_days': 'Agent 主机资源占用(每分钟一个样本)保留多少天',
}
const PLANS: Record<string, { title: string; description: string }> = {
hy2_ladder: { title: 'Hysteria2 阶梯测试', description: 'UDP 灌包,依次以 10、25、50、100 Mbit/s 各跑 10 秒,用来判断 Brutal 固定码率拥塞控制能不能跑得起来。' },
reality_session: { title: 'REALITY 会话测试', description: '建连测试、4 秒 TLS 批量传输(量吞吐峰值)和一个长连接保持。' },
}
export const translateSetting = (key: string, fallback: string): string => SETTINGS[key] ?? fallback
export const translatePlan = (id: string, title: string, description: string): { title: string; description: string } => PLANS[id] ?? { title, description }
+637
View File
@@ -0,0 +1,637 @@
@import "./tokens.css";
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--app-bg);
color: var(--app-ink);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3,
h4,
p {
margin: 0;
}
a {
color: var(--app-accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
.mono {
font-family: var(--font-mono);
font-size: 0.92em;
}
.muted {
color: var(--app-ink-muted);
}
.faint {
color: var(--app-ink-faint);
}
.num {
font-variant-numeric: tabular-nums;
}
.row {
display: flex;
align-items: center;
gap: 12px;
}
.row.wrap {
flex-wrap: wrap;
}
.grow {
flex: 1;
min-width: 0;
}
.stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.right {
margin-left: auto;
}
/* ── 卡片:靠色调分层,没有描边 ── */
.card {
background: var(--app-card);
border-radius: var(--r-card);
padding: 20px 24px;
}
.card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.inset {
background: var(--app-card-inset);
border-radius: var(--r-control);
padding: 12px 16px;
}
.divider {
height: 1px;
background: var(--app-outline-soft);
border: 0;
margin: 16px 0;
}
/* ── 按钮 ── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 36px;
padding: 0 18px;
border: 0;
border-radius: var(--r-pill);
background: var(--app-card-inset);
color: var(--app-ink);
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--app-card-inset), var(--app-ink) calc(var(--state-hover) * 100%));
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
.btn.primary {
background: var(--app-primary);
color: var(--app-on-primary);
}
.btn.primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--app-primary), var(--app-on-primary) calc(var(--state-hover) * 100%));
}
.btn.accent {
background: var(--app-accent);
color: var(--app-card);
}
.btn.text {
background: transparent;
color: var(--app-accent);
padding: 0 12px;
}
.btn.text:hover:not(:disabled) {
background: color-mix(in srgb, var(--app-accent), transparent calc((1 - var(--state-hover)) * 100%));
}
.btn.danger {
background: var(--app-bad-container);
color: var(--app-bad);
}
.btn.icon {
width: 36px;
padding: 0;
}
.btn.small {
height: 30px;
padding: 0 12px;
font-size: 13px;
}
/* ── 连接式分组(M3 Expressive connected button group):组内小圆角、外角大圆角、选中段变胶囊 ── */
.group {
display: inline-flex;
gap: 2px;
}
.group > button {
height: 36px;
padding: 0 16px;
border: 0;
background: var(--app-card-inset);
color: var(--app-ink-muted);
font-weight: 500;
cursor: pointer;
border-radius: var(--r-group-inner);
transition:
border-radius 0.18s,
background 0.15s;
}
.group > button:first-child {
border-radius: var(--r-group-outer) var(--r-group-inner) var(--r-group-inner) var(--r-group-outer);
}
.group > button:last-child {
border-radius: var(--r-group-inner) var(--r-group-outer) var(--r-group-outer) var(--r-group-inner);
}
.group > button:only-child {
border-radius: var(--r-group-outer);
}
.group > button:hover {
background: color-mix(in srgb, var(--app-card-inset), var(--app-ink) calc(var(--state-hover) * 100%));
}
.group > button.on {
background: var(--app-accent-container);
color: var(--app-on-accent-container);
border-radius: var(--r-group-outer);
}
/* ── 表单 ── */
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.field > label,
.field > .label {
font-size: 12px;
color: var(--app-ink-muted);
}
.field .hint {
font-size: 12px;
color: var(--app-ink-faint);
}
.input,
.select,
.textarea {
width: 100%;
min-height: 40px;
padding: 8px 14px;
border: 0;
border-radius: var(--r-control);
background: var(--app-card-inset);
outline: 2px solid transparent;
outline-offset: -2px;
transition: outline-color 0.15s;
}
.textarea {
resize: vertical;
font-family: var(--font-mono);
font-size: 13px;
}
.input:focus,
.select:focus,
.textarea:focus {
outline-color: var(--app-accent);
}
.input::placeholder {
color: var(--app-ink-faint);
}
.check {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.check input {
accent-color: var(--app-accent);
width: 16px;
height: 16px;
}
/* ── 小件 ── */
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 10px;
border-radius: var(--r-pill);
background: var(--app-card-inset);
color: var(--app-ink-muted);
font-size: 12px;
white-space: nowrap;
}
.chip.ok {
background: var(--app-ok-container);
color: var(--app-ok);
}
.chip.warn {
background: var(--app-warn-container);
color: var(--app-warn);
}
.chip.bad {
background: var(--app-bad-container);
color: var(--app-bad);
}
.chip.accent {
background: var(--app-accent-container);
color: var(--app-on-accent-container);
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--app-idle);
}
.dot.ok {
background: var(--app-ok);
}
.dot.warn {
background: var(--app-warn);
}
.dot.bad {
background: var(--app-bad);
}
/* ── 表格:细分隔线,没有边框 ── */
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
text-align: left;
font-weight: 500;
font-size: 12px;
color: var(--app-ink-faint);
padding: 8px 12px;
border-bottom: 1px solid var(--app-outline);
white-space: nowrap;
}
.table td {
padding: 12px;
border-bottom: 1px solid var(--app-outline-soft);
vertical-align: middle;
}
.table tr:last-child td {
border-bottom: 0;
}
.table.hover tbody tr {
cursor: pointer;
}
.table.hover tbody tr:hover td {
background: var(--app-card-hover);
}
.table th.r,
.table td.r {
text-align: right;
}
/* ── 指标块 ── */
.stat {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.stat .k {
font-size: 12px;
color: var(--app-ink-faint);
}
.stat .v {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.stat .v small {
font-size: 12px;
font-weight: 400;
color: var(--app-ink-muted);
margin-left: 2px;
}
.stat .v.ok {
color: var(--app-ok);
}
.stat .v.warn {
color: var(--app-warn);
}
.stat .v.bad {
color: var(--app-bad);
}
.grid {
display: grid;
gap: 16px;
}
.grid.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.grid.stats {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
@media (max-width: 900px) {
.grid.cols-2,
.grid.cols-3,
.grid.cols-4 {
grid-template-columns: minmax(0, 1fr);
}
}
/* ── 对话框与提示 ── */
.scrim {
position: fixed;
inset: 0;
background: color-mix(in srgb, var(--app-scrim), transparent 60%);
display: grid;
place-items: center;
z-index: 50;
padding: 16px;
}
.dialog {
background: var(--app-card);
border-radius: 28px;
padding: 24px;
width: min(560px, 100%);
max-height: calc(100vh - 32px);
overflow: auto;
box-shadow: 0 8px 32px color-mix(in srgb, var(--app-shadow), transparent 80%);
}
.dialog h2 {
font-size: 20px;
margin-bottom: 16px;
}
.dialog .actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.toasts {
position: fixed;
right: 24px;
bottom: 24px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 60;
}
.toast {
background: var(--app-primary);
color: var(--app-on-primary);
border-radius: var(--r-control);
padding: 10px 16px;
max-width: 420px;
box-shadow: 0 4px 16px color-mix(in srgb, var(--app-shadow), transparent 75%);
}
.toast.bad {
background: var(--app-bad);
color: var(--app-card);
}
.empty {
padding: 40px 16px;
text-align: center;
color: var(--app-ink-faint);
}
.notice {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px 18px;
border-radius: var(--r-control);
background: var(--app-accent-container);
color: var(--app-on-accent-container);
}
.notice.warn {
background: var(--app-warn-container);
color: var(--app-warn);
}
.notice.bad {
background: var(--app-bad-container);
color: var(--app-bad);
}
pre.code {
margin: 0;
padding: 12px 16px;
border-radius: var(--r-control);
background: var(--app-card-inset);
font-family: var(--font-mono);
font-size: 12.5px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid var(--app-outline);
border-top-color: var(--app-accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ── 图表 ── */
/* 拖动选区和十字光标:uPlot 自带的颜色在暗色下几乎看不见,换成主题令牌 */
.chart .u-select {
background: color-mix(in srgb, var(--app-accent), transparent 72%);
border-left: 1px solid var(--app-accent);
border-right: 1px solid var(--app-accent);
}
.chart .u-cursor-x,
.chart .u-cursor-y {
border-color: var(--app-ink-faint);
}
.chart-wrap {
position: relative;
}
.chart-hint {
margin-left: auto;
color: var(--app-ink-faint);
}
.chart-hint .btn {
height: 22px;
padding: 0 8px;
}
.chart-tip {
display: none;
position: absolute;
z-index: 5;
pointer-events: none;
padding: 6px 10px;
border-radius: 8px;
background: var(--app-primary);
color: var(--app-on-primary);
font-size: 12px;
line-height: 1.5;
white-space: pre;
font-variant-numeric: tabular-nums;
}
.group > button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.group > button:disabled:hover {
background: var(--app-card-inset);
}
.chart {
width: 100%;
}
.chart .u-legend {
font-family: var(--font-sans);
font-size: 12px;
color: var(--app-ink-muted);
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 12px;
color: var(--app-ink-muted);
margin-bottom: 4px;
}
.chart-legend i {
display: inline-block;
width: 10px;
height: 3px;
border-radius: 2px;
margin-right: 6px;
vertical-align: middle;
}
+96
View File
@@ -0,0 +1,96 @@
/*
* 「纸墨」主题令牌:暖中性纸白底、深墨主色、唯一的强调色钢笔墨水蓝、复古低饱和的状态色。
* 颜色只在这个文件里出现;其他样式一律引用令牌(stylelint 会拦住 hex / rgb / 具名色)。
*/
:root {
color-scheme: light;
--app-bg: #f4f2ed;
--app-card: #fbfaf7;
--app-card-inset: #ece9e2;
--app-card-hover: #f0ede6;
--app-outline: #d8d4c9;
--app-outline-soft: #e5e1d7;
--app-ink: #1f1d18;
--app-ink-muted: #5c5950;
--app-ink-faint: #8b877b;
--app-primary: #2a2720;
--app-on-primary: #f7f6f3;
--app-accent: #2a5da8;
--app-accent-container: #dce6f4;
--app-on-accent-container: #12305c;
--app-ok: #566f2c;
--app-ok-container: #e3ead2;
--app-warn: #9c6b0e;
--app-warn-container: #f1e4c4;
--app-bad: #a23e2c;
--app-bad-container: #f2d9d2;
--app-idle: #8b877b;
--app-scrim: #1f1d18;
--app-shadow: #1f1d18;
--chart-1: #2a5da8;
--chart-2: #b9770e;
--chart-3: #a23e2c;
--chart-4: #566f2c;
--chart-5: #7a4f7a;
--chart-6: #2f7a76;
--chart-grid: #e1ddd2;
--chart-axis: #7a766b;
--r-card: 24px;
--r-group-outer: 28px;
--r-group-inner: 8px;
--r-control: 12px;
--r-pill: 999px;
--state-hover: 0.08;
--state-press: 0.12;
--font-sans: "Source Han Sans SC", "Noto Sans SC", "Source Han Sans CN", "Microsoft YaHei UI", "PingFang SC", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;
}
:root[data-theme="dark"] {
color-scheme: dark;
--app-bg: #181714;
--app-card: #211f1b;
--app-card-inset: #2b2924;
--app-card-hover: #28261f;
--app-outline: #3b3830;
--app-outline-soft: #2f2d27;
--app-ink: #ece9e1;
--app-ink-muted: #b0ab9e;
--app-ink-faint: #7f7b70;
--app-primary: #ece9e1;
--app-on-primary: #1f1d18;
--app-accent: #8db0e6;
--app-accent-container: #26364f;
--app-on-accent-container: #cfe0f8;
--app-ok: #a6c06c;
--app-ok-container: #2c3520;
--app-warn: #dcae52;
--app-warn-container: #3a2f16;
--app-bad: #e3917f;
--app-bad-container: #432720;
--app-idle: #7f7b70;
--chart-1: #8db0e6;
--chart-2: #e0a74d;
--chart-3: #e3917f;
--chart-4: #a6c06c;
--chart-5: #c79ac7;
--chart-6: #72c0bb;
--chart-grid: #32302a;
--chart-axis: #8f8b7f;
}
+279
View File
@@ -0,0 +1,279 @@
import { Check, Copy, Moon, Plug, Sun } from 'lucide-react'
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { ApiError } from '../api/client'
import { getBase, getToken, normalizeBase, setSource } from '../api/source'
import { useTheme } from '../hooks'
import { translateCode, translateField, translateMessage } from '../i18n'
/** 连接式分组(单选):组内小圆角、外角大圆角,选中段变胶囊。 */
export function Segmented<T extends string>({ options, value, onChange }: { options: { value: T; label: ReactNode; disabled?: boolean; title?: string }[]; value: T; onChange: (v: T) => void }) {
return (
<div className="group" role="tablist">
{options.map((o) => (
<button key={o.value} type="button" role="tab" aria-selected={o.value === value} className={o.value === value ? 'on' : ''} disabled={o.disabled} title={o.title} onClick={() => onChange(o.value)}>
{o.label}
</button>
))}
</div>
)
}
export function Chip({ tone = '', children }: { tone?: 'ok' | 'warn' | 'bad' | 'accent' | ''; children: ReactNode }) {
return <span className={`chip ${tone}`}>{children}</span>
}
export function Dot({ tone = '' }: { tone?: 'ok' | 'warn' | 'bad' | '' }) {
return <span className={`dot ${tone}`} />
}
export function Spinner() {
return <span className="spinner" role="status" aria-label="加载中" />
}
export function Empty({ children }: { children: ReactNode }) {
return <div className="empty">{children}</div>
}
export function Stat({ label, value, unit, tone = '' }: { label: string; value: ReactNode; unit?: string; tone?: 'ok' | 'warn' | 'bad' | '' }) {
return (
<div className="stat">
<span className="k">{label}</span>
<span className={`v ${tone}`}>
{value}
{unit ? <small>{unit}</small> : null}
</span>
</div>
)
}
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<div className="field">
<span className="label">{label}</span>
{children}
{hint ? <span className="hint">{hint}</span> : null}
</div>
)
}
export function Dialog({ title, onClose, children, actions }: { title: string; onClose: () => void; children: ReactNode; actions?: ReactNode }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className="scrim" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div className="dialog" role="dialog" aria-modal="true" aria-label={title}>
<h2>{title}</h2>
{children}
{actions ? <div className="actions">{actions}</div> : null}
</div>
</div>
)
}
/** 错误提示:把常见的错误码、字段名和校验信息翻成中文;没有收录的信息原样显示。 */
export function describeError(e: unknown): string {
if (e instanceof ApiError) {
const fields = Object.entries(e.errors).flatMap(([field, messages]) => messages.map((m) => `${translateField(field)}:${translateMessage(m)}`))
if (fields.length > 0) return fields.join(';')
const byCode = translateCode(e.code)
// 有具体说明的(例如“要覆盖的模板不存在”)优先给说明,翻不出来就用错误码对应的话
const detail = e.detail ? translateMessage(e.detail) : ''
if (detail && detail !== e.detail) return detail
return byCode ?? (detail || e.code)
}
return e instanceof Error ? e.message : String(e)
}
export function ErrorNotice({ error }: { error: unknown }) {
if (!error) return null
const privateSite = error instanceof ApiError && error.code === 'site_private'
return <div className={`notice ${privateSite ? '' : 'bad'}`}>{describeError(error)}</div>
}
/* ── 提示 ── */
interface ToastItem {
id: number
text: string
bad: boolean
}
const ToastContext = createContext<(text: string, bad?: boolean) => void>(() => undefined)
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([])
const nextId = useRef(1)
const push = useCallback((text: string, bad = false) => {
const id = nextId.current++
setItems((list) => [...list, { id, text, bad }])
window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), bad ? 6000 : 3000)
}, [])
return (
<ToastContext.Provider value={push}>
{children}
<div className="toasts" aria-live="polite">
{items.map((t) => (
<div key={t.id} className={`toast ${t.bad ? 'bad' : ''}`}>
{t.text}
</div>
))}
</div>
</ToastContext.Provider>
)
}
export const useToast = () => useContext(ToastContext)
/** 执行一个操作,成功提示、失败显示错误。 */
export function useAction() {
const toast = useToast()
const [busy, setBusy] = useState(false)
const run = useCallback(
async (action: () => Promise<unknown>, done?: string) => {
setBusy(true)
try {
await action()
if (done) toast(done)
return true
} catch (e) {
toast(describeError(e), true)
return false
} finally {
setBusy(false)
}
},
[toast],
)
return { busy, run }
}
/** 确认对话框。 */
export function Confirm({ title, children, confirmText = '确定', danger, onConfirm, onClose }: { title: string; children: ReactNode; confirmText?: string; danger?: boolean; onConfirm: () => void | Promise<void>; onClose: () => void }) {
const [busy, setBusy] = useState(false)
return (
<Dialog
title={title}
onClose={onClose}
actions={
<>
<button type="button" className="btn text" onClick={onClose}>
取消
</button>
<button
type="button"
className={`btn ${danger ? 'danger' : 'primary'}`}
disabled={busy}
onClick={async () => {
setBusy(true)
try {
await onConfirm()
} finally {
setBusy(false)
onClose()
}
}}
>
{confirmText}
</button>
</>
}
>
<div className="muted">{children}</div>
</Dialog>
)
}
export function CopyButton({ text, label = '复制' }: { text: string; label?: string }) {
const [done, setDone] = useState(false)
return (
<button
type="button"
className="btn small"
onClick={async () => {
try {
await navigator.clipboard.writeText(text)
setDone(true)
window.setTimeout(() => setDone(false), 1500)
} catch {
// 没有剪贴板权限时用户可以手动选中
}
}}
>
{done ? <Check size={14} /> : <Copy size={14} />}
{done ? '已复制' : label}
</button>
)
}
export function ThemeToggle() {
const [theme, toggle] = useTheme()
return (
<button type="button" className="btn icon" onClick={toggle} aria-label="切换明暗主题" title="切换明暗主题">
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
)
}
/** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 API 令牌,后端要放行这个前端的来源)。 */
export function SourceButton({ tokenLabel = '只读令牌(站点私有时才需要)' }: { tokenLabel?: string }) {
const [open, setOpen] = useState(false)
const [base, setBase] = useState('')
const [token, setToken] = useState('')
const remote = getBase() !== ''
const show = () => {
setBase(getBase())
setToken(getToken())
setOpen(true)
}
const apply = (nextBase: string, nextToken: string) => {
setSource(nextBase, nextToken)
location.reload()
}
return (
<>
<button type="button" className={`btn icon${remote ? ' primary' : ''}`} onClick={show} aria-label="数据源" title={remote ? `数据源:${getBase()}` : '数据源:同源'}>
<Plug size={18} />
</button>
{open ? (
<Dialog
title="数据源"
onClose={() => setOpen(false)}
actions={
<>
<button type="button" className="btn" onClick={() => apply('', '')}>
恢复同源
</button>
<button type="button" className="btn primary" onClick={() => apply(base, token)}>
保存并刷新
</button>
</>
}
>
<div className="stack">
<p className="muted">默认连这个页面所在的后端。填了地址就连另一个后端(比如本机跑前端、连线上的);此时不用 Cookie,改用 API 令牌,后端需要放行这个页面的来源(<code>Monitor__CorsOrigins</code>)。</p>
<Field label="后端地址" hint={base && normalizeBase(base) !== base.trim() ? `将使用 ${normalizeBase(base)}` : '例如 https://mo.oeo.one;留空 = 同源'}>
<input className="input" value={base} onChange={(e) => setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus />
</Field>
<Field label={tokenLabel}>
<input className="input" type="password" value={token} onChange={(e) => setToken(e.target.value)} autoComplete="off" />
</Field>
</div>
</Dialog>
) : null}
</>
)
}
/** 下载文本文件(probe.json 只给一次)。 */
export function downloadText(filename: string, text: string) {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }))
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}