260 lines
7.9 KiB
TypeScript
260 lines
7.9 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import type { ServerConfig } from './types';
|
||
import { loadServers, saveServers, removeServer, exportServersToClipboard, importServersFromClipboard } from './utils/storage';
|
||
import { useServerMonitor } from './hooks/useServerMonitor';
|
||
import { ServerCard } from './components/ServerCard/ServerCard';
|
||
import { ServerDetail } from './components/ServerDetail/ServerDetail';
|
||
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';
|
||
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 [selectedServerId, setSelectedServerId] = useState<string | null>(null);
|
||
const [newServerForm, setNewServerForm] = useState({ name: '', url: '' });
|
||
const [showHeader, setShowHeader] = useState(true);
|
||
|
||
const statuses = useServerMonitor(servers, 2000);
|
||
|
||
const sensors = useSensors(
|
||
useSensor(PointerSensor),
|
||
useSensor(KeyboardSensor, {
|
||
coordinateGetter: sortableKeyboardCoordinates,
|
||
})
|
||
);
|
||
|
||
useEffect(() => {
|
||
const loaded = loadServers();
|
||
setServers(loaded);
|
||
}, []);
|
||
|
||
const handleUrlBlur = () => {
|
||
const formatted = formatServerUrl(newServerForm.url);
|
||
if (formatted !== newServerForm.url) {
|
||
setNewServerForm(prev => ({ ...prev, url: formatted }));
|
||
}
|
||
};
|
||
|
||
const handleAddServer = () => {
|
||
if (!newServerForm.name || !newServerForm.url) {
|
||
alert('请填写服务器名称和地址');
|
||
return;
|
||
}
|
||
|
||
const formattedUrl = formatServerUrl(newServerForm.url);
|
||
|
||
const newServer: ServerConfig = {
|
||
id: Date.now().toString(),
|
||
name: newServerForm.name,
|
||
url: formattedUrl,
|
||
enabled: true,
|
||
};
|
||
|
||
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);
|
||
|
||
return (
|
||
<div className="app">
|
||
{!showHeader && (
|
||
<button
|
||
className="btn-show-header"
|
||
onClick={() => setShowHeader(true)}
|
||
title="显示导航栏"
|
||
>
|
||
👁️
|
||
</button>
|
||
)}
|
||
|
||
{showHeader && (
|
||
<header className="app-header">
|
||
<h1>
|
||
<img className="app-logo" src="/logo.svg" alt="萌芽监控面板" />
|
||
萌芽监控面板
|
||
</h1>
|
||
<div className="header-actions">
|
||
<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>
|
||
)}
|
||
|
||
<main className="server-grid">
|
||
{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>
|
||
)}
|
||
</main>
|
||
|
||
{selectedStatus?.metrics && selectedServer && (
|
||
<ServerDetail
|
||
metrics={selectedStatus.metrics}
|
||
serverName={selectedServer.name}
|
||
onClose={() => setSelectedServerId(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|