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

@@ -0,0 +1,127 @@
"use strict";
/**
* 采集器基类
*
* 所有设备采集器都继承此类,统一采集接口。
* 采集器负责:
* 1. 建立 SSH 连接
* 2. 执行巡检命令
* 3. 解析输出为结构化指标数据
* 4. 关闭连接
*
* 子类需实现:
* - getSupportedMetrics() 返回支持的指标列表
* - collectMetrics() 执行所有采集命令并返回结构化指标
*/
const { INSPECTION_STATUS } = require("../types.cjs");
class BaseAdapter {
/**
* @param {object} connectionInfo - SSH 连接信息
* @param {string} connectionInfo.host - 主机 IP
* @param {number} connectionInfo.port - 端口
* @param {string} connectionInfo.username - 用户名
* @param {string} [connectionInfo.password] - 密码
* @param {string} [connectionInfo.privateKey] - 私钥
* @param {object} [options]
* @param {number} [options.timeoutMs] - 单条命令超时(毫秒)
*/
constructor(connectionInfo, options = {}) {
this.connectionInfo = connectionInfo;
this.timeoutMs = options.timeoutMs || 15000;
this.conn = null;
}
/**
* 建立连接
* 子类可以覆盖此方法实现不同的连接方式(如 Telnet)
* @returns {Promise<boolean>}
*/
async connect() {
// 基类不实现具体连接逻辑,由上层调度器负责 SSH 连接
// 或者由子类自行实现
return true;
}
/**
* 执行单条命令
* 由子类或注入的 exec 函数实现
* @param {string} command
* @returns {Promise<string>}
*/
async execute(command) {
throw new Error("execute() must be implemented by subclass or injected");
}
/**
* 关闭连接
*/
async disconnect() {
// 子类可覆盖
}
/**
* 获取支持的指标列表
* @returns {string[]}
*/
getSupportedMetrics() {
throw new Error("getSupportedMetrics() must be implemented by subclass");
}
/**
* 执行完整巡检,返回结构化指标数据
* @returns {Promise<object>}
* {
* online: boolean,
* metrics: { ... },
* raw: { ... } // 原始输出(调试用)
* }
*/
async collect() {
const result = {
online: false,
metrics: {},
raw: {},
error: null,
status: INSPECTION_STATUS.SUCCESS,
};
try {
const metrics = await this.collectMetrics();
result.online = true;
result.metrics = metrics;
} catch (err) {
result.error = err.message;
result.status = err.message.includes("timeout")
? INSPECTION_STATUS.TIMEOUT
: INSPECTION_STATUS.FAILED;
}
return result;
}
/**
* 子类实现:具体的采集逻辑
* @returns {Promise<object>} 结构化指标数据
*/
async collectMetrics() {
throw new Error("collectMetrics() must be implemented by subclass");
}
/**
* 安全地从字符串中提取数字
* @param {string} text
* @param {RegExp} regex
* @returns {number|null}
*/
extractNumber(text, regex) {
const match = (text || "").match(regex);
if (!match) return null;
const num = parseFloat(match[1]);
return isNaN(num) ? null : num;
}
}
module.exports = BaseAdapter;

View File

