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:
justme committed 2026-10-06 21:24:25 +08:00
1 parent 10634bdd80
commit 14b0de3544
95 files changed
+5524 -528

No files matched your search

+7 -4
View File
@@ -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 才能工作)。
+1 -1
View File
@@ -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
View File
@@ -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} />
+63
View File
@@ -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
View File
@@ -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 {
+8 -6
View File
@@ -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>,
)
+45 -12
View File
@@ -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}
</>
)
}
+3
View File
@@ -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">
+76 -3
View File
@@ -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>
</>
)
+6 -2
View File
@@ -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: '' },
+1
View File
@@ -0,0 +1 @@
export function buildDefines(dev: boolean): Record<string, string>
+25
View File
@@ -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
View File
@@ -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}` : 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)
+1 -1
View File
@@ -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>
+6 -4
View File
@@ -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>
</>
)
}
+29
View File
@@ -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>
)
}
+143
View File
@@ -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>
)
}
+258
View File
@@ -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
View File
@@ -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;
}
+6 -4
View File
@@ -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>,
)
+3 -3
View File
@@ -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
View File
@@ -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">
+81 -28
View File
@@ -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>
)
+182
View File
@@ -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
View File
@@ -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>
)
}
+6 -1
View File
@@ -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: '' },
},
+30 -1
View File
@@ -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
View File
@@ -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"
}
}
+22
View File
@@ -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
View File
@@ -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;
+64 -4
View File
@@ -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
+5
View File
@@ -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']>
+67
View File
@@ -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

+142
View File
@@ -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

+78
View File
@@ -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

+21
View File
@@ -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())}`
}
+47 -13
View File
@@ -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
View File
@@ -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'
+1
View File
@@ -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 秒精度的指标保留多少小时',
+104
View File
@@ -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>
)
}
+69
View File
@@ -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
View File
@@ -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;
}
+11
View File
@@ -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
View File
@@ -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}