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:
justme committed 2026-10-06 16:59:17 +08:00
1 parent 91c63d0cf2
commit a9538eae4a
13 files changed
+522 -100

No files matched your search

+77 -33
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>MyMonitor</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>
)
}
@@ -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} />
+42
View File
@@ -213,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 {