Files
MoShiro/web/admin/src/App.tsx
T
justme a9538eae4a 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.
2026-10-06 16:59:17 +08:00

179 lines
6.6 KiB
TypeScript

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 { 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'
import { Agents } from './pages/Agents'
import { Probes } from './pages/Probes'
import { System } from './pages/System'
import { Tasks } from './pages/Tasks'
import { Tests } from './pages/Tests'
/** 登录页:左边选后端(这个页面自己所在的,或保存过的,或添加一个),右边都是用户名 + 密码。 */
function Login() {
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 {
await login(username, password, adding ? url : undefined)
} catch (err) {
setError(describeError(err))
} finally {
setBusy(false)
}
}
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 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" 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>
)
}
const NAV = [
{ section: '运维', items: [{ to: '/agents', label: 'Agent', icon: Server }, { to: '/probes', label: 'Probe', icon: Radar }] },
{ section: '测试', items: [{ to: '/tasks', label: '任务', icon: ListChecks }, { to: '/tests', label: '按需测试', icon: FlaskConical }] },
{ section: '系统', items: [{ to: '/system', label: '系统', icon: Settings }] },
]
export function App() {
const { me, ready, logout } = useAuth()
if (!ready) {
return (
<div className="login">
<Spinner />
</div>
)
}
if (!me) return <Login />
return (
<div className="shell">
<aside className="side">
<a className="brand" href="/">
MyMonitor
</a>
{NAV.map((group) => (
<div key={group.section} style={{ display: 'contents' }}>
<div className="section">{group.section}</div>
{group.items.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={({ isActive }) => `nav ${isActive ? 'active' : ''}`}>
<Icon size={20} />
{label}
</NavLink>
))}
</div>
))}
<div className="spacer" />
<SourceButton variant="status" />
<div className="me">
<span className="avatar">{(me.username ?? 'A').slice(0, 1).toUpperCase()}</span>
<span className="grow">
<div style={{ fontWeight: 600 }}>{me.username ?? 'API 令牌'}</div>
<div className="faint" style={{ fontSize: 12 }}>
{me.scope}
</div>
</span>
<ThemeToggle />
<button type="button" className="btn icon" onClick={() => void logout()} aria-label="退出登录" title="退出登录">
<LogOut size={18} />
</button>
</div>
</aside>
<div className="main">
<Routes>
<Route path="/" element={<Navigate to="/agents" replace />} />
<Route path="/agents" element={<Agents />} />
<Route path="/agents/:id" element={<AgentDetail />} />
<Route path="/probes" element={<Probes />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/tests" element={<Tests />} />
<Route path="/system" element={<System />} />
<Route path="*" element={<Navigate to="/agents" replace />} />
</Routes>
</div>
</div>
)
}