feat(web): add backend switcher and connection status
Split the login page into backend list and credentials, show connection status in the sidebar and top bar, and render dialogs in a portal.
This commit is contained in:
1 parent
91c63d0cf2
commit
a9538eae4a
13 files changed
+522
-100
No files matched your search
+1
-1
@@ -19,7 +19,7 @@ npm run gen:api # 后端接口变了之后,重新生成 shared/
|
|||||||
npm run lint:css # 颜色只能写在 shared/theme/tokens.css,其他样式一律引用令牌
|
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 的版本和来源地址(这些只在管理端)。
|
- 展示端能拿到的主机数据只有汇总的数字(CPU、内存、磁盘占用,网卡累计流量与瞬时网速,运行时长),**没有 CPU 型号、主机名、网卡名,也没有任何 IP**——后端的公开接口不返回它们,Agent 也根本不采集。展示端不显示 Probe 的版本和来源地址(这些只在管理端)。
|
||||||
|
|||||||
+77
-33
@@ -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 { useState, type FormEvent } from 'react'
|
||||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
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 { describeError, Field, SourceButton, Spinner, ThemeToggle } from '@shared/ui'
|
||||||
import { useAuth } from './auth'
|
import { useAuth } from './auth'
|
||||||
import { AgentDetail } from './pages/AgentDetail'
|
import { AgentDetail } from './pages/AgentDetail'
|
||||||
@@ -11,22 +11,34 @@ import { System } from './pages/System'
|
|||||||
import { Tasks } from './pages/Tasks'
|
import { Tasks } from './pages/Tasks'
|
||||||
import { Tests } from './pages/Tests'
|
import { Tests } from './pages/Tests'
|
||||||
|
|
||||||
|
/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边都是用户名 + 密码。 */
|
||||||
function Login() {
|
function Login() {
|
||||||
const { login, loginWithToken } = useAuth()
|
const { login } = useAuth()
|
||||||
const remote = isRemote()
|
const active = getBase()
|
||||||
const [token, setTokenText] = useState('')
|
const saved = listSources()
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
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) => {
|
const submit = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
if (remote) await loginWithToken(token)
|
await login(username, password, adding ? url : undefined)
|
||||||
else await login(username, password)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(describeError(err))
|
setError(describeError(err))
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<div className="login">
|
<div className="login">
|
||||||
<form className="card stack" onSubmit={submit}>
|
<form className="card login-split" onSubmit={submit}>
|
||||||
<div>
|
<aside className="login-side">
|
||||||
<h1>MyMonitor</h1>
|
<div>
|
||||||
<p className="muted">管理端</p>
|
<h1>MyMonitor</h1>
|
||||||
</div>
|
<p className="muted">管理端</p>
|
||||||
{remote ? (
|
</div>
|
||||||
<Field label="管理令牌" hint="在管理端「系统 → API 令牌」里创建,范围选 admin">
|
<div className="src-list" role="listbox" aria-label="后端">
|
||||||
<input className="input" type="password" value={token} onChange={(e) => setTokenText(e.target.value)} autoComplete="off" autoFocus required />
|
{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>
|
||||||
) : (
|
<Field label="密码">
|
||||||
<>
|
<input className="input" name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
|
||||||
<Field label="用户名">
|
</Field>
|
||||||
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoFocus required />
|
{error ? <div className="notice bad">{error}</div> : null}
|
||||||
</Field>
|
<button className="btn primary" type="submit" disabled={busy}>
|
||||||
<Field label="密码">
|
{adding ? '保存并登录' : '登录'}
|
||||||
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required />
|
</button>
|
||||||
</Field>
|
</section>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{error ? <div className="notice bad">{error}</div> : null}
|
|
||||||
<button className="btn primary" type="submit" disabled={busy}>
|
|
||||||
登录
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
<div style={{ position: 'fixed', right: 16, bottom: 16 }}>
|
|
||||||
<SourceButton tokenLabel="管理令牌" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -102,6 +146,7 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
|
<SourceButton variant="status" />
|
||||||
<div className="me">
|
<div className="me">
|
||||||
<span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span>
|
<span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span>
|
||||||
<span className="grow">
|
<span className="grow">
|
||||||
@@ -110,7 +155,6 @@ export function App() {
|
|||||||
{me.scope}
|
{me.scope}
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
<SourceButton tokenLabel="管理令牌" />
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button type="button" className="btn icon" onClick={() => void logout()} aria-label="退出登录" title="退出登录">
|
<button type="button" className="btn icon" onClick={() => void logout()} aria-label="退出登录" title="退出登录">
|
||||||
<LogOut size={18} />
|
<LogOut size={18} />
|
||||||
|
|||||||
@@ -213,3 +213,45 @@
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 登录页:左边选后端,右边输入凭据 */
|
||||||
|
.login .card.login-split {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
background: var(--app-card-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-main {
|
||||||
|
padding: 32px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-main h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.login .card.login-split {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-side {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-main {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-21
@@ -1,14 +1,13 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
import { api, ApiError, setCsrf } from '@shared/api/client'
|
import { api, ApiError, loginTo, setCsrf } from '@shared/api/client'
|
||||||
import { isRemote, setToken } from '@shared/api/source'
|
import { getBase, normalizeBase, setSource, setToken } from '@shared/api/source'
|
||||||
import type { LoginResponse, MeDto } from '@shared/api/types'
|
import type { LoginResponse, MeDto } from '@shared/api/types'
|
||||||
|
|
||||||
interface Auth {
|
interface Auth {
|
||||||
me: MeDto | null
|
me: MeDto | null
|
||||||
ready: boolean
|
ready: boolean
|
||||||
login: (username: string, password: string) => Promise<void>
|
/** 用户名密码登录。`base` 是新添加的后端地址(登录成功才保存);不给就登录当前连着的后端。 */
|
||||||
/** 连别的后端时用 API 令牌登录(不用 Cookie)。 */
|
login: (username: string, password: string, base?: string) => Promise<void>
|
||||||
loginWithToken: (token: string) => Promise<void>
|
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,35 +30,40 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
.finally(() => setReady(true))
|
.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 })
|
const r = await api.post<LoginResponse>('/api/admin/auth/login', { username, password })
|
||||||
setCsrf(r.csrf_token)
|
setCsrf(r.csrf_token)
|
||||||
setMe({ kind: 'user', scope: 'admin', user_id: r.user_id, username: r.username, csrf_token: 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 () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (isRemote()) setToken('')
|
await api.post('/api/admin/auth/logout')
|
||||||
else await api.post('/api/admin/auth/logout')
|
} catch {
|
||||||
|
// 会话已经失效也没关系,下面照样清掉本地的
|
||||||
} finally {
|
} finally {
|
||||||
|
setToken('')
|
||||||
setCsrf(null)
|
setCsrf(null)
|
||||||
setMe(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 {
|
export function useAuth(): Auth {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { defineConfig, loadEnv } from 'vite'
|
|||||||
// BACKEND_URL=https://mo.oeo.one npm run dev:display
|
// BACKEND_URL=https://mo.oeo.one npm run dev:display
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '')
|
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 {
|
return {
|
||||||
base: '/admin/',
|
base: '/admin/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function App() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
<a href="/admin/">管理</a>
|
<a href="/admin/">管理</a>
|
||||||
</nav>
|
</nav>
|
||||||
<SourceButton />
|
<SourceButton variant="status" />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { defineConfig, loadEnv } from 'vite'
|
|||||||
// BACKEND_URL=https://mo.oeo.one npm run dev:display
|
// BACKEND_URL=https://mo.oeo.one npm run dev:display
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, fileURLToPath(new URL('..', import.meta.url)), '')
|
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 {
|
return {
|
||||||
base: '/',
|
base: '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
|||||||
@@ -57,6 +57,28 @@ async function request<T>(method: string, path: string, body?: unknown, init?: R
|
|||||||
return data as T
|
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 = {
|
export const api = {
|
||||||
get: <T>(path: string, query?: Query, init?: RequestInit) => request<T>('GET', withQuery(path, query), undefined, init),
|
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 ?? {}),
|
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
|
||||||
|
|||||||
@@ -1169,6 +1169,7 @@ export interface components {
|
|||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
return_session: null | boolean;
|
||||||
};
|
};
|
||||||
LoginResponse: {
|
LoginResponse: {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -1176,6 +1177,7 @@ export interface components {
|
|||||||
csrf_token: string;
|
csrf_token: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
expires_at: unknown;
|
expires_at: unknown;
|
||||||
|
session_token: null | string;
|
||||||
};
|
};
|
||||||
MeDto: {
|
MeDto: {
|
||||||
kind: string;
|
kind: string;
|
||||||
|
|||||||
@@ -41,17 +41,65 @@ export function getBase(): string {
|
|||||||
return normalizeBase(read(BASE_KEY) || (import.meta.env.VITE_API_BASE as string | undefined) || '')
|
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 {
|
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) {
|
export function setSource(base: string, token: string) {
|
||||||
write(BASE_KEY, normalizeBase(base))
|
const next = normalizeBase(base)
|
||||||
write(`monitor.api_token.${app}`, token.trim())
|
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) {
|
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() !== ''
|
export const isRemote = () => getBase() !== ''
|
||||||
@@ -66,6 +114,18 @@ export function authHeaders(): Record<string, string> {
|
|||||||
return isRemote() && token ? { Authorization: `Bearer ${token}` } : {}
|
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 {
|
export interface LiveSource {
|
||||||
onopen: (() => void) | null
|
onopen: (() => void) | null
|
||||||
onerror: (() => void) | null
|
onerror: (() => void) | null
|
||||||
|
|||||||
+137
-2
@@ -451,7 +451,8 @@ textarea {
|
|||||||
.scrim {
|
.scrim {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
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;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
@@ -465,7 +466,11 @@ textarea {
|
|||||||
width: min(560px, 100%);
|
width: min(560px, 100%);
|
||||||
max-height: calc(100vh - 32px);
|
max-height: calc(100vh - 32px);
|
||||||
overflow: auto;
|
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 {
|
.dialog h2 {
|
||||||
@@ -780,3 +785,133 @@ pre.code {
|
|||||||
background: var(--app-card-hover);
|
background: var(--app-card-hover);
|
||||||
color: var(--app-ink);
|
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);
|
||||||
|
}
|
||||||
@@ -59,6 +59,10 @@
|
|||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
|
/* 暗色下的阴影和遮罩要用纯黑,不然和深色背景差不多,看不出层次 */
|
||||||
|
--app-shadow: #000000;
|
||||||
|
--app-scrim: #000000;
|
||||||
|
|
||||||
--app-bg: #181714;
|
--app-bg: #181714;
|
||||||
--app-card: #211f1b;
|
--app-card: #211f1b;
|
||||||
--app-card-inset: #2b2924;
|
--app-card-inset: #2b2924;
|
||||||
|
|||||||
+145
-36
@@ -1,8 +1,9 @@
|
|||||||
import { Check, Copy, Moon, Plug, Sun } from 'lucide-react'
|
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 { Component, createContext, useCallback, useContext, useEffect, useRef, useState, type ErrorInfo, type ReactNode } from 'react'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError, loginTo } from '../api/client'
|
||||||
import { getBase, getToken, normalizeBase, setSource } from '../api/source'
|
import { activateSource, getBase, listSources, normalizeBase, pingBackend, removeSource, setSource } from '../api/source'
|
||||||
import { useTheme } from '../hooks'
|
import { useQuery, useTheme } from '../hooks'
|
||||||
import { translateCode, translateField, translateMessage } from '../i18n'
|
import { translateCode, translateField, translateMessage } from '../i18n'
|
||||||
|
|
||||||
/** 连接式分组(单选):组内小圆角、外角大圆角,选中段变胶囊。 */
|
/** 连接式分组(单选):组内小圆角、外角大圆角,选中段变胶囊。 */
|
||||||
@@ -64,14 +65,17 @@ export function Dialog({ title, onClose, children, actions }: { title: string; o
|
|||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
return (
|
// 渲染到 body 上:对话框是 position: fixed 的,如果放在带 backdrop-filter / transform 的祖先里(比如展示端的顶栏),
|
||||||
|
// 它会以那个祖先为定位基准,被挤到顶栏里、一半在屏幕外
|
||||||
|
return createPortal(
|
||||||
<div className="scrim" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
<div className="scrim" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
<div className="dialog" role="dialog" aria-modal="true" aria-label={title}>
|
<div className="dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
{actions ? <div className="actions">{actions}</div> : null}
|
{actions ? <div className="actions">{actions}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,49 +344,154 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { error: E
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 API 令牌,后端要放行这个前端的来源)。 */
|
/** 地址的简短写法:去掉协议,留 host(和端口)。 */
|
||||||
export function SourceButton({ tokenLabel = '只读令牌(站点私有时才需要)' }: { tokenLabel?: string }) {
|
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 [open, setOpen] = useState(false)
|
||||||
|
const [adding, setAdding] = useState(listSources().length === 0)
|
||||||
const [base, setBase] = useState('')
|
const [base, setBase] = useState('')
|
||||||
const [token, setToken] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const remote = getBase() !== ''
|
const [password, setPassword] = useState('')
|
||||||
const show = () => {
|
const [error, setError] = useState('')
|
||||||
setBase(getBase())
|
const [busy, setBusy] = useState(false)
|
||||||
setToken(getToken())
|
const [, refresh] = useState(0)
|
||||||
setOpen(true)
|
const active = getBase()
|
||||||
}
|
const saved = listSources()
|
||||||
const apply = (nextBase: string, nextToken: string) => {
|
const switchTo = (next: string) => {
|
||||||
setSource(nextBase, nextToken)
|
if (next !== active) {
|
||||||
location.reload()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" className={`btn icon${remote ? ' primary' : ''}`} onClick={show} aria-label="数据源" title={remote ? `数据源:${getBase()}` : '数据源:同源'}>
|
{variant === 'status' ? (
|
||||||
<Plug size={18} />
|
<button type="button" className={`src-status ${state}`} onClick={() => setOpen(true)} aria-label="数据源" title={`后端:${active || location.origin}(点击切换)`}>
|
||||||
</button>
|
<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 ? (
|
{open ? (
|
||||||
<Dialog
|
<Dialog
|
||||||
title="数据源"
|
title="数据源"
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||||||
<button type="button" className="btn" onClick={() => apply('', '')}>
|
关闭
|
||||||
恢复同源
|
</button>
|
||||||
</button>
|
|
||||||
<button type="button" className="btn primary" onClick={() => apply(base, token)}>
|
|
||||||
保存并刷新
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<p className="muted">默认连这个页面所在的后端。填了地址就连另一个后端(比如本机跑前端、连线上的);此时不用 Cookie,改用 API 令牌,后端需要放行这个页面的来源(<code>Monitor__CorsOrigins</code>)。</p>
|
{rows.length > 0 ? (
|
||||||
<Field label="后端地址" hint={base && normalizeBase(base) !== base.trim() ? `将使用 ${normalizeBase(base)}` : '例如 https://mo.oeo.one;留空 = 同源'}>
|
<div className="src-list" role="listbox" aria-label="已保存的后端">
|
||||||
<input className="input" value={base} onChange={(e) => setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus />
|
{rows.map((row) => {
|
||||||
</Field>
|
const on = row.base === active
|
||||||
<Field label={tokenLabel}>
|
return (
|
||||||
<input className="input" type="password" value={token} onChange={(e) => setToken(e.target.value)} autoComplete="off" />
|
<div key={row.base || 'same-origin'} className={`src-row${on ? ' on' : ''}`}>
|
||||||
</Field>
|
<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>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in new issue
Block a user