223 lines
6.9 KiB
TypeScript
223 lines
6.9 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { useServerMonitor } from './hooks/useServerMonitor';
|
||
import { usePublicServers } from './hooks/usePublicServers';
|
||
import { ServerCard } from './components/ServerCard/ServerCard';
|
||
import { ServerDetail } from './components/ServerDetail/ServerDetail';
|
||
import { AdminPanel } from './components/AdminPanel/AdminPanel';
|
||
import { RandomBackdrop } from './components/RandomBackdrop/RandomBackdrop';
|
||
import {
|
||
verifyAdminToken,
|
||
ADMIN_TOKEN_STORAGE,
|
||
} from './api/central';
|
||
import './App.css';
|
||
|
||
function App() {
|
||
const { servers, loading, error, reload } = usePublicServers(30000);
|
||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
|
||
const [showHeader, setShowHeader] = useState(true);
|
||
|
||
const [showTokenGate, setShowTokenGate] = useState(false);
|
||
const [tokenInput, setTokenInput] = useState('');
|
||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||
const [adminOpen, setAdminOpen] = useState(false);
|
||
|
||
const logoClickCount = useRef(0);
|
||
const logoClickReset = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const statuses = useServerMonitor();
|
||
|
||
const handleLogoClick = useCallback(() => {
|
||
if (logoClickReset.current) {
|
||
clearTimeout(logoClickReset.current);
|
||
}
|
||
logoClickCount.current += 1;
|
||
if (logoClickCount.current >= 5) {
|
||
logoClickCount.current = 0;
|
||
const saved = sessionStorage.getItem(ADMIN_TOKEN_STORAGE);
|
||
if (saved) {
|
||
setAdminOpen(true);
|
||
} else {
|
||
setShowTokenGate(true);
|
||
setTokenInput('');
|
||
setTokenError(null);
|
||
}
|
||
return;
|
||
}
|
||
logoClickReset.current = setTimeout(() => {
|
||
logoClickCount.current = 0;
|
||
}, 2000);
|
||
}, []);
|
||
|
||
const submitToken = async () => {
|
||
const ok = await verifyAdminToken(tokenInput.trim());
|
||
if (!ok) {
|
||
setTokenError('口令错误');
|
||
return;
|
||
}
|
||
sessionStorage.setItem(ADMIN_TOKEN_STORAGE, tokenInput.trim());
|
||
setShowTokenGate(false);
|
||
setTokenError(null);
|
||
setAdminOpen(true);
|
||
};
|
||
|
||
const closeAdmin = () => {
|
||
setAdminOpen(false);
|
||
};
|
||
|
||
const invalidateAdminSession = () => {
|
||
sessionStorage.removeItem(ADMIN_TOKEN_STORAGE);
|
||
setAdminOpen(false);
|
||
alert('管理凭证已失效,请重新验证');
|
||
};
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (logoClickReset.current) clearTimeout(logoClickReset.current);
|
||
};
|
||
}, []);
|
||
|
||
const selectedStatus = selectedServerId ? statuses[selectedServerId] : null;
|
||
const selectedServer = servers.find((s) => s.id === selectedServerId);
|
||
|
||
const adminToken = sessionStorage.getItem(ADMIN_TOKEN_STORAGE) || '';
|
||
|
||
return (
|
||
<div className="app">
|
||
<RandomBackdrop />
|
||
{showTokenGate && (
|
||
<div className="token-gate-overlay" role="dialog" aria-modal="true">
|
||
<div className="token-gate-dialog">
|
||
<h3>管理验证</h3>
|
||
<p className="token-gate-hint">请输入管理口令(内网默认与中心服务 ADMIN_TOKEN 一致)。</p>
|
||
<input
|
||
type="password"
|
||
className="token-gate-input"
|
||
placeholder="Token"
|
||
value={tokenInput}
|
||
onChange={(e) => {
|
||
setTokenInput(e.target.value);
|
||
setTokenError(null);
|
||
}}
|
||
autoFocus
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') void submitToken();
|
||
}}
|
||
/>
|
||
{tokenError && <p className="token-gate-error">{tokenError}</p>}
|
||
<div className="token-gate-actions">
|
||
<button type="button" className="btn-submit" onClick={() => void submitToken()}>
|
||
进入后台
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn-cancel"
|
||
onClick={() => {
|
||
setShowTokenGate(false);
|
||
setTokenError(null);
|
||
}}
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{adminOpen && adminToken && (
|
||
<AdminPanel
|
||
token={adminToken}
|
||
onClose={closeAdmin}
|
||
onSessionInvalid={invalidateAdminSession}
|
||
onSaved={() => void reload()}
|
||
/>
|
||
)}
|
||
|
||
{!showHeader && (
|
||
<button
|
||
className="btn-show-header"
|
||
onClick={() => setShowHeader(true)}
|
||
title="显示导航栏"
|
||
type="button"
|
||
>
|
||
👁️
|
||
</button>
|
||
)}
|
||
|
||
{showHeader && (
|
||
<header className="app-header">
|
||
<h1>
|
||
<button
|
||
type="button"
|
||
className="logo-hit"
|
||
onClick={handleLogoClick}
|
||
title="萌芽监控"
|
||
aria-label="萌芽监控"
|
||
>
|
||
<img className="app-logo" src="/logo.png" alt="" />
|
||
</button>
|
||
萌芽监控面板
|
||
</h1>
|
||
<div className="header-actions">
|
||
<button
|
||
type="button"
|
||
className="btn-icon"
|
||
onClick={() => setShowHeader(false)}
|
||
title="隐藏导航栏"
|
||
>
|
||
👁️
|
||
</button>
|
||
</div>
|
||
</header>
|
||
)}
|
||
|
||
<main className="server-grid">
|
||
{loading ? (
|
||
<div className="empty-state">
|
||
<p>正在从中心服务加载节点列表…</p>
|
||
</div>
|
||
) : error ? (
|
||
<div className="empty-state">
|
||
<p>无法加载监控列表</p>
|
||
<p className="hint">{error}</p>
|
||
<p className="hint">请确认 mengyamonitor-backend-server 已启动,且 VITE_CENTRAL_API_URL 或开发代理配置正确。</p>
|
||
</div>
|
||
) : servers.length === 0 ? (
|
||
<div className="empty-state">
|
||
<p>暂无已启用的监控节点</p>
|
||
<p className="hint">请联系管理员在后台添加采集端地址。</p>
|
||
</div>
|
||
) : (
|
||
servers.map((server) => {
|
||
const status = statuses[server.id];
|
||
const storageUsage =
|
||
status?.metrics?.storage?.reduce((max, s) => Math.max(max, s.usedPercent), 0) || 0;
|
||
return (
|
||
<ServerCard
|
||
key={server.id}
|
||
server={server}
|
||
online={status?.online || false}
|
||
metrics={status?.metrics}
|
||
cpuUsage={status?.metrics?.cpu.usagePercent || 0}
|
||
memoryUsage={status?.metrics?.memory.usedPercent || 0}
|
||
storageUsage={storageUsage}
|
||
uptime={status?.metrics?.uptimeSeconds}
|
||
onDetail={() => setSelectedServerId(server.id)}
|
||
/>
|
||
);
|
||
})
|
||
)}
|
||
</main>
|
||
|
||
{selectedStatus?.metrics && selectedServer && (
|
||
<ServerDetail
|
||
metrics={selectedStatus.metrics}
|
||
serverName={selectedServer.name}
|
||
onClose={() => setSelectedServerId(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|