chore: sync local updates
This commit is contained in:
@@ -1,247 +1,210 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ServerConfig } from './types';
|
||||
import { loadServers, saveServers, removeServer, exportServersToClipboard, importServersFromClipboard } from './utils/storage';
|
||||
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 {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
verifyAdminToken,
|
||||
ADMIN_TOKEN_STORAGE,
|
||||
} from './api/central';
|
||||
import './App.css';
|
||||
|
||||
const formatServerUrl = (url: string) => {
|
||||
let formatted = url.trim();
|
||||
|
||||
// Fix common typo .op -> .top based on user requirement
|
||||
if (formatted.endsWith('.op')) {
|
||||
formatted = formatted.slice(0, -3) + '.top';
|
||||
}
|
||||
|
||||
if (formatted && !/^https?:\/\//i.test(formatted)) {
|
||||
return `http://${formatted}`;
|
||||
}
|
||||
return formatted;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [servers, setServers] = useState<ServerConfig[]>([]);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const { servers, loading, error, reload } = usePublicServers(30000);
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
|
||||
const [newServerForm, setNewServerForm] = useState({ name: '', url: '' });
|
||||
const [showHeader, setShowHeader] = useState(true);
|
||||
|
||||
const statuses = useServerMonitor(servers, 2000);
|
||||
const [showTokenGate, setShowTokenGate] = useState(false);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||
const [adminOpen, setAdminOpen] = useState(false);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
const logoClickCount = useRef(0);
|
||||
const logoClickReset = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loaded = loadServers();
|
||||
setServers(loaded);
|
||||
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 handleUrlBlur = () => {
|
||||
const formatted = formatServerUrl(newServerForm.url);
|
||||
if (formatted !== newServerForm.url) {
|
||||
setNewServerForm(prev => ({ ...prev, url: formatted }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddServer = () => {
|
||||
if (!newServerForm.name || !newServerForm.url) {
|
||||
alert('请填写服务器名称和地址');
|
||||
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 formattedUrl = formatServerUrl(newServerForm.url);
|
||||
const closeAdmin = () => {
|
||||
setAdminOpen(false);
|
||||
};
|
||||
|
||||
const newServer: ServerConfig = {
|
||||
id: Date.now().toString(),
|
||||
name: newServerForm.name,
|
||||
url: formattedUrl,
|
||||
enabled: true,
|
||||
const invalidateAdminSession = () => {
|
||||
sessionStorage.removeItem(ADMIN_TOKEN_STORAGE);
|
||||
setAdminOpen(false);
|
||||
alert('管理凭证已失效,请重新验证');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (logoClickReset.current) clearTimeout(logoClickReset.current);
|
||||
};
|
||||
|
||||
const updated = [...servers, newServer];
|
||||
setServers(updated);
|
||||
saveServers(updated);
|
||||
setNewServerForm({ name: '', url: '' });
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const handleRemoveServer = (serverId: string) => {
|
||||
if (confirm('确定要移除这个服务器吗?')) {
|
||||
const updated = servers.filter(s => s.id !== serverId);
|
||||
setServers(updated);
|
||||
removeServer(serverId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowDetail = (serverId: string) => {
|
||||
setSelectedServerId(serverId);
|
||||
};
|
||||
|
||||
const handleExportServers = async () => {
|
||||
try {
|
||||
await exportServersToClipboard();
|
||||
alert('服务器配置已复制到剪贴板!');
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : '导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportServers = async () => {
|
||||
if (!confirm('导入服务器配置将添加到现有服务器列表中,是否继续?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const importedServers = await importServersFromClipboard();
|
||||
const updated = [...servers, ...importedServers];
|
||||
setServers(updated);
|
||||
saveServers(updated);
|
||||
alert(`成功导入 ${importedServers.length} 个服务器配置!`);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : '导入失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setServers((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
const newOrder = arrayMove(items, oldIndex, newIndex);
|
||||
saveServers(newOrder);
|
||||
return newOrder;
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedStatus = selectedServerId ? statuses[selectedServerId] : null;
|
||||
const selectedServer = servers.find(s => s.id === selectedServerId);
|
||||
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>
|
||||
<img className="app-logo" src="/logo.svg" alt="萌芽监控面板" />
|
||||
<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
|
||||
className="btn-icon"
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setShowHeader(false)}
|
||||
title="隐藏导航栏"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleExportServers}
|
||||
title="导出服务器配置到剪贴板"
|
||||
>
|
||||
📤
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleImportServers}
|
||||
title="从剪贴板导入服务器配置"
|
||||
>
|
||||
📥
|
||||
</button>
|
||||
<button className="btn-add" onClick={() => setShowAddForm(!showAddForm)} title={showAddForm ? '取消' : '添加服务器'}>
|
||||
{showAddForm ? '×' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<div className="add-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="服务器名称"
|
||||
value={newServerForm.name}
|
||||
onChange={(e) => setNewServerForm({ ...newServerForm, name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="服务器地址 (例如: http://192.168.1.100:9292)"
|
||||
value={newServerForm.url}
|
||||
onChange={(e) => setNewServerForm({ ...newServerForm, url: e.target.value })}
|
||||
onBlur={handleUrlBlur}
|
||||
/>
|
||||
<button className="btn-submit" onClick={handleAddServer}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<main className="server-grid">
|
||||
{servers.length === 0 ? (
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<p>还没有添加任何服务器</p>
|
||||
<p className="hint">点击右上角的"添加服务器"按钮开始使用</p>
|
||||
<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>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={servers.map((s) => s.id)} strategy={rectSortingStrategy}>
|
||||
{servers.map((server) => {
|
||||
const status = statuses[server.id];
|
||||
// Calculate storage usage (max of all mounts)
|
||||
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} // Pass max storage usage
|
||||
uptime={status?.metrics?.uptimeSeconds}
|
||||
onDetail={handleShowDetail}
|
||||
onRemove={handleRemoveServer}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user