@@ -0,0 +1,174 @@
"use strict";
/**
* 华为 / H3C 交换机采集器
*
* 采集指标:
* - cpu_usage CPU 使用率(%)
* - memory_usage 内存使用率(%)
* - port_status 端口状态 { up_count, down_count }
* - power_status 电源状态 { normal_count, abnormal_count }
* - fan_status 风扇状态
*
* 实现说明:
* 使用批量命令执行,一次连接执行所有命令,避免 VTY 超时
*/
const BaseAdapter = require("./baseAdapter.cjs");
class HuaweiH3CSwitchAdapter extends BaseAdapter {
constructor(connectionInfo, options = {}) {
super(connectionInfo, options);
this.adapterName = "huawei_h3c_switch";
if (options.execCommands) {
this.execCommands = options.execCommands;
}
}
async execCommands(commands, options = {}) {
const { execSshCommands } = require("../inspectionExec.cjs");
return await execSshCommands(this.connectionInfo, commands, options);
}
getSupportedMetrics() {
return [
"cpu_usage",
"memory_usage",
"port_status",
"power_status",
"fan_status",
];
}
async collectMetrics() {
const metrics = {};
const DEBUG = process.env.INSPECTION_DEBUG === "1";
if (DEBUG) console.log("[HuaweiH3C] 开始采集交换机指标");
const commands = [
"display cpu-usage",
"display memory",
"display interface brief",
"display power",
"display fan",
];
try {
const { results } = await this.execCommands(commands, { timeoutMs: 180000 });
if (DEBUG) {
console.log("[HuaweiH3C] 原始输出:");
for (let i = 0; i < results.length; i++) {
// 简化日志,不输出完整原始数据
const out = results[i]?.output || "";
console.log(`[HuaweiH3C] 命令${i + 1}: ${commands[i]} → ${out.length} 字符`);
}
}
if (results && results.length >= 5) {
metrics.cpu_usage = this.parseCpuUsage(results[0]?.output || "");
metrics.memory_usage = this.parseMemoryUsage(results[1]?.output || "");
metrics.port_status = this.parsePorts(results[2]?.output || "");
metrics.power_status = this.parsePower(results[3]?.output || "");
metrics.fan_status = this.parseFan(results[4]?.output || "");
}
if (DEBUG) console.log("[HuaweiH3C] Final:", JSON.stringify(metrics));
} catch (e) {
if (DEBUG) console.log("[HuaweiH3C] Error:", e.message);
throw e;
}
return metrics;
}
parseCpuUsage(output) {
// H3C Comware 5/7: "7% in last 5 seconds"
let m = output.match(/(\d+)\s*%\s*in\s+last\s+5\s+seconds/i);
if (m) return parseFloat(m[1]);
// 1 minute
m = output.match(/(\d+)\s*%\s*in\s+last\s+1\s+minute/i);
if (m) return parseFloat(m[1]);
// 华为格式
m = output.match(/CPU\s+Usage\s*:\s*(\d+)%/i);
if (m) return parseFloat(m[1]);
// 5 Seconds
m = output.match(/5\s*Seconds\s*[::]\s*(\d+)\s*%/i);
if (m) return parseFloat(m[1]);
return null;
}
parseMemoryUsage(output) {
// H3C: "Used Rate: 45%"
let m = output.match(/Used\s*Rate\s*[::]?\s*(\d+(?:\.\d+)?)\s*%/i);
if (m) return parseFloat(m[1]);
// 华为/其他: "Memory Using Percentage Is: 21%"
m = output.match(/Memory\s+Using\s+Percentage\s+Is\s*[::]?\s*(\d+(?:\.\d+)?)\s*%/i);
if (m) return parseFloat(m[1]);
// H3C bytes 格式: "System Total Memory(bytes): 65546160" / "Total Used Memory(bytes): 29528544"
const totalM = output.match(/System\s+Total\s+Memory\s*\(\s*bytes\s*\)\s*[::]?\s*(\d+)/i);
const usedM = output.match(/Total\s+Used\s+Memory\s*\(\s*bytes\s*\)\s*[::]?\s*(\d+)/i);
if (totalM && usedM) {
const total = parseFloat(totalM[1]);
const used = parseFloat(usedM[1]);
if (total > 0) return Math.round((used / total) * 100 * 10) / 10;
}
// MB 格式: "Memory Size: xxx MB"
const totalMB = output.match(/Memory\s*(?:Size|Total)\s*[::]?\s*(\d+)\s*MB/i);
const usedMB = output.match(/Memory\s*Used\s*[::]?\s*(\d+)\s*MB/i);
if (totalMB && usedMB) {
const total = parseFloat(totalMB[1]);
const used = parseFloat(usedMB[1]);
if (total > 0) return Math.round((used / total) * 100 * 10) / 10;
}
return null;
}
parsePorts(output) {
const lines = output.split("\n");
let upCount = 0, downCount = 0;
const ports = [];
for (const line of lines) {
// H3C 格式: "GE1/0/1 UP 100M(a)"
// 匹配 GE开头 + UP/DOWN
const geMatch = line.match(/^(GE\d+\/\d+\/\d+)\s+(UP|DOWN|up|down)/i);
if (geMatch) {
const phy = geMatch[2].toLowerCase();
ports.push({ name: geMatch[1], phy });
if (phy === "up") upCount++;
else downCount++;
continue;
}
// 匹配 GigabitEthernet 全称
const gigMatch = line.match(/^(GigabitEthernet\d+[\d\/]+)\s+(UP|DOWN|up|down)/i);
if (gigMatch) {
const phy = gigMatch[2].toLowerCase();
ports.push({ name: gigMatch[1], phy });
if (phy === "up") upCount++;
else downCount++;
}
}
return { up_count: upCount, down_count: downCount, ports };
}
parsePower(output) {
let normal = 0, abnormal = 0;
for (const line of output.split("\n")) {
if (/Normal|Supply|OK/i.test(line)) normal++;
else if (/Absent|Fault|Error/i.test(line)) abnormal++;
}
return { normal_count: normal, abnormal_count: abnormal };
}
parseFan(output) {
let normal = 0, abnormal = 0;
for (const line of output.split("\n")) {
if (/Normal|Auto|Running|OK/i.test(line)) normal++;
else if (/Fault|Absent|Error/i.test(line)) abnormal++;
}
return { normal_count: normal, abnormal_count: abnormal };
}
}
module.exports = HuaweiH3CSwitchAdapter;

