chore: initial project import
This commit is contained in:
commit
3fc4e5ca53
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))
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user