feat: 新增设备巡检模块实现完整的巡检告警功能

新增设备巡检模块核心功能,包括:
- 巡检数据存储与调度系统
- 华为/H3C交换机及Linux服务器采集器
- 设备扩展配置与告警规则管理
- 主机详情页巡检配置入口
- 产品名称自定义设置功能
- 网络设备SSH exec通道兼容处理
This commit is contained in:
2026-10-09 10:37:48 +08:00
parent 9202506c46
commit c64e00d835
75 changed files with 12087 additions and 446 deletions

View File

@@ -41,6 +41,8 @@ import {
type AsidePanelResizeProps,
} from "./ui/aside-panel";
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
import { HostInspectionSection } from "./inspection/HostInspectionSection";
import { InspectionErrorBoundary } from "./inspection/InspectionErrorBoundary";
import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
import {
LINUX_DISTRO_OPTION_IDS,
@@ -1157,6 +1159,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
clearProxyConfig={clearProxyConfig}
/>
{/* 巡检配置区 - 仅编辑已有主机时显示 */}
{initialData?.id && (
<InspectionErrorBoundary>
<HostInspectionSection
hostId={initialData.id}
hostLabel={form.label || initialData.label || "未命名"}
host={form}
keys={availableKeys}
/>
</InspectionErrorBoundary>
)}
<div className="flex items-center gap-3 py-2">
<div className="flex-1 h-px bg-border/60" />
<span className="text-xs text-muted-foreground">{t("hostDetails.otherProtocols")}</span>

View File

@@ -1,9 +1,8 @@
import React, { useEffect, useMemo, useState } from "react";
import { ArrowUpCircle, Bug, Check, Github, Loader2, MessageCircle, Newspaper, RefreshCcw } from "lucide-react";
import React, { useEffect, useState } from "react";
import { ArrowUpCircle, Check, Loader2, RefreshCcw } from "lucide-react";
import AppLogo from "./AppLogo";
import AppWordmark from "./AppWordmark";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
import { useI18n } from "../application/i18n/I18nProvider";
@@ -16,75 +15,6 @@ type AppInfo = {
platform?: string;
};
const REPO_URL = "https://github.com/binaricat/Netcatty";
const BUG_REPORT_TEMPLATE = "bug_report.yml";
const mapIssuePlatform = (platform?: string) => {
switch (platform) {
case "darwin":
return "macOS";
case "win32":
return "Windows";
case "linux":
return "Linux";
default:
return undefined;
}
};
/** Opens GitHub's Bug Report issue form with fields prefilled from the running app. */
export const buildIssueUrl = (appInfo: AppInfo) => {
const params = new URLSearchParams({
template: BUG_REPORT_TEMPLATE,
title: "[Bug] ",
});
if (appInfo.version) {
params.set("version", appInfo.version);
}
const platform = mapIssuePlatform(appInfo.platform);
if (platform) {
params.set("platform", platform);
}
const installSource =
appInfo.version === "0.0.0"
? "Built from source (npm run dev / pack)"
: "GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)";
params.set("install_source", installSource);
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown";
params.set(
"logs",
`Reported from Netcatty Settings (${appInfo.name} ${appInfo.version || "unknown"}).\n\nUser-Agent: ${ua}`,
);
return `${REPO_URL}/issues/new?${params.toString()}`;
};
const ActionRow: React.FC<{
icon: React.ReactNode;
title: string;
subtitle: string;
onClick: () => void;
}> = ({ icon, title, subtitle, onClick }) => (
<button
type="button"
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 rounded-lg px-3 py-3 text-left",
"hover:bg-muted/50 transition-colors"
)}
>
<div className="shrink-0 text-muted-foreground">{icon}</div>
<div className="min-w-0">
<div className="text-sm font-medium leading-tight">{title}</div>
<div className="text-xs text-muted-foreground mt-0.5 truncate">{subtitle}</div>
</div>
</button>
);
interface SettingsApplicationTabProps {
updateState: UpdateState;
checkNow: UseUpdateCheckResult['checkNow'];
@@ -92,9 +22,11 @@ interface SettingsApplicationTabProps {
installUpdate: UseUpdateCheckResult['installUpdate'];
startDownload: UseUpdateCheckResult['startDownload'];
isUpdateDemoMode: boolean;
productName: string;
setProductName: (value: string) => void;
}
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode }: SettingsApplicationTabProps) {
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode, productName, setProductName }: SettingsApplicationTabProps) {
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
@@ -163,10 +95,6 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
setTimeout(() => setLastCheckResult('none'), 3000);
};
const issueUrl = useMemo(() => buildIssueUrl(appInfo), [appInfo]);
const releasesUrl = `${REPO_URL}/releases`;
const discussionsUrl = `${REPO_URL}/discussions`;
return (
<SettingsTabContent value="application">
<div className="flex flex-col lg:flex-row gap-10 lg:gap-14">
@@ -174,7 +102,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
<div className="flex items-center gap-4">
<AppLogo className="w-16 h-16" />
<div>
<AppWordmark accessibleLabel="Netcatty" className="h-8 w-auto text-foreground" />
<AppWordmark accessibleLabel={productName} className="h-8 w-auto text-foreground" />
<div className="flex items-center gap-2 mt-1">
<span className="text-sm text-muted-foreground">
{appInfo.version ? appInfo.version : " "}
@@ -224,43 +152,35 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
}
</Button>
</SettingsAnchor>
</div>
<div className="flex-1">
<div className="space-y-2">
<SettingsAnchor anchorId="application-report-problem">
<ActionRow
icon={<Bug size={18} />}
title={t("settings.application.reportProblem")}
subtitle={t("settings.application.reportProblem.subtitle")}
onClick={() => void handleOpenExternal(issueUrl)}
{/* 产品名称配置 */}
<SettingsAnchor anchorId="application-product-name" className="mt-6">
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
{t('settings.application.productName') || 'Product Name'}
</label>
<input
type="text"
value={productName}
onChange={(e) => setProductName(e.target.value)}
onBlur={() => {
// 失焦时确保标题栏更新
const bridge = (window as any).netcatty;
bridge?.setWindowTitle?.(productName);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
}
}}
placeholder="NetMesh"
className="w-full px-3 py-2 rounded-lg border border-input bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</SettingsAnchor>
<SettingsAnchor anchorId="application-community">
<ActionRow
icon={<MessageCircle size={18} />}
title={t("settings.application.community")}
subtitle={t("settings.application.community.subtitle")}
onClick={() => void handleOpenExternal(discussionsUrl)}
/>
</SettingsAnchor>
<SettingsAnchor anchorId="application-github">
<ActionRow
icon={<Github size={18} />}
title="GitHub"
subtitle={t("settings.application.github.subtitle")}
onClick={() => void handleOpenExternal(REPO_URL)}
/>
</SettingsAnchor>
<SettingsAnchor anchorId="application-whats-new">
<ActionRow
icon={<Newspaper size={18} />}
title={t("settings.application.whatsNew")}
subtitle={t("settings.application.whatsNew.subtitle")}
onClick={() => void handleOpenExternal(releasesUrl)}
/>
</SettingsAnchor>
</div>
<p className="text-xs text-muted-foreground">
{t('settings.application.productNameHint') || 'Used in title bar, sidebar, and settings'}
</p>
</div>
</SettingsAnchor>
</div>
</div>

View File

@@ -2,16 +2,13 @@
* Settings Page - Standalone settings window content
* This component is rendered in a separate Electron window
*/
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Puzzle, Sparkles, TerminalSquare, X } from "lucide-react";
import { AppWindow, FileType, HardDrive, Keyboard, Palette, Puzzle, Sparkles, TerminalSquare, X } from "lucide-react";
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useAISettingsState } from "../application/state/useAISettingsState";
import { useAvailableFonts } from "../application/state/fontStore";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
import { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
import type { AppLockGateRenderContext } from "./AppLockGate";
import { toast } from "./ui/toast";
import { SettingsTabContent } from "./settings/settings-ui";
@@ -33,9 +30,8 @@ const LazySettingsAppearanceTab = lazy(() => import("./settings/tabs/SettingsApp
const LazySettingsFileAssociationsTab = lazy(() => import("./settings/tabs/SettingsFileAssociationsTab"));
const LazySettingsShortcutsTab = lazy(() => import("./settings/tabs/SettingsShortcutsTab"));
const LazySettingsAITab = lazy(() => import("./settings/tabs/SettingsAITab"));
const LazySettingsSyncTab = lazy(() => import("./settings/tabs/SettingsSyncTab"));
const LazySettingsTerminalTab = lazy(() => import("./settings/tabs/SettingsTerminalTab"));
const LazySettingsSystemTab = lazy(() => import("./settings/tabs/SettingsSystemTab"));
const LazySettingsTerminalTab = lazy(() => import("./settings/tabs/SettingsTerminalTab"));
const LazySettingsPluginsTab = lazy(() => import("./settings/tabs/SettingsPluginsTab"));
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
@@ -237,72 +233,6 @@ const SettingsAITabContainer: React.FC = () => {
);
};
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
const {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
notes,
noteGroups,
knownHosts,
groupConfigs,
importDataFromString,
clearVaultData,
} = useVaultState();
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
// Strip transient runtime fields before passing to sync
const portForwardingRulesForSync = useMemo(
() => sanitizePortForwardingRulesForSync(portForwardingRules) ?? [],
[portForwardingRules],
);
const vault = useMemo(
() => ({
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
notes,
noteGroups,
knownHosts,
groupConfigs,
}),
[
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
notes,
noteGroups,
knownHosts,
groupConfigs,
],
);
return (
<LazySettingsSyncTab
vault={vault}
portForwardingRules={portForwardingRulesForSync}
importDataFromString={importDataFromString}
importPortForwardingRules={importPortForwardingRules}
clearVaultData={clearVaultData}
onSettingsApplied={onSettingsApplied}
/>
);
};
const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLockState }> = ({ settings, appLock }) => {
const { t } = useI18n();
const { request, clearFocus, openSearch } = useSettingsFocus();
@@ -488,13 +418,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
<Sparkles size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.ai")}</span>
</TabsTrigger>
<TabsTrigger
value="sync"
className={settingsTabTriggerClassName}
>
<Cloud size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.syncCloud")}</span>
</TabsTrigger>
<TabsTrigger
value="system"
className={settingsTabTriggerClassName}
@@ -515,6 +438,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
installUpdate={installUpdate}
startDownload={startDownload}
isUpdateDemoMode={isUpdateDemoMode}
productName={settings.productName}
setProductName={settings.setProductName}
/>
</SettingsLazyTab>
)}
@@ -619,12 +544,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
</SettingsLazyTab>
)}
{mountedTabs.has("sync") && (
<SettingsLazyTab value="sync">
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
</SettingsLazyTab>
)}
{mountedTabs.has("system") && (
<SettingsLazyTab value="system">
<LazySettingsSystemTab
@@ -669,13 +588,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
hotkeyRegistrationError={settings.hotkeyRegistrationError}
globalHotkeyEnabled={settings.globalHotkeyEnabled}
setGlobalHotkeyEnabled={settings.setGlobalHotkeyEnabled}
autoUpdateEnabled={settings.autoUpdateEnabled}
setAutoUpdateEnabled={settings.setAutoUpdateEnabled}
updateState={updateState}
checkNow={checkNow}
installUpdate={installUpdate}
openReleasePage={openReleasePage}
startDownload={startDownload}
/>
</SettingsLazyTab>
)}

View File

@@ -156,6 +156,7 @@ import { VaultViewLayout } from "./vault/VaultViewLayout";
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers";
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
import { useInspectionScheduler } from "../application/state/useInspectionScheduler";
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
@@ -172,7 +173,10 @@ export type VaultSection =
| "notes"
| "port"
| "knownhosts"
| "logs";
| "logs"
| "inspection"
| "alerts"
| "alertRules";
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
if (previous.length !== next.length) return false;
@@ -298,6 +302,7 @@ interface VaultViewProps {
keepaliveInterval: number;
keepaliveCountMax: number;
};
productName?: string;
}
const VaultViewInner: React.FC<VaultViewProps> = ({
@@ -327,6 +332,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onUpdateKeys,
onImportOrReuseKey,
onUpdateIdentities,
productName,
onUpdateProxyProfiles,
onUpdateSnippets,
onUpdateSnippetPackages,
@@ -429,6 +435,32 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
}, [navigateToSection, onNavigateToSectionHandled]);
// 巡检调度器
const inspectionScheduler = useInspectionScheduler(hosts, keys);
// 监听 hosts 加载完成后触发一次巡检(StrictMode 下多次触发也没关系,scanLoop 有 runningRef 保护)
const prevHostsLenRef = useRef(0);
useEffect(() => {
if (hosts.length > 0 && prevHostsLenRef.current === 0) {
// hosts 从空变成非空,触发一次巡检(传入 hosts 参数,避免闭包问题)
console.log('[VaultView] hosts 加载完成,触发 scanLoop');
setTimeout(() => {
inspectionScheduler.scanLoop(hosts);
}, 500);
}
prevHostsLenRef.current = hosts.length;
}, [hosts.length]);
// 启动周期性巡检(30 秒一次)
useEffect(() => {
if (hosts.length === 0) return;
const id = setInterval(() => {
console.log('[VaultView] 定时触发 scanLoop');
inspectionScheduler.scanLoop(hosts);
}, 30000);
return () => clearInterval(id);
}, [hosts.length]);
// View mode, sorting, and tag filter state
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
@@ -1532,6 +1564,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
protocolSelectHost,
proxyProfiles,
ProxyProfilesManager,
productName,
quickConnectTarget,
quickConnectWarnings,
QuickConnectWizard,
@@ -1684,7 +1717,8 @@ export const vaultViewAreEqual = (
prev.terminalSettings?.keepaliveInterval ===
next.terminalSettings?.keepaliveInterval &&
prev.terminalSettings?.keepaliveCountMax ===
next.terminalSettings?.keepaliveCountMax;
next.terminalSettings?.keepaliveCountMax &&
prev.productName === next.productName;
return isEqual;
};

View File

