chore: sync local updates

This commit is contained in:
2026-05-16 19:03:34 +08:00
parent 9c086c3301
commit 80cf54a201
89 changed files with 10622 additions and 2443 deletions

View File

@@ -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>