View File

@@ -0,0 +1,102 @@
"use strict";
/**
* Linux 服务器采集器
*
* 采集指标(告警相关):
* - cpu_usage CPU 使用率(%)
* - memory_usage 内存使用率(%)
* - disk_usage 磁盘使用率(%,只显示超过 80% 的挂载点)
*
* 实现说明:
* 直接使用 POSIX shell 命令,命令与 NetMesh System Manager 的概览一致。
* 传入 exec 函数来执行远程命令(由调度器注入,复用 SSH 连接池)。
*/
const BaseAdapter = require("./baseAdapter.cjs");
class LinuxServerAdapter extends BaseAdapter {
constructor(connectionInfo, options = {}) {
super(connectionInfo, options);
this.adapterName = "linux_server";
// 支持外部注入 exec 函数(用于复用 SSH 连接)
if (options.execFn) {
this.execute = options.execFn;
}
}
getSupportedMetrics() {
return [
"cpu_usage",
"memory_usage",
"disk_usage",
];
}
async collectMetrics() {
const metrics = {};
const raw = {};
// 1. CPU 使用率(采样 top)
try {
raw.cpu = await this.execute("top -bn1 2>/dev/null | head -n 5");
const cpuMatch = (raw.cpu || "").match(/Cpu\(s\):\s*([\d.]+)\s*us,\s*([\d.]+)\s*sy/i);
if (cpuMatch) {
metrics.cpu_usage = Math.round(parseFloat(cpuMatch[1]) + parseFloat(cpuMatch[2]));
}
} catch (e) {
throw e; // SSH 失败或其他错误必须向上传播
}
// 2. 内存使用率
try {
raw.mem = await this.execute("free -k 2>/dev/null | head -n 2 | tail -n 1");
// 支持中英文输出: "Mem:" 或 "内存:"
const memMatch = (raw.mem || "").match(/(?:Mem:|内存[::])\s+(\d+)\s+(\d+)/);
if (memMatch) {
const total = parseInt(memMatch[1], 10);
const used = parseInt(memMatch[2], 10);
if (total > 0) {
metrics.memory_usage = Math.min(100, Math.round((used / total) * 100));
}
}
} catch (e) {
throw e;
}
// 3. 磁盘使用率(显示所有挂载点)
try {
raw.disk = await this.execute("df -h 2>/dev/null | tail -n +2 | grep -v '/dev/loop'");
const disks = this.parseDiskUsage(raw.disk);
if (disks.length > 0) metrics.disk_usage = disks;
} catch (e) {
throw e;
}
return metrics;
}
/**
* 解析磁盘使用率(df -h 输出格式),返回所有挂载点
*/
parseDiskUsage(text) {
const disks = [];
const lines = (text || "").split("\n");
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length < 6) continue;
const usageStr = parts[4] || "";
const usageMatch = usageStr.match(/^(\d+)%/);
if (!usageMatch) continue;
const usagePercent = parseInt(usageMatch[1], 10);
disks.push({
filesystem: parts[0],
usage_percent: usagePercent,
mount: parts[parts.length - 1],
});
}
return disks;
}
}
module.exports = LinuxServerAdapter;

