feat: node info, build info and acrylic web theme
Squash of recent dev work: return_session login, backend switcher, line detail timeline, overview strips, node platform/country/ISP info with flags and icons, build info (version, tag, time) behind site.show_build, and the acrylic display theme.
This commit is contained in:
1 parent
10634bdd80
commit
14b0de3544
95 files changed
+5524
-528
No files matched your search
+7
-4
@@ -10,18 +10,21 @@
|
||||
|
||||
```sh
|
||||
npm install # 在 web/ 下一次装好两个工程的依赖(npm workspaces)
|
||||
npm run dev # 两个一起启动
|
||||
npm run dev # 两个一起启动:展示端 http://localhost:5173/ ,管理端 http://localhost:5173/admin/(也可以直接开 5175)
|
||||
BACKEND_URL=https://mo.oeo.one npm run dev:display # http://127.0.0.1:5173/
|
||||
BACKEND_URL=https://mo.oeo.one npm run dev:admin # http://127.0.0.1:5174/admin/
|
||||
BACKEND_URL=https://mo.oeo.one npm run dev:admin # http://127.0.0.1:5175/admin/
|
||||
# 或者把 .env.example 复制成 .env.local,写好 BACKEND_URL,之后直接 npm run dev:display
|
||||
npm run build # 各自输出到 display/dist 和 admin/dist
|
||||
npm run gen:api # 后端接口变了之后,重新生成 shared/api/schema.ts
|
||||
npm run lint:css # 颜色只能写在 shared/theme/tokens.css,其他样式一律引用令牌
|
||||
```
|
||||
|
||||
连别的后端(本机跑前端、连线上的):点页面右上角(管理端登录页右下角)的插头图标,填后端地址和 API 令牌(展示端:站点私有时需要只读令牌;管理端:admin 范围的令牌),保存后刷新。设置存在浏览器里;跨域时不带 Cookie,所以后端要设 `Monitor__CorsOrigins=<这个前端的来源>`(如 `http://localhost:5173`)。也可以用构建时的 `VITE_API_BASE` 给默认地址。
|
||||
连别的后端(本机跑前端、连线上的):登录页左边选后端(这个页面自己所在的、保存过的,或者「添加后端」),右边都是用户名和密码;添加后端填地址、用户名、密码,登录成功才保存。跨域用不了 Cookie,所以登录时后端会返回会话令牌(`return_session`),前端存在浏览器里、之后每个请求带上,12 小时过期、退出登录即作废;用户看不到也不需要管它。后端需要放行这个页面的来源(`Monitor__CorsOrigins`)。展示端和管理端侧栏的插头图标是同一个切换器。
|
||||
|
||||
约定:
|
||||
- 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 Probe 的版本和来源地址(这些只在管理端)。
|
||||
- 时间范围(15 分钟 … 7 天)只在线路详情页;总览是卡片,Agent 主机状态通过 SSE(`/api/public/live?topics=hosts`)实时更新,其余每 10 秒刷新。
|
||||
- 线路详情页:先选**哪一天**(前一天 / 后一天 / 日期选择,URL 的 `day=YYYY-MM-DD`),下面是这一天的丢包缩略图,上面有可拖动的选区(拖空白处新建、拖两边调长短、拖中间平移;URL 的 `at=起_止`,单位是从选中那天 0 点算起的分钟,可以是负数或超过 1440)。缩略图的**刻度**缺省是 48 小时(连续两天,选区可以跨零点,比如晚高峰 20:00 到凌晨 02:00),也可以切成 24 小时(只看一天;URL 的 `span=24`,选区跨零点时自动用 48 小时)。刻度下方是**选区长度**:30 分钟、1 小时、3 小时、6 小时、12 小时、整天,点一下把选区设成这么长——今天且选区到现在为止的,继续看「最近一段」(实时刷新),否则保持选区的起点往后取。刻度上每小时一根短线,48 小时刻度每 3 小时标一个钟点(24 小时刻度每 2 小时),零点标日期;鼠标移到缩略图上会标出那个位置的时间;点一下空白处或者刻度下面的钟点:离那里近的端点(左端或右端)贴过去,另一个端点不动。鼠标停在轴上或刻度上时,会动的那个端点变色,它到目标位置之间染色:扩大是橙色,收缩是灰色。往日默认是整天;「整天」是本地时区的自然日。时间的文字不写「次日」,跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。
|
||||
- 图表上方有 **EWMA 平滑**开关(关、轻 α=0.3、中 α=0.1、重 α=0.03;URL 的 `ewma=low|mid|high`):开了之后每条线画成指数加权移动平均,原始数据以淡线垫在后面,缺口处断开;只在浏览器里算,不影响汇总的指标数字。
|
||||
- 图表在数据刷新(轮询、实时)时原地更新,不重建;拖选区时只在本地更新,松手才取数。
|
||||
- 总览是卡片,Agent 主机状态通过 SSE(`/api/public/live?topics=hosts`)实时更新,其余每 10 秒刷新。
|
||||
- 生产部署时,把 `display/dist` 放在站点根、`admin/dist` 放在 `/admin/` 下(同源,Cookie 和 CSRF 才能工作)。
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>线路质量 · 管理</title>
|
||||
<title>MyMonitor · 管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+78
-34
@@ -1,7 +1,7 @@
|
||||
import { FlaskConical, ListChecks, LogOut, Radar, Server, Settings } from 'lucide-react'
|
||||
import { FlaskConical, ListChecks, LogOut, Plus, Radar, Server, Settings } from 'lucide-react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { isRemote } from '@shared/api/source'
|
||||
import { activateSource, getBase, listSources } from '@shared/api/source'
|
||||
import { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui'
|
||||
import { useAuth } from './auth'
|
||||
import { AgentDetail } from './pages/AgentDetail'
|
||||
@@ -11,22 +11,34 @@ import { System } from './pages/System'
|
||||
import { Tasks } from './pages/Tasks'
|
||||
import { Tests } from './pages/Tests'
|
||||
|
||||
/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边都是用户名 + 密码。 */
|
||||
function Login() {
|
||||
const { login, loginWithToken } = useAuth()
|
||||
const remote = isRemote()
|
||||
const [token, setTokenText] = useState('')
|
||||
const { login } = useAuth()
|
||||
const active = getBase()
|
||||
const saved = listSources()
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const choose = (base: string) => {
|
||||
setAdding(false)
|
||||
setError('')
|
||||
setPassword('')
|
||||
if (base !== active) {
|
||||
activateSource(base)
|
||||
location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
if (remote) await loginWithToken(token)
|
||||
else await login(username, password)
|
||||
await login(username, password, adding ? url : undefined)
|
||||
} catch (err) {
|
||||
setError(describeError(err))
|
||||
} finally {
|
||||
@@ -34,35 +46,67 @@ function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [{ base: '', label: location.host }, ...saved.map((x) => ({ base: x.base, label: x.base.replace(/^https?:\/\//i, '') }))]
|
||||
const title = adding ? '添加后端' : active ? active.replace(/^https?:\/\//i, '') : location.origin
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<form className="card stack" onSubmit={submit}>
|
||||
<div>
|
||||
<h1>线路质量</h1>
|
||||
<p className="muted">管理端</p>
|
||||
</div>
|
||||
{remote ? (
|
||||
<Field label="管理令牌" hint="在管理端「系统 → API 令牌」里创建,范围选 admin">
|
||||
<input className="input" type="password" value={token} onChange={(e) => setTokenText(e.target.value)} autoComplete="off" autoFocus required />
|
||||
<form className="card login-split" onSubmit={submit}>
|
||||
<aside className="login-side">
|
||||
<div>
|
||||
<h1>MyMonitor</h1>
|
||||
<p className="muted">管理端</p>
|
||||
</div>
|
||||
<div className="src-list" role="listbox" aria-label="后端">
|
||||
{rows.map((row) => {
|
||||
const on = !adding && row.base === active
|
||||
return (
|
||||
<button key={row.base || 'page'} type="button" role="option" aria-selected={on} className={`src-pick${on ? ' on' : ''}`} onClick={() => choose(row.base)}>
|
||||
<span className="src-dot" />
|
||||
<span className="src-text">
|
||||
<b>{row.label}</b>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={adding}
|
||||
className={`src-pick${adding ? ' on' : ''}`}
|
||||
onClick={() => {
|
||||
setAdding(true)
|
||||
setUrl('')
|
||||
setPassword('')
|
||||
setError('')
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span className="src-text">
|
||||
<b>添加后端</b>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<section className="login-main stack">
|
||||
<h2>{title}</h2>
|
||||
{adding ? (
|
||||
<Field label="后端地址">
|
||||
<input className="input" name="backend-url" type="text" inputMode="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required />
|
||||
</Field>
|
||||
) : null}
|
||||
<Field label="用户名">
|
||||
<input className="input" name="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus={!adding} required />
|
||||
</Field>
|
||||
) : (
|
||||
<>
|
||||
<Field label="用户名">
|
||||
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus required />
|
||||
</Field>
|
||||
<Field label="密码">
|
||||
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{error ? <div className="notice bad">{error}</div> : null}
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
登录
|
||||
</button>
|
||||
<Field label="密码">
|
||||
<input className="input" name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
|
||||
</Field>
|
||||
{error ? <div className="notice bad">{error}</div> : null}
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
{adding ? '保存并登录' : '登录'}
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
<div style={{ position: 'fixed', right: 16, bottom: 16 }}>
|
||||
<SourceButton tokenLabel="管理令牌" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -88,7 +132,7 @@ export function App() {
|
||||
<div className="shell">
|
||||
<aside className="side">
|
||||
<a className="brand" href="/">
|
||||
线路质量
|
||||
MyMonitor
|
||||
</a>
|
||||
{NAV.map((group) => (
|
||||
<div key={group.section} style={{ display: 'contents' }}>
|
||||
@@ -102,6 +146,7 @@ export function App() {
|
||||
</div>
|
||||
))}
|
||||
<div className="spacer" />
|
||||
<SourceButton variant="status" />
|
||||
<div className="me">
|
||||
<span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span>
|
||||
<span className="grow">
|
||||
@@ -110,7 +155,6 @@ export function App() {
|
||||
{me.scope}
|
||||
</div>
|
||||
</span>
|
||||
<SourceButton tokenLabel="管理令牌" />
|
||||
<ThemeToggle />
|
||||
<button type="button" className="btn icon" onClick={() => void logout()} aria-label="退出登录" title="退出登录">
|
||||
<LogOut size={18} />
|
||||
|
||||
@@ -69,6 +69,27 @@
|
||||
background: var(--app-card);
|
||||
}
|
||||
|
||||
/* 用户名太长时省略,不要和右边的按钮叠在一起 */
|
||||
.me {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.me .grow {
|
||||
flex: 1 1 100px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.me .grow > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.me .btn,
|
||||
.me .avatar {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -192,3 +213,45 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录页:左边选后端,右边输入凭据 */
|
||||
.login .card.login-split {
|
||||
width: min(760px, 100%);
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 32px 24px;
|
||||
background: var(--app-card-inset);
|
||||
}
|
||||
|
||||
.login-main {
|
||||
padding: 32px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.login-main h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.login .card.login-split {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.login-side {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-main {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
+25
-21
@@ -1,14 +1,13 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { api, ApiError, setCsrf } from '@shared/api/client'
|
||||
import { isRemote, setToken } from '@shared/api/source'
|
||||
import { api, ApiError, loginTo, setCsrf } from '@shared/api/client'
|
||||
import { getBase, normalizeBase, setSource, setToken } from '@shared/api/source'
|
||||
import type { LoginResponse, MeDto } from '@shared/api/types'
|
||||
|
||||
interface Auth {
|
||||
me: MeDto | null
|
||||
ready: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
/** 连别的后端时用 API 令牌登录(不用 Cookie)。 */
|
||||
loginWithToken: (token: string) => Promise<void>
|
||||
/** 用户名密码登录。`base` 是新添加的后端地址(登录成功才保存);不给就登录当前连着的后端。 */
|
||||
login: (username: string, password: string, base?: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -31,35 +30,40 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
.finally(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const login = useCallback(async (username: string, password: string, newBase?: string) => {
|
||||
const base = newBase ? normalizeBase(newBase) : getBase()
|
||||
if (base) {
|
||||
// 别的后端:跨域用不了 Cookie,登录后拿回会话令牌存进浏览器(对用户不可见)
|
||||
const session = await loginTo(base, username, password)
|
||||
setSource(base, session)
|
||||
try {
|
||||
const m = await api.get<MeDto>('/api/admin/auth/me')
|
||||
setCsrf(m.csrf_token ?? null)
|
||||
setMe(m)
|
||||
} catch (e) {
|
||||
setToken('')
|
||||
throw e
|
||||
}
|
||||
return
|
||||
}
|
||||
const r = await api.post<LoginResponse>('/api/admin/auth/login', { username, password })
|
||||
setCsrf(r.csrf_token)
|
||||
setMe({ kind: 'user', scope: 'admin', user_id: r.user_id, username: r.username, csrf_token: r.csrf_token })
|
||||
}, [])
|
||||
|
||||
const loginWithToken = useCallback(async (token: string) => {
|
||||
setToken(token)
|
||||
try {
|
||||
const m = await api.get<MeDto>('/api/admin/auth/me')
|
||||
setCsrf(m.csrf_token ?? null)
|
||||
setMe(m)
|
||||
} catch (e) {
|
||||
setToken('')
|
||||
throw e
|
||||
}
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
if (isRemote()) setToken('')
|
||||
else await api.post('/api/admin/auth/logout')
|
||||
await api.post('/api/admin/auth/logout')
|
||||
} catch {
|
||||
// 会话已经失效也没关系,下面照样清掉本地的
|
||||
} finally {
|
||||
setToken('')
|
||||
setCsrf(null)
|
||||
setMe(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <AuthContext.Provider value={{ me, ready, login, loginWithToken, logout }}>{children}</AuthContext.Provider>
|
||||
return <AuthContext.Provider value={{ me, ready, login, logout }}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth(): Auth {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import '@shared/theme/base.css'
|
||||
import { configureSource } from '@shared/api/source'
|
||||
import { applyStoredTheme } from '@shared/hooks'
|
||||
import { ToastProvider } from '@shared/ui'
|
||||
import { ErrorBoundary, ToastProvider } from '@shared/ui'
|
||||
import { App } from './App'
|
||||
import { AuthProvider } from './auth'
|
||||
import './admin.css'
|
||||
@@ -15,11 +15,13 @@ applyStoredTheme()
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { Ban, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { AgentDto, CreatedJoinTokenDto, JoinTokenDto, Page } from '@shared/api/types'
|
||||
import { fmtAgo, fmtTime } from '@shared/format'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Confirm, CopyButton, Dialog, Dot, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
|
||||
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
||||
import { Chip, CommandTabs, Confirm, CopyButton, Dialog, Dot, Empty, ErrorNotice, Field, guessPlatform, Segmented, Spinner, useAction } from '@shared/ui'
|
||||
|
||||
export const STATUS: Record<string, { label: string; tone: 'ok' | 'warn' | 'bad' }> = {
|
||||
active: { label: '已启用', tone: 'ok' },
|
||||
@@ -45,11 +46,16 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
||||
>
|
||||
<div className="stack">
|
||||
<div className="notice warn">令牌明文只显示这一次,请现在就复制。</div>
|
||||
<Field label="在要加入的 VPS 上运行">
|
||||
<pre className="code">{created.install_command}</pre>
|
||||
<div>
|
||||
<CopyButton text={created.install_command} label="复制安装命令" />
|
||||
</div>
|
||||
<Field label="在要加入的机器上运行">
|
||||
<CommandTabs
|
||||
storageKey="monitor.install_tab"
|
||||
initial={guessPlatform()}
|
||||
tabs={[
|
||||
{ id: 'linux', label: 'Linux', command: created.install_commands.linux },
|
||||
{ id: 'macos', label: 'macOS', command: created.install_commands.macos },
|
||||
{ id: 'windows', label: 'Windows', command: created.install_commands.windows },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="令牌">
|
||||
<pre className="code">{created.plaintext}</pre>
|
||||
@@ -131,10 +137,12 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
||||
export function Agents() {
|
||||
const navigate = useNavigate()
|
||||
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 10_000)
|
||||
const infos = useNodeInfos()
|
||||
const tokens = useQuery(() => api.get<Page<JoinTokenDto>>('/api/admin/join-tokens'), [])
|
||||
const [filter, setFilter] = useState<'all' | 'pending' | 'disabled'>('all')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [revoking, setRevoking] = useState<JoinTokenDto>()
|
||||
const [deleting, setDeleting] = useState<JoinTokenDto>()
|
||||
const { run } = useAction()
|
||||
|
||||
const list = (agents.data?.items ?? []).filter((a) => filter === 'all' || a.status === filter)
|
||||
@@ -187,7 +195,12 @@ export function Agents() {
|
||||
<td>
|
||||
<AgentStatus agent={a} />
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{a.name}</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
<span className="row" style={{ gap: 8 }}>
|
||||
{a.name}
|
||||
<NodeTag info={infos.get(a.id)} />
|
||||
</span>
|
||||
</td>
|
||||
<td className="mono muted">{a.public_host || '—'}</td>
|
||||
<td className="muted">{a.agent_version ?? '—'}</td>
|
||||
<td>
|
||||
@@ -240,12 +253,18 @@ export function Agents() {
|
||||
<Chip tone={state === '可用' ? 'ok' : ''}>{state}</Chip>
|
||||
</td>
|
||||
<td className="r">
|
||||
{state === '可用' ? (
|
||||
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
|
||||
<span className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
|
||||
{state === '可用' ? (
|
||||
<button type="button" className="btn small danger" onClick={() => setRevoking(t)}>
|
||||
<Ban size={14} />
|
||||
吊销
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="btn small danger" onClick={() => setDeleting(t)}>
|
||||
<Trash2 size={14} />
|
||||
吊销
|
||||
删除
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -271,6 +290,20 @@ export function Agents() {
|
||||
吊销之后,这个令牌不能再用来注册 Agent。已经注册的 Agent 不受影响。
|
||||
</Confirm>
|
||||
) : null}
|
||||
{deleting ? (
|
||||
<Confirm
|
||||
title="删除加入令牌"
|
||||
danger
|
||||
confirmText="删除"
|
||||
onClose={() => setDeleting(undefined)}
|
||||
onConfirm={async () => {
|
||||
await run(() => api.del(`/api/admin/join-tokens/${deleting.id}?purge=true`), '已删除')
|
||||
tokens.reload()
|
||||
}}
|
||||
>
|
||||
从列表里彻底删除这条记录。{deleting.revoked_at || deleting.uses >= deleting.max_uses || Date.parse(deleting.expires_at) < Date.now() ? '它已经不能再用了。' : '它现在还能用,删除之后会立刻失效。'}已经注册的 Agent 不受影响。
|
||||
</Confirm>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { api } from '@shared/api/client'
|
||||
import type { Page, ProbeDto, ProbeIdentityDto } from '@shared/api/types'
|
||||
import { fmtAgo, fmtTime } from '@shared/format'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
||||
import { Chip, Confirm, CopyButton, Dialog, downloadText, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
|
||||
|
||||
/** probe.json 只在创建和轮换密钥时返回一次:这里让管理员马上下载或复制。 */
|
||||
@@ -84,6 +85,7 @@ type Pending = { kind: 'rotate' | 'revoke' | 'delete'; probe: ProbeDto }
|
||||
|
||||
export function Probes() {
|
||||
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 8000)
|
||||
const infos = useNodeInfos()
|
||||
const { run } = useAction()
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [renaming, setRenaming] = useState<ProbeDto>()
|
||||
@@ -123,6 +125,7 @@ export function Probes() {
|
||||
<div key={p.id} className="card">
|
||||
<div className="row wrap">
|
||||
<span style={{ fontSize: 18, fontWeight: 700 }}>{p.name}</span>
|
||||
<NodeTag info={infos.get(p.id)} />
|
||||
{p.revoked ? <Chip tone="bad">已吊销</Chip> : <Chip tone={p.connections.length > 0 ? 'ok' : ''}>{p.connections.length > 0 ? `连着 ${p.connections.length} 个 Agent` : '没有连接'}</Chip>}
|
||||
<span className="mono faint">{p.id}</span>
|
||||
<span className="right row wrap">
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { ApiTokenDto, CreatedApiTokenDto, EventDto, Page, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
|
||||
import type { AgentDto, ApiTokenDto, BuildInfoDto, CreatedApiTokenDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
|
||||
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
|
||||
import { fmtNum, fmtTime } from '@shared/format'
|
||||
import { translateSetting } from '@shared/i18n'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Confirm, CopyButton, Dialog, Empty, ErrorNotice, Field, Segmented, Spinner, useAction } from '@shared/ui'
|
||||
|
||||
type Tab = 'settings' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis'
|
||||
type Tab = 'settings' | 'versions' | 'tokens' | 'users' | 'events' | 'raw' | 'analysis'
|
||||
|
||||
function Settings() {
|
||||
const settings = useQuery(() => api.get<Page<SettingDto>>('/api/admin/settings'), [])
|
||||
@@ -76,6 +77,77 @@ function Settings() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 一行:名字、类型、版本、tag、提交、构建时间、系统。没有的写「—」。 */
|
||||
function BuildRow({ name, kind, version, tag, commit, builtAt, platform }: { name: string; kind: string; version?: string | null; tag?: string | null; commit?: string | null; builtAt?: string | null; platform?: string }) {
|
||||
const dash = <span className="faint">—</span>
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ fontWeight: 600 }}>{name}</td>
|
||||
<td>
|
||||
<Chip>{kind}</Chip>
|
||||
</td>
|
||||
<td className="mono">{version || dash}</td>
|
||||
<td className="mono">{tag || dash}</td>
|
||||
<td className="mono">{commit || dash}</td>
|
||||
<td className="muted">{fmtBuiltAt(builtAt) || dash}</td>
|
||||
<td className="muted">{platform || dash}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/** 每个程序的版本:后端、前端、每个 Agent 和 Probe 自己上报的(它们没升级就能看出来)。 */
|
||||
function Versions() {
|
||||
const info = useQuery(() => api.get<BuildInfoDto>('/api/admin/build-info'), [], 15_000)
|
||||
const agents = useQuery(() => api.get<Page<AgentDto>>('/api/admin/agents'), [], 30_000)
|
||||
const probes = useQuery(() => api.get<Page<ProbeDto>>('/api/admin/probes'), [], 30_000)
|
||||
const names = new Map<string, string>([...(agents.data?.items ?? []).map((a) => [a.id, a.name] as const), ...(probes.data?.items ?? []).map((p) => [p.id, p.name] as const)])
|
||||
const back = info.data?.backend
|
||||
const nodes = info.data?.nodes ?? []
|
||||
// 以后端的版本为准,节点的版本不一样就标出来(升级了一半的时候一眼能看到)
|
||||
const mismatch = (v?: string | null) => Boolean(v && back && v !== back.version)
|
||||
return (
|
||||
<div className="card">
|
||||
<ErrorNotice error={info.error} />
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>版本</th>
|
||||
<th>tag</th>
|
||||
<th>提交</th>
|
||||
<th>构建时间</th>
|
||||
<th>系统</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{back ? <BuildRow name="后端" kind="backend" version={back.version} tag={back.tag} commit={back.commit} builtAt={back.built_at} /> : null}
|
||||
<BuildRow name="前端" kind="web" version={frontendBuild.version} tag={frontendBuild.tag} commit={frontendBuild.commit} builtAt={frontendBuild.builtAt} />
|
||||
{nodes.map((n) => (
|
||||
<tr key={n.id}>
|
||||
<td style={{ fontWeight: 600 }}>{names.get(n.id) ?? n.id}</td>
|
||||
<td>
|
||||
<Chip>{n.kind}</Chip>
|
||||
</td>
|
||||
<td className="mono">
|
||||
{n.version ? mismatch(n.version) ? <Chip tone="warn">{n.version}</Chip> : n.version : <span className="faint">—</span>}
|
||||
</td>
|
||||
<td className="mono">{n.tag || <span className="faint">—</span>}</td>
|
||||
<td className="mono">{n.commit || <span className="faint">—</span>}</td>
|
||||
<td className="muted">{fmtBuiltAt(n.built_at) || <span className="faint">—</span>}</td>
|
||||
<td className="muted">{[n.os_name, n.arch].filter(Boolean).join(' / ') || <span className="faint">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{nodes.length === 0 && !info.loading ? <Empty>还没有节点上报过版本</Empty> : null}
|
||||
<p className="muted" style={{ marginTop: 12 }}>
|
||||
版本、tag 和构建时间是各个程序自己上报的。设置里打开「site.show_build」之后,展示页底部和节点卡片上才会给访客显示(提交号只在这里显示)。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Tokens() {
|
||||
const tokens = useQuery(() => api.get<Page<ApiTokenDto>>('/api/admin/api-tokens'), [])
|
||||
const { run } = useAction()
|
||||
@@ -418,6 +490,7 @@ export function System() {
|
||||
<Segmented
|
||||
options={[
|
||||
{ value: 'settings' as const, label: '设置' },
|
||||
{ value: 'versions' as const, label: '版本' },
|
||||
{ value: 'tokens' as const, label: 'API 令牌' },
|
||||
{ value: 'users' as const, label: '用户' },
|
||||
{ value: 'events' as const, label: '事件' },
|
||||
@@ -427,7 +500,7 @@ export function System() {
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
{tab === 'settings' ? <Settings /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
|
||||
{tab === 'settings' ? <Settings /> : tab === 'versions' ? <Versions /> : tab === 'tokens' ? <Tokens /> : tab === 'users' ? <Users /> : tab === 'events' ? <Events /> : tab === 'raw' ? <Raw /> : <Analysis />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import { buildDefines } from '../build-info.mjs'
|
||||
|
||||
// 开发时把 /api 转发给后端,地址取自环境变量 BACKEND_URL,或 web/.env.local 里的 BACKEND_URL=(见 web/.env.example):
|
||||
// BACKEND_URL=https://mo.oeo.one npm run dev:display
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '')
|
||||
const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:5000').replace(/\/+$/, '')
|
||||
const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:29181').replace(/\/+$/, '')
|
||||
return {
|
||||
base: '/admin/',
|
||||
define: buildDefines(mode === 'development'),
|
||||
plugins: [react()],
|
||||
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
|
||||
server: {
|
||||
port: 5174,
|
||||
// 5174 常被 Docker Desktop 占用,管理端用 5175;端口被占时直接报错,不悄悄换到别的端口
|
||||
port: 5175,
|
||||
strictPort: true,
|
||||
fs: { allow: ['..'] },
|
||||
proxy: {
|
||||
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export function buildDefines(dev: boolean): Record<string, string>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/**
|
||||
* 前端自己的构建信息:版本取自 web/package.json,tag 和提交取自 git(环境变量 MONITOR_TAG / MONITOR_COMMIT 优先,CI 里没有 git 历史时用),
|
||||
* 构建时间是这次构建的时刻。开发服务器里 tag 是「dev」,免得每次刷新都变。
|
||||
*/
|
||||
function git(args) {
|
||||
try {
|
||||
return execSync(`git ${args}`, { cwd: fileURLToPath(new URL('..', import.meta.url)), stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDefines(dev) {
|
||||
const version = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version
|
||||
return {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
__APP_TAG__: JSON.stringify(dev ? 'dev' : (process.env.MONITOR_TAG || git('describe --tags --always --dirty'))),
|
||||
__APP_COMMIT__: JSON.stringify(dev ? '' : (process.env.MONITOR_COMMIT || git('rev-parse --short=10 HEAD'))),
|
||||
__APP_BUILT_AT__: JSON.stringify(dev ? '' : (process.env.MONITOR_BUILT_AT || new Date().toISOString())),
|
||||
}
|
||||
}
|
||||
+46
-3
@@ -1,7 +1,50 @@
|
||||
// 同时启动展示端(5173)和管理端(5174/admin/);Ctrl+C 一起退出。
|
||||
// 同时启动展示端(5173)和管理端(5175,经展示端的 /admin/ 访问);Ctrl+C 一起退出。
|
||||
// 不经过 shell:直接用 node 跑各自目录下的 vite,输出带前缀,端口被占时直接报错。
|
||||
import { spawn } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const children = ['display', 'admin'].map((name) => spawn('npm', ['run', 'dev', '-w', name], { stdio: 'inherit', shell: true }))
|
||||
const stop = () => children.forEach((c) => c.kill())
|
||||
const vite = fileURLToPath(new URL('./node_modules/vite/bin/vite.js', import.meta.url))
|
||||
|
||||
// 颜色:和 Vite 自己的输出一致(绿色的 ➜、青色/蓝色的链接);设了 NO_COLOR 就不上色
|
||||
const color = !process.env.NO_COLOR
|
||||
const paint = (code, text) => (color ? `[${code}m${text}[0m` : text)
|
||||
const bold = (t) => paint('1', t)
|
||||
const dim = (t) => paint('2', t)
|
||||
const green = (t) => paint('32', t)
|
||||
const cyan = (t) => paint('36', t)
|
||||
const magenta = (t) => paint('35', t)
|
||||
const link = (url) => paint('36;4', url)
|
||||
|
||||
const apps = [
|
||||
{ name: 'display', tint: cyan, cwd: fileURLToPath(new URL('./display/', import.meta.url)) },
|
||||
{ name: 'admin ', tint: magenta, cwd: fileURLToPath(new URL('./admin/', import.meta.url)) },
|
||||
]
|
||||
|
||||
console.log('')
|
||||
console.log(` ${green('➜')} ${bold('展示端')} ${link('http://localhost:5173/')}`)
|
||||
console.log(` ${green('➜')} ${bold('管理端')} ${link('http://localhost:5173/admin/')} ${dim('(也可以直接开 http://localhost:5175/admin/)')}`)
|
||||
console.log('')
|
||||
|
||||
const children = apps.map(({ name, tint, cwd }) => {
|
||||
const child = spawn(process.execPath, [vite], { cwd, stdio: ['inherit', 'pipe', 'pipe'], env: { ...process.env, ...(color ? { FORCE_COLOR: '1' } : {}) } })
|
||||
const prefix = (stream, out) =>
|
||||
stream.on('data', (chunk) => {
|
||||
for (const line of String(chunk).split(/\r?\n/)) if (line.trim()) out.write(`${tint(`[${name}]`)} ${line}\n`)
|
||||
})
|
||||
prefix(child.stdout, process.stdout)
|
||||
prefix(child.stderr, process.stderr)
|
||||
child.on('exit', (code) => {
|
||||
if (code) {
|
||||
console.error(`${tint(`[${name}]`)} ${paint('31', `退出了(${code}),一起停掉`)}`)
|
||||
stop()
|
||||
process.exitCode = code
|
||||
}
|
||||
})
|
||||
return child
|
||||
})
|
||||
|
||||
function stop() {
|
||||
for (const c of children) c.kill()
|
||||
}
|
||||
process.on('SIGINT', stop)
|
||||
process.on('exit', stop)
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>线路质量</title>
|
||||
<title>MyMonitor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BuildFooter } from './BuildFooter'
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { SourceButton, ThemeToggle } from '@shared/ui'
|
||||
import { Compare } from './pages/Compare'
|
||||
@@ -9,9 +10,6 @@ export function App() {
|
||||
<>
|
||||
<header className="topbar">
|
||||
<div className="inner">
|
||||
<NavLink to="/" className="brand">
|
||||
线路质量
|
||||
</NavLink>
|
||||
<nav className="topnav">
|
||||
<NavLink to="/" end className={({ isActive }) => (isActive ? 'active' : '')}>
|
||||
总览
|
||||
@@ -21,7 +19,7 @@ export function App() {
|
||||
</NavLink>
|
||||
<a href="/admin/">管理</a>
|
||||
</nav>
|
||||
<SourceButton />
|
||||
<SourceButton variant="status" />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
@@ -31,6 +29,10 @@ export function App() {
|
||||
<Route path="/compare" element={<Compare />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<footer className="site-foot">
|
||||
国家和运营商由 <a href="https://ip.007995.xyz/" target="_blank" rel="noreferrer">ip.007995.xyz</a> 查询
|
||||
<BuildFooter />
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { api } from '@shared/api/client'
|
||||
import type { AboutDto, BuildDto } from '@shared/api/types'
|
||||
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
|
||||
/** 「0.1.1 · v0.1.1-5-gabc123 · 2026-10-06 20:30」:版本、tag(和版本一样就不重复写)、构建时间。 */
|
||||
function buildText(b: { version: string; tag?: string | null; built_at?: string | null }): string {
|
||||
const parts = [b.version]
|
||||
if (b.tag && b.tag.replace(/^v/, '') !== b.version) parts.push(b.tag)
|
||||
const at = fmtBuiltAt(b.built_at)
|
||||
if (at) parts.push(at)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面底部的构建信息:后端和前端的版本、tag、构建时间。要管理员在设置里打开「site.show_build」才显示;
|
||||
* 关着的时候(或者后端太旧没有这个接口)什么也不画,前端自己的信息也不单独露出来。
|
||||
*/
|
||||
export function BuildFooter() {
|
||||
const about = useQuery(() => api.get<AboutDto>('/api/public/about'), [], 5 * 60_000)
|
||||
if (!about.data?.show_build || !about.data.backend) return null
|
||||
const backend: BuildDto = about.data.backend
|
||||
return (
|
||||
<div className="build-info">
|
||||
<span>后端 {buildText(backend)}</span>
|
||||
<span>前端 {buildText({ version: frontendBuild.version, tag: frontendBuild.tag, built_at: frontendBuild.builtAt })}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useRef, useState, type MouseEvent } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { SeriesDto } from '@shared/api/types'
|
||||
import { fmtMs, fmtPct } from '@shared/format'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
|
||||
/** 总览卡片可以看的时长,以及每一格的长度(格数约 36~48,太多就挤了)。 */
|
||||
export const OVERVIEW_RANGES = [
|
||||
{ value: '3h', label: '3 小时', hours: 3, step: '5m', stepMin: 5 },
|
||||
{ value: '6h', label: '6 小时', hours: 6, step: '10m', stepMin: 10 },
|
||||
{ value: '12h', label: '12 小时', hours: 12, step: '15m', stepMin: 15 },
|
||||
] as const
|
||||
|
||||
export type OverviewRange = (typeof OVERVIEW_RANGES)[number]['value']
|
||||
|
||||
type Level = 'ok' | 'warn' | 'bad' | 'none'
|
||||
|
||||
const levelOf = (loss: number | null | undefined): Level => (loss === null || loss === undefined ? 'none' : loss <= 0.5 ? 'ok' : loss < 3 ? 'warn' : 'bad')
|
||||
|
||||
const clock = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
|
||||
interface StripProps {
|
||||
/** 左上:什么延迟(UDP 延迟 p50)。 */
|
||||
rttLabel: string
|
||||
rtt: number | null | undefined
|
||||
/** 右上:丢包还是重传。 */
|
||||
badLabel: string
|
||||
bad: number | null | undefined
|
||||
badHint?: string
|
||||
/** 每 5 分钟一格:这一格的丢包(重传)率、延迟。 */
|
||||
t: number[]
|
||||
badSeries: (number | null)[]
|
||||
ready: boolean
|
||||
/** 提示里这一格的「丢包」叫什么。 */
|
||||
tipBad: string
|
||||
/** 没有数据时占位的格数,和每格长度的说明(「每 5 分钟」)。 */
|
||||
cells: number
|
||||
stepText: string
|
||||
hoursText: string
|
||||
/** 悬停的那一格(UDP 和 TCP 共用);`null` = 没有。 */
|
||||
hover: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 一排小格(每 5 分钟一格,按这 5 分钟的丢包率着色:绿 / 黄 / 红 / 灰 = 没有数据)
|
||||
* 尽量轻。
|
||||
*/
|
||||
function Strip({ rttLabel, rtt, badLabel, bad, badHint, t, badSeries, ready, cells, stepText, hoursText, hover }: StripProps) {
|
||||
return (
|
||||
<div className="udp-strip">
|
||||
<div className="udp-head">
|
||||
<span className="udp-title">
|
||||
{rttLabel} <b className="num">{fmtMs(rtt)}</b>
|
||||
</span>
|
||||
<span className="udp-title" title={badHint}>
|
||||
{badLabel} <b className={`udp-rate num ${levelOf(bad)}`}>{fmtPct(bad)}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bars" role="img" aria-label={`近 ${hoursText}每 ${stepText}的${badLabel}情况`}>
|
||||
{(ready ? badSeries : Array.from({ length: cells }, () => null)).map((v, i) => (
|
||||
<span key={t[i] ?? i} className={`bar ${ready ? levelOf(v) : 'none'}${hover === i ? ' on' : ''}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lineId: string
|
||||
range: OverviewRange
|
||||
/** 近 3 小时的汇总(左上、右上的数字)。 */
|
||||
m: Record<string, number | null | undefined>
|
||||
}
|
||||
|
||||
/** 一条线路近一段时间的 UDP 和 TCP:各是一排小格,共用一次请求。 */
|
||||
export function LineStrips({ lineId, range, m }: Props) {
|
||||
const info = OVERVIEW_RANGES.find((r) => r.value === range) ?? OVERVIEW_RANGES[0]
|
||||
const series = useQuery(
|
||||
() => api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { range: info.value, anchor: 'latest', step: info.step, preset: 'udp,tcp', metrics: 'loss.total,rtt.p50,tcp.retrans,tcp.rtt.p50' }),
|
||||
[lineId, info.value],
|
||||
30_000,
|
||||
)
|
||||
const s = series.data
|
||||
const t = s?.t ?? []
|
||||
const col = (key: string): (number | null)[] => s?.values[key] ?? []
|
||||
// 悬停的那一格:UDP 和 TCP 的时间轴一样,所以同一格同时高亮;提示弹在卡片右侧,一次展示这段时间的 UDP 和 TCP
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const last = useRef<number | null>(null) // 提示收回去的动画期间,内容还要留着
|
||||
if (hover !== null) last.current = hover
|
||||
const shown = hover ?? last.current
|
||||
// 整块线路区域都能触发:按鼠标横向位置对应到小格(UDP 和 TCP 的小格一样宽,量第一排就行),不用精确指到小格上
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const onMove = (e: MouseEvent<HTMLDivElement>) => {
|
||||
const bars = box.current?.querySelector('.bars')
|
||||
if (!s || t.length < 1 || !bars) return
|
||||
const r = bars.getBoundingClientRect()
|
||||
setHover(Math.min(t.length - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * t.length))))
|
||||
}
|
||||
const common = { t, ready: Boolean(s), cells: Math.round((info.hours * 60) / info.stepMin), stepText: `${info.stepMin} 分钟`, hoursText: `${info.hours} 小时`, hover }
|
||||
const at = shown !== null && t[shown] !== undefined ? shown : null
|
||||
return (
|
||||
<div className="strips" ref={box} onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
|
||||
<Strip
|
||||
{...common}
|
||||
rttLabel="UDP 延迟 p50"
|
||||
rtt={m['rtt.p50']}
|
||||
badLabel="丢包"
|
||||
bad={m['loss.total']}
|
||||
badHint={`上行丢包 ${fmtPct(m['loss.up'])} 下行丢包 ${fmtPct(m['loss.down'])}`}
|
||||
badSeries={col('loss.total')}
|
||||
tipBad="丢包"
|
||||
/>
|
||||
<Strip {...common} rttLabel="TCP RTT p50" rtt={m['tcp.rtt.p50']} badLabel="重传" bad={m['tcp.retrans']} badSeries={col('tcp.retrans')} tipBad="重传" />
|
||||
<div className={`pop num${hover !== null && at !== null ? ' open' : ''}`} role="tooltip" aria-hidden={hover === null}>
|
||||
{at !== null ? (
|
||||
<>
|
||||
<b className="pop-time">
|
||||
{clock(t[at])}–{clock(t[at] + info.stepMin * 60_000)}
|
||||
</b>
|
||||
<div className="pop-group">
|
||||
<span className="pop-name">UDP</span>
|
||||
<span>
|
||||
丢包 <em className={levelOf(col('loss.total')[at])}>{fmtPct(col('loss.total')[at])}</em>
|
||||
</span>
|
||||
<span>
|
||||
延迟 p50 <em>{fmtMs(col('rtt.p50')[at])}</em>
|
||||
</span>
|
||||
</div>
|
||||
<div className="pop-group">
|
||||
<span className="pop-name">TCP</span>
|
||||
<span>
|
||||
重传 <em className={levelOf(col('tcp.retrans')[at])}>{fmtPct(col('tcp.retrans')[at])}</em>
|
||||
</span>
|
||||
<span>
|
||||
RTT p50 <em>{fmtMs(col('tcp.rtt.p50')[at])}</em>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useMemo, useRef, useState, type PointerEvent } from 'react'
|
||||
import { LoadingBar } from '@shared/ui'
|
||||
import { fmtSpan } from './ranges'
|
||||
|
||||
const MIN = 60_000
|
||||
/** 选区最短 5 分钟。 */
|
||||
const MIN_LEN = 5
|
||||
const EDGE_PX = 9
|
||||
|
||||
interface Props {
|
||||
/** 时间轴的起点(毫秒)。 */
|
||||
domainStart: number
|
||||
/** 时间轴的长度(分钟):1440 或 2880(连续 48 小时,选区可以跨零点)。 */
|
||||
domainMin: number
|
||||
t: number[]
|
||||
/** 丢包(缩略图画它)。 */
|
||||
values: (number | null)[]
|
||||
fromMs: number
|
||||
toMs: number
|
||||
onSelect: (fromMs: number, toMs: number) => void
|
||||
/** 缩略图的数据正在加载。 */
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
type Mode = 'l' | 'r' | 'move' | 'new'
|
||||
interface Drag {
|
||||
mode: Mode
|
||||
anchor: number
|
||||
from: number
|
||||
to: number
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
const hourOf = (ms: number) => new Date(ms).getHours()
|
||||
const dateOf = (ms: number) => `${new Date(ms).getMonth() + 1}/${new Date(ms).getDate()}`
|
||||
const clockOf = (ms: number) => `${String(new Date(ms).getHours()).padStart(2, '0')}:${String(new Date(ms).getMinutes()).padStart(2, '0')}`
|
||||
|
||||
/**
|
||||
* 连续 48 小时的缩略图(丢包),上面有一块可拖动的选区:
|
||||
* 拖空白处新建,拖两边调长短,拖中间整体平移;选区可以跨过零点(晚高峰常常是 20:00 到凌晨 02:00)。
|
||||
* 点一下空白处或者刻度下面的钟点:离那里近的端点贴过去,另一个端点不动;鼠标移上去会标出这个位置的时间。
|
||||
* 拖动过程中只在这里本地更新,松手才通知外面(外面再去取数),所以不会闪。
|
||||
*/
|
||||
export function Minimap({ domainStart, domainMin, t, values, fromMs, toMs, onSelect, loading = false }: Props) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const drag = useRef<Drag | null>(null)
|
||||
const latest = useRef<[number, number] | null>(null)
|
||||
const [live, setLive] = useState<[number, number] | null>(null)
|
||||
const [held, setHeld] = useState<[number, number] | null>(null)
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
|
||||
const propFrom = Math.round((fromMs - domainStart) / MIN)
|
||||
const propTo = Math.round((toMs - domainStart) / MIN)
|
||||
// 松手之后到外面的状态更新完成之前,选区停在松手的位置,不跳回去
|
||||
useEffect(() => {
|
||||
if (held && propFrom === held[0] && propTo === held[1]) setHeld(null)
|
||||
}, [propFrom, propTo, held])
|
||||
useEffect(() => {
|
||||
if (!held) return
|
||||
const id = window.setTimeout(() => setHeld(null), 4000)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [held])
|
||||
|
||||
const shown = live ?? held ?? [propFrom, propTo]
|
||||
const from = Math.max(0, shown[0])
|
||||
const to = Math.min(domainMin, shown[1])
|
||||
|
||||
const path = useMemo(() => {
|
||||
let max = 5
|
||||
for (const v of values) if (v !== null && v !== undefined && v > max) max = v
|
||||
const points: { x: number; v: number }[] = []
|
||||
for (let i = 0; i < t.length; i++) {
|
||||
const v = values[i]
|
||||
const x = (t[i] - domainStart) / (domainMin * MIN)
|
||||
if (v !== null && v !== undefined && x >= 0 && x <= 1) points.push({ x, v })
|
||||
}
|
||||
if (points.length < 2) return ''
|
||||
const first = points[0].x * 1000
|
||||
const last = points[points.length - 1].x * 1000
|
||||
return `M${first.toFixed(1)},60 ` + points.map((p) => `L${(p.x * 1000).toFixed(1)},${(58 - (Math.min(p.v, max) / max) * 54).toFixed(1)}`).join(' ') + ` L${last.toFixed(1)},60 Z`
|
||||
}, [t, values, domainStart, domainMin])
|
||||
|
||||
// 有数据的时间段:其余(还没有数据的更早时间、中间的缺口、还没到的未来)画成灰色斜纹
|
||||
const covered = useMemo(() => {
|
||||
const step = t.length > 1 ? t[1] - t[0] : 2 * MIN
|
||||
const segments: { x: number; w: number }[] = []
|
||||
let start = -1
|
||||
let last = -1
|
||||
const flush = () => {
|
||||
if (start >= 0) segments.push({ x: ((start - domainStart) / (domainMin * MIN)) * 1000, w: Math.max(2, ((last + step - start) / (domainMin * MIN)) * 1000) })
|
||||
start = -1
|
||||
}
|
||||
for (let i = 0; i < t.length; i++) {
|
||||
if (values[i] === null || values[i] === undefined) continue
|
||||
if (start >= 0 && t[i] - last > step * 2.5) flush()
|
||||
if (start < 0) start = t[i]
|
||||
last = t[i]
|
||||
}
|
||||
flush()
|
||||
return segments
|
||||
}, [t, values, domainStart, domainMin])
|
||||
|
||||
// 刻度:每小时一个小短线;24 小时刻度每 2 小时、48 小时刻度每 3 小时标一个钟点;零点标日期
|
||||
const ticks = useMemo(() => {
|
||||
const labelEvery = domainMin <= 1440 ? 2 : 3
|
||||
const list: { hour: number; frac: number; label: string | null; midnight: boolean }[] = []
|
||||
for (let h = 0; h <= domainMin / 60; h += 1) {
|
||||
const ms = domainStart + h * 3_600_000
|
||||
const hour = hourOf(ms)
|
||||
const labelled = h % labelEvery === 0 || hour === 0
|
||||
list.push({ hour: h, frac: h / (domainMin / 60), label: !labelled ? null : hour === 0 ? dateOf(ms) : `${hour}时`, midnight: hour === 0 })
|
||||
}
|
||||
return list
|
||||
}, [domainStart, domainMin])
|
||||
|
||||
const minuteAt = (e: PointerEvent) => {
|
||||
const rect = box.current!.getBoundingClientRect()
|
||||
return Math.min(domainMin, Math.max(0, ((e.clientX - rect.left) / rect.width) * domainMin))
|
||||
}
|
||||
const clamp = (a: number, b: number): [number, number] => {
|
||||
let lo = Math.round(Math.min(a, b))
|
||||
let hi = Math.round(Math.max(a, b))
|
||||
if (hi - lo < MIN_LEN) hi = Math.min(domainMin, lo + MIN_LEN)
|
||||
if (hi - lo < MIN_LEN) lo = hi - MIN_LEN
|
||||
return [Math.max(0, lo), Math.min(domainMin, hi)]
|
||||
}
|
||||
|
||||
const down = (e: PointerEvent) => {
|
||||
if (e.button !== 0) return
|
||||
const rect = box.current!.getBoundingClientRect()
|
||||
const pxPerMin = rect.width / domainMin
|
||||
const at = minuteAt(e)
|
||||
let mode: Mode = 'new'
|
||||
if (Math.abs(at - from) * pxPerMin <= EDGE_PX) mode = 'l'
|
||||
else if (Math.abs(at - to) * pxPerMin <= EDGE_PX) mode = 'r'
|
||||
else if (at > from && at < to) mode = 'move'
|
||||
drag.current = { mode, anchor: at, from, to, moved: false }
|
||||
latest.current = null
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
}
|
||||
const move = (e: PointerEvent) => {
|
||||
const d = drag.current
|
||||
if (!d) return
|
||||
const at = minuteAt(e)
|
||||
setHover(null)
|
||||
if (!d.moved && Math.abs(at - d.anchor) * (box.current!.getBoundingClientRect().width / domainMin) < 3) return
|
||||
d.moved = true
|
||||
let next: [number, number]
|
||||
if (d.mode === 'new') next = clamp(d.anchor, at)
|
||||
else if (d.mode === 'l') next = clamp(at, d.to)
|
||||
else if (d.mode === 'r') next = clamp(d.from, at)
|
||||
else {
|
||||
const len = d.to - d.from
|
||||
const start = Math.min(domainMin - len, Math.max(0, d.from + (at - d.anchor)))
|
||||
next = [Math.round(start), Math.round(start + len)]
|
||||
}
|
||||
latest.current = next
|
||||
setLive(next)
|
||||
}
|
||||
const up = () => {
|
||||
const d = drag.current
|
||||
const result = latest.current
|
||||
drag.current = null
|
||||
latest.current = null
|
||||
setLive(null)
|
||||
if (d?.moved && result && (result[0] !== d.from || result[1] !== d.to)) {
|
||||
setHeld(result)
|
||||
onSelect(domainStart + result[0] * MIN, domainStart + result[1] * MIN)
|
||||
} else if (d && !d.moved && (d.mode === 'new' || d.mode === 'move')) {
|
||||
// 只是点了一下:离点的位置近的那个端点贴过去,另一个端点不动
|
||||
snapTo(d.anchor, d.from, d.to)
|
||||
}
|
||||
}
|
||||
/** 哪个端点离 `minute` 近,就把哪个端点移到那里(点在选区外面也一样),另一端不动。 */
|
||||
const predict = (minute: number, a = from, b = to): { next: [number, number]; side: 'l' | 'r' } => {
|
||||
const at = Math.round(minute)
|
||||
return Math.abs(at - a) <= Math.abs(at - b) ? { next: clamp(at, b), side: 'l' } : { next: clamp(a, at), side: 'r' }
|
||||
}
|
||||
const snapTo = (minute: number, a = from, b = to) => {
|
||||
const { next } = predict(minute, a, b)
|
||||
if (next[0] === a && next[1] === b) return
|
||||
setHeld(next)
|
||||
onSelect(domainStart + next[0] * MIN, domainStart + next[1] * MIN)
|
||||
}
|
||||
// 点下去会动的是哪个端点:从那个端点到目标位置之间染色(扩大是一种颜色,收缩是另一种),那个端点也变色
|
||||
const target = hover !== null && !live && !drag.current ? predict(hover) : null
|
||||
const sweep = target
|
||||
? target.side === 'l'
|
||||
? { lo: Math.min(from, target.next[0]), hi: Math.max(from, target.next[0]), grow: target.next[0] < from, changed: target.next[0] !== from }
|
||||
: { lo: Math.min(to, target.next[1]), hi: Math.max(to, target.next[1]), grow: target.next[1] > to, changed: target.next[1] !== to }
|
||||
: null
|
||||
const jumpTo = (tickHour: number) => snapTo(tickHour * 60)
|
||||
const hoverMove = (e: PointerEvent) => {
|
||||
if (drag.current) return
|
||||
setHover(minuteAt(e))
|
||||
}
|
||||
|
||||
const length = to - from
|
||||
|
||||
return (
|
||||
<div className="minimap">
|
||||
<div ref={box} className="track" onPointerDown={down} onPointerMove={(e) => { move(e); hoverMove(e) }} onPointerLeave={() => setHover(null)} onPointerUp={up} onPointerCancel={up} role="slider" aria-label="拖动选择时间段" aria-valuemin={0} aria-valuemax={domainMin} aria-valuenow={from} tabIndex={0}>
|
||||
<LoadingBar active={loading} />
|
||||
<svg viewBox="0 0 1000 60" preserveAspectRatio="none" aria-hidden="true">
|
||||
{covered.map((c) => (
|
||||
<rect key={c.x} className="has" x={c.x} y="0" width={c.w} height="60" />
|
||||
))}
|
||||
<path d={path} />
|
||||
</svg>
|
||||
{ticks
|
||||
.filter((x) => x.midnight)
|
||||
.map((x) => (
|
||||
<div key={x.frac} className="midnight" style={{ left: `${x.frac * 100}%` }} />
|
||||
))}
|
||||
{sweep?.changed ? <div className={`sweep ${sweep.grow ? 'grow' : 'cut'}`} style={{ left: `${(sweep.lo / domainMin) * 100}%`, width: `${((sweep.hi - sweep.lo) / domainMin) * 100}%` }} /> : null}
|
||||
<div className="sel" style={{ left: `${(from / domainMin) * 100}%`, width: `${(length / domainMin) * 100}%` }}>
|
||||
<i className={`h l ${sweep?.changed && target?.side === 'l' ? `will ${sweep.grow ? 'grow' : 'cut'}` : ''}`} />
|
||||
<i className={`h r ${sweep?.changed && target?.side === 'r' ? `will ${sweep.grow ? 'grow' : 'cut'}` : ''}`} />
|
||||
</div>
|
||||
{hover !== null && !live ? (
|
||||
<>
|
||||
<div className="guide" style={{ left: `${(hover / domainMin) * 100}%` }} />
|
||||
<div className="guide-label num" style={{ left: `${Math.min(88, Math.max(0, (hover / domainMin) * 100 - 6))}%` }}>
|
||||
{dateOf(domainStart + hover * MIN)} {clockOf(domainStart + hover * MIN)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="ticks">
|
||||
{ticks.map((x, i) => (
|
||||
<button
|
||||
key={x.hour}
|
||||
type="button"
|
||||
className={`tick ${x.label ? '' : 'minor'} ${x.midnight ? 'date' : ''}`}
|
||||
style={{ left: `${x.frac * 100}%`, transform: i === 0 ? 'none' : i === ticks.length - 1 ? 'translateX(-100%)' : undefined }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerMove={(e) => {
|
||||
// 停在刻度上:按这个钟点预览(不是鼠标所在的像素位置)
|
||||
e.stopPropagation()
|
||||
if (x.hour * 60 < domainMin) setHover(x.hour * 60)
|
||||
}}
|
||||
onClick={() => jumpTo(x.hour)}
|
||||
title={`离 ${clockOf(domainStart + x.hour * 3_600_000)} 近的端点移到这里`}
|
||||
tabIndex={-1}
|
||||
disabled={x.hour * 60 >= domainMin}
|
||||
>
|
||||
{x.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="readout num">
|
||||
{fmtSpan(domainStart + from * MIN, domainStart + to * MIN)}
|
||||
<span className="faint"> · {length >= 60 ? `${Math.floor(length / 60)} 小时${length % 60 ? ` ${length % 60} 分` : ''}` : `${length} 分钟`}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
+773
-6
@@ -239,6 +239,7 @@
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
@@ -276,14 +277,28 @@
|
||||
|
||||
.line-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--r-control);
|
||||
background: var(--app-card-inset);
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
.line-link > .muted {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-link > .scores {
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.line-link:hover {
|
||||
text-decoration: none;
|
||||
background: var(--app-card-hover);
|
||||
@@ -398,15 +413,42 @@
|
||||
background: var(--app-ok);
|
||||
}
|
||||
|
||||
.line-link > * {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.range-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.day-pick {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
border-radius: var(--r-control);
|
||||
background: var(--app-card-inset);
|
||||
color: var(--app-ink);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.day-pick:hover {
|
||||
background: var(--app-card-hover);
|
||||
}
|
||||
|
||||
.day-pick input {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── 对比:每个 Probe 的最佳线路 + 矩阵 ── */
|
||||
.winners {
|
||||
margin-top: 24px;
|
||||
@@ -460,3 +502,728 @@
|
||||
.cell svg {
|
||||
color: var(--app-ok);
|
||||
}
|
||||
|
||||
/* ── 线路详情:指标平铺成一格一格 ── */
|
||||
.tiles {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
}
|
||||
|
||||
.tiles > .stat,
|
||||
.tiles > .tile {
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--r-card);
|
||||
background: var(--app-card);
|
||||
}
|
||||
|
||||
.tiles > .tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.tiles > .tile .k {
|
||||
font-size: 12px;
|
||||
color: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.tiles .stat .v {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.tiles > .tile .scores {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* ── 一天的缩略图和可拖动的选区 ── */
|
||||
.minimap {
|
||||
--pick: var(--chart-1);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.minimap .track {
|
||||
position: relative;
|
||||
height: 60px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: var(--r-control);
|
||||
/* 没有数据的地方:灰色斜纹 */
|
||||
background: repeating-linear-gradient(135deg, var(--app-card-inset) 0 5px, var(--app-card-hover) 5px 10px);
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.minimap svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--r-control);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.minimap svg path {
|
||||
fill: var(--app-bad);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.minimap svg rect.has {
|
||||
fill: var(--app-card);
|
||||
}
|
||||
|
||||
.minimap .sel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border: 2px solid var(--pick);
|
||||
border-radius: var(--r-control);
|
||||
background: color-mix(in srgb, var(--pick) 16%, transparent);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.minimap .sel .h {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
width: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--pick);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.minimap .sel .h.l {
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.minimap .sel .h.r {
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.minimap .ticks {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.minimap .tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
min-width: 16px;
|
||||
height: 18px;
|
||||
padding: 7px 2px 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--app-ink-faint);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* 刻度线:每小时一根,标了钟点的更长 */
|
||||
.minimap .tick::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 4px;
|
||||
background: var(--app-ink-faint);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.minimap .tick.minor::before {
|
||||
height: 3px;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.minimap .tick:not(.minor)::before {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.minimap .tick:hover:not(:disabled) {
|
||||
background: var(--app-card-hover);
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
.minimap .tick:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.minimap .tick.date {
|
||||
font-weight: 600;
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.minimap .guide {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--app-ink-muted);
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.minimap .guide-label {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--app-ink);
|
||||
color: var(--app-card);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.minimap .readout {
|
||||
font-size: 12px;
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.day-pick {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.minimap .midnight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--app-ink-faint);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
/* ── 线路详情的图表:按 UDP、TCP、评分分组,宽图占满一行 ── */
|
||||
.chart-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chart-section h2 {
|
||||
margin: 8px 4px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.chart-section .chart-card.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.minimap .loading-bar {
|
||||
z-index: 2;
|
||||
border-radius: var(--r-control) var(--r-control) 0 0;
|
||||
}
|
||||
|
||||
/* 点下去会动的端点:它到目标位置之间染色(扩大 = 强调色,收缩 = 灰色),端点本身也变色 */
|
||||
.minimap .sweep {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.minimap .sweep.grow {
|
||||
background: color-mix(in srgb, var(--chart-2) 32%, transparent);
|
||||
}
|
||||
|
||||
.minimap .sweep.cut {
|
||||
background: color-mix(in srgb, var(--app-ink) 18%, transparent);
|
||||
}
|
||||
|
||||
.minimap .sel .h {
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
|
||||
.minimap .sel .h.will.grow {
|
||||
background: var(--chart-2);
|
||||
}
|
||||
|
||||
.minimap .sel .h.will.cut {
|
||||
background: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
/* 顶栏里的连接状态:紧凑的一条,放在导航和主题切换之间 */
|
||||
.topbar .src-status {
|
||||
width: auto;
|
||||
max-width: 220px;
|
||||
border-radius: var(--r-pill, 999px);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar .src-status .st {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar .src-status {
|
||||
max-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.line-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lines-caption {
|
||||
padding: 0 4px 2px;
|
||||
font-size: 12px;
|
||||
color: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
/* ── 总览卡片里的 UDP:一排小格(每 5 分钟一格,按丢包着色)+ 延迟小走势,尽量轻 ── */
|
||||
.line-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.line-top .faint {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.udp-strip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.udp-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.udp-title {
|
||||
color: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.udp-title b {
|
||||
margin-left: 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
.udp-title b.udp-rate.ok {
|
||||
color: var(--app-ok);
|
||||
}
|
||||
|
||||
.udp-title b.udp-rate.warn {
|
||||
color: var(--app-warn);
|
||||
}
|
||||
|
||||
.udp-title b.udp-rate.bad {
|
||||
color: var(--app-bad);
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--app-idle);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.bar.ok {
|
||||
background: var(--app-ok);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.bar.warn {
|
||||
background: var(--app-warn);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bar.bad {
|
||||
background: var(--app-bad);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* UDP 和 TCP 两段之间留一点空 */
|
||||
.strips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 悬停的那一格:小格描边、走势图上一条竖线和一个点,再弹一个小提示 */
|
||||
.bar.on {
|
||||
outline: 2px solid var(--app-ink);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.strips {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 提示弹在卡片右侧:平时收在卡片边上(透明、偏左),悬停时滑出来,移开时缩回去 */
|
||||
.pop {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: calc(100% + 40px);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 168px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--app-ink);
|
||||
color: var(--app-card);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateX(-14px) scale(0.96);
|
||||
transform-origin: left center;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--app-shadow), transparent 68%);
|
||||
}
|
||||
|
||||
.pop.open {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 指向卡片的小三角 */
|
||||
.pop::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: -5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--app-ink);
|
||||
transform: rotate(45deg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.pop-time {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.pop-group {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.pop-name {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pop em {
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.pop em.warn {
|
||||
color: var(--app-warn-container);
|
||||
}
|
||||
|
||||
.pop em.bad {
|
||||
color: var(--app-bad-container);
|
||||
}
|
||||
|
||||
/* 右边放不下(窄屏、卡片贴着右边)时,提示改成盖在卡片上方 */
|
||||
@media (max-width: 900px) {
|
||||
.pop {
|
||||
left: auto;
|
||||
right: 0;
|
||||
top: -8px;
|
||||
transform: translateY(8px) scale(0.96);
|
||||
transform-origin: right bottom;
|
||||
}
|
||||
|
||||
.pop::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.site-foot {
|
||||
max-width: 900px;
|
||||
margin: 48px auto 24px;
|
||||
padding: 0 16px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
color: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.site-foot a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Agent 卡片:国旗开头,名字下面是「运营商 / 系统」两行(左边名字右边内容,和下面的流量、网速一个风格) */
|
||||
/* 亚克力:整张卡片半透明 + 背景模糊,细亮边,大一点的投影;标题栏是稍深的一条,底下垫放大模糊的国旗 */
|
||||
/* 背景图固定在最底下,自己模糊一点(边缘放大一点,免得模糊后露出白边);上面盖一层页面底色,亮色盖得浅一点,暗色盖得深 */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: -24px;
|
||||
z-index: -1;
|
||||
background-image:
|
||||
linear-gradient(color-mix(in srgb, var(--app-bg) var(--bg-veil), transparent), color-mix(in srgb, var(--app-bg) var(--bg-veil), transparent)),
|
||||
url('./assets/bg.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(var(--bg-blur));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-veil: 50%;
|
||||
--bg-blur: 10px;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg-veil: 82%;
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
background: color-mix(in srgb, var(--app-card) 76%, transparent);
|
||||
backdrop-filter: blur(28px) saturate(1.6);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
|
||||
0 14px 32px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
|
||||
}
|
||||
|
||||
.agent-head {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
margin: -20px -20px 0;
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--r-card) var(--r-card) 0 0;
|
||||
background: color-mix(in srgb, var(--app-ink) 5%, transparent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--app-ink) 8%, transparent);
|
||||
}
|
||||
|
||||
.agent-head .flag {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 0 0 1px var(--app-outline-soft);
|
||||
}
|
||||
|
||||
.agent-head .flag.ph {
|
||||
display: inline-block;
|
||||
background: var(--app-card-inset);
|
||||
}
|
||||
|
||||
.facts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fact .k {
|
||||
color: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.fact .v {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
.fact .v .isp {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.fact .v .isp rect {
|
||||
fill: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.fact .v .isp text {
|
||||
fill: var(--isp-ink);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fact .v .isp-broadnet rect {
|
||||
fill: var(--isp-broadnet);
|
||||
}
|
||||
|
||||
/* 线路行:国旗在 Probe 名字前面,运营商图标在后面 */
|
||||
.line-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.line-name .flag {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 13.5px;
|
||||
border-radius: 2px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 0 0 1px var(--app-outline-soft);
|
||||
}
|
||||
|
||||
.line-top .isp {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.line-top .isp rect {
|
||||
fill: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.line-top .isp text {
|
||||
fill: var(--isp-ink);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.line-top .isp-broadnet rect {
|
||||
fill: var(--isp-broadnet);
|
||||
}
|
||||
|
||||
/* 亚克力卡片里的线路块:不用实色,只叠一层淡淡的墨色和细描边,透出卡片本身 */
|
||||
.agent-card .line-link {
|
||||
background: color-mix(in srgb, var(--app-ink) 4%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 7%, transparent);
|
||||
}
|
||||
|
||||
.agent-card .line-link:hover {
|
||||
background: color-mix(in srgb, var(--app-ink) 7%, transparent);
|
||||
}
|
||||
|
||||
|
||||
/* 线路详情、对比等页面的卡片和指标块:同样的亚克力,一层压一层 */
|
||||
.card:not(.agent-card),
|
||||
.tiles > .stat,
|
||||
.tiles > .tile {
|
||||
background: color-mix(in srgb, var(--app-card) 76%, transparent);
|
||||
backdrop-filter: blur(28px) saturate(1.6);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
|
||||
0 14px 32px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
|
||||
}
|
||||
|
||||
/* 小的指标块浮得低一点:阴影收小 */
|
||||
.tiles > .stat {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 8%, transparent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--app-card) 70%, transparent),
|
||||
0 6px 14px -8px color-mix(in srgb, var(--app-shadow) 40%, transparent);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: color-mix(in srgb, var(--app-card) 62%, transparent);
|
||||
backdrop-filter: blur(20px) saturate(1.5);
|
||||
border-bottom-color: color-mix(in srgb, var(--app-ink) 7%, transparent);
|
||||
box-shadow: 0 4px 14px -8px color-mix(in srgb, var(--app-shadow) 28%, transparent);
|
||||
}
|
||||
|
||||
/* 时间轴:同样的亚克力。有数据的段是半透明的亮底,没数据的段是淡淡的斜纹,整条有细描边和投影 */
|
||||
.minimap .track {
|
||||
background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--app-card) 55%, var(--app-idle)) 0 5px, color-mix(in srgb, var(--app-card) 70%, var(--app-idle)) 5px 10px);
|
||||
backdrop-filter: blur(20px) saturate(1.6);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--app-ink) 9%, transparent),
|
||||
0 10px 24px -12px color-mix(in srgb, var(--app-shadow) 45%, transparent);
|
||||
}
|
||||
|
||||
.minimap svg rect.has {
|
||||
fill: color-mix(in srgb, var(--app-card) 80%, transparent);
|
||||
}
|
||||
|
||||
/* 直接落在背景图上的字(页面标题、分组名、说明):加一圈和页面底色一样的柔光,免得被图案吃掉 */
|
||||
body {
|
||||
text-shadow: 0 0 8px color-mix(in srgb, var(--app-bg) 90%, transparent), 0 0 2px color-mix(in srgb, var(--app-bg) 70%, transparent);
|
||||
}
|
||||
|
||||
.card,
|
||||
.pop,
|
||||
.tiles > .stat,
|
||||
.tiles > .tile,
|
||||
.topbar,
|
||||
button,
|
||||
input {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* 时间轴整条做成圆角:轨道、里面的图、选区用同一个半径 */
|
||||
.minimap .track,
|
||||
.minimap svg,
|
||||
.minimap .sel {
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
/* 版本、tag:等宽的小字 */
|
||||
.fact .v.mono-tag,
|
||||
.probe-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.probe-tag {
|
||||
padding: 0 6px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--app-ink) 6%, transparent);
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.build-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 4px 16px;
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import '@shared/theme/base.css'
|
||||
import { configureSource } from '@shared/api/source'
|
||||
import { applyStoredTheme } from '@shared/hooks'
|
||||
import { ToastProvider } from '@shared/ui'
|
||||
import { ErrorBoundary, ToastProvider } from '@shared/ui'
|
||||
import { App } from './App'
|
||||
import './display.css'
|
||||
|
||||
@@ -14,9 +14,11 @@ applyStoredTheme()
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -20,9 +20,9 @@ interface MetricInfo {
|
||||
const METRICS: MetricInfo[] = [
|
||||
{ id: 'rtt.p50', label: '延迟 p50', unit: 'ms', better: 'lower' },
|
||||
{ id: 'rtt.p95', label: '延迟 p95', unit: 'ms', better: 'lower' },
|
||||
{ id: 'loss.total', label: '丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.up', label: '上行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.down', label: '下行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.total', label: 'UDP 丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.up', label: 'UDP 上行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'loss.down', label: 'UDP 下行丢包', unit: '%', better: 'lower' },
|
||||
{ id: 'jitter.up.p95', label: '上行抖动', unit: 'ms', better: 'lower' },
|
||||
{ id: 'jitter.down.p95', label: '下行抖动', unit: 'ms', better: 'lower' },
|
||||
{ id: 'tcp.rtt.p50', label: 'TCP RTT p50', unit: 'ms', better: 'lower' },
|
||||
|
||||
+276
-134
@@ -2,14 +2,19 @@ import { ChevronLeft } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { LineDto, SeriesDto, TestDto } from '@shared/api/types'
|
||||
import type { LineDto, TestDto } from '@shared/api/types'
|
||||
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
|
||||
import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format'
|
||||
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
||||
import { fmtBytes, useHosts } from '../live'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Dot, Spinner, Stat } from '@shared/ui'
|
||||
import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui'
|
||||
import { PageError, ScoreChips } from '../lib'
|
||||
import { availablePresets, defaultPreset, monthsWithData, PRESETS, RangePicker, resolveRange, SLICES, type PresetValue, type RangeState, type SliceValue } from '../ranges'
|
||||
import { Minimap } from '../Minimap'
|
||||
import { readSpec, usePeriod, usePeriodSeries, writeSpec } from '../periods'
|
||||
import { dayBounds, dayKey, RangePicker, WINDOWS, type WindowValue } from '../ranges'
|
||||
|
||||
const MIN = 60_000
|
||||
|
||||
function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' {
|
||||
if (value === null || value === undefined) return ''
|
||||
@@ -23,70 +28,216 @@ interface HostSeries {
|
||||
values: Record<string, (number | null)[]>
|
||||
}
|
||||
|
||||
/** 指标卡片:好坏的分界(越低越好)和显示格式。 */
|
||||
const TILES: { label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string }[] = [
|
||||
{ label: 'UDP 延迟 p50', key: 'rtt.p50', fmt: fmtMs },
|
||||
{ label: 'UDP 延迟 p95', key: 'rtt.p95', fmt: fmtMs },
|
||||
{ label: 'UDP 丢包', key: 'loss.total', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ label: 'UDP 上行丢包', key: 'loss.up', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ label: 'UDP 下行丢包', key: 'loss.down', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
{ label: 'UDP 最长连续丢包', key: 'loss.run_max', fmt: fmtNum, unit: '包' },
|
||||
{ label: 'UDP 上行抖动 p95', key: 'jitter.up.p95', fmt: fmtMs },
|
||||
{ label: 'UDP 下行抖动 p95', key: 'jitter.down.p95', fmt: fmtMs },
|
||||
{ label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
|
||||
{ label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
|
||||
{ label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
|
||||
{ label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
|
||||
]
|
||||
|
||||
interface ChartDef {
|
||||
id: string
|
||||
title: string
|
||||
unit: string
|
||||
zero?: boolean
|
||||
fill?: boolean
|
||||
series: { key: string; label: string; color: string; dash?: number[] }[]
|
||||
group: 'udp' | 'tcp' | 'score'
|
||||
/** 占满一行。 */
|
||||
wide?: boolean
|
||||
}
|
||||
|
||||
const GROUPS = [
|
||||
{ id: 'udp', title: 'UDP(往返探测)' },
|
||||
{ id: 'tcp', title: 'TCP' },
|
||||
{ id: 'score', title: '评分' },
|
||||
] as const
|
||||
|
||||
const CHARTS: ChartDef[] = [
|
||||
{ id: 'rtt', title: '延迟', group: 'udp', wide: true, unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] },
|
||||
{ id: 'loss', title: '丢包', group: 'udp', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] },
|
||||
{ id: 'jitter', title: '抖动(p95)', group: 'udp', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] },
|
||||
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] },
|
||||
{ id: 'retrans', title: '重传率', group: 'tcp', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] },
|
||||
{ id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] },
|
||||
]
|
||||
|
||||
/** EWMA 平滑的力度:系数越小越平滑(s = α·x + (1−α)·s)。 */
|
||||
const EWMA = [
|
||||
{ value: 'off', label: '关', alpha: 0 },
|
||||
{ value: 'low', label: '轻', alpha: 0.3 },
|
||||
{ value: 'mid', label: '中', alpha: 0.1 },
|
||||
{ value: 'high', label: '重', alpha: 0.03 },
|
||||
] as const
|
||||
type EwmaValue = (typeof EWMA)[number]['value']
|
||||
|
||||
/** 指数加权移动平均;缺口(没有数据)处断开,缺口后重新开始,不会把缺口两边连在一起。 */
|
||||
function ewma(values: (number | null)[], alpha: number): (number | null)[] {
|
||||
let state: number | null = null
|
||||
return values.map((v) => {
|
||||
if (v === null || v === undefined) {
|
||||
state = null
|
||||
return null
|
||||
}
|
||||
state = state === null ? v : alpha * v + (1 - alpha) * state
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
/** 主机状态:实时数据(SSE)每隔几秒来一次,只重绘这一块,不影响上面的图表。 */
|
||||
function HostCard({ agentId, range }: { agentId: string | undefined; range: string | null }) {
|
||||
const history = useQuery(
|
||||
() => (agentId && range ? api.get<HostSeries>(`/api/public/agents/${agentId}/host`, { range }) : Promise.resolve(undefined)),
|
||||
[agentId, range],
|
||||
30_000,
|
||||
)
|
||||
const { hosts } = useHosts()
|
||||
const host = agentId ? hosts.get(agentId)?.host : undefined
|
||||
const data = history.data
|
||||
const charts = useMemo(
|
||||
() =>
|
||||
data
|
||||
? {
|
||||
usage: [
|
||||
{ label: 'CPU', values: data.values['cpu'] ?? [], color: '--chart-1' },
|
||||
{ label: '内存', values: data.values['mem'] ?? [], color: '--chart-2' },
|
||||
{ label: '磁盘', values: data.values['disk'] ?? [], color: '--chart-4' },
|
||||
],
|
||||
speed: [
|
||||
{ label: '上行', values: data.values['tx_bps'] ?? [], color: '--chart-2' },
|
||||
{ label: '下行', values: data.values['rx_bps'] ?? [], color: '--chart-1' },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
[data],
|
||||
)
|
||||
if (!host && !data) return null
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">主机状态</div>
|
||||
{host ? (
|
||||
<div className="grid stats">
|
||||
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(0)} unit="%" />
|
||||
<Stat label="内存" value={host.mem_total ? ((100 * (host.mem_used ?? 0)) / host.mem_total).toFixed(0) : '—'} unit="%" />
|
||||
<Stat label="磁盘" value={host.disk_total ? ((100 * (host.disk_used ?? 0)) / host.disk_total).toFixed(0) : '—'} unit="%" />
|
||||
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
|
||||
</div>
|
||||
) : null}
|
||||
{data && charts ? (
|
||||
<div className="grid cols-2" style={{ marginTop: 16 }}>
|
||||
<TimeChart t={data.t} unit="%" series={charts.usage} />
|
||||
<TimeChart t={data.t} unit=" B/s" series={charts.speed} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Line() {
|
||||
const { id = '' } = useParams()
|
||||
const [params, setParams] = useSearchParams()
|
||||
|
||||
const line = useQuery(() => api.get<LineDto>(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000)
|
||||
const infos = useNodeInfos()
|
||||
const ready = Boolean(line.data)
|
||||
const sinceMs = line.data?.data_since ? Date.parse(line.data.data_since) : null
|
||||
const nowMs = useMemo(() => Date.now(), [line.data])
|
||||
const spanMs = sinceMs === null ? null : nowMs - sinceMs
|
||||
const todayStart = dayBounds(nowMs).start
|
||||
|
||||
// 时间范围:只提供有数据的档位;URL 里的值如果超出了现有的数据就退回到默认
|
||||
const wanted = params.get('range')
|
||||
const available = availablePresets(spanMs)
|
||||
const preset: PresetValue = PRESETS.some((x) => x.value === wanted) && available.some((x) => x.value === wanted) ? (wanted as PresetValue) : defaultPreset(spanMs)
|
||||
const sliceParam = params.get('slice')
|
||||
const state: RangeState = {
|
||||
preset,
|
||||
month: params.get('month'),
|
||||
slice: SLICES.some((x) => x.value === sliceParam) ? (sliceParam as SliceValue) : 'all',
|
||||
page: Math.max(0, Number(params.get('page') ?? 0) || 0),
|
||||
}
|
||||
const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs])
|
||||
const resolved = resolveRange(state, months, nowMs)
|
||||
const rangeKey = JSON.stringify(resolved.query)
|
||||
const setRange = (next: RangeState) => {
|
||||
const q = new URLSearchParams()
|
||||
if (next.month) {
|
||||
q.set('month', next.month)
|
||||
if (next.slice !== 'all') q.set('slice', next.slice)
|
||||
if (next.page > 0) q.set('page', String(next.page))
|
||||
} else {
|
||||
q.set('range', next.preset)
|
||||
}
|
||||
const spec = readSpec(params)
|
||||
const period = usePeriod(id, ready, sinceMs, nowMs, spec)
|
||||
const series = usePeriodSeries(id, ready, period)
|
||||
|
||||
const update = (fn: (q: URLSearchParams) => void) => {
|
||||
const q = new URLSearchParams(params)
|
||||
fn(q)
|
||||
setParams(q, { replace: true })
|
||||
}
|
||||
const dayParam = (dayStart: number) => (dayStart === todayStart ? null : dayKey(dayStart))
|
||||
const changeDay = (next: { dayStart: number; window: WindowValue | null }) =>
|
||||
update((q) => writeSpec(q, { ...readSpec(q), day: dayParam(next.dayStart), range: next.window, at: null }))
|
||||
const selectRange = (fromMs: number, toMs: number) =>
|
||||
update((q) => {
|
||||
const a = Math.round((fromMs - period.dayStart) / MIN)
|
||||
const b = Math.round((toMs - period.dayStart) / MIN)
|
||||
writeSpec(q, { ...readSpec(q), day: dayParam(period.dayStart), range: null, at: `${a}_${b}` })
|
||||
})
|
||||
/** 点了一档时长。看的是最新的(今天、选区到现在)就继续看「最近一段」;否则保持选区的起点,往后取这么长。 */
|
||||
const pickLength = (w: WindowValue) =>
|
||||
update((q) => {
|
||||
const base = { ...readSpec(q), day: dayParam(period.dayStart) }
|
||||
const info = WINDOWS.find((x) => x.value === w)!
|
||||
if (w === 'day' || (period.isToday && (period.live || period.toMs >= nowMs - 2 * MIN))) {
|
||||
writeSpec(q, { ...base, range: w, at: null })
|
||||
return
|
||||
}
|
||||
const start = Math.min(period.fromMs, nowMs - info.ms)
|
||||
const a = Math.round((start - period.dayStart) / MIN)
|
||||
writeSpec(q, { ...base, range: null, at: `${a}_${a + info.ms / MIN}` })
|
||||
})
|
||||
const setScale = (scale: 24 | 48) =>
|
||||
update((q) => {
|
||||
const current = readSpec(q)
|
||||
// 换回 24 小时:跨零点的选区放不下了,回到默认
|
||||
writeSpec(q, { ...current, span: scale === 24 ? '24' : null, at: scale === 24 && period.domainMin > 1440 ? null : current.at })
|
||||
})
|
||||
|
||||
const series = useQuery(
|
||||
() => (line.data ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(id)}/series`, { ...resolved.query, preset: 'udp,tcp,scores' }) : Promise.resolve(undefined)),
|
||||
[id, rangeKey, Boolean(line.data)],
|
||||
state.month ? undefined : 10_000,
|
||||
)
|
||||
const agentId = line.data?.agent_id
|
||||
const hostHistory = useQuery(
|
||||
() => (agentId && !state.month ? api.get<HostSeries>(`/api/public/agents/${agentId}/host`, { range: resolved.hostRange }) : Promise.resolve(undefined)),
|
||||
[agentId, resolved.hostRange, Boolean(state.month)],
|
||||
30_000,
|
||||
)
|
||||
const { hosts } = useHosts()
|
||||
const host = agentId ? hosts.get(agentId)?.host : undefined
|
||||
const lastTestId = line.data?.last_test?.id
|
||||
const test = useQuery(() => (lastTestId ? api.get<TestDto>(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId])
|
||||
|
||||
const smooth = EWMA.find((x) => x.value === params.get('ewma')) ?? EWMA[0]
|
||||
const s = series.data
|
||||
const col = (key: string): (number | null)[] => s?.values[key] ?? []
|
||||
const m = s?.summary.metrics ?? {}
|
||||
const gaps = useMemo(() => (s?.gaps ?? []).map((g) => ({ from: Date.parse(g.from), to: Date.parse(g.to) })), [s])
|
||||
const t = s?.t ?? []
|
||||
|
||||
const chart = (title: string, unit: string, list: ChartSeries[], zero = true) =>
|
||||
s && list.some((x) => x.values.some((v) => v !== null)) ? (
|
||||
<div className="card chart-card">
|
||||
<h3>{title}</h3>
|
||||
<TimeChart t={t} series={list} unit={unit} gaps={gaps} zero={zero} />
|
||||
// 图表只在数据变化时更新;别的状态(加载中、实时推送)变化不会让它们闪
|
||||
const charts = useMemo(
|
||||
() =>
|
||||
s
|
||||
? GROUPS.map((group) => {
|
||||
const cards = CHARTS.filter((def) => def.group === group.id).map((def) => {
|
||||
const raw = def.series.map((x) => ({ x, values: s.values[x.key] ?? [] }))
|
||||
// 开了 EWMA:原始数据画成淡线垫在后面,平滑后的线画在上面
|
||||
const list: ChartSeries[] =
|
||||
smooth.alpha > 0
|
||||
? [
|
||||
...raw.map(({ x, values }) => ({ label: `${x.label}(原始)`, values, color: x.color, faint: true })),
|
||||
...raw.map(({ x, values }) => ({ label: x.label, values: ewma(values, smooth.alpha), color: x.color, dash: x.dash, fill: def.fill })),
|
||||
]
|
||||
: raw.map(({ x, values }) => ({ label: x.label, values, color: x.color, dash: x.dash, fill: def.fill }))
|
||||
if (!list.some((x) => x.values.some((v) => v !== null))) return null
|
||||
return { def, node: <TimeChart t={s.t} series={list} unit={def.unit} gaps={gaps} zero={def.zero !== false} height={def.wide ? 260 : 220} /> }
|
||||
})
|
||||
return { group, cards: cards.filter((c): c is NonNullable<typeof c> => c !== null) }
|
||||
}).filter((g) => g.cards.length > 0)
|
||||
: [],
|
||||
[s, gaps, smooth.alpha],
|
||||
)
|
||||
const loadingNow = !ready || series.busy
|
||||
const chartSections = charts.map(({ group, cards }) => (
|
||||
<section className="chart-section" key={group.id}>
|
||||
<h2>{group.title}</h2>
|
||||
<div className="grid cols-2">
|
||||
{cards.map(({ def, node }) => (
|
||||
<div className={`card chart-card has-loading ${def.wide ? 'wide' : ''}`} key={def.id}>
|
||||
<LoadingBar active={loadingNow} />
|
||||
<h3>{def.title}</h3>
|
||||
{node}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
</section>
|
||||
))
|
||||
|
||||
const result = test.data?.result
|
||||
const peak = result?.metrics?.['tcp.bulk.peak.max']
|
||||
@@ -109,111 +260,102 @@ export function Line() {
|
||||
<div className="row wrap" style={{ marginTop: 12 }}>
|
||||
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
|
||||
<span className="muted">{line.data.online ? (line.data.probe_connected ? '在线' : 'Agent 在线,Probe 未连接') : 'Agent 离线'}</span>
|
||||
<span className="row" style={{ gap: 6 }}>
|
||||
<span className="faint">Agent</span>
|
||||
<NodeTag info={infos.get(line.data.agent_id)} />
|
||||
</span>
|
||||
<span className="row" style={{ gap: 6 }}>
|
||||
<span className="faint">Probe</span>
|
||||
<NodeTag info={infos.get(line.data.probe_id)} />
|
||||
</span>
|
||||
{line.data.labels.map((x) => (
|
||||
<Chip key={x}>{x}</Chip>
|
||||
))}
|
||||
{series.loading && !s ? <Spinner /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="toolbar">
|
||||
<RangePicker state={state} onChange={setRange} sinceMs={sinceMs} nowMs={nowMs} />
|
||||
<span className="right row">
|
||||
<span className="muted">{resolved.label}</span>
|
||||
{series.loading && !s ? <Spinner /> : null}
|
||||
</span>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<RangePicker
|
||||
dayStart={period.dayStart}
|
||||
fromMs={period.fromMs}
|
||||
toMs={period.toMs}
|
||||
onChange={changeDay}
|
||||
onWindow={pickLength}
|
||||
scale={period.domainMin > 1440 ? 48 : 24}
|
||||
onScale={setScale}
|
||||
sinceMs={sinceMs}
|
||||
nowMs={nowMs}
|
||||
spanMs={period.spanMs}
|
||||
>
|
||||
<Minimap
|
||||
domainStart={period.domainStart}
|
||||
domainMin={period.domainMin}
|
||||
t={period.overview.t}
|
||||
values={period.overview.loss}
|
||||
fromMs={period.fromMs}
|
||||
toMs={period.toMs}
|
||||
loading={!ready || period.busy}
|
||||
onSelect={selectRange}
|
||||
/>
|
||||
</RangePicker>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PageError error={line.error ?? series.error} />
|
||||
|
||||
{!s && (loadingNow || !line.error) && !series.error ? (
|
||||
// 还没有数据:先摆出占位,每块上面有进度条
|
||||
<div className="stack" style={{ marginTop: 24 }}>
|
||||
<div className="tiles">
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<div className="skeleton has-loading" key={i}>
|
||||
<LoadingBar active />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton chart has-loading">
|
||||
<LoadingBar active />
|
||||
</div>
|
||||
<div className="grid cols-2">
|
||||
{[0, 1].map((i) => (
|
||||
<div className="skeleton chart has-loading" key={i}>
|
||||
<LoadingBar active />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{s ? (
|
||||
<div className="stack" style={{ marginTop: 24 }}>
|
||||
<div className="card">
|
||||
<div className="row wrap" style={{ justifyContent: 'space-between', gap: 24 }}>
|
||||
<div className="grid stats grow">
|
||||
<Stat label="延迟 p50" value={fmtMs(m['rtt.p50'])} />
|
||||
<Stat label="延迟 p95" value={fmtMs(m['rtt.p95'])} />
|
||||
<Stat label="丢包" value={fmtPct(m['loss.total'])} tone={tone(m['loss.total'], 0.5, 3)} />
|
||||
<Stat label="上行丢包" value={fmtPct(m['loss.up'])} tone={tone(m['loss.up'], 0.5, 3)} />
|
||||
<Stat label="下行丢包" value={fmtPct(m['loss.down'])} tone={tone(m['loss.down'], 0.5, 3)} />
|
||||
<Stat label="抖动(上 / 下)" value={`${fmtMs(m['jitter.up.p95'])} / ${fmtMs(m['jitter.down.p95'])}`} />
|
||||
</div>
|
||||
<div className="tiles">
|
||||
<div className="tile scores-tile has-loading">
|
||||
<LoadingBar active={loadingNow} />
|
||||
<span className="k">评分</span>
|
||||
<ScoreChips metrics={m} />
|
||||
</div>
|
||||
<hr className="divider" />
|
||||
<div className="grid stats">
|
||||
<Stat label="TCP RTT p50" value={fmtMs(m['tcp.rtt.p50'])} />
|
||||
<Stat label="TCP RTT p99" value={fmtMs(m['tcp.rtt.p99'])} />
|
||||
<Stat label="TCP RTT 最大" value={fmtMs(m['tcp.rtt.max'])} />
|
||||
<Stat label="TCP 重传" value={fmtPct(m['tcp.retrans'])} tone={tone(m['tcp.retrans'], 0.5, 3)} />
|
||||
<Stat label="最长连续丢包" value={fmtNum(m['loss.run_max'])} unit="包" />
|
||||
<Stat label="样本数(UDP / TCP)" value={`${fmtNum(s.summary.samples['udp_echo'])} / ${fmtNum(s.summary.samples['tcp_echo'])}`} />
|
||||
</div>
|
||||
{s.summary.coverage < 0.95 ? <p className="faint" style={{ marginTop: 12 }}>这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。</p> : null}
|
||||
{TILES.map((t) => (
|
||||
<Stat key={t.key} label={t.label} value={t.fmt(m[t.key])} unit={t.unit} tone={t.good !== undefined ? tone(m[t.key], t.good, t.poor ?? Infinity) : ''} />
|
||||
))}
|
||||
</div>
|
||||
<p className="faint">
|
||||
样本数:UDP {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}
|
||||
</p>
|
||||
{s.summary.coverage < 0.95 ? <p className="faint">这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。</p> : null}
|
||||
|
||||
<div className="grid cols-2">
|
||||
{chart('延迟(UDP 往返)', 'ms', [
|
||||
{ label: 'p50', values: col('rtt.p50'), color: '--chart-1' },
|
||||
{ label: 'p95', values: col('rtt.p95'), color: '--chart-2' },
|
||||
{ label: 'p99', values: col('rtt.p99'), color: '--chart-3' },
|
||||
])}
|
||||
{chart('丢包', '%', [
|
||||
{ label: '上行', values: col('loss.up'), color: '--chart-2' },
|
||||
{ label: '下行', values: col('loss.down'), color: '--chart-3' },
|
||||
{ label: '合计', values: col('loss.total'), color: '--chart-1', dash: [4, 3] },
|
||||
])}
|
||||
{chart('抖动(p95)', 'ms', [
|
||||
{ label: '上行', values: col('jitter.up.p95'), color: '--chart-2' },
|
||||
{ label: '下行', values: col('jitter.down.p95'), color: '--chart-1' },
|
||||
])}
|
||||
{chart('TCP 往返', 'ms', [
|
||||
{ label: 'p50', values: col('tcp.rtt.p50'), color: '--chart-1' },
|
||||
{ label: 'p95', values: col('tcp.rtt.p95'), color: '--chart-2' },
|
||||
{ label: '最大', values: col('tcp.rtt.max'), color: '--chart-3' },
|
||||
])}
|
||||
{chart('TCP 重传', '%', [{ label: '重传率', values: col('tcp.retrans'), color: '--chart-3', fill: true }])}
|
||||
{chart('综合评分', '', [
|
||||
{ label: 'REALITY', values: col('score.reality'), color: '--chart-1' },
|
||||
{ label: 'Hy2', values: col('score.hy2'), color: '--chart-2' },
|
||||
{ label: 'MC', values: col('score.mc'), color: '--chart-4' },
|
||||
{ label: '综合', values: col('score.base'), color: '--chart-5', dash: [4, 3] },
|
||||
])}
|
||||
<div className="row" style={{ justifyContent: 'flex-end', gap: 8 }}>
|
||||
<span className="faint" title="指数加权移动平均:把线条磨平,看清趋势;原始数据以淡线垫在后面">
|
||||
EWMA 平滑
|
||||
</span>
|
||||
<Segmented<EwmaValue>
|
||||
options={EWMA.map((x) => ({ value: x.value, label: x.label }))}
|
||||
value={smooth.value}
|
||||
onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))}
|
||||
/>
|
||||
</div>
|
||||
{chartSections}
|
||||
|
||||
{host || hostHistory.data ? (
|
||||
<div className="card">
|
||||
<div className="card-title">主机状态</div>
|
||||
{host ? (
|
||||
<div className="grid stats">
|
||||
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(0)} unit="%" />
|
||||
<Stat label="内存" value={host.mem_total ? ((100 * (host.mem_used ?? 0)) / host.mem_total).toFixed(0) : '—'} unit="%" />
|
||||
<Stat label="磁盘" value={host.disk_total ? ((100 * (host.disk_used ?? 0)) / host.disk_total).toFixed(0) : '—'} unit="%" />
|
||||
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
|
||||
</div>
|
||||
) : null}
|
||||
{hostHistory.data ? (
|
||||
<div className="grid cols-2" style={{ marginTop: 16 }}>
|
||||
<TimeChart
|
||||
t={hostHistory.data.t}
|
||||
unit="%"
|
||||
series={[
|
||||
{ label: 'CPU', values: hostHistory.data.values['cpu'] ?? [], color: '--chart-1' },
|
||||
{ label: '内存', values: hostHistory.data.values['mem'] ?? [], color: '--chart-2' },
|
||||
{ label: '磁盘', values: hostHistory.data.values['disk'] ?? [], color: '--chart-4' },
|
||||
]}
|
||||
/>
|
||||
<TimeChart
|
||||
t={hostHistory.data.t}
|
||||
unit=" B/s"
|
||||
series={[
|
||||
{ label: '上行', values: hostHistory.data.values['tx_bps'] ?? [], color: '--chart-2' },
|
||||
{ label: '下行', values: hostHistory.data.values['rx_bps'] ?? [], color: '--chart-1' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<HostCard agentId={agentId} range={period.hostRange} />
|
||||
|
||||
{test.data && result ? (
|
||||
<div className="card">
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { OverviewDto, OverviewLineDto } from '@shared/api/types'
|
||||
import { fmtAgo, fmtMs, fmtPct } from '@shared/format'
|
||||
import type { NodeInfoDto, OverviewDto, OverviewLineDto } from '@shared/api/types'
|
||||
import { fmtAgo } from '@shared/format'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { Chip, Dot, Empty, Segmented, Spinner } from '@shared/ui'
|
||||
import { fmtBytes, fmtSpeed, fmtUptime, pct, useHosts, type HostItem } from '../live'
|
||||
import { PageError, ScoreChips } from '../lib'
|
||||
import { Flag, IspIcon, useNodeInfos } from '@shared/nodes'
|
||||
import { OsIcon } from '@shared/osIcon'
|
||||
import { PageError } from '../lib'
|
||||
import { LineStrips, OVERVIEW_RANGES, type OverviewRange } from '../LineStrips'
|
||||
|
||||
const METRICS = ['rtt.p50', 'loss.total', 'jitter.up.p95', 'jitter.down.p95', 'score.base', 'score.reality', 'score.hy2', 'score.mc'].join(',')
|
||||
const METRICS = ['rtt.p50', 'loss.total', 'loss.up', 'loss.down', 'tcp.rtt.p50', 'tcp.retrans'].join(',')
|
||||
|
||||
type SortKey = 'name' | 'rtt' | 'score'
|
||||
type SortKey = 'name' | 'rtt' | 'loss'
|
||||
|
||||
interface AgentCard {
|
||||
id: string
|
||||
@@ -20,12 +23,6 @@ interface AgentCard {
|
||||
lines: OverviewLineDto[]
|
||||
}
|
||||
|
||||
function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (value <= good) return 'ok'
|
||||
if (value >= poor) return 'bad'
|
||||
return 'warn'
|
||||
}
|
||||
|
||||
function Meter({ label, value, detail }: { label: string; value: number | null; detail?: string }) {
|
||||
const level = value === null ? '' : value >= 90 ? 'bad' : value >= 75 ? 'warn' : 'ok'
|
||||
@@ -41,17 +38,49 @@ function Meter({ label, value, detail }: { label: string; value: number | null;
|
||||
)
|
||||
}
|
||||
|
||||
function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined }) {
|
||||
function Card({ agent, host, range, infos }: { agent: AgentCard; host: HostItem | undefined; range: OverviewRange; infos: Map<string, NodeInfoDto> }) {
|
||||
const h = host?.host ?? null
|
||||
const stale = !agent.online || !host || host.stale
|
||||
const info = infos.get(agent.id)
|
||||
return (
|
||||
<div className={`card agent-card ${agent.online ? '' : 'offline'}`}>
|
||||
<div className="agent-head">
|
||||
<Dot tone={agent.online ? 'ok' : 'bad'} />
|
||||
{/* 有国旗就用国旗开头(在线、离线看右边的标签),没有再用状态点 */}
|
||||
{info?.country ? <Flag code={info.country} /> : <Dot tone={agent.online ? 'ok' : 'bad'} />}
|
||||
<span className="agent-name">{agent.name}</span>
|
||||
<Chip tone={agent.online ? 'ok' : 'bad'}>{agent.online ? '在线' : '离线'}</Chip>
|
||||
{!agent.online && host?.last_seen_at ? <span className="faint">{fmtAgo(host.last_seen_at)}</span> : null}
|
||||
</div>
|
||||
{info?.isp_name || info?.os_name || info?.tag || info?.version ? (
|
||||
<div className="facts">
|
||||
{info.isp_name ? (
|
||||
<div className="fact">
|
||||
<span className="k">运营商</span>
|
||||
<span className="v">
|
||||
{info.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
|
||||
{info.isp_name}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{info.os_name ? (
|
||||
<div className="fact">
|
||||
<span className="k">系统</span>
|
||||
<span className="v">
|
||||
<OsIcon os={info.os} name={info.os_name} />
|
||||
{[info.os_name, info.arch].filter(Boolean).join(' / ')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{info.tag || info.version ? (
|
||||
<div className="fact">
|
||||
<span className="k">版本</span>
|
||||
<span className="v mono-tag" title={info.version ? `版本 ${info.version}` : undefined}>
|
||||
{info.tag || info.version}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{agent.labels.length > 0 ? (
|
||||
<div className="line-meta">
|
||||
{agent.labels.map((x) => (
|
||||
@@ -65,12 +94,21 @@ function Card({ agent, host }: { agent: AgentCard; host: HostItem | undefined })
|
||||
const m = l.summary.metrics
|
||||
return (
|
||||
<Link key={l.id} to={`/line/${encodeURIComponent(l.id)}`} className="line-link">
|
||||
<span className="muted">→ {l.probe_name}</span>
|
||||
<span className="grow" />
|
||||
{!l.probe_connected && l.online ? <Chip tone="warn">Probe 未连接</Chip> : null}
|
||||
<span className="num">{fmtMs(m['rtt.p50'])}</span>
|
||||
<Chip tone={tone(m['loss.total'], 0.5, 3)}>{fmtPct(m['loss.total'])}</Chip>
|
||||
<ScoreChips metrics={m} compact />
|
||||
<div className="line-top">
|
||||
<span className="line-name">
|
||||
→ {infos.get(l.probe_id)?.country ? <Flag code={infos.get(l.probe_id)!.country!} /> : null}
|
||||
{l.probe_name}
|
||||
</span>
|
||||
{infos.get(l.probe_id)?.isp_key ? <IspIcon ispKey={infos.get(l.probe_id)!.isp_key!} name={infos.get(l.probe_id)!.isp_name} /> : null}
|
||||
{infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version ? (
|
||||
<span className="probe-tag" title="Probe 版本">
|
||||
{infos.get(l.probe_id)?.tag || infos.get(l.probe_id)?.version}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="faint">近 {OVERVIEW_RANGES.find((r) => r.value === range)?.label ?? range}</span>
|
||||
{!l.probe_connected && l.online ? <Chip tone="warn">Probe 未连接</Chip> : null}
|
||||
</div>
|
||||
<LineStrips lineId={l.id} range={range} m={m} />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@@ -113,7 +151,21 @@ export function Overview() {
|
||||
const [probe, setProbe] = useState<string>('')
|
||||
const [sort, setSort] = useState<SortKey>('name')
|
||||
const { hosts, live } = useHosts()
|
||||
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range: '15m', metrics: METRICS }), [], 10_000)
|
||||
const infos = useNodeInfos()
|
||||
// 近多久的汇总和走势:3 / 6 / 12 小时,记在链接里(?range=6h)
|
||||
const [params, setParams] = useSearchParams()
|
||||
const range: OverviewRange = OVERVIEW_RANGES.find((r) => r.value === params.get('range'))?.value ?? '3h'
|
||||
const setRange = (v: OverviewRange) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (v === '3h') next.delete('range')
|
||||
else next.set('range', v)
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
const { data, error, loading } = useQuery(() => api.get<OverviewDto>('/api/public/overview', { range, metrics: METRICS }), [range], 10_000)
|
||||
|
||||
const probes = useMemo(() => [...new Set((data?.lines ?? []).map((l) => l.probe_name))].sort(), [data])
|
||||
const cards = useMemo(() => {
|
||||
@@ -131,13 +183,13 @@ export function Overview() {
|
||||
return [...byAgent.values()].sort((a, b) => {
|
||||
if (a.online !== b.online) return a.online ? -1 : 1
|
||||
if (sort === 'name') return a.name.localeCompare(b.name)
|
||||
const key = sort === 'rtt' ? 'rtt.p50' : 'score.base'
|
||||
const x = best(a, key, sort === 'rtt' ? Math.min : Math.max)
|
||||
const y = best(b, key, sort === 'rtt' ? Math.min : Math.max)
|
||||
const key = sort === 'rtt' ? 'rtt.p50' : 'loss.total'
|
||||
const x = best(a, key, Math.min)
|
||||
const y = best(b, key, Math.min)
|
||||
if (x === null && y === null) return a.name.localeCompare(b.name)
|
||||
if (x === null) return 1
|
||||
if (y === null) return -1
|
||||
return sort === 'rtt' ? x - y : y - x
|
||||
return x - y
|
||||
})
|
||||
}, [data, probe, sort])
|
||||
|
||||
@@ -146,7 +198,7 @@ export function Overview() {
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="hero">
|
||||
<h1>线路质量</h1>
|
||||
<h1>总览</h1>
|
||||
<p className="sub">
|
||||
{data ? `${online} / ${cards.length} 台在线` : '加载中…'}
|
||||
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
|
||||
@@ -154,11 +206,12 @@ export function Overview() {
|
||||
<div className="toolbar">
|
||||
{probes.length > 1 ? <Segmented options={[{ value: '', label: '全部 Probe' }, ...probes.map((p) => ({ value: p, label: p }))]} value={probe} onChange={setProbe} /> : null}
|
||||
<span className="right row">
|
||||
<Segmented<OverviewRange> options={OVERVIEW_RANGES.map((r) => ({ value: r.value, label: `近 ${r.label}` }))} value={range} onChange={setRange} />
|
||||
<Segmented
|
||||
options={[
|
||||
{ value: 'name' as const, label: '名称' },
|
||||
{ value: 'rtt' as const, label: '延迟' },
|
||||
{ value: 'score' as const, label: '评分' },
|
||||
{ value: 'loss' as const, label: '丢包' },
|
||||
]}
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
@@ -178,7 +231,7 @@ export function Overview() {
|
||||
) : (
|
||||
<div className="cards">
|
||||
{cards.map((c) => (
|
||||
<Card key={c.id} agent={c} host={hosts.get(c.id)} />
|
||||
<Card key={c.id} agent={c} host={hosts.get(c.id)} range={range} infos={infos} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useMemo } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { SeriesDto } from '@shared/api/types'
|
||||
import { useQuery } from '@shared/hooks'
|
||||
import { availableWindows, dayBounds, dayKey, defaultWindow, fmtDayLabel, fmtSpan, isWindow, parseDay, WINDOWS, type WindowValue } from './ranges'
|
||||
|
||||
const MIN = 60_000
|
||||
const HOUR = 60 * MIN
|
||||
|
||||
/** URL 里的一段时间:哪一天、快捷档位、或者拖出来的起止分钟(从当天 0 点算)。 */
|
||||
export interface PeriodSpec {
|
||||
day: string | null
|
||||
range: string | null
|
||||
at: string | null
|
||||
/** 缩略图的刻度:缺省连续 48 小时(可以跨零点);`24` = 只看一天。 */
|
||||
span: string | null
|
||||
}
|
||||
|
||||
export function readSpec(params: URLSearchParams): PeriodSpec {
|
||||
return { day: params.get('day'), range: params.get('range'), at: params.get('at'), span: params.get('span') }
|
||||
}
|
||||
|
||||
export function writeSpec(params: URLSearchParams, spec: PeriodSpec) {
|
||||
for (const [key, value] of [['day', spec.day], ['range', spec.range], ['at', spec.at], ['span', spec.span]] as const) {
|
||||
if (value) params.set(key, value)
|
||||
else params.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** `at=起_止`:从选中那天 0 点算起的分钟数,可以是负的(前一天晚上)或超过 1440(跨到后一天);晚高峰常常跨零点。 */
|
||||
export function parseAt(at: string | null): [number, number] | null {
|
||||
const m = /^(-?\d{1,5})_(-?\d{1,5})$/.exec(at ?? '')
|
||||
if (!m) return null
|
||||
const a = Number(m[1])
|
||||
const b = Number(m[2])
|
||||
return b > a && a >= -DOMAIN_MIN && b <= 2 * DOMAIN_MIN && b - a <= 2 * DOMAIN_MIN ? [a, b] : null
|
||||
}
|
||||
|
||||
/** 缩略图的时间轴:连续的 48 小时,选区可以跨零点。 */
|
||||
export const DOMAIN_MIN = 1440
|
||||
export const DOMAIN_SPAN = 2 * DOMAIN_MIN
|
||||
|
||||
export const fmtMinute = (minutes: number) => `${String(Math.floor(minutes / 60) % 24).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`
|
||||
|
||||
const fmtClock = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
|
||||
export interface Period {
|
||||
dayStart: number
|
||||
isToday: boolean
|
||||
/** 这一天里的数据概览(缩略图用)。 */
|
||||
overview: { t: number[]; loss: (number | null)[]; rtt: (number | null)[] }
|
||||
/** 缩略图时间轴的起点(毫秒):往日是这一天 0 点起的 48 小时;今天是昨天 0 点起的 48 小时。 */
|
||||
domainStart: number
|
||||
/** 缩略图的刻度长度(分钟):1440 或 2880。 */
|
||||
domainMin: number
|
||||
spanMs: number | null
|
||||
dataFirst: number | null
|
||||
available: WindowValue[]
|
||||
/** 当前选中的快捷档位;拖出来的区间是 `null`。 */
|
||||
win: WindowValue | null
|
||||
/** 选中的区间(毫秒):缩略图上的选区、也是对比时对齐的起点。 */
|
||||
fromMs: number
|
||||
toMs: number
|
||||
query: Record<string, string>
|
||||
hostRange: string | null
|
||||
label: string
|
||||
shortLabel: string
|
||||
/** 缩略图的数据正在加载(不含定时刷新)。 */
|
||||
busy: boolean
|
||||
/** 今天、且是「最近一段」:会跟着最新数据刷新。 */
|
||||
live: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「哪一天 + 看多长或拖出的区间」变成这一段的取数参数和概览。
|
||||
* 今天的快捷档位看最近一段(实时);往日的快捷档位从这一天第一条记录所在的那一分钟起算。
|
||||
*/
|
||||
export function usePeriod(lineId: string, ready: boolean, sinceMs: number | null, nowMs: number, spec: PeriodSpec): Period {
|
||||
const todayStart = dayBounds(nowMs).start
|
||||
const firstDay = sinceMs === null ? todayStart : dayBounds(sinceMs).start
|
||||
const dayStart = Math.min(Math.max(parseDay(spec.day) ?? todayStart, firstDay), todayStart)
|
||||
const isToday = dayStart === todayStart
|
||||
const bounds = dayBounds(dayStart)
|
||||
const dayEnd = isToday ? nowMs : bounds.end
|
||||
|
||||
// 缩略图的数据:连续 48 小时(选区可以跨零点);这一天自己的数据范围和档位从中截取
|
||||
// 刻度:缺省连续 48 小时(选区可以跨零点);用户选了 24 小时就只看这一天,除非选区本身跨了零点
|
||||
const wantedAt = parseAt(spec.at)
|
||||
const wide = spec.span !== '24' || (wantedAt !== null && (wantedAt[1] > DOMAIN_MIN || wantedAt[0] < 0))
|
||||
const domainMin = wide ? DOMAIN_SPAN : DOMAIN_MIN
|
||||
const before = (isToday && wide) || (wantedAt !== null && wantedAt[0] < 0) ? DOMAIN_MIN : 0
|
||||
const domainStart = dayStart - before * MIN
|
||||
const domainEnd = Math.min(domainStart + domainMin * MIN, nowMs)
|
||||
const probe = useQuery(
|
||||
() =>
|
||||
ready
|
||||
? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, {
|
||||
from: new Date(Math.max(domainStart, sinceMs === null ? domainStart : dayBounds(sinceMs).start)).toISOString(),
|
||||
to: new Date(domainEnd).toISOString(),
|
||||
step: wide ? '2m' : '1m',
|
||||
preset: 'udp',
|
||||
metrics: 'rtt.p50,loss.total',
|
||||
})
|
||||
: Promise.resolve(undefined),
|
||||
[lineId, domainStart, domainMin, ready, isToday ? Math.floor(nowMs / MIN) : 0],
|
||||
)
|
||||
const overview = useMemo(() => {
|
||||
const t = probe.data?.t ?? []
|
||||
return { t, loss: probe.data?.values['loss.total'] ?? [], rtt: probe.data?.values['rtt.p50'] ?? [] }
|
||||
}, [probe.data])
|
||||
|
||||
// 这一天自己的数据:第一条和最后一条记录
|
||||
let firstIdx = -1
|
||||
let lastIdx = -1
|
||||
for (let i = 0; i < overview.t.length; i++) {
|
||||
if (overview.t[i] < dayStart || overview.t[i] >= dayEnd) continue
|
||||
if (overview.rtt[i] === null || overview.rtt[i] === undefined) continue
|
||||
if (firstIdx < 0) firstIdx = i
|
||||
lastIdx = i
|
||||
}
|
||||
const stepMs = overview.t.length > 1 ? overview.t[1] - overview.t[0] : MIN
|
||||
const dataFirst = firstIdx >= 0 ? overview.t[firstIdx] : null
|
||||
// 哪些长度可选:选区可以跨到前一天/后一天,所以看的是「从这里往前(今天)或往后(往日)一共有多少数据」,不是这一天自己有多少
|
||||
const availSpan = isToday ? (sinceMs === null ? null : nowMs - sinceMs) : dataFirst === null ? null : nowMs - dataFirst
|
||||
const available = availableWindows(availSpan)
|
||||
|
||||
const dayLabel = fmtDayLabel(dayStart, nowMs)
|
||||
const at = wantedAt
|
||||
const wanted = isWindow(spec.range) && available.includes(spec.range) ? spec.range : null
|
||||
const win: WindowValue | null = at ? null : (wanted ?? defaultWindow(isToday, available))
|
||||
|
||||
let fromMs: number
|
||||
let toMs: number
|
||||
let query: Record<string, string>
|
||||
let hostRange: string | null = null
|
||||
let label: string
|
||||
let live = false
|
||||
if (at) {
|
||||
fromMs = bounds.start + at[0] * MIN
|
||||
toMs = Math.min(bounds.start + at[1] * MIN, nowMs)
|
||||
if (toMs <= fromMs) toMs = fromMs + MIN
|
||||
query = { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString() }
|
||||
label = fmtSpan(fromMs, toMs)
|
||||
} else if (win === 'day') {
|
||||
fromMs = bounds.start
|
||||
toMs = dayEnd
|
||||
query = { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString() }
|
||||
hostRange = isToday ? '24h' : null
|
||||
label = `${dayLabel} 全天`
|
||||
} else {
|
||||
const info = WINDOWS.find((w) => w.value === win)!
|
||||
if (isToday) {
|
||||
// 今天:最近一段,以最新的数据为终点
|
||||
const lastMs = lastIdx >= 0 ? overview.t[lastIdx] + stepMs : nowMs
|
||||
toMs = Math.min(nowMs, lastMs)
|
||||
fromMs = toMs - info.ms
|
||||
query = { range: info.value, anchor: 'latest' }
|
||||
hostRange = info.ms <= HOUR ? '1h' : info.value
|
||||
label = `${dayLabel} 最近 ${info.label}`
|
||||
live = true
|
||||
} else {
|
||||
// 往日:从这一天第一条记录所在的那一分钟起
|
||||
fromMs = Math.floor((dataFirst ?? bounds.start) / MIN) * MIN
|
||||
toMs = Math.min(fromMs + info.ms, bounds.end)
|
||||
query = { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString() }
|
||||
label = `${dayLabel} ${fmtClock(fromMs)} 起 ${info.label}`
|
||||
}
|
||||
}
|
||||
const shortLabel = fmtSpan(fromMs, toMs, true)
|
||||
return { dayStart, isToday, domainStart, domainMin, overview, spanMs: availSpan, dataFirst, available, win, fromMs, toMs, query, hostRange, label, shortLabel, busy: probe.busy, live }
|
||||
}
|
||||
|
||||
/** 这一段的序列;今天的「最近一段」会定时刷新。 */
|
||||
export function usePeriodSeries(lineId: string, ready: boolean, period: Period) {
|
||||
return useQuery(
|
||||
() => (ready ? api.get<SeriesDto>(`/api/public/lines/${encodeURIComponent(lineId)}/series`, { ...period.query, preset: 'udp,tcp,scores' }) : Promise.resolve(undefined)),
|
||||
[lineId, JSON.stringify(period.query), ready],
|
||||
period.isToday ? (period.win === 'day' ? 60_000 : 10_000) : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export { dayKey }
|
||||
+130
-140
@@ -1,11 +1,12 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useRef, type ReactNode } from 'react'
|
||||
import { Segmented } from '@shared/ui'
|
||||
|
||||
const MIN = 60_000
|
||||
const DAY = 24 * 60 * MIN
|
||||
const HOUR = 60 * MIN
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
/** 线路详情页的时间范围:只提供这条线路“有数据”的档位。 */
|
||||
/** 对比页的时间范围:最近一段。 */
|
||||
export const PRESETS = [
|
||||
{ value: '5m', label: '5 分钟', ms: 5 * MIN },
|
||||
{ value: '15m', label: '15 分钟', ms: 15 * MIN },
|
||||
@@ -19,172 +20,161 @@ export const PRESETS = [
|
||||
|
||||
export type PresetValue = (typeof PRESETS)[number]['value']
|
||||
|
||||
/** 进入某个月之后的切片:整月,或者从月初起按固定长度翻页。 */
|
||||
export const SLICES = [
|
||||
{ value: 'all', label: '整月', ms: 0 },
|
||||
{ value: '14d', label: '14 天', ms: 14 * DAY },
|
||||
{ value: '7d', label: '7 天', ms: 7 * DAY },
|
||||
{ value: '3d', label: '3 天', ms: 3 * DAY },
|
||||
{ value: '1d', label: '1 天', ms: DAY },
|
||||
/** 线路详情页:先选哪一天,再选这一天里看多长。`day` = 整个自然日。 */
|
||||
export const WINDOWS = [
|
||||
{ value: '30m', label: '30 分钟', ms: 30 * MIN },
|
||||
{ value: '1h', label: '1 小时', ms: HOUR },
|
||||
{ value: '3h', label: '3 小时', ms: 3 * HOUR },
|
||||
{ value: '6h', label: '6 小时', ms: 6 * HOUR },
|
||||
{ value: '12h', label: '12 小时', ms: 12 * HOUR },
|
||||
{ value: 'day', label: '整天', ms: DAY },
|
||||
] as const
|
||||
|
||||
export type SliceValue = (typeof SLICES)[number]['value']
|
||||
export type WindowValue = (typeof WINDOWS)[number]['value']
|
||||
|
||||
/** 数据不足整档时允许 10% 的误差:29 分钟的数据算得上“30 分钟”。至少保留最小的一档。 */
|
||||
const TOLERANCE = 0.9
|
||||
|
||||
export function availablePresets(spanMs: number | null) {
|
||||
if (spanMs === null) return [PRESETS[0]]
|
||||
const list = PRESETS.filter((p) => spanMs >= p.ms * TOLERANCE)
|
||||
return list.length > 0 ? list : [PRESETS[0]]
|
||||
}
|
||||
|
||||
/** 默认选“有数据的最大档”,但最长到 1 天(再长的范围图会很挤,用户可以自己点)。 */
|
||||
export function defaultPreset(spanMs: number | null): PresetValue {
|
||||
const list = availablePresets(spanMs).filter((p) => p.ms <= DAY)
|
||||
return list[list.length - 1]?.value ?? '5m'
|
||||
}
|
||||
|
||||
export interface MonthInfo {
|
||||
key: string // YYYY-MM
|
||||
label: string
|
||||
start: number
|
||||
end: number // 不含
|
||||
}
|
||||
export const isWindow = (v: string | null): v is WindowValue => WINDOWS.some((w) => w.value === v)
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
export function monthOf(ms: number): MonthInfo {
|
||||
/** 本地时区的 YYYY-MM-DD。 */
|
||||
export function dayKey(ms: number): string {
|
||||
const d = new Date(ms)
|
||||
const start = new Date(d.getFullYear(), d.getMonth(), 1).getTime()
|
||||
const end = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime()
|
||||
return { key: `${d.getFullYear()}-${pad(d.getMonth() + 1)}`, label: `${d.getFullYear()} 年 ${d.getMonth() + 1} 月`, start, end }
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
/** 从有数据的第一个月到当前月,新的在前。 */
|
||||
export function monthsWithData(sinceMs: number | null, nowMs: number): MonthInfo[] {
|
||||
if (sinceMs === null) return []
|
||||
const months: MonthInfo[] = []
|
||||
let cursor = monthOf(sinceMs)
|
||||
const last = monthOf(nowMs)
|
||||
while (cursor.start <= last.start) {
|
||||
months.push(cursor)
|
||||
cursor = monthOf(cursor.end)
|
||||
}
|
||||
return months.reverse()
|
||||
export function parseDay(key: string | null): number | null {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key ?? '')
|
||||
if (!m) return null
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
||||
return Number.isNaN(d.getTime()) ? null : d.getTime()
|
||||
}
|
||||
|
||||
export interface RangeState {
|
||||
/** 没有 month 就是“最近一段”。 */
|
||||
preset: PresetValue
|
||||
month: string | null
|
||||
slice: SliceValue
|
||||
page: number
|
||||
}
|
||||
|
||||
export interface ResolvedRange {
|
||||
query: { range?: string; from?: string; to?: string; anchor?: string }
|
||||
/** 主机历史只支持 ≥ 1 小时的 range,这里给一个对应的值。 */
|
||||
hostRange: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const fmtDay = (ms: number) => {
|
||||
/** 本地时区这一天的 [起点, 终点)。 */
|
||||
export function dayBounds(ms: number): { start: number; end: number } {
|
||||
const d = new Date(ms)
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
return { start: new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(), end: new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime() }
|
||||
}
|
||||
|
||||
export function resolveRange(state: RangeState, months: MonthInfo[], nowMs: number): ResolvedRange {
|
||||
if (!state.month) {
|
||||
const hostRange = state.preset === '5m' || state.preset === '15m' || state.preset === '30m' ? '1h' : state.preset
|
||||
return { query: { range: state.preset, anchor: 'latest' }, hostRange, label: PRESETS.find((p) => p.value === state.preset)?.label ?? state.preset }
|
||||
}
|
||||
const month = months.find((m) => m.key === state.month) ?? monthOf(nowMs)
|
||||
const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0]
|
||||
const end = Math.min(month.end, nowMs)
|
||||
let from = month.start
|
||||
let to = end
|
||||
if (slice.ms > 0) {
|
||||
from = month.start + state.page * slice.ms
|
||||
to = Math.min(from + slice.ms, end)
|
||||
}
|
||||
const spanDays = Math.max(1, Math.round((to - from) / DAY))
|
||||
return {
|
||||
query: { from: new Date(from).toISOString(), to: new Date(to).toISOString() },
|
||||
hostRange: spanDays >= 30 ? '30d' : `${Math.max(1, spanDays)}d`,
|
||||
label: slice.ms > 0 ? `${month.label} ${fmtDay(from)} – ${fmtDay(to - 1)}` : month.label,
|
||||
}
|
||||
const clock = (ms: number) => `${pad(new Date(ms).getHours())}:${pad(new Date(ms).getMinutes())}`
|
||||
const fullDay = (ms: number) => `${new Date(ms).getFullYear()}年${new Date(ms).getMonth() + 1}月${new Date(ms).getDate()}日`
|
||||
const shortDay = (ms: number) => `${new Date(ms).getMonth() + 1}月${new Date(ms).getDate()}日`
|
||||
|
||||
/** 一段时间的文字:同一天写一次日期(2026年10月5日 21:06–23:00),跨零点两头都写日期(2026年10月5日 21:06 – 2026年10月6日 02:31)。 */
|
||||
export function fmtSpan(fromMs: number, toMs: number, short = false): string {
|
||||
const day = short ? shortDay : fullDay
|
||||
const end = Math.max(fromMs, toMs - 1)
|
||||
// 正好到次日 0 点的写成 24:00
|
||||
return dayKey(fromMs) === dayKey(end) ? `${day(fromMs)} ${clock(fromMs)}–${clock(toMs) === '00:00' ? '24:00' : clock(toMs)}` : `${day(fromMs)} ${clock(fromMs)} – ${day(end)} ${clock(toMs)}`
|
||||
}
|
||||
|
||||
/** 一个月里按固定长度能翻几页。 */
|
||||
function pagesIn(month: MonthInfo, sliceMs: number, nowMs: number, sinceMs: number | null): { first: number; last: number } {
|
||||
const start = Math.max(month.start, sinceMs === null ? month.start : sinceMs - (sinceMs % DAY))
|
||||
const end = Math.min(month.end, nowMs)
|
||||
return { first: Math.max(0, Math.floor((start - month.start) / sliceMs)), last: Math.max(0, Math.ceil((end - month.start) / sliceMs) - 1) }
|
||||
export const fmtDayLabel = (startMs: number, nowMs: number) => {
|
||||
const d = new Date(startMs)
|
||||
const today = dayKey(startMs) === dayKey(nowMs)
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日${today ? '(今天)' : ''}`
|
||||
}
|
||||
|
||||
/** 数据不足整档时允许 10% 的误差:29 分钟的数据算得上“30 分钟”。 */
|
||||
const TOLERANCE = 0.9
|
||||
|
||||
/** 这一天里有多长的数据(毫秒),决定哪些档位可选;`null` = 这一天没有数据。 */
|
||||
export function availableWindows(spanMs: number | null): WindowValue[] {
|
||||
if (spanMs === null) return []
|
||||
const list = WINDOWS.filter((w) => w.value === 'day' || spanMs >= w.ms * TOLERANCE).map((w) => w.value)
|
||||
return list.length > 0 ? list : ['day']
|
||||
}
|
||||
|
||||
/** 默认:今天看最近 1 小时,往日看整天。 */
|
||||
export function defaultWindow(isToday: boolean, available: WindowValue[]): WindowValue {
|
||||
if (!isToday) return 'day'
|
||||
return available.includes('1h') ? '1h' : (available.filter((w) => w !== 'day').pop() ?? 'day')
|
||||
}
|
||||
|
||||
interface Props {
|
||||
state: RangeState
|
||||
onChange: (next: RangeState) => void
|
||||
/** 选中那天的 0 点(本地时区)。 */
|
||||
dayStart: number
|
||||
/** 选区(毫秒):长度正好等于某一档时,那一档高亮。 */
|
||||
fromMs: number
|
||||
toMs: number
|
||||
/** 换了一天(选区回到默认)。 */
|
||||
onChange: (next: { dayStart: number; window: WindowValue | null }) => void
|
||||
/** 点了一档时长:30 分钟、1 小时、3 小时、6 小时、12 小时、整天。 */
|
||||
onWindow: (window: WindowValue) => void
|
||||
/** 缩略图的刻度:24 小时(一天)或 48 小时(连续两天,可以跨零点)。 */
|
||||
scale: 24 | 48
|
||||
onScale: (scale: 24 | 48) => void
|
||||
/** 这条线路最早有数据的时间;`null` = 没有数据。 */
|
||||
sinceMs: number | null
|
||||
nowMs: number
|
||||
/** 这一天里数据的跨度(毫秒);未知或没有数据是 `null`。 */
|
||||
spanMs: number | null
|
||||
/** 放在日期和档位下面(缩略图)。 */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function RangePicker({ state, onChange, sinceMs, nowMs }: Props) {
|
||||
const spanMs = sinceMs === null ? null : nowMs - sinceMs
|
||||
const presets = useMemo(() => availablePresets(spanMs), [spanMs])
|
||||
const months = useMemo(() => monthsWithData(sinceMs, nowMs), [sinceMs, nowMs])
|
||||
const month = state.month ? months.find((m) => m.key === state.month) : undefined
|
||||
const monthLength = month ? Math.min(month.end, nowMs) - Math.max(month.start, sinceMs ?? month.start) : 0
|
||||
const slices = SLICES.filter((s) => s.ms === 0 || monthLength >= s.ms * TOLERANCE)
|
||||
const slice = SLICES.find((s) => s.value === state.slice) ?? SLICES[0]
|
||||
const pages = month && slice.ms > 0 ? pagesIn(month, slice.ms, nowMs, sinceMs) : undefined
|
||||
export function RangePicker({ dayStart, fromMs, toMs, onChange, onWindow, scale, onScale, sinceMs, nowMs, spanMs, children }: Props) {
|
||||
const picker = useRef<HTMLInputElement>(null)
|
||||
const today = dayBounds(nowMs).start
|
||||
const firstDay = sinceMs === null ? today : dayBounds(sinceMs).start
|
||||
const available = availableWindows(spanMs)
|
||||
const length = toMs - fromMs
|
||||
const active: WindowValue = WINDOWS.find((w) => Math.abs(length - w.ms) < 1.5 * MIN)?.value ?? ('' as WindowValue)
|
||||
const step = (days: number) => {
|
||||
const d = new Date(dayStart)
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + days).getTime()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="range-picker">
|
||||
<div className="row wrap">
|
||||
<Segmented
|
||||
options={PRESETS.map((p) => {
|
||||
const ok = presets.some((x) => x.value === p.value)
|
||||
return { value: p.value, label: p.label, disabled: !ok, title: ok ? undefined : '这条线路的数据还没有这么长' }
|
||||
})}
|
||||
value={state.month ? ('' as PresetValue) : state.preset}
|
||||
onChange={(v) => onChange({ ...state, preset: v, month: null, page: 0 })}
|
||||
/>
|
||||
{months.length > 0 ? (
|
||||
<select
|
||||
className="select"
|
||||
style={{ width: 'auto' }}
|
||||
disabled={spanMs === null || spanMs < DAY}
|
||||
title={spanMs !== null && spanMs >= DAY ? undefined : '数据超过 1 天之后可以按月查看'}
|
||||
value={state.month ?? ''}
|
||||
onChange={(e) => onChange({ ...state, month: e.target.value || null, slice: 'all', page: 0 })}
|
||||
aria-label="按月查看"
|
||||
>
|
||||
<option value="">按月查看</option>
|
||||
{months.map((m) => (
|
||||
<option key={m.key} value={m.key}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
<button type="button" className="btn icon small" disabled={dayStart <= firstDay} onClick={() => onChange({ dayStart: step(-1), window: null })} aria-label="前一天">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button type="button" className="day-pick" onClick={() => picker.current?.showPicker?.()}>
|
||||
{fmtDayLabel(dayStart, nowMs)}
|
||||
<input
|
||||
ref={picker}
|
||||
type="date"
|
||||
value={dayKey(dayStart)}
|
||||
min={dayKey(firstDay)}
|
||||
max={dayKey(today)}
|
||||
onChange={(e) => {
|
||||
const picked = parseDay(e.target.value)
|
||||
if (picked !== null) onChange({ dayStart: Math.min(Math.max(picked, firstDay), today), window: null })
|
||||
}}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</button>
|
||||
<button type="button" className="btn icon small" disabled={dayStart >= today} onClick={() => onChange({ dayStart: step(1), window: null })} aria-label="后一天">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</span>
|
||||
{dayStart !== today ? (
|
||||
<button type="button" className="btn small" onClick={() => onChange({ dayStart: today, window: null })}>
|
||||
回到今天
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{month ? (
|
||||
<div className="row wrap" style={{ marginTop: 12 }}>
|
||||
<Segmented options={slices.map((s) => ({ value: s.value, label: s.label }))} value={state.slice} onChange={(v) => onChange({ ...state, slice: v, page: 0 })} />
|
||||
{pages ? (
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
<button type="button" className="btn icon small" disabled={state.page <= pages.first} onClick={() => onChange({ ...state, page: state.page - 1 })} aria-label="上一段">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button type="button" className="btn icon small" disabled={state.page >= pages.last} onClick={() => onChange({ ...state, page: state.page + 1 })} aria-label="下一段">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
<div className="row wrap" style={{ marginTop: 8, justifyContent: 'space-between' }}>
|
||||
<Segmented
|
||||
options={WINDOWS.map((w) => {
|
||||
const ok = available.includes(w.value)
|
||||
return { value: w.value, label: w.label, disabled: !ok, title: ok ? undefined : '这一天的数据没有这么长' }
|
||||
})}
|
||||
value={active}
|
||||
onChange={onWindow}
|
||||
/>
|
||||
<Segmented<'24' | '48'>
|
||||
options={[
|
||||
{ value: '24', label: '24 小时刻度', title: '缩略图显示一天' },
|
||||
{ value: '48', label: '48 小时刻度', title: '缩略图显示连续两天,选区可以跨零点' },
|
||||
]}
|
||||
value={String(scale) as '24' | '48'}
|
||||
onChange={(v) => onScale(v === '48' ? 48 : 24)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import { buildDefines } from '../build-info.mjs'
|
||||
|
||||
// 开发时把 /api 转发给后端,地址取自环境变量 BACKEND_URL,或 web/.env.local 里的 BACKEND_URL=(见 web/.env.example):
|
||||
// BACKEND_URL=https://mo.oeo.one npm run dev:display
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '')
|
||||
const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:5000').replace(/\/+$/, '')
|
||||
const backend = (process.env.BACKEND_URL ?? env.BACKEND_URL ?? 'http://localhost:29181').replace(/\/+$/, '')
|
||||
return {
|
||||
base: '/',
|
||||
define: buildDefines(mode === 'development'),
|
||||
plugins: [react()],
|
||||
resolve: { alias: { '@shared': fileURLToPath(new URL('../shared', import.meta.url)) } },
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
fs: { allow: ['..'] },
|
||||
proxy: {
|
||||
// 开发时管理端是另一个 Vite 服务(5175);和线上一样用同源的 /admin/ 访问它
|
||||
'/admin': { target: 'http://localhost:5175', changeOrigin: true, ws: true },
|
||||
'/api': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
|
||||
'/healthz': { target: backend, changeOrigin: true, secure: true, cookieDomainRewrite: '' },
|
||||
},
|
||||
|
||||
Generated
+30
-1
@@ -10,7 +10,11 @@
|
||||
"workspaces": [
|
||||
"display",
|
||||
"admin"
|
||||
]
|
||||
],
|
||||
"dependencies": {
|
||||
"flag-icons": "^7.5.0",
|
||||
"simple-icons": "^16.34.0"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"name": "monitor-admin",
|
||||
@@ -998,6 +1002,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/flag-icons": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
|
||||
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "6.1.23",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz",
|
||||
@@ -2089,6 +2099,25 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-icons": {
|
||||
"version": "16.34.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.34.0.tgz",
|
||||
"integrity": "sha512-UTxKn0PVspk84B2hHF3Dqiqht1yDjJmDc/t4cO7PyCQxCMk6OjEu95pnpGjhLaGeQ4qBrGASbefTIe/+czrX7g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/simple-icons"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/simple-icons"
|
||||
}
|
||||
],
|
||||
"license": "CC0-1.0",
|
||||
"engines": {
|
||||
"node": ">=0.12.18"
|
||||
}
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||
|
||||
+8
-1
@@ -2,7 +2,10 @@
|
||||
"name": "monitor-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"workspaces": ["display", "admin"],
|
||||
"workspaces": [
|
||||
"display",
|
||||
"admin"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "node dev.mjs",
|
||||
"dev:display": "npm run dev -w display",
|
||||
@@ -10,5 +13,9 @@
|
||||
"build": "npm run build -w display && npm run build -w admin",
|
||||
"gen:api": "npx -y openapi-typescript ../backend/openapi/v1.json -o shared/api/schema.ts",
|
||||
"lint:css": "npm run lint:css -w display && npm run lint:css -w admin"
|
||||
},
|
||||
"dependencies": {
|
||||
"flag-icons": "^7.5.0",
|
||||
"simple-icons": "^16.34.0"
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,28 @@ async function request<T>(method: string, path: string, body?: unknown, init?: R
|
||||
return data as T
|
||||
}
|
||||
|
||||
/**
|
||||
* 在另一个后端上用用户名和密码登录:不设 Cookie(跨域用不了),把会话令牌放在响应里返回(`return_session`),
|
||||
* 调用方存进浏览器,之后每个请求带上。
|
||||
*/
|
||||
export async function loginTo(base: string, username: string, password: string): Promise<string> {
|
||||
const response = await fetch(`${base}/api/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
credentials: 'omit',
|
||||
body: JSON.stringify({ username, password, return_session: true }),
|
||||
})
|
||||
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 }
|
||||
throw new ApiError(response.status, problem.code ?? 'error', problem.detail ?? problem.title ?? response.statusText)
|
||||
}
|
||||
const token = (data as { session_token?: string | null } | undefined)?.session_token
|
||||
if (!token) throw new ApiError(502, 'no_session', '这个后端的版本还不支持跨域登录,请先升级后端')
|
||||
return token
|
||||
}
|
||||
|
||||
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 ?? {}),
|
||||
|
||||
+200
-3
@@ -174,6 +174,40 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/public/nodes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Display info of the visible agents and probes: system, architecture, country and ISP (never an address) */
|
||||
get: operations["nodes_list"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/public/about": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** The backend's version, tag and build time, if the site shows build info */
|
||||
get: operations["about_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/public/catalog/kinds": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -442,7 +476,7 @@ export interface paths {
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Revoke a join token */
|
||||
/** Revoke a join token; with purge=true delete the record for good */
|
||||
delete: operations["join_tokens_revoke"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
@@ -765,6 +799,23 @@ export interface paths {
|
||||
patch: operations["users_update"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/build-info": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Version, tag, commit and build time of the backend and every agent and probe */
|
||||
get: operations["build_info_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/healthz": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -854,6 +905,10 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
AboutDto: {
|
||||
show_build: boolean;
|
||||
backend: null | components["schemas"]["BuildDto"];
|
||||
};
|
||||
AgentDto: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -915,6 +970,16 @@ export interface components {
|
||||
tcp_port: number | string;
|
||||
spki: string;
|
||||
};
|
||||
BuildDto: {
|
||||
version: string;
|
||||
tag: null | string;
|
||||
commit: null | string;
|
||||
built_at: null | string;
|
||||
};
|
||||
BuildInfoDto: {
|
||||
backend: components["schemas"]["BuildDto"];
|
||||
nodes: components["schemas"]["NodeBuildDto"][];
|
||||
};
|
||||
ChangePasswordRequest: {
|
||||
password: string;
|
||||
};
|
||||
@@ -986,7 +1051,7 @@ export interface components {
|
||||
CreatedJoinTokenDto: {
|
||||
token: components["schemas"]["JoinTokenDto"];
|
||||
plaintext: string;
|
||||
install_command: string;
|
||||
install_commands: components["schemas"]["InstallCommandsDto"];
|
||||
};
|
||||
DimensionDto: {
|
||||
id: string;
|
||||
@@ -1112,6 +1177,11 @@ export interface components {
|
||||
[key: string]: (null | number | string)[];
|
||||
};
|
||||
};
|
||||
InstallCommandsDto: {
|
||||
linux: string;
|
||||
macos: string;
|
||||
windows: string;
|
||||
};
|
||||
JoinTokenDto: {
|
||||
id: string;
|
||||
note: null | string;
|
||||
@@ -1164,6 +1234,7 @@ export interface components {
|
||||
LoginRequest: {
|
||||
username: string;
|
||||
password: string;
|
||||
return_session: null | boolean;
|
||||
};
|
||||
LoginResponse: {
|
||||
user_id: string;
|
||||
@@ -1171,6 +1242,7 @@ export interface components {
|
||||
csrf_token: string;
|
||||
/** Format: date-time */
|
||||
expires_at: unknown;
|
||||
session_token: null | string;
|
||||
};
|
||||
MeDto: {
|
||||
kind: string;
|
||||
@@ -1202,6 +1274,29 @@ export interface components {
|
||||
version: number | string;
|
||||
needs: string[];
|
||||
};
|
||||
NodeBuildDto: {
|
||||
id: string;
|
||||
kind: string;
|
||||
os: null | string;
|
||||
os_name: null | string;
|
||||
arch: null | string;
|
||||
version: null | string;
|
||||
tag: null | string;
|
||||
commit: null | string;
|
||||
built_at: null | string;
|
||||
};
|
||||
NodeInfoDto: {
|
||||
id: string;
|
||||
kind: string;
|
||||
os: null | string;
|
||||
os_name: null | string;
|
||||
arch: null | string;
|
||||
country: null | string;
|
||||
isp_key: null | string;
|
||||
isp_name: null | string;
|
||||
version?: null | string;
|
||||
tag?: null | string;
|
||||
};
|
||||
OverviewDto: {
|
||||
range: string;
|
||||
/** Format: date-time */
|
||||
@@ -1259,6 +1354,10 @@ export interface components {
|
||||
items: components["schemas"]["ModelDto"][];
|
||||
next_cursor: null | string;
|
||||
};
|
||||
PageDtoOfNodeInfoDto: {
|
||||
items: components["schemas"]["NodeInfoDto"][];
|
||||
next_cursor: null | string;
|
||||
};
|
||||
PageDtoOfPlanDto: {
|
||||
items: components["schemas"]["PlanDto"][];
|
||||
next_cursor: null | string;
|
||||
@@ -2126,6 +2225,64 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
nodes_list: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PageDtoOfNodeInfoDto"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
about_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AboutDto"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
catalog_kinds: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2902,7 +3059,9 @@ export interface operations {
|
||||
};
|
||||
join_tokens_revoke: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
purge?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
@@ -4361,6 +4520,44 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
build_info_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["BuildInfoDto"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
health_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -41,17 +41,65 @@ export function getBase(): string {
|
||||
return normalizeBase(read(BASE_KEY) || (import.meta.env.VITE_API_BASE as string | undefined) || '')
|
||||
}
|
||||
|
||||
export interface SavedSource {
|
||||
base: string
|
||||
/** 在这个后端登录之后拿到的会话(只存在浏览器里);空 = 还没登录。 */
|
||||
token: string
|
||||
}
|
||||
|
||||
// v2:以前保存的是手填的管理 Token,现在是登录后拿到的会话,旧的不再使用
|
||||
const listKey = () => `monitor.sources.v2.${app}`
|
||||
|
||||
function readList(): SavedSource[] {
|
||||
try {
|
||||
const parsed = JSON.parse(read(listKey()) || '[]') as unknown
|
||||
return Array.isArray(parsed) ? (parsed as SavedSource[]).filter((x) => typeof x?.base === 'string' && x.base !== '') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存过的后端(每个前端各存各的登录会话);当前连接的那个一定在里面。不含这个页面自己所在的后端。 */
|
||||
export function listSources(): SavedSource[] {
|
||||
const list = readList()
|
||||
const active = getBase()
|
||||
if (active && !list.some((x) => x.base === active)) list.push({ base: active, token: '' })
|
||||
return list
|
||||
}
|
||||
|
||||
function writeList(list: SavedSource[]) {
|
||||
write(listKey(), JSON.stringify(list))
|
||||
}
|
||||
|
||||
/** 当前连接的后端的登录会话;这个页面自己所在的后端用 Cookie,没有。 */
|
||||
export function getToken(): string {
|
||||
return read(`monitor.api_token.${app}`)
|
||||
const active = getBase()
|
||||
return active ? (readList().find((x) => x.base === active)?.token ?? '') : ''
|
||||
}
|
||||
|
||||
/** 保存(或更新)一个后端和它的登录会话,并连过去。地址留空 = 这个页面自己所在的后端。 */
|
||||
export function setSource(base: string, token: string) {
|
||||
write(BASE_KEY, normalizeBase(base))
|
||||
write(`monitor.api_token.${app}`, token.trim())
|
||||
const next = normalizeBase(base)
|
||||
if (next) writeList([...readList().filter((x) => x.base !== next), { base: next, token }])
|
||||
write(BASE_KEY, next)
|
||||
}
|
||||
|
||||
/** 切换到已经保存过的后端(空串 = 这个页面自己所在的后端)。 */
|
||||
export function activateSource(base: string) {
|
||||
write(BASE_KEY, normalizeBase(base))
|
||||
}
|
||||
|
||||
/** 忘掉一个保存过的后端;正连着的话回到这个页面自己所在的后端。 */
|
||||
export function removeSource(base: string) {
|
||||
writeList(readList().filter((x) => x.base !== base))
|
||||
if (getBase() === base) write(BASE_KEY, '')
|
||||
}
|
||||
|
||||
/** 只改当前连接的后端的登录会话(过期、退出时清掉)。 */
|
||||
export function setToken(token: string) {
|
||||
write(`monitor.api_token.${app}`, token.trim())
|
||||
const active = getBase()
|
||||
if (!active) return
|
||||
writeList([...readList().filter((x) => x.base !== active), { base: active, token }])
|
||||
}
|
||||
|
||||
export const isRemote = () => getBase() !== ''
|
||||
@@ -66,6 +114,18 @@ export function authHeaders(): Record<string, string> {
|
||||
return isRemote() && token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前后端连不连得上:只看有没有响应(跨域时拿不到内容也没关系,所以用 no-cors);网络不通、域名解析失败、连接被拒才算失败。
|
||||
*/
|
||||
export async function pingBackend(): Promise<boolean> {
|
||||
try {
|
||||
await fetch(resolveUrl('/healthz'), { mode: 'no-cors', cache: 'no-store' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export interface LiveSource {
|
||||
onopen: (() => void) | null
|
||||
onerror: (() => void) | null
|
||||
|
||||
@@ -12,6 +12,11 @@ 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 NodeInfoDto = Narrow<S['NodeInfoDto']>
|
||||
export type AboutDto = Narrow<S['AboutDto']>
|
||||
export type BuildDto = Narrow<S['BuildDto']>
|
||||
export type BuildInfoDto = Narrow<S['BuildInfoDto']>
|
||||
export type NodeBuildDto = Narrow<S['NodeBuildDto']>
|
||||
export type OverviewDto = Narrow<S['OverviewDto']>
|
||||
export type OverviewLineDto = Narrow<S['OverviewLineDto']>
|
||||
export type SeriesDto = Narrow<S['SeriesDto']>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
|
||||
|
||||
viewBox="0 0 315 308.5899"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
|
||||
sodipodi:docname="China Mobile.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="px"
|
||||
inkscape:zoom="0.33541667"
|
||||
inkscape:cx="894.40994"
|
||||
inkscape:cy="76.024845"
|
||||
inkscape:window-width="1366"
|
||||
inkscape:window-height="705"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="g147"
|
||||
transform="translate(-4.2522286e-4,-4.801313e-4)"
|
||||
style="fill:none">
|
||||
<path
|
||||
id="path4"
|
||||
d="m 303.94434,190.02541 c -27.51878,-36.39255 -68.62753,-73.63677 -79.99218,-82.84938 -7.95801,-6.44699 -15.87023,-9.688803 -23.68172,-9.578911 -7.83896,0.09158 -15.39403,3.333391 -22.09744,9.157661 -1.33702,1.16303 -3.26929,2.87551 -5.64112,4.99093 -16.34643,13.77312 -66.83262,60.18416 -67.97733,61.26476 -3.83706,3.58065 -8.736412,5.93417 -14.240168,5.74186 -4.862719,-0.20147 -10.174163,-2.381 -15.63213,-7.42687 C 70.92761,167.85471 53.674573,154.31053 33.518558,131.65447 21.109924,117.70735 10.871657,105.28956 10.871657,105.28956 c 0,0 -2.2711,4.67956 -3.5440151,7.94885 -1.0622889,2.71982 -0.1465226,4.42315 0.3663065,5.16492 6.7400396,9.66133 37.5006296,46.46598 79.8914506,83.00505 7.774855,6.69426 15.870231,9.68881 23.690871,9.58808 7.8298,-0.10074 15.40319,-3.33339 22.08829,-9.15766 1.33701,-1.17219 3.26928,-2.88467 5.65027,-5.00009 16.33727,-13.76397 46.93302,-41.80473 61.42961,-55.25734 3.10444,-2.80224 5.39386,-4.9085 6.54772,-6.00742 3.82791,-3.58065 8.73641,-5.93417 14.23101,-5.7327 4.86272,0.19231 9.98185,2.59162 15.64129,7.42686 9.06609,7.73823 23.75498,21.3282 41.15454,39.671 12.80241,13.48924 22.57364,26.09018 22.67437,26.22755 0,0 2.38099,-4.84441 3.64475,-8.15948 1.04397,-2.72898 0.14652,-4.27663 -0.39378,-4.98177"
|
||||
style="fill:#8ec320;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:9.15766" />
|
||||
<path
|
||||
id="path5"
|
||||
style="fill:#0086d0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:9.15766"
|
||||
d="M 156.10156,0.03131407 C 137.03789,0.52973098 114.77341,7.1359501 93.236325,24.546939 64.362242,47.871483 27.182379,82.203304 17.749996,92.560611 c 0,0 7.032042,8.745959 18.076172,19.570309 2.133733,1.82237 4.385744,1.3185 6.253907,-0.65039 28.562721,-27.582854 59.508025,-55.725024 77.457035,-69.617185 30.29351,-23.416119 65.82473,-25.686148 100.45898,1.988282 12.89398,10.320675 24.34062,20.521892 41.0625,37.738281 21.38312,23.306232 35.33966,42.775662 50.60547,64.726562 0,0 -0.0636,-5.87932 -1.75781,-13.68164 -0.28389,-1.31871 -0.87122,-3.46178 -1.81446,-5.35742 -3.0678,-6.1631 -16.5557,-29.294843 -36.13476,-52.152346 C 233.13773,29.78552 208.6403,15.974983 192.92578,7.7422516 183.81906,2.9505093 170.92886,-0.35634353 156.10156,0.03131407 Z M 174.20312,44.796939 C 163.23454,44.687047 151.8957,47.985282 140.57226,56.250064 126.3779,66.625686 67.914597,120.91294 56.449215,133.8985 c 0,0 8.233,9.0834 20.56836,19.3125 3.19602,2.73814 7.079332,2.16165 10.421875,-1.19922 22.50952,-22.63771 68.45213,-63.150567 74.61523,-68.0957 17.32628,-14.707192 37.50228,-15.999325 55.98243,-2.16211 44.86334,33.60859 84.54217,86.49463 91.35546,96.35742 0,0 1.02645,-4.87142 1.63086,-9.23047 0.0366,-0.25642 0.87973,-4.18627 -1.37304,-7.49218 -42.07026,-60.76103 -86.85211,-98.241836 -104.04102,-107.591801 -9.8399,-5.370964 -20.43767,-8.890108 -31.40625,-9 z" />
|
||||
<path
|
||||
id="path7"
|
||||
style="fill:#0086d0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:9.15766"
|
||||
d="m 2.2636683,130.48249 c 0,0 -1.0264543,4.87143 -1.63085941,9.23046 -0.027473,0.24726 -0.86997028,4.17578 1.38281251,7.5 42.0794186,60.74272 86.8527046,98.24319 104.0507786,107.58399 19.67064,10.73277 42.37131,14.06667 65.02735,-2.46289 14.19436,-10.36646 72.65766,-64.66228 84.12304,-77.63867 0,0 -8.2336,-9.0939 -20.57812,-19.30469 -3.19602,-2.7473 -7.07872,-2.1603 -10.41211,1.19141 -22.50952,22.63771 -68.45275,63.15056 -74.625,68.0957 -17.31712,14.70718 -37.49252,16.00787 -55.972657,2.15234 C 48.765558,193.22156 9.0952779,140.35444 2.2636683,130.48249 Z m -2.26171879,31.79492 c 0,0 -0.11950648,5.6687 1.75781249,13.68164 0.3022026,1.30955 0.8601041,3.46178 1.8125,5.35742 3.067814,6.16309 16.557047,29.29484 36.126953,52.15234 38.819293,45.33039 63.316715,59.14032 79.031255,67.37305 20.81534,10.96171 61.40325,14.15734 99.6914,-16.80469 28.86492,-23.31538 66.06176,-57.6466 75.49414,-68.0039 0,0 -7.03204,-8.75513 -18.07617,-19.57032 -2.13374,-1.82236 -4.3955,-1.32765 -6.26367,0.6504 -28.56272,27.5737 -59.50803,55.72381 -77.45703,69.59765 -30.29352,23.43444 -65.8339,25.70642 -100.458987,-1.98633 C 78.775334,254.41315 67.319528,244.21119 50.597653,227.00397 29.214531,203.68858 15.258601,184.23746 0.00194951,162.27741 Z" />
|
||||
</g>
|
||||
<g
|
||||
id="g148"
|
||||
style="fill:#0086d0;fill-opacity:1"
|
||||
transform="translate(-4.2522286e-4,-4.801313e-4)">
|
||||
<path
|
||||
id="path9"
|
||||
style="fill:#0086d0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:9.15766"
|
||||
d="m 781.3418,15.791016 -0.51172,1.0625 c -7.17959,14.47825 -17.61958,27.327479 -30.95313,38.261718 -0.13736,0.119049 6.89468,5.513116 11.01563,8.123047 8.04042,-7.152128 14.6536,-14.2769 19.64453,-21.154297 h 39.14844 c -6.91404,8.992816 -13.68987,16.327529 -21.49219,21.4375 -5.16492,-4.587984 -9.61716,-8.250232 -16.26563,-13.460937 l -0.94335,-0.734375 -8.42383,9.140625 1.09961,0.861328 c 5.93416,4.871871 9.33907,7.892712 13.24023,11.601563 -10.23825,5.549537 -21.46383,9.935901 -34.27539,13.736328 0,0 8.3869,11.392965 8.625,11.310546 7.61001,-2.655719 23.06881,-9.523504 29.75391,-12.554687 -8.8463,17.463645 -28.24138,31.445845 -41.70313,38.808595 0,0 5.80411,6.22782 10.35547,9.83594 21.4747,-9.83532 36.20117,-28.92969 36.20117,-28.92969 h 29.63477 c -1.91396,10.20163 -10.81634,22.87679 -23.28907,30.52344 -4.9726,-4.92682 -9.79912,-9.77207 -15.6875,-15.04688 l -9.44921,8.22461 c 5.17407,4.78946 9.76168,9.71525 13.64453,13.78125 -18.91971,8.17779 -44.14063,9.33203 -44.14063,9.33203 l 5.23047,11.52149 c 0,0 74.07473,-1.87864 89.05664,-58.83008 l 0.10156,-12.363283 h -36.5664 c 2.37183,-3.168548 3.36133,-6.703125 3.36133,-6.703125 l -13.21485,-2.22461 c 2.51836,-1.382805 4.92578,-2.847883 7.2793,-4.322265 15.79695,-10.119207 26.91531,-22.079394 33.84766,-35.513672 l 0.16406,-0.310547 V 29.189453 h -46.51172 c 1.47438,-2.353517 2.83915,-4.798439 4.09375,-7.234375 l 0.67773,-1.328125 z M 436.50977,17.138672 V 44.335938 H 381.14258 V 120.4082 h 55.36719 v 41.29297 h 14.99218 V 120.4082 h 55.92578 V 44.335938 H 451.50195 V 17.138672 Z m 510.62304,0.300781 V 18.75 c 0.0275,9.661324 -8.3e-4,19.039133 -0.10156,28.160156 h -18.74414 v 13.625 h 18.54297 v 18.105469 c 0,27.546225 -4.72487,41.612575 -15.4668,57.958985 -6.39204,9.97268 -18.12305,16.75 -18.12305,16.75 l 10.7793,10.4668 c 0,0 36.7207,-19.23114 36.7207,-79.580082 l 0.28516,-23.701172 h 24.58789 c -0.27473,24.67072 -1.17188,48.948674 -2.55469,72.181644 -0.22894,7.27117 -3.32549,10.65039 -9.69921,10.65039 -5.36639,-0.10989 -16.17188,-0.13672 -16.17188,-0.13672 0.83334,4.34073 2.44531,14.45898 2.44531,14.45898 h 17.31641 c 12.24379,-0.20146 19.03914,-7.51923 20.1289,-21.84179 1.2546,-21.97837 2.21626,-51.501138 2.88477,-87.527348 L 1000,46.910156 h -38.70898 c 0.174,-9.276702 0.29284,-18.682851 0.32031,-28.115234 V 17.439453 Z M 541.71484,22.339844 V 158.01953 l 128.39844,-0.0742 V 22.339844 Z m 210.18555,0.667968 c 0,0 -12.52729,3.178015 -25.41211,4.917969 -16.5204,2.22531 -26.38281,3.195313 -26.38281,3.195313 L 701.57031,44.125 c 0,0 10.98938,-1.162815 19.47852,-2.673828 v 19.78125 H 698.3457 v 12.902344 h 20.74219 c -5.60448,17.298807 -14.11991,34.057594 -23.36914,47.281254 0,0 9.69675,10.47586 9.80664,10.34765 5.925,-8.35178 11.14608,-17.73735 15.52344,-27.90234 V 161.6543 H 734.3457 V 98.255859 c 4.92682,4.478091 9.4591,8.690861 13.72657,12.683591 l 0.9707,0.99024 9.41406,-10.52344 -0.9707,-0.87891 C 751.26828,95.041909 734.3457,81.296875 734.3457,81.296875 v -7.162109 h 19.95508 V 61.232422 H 734.3457 V 40.408203 c 14.10279,-2.316887 20.32032,-4.195312 20.32032,-4.195312 z m 114.58984,7.802735 v 13.296875 h 60.35743 V 30.810547 Z M 555.45898,35.197266 H 656.39453 V 145.0332 H 555.45898 Z M 565.45117,46.625 v 12.445312 h 33.63477 v 18.902344 h -30.87891 v 12.425782 h 30.87891 v 29.855472 h -35.88867 v 12.46289 h 85.25781 V 120.25391 H 612.38281 V 90.398438 h 30.90821 V 77.972656 H 612.38281 V 59.070312 h 33.78321 V 46.625 Z M 395.58008,57.632812 h 40.92969 v 49.423828 h -40.92969 z m 55.91797,0 h 41.43359 v 49.423828 h -41.43359 z m 409.0039,10.861329 v 13.341797 h 23.7168 c -1.41944,7.573379 -9.69655,32.703642 -20.92383,53.216792 -1.39197,2.53667 -3.33398,4.03711 -3.33398,4.03711 l 3.96484,9.56055 c 0,0 38.72781,-10.45791 50.36719,-12.20703 l 1.39258,3.49023 13.62695,-5.16601 C 925.01756,124.16302 918.4151,109.228 913.48828,98.541016 l -0.56836,-1.21875 -12.32617,5.531254 0.55859,1.24609 c 3.26012,7.26201 6.20931,13.85413 9.18555,21.33594 l -31.77734,7.50976 c 7.01476,-11.46539 15.77904,-37.29963 20.54101,-51.109372 h 33.49805 V 68.494141 Z m -233.88672,23.048828 -10.19335,7.693359 1.03515,1.072262 c 4.16673,4.36821 9.45038,10.78629 13.91016,16.77539 l 0.85156,1.08985 10.63281,-8.09571 L 642,108.99805 c -3.77295,-4.81693 -9.37852,-11.189522 -14.58008,-16.574222 z" />
|
||||
<path
|
||||
id="path18"
|
||||
style="fill:#0086d0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:9.15766"
|
||||
d="m 470.48633,187.40625 v 86.41211 h 11.19922 v -40.01953 c 1.9231,-2.72899 4.25907,-4.95345 6.89648,-6.54688 2.69235,-1.60259 5.54794,-2.4375 8.50586,-2.4375 3.49823,0 5.91673,0.9802 7.42773,2.97657 1.52933,2.00552 2.30665,5.19372 2.30665,9.40625 v 36.62109 h 11.18164 v -38.0957 c 0,-4.18505 -0.57708,-7.63713 -1.76758,-10.32032 -1.19965,-2.7015 -2.76439,-4.84493 -4.6875,-6.39257 -1.89564,-1.52933 -3.97465,-2.58297 -6.18164,-3.21485 -4.31326,-1.14471 -8.78321,-1.14419 -12.51953,-0.10937 -1.78574,0.45788 -3.52389,1.14456 -5.08985,1.96875 -1.57512,0.78755 -3.04066,1.82269 -4.35937,2.97656 -0.59524,0.57693 -1.20922,1.11643 -1.71289,1.69336 v -34.91797 z m 375.20703,0 v 86.41211 h 8.68164 l 1.53906,-5.0918 c 0.20147,0.19231 0.37484,0.34858 0.54883,0.56836 0.80588,0.94324 1.88564,1.81276 3.22266,2.72852 1.33702,0.8883 2.97693,1.64802 4.83593,2.28906 1.93226,0.60441 4.14867,0.95312 6.59375,0.95312 3.26929,0 6.49213,-0.65178 9.55079,-1.8789 3.08612,-1.26376 5.83312,-3.19563 8.23242,-5.75977 2.37184,-2.56414 4.32275,-5.84237 5.74218,-9.73437 1.43775,-3.8279 2.16993,-8.49876 2.16993,-13.7461 0,-3.79126 -0.40142,-7.1788 -1.15235,-10.13671 -0.75093,-2.93046 -1.76736,-5.49505 -3.05859,-7.65625 -1.30955,-2.15205 -2.82073,-4.00158 -4.5332,-5.4668 -1.68501,-1.44692 -3.47949,-2.61879 -5.34766,-3.52539 -1.7949,-0.90661 -3.70007,-1.54728 -5.5957,-1.87695 -1.84985,-0.37546 -3.61739,-0.55079 -5.23828,-0.55079 -3.49823,0 -6.67641,0.60504 -9.45118,1.80469 -2.02383,0.94324 -3.89251,2.10596 -5.56836,3.52539 v -32.85742 z m 83.82031,0 v 86.41211 h 11.19922 v -86.41211 z m -506.96875,4.3125 c -22.80256,0 -41.39258,18.57305 -41.39258,41.38477 0,22.82087 18.59002,41.42968 41.39258,41.42968 16.5021,0 31.41114,-9.79929 37.98633,-24.92773 l 0.6875,-1.52149 h -11.91406 l -0.29297,0.50391 c -5.54039,9.37744 -15.64245,15.19336 -26.4668,15.19336 -16.88671,0 -30.64062,-13.74523 -30.64062,-30.67773 0,-16.88672 13.75391,-30.63282 30.64062,-30.63282 10.77856,0 20.85172,5.72256 26.35547,15.01758 l 0.33984,0.53125 h 11.89454 L 460.47656,216.5 c -6.63013,-15.05518 -21.5028,-24.78125 -37.93164,-24.78125 z m 107.54883,1.72266 v 12.28125 h 11.19922 v -12.28125 z m 375.58203,0 v 12.28125 h 11.20899 v -12.28125 z m -216.13867,0.38476 v 79.99219 h 11.68555 v -38.72852 c 0,-7.4177 -0.21182,-15.5218 -0.57813,-24.17578 2.10626,5.58617 4.20394,10.83294 6.24609,15.75977 l 19.9004,47.14453 h 5.80468 l 19.88282,-47.14453 c 1.97805,-4.64294 4.07437,-9.91779 6.2539,-15.76953 -0.35715,8.76388 -0.54883,16.91437 -0.54883,24.18554 v 38.72852 h 11.64844 V 193.82617 H 754.3457 l -18.58984,44.66992 c -1.63922,3.8279 -3.67296,9.11179 -6.07227,15.79688 -2.17951,-6.06237 -4.19312,-11.35481 -6.04297,-15.78711 l -18.5996,-44.67969 z m 118.29883,20.9336 c -16.61199,0 -30.06446,13.46296 -30.06446,30.05664 0,16.64861 13.45247,30.10937 30.06446,30.10937 16.62114,0 30.08203,-13.46076 30.08203,-30.10937 0,-16.59368 -13.46089,-30.05664 -30.08203,-30.05664 z m -176.45899,0.17382 c -1.58427,0 -3.18794,0.0839 -4.7539,0.23047 -1.60259,0.17399 -3.13196,0.40166 -4.56055,0.68555 -1.41028,0.29305 -2.80131,0.64967 -4.07422,1.08008 -1.33702,0.43042 -2.47265,0.8883 -3.44336,1.38281 l -0.61328,0.27539 v 9.80859 l 1.49219,-0.55859 c 2.38099,-0.87913 4.7888,-1.6024 7.19726,-2.11523 2.39015,-0.49451 4.94559,-0.7793 7.65625,-0.7793 3.5257,0 6.38289,0.59579 8.44336,1.68555 1.98721,1.13555 3.47109,2.55414 4.35938,4.19336 0.42126,0.77839 0.71467,1.72299 0.88867,2.75781 0.20147,1.10807 0.27344,2.40673 0.27344,3.82617 v 2.41016 c -1.64838,-0.55861 -3.4979,-0.97989 -5.4668,-1.38282 -2.48172,-0.45789 -5.23692,-0.72374 -8.1582,-0.76953 -3.14108,0 -6.01763,0.43949 -8.63672,1.26367 -2.58246,0.86998 -4.88037,2.0959 -6.78516,3.7168 -1.89564,1.59344 -3.38046,3.60976 -4.43359,5.88086 -1.02566,2.29858 -1.5918,4.94333 -1.5918,7.85547 0,2.91213 0.49366,5.5691 1.42774,7.9043 0.93409,2.33519 2.30779,4.35957 4.00195,5.98046 1.72164,1.62091 3.79102,2.90164 6.14453,3.74415 2.32605,0.81502 4.92688,1.25585 7.73828,1.25585 2.033,0 4.00185,-0.23877 5.82422,-0.6875 1.81322,-0.4304 3.50778,-1.04473 5.03711,-1.77734 1.53849,-0.75093 2.9118,-1.62903 4.08398,-2.57226 0.59524,-0.4762 1.11682,-0.95214 1.61133,-1.4375 l 0.80664,5.02734 h 9.59766 v -37.99609 c 0,-1.82237 -0.14715,-3.52598 -0.42188,-5.06446 -0.2381,-1.57512 -0.72298,-3.07644 -1.3457,-4.40429 -1.61175,-3.45244 -4.35951,-6.23689 -8.06836,-8.33399 -3.69969,-2.02385 -8.4703,-3.08594 -14.23047,-3.08594 z m 344.125,0.006 c -3.25096,0.0458 -6.54162,0.68095 -9.5957,1.93555 -3.13192,1.28207 -5.93517,3.24237 -8.37109,5.83398 -2.40847,2.5733 -4.35839,5.83434 -5.81446,9.63477 -1.38281,3.81874 -2.125,8.37818 -2.125,13.51563 0,5.21986 0.76916,9.78064 2.23438,13.50781 1.47439,3.77295 3.60027,6.85021 6.22851,9.21289 2.66488,2.3352 5.85148,4.06539 9.46875,5.10937 3.53485,1.04397 7.38066,1.57617 11.4375,1.57617 3.3517,0 6.58445,-0.28438 9.53321,-0.86132 3.00371,-0.57693 5.54878,-1.40099 7.63672,-2.38086 l 0.56835,-0.32032 v -9.67968 L 995.25586,262.5 c -2.39931,0.86998 -4.81Line truncated
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,142 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) by Marsupilami -->
|
||||
<svg
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
version="1.0"
|
||||
|
||||
|
||||
viewBox="-46.62 -46.62 1680 1647.24"
|
||||
id="svg9522">
|
||||
<defs
|
||||
id="defs9524" />
|
||||
<path
|
||||
d="M 1014.2288,261 C 1014.2288,261 986.22877,230 949.22877,230 C 949.22877,230 868.22877,230 813.22877,405 C 813.22877,405 766.22877,558 766.22877,785 C 766.22877,785 766.22877,999 810.22877,1150 C 810.22877,1150 854.22877,1303 918.22877,1303 C 918.22877,1303 963.22877,1303 1001.2288,1219 C 1001.2288,1219 1039.2288,1135 1055.2288,998 L 1175.2288,968 C 1175.2288,968 1144.2288,1223 1048.2288,1388 C 1048.2288,1388 949.22877,1554 824.22877,1554 C 824.22877,1554 674.22877,1554 568.22877,1329 C 568.22877,1329 462.22877,1104 462.22877,785 C 462.22877,785 462.22877,466 568.22877,239 C 568.22877,239 674.22877,13 824.22877,13 C 824.22877,13 974.22877,13 1081.2288,243 L 1014.2288,261"
|
||||
id="path8564"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 1436.2288,568 C 1436.2288,568 1229.2288,739 899.22877,822 C 899.22877,822 568.22877,904 305.22877,850 C 305.22877,850 42.228768,794 3.2287685,633 C 3.2287685,633 -29.771232,499 115.22877,351 C 115.22877,351 258.22877,204 515.22877,106 L 532.22877,172 C 532.22877,172 382.22877,236 299.22877,317 C 299.22877,317 214.22877,402 233.22877,475 C 233.22877,475 258.22877,568 434.22877,596 C 434.22877,596 609.22877,623 836.22877,567 C 836.22877,567 1061.2288,511 1205.2288,404 C 1205.2288,404 1347.2288,296 1323.2288,202 C 1323.2288,202 1303.2288,121 1159.2288,86 L 1137.2288,-8.5265128e-14 C 1137.2288,-8.5265128e-14 1324.2288,7 1448.2288,69 C 1448.2288,69 1575.2288,131 1601.2288,236 C 1601.2288,236 1642.2288,396 1436.2288,568"
|
||||
id="path8566"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2620.2288,207 L 2620.2288,209 L 2628.2288,212 L 2638.2288,209 L 2643.2288,206 L 2653.2288,207 C 2653.2288,207 2665.2288,210 2675.2288,207 L 2678.2288,207 L 2628.2288,201 L 2620.2288,207"
|
||||
id="path8568"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2307.2288,35 C 2304.2288,38 2300.2288,39 2300.2288,39 L 2313.2288,69 L 2304.2288,69 L 2313.2288,98 C 2319.2288,131 2319.2288,196 2319.2288,196 L 2315.2288,213 L 2310.2288,231 L 2313.2288,264 C 2312.2288,277 2297.2288,292 2297.2288,292 L 2297.2288,308 C 2239.2288,329 2231.2288,340 2231.2288,340 L 2200.2288,349 C 2186.2288,358 2157.2288,369 2157.2288,369 C 2138.2288,388 2118.2288,391 2118.2288,391 L 2115.2288,393 L 2112.2288,393 L 2112.2288,394 L 2104.2288,394 C 2115.2288,372 2104.2288,349 2104.2288,349 L 2097.2288,337 L 2089.2288,307 C 2082.2288,283 2077.2288,281 2077.2288,281 L 2068.2288,295 L 2061.2288,313 L 2055.2288,313 L 2042.2288,346 L 2030.2288,376 L 2014.2288,420 C 2015.2288,425 1996.2288,464 1996.2288,464 L 1988.2288,472 C 1988.2288,500 1976.2288,508 1976.2288,508 L 1968.2288,528 C 1962.2288,543 1961.2288,541 1961.2288,541 C 1962.2288,549 1956.2288,591 1956.2288,591 C 1953.2288,633 1967.2288,641 1967.2288,641 C 1973.2288,650 1970.2288,653 1970.2288,653 L 1985.2288,683 L 1990.2288,682 L 1996.2288,692 C 2006.2288,715 2027.2288,709 2027.2288,709 L 2065.2288,698 L 2073.2288,732 C 2077.2288,754 2085.2288,760 2085.2288,760 L 2114.2288,766 L 2144.2288,769 L 2173.2288,756 L 2238.2288,741 C 2280.2288,732 2301.2288,721 2301.2288,721 C 2318.2288,753 2315.2288,779 2315.2288,779 L 2312.2288,831 L 2318.2288,853 L 2322.2288,874 L 2324.2288,971 L 2334.2288,1001 L 2330.2288,1013 L 2330.2288,1049 L 2340.2288,1079 L 2343.2288,1113 L 2349.2288,1113 L 2346.2288,1082 L 2357.2288,1084 L 2359.2288,1099 L 2362.2288,1098 L 2360.2288,1079 C 2349.2288,1075 2349.2288,1055 2349.2288,1055 C 2349.2288,1037 2357.2288,1025 2357.2288,1025 C 2363.2288,1027 2372.2288,1060 2372.2288,1060 C 2380.2288,1085 2381.2288,1101 2381.2288,1101 L 2402.2288,1101 L 2402.2288,1110 L 2396.2288,1105 C 2386.2288,1111 2384.2288,1143 2384.2288,1143 C 2383.2288,1175 2389.2288,1176 2389.2288,1176 C 2392.2288,1176 2408.2288,1164 2408.2288,1164 L 2404.2288,1170 L 2404.2288,1173 L 2410.2288,1173 L 2413.2288,1190 C 2420.2288,1190 2420.2288,1149 2420.2288,1149 L 2424.2288,1149 L 2431.2288,1155 L 2434.2288,1152 L 2434.2288,1113 L 2442.2288,1120 L 2452.2288,1111 L 2458.2288,1098 C 2460.2288,1089 2455.2288,1063 2455.2288,1063 C 2463.2288,1023 2464.2288,1002 2464.2288,1002 L 2464.2288,980 L 2493.2288,915 C 2501.2288,890 2502.2288,850 2502.2288,850 L 2505.2288,783 L 2517.2288,766 C 2528.2288,756 2529.2288,750 2529.2288,750 L 2552.2288,685 C 2570.2288,638 2582.2288,633 2582.2288,633 L 2640.2288,620 C 2678.2288,608 2675.2288,588 2675.2288,588 C 2694.2288,588 2702.2288,505 2702.2288,505 L 2718.2288,423 L 2732.2288,396 L 2738.2288,333 L 2749.2288,271 C 2752.2288,242 2735.2288,228 2735.2288,228 C 2724.2288,218 2693.2288,210 2693.2288,210 L 2690.2288,212 C 2697.2288,216 2712.2288,221 2712.2288,221 C 2726.2288,228 2729.2288,239 2729.2288,239 C 2733.2288,255 2723.2288,281 2723.2288,281 C 2714.2288,304 2699.2288,317 2699.2288,317 L 2700.2288,328 C 2696.2288,342 2673.2288,369 2673.2288,369 L 2641.2288,413 L 2613.2288,431 C 2575.2288,432 2563.2288,444 2563.2288,444 L 2558.2288,440 C 2552.2288,431 2552.2288,385 2552.2288,385 L 2548.2288,349 C 2554.2288,340 2554.2288,334 2554.2288,334 L 2554.2288,314 C 2564.2288,302 2623.2288,280 2623.2288,280 C 2676.2288,258 2682.2288,237 2682.2288,237 L 2685.2288,240 C 2684.2288,249 2693.2288,251 2693.2288,251 C 2693.2288,236 2703.2288,236 2703.2288,236 L 2724.2288,240 L 2724.2288,237 L 2690.2288,222 C 2690.2288,230 2684.2288,230 2684.2288,230 L 2671.2288,228 L 2670.2288,234 L 2675.2288,237 L 2668.2288,248 L 2665.2288,248 C 2665.2288,243 2646.2288,243 2646.2288,243 L 2652.2288,221 C 2635.2288,213 2634.2288,228 2634.2288,228 L 2632.2288,228 L 2629.2288,222 L 2626.2288,222 L 2625.2288,227 L 2613.2288,227 L 2605.2288,236 L 2596.2288,233 L 2602.2288,227 L 2596.2288,215 C 2608.2288,215 2614.2288,201 2614.2288,201 L 2540.2288,206 C 2540.2288,216 2561.2288,213 2561.2288,213 C 2584.2288,210 2587.2288,218 2587.2288,218 L 2564.2288,216 L 2543.2288,219 L 2540.2288,228 L 2534.2288,224 L 2528.2288,219 L 2513.2288,213 C 2504.2288,219 2499.2288,218 2499.2288,218 L 2489.2288,212 L 2479.2288,202 C 2467.2288,193 2414.2288,103 2414.2288,103 C 2375.2288,38 2307.2288,35 2307.2288,35 z M 2297.2288,402 L 2309.2288,450 L 2316.2288,482 C 2322.2288,502 2319.2288,515 2319.2288,515 L 2300.2288,543 L 2277.2288,573 C 2250.2288,590 2230.2288,596 2230.2288,596 L 2179.2288,603 C 2154.2288,606 2126.2288,632 2126.2288,632 C 2098.2288,656 2088.2288,655 2088.2288,655 C 2083.2288,658 2077.2288,659 2077.2288,659 C 2071.2288,644 2083.2288,635 2083.2288,635 C 2079.2288,629 2086.2288,612 2086.2288,612 L 2101.2288,594 L 2112.2288,573 L 2118.2288,547 C 2129.2288,540 2127.2288,532 2127.2288,532 L 2179.2288,472 L 2242.2288,444 C 2287.2288,425 2297.2288,402 2297.2288,402 z"
|
||||
id="path8570"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3320.2288,201 L 3359.2288,186 L 3399.2288,175 L 3435.2288,171 L 3450.2288,165 L 3491.2288,163 L 3532.2288,165 L 3548.2288,166 L 3565.2288,172 L 3577.2288,190 L 3591.2288,216 C 3591.2288,216 3583.2288,255 3591.2288,263 C 3591.2288,263 3585.2288,275 3582.2288,352 L 3574.2288,435 L 3568.2288,494 L 3573.2288,577 C 3573.2288,577 3573.2288,593 3565.2288,620 C 3565.2288,620 3574.2288,662 3565.2288,671 C 3565.2288,671 3585.2288,797 3582.2288,804 L 3571.2288,831 L 3562.2288,857 L 3561.2288,872 L 3565.2288,877 L 3562.2288,895 L 3564.2288,912 C 3564.2288,912 3562.2288,925 3556.2288,925 L 3541.2288,915 L 3529.2288,901 L 3529.2288,900 C 3529.2288,900 3503.2288,878 3491.2288,884 C 3491.2288,884 3491.2288,921 3515.2288,958 L 3520.2288,961 C 3520.2288,961 3523.2288,978 3551.2288,1022 L 3554.2288,1028 L 3577.2288,1096 C 3577.2288,1096 3594.2288,1120 3660.2288,1150 L 3666.2288,1149 L 3666.2288,1144 C 3666.2288,1144 3672.2288,1144 3686.2288,1128 C 3686.2288,1128 3704.2288,1105 3706.2288,1079 L 3706.2288,1028 L 3712.2288,992 L 3712.2288,955 L 3703.2288,922 C 3703.2288,922 3698.2288,896 3709.2288,892 C 3709.2288,892 3709.2288,839 3704.2288,836 C 3704.2288,836 3713.2288,795 3707.2288,748 L 3709.2288,711 L 3709.2288,673 L 3703.2288,590 L 3701.2288,505 L 3698.2288,429 L 3689.2288,357 C 3689.2288,357 3692.2288,336 3697.2288,328 C 3697.2288,328 3694.2288,316 3698.2288,278 C 3698.2288,278 3685.2288,277 3692.2288,230 L 3701.2288,172 C 3701.2288,172 3698.2288,157 3672.2288,124 L 3635.2288,83 C 3635.2288,83 3612.2288,71 3600.2288,74 L 3520.2288,86 C 3520.2288,86 3464.2288,92 3449.2288,109 L 3429.2288,107 L 3408.2288,112 L 3405.2288,115 L 3367.2288,119 L 3337.2288,125 L 3326.2288,125 L 3287.2288,139 L 3252.2288,156 L 3217.2288,169 C 3217.2288,169 3186.2288,195 3170.2288,192 C 3170.2288,192 3102.2288,209 3075.2288,225 C 3075.2288,225 3075.2288,227 3093.2288,231 C 3093.2288,231 3111.2288,236 3113.2288,246 L 3133.2288,252 C 3133.2288,252 3181.2288,260 3208.2288,242 C 3208.2288,242 3214.2288,245 3248.2288,230 C 3248.2288,230 3255.2288,221 3272.2288,221 L 3293.2288,209 L 3320.2288,201"
|
||||
id="path8572"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2443.2288,1125 L 2442.2288,1166 C 2442.2288,1166 2449.2288,1166 2449.2288,1147 C 2449.2288,1147 2451.2288,1129 2443.2288,1125"
|
||||
id="path8576"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2355.2288,1147 L 2360.2288,1164 L 2362.2288,1164 L 2366.2288,1155 L 2366.2288,1137 L 2363.2288,1137 L 2362.2288,1138 L 2365.2288,1128 L 2351.2288,1120 C 2351.2288,1120 2348.2288,1129 2355.2288,1147"
|
||||
id="path8578"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2375.2288,1176 L 2377.2288,1173 C 2377.2288,1173 2380.2288,1143 2374.2288,1137 C 2374.2288,1137 2366.2288,1144 2372.2288,1176 L 2375.2288,1176"
|
||||
id="path8580"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2354.2288,1246 L 2354.2288,1358 L 2448.2288,1358 L 2448.2288,1246 L 2529.2288,1246 L 2529.2288,1515 L 2448.2288,1515 L 2448.2288,1400 L 2354.2288,1400 L 2354.2288,1515 L 2272.2288,1515 L 2272.2288,1246 L 2354.2288,1246"
|
||||
id="path8582"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2079.2288,1309 C 2079.2288,1309 2055.2288,1333 2055.2288,1380 L 2055.2288,1383 C 2055.2288,1383 2055.2288,1432 2077.2288,1456 C 2077.2288,1456 2103.2288,1479 2141.2288,1479 L 2188.2288,1474 L 2218.2288,1467 L 2218.2288,1507 L 2188.2288,1516 L 2136.2288,1519 C 2136.2288,1519 2062.2288,1519 2015.2288,1486 C 2015.2288,1486 1970.2288,1453 1970.2288,1388 L 1970.2288,1376 C 1970.2288,1376 1970.2288,1315 2017.2288,1279 C 2017.2288,1279 2064.2288,1241 2135.2288,1241 L 2186.2288,1244 L 2218.2288,1252 L 2218.2288,1294 L 2189.2288,1287 L 2148.2288,1284 C 2148.2288,1284 2104.2288,1284 2079.2288,1309"
|
||||
id="path8584"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3489.2288,541 C 3489.2288,541 3491.2288,558 3471.2288,580 L 3465.2288,588 L 3464.2288,591 L 3458.2288,603 L 3455.2288,617 L 3446.2288,623 L 3443.2288,629 L 3446.2288,630 C 3446.2288,630 3456.2288,633 3473.2288,624 L 3489.2288,620 L 3508.2288,618 L 3521.2288,617 L 3529.2288,615 C 3529.2288,615 3547.2288,621 3561.2288,600 L 3564.2288,573 L 3561.2288,558 L 3556.2288,547 L 3550.2288,544 L 3541.2288,540 L 3520.2288,538 L 3517.2288,535 L 3497.2288,526 L 3482.2288,520 L 3489.2288,541"
|
||||
id="path8586"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3420.2288,242 L 3394.2288,249 L 3365.2288,255 L 3310.2288,267 L 3282.2288,283 L 3258.2288,289 C 3258.2288,289 3254.2288,292 3248.2288,293 L 3237.2288,295 L 3226.2288,298 L 3186.2288,316 L 3166.2288,328 L 3152.2288,331 L 3148.2288,334 L 3146.2288,336 L 3139.2288,345 L 3125.2288,352 L 3108.2288,364 C 3108.2288,364 3077.2288,364 3083.2288,387 L 3089.2288,387 C 3089.2288,387 3089.2288,391 3127.2288,407 L 3128.2288,425 C 3128.2288,425 3131.2288,438 3140.2288,444 L 3180.2288,444 L 3193.2288,446 L 3214.2288,446 C 3214.2288,446 3220.2288,446 3220.2288,450 L 3223.2288,470 L 3225.2288,485 L 3223.2288,508 L 3216.2288,523 L 3199.2288,526 L 3186.2288,534 L 3148.2288,549 L 3133.2288,561 L 3110.2288,576 L 3098.2288,588 L 3099.2288,593 L 3099.2288,597 L 3087.2288,608 L 3089.2288,620 L 3093.2288,623 L 3107.2288,636 L 3127.2288,639 L 3136.2288,655 L 3148.2288,667 L 3163.2288,670 L 3175.2288,668 L 3173.2288,677 C 3173.2288,677 3169.2288,686 3173.2288,686 L 3167.2288,706 L 3160.2288,720 L 3157.2288,727 L 3139.2288,741 L 3130.2288,753 L 3121.2288,769 L 3121.2288,776 L 3114.2288,791 L 3108.2288,800 L 3108.2288,812 L 3116.2288,834 L 3125.2288,848 C 3125.2288,848 3131.2288,856 3133.2288,854 C 3133.2288,854 3133.2288,856 3143.2288,860 C 3143.2288,860 3164.2288,863 3176.2288,860 L 3217.2288,851 L 3240.2288,839 L 3281.2288,825 L 3290.2288,818 C 3290.2288,818 3296.2288,818 3320.2288,800 C 3320.2288,800 3325.2288,803 3337.2288,791 C 3337.2288,791 3337.2288,792 3353.2288,783 L 3367.2288,779 C 3367.2288,779 3375.2288,776 3382.2288,771 L 3402.2288,765 L 3429.2288,747 L 3449.2288,730 C 3449.2288,730 3459.2288,724 3462.2288,712 L 3467.2288,703 L 3470.2288,698 L 3458.2288,698 C 3458.2288,698 3450.2288,698 3455.2288,686 L 3462.2288,673 L 3438.2288,671 C 3438.2288,671 3423.2288,671 3423.2288,665 L 3381.2288,668 L 3385.2288,652 L 3387.2288,639 L 3393.2288,623 C 3393.2288,623 3388.2288,623 3388.2288,614 L 3394.2288,609 L 3405.2288,608 L 3429.2288,574 L 3455.2288,547 L 3456.2288,540 L 3462.2288,528 L 3462.2288,515 L 3459.2288,500 L 3443.2288,484 L 3414.2288,464 L 3402.2288,458 L 3397.2288,453 L 3387.2288,446 L 3390.2288,441 C 3390.2288,441 3390.2288,428 3397.2288,413 L 3427.2288,381 C 3427.2288,381 3435.2288,381 3449.2288,352 L 3459.2288,339 L 3462.2288,326 L 3461.2288,313 L 3464.2288,304 L 3462.2288,290 L 3447.2288,264 L 3440.2288,254 C 3440.2288,254 3440.2288,242 3420.2288,242"
|
||||
id="path8588"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3172.2288,925 L 3149.2288,931 L 3114.2288,931 L 3098.2288,934 L 3080.2288,933 L 3099.2288,975 C 3099.2288,975 3118.2288,1010 3124.2288,1011 L 3142.2288,1016 L 3170.2288,1008 C 3170.2288,1008 3190.2288,1004 3193.2288,998 L 3199.2288,998 C 3199.2288,998 3225.2288,992 3231.2288,983 L 3263.2288,975 L 3293.2288,961 L 3307.2288,957 L 3329.2288,946 L 3353.2288,931 L 3415.2288,887 C 3415.2288,887 3440.2288,871 3497.2288,844 L 3511.2288,831 L 3511.2288,828 L 3502.2288,828 C 3502.2288,828 3494.2288,834 3462.2288,842 L 3432.2288,854 L 3423.2288,856 L 3396.2288,866 L 3365.2288,871 L 3323.2288,889 L 3302.2288,893 L 3281.2288,903 L 3269.2288,904 L 3240.2288,910 C 3240.2288,910 3216.2288,922 3210.2288,924 L 3196.2288,921 L 3172.2288,925"
|
||||
id="path8590"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3024.2288,283 L 3015.2288,310 C 3015.2288,310 3010.2288,325 3012.2288,342 L 3015.2288,354 L 3015.2288,396 L 3018.2288,422 L 3013.2288,441 L 3012.2288,473 L 3006.2288,487 L 2997.2288,525 L 3000.2288,532 L 3000.2288,564 L 2995.2288,582 L 2995.2288,596 L 2992.2288,624 L 2987.2288,644 L 2983.2288,707 L 2981.2288,707 L 2978.2288,726 L 2971.2288,742 C 2971.2288,742 2968.2288,765 2974.2288,779 L 2975.2288,798 L 2977.2288,824 C 2977.2288,824 2978.2288,838 2972.2288,845 L 2969.2288,869 C 2969.2288,869 2966.2288,924 2998.2288,951 C 2998.2288,951 3009.2288,961 3018.2288,958 L 3025.2288,949 C 3025.2288,949 3037.2288,933 3037.2288,904 L 3033.2288,856 L 3030.2288,842 L 3036.2288,797 C 3036.2288,797 3036.2288,777 3039.2288,769 C 3039.2288,769 3024.2288,759 3034.2288,711 L 3036.2288,683 L 3037.2288,665 C 3037.2288,665 3040.2288,650 3042.2288,650 L 3045.2288,635 L 3048.2288,624 L 3063.2288,553 L 3077.2288,512 L 3077.2288,505 L 3071.2288,494 L 3071.2288,478 L 3075.2288,467 L 3080.2288,443 L 3078.2288,443 L 3080.2288,425 L 3078.2288,407 C 3078.2288,407 3071.2288,407 3071.2288,358 L 3062.2288,329 L 3054.2288,320 L 3045.2288,302 L 3036.2288,274 L 3028.2288,267 L 3024.2288,283"
|
||||
id="path8592"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2661.2288,233 L 2662.2288,227 L 2658.2288,219 L 2658.2288,231 L 2661.2288,233"
|
||||
id="path8594"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2678.2288,224 L 2664.2288,219 L 2664.2288,224 L 2678.2288,224"
|
||||
id="path8596"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3051.2288,1246 L 3051.2288,1515 L 3016.2288,1515 L 2835.2288,1346 L 2835.2288,1515 L 2776.2288,1515 L 2776.2288,1246 L 2820.2288,1246 L 2994.2288,1406 L 2994.2288,1246 L 3051.2288,1246"
|
||||
id="path8598"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 2696.2288,1246 L 2696.2288,1515 L 2614.2288,1515 L 2614.2288,1246 L 2696.2288,1246"
|
||||
id="path8600"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3205.2288,1246 L 3083.2288,1515 L 3149.2288,1515 L 3176.2288,1451 L 3297.2288,1451 L 3323.2288,1515 L 3408.2288,1515 L 3281.2288,1246 L 3205.2288,1246 z M 3235.2288,1312 L 3281.2288,1412 L 3193.2288,1412 L 3235.2288,1312 z"
|
||||
id="path8602"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3535.2288,1246 L 3777.2288,1246 L 3777.2288,1285 L 3698.2288,1285 L 3698.2288,1515 L 3616.2288,1515 L 3616.2288,1285 L 3535.2288,1285 L 3535.2288,1246"
|
||||
id="path8604"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5302.2288,183 L 5313.2288,169 L 5324.2288,171 L 5331.2288,169 L 5364.2288,166 C 5364.2288,166 5366.2288,172 5334.2288,172 L 5316.2288,180 L 5327.2288,186 L 5361.2288,190 C 5361.2288,190 5372.2288,192 5387.2288,204 L 5407.2288,225 L 5392.2288,242 L 5379.2288,248 L 5366.2288,249 L 5314.2288,260 L 5274.2288,263 L 5242.2288,272 L 5175.2288,280 L 5147.2288,289 L 5130.2288,299 L 5127.2288,299 L 5124.2288,293 L 5116.2288,301 C 5116.2288,301 5113.2288,307 5113.2288,316 C 5113.2288,316 5113.2288,329 5121.2288,334 L 5139.2288,339 C 5139.2288,339 5151.2288,345 5157.2288,345 L 5195.2288,334 L 5236.2288,326 L 5274.2288,316 L 5336.2288,307 L 5532.2288,302 L 5582.2288,295 L 5597.2288,298 L 5611.2288,299 C 5611.2288,299 5629.2288,290 5635.2288,293 L 5637.2288,283 L 5637.2288,263 L 5649.2288,255 C 5649.2288,255 5673.2288,239 5664.2288,227 L 5632.2288,218 L 5596.2288,219 L 5544.2288,219 C 5544.2288,219 5525.2288,219 5526.2288,209 L 5528.2288,195 C 5528.2288,195 5522.2288,177 5525.2288,171 L 5523.2288,140 L 5503.2288,125 L 5491.2288,124 L 5478.2288,113 L 5422.2288,89 L 5401.2288,83 L 5389.2288,86 L 5370.2288,118 L 5369.2288,121 L 5358.2288,118 L 5345.2288,127 L 5346.2288,128 L 5339.2288,133 L 5316.2288,150 C 5316.2288,150 5302.2288,163 5313.2288,160 L 5316.2288,160 L 5310.2288,165 L 5281.2288,175 L 5278.2288,184 L 5286.2288,187 L 5302.2288,183"
|
||||
id="path8608"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 4244.2288,171 L 4241.2288,204 C 4241.2288,204 4224.2288,231 4220.2288,245 C 4220.2288,245 4206.2288,352 4197.2288,363 L 4180.2288,378 L 4161.2288,387 L 4140.2288,401 L 4088.2288,438 L 4087.2288,440 L 4108.2288,443 L 4120.2288,456 L 4144.2288,449 L 4170.2288,446 L 4171.2288,466 L 4176.2288,473 L 4159.2288,500 C 4159.2288,500 4159.2288,523 4078.2288,577 C 4078.2288,577 4075.2288,568 4078.2288,537 L 4084.2288,496 L 4082.2288,479 L 4070.2288,458 C 4070.2288,458 4056.2288,440 4055.2288,432 C 4055.2288,432 4038.2288,429 4037.2288,414 L 4028.2288,396 C 4028.2288,396 4025.2288,396 4020.2288,414 L 4011.2288,435 L 4008.2288,446 L 4005.2288,447 C 4005.2288,447 4005.2288,456 3993.2288,472 L 3981.2288,493 C 3981.2288,493 3978.2288,506 3981.2288,565 L 3976.2288,624 L 3967.2288,682 L 3955.2288,721 C 3955.2288,721 3946.2288,741 3955.2288,763 C 3955.2288,763 3960.2288,772 3979.2288,783 C 3979.2288,783 3998.2288,792 4008.2288,794 L 4020.2288,807 C 4020.2288,807 4026.2288,822 4031.2288,824 L 4061.2288,845 L 4090.2288,850 L 4114.2288,859 L 4131.2288,928 C 4131.2288,928 4146.2288,987 4165.2288,993 L 4167.2288,993 C 4167.2288,993 4168.2288,999 4179.2288,1007 L 4194.2288,1016 C 4194.2288,1016 4221.2288,1034 4242.2288,1040 L 4295.2288,1048 L 4341.2288,1049 L 4394.2288,1054 C 4394.2288,1054 4439.2288,1049 4444.2288,1040 L 4490.2288,1034 C 4490.2288,1034 4540.2288,1022 4537.2288,1017 L 4543.2288,1016 L 4633.2288,993 L 4655.2288,984 L 4651.2288,984 L 4655.2288,975 L 4658.2288,975 L 4657.2288,969 L 4660.2288,969 L 4663.2288,972 L 4664.2288,969 L 4663.2288,966 L 4667.2288,960 L 4663.2288,961 L 4670.2288,948 L 4681.2288,937 L 4682.2288,937 L 4682.2288,943 L 4690.2288,933 L 4692.2288,933 L 4692.2288,934 L 4688.2288,937 L 4690.2288,940 C 4690.2288,940 4688.2288,943 4692.2288,942 L 4693.2288,936 L 4698.2288,933 L 4699.2288,934 L 4704.2288,928 L 4707.2288,927 L 4707.2288,918 L 4701.2288,915 L 4698.2288,919 L 4698.2288,912 L 4695.2288,910 L 4695.2288,913 L 4692.2288,912 L 4692.2288,907 L 4688.2288,903 L 4695.2288,898 L 4693.2288,895 L 4685.2288,903 L 4688.2288,893 L 4693.2288,893 L 4695.2288,890 L 4682.2288,889 L 4678.2288,892 L 4676.2288,890 L 4678.2288,883 L 4669.2288,881 L 4675.2288,878 L 4672.2288,875 L 4672.2288,872 L 4676.2288,874 L 4676.2288,872 L 4670.2288,866 L 4669.2288,868 L 4664.2288,862 L 4663.2288,860 L 4657.2288,862 L 4655.2288,859 L 4655.2288,863 L 4660.2288,863 L 4660.2288,872 L 4658.2288,872 L 4652.2288,860 L 4649.2288,859 L 4646.2288,860 L 4652.2288,856 L 4654.2288,856 L 4643.2288,854 C 4643.2288,854 4637.2288,854 4636.2288,859 L 4625.2288,865 L 4608.2288,877 L 4590.2288,881 L 4572.2288,889 L 4513.2288,900 L 4463.2288,915 L 4445.2288,918 L 4348.2288,928 L 4323.2288,919 C 4323.2288,919 4318.2288,919 4315.2288,915 L 4312.2288,904 C 4312.2288,904 4292.2288,863 4295.2288,853 L 4323.2288,816 L 4338.2288,803 C 4338.2288,803 4348.2288,795 4365.2288,797 L 4382.2288,794 L 4403.2288,791 L 4416.2288,783 L 4433.2288,780 L 4450.2288,776 L 4466.2288,772 L 4534.2288,763 L 4543.2288,756 L 4551.2288,744 L 4565.2288,724 C 4565.2288,724 4580.2288,712 4581.2288,700 L 4581.2288,685 L 4586.2288,667 L 4571.2288,632 L 4555.2288,599 L 4584.2288,525 L 4589.2288,506 C 4589.2288,506 4593.2288,491 4598.2288,491 L 4602.2288,470 L 4607.2288,447 C 4607.2288,447 4613.2288,434 4611.2288,426 L 4608.2288,407 L 4611.2288,394 L 4607.2288,379 C 4607.2288,379 4601.2288,370 4601.2288,364 L 4586.2288,342 L 4574.2288,308 C 4574.2288,308 4568.2288,293 4543.2288,283 L 4539.2288,286 L 4516.2288,277 C 4516.2288,277 4501.2288,267 4487.2288,271 L 4456.2288,266 L 4401.2288,292 L 4341.2288,325 L 4323.2288,325 L 4335.2288,248 C 4335.2288,248 4338.2288,207 4295.2288,187 L 4271.2288,175 L 4244.2288,171 z M 4430.2288,354 L 4475.2288,364 L 4474.2288,370 L 4483.2288,382 L 4483.2288,391 C 4483.2288,391 4483.2288,399 4471.2288,411 L 4457.2288,411 L 4454.2288,411 C 4454.2288,411 4407.2288,444 4379.2288,450 L 4356.2288,453 L 4345.2288,453 C 4345.2288,453 4333.2288,456 4320.2288,453 C 4320.2288,453 4301.2288,450 4298.2288,440 L 4301.2288,419 C 4301.2288,419 4306.2288,404 4310.2288,402 C 4310.2288,402 4310.2288,394 4324.2288,388 L 4338.2288,382 C 4338.2288,382 4350.2288,376 4372.2288,370 L 4389.2288,361 L 4412.2288,355 L 4430.2288,354 z M 4439.2288,526 L 4442.2288,529 C 4442.2288,529 4447.2288,534 4444.2288,540 L 4434.2288,588 L 4427.2288,600 C 4427.2288,600 4419.2288,611 4379.2288,629 C 4379.2288,629 4338.2288,656 4315.2288,639 L 4307.2288,618 L 4312.2288,602 C 4312.2288,602 4310.2288,588 4314.2288,580 L 4327.2288,565 L 4360.2288,547 L 4403.2288,532 L 4439.2288,526 z M 4114.2288,630 L 4134.2288,638 C 4134.2288,638 4143.2288,647 4137.2288,674 L 4125.2288,711 L 4123.2288,718 L 4114.2288,732 L 4093.2288,736 C 4093.2288,736 4087.2288,736 4067.2288,747 C 4067.2288,747 4058.2288,753 4047.2288,757 L 4040.2288,765 L 4038.2288,763 L 4046.2288,738 L 4043.2288,723 L 4047.2288,709 L 4049.2288,695 L 4061.2288,662 L 4066.2288,647 L 4067.2288,647 C 4067.2288,647 4078.2288,647 4093.2288,6Line truncated
|
||||
id="path8610"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 4982.2288,283 L 4982.2288,292 C 4982.2288,292 4970.2288,328 4970.2288,345 L 4950.2288,376 L 4947.2288,387 L 4941.2288,399 L 4924.2288,435 L 4897.2288,494 L 4882.2288,512 L 4859.2288,547 L 4852.2288,570 L 4844.2288,594 L 4846.2288,617 L 4847.2288,652 C 4847.2288,652 4852.2288,655 4855.2288,656 L 4865.2288,671 L 4867.2288,703 L 4865.2288,736 L 4870.2288,729 L 4874.2288,717 L 4874.2288,715 L 4876.2288,715 L 4877.2288,717 L 4874.2288,723 L 4876.2288,726 L 4879.2288,726 L 4884.2288,717 L 4893.2288,689 L 4909.2288,671 L 4918.2288,665 L 4980.2288,605 C 4980.2288,605 4998.2288,599 5009.2288,624 L 5012.2288,647 L 5018.2288,692 L 5018.2288,750 L 5011.2288,783 L 5004.2288,842 L 4995.2288,869 L 4983.2288,939 L 4982.2288,1014 C 4982.2288,1014 4982.2288,1046 5012.2288,1049 L 5026.2288,1033 C 5026.2288,1033 5048.2288,1034 5039.2288,1022 L 5039.2288,1010 L 5051.2288,1001 L 5062.2288,1001 L 5077.2288,977 L 5083.2288,931 L 5092.2288,831 L 5095.2288,797 L 5101.2288,763 L 5112.2288,630 C 5112.2288,630 5107.2288,600 5109.2288,588 L 5118.2288,567 C 5118.2288,567 5122.2288,558 5122.2288,534 L 5119.2288,523 L 5110.2288,515 L 5101.2288,505 C 5101.2288,505 5094.2288,502 5085.2288,497 L 5056.2288,484 L 5038.2288,488 L 5042.2288,470 L 5048.2288,455 L 5050.2288,425 L 5054.2288,410 C 5054.2288,410 5056.2288,390 5044.2288,364 L 5023.2288,322 L 5021.2288,310 L 5011.2288,284 L 4988.2288,264 L 4982.2288,283"
|
||||
id="path8612"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 4177.2288,1246 L 4177.2288,1476 L 4312.2288,1476 L 4312.2288,1515 L 4096.2288,1515 L 4096.2288,1246 L 4177.2288,1246"
|
||||
id="path8620"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 3896.2288,1285 L 3896.2288,1359 L 4004.2288,1359 L 4004.2288,1400 L 3896.2288,1400 L 3896.2288,1476 L 4044.2288,1476 L 4044.2288,1515 L 3815.2288,1515 L 3815.2288,1246 L 4035.2288,1246 L 4035.2288,1285 L 3896.2288,1285"
|
||||
id="path8622"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 4366.2288,1246 L 4587.2288,1246 L 4587.2288,1285 L 4448.2288,1285 L 4448.2288,1359 L 4555.2288,1359 L 4555.2288,1400 L 4448.2288,1400 L 4448.2288,1476 L 4598.2288,1476 L 4598.2288,1515 L 4366.2288,1515 L 4366.2288,1246"
|
||||
id="path8624"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 4741.2288,1309 C 4741.2288,1309 4716.2288,1335 4716.2288,1380 L 4716.2288,1383 C 4716.2288,1383 4716.2288,1432 4738.2288,1456 C 4738.2288,1456 4766.2288,1479 4802.2288,1479 L 4849.2288,1474 L 4879.2288,1467 L 4879.2288,1507 L 4849.2288,1516 L 4797.2288,1519 C 4797.2288,1519 4722.2288,1519 4678.2288,1486 C 4678.2288,1486 4631.2288,1453 4631.2288,1388 L 4631.2288,1376 C 4631.2288,1376 4631.2288,1315 4678.2288,1279 C 4678.2288,1279 4725.2288,1241 4797.2288,1241 L 4847.2288,1244 L 4879.2288,1252 L 4879.2288,1294 L 4852.2288,1287 L 4811.2288,1284 C 4811.2288,1284 4767.2288,1284 4741.2288,1309"
|
||||
id="path8626"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5066.2288,1241 C 5066.2288,1241 4992.2288,1241 4955.2288,1279 C 4955.2288,1279 4915.2288,1317 4915.2288,1374 L 4915.2288,1389 C 4915.2288,1389 4915.2288,1451 4958.2288,1486 C 4958.2288,1486 4997.2288,1519 5065.2288,1519 C 5065.2288,1519 5133.2288,1519 5172.2288,1486 C 5172.2288,1486 5215.2288,1451 5215.2288,1389 L 5215.2288,1374 C 5215.2288,1374 5215.2288,1317 5175.2288,1279 C 5175.2288,1279 5139.2288,1241 5066.2288,1241 z M 5065.2288,1281 C 5065.2288,1281 5109.2288,1281 5119.2288,1303 C 5119.2288,1303 5131.2288,1330 5131.2288,1377 L 5131.2288,1385 C 5131.2288,1385 5131.2288,1433 5116.2288,1459 C 5116.2288,1459 5101.2288,1482 5065.2288,1482 C 5065.2288,1482 5023.2288,1482 5011.2288,1460 C 5011.2288,1460 4998.2288,1442 4998.2288,1385 L 4998.2288,1377 C 4998.2288,1377 4998.2288,1323 5012.2288,1302 C 5012.2288,1302 5023.2288,1281 5065.2288,1281 z"
|
||||
id="path8630"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5500.2288,357 L 5488.2288,357 L 5470.2288,357 C 5470.2288,357 5449.2288,357 5440.2288,351 L 5402.2288,351 L 5370.2288,360 L 5340.2288,372 C 5340.2288,372 5334.2288,373 5331.2288,381 C 5331.2288,381 5327.2288,391 5333.2288,402 C 5333.2288,402 5340.2288,411 5352.2288,414 L 5390.2288,434 L 5399.2288,443 L 5452.2288,467 L 5478.2288,473 L 5514.2288,473 L 5526.2288,478 C 5526.2288,478 5532.2288,481 5537.2288,478 C 5537.2288,478 5544.2288,473 5556.2288,449 L 5564.2288,440 L 5564.2288,428 L 5565.2288,414 L 5552.2288,402 L 5544.2288,382 C 5544.2288,382 5531.2288,378 5525.2288,375 L 5513.2288,363 L 5500.2288,357"
|
||||
id="path8632"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5354.2288,508 L 5293.2288,518 L 5292.2288,525 C 5292.2288,525 5289.2288,531 5292.2288,538 L 5302.2288,558 L 5317.2288,577 L 5345.2288,600 C 5345.2288,600 5352.2288,608 5366.2288,611 L 5396.2288,612 L 5422.2288,617 C 5422.2288,617 5434.2288,618 5455.2288,630 L 5476.2288,627 L 5491.2288,627 L 5506.2288,620 C 5506.2288,620 5522.2288,617 5523.2288,615 C 5523.2288,615 5531.2288,612 5538.2288,599 L 5547.2288,576 L 5547.2288,567 L 5549.2288,559 C 5549.2288,559 5550.2288,553 5558.2288,550 L 5568.2288,544 L 5568.2288,523 C 5568.2288,523 5568.2288,520 5553.2288,517 L 5534.2288,515 L 5513.2288,518 L 5455.2288,505 L 5423.2288,503 L 5354.2288,508"
|
||||
id="path8634"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5213.2288,759 L 5222.2288,800 C 5222.2288,800 5218.2288,824 5221.2288,845 L 5222.2288,893 L 5221.2288,928 L 5237.2288,995 L 5240.2288,1002 L 5240.2288,1027 C 5240.2288,1027 5243.2288,1040 5254.2288,1045 L 5272.2288,1049 L 5296.2288,1060 L 5304.2288,1070 C 5304.2288,1070 5308.2288,1076 5314.2288,1075 C 5314.2288,1075 5319.2288,1073 5319.2288,1060 L 5331.2288,1039 L 5333.2288,1016 L 5339.2288,992 L 5343.2288,975 L 5352.2288,893 L 5348.2288,881 L 5340.2288,869 L 5342.2288,853 L 5342.2288,838 L 5345.2288,836 L 5390.2288,838 L 5407.2288,819 C 5407.2288,819 5432.2288,795 5448.2288,789 L 5455.2288,800 C 5455.2288,800 5461.2288,816 5457.2288,841 L 5423.2288,880 L 5402.2288,901 C 5402.2288,901 5389.2288,912 5386.2288,925 L 5379.2288,930 L 5378.2288,940 L 5376.2288,968 C 5376.2288,968 5376.2288,992 5379.2288,998 L 5392.2288,1007 L 5407.2288,1013 L 5419.2288,1023 L 5467.2288,1027 L 5517.2288,1017 L 5546.2288,1023 L 5584.2288,1022 L 5600.2288,1013 L 5612.2288,1008 L 5623.2288,996 L 5623.2288,980 L 5626.2288,943 L 5612.2288,936 L 5609.2288,934 L 5593.2288,921 L 5591.2288,909 L 5614.2288,848 L 5615.2288,815 C 5615.2288,815 5611.2288,807 5617.2288,791 L 5617.2288,760 L 5600.2288,741 L 5582.2288,714 L 5561.2288,697 L 5552.2288,689 L 5528.2288,683 L 5503.2288,685 L 5490.2288,679 L 5443.2288,711 L 5349.2288,757 L 5337.2288,762 L 5325.2288,765 C 5325.2288,765 5313.2288,765 5308.2288,760 L 5298.2288,748 L 5289.2288,738 L 5283.2288,723 L 5265.2288,715 L 5246.2288,707 C 5246.2288,707 5237.2288,703 5233.2288,694 L 5228.2288,688 L 5221.2288,691 L 5215.2288,729 L 5213.2288,759"
|
||||
id="path8636"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5314.2288,1515 L 5259.2288,1515 L 5259.2288,1246 L 5319.2288,1246 L 5455.2288,1442 L 5594.2288,1246 L 5647.2288,1246 L 5647.2288,1515 L 5573.2288,1515 L 5573.2288,1361 L 5463.2288,1515 L 5419.2288,1515 L 5314.2288,1361 L 5314.2288,1515"
|
||||
id="path8638"
|
||||
style="fill:#02489d;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
|
||||
</svg>
|
||||
<!-- version: 20090314, original size: 5666.248 1554, border: 3% -->
|
||||
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 25.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1"
|
||||
id="svg2" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 392 293.6"
|
||||
style="enable-background:new 0 0 1000 293.6;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#E60027;}
|
||||
</style>
|
||||
<sodipodi:namedview bordercolor="#000000" borderopacity="0.25" id="namedview1" inkscape:current-layer="svg2" inkscape:cx="500" inkscape:cy="146.85792" inkscape:deskcolor="#d1d1d1" inkscape:pagecheckerboard="0" inkscape:pageopacity="0.0" inkscape:showpageshadow="2" inkscape:window-height="705" inkscape:window-maximized="1" inkscape:window-width="1366" inkscape:window-x="-8" inkscape:window-y="-8" inkscape:zoom="0.732" pagecolor="#ffffff">
|
||||
</sodipodi:namedview>
|
||||
<g id="g23530" transform="matrix(4.6372088,0,0,4.6372088,-2380.96,-486.51923)">
|
||||
<path id="path47920" class="st0" d="M538.7,145.6l5.1-5l-4-4l4-4.1l-5.1-5l-4,4l-2.7-2.7c-4.2-4.2-11.2-4.2-15.4,0
|
||||
c-2.1,2.1-3.2,4.9-3.2,7.7c0,2.8,1,5.6,3.2,7.7c4.2,4.3,11.2,4.3,15.4,0c0.9-0.9,1.8-1.8,2.7-2.7L538.7,145.6z M527,139.2
|
||||
c-1.5,1.5-3.9,1.5-5.3,0c-0.7-0.7-1.1-1.7-1.1-2.6c0-1,0.4-2,1.1-2.7c1.5-1.5,3.9-1.5,5.3,0c1.2,1.2,2.6,2.7,2.6,2.7
|
||||
S528.2,138,527,139.2 M594.2,128.9c-4.2-4.2-11.2-4.2-15.4,0c-1.2,1.2-2.7,2.7-2.7,2.7l-4-4l-5,5l4,4.1l-4,4l5,5l4-4
|
||||
c0,0,1.5,1.4,2.7,2.7c4.3,4.3,11.2,4.3,15.4,0c2.1-2.1,3.2-4.9,3.2-7.7C597.4,133.8,596.3,131,594.2,128.9 M589.1,139.2
|
||||
c-1.5,1.5-3.8,1.5-5.3,0c-1.2-1.2-2.7-2.6-2.7-2.6s1.5-1.5,2.7-2.7c1.5-1.5,3.9-1.5,5.3,0c0.7,0.7,1.1,1.7,1.1,2.7
|
||||
C590.2,137.5,589.9,138.5,589.1,139.2 M560.5,136.6c0,0,11.9-11.9,13-13c4.3-4.3,4.3-11.2,0-15.4c-4.2-4.2-11.1-4.2-15.4,0
|
||||
c-1.7,1.7-2.6,2.7-2.6,2.7s-1-1-2.6-2.7c-4.3-4.2-11.2-4.2-15.4,0c-4.3,4.3-4.3,11.2,0,15.4l13.1,13c0,0-11.9,11.8-13.1,13
|
||||
c-4.3,4.3-4.3,11.2,0,15.4c4.3,4.2,11.2,4.2,15.4,0c1.7-1.7,2.6-2.7,2.6-2.7s1,1,2.6,2.7c4.3,4.2,11.2,4.2,15.4,0
|
||||
c4.3-4.3,4.3-11.2,0-15.4C572.4,148.4,560.5,136.6,560.5,136.6 M542.4,118.5c-1.4-1.5-1.4-3.8,0-5.3c1.5-1.4,3.9-1.4,5.3,0l2.7,2.7
|
||||
l-4,4l5,5.1l4-4l4,4l5-5.1l-4-4c0,0,1.2-1.3,2.7-2.7c1.5-1.4,3.8-1.4,5.3,0c1.5,1.5,1.5,3.8,0,5.3c-1.5,1.5-13.1,13.1-13.1,13.1
|
||||
S543.9,120,542.4,118.5 M568.5,160c-1.5,1.4-3.8,1.4-5.3,0c-1.4-1.4-2.7-2.7-2.7-2.7l4-4l-5-5l-4,4l-4-4l-5,5l4,4
|
||||
c0,0-1.3,1.2-2.7,2.7c-1.4,1.4-3.8,1.4-5.3,0c-1.4-1.5-1.4-3.9,0-5.3c1.5-1.5,13-13.1,13-13.1s11.5,11.6,13.1,13.1
|
||||
C570,156.1,570,158.5,568.5,160"/>
|
||||
<g id="g23504">
|
||||
<path id="path23467" d="M644,149.2c-2.5-0.1-3.3,1.3-3.3,1.3l-0.1-1.2h-2.2v10.3h2.6v-6.3c0-1.3,0.8-2.4,2.3-2.4
|
||||
c1.5,0,2,1.4,2,2.4v6.3h2.4v-6.1C647.6,150.4,645.9,149.2,644,149.2"/>
|
||||
<path id="path23465" d="M621.8,157.1c-0.6,0.8-1.3,1.5-2.2,2c-1,0.6-2.2,0.9-3.5,0.9c-1.9,0-3.5-0.7-4.8-2c-1.3-1.3-2-2.9-2-4.8
|
||||
c0-2,0.7-3.7,2.3-5c1.3-1.2,2.8-1.8,4.5-1.8c1.2,0,2.4,0.3,3.5,0.9c0.9,0.5,1.6,1.2,2.2,2l-2.2,1.6c-0.1-0.4-0.4-0.7-0.6-1
|
||||
c-0.7-0.8-1.7-1.3-2.8-1.3c-1.1,0-2.1,0.5-2.8,1.3c-0.8,0.9-1.1,2-1.1,3.3c0,1.4,0.4,2.5,1.1,3.4c0.7,0.8,1.7,1.3,2.8,1.3
|
||||
c1.1,0,2.1-0.4,2.8-1.3c0.3-0.3,0.5-0.7,0.6-1C619.6,155.5,621.2,156.7,621.8,157.1"/>
|
||||
<path id="path23463" d="M622.7,144.7h2.6v5.8c0,0,0.9-1.3,3-1.3c1.9,0,3.6,1.2,3.6,4.4v6.1h-2.4v-6.3c0-1.1-0.4-2.4-1.9-2.4
|
||||
c-1.5,0-2.2,1.1-2.2,2.4v6.3h-2.6L622.7,144.7z"/>
|
||||
<path id="path47924" d="M657.3,149.4v1.2c-1-1-2.1-1.5-3.4-1.5c-1.4,0-2.7,0.5-3.6,1.5c-1,1-1.5,2.3-1.5,3.8
|
||||
c0,1.6,0.5,2.9,1.5,3.9c1,1,2.2,1.6,3.7,1.6c1.2,0,2.3-0.5,3.3-1.4v1.1h2.5v-10.3H657.3z M656.6,156.9c-0.6,0.6-1.3,0.9-2.3,0.9
|
||||
c-0.9,0-1.6-0.3-2.1-0.9c-0.5-0.6-0.8-1.4-0.8-2.4c0-0.9,0.3-1.7,0.8-2.3c0.6-0.6,1.3-0.9,2.2-0.9c0.9,0,1.6,0.3,2.2,0.9
|
||||
c0.6,0.6,0.9,1.4,0.9,2.3C657.4,155.5,657.1,156.2,656.6,156.9"/>
|
||||
<path id="path23481" class="st0" d="M689,145.5c-0.8,0-1.4,0.6-1.4,1.4c0,0.7,0.5,1.4,1.4,1.4h0c0.9,0,1.4-0.6,1.4-1.4
|
||||
C690.4,146.1,689.9,145.5,689,145.5z M687.8,149.4v10.3h2.4v-10.3H687.8z"/>
|
||||
<path id="path23487" class="st0" d="M635.1,145.5c-0.8,0-1.4,0.6-1.4,1.4c0,0.7,0.5,1.4,1.4,1.4h0c0.9,0,1.4-0.6,1.4-1.4
|
||||
C636.5,146.1,636,145.5,635.1,145.5z M633.9,149.3v10.4h2.4v-10.4H633.9z"/>
|
||||
<path id="path47930" d="M725.7,149.2c-1.5,0-2.6,0.6-3.5,1.6c-0.6-1.1-1.6-1.6-2.7-1.6c-2.2-0.1-3.1,1.3-3.1,1.3l-0.1-1.2H714
|
||||
v10.3h2.6v-6.2c0-1.3,0.7-2.4,2.1-2.4c1.4,0,1.8,1.3,1.8,2.4v6.2h2.4v-6.1v0c0-1.8,0.8-2.6,2-2.6c1.4,0,1.8,1.3,1.8,2.4v6.2h2.4
|
||||
v-6C729,150.5,727.5,149.3,725.7,149.2"/>
|
||||
<path id="path23475" d="M701.9,157.7c-0.4,0.7-1,1.2-1.7,1.6c-0.8,0.5-1.8,0.7-2.8,0.7c-1.5,0-2.8-0.6-3.8-1.6
|
||||
c-1.1-1-1.6-2.3-1.6-3.8c0-1.6,0.6-2.9,1.8-4c1-0.9,2.3-1.4,3.6-1.4c0.9,0,1.9,0.2,2.7,0.7c0.7,0.4,1.3,0.9,1.7,1.5l-1.9,1.4
|
||||
c-0.1-0.3-0.3-0.5-0.5-0.7c-0.5-0.6-1.2-1-2.1-1c-0.8,0-1.5,0.3-2,1c-0.6,0.6-0.8,1.5-0.8,2.5c0,1,0.3,1.9,0.8,2.5
|
||||
c0.5,0.6,1.2,0.9,2,0.9c0.8,0,1.5-0.3,2-1c0.2-0.2,0.4-0.5,0.5-0.8L701.9,157.7z"/>
|
||||
<path id="path23473" inkscape:label="path23473" d="M668.9,159.8c2.5,0,3.3-1.3,3.3-1.3l0.1,1.2h2.2v-10.3h-2.6v6.3
|
||||
c0,1.3-0.7,2.4-2.2,2.4c-1.5,0-2.1-1.4-2.1-2.4v-6.3H665v6C665,158.6,667,159.8,668.9,159.8"/>
|
||||
<path id="path23471" d="M682.3,149.2c-2.5,0-3.3,1.4-3.3,1.4l-0.1-1.2h-2.3v10.3h2.6v-6.2c0-1.4,0.8-2.4,2.2-2.4
|
||||
c1.5,0,2,1.3,2,2.4v6.2h2.4v-6C685.8,150.5,684.1,149.2,682.3,149.2"/>
|
||||
<path id="path47936" d="M707.4,149.1c1,0,1.9,0.2,2.7,0.7c0.9,0.5,1.5,1.2,2,1.9c0.5,0.8,0.7,1.8,0.7,2.7c0,1-0.3,1.9-0.7,2.7
|
||||
c-0.5,0.8-1.1,1.5-1.9,2c-0.8,0.5-1.8,0.7-2.7,0.7c-1.5,0-2.7-0.5-3.8-1.6c-1.1-1-1.6-2.3-1.6-3.8c0-1.6,0.6-2.9,1.8-4
|
||||
C704.9,149.6,706,149.1,707.4,149.1 M707.5,150.9c-0.8,0-1.5,0.3-2,1c-0.6,0.7-0.8,1.6-0.8,2.6c0,1.1,0.3,2,0.8,2.7
|
||||
c0.6,0.6,1.2,1,2.1,1c0.8,0,1.5-0.3,2.1-1c0.6-0.7,0.9-1.6,0.9-2.6c0-1-0.3-1.9-0.8-2.6C709.1,151.2,708.4,150.9,707.5,150.9"/>
|
||||
</g>
|
||||
<g id="g23510">
|
||||
<path id="path47940" d="M695.1,113.2l-3.6,5.6h-2.6l2.1-1.5c-1.8-2.5-3.4-4.1-3.4-4.1l-2,1.2l2.6,4.4H685v2.5h4.2v3.9H685v2.6h3.7
|
||||
l-4.4,11.3h1.9c1,0,2-0.7,2.4-1.6l2.7-6.8l3.7,8.4h3.7l-5.1-11.4h4.4v-2.6h-4.9v-3.9h4.9v-2.5h-3.2l2.9-3.7L695.1,113.2z
|
||||
M672.3,113.7v2.6h7.5v3.9h-3.1v-2.6h-3.4v15.7l-1.5,0.5v2.8l8-2.3v5.2h1.4c1.1,0,1.8-0.7,2.2-2.1v-4.1l1.7-0.5V130l-1.7,0.5
|
||||
v-14.2c0-1.6-1-2.6-2.2-2.6H672.3z M676.7,122.7h3.1v3.1h-3.1V122.7z M676.7,128.4h3.1v3.1l-3.1,0.9V128.4z"/>
|
||||
<path id="path47948" d="M633.2,116.7L633.2,116.7h-8.3v-3.6h-4.3v3.6h-5.4v2.6h5.4v10.3h-6.7v-12.9h-4.3v15.4h11.1v7.3h0.7
|
||||
c2.5-0.1,3.6-1.2,3.7-3v-4.3h7.3c2,0,3.6-1.5,3.7-3.6v-9.2C635.9,117.9,634.7,116.7,633.2,116.7 M631.6,128L631.6,128
|
||||
c0,0.9-0.7,1.6-1.6,1.6h0h-5v-10.3h6.7V128z"/>
|
||||
<path id="path47952" d="M641.1,114v25h22.4c2,0,3.6-1.6,3.6-3.7v-18.6c0-1.5-1.2-2.7-2.8-2.7h-17.9v2.5h16.7v18.4
|
||||
c0,0.9-0.7,1.6-1.6,1.6h-16.4V114H641.1z M646.5,119v2.5h5.3v3.1h-5.3v2.6h5.3v4.2h-5.4v2.5h15.4v-2.5h-1.9l1.3-1.6
|
||||
c-1.7-1.5-3-2.3-3-2.3l-1.5,1.5l2.2,2.4h-3v-4.2h5.9v-2.6h-5.9v-3.1h5.9V119H646.5z"/>
|
||||
<path id="path47960" d="M710,114.2v2.5h13.1l-4.3,2.6c-1.6-1.1-3.5-1.9-3.5-1.9L714,119l1.3,1.2h-5.2v15.2h1
|
||||
c1.1,0,2.4-0.8,2.4-2.3V131h4v4.4h1.1c1.3,0,2.3-0.7,2.3-2.3V131h4v1c0,0.5-0.6,0.8-1,0.8h-1.5l0.7,2.6h2.5c1.7,0,3.1-1.3,3.1-3.1
|
||||
v-10c0-1.2-1-2.1-2.1-2.1h-4l5.3-3.2v-2.9H710z M704.7,114.7l-2,1.4l3.4,4.5l2.6-1.9C706.7,116.1,704.7,114.7,704.7,114.7z
|
||||
M702.4,121.8v2.5h1.9v11.6c0,2,1.7,3.2,3.1,3.2h21.7v-2.5h-19.3c-0.9,0-1.7-0.8-1.7-1.7v-13.1H702.4z M713.7,122.7h4v1.7h-4
|
||||
V122.7z M721.1,122.7h4v1.7h-4V122.7z M713.7,126.9h4v1.7h-4V126.9z M721.1,126.9h4v1.7h-4V126.9z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,21 @@
|
||||
/** 前端自己的构建信息(构建时由 Vite 写进来,见 web/build-info.mjs)。 */
|
||||
declare const __APP_VERSION__: string
|
||||
declare const __APP_TAG__: string
|
||||
declare const __APP_COMMIT__: string
|
||||
declare const __APP_BUILT_AT__: string
|
||||
|
||||
export const frontendBuild = {
|
||||
version: __APP_VERSION__,
|
||||
tag: __APP_TAG__,
|
||||
commit: __APP_COMMIT__,
|
||||
builtAt: __APP_BUILT_AT__,
|
||||
}
|
||||
|
||||
/** 构建时间显示成本地时间「2026-10-06 20:30」;没有(开发服务器)就是空。 */
|
||||
export function fmtBuiltAt(iso: string | null | undefined): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export interface ChartSeries {
|
||||
color: string
|
||||
dash?: number[]
|
||||
fill?: boolean
|
||||
/** 原始数据的淡线(开了 EWMA 平滑时垫在平滑线后面):画细、画淡,图例和提示框里不出现。 */
|
||||
faint?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -22,14 +24,33 @@ interface Props {
|
||||
zero?: boolean
|
||||
/** 缺口:这些时间段用底色标出。 */
|
||||
gaps?: { from: number; to: number }[]
|
||||
/** 为真时 `t` 是「从起点算起的毫秒数」而不是 Unix 毫秒:横轴显示 0:00、0:15…,用来把两段时间叠在一起比。 */
|
||||
offset?: boolean
|
||||
}
|
||||
|
||||
/** 偏移量(毫秒)→ h:mm:ss / m:ss。 */
|
||||
export function fmtOffset(ms: number): string {
|
||||
const total = Math.round(ms / 1000)
|
||||
const h = Math.floor(total / 3600)
|
||||
const m = Math.floor((total % 3600) / 60)
|
||||
const sec = total % 60
|
||||
const two = (n: number) => String(n).padStart(2, '0')
|
||||
return h > 0 ? `${h}:${two(m)}${sec ? ':' + two(sec) : ''}` : `${m}:${two(sec)}`
|
||||
}
|
||||
|
||||
function cssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
/** 毫秒到 1 秒以上换成秒(30000ms → 30s),坐标轴和提示框都不会出现一长串数字。 */
|
||||
function withUnit(v: number, unit: string, digits?: number): string {
|
||||
if (unit === 'ms' && Math.abs(v) >= 1000) return `${digits === undefined ? +(v / 1000).toFixed(1) : (v / 1000).toFixed(digits)}s`
|
||||
return `${v}${unit}`
|
||||
}
|
||||
|
||||
function formatValue(v: number | null | undefined, unit: string): string {
|
||||
if (v === null || v === undefined) return '—'
|
||||
if (unit === 'ms' && Math.abs(v) >= 1000) return withUnit(v, unit, 2)
|
||||
const text = Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2)
|
||||
return `${text}${unit}`
|
||||
}
|
||||
@@ -40,12 +61,17 @@ function formatValue(v: number | null | undefined, unit: string): string {
|
||||
* - 在图上拖动选一段放大预览,双击或点「还原」恢复;数据刷新时保持放大的区间;
|
||||
* - 鼠标悬停显示这个时刻每条线的值。
|
||||
*/
|
||||
export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [] }: Props) {
|
||||
export function TimeChart({ t, series, height = 220, unit = '', zero = true, gaps = [], offset = false }: 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 latest = useRef({ t, series, gaps })
|
||||
latest.current = { t, series, gaps }
|
||||
const structure = JSON.stringify([series.map((x) => [x.label, x.color, x.dash, x.fill, x.faint]), unit, zero, offset, height])
|
||||
const dataOf = (): AlignedData => [latest.current.t.map((v) => v / 1000), ...latest.current.series.map((x) => x.values.map((v) => (v === null ? null : v)))]
|
||||
|
||||
const reset = useCallback(() => {
|
||||
zoom.current = null
|
||||
@@ -70,12 +96,12 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
||||
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]) },
|
||||
x: { time: !offset, 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}`) },
|
||||
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { stroke: grid }, font, ...(offset ? { values: (_u: uPlot, ticks: number[]) => ticks.map((v) => fmtOffset(v * 1000)) } : {}) },
|
||||
{ stroke: axis, grid: { stroke: grid, width: 1 }, ticks: { show: false }, font, size: 56, values: (_u, ticks) => ticks.map((v) => withUnit(v, unit)) },
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
@@ -83,8 +109,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
||||
const color = cssVar(s.color)
|
||||
return {
|
||||
label: s.label,
|
||||
stroke: color,
|
||||
width: 1.8,
|
||||
stroke: s.faint ? color + '55' : color,
|
||||
width: s.faint ? 1 : 1.8,
|
||||
dash: s.dash,
|
||||
fill: s.fill ? color + '22' : undefined,
|
||||
spanGaps: false,
|
||||
@@ -120,8 +146,8 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
||||
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)}`)
|
||||
const lines = [offset ? `起点后 ${fmtOffset(u.data[0][idx] * 1000)}` : 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 < latest.current.series.length; i++) if (!latest.current.series[i].faint) lines.push(`${latest.current.series[i].label} ${formatValue(u.data[i + 1][idx], unit)}`)
|
||||
box.textContent = lines.join('\n')
|
||||
box.style.display = 'block'
|
||||
const width = box.offsetWidth
|
||||
@@ -135,7 +161,7 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
||||
const { ctx } = u
|
||||
ctx.save()
|
||||
ctx.fillStyle = shade
|
||||
for (const g of gaps) {
|
||||
for (const g of latest.current.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)
|
||||
@@ -145,8 +171,7 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
||||
],
|
||||
},
|
||||
}
|
||||
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)
|
||||
plot.current = new uPlot(options, dataOf(), el)
|
||||
// 把提示框放进绘图区,这样它的位置和光标是同一个坐标系
|
||||
if (tip.current) plot.current.over.appendChild(tip.current)
|
||||
}
|
||||
@@ -161,12 +186,21 @@ export function TimeChart({ t, series, height = 220, unit = '', zero = true, gap
|
||||
plot.current?.destroy()
|
||||
plot.current = null
|
||||
}
|
||||
}, [t, series, height, unit, zero, gaps, reset])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [structure, reset])
|
||||
|
||||
useEffect(() => {
|
||||
const u = plot.current
|
||||
if (!u) return
|
||||
u.setData(dataOf(), !zoom.current)
|
||||
u.redraw(false)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [t, series, gaps])
|
||||
|
||||
return (
|
||||
<div className="chart-wrap">
|
||||
<div className="chart-legend">
|
||||
{series.map((s) => (
|
||||
{series.filter((s) => !s.faint).map((s) => (
|
||||
<span key={s.label}>
|
||||
<i style={{ background: `var(${s.color})` }} />
|
||||
{s.label}
|
||||
|
||||
+10
-2
@@ -5,6 +5,8 @@ export interface QueryState<T> {
|
||||
data: T | undefined
|
||||
error: ApiError | Error | undefined
|
||||
loading: boolean
|
||||
/** 正在加载,而且不是定时刷新引起的(第一次加载、换了条件):用来显示进度条,定时刷新不打扰用户。 */
|
||||
busy: boolean
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
@@ -14,17 +16,23 @@ export function useQuery<T>(fetcher: () => Promise<T>, deps: unknown[], interval
|
||||
const [error, setError] = useState<ApiError | Error>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tick, setTick] = useState(0)
|
||||
const [silent, setSilent] = useState(false)
|
||||
const lastTick = useRef(0)
|
||||
const fetcherRef = useRef(fetcher)
|
||||
fetcherRef.current = fetcher
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
// 只有 tick 变了(定时刷新、reload)才算静默刷新;条件变了是用户能感觉到的加载
|
||||
setSilent(lastTick.current !== tick)
|
||||
lastTick.current = tick
|
||||
setLoading(true)
|
||||
fetcherRef
|
||||
.current()
|
||||
.then((value) => {
|
||||
if (!alive) return
|
||||
setData(value)
|
||||
// 轮询取到一模一样的数据时保持原来的对象,依赖它的图表不会白白重建(那是闪烁的来源)
|
||||
setData((previous) => (previous !== undefined && JSON.stringify(previous) === JSON.stringify(value) ? previous : value))
|
||||
setError(undefined)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
@@ -46,7 +54,7 @@ export function useQuery<T>(fetcher: () => Promise<T>, deps: unknown[], interval
|
||||
}, [intervalMs])
|
||||
|
||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||
return { data, error, loading, reload }
|
||||
return { data, error, loading, busy: loading && !silent, reload }
|
||||
}
|
||||
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
@@ -143,6 +143,7 @@ export function translateCode(code: string): string | undefined {
|
||||
|
||||
const SETTINGS: Record<string, string> = {
|
||||
'site.public': '匿名访客能不能读展示页的数据(关闭时只有登录的管理员能看)',
|
||||
'site.show_build': '展示页底部和节点卡片上显示后端、前端、Agent、Probe 的版本、tag 和构建时间',
|
||||
'retention.raw_days': '持续任务的原始数据块保留多少天',
|
||||
'retention.test_raw_days': '按需测试的原始数据块保留多少天',
|
||||
'retention.metric_10s_hours': '10 秒精度的指标保留多少小时',
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from './api/client'
|
||||
import type { NodeInfoDto, Page } from './api/types'
|
||||
import { useQuery } from './hooks'
|
||||
import { OsIcon } from './osIcon'
|
||||
import mobileIcon from './assets/isp/mobile.svg?url'
|
||||
import telecomIcon from './assets/isp/telecom.svg?url'
|
||||
import unicomIcon from './assets/isp/unicom.svg?url'
|
||||
|
||||
/**
|
||||
* 节点(Agent、Probe)的展示信息:国旗、运营商、系统和架构。
|
||||
* 后端只给国家代码、运营商名、系统名和架构——没有 IP,所以这里也画不出 IP。
|
||||
*/
|
||||
|
||||
/** 所有国旗的 SVG,按需加载:用到哪个国家才会下载哪个文件。 */
|
||||
const flagLoaders = import.meta.glob<string>('../node_modules/flag-icons/flags/4x3/*.svg', { query: '?url', import: 'default' })
|
||||
|
||||
const regionNames = typeof Intl !== 'undefined' && 'DisplayNames' in Intl ? new Intl.DisplayNames(['zh-CN'], { type: 'region' }) : undefined
|
||||
|
||||
export function regionName(code: string): string {
|
||||
try {
|
||||
return regionNames?.of(code.toUpperCase()) ?? code
|
||||
} catch {
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
export function Flag({ code }: { code: string }) {
|
||||
const [url, setUrl] = useState<string>()
|
||||
useEffect(() => {
|
||||
const load = flagLoaders[`../node_modules/flag-icons/flags/4x3/${code.toLowerCase()}.svg`]
|
||||
if (!load) {
|
||||
setUrl(undefined)
|
||||
return
|
||||
}
|
||||
let alive = true
|
||||
void load().then((u) => alive && setUrl(u))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [code])
|
||||
const name = regionName(code)
|
||||
if (!url) return <span className="flag ph" title={name} />
|
||||
return <img className="flag" src={url} alt={name} title={name} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营商的小图标:三大运营商用它们各自的标志(只取标志,不带文字),广电没有现成的文件,用品牌色方块加一个字代替;
|
||||
* 别的运营商没有图标,只显示名字。这些标志是各公司的商标,只用来标明「这是哪家运营商」。
|
||||
*/
|
||||
const ISP_ICON: Record<string, string> = { mobile: mobileIcon, telecom: telecomIcon, unicom: unicomIcon }
|
||||
const ISP_GLYPH: Record<string, string> = { broadnet: '广' }
|
||||
|
||||
export function IspIcon({ ispKey, name }: { ispKey: string; name?: string | null }) {
|
||||
const icon = ISP_ICON[ispKey]
|
||||
if (icon) return <img className="isp logo" src={icon} alt={name ?? ispKey} title={name ?? ispKey} />
|
||||
const glyph = ISP_GLYPH[ispKey]
|
||||
if (!glyph) return null
|
||||
return (
|
||||
<svg className={`isp isp-${ispKey}`} viewBox="0 0 16 16" role="img" aria-label={name ?? ispKey}>
|
||||
<title>{name ?? ispKey}</title>
|
||||
<rect width="16" height="16" rx="4" />
|
||||
<text x="8" y="12.2" textAnchor="middle">
|
||||
{glyph}
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 节点的展示信息,按节点 id 查:`useNodeInfos().get(id)`。每分钟刷新一次。 */
|
||||
export function useNodeInfos(): Map<string, NodeInfoDto> {
|
||||
const { data } = useQuery(() => api.get<Page<NodeInfoDto>>('/api/public/nodes'), [], 60_000)
|
||||
return useMemo(() => new Map((data?.items ?? []).map((n) => [n.id, n])), [data])
|
||||
}
|
||||
|
||||
/** 系统和架构:「Debian 12 · arm64」。 */
|
||||
export const platformText = (info: NodeInfoDto | undefined) => [info?.os_name, info?.arch].filter(Boolean).join(' · ')
|
||||
|
||||
interface TagProps {
|
||||
info: NodeInfoDto | undefined
|
||||
/** 不写系统和架构,只留国旗和运营商。 */
|
||||
geoOnly?: boolean
|
||||
}
|
||||
|
||||
/** 国旗 + 运营商(图标,或者别的运营商的名字)+ 系统和架构。什么都没有就什么也不画。 */
|
||||
export function NodeTag({ info, geoOnly }: TagProps) {
|
||||
if (!info) return null
|
||||
const platform = geoOnly ? '' : platformText(info)
|
||||
if (!info.country && !info.isp_name && !platform) return null
|
||||
const title = [info.country ? regionName(info.country) : null, info.isp_name, platformText(info)].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<span className="node-tag" title={title}>
|
||||
{info.country ? <Flag code={info.country} /> : null}
|
||||
{info.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
|
||||
{info.isp_key === 'other' && info.isp_name ? <span className="isp-name">{info.isp_name}</span> : null}
|
||||
{platform ? (
|
||||
<span className="plat">
|
||||
<OsIcon os={info.os} name={info.os_name} />
|
||||
{platform}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
siAlmalinux,
|
||||
siAlpinelinux,
|
||||
siApple,
|
||||
siArchlinux,
|
||||
siCentos,
|
||||
siDebian,
|
||||
siFedora,
|
||||
siFreebsd,
|
||||
siKalilinux,
|
||||
siLinux,
|
||||
siLinuxmint,
|
||||
siNixos,
|
||||
siOpensuse,
|
||||
siOpenwrt,
|
||||
siRaspberrypi,
|
||||
siRedhat,
|
||||
siRockylinux,
|
||||
siUbuntu,
|
||||
} from 'simple-icons'
|
||||
|
||||
interface Glyph {
|
||||
/** 24×24 的图形路径。 */
|
||||
path: string
|
||||
/** 品牌色(6 位十六进制);黑色的图标不填,用文字颜色。 */
|
||||
color?: string
|
||||
}
|
||||
|
||||
const fromSimple = (icon: { path: string; hex: string }, useBrandColor = true): Glyph => ({ path: icon.path, color: useBrandColor ? icon.hex : undefined })
|
||||
|
||||
/** Windows 的四个窗格(simple-icons 没有收,自己画的最简形状)。 */
|
||||
const WINDOWS: Glyph = { path: 'M2 4.6 10.4 3.4v7.8H2zM11.6 3.2 22 1.8v9.4H11.6zM2 12.8h8.4v7.8L2 19.4zM11.6 12.8H22v9.4l-10.4-1.4z', color: '0078D4' }
|
||||
|
||||
/** 按系统名挑图标:先看 Windows、macOS,再按发行版名字找,认不出的 Linux 用企鹅。 */
|
||||
function pickGlyph(os: string | null | undefined, name: string | null | undefined): Glyph | undefined {
|
||||
if (os === 'windows') return WINDOWS
|
||||
if (os === 'macos') return fromSimple(siApple, false)
|
||||
const n = (name ?? '').toLowerCase()
|
||||
if (n.includes('debian')) return fromSimple(siDebian)
|
||||
if (n.includes('ubuntu')) return fromSimple(siUbuntu)
|
||||
if (n.includes('alpine')) return fromSimple(siAlpinelinux)
|
||||
if (n.includes('openwrt')) return fromSimple(siOpenwrt)
|
||||
if (n.includes('raspbian') || n.includes('raspberry')) return fromSimple(siRaspberrypi)
|
||||
if (n.includes('kali')) return fromSimple(siKalilinux)
|
||||
if (n.includes('mint')) return fromSimple(siLinuxmint)
|
||||
if (n.includes('rocky')) return fromSimple(siRockylinux)
|
||||
if (n.includes('alma')) return fromSimple(siAlmalinux, false)
|
||||
if (n.includes('centos')) return fromSimple(siCentos)
|
||||
if (n.includes('fedora')) return fromSimple(siFedora)
|
||||
if (n.includes('red hat') || n.includes('rhel')) return fromSimple(siRedhat)
|
||||
if (n.includes('arch')) return fromSimple(siArchlinux)
|
||||
if (n.includes('suse')) return fromSimple(siOpensuse)
|
||||
if (n.includes('nixos')) return fromSimple(siNixos)
|
||||
if (n.includes('freebsd')) return fromSimple(siFreebsd)
|
||||
if (os === 'linux') return fromSimple(siLinux, false)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 系统的小图标(Debian、Ubuntu、Windows、macOS……)。认不出的系统不画。 */
|
||||
export function OsIcon({ os, name }: { os: string | null | undefined; name: string | null | undefined }) {
|
||||
const glyph = pickGlyph(os, name)
|
||||
if (!glyph) return null
|
||||
return (
|
||||
<svg className="os-icon" viewBox="0 0 24 24" role="img" aria-label={name ?? os ?? ''} style={glyph.color ? ({ '--os-c': `#${glyph.color}` } as React.CSSProperties) : undefined}>
|
||||
<title>{name ?? os ?? ''}</title>
|
||||
<path d={glyph.path} className={glyph.color ? 'brand' : 'plain'} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
+374
-2
@@ -451,7 +451,8 @@ textarea {
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--app-scrim), transparent 60%);
|
||||
background: color-mix(in srgb, var(--app-scrim), transparent 55%);
|
||||
backdrop-filter: blur(3px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 50;
|
||||
@@ -465,7 +466,11 @@ textarea {
|
||||
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%);
|
||||
border: 1px solid var(--app-outline-soft);
|
||||
/* 近处一圈细的、远处一大片柔的:边缘清楚,又不会糊成一团 */
|
||||
box-shadow:
|
||||
0 1px 2px color-mix(in srgb, var(--app-shadow), transparent 80%),
|
||||
0 12px 40px color-mix(in srgb, var(--app-shadow), transparent 72%);
|
||||
}
|
||||
|
||||
.dialog h2 {
|
||||
@@ -635,3 +640,370 @@ pre.code {
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── 组件上的加载进度条:贴着卡片的上边缘,流动的渐变 ── */
|
||||
.has-loading {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-bar i {
|
||||
display: block;
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--chart-1);
|
||||
animation: loading-bar-slide 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes loading-bar-slide {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(250%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 还没有数据时的占位 */
|
||||
.skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 72px;
|
||||
border-radius: var(--r-card);
|
||||
background: var(--app-card);
|
||||
}
|
||||
|
||||
.skeleton.chart {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.skeleton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 16px 16px auto;
|
||||
height: 12px;
|
||||
width: 40%;
|
||||
border-radius: 6px;
|
||||
background: var(--app-card-inset);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.loading-bar i {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 带标签切换的命令框 ── */
|
||||
.cmd-tabs {
|
||||
border-radius: var(--r-control);
|
||||
background: var(--app-card-inset);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cmd-tablist {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--app-outline);
|
||||
}
|
||||
|
||||
.cmd-tablist button {
|
||||
position: relative;
|
||||
padding: 10px 10px 9px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--app-ink-faint);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cmd-tablist button:hover {
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
.cmd-tablist button.on {
|
||||
color: var(--app-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cmd-tablist button.on::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--app-ink);
|
||||
}
|
||||
|
||||
.cmd-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
}
|
||||
|
||||
.cmd-body code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.cmd-copy {
|
||||
flex: none;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--app-ink-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cmd-copy:hover {
|
||||
background: var(--app-card-hover);
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
/* ── 数据源列表:连接中的高亮,点一行就切换 ── */
|
||||
.src-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.src-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.src-pick {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--app-outline-soft);
|
||||
border-radius: var(--r-control);
|
||||
background: var(--app-card-inset);
|
||||
color: var(--app-ink);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.src-pick:hover {
|
||||
background: var(--app-card-hover);
|
||||
}
|
||||
|
||||
.src-row.on .src-pick,
|
||||
.src-pick.on {
|
||||
border-color: var(--app-accent);
|
||||
background: var(--app-accent-container);
|
||||
color: var(--app-on-accent-container);
|
||||
}
|
||||
|
||||
.src-dot {
|
||||
flex: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.src-row.on .src-dot,
|
||||
.src-pick.on .src-dot {
|
||||
border-color: var(--app-accent);
|
||||
background: var(--app-accent);
|
||||
}
|
||||
|
||||
.src-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.src-text b {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.src-text small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--app-ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── 数据源状态条:放在用户信息上方,一眼看到连的是哪个后端、连得上没有 ── */
|
||||
.src-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--app-outline-soft);
|
||||
border-radius: var(--r-control);
|
||||
background: var(--app-card);
|
||||
color: var(--app-ink);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.src-status:hover {
|
||||
background: var(--app-card-hover);
|
||||
}
|
||||
|
||||
.src-status .st-dot {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--app-idle);
|
||||
}
|
||||
|
||||
.src-status.ok .st-dot {
|
||||
background: var(--app-ok);
|
||||
}
|
||||
|
||||
.src-status.bad .st-dot {
|
||||
background: var(--app-bad);
|
||||
}
|
||||
|
||||
.src-status .st-host {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.src-status .st {
|
||||
flex: none;
|
||||
color: var(--app-ink-muted);
|
||||
}
|
||||
|
||||
.src-status.bad .st {
|
||||
color: var(--app-bad);
|
||||
}
|
||||
|
||||
/* ── 节点的国旗、运营商图标、系统和架构 ── */
|
||||
.node-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.node-tag .flag {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 13.5px;
|
||||
border-radius: 2px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 0 0 1px var(--app-outline-soft);
|
||||
}
|
||||
|
||||
.node-tag .flag.ph {
|
||||
display: inline-block;
|
||||
background: var(--app-card-inset);
|
||||
}
|
||||
|
||||
.node-tag .isp {
|
||||
flex: none;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
/* 三大运营商的标志:留出一点空,比例保持 */
|
||||
.node-tag .isp.logo {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.node-tag .isp rect {
|
||||
fill: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
.node-tag .isp text {
|
||||
fill: var(--isp-ink);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.node-tag .isp-mobile rect {
|
||||
fill: var(--isp-mobile);
|
||||
}
|
||||
|
||||
.node-tag .isp-telecom rect {
|
||||
fill: var(--isp-telecom);
|
||||
}
|
||||
|
||||
.node-tag .isp-unicom rect {
|
||||
fill: var(--isp-unicom);
|
||||
}
|
||||
|
||||
.node-tag .isp-broadnet rect {
|
||||
fill: var(--isp-broadnet);
|
||||
}
|
||||
|
||||
.node-tag .isp-name,
|
||||
.node-tag .plat {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: var(--app-ink-faint);
|
||||
}
|
||||
|
||||
/* 系统的小图标:有品牌色的用品牌色(和文字色混一点,亮暗两个主题里都看得清),没有的跟文字走 */
|
||||
.os-icon {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.os-icon path.brand {
|
||||
fill: color-mix(in srgb, var(--os-c) 72%, var(--app-ink));
|
||||
}
|
||||
|
||||
.os-icon path.plain {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.node-tag .plat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
@@ -3,6 +3,13 @@
|
||||
* 颜色只在这个文件里出现;其他样式一律引用令牌(stylelint 会拦住 hex / rgb / 具名色)。
|
||||
*/
|
||||
:root {
|
||||
/* 运营商小图标的底色(自己画的简化图标,不是官方 Logo) */
|
||||
--isp-mobile: #1c9be0;
|
||||
--isp-telecom: #1459a8;
|
||||
--isp-unicom: #d8261f;
|
||||
--isp-broadnet: #de6a1b;
|
||||
--isp-ink: #ffffff;
|
||||
|
||||
color-scheme: light;
|
||||
|
||||
--app-bg: #f4f2ed;
|
||||
@@ -59,6 +66,10 @@
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
/* 暗色下的阴影和遮罩要用纯黑,不然和深色背景差不多,看不出层次 */
|
||||
--app-shadow: #000000;
|
||||
--app-scrim: #000000;
|
||||
|
||||
--app-bg: #181714;
|
||||
--app-card: #211f1b;
|
||||
--app-card-inset: #2b2924;
|
||||
|
||||
+270
-39
@@ -1,8 +1,9 @@
|
||||
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 { Check, Copy, Moon, Plug, Plus, Sun, X } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Component, createContext, useCallback, useContext, useEffect, useRef, useState, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { ApiError, loginTo } from '../api/client'
|
||||
import { activateSource, getBase, listSources, normalizeBase, pingBackend, removeSource, setSource } from '../api/source'
|
||||
import { useQuery, useTheme } from '../hooks'
|
||||
import { translateCode, translateField, translateMessage } from '../i18n'
|
||||
|
||||
/** 连接式分组(单选):组内小圆角、外角大圆角,选中段变胶囊。 */
|
||||
@@ -64,14 +65,17 @@ export function Dialog({ title, onClose, children, actions }: { title: string; o
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
return (
|
||||
// 渲染到 body 上:对话框是 position: fixed 的,如果放在带 backdrop-filter / transform 的祖先里(比如展示端的顶栏),
|
||||
// 它会以那个祖先为定位基准,被挤到顶栏里、一半在屏幕外
|
||||
return createPortal(
|
||||
<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>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -218,49 +222,276 @@ export function ThemeToggle() {
|
||||
)
|
||||
}
|
||||
|
||||
/** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 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()
|
||||
/**
|
||||
* 组件上的加载进度条:放在卡片(`position: relative`)里,贴着上边缘。
|
||||
* 数据在加载时流动,不加载时不占地方。
|
||||
*/
|
||||
export function LoadingBar({ active }: { active: boolean }) {
|
||||
return active ? (
|
||||
<div className="loading-bar" role="progressbar" aria-label="加载中">
|
||||
<i />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
export interface CommandTab {
|
||||
id: string
|
||||
label: string
|
||||
command: string
|
||||
}
|
||||
|
||||
/** 按浏览器所在的系统挑一个默认的标签(Windows / macOS / 其他当 Linux)。 */
|
||||
export function guessPlatform(): 'windows' | 'macos' | 'linux' {
|
||||
const text = `${navigator.platform} ${navigator.userAgent}`.toLowerCase()
|
||||
return text.includes('win') ? 'windows' : text.includes('mac') ? 'macos' : 'linux'
|
||||
}
|
||||
|
||||
/**
|
||||
* 带标签切换的命令框(curl / npm / bun 那样):上面一排下划线标签,下面是等宽的命令,右侧一个复制图标。
|
||||
* 记住上次选的标签(只是浏览器里的小方便)。
|
||||
*/
|
||||
export function CommandTabs({ tabs, initial, storageKey }: { tabs: CommandTab[]; initial?: string; storageKey?: string }) {
|
||||
const [active, setActive] = useState(() => {
|
||||
let saved: string | null = null
|
||||
try {
|
||||
saved = storageKey ? localStorage.getItem(storageKey) : null
|
||||
} catch {
|
||||
// 浏览器禁用存储时只是不记住
|
||||
}
|
||||
return [saved, initial].find((id) => id && tabs.some((t) => t.id === id)) ?? tabs[0]?.id ?? ''
|
||||
})
|
||||
const [done, setDone] = useState(false)
|
||||
const tab = tabs.find((t) => t.id === active) ?? tabs[0]
|
||||
if (!tab) return null
|
||||
const pick = (id: string) => {
|
||||
setActive(id)
|
||||
try {
|
||||
if (storageKey) localStorage.setItem(storageKey, id)
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="cmd-tabs">
|
||||
<div className="cmd-tablist" role="tablist">
|
||||
{tabs.map((t) => (
|
||||
<button key={t.id} type="button" role="tab" aria-selected={t.id === tab.id} className={t.id === tab.id ? 'on' : ''} onClick={() => pick(t.id)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="cmd-body">
|
||||
<code>{tab.command}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="cmd-copy"
|
||||
aria-label="复制命令"
|
||||
title="复制命令"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(tab.command)
|
||||
setDone(true)
|
||||
window.setTimeout(() => setDone(false), 1500)
|
||||
} catch {
|
||||
// 没有剪贴板权限时用户可以手动选中
|
||||
}
|
||||
}}
|
||||
>
|
||||
{done ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面出错时显示提示,而不是整页白屏:说明出了什么错,可以重试(重新渲染这一块)或刷新整个页面。
|
||||
* 常见原因是前端和后端版本对不上(后端还没升级到新接口)。
|
||||
*/
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state: { error: Error | null } = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error(error, info.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state
|
||||
if (!error) return this.props.children
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="card stack" style={{ maxWidth: 520 }}>
|
||||
<div>
|
||||
<h1>页面出错了</h1>
|
||||
<p className="muted">这一页没能显示出来。如果刚升级过,多半是前端和后端的版本对不上:先把后端升级到对应的版本,再刷新。</p>
|
||||
</div>
|
||||
<pre className="code">{error.message}</pre>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<button type="button" className="btn primary" onClick={() => this.setState({ error: null })}>
|
||||
重试
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => location.reload()}>
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 地址的简短写法:去掉协议,留 host(和端口)。 */
|
||||
const shortHost = (base: string) => base.replace(/^https?:\/\//i, '')
|
||||
|
||||
/**
|
||||
* 数据源:默认连这个页面所在的后端(同源);也可以保存几个别的后端(比如本机跑前端、连线上的),在列表里点一下就切换。
|
||||
* 跨域时不用 Cookie,改用 API 令牌,所以每个后端各存一个令牌(只在浏览器里),后端需要放行这个页面的来源(`Monitor__CorsOrigins`)。
|
||||
*/
|
||||
export function SourceButton({ variant = 'icon' }: { variant?: 'icon' | 'status' }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [adding, setAdding] = useState(listSources().length === 0)
|
||||
const [base, setBase] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [, refresh] = useState(0)
|
||||
const active = getBase()
|
||||
const saved = listSources()
|
||||
const switchTo = (next: string) => {
|
||||
if (next !== active) {
|
||||
activateSource(next)
|
||||
location.reload()
|
||||
}
|
||||
}
|
||||
// 状态条用:每 15 秒问一下当前后端还连不连得上
|
||||
const health = useQuery(() => (variant === 'status' ? pingBackend() : Promise.resolve(undefined)), [variant, active], variant === 'status' ? 15_000 : undefined)
|
||||
const state = health.data === false ? 'bad' : health.data === undefined ? 'wait' : 'ok'
|
||||
const rows = saved.map((x) => ({ base: x.base, label: shortHost(x.base), detail: x.token ? '已登录' : '未登录' }))
|
||||
// 连接中的排在最前面
|
||||
rows.sort((a, b) => Number(b.base === active) - Number(a.base === active))
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={`btn icon${remote ? ' primary' : ''}`} onClick={show} aria-label="数据源" title={remote ? `数据源:${getBase()}` : '数据源:同源'}>
|
||||
<Plug size={18} />
|
||||
</button>
|
||||
{variant === 'status' ? (
|
||||
<button type="button" className={`src-status ${state}`} onClick={() => setOpen(true)} aria-label="数据源" title={`后端:${active || location.origin}(点击切换)`}>
|
||||
<span className="st-dot" />
|
||||
<span className="st-host">{shortHost(active || location.origin)}</span>
|
||||
<span className="st">{state === 'ok' ? '已连接' : state === 'bad' ? '无法连接' : '连接中…'}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className={`btn icon${active ? ' primary' : ''}`} onClick={() => setOpen(true)} aria-label="数据源" title={active ? `后端:${active}` : `后端:${location.origin}`}>
|
||||
<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>
|
||||
</>
|
||||
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||||
关闭
|
||||
</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>
|
||||
{rows.length > 0 ? (
|
||||
<div className="src-list" role="listbox" aria-label="已保存的后端">
|
||||
{rows.map((row) => {
|
||||
const on = row.base === active
|
||||
return (
|
||||
<div key={row.base || 'same-origin'} className={`src-row${on ? ' on' : ''}`}>
|
||||
<button type="button" role="option" aria-selected={on} className="src-pick" onClick={() => switchTo(row.base)} title={on ? '正在连接' : '点击切换到这个后端'}>
|
||||
<span className="src-dot" />
|
||||
<span className="src-text">
|
||||
<b>{row.label}</b>
|
||||
<small>{row.detail}</small>
|
||||
</span>
|
||||
{on ? <span className="chip accent">连接中</span> : null}
|
||||
</button>
|
||||
{row.base ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn icon small"
|
||||
aria-label="忘掉这个后端"
|
||||
title={on ? '断开并忘掉这个后端' : '忘掉这个后端'}
|
||||
onClick={() => {
|
||||
removeSource(row.base)
|
||||
if (on) location.reload()
|
||||
else refresh((n) => n + 1)
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">还没有保存后端。填上后端地址和用户名、密码,就可以连上去;不填则使用 {location.origin} 的 API。</p>
|
||||
)}
|
||||
{adding ? (
|
||||
<div className="stack">
|
||||
<Field label="后端地址" hint={base && normalizeBase(base) !== base.trim() ? `将使用 ${normalizeBase(base)}` : '例如 https://mo.oeo.one'}>
|
||||
<input className="input" name="backend-url" type="url" autoComplete="url" value={base} onChange={(e) => setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus />
|
||||
</Field>
|
||||
<Field label="用户名" hint="站点公开、只想看数据的话可以留空">
|
||||
<input className="input" name="backend-username" autoComplete="off" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="密码">
|
||||
<input className="input" name="backend-password" type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</Field>
|
||||
{error ? <div className="notice bad">{error}</div> : null}
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={!normalizeBase(base) || busy}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
// 填了用户名就先登录(拿回会话存起来),登录成功才保存这个后端
|
||||
setSource(base, username ? await loginTo(normalizeBase(base), username, password) : '')
|
||||
location.reload()
|
||||
} catch (err) {
|
||||
setError(describeError(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存并连接
|
||||
</button>
|
||||
{rows.length > 0 ? (
|
||||
<button type="button" className="btn" onClick={() => setAdding(false)}>
|
||||
取消
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn small"
|
||||
onClick={() => {
|
||||
setBase('')
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setError('')
|
||||
setAdding(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
添加后端
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
) : null}
|
||||
|
||||
Reference in new issue
Block a user