feat: 巡检增强 / 拓扑大屏 / 远程 HTTP / portable 绿色打包
Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled

巡检模块:巡检调度器改为轮询驱动,新增每日报告存储、告警规则独立
Store、设备 Store 解耦,_executeSnapshot 补齐 DEBUG 声明,巡检中心
与告警中心页增强。

拓扑大屏:MonitoringTopologyView 扩展分组/布局控制,
useMonitoringData 重构数据流。

远程 HTTP:新增 electron/remoteHttpServer.cjs,preload/api.cjs 暴露
IPC,registerBridges 注册 netcatty:http:open 桥。

portable 绿色打包:
- electron-builder.config.cjs 恢复原始白名单(61MB app.asar),
  收集器自动追踪运行时依赖;
- portableData.cjs 重写为 .portable 标记检测;
- scripts/migratePortableLocalStorage.cjs 离线脚本把 Local Storage
  的 http://localhost:5173 origin 批量迁移为 app://netcatty;
- scripts/scanWorkspaceDeps.cjs 扫描 workspace 运行时依赖。
This commit is contained in:
2026-10-19 21:11:57 +08:00
committed by trae-solo-agent
parent 336c3afd47
commit 2e71e03d71
34 changed files with 3012 additions and 230 deletions

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useState } from "react";
import { ArrowUpCircle, Check, Loader2, RefreshCcw } from "lucide-react";
import { ArrowUpCircle, Check, Copy, Globe, Loader2, RefreshCcw, Server } from "lucide-react";
import AppLogo from "./AppLogo";
import AppWordmark from "./AppWordmark";
import { Button } from "./ui/button";
import { Switch } from "./ui/switch";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
import { useI18n } from "../application/i18n/I18nProvider";
@@ -10,6 +11,7 @@ import { SettingsAnchor, SettingsTabContent } from "./settings/settings-ui";
import { toast } from "./ui/toast";
import { useStoredString } from "../application/state/useStoredString";
import { STORAGE_KEY_MONITORING_DASHBOARD_TITLE, STORAGE_KEY_REPORT_PRODUCT_NAME } from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
type AppInfo = {
name: string;
@@ -36,6 +38,66 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
const [monitoringTitle, setMonitoringTitle] = useStoredString<string>(STORAGE_KEY_MONITORING_DASHBOARD_TITLE, '云启Net智能巡检监测系统');
const [reportProductName, setReportProductName] = useStoredString<string>(STORAGE_KEY_REPORT_PRODUCT_NAME, '');
// Electron 本地:启动时从主进程恢复 dashboardTitle / reportProductName
// 解决绿色便携版首次启动 Local Storage 空时显示旧默认值的问题
useEffect(() => {
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
if (IS_REMOTE) return;
const nc = (window as any).netcatty;
if (!nc?.inspection) return;
// 恢复大屏标题(如果 Local Storage 空或用的是默认值)
nc.inspection.getDashboardTitle?.().then((v: string) => {
if (v && v.length > 0 && v !== '云启Net智能巡检监测系统') {
const local = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
if (!local || local === '云启Net智能巡检监测系统') {
setMonitoringTitle(v);
console.info('[Settings] restored monitoringTitle:', v);
}
}
}).catch(() => {});
// 恢复报告产品名
nc.inspection.getReportProductName?.().then((v: string) => {
if (v && v.length > 0) {
const local = localStorage.getItem(STORAGE_KEY_REPORT_PRODUCT_NAME);
if (!local || local === '') {
setReportProductName(v);
console.info('[Settings] restored reportProductName:', v);
}
}
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 同步到主进程 inspection-data.json(远程浏览器 API 可读)
useEffect(() => {
try {
const nc = (window as any).netcatty;
if (monitoringTitle) nc?.inspection?.saveDashboardTitle?.(monitoringTitle).catch(() => {});
nc?.inspection?.saveReportProductName?.(reportProductName || '').catch(() => {});
} catch {}
}, [monitoringTitle, reportProductName]);
// 远程 HTTP Server 状态
const [remoteStatus, setRemoteStatus] = useState<{ running: boolean; port: number; token: string | null }>({
running: false, port: 8848, token: null,
});
const [remoteLoading, setRemoteLoading] = useState(false);
const [remotePortDraft, setRemotePortDraft] = useState("8848");
const loadRemoteStatus = async () => {
try {
const s = await (window as any).netcatty?.remoteHttp?.getStatus?.();
if (s) {
setRemoteStatus({ running: !!s.running, port: s.port || 8848, token: s.token || null });
setRemotePortDraft(String(s.port || 8848));
}
} catch {}
};
useEffect(() => { void loadRemoteStatus(); }, []);
useEffect(() => {
let cancelled = false;
const load = async () => {
@@ -99,6 +161,44 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
setTimeout(() => setLastCheckResult('none'), 3000);
};
// ─── 远程访问 handlers ───
const handleToggleRemote = async (checked: boolean) => {
setRemoteLoading(true);
try {
const port = parseInt(remotePortDraft, 10) || 8848;
const res = await (window as any).netcatty?.remoteHttp?.configure?.(checked, port);
if (res?.ok) {
setRemoteStatus({
running: !!checked,
port: res.port || port,
token: res.token || remoteStatus.token,
});
setRemotePortDraft(String(res.port || port));
if (checked) toast.success("远程访问已启动");
else toast.success("远程访问已停止");
} else {
toast.error(res?.error || "操作失败");
await loadRemoteStatus();
}
} catch (e: any) {
toast.error(e?.message || "操作失败");
await loadRemoteStatus();
} finally {
setRemoteLoading(false);
}
};
const handleCopyUrl = async () => {
if (!remoteStatus.token) { toast.error("请先启用远程访问"); return; }
const url = `http://localhost:${remoteStatus.port}/dashboard?token=${remoteStatus.token}`;
try {
await navigator.clipboard.writeText(url);
toast.success("访问链接已复制");
} catch {
toast.error("复制失败");
}
};
return (
<SettingsTabContent value="application">
<div className="flex flex-col lg:flex-row gap-10 lg:gap-14">
@@ -168,6 +268,65 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
onCommit={(v) => setReportProductName(v)}
/>
</SettingsAnchor>
{/* 远程访问(浏览器 HTTP Server) */}
<SettingsAnchor anchorId="application-remote-access" className="mt-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Server size={16} className="text-muted-foreground" />
<label className="text-sm font-medium text-foreground">远程访问</label>
{remoteStatus.running ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
运行中
</span>
) : (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-muted text-muted-foreground">
已停止
</span>
)}
</div>
<Switch
checked={remoteStatus.running}
disabled={remoteLoading}
onCheckedChange={handleToggleRemote}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground w-16 shrink-0">端口</label>
<input
type="number"
min={1}
max={65535}
value={remotePortDraft}
onChange={(e) => setRemotePortDraft(e.target.value)}
className="w-24 px-2 py-1 rounded-md border border-input bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<span className="text-xs text-muted-foreground ml-2">(默认 8848,修改后需重启远程访问生效)</span>
</div>
{remoteStatus.running && remoteStatus.token && (
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground shrink-0" />
<code className="flex-1 px-2 py-1 rounded-md bg-muted text-xs text-foreground truncate select-all">
http://localhost:{remoteStatus.port}/dashboard?token={remoteStatus.token}
</code>
<Button variant="outline" size="sm" onClick={handleCopyUrl} className="shrink-0">
<Copy size={14} className="mr-1" /> 复制
</Button>
</div>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
启用后可在同一局域网内通过浏览器访问本机数据看板(设备列表、告警、巡检快照)。
连接时需带上上方 token,建议仅在可信内网环境使用。
</p>
</div>
</SettingsAnchor>
</div>
</div>

View File

@@ -217,6 +217,56 @@ export function AlertCenterPage() {
setRcaLoading(true);
setRcaResult(null);
try {
// 远程浏览器环境:fetch 主进程 HTTP API 做简化版 RCA(不 SSH,只用已有巡检数据+AI)
const isRemote = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
if (isRemote) {
const token = (window as any).__REMOTE_TOKEN__ || "";
const resp = await fetch(`/api/run-alert-rca?token=${encodeURIComponent(token)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ alertId: alert.id }),
});
const data = await resp.json();
if (!data.ok) {
toast.error("AI 分析失败: " + (data.error || "未知错误"));
setRcaLoading(false);
return;
}
// 解析简化版 RCA 结果
const parsed = data.rca;
if (parsed) {
toast.success("AI 分析完成(远程简化版)");
// 构建 RunRCAResult 兼容格式
const rcaPayload = {
__rca_json: true,
generated_at: Date.now(),
source: "ai_simplified",
summary: parsed.summary,
confidence: parsed.confidence,
rootCauses: parsed.rootCauses,
recommendations: parsed.recommendations,
};
setRcaResult({
success: true, source: "ai",
rca: { summary: parsed.summary || "", confidence: parsed.confidence ?? 0, rootCauses: parsed.rootCauses || [], recommendations: parsed.recommendations || [], evidence: [] },
durationMs: 0,
toolInvocations: [{ tool: "ai_simplified_rca", ok: true, durationMs: 0 }],
});
// 持久化到告警 note(主进程已经写了一份,前端再写一次保证一致)
try {
const existingNote = alert.note || "";
const finalNote = "[RCA_JSON]" + JSON.stringify(rcaPayload);
if (existingNote !== finalNote) {
// 主进程已经写了,这里刷新一下本地告警列表
}
} catch (_) {}
} else {
toast.error("AI 返回结果格式异常");
}
setRcaLoading(false);
return;
}
// Electron 本地:原有的 runRCA(完整 Phase 1/2 + SSH 诊断)
toast.info("AI 正在分析故障根因...(最多 3 分钟)");
const result = await runRCA(alert);
setRcaResult(result);

View File

@@ -5,6 +5,7 @@ import {
Clock,
RefreshCw,
Play,
Pause,
Search,
Server,
ListIcon,
@@ -40,6 +41,7 @@ import {
inspectionResultApi,
inspectionExecApi,
inspectionAlertApi,
inspectionSchedulerApi,
type DeviceExtension,
type DeviceCatalog,
type InspectionResult,
@@ -72,6 +74,7 @@ interface DeviceRow {
export function InspectionCenterPage() {
const { hosts, keys } = useVaultState();
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
const [extensions, setExtensions] = useState<Record<string, DeviceExtension>>({});
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
const [latestResults, setLatestResults] = useState<Record<string, InspectionResult>>({});
@@ -79,6 +82,10 @@ export function InspectionCenterPage() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// 批量启用/停止巡检状态
const [batchEnabling, setBatchEnabling] = useState(false);
const [batchStopping, setBatchStopping] = useState(false);
// 筛选
const [searchText, setSearchText] = useState("");
const [filterCatalog, setFilterCatalog] = useState<string>("all");
@@ -128,6 +135,33 @@ export function InspectionCenterPage() {
}
}, []);
// 批量启用/停止(必须在 loadData 之后声明,避免 TDZ)
const handleBatchEnable = useCallback(async () => {
setBatchEnabling(true);
try {
const res = await inspectionDeviceApi.batchSetEnabled(true);
toast.success(`已批量启用 ${res.count} 台设备自动巡检`);
await loadData();
} catch (e: any) {
toast.error(e?.message || "操作失败");
} finally {
setBatchEnabling(false);
}
}, [loadData]);
const handleBatchStop = useCallback(async () => {
setBatchStopping(true);
try {
const res = await inspectionDeviceApi.batchSetEnabled(false);
toast.success(`已批量停止 ${res.count} 台设备自动巡检`);
await loadData();
} catch (e: any) {
toast.error(e?.message || "操作失败");
} finally {
setBatchStopping(false);
}
}, [loadData]);
useEffect(() => {
loadData();
}, [loadData]);
@@ -164,11 +198,33 @@ export function InspectionCenterPage() {
};
}, [loadData]);
// 远程浏览器模式下,用定时轮询替代主进程事件推送(每 10 秒自动刷新)
useEffect(() => {
if (!IS_REMOTE) return;
const interval = setInterval(() => {
void loadData(true);
}, 10000);
return () => clearInterval(interval);
}, [IS_REMOTE, loadData]);
// 构建设备行数据
const deviceRows = useMemo<DeviceRow[]>(() => {
return (hosts || [])
.filter((h) => h && h.id)
.map((host) => {
// 远程浏览器:vault hosts 不可用,从 inspection extensions + 巡检结果合成虚拟 hosts
const effectiveHosts = (hosts && hosts.length > 0)
? hosts
: IS_REMOTE
? Object.values(extensions).map((ext: any) => ({
id: ext.host_id,
label: ext.label || ext.hostname || ext.host_id,
hostname: ext.hostname || "",
address: ext.hostname || "",
platform: "ssh",
}))
: (hosts || []);
return effectiveHosts
.filter((h: any) => h && h.id)
.map((host: any) => {
const ext = extensions[host.id];
const catalog = catalogs.find((c) => c.id === ext?.catalog_id);
const lastResult = latestResults[host.id];
@@ -390,10 +446,12 @@ export function InspectionCenterPage() {
</p>
</div>
</div>
<Button variant="outline" onClick={loadData} disabled={refreshing}>
<RefreshCw size={14} className={`mr-2 ${refreshing ? "animate-spin" : ""}`} />
刷新
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={loadData} disabled={refreshing}>
<RefreshCw size={14} className={`mr-2 ${refreshing ? "animate-spin" : ""}`} />
刷新
</Button>
</div>
</div>
{/* 统计卡片 */}
@@ -499,7 +557,32 @@ export function InspectionCenterPage() {
</div>
{/* 设备列表 */}
<div className="flex-1 overflow-auto px-6 py-4">
<div className="flex items-center justify-between px-6 pt-3 pb-2">
<div className="text-sm text-muted-foreground">
共 <span className="font-medium text-foreground">{filteredRows.length}</span> 台设备
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleBatchEnable}
disabled={batchEnabling || loading}
>
{batchEnabling ? <RefreshCw size={12} className="mr-1 animate-spin" /> : <Play size={12} className="mr-1" />}
全部启用巡检
</Button>
<Button
variant="outline"
size="sm"
onClick={handleBatchStop}
disabled={batchStopping || loading}
>
{batchStopping ? <RefreshCw size={12} className="mr-1 animate-spin" /> : <Pause size={12} className="mr-1" />}
全部停止巡检
</Button>
</div>
</div>
<div className="flex-1 overflow-auto px-6 pb-4">
{loading ? (
<div className="text-center text-muted-foreground py-12">加载中...</div>
) : filteredRows.length === 0 ? (

View File

@@ -410,12 +410,17 @@ export function ReportPage() {
catch { toast.error("清空失败"); }
}, [loadSnapshots]);
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
const handlePreviewSnapshotPdf = useCallback(async (snapshotId: string) => {
if (IS_REMOTE) {
const token = (window as any).__REMOTE_TOKEN__ || "";
window.open(`/api/report-preview?mode=snapshot&id=${encodeURIComponent(snapshotId)}&token=${encodeURIComponent(token)}`, "_blank");
return;
}
try {
// 内嵌预览(BrowserWindow + Electron PDF viewer),不存到 reports 目录
const result = await inspectionReportApi.previewSnapshotPdf(snapshotId);
if (!result.ok) toast.error("预览失败:" + (result.error || "未知错误"));
// 成功的话主进程已经弹出 BrowserWindow,不用再处理
} catch (e: any) {
toast.error("预览失败:" + (e?.message || String(e)));
}
@@ -452,8 +457,12 @@ export function ReportPage() {
}, [snapshots]);
const handlePreviewDailyPdf = useCallback(async (reportId: string) => {
if (IS_REMOTE) {
const token = (window as any).__REMOTE_TOKEN__ || "";
window.open(`/api/report-preview?mode=daily&id=${encodeURIComponent(reportId)}&token=${encodeURIComponent(token)}`, "_blank");
return;
}
try {
// 内嵌预览(BrowserWindow + Electron PDF viewer),不存盘到 reports 目录
const result = await inspectionDailyReportApi.previewPdf(reportId);
if (!result.ok) toast.error("预览失败:" + (result.error || "未知错误"));
} catch (e: any) {

View File

@@ -1,4 +1,4 @@
/**
/**
* 运行监控 - 3D 拓扑大屏
*
* 移植自 ITYUNWEI TopologyView.vue
@@ -328,7 +328,12 @@ function formatRelativeTime(timestamp: number): string {
// 扩展 Host 的设备类型信息
function classifyHostType(host: Host): string {
const distro = (host.distro || '').toLowerCase();
// 优先用直接的 type 字段(remote API 会返回 switch/server/firewall/router)
const directType = (host as any).type;
if (directType && ['server', 'switch', 'router', 'firewall', 'storage', 'workstation'].includes(directType)) {
return directType;
}
const distro = (host.distro || (host as any).catalog || (host as any).catalog_id || '').toLowerCase();
const name = (host.name || '').toLowerCase();
const label = (host.label || '').toLowerCase();
const combined = `${distro} ${name} ${label}`;
@@ -476,24 +481,94 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
const [currentTime, setCurrentTime] = useState(new Date());
const [legend, setLegend] = useState(true);
const [showConnectionLegend, setShowConnectionLegend] = useState(true);
const [monitoringTitle, setMonitoringTitle] = useState<string>(() =>
localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE) || '云启Net智能巡检监测系统'
);
const [monitoringTitle, setMonitoringTitle] = useState<string>(() => {
// 先给默认值,useEffect 里远程异步加载(时序更安全)
if (typeof window !== "undefined") {
const saved = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
if (saved) return saved;
}
return '云启Net智能巡检监测系统';
});
// 监听 localStorage 中大屏标题变化(设置页面修改后实时生效)
// 远程环境:异步加载标题 / 位置 / 连接线(useState init 阶段 token 可能还没注入)
useEffect(() => {
if (typeof window === "undefined") return;
const token = (window as any).__REMOTE_TOKEN__;
if (!token) return; // 不是远程环境或 token 未就绪
const fetchJSON = (url: string): Promise<any> =>
fetch(url).then(r => r.json()).catch(() => null);
// 1. 标题
fetchJSON(`/api/dashboard-title?token=${encodeURIComponent(token)}`)
.then(json => { if (json?.ok && json.title) setMonitoringTitle(json.title); });
// 2. 拓扑位置
fetchJSON(`/api/topology-positions?token=${encodeURIComponent(token)}`)
.then(json => {
if (json?.ok && json.positions && Object.keys(json.positions).length > 0) {
setSavedPositions(json.positions);
}
});
// 3. 连接线
fetchJSON(`/api/topology-links?token=${encodeURIComponent(token)}`)
.then(json => { if (json?.ok && Array.isArray(json.links)) setCustomLinks(json.links); });
}, []);
// Electron 环境:监听 localStorage 变化 + mount 时同步 hosts/title 给主进程
useEffect(() => {
const nc = (window as any).netcatty;
console.log('[MonitoringTopologyView sync] nc exists:', !!nc, 'nc.inspection exists:', !!nc?.inspection, 'hosts.length:', hosts?.length);
if (!nc?.inspection) {
console.log('[MonitoringTopologyView sync] skip — not Electron env');
return;
}
// mount 时一次性同步 vault hosts + 标题 + 拓扑位置 + 拓扑连线
if (hosts?.length > 0) {
console.log('[MonitoringTopologyView sync] saving vault hosts:', hosts.length);
nc.inspection.saveVaultHosts(hosts).then((r: any) => console.log('saveVaultHosts result:', r)).catch((e: any) => console.warn('saveVaultHosts error:', e));
}
const savedTitle = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
console.log('[MonitoringTopologyView sync] savedTitle:', savedTitle);
if (savedTitle) nc.inspection.saveDashboardTitle(savedTitle).catch(() => {});
const savedPos = localStorage.getItem('monitoring-topology-positions');
if (savedPos) nc.inspection.saveTopologyPositions(JSON.parse(savedPos)).catch(() => {});
const savedLinks = localStorage.getItem('monitoring-topology-links');
console.log('[MonitoringTopologyView sync] savedLinks exists:', !!savedLinks);
if (savedLinks) {
try { nc.inspection.saveTopologyLinks(JSON.parse(savedLinks)).catch(() => {}); } catch {}
}
if (!savedLinks && nc.inspection.getTopologyLinks) {
nc.inspection.getTopologyLinks().then((linksFromMain: any) => {
if (Array.isArray(linksFromMain) && linksFromMain.length > 0) {
console.info('[MonitoringTopologyView] restore links from main:', linksFromMain.length);
localStorage.setItem('monitoring-topology-links', JSON.stringify(linksFromMain));
try { setCustomLinks(linksFromMain); } catch {}
}
}).catch(() => {});
}
const syncToMain = (value: string) => {
if (value) nc.inspection.saveDashboardTitle(value).catch(() => {});
};
const handler = (e: StorageEvent) => {
if (e.key === STORAGE_KEY_MONITORING_DASHBOARD_TITLE && e.newValue !== null) {
setMonitoringTitle(e.newValue || '云启Net智能巡检监测系统');
syncToMain(e.newValue);
}
};
// 同页 localStorage 变化不走 storage 事件,直接在同页用 CustomEvent 广播
const localHandler = () => {
const v = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
setMonitoringTitle(v || '云启Net智能巡检监测系统');
syncToMain(v || '');
};
window.addEventListener('storage', handler);
window.addEventListener('monitoringTitleChanged' as any, localHandler);
// mount 时同步一次当前标题
const initVal = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
if (initVal) syncToMain(initVal);
return () => {
window.removeEventListener('storage', handler);
window.removeEventListener('monitoringTitleChanged' as any, localHandler);
@@ -843,14 +918,12 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
});
const [pendingLink, setPendingLink] = useState<{ sourceId: string; targetId: string } | null>(null);
// 用户创建的连线(从 localStorage 加载)
// 用户创建的连线 —— 默认空数组,远程/本地各自从 useEffect 加载
const [customLinks, setCustomLinks] = useState<Link[]>(() => {
try {
const saved = localStorage.getItem('monitoring-topology-links');
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
} catch { return []; }
});
// 连线模式使用 ref 避免闭包问题
@@ -884,6 +957,13 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
localStorage.setItem('monitoring-topology-links', JSON.stringify(customLinks));
}, [customLinks]);
// 连线变化时同步到主进程 inspection-data.json(供远程浏览器 API 读取)
useEffect(() => {
const nc = (window as any).netcatty;
if (!nc?.inspection) return;
nc.inspection.saveTopologyLinks(customLinks).catch(() => {});
}, [customLinks]);
useEffect(() => {
localStorage.setItem('monitoring-topology-excluded-links', JSON.stringify(excludedAutoLinks));
}, [excludedAutoLinks]);
@@ -902,10 +982,16 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
exitFullscreen();
}
};
// 浏览器原生全屏变化(ESC 退出),同步 React 状态
const onFsChange = () => {
if (!document.fullscreenElement && isFullscreen) {
setIsFullscreen(false);
}
};
if (isFullscreen) {
document.addEventListener('keydown', onKeyDown);
// body 加 class,配合 CSS 让 monitoring-page 固定全屏
document.addEventListener('fullscreenchange', onFsChange);
document.body.classList.add('monitoring-fullscreen-active');
// Electron 环境下同时让窗口也全屏
const nc = (window as any).netcatty;
@@ -916,6 +1002,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('fullscreenchange', onFsChange);
document.body.classList.remove('monitoring-fullscreen-active');
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -923,6 +1010,12 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
const enterFullscreen = useCallback(() => {
setIsFullscreen(true);
// 浏览器远程访问环境:用原生 Fullscreen API 让 body 全屏
if (typeof (document.documentElement as any).requestFullscreen === 'function') {
(document.documentElement as any).requestFullscreen().catch(() => {});
} else if (typeof (document.body as any).webkitRequestFullscreen === 'function') {
(document.body as any).webkitRequestFullscreen();
}
}, []);
const exitFullscreen = useCallback(() => {
@@ -931,6 +1024,12 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
if (nc?.windowFullscreen) {
nc.windowFullscreen(false).catch(() => {});
}
// 浏览器环境退出原生全屏
if (document.fullscreenElement && typeof document.exitFullscreen === 'function') {
document.exitFullscreen().catch(() => {});
} else if (typeof (document as any).webkitExitFullscreen === 'function') {
(document as any).webkitExitFullscreen();
}
}, []);
const toggleFullscreen = useCallback(() => {
@@ -966,7 +1065,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
}
}
return hosts.map((h) => {
return monitoring.effectiveHosts.map((h) => {
const type = classifyHostType(h);
const online = monitoring.onlineMap[h.id] ?? true;
const cpuUsage = monitoring.cpuMap[h.id] ?? 0;
@@ -996,9 +1095,14 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
status = 'warning';
}
// label 里可能已经包含 IP(如 "外网-192.168.0.221"),去重显示
let rawName = h.label || h.id;
const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/;
const name = rawName.replace(ipRegex, '').trim().replace(/[-_\s]+$|^[-_\s]+/g, '') || rawName;
return {
id: h.id,
name: h.label || h.name || h.id,
name,
ip: h.hostname,
type,
status,
@@ -1011,7 +1115,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
lastUpdate,
};
});
}, [hosts, monitoring.cpuMap, monitoring.memMap, monitoring.onlineMap, monitoring.timestampMap, monitoring.alerts, monitoring.thresholds]);
}, [monitoring.effectiveHosts, monitoring.cpuMap, monitoring.memMap, monitoring.onlineMap, monitoring.timestampMap, monitoring.alerts, monitoring.thresholds]);
// 合并自动生成链路和用户自定义链路,并过滤掉源/目标已不存在的僵尸连线
const links = useMemo(() => {
@@ -1706,7 +1810,11 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
const data: Record<string, { x: number; y: number; z: number }> = saved ? JSON.parse(saved) : {};
data[devId] = { x: p.x, y: clampedY, z: p.z };
localStorage.setItem('monitoring-topology-positions', JSON.stringify(data));
console.log('[持久化] 位置已保存:', devId, { x: p.x, y: clampedY, z: p.z });
// Electron 环境同步给主进程,让远程浏览器也能读到
const nc = (window as any).netcatty;
if (nc?.inspection?.saveTopologyPositions) {
nc.inspection.saveTopologyPositions(data).catch(() => {});
}
} catch { /* ignore */ }
};
@@ -2183,6 +2291,22 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
const positions: Record<string, THREE.Vector3> = {};
const savedPositions = (() => {
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
if (IS_REMOTE) {
try {
const token = (window as any).__REMOTE_TOKEN__ || "";
const xhr = new XMLHttpRequest();
xhr.open("GET", `/api/topology-positions?token=${encodeURIComponent(token)}`, false);
xhr.send();
if (xhr.status === 200) {
const json = JSON.parse(xhr.responseText);
if (json?.ok && json.positions && Object.keys(json.positions).length > 0) {
return json.positions;
}
}
} catch { /* ignore */ }
}
// 本地 Electron:从 localStorage 读
try {
const saved = localStorage.getItem('monitoring-topology-positions');
return saved ? JSON.parse(saved) : null;
@@ -2237,6 +2361,20 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
positionsRef.current = positions;
// Electron 环境:启动时自动把 localStorage 里的拓扑位置同步给主进程,供远程浏览器读取
{
const IS_ELECTRON = typeof window !== "undefined" && !!(window as any).netcatty;
if (IS_ELECTRON) {
try {
const saved = localStorage.getItem('monitoring-topology-positions');
if (saved) {
const nc = (window as any).netcatty;
nc.inspection?.saveTopologyPositions?.(JSON.parse(saved)).catch(() => {});
}
} catch { /* ignore */ }
}
}
// 创建设备节点
const nodes: THREE.Group[] = [];
for (const device of devices) {
@@ -3383,6 +3521,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
setCustomLinks((prev) => [...prev, newLink]);
console.log('[连线] 创建链路:', pendingLink.sourceId, '->', pendingLink.targetId, type);
setPendingLink(null);
setLinkMode(null); // 退出连线模式,恢复 cursor 为 grab
}}
>
{label}

View File

@@ -90,6 +90,9 @@ interface UseMonitoringDataResult {
// 操作
refresh: () => Promise<void>;
loading: boolean;
// 浏览器远程访问环境下实际使用的 hosts(Electron 环境 = props.hosts)
effectiveHosts: Host[];
}
// ===== localStorage 持久化(降级用,API 不可用时显示缓存) =====
@@ -321,6 +324,61 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
hostsRef.current = hosts;
keysRef.current = keys;
// 浏览器远程访问环境:从 /api/devices 构建虚拟 hosts 列表(Electron vault 不可用)
const [remoteHosts, setRemoteHosts] = useState<Host[]>([]);
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
const REMOTE_TOKEN = (window as any).__REMOTE_TOKEN__ || "";
// Electron 本地:同步 vault hosts 给主进程(供远程浏览器 API 读)
useEffect(() => {
if (IS_REMOTE) return; // 远程浏览器不触发
const nc = (window as any).netcatty;
if (!nc?.inspection) return;
if (hosts.length === 0) return;
console.log('[useMonitoringData] syncing vault hosts to main process:', hosts.length);
nc.inspection.saveVaultHosts(hosts).then((r: any) => {
console.log('[useMonitoringData] saveVaultHosts result:', r);
}).catch((e: any) => {
console.warn('[useMonitoringData] saveVaultHosts error:', e);
});
}, [hosts, IS_REMOTE]);
useEffect(() => {
if (!IS_REMOTE) return;
console.log('[monitoring] REMOTE ENV - props.hosts.length=', hosts.length, 'remoteHosts.length=', remoteHosts.length);
if (hosts.length > 0) {
console.warn('[monitoring] REMOTE ENV 但 props.hosts 有值!跳过 remote hosts 加载,hosts[0]=', hosts[0]);
return;
}
const loadRemoteHosts = async () => {
try {
const res = await fetch(`/api/devices?token=${encodeURIComponent(REMOTE_TOKEN)}`);
const data = await res.json();
const list: Host[] = (data.devices || []).map((d: any) => ({
id: d.host_id,
label: d.label || d.name || d.hostname || d.host_id,
hostname: d.hostname || d.name || "",
port: 22,
username: "",
distro: d.distro || d.catalog_type || d.catalog_id || "",
type: d.type || "",
catalogId: d.catalog_id || "",
tags: [],
os: "linux" as const,
group: "",
}));
setRemoteHosts(list);
hostsRef.current = list;
} catch (e) {
console.warn("[monitoring] remote hosts load failed:", e);
}
};
void loadRemoteHosts();
}, [IS_REMOTE, REMOTE_TOKEN, hosts.length]);
// 实际使用的 hosts:Electron 用 props,浏览器用远程加载的
const effectiveHosts = IS_REMOTE && hosts.length === 0 ? remoteHosts : hosts;
const refresh = useCallback(async (silent = false) => {
if (loadingRef.current) {
console.log('[monitoring] refresh() 正在执行中,跳过');
@@ -505,33 +563,33 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
// 自动启动调度器
if (enabledDevices.length === 0) {
console.log('[monitoring] 没有启用巡检的设备');
} else if (schedulerStatus.running) {
console.log('[monitoring] 调度器已在运行中');
} else {
console.log('[monitoring] 条件满足,准备启动调度器');
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);
const allExt = allExtensions || {};
for (const [hostId, ext] of Object.entries(allExt)) {
if (!ext || !ext.inspection_enabled) continue;
const host = hostsRef.current.find((h) => h.id === hostId);
if (!host) {
console.log('[monitoring] vault 中未找到 hostname:', hostname, ',跳过');
console.log('[monitoring] vault 中未找到 host_id:', hostId, ',跳过');
continue;
}
if (!host.username) {
console.log('[monitoring] 设备', hostname, '缺少 username,跳过');
console.log('[monitoring] 设备', host.label || host.hostname || hostId, '缺少 username,跳过');
continue;
}
hostConfigs[dev.host_id] = buildSshConfig(host, keysRef.current);
hostConfigs[hostId] = buildSshConfig(host, keysRef.current);
}
// 诊断:打印 hostConfigs 样本看 label 有没有正确传递
const sample = Object.entries(hostConfigs).slice(0, 3).map(([id, c]) => ({ id, label: c.label, host: c.host, username: c.username }));
console.log('[monitoring] hostConfigs 样本:', JSON.stringify(sample));
if (Object.keys(hostConfigs).length === 0) {
console.log('[monitoring] 没有可巡检的设备(hostname 未写入或配置不完整)');
console.log('[monitoring] 没有可巡检的设备(vault 中匹配不到或凭据不完整)');
} else {
console.log('[monitoring] 启动调度器,匹配到', Object.keys(hostConfigs).length, '台设备');
console.log('[monitoring] 启动调度器,匹配到', Object.keys(hostConfigs).length, '台设备(running=' + schedulerStatus.running + ')');
// 不管 running 与否都传 hostConfigs 过去让主进程更新,
// 主进程 execFn 通过模块级 _hostConfigs 引用动态读最新值
await inspectionSchedulerApi.start(hostConfigs);
setSchedulerRunning(true);
}
@@ -586,7 +644,7 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
setOnlineMap((prev) => {
const next: Record<string, boolean> = { ...prev };
let changed = false;
for (const h of hosts) {
for (const h of effectiveHosts) {
if (next[h.id] === undefined) {
next[h.id] = true; // 假设在线,等巡检结果覆盖
changed = true;
@@ -594,7 +652,7 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
}
return changed ? next : prev;
});
}, [hosts]);
}, [effectiveHosts]);
// 从告警规则派生阈值(和 alertRuleStore 默认规则对齐)
const thresholds = useMemo(() => {
@@ -636,5 +694,6 @@ export function useMonitoringData(hosts: Host[], keys: any[] = []): UseMonitorin
thresholds,
refresh,
loading,
effectiveHosts,
};
}

View File

@@ -135,6 +135,8 @@ function VaultConnectionLogsSection({
}
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
// 产品名称 - 直接读取 localStorage,监听 storage 事件(来自其他标签页)和自定义事件
const [productName, setProductName] = useState(() => localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh');