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

+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}