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:
1 parent
10634bdd80
commit
14b0de3544
95 files changed
+5524
-528
No files matched your search
+270
-39
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user