View File

@@ -0,0 +1,237 @@
"use strict";
/**
* Windows 服务器采集器
*
* 采集指标:
* - cpu_usage CPU 使用率(%)
* - memory_usage 内存使用率(%)
* - disk_usage 磁盘使用率(%)
* - process_count 进程数
* - uptime 运行时间
* - service_status 关键服务状态
*
* 实现说明:
* 使用 PowerShell Base64 EncodedCommand 方式执行,与 NetMesh System Manager 一致。
* 传入 exec 函数执行远程命令(由调度器注入)。
*/
const BaseAdapter = require("./baseAdapter.cjs");
class WindowsServerAdapter extends BaseAdapter {
constructor(connectionInfo, options = {}) {
super(connectionInfo, options);
this.adapterName = "windows_server";
if (options.execFn) {
this.execute = options.execFn;
}
}
getSupportedMetrics() {
return [
"cpu_usage",
"memory_usage",
"disk_usage",
"process_count",
"uptime",
"service_status",
];
}
async collectMetrics() {
const metrics = {};
const raw = {};
const DEBUG = process.env.INSPECTION_DEBUG === "1";
// 1. CPU + 内存 + 运行时间(快速,独立执行)
try {
const script = this._buildFastStatsScript();
const cmd = this._buildPowerShellCmd(script);
if (DEBUG) console.log("[WinAdapter] 执行命令:", cmd);
raw.stats = await this.execute(cmd);
if (DEBUG) console.log("[WinAdapter] 原始输出:", raw.stats);
const parsed = this._parseStats(raw.stats);
if (DEBUG) console.log("[WinAdapter] 解析结果:", parsed);
Object.assign(metrics, parsed);
} catch (e) {
raw.stats_error = e.message;
if (DEBUG) console.error("[WinAdapter] 采集失败:", e.message);
}
// 2. 磁盘使用率(较慢,单独执行,超时放宽到 30s)
try {
const diskScript = this._buildDiskScript();
const diskCmd = this._buildPowerShellCmd(diskScript);
if (DEBUG) console.log("[WinAdapter] 磁盘命令:", diskCmd);
raw.disk = await this.execute(diskCmd);
if (DEBUG) console.log("[WinAdapter] 磁盘原始输出:", raw.disk);
metrics.disk_usage = this._parseDisk(raw.disk);
} catch (e) {
raw.disk_error = e.message;
if (DEBUG) console.error("[WinAdapter] 磁盘采集失败:", e.message);
}
// 3. 关键服务状态
try {
const serviceScript = this._buildServiceScript();
const cmd = this._buildPowerShellCmd(serviceScript);
if (DEBUG) console.log("[WinAdapter] 服务命令:", cmd);
raw.services = await this.execute(cmd);
if (DEBUG) console.log("[WinAdapter] 服务原始输出:", raw.services);
metrics.service_status = this._parseServices(raw.services);
} catch (e) {
metrics.service_status = [];
raw.services_error = e.message;
if (DEBUG) console.error("[WinAdapter] 服务采集失败:", e.message);
}
return metrics;
}
/**
* 快速统计:CPU + 内存(使用更快的 WMI 查询)
*/
_buildFastStatsScript() {
return `
try { $cpu = (Get-WmiObject Win32_Processor).LoadPercentage; if (-not $cpu) { $cpu = 0 } } catch { $cpu = 0 }
try { $os = Get-WmiObject Win32_OperatingSystem; $total = [math]::Round($os.TotalVisibleMemorySize/1MB,0); $free = [math]::Round($os.FreePhysicalMemory/1MB,0); $usage = if ($total -gt 0) { [math]::Round(($total-$free)/$total*100,1) } else { 0 } } catch { $total = 0; $usage = 0; $free = 0 }
@{ cpu = $cpu; mem_usage = $usage; mem_total = $total; mem_free = $free } | ConvertTo-Json -Compress
`.trim();
}
/** 磁盘使用率(只查 C: 盘) */
_buildDiskScript() {
return `
try {
$disk = Get-WmiObject Win32_LogicalDisk -Filter "DeviceID='C:'"
if ($disk -and $disk.Size -gt 0) {
$s = [math]::Round($disk.Size/1GB,1); $f = [math]::Round($disk.FreeSpace/1GB,1); $u = [math]::Round(($disk.Size-$disk.FreeSpace)/$disk.Size*100,1)
@{ d='C:'; s=$s; f=$f; u=$u } | ConvertTo-Json -Compress
} else { 'null' }
} catch { 'null' }
`.trim();
}
_parseDisk(stdout) {
try {
const json = this._extractJson(stdout);
if (!json || json === "null" || json === null) return [];
return [{
drive: json.d || "C:",
size_gb: json.s || 0,
free_gb: json.f || 0,
usage_percent: json.u || 0,
}];
} catch {
return [];
}
}
/**
* 构建 PowerShell 命令:UTF-16LE Base64 编码 + -EncodedCommand
* Node.js Buffer.from(str, "utf16le") 是最可靠的编码方式
*/
_buildPowerShellCmd(script) {
// 用 Node.js 原生 Buffer 进行 UTF-16LE 编码,再转 Base64
// UTF-16LE 编码(不带 BOM),然后 Base64
const utf16le = Buffer.from(script, "utf16le");
const encoded = utf16le.toString("base64");
return `powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
}
_buildServiceScript() {
return `
$ErrorActionPreference = 'SilentlyContinue'
# 关键服务
$services = @(
"wuauserv", # Windows Update
"Spooler", # Print Spooler
"TermService", # Remote Desktop
"DHCP", # DHCP Client
"DNScache" # DNS Client
)
$results = foreach ($svc in $services) {
$s = Get-Service -Name $svc -ErrorAction SilentlyContinue
if ($s) {
[PSCustomObject]@{
name = $s.Name
display_name = $s.DisplayName
status = $s.Status.ToString()
start_type = $s.StartType.ToString()
}
}
}
$results | ConvertTo-Json -Depth 3
`.trim();
}
_parseStats(stdout) {
try {
const json = this._extractJson(stdout);
if (!json) return {};
const result = {};
if (json.cpu !== undefined) result.cpu_usage = json.cpu;
if (json.mem_usage !== undefined) result.memory_usage = json.mem_usage;
if (json.mem_total !== undefined && json.mem_total > 0) {
result.memory = {
total_mb: json.mem_total,
used_mb: Math.round((json.mem_usage / 100) * json.mem_total),
};
}
return result;
} catch (e) {
return {};
}
}
_parseServices(stdout) {
try {
const json = this._extractJson(stdout);
if (!json) return [];
return Array.isArray(json) ? json : [json];
} catch (e) {
return [];
}
}
/**
* 从 PowerShell 输出中提取 JSON(可能有多余的警告/文本)
*/
_extractJson(text) {
if (!text) return null;
const start = text.indexOf("{");
const startArr = text.indexOf("[");
const realStart = startArr === -1 ? start : (start === -1 ? startArr : Math.min(start, startArr));
if (realStart === -1) return null;
let depth = 0;
let end = -1;
const open = text[realStart];
const close = open === "{" ? "}" : "]";
for (let i = realStart; i < text.length; i++) {
if (text[i] === open) depth++;
else if (text[i] === close) {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
if (end === -1) return null;
try {
return JSON.parse(text.substring(realStart, end + 1));
} catch (e) {
return null;
}
}
}
module.exports = WindowsServerAdapter;