@@ -0,0 +1,528 @@
import {
AlertTriangle,
CheckCircle,
XCircle,
Clock,
MessageSquare,
RefreshCw,
Filter,
Info,
AlertOctagon,
Bell,
ChevronRight,
} from "lucide-react";
import React, { useEffect, useState, useCallback } from "react";
import { Button } from "../ui/button";
import { Badge } from "../ui/badge";
import { Input } from "../ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../ui/dialog";
import { Label } from "../ui/label";
import { Textarea } from "../ui/textarea";
import {
inspectionAlertApi,
type AlertRecord,
type AlertLevel,
type AlertRecordStatus,
type AlertStats,
} from "../../infrastructure/inspection/inspectionApi";
import { toast } from "../ui/toast";
const LEVEL_COLORS: Record<AlertLevel, { bg: string; text: string; border: string; dot: string }> = {
critical: {
bg: "bg-red-500/10",
text: "text-red-500",
border: "border-red-500/30",
dot: "bg-red-500",
},
warning: {
bg: "bg-orange-500/10",
text: "text-orange-500",
border: "border-orange-500/30",
dot: "bg-orange-500",
},
info: {
bg: "bg-blue-500/10",
text: "text-blue-500",
border: "border-blue-500/30",
dot: "bg-blue-500",
},
};
const LEVEL_LABELS: Record<AlertLevel, string> = {
critical: "严重",
warning: "警告",
info: "提示",
};
const STATUS_LABELS: Record<AlertRecordStatus, string> = {
active: "待处理",
acknowledged: "已确认",
resolved: "已恢复",
closed: "已关闭",
};
const STATUS_COLORS: Record<AlertRecordStatus, string> = {
active: "text-red-500",
acknowledged: "text-orange-500",
resolved: "text-green-500",
closed: "text-muted-foreground",
};
export function AlertCenterPage() {
const [alerts, setAlerts] = useState<AlertRecord[]>([]);
const [stats, setStats] = useState<AlertStats | null>(null);
const [loading, setLoading] = useState(true);
const [selectedAlert, setSelectedAlert] = useState<AlertRecord | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [noteDialogOpen, setNoteDialogOpen] = useState(false);
const [noteText, setNoteText] = useState("");
// 筛选
const [filterLevel, setFilterLevel] = useState<string>("all");
const [filterStatus, setFilterStatus] = useState<string>("all");
const [searchText, setSearchText] = useState("");
const [activeTab, setActiveTab] = useState<"active" | "all">("active");
const loadData = useCallback(async () => {
setLoading(true);
try {
const [statsData, alertsData] = await Promise.all([
inspectionAlertApi.getStats(),
activeTab === "active"
? inspectionAlertApi.getActive()
: inspectionAlertApi.list({ limit: 200 }),
]);
setStats(statsData);
setAlerts(alertsData);
} catch (e: any) {
console.error("[AlertCenter] 加载失败:", e);
} finally {
setLoading(false);
}
}, [activeTab]);
useEffect(() => {
loadData();
}, [loadData]);
// 筛选后的数据
const filteredAlerts = (alerts ?? []).filter((a) => {
if (filterLevel !== "all" && a.level !== filterLevel) return false;
if (filterStatus !== "all" && a.status !== filterStatus) return false;
if (searchText) {
const s = searchText.toLowerCase();
if (
!a.device_name.toLowerCase().includes(s) &&
!a.rule_name.toLowerCase().includes(s) &&
!a.message.toLowerCase().includes(s)
)
return false;
}
return true;
});
const handleViewDetail = (alert: AlertRecord) => {
setSelectedAlert(alert);
setDetailOpen(true);
};
const handleAcknowledge = async () => {
if (!selectedAlert) return;
try {
await inspectionAlertApi.acknowledge(selectedAlert.id, "operator");
toast.success("已确认告警");
setDetailOpen(false);
loadData();
} catch (e: any) {
toast.error("操作失败");
}
};
const handleResolve = async () => {
if (!selectedAlert) return;
try {
await inspectionAlertApi.resolve(selectedAlert.id, "manual", "operator");
toast.success("已恢复告警");
setDetailOpen(false);
loadData();
} catch (e: any) {
toast.error("操作失败");
}
};
const handleClose = () => {
if (!selectedAlert) return;
setNoteText("");
setNoteDialogOpen(true);
};
const handleCloseConfirm = async () => {
if (!selectedAlert) return;
try {
await inspectionAlertApi.close(selectedAlert.id, noteText);
toast.success("已关闭告警");
setNoteDialogOpen(false);
setDetailOpen(false);
loadData();
} catch (e: any) {
toast.error("操作失败");
}
};
const formatTime = (ts: number) => {
return new Date(ts).toLocaleString();
};
const getTimeAgo = (ts: number) => {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return "刚刚";
if (mins < 60) return `${mins} 分钟前`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
return `${days} 天前`;
};
return (
<div className="h-full flex flex-col">
{/* 顶部统计 */}
<div className="px-6 py-4 border-b border-border/60">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-red-500/10 flex items-center justify-center">
<Bell size={18} className="text-red-500" />
</div>
<div>
<h2 className="text-lg font-semibold">告警中心</h2>
<p className="text-sm text-muted-foreground">
监控设备巡检产生的告警
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={loadData}>
<RefreshCw size={14} className="mr-2" />
刷新
</Button>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-3">
<div className="p-3 rounded-lg border border-red-500/30 bg-red-500/5">
<div className="flex items-center gap-2">
<AlertOctagon size={16} className="text-red-500" />
<span className="text-sm text-muted-foreground">严重告警</span>
</div>
<div className="text-2xl font-bold text-red-500 mt-1">
{stats?.active_critical ?? 0}
</div>
</div>
<div className="p-3 rounded-lg border border-orange-500/30 bg-orange-500/5">
<div className="flex items-center gap-2">
<AlertTriangle size={16} className="text-orange-500" />
<span className="text-sm text-muted-foreground">警告</span>
</div>
<div className="text-2xl font-bold text-orange-500 mt-1">
{stats?.active_warning ?? 0}
</div>
</div>
<div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">
<div className="flex items-center gap-2">
<Info size={16} className="text-blue-500" />
<span className="text-sm text-muted-foreground">提示</span>
</div>
<div className="text-2xl font-bold text-blue-500 mt-1">
{stats?.active_info ?? 0}
</div>
</div>
<div className="p-3 rounded-lg border border-green-500/30 bg-green-500/5">
<div className="flex items-center gap-2">
<CheckCircle size={16} className="text-green-500" />
<span className="text-sm text-muted-foreground">已恢复</span>
</div>
<div className="text-2xl font-bold text-green-500 mt-1">
{stats?.resolved ?? 0}
</div>
</div>
</div>
</div>
{/* 筛选栏 */}
<div className="flex items-center gap-3 px-6 py-3 border-b border-border/60">
<div className="flex bg-secondary/50 rounded-md p-0.5">
<button
onClick={() => setActiveTab("active")}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
activeTab === "active"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
待处理
{stats && stats.active > 0 && (
<Badge className="ml-2" variant="secondary">
{stats.active}
</Badge>
)}
</button>
<button
onClick={() => setActiveTab("all")}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
activeTab === "all"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
全部
</button>
</div>
<div className="flex-1" />
<Select value={filterLevel} onValueChange={setFilterLevel}>
<SelectTrigger className="w-32">
<SelectValue placeholder="级别" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部级别</SelectItem>
<SelectItem value="critical">严重</SelectItem>
<SelectItem value="warning">警告</SelectItem>
<SelectItem value="info">提示</SelectItem>
</SelectContent>
</Select>
{activeTab === "all" && (
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-32">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="active">待处理</SelectItem>
<SelectItem value="acknowledged">已确认</SelectItem>
<SelectItem value="resolved">已恢复</SelectItem>
<SelectItem value="closed">已关闭</SelectItem>
</SelectContent>
</Select>
)}
<Input
placeholder="搜索设备/规则..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-48"
/>
</div>
{/* 告警列表 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="text-center text-muted-foreground py-12">加载中...</div>
) : filteredAlerts.length === 0 ? (
<div className="text-center text-muted-foreground py-16">
<CheckCircle size={40} className="mx-auto mb-3 text-green-500 opacity-40" />
<p className="text-sm">暂无告警记录</p>
<p className="text-xs text-muted-foreground mt-1">
{activeTab === "active" ? "所有设备运行正常" : "还没有产生过告警"}
</p>
</div>
) : (
<div className="divide-y divide-border/60">
{filteredAlerts.map((alert) => {
const colors = LEVEL_COLORS[alert.level];
return (
<div
key={alert.id}
onClick={() => handleViewDetail(alert)}
className="flex items-center gap-4 px-6 py-3 hover:bg-accent/30 cursor-pointer transition-colors"
>
{/* 级别指示点 */}
<div className={`w-2 h-2 rounded-full ${colors.dot}`} />
{/* 主要内容 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-medium truncate">{alert.device_name}</span>
<Badge
variant="outline"
className={`${colors.bg} ${colors.text} ${colors.border} border`}
>
{LEVEL_LABELS[alert.level]}
</Badge>
<span className={`text-xs ${STATUS_COLORS[alert.status]}`}>
{STATUS_LABELS[alert.status]}
</span>
</div>
<div className="text-sm text-muted-foreground truncate">
{alert.rule_name} — {alert.message}
</div>
</div>
{/* 时间 */}
<div className="text-right shrink-0">
<div className="text-sm text-muted-foreground">
{getTimeAgo(alert.triggered_at)}
</div>
<div className="text-xs text-muted-foreground/60">
{formatTime(alert.triggered_at)}
</div>
</div>
<ChevronRight size={16} className="text-muted-foreground shrink-0" />
</div>
);
})}
</div>
)}
</div>
{/* 详情弹窗 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-lg">
{selectedAlert && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${LEVEL_COLORS[selectedAlert.level].dot}`}
/>
告警详情
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-xs text-muted-foreground mb-1">设备名称</div>
<div className="text-sm font-medium">{selectedAlert.device_name}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">告警级别</div>
<Badge
variant="outline"
className={`${LEVEL_COLORS[selectedAlert.level].bg} ${
LEVEL_COLORS[selectedAlert.level].text
} ${LEVEL_COLORS[selectedAlert.level].border} border`}
>
{LEVEL_LABELS[selectedAlert.level]}
</Badge>
</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">规则名称</div>
<div className="text-sm">{selectedAlert.rule_name}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">告警消息</div>
<div className="text-sm p-2 bg-secondary/30 rounded-md">
{selectedAlert.message}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-xs text-muted-foreground mb-1">当前值</div>
<div className="text-sm font-mono">{selectedAlert.metric_value}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">阈值</div>
<div className="text-sm font-mono">{selectedAlert.threshold}</div>
</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">触发时间</div>
<div className="text-sm">{formatTime(selectedAlert.triggered_at)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">当前状态</div>
<div className={`text-sm font-medium ${STATUS_COLORS[selectedAlert.status]}`}>
{STATUS_LABELS[selectedAlert.status]}
</div>
</div>
{selectedAlert.note && (
<div>
<div className="text-xs text-muted-foreground mb-1">备注</div>
<div className="text-sm text-muted-foreground">{selectedAlert.note}</div>
</div>
)}
</div>
<DialogFooter className="flex justify-between">
<Button variant="outline" onClick={() => setDetailOpen(false)}>
关闭
</Button>
<div className="flex gap-2">
{selectedAlert.status === "active" && (
<Button variant="outline" onClick={handleAcknowledge}>
<MessageSquare size={14} className="mr-2" />
确认
</Button>
)}
{(selectedAlert.status === "active" ||
selectedAlert.status === "acknowledged") && (
<Button variant="outline" onClick={handleResolve}>
<CheckCircle size={14} className="mr-2" />
恢复
</Button>
)}
{selectedAlert.status !== "closed" && (
<Button variant="destructive" onClick={handleClose}>
<XCircle size={14} className="mr-2" />
关闭
</Button>
)}
</div>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
{/* 关闭备注弹窗 */}
<Dialog open={noteDialogOpen} onOpenChange={setNoteDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>关闭告警</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<Label>关闭原因(可选)</Label>
<Textarea
placeholder="请输入关闭原因或处理备注..."
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNoteDialogOpen(false)}>
取消
</Button>
<Button variant="destructive" onClick={handleCloseConfirm}>
确认关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default AlertCenterPage;

View File

