Files
mengyamonitor/mengyamonitor-frontend/src/App.tsx
2026-05-16 19:03:34 +08:00

223 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;