mirror of
https://github.com/fankes/komari-theme-purcarte.git
synced 2025-10-19 03:49:22 +08:00
feat(theme): 新增主题可配置项,优化代码逻辑和样式
- 在 `komari-theme.json` 中添加了新的配置选项 - 支持自定义标题栏、内容区、实例页面和通用UI元素 - 优化部分组件调用逻辑 - 优化页面样式
This commit is contained in:
15
README.md
15
README.md
@@ -13,15 +13,6 @@
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 本主题在 Gemini 的辅助下完成,融合了官方主题的部分设计与个人审美偏好,旨在提供一种简洁、美观的磨砂玻璃质感界面
|
> 本主题在 Gemini 的辅助下完成,融合了官方主题的部分设计与个人审美偏好,旨在提供一种简洁、美观的磨砂玻璃质感界面
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
>
|
|
||||||
> **当前版本注意事项**
|
|
||||||
>
|
|
||||||
> - [ ] `Instance` 页面尚在完善中,目前仅基于官方样式进行了微调
|
|
||||||
> - [x] 延迟信息图表的有较大问题仍需优化(优化完成)
|
|
||||||
>
|
|
||||||
> 如果您对以上页面的功能和展示有较高要求,建议暂时选用 [社区中的其他主题](https://komari-document.pages.dev/community/theme)。
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
### 安装与启用
|
### 安装与启用
|
||||||
@@ -35,10 +26,12 @@
|
|||||||
|
|
||||||
#### Komari v1.0.5 及以上版本
|
#### Komari v1.0.5 及以上版本
|
||||||
|
|
||||||
如果 Komari 版本为 v1.0.5 或更高版本,可直接在 `Komari 后台 > PurCarte设置` 中配置背景图片等主题选项,无需手动添加自定义代码
|
如果 Komari 版本为 v1.0.5 或更高版本,可直接在 `Komari 后台 > PurCarte设置` 中配置背景图片等主题选项,无需手动添加自定义代码,如已添加自定义代码需要删去背景相关 style 避免干扰
|
||||||
|
|
||||||
#### 旧版本配置方法
|
#### 旧版本配置方法
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
对于旧版本,请在 `Komari 后台 > 设置 > 站点 > 自定义 Body` 处添加以下代码并保存:
|
对于旧版本,请在 `Komari 后台 > 设置 > 站点 > 自定义 Body` 处添加以下代码并保存:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
@@ -57,6 +50,8 @@
|
|||||||
</style>
|
</style>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## 🛠️ 本地开发
|
## 🛠️ 本地开发
|
||||||
|
|
||||||
1. **克隆仓库**
|
1. **克隆仓库**
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
"name": "Komari Theme PurCart",
|
"name": "Komari Theme PurCart",
|
||||||
"short": "PurCarte",
|
"short": "PurCarte",
|
||||||
"description": "A frosted glass theme for Komari",
|
"description": "A frosted glass theme for Komari",
|
||||||
"version": "0.1.3",
|
"version": "1.0.0",
|
||||||
"author": "Montia & Gemini",
|
"author": "Montia & Gemini",
|
||||||
"url": "https://github.com/Montia37/Komari-theme-purcarte",
|
"url": "https://github.com/Montia37/Komari-theme-purcarte",
|
||||||
"preview": "preview.png",
|
"preview": "preview.png",
|
||||||
@@ -10,16 +10,135 @@
|
|||||||
"type": "managed",
|
"type": "managed",
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"name": "背景",
|
"name": "样式调整",
|
||||||
"type": "title"
|
"type": "title"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "backgroundImage",
|
"key": "backgroundImage",
|
||||||
"name": "背景图片链接",
|
"name": "背景图片链接",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"required": false,
|
|
||||||
"default": "/assets/Moonlit-Scenery.webp",
|
"default": "/assets/Moonlit-Scenery.webp",
|
||||||
"help": "目前仅支持单张背景图片(eg: https://test.com/1.png)"
|
"help": "目前仅支持单张背景图片(eg: https://test.com/1.png)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "tagDefaultColorList",
|
||||||
|
"name": "标签默认颜色列表",
|
||||||
|
"type": "string",
|
||||||
|
"default": "ruby,gray,gold,bronze,brown,yellow,amber,orange,tomato,red,crimson,pink,plum,purple,violet,iris,indigo,blue,cyan,teal,jade,green,grass,lime,mint,sky",
|
||||||
|
"help": "标签默认颜色列表,用于修改默认解析颜色顺序以及使用的颜色池,逗号分隔(可用的颜色列表请参考:https://www.radix-ui.com/themes/docs/theme/color,改完没有生效则说明填写有误)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "标题栏设置",
|
||||||
|
"type": "title"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enableLogo",
|
||||||
|
"name": "启用标题栏左侧 Logo",
|
||||||
|
"type": "switch",
|
||||||
|
"default": false,
|
||||||
|
"help": "启用后默认在标题栏左侧显示 Logo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "logoUrl",
|
||||||
|
"name": "Logo 图片链接",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"default": "/assets/logo.png",
|
||||||
|
"help": "Logo 图片链接(eg: https://test.com/logo.png)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enableTitle",
|
||||||
|
"name": "启用标题栏标题",
|
||||||
|
"type": "switch",
|
||||||
|
"default": true,
|
||||||
|
"help": "启用后默认在顶栏左侧显示标题"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "titleText",
|
||||||
|
"name": "标题栏标题文本",
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"help": "标题栏左侧显示的文本(留空则使用站点标题)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enableSearchButton",
|
||||||
|
"name": "启用标题栏按钮",
|
||||||
|
"type": "switch",
|
||||||
|
"default": true,
|
||||||
|
"help": "启用后默认在标题栏右侧显示搜索按钮"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "selectedDefaultView",
|
||||||
|
"name": "默认展示视图",
|
||||||
|
"type": "select",
|
||||||
|
"options": [
|
||||||
|
"grid",
|
||||||
|
"table"
|
||||||
|
],
|
||||||
|
"default": "grid",
|
||||||
|
"help": "设置默认展示视图为网格或表格(优先使用 localStorage)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "selectedDefaultAppearance",
|
||||||
|
"name": "默认外观",
|
||||||
|
"type": "select",
|
||||||
|
"options": [
|
||||||
|
"light",
|
||||||
|
"dark",
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
"default": "system",
|
||||||
|
"help": "设置默认外观为浅色、深色或系统主题(优先使用 localStorage)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enableAdminButton",
|
||||||
|
"name": "启用管理按钮",
|
||||||
|
"type": "switch",
|
||||||
|
"default": true,
|
||||||
|
"help": "启用后默认在标题栏右侧显示管理按钮"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "内容设置",
|
||||||
|
"type": "title"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enableStatsBar",
|
||||||
|
"name": "启用统计栏",
|
||||||
|
"type": "switch",
|
||||||
|
"default": true,
|
||||||
|
"help": "启用后默认显示统计栏"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enableGroupedBar",
|
||||||
|
"name": "启用分组栏",
|
||||||
|
"type": "switch",
|
||||||
|
"default": true,
|
||||||
|
"help": "启用后默认显示分组栏"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Instance 设置",
|
||||||
|
"type": "title"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enableInstanceDetail",
|
||||||
|
"name": "启用 Instance 详情信息",
|
||||||
|
"type": "switch",
|
||||||
|
"default": true,
|
||||||
|
"help": "启用后默认显示 Instance 详情"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enablePingChart",
|
||||||
|
"name": "启用延迟图表",
|
||||||
|
"type": "switch",
|
||||||
|
"default": true,
|
||||||
|
"help": "启用后默认显示延迟图表"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "pingChatrtMaxPoints",
|
||||||
|
"name": "延迟图表最大渲染点数",
|
||||||
|
"type": "number",
|
||||||
|
"default": 0,
|
||||||
|
"help": "设置延迟图表的最大渲染点数来优化图表渲染,0 表示不限制,推荐值为 2000 或更小的值"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@@ -1,39 +0,0 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { BACKGROUND } from "@/config/default";
|
|
||||||
import type { PublicInfo } from "@/types/node.d";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 动态效果不佳,暂时仅使用静态背景
|
|
||||||
*/
|
|
||||||
interface ThemeSettings {
|
|
||||||
backgroundImage?: string; // 背景图片URL
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BackgroundProps {
|
|
||||||
publicSettings: PublicInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Background({ publicSettings }: BackgroundProps) {
|
|
||||||
const theme = (publicSettings?.theme_settings as ThemeSettings) || {};
|
|
||||||
|
|
||||||
// 使用 useMemo 缓存背景图片列表,避免每次渲染时重新计算
|
|
||||||
const imageUrl = useMemo(() => {
|
|
||||||
return theme.backgroundImage
|
|
||||||
? theme.backgroundImage
|
|
||||||
: BACKGROUND.backgroundImage;
|
|
||||||
}, [theme.backgroundImage]);
|
|
||||||
|
|
||||||
// 背景切换逻辑
|
|
||||||
useEffect(() => {
|
|
||||||
// 当当前图片URL变化时,更新CSS变量
|
|
||||||
document.body.style.setProperty(
|
|
||||||
"--body-background-url",
|
|
||||||
`url(${imageUrl})`
|
|
||||||
);
|
|
||||||
}, [imageUrl]);
|
|
||||||
|
|
||||||
// 此组件不渲染任何可见内容
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Background;
|
|
@@ -8,16 +8,16 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
CircleUserIcon,
|
CircleUserIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { useIsMobile } from "@/hooks/useMobile";
|
import { useIsMobile } from "@/hooks/useMobile";
|
||||||
|
import { useConfigItem } from "@/config";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
viewMode: "card" | "list";
|
viewMode: "grid" | "table";
|
||||||
setViewMode: (mode: "card" | "list") => void;
|
setViewMode: (mode: "grid" | "table") => void;
|
||||||
theme: string;
|
theme: string;
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
sitename: string;
|
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
setSearchTerm: (term: string) => void;
|
setSearchTerm: (term: string) => void;
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,6 @@ export const Header = ({
|
|||||||
setViewMode,
|
setViewMode,
|
||||||
theme,
|
theme,
|
||||||
toggleTheme,
|
toggleTheme,
|
||||||
sitename,
|
|
||||||
searchTerm,
|
searchTerm,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
}: HeaderProps) => {
|
}: HeaderProps) => {
|
||||||
@@ -35,13 +34,30 @@ export const Header = ({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isInstancePage = location.pathname.startsWith("/instance");
|
const isInstancePage = location.pathname.startsWith("/instance");
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const enableTitle = useConfigItem("enableTitle");
|
||||||
|
const sitename = useConfigItem("titleText");
|
||||||
|
const enableLogo = useConfigItem("enableLogo");
|
||||||
|
const logoUrl = useConfigItem("logoUrl");
|
||||||
|
const enableSearchButton = useConfigItem("enableSearchButton");
|
||||||
|
const enableAdminButton = useConfigItem("enableAdminButton");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sitename) {
|
||||||
|
document.title = sitename;
|
||||||
|
}
|
||||||
|
}, [sitename]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-background/60 backdrop-blur-[10px] border-b border-border/60 sticky top-0 flex items-center justify-center shadow-sm z-10">
|
<header className="bg-background/60 backdrop-blur-[10px] border-b border-border/60 sticky top-0 flex items-center justify-center shadow-sm z-10">
|
||||||
<div className="w-[90%] max-w-screen-2xl px-4 py-2 flex items-center justify-between">
|
<div className="w-[90%] max-w-screen-2xl px-4 py-2 flex items-center justify-between">
|
||||||
<div className="flex items-center text-shadow-lg text-accent-foreground">
|
<div className="flex items-center text-shadow-lg text-accent-foreground">
|
||||||
<a href="/" className="text-2xl font-bold">
|
<a href="/" className="flex items-center gap-2 text-2xl font-bold">
|
||||||
{sitename}
|
{enableLogo && logoUrl && (
|
||||||
|
<img src={logoUrl} alt="logo" className="h-8" />
|
||||||
|
)}
|
||||||
|
{enableTitle && (
|
||||||
|
<span className="hidden md:inline">{sitename}</span>
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -81,19 +97,21 @@ export const Header = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
{enableSearchButton && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
onClick={() => setIsSearchOpen(!isSearchOpen)}>
|
size="icon"
|
||||||
<Search className="size-5 text-primary" />
|
onClick={() => setIsSearchOpen(!isSearchOpen)}>
|
||||||
</Button>
|
<Search className="size-5 text-primary" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setViewMode(viewMode === "card" ? "list" : "card")
|
setViewMode(viewMode === "grid" ? "table" : "grid")
|
||||||
}>
|
}>
|
||||||
{viewMode === "card" ? (
|
{viewMode === "grid" ? (
|
||||||
<Table2 className="size-5 text-primary" />
|
<Table2 className="size-5 text-primary" />
|
||||||
) : (
|
) : (
|
||||||
<Grid3X3 className="size-5 text-primary" />
|
<Grid3X3 className="size-5 text-primary" />
|
||||||
@@ -108,11 +126,13 @@ export const Header = ({
|
|||||||
<Moon className="size-5 text-primary" />
|
<Moon className="size-5 text-primary" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<a href="/admin" target="_blank" rel="noopener noreferrer">
|
{enableAdminButton && (
|
||||||
<Button variant="ghost" size="icon">
|
<a href="/admin" target="_blank" rel="noopener noreferrer">
|
||||||
<CircleUserIcon className="size-5 text-primary" />
|
<Button variant="ghost" size="icon">
|
||||||
</Button>
|
<CircleUserIcon className="size-5 text-primary" />
|
||||||
</a>
|
</Button>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
@@ -6,25 +6,12 @@ import { CpuIcon, MemoryStickIcon, HardDriveIcon } from "lucide-react";
|
|||||||
import Flag from "./Flag";
|
import Flag from "./Flag";
|
||||||
import { Tag } from "../ui/tag";
|
import { Tag } from "../ui/tag";
|
||||||
import { useNodeCommons } from "@/hooks/useNodeCommons";
|
import { useNodeCommons } from "@/hooks/useNodeCommons";
|
||||||
|
import { ProgressBar } from "../ui/progress-bar";
|
||||||
|
|
||||||
interface NodeCardProps {
|
interface NodeCardProps {
|
||||||
node: NodeWithStatus;
|
node: NodeWithStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProgressBar = ({
|
|
||||||
value,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
className?: string;
|
|
||||||
}) => (
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3 dark:bg-gray-700">
|
|
||||||
<div
|
|
||||||
className={`h-3 rounded-full ${className}`}
|
|
||||||
style={{ width: `${value}%` }}></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const NodeCard = ({ node }: NodeCardProps) => {
|
export const NodeCard = ({ node }: NodeCardProps) => {
|
||||||
const {
|
const {
|
||||||
stats,
|
stats,
|
||||||
|
13
src/components/ui/progress-bar.tsx
Normal file
13
src/components/ui/progress-bar.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const ProgressBar = ({
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-3 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
className={`h-3 rounded-full transition-all duration-500 ${className}`}
|
||||||
|
style={{ width: `${value}%` }}></div>
|
||||||
|
</div>
|
||||||
|
);
|
@@ -1,12 +1,14 @@
|
|||||||
import { Badge } from "@radix-ui/themes";
|
import { Badge } from "@radix-ui/themes";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "@/utils";
|
import { cn } from "@/utils";
|
||||||
|
import { useConfigItem } from "@/config";
|
||||||
|
|
||||||
interface TagProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface TagProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const colors: Array<
|
// 定义颜色类型
|
||||||
|
type ColorType =
|
||||||
| "ruby"
|
| "ruby"
|
||||||
| "gray"
|
| "gray"
|
||||||
| "gold"
|
| "gold"
|
||||||
@@ -32,65 +34,61 @@ const colors: Array<
|
|||||||
| "grass"
|
| "grass"
|
||||||
| "lime"
|
| "lime"
|
||||||
| "mint"
|
| "mint"
|
||||||
| "sky"
|
| "sky";
|
||||||
> = [
|
|
||||||
"ruby",
|
|
||||||
"gray",
|
|
||||||
"gold",
|
|
||||||
"bronze",
|
|
||||||
"brown",
|
|
||||||
"yellow",
|
|
||||||
"amber",
|
|
||||||
"orange",
|
|
||||||
"tomato",
|
|
||||||
"red",
|
|
||||||
"crimson",
|
|
||||||
"pink",
|
|
||||||
"plum",
|
|
||||||
"purple",
|
|
||||||
"violet",
|
|
||||||
"iris",
|
|
||||||
"indigo",
|
|
||||||
"blue",
|
|
||||||
"cyan",
|
|
||||||
"teal",
|
|
||||||
"jade",
|
|
||||||
"green",
|
|
||||||
"grass",
|
|
||||||
"lime",
|
|
||||||
"mint",
|
|
||||||
"sky",
|
|
||||||
];
|
|
||||||
|
|
||||||
|
// 默认颜色列表,将在组件内部使用
|
||||||
|
const defaultColorList: ColorType[] = [
|
||||||
|
"blue",
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"yellow",
|
||||||
|
"purple",
|
||||||
|
"cyan",
|
||||||
|
"orange",
|
||||||
|
"pink",
|
||||||
|
];
|
||||||
// 解析带颜色的标签
|
// 解析带颜色的标签
|
||||||
const parseTagWithColor = (tag: string) => {
|
const parseTagWithColor = (tag: string, availableColors: ColorType[]) => {
|
||||||
const colorMatch = tag.match(/<(\w+)>$/);
|
const colorMatch = tag.match(/<(\w+)>$/);
|
||||||
if (colorMatch) {
|
if (colorMatch) {
|
||||||
const color = colorMatch[1].toLowerCase();
|
const color = colorMatch[1].toLowerCase();
|
||||||
const text = tag.replace(/<\w+>$/, "");
|
const text = tag.replace(/<\w+>$/, "");
|
||||||
// 检查颜色是否在支持的颜色列表中
|
// 检查颜色是否在支持的颜色列表中
|
||||||
if (colors.includes(color as any)) {
|
if (availableColors.includes(color as ColorType)) {
|
||||||
return { text, color: color as (typeof colors)[number] };
|
return { text, color: color as ColorType };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { text: tag, color: null };
|
return { text: tag, color: null as unknown as ColorType | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
const Tag = React.forwardRef<HTMLDivElement, TagProps>(
|
const Tag = React.forwardRef<HTMLDivElement, TagProps>(
|
||||||
({ className, tags, ...props }, ref) => {
|
({ className, tags, ...props }, ref) => {
|
||||||
|
// 在组件内部使用 useConfigItem 钩子
|
||||||
|
const tagDefaultColorList = useConfigItem("tagDefaultColorList");
|
||||||
|
|
||||||
|
// 解析配置的颜色列表
|
||||||
|
const colorList = React.useMemo(() => {
|
||||||
|
if (!tagDefaultColorList) {
|
||||||
|
return defaultColorList;
|
||||||
|
}
|
||||||
|
return tagDefaultColorList
|
||||||
|
.split(",")
|
||||||
|
.map((color: string) => color.trim()) as ColorType[];
|
||||||
|
}, [tagDefaultColorList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("flex flex-wrap gap-1", className)}
|
className={cn("flex flex-wrap gap-1", className)}
|
||||||
{...props}>
|
{...props}>
|
||||||
{tags.map((tag, index) => {
|
{tags.map((tag, index) => {
|
||||||
const { text, color } = parseTagWithColor(tag);
|
const { text, color } = parseTagWithColor(tag, colorList);
|
||||||
const badgeColor = color || colors[index % colors.length];
|
const badgeColor = color || colorList[index % colorList.length];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
key={index}
|
key={index}
|
||||||
color={badgeColor}
|
color={badgeColor as ColorType}
|
||||||
variant="soft"
|
variant="soft"
|
||||||
className="text-sm">
|
className="text-sm">
|
||||||
<label className="text-xs">{text}</label>
|
<label className="text-xs">{text}</label>
|
||||||
|
6
src/config/ConfigContext.ts
Normal file
6
src/config/ConfigContext.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
import type { ConfigOptions } from "./default";
|
||||||
|
import { DEFAULT_CONFIG } from "./default";
|
||||||
|
|
||||||
|
// 创建配置上下文
|
||||||
|
export const ConfigContext = createContext<ConfigOptions>(DEFAULT_CONFIG);
|
73
src/config/ConfigProvider.tsx
Normal file
73
src/config/ConfigProvider.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { type ReactNode, useEffect, useMemo } from "react";
|
||||||
|
import type { PublicInfo } from "@/types/node.d";
|
||||||
|
import { ConfigContext } from "./ConfigContext";
|
||||||
|
import { DEFAULT_CONFIG, type ConfigOptions } from "./default";
|
||||||
|
|
||||||
|
// 配置提供者属性类型
|
||||||
|
interface ConfigProviderProps {
|
||||||
|
publicSettings: PublicInfo | null; // 公共设置,可能为 null
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置提供者组件,用于将配置传递给子组件
|
||||||
|
*/
|
||||||
|
export function ConfigProvider({
|
||||||
|
publicSettings,
|
||||||
|
children,
|
||||||
|
}: ConfigProviderProps) {
|
||||||
|
const theme = useMemo(() => {
|
||||||
|
return (publicSettings?.theme_settings as ConfigOptions) || {};
|
||||||
|
}, [publicSettings?.theme_settings]);
|
||||||
|
|
||||||
|
// 使用 useMemo 缓存背景图片,避免每次渲染时重新计算
|
||||||
|
const backgroundImage = useMemo(() => {
|
||||||
|
return theme.backgroundImage || "";
|
||||||
|
}, [theme.backgroundImage]);
|
||||||
|
|
||||||
|
// 背景切换逻辑
|
||||||
|
useEffect(() => {
|
||||||
|
if (backgroundImage) {
|
||||||
|
document.body.style.setProperty(
|
||||||
|
"--body-background-url",
|
||||||
|
`url(${backgroundImage})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
document.body.style.removeProperty("--body-background-url");
|
||||||
|
}
|
||||||
|
}, [backgroundImage]);
|
||||||
|
|
||||||
|
const config: ConfigOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
tagDefaultColorList:
|
||||||
|
theme.tagDefaultColorList || DEFAULT_CONFIG.tagDefaultColorList,
|
||||||
|
enableLogo: theme.enableLogo ?? DEFAULT_CONFIG.enableLogo,
|
||||||
|
logoUrl: theme.logoUrl || DEFAULT_CONFIG.logoUrl,
|
||||||
|
enableTitle: theme.enableTitle ?? DEFAULT_CONFIG.enableTitle,
|
||||||
|
titleText: theme.titleText || DEFAULT_CONFIG.titleText,
|
||||||
|
enableSearchButton:
|
||||||
|
theme.enableSearchButton ?? DEFAULT_CONFIG.enableSearchButton,
|
||||||
|
selectedDefaultView:
|
||||||
|
theme.selectedDefaultView || DEFAULT_CONFIG.selectedDefaultView,
|
||||||
|
selectedDefaultAppearance:
|
||||||
|
theme.selectedDefaultAppearance ||
|
||||||
|
DEFAULT_CONFIG.selectedDefaultAppearance,
|
||||||
|
enableAdminButton:
|
||||||
|
theme.enableAdminButton ?? DEFAULT_CONFIG.enableAdminButton,
|
||||||
|
enableStatsBar: theme.enableStatsBar ?? DEFAULT_CONFIG.enableStatsBar,
|
||||||
|
enableGroupedBar:
|
||||||
|
theme.enableGroupedBar ?? DEFAULT_CONFIG.enableGroupedBar,
|
||||||
|
enableInstanceDetail:
|
||||||
|
theme.enableInstanceDetail ?? DEFAULT_CONFIG.enableInstanceDetail,
|
||||||
|
enablePingChart: theme.enablePingChart ?? DEFAULT_CONFIG.enablePingChart,
|
||||||
|
pingChartMaxPoints:
|
||||||
|
theme.pingChartMaxPoints || DEFAULT_CONFIG.pingChartMaxPoints,
|
||||||
|
backgroundImage,
|
||||||
|
}),
|
||||||
|
[theme, backgroundImage]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigContext.Provider value={config}>{children}</ConfigContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,5 +1,38 @@
|
|||||||
export const BACKGROUND = {
|
// 配置类型定义
|
||||||
backgroundImage: "",
|
export interface ConfigOptions {
|
||||||
// switchTime: 10, // 10 seconds
|
backgroundImage?: string; // 背景图片URL
|
||||||
// transition: "background-image 0.8s ease-in-out", // CSS transition for background change
|
tagDefaultColorList?: string; // 标签默认颜色列表
|
||||||
|
enableLogo?: boolean; // 是否启用Logo
|
||||||
|
logoUrl?: string; // Logo图片URL
|
||||||
|
enableTitle?: boolean; // 是否启用标题
|
||||||
|
titleText?: string; // 标题文本
|
||||||
|
enableSearchButton?: boolean; // 是否启用搜索按钮
|
||||||
|
selectedDefaultView?: "grid" | "table"; // 默认视图模式
|
||||||
|
selectedDefaultAppearance?: "light" | "dark" | "system"; // 默认外观模式
|
||||||
|
enableAdminButton?: boolean; // 是否启用管理员按钮
|
||||||
|
enableStatsBar?: boolean; // 是否启用统计栏
|
||||||
|
enableGroupedBar?: boolean; // 是否启用分组栏
|
||||||
|
enableInstanceDetail?: boolean; // 是否启用实例详情
|
||||||
|
enablePingChart?: boolean; // 是否启用延迟图表
|
||||||
|
pingChartMaxPoints?: number; // 延迟图表最大点数
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认配置值
|
||||||
|
export const DEFAULT_CONFIG: ConfigOptions = {
|
||||||
|
backgroundImage: "/assets/Moonlit-Scenery.webp",
|
||||||
|
tagDefaultColorList:
|
||||||
|
"ruby,gray,gold,bronze,brown,yellow,amber,orange,tomato,red,crimson,pink,plum,purple,violet,iris,indigo,blue,cyan,teal,jade,green,grass,lime,mint,sky",
|
||||||
|
enableLogo: false,
|
||||||
|
logoUrl: "/assets/logo.png",
|
||||||
|
enableTitle: true,
|
||||||
|
titleText: "Komari",
|
||||||
|
enableSearchButton: true,
|
||||||
|
selectedDefaultView: "grid",
|
||||||
|
selectedDefaultAppearance: "system",
|
||||||
|
enableAdminButton: true,
|
||||||
|
enableStatsBar: true,
|
||||||
|
enableGroupedBar: true,
|
||||||
|
enableInstanceDetail: true,
|
||||||
|
enablePingChart: true,
|
||||||
|
pingChartMaxPoints: 0,
|
||||||
};
|
};
|
||||||
|
26
src/config/hooks.ts
Normal file
26
src/config/hooks.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import type { ConfigOptions } from "./default";
|
||||||
|
import { ConfigContext } from "./ConfigContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用全局配置 Hook,用于获取当前应用配置
|
||||||
|
* @returns 配置对象
|
||||||
|
*/
|
||||||
|
export function useAppConfig(): ConfigOptions {
|
||||||
|
return useContext(ConfigContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用特定配置项 Hook,直接获取某个配置项的值
|
||||||
|
* @param key 配置项键名
|
||||||
|
* @returns 配置项的值
|
||||||
|
*/
|
||||||
|
export function useConfigItem<K extends keyof ConfigOptions>(
|
||||||
|
key: K
|
||||||
|
): ConfigOptions[K] {
|
||||||
|
const config = useContext(ConfigContext);
|
||||||
|
return config[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出配置类型
|
||||||
|
export type { ConfigOptions } from "./default";
|
5
src/config/index.ts
Normal file
5
src/config/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// 从各个文件中导出
|
||||||
|
export { ConfigProvider } from "./ConfigProvider";
|
||||||
|
export { useAppConfig, useConfigItem } from "./hooks";
|
||||||
|
export type { ConfigOptions } from "./hooks";
|
||||||
|
export { DEFAULT_CONFIG } from "./default";
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from "react";
|
|||||||
import { useNodeData } from "@/contexts/NodeDataContext";
|
import { useNodeData } from "@/contexts/NodeDataContext";
|
||||||
import type { HistoryRecord, NodeData, NodeStats } from "@/types/node";
|
import type { HistoryRecord, NodeData, NodeStats } from "@/types/node";
|
||||||
import { useLiveData } from "@/contexts/LiveDataContext";
|
import { useLiveData } from "@/contexts/LiveDataContext";
|
||||||
|
import fillMissingTimePoints from "@/utils/RecordHelper";
|
||||||
|
|
||||||
export const useLoadCharts = (node: NodeData | null, hours: number) => {
|
export const useLoadCharts = (node: NodeData | null, hours: number) => {
|
||||||
const { getLoadHistory, getRecentLoadHistory } = useNodeData();
|
const { getLoadHistory, getRecentLoadHistory } = useNodeData();
|
||||||
@@ -97,43 +98,60 @@ export const useLoadCharts = (node: NodeData | null, hours: number) => {
|
|||||||
});
|
});
|
||||||
}, [liveData, node?.uuid, isRealtime]);
|
}, [liveData, node?.uuid, isRealtime]);
|
||||||
|
|
||||||
const historicalChartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
return historicalData.map((record) => ({
|
const rawData = isRealtime ? realtimeData : historicalData;
|
||||||
|
const mappedData = rawData.map((record) => ({
|
||||||
|
...record,
|
||||||
time: new Date(record.time).getTime(),
|
time: new Date(record.time).getTime(),
|
||||||
cpu: record.cpu,
|
|
||||||
ram: record.ram,
|
|
||||||
disk: record.disk,
|
|
||||||
load: record.load,
|
|
||||||
net_out: record.net_out,
|
|
||||||
net_in: record.net_in,
|
|
||||||
connections: record.connections,
|
|
||||||
process: record.process,
|
|
||||||
swap: record.swap,
|
|
||||||
connections_udp: record.connections_udp,
|
|
||||||
}));
|
}));
|
||||||
}, [historicalData]);
|
|
||||||
|
|
||||||
const realtimeChartData = useMemo(() => {
|
if (isRealtime) {
|
||||||
return realtimeData.map((record) => ({
|
return mappedData;
|
||||||
time: new Date(record.time).getTime(),
|
}
|
||||||
cpu: record.cpu,
|
|
||||||
ram: record.ram,
|
const minute = 60;
|
||||||
disk: record.disk,
|
const hour = minute * 60;
|
||||||
load: record.load,
|
|
||||||
net_out: record.net_out,
|
const stringifiedData = mappedData.map((d) => ({
|
||||||
net_in: record.net_in,
|
...d,
|
||||||
connections: record.connections,
|
time: new Date(d.time).toISOString(),
|
||||||
process: record.process,
|
|
||||||
swap: record.swap,
|
|
||||||
connections_udp: record.connections_udp,
|
|
||||||
}));
|
}));
|
||||||
}, [realtimeData]);
|
|
||||||
|
|
||||||
const chartData = isRealtime ? realtimeChartData : historicalChartData;
|
let filledData;
|
||||||
|
if (hours === 1) {
|
||||||
|
filledData = fillMissingTimePoints(
|
||||||
|
stringifiedData,
|
||||||
|
minute,
|
||||||
|
hour,
|
||||||
|
minute * 2
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const interval = hours > 120 ? hour : minute * 15;
|
||||||
|
const maxGap = interval * 2;
|
||||||
|
filledData = fillMissingTimePoints(
|
||||||
|
stringifiedData,
|
||||||
|
interval,
|
||||||
|
hour * hours,
|
||||||
|
maxGap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return filledData.map((d) => ({ ...d, time: new Date(d.time!).getTime() }));
|
||||||
|
}, [isRealtime, realtimeData, historicalData, hours]);
|
||||||
|
|
||||||
|
const memoryChartData = useMemo(() => {
|
||||||
|
return chartData.map((item) => ({
|
||||||
|
...item,
|
||||||
|
ram: ((item.ram ?? 0) / (node?.mem_total ?? 1)) * 100,
|
||||||
|
ram_raw: item.ram,
|
||||||
|
swap: ((item.swap ?? 0) / (node?.swap_total ?? 1)) * 100,
|
||||||
|
swap_raw: item.swap,
|
||||||
|
}));
|
||||||
|
}, [chartData, node?.mem_total, node?.swap_total]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
chartData,
|
chartData,
|
||||||
|
memoryChartData,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@@ -1,8 +1,11 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useConfigItem } from "@/config/hooks";
|
||||||
|
|
||||||
type Theme = "light" | "dark" | "system";
|
type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
export const useTheme = () => {
|
export const useTheme = () => {
|
||||||
|
const defaultAppearance = useConfigItem("selectedDefaultAppearance");
|
||||||
|
|
||||||
const [theme, setTheme] = useState<Theme>(() => {
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
const storedTheme = localStorage.getItem("appearance");
|
const storedTheme = localStorage.getItem("appearance");
|
||||||
if (
|
if (
|
||||||
@@ -12,7 +15,7 @@ export const useTheme = () => {
|
|||||||
) {
|
) {
|
||||||
return storedTheme;
|
return storedTheme;
|
||||||
}
|
}
|
||||||
return "system";
|
return (defaultAppearance as Theme) || "system";
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
83
src/main.tsx
83
src/main.tsx
@@ -5,7 +5,7 @@ import "./index.css";
|
|||||||
import "@radix-ui/themes/styles.css";
|
import "@radix-ui/themes/styles.css";
|
||||||
import { Theme } from "@radix-ui/themes";
|
import { Theme } from "@radix-ui/themes";
|
||||||
import { Header } from "@/components/sections/Header";
|
import { Header } from "@/components/sections/Header";
|
||||||
import Background from "@/components/sections/Background";
|
import { ConfigProvider } from "@/config";
|
||||||
import { useTheme } from "@/hooks/useTheme";
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
import { NodeDataProvider } from "@/contexts/NodeDataContext";
|
import { NodeDataProvider } from "@/contexts/NodeDataContext";
|
||||||
import { LiveDataProvider } from "@/contexts/LiveDataContext";
|
import { LiveDataProvider } from "@/contexts/LiveDataContext";
|
||||||
@@ -17,60 +17,57 @@ const HomePage = lazy(() => import("@/pages/Home"));
|
|||||||
const InstancePage = lazy(() => import("@/pages/instance"));
|
const InstancePage = lazy(() => import("@/pages/instance"));
|
||||||
const NotFoundPage = lazy(() => import("@/pages/NotFound"));
|
const NotFoundPage = lazy(() => import("@/pages/NotFound"));
|
||||||
|
|
||||||
|
import { useConfigItem } from "@/config";
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const { publicSettings } = useNodeData();
|
const { publicSettings } = useNodeData();
|
||||||
const [viewMode, setViewMode] = useState<"card" | "list">(() => {
|
const defaultView = useConfigItem("selectedDefaultView");
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
|
||||||
const savedMode = localStorage.getItem("nodeViewMode");
|
const savedMode = localStorage.getItem("nodeViewMode");
|
||||||
if (savedMode === "table") {
|
return savedMode === "grid" || savedMode === "table"
|
||||||
return "list";
|
? savedMode
|
||||||
}
|
: defaultView || "grid";
|
||||||
return "card";
|
|
||||||
});
|
});
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
const sitename = publicSettings ? publicSettings.sitename : "Komari";
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = sitename;
|
localStorage.setItem("nodeViewMode", viewMode);
|
||||||
}, [sitename]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const modeToStore = viewMode === "card" ? "grid" : "table";
|
|
||||||
localStorage.setItem("nodeViewMode", modeToStore);
|
|
||||||
}, [viewMode]);
|
}, [viewMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Theme
|
<ConfigProvider publicSettings={publicSettings}>
|
||||||
appearance="inherit"
|
<Theme
|
||||||
scaling="110%"
|
appearance="inherit"
|
||||||
style={{ backgroundColor: "transparent" }}>
|
scaling="110%"
|
||||||
{/* 使用背景组件 */}
|
style={{ backgroundColor: "transparent" }}>
|
||||||
{publicSettings && <Background publicSettings={publicSettings} />}
|
<div className="min-h-screen flex flex-col text-sm">
|
||||||
<div className="min-h-screen flex flex-col text-sm">
|
<Header
|
||||||
<Header
|
viewMode={viewMode}
|
||||||
viewMode={viewMode}
|
setViewMode={setViewMode}
|
||||||
setViewMode={setViewMode}
|
theme={theme}
|
||||||
theme={theme}
|
toggleTheme={toggleTheme}
|
||||||
toggleTheme={toggleTheme}
|
searchTerm={searchTerm}
|
||||||
sitename={sitename}
|
setSearchTerm={setSearchTerm}
|
||||||
searchTerm={searchTerm}
|
/>
|
||||||
setSearchTerm={setSearchTerm}
|
<Suspense fallback={<Loading />}>
|
||||||
/>
|
<Routes>
|
||||||
<Suspense fallback={<Loading />}>
|
<Route
|
||||||
<Routes>
|
path="/"
|
||||||
<Route
|
element={
|
||||||
path="/"
|
<HomePage viewMode={viewMode} searchTerm={searchTerm} />
|
||||||
element={<HomePage viewMode={viewMode} searchTerm={searchTerm} />}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/instance/:uuid" element={<InstancePage />} />
|
<Route path="/instance/:uuid" element={<InstancePage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</Theme>
|
</Theme>
|
||||||
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -8,9 +8,10 @@ import Loading from "@/components/loading";
|
|||||||
import type { NodeWithStatus } from "@/types/node";
|
import type { NodeWithStatus } from "@/types/node";
|
||||||
import { useNodeData } from "@/contexts/NodeDataContext";
|
import { useNodeData } from "@/contexts/NodeDataContext";
|
||||||
import { useLiveData } from "@/contexts/LiveDataContext";
|
import { useLiveData } from "@/contexts/LiveDataContext";
|
||||||
|
import { useConfigItem } from "@/config";
|
||||||
|
|
||||||
interface HomePageProps {
|
interface HomePageProps {
|
||||||
viewMode: "card" | "list";
|
viewMode: "grid" | "table";
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ const HomePage: React.FC<HomePageProps> = ({ viewMode, searchTerm }) => {
|
|||||||
const { nodes: staticNodes, loading, getGroups } = useNodeData();
|
const { nodes: staticNodes, loading, getGroups } = useNodeData();
|
||||||
const { liveData } = useLiveData();
|
const { liveData } = useLiveData();
|
||||||
const [selectedGroup, setSelectedGroup] = useState("所有");
|
const [selectedGroup, setSelectedGroup] = useState("所有");
|
||||||
|
const enableGroupedBar = useConfigItem("enableGroupedBar");
|
||||||
|
const enableStatsBar = useConfigItem("enableStatsBar");
|
||||||
const [displayOptions, setDisplayOptions] = useState({
|
const [displayOptions, setDisplayOptions] = useState({
|
||||||
time: true,
|
time: true,
|
||||||
online: true,
|
online: true,
|
||||||
@@ -80,47 +83,51 @@ const HomePage: React.FC<HomePageProps> = ({ viewMode, searchTerm }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[90%] max-w-screen-2xl mx-auto flex-1 flex flex-col pb-10">
|
<div className="w-[90%] max-w-screen-2xl mx-auto flex-1 flex flex-col pb-10">
|
||||||
<StatsBar
|
{enableStatsBar && (
|
||||||
displayOptions={displayOptions}
|
<StatsBar
|
||||||
setDisplayOptions={setDisplayOptions}
|
displayOptions={displayOptions}
|
||||||
stats={stats}
|
setDisplayOptions={setDisplayOptions}
|
||||||
loading={loading}
|
stats={stats}
|
||||||
currentTime={currentTime}
|
loading={loading}
|
||||||
/>
|
currentTime={currentTime}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="flex-1 px-4 pb-4">
|
<main className="flex-1 px-4 pb-4">
|
||||||
<div className="flex overflow-auto whitespace-nowrap overflow-x-auto items-center min-w-[300px] text-secondary-foreground box-border border space-x-4 px-4 rounded-lg mb-4 bg-card backdrop-blur-[10px]">
|
{enableGroupedBar && (
|
||||||
<span>分组</span>
|
<div className="flex overflow-auto whitespace-nowrap overflow-x-auto items-center min-w-[300px] text-secondary-foreground box-border border space-x-4 px-4 rounded-lg mb-4 bg-card backdrop-blur-[10px]">
|
||||||
{groups.map((group: string) => (
|
<span>分组</span>
|
||||||
<Button
|
{groups.map((group: string) => (
|
||||||
key={group}
|
<Button
|
||||||
variant={selectedGroup === group ? "secondary" : "ghost"}
|
key={group}
|
||||||
size="sm"
|
variant={selectedGroup === group ? "secondary" : "ghost"}
|
||||||
onClick={() => setSelectedGroup(group)}>
|
size="sm"
|
||||||
{group}
|
onClick={() => setSelectedGroup(group)}>
|
||||||
</Button>
|
{group}
|
||||||
))}
|
</Button>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 mt-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loading text="正在努力获取数据中..." />
|
<Loading text="正在努力获取数据中..." />
|
||||||
) : filteredNodes.length > 0 ? (
|
) : filteredNodes.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
viewMode === "card"
|
viewMode === "grid"
|
||||||
? ""
|
? ""
|
||||||
: "space-y-2 bg-card overflow-auto backdrop-blur-[10px] rounded-lg p-2"
|
: "space-y-2 bg-card overflow-auto backdrop-blur-[10px] rounded-lg p-2"
|
||||||
}>
|
}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
viewMode === "card"
|
viewMode === "grid"
|
||||||
? "grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4"
|
? "grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4"
|
||||||
: "min-w-[1080px]"
|
: "min-w-[1080px]"
|
||||||
}>
|
}>
|
||||||
{viewMode === "list" && <NodeListHeader />}
|
{viewMode === "table" && <NodeListHeader />}
|
||||||
{filteredNodes.map((node: NodeWithStatus) =>
|
{filteredNodes.map((node: NodeWithStatus) =>
|
||||||
viewMode === "card" ? (
|
viewMode === "grid" ? (
|
||||||
<NodeCard key={node.uuid} node={node} />
|
<NodeCard key={node.uuid} node={node} />
|
||||||
) : (
|
) : (
|
||||||
<NodeListItem key={node.uuid} node={node} />
|
<NodeListItem key={node.uuid} node={node} />
|
||||||
|
@@ -1,40 +1,30 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import type { NodeWithStatus } from "@/types/node";
|
import type { NodeWithStatus } from "@/types/node";
|
||||||
import { useMemo, memo } from "react";
|
import { useMemo, memo } from "react";
|
||||||
|
import { formatBytes, formatUptime } from "@/utils";
|
||||||
|
|
||||||
interface InstanceProps {
|
interface InstanceProps {
|
||||||
node: NodeWithStatus;
|
node: NodeWithStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatUptime = (uptime: number) => {
|
const formatTrafficLimit = (
|
||||||
if (!uptime) return "N/A";
|
limit?: number,
|
||||||
const days = Math.floor(uptime / 86400);
|
type?: "sum" | "max" | "min" | "up" | "down"
|
||||||
const hours = Math.floor((uptime % 86400) / 3600);
|
) => {
|
||||||
const minutes = Math.floor((uptime % 3600) / 60);
|
if (!limit) return "未设置";
|
||||||
const seconds = Math.floor(uptime % 60);
|
|
||||||
|
|
||||||
let result = "";
|
const limitText = formatBytes(limit);
|
||||||
if (days > 0) result += `${days} 天 `;
|
|
||||||
if (hours > 0 || days > 0) result += `${hours} 时 `;
|
|
||||||
if (minutes > 0 || hours > 0 || days > 0) result += `${minutes} 分 `;
|
|
||||||
result += `${seconds} 秒`;
|
|
||||||
|
|
||||||
return result.trim();
|
const typeText =
|
||||||
};
|
{
|
||||||
|
sum: "总和",
|
||||||
|
max: "最大值",
|
||||||
|
min: "最小值",
|
||||||
|
up: "上传",
|
||||||
|
down: "下载",
|
||||||
|
}[type || "max"] || "";
|
||||||
|
|
||||||
const formatBytes = (bytes: number, unit: "KB" | "MB" | "GB" = "GB") => {
|
return `${limitText} (${typeText})`;
|
||||||
if (bytes === 0) return `0 ${unit}`;
|
|
||||||
const k = 1024;
|
|
||||||
switch (unit) {
|
|
||||||
case "KB":
|
|
||||||
return `${(bytes / k).toFixed(2)} KB`;
|
|
||||||
case "MB":
|
|
||||||
return `${(bytes / (k * k)).toFixed(2)} MB`;
|
|
||||||
case "GB":
|
|
||||||
return `${(bytes / (k * k * k)).toFixed(2)} GB`;
|
|
||||||
default:
|
|
||||||
return `${bytes} B`;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Instance = memo(({ node }: InstanceProps) => {
|
const Instance = memo(({ node }: InstanceProps) => {
|
||||||
@@ -47,33 +37,33 @@ const Instance = memo(({ node }: InstanceProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle>详细信息</CardTitle>
|
<CardTitle>详细信息</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<CardContent className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">CPU</p>
|
|
||||||
<p>{`${node.cpu_name} (x${node.cpu_cores})`}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">架构</p>
|
|
||||||
<p>{node.arch}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">虚拟化</p>
|
|
||||||
<p>{node.virtualization}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground">GPU</p>
|
|
||||||
<p>{node.gpu_name || "N/A"}</p>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<p className="text-muted-foreground">操作系统</p>
|
<p className="text-muted-foreground text-sm">CPU</p>
|
||||||
<p>{node.os}</p>
|
<p className="text-sm">{`${node.cpu_name} (x${node.cpu_cores})`}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">内存</p>
|
<p className="text-muted-foreground text-sm">架构</p>
|
||||||
<p>
|
<p className="text-sm">{node.arch}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">虚拟化</p>
|
||||||
|
<p className="text-sm">{node.virtualization}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">GPU</p>
|
||||||
|
<p className="text-sm">{node.gpu_name || "N/A"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">操作系统</p>
|
||||||
|
<p className="text-sm">{node.os}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">内存</p>
|
||||||
|
<p className="text-sm">
|
||||||
{stats && isOnline
|
{stats && isOnline
|
||||||
? `${formatBytes(stats.ram.used)} / ${formatBytes(
|
? `${formatBytes(stats.ram.used)} / ${formatBytes(
|
||||||
node.mem_total
|
node.mem_total
|
||||||
@@ -82,18 +72,18 @@ const Instance = memo(({ node }: InstanceProps) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">交换</p>
|
<p className="text-muted-foreground text-sm">交换</p>
|
||||||
<p>
|
<p className="text-sm">
|
||||||
{stats && isOnline
|
{stats && isOnline
|
||||||
? `${formatBytes(stats.swap.used, "MB")} / ${formatBytes(
|
? `${formatBytes(stats.swap.used)} / ${formatBytes(
|
||||||
node.swap_total
|
node.swap_total
|
||||||
)}`
|
)}`
|
||||||
: `N/A / ${formatBytes(node.swap_total)}`}
|
: `N/A / ${formatBytes(node.swap_total)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">磁盘</p>
|
<p className="text-muted-foreground text-sm">磁盘</p>
|
||||||
<p>
|
<p className="text-sm">
|
||||||
{stats && isOnline
|
{stats && isOnline
|
||||||
? `${formatBytes(stats.disk.used)} / ${formatBytes(
|
? `${formatBytes(stats.disk.used)} / ${formatBytes(
|
||||||
node.disk_total
|
node.disk_total
|
||||||
@@ -101,20 +91,24 @@ const Instance = memo(({ node }: InstanceProps) => {
|
|||||||
: `N/A / ${formatBytes(node.disk_total)}`}
|
: `N/A / ${formatBytes(node.disk_total)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">运行时间</p>
|
||||||
|
<p className="text-sm">{formatUptime(stats?.uptime || 0)}</p>
|
||||||
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<p className="text-muted-foreground">网络</p>
|
<p className="text-muted-foreground text-sm">实时网络</p>
|
||||||
<p>
|
<p className="text-sm">
|
||||||
{stats && isOnline
|
{stats && isOnline
|
||||||
? `↑ ${formatBytes(stats.network.up, "KB")}/s ↓ ${formatBytes(
|
? `↑ ${formatBytes(stats.network.up, true)} ↓ ${formatBytes(
|
||||||
stats.network.down,
|
stats.network.down,
|
||||||
"KB"
|
true
|
||||||
)}/s`
|
)}`
|
||||||
: "N/A"}
|
: "N/A"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="md:col-span-2">
|
||||||
<p className="text-muted-foreground">总流量</p>
|
<p className="text-muted-foreground text-sm">总流量</p>
|
||||||
<p>
|
<p className="text-sm">
|
||||||
{stats && isOnline
|
{stats && isOnline
|
||||||
? `↑ ${formatBytes(stats.network.totalUp)} ↓ ${formatBytes(
|
? `↑ ${formatBytes(stats.network.totalUp)} ↓ ${formatBytes(
|
||||||
stats.network.totalDown
|
stats.network.totalDown
|
||||||
@@ -122,13 +116,15 @@ const Instance = memo(({ node }: InstanceProps) => {
|
|||||||
: "N/A"}
|
: "N/A"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="md:col-span-2">
|
||||||
<p className="text-muted-foreground">运行时间</p>
|
<p className="text-muted-foreground text-sm">流量限制</p>
|
||||||
<p>{formatUptime(stats?.uptime || 0)}</p>
|
<p className="text-sm">
|
||||||
|
{formatTrafficLimit(node.traffic_limit, node.traffic_limit_type)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground">最后上报</p>
|
<p className="text-muted-foreground text-sm">最后上报</p>
|
||||||
<p>
|
<p className="text-sm">
|
||||||
{stats && isOnline
|
{stats && isOnline
|
||||||
? new Date(stats.updated_at).toLocaleString()
|
? new Date(stats.updated_at).toLocaleString()
|
||||||
: "N/A"}
|
: "N/A"}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useState, useEffect, useRef } from "react";
|
import { memo, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Area,
|
Area,
|
||||||
@@ -13,410 +13,352 @@ import { ChartContainer } from "@/components/ui/chart";
|
|||||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import type { NodeData, NodeStats } from "@/types/node";
|
import type { NodeData, NodeStats } from "@/types/node";
|
||||||
import { formatBytes } from "@/utils";
|
import { formatBytes } from "@/utils";
|
||||||
import fillMissingTimePoints, { type RecordFormat } from "@/utils/RecordHelper";
|
|
||||||
import { Flex } from "@radix-ui/themes";
|
import { Flex } from "@radix-ui/themes";
|
||||||
import Loading from "@/components/loading";
|
import Loading from "@/components/loading";
|
||||||
import { useNodeData } from "@/contexts/NodeDataContext";
|
import { useLoadCharts } from "@/hooks/useLoadCharts";
|
||||||
|
|
||||||
interface LoadChartsProps {
|
interface LoadChartsProps {
|
||||||
node: NodeData;
|
node: NodeData;
|
||||||
hours: number;
|
hours: number;
|
||||||
data?: RecordFormat[];
|
|
||||||
liveData?: NodeStats;
|
liveData?: NodeStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoadCharts = memo(
|
const LoadCharts = memo(({ node, hours, liveData }: LoadChartsProps) => {
|
||||||
({ node, hours, data = [], liveData }: LoadChartsProps) => {
|
const { loading, error, chartData, memoryChartData } = useLoadCharts(
|
||||||
const { getLoadHistory } = useNodeData();
|
node,
|
||||||
const [historicalData, setHistoricalData] = useState<RecordFormat[]>([]);
|
hours
|
||||||
const [loading, setLoading] = useState(true);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const isRealtime = hours === 0;
|
const chartDataLengthRef = useRef(0);
|
||||||
|
chartDataLengthRef.current = chartData.length;
|
||||||
|
|
||||||
// 获取历史数据
|
// 格式化函数
|
||||||
useEffect(() => {
|
const timeFormatter = useCallback((value: any, index: number) => {
|
||||||
if (!isRealtime) {
|
if (chartDataLengthRef.current === 0) return "";
|
||||||
const fetchHistoricalData = async () => {
|
if (index === 0 || index === chartDataLengthRef.current - 1) {
|
||||||
setLoading(true);
|
return new Date(value).toLocaleTimeString([], {
|
||||||
setError(null);
|
hour: "2-digit",
|
||||||
try {
|
minute: "2-digit",
|
||||||
const data = await getLoadHistory(node.uuid, hours);
|
});
|
||||||
setHistoricalData(data?.records || []);
|
}
|
||||||
} catch (err: any) {
|
return "";
|
||||||
setError(err.message || "获取历史数据失败");
|
}, []);
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchHistoricalData();
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [node.uuid, hours, getLoadHistory, isRealtime]);
|
|
||||||
|
|
||||||
// 准备图表数据
|
const labelFormatter = useCallback(
|
||||||
const minute = 60;
|
(value: any) => {
|
||||||
const hour = minute * 60;
|
const date = new Date(value);
|
||||||
const chartData = isRealtime
|
if (hours === 0) {
|
||||||
? data
|
return date.toLocaleTimeString([], {
|
||||||
: hours === 1
|
|
||||||
? fillMissingTimePoints(historicalData ?? [], minute, hour, minute * 2)
|
|
||||||
: (() => {
|
|
||||||
const interval = hours > 120 ? hour : minute * 15;
|
|
||||||
const maxGap = interval * 2;
|
|
||||||
return fillMissingTimePoints(
|
|
||||||
historicalData ?? [],
|
|
||||||
interval,
|
|
||||||
hour * hours,
|
|
||||||
maxGap
|
|
||||||
);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const chartDataLengthRef = useRef(0);
|
|
||||||
chartDataLengthRef.current = chartData.length;
|
|
||||||
|
|
||||||
// 内存图表数据转换
|
|
||||||
const memoryChartData = chartData.map((item) => ({
|
|
||||||
...item,
|
|
||||||
ram: ((item.ram ?? 0) / (node?.mem_total ?? 1)) * 100,
|
|
||||||
ram_raw: item.ram,
|
|
||||||
swap: ((item.swap ?? 0) / (node?.swap_total ?? 1)) * 100,
|
|
||||||
swap_raw: item.swap,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 格式化函数
|
|
||||||
const timeFormatter = useCallback((value: any, index: number) => {
|
|
||||||
if (chartDataLengthRef.current === 0) return "";
|
|
||||||
if (index === 0 || index === chartDataLengthRef.current - 1) {
|
|
||||||
return new Date(value).toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return "";
|
return date.toLocaleString([], {
|
||||||
}, []);
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[hours]
|
||||||
|
);
|
||||||
|
|
||||||
const labelFormatter = useCallback(
|
// 样式和颜色
|
||||||
(value: any) => {
|
const cn = "flex flex-col w-full h-full gap-4 justify-between";
|
||||||
const date = new Date(value);
|
const chartMargin = { top: 10, right: 16, bottom: 10, left: 16 };
|
||||||
if (hours === 0) {
|
const colors = ["#F38181", "#FCE38A", "#EAFFD0", "#95E1D3"];
|
||||||
return date.toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return date.toLocaleString([], {
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[hours]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 样式和颜色
|
// 图表配置
|
||||||
const cn = "flex flex-col w-full h-full gap-4 justify-between";
|
const chartConfigs = [
|
||||||
const chartMargin = { top: 10, right: 16, bottom: 10, left: 16 };
|
{
|
||||||
const colors = ["#F38181", "#FCE38A", "#EAFFD0", "#95E1D3"];
|
id: "cpu",
|
||||||
|
title: "CPU",
|
||||||
// 图表配置
|
type: "area",
|
||||||
const chartConfigs = [
|
value: liveData?.cpu?.usage ? `${liveData.cpu.usage.toFixed(2)}%` : "-",
|
||||||
{
|
dataKey: "cpu",
|
||||||
id: "cpu",
|
yAxisDomain: [0, 100],
|
||||||
title: "CPU",
|
yAxisFormatter: (value: number, index: number) =>
|
||||||
type: "area",
|
index !== 0 ? `${value}%` : "",
|
||||||
value: liveData?.cpu?.usage ? `${liveData.cpu.usage.toFixed(2)}%` : "-",
|
color: colors[0],
|
||||||
dataKey: "cpu",
|
data: chartData,
|
||||||
yAxisDomain: [0, 100],
|
tooltipFormatter: (value: number) => `${value.toFixed(2)}%`,
|
||||||
yAxisFormatter: (value: number, index: number) =>
|
tooltipLabel: "CPU 使用率",
|
||||||
index !== 0 ? `${value}%` : "",
|
},
|
||||||
color: colors[0],
|
{
|
||||||
data: chartData,
|
id: "memory",
|
||||||
tooltipFormatter: (value: number) => `${value.toFixed(2)}%`,
|
title: "内存",
|
||||||
tooltipLabel: "CPU 使用率",
|
type: "area",
|
||||||
},
|
value: (
|
||||||
{
|
<Flex gap="0" direction="column" align="end" className="text-sm">
|
||||||
id: "memory",
|
<label>
|
||||||
title: "内存",
|
{liveData?.ram?.used
|
||||||
type: "area",
|
? `${formatBytes(liveData.ram.used)} / ${formatBytes(
|
||||||
value: (
|
node?.mem_total || 0
|
||||||
<Flex gap="0" direction="column" align="end" className="text-sm">
|
)}`
|
||||||
<label>
|
: "-"}
|
||||||
{liveData?.ram?.used
|
</label>
|
||||||
? `${formatBytes(liveData.ram.used)} / ${formatBytes(
|
<label>
|
||||||
node?.mem_total || 0
|
{liveData?.swap?.used
|
||||||
)}`
|
? `${formatBytes(liveData.swap.used)} / ${formatBytes(
|
||||||
: "-"}
|
node?.swap_total || 0
|
||||||
</label>
|
)}`
|
||||||
<label>
|
: "-"}
|
||||||
{liveData?.swap?.used
|
</label>
|
||||||
? `${formatBytes(liveData.swap.used)} / ${formatBytes(
|
</Flex>
|
||||||
node?.swap_total || 0
|
),
|
||||||
)}`
|
series: [
|
||||||
: "-"}
|
{
|
||||||
</label>
|
dataKey: "ram",
|
||||||
</Flex>
|
color: colors[0],
|
||||||
),
|
tooltipLabel: "内存",
|
||||||
series: [
|
tooltipFormatter: (value: number, raw: any) =>
|
||||||
{
|
`${formatBytes(raw?.ram_raw || 0)} (${value.toFixed(0)}%)`,
|
||||||
dataKey: "ram",
|
},
|
||||||
color: colors[0],
|
{
|
||||||
tooltipLabel: "内存",
|
dataKey: "swap",
|
||||||
tooltipFormatter: (value: number, raw: any) =>
|
color: colors[1],
|
||||||
`${formatBytes(raw?.ram_raw || 0)} (${value.toFixed(0)}%)`,
|
tooltipLabel: "交换",
|
||||||
},
|
tooltipFormatter: (value: number, raw: any) =>
|
||||||
{
|
`${formatBytes(raw?.swap_raw || 0)} (${value.toFixed(0)}%)`,
|
||||||
dataKey: "swap",
|
},
|
||||||
color: colors[1],
|
],
|
||||||
tooltipLabel: "交换",
|
yAxisDomain: [0, 100],
|
||||||
tooltipFormatter: (value: number, raw: any) =>
|
yAxisFormatter: (value: number, index: number) =>
|
||||||
`${formatBytes(raw?.swap_raw || 0)} (${value.toFixed(0)}%)`,
|
index !== 0 ? `${value}%` : "",
|
||||||
},
|
data: memoryChartData,
|
||||||
],
|
},
|
||||||
yAxisDomain: [0, 100],
|
{
|
||||||
yAxisFormatter: (value: number, index: number) =>
|
id: "disk",
|
||||||
index !== 0 ? `${value}%` : "",
|
title: "磁盘",
|
||||||
data: memoryChartData,
|
type: "area",
|
||||||
},
|
value: liveData?.disk?.used
|
||||||
{
|
? `${formatBytes(liveData.disk.used)} / ${formatBytes(
|
||||||
id: "disk",
|
node?.disk_total || 0
|
||||||
title: "磁盘",
|
)}`
|
||||||
type: "area",
|
: "-",
|
||||||
value: liveData?.disk?.used
|
dataKey: "disk",
|
||||||
? `${formatBytes(liveData.disk.used)} / ${formatBytes(
|
yAxisDomain: [0, node?.disk_total || 100],
|
||||||
node?.disk_total || 0
|
yAxisFormatter: (value: number, index: number) =>
|
||||||
)}`
|
index !== 0 ? formatBytes(value) : "",
|
||||||
: "-",
|
color: colors[0],
|
||||||
dataKey: "disk",
|
data: chartData,
|
||||||
yAxisDomain: [0, node?.disk_total || 100],
|
tooltipFormatter: (value: number) => formatBytes(value),
|
||||||
yAxisFormatter: (value: number, index: number) =>
|
tooltipLabel: "磁盘使用",
|
||||||
index !== 0 ? formatBytes(value) : "",
|
},
|
||||||
color: colors[0],
|
{
|
||||||
data: chartData,
|
id: "network",
|
||||||
tooltipFormatter: (value: number) => formatBytes(value),
|
title: "网络",
|
||||||
tooltipLabel: "磁盘使用",
|
type: "line",
|
||||||
},
|
value: (
|
||||||
{
|
<>
|
||||||
id: "network",
|
|
||||||
title: "网络",
|
|
||||||
type: "line",
|
|
||||||
value: (
|
|
||||||
<>
|
|
||||||
<Flex gap="0" align="end" direction="column" className="text-sm">
|
|
||||||
<span>↑ {formatBytes(liveData?.network.up || 0)}/s</span>
|
|
||||||
<span>↓ {formatBytes(liveData?.network.down || 0)}/s</span>
|
|
||||||
</Flex>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
dataKey: "net_in",
|
|
||||||
color: colors[0],
|
|
||||||
tooltipLabel: "下载",
|
|
||||||
tooltipFormatter: (value: number) => `${formatBytes(value)}/s`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dataKey: "net_out",
|
|
||||||
color: colors[3],
|
|
||||||
tooltipLabel: "上传",
|
|
||||||
tooltipFormatter: (value: number) => `${formatBytes(value)}/s`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
yAxisFormatter: (value: number, index: number) =>
|
|
||||||
index !== 0 ? formatBytes(value) : "",
|
|
||||||
data: chartData,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "connections",
|
|
||||||
title: "连接数",
|
|
||||||
type: "line",
|
|
||||||
value: (
|
|
||||||
<Flex gap="0" align="end" direction="column" className="text-sm">
|
<Flex gap="0" align="end" direction="column" className="text-sm">
|
||||||
<span>TCP: {liveData?.connections.tcp}</span>
|
<span>↑ {formatBytes(liveData?.network.up || 0)}/s</span>
|
||||||
<span>UDP: {liveData?.connections.udp}</span>
|
<span>↓ {formatBytes(liveData?.network.down || 0)}/s</span>
|
||||||
</Flex>
|
</Flex>
|
||||||
),
|
</>
|
||||||
series: [
|
),
|
||||||
{
|
series: [
|
||||||
dataKey: "connections",
|
{
|
||||||
color: colors[0],
|
dataKey: "net_in",
|
||||||
tooltipLabel: "TCP 连接",
|
color: colors[0],
|
||||||
},
|
tooltipLabel: "下载",
|
||||||
{
|
tooltipFormatter: (value: number) => `${formatBytes(value)}/s`,
|
||||||
dataKey: "connections_udp",
|
},
|
||||||
color: colors[1],
|
{
|
||||||
tooltipLabel: "UDP 连接",
|
dataKey: "net_out",
|
||||||
},
|
color: colors[3],
|
||||||
],
|
tooltipLabel: "上传",
|
||||||
data: chartData,
|
tooltipFormatter: (value: number) => `${formatBytes(value)}/s`,
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
id: "process",
|
yAxisFormatter: (value: number, index: number) =>
|
||||||
title: "进程数",
|
index !== 0 ? formatBytes(value) : "",
|
||||||
type: "line",
|
data: chartData,
|
||||||
value: liveData?.process || "-",
|
},
|
||||||
dataKey: "process",
|
{
|
||||||
color: colors[0],
|
id: "connections",
|
||||||
data: chartData,
|
title: "连接数",
|
||||||
tooltipLabel: "进程数",
|
type: "line",
|
||||||
},
|
value: (
|
||||||
];
|
<Flex gap="0" align="end" direction="column" className="text-sm">
|
||||||
|
<span>TCP: {liveData?.connections.tcp}</span>
|
||||||
|
<span>UDP: {liveData?.connections.udp}</span>
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
dataKey: "connections",
|
||||||
|
color: colors[0],
|
||||||
|
tooltipLabel: "TCP 连接",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataKey: "connections_udp",
|
||||||
|
color: colors[1],
|
||||||
|
tooltipLabel: "UDP 连接",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
data: chartData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "process",
|
||||||
|
title: "进程数",
|
||||||
|
type: "line",
|
||||||
|
value: liveData?.process || "-",
|
||||||
|
dataKey: "process",
|
||||||
|
color: colors[0],
|
||||||
|
data: chartData,
|
||||||
|
tooltipLabel: "进程数",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// 通用提示组件
|
// 通用提示组件
|
||||||
const CustomTooltip = ({ active, payload, label, chartConfig }: any) => {
|
const CustomTooltip = ({ active, payload, label, chartConfig }: any) => {
|
||||||
if (!active || !payload || !payload.length) return null;
|
if (!active || !payload || !payload.length) return null;
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-background/80 p-3 border rounded-lg shadow-lg max-w-xs">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
|
||||||
{labelFormatter(label)}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{payload.map((item: any, index: number) => {
|
|
||||||
const series = chartConfig.series
|
|
||||||
? chartConfig.series.find(
|
|
||||||
(s: any) => s.dataKey === item.dataKey
|
|
||||||
)
|
|
||||||
: {
|
|
||||||
dataKey: chartConfig.dataKey,
|
|
||||||
tooltipLabel: chartConfig.tooltipLabel,
|
|
||||||
tooltipFormatter: chartConfig.tooltipFormatter,
|
|
||||||
};
|
|
||||||
|
|
||||||
let value = item.value;
|
|
||||||
if (series?.tooltipFormatter) {
|
|
||||||
value = series.tooltipFormatter(value, item.payload);
|
|
||||||
} else {
|
|
||||||
value = value.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${item.dataKey}-${index}`}
|
|
||||||
className="flex justify-between items-center">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-sm"
|
|
||||||
style={{ backgroundColor: item.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
|
||||||
{series?.tooltipLabel || item.dataKey}:
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-bold ml-2">{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 根据配置渲染图表
|
|
||||||
const renderChart = (config: any) => {
|
|
||||||
const ChartComponent = config.type === "area" ? AreaChart : LineChart;
|
|
||||||
const DataComponent =
|
|
||||||
config.type === "area" ? Area : (Line as React.ComponentType<any>);
|
|
||||||
|
|
||||||
const chartConfig = config.series
|
|
||||||
? config.series.reduce((acc: any, series: any) => {
|
|
||||||
acc[series.dataKey] = {
|
|
||||||
label: series.tooltipLabel || series.dataKey,
|
|
||||||
color: series.color,
|
|
||||||
};
|
|
||||||
return acc;
|
|
||||||
}, {})
|
|
||||||
: {
|
|
||||||
[config.dataKey]: {
|
|
||||||
label: config.tooltipLabel || config.dataKey,
|
|
||||||
color: config.color,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={cn} key={config.id}>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
{config.title}
|
|
||||||
</CardTitle>
|
|
||||||
<span className="text-sm font-bold">{config.value}</span>
|
|
||||||
</CardHeader>
|
|
||||||
<ChartContainer config={chartConfig}>
|
|
||||||
<ChartComponent data={config.data} margin={chartMargin}>
|
|
||||||
<CartesianGrid strokeDasharray="2 4" vertical={false} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="time"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tick={{ fontSize: 11 }}
|
|
||||||
tickFormatter={timeFormatter}
|
|
||||||
interval={0}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
domain={config.yAxisDomain}
|
|
||||||
tickFormatter={config.yAxisFormatter}
|
|
||||||
orientation="left"
|
|
||||||
type="number"
|
|
||||||
tick={{ dx: -10 }}
|
|
||||||
mirror={true}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
cursor={false}
|
|
||||||
content={(props: any) => (
|
|
||||||
<CustomTooltip {...props} chartConfig={config} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{config.series ? (
|
|
||||||
config.series.map((series: any) => (
|
|
||||||
<DataComponent
|
|
||||||
key={series.dataKey}
|
|
||||||
dataKey={series.dataKey}
|
|
||||||
animationDuration={0}
|
|
||||||
stroke={series.color}
|
|
||||||
fill={config.type === "area" ? series.color : undefined}
|
|
||||||
opacity={0.8}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<DataComponent
|
|
||||||
dataKey={config.dataKey}
|
|
||||||
animationDuration={0}
|
|
||||||
stroke={config.color}
|
|
||||||
fill={config.type === "area" ? config.color : undefined}
|
|
||||||
opacity={0.8}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ChartComponent>
|
|
||||||
</ChartContainer>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="bg-background/80 p-3 border rounded-lg shadow-lg max-w-xs">
|
||||||
{loading && (
|
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-card/50 backdrop-blur-sm rounded-lg z-10">
|
{labelFormatter(label)}
|
||||||
<Loading text="正在加载图表数据..." />
|
</p>
|
||||||
</div>
|
<div className="space-y-1">
|
||||||
)}
|
{payload.map((item: any, index: number) => {
|
||||||
{error && (
|
const series = chartConfig.series
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-card/50 backdrop-blur-sm rounded-lg z-10">
|
? chartConfig.series.find((s: any) => s.dataKey === item.dataKey)
|
||||||
<p className="text-red-500">{error}</p>
|
: {
|
||||||
</div>
|
dataKey: chartConfig.dataKey,
|
||||||
)}
|
tooltipLabel: chartConfig.tooltipLabel,
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
tooltipFormatter: chartConfig.tooltipFormatter,
|
||||||
{chartConfigs.map(renderChart)}
|
};
|
||||||
|
|
||||||
|
let value = item.value;
|
||||||
|
if (series?.tooltipFormatter) {
|
||||||
|
value = series.tooltipFormatter(value, item.payload);
|
||||||
|
} else {
|
||||||
|
value = value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.dataKey}-${index}`}
|
||||||
|
className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-sm"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{series?.tooltipLabel || item.dataKey}:
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold ml-2">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
);
|
|
||||||
|
// 根据配置渲染图表
|
||||||
|
const renderChart = (config: any) => {
|
||||||
|
const ChartComponent = config.type === "area" ? AreaChart : LineChart;
|
||||||
|
const DataComponent =
|
||||||
|
config.type === "area" ? Area : (Line as React.ComponentType<any>);
|
||||||
|
|
||||||
|
const chartConfig = config.series
|
||||||
|
? config.series.reduce((acc: any, series: any) => {
|
||||||
|
acc[series.dataKey] = {
|
||||||
|
label: series.tooltipLabel || series.dataKey,
|
||||||
|
color: series.color,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
: {
|
||||||
|
[config.dataKey]: {
|
||||||
|
label: config.tooltipLabel || config.dataKey,
|
||||||
|
color: config.color,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn} key={config.id}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{config.title}</CardTitle>
|
||||||
|
<span className="text-sm font-bold">{config.value}</span>
|
||||||
|
</CardHeader>
|
||||||
|
<ChartContainer config={chartConfig}>
|
||||||
|
<ChartComponent data={config.data} margin={chartMargin}>
|
||||||
|
<CartesianGrid strokeDasharray="2 4" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="time"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
tickFormatter={timeFormatter}
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
domain={config.yAxisDomain}
|
||||||
|
tickFormatter={config.yAxisFormatter}
|
||||||
|
orientation="left"
|
||||||
|
type="number"
|
||||||
|
tick={{ dx: -10 }}
|
||||||
|
mirror={true}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={false}
|
||||||
|
content={(props: any) => (
|
||||||
|
<CustomTooltip {...props} chartConfig={config} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{config.series ? (
|
||||||
|
config.series.map((series: any) => (
|
||||||
|
<DataComponent
|
||||||
|
key={series.dataKey}
|
||||||
|
dataKey={series.dataKey}
|
||||||
|
animationDuration={0}
|
||||||
|
stroke={series.color}
|
||||||
|
fill={config.type === "area" ? series.color : undefined}
|
||||||
|
opacity={0.8}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<DataComponent
|
||||||
|
dataKey={config.dataKey}
|
||||||
|
animationDuration={0}
|
||||||
|
stroke={config.color}
|
||||||
|
fill={config.type === "area" ? config.color : undefined}
|
||||||
|
opacity={0.8}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartComponent>
|
||||||
|
</ChartContainer>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-card/50 backdrop-blur-sm rounded-lg z-10">
|
||||||
|
<Loading text="正在加载图表数据..." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-card/50 backdrop-blur-sm rounded-lg z-10">
|
||||||
|
<p className="text-red-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{chartConfigs.map(renderChart)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export default LoadCharts;
|
export default LoadCharts;
|
||||||
|
@@ -17,6 +17,7 @@ import Loading from "@/components/loading";
|
|||||||
import { usePingChart } from "@/hooks/usePingChart";
|
import { usePingChart } from "@/hooks/usePingChart";
|
||||||
import fillMissingTimePoints, { cutPeakValues } from "@/utils/RecordHelper";
|
import fillMissingTimePoints, { cutPeakValues } from "@/utils/RecordHelper";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useConfigItem } from "@/config";
|
||||||
|
|
||||||
interface PingChartProps {
|
interface PingChartProps {
|
||||||
node: NodeData;
|
node: NodeData;
|
||||||
@@ -28,6 +29,7 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
|||||||
const [visiblePingTasks, setVisiblePingTasks] = useState<number[]>([]);
|
const [visiblePingTasks, setVisiblePingTasks] = useState<number[]>([]);
|
||||||
const [timeRange, setTimeRange] = useState<[number, number] | null>(null);
|
const [timeRange, setTimeRange] = useState<[number, number] | null>(null);
|
||||||
const [cutPeak, setCutPeak] = useState(false);
|
const [cutPeak, setCutPeak] = useState(false);
|
||||||
|
const maxPointsToRender = useConfigItem("pingChartMaxPoints") || 0; // 0表示不限制
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pingHistory?.tasks) {
|
if (pingHistory?.tasks) {
|
||||||
@@ -106,12 +108,11 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加渲染硬限制以防止崩溃,即使在间隔调整后也是如此
|
// 添加渲染硬限制以防止崩溃,即使在间隔调整后也是如此
|
||||||
const MAX_POINTS_TO_RENDER = 0;
|
if (full.length > maxPointsToRender && maxPointsToRender > 0) {
|
||||||
if (full.length > MAX_POINTS_TO_RENDER && MAX_POINTS_TO_RENDER > 0) {
|
|
||||||
console.log(
|
console.log(
|
||||||
`数据量过大 (${full.length}), 降采样至 ${MAX_POINTS_TO_RENDER} 个点。`
|
`数据量过大 (${full.length}), 降采样至 ${maxPointsToRender} 个点。`
|
||||||
);
|
);
|
||||||
const samplingFactor = Math.ceil(full.length / MAX_POINTS_TO_RENDER);
|
const samplingFactor = Math.ceil(full.length / maxPointsToRender);
|
||||||
const sampledData = [];
|
const sampledData = [];
|
||||||
for (let i = 0; i < full.length; i += samplingFactor) {
|
for (let i = 0; i < full.length; i += samplingFactor) {
|
||||||
sampledData.push(full[i]);
|
sampledData.push(full[i]);
|
||||||
@@ -125,7 +126,7 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return full;
|
return full;
|
||||||
}, [pingHistory, hours, cutPeak]);
|
}, [pingHistory, hours, maxPointsToRender, cutPeak]);
|
||||||
|
|
||||||
const handleTaskVisibilityToggle = (taskId: number) => {
|
const handleTaskVisibilityToggle = (taskId: number) => {
|
||||||
setVisiblePingTasks((prev) =>
|
setVisiblePingTasks((prev) =>
|
||||||
|
@@ -10,6 +10,7 @@ const LoadCharts = lazy(() => import("./LoadCharts"));
|
|||||||
const PingChart = lazy(() => import("./PingChart"));
|
const PingChart = lazy(() => import("./PingChart"));
|
||||||
import Loading from "@/components/loading";
|
import Loading from "@/components/loading";
|
||||||
import Flag from "@/components/sections/Flag";
|
import Flag from "@/components/sections/Flag";
|
||||||
|
import { useConfigItem } from "@/config";
|
||||||
|
|
||||||
const InstancePage = () => {
|
const InstancePage = () => {
|
||||||
const { uuid } = useParams<{ uuid: string }>();
|
const { uuid } = useParams<{ uuid: string }>();
|
||||||
@@ -20,12 +21,13 @@ const InstancePage = () => {
|
|||||||
loading: nodesLoading,
|
loading: nodesLoading,
|
||||||
} = useNodeData();
|
} = useNodeData();
|
||||||
const { liveData } = useLiveData();
|
const { liveData } = useLiveData();
|
||||||
const { getRecentLoadHistory } = useNodeData();
|
useNodeData();
|
||||||
const [staticNode, setStaticNode] = useState<NodeData | null>(null);
|
const [staticNode, setStaticNode] = useState<NodeData | null>(null);
|
||||||
const [chartType, setChartType] = useState<"load" | "ping">("load");
|
const [chartType, setChartType] = useState<"load" | "ping">("load");
|
||||||
const [loadHours, setLoadHours] = useState<number>(0);
|
const [loadHours, setLoadHours] = useState<number>(0);
|
||||||
const [pingHours, setPingHours] = useState<number>(1); // 默认1小时
|
const [pingHours, setPingHours] = useState<number>(1); // 默认1小时
|
||||||
const [realtimeChartData, setRealtimeChartData] = useState<any[]>([]);
|
const enableInstanceDetail = useConfigItem("enableInstanceDetail");
|
||||||
|
const enablePingChart = useConfigItem("enablePingChart");
|
||||||
|
|
||||||
const maxRecordPreserveTime = publicSettings?.record_preserve_time || 0; // 默认0表示关闭
|
const maxRecordPreserveTime = publicSettings?.record_preserve_time || 0; // 默认0表示关闭
|
||||||
const maxPingRecordPreserveTime =
|
const maxPingRecordPreserveTime =
|
||||||
@@ -76,57 +78,6 @@ const InstancePage = () => {
|
|||||||
setStaticNode(foundNode || null);
|
setStaticNode(foundNode || null);
|
||||||
}, [staticNodes, uuid]);
|
}, [staticNodes, uuid]);
|
||||||
|
|
||||||
// Effect for fetching initial realtime data
|
|
||||||
useEffect(() => {
|
|
||||||
if (uuid && loadHours === 0) {
|
|
||||||
const fetchInitialData = async () => {
|
|
||||||
try {
|
|
||||||
const data = await getRecentLoadHistory(uuid);
|
|
||||||
setRealtimeChartData(data?.records || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch initial realtime chart data:", error);
|
|
||||||
setRealtimeChartData([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchInitialData();
|
|
||||||
}
|
|
||||||
}, [uuid, loadHours, getRecentLoadHistory]);
|
|
||||||
|
|
||||||
// Effect for handling live data updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (loadHours !== 0 || !liveData?.data || !uuid || !liveData.data[uuid]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = liveData.data[uuid];
|
|
||||||
const newRecord = {
|
|
||||||
client: uuid,
|
|
||||||
time: new Date(stats.updated_at).toISOString(),
|
|
||||||
cpu: stats.cpu.usage,
|
|
||||||
ram: stats.ram.used,
|
|
||||||
disk: stats.disk.used,
|
|
||||||
load: stats.load.load1,
|
|
||||||
net_in: stats.network.down,
|
|
||||||
net_out: stats.network.up,
|
|
||||||
process: stats.process,
|
|
||||||
connections: stats.connections.tcp,
|
|
||||||
gpu: 0,
|
|
||||||
ram_total: stats.ram.total,
|
|
||||||
swap: stats.swap.used,
|
|
||||||
swap_total: stats.swap.total,
|
|
||||||
temp: 0,
|
|
||||||
disk_total: stats.disk.total,
|
|
||||||
net_total_up: stats.network.totalUp,
|
|
||||||
net_total_down: stats.network.totalDown,
|
|
||||||
connections_udp: stats.connections.udp,
|
|
||||||
};
|
|
||||||
|
|
||||||
setRealtimeChartData((prev) => {
|
|
||||||
const updated = [...prev, newRecord];
|
|
||||||
return updated.length > 600 ? updated.slice(-600) : updated;
|
|
||||||
});
|
|
||||||
}, [liveData, uuid, loadHours]);
|
|
||||||
|
|
||||||
const node = useMemo(() => {
|
const node = useMemo(() => {
|
||||||
if (!staticNode) return null;
|
if (!staticNode) return null;
|
||||||
const isOnline = liveData?.online.includes(staticNode.uuid) ?? false;
|
const isOnline = liveData?.online.includes(staticNode.uuid) ?? false;
|
||||||
@@ -174,45 +125,49 @@ const InstancePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Instance node={node as NodeWithStatus} />
|
{enableInstanceDetail && <Instance node={node as NodeWithStatus} />}
|
||||||
|
|
||||||
<div className="flex justify-center space-x-2">
|
<div className="bg-card border rounded-lg py-3 px-4 inline-block mx-auto">
|
||||||
<Button
|
<div className="flex justify-center space-x-2">
|
||||||
variant={chartType === "load" ? "secondary" : "ghost"}
|
<Button
|
||||||
onClick={() => setChartType("load")}>
|
variant={chartType === "load" ? "secondary" : "ghost"}
|
||||||
负载
|
onClick={() => setChartType("load")}>
|
||||||
</Button>
|
负载
|
||||||
<Button
|
</Button>
|
||||||
variant={chartType === "ping" ? "secondary" : "ghost"}
|
{enablePingChart && (
|
||||||
onClick={() => setChartType("ping")}>
|
<Button
|
||||||
延迟
|
variant={chartType === "ping" ? "secondary" : "ghost"}
|
||||||
</Button>
|
onClick={() => setChartType("ping")}>
|
||||||
|
延迟
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{chartType === "load" ? (
|
||||||
|
<div className="flex justify-center space-x-2 mt-2">
|
||||||
|
{loadTimeRanges.map((range) => (
|
||||||
|
<Button
|
||||||
|
key={range.label}
|
||||||
|
variant={loadHours === range.hours ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLoadHours(range.hours)}>
|
||||||
|
{range.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center space-x-2 mt-2">
|
||||||
|
{pingTimeRanges.map((range) => (
|
||||||
|
<Button
|
||||||
|
key={range.label}
|
||||||
|
variant={pingHours === range.hours ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPingHours(range.hours)}>
|
||||||
|
{range.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{chartType === "load" ? (
|
|
||||||
<div className="flex justify-center space-x-2">
|
|
||||||
{loadTimeRanges.map((range) => (
|
|
||||||
<Button
|
|
||||||
key={range.label}
|
|
||||||
variant={loadHours === range.hours ? "secondary" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setLoadHours(range.hours)}>
|
|
||||||
{range.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex justify-center space-x-2">
|
|
||||||
{pingTimeRanges.map((range) => (
|
|
||||||
<Button
|
|
||||||
key={range.label}
|
|
||||||
variant={pingHours === range.hours ? "secondary" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPingHours(range.hours)}>
|
|
||||||
{range.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
@@ -224,7 +179,6 @@ const InstancePage = () => {
|
|||||||
<LoadCharts
|
<LoadCharts
|
||||||
node={staticNode}
|
node={staticNode}
|
||||||
hours={loadHours}
|
hours={loadHours}
|
||||||
data={realtimeChartData}
|
|
||||||
liveData={liveData?.data[staticNode.uuid]}
|
liveData={liveData?.data[staticNode.uuid]}
|
||||||
/>
|
/>
|
||||||
) : chartType === "ping" && staticNode ? (
|
) : chartType === "ping" && staticNode ? (
|
||||||
|
2
src/types/node.d.ts
vendored
2
src/types/node.d.ts
vendored
@@ -18,6 +18,8 @@ export interface NodeData {
|
|||||||
expired_at: string | null;
|
expired_at: string | null;
|
||||||
group: string;
|
group: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
|
traffic_limit?: number;
|
||||||
|
traffic_limit_type?: "sum" | "max" | "min" | "up" | "down";
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user