@@ -0,0 +1,589 @@
import { Plus, Trash2, Edit2, AlertTriangle, Bell, Settings2, Power, PowerOff } from "lucide-react";
import React, { useEffect, useState, useCallback } from "react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Badge } from "../ui/badge";
import { Switch } from "../ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../ui/dialog";
import { Label } from "../ui/label";
import {
inspectionAlertRuleApi,
inspectionCatalogApi,
type AlertRule,
type AlertLevel,
type Operator,
type DeviceCatalog,
} from "../../infrastructure/inspection/inspectionApi";
import { toast } from "../ui/toast";
const LEVEL_OPTIONS: { value: AlertLevel; label: string; color: string }[] = [
{ value: "critical", label: "严重", color: "text-red-500 bg-red-500/10 border-red-500/30" },
{ value: "warning", label: "警告", color: "text-orange-500 bg-orange-500/10 border-orange-500/30" },
{ value: "info", label: "提示", color: "text-blue-500 bg-blue-500/10 border-blue-500/30" },
];
const OPERATOR_OPTIONS: { value: Operator; label: string }[] = [
{ value: ">", label: "大于 ( > )" },
{ value: "<", label: "小于 ( < )" },
{ value: ">=", label: "大于等于 ( >= )" },
{ value: "<=", label: "小于等于 ( <= )" },
{ value: "==", label: "等于 ( == )" },
{ value: "!=", label: "不等于 ( != )" },
];
const COMMON_METRICS = [
{ value: "cpu_usage", label: "CPU 使用率 (%)", group: "通用" },
{ value: "memory_usage", label: "内存使用率 (%)", group: "通用" },
{ value: "disk_usage", label: "磁盘使用率 (%)", group: "通用" },
{ value: "load_1m", label: "系统负载 (1分钟)", group: "通用" },
{ value: "online", label: "设备在线状态", group: "通用" },
{ value: "temperature", label: "设备温度 (°C)", group: "网络设备" },
{ value: "power_failure_count", label: "电源故障数", group: "网络设备" },
{ value: "fan_failure_count", label: "风扇故障数", group: "网络设备" },
{ value: "port_down_count", label: "端口 Down 数", group: "网络设备" },
{ value: "port_total_count", label: "端口总数", group: "网络设备" },
];
const SCOPE_OPTIONS = [
{ value: "all", label: "全部设备" },
{ value: "tier", label: "按设备分级" },
{ value: "catalog", label: "按设备类型" },
{ value: "device", label: "指定设备" },
];
export function AlertRulesPage() {
const [rules, setRules] = useState<AlertRule[]>([]);
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<AlertRule | null>(null);
// 筛选条件
const [filterLevel, setFilterLevel] = useState<"all" | AlertLevel>("all");
const [filterScope, setFilterScope] = useState<"all" | "preset" | "custom">("all");
const [searchText, setSearchText] = useState("");
// 表单状态
const [form, setForm] = useState({
name: "",
metric: "cpu_usage",
operator: ">" as Operator,
threshold: "80",
level: "warning" as AlertLevel,
duration: 1,
scope_type: "all" as "all" | "tier" | "catalog" | "device",
scope_value: "" as string,
enabled: true,
});
const loadRules = useCallback(async () => {
setLoading(true);
try {
const [list, catList] = await Promise.all([
inspectionAlertRuleApi.list(),
inspectionCatalogApi.list(),
]);
setRules(list);
setCatalogs(catList);
} catch (e: any) {
console.error("[AlertRules] 加载失败:", e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadRules();
}, [loadRules]);
const handleCreate = () => {
setEditingRule(null);
setForm({
name: "",
metric: "cpu_usage",
operator: ">",
threshold: "80",
level: "warning",
duration: 1,
scope_type: "all",
scope_value: "",
enabled: true,
});
setDialogOpen(true);
};
const handleEdit = (rule: AlertRule) => {
setEditingRule(rule);
setForm({
name: rule.name,
metric: rule.metric,
operator: rule.operator,
threshold: rule.threshold,
level: rule.level,
duration: rule.duration,
scope_type: rule.scope_type as any,
scope_value: Array.isArray(rule.scope_value)
? rule.scope_value.join(", ")
: rule.scope_value || "",
enabled: rule.enabled,
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!form.name.trim()) {
toast.error("请输入规则名称");
return;
}
if (!form.threshold.trim()) {
toast.error("请输入阈值");
return;
}
try {
const data: Partial<AlertRule> = {
name: form.name,
metric: form.metric,
operator: form.operator,
threshold: form.threshold,
level: form.level,
duration: form.duration,
scope_type: form.scope_type,
scope_value:
form.scope_type === "all"
? null
: form.scope_type === "device"
? form.scope_value.split(",").map((s) => s.trim()).filter(Boolean)
: form.scope_value || null,
enabled: form.enabled,
};
if (editingRule) {
await inspectionAlertRuleApi.update(editingRule.id, data);
toast.success("规则已更新");
} else {
await inspectionAlertRuleApi.create(data);
toast.success("规则已创建");
}
setDialogOpen(false);
loadRules();
} catch (e: any) {
toast.error("保存失败: " + e.message);
}
};
const handleToggle = async (ruleId: string, enabled: boolean) => {
try {
await inspectionAlertRuleApi.toggle(ruleId, enabled);
toast.success(enabled ? "已启用" : "已停用");
loadRules();
} catch (e: any) {
toast.error("操作失败");
}
};
const handleDelete = async (ruleId: string) => {
if (!confirm("确定要删除这条规则吗?")) return;
try {
await inspectionAlertRuleApi.remove(ruleId);
toast.success("已删除");
loadRules();
} catch (e: any) {
toast.error("删除失败");
}
};
const getLevelBadge = (level: AlertLevel) => {
const opt = LEVEL_OPTIONS.find((l) => l.value === level);
return (
<Badge variant="outline" className={opt?.color}>
{opt?.label}
</Badge>
);
};
const getScopeLabel = (rule: AlertRule) => {
switch (rule.scope_type) {
case "all":
return "全部设备";
case "tier":
const tierLabels: Record<string, string> = {
core: "核心级",
aggregation: "重要级",
access: "一般",
};
return `分级: ${tierLabels[String(rule.scope_value)] || rule.scope_value}`;
case "catalog":
const cat = catalogs.find((c) => c.id === rule.scope_value);
return `类型: ${cat?.name || rule.scope_value}`;
case "device":
return `指定设备 (${Array.isArray(rule.scope_value) ? rule.scope_value.length : 0})`;
default:
return rule.scope_type;
}
};
// 筛选后的数据
const filteredRules = (rules ?? []).filter((r) => {
if (filterLevel !== "all" && r.level !== filterLevel) return false;
if (filterScope === "preset" && !r.preset) return false;
if (filterScope === "custom" && r.preset) return false;
if (searchText) {
const s = searchText.toLowerCase();
if (!r.name.toLowerCase().includes(s) && !r.metric.toLowerCase().includes(s)) {
return false;
}
}
return true;
});
return (
<div className="h-full flex flex-col">
{/* 顶部工具栏 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
<AlertTriangle size={18} className="text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold">告警规则配置</h2>
<p className="text-sm text-muted-foreground">
定义设备巡检的告警触发条件(共 {(rules?.length ?? 0)} 条)
</p>
</div>
</div>
<Button onClick={handleCreate}>
<Plus size={16} className="mr-2" />
新增规则
</Button>
</div>
{/* 筛选栏 */}
<div className="flex items-center gap-3 px-6 py-3 border-b border-border/40 bg-muted/20">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">级别:</span>
<div className="flex gap-1">
{[
{ value: "all", label: "全部" },
{ value: "critical", label: "严重" },
{ value: "warning", label: "警告" },
{ value: "info", label: "提示" },
].map((opt) => (
<button
key={opt.value}
onClick={() => setFilterLevel(opt.value as any)}
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
filterLevel === opt.value
? "bg-primary text-primary-foreground"
: "bg-background hover:bg-accent text-muted-foreground"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="w-px h-4 bg-border/60" />
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">分类:</span>
<div className="flex gap-1">
{[
{ value: "all", label: "全部" },
{ value: "preset", label: "预置" },
{ value: "custom", label: "自定义" },
].map((opt) => (
<button
key={opt.value}
onClick={() => setFilterScope(opt.value as any)}
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
filterScope === opt.value
? "bg-primary text-primary-foreground"
: "bg-background hover:bg-accent text-muted-foreground"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex-1" />
<div className="relative w-56">
<Input
placeholder="搜索规则名称..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
{/* 规则列表 */}
<div className="flex-1 overflow-auto p-6">
{loading ? (
<div className="text-center text-muted-foreground py-8">加载中...</div>
) : filteredRules.length === 0 ? (
<div className="text-center text-muted-foreground py-12">
<Bell size={40} className="mx-auto mb-3 opacity-40" />
<p>暂无符合条件的告警规则</p>
<p className="text-sm">点击右上角新增规则开始配置</p>
</div>
) : (
<div className="space-y-2">
{filteredRules.map((rule) => (
<div
key={rule.id}
className="flex items-center gap-4 p-4 border border-border/60 rounded-lg hover:bg-accent/30 transition-colors"
>
{/* 启用开关 */}
<div className="flex flex-col items-center gap-1">
<Switch
checked={rule.enabled}
onCheckedChange={(v) => handleToggle(rule.id, v)}
/>
<span className="text-[10px] text-muted-foreground">
{rule.enabled ? "启用" : "停用"}
</span>
</div>
{/* 规则信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{rule.name}</span>
{getLevelBadge(rule.level)}
{rule.preset && (
<Badge variant="secondary" className="text-xs">
预置
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground flex items-center gap-3">
<span>
{COMMON_METRICS.find((m) => m.value === rule.metric)?.label || rule.metric}
</span>
<span className="font-mono">{rule.operator}</span>
<span>{rule.threshold}</span>
<span className="text-border">|</span>
<span>{getScopeLabel(rule)}</span>
{rule.duration > 1 && (
<>
<span className="text-border">|</span>
<span>持续 {rule.duration} 次</span>
</>
)}
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(rule)}
title="编辑"
>
<Edit2 size={15} />
</Button>
{!rule.preset && (
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(rule.id)}
title="删除"
className="text-destructive hover:text-destructive"
>
<Trash2 size={15} />
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* 编辑弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingRule ? "编辑告警规则" : "新增告警规则"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* 规则名称 */}
<div className="space-y-2">
<Label>规则名称</Label>
<Input
placeholder="例如:CPU 使用率过高"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
{/* 指标 + 操作符 + 阈值 */}
<div className="grid grid-cols-3 gap-3">
<div className="space-y-2">
<Label>指标</Label>
<Select value={form.metric} onValueChange={(v) => setForm({ ...form, metric: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COMMON_METRICS.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>条件</Label>
<Select
value={form.operator}
onValueChange={(v) => setForm({ ...form, operator: v as Operator })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATOR_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>阈值</Label>
<Input
placeholder="80"
value={form.threshold}
onChange={(e) => setForm({ ...form, threshold: e.target.value })}
/>
</div>
</div>
{/* 告警级别 */}
<div className="space-y-2">
<Label>告警级别</Label>
<div className="flex gap-2">
{LEVEL_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setForm({ ...form, level: opt.value })}
className={`flex-1 py-2 px-3 rounded-md border text-sm font-medium transition-colors ${
form.level === opt.value
? `border-primary/50 ${opt.color}`
: "border-border hover:border-primary/30"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* 适用范围 */}
<div className="space-y-2">
<Label>适用范围</Label>
<div className="flex gap-2">
<Select
value={form.scope_type}
onValueChange={(v) => setForm({ ...form, scope_type: v as any })}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
{form.scope_type !== "all" && (
<Select
value={form.scope_value}
onValueChange={(v) => setForm({ ...form, scope_value: v })}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="请选择" />
</SelectTrigger>
<SelectContent>
{form.scope_type === "catalog" &&
catalogs.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
{form.scope_type === "tier" && (
<>
<SelectItem value="core">核心级</SelectItem>
<SelectItem value="aggregation">重要级</SelectItem>
<SelectItem value="access">一般</SelectItem>
</>
)}
</SelectContent>
</Select>
)}
</div>
{form.scope_type === "device" && (
<Input
placeholder="输入设备 ID,多个用逗号分隔"
value={form.scope_value}
onChange={(e) => setForm({ ...form, scope_value: e.target.value })}
className="mt-1"
/>
)}
</div>
{/* 持续次数 */}
<div className="space-y-2">
<Label>持续次数(防抖动)</Label>
<div className="flex items-center gap-3">
<Input
type="number"
min={1}
max={10}
value={form.duration}
onChange={(e) =>
setForm({ ...form, duration: parseInt(e.target.value, 10) || 1 })
}
className="w-24"
/>
<span className="text-sm text-muted-foreground">
连续触发 N 次后才产生告警,避免瞬时波动误报
</span>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button onClick={handleSave}>{editingRule ? "保存修改" : "创建规则"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default AlertRulesPage;

View File

@@ -0,0 +1,298 @@
import { Activity, AlertTriangle, Clock, Cpu, Layers, Settings2, Wifi } from "lucide-react";
import React, { useEffect, useState, useCallback } from "react";
import { Button } from "../ui/button";
import { Switch } from "../ui/switch";
import { Input } from "../ui/input";
import { Badge } from "../ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Label } from "../ui/label";
import {
inspectionDeviceApi,
inspectionCatalogApi,
inspectionExecApi,
type DeviceCatalog,
type DeviceExtension,
type DeviceTierInfo,
type DeviceTier,
} from "../../infrastructure/inspection/inspectionApi";
import { toast } from "../ui/toast";
import { HostDetailsSection } from "../host-details";
interface HostInspectionSectionProps {
hostId: string;
hostLabel: string;
host: any;
keys: any[];
}
export function HostInspectionSection({ hostId, hostLabel, host, keys }: HostInspectionSectionProps) {
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
const [tiers, setTiers] = useState<DeviceTierInfo[]>([]);
const [extension, setExtension] = useState<DeviceExtension | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// 本地编辑状态
const [enabled, setEnabled] = useState(false);
const [catalogId, setCatalogId] = useState("");
const [tier, setTier] = useState<string>("");
const [customInterval, setCustomInterval] = useState<string>("");
const loadData = useCallback(async () => {
setLoading(true);
try {
const [catalogList, tierList, ext] = await Promise.all([
inspectionCatalogApi.list(),
inspectionCatalogApi.getTiers(),
inspectionDeviceApi.get(hostId),
]);
const safeCatalogs = Array.isArray(catalogList) ? catalogList : [];
const safeTiers = Array.isArray(tierList) ? tierList : [];
setCatalogs(safeCatalogs);
setTiers(safeTiers);
setExtension(ext || null);
if (ext) {
setEnabled(ext.inspection_enabled);
setCatalogId(ext.catalog_id);
setTier(ext.tier || "");
setCustomInterval(ext.inspection_interval?.toString() || "");
} else {
// 默认值
setCatalogId(safeCatalogs[0]?.id || "linux");
setTier("access");
}
} catch (e: any) {
console.error("[Inspection] 加载失败:", e);
} finally {
setLoading(false);
}
}, [hostId]);
useEffect(() => {
loadData();
}, [loadData]);
const handleSave = async () => {
if (!catalogId) {
toast.error("请选择设备类型");
return;
}
setSaving(true);
try {
const data: Partial<DeviceExtension> = {
inspection_enabled: enabled,
catalog_id: catalogId,
tier: (tier as DeviceTier) || null,
inspection_interval: customInterval ? parseInt(customInterval, 10) : null,
// 存 hostname,用于调度器匹配 vault SSH 配置
hostname: host?.hostname || null,
};
const result = await inspectionDeviceApi.upsert(hostId, data);
setExtension(result);
toast.success(enabled ? "巡检已启用" : "巡检已停用");
} catch (e: any) {
toast.error("保存失败: " + e.message);
} finally {
setSaving(false);
}
};
const handleScanNow = async () => {
try {
if (!host || !host.hostname) {
toast.error("主机配置不完整,无法巡检");
return;
}
// 构造连接配置
const hostConfig: any = {
id: host.id || hostId,
label: hostLabel,
hostname: host.hostname,
port: host.port || 22,
username: host.username,
};
// 密码
if (host.password) {
hostConfig.password = host.password;
}
// 私钥
if (host.identityFileId && keys && keys.length) {
const key = keys.find((k) => k.id === host.identityFileId);
if (key && key.privateKey) {
hostConfig.privateKeyContent = key.privateKey;
if (key.passphrase) {
hostConfig.passphrase = key.passphrase;
}
}
}
toast.info(`正在对 ${hostLabel} 执行立即巡检...`);
const result = await inspectionExecApi.runInspection(hostId, hostConfig, catalogId);
if (result?.success) {
toast.success(`巡检完成,耗时 ${(result.duration / 1000).toFixed(1)} 秒`);
if (result.alerts && result.alerts.length > 0) {
toast.warning(`触发 ${result.alerts.length} 条告警`);
}
} else {
toast.error(`巡检失败: ${result?.error || "未知错误"}`);
}
// 刷新扩展信息(更新 last_run_at)
loadData();
} catch (e: any) {
toast.error("巡检触发失败: " + e.message);
}
};
// 计算有效周期
const selectedTier = tiers.find((t) => t.key === tier);
const selectedCatalog = catalogs.find((c) => c.id === catalogId);
const effectiveInterval = customInterval
? `${customInterval} 分钟`
: selectedTier
? `${selectedTier.default_interval} 分钟(${selectedTier.label}默认)`
: "5 分钟(系统默认)";
return (
<HostDetailsSection
icon={<Activity size={16} />}
title="巡检配置"
hint={`为 ${hostLabel} 配置自动巡检`}
>
{loading ? (
<div className="text-sm text-muted-foreground">加载中...</div>
) : (
<div className="space-y-4">
{/* 启用开关 */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="text-sm font-medium">启用自动巡检</div>
<div className="text-xs text-muted-foreground">
开启后系统将按周期自动巡检该设备
</div>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
{/* 设备类型 */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">设备类型</Label>
<Select value={catalogId} onValueChange={setCatalogId}>
<SelectTrigger>
<SelectValue placeholder="请选择设备类型" />
</SelectTrigger>
<SelectContent>
{catalogs.map((c) => (
<SelectItem key={c.id} value={c.id}>
<div className="flex items-center gap-2">
<span>{c.name}</span>
<span className="text-xs text-muted-foreground">{c.vendor}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedCatalog && (
<p className="text-xs text-muted-foreground">{selectedCatalog.description}</p>
)}
</div>
{/* 设备分级 */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">设备分级</Label>
<div className="flex gap-2">
{tiers.map((t) => (
<button
key={t.key}
onClick={() => setTier(tier === t.key ? "" : t.key)}
className={`flex-1 px-3 py-2 rounded-md border text-sm transition-colors ${
tier === t.key
? "border-primary bg-primary/10 text-primary"
: "border-border hover:border-primary/50"
}`}
>
<div className="font-medium">{t.label}</div>
<div className="text-xs text-muted-foreground">
默认 {t.default_interval} 分钟
</div>
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
分级决定默认巡检周期,也可单独设置
</p>
</div>
{/* 自定义周期 */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
自定义巡检周期(分钟,留空则使用默认)
</Label>
<Input
type="number"
min={1}
placeholder="留空使用默认值"
value={customInterval}
onChange={(e) => setCustomInterval(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
有效周期:<span className="font-medium">{effectiveInterval}</span>
</p>
</div>
{/* 上次巡检信息 */}
{extension?.last_scan_at && (
<div className="flex items-center gap-2 p-3 bg-secondary/30 rounded-md text-sm">
<Clock size={14} className="text-muted-foreground" />
<div>
<span className="text-muted-foreground">上次巡检:</span>
<span className="font-medium">
{new Date(extension.last_scan_at).toLocaleString()}
</span>
<Badge
variant={extension.last_scan_status === "success" ? "default" : "destructive"}
className="ml-2"
>
{extension.last_scan_status === "success"
? "成功"
: extension.last_scan_status === "timeout"
? "超时"
: "失败"}
</Badge>
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex gap-2 pt-2">
<Button onClick={handleSave} disabled={saving} className="flex-1">
{saving ? "保存中..." : "保存配置"}
</Button>
<Button
variant="outline"
onClick={handleScanNow}
disabled={!enabled}
className="flex-1"
>
<Activity size={14} className="mr-2" />
立即巡检
</Button>
</div>
</div>
)}
</HostDetailsSection>
);
}

View File

@@ -0,0 +1,847 @@
import {
Activity,
CheckCircle2,
XCircle,
Clock,
RefreshCw,
Play,
Search,
Server,
ListIcon,
AlertTriangle,
Settings,
ChevronDown,
ChevronRight,
} from "lucide-react";
import React, { useEffect, useState, useCallback, useMemo } from "react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Badge } from "../ui/badge";
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../ui/dialog";
import {
inspectionDeviceApi,
inspectionCatalogApi,
inspectionResultApi,
inspectionExecApi,
inspectionAlertApi,
type DeviceExtension,
type DeviceCatalog,
type InspectionResult,
type AlertStats,
} from "../../infrastructure/inspection/inspectionApi";
import { useVaultState } from "../../application/state/useVaultState";
import { toast } from "../ui/toast";
const TIER_LABELS: Record<string, string> = {
core: "核心级",
aggregation: "重要级",
access: "一般",
};
interface DeviceRow {
hostId: string;
hostName: string;
hostAddress: string;
hostPlatform: string;
catalogId: string;
catalogName: string;
tier: string;
enabled: boolean;
interval: number;
intervalLabel: string;
lastStatus: "success" | "failed" | "never";
lastTime: number | null;
lastResult?: InspectionResult;
}
export function InspectionCenterPage() {
const { hosts, keys } = useVaultState();
const [extensions, setExtensions] = useState<Record<string, DeviceExtension>>({});
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
const [latestResults, setLatestResults] = useState<Record<string, InspectionResult>>({});
const [alertStats, setAlertStats] = useState<AlertStats | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// 筛选
const [searchText, setSearchText] = useState("");
const [filterCatalog, setFilterCatalog] = useState<string>("all");
const [filterTier, setFilterTier] = useState<string>("all");
const [filterStatus, setFilterStatus] = useState<string>("all");
// 详情抽屉
const [detailHostId, setDetailHostId] = useState<string | null>(null);
const [detailResults, setDetailResults] = useState<InspectionResult[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
// 配置弹窗
const [configOpen, setConfigOpen] = useState(false);
const [configHostId, setConfigHostId] = useState<string | null>(null);
const [configSaving, setConfigSaving] = useState(false);
const [configForm, setConfigForm] = useState({
inspection_enabled: true,
catalog_id: "",
tier: "",
inspection_interval: "" as string,
});
const loadData = useCallback(async () => {
setRefreshing(true);
try {
const [extMap, catList, results, stats] = await Promise.all([
inspectionDeviceApi.getAll(),
inspectionCatalogApi.list(),
inspectionResultApi.getLatestAll(),
inspectionAlertApi.getStats(),
]);
setExtensions(extMap || {});
setCatalogs(Array.isArray(catList) ? catList : []);
if (Array.isArray(results)) {
const map: Record<string, InspectionResult> = {};
results.forEach((r) => {
if (r.device_id) map[r.device_id] = r;
});
setLatestResults(map);
}
setAlertStats(stats);
} catch (e: any) {
console.error("[InspectionCenter] 加载失败:", e);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// 构建设备行数据
const deviceRows = useMemo<DeviceRow[]>(() => {
return (hosts || [])
.filter((h) => h && h.id)
.map((host) => {
const ext = extensions[host.id];
const catalog = catalogs.find((c) => c.id === ext?.catalog_id);
const lastResult = latestResults[host.id];
const lastStatus: "success" | "failed" | "never" = lastResult
? lastResult.status === "success"
? "success"
: "failed"
: "never";
// 计算有效周期
let interval = 5;
let intervalLabel = "5 分钟(系统默认)";
if (ext?.inspection_interval) {
interval = ext.inspection_interval;
intervalLabel = `${interval} 分钟(自定义)`;
} else if (ext?.tier) {
const tierDefault = ext.tier === "core" ? 5 : ext.tier === "aggregation" ? 10 : 15;
interval = tierDefault;
intervalLabel = `${tierDefault} 分钟(${TIER_LABELS[ext.tier] || ext.tier}默认)`;
}
return {
hostId: host.id,
hostName: host.label || host.hostname || host.address || "未命名",
hostAddress: host.address || "",
hostPlatform: host.platform || "ssh",
catalogId: ext?.catalog_id || "",
catalogName: catalog?.name || ext?.catalog_id || "未配置",
tier: ext?.tier || "",
enabled: ext?.inspection_enabled || false,
interval,
intervalLabel,
lastStatus,
lastTime: lastResult?.timestamp || null,
lastResult,
};
});
}, [hosts, extensions, catalogs, latestResults]);
// 筛选
const filteredRows = useMemo(() => {
return deviceRows.filter((row) => {
if (searchText) {
const s = searchText.toLowerCase();
if (
!row.hostName.toLowerCase().includes(s) &&
!row.hostAddress.toLowerCase().includes(s) &&
!row.catalogName.toLowerCase().includes(s)
) {
return false;
}
}
if (filterCatalog !== "all" && row.catalogId !== filterCatalog) return false;
if (filterTier !== "all" && row.tier !== filterTier) return false;
if (filterStatus === "enabled" && !row.enabled) return false;
if (filterStatus === "disabled" && row.enabled) return false;
return true;
});
}, [deviceRows, searchText, filterCatalog, filterTier, filterStatus]);
const stats = useMemo(() => {
const total = deviceRows.length;
const enabled = deviceRows.filter((r) => r.enabled).length;
const success = deviceRows.filter((r) => r.lastStatus === "success").length;
const failed = deviceRows.filter((r) => r.lastStatus === "failed").length;
return { total, enabled, success, failed };
}, [deviceRows]);
const handleScanNow = async (hostId: string, hostName: string) => {
try {
const host = hosts.find((h: any) => h.id === hostId);
if (!host) {
toast.error("未找到主机配置");
return;
}
// 构造连接配置
const hostConfig: any = {
id: host.id,
label: host.label,
hostname: host.hostname,
port: host.port || 22,
username: host.username,
};
// 密码
if (host.password) {
hostConfig.password = host.password;
}
// 私钥
if (host.identityFileId) {
const key = keys.find((k: any) => k.id === host.identityFileId);
if (key && key.privateKey) {
hostConfig.privateKeyContent = key.privateKey;
if (key.passphrase) {
hostConfig.passphrase = key.passphrase;
}
}
}
const catalogId = extensions[hostId]?.catalog_id || "linux_server";
toast.info(`正在对 ${hostName} 执行立即巡检...`);
const result = await inspectionExecApi.runInspection(hostId, hostConfig, catalogId);
if (result?.success) {
toast.success(`巡检完成,耗时 ${(result.duration / 1000).toFixed(1)} 秒`);
if (result.alerts && result.alerts.length > 0) {
toast.warning(`触发 ${result.alerts.length} 条告警`);
}
} else {
toast.error(`巡检失败: ${result?.error || "未知错误"}`);
}
setTimeout(() => loadData(), 1000);
} catch (e: any) {
toast.error("巡检触发失败: " + e.message);
}
};
const openDetail = async (hostId: string) => {
setDetailHostId(hostId);
setDetailLoading(true);
try {
const results = await inspectionResultApi.getLatestByDevice(hostId, 20);
setDetailResults(Array.isArray(results) ? results : []);
} catch (e: any) {
console.error("[InspectionCenter] 加载详情失败:", e);
setDetailResults([]);
} finally {
setDetailLoading(false);
}
};
// 打开配置弹窗
const openConfig = (row: DeviceRow) => {
setConfigHostId(row.hostId);
setConfigForm({
inspection_enabled: row.enabled,
catalog_id: row.catalogId || "linux",
tier: row.tier || "access",
inspection_interval: extensions[row.hostId]?.inspection_interval?.toString() || "",
});
setConfigOpen(true);
};
// 保存配置
const saveConfig = async () => {
if (!configHostId) return;
setConfigSaving(true);
try {
const data: any = {
inspection_enabled: configForm.inspection_enabled,
catalog_id: configForm.catalog_id,
tier: configForm.tier || null,
inspection_interval: configForm.inspection_interval
? parseInt(configForm.inspection_interval, 10)
: null,
};
await inspectionDeviceApi.upsert(configHostId, data);
toast.success("配置已保存");
setConfigOpen(false);
loadData();
} catch (e: any) {
toast.error("保存失败: " + e.message);
} finally {
setConfigSaving(false);
}
};
const formatTime = (ts: number | null) => {
if (!ts) return "-";
const d = new Date(ts);
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d.getSeconds().toString().padStart(2, "0")}`;
};
const formatDuration = (ms: number | undefined) => {
if (!ms) return "-";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
const getStatusBadge = (status: "success" | "failed" | "never") => {
switch (status) {
case "success":
return (
<Badge variant="outline" className="text-emerald-500 bg-emerald-500/10 border-emerald-500/30">
<CheckCircle2 size={11} className="mr-1" /> 成功
</Badge>
);
case "failed":
return (
<Badge variant="outline" className="text-red-500 bg-red-500/10 border-red-500/30">
<XCircle size={11} className="mr-1" /> 失败
</Badge>
);
default:
return (
<Badge variant="outline" className="text-muted-foreground">
<Clock size={11} className="mr-1" /> 未执行
</Badge>
);
}
};
return (
<div className="h-full flex flex-col">
{/* 顶部标题栏 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border/60">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
<Activity size={18} className="text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold">巡检中心</h2>
<p className="text-sm text-muted-foreground">
所有主机巡检状态概览与执行记录
</p>
</div>
</div>
<Button variant="outline" onClick={loadData} disabled={refreshing}>
<RefreshCw size={14} className={`mr-2 ${refreshing ? "animate-spin" : ""}`} />
刷新
</Button>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-3 px-6 py-4">
<div className="p-4 rounded-lg border border-border/60 bg-card">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<Server size={14} />
设备总数
</div>
<div className="text-2xl font-semibold">{stats.total}</div>
<div className="text-xs text-muted-foreground mt-1">
已启用巡检: {stats.enabled}
</div>
</div>
<div className="p-4 rounded-lg border border-border/60 bg-card">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<CheckCircle2 size={14} className="text-emerald-500" />
巡检成功
</div>
<div className="text-2xl font-semibold text-emerald-500">{stats.success}</div>
<div className="text-xs text-muted-foreground mt-1">
上次巡检结果
</div>
</div>
<div className="p-4 rounded-lg border border-border/60 bg-card">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<XCircle size={14} className="text-red-500" />
巡检失败
</div>
<div className="text-2xl font-semibold text-red-500">{stats.failed}</div>
<div className="text-xs text-muted-foreground mt-1">
需关注设备
</div>
</div>
<div className="p-4 rounded-lg border border-border/60 bg-card">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<AlertTriangle size={14} className="text-orange-500" />
活跃告警
</div>
<div className="text-2xl font-semibold text-orange-500">
{(alertStats?.critical || 0) + (alertStats?.warning || 0) + (alertStats?.info || 0)}
</div>
<div className="text-xs text-muted-foreground mt-1">
严重 {alertStats?.critical || 0} / 警告 {alertStats?.warning || 0}
</div>
</div>
</div>
{/* 筛选栏 */}
<div className="flex items-center gap-3 px-6 py-3 border-b border-border/40 bg-muted/20">
<div className="relative flex-1 max-w-xs">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索主机名、IP、设备类型..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-8 h-8 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">类型:</span>
<Select value={filterCatalog} onValueChange={setFilterCatalog}>
<SelectTrigger className="w-32 h-8 text-xs">
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部类型</SelectItem>
{catalogs.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">分级:</span>
<Select value={filterTier} onValueChange={setFilterTier}>
<SelectTrigger className="w-24 h-8 text-xs">
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部分级</SelectItem>
<SelectItem value="core">核心级</SelectItem>
<SelectItem value="aggregation">重要级</SelectItem>
<SelectItem value="access">一般</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">状态:</span>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-24 h-8 text-xs">
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="enabled">已启用</SelectItem>
<SelectItem value="disabled">已停用</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 设备列表 */}
<div className="flex-1 overflow-auto px-6 py-4">
{loading ? (
<div className="text-center text-muted-foreground py-12">加载中...</div>
) : filteredRows.length === 0 ? (
<div className="text-center text-muted-foreground py-16">
<Server size={40} className="mx-auto mb-3 opacity-40" />
<p>暂无符合条件的设备</p>
<p className="text-sm mt-1">请在主机管理中添加设备并配置巡检</p>
</div>
) : (
<div className="space-y-2">
{/* 表头 */}
<div className="grid grid-cols-12 gap-3 px-4 py-2 text-xs text-muted-foreground font-medium">
<div className="col-span-3">设备</div>
<div className="col-span-2">设备类型</div>
<div className="col-span-1">分级</div>
<div className="col-span-2">巡检周期</div>
<div className="col-span-1">状态</div>
<div className="col-span-1">上次时间</div>
<div className="col-span-2 text-right">操作</div>
</div>
{filteredRows.map((row) => (
<div
key={row.hostId}
className="grid grid-cols-12 gap-3 items-center p-3 border border-border/60 rounded-lg hover:bg-accent/30 transition-colors cursor-pointer"
onClick={() => openDetail(row.hostId)}
>
<div className="col-span-3 min-w-0">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded bg-primary/10 flex items-center justify-center shrink-0">
<Server size={13} className="text-primary" />
</div>
<div className="min-w-0">
<div className="font-medium text-sm truncate">{row.hostName}</div>
<div className="text-xs text-muted-foreground truncate">{row.hostAddress}</div>
</div>
</div>
</div>
<div className="col-span-2 text-sm">{row.catalogName}</div>
<div className="col-span-1">
{row.tier ? (
<Badge variant="secondary" className="text-xs">
{TIER_LABELS[row.tier] || row.tier}
</Badge>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</div>
<div className="col-span-2 text-xs text-muted-foreground">
{row.enabled ? row.intervalLabel : "未启用"}
</div>
<div className="col-span-1">{getStatusBadge(row.lastStatus)}</div>
<div className="col-span-1 text-xs text-muted-foreground">
{formatTime(row.lastTime)}
</div>
<div className="col-span-2 flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
openConfig(row);
}}
title="巡检配置"
>
<Settings size={13} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
openDetail(row.hostId);
}}
title="巡检记录"
>
<ListIcon size={13} />
</Button>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
disabled={!row.enabled}
onClick={(e) => {
e.stopPropagation();
handleScanNow(row.hostId, row.hostName);
}}
title="立即巡检"
>
<Play size={13} />
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* 巡检记录详情弹窗 */}
<Dialog
open={!!detailHostId}
onOpenChange={(open) => !open && setDetailHostId(null)}
>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Activity size={16} />
巡检记录
<span className="text-sm font-normal text-muted-foreground">
({deviceRows.find((r) => r.hostId === detailHostId)?.hostName || ""})
</span>
</DialogTitle>
</DialogHeader>
{detailLoading ? (
<div className="text-center text-muted-foreground py-12">加载中...</div>
) : detailResults.length === 0 ? (
<div className="text-center text-muted-foreground py-12">
<Clock size={32} className="mx-auto mb-2 opacity-40" />
<p>暂无巡检记录</p>
</div>
) : (
<div className="space-y-3 overflow-y-auto pr-2">
{detailResults.map((result, idx) => (
<div
key={idx}
className={`p-3 rounded-lg border ${
result.status === "success"
? "border-emerald-500/30 bg-emerald-500/5"
: "border-red-500/30 bg-red-500/5"
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{result.status === "success" ? (
<CheckCircle2 size={14} className="text-emerald-500" />
) : (
<XCircle size={14} className="text-red-500" />
)}
<span className="text-sm font-medium">
{result.status === "success" ? "巡检成功" : "巡检失败"}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{formatTime(result.timestamp)}</span>
<span>耗时 {formatDuration(result.duration_ms)}</span>
</div>
</div>
{result.metrics && Object.keys(result.metrics).length > 0 && (
<>
{/* 格式化指标(精简展示) */}
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
{Object.entries(result.metrics)
.filter(([key]) => !["load_avg", "process_count", "uptime", "load_1m", "load_5m", "load_15m"].includes(key))
.map(([key, val]) => (
<div
key={key}
className="flex items-center justify-between px-2 py-1 bg-background/50 rounded"
>
<span className="text-muted-foreground">{formatMetricLabel(key)}</span>
<span className="font-mono">{formatMetricValue(key, val)}</span>
</div>
))}
</div>
{/* 原始数据(可折叠) */}
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors group">
<ChevronRight size={12} className="data-[state=open]:hidden" />
<ChevronDown size={12} className="hidden data-[state=open]:block" />
<span>查看完整数据</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<pre className="text-xs bg-background/80 p-2 rounded border border-border/50 overflow-auto max-h-40 font-mono">
{JSON.stringify(result.metrics, null, 2)}
</pre>
</CollapsibleContent>
</Collapsible>
</>
)}
{result.error_message && (
<div className="mt-2 p-2 bg-red-500/10 rounded text-xs text-red-500 font-mono">
{result.error_message}
</div>
)}
</div>
))}
</div>
)}
</DialogContent>
</Dialog>
{/* 配置弹窗 */}
<Dialog open={configOpen} onOpenChange={setConfigOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings size={16} />
巡检配置
<span className="text-sm font-normal text-muted-foreground">
({deviceRows.find((r) => r.hostId === configHostId)?.hostName || ""})
</span>
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* 启用开关 */}
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">启用自动巡检</Label>
<p className="text-xs text-muted-foreground mt-0.5">
开启后将按周期自动执行巡检
</p>
</div>
<Switch
checked={configForm.inspection_enabled}
onCheckedChange={(v) =>
setConfigForm({ ...configForm, inspection_enabled: v })
}
/>
</div>
{/* 设备类型 */}
<div className="space-y-2">
<Label>设备类型</Label>
<Select
value={configForm.catalog_id}
onValueChange={(v) => setConfigForm({ ...configForm, catalog_id: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{catalogs.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 设备分级 */}
<div className="space-y-2">
<Label>设备分级</Label>
<Select
value={configForm.tier || ""}
onValueChange={(v) => setConfigForm({ ...configForm, tier: v })}
>
<SelectTrigger>
<SelectValue placeholder="请选择分级" />
</SelectTrigger>
<SelectContent>
<SelectItem value="core">核心级(默认 5 分钟)</SelectItem>
<SelectItem value="aggregation">重要级(默认 10 分钟)</SelectItem>
<SelectItem value="access">一般(默认 15 分钟)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 巡检周期 */}
<div className="space-y-2">
<Label>巡检周期(分钟)</Label>
<Input
type="number"
min={1}
placeholder="留空则使用分级默认周期"
value={configForm.inspection_interval}
onChange={(e) =>
setConfigForm({ ...configForm, inspection_interval: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
优先级:自定义周期 → 分级默认 → 系统默认(5 分钟)
</p>
<div className="p-2 bg-muted/30 rounded text-xs text-muted-foreground">
<span className="font-medium text-foreground">有效周期:</span>
{configForm.inspection_interval
? `${configForm.inspection_interval} 分钟(自定义)`
: configForm.tier
? `${configForm.tier === "core" ? 5 : configForm.tier === "aggregation" ? 10 : 15} 分钟(${TIER_LABELS[configForm.tier] || configForm.tier}默认)`
: "5 分钟(系统默认)"}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setConfigOpen(false)}>
取消
</Button>
<Button onClick={saveConfig} disabled={configSaving}>
{configSaving ? "保存中..." : "保存配置"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ---------- 工具函数 ----------
function formatMetricLabel(key: string): string {
const map: Record<string, string> = {
cpu_usage: "CPU 使用率",
memory_usage: "内存使用率",
disk_usage: "磁盘使用率",
load_1m: "负载 (1分钟)",
load_5m: "负载 (5分钟)",
load_15m: "负载 (15分钟)",
online: "在线状态",
temperature: "设备温度",
power_failure_count: "电源故障数",
fan_failure_count: "风扇故障数",
port_down_count: "端口 Down 数",
port_total_count: "端口总数",
port_status: "端口状态",
power_status: "电源状态",
fan_status: "风扇状态",
};
return map[key] || key;
}
function formatMetricValue(key: string, val: any): string {
if (val === null || val === undefined) return "-";
if (key === "online") return val ? "在线" : "离线";
if (key === "cpu_usage" || key === "memory_usage") {
return `${val}%`;
}
if (key === "temperature") return `${val}°C`;
// disk_usage 是数组 { filesystem, usage_percent, mount }
if (key === "disk_usage") {
if (Array.isArray(val) && val.length > 0) {
return val.map((d: any) => `${d.mount || d.filesystem}: ${d.usage_percent}%`).join(" | ");
}
return "-";
}
// 网络设备: port_status { up_count, down_count, ports: [{name, phy}] }
if (key === "port_status") {
if (typeof val !== "object") return String(val);
const up = val.up_count ?? 0;
const down = val.down_count ?? 0;
const total = up + down;
// 简写端口名: GigabitEthernet0/0/1 → GE0/0/1, TenGigE → XGE 等
const shortName = (n: string) => n
.replace(/GigabitEthernet/g, "GE")
.replace(/TenGigabitEthernet/g, "XGE")
.replace(/TwentyFiveGigE/g, "25GE")
.replace(/FortyGigE/g, "40GE")
.replace(/HundredGigE/g, "100GE")
.replace(/Ethernet/g, "Eth");
if (Array.isArray(val.ports) && val.ports.length > 0) {
const upList = val.ports.filter((p: any) => p.phy === "up").map((p: any) => shortName(p.name));
const downList = val.ports.filter((p: any) => p.phy === "down").map((p: any) => shortName(p.name));
const parts: string[] = [`${up}/${total} UP`];
if (upList.length > 0) parts.push(`↑${upList.join(",")}`);
if (downList.length > 0 && downList.length <= 12) parts.push(`↓${downList.join(",")}`);
else if (downList.length > 12) parts.push(`↓${downList.length}个down`);
return parts.join(" ");
}
return `${up}/${total} UP`;
}
// 网络设备: power_status / fan_status { normal_count, abnormal_count }
if (key === "power_status" || key === "fan_status") {
if (typeof val !== "object") return String(val);
const normal = val.normal_count ?? 0;
const abnormal = val.abnormal_count ?? 0;
if (abnormal > 0) return `⚠ ${abnormal}异常 / ${normal}正常`;
return `${normal}个正常`;
}
return String(val);
}
export default InspectionCenterPage;

View File

@@ -0,0 +1,38 @@
import React from "react";
/**
* 简易 ErrorBoundary,用于包裹巡检相关组件,
* 防止组件内部错误导致整个 AsidePanel 空白。
*/
export class InspectionErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback?: React.ReactNode },
{ hasError: boolean; error: Error | null }
> {
constructor(props: { children: React.ReactNode; fallback?: React.ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("[InspectionErrorBoundary] 组件渲染错误:", error, info);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="p-3 rounded-md border border-destructive/30 bg-destructive/5 text-sm">
<div className="font-medium text-destructive mb-1">巡检配置加载失败</div>
<div className="text-xs text-muted-foreground font-mono">
{this.state.error?.message || "未知错误"}
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,456 @@
import React, { useState, useEffect } from 'react';
import {
X,
Server,
Cpu,
HardDrive,
Activity,
AlertTriangle,
CheckCircle2,
XCircle,
Clock,
Loader2,
ChevronRight,
} from 'lucide-react';
// ============ 类型定义 ============
export type PanelType =
| 'deviceList' // 设备列表(按状态筛选)
| 'alertList' // 告警列表(按级别筛选)
| 'deviceDetail' // 单设备详情
| 'taskList'; // 巡检任务列表(按状态筛选)
export interface DetailPanelProps {
open: boolean;
onClose: () => void;
panelType: PanelType;
panelTitle: string;
// 设备列表
deviceList?: Array<{
id: string;
name: string;
ip?: string;
type: string;
status: 'online' | 'offline' | 'warning' | 'critical';
cpu?: number;
memory?: number;
lastUpdate?: number;
alertCount?: number;
}>;
// 告警列表
alertList?: Array<{
id: string;
deviceId: string;
deviceName: string;
level: 'critical' | 'warning' | 'info';
message: string;
time: string;
metric?: string;
metricValue?: string;
threshold?: string;
}>;
// 设备详情
deviceDetail?: {
id: string;
name: string;
ip?: string;
type: string;
status: 'online' | 'offline' | 'warning' | 'critical';
cpu?: number;
memory?: number;
disk?: Array<{ mount: string; usage: number }>;
lastUpdate?: number;
alerts?: Array<{
id: string;
level: string;
message: string;
time: string;
}>;
};
// 任务列表
taskList?: Array<{
deviceId: string;
deviceName: string;
ip: string;
status: 'running' | 'success' | 'failed' | 'pending';
lastScanAt?: number;
durationMs?: number;
errorMessage?: string;
interval?: number;
}>;
// 点击设备回调
onDeviceClick?: (deviceId: string) => void;
// 点击告警回调
onAlertClick?: (alertId: string) => void;
}
// ============ 辅助函数 ============
const statusColors: Record<string, { bg: string; text: string; dot: string; label: string }> = {
online: { bg: 'bg-emerald-500/15', text: 'text-emerald-400', dot: 'bg-emerald-500', label: '在线' },
offline: { bg: 'bg-slate-500/15', text: 'text-slate-400', dot: 'bg-slate-500', label: '离线' },
warning: { bg: 'bg-amber-500/15', text: 'text-amber-400', dot: 'bg-amber-500', label: '警告' },
critical: { bg: 'bg-red-500/15', text: 'text-red-400', dot: 'bg-red-500', label: '严重' },
running: { bg: 'bg-sky-500/15', text: 'text-sky-400', dot: 'bg-sky-500', label: '执行中' },
success: { bg: 'bg-emerald-500/15', text: 'text-emerald-400', dot: 'bg-emerald-500', label: '成功' },
failed: { bg: 'bg-red-500/15', text: 'text-red-400', dot: 'bg-red-500', label: '失败' },
pending: { bg: 'bg-slate-500/15', text: 'text-slate-400', dot: 'bg-slate-500', label: '待执行' },
};
const levelColors: Record<string, { bg: string; text: string; dot: string; label: string }> = {
critical: { bg: 'bg-red-500/15', text: 'text-red-400', dot: 'bg-red-500', label: '严重' },
major: { bg: 'bg-orange-500/15', text: 'text-orange-400', dot: 'bg-orange-500', label: '重要' },
warning: { bg: 'bg-amber-500/15', text: 'text-amber-400', dot: 'bg-amber-500', label: '警告' },
minor: { bg: 'bg-yellow-500/15', text: 'text-yellow-400', dot: 'bg-yellow-500', label: '次要' },
info: { bg: 'bg-sky-500/15', text: 'text-sky-400', dot: 'bg-sky-500', label: '提示' },
};
function formatRelativeTime(ts?: number): string {
if (!ts) return '-';
const diff = Date.now() - ts;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
return `${Math.floor(diff / 86400000)} 天前`;
}
// ============ 组件 ============
export default function MonitoringDetailPanel({
open,
onClose,
panelType,
panelTitle,
deviceList = [],
alertList = [],
deviceDetail,
taskList = [],
onDeviceClick,
}: DetailPanelProps) {
// ============ 设备列表渲染 ============
const renderDeviceList = () => (
<div className="space-y-2">
{deviceList.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">暂无数据</div>
)}
{deviceList.map((d) => {
const sc = statusColors[d.status] || statusColors.online;
return (
<div
key={d.id}
className="p-3 rounded-lg border cursor-pointer transition-all hover:border-cyan-500/40 hover:bg-cyan-500/5"
style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}
onClick={() => onDeviceClick?.(d.id)}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2 min-w-0">
<div className={`w-2 h-2 rounded-full ${sc.dot} flex-shrink-0`} />
<span className="text-sm font-medium text-foreground truncate">{d.name}</span>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{d.ip && <span>{d.ip}</span>}
<span>{d.type}</span>
<span className={`${sc.text}`}>{sc.label}</span>
</div>
{(d.cpu !== undefined || d.memory !== undefined) && (
<div className="flex items-center gap-4 mt-2 text-xs">
{d.cpu !== undefined && (
<div className="flex items-center gap-1">
<Cpu className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">CPU</span>
<span className={d.cpu > 80 ? 'text-red-400' : d.cpu > 60 ? 'text-amber-400' : 'text-emerald-400'}>
{d.cpu}%
</span>
</div>
)}
{d.memory !== undefined && (
<div className="flex items-center gap-1">
<HardDrive className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">内存</span>
<span className={d.memory > 85 ? 'text-red-400' : d.memory > 70 ? 'text-amber-400' : 'text-emerald-400'}>
{d.memory}%
</span>
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
// ============ 告警列表渲染 ============
const renderAlertList = () => (
<div className="space-y-2">
{alertList.length === 0 && (
<div className="text-center py-8 text-emerald-400 text-sm">暂无告警</div>
)}
{alertList.map((a) => {
const lc = levelColors[a.level] || levelColors.info;
return (
<div
key={a.id}
className="p-3 rounded-lg border transition-all"
style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}
>
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0">
<div className={`w-2 h-2 rounded-full ${lc.dot} flex-shrink-0 mt-0.5`} />
<span className={`text-xs font-medium ${lc.text} flex-shrink-0`}>{lc.label}</span>
</div>
<span className="text-xs text-muted-foreground flex-shrink-0">{a.time}</span>
</div>
<div className="text-sm text-foreground mb-1">{a.message}</div>
<div className="text-xs text-muted-foreground">
设备:<span className="text-cyan-400">{a.deviceName}</span>
{a.metric && a.metricValue && (
<span className="ml-3">
{a.metric}: {a.metricValue}
{a.threshold && <span className="text-muted-foreground"> (阈值 {a.threshold})</span>}
</span>
)}
</div>
</div>
);
})}
</div>
);
// ============ 设备详情渲染 ============
const renderDeviceDetail = () => {
if (!deviceDetail) return null;
const sc = statusColors[deviceDetail.status] || statusColors.online;
return (
<div className="space-y-4">
{/* 基本信息 */}
<div className="p-3 rounded-lg border" style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}>
<div className="flex items-center gap-2 mb-2">
<Server className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-medium">基本信息</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<div className="text-muted-foreground mb-0.5">设备名称</div>
<div className="text-foreground font-medium">{deviceDetail.name}</div>
</div>
<div>
<div className="text-muted-foreground mb-0.5">设备类型</div>
<div className="text-foreground">{deviceDetail.type}</div>
</div>
<div>
<div className="text-muted-foreground mb-0.5">IP 地址</div>
<div className="text-foreground font-mono">{deviceDetail.ip || '-'}</div>
</div>
<div>
<div className="text-muted-foreground mb-0.5">运行状态</div>
<div className={`${sc.text} font-medium`}>{sc.label}</div>
</div>
</div>
</div>
{/* 资源使用 */}
<div className="p-3 rounded-lg border" style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}>
<div className="flex items-center gap-2 mb-3">
<Activity className="w-4 h-4 text-cyan-400" />
<span className="text-sm font-medium">资源使用</span>
</div>
<div className="space-y-3">
{/* CPU */}
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-muted-foreground">CPU 使用率</span>
<span className={deviceDetail.cpu && deviceDetail.cpu > 80 ? 'text-red-400' : 'text-foreground'}>
{deviceDetail.cpu ?? '-'}%
</span>
</div>
<div className="h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${deviceDetail.cpu ?? 0}%`,
background: deviceDetail.cpu && deviceDetail.cpu > 80 ? '#f87171'
: deviceDetail.cpu && deviceDetail.cpu > 60 ? '#fbbf24' : '#34d399',
}}
/>
</div>
</div>
{/* 内存 */}
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-muted-foreground">内存使用率</span>
<span className={deviceDetail.memory && deviceDetail.memory > 85 ? 'text-red-400' : 'text-foreground'}>
{deviceDetail.memory ?? '-'}%
</span>
</div>
<div className="h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${deviceDetail.memory ?? 0}%`,
background: deviceDetail.memory && deviceDetail.memory > 85 ? '#f87171'
: deviceDetail.memory && deviceDetail.memory > 70 ? '#fbbf24' : '#34d399',
}}
/>
</div>
</div>
{/* 磁盘 */}
{deviceDetail.disk && deviceDetail.disk.length > 0 && (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">磁盘使用率</div>
{deviceDetail.disk.map((d, i) => (
<div key={i}>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-muted-foreground font-mono">{d.mount}</span>
<span className={d.usage > 90 ? 'text-red-400' : d.usage > 80 ? 'text-amber-400' : 'text-foreground'}>
{d.usage}%
</span>
</div>
<div className="h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${d.usage}%`,
background: d.usage > 90 ? '#f87171' : d.usage > 80 ? '#fbbf24' : '#34d399',
}}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 更新时间 */}
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<Clock className="w-3 h-3" />
最后更新:{formatRelativeTime(deviceDetail.lastUpdate)}
</div>
{/* 告警历史 */}
{deviceDetail.alerts && deviceDetail.alerts.length > 0 && (
<div className="p-3 rounded-lg border" style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}>
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-amber-400" />
<span className="text-sm font-medium">告警记录</span>
</div>
<div className="space-y-2">
{deviceDetail.alerts.map((a) => {
const lc = levelColors[a.level] || levelColors.info;
return (
<div key={a.id} className="flex items-start gap-2 text-xs">
<div className={`w-1.5 h-1.5 rounded-full ${lc.dot} flex-shrink-0 mt-1`} />
<div className="flex-1 min-w-0">
<div className="text-foreground">{a.message}</div>
<div className="text-muted-foreground mt-0.5">{a.time}</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
};
// ============ 任务列表渲染 ============
const renderTaskList = () => (
<div className="space-y-2">
{taskList.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">暂无数据</div>
)}
{taskList.map((t) => {
const sc = statusColors[t.status] || statusColors.pending;
const durationStr = t.durationMs ? `${(t.durationMs / 1000).toFixed(1)}s` : null;
const intervalStr = t.interval ? `${t.interval}秒` : null;
return (
<div
key={t.deviceId}
className="p-3 rounded-lg border transition-all"
style={{ background: 'rgba(15,23,42,0.6)', borderColor: 'rgba(148,163,184,0.15)' }}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2 min-w-0">
{t.status === 'running' && <Loader2 className="w-3.5 h-3.5 text-sky-400 animate-spin flex-shrink-0" />}
{t.status === 'success' && <CheckCircle2 className="w-3.5 h-3.5 text-emerald-400 flex-shrink-0" />}
{t.status === 'failed' && <XCircle className="w-3.5 h-3.5 text-red-400 flex-shrink-0" />}
{t.status === 'pending' && <Clock className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />}
<span className="text-sm font-medium text-foreground truncate">{t.deviceName}</span>
</div>
<span className={`text-xs font-medium ${sc.text} flex-shrink-0`}>{sc.label}</span>
</div>
<div className="text-xs text-muted-foreground font-mono mb-1">{t.ip}</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
{t.lastScanAt && <span>上次 {formatRelativeTime(t.lastScanAt)}</span>}
{durationStr && <span>耗时 {durationStr}</span>}
{intervalStr && <span>间隔 {intervalStr}</span>}
</div>
{t.errorMessage && (
<div className="mt-2 text-xs text-red-400 bg-red-500/10 p-2 rounded">
{t.errorMessage}
</div>
)}
</div>
);
})}
</div>
);
// ============ 主渲染 ============
return (
<>
{/* 遮罩 */}
<div
className={`absolute inset-0 bg-black/50 z-40 transition-opacity ${
open ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
onClick={onClose}
/>
{/* 面板 */}
<div
className={`absolute right-0 top-0 bottom-0 z-50 transition-transform duration-300 ease-out flex flex-col ${
open ? 'translate-x-0' : 'translate-x-full'
}`}
style={{
width: '420px',
background: '#0f172a',
borderLeft: '1px solid rgba(148,163,184,0.2)',
}}
>
{/* 标题栏 */}
<div
className="flex items-center justify-between px-4 py-3 flex-shrink-0"
style={{ borderBottom: '1px solid rgba(148,163,184,0.15)' }}
>
<div className="flex items-center gap-2">
<div
className="w-1 h-5 rounded-full"
style={{ background: 'linear-gradient(180deg, #06b6d4, #3b82f6)' }}
/>
<span className="text-sm font-medium text-foreground">{panelTitle}</span>
</div>
<button
onClick={onClose}
className="p-1 rounded hover:bg-slate-700/50 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto p-3">
{panelType === 'deviceList' && renderDeviceList()}
{panelType === 'alertList' && renderAlertList()}
{panelType === 'deviceDetail' && renderDeviceDetail()}
{panelType === 'taskList' && renderTaskList()}
</div>
</div>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,637 @@
/**
* 精细 3D 设备模型工厂
* 参考 ITYUNWEI modelFactory.ts 设计
*/
import * as THREE from 'three';
export type DeviceStatus = 'normal' | 'warning' | 'critical' | 'offline';
export type DeviceType = 'server' | 'switch' | 'router' | 'firewall' | 'storage' | 'workstation';
const statusColors: Record<DeviceStatus, number> = {
normal: 0x10b981,
warning: 0xf59e0b,
critical: 0xef4444,
offline: 0x6b7280,
};
// 不同设备类型的默认发光颜色
const typeColors: Record<DeviceType, number> = {
server: 0x60a5fa, // 蓝色 - 服务器
switch: 0x22d3ee, // 青色 - 交换机
router: 0xa78bfa, // 紫色 - 路由器
firewall: 0xf87171, // 红色 - 防火墙
storage: 0xfbbf24, // 黄色 - 存储
workstation: 0x34d399, // 绿色 - 工作站
};
/** 创建设备基础材质:金属外壳 + 状态发光 */
function createBodyMaterial(status: DeviceStatus, type?: DeviceType): THREE.MeshStandardMaterial {
// 根据状态和类型决定发光颜色
let emissiveColor = statusColors[status];
if (status === 'normal' && type) {
emissiveColor = typeColors[type] || emissiveColor;
}
const intensity = status === 'normal' ? 0.5 : status === 'warning' ? 0.7 : status === 'critical' ? 1.0 : 0.2;
return new THREE.MeshStandardMaterial({
color: 0x2c3e5a,
metalness: 0.55,
roughness: 0.35,
emissive: emissiveColor,
emissiveIntensity: intensity,
side: THREE.DoubleSide,
});
}
/** LED 指示灯材质 */
function createLedMaterial(color: number, intensity = 0.8): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: intensity,
metalness: 0.2,
roughness: 0.3,
side: THREE.DoubleSide,
});
}
/** 前面板塑料材质 */
function createPanelMaterial(): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color: 0x334155,
metalness: 0.3,
roughness: 0.6,
side: THREE.DoubleSide,
});
}
/** 端口金属材质 */
function createPortMaterial(): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
color: 0x0f172a,
metalness: 0.9,
roughness: 0.2,
side: THREE.DoubleSide,
});
}
// ============================================================
// 1. 服务器 Server - 机架式服务器
// ============================================================
export function createServerModel(status: DeviceStatus, scale = 1.5): THREE.Group {
const group = new THREE.Group();
const bodyMat = createBodyMaterial(status, 'server');
const panelMat = createPanelMaterial();
// 主体机箱(扁平长方体)
const body = new THREE.Mesh(
new THREE.BoxGeometry(scale * 1.6, scale * 0.5, scale * 1.0),
bodyMat
);
body.userData.isBody = true;
group.add(body);
// 前面板
const frontPanel = new THREE.Mesh(
new THREE.BoxGeometry(scale * 1.62, scale * 0.46, scale * 0.04),
panelMat
);
frontPanel.position.z = scale * 0.52;
group.add(frontPanel);
// 前面板 - 硬盘槽位(4个)
for (let i = 0; i < 4; i++) {
const tray = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.3, scale * 0.35, scale * 0.02),
new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.5, roughness: 0.5 })
);
tray.position.set(-scale * 0.55 + i * scale * 0.37, 0, scale * 0.54);
group.add(tray);
// 硬盘槽位 LED 灯
const ledColor = i === 0 ? statusColors.normal : i === 1 ? statusColors.warning : 0x475569;
const led = new THREE.Mesh(
new THREE.SphereGeometry(0.02, 8, 8),
createLedMaterial(ledColor, i < 2 ? 1.0 : 0.5)
);
led.position.set(-scale * 0.55 + i * scale * 0.37, scale * 0.12, scale * 0.56);
group.add(led);
}
// 服务器状态 LED 灯(右上角)
const powerLed = new THREE.Mesh(
new THREE.SphereGeometry(0.025, 10, 10),
createLedMaterial(statusColors[status], 1.2)
);
powerLed.position.set(scale * 0.68, scale * 0.18, scale * 0.56);
group.add(powerLed);
const statusLed = new THREE.Mesh(
new THREE.SphereGeometry(0.02, 10, 10),
createLedMaterial(0x22d3ee, 1.0)
);
statusLed.position.set(scale * 0.72, scale * 0.18, scale * 0.56);
group.add(statusLed);
// 顶部散热格栅
for (let i = 0; i < 6; i++) {
const fin = new THREE.Mesh(
new THREE.BoxGeometry(scale * 1.2, 0.01, 0.03),
new THREE.MeshStandardMaterial({ color: 0x475569, metalness: 0.8, roughness: 0.3 })
);
fin.position.set(0, scale * 0.26, -scale * 0.3 + i * scale * 0.12);
group.add(fin);
}
return group;
}
// ============================================================
// 2. 交换机 Switch - 扁平机架式 + 端口阵列
// ============================================================
export function createSwitchModel(status: DeviceStatus, scale = 1.8): THREE.Group {
const group = new THREE.Group();
const bodyMat = createBodyMaterial(status, 'switch');
const panelMat = createPanelMaterial();
const portMat = createPortMaterial();
const earMat = new THREE.MeshStandardMaterial({ color: 0x475569, metalness: 0.8, roughness: 0.3 });
// 主体机箱(非常扁平)
const body = new THREE.Mesh(
new THREE.BoxGeometry(scale * 1.8, scale * 0.28, scale * 0.8),
bodyMat
);
body.userData.isBody = true;
group.add(body);
// 前面板
const frontPanel = new THREE.Mesh(
new THREE.BoxGeometry(scale * 1.82, scale * 0.24, scale * 0.03),
panelMat
);
frontPanel.position.z = scale * 0.41;
group.add(frontPanel);
// 机架耳
const leftEar = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.12, scale * 0.22, scale * 0.15),
earMat
);
leftEar.position.set(-scale * 0.97, 0, 0);
group.add(leftEar);
const rightEar = leftEar.clone();
rightEar.position.x = scale * 0.97;
group.add(rightEar);
// 端口阵列(24口,2行 x 12列)
const portSize = scale * 0.06;
const portGap = scale * 0.08;
for (let row = 0; row < 2; row++) {
for (let col = 0; col < 12; col++) {
const port = new THREE.Mesh(
new THREE.BoxGeometry(portSize, portSize, scale * 0.05),
portMat
);
const startX = -scale * 0.72 + col * portGap;
const y = row === 0 ? scale * 0.06 : -scale * 0.06;
port.position.set(startX, y, scale * 0.44);
group.add(port);
// 端口上方的小 LED(亮绿色)
const portLed = new THREE.Mesh(
new THREE.SphereGeometry(0.012, 6, 6),
createLedMaterial(0x22c55e, 0.8)
);
portLed.position.set(startX, y + scale * 0.05, scale * 0.47);
group.add(portLed);
}
}
// 品牌区域
const brandArea = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.3, scale * 0.08, 0.035),
new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.5, roughness: 0.5 })
);
brandArea.position.set(-scale * 0.82, scale * 0.12, scale * 0.44);
group.add(brandArea);
// 状态 LED 灯组
const ledPositions = [
{ color: statusColors[status], x: scale * 0.82, y: scale * 0.08 },
{ color: 0x22d3ee, x: scale * 0.87, y: scale * 0.08 },
{ color: 0x22c55e, x: scale * 0.82, y: -scale * 0.08 },
{ color: 0xf59e0b, x: scale * 0.87, y: -scale * 0.08 },
];
ledPositions.forEach(({ color, x, y }) => {
const led = new THREE.Mesh(
new THREE.SphereGeometry(0.018, 8, 8),
createLedMaterial(color, 1.0)
);
led.position.set(x, y, scale * 0.44);
group.add(led);
});
return group;
}
// ============================================================
// 3. 路由器 Router - 1U 机架式 + 天线 + 端口阵列
// ============================================================
export function createRouterModel(status: DeviceStatus, scale = 1.8): THREE.Group {
const group = new THREE.Group();
const bodyMat = createBodyMaterial(status, 'router');
const panelMat = createPanelMaterial();
const portMat = createPortMaterial();
const earMat = new THREE.MeshStandardMaterial({ color: 0x475569, metalness: 0.8, roughness: 0.3 });
// 1U 主体机箱
const body = new THREE.Mesh(
new THREE.BoxGeometry(scale * 1.6, scale * 0.35, scale * 0.9),
bodyMat
);
body.userData.isBody = true;
group.add(body);
// 前面板
const frontPanel = new THREE.Mesh(
new THREE.BoxGeometry(scale * 1.62, scale * 0.31, scale * 0.03),
panelMat
);
frontPanel.position.z = scale * 0.47;
group.add(frontPanel);
// 机架耳
const leftEar = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.1, scale * 0.28, scale * 0.12),
earMat
);
leftEar.position.set(-scale * 0.86, 0, 0);
group.add(leftEar);
const rightEar = leftEar.clone();
rightEar.position.x = scale * 0.86;
group.add(rightEar);
// 品牌区域 + 状态 LED
const brandArea = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.22, scale * 0.07, 0.035),
new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.6, roughness: 0.4 })
);
brandArea.position.set(-scale * 0.65, scale * 0.1, scale * 0.49);
group.add(brandArea);
// 状态 LED 组
const routerLeds = [
{ color: statusColors[status], x: -scale * 0.38 },
{ color: 0x22d3ee, x: -scale * 0.33 },
{ color: 0x22c55e, x: -scale * 0.28 },
{ color: 0xf59e0b, x: -scale * 0.23 },
];
routerLeds.forEach(({ color, x }) => {
const led = new THREE.Mesh(
new THREE.SphereGeometry(0.018, 8, 8),
createLedMaterial(color, 1.0)
);
led.position.set(x, scale * 0.1, scale * 0.49);
group.add(led);
});
// WAN 口(光纤口样式)
const sfpMat = new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.7, roughness: 0.3 });
const wanPort = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.12, scale * 0.14, scale * 0.06),
sfpMat
);
wanPort.position.set(-scale * 0.05, -scale * 0.02, scale * 0.5);
group.add(wanPort);
const wanRing = new THREE.Mesh(
new THREE.TorusGeometry(scale * 0.045, 0.008, 6, 16),
new THREE.MeshStandardMaterial({ color: 0xfbbf24, emissive: 0xfbbf24, emissiveIntensity: 0.6, metalness: 0.5, roughness: 0.3 })
);
wanRing.rotation.x = Math.PI / 2;
wanRing.position.set(-scale * 0.05, scale * 0.08, scale * 0.53);
group.add(wanRing);
// LAN 口阵列(4个)
for (let i = 0; i < 4; i++) {
const port = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.1, scale * 0.13, scale * 0.05),
portMat
);
port.position.set(scale * 0.2 + i * scale * 0.13, -scale * 0.02, scale * 0.5);
group.add(port);
const lanLed = new THREE.Mesh(
new THREE.SphereGeometry(0.012, 6, 6),
createLedMaterial(0x22c55e, 0.9)
);
lanLed.position.set(scale * 0.2 + i * scale * 0.13, -scale * 0.11, scale * 0.52);
group.add(lanLed);
}
// 天线
const antennaMat = new THREE.MeshStandardMaterial({ color: 0x64748b, metalness: 0.9, roughness: 0.2 });
// 天线 1 - 左侧直立
const ant1 = new THREE.Mesh(
new THREE.CylinderGeometry(0.012, 0.015, scale * 0.7, 8),
antennaMat
);
ant1.position.set(-scale * 0.55, scale * 0.5, -scale * 0.1);
group.add(ant1);
const ant1Tip = new THREE.Mesh(
new THREE.SphereGeometry(0.022, 10, 10),
createLedMaterial(0x22d3ee, 1.2)
);
ant1Tip.position.set(-scale * 0.55, scale * 0.87, -scale * 0.1);
group.add(ant1Tip);
// 天线 2 - 右侧略向后倾斜
const ant2 = new THREE.Mesh(
new THREE.CylinderGeometry(0.012, 0.015, scale * 0.65, 8),
antennaMat
);
ant2.position.set(scale * 0.55, scale * 0.48, -scale * 0.15);
ant2.rotation.x = 0.15;
ant2.rotation.z = -0.1;
group.add(ant2);
const ant2Tip = new THREE.Mesh(
new THREE.SphereGeometry(0.022, 10, 10),
createLedMaterial(0x22d3ee, 1.2)
);
ant2Tip.position.set(scale * 0.52, scale * 0.83, -scale * 0.2);
group.add(ant2Tip);
return group;
}
// ============================================================
// 4. 防火墙 Firewall - 1U 机架式 + LCD 屏 + 盾形浮雕
// ============================================================
export function createFirewallModel(status: DeviceStatus, scale = 1.8): THREE.Group {
const group = new THREE.Group();
const bodyMat = createBodyMaterial(status, 'firewall');
const panelMat = createPanelMaterial();
const portMat = createPortMaterial();
const earMat = new THREE.MeshStandardMaterial({ color: 0x475569, metalness: 0.8, roughness: 0.3 });
// 1U 主体机箱
const body = new THREE.Mesh(
new THREE.BoxGeometry(scale * 1.8, scale * 0.36, scale * 0.9),
bodyMat
);
body.userData.isBody = true;
group.add(body);
// 前面板
const frontPanel = new THREE.Mesh(
new THREE.BoxGeometry(scale * 1.82, scale * 0.32, scale * 0.03),
panelMat
);
frontPanel.position.z = scale * 0.48;
group.add(frontPanel);
// 机架耳
const leftEar = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.1, scale * 0.29, scale * 0.12),
earMat
);
leftEar.position.set(-scale * 0.97, 0, 0);
group.add(leftEar);
const rightEar = leftEar.clone();
rightEar.position.x = scale * 0.97;
group.add(rightEar);
// 红色安全装饰条
const redStrip = new THREE.Mesh(
new THREE.BoxGeometry(scale * 1.55, 0.018, 0.035),
new THREE.MeshStandardMaterial({
color: 0xef4444,
emissive: 0xef4444,
emissiveIntensity: 0.4,
metalness: 0.3,
roughness: 0.5,
})
);
redStrip.position.set(0, scale * 0.15, scale * 0.5);
group.add(redStrip);
// LCD 状态显示屏
const lcdFrame = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.32, scale * 0.2, scale * 0.04),
new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.6, roughness: 0.4 })
);
lcdFrame.position.set(-scale * 0.55, -scale * 0.02, scale * 0.49);
group.add(lcdFrame);
// LCD 显示面(发光蓝绿色)
const lcdScreen = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.28, scale * 0.16, 0.01),
new THREE.MeshStandardMaterial({
color: 0x0d9488,
emissive: 0x14b8a6,
emissiveIntensity: 0.7,
metalness: 0.2,
roughness: 0.3,
})
);
lcdScreen.position.set(-scale * 0.55, -scale * 0.02, scale * 0.52);
group.add(lcdScreen);
// 盾形浮雕
const shieldEmbossMat = new THREE.MeshStandardMaterial({
color: 0x6366f1,
metalness: 0.7,
roughness: 0.3,
emissive: 0x6366f1,
emissiveIntensity: 0.35,
});
const shieldTop = new THREE.Mesh(
new THREE.ConeGeometry(scale * 0.08, scale * 0.14, 4),
shieldEmbossMat
);
shieldTop.rotation.y = Math.PI / 4;
shieldTop.position.set(-scale * 0.18, -scale * 0.03, scale * 0.5);
group.add(shieldTop);
const shieldBody = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.12, scale * 0.14, 0.02),
shieldEmbossMat
);
shieldBody.position.set(-scale * 0.18, -scale * 0.13, scale * 0.5);
group.add(shieldBody);
// 盾心
const shieldCore = new THREE.Mesh(
new THREE.SphereGeometry(0.012, 8, 8),
createLedMaterial(0x22d3ee, 1.2)
);
shieldCore.position.set(-scale * 0.18, -scale * 0.07, scale * 0.52);
group.add(shieldCore);
// 状态 LED 组
const fwLeds = [
{ color: statusColors[status], x: scale * 0.02 },
{ color: 0xef4444, x: scale * 0.08 },
{ color: 0xf59e0b, x: scale * 0.14 },
{ color: 0x22c55e, x: scale * 0.2 },
];
fwLeds.forEach(({ color, x }) => {
const led = new THREE.Mesh(
new THREE.SphereGeometry(0.018, 8, 8),
createLedMaterial(color, 1.0)
);
led.position.set(x, scale * 0.08, scale * 0.5);
group.add(led);
});
// SFP 口
const sfpMat = new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.7, roughness: 0.3 });
for (let i = 0; i < 2; i++) {
const sfp = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.1, scale * 0.12, scale * 0.06),
sfpMat
);
sfp.position.set(scale * 0.45 + i * scale * 0.15, -scale * 0.02, scale * 0.5);
group.add(sfp);
const sfpLed = new THREE.Mesh(
new THREE.SphereGeometry(0.012, 6, 6),
createLedMaterial(0x22d3ee, 1.0)
);
sfpLed.position.set(scale * 0.45 + i * scale * 0.15, -scale * 0.12, scale * 0.52);
group.add(sfpLed);
}
// 网口
for (let i = 0; i < 2; i++) {
const port = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.08, scale * 0.1, scale * 0.05),
portMat
);
port.position.set(scale * 0.78 + i * scale * 0.11, -scale * 0.03, scale * 0.5);
group.add(port);
const portLed = new THREE.Mesh(
new THREE.SphereGeometry(0.01, 6, 6),
createLedMaterial(0x22c55e, 0.9)
);
portLed.position.set(scale * 0.78 + i * scale * 0.11, -scale * 0.11, scale * 0.52);
group.add(portLed);
}
return group;
}
// ============================================================
// 5. 存储 Storage - 磁盘阵列
// ============================================================
export function createStorageModel(status: DeviceStatus, scale = 1.5): THREE.Group {
const group = new THREE.Group();
const bodyMat = createBodyMaterial(status, 'storage');
const panelMat = createPanelMaterial();
// 主体大型方体
const body = new THREE.Mesh(
new THREE.BoxGeometry(scale * 1.4, scale * 0.9, scale * 1.1),
bodyMat
);
body.userData.isBody = true;
group.add(body);
// 前面板
const frontPanel = new THREE.Mesh(
new THREE.BoxGeometry(scale * 1.42, scale * 0.82, scale * 0.04),
panelMat
);
frontPanel.position.z = scale * 0.57;
group.add(frontPanel);
// 磁盘槽位(3行 x 4列)
const driveW = scale * 0.28;
const driveH = scale * 0.18;
const startX = -scale * 0.55;
const startY = scale * 0.32;
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 4; col++) {
const drive = new THREE.Mesh(
new THREE.BoxGeometry(driveW, driveH, scale * 0.025),
new THREE.MeshStandardMaterial({
color: row === 2 && col >= 2 ? 0x0f172a : 0x1e293b,
metalness: 0.4,
roughness: 0.5,
})
);
drive.position.set(
startX + col * (driveW + 0.02),
startY - row * (driveH + 0.04),
scale * 0.6
);
group.add(drive);
// 磁盘 LED
const isActive = !(row === 2 && col >= 2);
const ledColor = isActive
? row === 0 && col === 0
? statusColors[status]
: 0x22c55e
: 0x475569;
const led = new THREE.Mesh(
new THREE.SphereGeometry(0.015, 8, 8),
createLedMaterial(ledColor, isActive ? 0.8 : 0.3)
);
led.position.set(
startX + col * (driveW + 0.02) - driveW * 0.35,
startY - row * (driveH + 0.04),
scale * 0.62
);
group.add(led);
}
}
// 状态区域
const statusArea = new THREE.Mesh(
new THREE.BoxGeometry(scale * 0.3, scale * 0.1, scale * 0.045),
new THREE.MeshStandardMaterial({ color: 0x0f172a, metalness: 0.6, roughness: 0.4 })
);
statusArea.position.set(-scale * 0.5, -scale * 0.32, scale * 0.59);
group.add(statusArea);
const stLeds = [
{ color: statusColors[status], x: -scale * 0.58 },
{ color: 0x22d3ee, x: -scale * 0.53 },
{ color: 0x22c55e, x: -scale * 0.48 },
];
stLeds.forEach(({ color, x }) => {
const led = new THREE.Mesh(
new THREE.SphereGeometry(0.02, 10, 10),
createLedMaterial(color, 1.0)
);
led.position.set(x, -scale * 0.32, scale * 0.61);
group.add(led);
});
return group;
}
// ============================================================
// 统一的设备模型创建入口
// ============================================================
export function createDeviceModel(type: DeviceType, status: DeviceStatus, scale?: number): THREE.Group {
switch (type) {
case 'server':
return createServerModel(status, scale ?? 1.5);
case 'switch':
return createSwitchModel(status, scale ?? 1.8);
case 'router':
return createRouterModel(status, scale ?? 2.0);
case 'firewall':
return createFirewallModel(status, scale ?? 2.0);
case 'storage':
return createStorageModel(status, scale ?? 1.6);
case 'workstation':
// 工作站用服务器模型代替(简化版)
return createServerModel(status, scale ?? 1.4);
default:
return createServerModel(status, scale ?? 1.5);
}
}

View File

@@ -0,0 +1,569 @@
/**
* useMonitoringData - 运行监控大屏数据 hook
*
* 数据源(全部来自 NetMesh 真实 API):
* - hosts: props 传入(vault 里的设备列表)
* - inspection.getLatestAll() → 每台设备最新巡检结果(含 metrics:cpu/memory/online)
* - inspectionAlertApi.getActive() → 活动告警列表
* - inspectionAlertApi.getStats() → 告警统计
* - inspectionRuleApi.list() → 巡检规则(用于任务状态统计)
*/
import { useEffect, useState, useCallback, useRef } from 'react';
import type { Host } from '../../domain/models';
import {
inspectionResultApi,
inspectionAlertApi,
inspectionAlertRuleApi,
inspectionSchedulerApi,
inspectionDeviceApi,
type AlertRecord,
type InspectionResult,
type AlertLevel,
type AlertStats,
} from '../../infrastructure/inspection/inspectionApi';
// ===== 对外类型 =====
export interface MonitoringAlert {
id: string;
deviceId: string;
deviceName: string;
level: 'critical' | 'major' | 'warning' | 'minor' | 'info';
message: string;
time: string;
metric?: string;
metricValue?: string;
threshold?: string;
}
export interface TrendPoint {
date: string; // MM-DD
count: number;
}
export interface TaskStats {
running: number;
success: number;
failed: number;
pending: number;
}
export interface TaskDevice {
deviceId: string;
deviceName: string;
ip: string;
status: 'running' | 'success' | 'failed' | 'pending';
lastScanAt?: number;
durationMs?: number;
errorMessage?: string;
interval?: number; // 巡检间隔(秒)
}
interface UseMonitoringDataResult {
// 指标缓存
cpuMap: Record<string, number>;
memMap: Record<string, number>;
onlineMap: Record<string, boolean>;
timestampMap: Record<string, number>; // 每台设备最新巡检时间戳
// 告警
alerts: MonitoringAlert[];
alarmTrend: TrendPoint[];
alertStats: AlertStats | null;
// 巡检任务
taskStats: TaskStats;
taskDevices: TaskDevice[];
schedulerRunning: boolean;
// 操作
refresh: () => Promise<void>;
loading: boolean;
}
// ===== localStorage 持久化(降级用,API 不可用时显示缓存) =====
const LS_CPU = 'netmesh:monitoring:cpuMap';
const LS_MEM = 'netmesh:monitoring:memMap';
const LS_ALERTS = 'netmesh:monitoring:alerts';
const LS_TREND = 'netmesh:monitoring:trend';
function readLS<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
function writeLS(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch { /* 忽略 */ }
}
// 将 NetMesh AlertLevel 映射到监控页面的 5 级
function mapLevel(l: AlertLevel): MonitoringAlert['level'] {
if (l === 'critical') return 'critical';
if (l === 'warning') return 'warning';
return 'info';
}
// 格式化告警时间:今天 → HH:mm,昨天 → 昨天 HH:mm,更早 → MM-DD HH:mm
function formatAlertTime(ts: number): string {
const d = new Date(ts);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = d.toDateString() === yesterday.toDateString();
const timeStr = d.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' });
if (isToday) return timeStr;
if (isYesterday) return `昨天 ${timeStr}`;
const ds = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return `${ds} ${timeStr}`;
}
// 从巡检结果 metrics 里提取 cpu 使用率(数字百分比)
function extractCpu(metrics: Record<string, any>): number {
if (!metrics) return 0;
// NetMesh 巡检标准字段
if (typeof metrics.cpu_usage === 'number' && !isNaN(metrics.cpu_usage)) return metrics.cpu_usage;
if (typeof metrics.cpu_usage === 'string') {
const n = parseFloat(metrics.cpu_usage);
if (!isNaN(n)) return n;
}
// 网络设备
if (metrics.cpuUtilization !== undefined) {
const v = Number(metrics.cpuUtilization);
if (!isNaN(v)) return v;
}
if (metrics.displayCpu?.cpuUsage !== undefined) {
const v = Number(metrics.displayCpu.cpuUsage);
if (!isNaN(v)) return v;
}
// 遍历所有字段找数值型 CPU 字段
for (const key of Object.keys(metrics)) {
const kl = key.toLowerCase();
if (kl.includes('cpu') && (kl.includes('usage') || kl.includes('util') || kl.includes('percent'))) {
const v = metrics[key];
if (typeof v === 'number' && !isNaN(v)) return v;
if (typeof v === 'string') {
const n = parseFloat(v);
if (!isNaN(n)) return n;
}
}
}
return 0;
}
// 从巡检结果 metrics 里提取内存使用率
function extractMem(metrics: Record<string, any>): number {
if (!metrics) return 0;
// NetMesh 巡检标准字段
if (typeof metrics.memory_usage === 'number' && !isNaN(metrics.memory_usage)) return metrics.memory_usage;
if (typeof metrics.memory_usage === 'string') {
const n = parseFloat(metrics.memory_usage);
if (!isNaN(n)) return n;
}
// 网络设备
if (metrics.displayMemory?.memPercent !== undefined) {
const v = Number(metrics.displayMemory.memPercent);
if (!isNaN(v)) return v;
}
if (metrics.memoryPercent !== undefined) {
const v = Number(metrics.memoryPercent);
if (!isNaN(v)) return v;
}
if (metrics.mem_percent !== undefined) {
const v = Number(metrics.mem_percent);
if (!isNaN(v)) return v;
}
// 遍历所有字段找数值型内存字段
for (const key of Object.keys(metrics)) {
const kl = key.toLowerCase();
if ((kl.includes('mem') || kl.includes('memory')) && (kl.includes('usage') || kl.includes('util') || kl.includes('percent'))) {
const v = metrics[key];
if (typeof v === 'number' && !isNaN(v)) return v;
if (typeof v === 'string') {
const n = parseFloat(v);
if (!isNaN(n)) return n;
}
}
}
// 如果有 mem_used 和 mem_total,计算百分比
const used = metrics.mem_used ?? metrics.memory_used ?? metrics.used_memory;
const total = metrics.mem_total ?? metrics.memory_total ?? metrics.total_memory;
if (typeof used === 'number' && typeof total === 'number' && total > 0) {
return Math.min(100, Math.round((used / total) * 100));
}
return 0;
}
// 生成过去 N 天的空趋势骨架
function buildTrendSkeleton(days = 14): TrendPoint[] {
const today = new Date();
const out: TrendPoint[] = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const ds = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
out.push({ date: ds, count: 0 });
}
return out;
}
// 把告警数组按天统计到趋势
function buildTrendFromAlerts(allAlerts: AlertRecord[], days = 14): TrendPoint[] {
const trend = buildTrendSkeleton(days);
const map = new Map(trend.map((t) => [t.date, 0]));
const today = new Date();
const earliest = new Date(today);
earliest.setDate(earliest.getDate() - days + 1);
earliest.setHours(0, 0, 0, 0);
for (const a of allAlerts) {
const t = new Date(a.triggered_at);
if (t < earliest) continue;
const ds = `${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`;
if (map.has(ds)) {
map.set(ds, (map.get(ds) || 0) + 1);
}
}
return trend.map((t) => ({ ...t, count: map.get(t.date) || 0 }));
}
// ===== Hook =====
// 工具函数:从 Host + keys 构造 SSH 连接配置(用于调度器执行巡检)
function buildSshConfig(host: Host, keys: any[]): Record<string, unknown> {
const config: Record<string, unknown> = {
host: host.hostname,
port: host.port || 22,
username: host.username,
label: host.label || host.hostname,
};
if (host.password) {
config.password = host.password;
}
// 私钥优先用 identityId 匹配 keys 数组
const keyId = host.identityId || host.identityFileId;
if (keyId) {
const key = keys.find((k) => k.id === keyId);
if (key?.privateKeyContent) {
config.privateKey = key.privateKeyContent;
if (key.passphrase) config.passphrase = key.passphrase;
}
}
return config;
}
export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitoringDataResult {
const [cpuMap, setCpuMap] = useState<Record<string, number>>(() => readLS(LS_CPU, {}));
const [memMap, setMemMap] = useState<Record<string, number>>(() => readLS(LS_MEM, {}));
const [onlineMap, setOnlineMap] = useState<Record<string, boolean>>({});
const [timestampMap, setTimestampMap] = useState<Record<string, number>>({});
const [alerts, setAlerts] = useState<MonitoringAlert[]>(() => {
const cached = readLS<MonitoringAlert[]>(LS_ALERTS, []);
return cached;
});
// 初始 alertStats 也从缓存告警计算,保证和列表初始状态一致
const [alertStats, setAlertStats] = useState<AlertStats | null>(() => {
const cached = readLS<MonitoringAlert[]>(LS_ALERTS, []);
if (cached.length === 0) return null;
let critical = 0, warning = 0, info = 0;
for (const a of cached) {
if (a.level === 'critical') critical++;
else if (a.level === 'warning') warning++;
else info++;
}
return {
total: cached.length,
active: cached.length,
active_critical: critical,
active_warning: warning,
active_info: info,
acknowledged: 0,
resolved: 0,
closed: 0,
};
});
const [alarmTrend, setAlarmTrend] = useState<TrendPoint[]>(() => {
const cached = readLS<TrendPoint[]>(LS_TREND, []);
return cached.length > 0 ? cached : buildTrendSkeleton(14);
});
const [taskStats, setTaskStats] = useState<TaskStats>({
running: 0,
success: 0,
failed: 0,
pending: 0,
});
const [taskDevices, setTaskDevices] = useState<TaskDevice[]>([]);
const [schedulerRunning, setSchedulerRunning] = useState(false);
const [loading, setLoading] = useState(false);
const loadingRef = useRef(false);
// 用 ref 存最新的 hosts/keys,避免闭包陷阱
const hostsRef = useRef(hosts);
const keysRef = useRef(keys);
hostsRef.current = hosts;
keysRef.current = keys;
const refresh = useCallback(async () => {
console.log('[monitoring] refresh() 被调用');
if (loadingRef.current) {
console.log('[monitoring] refresh() 正在执行中,跳过');
return;
}
loadingRef.current = true;
setLoading(true);
try {
// ===== 1. 巡检最新结果 → CPU / 内存 / 在线状态 / 成功失败统计 =====
let latestResults: InspectionResult[] = [];
try {
const results: InspectionResult[] = await inspectionResultApi.getLatestAll();
latestResults = results;
const newCpu: Record<string, number> = {};
const newMem: Record<string, number> = {};
const newOnline: Record<string, boolean> = {};
const newTimestamps: Record<string, number> = {};
let successCount = 0;
let failedCount = 0;
for (const r of results) {
const cpu = extractCpu(r.metrics);
const mem = extractMem(r.metrics);
newCpu[r.device_id] = cpu;
newMem[r.device_id] = mem;
newOnline[r.device_id] = r.online !== false;
newTimestamps[r.device_id] = r.timestamp;
if (r.status === 'success') successCount++;
else failedCount++;
// 调试:打印每台设备的 metrics 结构,方便排查字段
console.log(`[monitoring] device ${r.device_id}: cpu=${cpu}, mem=${mem}, metrics keys=`, Object.keys(r.metrics || {}));
}
console.log(`[monitoring] loaded ${results.length} results: cpuMap=`, Object.keys(newCpu).length, 'memMap=', Object.keys(newMem).length);
setCpuMap((prev) => {
const merged = { ...prev, ...newCpu };
writeLS(LS_CPU, merged);
return merged;
});
setMemMap((prev) => {
const merged = { ...prev, ...newMem };
writeLS(LS_MEM, merged);
return merged;
});
setOnlineMap((prev) => ({ ...prev, ...newOnline }));
setTimestampMap((prev) => ({ ...prev, ...newTimestamps }));
setTaskStats((prev) => ({ ...prev, success: successCount, failed: failedCount }));
} catch (e) {
console.log('[monitoring] getLatestAll failed, using cached:', e);
}
// ===== 2. 活动告警(统一数据源:列表、概览、计数都从 active 列表里统计)=====
try {
const active: AlertRecord[] = await inspectionAlertApi.getActive();
console.log('[monitoring] getActive 返回:', active.length, '条告警');
const mapped: MonitoringAlert[] = active.map((a) => ({
id: a.id,
deviceId: a.device_id,
deviceName: a.device_name,
level: mapLevel(a.level),
message: a.message || a.rule_name,
time: formatAlertTime(a.triggered_at),
metric: a.metric,
metricValue: a.metric_value,
threshold: a.threshold,
}));
setAlerts(mapped);
writeLS(LS_ALERTS, mapped);
// 统一从 active 列表统计告警计数(和列表、概览保持完全一致)
let activeCritical = 0;
let activeWarning = 0;
let activeInfo = 0;
for (const a of active) {
if (a.level === 'critical') activeCritical++;
else if (a.level === 'warning') activeWarning++;
else activeInfo++;
}
setAlertStats({
total: active.length, // 用活动告警总数,不是历史总告警
active: active.length,
active_critical: activeCritical,
active_warning: activeWarning,
active_info: activeInfo,
acknowledged: 0,
resolved: 0,
closed: 0,
});
// 趋势:拉最近 200 条历史告警算 14 天趋势
try {
const all = await inspectionAlertApi.list({ limit: 200 });
const trend = buildTrendFromAlerts(all, 14);
setAlarmTrend(trend);
writeLS(LS_TREND, trend);
} catch { /* 忽略趋势拉取失败 */ }
} catch (e) {
console.log('[monitoring] alerts failed:', e);
}
// ===== 3. 调度器状态 + 设备扩展 → 任务统计 + 任务设备列表 =====
try {
const [schedulerStatus, enabledDevices, allExtensions] = await Promise.all([
inspectionSchedulerApi.status(),
inspectionDeviceApi.getEnabled(),
inspectionDeviceApi.getAll(),
]);
setSchedulerRunning(schedulerStatus.running);
const running = schedulerStatus.active_scans || 0;
// 构建结果 map
const resultMap = new Map(latestResults.map((r) => [r.device_id, r]));
// 构建所有设备的任务状态列表(过滤掉没有 hostname 的无效设备)
const allExtList = Object.values(allExtensions || {});
const taskDeviceList: TaskDevice[] = allExtList
.map((ext) => {
const host = hostsRef.current.find((h) => h.id === ext.host_id);
const result = resultMap.get(ext.host_id);
const isEnabled = ext.inspection_enabled;
// 没有 hostname 的设备(配置不完整),过滤掉
if (!host?.hostname) return null;
let status: TaskDevice['status'];
if (isEnabled) {
// 启用了定时巡检
if (result) {
if (result.status === 'success') status = 'success';
else status = 'failed';
} else {
// 启用了但还没巡检过 → 归为成功(表示已配置)
status = 'success';
}
} else {
// 未启用定时巡检 → 待执行
status = 'pending';
}
return {
deviceId: ext.host_id,
deviceName: host?.label || host?.hostname || ext.host_id,
ip: host?.hostname || '',
status,
lastScanAt: result?.timestamp,
durationMs: result?.duration_ms,
errorMessage: result?.error_message || undefined,
interval: ext.inspection_interval || undefined,
inspectionEnabled: isEnabled,
};
})
.filter((d): d is TaskDevice => d !== null);
// 统计:执行中=启用巡检的设备数,成功/失败是其中的子集,待执行=未启用的
const enabledTaskDevices = taskDeviceList.filter((d) => d.inspectionEnabled);
const runningCount = enabledTaskDevices.length;
const successCount = enabledTaskDevices.filter((d) => d.status === 'success').length;
const failedCount = enabledTaskDevices.filter((d) => d.status === 'failed').length;
const pendingCount = taskDeviceList.filter((d) => !d.inspectionEnabled).length;
console.log('[monitoring] 任务统计:', { running: runningCount, success: successCount, failed: failedCount, pending: pendingCount, total: taskDeviceList.length });
console.log('[monitoring] 启用巡检设备数:', enabledTaskDevices.length, '全部扩展设备数:', allExtList.length, '巡检结果数:', latestResults.length);
setTaskDevices(taskDeviceList);
setTaskStats({
running: runningCount,
success: successCount,
failed: failedCount,
pending: pendingCount,
});
// 自动启动调度器
if (enabledDevices.length === 0) {
console.log('[monitoring] 没有启用巡检的设备');
} else if (schedulerStatus.running) {
console.log('[monitoring] 调度器已在运行中');
} else {
console.log('[monitoring] 条件满足,准备启动调度器');
try {
const hostConfigs: Record<string, Record<string, unknown>> = {};
for (const dev of enabledDevices) {
const hostname = dev.hostname;
if (!hostname) {
console.log('[monitoring] 设备', dev.host_id, '没有 hostname,跳过');
continue;
}
const host = hostsRef.current.find((h) => h.hostname === hostname);
if (!host) {
console.log('[monitoring] vault 中未找到 hostname:', hostname, ',跳过');
continue;
}
if (!host.username) {
console.log('[monitoring] 设备', hostname, '缺少 username,跳过');
continue;
}
hostConfigs[dev.host_id] = buildSshConfig(host, keysRef.current);
}
if (Object.keys(hostConfigs).length === 0) {
console.log('[monitoring] 没有可巡检的设备(hostname 未写入或配置不完整)');
} else {
console.log('[monitoring] 启动调度器,匹配到', Object.keys(hostConfigs).length, '台设备');
await inspectionSchedulerApi.start(hostConfigs);
setSchedulerRunning(true);
}
} catch (e) {
console.log('[monitoring] 自动启动调度器失败:', e);
}
}
} catch (e) {
console.log('[monitoring] scheduler status failed:', e);
}
} finally {
loadingRef.current = false;
setLoading(false);
}
}, []);
// 初始加载 + 定期刷新(每 30 秒)
useEffect(() => {
refresh();
const id = setInterval(refresh, 30000);
return () => clearInterval(id);
}, [refresh]);
// hosts 变化时,把在线状态默认设为 true(没巡检数据前)
useEffect(() => {
setOnlineMap((prev) => {
const next: Record<string, boolean> = { ...prev };
let changed = false;
for (const h of hosts) {
if (next[h.id] === undefined) {
next[h.id] = true; // 假设在线,等巡检结果覆盖
changed = true;
}
}
return changed ? next : prev;
});
}, [hosts]);
return {
cpuMap,
memMap,
onlineMap,
timestampMap,
alerts,
alarmTrend,
alertStats,
taskStats,
taskDevices,
schedulerRunning,
refresh,
loading,
};
}

View File

@@ -1,14 +1,13 @@
/**
* Settings System Tab - System information, temp file management, session logs, and global hotkey
*/
import { ChevronDown, ChevronRight, Download, ExternalLink, FolderOpen, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { ChevronDown, ChevronRight, FolderOpen, RefreshCw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import type { AppLockSystemUnlockStatus } from "../../../application/state/useAppLockState";
import type { AppLockSettings, AppLockSettingsChangeError, AppLockTimeoutMinutes } from "../../../domain/appLock";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { UpdateState } from '../../../application/state/useUpdateCheck';
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import type { HttpNetworkProxyMode, HttpNetworkProxySettings } from "../../../domain/httpNetworkProxy";
import { Button } from "../../ui/button";
@@ -64,22 +63,6 @@ function formatBytes(bytes: number): string {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
/** Returns a locale-agnostic relative time string for the given timestamp. */
function formatLastChecked(
timestamp: number | null,
t: (key: string) => string,
): string {
if (!timestamp) return '';
const diffMs = Date.now() - timestamp;
if (diffMs < 0) return t('settings.update.lastCheckedJustNow');
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return t('settings.update.lastCheckedJustNow');
if (diffMins < 60)
return t('settings.update.lastCheckedMinutesAgo').replace('{n}', String(diffMins));
const diffHours = Math.floor(diffMins / 60);
return t('settings.update.lastCheckedHoursAgo').replace('{n}', String(diffHours));
}
interface SettingsSystemTabProps {
appLockSettings: AppLockSettings;
setAppLockTimeoutMinutes: (timeoutMinutes: AppLockTimeoutMinutes) => void;
@@ -129,14 +112,6 @@ interface SettingsSystemTabProps {
hotkeyRegistrationError: string | null;
globalHotkeyEnabled: boolean;
setGlobalHotkeyEnabled: (enabled: boolean) => void;
autoUpdateEnabled: boolean;
setAutoUpdateEnabled: (enabled: boolean) => void;
// Unified update state — from useUpdateCheck hook in SettingsPageContent
updateState: UpdateState;
checkNow: () => Promise<unknown>;
installUpdate: () => void;
openReleasePage: () => void;
startDownload: () => void;
}
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
@@ -181,13 +156,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
hotkeyRegistrationError,
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
autoUpdateEnabled,
setAutoUpdateEnabled,
updateState,
checkNow,
installUpdate,
openReleasePage,
startDownload,
}) => {
const { t } = useI18n();
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
@@ -211,18 +179,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const [isClearingSessionLogs, setIsClearingSessionLogs] = useState(false);
const [sessionLogsClearResult, setSessionLogsClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
const [appVersion, setAppVersion] = useState('');
// Load app version on mount
useEffect(() => {
const promise = netcattyBridge.get()?.getAppInfo?.();
if (promise) {
promise.then((info) => {
setAppVersion(info?.version ?? '');
}).catch(() => {});
}
}, []);
const loadTempDirInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getTempDirInfo) return;
@@ -473,142 +429,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
];
return (
<SettingsTabContent value="system">
<SectionHeader title={t('settings.update.title')} anchorId="system-update" />
<SettingCard className="space-y-3 py-4">
{/* Current version */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{t('settings.update.currentVersion')}
</span>
<span className="text-sm font-mono">
{updateState.currentVersion || appVersion || '...'}
</span>
</div>
{/* Status message — priority: autoDownloadStatus > isChecking/manualCheckStatus */}
{updateState.autoDownloadStatus === 'downloading' && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t('settings.update.downloading').replace('{percent}', String(updateState.downloadPercent))}
</p>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${updateState.downloadPercent}%` }}
/>
</div>
</div>
)}
{updateState.autoDownloadStatus === 'ready' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.readyToInstall')}
</p>
)}
{updateState.autoDownloadStatus === 'error' && (
<p className="text-sm text-destructive">
{updateState.downloadError || t('settings.update.error')}
</p>
)}
{updateState.autoDownloadStatus === 'idle' && (
<>
{updateState.manualCheckStatus === 'up-to-date' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.upToDate')}
</p>
)}
{(updateState.manualCheckStatus === 'available' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
<p className="text-sm text-blue-600 dark:text-blue-400">
{t('settings.update.available').replace(
'{version}',
updateState.latestRelease?.version ?? ''
)}
</p>
)}
{updateState.manualCheckStatus === 'error' && (
<p className="text-sm text-destructive">
{updateState.error || t('settings.update.error')}
</p>
)}
</>
)}
{/* Action buttons */}
<div className="flex items-center gap-2 pt-1">
{/* Checking spinner — shown when isChecking OR manualCheckStatus=checking, but no active download */}
{(updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') &&
(updateState.isChecking || updateState.manualCheckStatus === 'checking') ? (
<Button variant="outline" size="sm" disabled>
<RefreshCw size={14} className="mr-1.5 animate-spin" />
{t('settings.update.checking')}
</Button>
) : (updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') ? (
/* Check button — shown in idle states and in error state (allows retry) */
<Button
variant="outline"
size="sm"
onClick={() => void checkNow()}
>
<RefreshCw size={14} className="mr-1.5" />
{t('settings.update.checkForUpdates')}
</Button>
) : null}
{/* Install button — shown when download is complete */}
{updateState.autoDownloadStatus === 'ready' && (
<Button variant="default" size="sm" onClick={installUpdate}>
<RotateCcw size={14} className="mr-1.5" />
{t('settings.update.restartNow')}
</Button>
)}
{/* Open releases — shown on download error */}
{updateState.autoDownloadStatus === 'error' && (
<Button variant="ghost" size="sm" onClick={openReleasePage}>
<ExternalLink size={14} className="mr-1.5" />
{t('settings.update.manualDownload')}
</Button>
)}
{/* Download button — shown when update found and no download in progress */}
{updateState.autoDownloadStatus === 'idle' &&
updateState.manualCheckStatus === 'available' && (
<Button variant="outline" size="sm" onClick={startDownload}>
<Download size={14} className="mr-1.5" />
{t('update.downloadNow')}
</Button>
)}
{/* Open releases — fallback for unsupported platforms or check errors */}
{updateState.autoDownloadStatus === 'idle' &&
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
<Button variant="ghost" size="sm" onClick={openReleasePage}>
<ExternalLink size={14} className="mr-1.5" />
{t('settings.update.manualDownload')}
</Button>
)}
</div>
<SettingRow
anchorId="system-auto-update"
label={t('settings.update.autoUpdateEnabled')}
description={t('settings.update.autoUpdateEnabledDesc')}
>
<Toggle
checked={autoUpdateEnabled}
onChange={setAutoUpdateEnabled}
/>
</SettingRow>
</SettingCard>
<SettingHint>
{updateState.lastCheckedAt && (
<span>
{t('settings.update.lastCheckedPrefix')}
{formatLastChecked(updateState.lastCheckedAt, t)}
{' '}
</span>
)}
{t('settings.update.hint')}
</SettingHint>
<SectionHeader title={t("settings.autoLaunch.title")} />
<SettingCard className="py-4">
<SettingRow

View File

@@ -5,6 +5,9 @@ import {
HardDrive,
MemoryStick,
Network,
Server,
Plug,
Fan,
} from 'lucide-react';
import React, { memo, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -363,6 +366,8 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
isConnected: true,
});
const hasStats = Boolean(stats.lastUpdated);
// Network device detection: backend sets this field only for H3C/Huawei/Cisco etc.
const isNetworkDevice = Boolean(stats.networkDevicePorts);
const memoryPercent = ratioPercent(stats?.memUsed, stats?.memTotal);
const mountedDiskUsage = aggregateMountedDiskUsage(stats.disks);
@@ -374,6 +379,22 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
const loadOne = stats?.loadAverage?.[0] ?? null;
const loadPercent = ratioPercent(loadOne, stats?.cpuCores);
// Network device specific
const ndPorts = stats.networkDevicePorts ?? [];
const ndUp = stats.networkDevicePortUp ?? ndPorts.filter(p => p.phy === 'up').length;
const ndDown = stats.networkDevicePortDown ?? ndPorts.length - ndUp;
// Helper to shorten interface names: GigabitEthernet0/0/1 → GE0/0/1
const shortPortName = (name: string) => name
.replace(/^GigabitEthernet/i, 'GE')
.replace(/^TenGigEthernet/i, 'XGE')
.replace(/^TwentyFiveGigEthernet/i, '25GE')
.replace(/^FortyGigEthernet/i, '40GE')
.replace(/^HundredGigEthernet/i, '100GE')
.replace(/^Ethernet/i, 'Eth')
.replace(/^Serial/i, 'Ser')
.replace(/^Vlan-interface/i, 'Vlan');
useEffect(() => {
setHistory([]);
}, [sessionId]);
@@ -425,6 +446,89 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
<SystemPanelLoading message={t('systemManager.overview.loading')} />
) : showEmpty ? (
<SystemPanelEmpty icon={Activity} message={t('systemManager.overview.empty')} />
) : hasStats && isNetworkDevice ? (
/* ==================== NETWORK DEVICE VIEW ==================== */
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 space-y-4">
{/* Banner */}
<div className="flex items-center gap-2 text-[11px] font-medium text-muted-foreground">
<Server size={13} className="text-violet-500" />
<span>网络设备模式 · VRP / Comware CLI</span>
</div>
{/* Main metric cards: CPU + Memory */}
<div className="grid grid-cols-2 gap-3">
<MetricCard
label="CPU"
value={formatPercent(stats.cpu)}
detail="处理器使用率"
icon={Cpu}
gaugeValue={stats.cpu}
trendValues={trends.cpu}
trendMax={100}
tone="text-violet-500"
gradientId="nd-cpu-card"
/>
<MetricCard
label="内存"
value={memoryPercent !== null ? formatPercent(memoryPercent) : '--'}
detail={`${formatMemoryMb(stats.memUsed)} / ${formatMemoryMb(stats.memTotal)}`}
icon={MemoryStick}
gaugeValue={memoryPercent}
trendValues={trends.memory}
trendMax={100}
tone="text-emerald-500"
gradientId="nd-mem-card"
/>
</div>
{/* Port status summary */}
<section
className="rounded-lg border p-3"
style={{ borderColor: 'hsl(var(--border) / 0.7)', background: 'hsl(var(--background))' }}
>
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-xs font-semibold" style={{ color: 'hsl(var(--foreground))' }}>
<Network size={13} className="text-cyan-500" />
端口状态
</div>
<div className="flex items-center gap-3 text-[11px]">
<span className="text-emerald-500">↑ {ndUp} UP</span>
<span className="text-muted-foreground">↓ {ndDown} DOWN</span>
<span className="text-muted-foreground">共 {ndPorts.length}</span>
</div>
</div>
{ndPorts.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{ndPorts.slice(0, 48).map((port) => (
<div
key={port.name}
className={cn(
'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-mono tabular-nums',
port.phy === 'up'
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-600'
: 'border-muted/40 bg-muted/20 text-muted-foreground',
)}
title={`${port.name} — ${port.phy.toUpperCase()}`}
>
<span className={cn('h-1.5 w-1.5 rounded-full', port.phy === 'up' ? 'bg-emerald-500' : 'bg-muted-foreground/40')} />
{shortPortName(port.name)}
</div>
))}
{ndPorts.length > 48 && (
<span className="text-[10px] text-muted-foreground py-0.5">+{ndPorts.length - 48} 更多</span>
)}
</div>
) : (
<div className="text-[11px]" style={{ color: 'hsl(var(--muted-foreground))' }}>暂无端口数据</div>
)}
</section>
{/* Power / Fan status (placeholders — extend when we add display device parsing) */}
<div className="grid grid-cols-2 gap-3">
<InfoPill label="电源" value="未采集" icon={Plug} tone="text-amber-500" />
<InfoPill label="风扇" value="未采集" icon={Fan} tone="text-cyan-500" />
</div>
</div>
) : hasStats ? (
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 space-y-4">
{/* Main metric cards */}

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useCallback } from "react";
import React, { useCallback, lazy, Suspense, useState, useEffect } from "react";
import { AlertTriangle, Bell, Activity, Network } from "lucide-react";
import { deleteVaultKey } from "../../application/defaultKeyPassphrases";
import { usePluginImporterCommit } from "../../application/state/usePluginImporterCommit";
import { preserveConcurrentHostLineTimestampUpdate } from "../../domain/host";
@@ -9,7 +10,8 @@ import {
collectVisibleVaultHostIds,
retainVisibleVaultGroupSelection,
} from "../../domain/vaultGroupSelection";
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH } from "@/infrastructure/config/storageKeys.ts";
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH, STORAGE_KEY_PRODUCT_NAME } from "@/infrastructure/config/storageKeys.ts";
import { localStorageAdapter } from "@/infrastructure/persistence/localStorageAdapter.ts";
import { VaultHostListSection } from "./VaultHostListSection";
import { VaultImportProgressPanel } from "./ImportVaultDialog";
import {
@@ -23,6 +25,14 @@ import { useNotesStore } from "../../application/state/notesStore";
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
import { toast } from "../ui/toast";
import { AppWordmark } from "../AppWordmark";
import { InspectionErrorBoundary } from "../inspection/InspectionErrorBoundary";
// 巡检模块页面(自包含,通过 IPC 直接调用主进程)
const AlertCenterPage = lazy(() => import("../inspection/AlertCenterPage"));
const AlertRulesPage = lazy(() => import("../inspection/AlertRulesPage"));
const InspectionCenterPage = lazy(() => import("../inspection/InspectionCenterPage"));
// 运行监控 - 3D 拓扑大屏
const MonitoringTopologyView = lazy(() => import("../monitoring/MonitoringTopologyView"));
type VaultViewLayoutContext = Record<string, any>;
@@ -123,6 +133,27 @@ function VaultConnectionLogsSection({
}
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
// 产品名称 - 直接读取 localStorage,监听 storage 事件(来自其他标签页)和自定义事件
const [productName, setProductName] = useState(() => localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh');
// 监听 productName 变化
useEffect(() => {
const handler = () => {
const newName = localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh';
console.log('[VaultViewLayout] productName changed to:', newName);
setProductName(newName);
};
// 监听 storage 事件(来自其他标签页)
window.addEventListener('storage', handler);
// 监听自定义事件(来自同一标签页的设置页面)
window.addEventListener('productNameChanged' as any, handler);
console.log('[VaultViewLayout] productName listener mounted, current:', productName);
return () => {
window.removeEventListener('storage', handler);
window.removeEventListener('productNameChanged' as any, handler);
};
}, []);
const {
Activity,
allGroupPaths,
@@ -550,7 +581,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
>
<AppLogo className="h-8 w-8 flex-shrink-0" />
{!sidebarCollapsed && (
<AppWordmark className="h-5 w-auto text-foreground" />
<span className="text-sm font-medium">{productName}</span>
)}
</button>
</TooltipTrigger>
@@ -772,6 +803,105 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</TooltipContent>
)}
</Tooltip>
{/* 分隔线 */}
<div className="h-px bg-border/60 my-2" />
{/* 巡检中心 - inspection */}
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "inspection" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "inspection" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("inspection")}
>
<Activity size={16} className="flex-shrink-0" />
{!sidebarCollapsed && "巡检中心"}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">巡检中心</TooltipContent>
)}
</Tooltip>
{/* 告警中心 - alerts */}
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "alerts" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "alerts" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("alerts")}
>
<Bell size={16} className="flex-shrink-0" />
{!sidebarCollapsed && "告警中心"}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">告警中心</TooltipContent>
)}
</Tooltip>
{/* 告警规则 - alertRules */}
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "alertRules" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "alertRules" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("alertRules")}
>
<AlertTriangle size={16} className="flex-shrink-0" />
{!sidebarCollapsed && "告警规则"}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">告警规则</TooltipContent>
)}
</Tooltip>
{/* 运行监控 - monitoring (3D 拓扑大屏) */}
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "monitoring" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed
? "justify-center p-0"
: "justify-start gap-3",
currentSection === "monitoring" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("monitoring")}
>
<Network size={16} className="flex-shrink-0" />
{!sidebarCollapsed && "运行监控"}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && (
<TooltipContent side="right">运行监控</TooltipContent>
)}
</Tooltip>
</div>
<div
@@ -1402,6 +1532,82 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</Suspense>
</LazyLoadBoundary>
)}
{/* 巡检中心 */}
{currentSection === "inspection" && (
<LazyLoadBoundary name="Inspection center" resetKey="inspection-center">
<Suspense fallback={<VaultSectionLoading />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<InspectionErrorBoundary>
<InspectionCenterPage hosts={ctx.hosts} keys={ctx.keys} />
</InspectionErrorBoundary>
</div>
</Suspense>
</LazyLoadBoundary>
)}
{/* 告警中心 */}
{currentSection === "alerts" && (
<LazyLoadBoundary name="Alert center" resetKey="alert-center">
<Suspense fallback={<VaultSectionLoading />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<InspectionErrorBoundary>
<AlertCenterPage />
</InspectionErrorBoundary>
</div>
</Suspense>
</LazyLoadBoundary>
)}
{/* 告警规则配置 */}
{currentSection === "alertRules" && (
<LazyLoadBoundary name="Alert rules" resetKey="alert-rules">
<Suspense fallback={<VaultSectionLoading />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<InspectionErrorBoundary>
<AlertRulesPage />
</InspectionErrorBoundary>
</div>
</Suspense>
</LazyLoadBoundary>
)}
{/* 运行监控 - 3D 拓扑大屏 */}
{currentSection === "monitoring" && (
<LazyLoadBoundary
name="Monitoring topology"
resetKey="monitoring-topology"
fallback={(error) => (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<div className="text-base font-medium text-foreground">Monitoring topology could not load.</div>
{error && (
<div className="w-full max-w-xl overflow-auto rounded border border-red-500/30 bg-red-500/5 p-3 text-left text-xs text-red-400">
<div className="mb-1 font-semibold">Error: {error.message}</div>
<pre className="whitespace-pre-wrap font-mono text-[10px] opacity-80">
{error.stack}
</pre>
</div>
)}
<button
type="button"
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
)}
>
<Suspense fallback={<VaultSectionLoading />}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<MonitoringTopologyView
hosts={ctx.hosts}
keys={ctx.keys}
/>
</div>
</Suspense>
</LazyLoadBoundary>
)}
</div>
{/* Group Details Panel */}