feat: 新增流量限制功能,优化节点卡片和列表显示

This commit is contained in:
Montia37
2025-08-15 23:26:09 +08:00
parent 48be5c104d
commit 910f74b96d
10 changed files with 303 additions and 99 deletions

View File

@@ -22,7 +22,7 @@
### 配置背景图片 ### 配置背景图片
> 为获得最佳视觉效果,建议搭配背景图片使用 > 为获得最佳视觉效果,建议搭配背景图片使用
#### Komari v1.0.5 及以上版本 #### Komari v1.0.5 及以上版本

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": "1.0.2", "version": "1.0.3",
"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",

View File

@@ -1,5 +1,10 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatBytes, formatUptime, getOSImage } from "@/utils"; import {
formatBytes,
formatUptime,
getOSImage,
formatTrafficLimit,
} from "@/utils";
import type { NodeWithStatus } from "@/types/node"; import type { NodeWithStatus } from "@/types/node";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { CpuIcon, MemoryStickIcon, HardDriveIcon } from "lucide-react"; import { CpuIcon, MemoryStickIcon, HardDriveIcon } from "lucide-react";
@@ -7,6 +12,7 @@ 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"; import { ProgressBar } from "../ui/progress-bar";
import { CircleProgress } from "../ui/circle-progress";
interface NodeCardProps { interface NodeCardProps {
node: NodeWithStatus; node: NodeWithStatus;
@@ -23,6 +29,7 @@ export const NodeCard = ({ node }: NodeCardProps) => {
diskUsage, diskUsage,
load, load,
expired_at, expired_at,
trafficPercentage,
} = useNodeCommons(node); } = useNodeCommons(node);
const getProgressBarClass = (percentage: number) => { const getProgressBarClass = (percentage: number) => {
@@ -129,7 +136,7 @@ export const NodeCard = ({ node }: NodeCardProps) => {
</div> </div>
<div className="border-t border-border/60 my-2"></div> <div className="border-t border-border/60 my-2"></div>
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
<span className="text-secondary-foreground"></span> <span className="text-secondary-foreground"></span>
<div> <div>
<span> {stats ? formatBytes(stats.network.up, true) : "N/A"}</span> <span> {stats ? formatBytes(stats.network.up, true) : "N/A"}</span>
<span className="ml-2"> <span className="ml-2">
@@ -137,27 +144,52 @@ export const NodeCard = ({ node }: NodeCardProps) => {
</span> </span>
</div> </div>
</div> </div>
<div className="flex justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-secondary-foreground"></span> <span className="text-secondary-foreground w-1/4"></span>
<div className="flex items-center justify-between w-3/4">
<div className="flex items-center justify-center w-1/3">
{node.traffic_limit !== 0 && isOnline && stats && (
<CircleProgress
value={trafficPercentage}
maxValue={100}
size={32}
strokeWidth={4}
showPercentage={true}
/>
)}
</div>
<div className="w-2/3 text-right">
<div> <div>
<span> {stats ? formatBytes(stats.network.totalUp) : "N/A"}</span> <span>
{stats ? formatBytes(stats.network.totalUp) : "N/A"}
</span>
<span className="ml-2"> <span className="ml-2">
{stats ? formatBytes(stats.network.totalDown) : "N/A"} {stats ? formatBytes(stats.network.totalDown) : "N/A"}
</span> </span>
</div> </div>
{node.traffic_limit !== 0 && isOnline && stats && (
<div className="text-right">
{formatTrafficLimit(
node.traffic_limit,
node.traffic_limit_type
)}
</div>
)}
</div>
</div>
</div> </div>
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
<span className="text-secondary-foreground"></span> <span className="text-secondary-foreground"></span>
<span>{load}</span> <span>{load}</span>
</div> </div>
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
<div className="flex justify-between w-full"> <div className="flex justify-start w-full">
<span className="text-secondary-foreground"></span> <span className="text-secondary-foreground"></span>
<div className="flex items-center gap-1">{expired_at}</div> <div className="flex items-center gap-1">{expired_at}</div>
</div> </div>
<div className="border-l border-border/60 mx-2"></div> <div className="border-l border-border/60 mx-2"></div>
<div className="flex justify-between w-full"> <div className="flex justify-start w-full">
<span className="text-secondary-foreground">线</span> <span className="text-secondary-foreground">线</span>
<span> <span>
{isOnline && stats ? formatUptime(stats.uptime) : "离线"} {isOnline && stats ? formatUptime(stats.uptime) : "离线"}
</span> </span>

View File

@@ -1,12 +1,12 @@
export const NodeListHeader = () => { export const NodeListHeader = () => {
return ( return (
<div className="text-primary font-bold grid grid-cols-12 text-center shadow-md gap-4 p-2 items-center rounded-lg bg-card/50 transition-colors duration-200"> <div className="text-primary font-bold grid grid-cols-10 text-center shadow-md gap-4 p-2 items-center rounded-lg bg-card/50 transition-colors duration-200">
<div className="col-span-3"></div> <div className="col-span-2"></div>
<div className="col-span-1">CPU</div> <div className="col-span-1">CPU</div>
<div className="col-span-1"></div> <div className="col-span-1"></div>
<div className="col-span-1">SWAP</div> <div className="col-span-1">SWAP</div>
<div className="col-span-1"></div> <div className="col-span-1"></div>
<div className="col-span-2"></div> <div className="col-span-1"></div>
<div className="col-span-2"></div> <div className="col-span-2"></div>
<div className="col-span-1"></div> <div className="col-span-1"></div>
</div> </div>

View File

@@ -1,10 +1,11 @@
import { formatBytes, formatUptime } from "@/utils"; import { formatBytes, formatTrafficLimit, formatUptime } from "@/utils";
import type { NodeWithStatus } from "@/types/node"; import type { NodeWithStatus } from "@/types/node";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { CpuIcon, MemoryStickIcon, HardDriveIcon } from "lucide-react"; 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 { CircleProgress } from "../ui/circle-progress";
interface NodeListItemProps { interface NodeListItemProps {
node: NodeWithStatus; node: NodeWithStatus;
@@ -21,22 +22,23 @@ export const NodeListItem = ({ node }: NodeListItemProps) => {
diskUsage, diskUsage,
load, load,
expired_at, expired_at,
trafficPercentage,
} = useNodeCommons(node); } = useNodeCommons(node);
return ( return (
<div <div
className={`grid grid-cols-12 text-center shadow-md gap-4 p-2 items-center rounded-lg ${ className={`grid grid-cols-10 text-center shadow-md gap-4 p-2 items-center rounded-lg ${
isOnline isOnline
? "" ? ""
: "striped-bg-red-translucent-diagonal ring-2 ring-red-500/50" : "striped-bg-red-translucent-diagonal ring-2 ring-red-500/50"
} text-secondary-foreground transition-colors duration-200`}> } text-secondary-foreground transition-colors duration-200`}>
<div className="col-span-3 flex items-center text-left"> <div className="col-span-2 flex items-center text-left">
<Flag flag={node.region} /> <Flag flag={node.region} />
<Link to={`/instance/${node.uuid}`}> <Link to={`/instance/${node.uuid}`}>
<div className="ml-2 w-full"> <div className="ml-2 w-full">
<div className="text-base font-bold">{node.name}</div> <div className="text-base font-bold">{node.name}</div>
<Tag className="text-xs" tags={tagList} /> <Tag className="text-xs" tags={tagList} />
<div className="flex text-xs"> <div className="flex text-xs text-nowrap">
<div className="flex"> <div className="flex">
<span className="text-secondary-foreground"></span> <span className="text-secondary-foreground"></span>
<div className="flex items-center gap-1">{expired_at}</div> <div className="flex items-center gap-1">{expired_at}</div>
@@ -86,17 +88,42 @@ export const NodeListItem = ({ node }: NodeListItemProps) => {
{isOnline ? `${diskUsage.toFixed(1)}%` : "N/A"} {isOnline ? `${diskUsage.toFixed(1)}%` : "N/A"}
</div> </div>
</div> </div>
<div className="col-span-2"> <div className="col-span-1">
<span> <div> {stats ? formatBytes(stats.network.up, true) : "N/A"}</div>
{stats ? formatBytes(stats.network.up, true) : "N/A"}{" "} <div> {stats ? formatBytes(stats.network.down, true) : "N/A"}</div>
{stats ? formatBytes(stats.network.down, true) : "N/A"}
</span>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<div className="flex items-center justify-around">
{node.traffic_limit !== 0 && isOnline && stats && (
<div className="flex items-center">
<CircleProgress
value={trafficPercentage}
maxValue={100}
size={32}
strokeWidth={4}
showPercentage={true}
/>
</div>
)}
<div className={node.traffic_limit !== 0 ? "w-2/3" : "w-full"}>
<div>
<span> <span>
{stats ? formatBytes(stats.network.totalUp) : "N/A"}{" "} {stats ? formatBytes(stats.network.totalUp) : "N/A"}
{stats ? formatBytes(stats.network.totalDown) : "N/A"}
</span> </span>
<span className="ml-2">
{stats ? formatBytes(stats.network.totalDown) : "N/A"}
</span>
</div>
{node.traffic_limit !== 0 && isOnline && stats && (
<div>
{formatTrafficLimit(
node.traffic_limit,
node.traffic_limit_type
)}
</div>
)}
</div>
</div>
</div> </div>
<div className="col-span-1"> <div className="col-span-1">
<span>{load}</span> <span>{load}</span>

View File

@@ -0,0 +1,66 @@
import React from "react";
interface CircleProgressProps {
value: number;
maxValue?: number;
size?: number;
strokeWidth?: number;
className?: string;
showPercentage?: boolean;
color?: string;
}
export const CircleProgress: React.FC<CircleProgressProps> = ({
value,
maxValue = 100,
size = 40,
strokeWidth = 4,
className = "",
showPercentage = true,
color = "",
}) => {
const percentage = Math.min(100, Math.max(0, (value / maxValue) * 100));
const radius = size / 2 - strokeWidth / 2;
const circumference = radius * 2 * Math.PI;
const strokeDashoffset = circumference - (percentage / 100) * circumference;
const getColor = () => {
if (color) return color;
if (percentage > 90) return "stroke-red-600";
if (percentage > 50) return "stroke-yellow-400";
return "stroke-green-500";
};
return (
<div className={`relative inline-flex ${className}`}>
<svg width={size} height={size} className="transform -rotate-90">
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
className="stroke-gray-200 dark:stroke-gray-700"
strokeWidth={strokeWidth}
fill="none"
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
className={getColor()}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
fill="none"
/>
</svg>
{showPercentage && (
<div className="absolute inset-0 flex items-center justify-center text-xs font-medium">
{percentage.toFixed(0)}%
</div>
)}
</div>
);
};

View File

@@ -68,6 +68,33 @@ export const useNodeCommons = (node: NodeWithStatus) => {
: []), : []),
]; ];
// 计算流量使用百分比
const trafficPercentage = useMemo(() => {
if (!node.traffic_limit || !stats || !isOnline) return 0;
// 根据流量限制类型确定使用的流量值
let usedTraffic = 0;
switch (node.traffic_limit_type) {
case "up":
usedTraffic = stats.network.totalUp;
break;
case "down":
usedTraffic = stats.network.totalDown;
break;
case "sum":
usedTraffic = stats.network.totalUp + stats.network.totalDown;
break;
case "min":
usedTraffic = Math.min(stats.network.totalUp, stats.network.totalDown);
break;
default: // max 或者未设置
usedTraffic = Math.max(stats.network.totalUp, stats.network.totalDown);
break;
}
return (usedTraffic / node.traffic_limit) * 100;
}, [node.traffic_limit, node.traffic_limit_type, stats, isOnline]);
return { return {
stats, stats,
isOnline, isOnline,
@@ -78,5 +105,6 @@ export const useNodeCommons = (node: NodeWithStatus) => {
diskUsage, diskUsage,
load, load,
expired_at, expired_at,
trafficPercentage,
}; };
}; };

View File

@@ -1,32 +1,13 @@
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"; import { formatBytes, formatUptime, formatTrafficLimit } from "@/utils";
import { CircleProgress } from "@/components/ui/circle-progress";
interface InstanceProps { interface InstanceProps {
node: NodeWithStatus; node: NodeWithStatus;
} }
const formatTrafficLimit = (
limit?: number,
type?: "sum" | "max" | "min" | "up" | "down"
) => {
if (!limit) return "未设置";
const limitText = formatBytes(limit);
const typeText =
{
sum: "总和",
max: "最大值",
min: "最小值",
up: "上传",
down: "下载",
}[type || "max"] || "";
return `${limitText} (${typeText})`;
};
const Instance = memo(({ node }: InstanceProps) => { const Instance = memo(({ node }: InstanceProps) => {
const { stats, isOnline } = useMemo(() => { const { stats, isOnline } = useMemo(() => {
return { return {
@@ -35,6 +16,33 @@ const Instance = memo(({ node }: InstanceProps) => {
}; };
}, [node]); }, [node]);
// 计算流量使用百分比
const trafficPercentage = useMemo(() => {
if (!node.traffic_limit || !stats || !isOnline) return 0;
// 根据流量限制类型确定使用的流量值
let usedTraffic = 0;
switch (node.traffic_limit_type) {
case "up":
usedTraffic = stats.network.totalUp;
break;
case "down":
usedTraffic = stats.network.totalDown;
break;
case "sum":
usedTraffic = stats.network.totalUp + stats.network.totalDown;
break;
case "min":
usedTraffic = Math.min(stats.network.totalUp, stats.network.totalDown);
break;
default: // max 或者未设置
usedTraffic = Math.max(stats.network.totalUp, stats.network.totalDown);
break;
}
return (usedTraffic / node.traffic_limit) * 100;
}, [node.traffic_limit, node.traffic_limit_type, stats, isOnline]);
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
@@ -95,7 +103,7 @@ const Instance = memo(({ node }: InstanceProps) => {
<p className="text-muted-foreground text-sm"></p> <p className="text-muted-foreground text-sm"></p>
<p className="text-sm">{formatUptime(stats?.uptime || 0)}</p> <p className="text-sm">{formatUptime(stats?.uptime || 0)}</p>
</div> </div>
<div className="md:col-span-2"> <div>
<p className="text-muted-foreground text-sm"></p> <p className="text-muted-foreground text-sm"></p>
<p className="text-sm"> <p className="text-sm">
{stats && isOnline {stats && isOnline
@@ -106,8 +114,17 @@ const Instance = memo(({ node }: InstanceProps) => {
: "N/A"} : "N/A"}
</p> </p>
</div> </div>
<div className="md:col-span-2"> <div>
<p className="text-muted-foreground text-sm"></p> <p className="text-muted-foreground text-sm"></p>
<p className="flex items-center gap-2">
{node.traffic_limit && isOnline && (
<CircleProgress
value={trafficPercentage}
maxValue={100}
size={36}
/>
)}
<div>
<p className="text-sm"> <p className="text-sm">
{stats && isOnline {stats && isOnline
? `${formatBytes(stats.network.totalUp)}${formatBytes( ? `${formatBytes(stats.network.totalUp)}${formatBytes(
@@ -115,11 +132,23 @@ const Instance = memo(({ node }: InstanceProps) => {
)}` )}`
: "N/A"} : "N/A"}
</p> </p>
</div>
<div className="md:col-span-2">
<p className="text-muted-foreground text-sm"></p>
<p className="text-sm"> <p className="text-sm">
{formatTrafficLimit(node.traffic_limit, node.traffic_limit_type)} {formatTrafficLimit(
node.traffic_limit,
node.traffic_limit_type
)}
</p>
</div>
</p>
</div>
<div>
<p className="text-muted-foreground text-sm"></p>
<p className="text-sm">
{stats && isOnline
? `${stats.load.load1.toFixed(2)} | ${stats.load.load5.toFixed(
2
)} | ${stats.load.load15.toFixed(2)}`
: "N/A"}
</p> </p>
</div> </div>
<div> <div>

View File

@@ -127,7 +127,8 @@ const InstancePage = () => {
{enableInstanceDetail && <Instance node={node as NodeWithStatus} />} {enableInstanceDetail && <Instance node={node as NodeWithStatus} />}
<div className="bg-card border rounded-lg py-3 px-4 inline-block mx-auto"> <div className="flex justify-center w-full">
<div className="bg-card border rounded-lg py-3 px-4">
<div className="flex justify-center space-x-2"> <div className="flex justify-center space-x-2">
<Button <Button
variant={chartType === "load" ? "secondary" : "ghost"} variant={chartType === "load" ? "secondary" : "ghost"}
@@ -168,6 +169,7 @@ const InstancePage = () => {
</div> </div>
)} )}
</div> </div>
</div>
<Suspense <Suspense
fallback={ fallback={

View File

@@ -76,3 +76,23 @@ export const formatPrice = (
return `${currency}${price.toFixed(2)}/${cycleStr}`; return `${currency}${price.toFixed(2)}/${cycleStr}`;
}; };
export const formatTrafficLimit = (
limit?: number,
type?: "sum" | "max" | "min" | "up" | "down"
) => {
if (!limit) return "未设置";
const limitText = formatBytes(limit);
const typeText =
{
sum: "总和",
max: "最大值",
min: "最小值",
up: "上传",
down: "下载",
}[type || "max"] || "";
return `${limitText} (${typeText})`;
};