diff --git a/web/README.md b/web/README.md index a111d32..298fcfa 100644 --- a/web/README.md +++ b/web/README.md @@ -19,7 +19,7 @@ npm run gen:api # 后端接口变了之后,重新生成 shared/ 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 的版本和来源地址(这些只在管理端)。 diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index a3244f2..c930c93 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -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 (
-
-
-

MyMonitor

-

管理端

-
- {remote ? ( - - setTokenText(e.target.value)} autoComplete="off" autoFocus required /> + + +
+

{title}

+ {adding ? ( + + setUrl(e.target.value)} placeholder="例如 https://你的域名" autoComplete="off" autoFocus required /> + + ) : null} + + setUsername(e.target.value)} autoComplete="username" autoFocus={!adding} required /> - ) : ( - <> - - setUsername(e.target.value)} autoComplete="username" autoFocus required /> - - - setPassword(e.target.value)} autoComplete="current-password" required /> - - - )} - {error ?
{error}
: null} - + + setPassword(e.target.value)} autoComplete="current-password" required /> + + {error ?
{error}
: null} + +
-
- -
) } @@ -102,6 +146,7 @@ export function App() { ))}
+
{(me.username ?? 'A').slice(0, 1).toUpperCase()} @@ -110,7 +155,6 @@ export function App() { {me.scope}
-
diff --git a/web/display/vite.config.ts b/web/display/vite.config.ts index 8db1b23..d4a7211 100644 --- a/web/display/vite.config.ts +++ b/web/display/vite.config.ts @@ -6,7 +6,7 @@ import { defineConfig, loadEnv } from 'vite' // 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: '/', plugins: [react()], diff --git a/web/shared/api/client.ts b/web/shared/api/client.ts index a32692a..78035d7 100644 --- a/web/shared/api/client.ts +++ b/web/shared/api/client.ts @@ -57,6 +57,28 @@ async function request(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 { + 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: (path: string, query?: Query, init?: RequestInit) => request('GET', withQuery(path, query), undefined, init), post: (path: string, body?: unknown) => request('POST', path, body ?? {}), diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 2c57ac6..d2ea42d 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -1169,6 +1169,7 @@ export interface components { LoginRequest: { username: string; password: string; + return_session: null | boolean; }; LoginResponse: { user_id: string; @@ -1176,6 +1177,7 @@ export interface components { csrf_token: string; /** Format: date-time */ expires_at: unknown; + session_token: null | string; }; MeDto: { kind: string; diff --git a/web/shared/api/source.ts b/web/shared/api/source.ts index 15c33ea..4dc6a13 100644 --- a/web/shared/api/source.ts +++ b/web/shared/api/source.ts @@ -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 { return isRemote() && token ? { Authorization: `Bearer ${token}` } : {} } +/** + * 当前后端连不连得上:只看有没有响应(跨域时拿不到内容也没关系,所以用 no-cors);网络不通、域名解析失败、连接被拒才算失败。 + */ +export async function pingBackend(): Promise { + 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 diff --git a/web/shared/theme/base.css b/web/shared/theme/base.css index d355d66..7cda66d 100644 --- a/web/shared/theme/base.css +++ b/web/shared/theme/base.css @@ -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 { @@ -780,3 +785,133 @@ pre.code { 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); +} diff --git a/web/shared/theme/tokens.css b/web/shared/theme/tokens.css index 5a4b190..5d1f978 100644 --- a/web/shared/theme/tokens.css +++ b/web/shared/theme/tokens.css @@ -59,6 +59,10 @@ :root[data-theme="dark"] { color-scheme: dark; + /* 暗色下的阴影和遮罩要用纯黑,不然和深色背景差不多,看不出层次 */ + --app-shadow: #000000; + --app-scrim: #000000; + --app-bg: #181714; --app-card: #211f1b; --app-card-inset: #2b2924; diff --git a/web/shared/ui/index.tsx b/web/shared/ui/index.tsx index 8c0f90c..a25484c 100644 --- a/web/shared/ui/index.tsx +++ b/web/shared/ui/index.tsx @@ -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 { ApiError } from '../api/client' -import { getBase, getToken, normalizeBase, setSource } from '../api/source' -import { useTheme } from '../hooks' +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(
e.target === e.currentTarget && onClose()}>

{title}

{children} {actions ?
{actions}
: null}
-
+ , + document.body, ) } @@ -340,49 +344,154 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { error: E } } -/** 数据源设置:默认连同源的后端;填了地址就连别的后端(跨域用 API 令牌,后端要放行这个前端的来源)。 */ -export function SourceButton({ tokenLabel = '只读令牌(站点私有时才需要)' }: { tokenLabel?: string }) { +/** 地址的简短写法:去掉协议,留 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 [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() + 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 ( <> - + {variant === 'status' ? ( + + ) : ( + + )} {open ? ( setOpen(false)} actions={ - <> - - - + } >
-

默认连这个页面所在的后端。填了地址就连另一个后端(比如本机跑前端、连线上的);此时不用 Cookie,改用 API 令牌,后端需要放行这个页面的来源(Monitor__CorsOrigins)。

- - setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus /> - - - setToken(e.target.value)} autoComplete="off" /> - + {rows.length > 0 ? ( +
+ {rows.map((row) => { + const on = row.base === active + return ( +
+ + {row.base ? ( + + ) : null} +
+ ) + })} +
+ ) : ( +

还没有保存后端。填上后端地址和用户名、密码,就可以连上去;不填则使用 {location.origin} 的 API。

+ )} + {adding ? ( +
+ + setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus /> + + + setUsername(e.target.value)} /> + + + setPassword(e.target.value)} /> + + {error ?
{error}
: null} +
+ + {rows.length > 0 ? ( + + ) : null} +
+
+ ) : ( +
+ +
+ )}
) : null}