From ea156c33bc0e19cc5a493232d321f21e1efb83bc Mon Sep 17 00:00:00 2001 From: sagitchu Date: Tue, 21 Apr 2026 12:40:00 +0800 Subject: [PATCH] style(frontend): sync remaining glass UI theme files and cyberpunk theme --- vite-frontend/src/App.tsx | 38 +- vite-frontend/src/api/index.ts | 11 +- vite-frontend/src/components/distro-icon.tsx | 49 +- .../src/components/theme-settings.tsx | 13 +- vite-frontend/src/components/ui/card.tsx | 17 +- vite-frontend/src/components/ui/dialog.tsx | 8 +- vite-frontend/src/config/site.ts | 6 +- vite-frontend/src/layouts/admin.tsx | 14 +- vite-frontend/src/layouts/h5.tsx | 5 +- vite-frontend/src/main.tsx | 6 +- vite-frontend/src/pages/config.tsx | 122 ++- vite-frontend/src/pages/dashboard.tsx | 41 +- .../components/announcement-modal.tsx | 12 +- .../dashboard/components/flow-chart-card.tsx | 11 +- .../dashboard/components/metric-card.tsx | 8 +- .../src/pages/dashboard/use-dashboard-data.ts | 18 +- vite-frontend/src/pages/forward.tsx | 20 +- vite-frontend/src/pages/group.tsx | 868 ++++++++--------- vite-frontend/src/pages/index.tsx | 16 +- vite-frontend/src/pages/limit.tsx | 275 +++--- vite-frontend/src/pages/monitor.tsx | 59 +- vite-frontend/src/pages/node.tsx | 57 +- vite-frontend/src/pages/node/monitor-view.tsx | 867 ++++++++++++----- .../src/pages/node/tunnel-monitor-view.tsx | 530 +++++++--- vite-frontend/src/pages/panel-sharing.tsx | 10 +- vite-frontend/src/pages/tunnel.tsx | 914 ++++++++++-------- vite-frontend/src/pages/user.tsx | 63 +- .../src/shadcn-bridge/heroui/card.tsx | 7 +- .../src/shadcn-bridge/heroui/dropdown.tsx | 6 +- .../src/shadcn-bridge/heroui/select.tsx | 2 +- vite-frontend/src/themes/context.tsx | 68 +- .../example-cyberpunk/components/button.tsx | 3 +- .../src/themes/example-cyberpunk/index.ts | 1 + vite-frontend/src/themes/registry.ts | 34 +- vite-frontend/src/themes/types.ts | 10 +- 35 files changed, 2634 insertions(+), 1555 deletions(-) diff --git a/vite-frontend/src/App.tsx b/vite-frontend/src/App.tsx index d5b9b31..6a420c7 100644 --- a/vite-frontend/src/App.tsx +++ b/vite-frontend/src/App.tsx @@ -1,4 +1,10 @@ -import { Route, Routes, useLocation, useNavigate, Navigate } from "react-router-dom"; +import { + Route, + Routes, + useLocation, + useNavigate, + Navigate, +} from "react-router-dom"; import { useEffect } from "react"; import { AnimatePresence } from "framer-motion"; @@ -85,21 +91,39 @@ function App() { useEffect(() => { const updateBg = () => { const customBg = siteConfig.app_bg_image; + if (customBg) { if (customBg === "theme") { document.documentElement.style.removeProperty("--custom-bg-image"); document.documentElement.style.removeProperty("--custom-bg-color"); document.documentElement.classList.add("has-theme-bg"); document.documentElement.classList.remove("has-custom-bg"); - } else if (customBg.startsWith("http") || customBg.startsWith("data:") || customBg.startsWith("/") || customBg.startsWith("blob:")) { - document.documentElement.style.setProperty("--custom-bg-image", `url(${customBg})`); - document.documentElement.style.setProperty("--custom-bg-color", "transparent"); + } else if ( + customBg.startsWith("http") || + customBg.startsWith("data:") || + customBg.startsWith("/") || + customBg.startsWith("blob:") + ) { + document.documentElement.style.setProperty( + "--custom-bg-image", + `url(${customBg})`, + ); + document.documentElement.style.setProperty( + "--custom-bg-color", + "transparent", + ); document.documentElement.classList.add("has-custom-bg"); document.documentElement.classList.remove("has-theme-bg"); } else { // Assume solid color like "#ffffff", "white", etc. - document.documentElement.style.setProperty("--custom-bg-image", "none"); - document.documentElement.style.setProperty("--custom-bg-color", customBg); + document.documentElement.style.setProperty( + "--custom-bg-image", + "none", + ); + document.documentElement.style.setProperty( + "--custom-bg-color", + customBg, + ); document.documentElement.classList.add("has-custom-bg"); document.documentElement.classList.remove("has-theme-bg"); } @@ -110,8 +134,10 @@ function App() { document.documentElement.classList.remove("has-theme-bg"); } }; + updateBg(); window.addEventListener("site-config-updated", updateBg); + return () => { window.removeEventListener("site-config-updated", updateBg); }; diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 9dadeea..1145736 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -149,9 +149,12 @@ export const deleteTunnelWithForwards = (data: { data, ); export const previewBatchTunnelDelete = (ids: number[]) => - Network.post("/tunnel/batch-delete-preview", { - ids, - }); + Network.post( + "/tunnel/batch-delete-preview", + { + ids, + }, + ); export const batchDeleteTunnelsWithForwards = (data: { ids: number[]; action: "replace" | "delete_forwards"; @@ -496,12 +499,14 @@ export const getServiceMonitorResults = ( options?: { limit?: number; start?: number; end?: number }, ) => { const params: Record = {}; + if (options?.start != null && options?.end != null) { params.start = String(options.start); params.end = String(options.end); } else if (options?.limit != null) { params.limit = String(options.limit); } + return Network.get( `/monitor/services/${monitorId}/results`, params, diff --git a/vite-frontend/src/components/distro-icon.tsx b/vite-frontend/src/components/distro-icon.tsx index 3936218..3aa056b 100644 --- a/vite-frontend/src/components/distro-icon.tsx +++ b/vite-frontend/src/components/distro-icon.tsx @@ -31,7 +31,7 @@ const distroColors: Record = { function UbuntuIcon(props: SVGProps) { return ( - + ); @@ -39,7 +39,7 @@ function UbuntuIcon(props: SVGProps) { function DebianIcon(props: SVGProps) { return ( - + ); @@ -47,7 +47,7 @@ function DebianIcon(props: SVGProps) { function CentOSIcon(props: SVGProps) { return ( - + ); @@ -55,7 +55,7 @@ function CentOSIcon(props: SVGProps) { function AlpineIcon(props: SVGProps) { return ( - + ); @@ -63,7 +63,7 @@ function AlpineIcon(props: SVGProps) { function FedoraIcon(props: SVGProps) { return ( - + ); @@ -71,7 +71,7 @@ function FedoraIcon(props: SVGProps) { function ArchIcon(props: SVGProps) { return ( - + ); @@ -80,7 +80,7 @@ function ArchIcon(props: SVGProps) { function LinuxIcon(props: SVGProps) { // Official Tux (Linux penguin) logo from simple-icons return ( - + ); @@ -102,17 +102,36 @@ export function parseDistroFromVersion(version?: string): string { // Try to extract from parenthetical format: (distro/arch) const match = lower.match(/\(([^/)\s]+)/); + if (match) { return match[1].trim(); } // Fallback: check for known distro names anywhere in the string const knownDistros = [ - "ubuntu", "debian", "centos", "rocky", "alma", "alpine", - "fedora", "arch", "opensuse", "suse", "rhel", "redhat", - "oracle", "amazon", "amzn", "kali", "mint", "gentoo", - "void", "nixos", "manjaro", + "ubuntu", + "debian", + "centos", + "rocky", + "alma", + "alpine", + "fedora", + "arch", + "opensuse", + "suse", + "rhel", + "redhat", + "oracle", + "amazon", + "amzn", + "kali", + "mint", + "gentoo", + "void", + "nixos", + "manjaro", ]; + for (const d of knownDistros) { if (lower.includes(d)) return d; } @@ -125,9 +144,11 @@ export function parseDistroFromVersion(version?: string): string { */ export function getDistroColor(distro: string): string { const key = distro.toLowerCase(); + for (const [name, color] of Object.entries(distroColors)) { if (key.includes(name)) return color; } + return "#6366F1"; // default indigo for unknown linux } @@ -139,10 +160,12 @@ export function DistroIcon({ distro, ...props }: DistroIconProps) { if (key.includes("ubuntu")) return ; if (key.includes("debian")) return ; - if (key.includes("centos") || key.includes("rocky") || key.includes("alma")) return ; + if (key.includes("centos") || key.includes("rocky") || key.includes("alma")) + return ; if (key.includes("alpine")) return ; if (key.includes("fedora")) return ; - if (key.includes("arch") || key.includes("manjaro")) return ; + if (key.includes("arch") || key.includes("manjaro")) + return ; return ; } diff --git a/vite-frontend/src/components/theme-settings.tsx b/vite-frontend/src/components/theme-settings.tsx index b685812..084829d 100644 --- a/vite-frontend/src/components/theme-settings.tsx +++ b/vite-frontend/src/components/theme-settings.tsx @@ -7,13 +7,14 @@ * • "Reset to default" option */ +import type { ThemeMode } from "@/themes/registry"; + import React from "react"; import toast from "react-hot-toast"; import { Card, CardBody } from "@/shadcn-bridge/heroui/card"; import { Button } from "@/shadcn-bridge/heroui/button"; import { useThemeContext } from "@/themes/context"; -import type { ThemeMode } from "@/themes/registry"; // ─── Constants ────────────────────────────────────────────────────────────── @@ -39,12 +40,14 @@ export const ThemeSettings: React.FC = () => { const handleModeChange = (m: ThemeMode) => { setMode(m); const label = m === "light" ? "亮色" : m === "dark" ? "暗色" : "跟随系统"; + toast.success(`已切换为${label}模式`); }; const handleThemeSelect = (id: string) => { switchTheme(id); const theme = themes.find((t) => t.id === id); + toast.success(`已切换主题「${theme?.name ?? id}」`); }; @@ -125,7 +128,13 @@ export const ThemeSettings: React.FC = () => {
-
+
{/* Info */} diff --git a/vite-frontend/src/components/ui/card.tsx b/vite-frontend/src/components/ui/card.tsx index 9a1a64f..e11bcab 100644 --- a/vite-frontend/src/components/ui/card.tsx +++ b/vite-frontend/src/components/ui/card.tsx @@ -1,4 +1,5 @@ import * as React from "react"; + import { cn } from "@/lib/utils"; function Card({ className, style, ...props }: React.ComponentProps<"div">) { @@ -7,16 +8,18 @@ function Card({ className, style, ...props }: React.ComponentProps<"div">) { className={cn( "relative flex flex-col transition-all duration-300", "rounded-[24px] text-card-foreground shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:shadow-[0_12px_40px_rgba(0,0,0,0.4)]", - className + className, )} + data-slot="card" style={{ ...style, - background: "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)", - boxShadow: "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 12px 40px rgba(0,0,0,0.12)", + background: + "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)", + boxShadow: + "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 12px 40px rgba(0,0,0,0.12)", backdropFilter: "blur(24px) saturate(180%)", WebkitBackdropFilter: "blur(24px) saturate(180%)", }} - data-slot="card" {...props} /> ); @@ -63,11 +66,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"p">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) { return ( -
+
); } diff --git a/vite-frontend/src/components/ui/dialog.tsx b/vite-frontend/src/components/ui/dialog.tsx index 89de6cb..8923c0e 100644 --- a/vite-frontend/src/components/ui/dialog.tsx +++ b/vite-frontend/src/components/ui/dialog.tsx @@ -62,13 +62,15 @@ function DialogContent({ "rounded-[24px] text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.5)] border border-white/80 dark:border-white/10", className, )} + data-slot="dialog-content" style={{ - background: "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)", - boxShadow: "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 20px 60px rgba(0,0,0,0.2)", + background: + "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)", + boxShadow: + "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 20px 60px rgba(0,0,0,0.2)", backdropFilter: "blur(24px) saturate(180%)", WebkitBackdropFilter: "blur(24px) saturate(180%)", }} - data-slot="dialog-content" {...props} >
diff --git a/vite-frontend/src/config/site.ts b/vite-frontend/src/config/site.ts index 188fe7b..6af42e6 100644 --- a/vite-frontend/src/config/site.ts +++ b/vite-frontend/src/config/site.ts @@ -32,8 +32,10 @@ const getInitialConfig = () => { localStorage.getItem(CACHE_PREFIX + "app_favicon") || ""; const cachedAppBgImage = localStorage.getItem(CACHE_PREFIX + "app_bg_image") || ""; - const isCommercial = localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true"; - const hideFooterBrand = localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true"; + const isCommercial = + localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true"; + const hideFooterBrand = + localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true"; if (cachedAppName) { return { diff --git a/vite-frontend/src/layouts/admin.tsx b/vite-frontend/src/layouts/admin.tsx index 387fed8..b1e3095 100644 --- a/vite-frontend/src/layouts/admin.tsx +++ b/vite-frontend/src/layouts/admin.tsx @@ -198,19 +198,23 @@ export default function AdminLayout({ if (adminFlag) { setMonitorAllowed(true); setMonitorAccessReason(null); + return; } let cancelled = false; + (async () => { try { const res = await getMonitorAccess(); + if (cancelled) return; if (res.code === 0 && res.data) { setMonitorAllowed(Boolean(res.data.allowed)); setMonitorAccessReason( - res.data.allowed ? null : (res.data.reason || null), + res.data.allowed ? null : res.data.reason || null, ); + return; } // Fail open to preserve legacy navigation behavior. @@ -408,6 +412,7 @@ export default function AdminLayout({ return (
  • { try { const res = await getMonitorAccess(); + if (cancelled) return; if (res.code === 0 && res.data) { setMonitorAllowed(Boolean(res.data.allowed)); setMonitorAccessReason( - res.data.allowed ? null : (res.data.reason || null), + res.data.allowed ? null : res.data.reason || null, ); + return; } setMonitorAllowed(true); diff --git a/vite-frontend/src/main.tsx b/vite-frontend/src/main.tsx index f3e2f8e..421695c 100644 --- a/vite-frontend/src/main.tsx +++ b/vite-frontend/src/main.tsx @@ -13,7 +13,9 @@ const updateSW = registerSW({ toast( (t) => (
    - 发现新版本,是否立即刷新以应用更新? + + 发现新版本,是否立即刷新以应用更新? +
    ), - { duration: Infinity, position: "bottom-right" } + { duration: Infinity, position: "bottom-right" }, ); }, }); diff --git a/vite-frontend/src/pages/config.tsx b/vite-frontend/src/pages/config.tsx index d2735b4..d21f7ca 100644 --- a/vite-frontend/src/pages/config.tsx +++ b/vite-frontend/src/pages/config.tsx @@ -89,7 +89,8 @@ const CONFIG_ITEMS: ConfigItem[] = [ { key: "app_bg_image", label: "自定义背景", - description: "上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)", + description: + "上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)", type: "bg_image", }, { @@ -141,7 +142,8 @@ const CONFIG_ITEMS: ConfigItem[] = [ { key: "monitor_tunnel_quality_enabled", label: "实时隧道质量检测", - description: "关闭后,前端停止自动刷新,后端停止实时隧道质量探测(全局配置)", + description: + "关闭后,前端停止自动刷新,后端停止实时隧道质量探测(全局配置)", type: "switch", }, { @@ -388,11 +390,13 @@ export default function ConfigPage() { const handleActivateLicense = async () => { if (!licenseKeyInput.trim()) { toast.error("请输入有效的商业授权码"); + return; } setActivatingLicense(true); try { const res = await activateLicense(licenseKeyInput.trim()); + if (res.code === 0) { toast.success("商业版授权激活成功!"); setLicenseKeyInput(""); @@ -479,7 +483,9 @@ export default function ConfigPage() { if (changedKeys.includes("monitor_tunnel_quality_enabled")) { window.dispatchEvent( new CustomEvent("monitorTunnelQualityEnabledChanged", { - detail: { enabled: configs["monitor_tunnel_quality_enabled"] === "true" }, + detail: { + enabled: configs["monitor_tunnel_quality_enabled"] === "true", + }, }), ); } @@ -496,7 +502,11 @@ export default function ConfigPage() { // 检查配置项是否应该显示(依赖检查) const shouldShowItem = (item: ConfigItem): boolean => { // 隐藏商业版专属的设置项,它们会在专门的卡片中展示 - if (["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key)) { + if ( + ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes( + item.key, + ) + ) { return false; } @@ -555,12 +565,16 @@ export default function ConfigPage() { } }; - const handleBgImageUpload = async (e: React.ChangeEvent) => { + const handleBgImageUpload = async ( + e: React.ChangeEvent, + ) => { const file = e.target.files?.[0]; + if (!file) return; if (!file.type.startsWith("image/")) { toast.error("只能上传图片文件"); + return; } @@ -568,8 +582,10 @@ export default function ConfigPage() { try { const compressedImage = await new Promise((resolve, reject) => { const reader = new FileReader(); + reader.onload = (event) => { const img = new Image(); + img.onload = () => { const canvas = document.createElement("canvas"); let width = img.width; @@ -592,14 +608,17 @@ export default function ConfigPage() { canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); + if (!ctx) { reject(new Error("Canvas context is null")); + return; } ctx.drawImage(img, 0, 0, width, height); - + // Output as webp for better compression const dataUrl = canvas.toDataURL("image/webp", 0.8); + resolve(dataUrl); }; img.onerror = () => reject(new Error("图片加载失败")); @@ -621,62 +640,66 @@ export default function ConfigPage() { const renderBgImageUploader = () => { const bgImage = configs["app_bg_image"] || ""; - const isImage = bgImage.startsWith("http") || bgImage.startsWith("data:") || bgImage.startsWith("/") || bgImage.startsWith("blob:"); + const isImage = + bgImage.startsWith("http") || + bgImage.startsWith("data:") || + bgImage.startsWith("/") || + bgImage.startsWith("blob:"); const isTheme = bgImage === "theme"; const isSolidColor = bgImage && !isImage && !isTheme; - + return (
    {bgImage && ( )}
    - + {bgImage && isImage && (
    背景预览
    预览效果 @@ -686,13 +709,15 @@ export default function ConfigPage() { {bgImage && isTheme && (
    - 当前为自适应纯色背景 + + 当前为自适应纯色背景 +
    )} {bgImage && isSolidColor && ( -
    @@ -811,8 +836,8 @@ export default function ConfigPage() {
    @@ -1135,30 +1166,47 @@ export default function ConfigPage() {
    setLicenseKeyInput(e.target.value)} - isDisabled={configs.is_commercial === "true"} - description={configs.is_commercial === "true" ? (configs.license_expiry && configs.license_expiry !== "never" ? `已激活商业版授权 (有效期至: ${new Date(configs.license_expiry).toLocaleDateString()})` : "已激活商业版授权 (永久有效)") : "需商业授权才能修改站名、图标并隐藏页脚品牌"} />
    - + {configs.is_commercial === "true" && (
    -

    白标与品牌设置

    - {CONFIG_ITEMS.filter(item => ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key)).map((item, index) => ( +

    + 白标与品牌设置 +

    + {CONFIG_ITEMS.filter((item) => + [ + "app_name", + "app_logo", + "app_favicon", + "hide_footer_brand", + ].includes(item.key), + ).map((item, index) => (
    @@ -1511,18 +1559,18 @@ export default function ConfigPage() { {hasChanges && ( - + + + + } onPress={() => navigate("/profile")} > @@ -680,7 +699,17 @@ export default function DashboardPage() { className="text-danger" color="danger" startContent={ - + + + } onPress={handleLogout} > diff --git a/vite-frontend/src/pages/dashboard/components/announcement-modal.tsx b/vite-frontend/src/pages/dashboard/components/announcement-modal.tsx index b4dbbf5..f1da34f 100644 --- a/vite-frontend/src/pages/dashboard/components/announcement-modal.tsx +++ b/vite-frontend/src/pages/dashboard/components/announcement-modal.tsx @@ -1,4 +1,8 @@ import type { AnnouncementData } from "@/api"; + +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + import { Button } from "@/shadcn-bridge/heroui/button"; import { Modal, @@ -7,8 +11,6 @@ import { ModalFooter, ModalHeader, } from "@/shadcn-bridge/heroui/modal"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; interface AnnouncementModalProps { announcement: AnnouncementData; @@ -24,7 +26,11 @@ export const AnnouncementModal = ({ onDontShowAgain, }: AnnouncementModalProps) => { return ( - !open && onClose()} size="2xl"> + !open && onClose()} + > 平台公告 diff --git a/vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx b/vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx index 15d0855..3a017c1 100644 --- a/vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx +++ b/vite-frontend/src/pages/dashboard/components/flow-chart-card.tsx @@ -50,7 +50,10 @@ export const FlowChartCard = ({ ) : (
    - + - +
    diff --git a/vite-frontend/src/pages/dashboard/components/metric-card.tsx b/vite-frontend/src/pages/dashboard/components/metric-card.tsx index 776fdf4..1d18fed 100644 --- a/vite-frontend/src/pages/dashboard/components/metric-card.tsx +++ b/vite-frontend/src/pages/dashboard/components/metric-card.tsx @@ -25,9 +25,7 @@ export const MetricCard = ({

    {title}

    -
    +
    {icon}
    @@ -35,9 +33,7 @@ export const MetricCard = ({ {value}

    -
    - {bottomContent} -
    +
    {bottomContent}
    ); diff --git a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts index 27dc429..3be92ee 100644 --- a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts +++ b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts @@ -349,17 +349,22 @@ export const useDashboardData = (): DashboardDataState => { if (res.code === 0 && res.data && res.data.enabled === 1) { setAnnouncement(res.data); - + try { - const storedTimeStr = localStorage.getItem("flvx_announcement_seen_time"); + const storedTimeStr = localStorage.getItem( + "flvx_announcement_seen_time", + ); const storedTime = storedTimeStr ? parseInt(storedTimeStr, 10) : 0; const updateTime = res.data.update_time || 0; - + if (updateTime > storedTime) { setIsAnnouncementModalOpen(true); } } catch (err) { - console.warn("Failed to read localStorage for announcement state", err); + console.warn( + "Failed to read localStorage for announcement state", + err, + ); setIsAnnouncementModalOpen(true); } } else { @@ -376,7 +381,10 @@ export const useDashboardData = (): DashboardDataState => { setIsAnnouncementModalOpen(false); if (announcement && announcement.update_time) { try { - localStorage.setItem("flvx_announcement_seen_time", announcement.update_time.toString()); + localStorage.setItem( + "flvx_announcement_seen_time", + announcement.update_time.toString(), + ); } catch (err) { console.warn("Failed to set localStorage for announcement state", err); } diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 28051c4..c6a2948 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -4189,7 +4189,8 @@ export default function ForwardPage() { -
    +
    {diagnosisProgress.total > 0 ? diagnosisProgress.total @@ -5519,7 +5521,7 @@ export default function ForwardPage() { return (

    @@ -5527,7 +5529,7 @@ export default function ForwardPage() {

    - + - + {results.map((result, index) => { const isDiagnosing = Boolean( result.diagnosing, @@ -5565,7 +5567,7 @@ export default function ForwardPage() { isDiagnosing ? "bg-warning-50 dark:bg-warning-900/20" : isSuccess - ? "bg-white dark:bg-gray-800" + ? "bg-content1" : "bg-danger-50 dark:bg-danger-900/30" }`} > @@ -5752,7 +5754,7 @@ export default function ForwardPage() { isDiagnosing ? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20" : isSuccess - ? "border-divider bg-white dark:bg-gray-800" + ? "border-divider bg-content1" : "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30" }`} > @@ -5940,7 +5942,7 @@ export default function ForwardPage() { )} - + diff --git a/vite-frontend/src/pages/group.tsx b/vite-frontend/src/pages/group.tsx index 53cd00c..14e8cd9 100644 --- a/vite-frontend/src/pages/group.tsx +++ b/vite-frontend/src/pages/group.tsx @@ -24,7 +24,6 @@ import { TableRow, } from "@/shadcn-bridge/heroui/table"; import { Chip } from "@/shadcn-bridge/heroui/chip"; - import { assignGroupPermission, assignTunnelsToGroup, @@ -477,451 +476,454 @@ export default function GroupPage() { ) : ( <> - -

    隧道分组

    - -
    - -
    路径 @@ -5546,7 +5548,7 @@ export default function ForwardPage() {
    +

    隧道分组

    + + + +
    + + 名称 + 隧道 + 状态 + 创建时间 + 操作 + + + {(item) => ( + + {item.name} + + {item.tunnelNames.length > 0 + ? item.tunnelNames.join("、") + : "-"} + + + + {item.status === 1 ? "启用" : "停用"} + + + {formatDate(item.createdTime)} + +
    + + + +
    +
    +
    + )} +
    +
    + + + + + +

    用户分组

    + +
    + + + + 名称 + 用户 + 状态 + 创建时间 + 操作 + + + {(item) => ( + + {item.name} + + {item.userNames.length > 0 + ? item.userNames.join("、") + : "-"} + + + + {item.status === 1 ? "启用" : "停用"} + + + {formatDate(item.createdTime)} + +
    + + + +
    +
    +
    + )} +
    +
    +
    +
    + + + +

    权限分配

    +
    + +
    + + + +
    + + + + ID + 用户分组 + 隧道分组 + 创建时间 + 操作 + + + {(item) => ( + + {item.id} + + {item.userGroupName || item.userGroupId} + + + {item.tunnelGroupName || item.tunnelGroupId} + + {formatDate(item.createdTime)} + + + + + )} + +
    +
    +
    + + - - 名称 - 隧道 - 状态 - 创建时间 - 操作 - - - {(item) => ( - - {item.name} - - {item.tunnelNames.length > 0 - ? item.tunnelNames.join("、") - : "-"} - - - - {item.status === 1 ? "启用" : "停用"} - - - {formatDate(item.createdTime)} - -
    - - - -
    -
    -
    - )} -
    - - - + + + {editingTunnelGroup ? "编辑隧道分组" : "新建隧道分组"} + + + setGroupName(e.target.value)} + /> + + + + + + + +
    + + - - 名称 - 用户 - 状态 - 创建时间 - 操作 - - - {(item) => ( - - {item.name} - - {item.userNames.length > 0 - ? item.userNames.join("、") - : "-"} - - - - {item.status === 1 ? "启用" : "停用"} - - - {formatDate(item.createdTime)} - -
    - - - -
    -
    -
    - )} -
    - - - + + + {editingUserGroup ? "编辑用户分组" : "新建用户分组"} + + + setGroupName(e.target.value)} + /> + { - const key = Array.from(keys as Set)[0]; + if (key) { + setGroupStatus(String(key)); + } + }} + > + 启用 + 停用 + + + + + + + +
    - setSelectedUserGroupId(key ? Number(key) : null); - }} - > - {(item) => {item.name}} - - - -
    - - - - ID - 用户分组 - 隧道分组 - 创建时间 - 操作 - - - {(item) => ( - - {item.id} - - {item.userGroupName || item.userGroupId} - - - {item.tunnelGroupName || item.tunnelGroupId} - - {formatDate(item.createdTime)} - - - - - )} - -
    - - + + 分配隧道 - {assignTunnelGroup?.name} + + +

    + 当前已选:{selectedTunnelSummary} +

    +

    + 不选择任何隧道并保存将清空该分组成员。 +

    +
    + + + + +
    + - - - - {editingTunnelGroup ? "编辑隧道分组" : "新建隧道分组"} - - - setGroupName(e.target.value)} - /> - - - - - - - - - - - - - {editingUserGroup ? "编辑用户分组" : "新建用户分组"} - - - setGroupName(e.target.value)} - /> - - - - - - - - - - - - 分配隧道 - {assignTunnelGroup?.name} - - -

    - 当前已选:{selectedTunnelSummary} -

    -

    - 不选择任何隧道并保存将清空该分组成员。 -

    -
    - - - - -
    -
    - - - - 分配用户 - {assignUserGroup?.name} - - -

    - 当前已选:{selectedUserSummary} -

    -

    - 不选择任何用户并保存将清空该分组成员。 -

    -
    - - - - -
    -
    + + + 分配用户 - {assignUserGroup?.name} + + +

    + 当前已选:{selectedUserSummary} +

    +

    + 不选择任何用户并保存将清空该分组成员。 +

    +
    + + + + +
    +
    )} diff --git a/vite-frontend/src/pages/index.tsx b/vite-frontend/src/pages/index.tsx index a0faa41..8302c70 100644 --- a/vite-frontend/src/pages/index.tsx +++ b/vite-frontend/src/pages/index.tsx @@ -162,7 +162,9 @@ export default function IndexPage() { -

    {siteConfig.name}

    +

    + {siteConfig.name} +

    Sign in to manage your networks

    @@ -171,7 +173,8 @@ export default function IndexPage() {
    - {loading ? (showCaptcha ? "Verifying..." : "Signing in...") : "Sign In"} + {loading + ? showCaptcha + ? "Verifying..." + : "Signing in..." + : "Sign In"}
    diff --git a/vite-frontend/src/pages/limit.tsx b/vite-frontend/src/pages/limit.tsx index bea3f0a..7753371 100644 --- a/vite-frontend/src/pages/limit.tsx +++ b/vite-frontend/src/pages/limit.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from "react"; import toast from "react-hot-toast"; +import { LayoutGrid, List } from "lucide-react"; import { AnimatedPage, @@ -18,7 +19,6 @@ import { ModalFooter, } from "@/shadcn-bridge/heroui/modal"; import { Chip } from "@/shadcn-bridge/heroui/chip"; -import { LayoutGrid, List } from "lucide-react"; import { Table, TableHeader, @@ -251,7 +251,11 @@ export default function LimitPage() { variant="flat" onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")} > - {viewMode === "list" ? : } + {viewMode === "list" ? ( + + ) : ( + + )}
    - - - )} - - - - ) : ( - - {filteredRules.map((rule) => ( - - - -
    -
    -

    - {rule.name} -

    -
    - - {rule.status === 1 ? "运行" : "异常"} - -
    -
    - -
    -
    - - 速度限制 - - - {rule.speed} Mbps - -
    -
    - -
    - - -
    -
    -
    -
    - ))} -
    + + + + ))} + ) ) : ( /* 空状态 */ diff --git a/vite-frontend/src/pages/monitor.tsx b/vite-frontend/src/pages/monitor.tsx index 309e9fb..1c9e96a 100644 --- a/vite-frontend/src/pages/monitor.tsx +++ b/vite-frontend/src/pages/monitor.tsx @@ -2,7 +2,13 @@ import type { MonitorNodeApiItem } from "@/api/types"; import { useCallback, useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; -import { RefreshCw, LayoutGrid, List, Server, ArrowRightLeft } from "lucide-react"; +import { + RefreshCw, + LayoutGrid, + List, + Server, + ArrowRightLeft, +} from "lucide-react"; import { AnimatedPage } from "@/components/animated-page"; import { Button } from "@/shadcn-bridge/heroui/button"; @@ -38,11 +44,16 @@ export default function MonitorPage() { const [viewMode, setViewMode] = useState<"list" | "grid">("list"); const [activeTab, setActiveTab] = useState("nodes"); - const [realtimeNodeStatus, setRealtimeNodeStatus] = useState>({}); - const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState>({}); + const [realtimeNodeStatus, setRealtimeNodeStatus] = useState< + Record + >({}); + const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState< + Record + >({}); const handleRealtimeMessage = useCallback((message: any) => { const nodeId = Number(message?.id ?? 0); + if (!nodeId || Number.isNaN(nodeId)) return; const type = String(message?.type ?? ""); @@ -50,15 +61,18 @@ export default function MonitorPage() { if (type === "status") { const status = Number(payload); + setRealtimeNodeStatus((prev) => ({ ...prev, [nodeId]: status === 1 ? "online" : "offline", })); + return; } if (type === "metric") { let raw = payload; + if (typeof raw === "string") { try { raw = JSON.parse(raw); @@ -77,7 +91,7 @@ export default function MonitorPage() { netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0), tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0), udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0), - } + }, })); } }, []); @@ -89,6 +103,7 @@ export default function MonitorPage() { const loadNodes = useCallback(async (options?: { silent?: boolean }) => { const silent = options?.silent ?? false; + if (!silent) setNodesLoading(true); try { const response = await getMonitorNodes(); @@ -133,7 +148,9 @@ export default function MonitorPage() { .map((n) => ({ id: Number(n.id), name: String(n.name ?? ""), - connectionStatus: realtimeNodeStatus[Number(n.id)] || (n.status === 1 ? "online" : "offline"), + connectionStatus: + realtimeNodeStatus[Number(n.id)] || + (n.status === 1 ? "online" : "offline"), version: n.version, })); @@ -151,6 +168,7 @@ export default function MonitorPage() { if (node.connectionStatus === "online") { onlineCount++; const metric = realtimeNodeMetrics[node.id]; + if (metric) { totalCpu += metric.cpuUsage; totalConns += metric.tcpConns + metric.udpConns; @@ -161,6 +179,7 @@ export default function MonitorPage() { }); const avgCpu = onlineCount > 0 ? totalCpu / onlineCount : 0; + return { avgCpu, totalConns, @@ -174,10 +193,14 @@ export default function MonitorPage() {
    - System Load + + System Load +
    - {aggregateMetrics.avgCpu.toFixed(1)}% + + {aggregateMetrics.avgCpu.toFixed(1)}% +
    @@ -191,10 +214,14 @@ export default function MonitorPage() {
    - Active Connections + + Active Connections +
    - {aggregateMetrics.totalConns} + + {aggregateMetrics.totalConns} +
    @@ -208,10 +235,14 @@ export default function MonitorPage() {
    - Bandwidth + + Bandwidth +
    - {formatBytesPerSecond(aggregateMetrics.totalBandwidth)} + + {formatBytesPerSecond(aggregateMetrics.totalBandwidth)} +
    @@ -236,7 +267,11 @@ export default function MonitorPage() { variant="flat" onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")} > - {viewMode === "list" ? : } + {viewMode === "list" ? ( + + ) : ( + + )} {activeTab === "nodes" && ( )}
    )} - -
    {/* 操作按钮 */}
    diff --git a/vite-frontend/src/pages/node/monitor-view.tsx b/vite-frontend/src/pages/node/monitor-view.tsx index a356980..54b4f58 100644 --- a/vite-frontend/src/pages/node/monitor-view.tsx +++ b/vite-frontend/src/pages/node/monitor-view.tsx @@ -30,11 +30,14 @@ import { ArrowUp, ArrowDown, Eye, - } from "lucide-react"; import toast from "react-hot-toast"; -import { DistroIcon, parseDistroFromVersion, getDistroColor } from "@/components/distro-icon"; +import { + DistroIcon, + parseDistroFromVersion, + getDistroColor, +} from "@/components/distro-icon"; import { getNodeMetrics, getServiceMonitorList, @@ -77,7 +80,10 @@ import { Progress } from "@/shadcn-bridge/heroui/progress"; import { useNodeRealtime } from "@/pages/node/use-node-realtime"; interface MonitorViewProps { - nodeMap: Map; + nodeMap: Map< + number, + { id: number; name: string; connectionStatus: string; version?: string } + >; viewMode?: "list" | "grid"; } @@ -155,7 +161,9 @@ const formatUptime = (seconds: number) => { if (!seconds) return "-"; const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); + if (days > 0) return `${days} 天 ${hours} 小时`; + return `${hours} 小时`; }; @@ -164,48 +172,71 @@ const getColorByUsage = (usage?: number) => { if (usage >= 90) return "danger"; if (usage >= 75) return "warning"; if (usage >= 50) return "primary"; + return "success"; }; -function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNodeMetric | null; onPress?: () => void }) { +function ServerCard({ + node, + metric, + onPress, +}: { + node: any; + metric: RealtimeNodeMetric | null; + onPress?: () => void; +}) { const isOnline = node.connectionStatus === "online"; const distro = parseDistroFromVersion(node.version); const distroColor = getDistroColor(distro); - + return ( {/* Dynamic top gradient bar based on status */} -
    - +
    + {/* Decorative background glow */} -
    +
    - +
    - +
    -

    {node.name}

    +

    + {node.name} +

    {isOnline ? "在线" : "离线"}
    - +
    CPU - {isOnline && metric ? `${metric.cpuUsage.toFixed(1)}%` : "-"} + + {isOnline && metric ? `${metric.cpuUsage.toFixed(1)}%` : "-"} +
    内存 - {isOnline && metric ? `${metric.memoryUsage.toFixed(1)}%` : "-"} + + {isOnline && metric ? `${metric.memoryUsage.toFixed(1)}%` : "-"} +
    ↑ 上传
    - {isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"} + + {isOnline && metric + ? formatBytesPerSecond(metric.netOutSpeed) + : "-"} +
    @@ -241,18 +278,26 @@ function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNode 下载 ↓
    - {isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"} + + {isOnline && metric + ? formatBytesPerSecond(metric.netInSpeed) + : "-"} +
    - {metric ? formatUptime(metric.uptime) : "-"} + + {metric ? formatUptime(metric.uptime) : "-"} +
    - Load: {metric ? metric.load1.toFixed(2) : "-"} + + Load: {metric ? metric.load1.toFixed(2) : "-"} +
    @@ -296,39 +341,96 @@ const METRIC_TYPE_BUTTONS: { key: MetricType; label: string }[] = [ ]; const NodeMetricsChartCard = React.memo(function NodeMetricsChartCard({ - rangeMs, onRangeChange, activeMetricType, onMetricTypeChange, - loading, error, truncated, maxRows, data, nodeId, onRefresh, + rangeMs, + onRangeChange, + activeMetricType, + onMetricTypeChange, + loading, + error, + truncated, + maxRows, + data, + nodeId, + onRefresh, }: NodeMetricsChartCardProps) { const chartConfig = (() => { switch (activeMetricType) { - case "cpu": return { lines: [{ dataKey: "cpu", color: "#3b82f6", name: "CPU %" }], yAxisLabel: "使用率 (%)" }; - case "memory": return { lines: [{ dataKey: "memory", color: "#8b5cf6", name: "内存 %" }], yAxisLabel: "使用率 (%)" }; - case "disk": return { lines: [{ dataKey: "disk", color: "#f59e0b", name: "磁盘 %" }], yAxisLabel: "使用率 (%)" }; - case "network": return { lines: [{ dataKey: "netIn", color: "#10b981", name: "入站速度" }, { dataKey: "netOut", color: "#ef4444", name: "出站速度" }], yAxisLabel: "速度 (bytes/s)" }; - case "load": return { lines: [{ dataKey: "load1", color: "#3b82f6", name: "负载 1m" }, { dataKey: "load5", color: "#8b5cf6", name: "负载 5m" }, { dataKey: "load15", color: "#f59e0b", name: "负载 15m" }], yAxisLabel: "负载值" }; - case "connections": return { lines: [{ dataKey: "tcp", color: "#3b82f6", name: "TCP 连接" }, { dataKey: "udp", color: "#10b981", name: "UDP 连接" }], yAxisLabel: "连接数" }; + case "cpu": + return { + lines: [{ dataKey: "cpu", color: "#3b82f6", name: "CPU %" }], + yAxisLabel: "使用率 (%)", + }; + case "memory": + return { + lines: [{ dataKey: "memory", color: "#8b5cf6", name: "内存 %" }], + yAxisLabel: "使用率 (%)", + }; + case "disk": + return { + lines: [{ dataKey: "disk", color: "#f59e0b", name: "磁盘 %" }], + yAxisLabel: "使用率 (%)", + }; + case "network": + return { + lines: [ + { dataKey: "netIn", color: "#10b981", name: "入站速度" }, + { dataKey: "netOut", color: "#ef4444", name: "出站速度" }, + ], + yAxisLabel: "速度 (bytes/s)", + }; + case "load": + return { + lines: [ + { dataKey: "load1", color: "#3b82f6", name: "负载 1m" }, + { dataKey: "load5", color: "#8b5cf6", name: "负载 5m" }, + { dataKey: "load15", color: "#f59e0b", name: "负载 15m" }, + ], + yAxisLabel: "负载值", + }; + case "connections": + return { + lines: [ + { dataKey: "tcp", color: "#3b82f6", name: "TCP 连接" }, + { dataKey: "udp", color: "#10b981", name: "UDP 连接" }, + ], + yAxisLabel: "连接数", + }; } })(); const yAxisTickFormatter = (value: unknown) => { const n = Number(value); + if (!Number.isFinite(n)) return ""; switch (activeMetricType) { - case "network": return formatBytesPerSecond(n); - case "cpu": case "memory": case "disk": return `${n.toFixed(0)}%`; - case "load": return n.toFixed(1); - case "connections": return String(Math.round(n)); + case "network": + return formatBytesPerSecond(n); + case "cpu": + case "memory": + case "disk": + return `${n.toFixed(0)}%`; + case "load": + return n.toFixed(1); + case "connections": + return String(Math.round(n)); } }; const tooltipFormatter = (value: unknown) => { const n = Number(value); + if (!Number.isFinite(n)) return "-"; switch (activeMetricType) { - case "network": return formatBytesPerSecond(n); - case "cpu": case "memory": case "disk": return `${n.toFixed(1)}%`; - case "load": return n.toFixed(2); - case "connections": return String(Math.round(n)); + case "network": + return formatBytesPerSecond(n); + case "cpu": + case "memory": + case "disk": + return `${n.toFixed(1)}%`; + case "load": + return n.toFixed(2); + case "connections": + return String(Math.round(n)); } }; @@ -342,6 +444,7 @@ const NodeMetricsChartCard = React.memo(function NodeMetricsChartCard({ selectedKeys={[String(rangeMs)]} onSelectionChange={(keys) => { const v = Number(Array.from(keys)[0]); + if (v > 0) onRangeChange(v); }} > @@ -350,7 +453,12 @@ const NodeMetricsChartCard = React.memo(function NodeMetricsChartCard({ 6小时 24小时 - @@ -372,7 +480,9 @@ const NodeMetricsChartCard = React.memo(function NodeMetricsChartCard({
    {loading ? ( -
    +
    + +
    ) : error ? (
    {error}
    ) : data.length > 0 ? ( @@ -384,18 +494,32 @@ const NodeMetricsChartCard = React.memo(function NodeMetricsChartCard({ {chartConfig.lines.map((line) => ( - + ))}
    {truncated && ( -
    数据点过多,已截断为最近 {maxRows} 条,建议缩小时间范围。
    +
    + 数据点过多,已截断为最近 {maxRows} 条,建议缩小时间范围。 +
    )} ) : ( @@ -406,7 +530,6 @@ const NodeMetricsChartCard = React.memo(function NodeMetricsChartCard({ ); }); - const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = { checkerScanIntervalSec: 1, minIntervalSec: 1, @@ -427,16 +550,12 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { const [metricsRangeMs, setMetricsRangeMs] = useState(60 * 60 * 1000); - - const [serviceMonitors, setServiceMonitors] = useState< ServiceMonitorApiItem[] >([]); const [monitorsLoading, setMonitorsLoading] = useState(false); const [, setMonitorsError] = useState(null); - const [, setLatestResultsError] = useState( - null, - ); + const [, setLatestResultsError] = useState(null); const [monitorResults, setMonitorResults] = useState< Record >({}); @@ -457,8 +576,12 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { enabled: true, }); const [submitLoading, setSubmitLoading] = useState(false); - const [activeServiceMonitorId, setActiveServiceMonitorId] = useState(null); - const [serviceMonitorRangeMs, setServiceMonitorRangeMs] = useState(60 * 60 * 1000); + const [activeServiceMonitorId, setActiveServiceMonitorId] = useState< + number | null + >(null); + const [serviceMonitorRangeMs, setServiceMonitorRangeMs] = useState( + 60 * 60 * 1000, + ); const [accessDenied, setAccessDenied] = useState(null); const [resultsModalOpen, setResultsModalOpen] = useState(false); @@ -517,6 +640,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { if (type === "metric") { let raw = payload; + if (typeof raw === "string") { try { raw = JSON.parse(raw); @@ -539,7 +663,9 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { memoryUsage: Number(metric.memoryUsage ?? metric.memory_usage ?? 0), diskUsage: Number(metric.diskUsage ?? metric.disk_usage ?? 0), netInBytes: Number(metric.netInBytes ?? metric.bytes_received ?? 0), - netOutBytes: Number(metric.netOutBytes ?? metric.bytes_transmitted ?? 0), + netOutBytes: Number( + metric.netOutBytes ?? metric.bytes_transmitted ?? 0, + ), netInSpeed: Number(metric.netInSpeed ?? metric.net_in_speed ?? 0), netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0), load1: Number(metric.load1 ?? 0), @@ -582,11 +708,10 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { } }, [preferredNodeId, selectedNodeId]); - - const loadMetrics = useCallback( async (nodeId: number, options?: { silent?: boolean }) => { const silent = options?.silent ?? false; + if (!silent) setMetricsLoading(true); try { const end = Date.now(); @@ -615,7 +740,10 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { setMetricsTruncated(false); const msg = response.msg || "加载指标失败"; const isTimeout = msg.toLowerCase().includes("timeout"); - const friendlyMsg = isTimeout ? "加载指标超时,请缩小时间范围后重试" : msg; + const friendlyMsg = isTimeout + ? "加载指标超时,请缩小时间范围后重试" + : msg; + setMetricsError(friendlyMsg); if (!silent) toast.error(friendlyMsg); } catch { @@ -628,33 +756,37 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { [metricsRangeMs], ); - const loadServiceMonitors = useCallback(async (options?: { silent?: boolean }) => { - const silent = options?.silent ?? false; - if (!silent) setMonitorsLoading(true); - try { - const response = await getServiceMonitorList(); + const loadServiceMonitors = useCallback( + async (options?: { silent?: boolean }) => { + const silent = options?.silent ?? false; - if (response.code === 0 && response.data) { - setAccessDenied(null); - setMonitorsError(null); - setServiceMonitors(response.data); + if (!silent) setMonitorsLoading(true); + try { + const response = await getServiceMonitorList(); - return; + if (response.code === 0 && response.data) { + setAccessDenied(null); + setMonitorsError(null); + setServiceMonitors(response.data); + + return; + } + if (response.code === 403) { + setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); + setMonitorsError(null); + + return; + } + setMonitorsError(response.msg || "加载服务监控失败"); + if (!silent) toast.error(response.msg || "加载服务监控失败"); + } catch { + if (!silent) setMonitorsError("加载服务监控失败"); + } finally { + if (!silent) setMonitorsLoading(false); } - if (response.code === 403) { - setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); - setMonitorsError(null); - - return; - } - setMonitorsError(response.msg || "加载服务监控失败"); - if (!silent) toast.error(response.msg || "加载服务监控失败"); - } catch { - if (!silent) setMonitorsError("加载服务监控失败"); - } finally { - if (!silent) setMonitorsLoading(false); - } - }, []); + }, + [], + ); const loadServiceMonitorLimits = useCallback(async () => { try { @@ -677,11 +809,15 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { }, []); const loadMonitorResults = useCallback( - async (monitorId: number, options?: { rangeMs?: number; limit?: number }) => { + async ( + monitorId: number, + options?: { rangeMs?: number; limit?: number }, + ) => { try { - const apiOptions = options?.rangeMs != null - ? { start: Date.now() - options.rangeMs, end: Date.now() } - : { limit: options?.limit ?? 100 }; + const apiOptions = + options?.rangeMs != null + ? { start: Date.now() - options.rangeMs, end: Date.now() } + : { limit: options?.limit ?? 100 }; const response = await getServiceMonitorResults(monitorId, apiOptions); if (response.code === 0 && response.data) { @@ -803,33 +939,43 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { const firstMonitor = serviceMonitors.find( (m) => m.nodeId === detailNodeId || m.nodeId === 0, ); - if (firstMonitor && (!monitorResults[firstMonitor.id] || monitorResults[firstMonitor.id].length <= 1)) { - void loadMonitorResults(firstMonitor.id, { rangeMs: serviceMonitorRangeMs }); + + if ( + firstMonitor && + (!monitorResults[firstMonitor.id] || + monitorResults[firstMonitor.id].length <= 1) + ) { + void loadMonitorResults(firstMonitor.id, { + rangeMs: serviceMonitorRangeMs, + }); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [detailNodeId, serviceMonitors]); // Reload results for the active service monitor chart when time range changes useEffect(() => { if (!activeServiceMonitorId) return; - void loadMonitorResults(activeServiceMonitorId, { rangeMs: serviceMonitorRangeMs }); + void loadMonitorResults(activeServiceMonitorId, { + rangeMs: serviceMonitorRangeMs, + }); }, [activeServiceMonitorId, serviceMonitorRangeMs, loadMonitorResults]); - const chartData = useMemo(() => metrics.map((m) => ({ - time: formatTimestamp(m.timestamp, metricsRangeMs), - cpu: m.cpuUsage, - memory: m.memoryUsage, - disk: m.diskUsage, - netIn: m.netInSpeed, - netOut: m.netOutSpeed, - load1: m.load1, - load5: m.load5, - load15: m.load15, - tcp: m.tcpConns, - udp: m.udpConns, - })), [metrics, metricsRangeMs]); - - + const chartData = useMemo( + () => + metrics.map((m) => ({ + time: formatTimestamp(m.timestamp, metricsRangeMs), + cpu: m.cpuUsage, + memory: m.memoryUsage, + disk: m.diskUsage, + netIn: m.netInSpeed, + netOut: m.netOutSpeed, + load1: m.load1, + load5: m.load5, + load15: m.load15, + tcp: m.tcpConns, + udp: m.udpConns, + })), + [metrics, metricsRangeMs], + ); const handleOpenEditModal = (monitor?: ServiceMonitorApiItem) => { if (monitor) { @@ -1035,7 +1181,10 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { return Math.max(interval, resolvedServiceMonitorLimits.minIntervalSec); }, - [resolvedServiceMonitorLimits.defaultIntervalSec, resolvedServiceMonitorLimits.minIntervalSec], + [ + resolvedServiceMonitorLimits.defaultIntervalSec, + resolvedServiceMonitorLimits.minIntervalSec, + ], ); // Backend batches DB writes every 30s, but latest API reads from in-memory cache. @@ -1044,22 +1193,31 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { const SERVICE_MONITOR_REPORT_INTERVAL_MS = 30_000; // matches backend serviceMonitorReportInterval const isResultStale = useCallback( - (monitor: ServiceMonitorApiItem, latestResult: ServiceMonitorResultApiItem | null) => { + ( + monitor: ServiceMonitorApiItem, + latestResult: ServiceMonitorResultApiItem | null, + ) => { if (monitor.enabled !== 1) { return false; } - if (!latestResult || !latestResult.timestamp) { - return false; - } + if (!latestResult || !latestResult.timestamp) { + return false; + } const intervalMs = resolveMonitorIntervalSec(monitor) * 1000; // Budget = batch report interval + one check interval + scan jitter + grace const budgetMs = - SERVICE_MONITOR_REPORT_INTERVAL_MS + intervalMs + resolvedServiceMonitorLimits.checkerScanIntervalSec * 1000 + 5000; + SERVICE_MONITOR_REPORT_INTERVAL_MS + + intervalMs + + resolvedServiceMonitorLimits.checkerScanIntervalSec * 1000 + + 5000; return Date.now() - latestResult.timestamp > budgetMs; }, - [resolveMonitorIntervalSec, resolvedServiceMonitorLimits.checkerScanIntervalSec], + [ + resolveMonitorIntervalSec, + resolvedServiceMonitorLimits.checkerScanIntervalSec, + ], ); const monitorSummary = useMemo(() => { @@ -1077,10 +1235,12 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { } const latest = getLatestResult(m.id); - if (!latest) { - unknown += 1; - return; - } + + if (!latest) { + unknown += 1; + + return; + } if (isResultStale(m, latest)) { stale += 1; @@ -1095,15 +1255,18 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { return { disabled, ok, fail, unknown, stale }; }, [getLatestResult, isResultStale, serviceMonitors]); - - - const detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null; - const detailRealtimeMetric = detailNodeId != null ? realtimeNodeMetrics[detailNodeId] || null : null; + const detailNode = + detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null; + const detailRealtimeMetric = + detailNodeId != null ? realtimeNodeMetrics[detailNodeId] || null : null; // Service monitors filtered for the detail node (0 = panel-executed, show all in that case) - const detailServiceMonitors = detailNodeId != null - ? serviceMonitors.filter((m) => m.nodeId === detailNodeId || m.nodeId === 0) - : serviceMonitors; + const detailServiceMonitors = + detailNodeId != null + ? serviceMonitors.filter( + (m) => m.nodeId === detailNodeId || m.nodeId === 0, + ) + : serviceMonitors; return (
    @@ -1127,23 +1290,40 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { <>
    -
    - {wsConnected ? "实时已连接" : wsConnecting ? "实时连接中" : "实时未连接"} +
    + + {wsConnected + ? "实时已连接" + : wsConnecting + ? "实时连接中" + : "实时未连接"} +
    - 节点在线 {onlineNodes.length}/{nodes.length} - 监控 成功 {monitorSummary.ok} / 失败 {monitorSummary.fail} - {monitorSummary.stale > 0 && 陈旧 {monitorSummary.stale}} + + 节点在线 {onlineNodes.length}/{nodes.length} + + + 监控 成功 {monitorSummary.ok} / 失败 {monitorSummary.fail} + + {monitorSummary.stale > 0 && ( + + 陈旧 {monitorSummary.stale} + + )}
    {viewMode === "grid" ? (
    {nodes.map((node) => { const metric = realtimeNodeMetrics[node.id] || null; + return ( { setDetailNodeId(node.id); setSelectedNodeId(node.id); @@ -1158,7 +1338,8 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { aria-label="节点列表" className="overflow-x-auto min-w-full" classNames={{ - wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl", + wrapper: + "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl", th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]", td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0", tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors", @@ -1184,26 +1365,54 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { return ( -
    +
    - - {node.name} + + + {node.name} +
    - {isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"} + + {isOnline && metric + ? formatBytesPerSecond(metric.netOutSpeed) + : "-"} +
    - +
    - {isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"} + + {isOnline && metric + ? formatBytesPerSecond(metric.netInSpeed) + : "-"} +
    - +
    @@ -1211,58 +1420,105 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
    - {isOnline && metric ? formatBytes(metric.netOutBytes) : "-"} + + {isOnline && metric + ? formatBytes(metric.netOutBytes) + : "-"} +
    - +
    - {isOnline && metric ? formatBytes(metric.netInBytes) : "-"} + + {isOnline && metric + ? formatBytes(metric.netInBytes) + : "-"} +
    - +
    - {isOnline && metric ? formatUptime(metric.uptime) : "-"} + {isOnline && metric + ? formatUptime(metric.uptime) + : "-"}
    -
    TCP {isOnline && metric ? metric.tcpConns : "-"}
    -
    UDP {isOnline && metric ? metric.udpConns : "-"}
    +
    + TCP {isOnline && metric ? metric.tcpConns : "-"} +
    +
    + UDP {isOnline && metric ? metric.udpConns : "-"} +
    {isOnline && metric ? ( - + ) : (
    )} - {isOnline && metric ? `${metric.cpuUsage.toFixed(1)}%` : "-"} + + {isOnline && metric + ? `${metric.cpuUsage.toFixed(1)}%` + : "-"} +
    {isOnline && metric ? ( - + ) : (
    )} - {isOnline && metric ? `${metric.memoryUsage.toFixed(1)}%` : "-"} + + {isOnline && metric + ? `${metric.memoryUsage.toFixed(1)}%` + : "-"} +
    {isOnline && metric ? ( - + ) : (
    )} - {isOnline && metric ? `${metric.diskUsage.toFixed(1)}%` : "-"} + + {isOnline && metric + ? `${metric.diskUsage.toFixed(1)}%` + : "-"} +
    @@ -1295,20 +1551,44 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { <> {/* Header */}
    -
    - -

    {detailNode?.name || `节点 #${detailNodeId}`}

    - + +

    + {detailNode?.name || `节点 #${detailNodeId}`} +

    + {detailNode?.connectionStatus === "online" ? "在线" : "离线"}
    -
    - {wsConnected ? "实时已连接" : wsConnecting ? "实时连接中" : "实时未连接"} +
    + + {wsConnected + ? "实时已连接" + : wsConnecting + ? "实时连接中" + : "实时未连接"} +
    @@ -1316,17 +1596,48 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { {detailRealtimeMetric && (
    {[ - { label: "CPU", value: `${detailRealtimeMetric.cpuUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.cpuUsage) }, - { label: "内存", value: `${detailRealtimeMetric.memoryUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.memoryUsage) }, - { label: "磁盘", value: `${detailRealtimeMetric.diskUsage.toFixed(1)}%`, color: getColorByUsage(detailRealtimeMetric.diskUsage) }, - { label: "↓ 下行速度", value: formatBytesPerSecond(detailRealtimeMetric.netInSpeed), color: "success" as const }, - { label: "↑ 上行速度", value: formatBytesPerSecond(detailRealtimeMetric.netOutSpeed), color: "primary" as const }, - { label: "运行时间", value: formatUptime(detailRealtimeMetric.uptime), color: "default" as const }, + { + label: "CPU", + value: `${detailRealtimeMetric.cpuUsage.toFixed(1)}%`, + color: getColorByUsage(detailRealtimeMetric.cpuUsage), + }, + { + label: "内存", + value: `${detailRealtimeMetric.memoryUsage.toFixed(1)}%`, + color: getColorByUsage(detailRealtimeMetric.memoryUsage), + }, + { + label: "磁盘", + value: `${detailRealtimeMetric.diskUsage.toFixed(1)}%`, + color: getColorByUsage(detailRealtimeMetric.diskUsage), + }, + { + label: "↓ 下行速度", + value: formatBytesPerSecond(detailRealtimeMetric.netInSpeed), + color: "success" as const, + }, + { + label: "↑ 上行速度", + value: formatBytesPerSecond(detailRealtimeMetric.netOutSpeed), + color: "primary" as const, + }, + { + label: "运行时间", + value: formatUptime(detailRealtimeMetric.uptime), + color: "default" as const, + }, ].map((item) => ( - + - {item.label} - + + {item.label} + + {item.value} @@ -1337,37 +1648,45 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { {/* Node metrics chart */} - {/* Service monitors chart – same style as node metrics */} {(() => { // Resolve which monitor is active const resolvedActiveMonitor = - detailServiceMonitors.find((m) => m.id === activeServiceMonitorId) || + detailServiceMonitors.find( + (m) => m.id === activeServiceMonitorId, + ) || detailServiceMonitors[0] || null; const resolvedActiveMonitorId = resolvedActiveMonitor?.id ?? null; - const activeLatestResult = resolvedActiveMonitorId != null ? getLatestResult(resolvedActiveMonitorId) : null; - const activeStale = resolvedActiveMonitor ? isResultStale(resolvedActiveMonitor, activeLatestResult) : false; - const activeResults = resolvedActiveMonitorId != null ? (monitorResults[resolvedActiveMonitorId] || []) : []; - const activeLatencyData = [...activeResults] - .map((r) => ({ - time: formatTimestamp(r.timestamp, serviceMonitorRangeMs), - latency: r.success === 1 ? r.latencyMs : null, - success: r.success, - })); + const activeLatestResult = + resolvedActiveMonitorId != null + ? getLatestResult(resolvedActiveMonitorId) + : null; + const activeStale = resolvedActiveMonitor + ? isResultStale(resolvedActiveMonitor, activeLatestResult) + : false; + const activeResults = + resolvedActiveMonitorId != null + ? monitorResults[resolvedActiveMonitorId] || [] + : []; + const activeLatencyData = [...activeResults].map((r) => ({ + time: formatTimestamp(r.timestamp, serviceMonitorRangeMs), + latency: r.success === 1 ? r.latencyMs : null, + success: r.success, + })); return ( @@ -1379,26 +1698,40 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { selectedKeys={[String(serviceMonitorRangeMs)]} onSelectionChange={(keys) => { const v = Number(Array.from(keys)[0]); + if (v > 0) setServiceMonitorRangeMs(v); }} > - 1小时 - 6小时 - 24小时 + + 1小时 + + + 6小时 + + + 24小时 + {resolvedActiveMonitorId != null && ( )} - @@ -1406,7 +1739,9 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { {monitorsLoading ? ( -
    +
    + +
    ) : detailServiceMonitors.length > 0 ? ( <> {/* Monitor switcher buttons – like metric type tabs */} @@ -1414,30 +1749,52 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { {detailServiceMonitors.map((monitor) => { const lr = getLatestResult(monitor.id); const statusColor = - monitor.enabled !== 1 ? "default" : - !lr ? "default" : - lr.success === 1 ? "success" : "danger"; - const isActive = monitor.id === resolvedActiveMonitorId; + monitor.enabled !== 1 + ? "default" + : !lr + ? "default" + : lr.success === 1 + ? "success" + : "danger"; + const isActive = + monitor.id === resolvedActiveMonitorId; return ( ); @@ -1448,24 +1805,77 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { {resolvedActiveMonitor && (
    - {resolvedActiveMonitor.type.toUpperCase()} - {resolvedActiveMonitor.target} - 每秒测试,30秒上报 - {activeLatestResult && Number.isFinite(activeLatestResult.latencyMs) ? ( - {activeLatestResult.latencyMs.toFixed(0)}ms + + {resolvedActiveMonitor.type.toUpperCase()} + + + {resolvedActiveMonitor.target} + + + 每秒测试,30秒上报 + + {activeLatestResult && + Number.isFinite(activeLatestResult.latencyMs) ? ( + + {activeLatestResult.latencyMs.toFixed(0)}ms + + ) : null} + {activeStale ? ( + + 陈旧 + + ) : null} + {resolvedActiveMonitor.enabled !== 1 ? ( + + 已禁用 + ) : null} - {activeStale ? 陈旧 : null} - {resolvedActiveMonitor.enabled !== 1 ? 已禁用 : null}
    - + - } onPress={() => resolvedActiveMonitorId != null && handleRunMonitor(resolvedActiveMonitorId)}>立即检查 - } onPress={() => resolvedActiveMonitorId != null && openResultsModal(resolvedActiveMonitorId)}>查看记录 - } onPress={() => resolvedActiveMonitor && handleOpenEditModal(resolvedActiveMonitor)}>编辑 - } onPress={() => resolvedActiveMonitorId != null && handleDeleteMonitor(resolvedActiveMonitorId)}>删除 + } + onPress={() => + resolvedActiveMonitorId != null && + handleRunMonitor(resolvedActiveMonitorId) + } + > + 立即检查 + + } + onPress={() => + resolvedActiveMonitorId != null && + openResultsModal(resolvedActiveMonitorId) + } + > + 查看记录 + + } + onPress={() => + resolvedActiveMonitor && + handleOpenEditModal(resolvedActiveMonitor) + } + > + 编辑 + + } + onPress={() => + resolvedActiveMonitorId != null && + handleDeleteMonitor(resolvedActiveMonitorId) + } + > + 删除 +
    @@ -1478,22 +1888,46 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { - `${Math.round(v)}ms`} /> - [`${Number(value).toFixed(0)}ms`, "延迟"]} + + `${Math.round(v)}ms` + } + /> + [ + `${Number(value).toFixed(0)}ms`, + "延迟", + ]} + labelStyle={{ color: "#fff" }} + /> + -
    ) : ( -
    暂无检查记录
    +
    + 暂无检查记录 +
    )} ) : ( -
    暂无服务监控,点击"添加监控"创建
    +
    + 暂无服务监控,点击"添加监控"创建 +
    )} @@ -1586,7 +2020,8 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { aria-label="监控记录" className="w-full overflow-x-auto" classNames={{ - wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl", + wrapper: + "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl", th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]", td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0", tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors", diff --git a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx index b1ac66f..e326871 100644 --- a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx +++ b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx @@ -5,7 +5,13 @@ import type { TunnelQualityHopApiItem, } from "@/api/types"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { LineChart, Line, @@ -35,7 +41,6 @@ import { getMonitorTunnelQualityHistory, getConfigByName, } from "@/api"; - import { Button } from "@/shadcn-bridge/heroui/button"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Chip } from "@/shadcn-bridge/heroui/chip"; @@ -54,7 +59,8 @@ interface TunnelMonitorViewProps { } const QUALITY_POLL_INTERVAL = 1_000; // 1 second -const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled"; +const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = + "monitor_tunnel_quality_enabled"; const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT = "monitorTunnelQualityEnabledChanged"; @@ -78,7 +84,13 @@ const formatTimestamp = (ts: number, rangeMs?: number): string => { }); }; -function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) { +function LatencyDisplay({ + value, + loading, +}: { + value?: number; + loading?: boolean; +}) { if (loading) { return ; } @@ -87,11 +99,16 @@ function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean } const ms = value.toFixed(0); let colorClass = "text-success"; + if (value > 200) colorClass = "text-danger"; else if (value > 100) colorClass = "text-warning"; else if (value > 50) colorClass = "text-primary"; - return {ms}ms; + return ( + + {ms}ms + + ); } function LiveDot() { @@ -116,9 +133,11 @@ function UptimeHistoryBar({ }) { const bars = [...Array(UPTIME_KUMA_BARS_COUNT)].map((_, i) => { const historyIndex = history.length - UPTIME_KUMA_BARS_COUNT + i; + if (historyIndex >= 0 && historyIndex < history.length) { return history[historyIndex]; } + return null; }); @@ -183,13 +202,20 @@ const TIME_RANGE_OPTIONS = [ { key: String(24 * 60 * 60 * 1000), label: "24小时" }, ]; -function TimeRangeSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) { +function TimeRangeSelect({ + value, + onChange, +}: { + value: number; + onChange: (v: number) => void; +}) { return (