chore: sync local updates
This commit is contained in:
4
mengyamonitor-frontend/.env.production
Normal file
4
mengyamonitor-frontend/.env.production
Normal file
@@ -0,0 +1,4 @@
|
||||
# 公网:用户通过 https 打开前端时,API 须为浏览器可访问的 https 地址(经 Nginx 反代的中心)
|
||||
VITE_CENTRAL_API_URL=https://monitor.api.shumengya.top
|
||||
#
|
||||
# 若静态站仅内网 http 部署,请改为 http://内网中心IP:9393 后再 npm run build(勿与混合内容冲突)
|
||||
@@ -57,8 +57,8 @@ npm run preview
|
||||
## 项目结构
|
||||
```
|
||||
src/
|
||||
├── api/ # API 调用
|
||||
│ └── monitor.ts # 监控 API
|
||||
├── api/ # API 调用(中心 REST、管理接口)
|
||||
│ └── central.ts
|
||||
├── components/ # React 组件
|
||||
│ ├── ServerCard/ # 服务器卡片组件
|
||||
│ └── ServerDetail/ # 服务器详情弹窗
|
||||
@@ -85,11 +85,8 @@ server: {
|
||||
}
|
||||
```
|
||||
|
||||
### 修改轮询间隔
|
||||
编辑 `src/App.tsx` 文件中的 `useServerMonitor` 第二个参数:
|
||||
```typescript
|
||||
const statuses = useServerMonitor(servers, 2000); // 2000ms = 2秒
|
||||
```
|
||||
### 实时指标
|
||||
看板通过 `useServerMonitor` 连接中心 WebSocket(`/api/ws/monitor`),无 HTTP 轮询。
|
||||
|
||||
## 部署
|
||||
|
||||
|
||||
1
mengyamonitor-frontend/dev-dist/registerSW.js
Normal file
1
mengyamonitor-frontend/dev-dist/registerSW.js
Normal file
@@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
103
mengyamonitor-frontend/dev-dist/sw.js
Normal file
103
mengyamonitor-frontend/dev-dist/sw.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-6fc00345'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "registerSW.js",
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.ds91o44ltm"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/],
|
||||
denylist: [/^\/central-api(?:\/|$)/]
|
||||
}));
|
||||
workbox.registerRoute(/^https:\/\/.*\/api\//i, new workbox.NetworkFirst({
|
||||
"cacheName": "api-cache",
|
||||
"networkTimeoutSeconds": 10,
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 300
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
|
||||
}));
|
||||
4705
mengyamonitor-frontend/dev-dist/workbox-6fc00345.js
Normal file
4705
mengyamonitor-frontend/dev-dist/workbox-6fc00345.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,12 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<link rel="apple-touch-icon" href="/logo.svg" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/logo2.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1a1a2e" />
|
||||
<meta name="description" content="服务器监控面板 - 萌芽监控" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="萌芽监控" />
|
||||
|
||||
62
mengyamonitor-frontend/package-lock.json
generated
62
mengyamonitor-frontend/package-lock.json
generated
@@ -8,9 +8,6 @@
|
||||
"name": "mengyamonitor-frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
@@ -1586,59 +1583,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -6606,12 +6550,6 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
|
||||
BIN
mengyamonitor-frontend/public/favicon.ico
Normal file
BIN
mengyamonitor-frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 3.8 MiB |
@@ -1,16 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a1a2e"/>
|
||||
<stop offset="100%" style="stop-color:#16213e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="leaf" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#4ecca3"/>
|
||||
<stop offset="100%" style="stop-color:#2d8f7a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||
<path fill="url(#leaf)" d="M256 96c-20 80-80 160-80 240 0 53 35.8 96 80 96s80-43 80-96c0-80-60-160-80-240z"/>
|
||||
<path fill="url(#leaf)" opacity=".9" d="M256 96c20 80 60 160 60 240 0 53-35.8 96-80 96s-80-43-80-96c0-80 40-160 60-240z"/>
|
||||
<ellipse cx="256" cy="336" rx="48" ry="24" fill="url(#leaf)" opacity=".7"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 867 B |
@@ -1,8 +1,9 @@
|
||||
.app {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
padding: 0.65rem 0.75rem 0.85rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -10,12 +11,14 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 0.65rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: var(--surface-color);
|
||||
border-radius: var(--radius-md);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border-radius: var(--radius-glass);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--glass-border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -27,27 +30,29 @@
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 600;
|
||||
transform: translateY(-3px);
|
||||
gap: 0.55rem;
|
||||
font-weight: 700;
|
||||
transform: translateY(-1px);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
display: inline-block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: var(--surface-color);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 0.45rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -183,18 +188,20 @@
|
||||
|
||||
.server-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
padding: 2rem 1.25rem;
|
||||
background: var(--surface-color);
|
||||
border-radius: var(--radius-lg);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border-radius: var(--radius-glass);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px dashed var(--border-color);
|
||||
border: 1px dashed var(--glass-border);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
@@ -208,6 +215,95 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.logo-hit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.logo-hit:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.token-gate-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
background: rgba(10, 12, 22, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.token-gate-dialog {
|
||||
width: min(400px, 100%);
|
||||
background: var(--surface-color);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-glass);
|
||||
padding: 1.1rem 1.25rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.token-gate-dialog h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.token-gate-hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.token-gate-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.token-gate-error {
|
||||
color: #f87171;
|
||||
font-size: 0.8125rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.token-gate-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: var(--surface-color);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.65rem 1.25rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app {
|
||||
padding: 1rem;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
109
mengyamonitor-frontend/src/api/central.ts
Normal file
109
mengyamonitor-frontend/src/api/central.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/** Central dashboard API base (no trailing slash). Dev default uses Vite proxy. */
|
||||
export const centralApiBase = (): string => {
|
||||
const v = import.meta.env.VITE_CENTRAL_API_URL as string | undefined;
|
||||
return (v && v.trim()) || '/central-api';
|
||||
};
|
||||
|
||||
export async function fetchPublicServers(): Promise<
|
||||
{ id: string; name: string; url: string; enabled: boolean }[]
|
||||
> {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/servers`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`无法加载服务器列表 (${res.status})`);
|
||||
}
|
||||
const body = await res.json();
|
||||
const data = body.data;
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('无效的服务器列表响应');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function verifyAdminToken(token: string): Promise<boolean> {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/admin/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
const adminHeaders = (token: string, json = false): HeadersInit => {
|
||||
const h: Record<string, string> = { 'X-Admin-Token': token };
|
||||
if (json) h['Content-Type'] = 'application/json';
|
||||
return h;
|
||||
};
|
||||
|
||||
export async function adminListServers(token: string) {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/admin/servers`, { headers: adminHeaders(token) });
|
||||
if (res.status === 401) throw new Error('UNAUTHORIZED');
|
||||
if (!res.ok) throw new Error(`加载失败 (${res.status})`);
|
||||
const body = await res.json();
|
||||
return body.data as {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
agentKey?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function adminCreateServer(
|
||||
token: string,
|
||||
payload: { name: string; url?: string; enabled: boolean }
|
||||
) {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/admin/servers`, {
|
||||
method: 'POST',
|
||||
headers: adminHeaders(token, true),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: string }).error || `创建失败 (${res.status})`);
|
||||
}
|
||||
return (await res.json()).data;
|
||||
}
|
||||
|
||||
export async function adminUpdateServer(
|
||||
token: string,
|
||||
id: string,
|
||||
payload: { name: string; url?: string; enabled: boolean; agentKey?: string }
|
||||
) {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/admin/servers/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: adminHeaders(token, true),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: string }).error || `更新失败 (${res.status})`);
|
||||
}
|
||||
return (await res.json()).data;
|
||||
}
|
||||
|
||||
export async function adminDeleteServer(token: string, id: string) {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/admin/servers/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: adminHeaders(token, false),
|
||||
});
|
||||
if (!res.ok) throw new Error(`删除失败 (${res.status})`);
|
||||
}
|
||||
|
||||
export async function adminReorderServers(token: string, ids: string[]) {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/admin/servers/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: adminHeaders(token, true),
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`排序失败 (${res.status})`);
|
||||
}
|
||||
|
||||
export const ADMIN_TOKEN_STORAGE = 'mengya_monitor_admin_token';
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { ServerMetrics } from '../types';
|
||||
|
||||
export const fetchServerMetrics = async (serverUrl: string): Promise<ServerMetrics> => {
|
||||
// 测量客户端到服务器的延迟(使用健康检查端点)
|
||||
const clientToServerLatency = await measureLatency(`${serverUrl}/api/health`);
|
||||
|
||||
// 并行请求各个端点
|
||||
const endpoints = [
|
||||
'/api/metrics/cpu',
|
||||
'/api/metrics/memory',
|
||||
'/api/metrics/storage',
|
||||
'/api/metrics/gpu',
|
||||
'/api/metrics/network',
|
||||
'/api/metrics/system',
|
||||
'/api/metrics/docker',
|
||||
'/api/metrics/latency',
|
||||
];
|
||||
|
||||
const results = await Promise.all(
|
||||
endpoints.map(endpoint =>
|
||||
fetch(`${serverUrl}${endpoint}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${endpoint}`);
|
||||
return res.json();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error fetching ${endpoint}:`, err);
|
||||
return null;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// 合并所有结果
|
||||
const [cpuRes, memRes, storageRes, gpuRes, networkRes, systemRes, dockerRes, latencyRes] = results;
|
||||
|
||||
const system = systemRes?.data || {};
|
||||
const docker = dockerRes?.data || {};
|
||||
const latency = latencyRes?.data || {};
|
||||
|
||||
// 将 docker 数据合并到 system.dockerStats
|
||||
return {
|
||||
hostname: system.hostname || 'Unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
cpu: cpuRes?.data || {},
|
||||
memory: memRes?.data || {},
|
||||
storage: storageRes?.data || [],
|
||||
gpu: gpuRes?.data || [],
|
||||
network: networkRes?.data || [],
|
||||
system: {
|
||||
...system,
|
||||
dockerStats: docker
|
||||
},
|
||||
os: system.os || { kernel: '', distro: '', architecture: '' },
|
||||
uptimeSeconds: system.uptimeSeconds || 0,
|
||||
latency: {
|
||||
clientToServer: clientToServerLatency,
|
||||
external: latency.external || {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// 测量延迟
|
||||
async function measureLatency(url: string): Promise<number> {
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
const endTime = performance.now();
|
||||
|
||||
if (response.ok) {
|
||||
return Math.round(endTime - startTime);
|
||||
}
|
||||
return -1; // 表示失败
|
||||
} catch {
|
||||
return -1; // 表示超时或失败
|
||||
}
|
||||
}
|
||||
|
||||
export const checkServerHealth = async (serverUrl: string): Promise<boolean> => {
|
||||
try {
|
||||
const url = `${serverUrl}/api/health`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
202
mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.css
Normal file
202
mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.css
Normal file
@@ -0,0 +1,202 @@
|
||||
.admin-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 12, 22, 0.72);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
width: min(1100px, 100%);
|
||||
background: var(--surface-color);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-glass);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 1rem 1.15rem 1.25rem;
|
||||
}
|
||||
|
||||
.admin-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.admin-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.75rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.admin-close:hover {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.admin-hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-loading {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-error {
|
||||
color: #f87171;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-table code {
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-table input[type='text'] {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.admin-url-input {
|
||||
max-width: 360px !important;
|
||||
}
|
||||
|
||||
.admin-order-btns {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-order-btns button {
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.admin-order-btns button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-actions .admin-btn + .admin-btn {
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.admin-btn {
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface-color);
|
||||
color: var(--text-main);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-btn:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.admin-btn.danger {
|
||||
border-color: #b91c1c;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.admin-btn.danger:hover {
|
||||
background: rgba(185, 28, 28, 0.2);
|
||||
}
|
||||
|
||||
.admin-server-id-cell,
|
||||
.admin-agent-key-cell {
|
||||
vertical-align: top;
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.admin-agent-key-cell .admin-key {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.admin-server-id {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.admin-btn-ghost {
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.admin-key {
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.admin-muted {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 1.5rem !important;
|
||||
}
|
||||
279
mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.tsx
Normal file
279
mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
adminListServers,
|
||||
adminUpdateServer,
|
||||
adminDeleteServer,
|
||||
adminReorderServers,
|
||||
} from '../../api/central';
|
||||
import './AdminPanel.css';
|
||||
|
||||
type Row = { id: string; name: string; url: string; enabled: boolean; sortOrder: number; agentKey?: string };
|
||||
|
||||
const formatServerUrl = (url: string) => {
|
||||
let formatted = url.trim();
|
||||
if (formatted.endsWith('.op')) {
|
||||
formatted = formatted.slice(0, -3) + '.top';
|
||||
}
|
||||
if (formatted && !/^https?:\/\//i.test(formatted)) {
|
||||
return `http://${formatted}`;
|
||||
}
|
||||
return formatted;
|
||||
};
|
||||
|
||||
export const AdminPanel = ({
|
||||
token,
|
||||
onClose,
|
||||
onSessionInvalid,
|
||||
onSaved,
|
||||
}: {
|
||||
token: string;
|
||||
onClose: () => void;
|
||||
onSessionInvalid: () => void;
|
||||
onSaved?: () => void;
|
||||
}) => {
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState({ name: '', url: '', enabled: true });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setErr(null);
|
||||
try {
|
||||
const list = await adminListServers(token);
|
||||
setRows(list);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === 'UNAUTHORIZED') {
|
||||
onSessionInvalid();
|
||||
return;
|
||||
}
|
||||
setErr(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, onSessionInvalid]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const startEdit = (r: Row) => {
|
||||
setEditId(r.id);
|
||||
setEditForm({ name: r.name, url: r.url, enabled: r.enabled });
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editId) return;
|
||||
try {
|
||||
const u = editForm.url.trim();
|
||||
await adminUpdateServer(token, editId, {
|
||||
name: editForm.name.trim(),
|
||||
...(u ? { url: formatServerUrl(u) } : { url: '' }),
|
||||
enabled: editForm.enabled,
|
||||
});
|
||||
setEditId(null);
|
||||
await load();
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
if (!confirm('确定删除该监控节点?')) return;
|
||||
try {
|
||||
await adminDeleteServer(token, id);
|
||||
await load();
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const copyServerId = useCallback((id: string) => {
|
||||
void navigator.clipboard.writeText(id).catch(() => {
|
||||
window.prompt('请手动复制(环境变量 SERVER_ID)', id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const copyAgentKey = useCallback((key: string) => {
|
||||
void navigator.clipboard.writeText(key).catch(() => {
|
||||
window.prompt('请手动复制(环境变量 AGENT_KEY)', key);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const move = async (index: number, dir: -1 | 1) => {
|
||||
const j = index + dir;
|
||||
if (j < 0 || j >= rows.length) return;
|
||||
const next = [...rows];
|
||||
[next[index], next[j]] = [next[j], next[index]];
|
||||
const ids = next.map((r) => r.id);
|
||||
setRows(next);
|
||||
try {
|
||||
await adminReorderServers(token, ids);
|
||||
await load();
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : '排序失败');
|
||||
void load();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-overlay" role="dialog" aria-modal="true">
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-header">
|
||||
<h2>监控管理后台</h2>
|
||||
<button type="button" className="admin-close" onClick={onClose} title="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p className="admin-hint">
|
||||
节点由采集端 gRPC 上报后出现在列表中。环境变量 CENTRAL_GRPC、<strong>SERVER_ID 须为下表「节点 ID」列的完整 UUID</strong>
|
||||
(勿把「名称」当成 SERVER_ID)、AGENT_KEY 与「节点密钥」一致。看板经 WebSocket
|
||||
拉取中心内存快照;「备注链接」可选,可在行内编辑。
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<p className="admin-loading">加载中…</p>
|
||||
) : (
|
||||
<>
|
||||
{err && <p className="admin-error">{err}</p>}
|
||||
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>顺序</th>
|
||||
<th>名称</th>
|
||||
<th>节点 ID(SERVER_ID)</th>
|
||||
<th>备注链接</th>
|
||||
<th>节点密钥 (gRPC)</th>
|
||||
<th>启用</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="admin-empty">
|
||||
暂无节点;请确认采集端已配置并连接中心,成功上报后将显示在此。
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((r, i) => (
|
||||
<tr key={r.id}>
|
||||
<td>
|
||||
<div className="admin-order-btns">
|
||||
<button type="button" title="上移" onClick={() => void move(i, -1)} disabled={i === 0}>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="下移"
|
||||
onClick={() => void move(i, 1)}
|
||||
disabled={i === rows.length - 1}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{editId === r.id ? (
|
||||
<input
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
r.name
|
||||
)}
|
||||
</td>
|
||||
<td className="admin-server-id-cell">
|
||||
<code className="admin-server-id" title={r.id}>
|
||||
{r.id}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-ghost"
|
||||
title="复制为采集端 SERVER_ID"
|
||||
onClick={() => copyServerId(r.id)}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{editId === r.id ? (
|
||||
<input
|
||||
className="admin-url-input"
|
||||
value={editForm.url}
|
||||
onChange={(e) => setEditForm({ ...editForm, url: e.target.value })}
|
||||
/>
|
||||
) : r.url ? (
|
||||
<code>{r.url}</code>
|
||||
) : (
|
||||
<span className="admin-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="admin-agent-key-cell">
|
||||
{r.agentKey ? (
|
||||
<>
|
||||
<code className="admin-key" title={r.agentKey}>
|
||||
{r.agentKey.slice(0, 8)}…
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-ghost"
|
||||
title="复制完整 AGENT_KEY(写入采集端配置)"
|
||||
onClick={() => copyAgentKey(r.agentKey!)}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="admin-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editId === r.id ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.enabled}
|
||||
onChange={(e) => setEditForm({ ...editForm, enabled: e.target.checked })}
|
||||
/>
|
||||
) : (
|
||||
<span>{r.enabled ? '是' : '否'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="admin-actions">
|
||||
{editId === r.id ? (
|
||||
<>
|
||||
<button type="button" className="admin-btn" onClick={() => void saveEdit()}>
|
||||
保存
|
||||
</button>
|
||||
<button type="button" className="admin-btn" onClick={() => setEditId(null)}>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="admin-btn" onClick={() => startEdit(r)}>
|
||||
编辑
|
||||
</button>
|
||||
<button type="button" className="admin-btn danger" onClick={() => void remove(r.id)}>
|
||||
删除
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
.circular-progress {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.circular-progress svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.progress-ring-bg {
|
||||
fill: none;
|
||||
stroke: #e2e8f0;
|
||||
transition: stroke 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-ring-circle {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.5s ease;
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
font-weight: 700;
|
||||
fill: var(--text-main);
|
||||
transform: rotate(90deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
fill: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
transform: rotate(90deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.progress-sublabel {
|
||||
fill: var(--text-light);
|
||||
font-weight: 400;
|
||||
transform: rotate(90deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import './CircularProgress.css';
|
||||
|
||||
interface CircularProgressProps {
|
||||
value: number;
|
||||
label: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
subLabel?: string;
|
||||
}
|
||||
|
||||
export const CircularProgress = ({
|
||||
value,
|
||||
label,
|
||||
color = 'var(--primary-color)',
|
||||
size = 150,
|
||||
subLabel
|
||||
}: CircularProgressProps) => {
|
||||
const radius = size * 0.36; // 36% of size
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (value / 100) * circumference;
|
||||
const center = size / 2;
|
||||
|
||||
// 根据尺寸动态计算字体大小
|
||||
const valueFontSize = size * 0.12; // 12% of size
|
||||
const subLabelFontSize = size * 0.08; // 8% of size
|
||||
const labelFontSize = size * 0.09; // 9% of size
|
||||
|
||||
return (
|
||||
<div className="circular-progress" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle
|
||||
className="progress-ring-bg"
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
strokeWidth={size * 0.08}
|
||||
/>
|
||||
<circle
|
||||
className="progress-ring-circle"
|
||||
stroke={color}
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={offset}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
strokeWidth={size * 0.08}
|
||||
/>
|
||||
{subLabel ? (
|
||||
<text
|
||||
x={center}
|
||||
y={center}
|
||||
className="progress-value"
|
||||
textAnchor="middle"
|
||||
dy="0.3em"
|
||||
fontSize={subLabelFontSize}
|
||||
>
|
||||
{subLabel}
|
||||
</text>
|
||||
) : (
|
||||
<text
|
||||
x={center}
|
||||
y={center}
|
||||
className="progress-value"
|
||||
textAnchor="middle"
|
||||
dy="0.3em"
|
||||
fontSize={valueFontSize}
|
||||
>
|
||||
{Math.round(value)}%
|
||||
</text>
|
||||
)}
|
||||
<text
|
||||
x={center}
|
||||
y={center + size * 0.22}
|
||||
className="progress-label"
|
||||
textAnchor="middle"
|
||||
fontSize={labelFontSize}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
.random-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0; /* 低于 #root (z-index: 1),保证监控卡片与顶栏在上层 */
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 略放大并外扩,减轻模糊后边缘露底 */
|
||||
.random-backdrop__image {
|
||||
position: absolute;
|
||||
inset: -16px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
transform: scale(1.04);
|
||||
/* 需求「10%」按常用约定映射为 10px 半径(CSS blur 无百分比) */
|
||||
filter: blur(10px);
|
||||
-webkit-filter: blur(10px);
|
||||
}
|
||||
|
||||
.random-backdrop__fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(120% 80% at 20% 20%, rgba(252, 231, 243, 0.95) 0%, transparent 55%),
|
||||
radial-gradient(100% 90% at 80% 30%, rgba(224, 231, 255, 0.9) 0%, transparent 50%),
|
||||
linear-gradient(160deg, #fdf4ff 0%, #e0f2fe 45%, #fce7f3 100%);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import './RandomBackdrop.css';
|
||||
|
||||
const RANDBG_JSON =
|
||||
'https://randbg.api.smyhub.com/api/random?format=json&mode=auto';
|
||||
|
||||
type RandomBgResponse = {
|
||||
url?: string;
|
||||
};
|
||||
|
||||
/** 挂到 body,避免与 .app 内 filter/层叠上下文叠在一起盖住卡片 */
|
||||
export const RandomBackdrop = () => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetch(RANDBG_JSON)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
return r.json() as Promise<RandomBgResponse>;
|
||||
})
|
||||
.then((data) => {
|
||||
const u = data?.url;
|
||||
if (!cancelled && typeof u === 'string' && u.length > 0) {
|
||||
setImageUrl(u);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* 保持 fallback 底色 */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return createPortal(
|
||||
<div className="random-backdrop" aria-hidden>
|
||||
{!imageUrl && <div className="random-backdrop__fallback" />}
|
||||
{imageUrl && (
|
||||
<div
|
||||
className="random-backdrop__image"
|
||||
style={{ backgroundImage: `url(${imageUrl})` }}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -1,23 +1,27 @@
|
||||
.server-card {
|
||||
background: var(--surface-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border-radius: var(--radius-glass);
|
||||
padding: 0.85rem 1rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 248px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.server-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: rgba(255, 255, 255, 0.75);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.server-card.online {
|
||||
border-top: 4px solid var(--success-color);
|
||||
border-top: 3px solid var(--primary-color);
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.server-card.offline {
|
||||
@@ -29,13 +33,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.5rem;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-header:active {
|
||||
cursor: grabbing;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
@@ -53,25 +51,6 @@
|
||||
|
||||
.status-online {
|
||||
background: var(--success-color);
|
||||
box-shadow: 0 0 0 4px rgba(134, 239, 172, 0.2);
|
||||
}
|
||||
|
||||
.status-online::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
border: 1px solid var(--success-color);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); opacity: 0.5; }
|
||||
70% { transform: scale(1.5); opacity: 0; }
|
||||
100% { transform: scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@@ -86,9 +65,10 @@
|
||||
|
||||
.server-name {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.server-hostname {
|
||||
@@ -124,62 +104,68 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metrics-grid-main {
|
||||
.card-stats-compact {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.circular-progress-small {
|
||||
.stat-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.circular-progress-small .progress-ring-bg {
|
||||
fill: none;
|
||||
stroke: #e2e8f0;
|
||||
stroke-width: 6;
|
||||
.stat-k {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.circular-progress-small .progress-ring-circle {
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.5s ease;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.circular-progress-small .progress-value-small {
|
||||
font-size: 14px;
|
||||
.stat-v {
|
||||
color: var(--text-main);
|
||||
font-weight: 600;
|
||||
fill: var(--text-main);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.circular-progress-small .progress-label-small {
|
||||
font-size: 10px;
|
||||
fill: var(--text-secondary);
|
||||
.lat-good {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.lat-mid {
|
||||
color: #fde047;
|
||||
}
|
||||
|
||||
.lat-bad {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.card-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-color);
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
@@ -215,10 +201,11 @@
|
||||
.card-performance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-color);
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.performance-section {
|
||||
@@ -256,9 +243,9 @@
|
||||
}
|
||||
|
||||
.performance-item-value {
|
||||
color: var(--primary-color);
|
||||
color: #0284c7;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
font-family: ui-monospace, monospace;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -267,10 +254,11 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-color);
|
||||
padding: 0.45rem 0.55rem;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.footer-row {
|
||||
@@ -325,13 +313,13 @@
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--bg-color);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
@@ -357,31 +345,40 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-color);
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 0.65rem;
|
||||
padding: 0.65rem;
|
||||
color: var(--text-light);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.offline-state.sync-pending {
|
||||
border: 1px dashed var(--primary-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.btn-detail {
|
||||
width: 100%;
|
||||
background: var(--surface-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.btn-detail:hover {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
background: linear-gradient(135deg, rgba(125, 211, 252, 0.85), rgba(196, 181, 253, 0.75));
|
||||
color: #0f172a;
|
||||
border-color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
@@ -1,79 +1,7 @@
|
||||
import type { ServerConfig, ServerMetrics } from '../../types';
|
||||
import { formatUptime, formatBytes } from '../../utils/format';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { formatUptime, formatBytes, formatPercent, formatLatencyMs } from '../../utils/format';
|
||||
import './ServerCard.css';
|
||||
|
||||
const CircularProgress = ({ value, label, color, size = 60 }: { value: number; label: string; color: string; size?: number }) => {
|
||||
const radius = size * 0.36;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (value / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="circular-progress-small">
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle
|
||||
className="progress-ring-bg"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
/>
|
||||
<circle
|
||||
className="progress-ring-circle"
|
||||
stroke={color}
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={offset}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
/>
|
||||
<text x={size / 2} y={size / 2 - 8} className="progress-value-small" textAnchor="middle" dy="7px">
|
||||
{Math.round(value)}%
|
||||
</text>
|
||||
<text x={size / 2} y={size / 2 + 12} className="progress-label-small" textAnchor="middle">
|
||||
{label}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadProgress = ({ value, maxValue = 1.0, size = 80 }: { value: number; maxValue?: number; size?: number }) => {
|
||||
const percentage = Math.min((value / maxValue) * 100, 100);
|
||||
const radius = size * 0.36;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (percentage / 100) * circumference;
|
||||
const color = percentage > 80 ? '#f87171' : percentage > 50 ? '#fde047' : '#86efac';
|
||||
|
||||
return (
|
||||
<div className="circular-progress-small">
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle
|
||||
className="progress-ring-bg"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
/>
|
||||
<circle
|
||||
className="progress-ring-circle"
|
||||
stroke={color}
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={offset}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
/>
|
||||
<text x={size / 2} y={size / 2 - 8} className="progress-value-small" textAnchor="middle" dy="7px">
|
||||
{value.toFixed(2)}
|
||||
</text>
|
||||
<text x={size / 2} y={size / 2 + 12} className="progress-label-small" textAnchor="middle">
|
||||
负载
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ServerCardProps {
|
||||
server: ServerConfig;
|
||||
online: boolean;
|
||||
@@ -83,7 +11,6 @@ export interface ServerCardProps {
|
||||
storageUsage?: number;
|
||||
uptime?: number;
|
||||
onDetail: (serverId: string) => void;
|
||||
onRemove: (serverId: string) => void;
|
||||
}
|
||||
|
||||
export const ServerCard = ({
|
||||
@@ -95,24 +22,7 @@ export const ServerCard = ({
|
||||
storageUsage = 0,
|
||||
uptime,
|
||||
onDetail,
|
||||
onRemove,
|
||||
}: ServerCardProps) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: server.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
// 从 URL 提取 IP 地址
|
||||
const extractIP = (url: string): string => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
@@ -122,11 +32,10 @@ export const ServerCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 获取主 IP 地址(从网络接口)
|
||||
const getMainIP = (): string => {
|
||||
if (metrics?.network && metrics.network.length > 0) {
|
||||
const mainInterface = metrics.network.find(iface =>
|
||||
iface.ipAddress && iface.ipAddress !== 'N/A' && !iface.ipAddress.startsWith('127.')
|
||||
const mainInterface = metrics.network.find(
|
||||
(iface) => iface.ipAddress && iface.ipAddress !== 'N/A' && !iface.ipAddress.startsWith('127.')
|
||||
);
|
||||
return mainInterface?.ipAddress || extractIP(server.url);
|
||||
}
|
||||
@@ -144,74 +53,84 @@ export const ServerCard = ({
|
||||
const diskWriteSpeed = metrics?.system?.diskWriteSpeed || 0;
|
||||
const networkRxSpeed = metrics?.system?.networkRxSpeed || 0;
|
||||
const networkTxSpeed = metrics?.system?.networkTxSpeed || 0;
|
||||
const latency = metrics?.latency?.clientToServer || -1;
|
||||
const latency = metrics?.latency?.clientToServer ?? -1;
|
||||
const processCount = metrics?.system?.processCount || 0;
|
||||
const packageCount = metrics?.system?.packageCount || 0;
|
||||
const loggedInUsers = metrics?.system?.loggedInUsers;
|
||||
const tcpConnections = metrics?.system?.tcpConnections;
|
||||
const swapTotalBytes = metrics?.memory?.swapTotalBytes;
|
||||
const swapType = metrics?.memory?.swapType;
|
||||
|
||||
// 延迟颜色判断
|
||||
const getLatencyColor = (latency: number): string => {
|
||||
if (latency < 0) return '#ef4444'; // 超时/失败 - 红色
|
||||
if (latency < 50) return '#86efac'; // 低延迟 - 绿色
|
||||
if (latency < 200) return '#fde047'; // 中延迟 - 黄色
|
||||
return '#f87171'; // 高延迟 - 红色
|
||||
};
|
||||
const loadRatio = cpuCores > 0 ? (loadAverage / cpuCores) * 100 : 0;
|
||||
|
||||
const latencyColor = getLatencyColor(latency);
|
||||
const latencyClass =
|
||||
!Number.isFinite(latency) || latency < 0
|
||||
? 'lat-bad'
|
||||
: latency < 50
|
||||
? 'lat-good'
|
||||
: latency < 200
|
||||
? 'lat-mid'
|
||||
: 'lat-bad';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`server-card ${online ? 'online' : 'offline'}`}
|
||||
>
|
||||
<div className="card-header" {...attributes} {...listeners}>
|
||||
<div className={`server-card ${online ? 'online' : 'offline'}`}>
|
||||
<div className="card-header">
|
||||
<div className="server-info">
|
||||
<div className={`status-indicator ${online ? 'status-online' : 'status-offline'}`} />
|
||||
<div className="server-name-group">
|
||||
<h3 className="server-name">{server.name}</h3>
|
||||
{online && hostname !== 'Unknown' && (
|
||||
<span className="server-hostname">{hostname}</span>
|
||||
)}
|
||||
{online && hostname !== 'Unknown' && <span className="server-hostname">{hostname}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn-remove"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(server.id);
|
||||
}}
|
||||
title="移除服务器"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{online && metrics ? (
|
||||
{!online ? (
|
||||
<div className="offline-state">
|
||||
<p>服务器连接断开</p>
|
||||
</div>
|
||||
) : !metrics ? (
|
||||
<div className="offline-state sync-pending">
|
||||
<p>正在从中心同步指标…</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card-main-metrics">
|
||||
<div className="metrics-grid-main">
|
||||
<div className="card-stats-compact">
|
||||
{loadAverage > 0 && (
|
||||
<LoadProgress value={loadAverage} maxValue={cpuCores} size={80} />
|
||||
<div className="stat-line">
|
||||
<span className="stat-k">负载</span>
|
||||
<span className="stat-v">
|
||||
{loadAverage.toFixed(2)} / {cpuCores} 核(≈ {formatPercent(loadRatio)})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="stat-line">
|
||||
<span className="stat-k">CPU</span>
|
||||
<span className="stat-v">{formatPercent(cpuUsage)}</span>
|
||||
</div>
|
||||
<div className="stat-line">
|
||||
<span className="stat-k">内存</span>
|
||||
<span className="stat-v">{formatPercent(memoryUsage)}</span>
|
||||
</div>
|
||||
<div className="stat-line">
|
||||
<span className="stat-k">存储峰值</span>
|
||||
<span className="stat-v">{formatPercent(storageUsage)}</span>
|
||||
</div>
|
||||
{swapTotalBytes !== undefined && (
|
||||
<div className="stat-line">
|
||||
<span className="stat-k">Swap</span>
|
||||
<span className="stat-v">
|
||||
{formatBytes(swapTotalBytes)}
|
||||
{swapType ? ` · ${swapType}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{tcpConnections !== undefined && (
|
||||
<div className="stat-line">
|
||||
<span className="stat-k">TCP</span>
|
||||
<span className="stat-v">{tcpConnections}</span>
|
||||
</div>
|
||||
)}
|
||||
<CircularProgress
|
||||
value={cpuUsage}
|
||||
label="CPU"
|
||||
color={cpuUsage > 80 ? '#f87171' : '#86efac'}
|
||||
size={80}
|
||||
/>
|
||||
<CircularProgress
|
||||
value={memoryUsage}
|
||||
label="内存"
|
||||
color={memoryUsage > 80 ? '#fde047' : '#6ee7b7'}
|
||||
size={80}
|
||||
/>
|
||||
<CircularProgress
|
||||
value={storageUsage}
|
||||
label="存储"
|
||||
color={storageUsage > 90 ? '#f87171' : '#a7f3d0'}
|
||||
size={80}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card-info-grid">
|
||||
@@ -239,6 +158,12 @@ export const ServerCard = ({
|
||||
<span className="info-label">进程</span>
|
||||
<span className="info-value">{processCount}</span>
|
||||
</div>
|
||||
{loggedInUsers !== undefined && (
|
||||
<div className="info-row">
|
||||
<span className="info-label">会话</span>
|
||||
<span className="info-value">{loggedInUsers}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="info-row">
|
||||
<span className="info-label">软件包</span>
|
||||
<span className="info-value">{packageCount}</span>
|
||||
@@ -254,11 +179,15 @@ export const ServerCard = ({
|
||||
<div className="performance-row">
|
||||
<span className="performance-item">
|
||||
<span className="performance-item-label">读取</span>
|
||||
<span className="performance-item-value">{diskReadSpeed > 0 ? formatBytes(diskReadSpeed * 1024 * 1024) + '/s' : '0 B/s'}</span>
|
||||
<span className="performance-item-value">
|
||||
{diskReadSpeed > 0 ? formatBytes(diskReadSpeed * 1024 * 1024) + '/s' : '0 B/s'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="performance-item">
|
||||
<span className="performance-item-label">写入</span>
|
||||
<span className="performance-item-value">{diskWriteSpeed > 0 ? formatBytes(diskWriteSpeed * 1024 * 1024) + '/s' : '0 B/s'}</span>
|
||||
<span className="performance-item-value">
|
||||
{diskWriteSpeed > 0 ? formatBytes(diskWriteSpeed * 1024 * 1024) + '/s' : '0 B/s'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,11 +199,15 @@ export const ServerCard = ({
|
||||
<div className="performance-row">
|
||||
<span className="performance-item">
|
||||
<span className="performance-item-label">下载</span>
|
||||
<span className="performance-item-value">{networkRxSpeed > 0 ? formatBytes(networkRxSpeed * 1024 * 1024) + '/s' : '0 B/s'}</span>
|
||||
<span className="performance-item-value">
|
||||
{networkRxSpeed > 0 ? formatBytes(networkRxSpeed * 1024 * 1024) + '/s' : '0 B/s'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="performance-item">
|
||||
<span className="performance-item-label">上传</span>
|
||||
<span className="performance-item-value">{networkTxSpeed > 0 ? formatBytes(networkTxSpeed * 1024 * 1024) + '/s' : '0 B/s'}</span>
|
||||
<span className="performance-item-value">
|
||||
{networkTxSpeed > 0 ? formatBytes(networkTxSpeed * 1024 * 1024) + '/s' : '0 B/s'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,23 +218,17 @@ export const ServerCard = ({
|
||||
<span className="footer-label">运行时间</span>
|
||||
<span className="footer-value">{uptime !== undefined ? formatUptime(uptime) : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="footer-row">
|
||||
<span className="footer-label">延迟</span>
|
||||
<span className="footer-value" style={{ color: latencyColor }}>
|
||||
{latency > 0 ? `${latency} ms` : latency === -1 ? '超时' : 'N/A'}
|
||||
</span>
|
||||
<div className="footer-row" title="采集器到监控中心的 TCP 建连耗时(由采集端上报)">
|
||||
<span className="footer-label">采集器→中心</span>
|
||||
<span className={`footer-value ${latencyClass}`}>{formatLatencyMs(latency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="offline-state">
|
||||
<p>服务器连接断开</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="card-footer">
|
||||
<button className="btn-detail" onClick={() => onDetail(server.id)}>
|
||||
<button type="button" className="btn-detail" onClick={() => onDetail(server.id)}>
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
import type { ServerMetrics } from '../../types';
|
||||
import { formatBytes, formatUptime, formatPercent } from '../../utils/format';
|
||||
import { CircularProgress } from '../Common/CircularProgress';
|
||||
import { formatBytes, formatUptime, formatPercent, formatLatencyMs } from '../../utils/format';
|
||||
import './ServerDetail.css';
|
||||
|
||||
interface ServerDetailProps {
|
||||
@@ -20,12 +19,12 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
|
||||
<div className="modal-body">
|
||||
<section className="detail-section">
|
||||
<h3>系统信息</h3>
|
||||
<h3 className="detail-category-heading">系统与环境</h3>
|
||||
<div className="system-info-container">
|
||||
{/* 系统基本信息 */}
|
||||
<div className="system-info-group">
|
||||
<h4 className="info-group-title">基本信息</h4>
|
||||
<div className="system-info-grid">
|
||||
<div className="system-info-grid system-info-grid--cols-3">
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<span className="system-info-label">主机名</span>
|
||||
@@ -59,7 +58,7 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
{/* 运行时信息 */}
|
||||
<div className="system-info-group">
|
||||
<h4 className="info-group-title">运行时信息</h4>
|
||||
<div className="system-info-grid">
|
||||
<div className="system-info-grid system-info-grid--cols-3">
|
||||
<div className="system-info-item system-info-item-highlight">
|
||||
<div className="system-info-content">
|
||||
<span className="system-info-label">运行时间</span>
|
||||
@@ -72,6 +71,22 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
<span className="system-info-value">{metrics.system.processCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
{metrics.system.loggedInUsers !== undefined && (
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<span className="system-info-label">登录会话</span>
|
||||
<span className="system-info-value">{metrics.system.loggedInUsers}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{metrics.system.tcpConnections !== undefined && (
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<span className="system-info-label">TCP 连接数</span>
|
||||
<span className="system-info-value">{metrics.system.tcpConnections}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{metrics.system.packageCount > 0 && (
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
@@ -86,7 +101,7 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
{/* 性能指标 */}
|
||||
<div className="system-info-group">
|
||||
<h4 className="info-group-title">性能指标</h4>
|
||||
<div className="system-info-grid">
|
||||
<div className="system-info-grid system-info-grid--cols-3">
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<span className="system-info-label">磁盘读取</span>
|
||||
@@ -129,34 +144,48 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
{/* 延迟信息 */}
|
||||
{metrics.latency && (
|
||||
<div className="system-info-group">
|
||||
<h4 className="info-group-title">延迟检测</h4>
|
||||
<div className="system-info-grid">
|
||||
<h4 className="info-group-title">网络延迟(采集端测量)</h4>
|
||||
<p className="latency-section-hint">
|
||||
下列指标均在采集器所在机器上探测:第一项为到监控中心 gRPC 地址的 TCP
|
||||
建连时间;外网三项为采集端对公网站点的 TCP(:80 / :443)建连耗时,非 ICMP ping。
|
||||
</p>
|
||||
<div className="system-info-grid system-info-grid--cols-4">
|
||||
<div className="system-info-item system-info-item-highlight">
|
||||
<div className="system-info-content">
|
||||
<span className="system-info-label">客户端到服务器</span>
|
||||
<div className="system-info-content system-info-content--stack">
|
||||
<span className="system-info-label">采集器到监控中心</span>
|
||||
<span className="system-info-sublabel">与 CENTRAL_GRPC 的 TCP 连通</span>
|
||||
<span className="system-info-value highlight">
|
||||
{metrics.latency.clientToServer >= 0 ? `${metrics.latency.clientToServer} ms` : '超时'}
|
||||
{formatLatencyMs(metrics.latency.clientToServer)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{metrics.latency.external && (
|
||||
<>
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<div className="system-info-content system-info-content--stack">
|
||||
<span className="system-info-label">百度 (baidu.com)</span>
|
||||
<span className="system-info-value">{metrics.latency.external['baidu.com'] || '超时'}</span>
|
||||
<span className="system-info-sublabel">采集端 TCP 探测</span>
|
||||
<span className="system-info-value">
|
||||
{metrics.latency.external['baidu.com'] || '超时'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<div className="system-info-content system-info-content--stack">
|
||||
<span className="system-info-label">谷歌 (google.com)</span>
|
||||
<span className="system-info-value">{metrics.latency.external['google.com'] || '超时'}</span>
|
||||
<span className="system-info-sublabel">采集端 TCP 探测</span>
|
||||
<span className="system-info-value">
|
||||
{metrics.latency.external['google.com'] || '超时'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<div className="system-info-content system-info-content--stack">
|
||||
<span className="system-info-label">GitHub (github.com)</span>
|
||||
<span className="system-info-value">{metrics.latency.external['github.com'] || '超时'}</span>
|
||||
<span className="system-info-sublabel">采集端 TCP 探测</span>
|
||||
<span className="system-info-value">
|
||||
{metrics.latency.external['github.com'] || '超时'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -168,152 +197,152 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
</section>
|
||||
|
||||
<section className="detail-section">
|
||||
<h3>CPU</h3>
|
||||
<div className="cpu-section-content">
|
||||
<div className="cpu-circular-group">
|
||||
<div className="cpu-circular">
|
||||
<CircularProgress
|
||||
value={metrics.cpu.usagePercent}
|
||||
label=""
|
||||
size={140}
|
||||
color={metrics.cpu.usagePercent > 80 ? '#f87171' : '#86efac'}
|
||||
subLabel={formatPercent(metrics.cpu.usagePercent)}
|
||||
/>
|
||||
</div>
|
||||
{metrics.cpu.loadAverages && metrics.cpu.loadAverages.length > 0 && metrics.cpu.cores > 0 && (
|
||||
<div className="cpu-load-circular">
|
||||
<CircularProgress
|
||||
value={Math.min((metrics.cpu.loadAverages[0] / metrics.cpu.cores) * 100, 100)}
|
||||
label="负载"
|
||||
size={140}
|
||||
color={metrics.cpu.loadAverages[0] / metrics.cpu.cores > 0.8 ? '#fde047' : '#a7f3d0'}
|
||||
subLabel={metrics.cpu.loadAverages[0].toFixed(2)}
|
||||
/>
|
||||
<h3 className="detail-category-heading">处理器与内存</h3>
|
||||
<div className="detail-subsection">
|
||||
<h4 className="detail-subheading">处理器</h4>
|
||||
<div className="detail-plain">
|
||||
<div className="detail-grid detail-grid--cpu">
|
||||
<div className="detail-item detail-item--span-full">
|
||||
<span className="item-label">型号</span>
|
||||
<span className="item-value">{metrics.cpu.model}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="item-label">型号</span>
|
||||
<span className="item-value">{metrics.cpu.model}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">核心数</span>
|
||||
<span className="item-value">{metrics.cpu.cores}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">使用率</span>
|
||||
<span className="item-value highlight">{formatPercent(metrics.cpu.usagePercent)}</span>
|
||||
</div>
|
||||
{metrics.cpu.temperature && metrics.cpu.temperature > 0 && (
|
||||
<div className="detail-item">
|
||||
<span className="item-label">温度</span>
|
||||
<span className="item-value">{metrics.cpu.temperature.toFixed(1)}°C</span>
|
||||
<span className="item-label">核心数</span>
|
||||
<span className="item-value">{metrics.cpu.cores}</span>
|
||||
</div>
|
||||
)}
|
||||
{metrics.cpu.loadAverages && metrics.cpu.loadAverages.length > 0 && (
|
||||
<div className="detail-item">
|
||||
<span className="item-label">负载平均值</span>
|
||||
<span className="item-value">{metrics.cpu.loadAverages.join(' / ')}</span>
|
||||
<span className="item-label">使用率</span>
|
||||
<span className="item-value emphasize">{formatPercent(metrics.cpu.usagePercent)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{metrics.cpu.perCoreUsage && metrics.cpu.perCoreUsage.length > 0 && (
|
||||
<div className="per-core-section">
|
||||
<h4>各核心使用率</h4>
|
||||
<div className="core-grid-circular">
|
||||
{metrics.cpu.perCoreUsage.map((core) => (
|
||||
<div key={core.core} className="core-item-circular">
|
||||
<CircularProgress
|
||||
value={core.percent}
|
||||
label={`Core ${core.core}`}
|
||||
size={140}
|
||||
color={core.percent > 80 ? '#f87171' : '#86efac'}
|
||||
subLabel={formatPercent(core.percent)}
|
||||
/>
|
||||
{metrics.cpu.temperature && metrics.cpu.temperature > 0 && (
|
||||
<div className="detail-item">
|
||||
<span className="item-label">温度</span>
|
||||
<span className="item-value">{metrics.cpu.temperature.toFixed(1)}°C</span>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{metrics.cpu.loadAverages && metrics.cpu.loadAverages.length > 0 && (
|
||||
<>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">负载 (1 / 5 / 15 min)</span>
|
||||
<span className="item-value">{metrics.cpu.loadAverages.map((l) => l.toFixed(2)).join(' / ')}</span>
|
||||
</div>
|
||||
{metrics.cpu.cores > 0 && (
|
||||
<div className="detail-item">
|
||||
<span className="item-label">相对核心(1 min 负载)</span>
|
||||
<span className="item-value">
|
||||
{formatPercent(Math.min((metrics.cpu.loadAverages[0] / metrics.cpu.cores) * 100, 100))}{' '}
|
||||
<span className="item-sub">
|
||||
({metrics.cpu.loadAverages[0].toFixed(2)} / {metrics.cpu.cores} 核)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{metrics.cpu.perCoreUsage && metrics.cpu.perCoreUsage.length > 0 && (
|
||||
<div className="per-core-plain">
|
||||
<h4 className="detail-subheading detail-subheading--inline">各核心使用率</h4>
|
||||
<div className="per-core-grid">
|
||||
{metrics.cpu.perCoreUsage.map((core) => (
|
||||
<div key={core.core} className="per-core-chip">
|
||||
<span className="per-core-name">核心 {core.core}</span>
|
||||
<span className="per-core-pct">{formatPercent(core.percent)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="detail-section">
|
||||
<h3>内存</h3>
|
||||
<div className="memory-section-content">
|
||||
<div className="memory-circular">
|
||||
<CircularProgress
|
||||
value={metrics.memory.usedPercent}
|
||||
label=""
|
||||
size={140}
|
||||
color={metrics.memory.usedPercent > 80 ? '#fde047' : '#6ee7b7'}
|
||||
subLabel={formatPercent(metrics.memory.usedPercent)}
|
||||
/>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="item-label">总容量</span>
|
||||
<span className="item-value">{formatBytes(metrics.memory.totalBytes)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">已使用</span>
|
||||
<span className="item-value">{formatBytes(metrics.memory.usedBytes)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">可用</span>
|
||||
<span className="item-value">{formatBytes(metrics.memory.freeBytes)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">使用率</span>
|
||||
<span className="item-value highlight">{formatPercent(metrics.memory.usedPercent)}</span>
|
||||
<div className="detail-subsection">
|
||||
<h4 className="detail-subheading">内存</h4>
|
||||
<div className="detail-plain">
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="item-label">使用率</span>
|
||||
<span className="item-value emphasize">{formatPercent(metrics.memory.usedPercent)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">总容量</span>
|
||||
<span className="item-value">{formatBytes(metrics.memory.totalBytes)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">已使用</span>
|
||||
<span className="item-value">{formatBytes(metrics.memory.usedBytes)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">可用</span>
|
||||
<span className="item-value">{formatBytes(metrics.memory.freeBytes)}</span>
|
||||
</div>
|
||||
{(metrics.memory.swapTotalBytes !== undefined ||
|
||||
metrics.memory.swapType !== undefined) && (
|
||||
<>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">Swap 总量</span>
|
||||
<span className="item-value">
|
||||
{formatBytes(metrics.memory.swapTotalBytes ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">Swap 已用</span>
|
||||
<span className="item-value">
|
||||
{formatBytes(metrics.memory.swapUsedBytes ?? 0)}
|
||||
{metrics.memory.swapUsedPercent !== undefined && (
|
||||
<span className="item-sub"> ({formatPercent(metrics.memory.swapUsedPercent)})</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">Swap 可用</span>
|
||||
<span className="item-value">
|
||||
{formatBytes(metrics.memory.swapFreeBytes ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">Swap 类型</span>
|
||||
<span className="item-value">{metrics.memory.swapType ?? '—'}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-section">
|
||||
<h3>存储</h3>
|
||||
<div className="storage-grid-circular">
|
||||
<h3 className="detail-category-heading">存储与磁盘</h3>
|
||||
<ul className="storage-plain-list">
|
||||
{metrics.storage.map((disk, index) => (
|
||||
<div key={index} className="storage-item-circular">
|
||||
<CircularProgress
|
||||
value={disk.usedPercent}
|
||||
label={disk.mount}
|
||||
size={160}
|
||||
color={disk.usedPercent > 90 ? '#f87171' : '#a7f3d0'}
|
||||
subLabel={formatPercent(disk.usedPercent)}
|
||||
/>
|
||||
<div className="storage-details-mini">
|
||||
<div>总: {formatBytes(disk.totalBytes)}</div>
|
||||
<div>用: {formatBytes(disk.usedBytes)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<li key={index} className="storage-plain-row">
|
||||
<div className="storage-plain-head">
|
||||
<span className="storage-mount">{disk.mount}</span>
|
||||
<span className="storage-pct">{formatPercent(disk.usedPercent)}</span>
|
||||
</div>
|
||||
<div className="storage-meta">
|
||||
已用 {formatBytes(disk.usedBytes)} / 共 {formatBytes(disk.totalBytes)} · 可用{' '}
|
||||
{formatBytes(disk.freeBytes)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{metrics.gpu && metrics.gpu.length > 0 && metrics.gpu[0].status !== 'not_available' && (
|
||||
<section className="detail-section">
|
||||
<h3>GPU</h3>
|
||||
<h3 className="detail-category-heading">图形设备</h3>
|
||||
{metrics.gpu.map((gpu, index) => (
|
||||
<div key={index} className="gpu-item">
|
||||
<div className="gpu-header">
|
||||
<strong>{gpu.name}</strong>
|
||||
<span className={`gpu-status ${gpu.status}`}>{gpu.status}</span>
|
||||
</div>
|
||||
<div className="gpu-bar-container">
|
||||
<div className="gpu-bar-label">利用率</div>
|
||||
<div className="gpu-bar-bg">
|
||||
<div
|
||||
className="gpu-bar"
|
||||
style={{ width: `${gpu.utilizationPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="gpu-bar-value">{formatPercent(gpu.utilizationPercent)}</div>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="item-label">利用率</span>
|
||||
<span className="item-value emphasize">{formatPercent(gpu.utilizationPercent)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">显存总量</span>
|
||||
<span className="item-value">{gpu.memoryTotalMB} MB</span>
|
||||
@@ -336,167 +365,150 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
|
||||
{metrics.network && metrics.network.length > 0 && (
|
||||
<section className="detail-section">
|
||||
<h3>网络接口</h3>
|
||||
<div className="network-list">
|
||||
<h3 className="detail-category-heading">网络</h3>
|
||||
<div className="network-compact" role="table" aria-label="网络接口">
|
||||
<div className="network-compact__head" role="row">
|
||||
<span role="columnheader">接口</span>
|
||||
<span role="columnheader">IP</span>
|
||||
<span role="columnheader">MAC</span>
|
||||
<span role="columnheader">接收</span>
|
||||
<span role="columnheader">发送</span>
|
||||
</div>
|
||||
{metrics.network.map((iface, index) => (
|
||||
<div key={index} className="network-item">
|
||||
<div className="network-header">
|
||||
<div className="network-name-group">
|
||||
<div className="network-name-info">
|
||||
<strong className="network-name">{iface.name}</strong>
|
||||
{iface.ipAddress && (
|
||||
<span className="network-ip has-ip">
|
||||
{iface.ipAddress}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="network-details">
|
||||
{(iface.macAddress || iface.ipAddress) && (
|
||||
<div className="network-detail-row">
|
||||
{iface.macAddress && (
|
||||
<div className="network-detail-item">
|
||||
<span className="network-detail-label">MAC 地址</span>
|
||||
<span className="network-detail-value">{iface.macAddress}</span>
|
||||
</div>
|
||||
)}
|
||||
{iface.ipAddress && (
|
||||
<div className="network-detail-item">
|
||||
<span className="network-detail-label">IP 地址</span>
|
||||
<span className="network-detail-value">{iface.ipAddress}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="network-traffic">
|
||||
<div className="traffic-item">
|
||||
<div className="traffic-header">
|
||||
<span className="traffic-label">接收流量</span>
|
||||
<span className="traffic-value">{formatBytes(iface.rxBytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="traffic-item">
|
||||
<div className="traffic-header">
|
||||
<span className="traffic-label">发送流量</span>
|
||||
<span className="traffic-value">{formatBytes(iface.txBytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div key={index} className="network-compact__row" role="row">
|
||||
<strong className="network-compact__iface" role="cell">
|
||||
{iface.name}
|
||||
</strong>
|
||||
<span
|
||||
className={
|
||||
iface.ipAddress && iface.ipAddress !== 'N/A'
|
||||
? 'network-compact__ip network-compact__ip--ok'
|
||||
: 'network-compact__placeholder'
|
||||
}
|
||||
role="cell"
|
||||
>
|
||||
{iface.ipAddress && iface.ipAddress !== 'N/A' ? iface.ipAddress : '—'}
|
||||
</span>
|
||||
<span
|
||||
className="network-compact__mac"
|
||||
role="cell"
|
||||
title={iface.macAddress || undefined}
|
||||
>
|
||||
{iface.macAddress || '—'}
|
||||
</span>
|
||||
<span className="network-compact__num" role="cell" title="接收流量">
|
||||
{formatBytes(iface.rxBytes)}
|
||||
</span>
|
||||
<span className="network-compact__num" role="cell" title="发送流量">
|
||||
{formatBytes(iface.txBytes)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{metrics.system.topProcesses && metrics.system.topProcesses.length > 0 && (
|
||||
{((metrics.system.topProcesses && metrics.system.topProcesses.length > 0) ||
|
||||
(metrics.system.dockerStats && metrics.system.dockerStats.available)) && (
|
||||
<section className="detail-section">
|
||||
<h3>Top 5 进程</h3>
|
||||
<div className="process-list">
|
||||
{metrics.system.topProcesses.map((proc, index) => (
|
||||
<div key={index} className="process-item">
|
||||
<div className="process-rank">{index + 1}</div>
|
||||
<div className="process-content">
|
||||
<div className="process-header">
|
||||
<div className="process-name-group">
|
||||
<strong className="process-name">{proc.name}</strong>
|
||||
<span className="process-pid">PID: {proc.pid}</span>
|
||||
</div>
|
||||
<h3 className="detail-category-heading">进程与服务</h3>
|
||||
{metrics.system.topProcesses && metrics.system.topProcesses.length > 0 && (
|
||||
<div className="detail-subsection">
|
||||
<h4 className="detail-subheading">资源占用较高的进程(前 10)</h4>
|
||||
<div className="process-compact" role="table" aria-label="进程占用">
|
||||
<div className="process-compact__head" role="row">
|
||||
<span role="columnheader">#</span>
|
||||
<span role="columnheader">进程</span>
|
||||
<span role="columnheader">PID</span>
|
||||
<span role="columnheader">CPU</span>
|
||||
<span role="columnheader">内存</span>
|
||||
</div>
|
||||
{metrics.system.topProcesses.map((proc, index) => (
|
||||
<div key={index} className="process-compact__row" role="row">
|
||||
<span className="process-compact__rank" role="cell">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="process-compact__name" role="cell" title={proc.name}>
|
||||
{proc.name}
|
||||
</span>
|
||||
<span className="process-compact__pid" role="cell">
|
||||
{proc.pid}
|
||||
</span>
|
||||
<span className="process-compact__num" role="cell">
|
||||
{proc.cpu}%
|
||||
</span>
|
||||
<span className="process-compact__num process-compact__mem" role="cell">
|
||||
{proc.memory}%
|
||||
<span className="process-compact__mem-abs"> {proc.memoryMB.toFixed(1)} MB</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="process-metrics">
|
||||
<div className="process-metric-item">
|
||||
<div className="metric-header">
|
||||
<span className="metric-label">CPU</span>
|
||||
<span className="metric-value">{proc.cpu}%</span>
|
||||
</div>
|
||||
<div className="metric-bar-container">
|
||||
<div
|
||||
className="metric-bar cpu-bar"
|
||||
style={{ width: `${Math.min(proc.cpu, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="process-metric-item">
|
||||
<div className="metric-header">
|
||||
<span className="metric-label">内存</span>
|
||||
<span className="metric-value">{proc.memory}% ({proc.memoryMB.toFixed(1)} MB)</span>
|
||||
</div>
|
||||
<div className="metric-bar-container">
|
||||
<div
|
||||
className="metric-bar memory-bar"
|
||||
style={{ width: `${Math.min(proc.memory, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats && metrics.system.dockerStats.available && (
|
||||
<div className="detail-subsection">
|
||||
<h4 className="detail-subheading">Docker</h4>
|
||||
{metrics.system.dockerStats.version && (
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="item-label">Docker 版本</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.version}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">运行中的容器</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.running}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">停止的容器</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.stopped}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">镜像数量</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.imageCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats && metrics.system.dockerStats.available && (
|
||||
<section className="detail-section">
|
||||
<h3>Docker 信息</h3>
|
||||
{metrics.system.dockerStats.version && (
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="item-label">Docker 版本</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.version}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">运行中的容器</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.running}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">停止的容器</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.stopped}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">镜像数量</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.imageCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats.runningNames && metrics.system.dockerStats.runningNames.length > 0 && (
|
||||
<div className="docker-containers-section">
|
||||
<h4>运行中的容器</h4>
|
||||
<div className="docker-names-list">
|
||||
{metrics.system.dockerStats.runningNames.map((name, index) => (
|
||||
<span key={index} className="docker-name-tag running">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats.stoppedNames && metrics.system.dockerStats.stoppedNames.length > 0 && (
|
||||
<div className="docker-containers-section">
|
||||
<h4>停止的容器</h4>
|
||||
<div className="docker-names-list">
|
||||
{metrics.system.dockerStats.stoppedNames.map((name, index) => (
|
||||
<span key={index} className="docker-name-tag stopped">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats.imageNames && metrics.system.dockerStats.imageNames.length > 0 && (
|
||||
<div className="docker-containers-section">
|
||||
<h4>镜像列表</h4>
|
||||
<div className="docker-names-list">
|
||||
{metrics.system.dockerStats.imageNames.map((name, index) => (
|
||||
<span key={index} className="docker-name-tag image">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{metrics.system.dockerStats.runningNames && metrics.system.dockerStats.runningNames.length > 0 && (
|
||||
<div className="docker-containers-section">
|
||||
<h4 className="detail-subheading detail-subheading--minor">运行中的容器</h4>
|
||||
<div className="docker-names-list">
|
||||
{metrics.system.dockerStats.runningNames.map((name, index) => (
|
||||
<span key={index} className="docker-name-tag running">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats.stoppedNames && metrics.system.dockerStats.stoppedNames.length > 0 && (
|
||||
<div className="docker-containers-section">
|
||||
<h4 className="detail-subheading detail-subheading--minor">停止的容器</h4>
|
||||
<div className="docker-names-list">
|
||||
{metrics.system.dockerStats.stoppedNames.map((name, index) => (
|
||||
<span key={index} className="docker-name-tag stopped">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats.imageNames && metrics.system.dockerStats.imageNames.length > 0 && (
|
||||
<div className="docker-containers-section">
|
||||
<h4 className="detail-subheading detail-subheading--minor">镜像列表</h4>
|
||||
<div className="docker-names-list">
|
||||
{metrics.system.dockerStats.imageNames.map((name, index) => (
|
||||
<span key={index} className="docker-name-tag image">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -504,7 +516,8 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
|
||||
{metrics.system.systemLogs && metrics.system.systemLogs.length > 0 && (
|
||||
<section className="detail-section">
|
||||
<h3>系统日志</h3>
|
||||
<h3 className="detail-category-heading">诊断与日志</h3>
|
||||
<h4 className="detail-subheading">系统日志</h4>
|
||||
<div className="logs-container">
|
||||
{metrics.system.systemLogs.map((log, index) => (
|
||||
<div key={index} className="log-line">
|
||||
|
||||
37
mengyamonitor-frontend/src/hooks/usePublicServers.ts
Normal file
37
mengyamonitor-frontend/src/hooks/usePublicServers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { ServerConfig } from '../types';
|
||||
import { fetchPublicServers } from '../api/central';
|
||||
|
||||
export const usePublicServers = (refreshMs: number = 30000) => {
|
||||
const [servers, setServers] = useState<ServerConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const list = await fetchPublicServers();
|
||||
setServers(
|
||||
list.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
url: s.url.replace(/\/$/, ''),
|
||||
enabled: s.enabled,
|
||||
}))
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败');
|
||||
setServers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const t = setInterval(() => void reload(), refreshMs);
|
||||
return () => clearInterval(t);
|
||||
}, [reload, refreshMs]);
|
||||
|
||||
return { servers, loading, error, reload };
|
||||
};
|
||||
@@ -1,58 +1,107 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { ServerConfig, ServerStatus } from '../types';
|
||||
import { fetchServerMetrics } from '../api/monitor';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ServerStatus } from '../types';
|
||||
import { centralApiBase } from '../api/central';
|
||||
|
||||
export const useServerMonitor = (servers: ServerConfig[], interval: number = 2000) => {
|
||||
function monitorWsURL(): string {
|
||||
const base = centralApiBase();
|
||||
if (base.startsWith('http://') || base.startsWith('https://')) {
|
||||
const u = new URL(base);
|
||||
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
u.pathname = (u.pathname.replace(/\/$/, '') + '/api/ws/monitor').replace(/\/+/g, '/');
|
||||
if (!u.pathname.startsWith('/')) u.pathname = '/' + u.pathname;
|
||||
return u.toString();
|
||||
}
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const path = `${base.replace(/\/$/, '')}/api/ws/monitor`.replace(/\/+/g, '/');
|
||||
return `${proto}//${window.location.host}${path.startsWith('/') ? path : '/' + path}`;
|
||||
}
|
||||
|
||||
function detachWebSocket(ws: WebSocket) {
|
||||
ws.onmessage = null;
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
ws.onopen = null;
|
||||
}
|
||||
|
||||
/** Live metrics from the central WebSocket snapshot (gRPC-backed agents only). */
|
||||
export const useServerMonitor = () => {
|
||||
const [statuses, setStatuses] = useState<Record<string, ServerStatus>>({});
|
||||
|
||||
const fetchMetrics = useCallback(async (server: ServerConfig) => {
|
||||
if (!server.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const metrics = await fetchServerMetrics(server.url);
|
||||
setStatuses(prev => ({
|
||||
...prev,
|
||||
[server.id]: {
|
||||
serverId: server.id,
|
||||
online: true,
|
||||
metrics,
|
||||
lastUpdate: Date.now(),
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
setStatuses(prev => ({
|
||||
...prev,
|
||||
[server.id]: {
|
||||
serverId: server.id,
|
||||
online: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
lastUpdate: Date.now(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial fetch
|
||||
servers.forEach(server => {
|
||||
if (server.enabled) {
|
||||
fetchMetrics(server);
|
||||
let cancelled = false;
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let socket: WebSocket | null = null;
|
||||
const url = monitorWsURL();
|
||||
|
||||
const clearPending = () => {
|
||||
if (pendingTimer !== undefined) {
|
||||
clearTimeout(pendingTimer);
|
||||
pendingTimer = undefined;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Set up polling
|
||||
const timer = setInterval(() => {
|
||||
servers.forEach(server => {
|
||||
if (server.enabled) {
|
||||
fetchMetrics(server);
|
||||
const scheduleConnect = (delayMs: number) => {
|
||||
clearPending();
|
||||
pendingTimer = window.setTimeout(() => {
|
||||
pendingTimer = undefined;
|
||||
if (!cancelled) openSocket();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const openSocket = () => {
|
||||
if (cancelled) return;
|
||||
clearPending();
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
} catch {
|
||||
if (!cancelled) scheduleConnect(2000);
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = socket;
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data as string) as {
|
||||
type?: string;
|
||||
data?: Record<string, ServerStatus>;
|
||||
};
|
||||
if (msg.type === 'snapshot' && msg.data && typeof msg.data === 'object') {
|
||||
setStatuses((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [id, st] of Object.entries(msg.data!)) {
|
||||
if (st && typeof st === 'object' && 'serverId' in st) {
|
||||
next[id] = st as ServerStatus;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore bad frame */
|
||||
}
|
||||
});
|
||||
}, interval);
|
||||
};
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [servers, interval, fetchMetrics]);
|
||||
ws.onclose = () => {
|
||||
socket = null;
|
||||
if (cancelled) return;
|
||||
scheduleConnect(2000);
|
||||
};
|
||||
};
|
||||
|
||||
// 推迟到下个 macrotask,避免 React StrictMode 开发环境下首次 effect 立即卸载时
|
||||
// 在 CONNECTING 阶段 close() 触发浏览器 “closed before connection is established” 噪声。
|
||||
scheduleConnect(0);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearPending();
|
||||
if (socket) {
|
||||
detachWebSocket(socket);
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return statuses;
|
||||
};
|
||||
|
||||
@@ -13,42 +13,49 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Theme Colors - 淡绿色/淡黄绿色渐变 */
|
||||
--primary-color: #86efac;
|
||||
--primary-hover: #6ee7b7;
|
||||
--secondary-color: #a7f3d0;
|
||||
/* Pastel / glass accents(参考玻璃拟态面板) */
|
||||
--primary-color: #7dd3fc;
|
||||
--primary-hover: #38bdf8;
|
||||
--secondary-color: #c4b5fd;
|
||||
--accent-rose: #f9a8d4;
|
||||
--warning-color: #fde047;
|
||||
--danger-color: #f87171;
|
||||
--success-color: #86efac;
|
||||
|
||||
/* Backgrounds - 纯白色 */
|
||||
--bg-color: #ffffff;
|
||||
--surface-color: #ffffff;
|
||||
--surface-hover: #ffffff;
|
||||
|
||||
--success-color: #6ee7b7;
|
||||
|
||||
/* Glass surfaces(--bg-color 用于表单、凹槽等需可读底色的区域) */
|
||||
--bg-color: rgba(255, 255, 255, 0.72);
|
||||
--surface-color: rgba(255, 255, 255, 0.52);
|
||||
--surface-hover: rgba(255, 255, 255, 0.64);
|
||||
--glass-border: rgba(255, 255, 255, 0.45);
|
||||
--glass-shadow: 0 8px 32px rgba(99, 102, 241, 0.08), 0 2px 12px rgba(15, 23, 42, 0.06);
|
||||
--backdrop-blur: blur(12px);
|
||||
|
||||
/* Text */
|
||||
--text-main: #0f172a;
|
||||
--text-main: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-light: #94a3b8;
|
||||
|
||||
/* Borders & Shadows - 灰白色边框 */
|
||||
--border-color: #e2e8f0;
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 1rem;
|
||||
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
|
||||
--border-color: rgba(255, 255, 255, 0.55);
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
--shadow-md: var(--glass-shadow);
|
||||
--shadow-lg: 0 16px 48px rgba(99, 102, 241, 0.12), 0 4px 16px rgba(15, 23, 42, 0.08);
|
||||
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-glass: 0.625rem;
|
||||
|
||||
--font-ui:
|
||||
system-ui, -apple-system, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: 100%;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light;
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-color);
|
||||
background-color: transparent;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -60,7 +67,13 @@ body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-color);
|
||||
background-color: rgba(250, 245, 255, 0.4);
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
@@ -24,6 +24,13 @@ export interface MemoryMetrics {
|
||||
usedBytes: number;
|
||||
freeBytes: number;
|
||||
usedPercent: number;
|
||||
/** 交换分区 / swap 总量(字节),由新版采集端上报 */
|
||||
swapTotalBytes?: number;
|
||||
swapUsedBytes?: number;
|
||||
swapFreeBytes?: number;
|
||||
swapUsedPercent?: number;
|
||||
/** file / partition / zram 等,来自 /proc/swaps;「无」表示未配置 swap */
|
||||
swapType?: string;
|
||||
}
|
||||
|
||||
export interface StorageMetrics {
|
||||
@@ -55,6 +62,10 @@ export interface NetworkInterface {
|
||||
|
||||
export interface SystemStats {
|
||||
processCount: number;
|
||||
/** 当前登录会话数(who 行数);新版采集端上报 */
|
||||
loggedInUsers?: number;
|
||||
/** TCP 套接字数(/proc/net/tcp + tcp6,含监听与各状态);新版采集端上报 */
|
||||
tcpConnections?: number;
|
||||
packageCount: number;
|
||||
packageManager: string;
|
||||
temperature?: number;
|
||||
|
||||
@@ -43,3 +43,10 @@ export const formatUptime = (seconds: number): string => {
|
||||
export const formatPercent = (value: number): string => {
|
||||
return `${Math.round(value * 10) / 10}%`;
|
||||
};
|
||||
|
||||
/** 采集端上报的毫秒延迟(与 Go 侧 TCP 探测展示一致) */
|
||||
export const formatLatencyMs = (ms: number): string => {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '超时';
|
||||
if (ms < 1) return `${ms.toFixed(1)} ms`;
|
||||
return `${Math.round(ms)} ms`;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['logo.svg'],
|
||||
includeAssets: ['logo.png', 'logo2.png', 'favicon.ico'],
|
||||
manifest: {
|
||||
name: '萌芽监控面板',
|
||||
short_name: '萌芽监控',
|
||||
@@ -20,12 +20,15 @@ export default defineConfig({
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{ src: '/logo.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' }
|
||||
{ src: '/logo.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/logo2.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||
],
|
||||
categories: ['utilities', 'productivity']
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,svg,woff2}'],
|
||||
// 避免开发/生产 SPA 回退误吞 /central-api 转发
|
||||
navigateFallbackDenylist: [/^\/central-api(?:\/|$)/],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/.*\/api\//i,
|
||||
@@ -47,5 +50,13 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 2929,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/central-api': {
|
||||
target: 'http://127.0.0.1:9393',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewrite: (path) => path.replace(/^\/central-api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user