feat(theme): 新增主题可配置项,优化代码逻辑和样式

-  在 `komari-theme.json` 中添加了新的配置选项
- 支持自定义标题栏、内容区、实例页面和通用UI元素
- 优化部分组件调用逻辑
- 优化页面样式
This commit is contained in:
Montia37
2025-08-15 19:27:55 +08:00
parent e74611b947
commit 1c1f739043
21 changed files with 922 additions and 766 deletions

View File

@@ -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. **克隆仓库**

View File

@@ -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 或更小的值"
} }
] ]
} }

View File

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

View File

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

View File

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

View 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>
);

View File

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

View 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);

View 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>
);
}

View File

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

@@ -0,0 +1,5 @@
// 从各个文件中导出
export { ConfigProvider } from "./ConfigProvider";
export { useAppConfig, useConfigItem } from "./hooks";
export type { ConfigOptions } from "./hooks";
export { DEFAULT_CONFIG } from "./default";

View File

@@ -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,
}; };
}; };

View File

@@ -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(() => {

View File

@@ -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>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

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