mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-05-06 13:58:43 +00:00
feat: enhance tooltips and loading states in NetworkChart and ServerDetailChart; add translations for TSDB and login requirements
This commit is contained in:
@@ -28,6 +28,12 @@ import {
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
fetchLoginUser,
|
||||
fetchMonitor,
|
||||
@@ -533,41 +539,55 @@ export const NetworkChartClient = React.memo(function NetworkChart({
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3 sm:-mt-5 -mt-3 flex-wrap">
|
||||
<div className="flex items-center gap-1 rounded-full bg-muted dark:bg-muted/40 p-0.5 border border-border/60 dark:border-border">
|
||||
{TIME_RANGE_OPTIONS.map((option) => {
|
||||
const isLocked = !isLogin && option.value !== "1d";
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (!isLocked) {
|
||||
onPeriodChange(option.value);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300",
|
||||
period === option.value
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
isLocked && "cursor-not-allowed opacity-40 grayscale",
|
||||
)}
|
||||
>
|
||||
{period === option.value && (
|
||||
<m.div
|
||||
layoutId="network-period-selector-active"
|
||||
className="absolute inset-0 z-10 h-full w-full bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-20">{option.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={120}>
|
||||
<div className="flex items-center gap-1 rounded-full bg-muted dark:bg-muted/40 p-0.5 border border-border/60 dark:border-border">
|
||||
{TIME_RANGE_OPTIONS.map((option) => {
|
||||
const isLocked = !isLogin && option.value !== "1d";
|
||||
const optionItem = (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!isLocked) {
|
||||
onPeriodChange(option.value);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300",
|
||||
period === option.value
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
isLocked && "cursor-not-allowed opacity-40 grayscale",
|
||||
)}
|
||||
>
|
||||
{period === option.value && (
|
||||
<m.div
|
||||
layoutId="network-period-selector-active"
|
||||
className="absolute inset-0 z-10 h-full w-full bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-20">{option.label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLocked) {
|
||||
return (
|
||||
<Tooltip key={option.value}>
|
||||
<TooltipTrigger asChild>{optionItem}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("monitor.loginRequired", "Please login to view")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={option.value}>{optionItem}</div>;
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="Peak"
|
||||
|
||||
@@ -18,9 +18,19 @@ import {
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useWebSocketContext } from "@/hooks/use-websocket-context";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import { fetchLoginUser, fetchServerMetrics } from "@/lib/nezha-api";
|
||||
import {
|
||||
fetchLoginUser,
|
||||
fetchServerMetrics,
|
||||
fetchSetting,
|
||||
} from "@/lib/nezha-api";
|
||||
import {
|
||||
cn,
|
||||
formatNezhaInfo,
|
||||
@@ -77,14 +87,22 @@ type connectChartData = {
|
||||
udp: number;
|
||||
};
|
||||
|
||||
const MIN_HISTORY_LOADING_MS = 300;
|
||||
const sleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
function PeriodSelector({
|
||||
selectedPeriod,
|
||||
onPeriodChange,
|
||||
isLogin,
|
||||
isTsdbEnabled,
|
||||
}: {
|
||||
selectedPeriod: ChartPeriod;
|
||||
onPeriodChange: (period: ChartPeriod) => void;
|
||||
isLogin: boolean;
|
||||
isTsdbEnabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -96,44 +114,73 @@ function PeriodSelector({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-0.5 mb-3 flex-wrap sm:-mt-5 -mt-3 p-0.5 bg-muted dark:bg-muted/40 rounded-full w-fit border border-border/60 dark:border-border">
|
||||
{periods.map((period) => {
|
||||
// Only realtime and 1d are available for non-logged-in users
|
||||
const isLocked =
|
||||
!isLogin && period.value !== "realtime" && period.value !== "1d";
|
||||
return (
|
||||
<div
|
||||
key={period.value}
|
||||
onClick={() => {
|
||||
if (!isLocked) {
|
||||
onPeriodChange(period.value);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300",
|
||||
selectedPeriod === period.value
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
isLocked && "cursor-not-allowed opacity-40 grayscale",
|
||||
)}
|
||||
>
|
||||
{selectedPeriod === period.value && (
|
||||
<m.div
|
||||
layoutId="period-selector-active"
|
||||
className="absolute inset-0 z-10 h-full w-full bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
|
||||
transition={{ type: "spring", stiffness: 250, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-20 flex items-center gap-1.5">
|
||||
{period.value === "realtime" && (
|
||||
<span className="inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500 dark:bg-emerald-400"></span>
|
||||
<TooltipProvider delayDuration={120}>
|
||||
<div className="flex gap-0.5 mb-3 flex-wrap sm:-mt-5 -mt-3 p-0.5 bg-muted dark:bg-muted/40 rounded-full w-fit border border-border/60 dark:border-border">
|
||||
{periods.map((period) => {
|
||||
const isHistoryPeriod = period.value !== "realtime";
|
||||
const isLockedByTsdb = !isTsdbEnabled && isHistoryPeriod;
|
||||
// Only realtime and 1d are available for non-logged-in users
|
||||
const isLockedByLogin =
|
||||
!isLockedByTsdb &&
|
||||
!isLogin &&
|
||||
period.value !== "realtime" &&
|
||||
period.value !== "1d";
|
||||
const isLocked = isLockedByTsdb || isLockedByLogin;
|
||||
|
||||
const periodItem = (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!isLocked) {
|
||||
onPeriodChange(period.value);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-full px-3 py-1.5 text-xs font-medium transition-colors duration-300",
|
||||
selectedPeriod === period.value
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
isLocked && "cursor-not-allowed opacity-40 grayscale",
|
||||
)}
|
||||
{period.label}
|
||||
>
|
||||
{selectedPeriod === period.value && (
|
||||
<m.div
|
||||
layoutId="period-selector-active"
|
||||
className="absolute inset-0 z-10 h-full w-full bg-white dark:bg-background rounded-full ring-1 ring-border/60 dark:ring-border/40"
|
||||
transition={{ type: "spring", stiffness: 250, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-20 flex items-center gap-1.5">
|
||||
{period.value === "realtime" && (
|
||||
<span className="inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500 dark:bg-emerald-400"></span>
|
||||
)}
|
||||
{period.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLockedByTsdb || isLockedByLogin) {
|
||||
return (
|
||||
<Tooltip key={period.value}>
|
||||
<TooltipTrigger asChild>{periodItem}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isLockedByTsdb
|
||||
? t(
|
||||
"serverDetailChart.tsdbRequired",
|
||||
"Enable TSDB to use historical data",
|
||||
)
|
||||
: t(
|
||||
"serverDetailChart.loginRequired",
|
||||
"Please login to view",
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={period.value}>{periodItem}</div>;
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,12 +208,31 @@ export default function ServerDetailChart({
|
||||
? !!userData?.data?.id && !!document.cookie
|
||||
: false;
|
||||
|
||||
const { data: settingData } = useQuery({
|
||||
queryKey: ["setting"],
|
||||
queryFn: () => fetchSetting(),
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const isTsdbEnabled = settingData?.data?.tsdb_enabled ?? true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTsdbEnabled && selectedPeriod !== "realtime") {
|
||||
setSelectedPeriod("realtime");
|
||||
}
|
||||
}, [isTsdbEnabled, selectedPeriod]);
|
||||
|
||||
// Reset period if user is not logged in and selected period is restricted
|
||||
useEffect(() => {
|
||||
if (!isLogin && selectedPeriod !== "realtime" && selectedPeriod !== "1d") {
|
||||
if (
|
||||
isTsdbEnabled &&
|
||||
!isLogin &&
|
||||
selectedPeriod !== "realtime" &&
|
||||
selectedPeriod !== "1d"
|
||||
) {
|
||||
setSelectedPeriod("1d");
|
||||
}
|
||||
}, [isLogin, selectedPeriod]);
|
||||
}, [isLogin, isTsdbEnabled, selectedPeriod]);
|
||||
|
||||
if (!connected && !lastMessage) {
|
||||
return <ServerDetailChartLoading />;
|
||||
@@ -195,6 +261,7 @@ export default function ServerDetailChart({
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
isLogin={isLogin}
|
||||
isTsdbEnabled={isTsdbEnabled}
|
||||
/>
|
||||
<section className="grid md:grid-cols-2 lg:grid-cols-3 grid-cols-1 gap-3 server-charts">
|
||||
<CpuChart
|
||||
@@ -274,22 +341,24 @@ function useHistoricalData<T>(
|
||||
const [historicalData, setHistoricalData] = useState<T[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [displayData, setDisplayData] = useState<T[]>([]);
|
||||
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [loadedPeriod, setLoadedPeriod] = useState<ChartPeriod>("realtime");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (period === "realtime") {
|
||||
setHistoricalData([]);
|
||||
setDisplayData([]);
|
||||
if (loadingTimerRef.current) {
|
||||
clearTimeout(loadingTimerRef.current);
|
||||
}
|
||||
return;
|
||||
setIsLoading(false);
|
||||
setLoadedPeriod("realtime");
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loadingTimerRef.current = setTimeout(() => {
|
||||
setIsLoading(true);
|
||||
}, 200);
|
||||
const loadingStartedAt = Date.now();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetchServerMetrics(
|
||||
@@ -301,24 +370,35 @@ function useHistoricalData<T>(
|
||||
const transformedData = response.data.data_points.map((point) =>
|
||||
transformData(point.ts, point.value),
|
||||
);
|
||||
setHistoricalData(transformedData);
|
||||
setDisplayData(transformedData);
|
||||
if (!cancelled) {
|
||||
setHistoricalData(transformedData);
|
||||
setDisplayData(transformedData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch ${metricName} metrics:`, error);
|
||||
} finally {
|
||||
if (loadingTimerRef.current) {
|
||||
clearTimeout(loadingTimerRef.current);
|
||||
loadingTimerRef.current = null;
|
||||
const elapsed = Date.now() - loadingStartedAt;
|
||||
if (elapsed < MIN_HISTORY_LOADING_MS) {
|
||||
await sleep(MIN_HISTORY_LOADING_MS - elapsed);
|
||||
}
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
setLoadedPeriod(period);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [serverId, metricName, period, transformData]);
|
||||
|
||||
return { historicalData, displayData, isLoading };
|
||||
const isHistoricalLoading =
|
||||
period !== "realtime" && (isLoading || loadedPeriod !== period);
|
||||
|
||||
return { historicalData, displayData, isLoading: isHistoricalLoading };
|
||||
}
|
||||
|
||||
function GpuChart({
|
||||
@@ -938,14 +1018,23 @@ function MemChart({
|
||||
[],
|
||||
);
|
||||
const [isLoadingMem, setIsLoadingMem] = useState(false);
|
||||
const [loadedPeriodMem, setLoadedPeriodMem] =
|
||||
useState<ChartPeriod>("realtime");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (period === "realtime") {
|
||||
setMemHistoricalData([]);
|
||||
return;
|
||||
setIsLoadingMem(false);
|
||||
setLoadedPeriodMem("realtime");
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const fetchMemData = async () => {
|
||||
const loadingStartedAt = Date.now();
|
||||
setIsLoadingMem(true);
|
||||
try {
|
||||
const [memResponse, swapResponse] = await Promise.all([
|
||||
@@ -978,16 +1067,28 @@ function MemChart({
|
||||
swap: swapMap.get(point.ts) || 0,
|
||||
};
|
||||
});
|
||||
setMemHistoricalData(combinedData);
|
||||
if (!cancelled) {
|
||||
setMemHistoricalData(combinedData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch memory metrics:", error);
|
||||
} finally {
|
||||
setIsLoadingMem(false);
|
||||
const elapsed = Date.now() - loadingStartedAt;
|
||||
if (elapsed < MIN_HISTORY_LOADING_MS) {
|
||||
await sleep(MIN_HISTORY_LOADING_MS - elapsed);
|
||||
}
|
||||
if (!cancelled) {
|
||||
setIsLoadingMem(false);
|
||||
setLoadedPeriodMem(period);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchMemData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [data.id, period, data.host.mem_total, data.host.swap_total]);
|
||||
|
||||
// 初始化历史数据
|
||||
@@ -1058,6 +1159,8 @@ function MemChart({
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const displayData = period === "realtime" ? memChartData : memHistoricalData;
|
||||
const isMemLoading =
|
||||
period !== "realtime" && (isLoadingMem || loadedPeriodMem !== period);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -1121,7 +1224,7 @@ function MemChart({
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[130px] w-full"
|
||||
>
|
||||
{isLoadingMem ? (
|
||||
{isMemLoading ? (
|
||||
<ChartSkeleton />
|
||||
) : (
|
||||
<AreaChart
|
||||
@@ -1450,14 +1553,23 @@ function NetworkChart({
|
||||
networkChartData[]
|
||||
>([]);
|
||||
const [isLoadingNetwork, setIsLoadingNetwork] = useState(false);
|
||||
const [loadedPeriodNetwork, setLoadedPeriodNetwork] =
|
||||
useState<ChartPeriod>("realtime");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (period === "realtime") {
|
||||
setNetworkHistoricalData([]);
|
||||
return;
|
||||
setIsLoadingNetwork(false);
|
||||
setLoadedPeriodNetwork("realtime");
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const fetchNetworkData = async () => {
|
||||
const loadingStartedAt = Date.now();
|
||||
setIsLoadingNetwork(true);
|
||||
try {
|
||||
const [uploadResponse, downloadResponse] = await Promise.all([
|
||||
@@ -1479,16 +1591,28 @@ function NetworkChart({
|
||||
upload: point.value / 1024 / 1024, // Convert bytes to MB
|
||||
download: downloadMap.get(point.ts) || 0,
|
||||
}));
|
||||
setNetworkHistoricalData(combinedData);
|
||||
if (!cancelled) {
|
||||
setNetworkHistoricalData(combinedData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch network metrics:", error);
|
||||
} finally {
|
||||
setIsLoadingNetwork(false);
|
||||
const elapsed = Date.now() - loadingStartedAt;
|
||||
if (elapsed < MIN_HISTORY_LOADING_MS) {
|
||||
await sleep(MIN_HISTORY_LOADING_MS - elapsed);
|
||||
}
|
||||
if (!cancelled) {
|
||||
setIsLoadingNetwork(false);
|
||||
setLoadedPeriodNetwork(period);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchNetworkData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [data.id, period]);
|
||||
|
||||
// 初始化历史数据
|
||||
@@ -1554,6 +1678,9 @@ function NetworkChart({
|
||||
|
||||
const displayData =
|
||||
period === "realtime" ? networkChartData : networkHistoricalData;
|
||||
const isNetworkLoading =
|
||||
period !== "realtime" &&
|
||||
(isLoadingNetwork || loadedPeriodNetwork !== period);
|
||||
|
||||
let maxDownload = Math.max(...displayData.map((item) => item.download));
|
||||
maxDownload = Math.ceil(maxDownload);
|
||||
@@ -1616,7 +1743,7 @@ function NetworkChart({
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[130px] w-full"
|
||||
>
|
||||
{isLoadingNetwork ? (
|
||||
{isNetworkLoading ? (
|
||||
<ChartSkeleton />
|
||||
) : (
|
||||
<LineChart
|
||||
@@ -1733,14 +1860,23 @@ function ConnectChart({
|
||||
connectChartData[]
|
||||
>([]);
|
||||
const [isLoadingConnect, setIsLoadingConnect] = useState(false);
|
||||
const [loadedPeriodConnect, setLoadedPeriodConnect] =
|
||||
useState<ChartPeriod>("realtime");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (period === "realtime") {
|
||||
setConnectHistoricalData([]);
|
||||
return;
|
||||
setIsLoadingConnect(false);
|
||||
setLoadedPeriodConnect("realtime");
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const fetchConnectData = async () => {
|
||||
const loadingStartedAt = Date.now();
|
||||
setIsLoadingConnect(true);
|
||||
try {
|
||||
const [tcpResponse, udpResponse] = await Promise.all([
|
||||
@@ -1761,16 +1897,28 @@ function ConnectChart({
|
||||
tcp: point.value,
|
||||
udp: udpMap.get(point.ts) || 0,
|
||||
}));
|
||||
setConnectHistoricalData(combinedData);
|
||||
if (!cancelled) {
|
||||
setConnectHistoricalData(combinedData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch connection metrics:", error);
|
||||
} finally {
|
||||
setIsLoadingConnect(false);
|
||||
const elapsed = Date.now() - loadingStartedAt;
|
||||
if (elapsed < MIN_HISTORY_LOADING_MS) {
|
||||
await sleep(MIN_HISTORY_LOADING_MS - elapsed);
|
||||
}
|
||||
if (!cancelled) {
|
||||
setIsLoadingConnect(false);
|
||||
setLoadedPeriodConnect(period);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchConnectData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [data.id, period]);
|
||||
|
||||
// 初始化历史数据
|
||||
@@ -1842,6 +1990,9 @@ function ConnectChart({
|
||||
|
||||
const displayData =
|
||||
period === "realtime" ? connectChartData : connectHistoricalData;
|
||||
const isConnectLoading =
|
||||
period !== "realtime" &&
|
||||
(isLoadingConnect || loadedPeriodConnect !== period);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -1873,7 +2024,7 @@ function ConnectChart({
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[130px] w-full"
|
||||
>
|
||||
{isLoadingConnect ? (
|
||||
{isConnectLoading ? (
|
||||
<ChartSkeleton />
|
||||
) : (
|
||||
<LineChart
|
||||
|
||||
@@ -1,59 +1,23 @@
|
||||
export default function ChartSkeleton() {
|
||||
export default function ChartSkeleton({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
}) {
|
||||
const resolvedWidth = typeof width === "number" ? `${width}px` : width;
|
||||
const resolvedHeight = typeof height === "number" ? `${height}px` : height;
|
||||
|
||||
return (
|
||||
<div className="h-[130px] w-full px-6 py-3">
|
||||
<div className="relative h-full w-full animate-pulse">
|
||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-muted-foreground/20" />
|
||||
<div className="absolute bottom-1 left-0 right-0 flex items-end justify-between gap-1 px-2">
|
||||
<div
|
||||
className="w-2 bg-muted-foreground/20 rounded-t"
|
||||
style={{ height: "40%" }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 bg-muted-foreground/30 rounded-t"
|
||||
style={{ height: "65%" }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 bg-muted-foreground/25 rounded-t"
|
||||
style={{ height: "45%" }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 bg-muted-foreground/35 rounded-t"
|
||||
style={{ height: "80%" }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 bg-muted-foreground/20 rounded-t"
|
||||
style={{ height: "55%" }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 bg-muted-foreground/30 rounded-t"
|
||||
style={{ height: "70%" }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 bg-muted-foreground/25 rounded-t"
|
||||
style={{ height: "50%" }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 bg-muted-foreground/20 rounded-t"
|
||||
style={{ height: "35%" }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 bg-muted-foreground/30 rounded-t"
|
||||
style={{ height: "60%" }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 bg-muted-foreground/25 rounded-t"
|
||||
style={{ height: "45%" }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 bg-muted-foreground/35 rounded-t"
|
||||
style={{ height: "75%" }}
|
||||
/>
|
||||
<div
|
||||
className="w-2 bg-muted-foreground/20 rounded-t"
|
||||
style={{ height: "55%" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/20 via-transparent to-background/10 rounded-md" />
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden"
|
||||
style={{
|
||||
width: resolvedWidth || "100%",
|
||||
height: resolvedHeight || "100%",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="size-4 rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground/70 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user