mirror of
https://github.com/fankes/komari-theme-purcarte.git
synced 2025-10-20 04:19:22 +08:00
feat: 添加磨砂玻璃效果自定义配置及相关样式支持
This commit is contained in:
@@ -8,7 +8,7 @@ import Loading from "@/components/loading";
|
||||
import type { NodeWithStatus } from "@/types/node";
|
||||
import { useNodeData } from "@/contexts/NodeDataContext";
|
||||
import { useLiveData } from "@/contexts/LiveDataContext";
|
||||
import { useConfigItem } from "@/config";
|
||||
import { useAppConfig } from "@/config";
|
||||
|
||||
interface HomePageProps {
|
||||
viewMode: "grid" | "table";
|
||||
@@ -19,8 +19,7 @@ const HomePage: React.FC<HomePageProps> = ({ viewMode, searchTerm }) => {
|
||||
const { nodes: staticNodes, loading, getGroups } = useNodeData();
|
||||
const { liveData } = useLiveData();
|
||||
const [selectedGroup, setSelectedGroup] = useState("所有");
|
||||
const enableGroupedBar = useConfigItem("enableGroupedBar");
|
||||
const enableStatsBar = useConfigItem("enableStatsBar");
|
||||
const { enableGroupedBar, enableStatsBar, enableSwap } = useAppConfig();
|
||||
const [displayOptions, setDisplayOptions] = useState({
|
||||
time: true,
|
||||
online: true,
|
||||
@@ -95,7 +94,7 @@ const HomePage: React.FC<HomePageProps> = ({ viewMode, searchTerm }) => {
|
||||
|
||||
<main className="flex-1 px-4 pb-4">
|
||||
{enableGroupedBar && (
|
||||
<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]">
|
||||
<div className="flex overflow-auto whitespace-nowrap overflow-x-auto items-center min-w-[300px] text-secondary-foreground box-border border border-border space-x-4 px-4 rounded-lg mb-4 purcarte-blur">
|
||||
<span>分组</span>
|
||||
{groups.map((group: string) => (
|
||||
<Button
|
||||
@@ -117,7 +116,7 @@ const HomePage: React.FC<HomePageProps> = ({ viewMode, searchTerm }) => {
|
||||
className={
|
||||
viewMode === "grid"
|
||||
? ""
|
||||
: "space-y-2 bg-card overflow-auto backdrop-blur-[10px] rounded-lg p-2"
|
||||
: "space-y-2 overflow-auto box-border border border-border purcarte-blur rounded-lg p-2"
|
||||
}>
|
||||
<div
|
||||
className={
|
||||
@@ -125,12 +124,22 @@ const HomePage: React.FC<HomePageProps> = ({ viewMode, searchTerm }) => {
|
||||
? "grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4"
|
||||
: "min-w-[1080px]"
|
||||
}>
|
||||
{viewMode === "table" && <NodeListHeader />}
|
||||
{viewMode === "table" && (
|
||||
<NodeListHeader enableSwap={enableSwap} />
|
||||
)}
|
||||
{filteredNodes.map((node: NodeWithStatus) =>
|
||||
viewMode === "grid" ? (
|
||||
<NodeCard key={node.uuid} node={node} />
|
||||
<NodeCard
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
enableSwap={enableSwap}
|
||||
/>
|
||||
) : (
|
||||
<NodeListItem key={node.uuid} node={node} />
|
||||
<NodeListItem
|
||||
key={node.uuid}
|
||||
node={node}
|
||||
enableSwap={enableSwap}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
@@ -97,14 +97,14 @@ const LoadCharts = memo(({ node, hours, liveData }: LoadChartsProps) => {
|
||||
? `${formatBytes(liveData.ram.used)} / ${formatBytes(
|
||||
node?.mem_total || 0
|
||||
)}`
|
||||
: "-"}
|
||||
: "N/A"}
|
||||
</label>
|
||||
<label>
|
||||
{liveData?.swap?.used
|
||||
? `${formatBytes(liveData.swap.used)} / ${formatBytes(
|
||||
node?.swap_total || 0
|
||||
)}`
|
||||
: "-"}
|
||||
: "N/A"}
|
||||
</label>
|
||||
</Flex>
|
||||
),
|
||||
@@ -199,6 +199,8 @@ const LoadCharts = memo(({ node, hours, liveData }: LoadChartsProps) => {
|
||||
tooltipLabel: "UDP 连接",
|
||||
},
|
||||
],
|
||||
yAxisFormatter: (value: number, index: number) =>
|
||||
index !== 0 ? `${value}` : "",
|
||||
data: chartData,
|
||||
},
|
||||
{
|
||||
@@ -208,6 +210,8 @@ const LoadCharts = memo(({ node, hours, liveData }: LoadChartsProps) => {
|
||||
value: liveData?.process || "-",
|
||||
dataKey: "process",
|
||||
color: colors[0],
|
||||
yAxisFormatter: (value: number, index: number) =>
|
||||
index !== 0 ? `${value}` : "",
|
||||
data: chartData,
|
||||
tooltipLabel: "进程数",
|
||||
},
|
||||
@@ -260,8 +264,8 @@ const LoadCharts = memo(({ node, hours, liveData }: LoadChartsProps) => {
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={{ fontSize: 10 }}
|
||||
axisLine={{ stroke: "var(--muted-foreground)" }}
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
tickFormatter={timeFormatter}
|
||||
interval={0}
|
||||
height={20}
|
||||
@@ -273,8 +277,11 @@ const LoadCharts = memo(({ node, hours, liveData }: LoadChartsProps) => {
|
||||
tickFormatter={config.yAxisFormatter}
|
||||
orientation="left"
|
||||
type="number"
|
||||
tick={{ fontSize: 10, dx: -8 }}
|
||||
width={25}
|
||||
tick={{
|
||||
dx: -8,
|
||||
fill: "var(--muted-foreground)",
|
||||
}}
|
||||
width={200}
|
||||
mirror={true}
|
||||
/>
|
||||
<Tooltip
|
||||
@@ -319,12 +326,12 @@ const LoadCharts = memo(({ node, hours, liveData }: LoadChartsProps) => {
|
||||
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">
|
||||
<div className="absolute inset-0 flex items-center justify-center purcarte-blur 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">
|
||||
<div className="absolute inset-0 flex items-center justify-center purcarte-blur rounded-lg z-10">
|
||||
<p className="text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
@@ -8,6 +8,7 @@ import {
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Brush,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -18,6 +19,7 @@ import { usePingChart } from "@/hooks/usePingChart";
|
||||
import fillMissingTimePoints, { cutPeakValues } from "@/utils/RecordHelper";
|
||||
import { useConfigItem } from "@/config";
|
||||
import { CustomTooltip } from "@/components/ui/tooltip";
|
||||
import Tips from "@/components/ui/tips";
|
||||
|
||||
interface PingChartProps {
|
||||
node: NodeData;
|
||||
@@ -29,6 +31,7 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
||||
const [visiblePingTasks, setVisiblePingTasks] = useState<number[]>([]);
|
||||
const [timeRange, setTimeRange] = useState<[number, number] | null>(null);
|
||||
const [cutPeak, setCutPeak] = useState(false);
|
||||
const [connectBreaks, setConnectBreaks] = useState(false);
|
||||
const maxPointsToRender = useConfigItem("pingChartMaxPoints") || 0; // 0表示不限制
|
||||
|
||||
useEffect(() => {
|
||||
@@ -144,70 +147,103 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
||||
}, [pingHistory?.tasks]);
|
||||
|
||||
const generateColor = useCallback(
|
||||
(taskName: string, total: number) => {
|
||||
(taskName: string, total: number, isBreakPoints?: boolean) => {
|
||||
const index = sortedTasks.findIndex((t) => t.name === taskName);
|
||||
if (index === -1) return "#000000"; // Fallback color
|
||||
|
||||
const hue = (index * (360 / total)) % 360;
|
||||
return `hsl(${hue}, 50%, 60%)`;
|
||||
return `hsla(${hue}, 50%, 60%, ${isBreakPoints ? 0.7 : 1})`;
|
||||
},
|
||||
[sortedTasks]
|
||||
);
|
||||
|
||||
const breakPoints = useMemo(() => {
|
||||
if (!connectBreaks || !chartData || chartData.length < 2) {
|
||||
return [];
|
||||
}
|
||||
const points: { x: number; color: string }[] = [];
|
||||
for (const task of sortedTasks) {
|
||||
if (!visiblePingTasks.includes(task.id)) {
|
||||
continue;
|
||||
}
|
||||
const taskKey = String(task.id);
|
||||
for (let i = 1; i < chartData.length; i++) {
|
||||
const prevPoint = chartData[i - 1];
|
||||
const currentPoint = chartData[i];
|
||||
|
||||
const isBreak =
|
||||
(currentPoint[taskKey] === null ||
|
||||
currentPoint[taskKey] === undefined) &&
|
||||
prevPoint[taskKey] !== null &&
|
||||
prevPoint[taskKey] !== undefined;
|
||||
|
||||
if (isBreak) {
|
||||
points.push({
|
||||
x: currentPoint.time,
|
||||
color: generateColor(task.name, sortedTasks.length, true),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}, [chartData, sortedTasks, visiblePingTasks, generateColor, connectBreaks]);
|
||||
|
||||
return (
|
||||
<div className="relative space-y-4">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-card/50 backdrop-blur-sm rounded-lg z-10">
|
||||
<div className="absolute inset-0 flex items-center justify-center purcarte-blur 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">
|
||||
<div className="absolute inset-0 flex items-center justify-center purcarte-blur rounded-lg z-10">
|
||||
<p className="text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-2">
|
||||
<div className="flex flex-wrap gap-2 items-center justify-center">
|
||||
{sortedTasks.map((task) => {
|
||||
const values = chartData
|
||||
.map((d) => d[task.id])
|
||||
.filter((v) => v !== null && v !== undefined) as number[];
|
||||
const loss =
|
||||
chartData.length > 0
|
||||
? (1 - values.length / chartData.length) * 100
|
||||
: 0;
|
||||
const min = values.length > 0 ? Math.min(...values) : 0;
|
||||
const isVisible = visiblePingTasks.includes(task.id);
|
||||
const color = generateColor(task.name, sortedTasks.length);
|
||||
{pingHistory?.tasks && pingHistory.tasks.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-2">
|
||||
<div className="flex flex-wrap gap-2 items-center justify-center">
|
||||
{sortedTasks.map((task) => {
|
||||
const values = chartData
|
||||
.map((d) => d[task.id])
|
||||
.filter((v) => v !== null && v !== undefined) as number[];
|
||||
const loss =
|
||||
chartData.length > 0
|
||||
? (1 - values.length / chartData.length) * 100
|
||||
: 0;
|
||||
const min = values.length > 0 ? Math.min(...values) : 0;
|
||||
const isVisible = visiblePingTasks.includes(task.id);
|
||||
const color = generateColor(task.name, sortedTasks.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`h-auto px-3 py-1.5 flex flex-col leading-snug text-center cursor-pointer rounded-md transition-all outline-2 outline ${
|
||||
isVisible ? "" : "outline-transparent"
|
||||
}`}
|
||||
onClick={() => handleTaskVisibilityToggle(task.id)}
|
||||
style={{
|
||||
outlineColor: isVisible ? color : undefined,
|
||||
boxShadow: isVisible ? `0 0 8px ${color}` : undefined,
|
||||
}}>
|
||||
<div className="font-semibold">{task.name}</div>
|
||||
<span className="text-xs font-normal">
|
||||
{loss.toFixed(1)}% | {min.toFixed(0)}ms
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`h-auto px-3 py-1.5 flex flex-col leading-snug text-center cursor-pointer rounded-md transition-all outline-2 outline ${
|
||||
isVisible ? "" : "outline-transparent"
|
||||
}`}
|
||||
onClick={() => handleTaskVisibilityToggle(task.id)}
|
||||
style={{
|
||||
outlineColor: isVisible ? color : undefined,
|
||||
boxShadow: isVisible ? `0 0 8px ${color}` : undefined,
|
||||
}}>
|
||||
<div className="font-semibold">{task.name}</div>
|
||||
<span className="text-xs font-normal">
|
||||
{loss.toFixed(1)}% | {min.toFixed(0)}ms
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="peak-shaving"
|
||||
@@ -215,6 +251,30 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
||||
onCheckedChange={setCutPeak}
|
||||
/>
|
||||
<Label htmlFor="peak-shaving">平滑</Label>
|
||||
<Tips>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
'<h2 class="text-lg font-bold">关于数据平滑的提示</h2><p>当您开启平滑后,您在统计图中看到的曲线经过<strong>指数加权移动平均 (EWMA)</strong> 算法处理,这是一种常用的数据平滑技术。</p></br><p>需要注意的是,经过EWMA算法平滑后的曲线所展示的数值,<strong>并非原始的、真实的测量数据</strong>。它们是根据EWMA算法计算得出的一个<strong>平滑趋势线</strong>,旨在减少数据波动,使数据模式和趋势更容易被识别。</p></br><p>因此,您看到的数值更像是<strong>视觉上的呈现</strong>,帮助您更好地理解数据的整体走向和长期趋势,而不是每一个时间点的精确真实值。如果您需要查看具体、原始的数据点,请参考未经平滑处理的数据视图。</p>',
|
||||
}}
|
||||
/>
|
||||
</Tips>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="connect-breaks"
|
||||
checked={connectBreaks}
|
||||
onCheckedChange={setConnectBreaks}
|
||||
/>
|
||||
<Label htmlFor="connect-breaks">连接断点</Label>
|
||||
<Tips>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
'<h2 class="text-lg font-bold">关于连接断点的提示</h2><p>当您开启"连接断点"功能后,图表中的曲线将会跨过那些由于网络问题或其他原因导致的丢包点,形成一条连续的线条。同时,系统会在丢包位置显示<strong>半透明的垂直参考线</strong>来标记断点位置。</p>',
|
||||
}}
|
||||
/>
|
||||
</Tips>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,24 +304,39 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
|
||||
minute: "2-digit",
|
||||
});
|
||||
}}
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
scale="time"
|
||||
/>
|
||||
<YAxis mirror={true} width={30} />
|
||||
<YAxis
|
||||
mirror={true}
|
||||
width={30}
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
content={<CustomTooltip labelFormatter={lableFormatter} />}
|
||||
/>
|
||||
{connectBreaks &&
|
||||
breakPoints.map((point, index) => (
|
||||
<ReferenceLine
|
||||
key={`break-${index}`}
|
||||
x={point.x}
|
||||
stroke={point.color}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
))}
|
||||
{sortedTasks.map((task) => (
|
||||
<Line
|
||||
key={task.id}
|
||||
type={cutPeak ? "basis" : "linear"}
|
||||
type={"basis"}
|
||||
dataKey={String(task.id)}
|
||||
name={task.name}
|
||||
stroke={generateColor(task.name, sortedTasks.length)}
|
||||
strokeWidth={2}
|
||||
hide={!visiblePingTasks.includes(task.id)}
|
||||
dot={false}
|
||||
connectNulls={false}
|
||||
connectNulls={connectBreaks}
|
||||
/>
|
||||
))}
|
||||
<Brush
|
||||
|
@@ -11,6 +11,7 @@ const PingChart = lazy(() => import("./PingChart"));
|
||||
import Loading from "@/components/loading";
|
||||
import Flag from "@/components/sections/Flag";
|
||||
import { useConfigItem } from "@/config";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
|
||||
const InstancePage = () => {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
@@ -28,6 +29,7 @@ const InstancePage = () => {
|
||||
const [pingHours, setPingHours] = useState<number>(1); // 默认1小时
|
||||
const enableInstanceDetail = useConfigItem("enableInstanceDetail");
|
||||
const enablePingChart = useConfigItem("enablePingChart");
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const maxRecordPreserveTime = publicSettings?.record_preserve_time || 0; // 默认0表示关闭
|
||||
const maxPingRecordPreserveTime =
|
||||
@@ -106,10 +108,10 @@ const InstancePage = () => {
|
||||
|
||||
return (
|
||||
<div className="w-[90%] max-w-screen-2xl mx-auto flex-1 flex flex-col pb-15 p-4 space-y-4">
|
||||
<div className="flex items-center justify-between bg-card box-border border rounded-lg p-4 mb-4 text-secondary-foreground">
|
||||
<div className="flex items-center justify-between purcarte-blur box-border border border-border rounded-lg p-4 mb-4 text-secondary-foreground">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Button
|
||||
className="bg-card flex-shrink-0"
|
||||
className="flex-shrink-0"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}>
|
||||
@@ -127,24 +129,31 @@ const InstancePage = () => {
|
||||
|
||||
{enableInstanceDetail && <Instance node={node as NodeWithStatus} />}
|
||||
|
||||
<div className="flex justify-center w-full">
|
||||
<div className="bg-card border rounded-lg py-3 px-4">
|
||||
<div className="flex flex-col items-center w-full space-y-4">
|
||||
<div className="purcarte-blur box-border border border-border rounded-lg p-2">
|
||||
<div className="flex justify-center space-x-2">
|
||||
<Button
|
||||
variant={chartType === "load" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setChartType("load")}>
|
||||
负载
|
||||
</Button>
|
||||
{enablePingChart && (
|
||||
<Button
|
||||
variant={chartType === "ping" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setChartType("ping")}>
|
||||
延迟
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`purcarte-blur box-border border border-border justify-center rounded-lg p-2 ${
|
||||
isMobile ? "w-full" : ""
|
||||
}`}>
|
||||
{chartType === "load" ? (
|
||||
<div className="flex justify-center space-x-2 mt-2">
|
||||
<div className="flex space-x-2 overflow-x-auto whitespace-nowrap">
|
||||
{loadTimeRanges.map((range) => (
|
||||
<Button
|
||||
key={range.label}
|
||||
@@ -156,7 +165,7 @@ const InstancePage = () => {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center space-x-2 mt-2">
|
||||
<div className="flex space-x-2 overflow-x-auto whitespace-nowrap">
|
||||
{pingTimeRanges.map((range) => (
|
||||
<Button
|
||||
key={range.label}
|
||||
|
Reference in New Issue
Block a user