Parser / archive: - Add .sds extension as tar-format alias (archive.go) - Add tests for multipart upload size limits (multipart_limits_test.go) - Remove supermicro crashdump parser (ADL-015) Dell parser: - Remove GPU duplicates from PCIeDevices (DCIM_VideoView vs DCIM_PCIDeviceView both list the same GPU; VideoView record is authoritative) Server: - Add LOGPILE_CONVERT_MAX_MB env var for independent convert batch size limit - Improve "file too large" error message with current limit value Web: - Add CONVERT_MAX_FILES_PER_BATCH = 1000 cap - Minor UI copy and CSS fixes Bible: - bible-local/06-parsers.md: add pci.ids enrichment rule (enrich model from pciids when name is empty but vendor_id+device_id are present) - Sync bible submodule and local overview/architecture docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3517 lines
95 KiB
Go
3517 lines
95 KiB
Go
// Package h3c provides parser for H3C SDS diagnostic archives.
|
|
package h3c
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/logpile/internal/models"
|
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
|
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
|
|
)
|
|
|
|
const (
|
|
parserVersionG5 = "2.1"
|
|
parserVersionG6 = "2.1"
|
|
)
|
|
|
|
func init() {
|
|
parser.Register(&G5Parser{})
|
|
parser.Register(&G6Parser{})
|
|
}
|
|
|
|
// G5Parser implements VendorParser for H3C G5 SDS archives.
|
|
type G5Parser struct{}
|
|
|
|
func (p *G5Parser) Name() string { return "H3C SDS Parser G5" }
|
|
func (p *G5Parser) Vendor() string { return "h3c_g5" }
|
|
func (p *G5Parser) Version() string { return parserVersionG5 }
|
|
func (p *G5Parser) Detect(files []parser.ExtractedFile) int {
|
|
return detectH3CG5(files)
|
|
}
|
|
func (p *G5Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
|
|
return parseH3CG5(files), nil
|
|
}
|
|
|
|
// G6Parser implements VendorParser for H3C G6 SDS archives.
|
|
type G6Parser struct{}
|
|
|
|
func (p *G6Parser) Name() string { return "H3C SDS Parser G6" }
|
|
func (p *G6Parser) Vendor() string { return "h3c_g6" }
|
|
func (p *G6Parser) Version() string { return parserVersionG6 }
|
|
func (p *G6Parser) Detect(files []parser.ExtractedFile) int {
|
|
return detectH3CG6(files)
|
|
}
|
|
func (p *G6Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
|
|
return parseH3CG6(files), nil
|
|
}
|
|
|
|
func detectH3CG5(files []parser.ExtractedFile) int {
|
|
confidence := 0
|
|
for _, f := range files {
|
|
path := strings.ToLower(f.Path)
|
|
switch {
|
|
case path == "bmc/pack.info":
|
|
confidence += 6
|
|
case strings.HasSuffix(path, "/fruinfo.ini") || strings.HasSuffix(path, "fruinfo.ini"):
|
|
confidence += 10
|
|
case strings.HasSuffix(path, "/hardware_info.ini") || strings.HasSuffix(path, "hardware_info.ini"):
|
|
confidence += 35
|
|
case strings.HasSuffix(path, "/hardware.info") || strings.HasSuffix(path, "hardware.info"):
|
|
confidence += 24
|
|
case strings.HasSuffix(path, "/firmware_version.ini") || strings.HasSuffix(path, "firmware_version.ini"):
|
|
confidence += 20
|
|
case strings.HasSuffix(path, "/test.csv") || strings.HasSuffix(path, "/test1.csv"):
|
|
confidence += 20
|
|
case strings.HasSuffix(path, "/raid.json") || strings.HasSuffix(path, "raid.json"):
|
|
confidence += 8
|
|
}
|
|
|
|
if strings.Contains(path, "fruinfo.ini") && containsH3CMarkers(f.Content) {
|
|
confidence += 10
|
|
}
|
|
if confidence >= 100 {
|
|
return 100
|
|
}
|
|
}
|
|
return minInt(confidence, 100)
|
|
}
|
|
|
|
func detectH3CG6(files []parser.ExtractedFile) int {
|
|
confidence := 0
|
|
for _, f := range files {
|
|
path := strings.ToLower(f.Path)
|
|
switch {
|
|
case path == "bmc/pack.info":
|
|
confidence += 10
|
|
case strings.HasSuffix(path, "/fruinfo.ini") || strings.HasSuffix(path, "fruinfo.ini"):
|
|
confidence += 12
|
|
case strings.HasSuffix(path, "/board_info.ini") || strings.HasSuffix(path, "board_info.ini"):
|
|
confidence += 14
|
|
case strings.HasSuffix(path, "/firmware_version.json") || strings.HasSuffix(path, "firmware_version.json"):
|
|
confidence += 24
|
|
case strings.HasSuffix(path, "/cpudetailinfo.xml") || strings.HasSuffix(path, "cpudetailinfo.xml"):
|
|
confidence += 22
|
|
case strings.HasSuffix(path, "/memorydetailinfo.xml") || strings.HasSuffix(path, "memorydetailinfo.xml"):
|
|
confidence += 22
|
|
case strings.HasSuffix(path, "/sel.json") || strings.HasSuffix(path, "sel.json"):
|
|
confidence += 18
|
|
case strings.HasSuffix(path, "/sensor_info.ini") || strings.HasSuffix(path, "sensor_info.ini"):
|
|
confidence += 8
|
|
}
|
|
|
|
if (strings.Contains(path, "board_info.ini") || strings.Contains(path, "fruinfo.ini")) && containsH3CMarkers(f.Content) {
|
|
confidence += 10
|
|
}
|
|
if confidence >= 100 {
|
|
return 100
|
|
}
|
|
}
|
|
return minInt(confidence, 100)
|
|
}
|
|
|
|
func containsH3CMarkers(content []byte) bool {
|
|
s := strings.ToLower(string(content))
|
|
return strings.Contains(s, "h3c") || strings.Contains(s, "new h3c technologies")
|
|
}
|
|
|
|
func newAnalysisResult() *models.AnalysisResult {
|
|
return &models.AnalysisResult{
|
|
Events: make([]models.Event, 0),
|
|
FRU: make([]models.FRUInfo, 0),
|
|
Sensors: make([]models.SensorReading, 0),
|
|
Hardware: &models.HardwareConfig{
|
|
Firmware: make([]models.FirmwareInfo, 0),
|
|
CPUs: make([]models.CPU, 0),
|
|
Memory: make([]models.MemoryDIMM, 0),
|
|
Storage: make([]models.Storage, 0),
|
|
Volumes: make([]models.StorageVolume, 0),
|
|
Devices: make([]models.HardwareDevice, 0),
|
|
NetworkCards: make([]models.NIC, 0),
|
|
NetworkAdapters: make([]models.NetworkAdapter, 0),
|
|
PowerSupply: make([]models.PSU, 0),
|
|
},
|
|
}
|
|
}
|
|
|
|
func parseH3CG5(files []parser.ExtractedFile) *models.AnalysisResult {
|
|
result := newAnalysisResult()
|
|
firmwareSeen := make(map[string]struct{})
|
|
|
|
if f := parser.FindFileByName(files, "FRUInfo.ini"); f != nil {
|
|
parseFRUInfoINI(f.Content, result)
|
|
}
|
|
if f := parser.FindFileByName(files, "board_info.ini"); f != nil {
|
|
parseBoardInfoINI(f.Content, result, firmwareSeen)
|
|
}
|
|
if f := parser.FindFileByName(files, "board_cfg.ini"); f != nil {
|
|
parseBoardCfgINI(f.Content, result)
|
|
}
|
|
if f := parser.FindFileByName(files, "firmware_version.ini"); f != nil {
|
|
parseFirmwareVersionINI(f.Content, result, firmwareSeen)
|
|
}
|
|
if f := parser.FindFileByName(files, "hardware_info.ini"); f != nil {
|
|
parseHardwareInfoINI(f.Content, result)
|
|
}
|
|
if f := parser.FindFileByName(files, "hardware.info"); f != nil {
|
|
appendUniqueStorages(&result.Hardware.Storage, parseHardwareInfoStorageINI(f.Content))
|
|
}
|
|
if f := parser.FindFileByName(files, "storage_disk.ini"); f != nil {
|
|
appendUniqueStorages(&result.Hardware.Storage, parseStorageINI(f.Content))
|
|
}
|
|
if f := parser.FindFileByName(files, "raid.json"); f != nil {
|
|
result.Hardware.Volumes = append(result.Hardware.Volumes, parseRAIDJSONVolumes(f.Content)...)
|
|
}
|
|
for _, f := range files {
|
|
path := strings.ToLower(f.Path)
|
|
if strings.Contains(path, "storage_raid-") && strings.HasSuffix(path, ".txt") {
|
|
storages, volumes := parseRAIDDetailTXT(f.Content)
|
|
appendUniqueStorages(&result.Hardware.Storage, storages)
|
|
result.Hardware.Volumes = append(result.Hardware.Volumes, volumes...)
|
|
}
|
|
}
|
|
if f := parser.FindFileByName(files, "NVMe_info.txt"); f != nil {
|
|
appendUniqueStorages(&result.Hardware.Storage, parseNVMeInfo(f.Content))
|
|
}
|
|
if f := parser.FindFileByName(files, "Raid_BP_Conf_Info.ini"); f != nil {
|
|
result.Hardware.Devices = append(result.Hardware.Devices, parseRaidBPConfDevices(f.Content)...)
|
|
}
|
|
if f := parser.FindFileByName(files, "psu_cfg.ini"); f != nil {
|
|
appendUniquePSUs(&result.Hardware.PowerSupply, parsePSUCfgINI(f.Content))
|
|
}
|
|
if f := parser.FindFileByName(files, "net_cfg.ini"); f != nil {
|
|
adapters := parseNetCfgNetworkAdapters(f.Content)
|
|
if len(result.Hardware.NetworkAdapters) == 0 {
|
|
appendUniqueNetworkAdapters(&result.Hardware.NetworkAdapters, adapters)
|
|
appendUniqueNICs(&result.Hardware.NetworkCards, networkCardsFromAdapters(adapters))
|
|
}
|
|
}
|
|
enrichStorageWithSmartdata(&result.Hardware.Storage, files)
|
|
if f := parser.FindFileByName(files, "PCIe_arguments_table.xml"); f != nil {
|
|
enrichStorageFromPCIeArguments(&result.Hardware.Storage, f.Content)
|
|
}
|
|
if f := parser.FindFileByName(files, "sensor_info.ini"); f != nil {
|
|
result.Sensors = parseSensorInfoINI(f.Content)
|
|
}
|
|
|
|
result.Events = parseSELCSVFiles(files)
|
|
if len(result.Events) == 0 {
|
|
if f := parser.FindFileByName(files, "Sel.json"); f != nil {
|
|
result.Events = parseSELJSON(f.Content)
|
|
}
|
|
}
|
|
if len(result.Events) == 0 {
|
|
if f := parser.FindFileByName(files, "sel_list.txt"); f != nil {
|
|
result.Events = parseSELListTXT(f.Content)
|
|
}
|
|
}
|
|
result.Hardware.Storage = dedupeStorage(result.Hardware.Storage)
|
|
result.Hardware.Volumes = dedupeVolumes(result.Hardware.Volumes)
|
|
|
|
return result
|
|
}
|
|
|
|
func parseH3CG6(files []parser.ExtractedFile) *models.AnalysisResult {
|
|
result := newAnalysisResult()
|
|
firmwareSeen := make(map[string]struct{})
|
|
|
|
if f := parser.FindFileByName(files, "FRUInfo.ini"); f != nil {
|
|
parseFRUInfoINI(f.Content, result)
|
|
}
|
|
if f := parser.FindFileByName(files, "board_info.ini"); f != nil {
|
|
parseBoardInfoINI(f.Content, result, firmwareSeen)
|
|
}
|
|
if f := parser.FindFileByName(files, "firmware_version.json"); f != nil {
|
|
parseFirmwareJSON(f.Content, result, firmwareSeen)
|
|
}
|
|
if f := parser.FindFileByName(files, "CPUDetailInfo.xml"); f != nil {
|
|
parseCPUXML(f.Content, result)
|
|
}
|
|
if f := parser.FindFileByName(files, "MemoryDetailInfo.xml"); f != nil {
|
|
parseMemoryXML(f.Content, result)
|
|
}
|
|
if f := parser.FindFileByName(files, "hardware_info.ini"); f != nil {
|
|
parseHardwareInfoINI(f.Content, result)
|
|
}
|
|
if f := parser.FindFileByName(files, "storage_disk.ini"); f != nil {
|
|
appendUniqueStorages(&result.Hardware.Storage, parseStorageINI(f.Content))
|
|
}
|
|
if f := parser.FindFileByName(files, "raid.json"); f != nil {
|
|
result.Hardware.Volumes = append(result.Hardware.Volumes, parseRAIDJSONVolumes(f.Content)...)
|
|
}
|
|
for _, f := range files {
|
|
path := strings.ToLower(f.Path)
|
|
if strings.Contains(path, "storage_raid-") && strings.HasSuffix(path, ".txt") {
|
|
storages, volumes := parseRAIDDetailTXT(f.Content)
|
|
appendUniqueStorages(&result.Hardware.Storage, storages)
|
|
result.Hardware.Volumes = append(result.Hardware.Volumes, volumes...)
|
|
}
|
|
}
|
|
if f := parser.FindFileByName(files, "NVMe_info.txt"); f != nil {
|
|
appendUniqueStorages(&result.Hardware.Storage, parseNVMeInfo(f.Content))
|
|
}
|
|
if f := parser.FindFileByName(files, "psu_cfg.ini"); f != nil {
|
|
appendUniquePSUs(&result.Hardware.PowerSupply, parsePSUCfgINI(f.Content))
|
|
}
|
|
if f := parser.FindFileByName(files, "net_cfg.ini"); f != nil {
|
|
adapters := parseNetCfgNetworkAdapters(f.Content)
|
|
if len(result.Hardware.NetworkAdapters) == 0 {
|
|
appendUniqueNetworkAdapters(&result.Hardware.NetworkAdapters, adapters)
|
|
appendUniqueNICs(&result.Hardware.NetworkCards, networkCardsFromAdapters(adapters))
|
|
}
|
|
}
|
|
if f := parser.FindFileByName(files, "PCIe_arguments_table.xml"); f != nil {
|
|
enrichStorageFromPCIeArguments(&result.Hardware.Storage, f.Content)
|
|
}
|
|
if f := parser.FindFileByName(files, "sensor_info.ini"); f != nil {
|
|
result.Sensors = parseSensorInfoINI(f.Content)
|
|
}
|
|
|
|
if f := parser.FindFileByName(files, "Sel.json"); f != nil {
|
|
result.Events = parseSELJSON(f.Content)
|
|
}
|
|
if len(result.Events) == 0 {
|
|
if f := parser.FindFileByName(files, "sel_list.txt"); f != nil {
|
|
result.Events = parseSELListTXT(f.Content)
|
|
}
|
|
}
|
|
result.Hardware.Storage = dedupeStorage(result.Hardware.Storage)
|
|
result.Hardware.Volumes = dedupeVolumes(result.Hardware.Volumes)
|
|
|
|
return result
|
|
}
|
|
|
|
func parseFRUInfoINI(content []byte, result *models.AnalysisResult) {
|
|
sections := parseLooseINI(string(content))
|
|
names := sortedSectionNames(sections)
|
|
|
|
for _, sectionName := range names {
|
|
section := sections[sectionName]
|
|
fru := models.FRUInfo{
|
|
DeviceID: sectionName,
|
|
Description: sectionName,
|
|
ChassisType: getSectionValue(section, "Chassis Type"),
|
|
Manufacturer: getSectionValue(section, "Board Manufacturer", "Product Manufacturer"),
|
|
ProductName: getSectionValue(section, "Product Product Name", "Board Product Name"),
|
|
SerialNumber: getSectionValue(section, "Product Serial Number", "Board Top Serial Number", "Board Serial Number", "Chassis Serial Number"),
|
|
PartNumber: getSectionValue(section, "Product Part Number", "Board Part Number", "Chassis Part Number"),
|
|
Version: getSectionValue(section, "Product Version"),
|
|
MfgDate: getSectionValue(section, "Mfg.Date"),
|
|
AssetTag: getSectionValue(section, "Product Asset Tag"),
|
|
}
|
|
|
|
if isEmptyFRU(fru) {
|
|
continue
|
|
}
|
|
result.FRU = append(result.FRU, fru)
|
|
|
|
if strings.EqualFold(sectionName, "baseboard") {
|
|
applyBoardFromFRU(&result.Hardware.BoardInfo, fru)
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseBoardInfoINI(content []byte, result *models.AnalysisResult, firmwareSeen map[string]struct{}) {
|
|
sections := parseLooseINI(string(content))
|
|
names := sortedSectionNames(sections)
|
|
|
|
for _, sectionName := range names {
|
|
section := sections[sectionName]
|
|
|
|
if strings.EqualFold(sectionName, "System board") {
|
|
board := &result.Hardware.BoardInfo
|
|
setIfEmpty(&board.Manufacturer, getSectionValue(section, "BoardMfr"))
|
|
setIfEmpty(&board.ProductName, getSectionValue(section, "BoardProductName"))
|
|
setIfEmpty(&board.SerialNumber, getSectionValue(section, "BoardSerialNum"))
|
|
setIfEmpty(&board.PartNumber, getSectionValue(section, "BoardPartNum"))
|
|
setIfEmpty(&board.Version, getSectionValue(section, "PCB Version"))
|
|
}
|
|
|
|
for key, value := range section {
|
|
keyTrim := strings.TrimSpace(key)
|
|
valueTrim := strings.TrimSpace(value)
|
|
if valueTrim == "" || strings.EqualFold(valueTrim, "N/A") {
|
|
continue
|
|
}
|
|
if !strings.Contains(strings.ToLower(keyTrim), "version") {
|
|
continue
|
|
}
|
|
deviceName := strings.TrimSpace(fmt.Sprintf("%s %s", sectionName, keyTrim))
|
|
appendFirmwareUnique(result, firmwareSeen, models.FirmwareInfo{
|
|
DeviceName: deviceName,
|
|
Version: valueTrim,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseFirmwareVersionINI(content []byte, result *models.AnalysisResult, firmwareSeen map[string]struct{}) {
|
|
sections := parseLooseINI(string(content))
|
|
names := sortedSectionNames(sections)
|
|
|
|
for _, sectionName := range names {
|
|
section := sections[sectionName]
|
|
|
|
if strings.EqualFold(sectionName, "System board") {
|
|
setIfEmpty(&result.Hardware.BoardInfo.Version, getSectionValue(section, "PCB Version"))
|
|
}
|
|
|
|
for key, value := range section {
|
|
keyTrim := strings.TrimSpace(key)
|
|
valueTrim := strings.TrimSpace(value)
|
|
if valueTrim == "" || strings.EqualFold(valueTrim, "N/A") {
|
|
continue
|
|
}
|
|
|
|
normKey := normalizeKey(keyTrim)
|
|
if !strings.Contains(normKey, "version") {
|
|
continue
|
|
}
|
|
|
|
appendFirmwareUnique(result, firmwareSeen, models.FirmwareInfo{
|
|
DeviceName: strings.TrimSpace(fmt.Sprintf("%s %s", sectionName, keyTrim)),
|
|
Version: valueTrim,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseHardwareInfoINI(content []byte, result *models.AnalysisResult) {
|
|
sections := parseLooseINI(string(content))
|
|
names := sortedSectionNames(sections)
|
|
|
|
for _, sectionName := range names {
|
|
section := sections[sectionName]
|
|
normSection := normalizeKey(sectionName)
|
|
|
|
switch {
|
|
case strings.HasPrefix(normSection, "processorsprocessor"):
|
|
cpu := parseCPUFromHardwareInfoSection(sectionName, section)
|
|
if cpu.Model == "" {
|
|
continue
|
|
}
|
|
if idx := findCPUIndex(result.Hardware.CPUs, cpu); idx >= 0 {
|
|
mergeCPU(&result.Hardware.CPUs[idx], cpu)
|
|
continue
|
|
}
|
|
result.Hardware.CPUs = append(result.Hardware.CPUs, cpu)
|
|
case strings.HasPrefix(normSection, "memorydetailsdimmindex"):
|
|
dimm := parseDIMMFromHardwareInfoSection(sectionName, section)
|
|
if dimm.Slot == "" && dimm.SerialNumber == "" {
|
|
continue
|
|
}
|
|
if idx := findMemoryIndex(result.Hardware.Memory, dimm); idx >= 0 {
|
|
mergeMemoryDIMM(&result.Hardware.Memory[idx], dimm)
|
|
continue
|
|
}
|
|
result.Hardware.Memory = append(result.Hardware.Memory, dimm)
|
|
}
|
|
}
|
|
|
|
adapters := parseHardwareInfoNetworkAdapters(content)
|
|
appendUniqueNetworkAdapters(&result.Hardware.NetworkAdapters, adapters)
|
|
appendUniqueNICs(&result.Hardware.NetworkCards, networkCardsFromAdapters(result.Hardware.NetworkAdapters))
|
|
}
|
|
|
|
func parseHardwareInfoNetworkAdapters(content []byte) []models.NetworkAdapter {
|
|
blocks := parseKeyValueBlocks(string(content))
|
|
out := make([]models.NetworkAdapter, 0)
|
|
|
|
for _, block := range blocks {
|
|
deviceType := strings.ToLower(strings.TrimSpace(getSectionValue(block, "Device Type")))
|
|
hasNetworkHints := deviceType == "nic" ||
|
|
getSectionValue(block, "Network Port") != "" ||
|
|
getSectionValue(block, "MAC Address") != "" ||
|
|
getSectionValue(block, "Vendor ID") != "" ||
|
|
getSectionValue(block, "Device ID") != ""
|
|
if !hasNetworkHints {
|
|
continue
|
|
}
|
|
|
|
slot := normalizePCIeSlotLabel(getSectionValue(block, "Location", "Slot", "PCIe Slot"))
|
|
if slot == "" {
|
|
slot = firstNonEmpty(getSectionValue(block, "Network Port"), "NIC")
|
|
}
|
|
|
|
mac := normalizeMACAddress(getSectionValue(block, "MAC Address"))
|
|
productName := normalizeUnknownString(getSectionValue(block, "Product Name", "Model"))
|
|
vendorID := parseMaybeInt(getSectionValue(block, "Vendor ID"))
|
|
deviceID := parseMaybeInt(getSectionValue(block, "Device ID"))
|
|
vendor := normalizeUnknownString(getSectionValue(block, "Vendor", "Manufacturer"))
|
|
if vendor == "" && vendorID > 0 {
|
|
vendor = strings.TrimSpace(pciids.VendorName(vendorID))
|
|
}
|
|
|
|
networkPort := normalizeUnknownString(getSectionValue(block, "Network Port"))
|
|
speed := normalizeUnknownString(firstNonEmpty(getSectionValue(block, "Current Speed"), getSectionValue(block, "Speed")))
|
|
bandwidth := normalizeUnknownString(getSectionValue(block, "Current Bandwidth", "Link Width"))
|
|
status := normalizeComponentStatus(firstNonEmpty(getSectionValue(block, "Network Status"), getSectionValue(block, "Status")))
|
|
if status == "" {
|
|
status = "ok"
|
|
}
|
|
|
|
descParts := make([]string, 0, 3)
|
|
if networkPort != "" {
|
|
descParts = append(descParts, networkPort)
|
|
}
|
|
if speed != "" {
|
|
descParts = append(descParts, "speed "+speed)
|
|
}
|
|
if bandwidth != "" {
|
|
descParts = append(descParts, "link "+bandwidth)
|
|
}
|
|
|
|
adapter := models.NetworkAdapter{
|
|
Slot: slot,
|
|
Location: slot,
|
|
Present: status != "absent",
|
|
Model: firstNonEmpty(productName, "Ethernet Adapter"),
|
|
Description: strings.Join(descParts, "; "),
|
|
Vendor: vendor,
|
|
VendorID: vendorID,
|
|
DeviceID: deviceID,
|
|
SerialNumber: normalizeUnknownString(getSectionValue(block, "Serial Number", "SerialNumber", "SN")),
|
|
PartNumber: normalizeUnknownString(getSectionValue(block, "Part Number", "PartNumber", "PN")),
|
|
Firmware: normalizeUnknownString(getSectionValue(block, "Firmware Version", "Firmware")),
|
|
PortCount: inferPortCountFromNetworkFields(productName, networkPort),
|
|
Status: status,
|
|
}
|
|
if mac != "" {
|
|
adapter.MACAddresses = []string{mac}
|
|
if adapter.PortCount == 0 {
|
|
adapter.PortCount = 1
|
|
}
|
|
}
|
|
|
|
if isNetworkAdapterEmpty(adapter) {
|
|
continue
|
|
}
|
|
out = append(out, adapter)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func parseCPUFromHardwareInfoSection(sectionName string, section map[string]string) models.CPU {
|
|
socket := parseSectionIndex(sectionName, `(?i)processor\s+(\d+)`)
|
|
statusRaw := getSectionValue(section, "Status")
|
|
|
|
return models.CPU{
|
|
Socket: socket,
|
|
Model: getSectionValue(section, "Model"),
|
|
Cores: parseMaybeIntLoose(getSectionValue(section, "Cores")),
|
|
Threads: parseMaybeIntLoose(getSectionValue(section, "Threads")),
|
|
FrequencyMHz: parseMaybeIntLoose(getSectionValue(section, "Frequency", "Processor Speed")),
|
|
L1CacheKB: parseMaybeIntLoose(getSectionValue(section, "L1 Cache")),
|
|
L2CacheKB: parseMaybeIntLoose(getSectionValue(section, "L2 Cache")),
|
|
L3CacheKB: parseMaybeIntLoose(getSectionValue(section, "L3 Cache")),
|
|
PPIN: getSectionValue(section, "CPU PPIN", "PPIN"),
|
|
SerialNumber: getSectionValue(section, "Processor ID", "Serial Number"),
|
|
Status: normalizeComponentStatus(statusRaw),
|
|
}
|
|
}
|
|
|
|
func parseDIMMFromHardwareInfoSection(sectionName string, section map[string]string) models.MemoryDIMM {
|
|
statusRaw := getSectionValue(section, "Status")
|
|
status := normalizeComponentStatus(statusRaw)
|
|
present := status != "absent"
|
|
|
|
slot := firstNonEmpty(
|
|
getSectionValue(section, "Socket ID", "Slot", "Name"),
|
|
sectionName,
|
|
)
|
|
location := strings.TrimSpace(getSectionValue(section, "Location"))
|
|
channel := strings.TrimSpace(getSectionValue(section, "Channel"))
|
|
if location != "" && channel != "" {
|
|
location = location + " CH" + channel
|
|
}
|
|
|
|
return models.MemoryDIMM{
|
|
Slot: slot,
|
|
Location: location,
|
|
Present: present,
|
|
SizeMB: parseMaybeIntLoose(getSectionValue(section, "Size", "DIMM Size")),
|
|
Type: firstNonEmpty(getSectionValue(section, "Type"), getSectionValue(section, "Technology"), "DIMM"),
|
|
Technology: getSectionValue(section, "Technology"),
|
|
MaxSpeedMHz: parseMaybeIntLoose(getSectionValue(section, "Maximum Frequency", "Max Frequency")),
|
|
CurrentSpeedMHz: parseMaybeIntLoose(getSectionValue(section, "Current Frequency", "Operating Frequency")),
|
|
Manufacturer: getSectionValue(section, "Manufacture", "Manufacturer"),
|
|
SerialNumber: getSectionValue(section, "Serial Number", "SerialNumber"),
|
|
PartNumber: getSectionValue(section, "Part Number"),
|
|
Status: status,
|
|
Ranks: parseMaybeIntLoose(getSectionValue(section, "Ranks")),
|
|
}
|
|
}
|
|
|
|
func parseHardwareInfoStorageINI(content []byte) []models.Storage {
|
|
sections := parseLooseINI(string(content))
|
|
names := sortedSectionNames(sections)
|
|
out := make([]models.Storage, 0)
|
|
|
|
for _, sectionName := range names {
|
|
section := sections[sectionName]
|
|
normSection := normalizeKey(sectionName)
|
|
if !strings.HasPrefix(normSection, "disk") && !strings.HasPrefix(normSection, "nvme") {
|
|
continue
|
|
}
|
|
|
|
slotNum := getSectionValue(section, "SlotNum")
|
|
frontRear := getSectionValue(section, "FrontOrRear")
|
|
slot := firstNonEmpty(
|
|
getSectionValue(section, "NvmePhySlot", "DiskSlotDesc", "Position", "Slot"),
|
|
formatSlot(frontRear, slotNum),
|
|
slotNum,
|
|
sectionName,
|
|
)
|
|
|
|
presentRaw := getSectionValue(section, "Present")
|
|
present := true
|
|
if strings.TrimSpace(presentRaw) != "" {
|
|
present = isTruthy(presentRaw)
|
|
}
|
|
status := normalizeStorageStatus(getSectionValue(section, "Status"), present)
|
|
if status == "" {
|
|
status = normalizeStorageStatus(presentRaw, present)
|
|
}
|
|
|
|
storageType := "disk"
|
|
if strings.HasPrefix(normSection, "nvme") || strings.Contains(strings.ToLower(slot), "nvme") {
|
|
storageType = "nvme"
|
|
}
|
|
|
|
location := getSectionValue(section, "FrontOrRear", "Location")
|
|
if location == "" {
|
|
slotLower := strings.ToLower(slot)
|
|
switch {
|
|
case strings.Contains(slotLower, "front"):
|
|
location = "Front"
|
|
case strings.Contains(slotLower, "rear"):
|
|
location = "Rear"
|
|
}
|
|
}
|
|
|
|
item := models.Storage{
|
|
Slot: slot,
|
|
Type: storageType,
|
|
SerialNumber: getSectionValue(section, "SerialNumber"),
|
|
Present: present,
|
|
Location: location,
|
|
Status: status,
|
|
}
|
|
if isStorageEmpty(item) {
|
|
continue
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
|
|
return dedupeStorage(out)
|
|
}
|
|
|
|
func parseSmartdataStorages(files []parser.ExtractedFile) []models.Storage {
|
|
out := make([]models.Storage, 0)
|
|
|
|
for _, f := range files {
|
|
path := strings.ToLower(strings.TrimSpace(f.Path))
|
|
if !strings.HasPrefix(path, "static/smartdata/") || !strings.HasSuffix(path, ".txt") {
|
|
continue
|
|
}
|
|
|
|
model, serial := parseSmartdataDevice(f.Content)
|
|
if serial == "" {
|
|
continue
|
|
}
|
|
|
|
slot := smartdataSlotFromPath(f.Path)
|
|
st := models.Storage{
|
|
Slot: slot,
|
|
Type: inferStorageTypeFromValues(slot, "", "", model),
|
|
Model: model,
|
|
SerialNumber: serial,
|
|
Present: true,
|
|
Status: "ok",
|
|
}
|
|
if st.Type == "" {
|
|
st.Type = "disk"
|
|
}
|
|
out = append(out, st)
|
|
}
|
|
|
|
return dedupeStorage(out)
|
|
}
|
|
|
|
func enrichStorageWithSmartdata(storages *[]models.Storage, files []parser.ExtractedFile) {
|
|
for _, add := range parseSmartdataStorages(files) {
|
|
idx := findStorageIndex(*storages, add)
|
|
if idx < 0 {
|
|
continue
|
|
}
|
|
mergeStorage(&(*storages)[idx], add)
|
|
}
|
|
}
|
|
|
|
type pcieStorageProfile struct {
|
|
Model string
|
|
Manufacturer string
|
|
IsNVMe bool
|
|
}
|
|
|
|
func enrichStorageFromPCIeArguments(storages *[]models.Storage, content []byte) {
|
|
profiles := parseStorageProfilesFromPCIeArgumentsXML(content)
|
|
if len(profiles) == 0 {
|
|
return
|
|
}
|
|
|
|
nvmeVendor, nvmeModel := chooseNVMeProfileFallback(*storages, profiles)
|
|
nonNVMeVendor, nonNVMeModel := selectUniqueVendorModel(filterPCIeStorageProfilesByNVMe(profiles, false))
|
|
|
|
for i := range *storages {
|
|
s := &(*storages)[i]
|
|
isNVMeStorage := strings.EqualFold(strings.TrimSpace(s.Type), "nvme")
|
|
|
|
vendor, model := nonNVMeVendor, nonNVMeModel
|
|
if isNVMeStorage {
|
|
vendor, model = nvmeVendor, nvmeModel
|
|
}
|
|
if vendor == "" && model == "" {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(s.Manufacturer) == "" && vendor != "" {
|
|
s.Manufacturer = vendor
|
|
}
|
|
if strings.TrimSpace(s.Model) == "" {
|
|
if model != "" {
|
|
s.Model = model
|
|
} else if vendor != "" && isNVMeStorage {
|
|
s.Model = "NVMe SSD"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func chooseNVMeProfileFallback(storages []models.Storage, profiles []pcieStorageProfile) (vendor, model string) {
|
|
nvmeProfiles := filterPCIeStorageProfilesByNVMe(profiles, true)
|
|
if len(nvmeProfiles) == 0 {
|
|
return "", ""
|
|
}
|
|
|
|
vendor, model = selectUniqueVendorModel(nvmeProfiles)
|
|
if vendor != "" || model != "" {
|
|
return vendor, model
|
|
}
|
|
|
|
frontBayNVMe := false
|
|
for _, s := range storages {
|
|
if strings.ToLower(strings.TrimSpace(s.Type)) != "nvme" {
|
|
continue
|
|
}
|
|
slotLower := strings.ToLower(strings.TrimSpace(s.Slot))
|
|
if strings.Contains(slotLower, "front") {
|
|
frontBayNVMe = true
|
|
break
|
|
}
|
|
if n := parseMaybeInt(slotLower); n >= 100 {
|
|
frontBayNVMe = true
|
|
break
|
|
}
|
|
}
|
|
if !frontBayNVMe {
|
|
return "", ""
|
|
}
|
|
|
|
sffProfiles := make([]pcieStorageProfile, 0)
|
|
for _, p := range nvmeProfiles {
|
|
nameLower := strings.ToLower(strings.TrimSpace(p.Model))
|
|
if strings.Contains(nameLower, "sff") {
|
|
sffProfiles = append(sffProfiles, p)
|
|
}
|
|
}
|
|
if len(sffProfiles) == 0 {
|
|
return "", ""
|
|
}
|
|
return selectUniqueVendorModel(sffProfiles)
|
|
}
|
|
|
|
func selectUniqueVendorModel(profiles []pcieStorageProfile) (vendor, model string) {
|
|
vendorSet := make(map[string]struct{})
|
|
modelSet := make(map[string]struct{})
|
|
for _, p := range profiles {
|
|
if strings.TrimSpace(p.Manufacturer) != "" {
|
|
vendorSet[p.Manufacturer] = struct{}{}
|
|
}
|
|
if strings.TrimSpace(p.Model) != "" && !strings.EqualFold(strings.TrimSpace(p.Model), "N/A") {
|
|
modelSet[p.Model] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if len(vendorSet) == 1 {
|
|
for v := range vendorSet {
|
|
vendor = v
|
|
}
|
|
}
|
|
if len(modelSet) == 1 {
|
|
for m := range modelSet {
|
|
model = m
|
|
}
|
|
}
|
|
return vendor, model
|
|
}
|
|
|
|
func filterPCIeStorageProfilesByNVMe(profiles []pcieStorageProfile, isNVMe bool) []pcieStorageProfile {
|
|
out := make([]pcieStorageProfile, 0, len(profiles))
|
|
for _, p := range profiles {
|
|
if p.IsNVMe == isNVMe {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseStorageProfilesFromPCIeArgumentsXML(content []byte) []pcieStorageProfile {
|
|
root, ok := parseXMLRoot(content)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
out := make([]pcieStorageProfile, 0)
|
|
seen := make(map[string]struct{})
|
|
|
|
for _, node := range root.Nodes {
|
|
name := strings.ToUpper(strings.TrimSpace(node.XMLName.Local))
|
|
if !strings.HasPrefix(name, "PCIE") {
|
|
continue
|
|
}
|
|
|
|
base := findChildXMLNode(node, "base_args")
|
|
typeGet := findChildXMLNode(node, "type_get_args")
|
|
if base == nil || typeGet == nil {
|
|
continue
|
|
}
|
|
|
|
baseFields := xmlNodeFields(*base)
|
|
itemName := strings.TrimSpace(baseFields["name"])
|
|
itemType := strings.ToLower(strings.TrimSpace(baseFields["type"]))
|
|
if itemName == "" {
|
|
continue
|
|
}
|
|
|
|
lowerName := strings.ToLower(itemName)
|
|
if strings.HasPrefix(strings.ToUpper(itemName), "EX-") {
|
|
continue
|
|
}
|
|
isNVMe := strings.Contains(lowerName, "nvme") || strings.Contains(itemType, "nvme")
|
|
if !isNVMe &&
|
|
!containsAny(lowerName, "ssd", "hdd", "disk", "sas", "sata") &&
|
|
!containsAny(itemType, "ssd", "hdd", "disk", "sas", "sata") {
|
|
continue
|
|
}
|
|
|
|
bios := findChildXMLNode(*typeGet, "bios_args")
|
|
if bios == nil {
|
|
continue
|
|
}
|
|
vendorRaw := strings.TrimSpace(xmlNodeFields(*bios)["vendor_id"])
|
|
vendorID := parseMaybeInt(vendorRaw)
|
|
if vendorID == 0 || strings.EqualFold(vendorRaw, "0xFFFF") {
|
|
continue
|
|
}
|
|
|
|
vendorName := strings.TrimSpace(pciids.VendorName(vendorID))
|
|
if vendorName == "" {
|
|
vendorName = fmt.Sprintf("0x%X", vendorID)
|
|
}
|
|
|
|
profile := pcieStorageProfile{
|
|
Model: itemName,
|
|
Manufacturer: vendorName,
|
|
IsNVMe: isNVMe,
|
|
}
|
|
key := strings.ToLower(profile.Model + "|" + profile.Manufacturer + "|" + strconv.FormatBool(profile.IsNVMe))
|
|
if _, exists := seen[key]; exists {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, profile)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func findChildXMLNode(parent xmlNode, childName string) *xmlNode {
|
|
target := strings.ToLower(strings.TrimSpace(childName))
|
|
for i := range parent.Nodes {
|
|
if strings.ToLower(strings.TrimSpace(parent.Nodes[i].XMLName.Local)) == target {
|
|
return &parent.Nodes[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
netCfgHeaderRE = regexp.MustCompile(`^([^\s]+)\s+Link\s+encap:`)
|
|
netCfgHWAddrRE = regexp.MustCompile(`(?i)\bHWaddr\s+([0-9a-f]{2}(?::[0-9a-f]{2}){5})`)
|
|
netCfgInet4OldRE = regexp.MustCompile(`\binet addr:([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)`)
|
|
netCfgInet4NewRE = regexp.MustCompile(`\binet\s+([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)(?:/[0-9]+)?`)
|
|
netCfgInet6OldRE = regexp.MustCompile(`\binet6 addr:\s*([0-9a-f:]+(?:/[0-9]+)?)`)
|
|
netCfgInet6NewRE = regexp.MustCompile(`\binet6\s+([0-9a-f:]+(?:/[0-9]+)?)`)
|
|
pcieSlotDigitsRE = regexp.MustCompile(`(?i)(\d+)`)
|
|
portCountPattRE = regexp.MustCompile(`(?i)(\d+)\s*p\b`)
|
|
portCountMulRE = regexp.MustCompile(`(?i)(\d+)\s*\*\s*\d+\s*g`)
|
|
portSuffixRE = regexp.MustCompile(`(?i)port\s+(\d+)`)
|
|
)
|
|
|
|
type netCfgAdapterState struct {
|
|
Slot string
|
|
Names []string
|
|
MACs []string
|
|
IPv4 []string
|
|
IPv6 []string
|
|
Up bool
|
|
}
|
|
|
|
func parseNetCfgNetworkAdapters(content []byte) []models.NetworkAdapter {
|
|
lines := strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n")
|
|
byKey := make(map[string]*netCfgAdapterState)
|
|
order := make([]string, 0)
|
|
|
|
var current *netCfgAdapterState
|
|
var currentName string
|
|
|
|
for _, raw := range lines {
|
|
line := strings.TrimRight(raw, "\r")
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
current = nil
|
|
currentName = ""
|
|
continue
|
|
}
|
|
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
|
current = nil
|
|
currentName = ""
|
|
continue
|
|
}
|
|
|
|
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
|
m := netCfgHeaderRE.FindStringSubmatch(line)
|
|
if len(m) != 2 {
|
|
current = nil
|
|
currentName = ""
|
|
continue
|
|
}
|
|
|
|
ifaceName := strings.TrimSpace(m[1])
|
|
baseName := baseInterfaceName(ifaceName)
|
|
if strings.EqualFold(baseName, "lo") {
|
|
current = nil
|
|
currentName = ""
|
|
continue
|
|
}
|
|
|
|
mac := normalizeMACAddress(extractFirstRegexpGroup(line, netCfgHWAddrRE))
|
|
key := "if:" + strings.ToLower(baseName)
|
|
if mac != "" {
|
|
key = "mac:" + strings.ToLower(mac)
|
|
}
|
|
|
|
state, ok := byKey[key]
|
|
if !ok {
|
|
state = &netCfgAdapterState{Slot: baseName}
|
|
byKey[key] = state
|
|
order = append(order, key)
|
|
}
|
|
if state.Slot == "" {
|
|
state.Slot = baseName
|
|
}
|
|
appendStringUnique(&state.Names, ifaceName)
|
|
if mac != "" {
|
|
appendStringUnique(&state.MACs, mac)
|
|
}
|
|
|
|
current = state
|
|
currentName = ifaceName
|
|
continue
|
|
}
|
|
|
|
if current == nil {
|
|
continue
|
|
}
|
|
|
|
upper := strings.ToUpper(trimmed)
|
|
if strings.Contains(upper, "UP") && !strings.Contains(upper, "DOWN") {
|
|
current.Up = true
|
|
}
|
|
appendMatchesUnique(¤t.IPv4, trimmed, netCfgInet4OldRE)
|
|
appendMatchesUnique(¤t.IPv4, trimmed, netCfgInet4NewRE)
|
|
appendMatchesUnique(¤t.IPv6, strings.ToLower(trimmed), netCfgInet6OldRE)
|
|
appendMatchesUnique(¤t.IPv6, strings.ToLower(trimmed), netCfgInet6NewRE)
|
|
if currentName != "" {
|
|
appendStringUnique(¤t.Names, currentName)
|
|
}
|
|
}
|
|
|
|
out := make([]models.NetworkAdapter, 0, len(order))
|
|
for _, key := range order {
|
|
state := byKey[key]
|
|
if state == nil {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(state.Slot) == "" && len(state.MACs) == 0 {
|
|
continue
|
|
}
|
|
|
|
status := "down"
|
|
if state.Up {
|
|
status = "ok"
|
|
}
|
|
|
|
descParts := make([]string, 0, 3)
|
|
if len(state.Names) > 0 {
|
|
descParts = append(descParts, "interfaces: "+strings.Join(state.Names, ", "))
|
|
}
|
|
if len(state.IPv4) > 0 {
|
|
descParts = append(descParts, "ipv4: "+strings.Join(state.IPv4, ", "))
|
|
}
|
|
if len(state.IPv6) > 0 {
|
|
descParts = append(descParts, "ipv6: "+strings.Join(state.IPv6, ", "))
|
|
}
|
|
|
|
out = append(out, models.NetworkAdapter{
|
|
Slot: strings.TrimSpace(state.Slot),
|
|
Location: strings.TrimSpace(firstNonEmpty(state.Slot, firstSliceValue(state.Names))),
|
|
Present: true,
|
|
Model: "Ethernet Interface",
|
|
Description: strings.Join(descParts, "; "),
|
|
PortCount: maxInt(1, len(state.MACs)),
|
|
MACAddresses: append([]string(nil), state.MACs...),
|
|
Status: status,
|
|
})
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func networkCardsFromAdapters(items []models.NetworkAdapter) []models.NIC {
|
|
out := make([]models.NIC, 0, len(items))
|
|
for _, item := range items {
|
|
mac := firstSliceValue(item.MACAddresses)
|
|
if strings.TrimSpace(item.Model) == "" && strings.TrimSpace(mac) == "" {
|
|
continue
|
|
}
|
|
out = append(out, models.NIC{
|
|
Name: firstNonEmpty(item.Slot, item.Location, "NIC"),
|
|
Model: firstNonEmpty(item.Model, "Ethernet Interface"),
|
|
Description: item.Description,
|
|
MACAddress: mac,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func extractFirstRegexpGroup(s string, re *regexp.Regexp) string {
|
|
if re == nil {
|
|
return ""
|
|
}
|
|
m := re.FindStringSubmatch(s)
|
|
if len(m) < 2 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(m[1])
|
|
}
|
|
|
|
func appendMatchesUnique(dst *[]string, text string, re *regexp.Regexp) {
|
|
if re == nil {
|
|
return
|
|
}
|
|
matches := re.FindAllStringSubmatch(text, -1)
|
|
for _, m := range matches {
|
|
if len(m) < 2 {
|
|
continue
|
|
}
|
|
appendStringUnique(dst, strings.TrimSpace(m[1]))
|
|
}
|
|
}
|
|
|
|
func appendStringUnique(dst *[]string, v string) {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return
|
|
}
|
|
for _, cur := range *dst {
|
|
if strings.EqualFold(strings.TrimSpace(cur), v) {
|
|
return
|
|
}
|
|
}
|
|
*dst = append(*dst, v)
|
|
}
|
|
|
|
func normalizeMACAddress(s string) string {
|
|
s = strings.ToUpper(strings.TrimSpace(s))
|
|
if len(s) == 17 {
|
|
return s
|
|
}
|
|
s = strings.ReplaceAll(s, "-", "")
|
|
s = strings.ReplaceAll(s, ":", "")
|
|
if len(s) != 12 {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, 6)
|
|
for i := 0; i < len(s); i += 2 {
|
|
parts = append(parts, s[i:i+2])
|
|
}
|
|
return strings.Join(parts, ":")
|
|
}
|
|
|
|
func baseInterfaceName(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if idx := strings.Index(name, "."); idx > 0 {
|
|
return strings.TrimSpace(name[:idx])
|
|
}
|
|
return name
|
|
}
|
|
|
|
func firstSliceValue(items []string) string {
|
|
for _, item := range items {
|
|
if strings.TrimSpace(item) != "" {
|
|
return strings.TrimSpace(item)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeUnknownString(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
lower := strings.ToLower(s)
|
|
if lower == "n/a" || lower == "na" || lower == "-" || lower == "unknown" || lower == "none" {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
func normalizePCIeSlotLabel(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
if strings.EqualFold(raw, "N/A") || strings.EqualFold(raw, "-") {
|
|
return ""
|
|
}
|
|
m := pcieSlotDigitsRE.FindStringSubmatch(raw)
|
|
if len(m) >= 2 {
|
|
if n := parseMaybeInt(m[1]); n > 0 {
|
|
return fmt.Sprintf("PCIe %d", n)
|
|
}
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func inferPortCountFromNetworkFields(model, networkPort string) int {
|
|
modelLower := strings.ToLower(strings.TrimSpace(model))
|
|
if modelLower != "" {
|
|
if m := portCountPattRE.FindStringSubmatch(modelLower); len(m) == 2 {
|
|
if n := parseMaybeInt(m[1]); n > 0 {
|
|
return n
|
|
}
|
|
}
|
|
if m := portCountMulRE.FindStringSubmatch(modelLower); len(m) == 2 {
|
|
if n := parseMaybeInt(m[1]); n > 0 {
|
|
return n
|
|
}
|
|
}
|
|
}
|
|
if m := portSuffixRE.FindStringSubmatch(strings.ToLower(strings.TrimSpace(networkPort))); len(m) == 2 {
|
|
if n := parseMaybeInt(m[1]); n > 0 {
|
|
return n
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func parseSmartdataDevice(content []byte) (model, serial string) {
|
|
scanner := bufio.NewScanner(strings.NewReader(string(content)))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || !strings.Contains(line, ":") {
|
|
continue
|
|
}
|
|
|
|
key, value, ok := parseColonKVLine(line)
|
|
if !ok {
|
|
continue
|
|
}
|
|
keyNorm := normalizeKey(key)
|
|
val := strings.TrimSpace(value)
|
|
if val == "" {
|
|
continue
|
|
}
|
|
|
|
switch keyNorm {
|
|
case "modelinfo", "devicemodel", "modelnumber":
|
|
if model == "" {
|
|
model = val
|
|
}
|
|
case "serialnumber":
|
|
if serial == "" {
|
|
serial = val
|
|
}
|
|
}
|
|
}
|
|
|
|
return strings.TrimSpace(model), strings.TrimSpace(serial)
|
|
}
|
|
|
|
func smartdataSlotFromPath(path string) string {
|
|
path = strings.ReplaceAll(path, "\\", "/")
|
|
parts := strings.Split(path, "/")
|
|
for i := range parts {
|
|
if strings.EqualFold(parts[i], "smartdata") && i+1 < len(parts) {
|
|
return strings.TrimSpace(parts[i+1])
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parsePSUCfgINI(content []byte) []models.PSU {
|
|
section := ""
|
|
current := make(map[string]string)
|
|
records := make([]map[string]string, 0)
|
|
isPSUSection := func(name string) bool {
|
|
norm := normalizeKey(name)
|
|
return norm == "activestandbyconfiguration" || strings.HasPrefix(norm, "psu")
|
|
}
|
|
|
|
flush := func() {
|
|
if len(current) == 0 {
|
|
return
|
|
}
|
|
records = append(records, current)
|
|
current = make(map[string]string)
|
|
}
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(string(content)))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
|
if isPSUSection(section) {
|
|
flush()
|
|
}
|
|
section = strings.TrimSpace(line[1 : len(line)-1])
|
|
continue
|
|
}
|
|
|
|
if !isPSUSection(section) {
|
|
continue
|
|
}
|
|
|
|
key, value, ok := parseFlexibleKVLine(line)
|
|
if !ok {
|
|
continue
|
|
}
|
|
keyNorm := normalizeKey(key)
|
|
if keyNorm == "powerid" && len(current) > 0 && getSectionValue(current, "Power ID", "PowerID") != "" {
|
|
flush()
|
|
}
|
|
current[key] = value
|
|
}
|
|
if isPSUSection(section) {
|
|
flush()
|
|
}
|
|
|
|
out := make([]models.PSU, 0, len(records))
|
|
for _, rec := range records {
|
|
id := strings.TrimSpace(getSectionValue(rec, "Power ID", "PowerID"))
|
|
slot := firstNonEmpty(id, getSectionValue(rec, "Slot", "Position"))
|
|
if slot != "" && !strings.HasPrefix(strings.ToUpper(slot), "PSU") {
|
|
slot = "PSU" + slot
|
|
}
|
|
|
|
present := true
|
|
presentRaw := strings.TrimSpace(getSectionValue(rec, "Present Status", "Present"))
|
|
if presentRaw != "" {
|
|
present = containsAny(strings.ToLower(presentRaw), "present", "yes", "ok", "active")
|
|
}
|
|
|
|
status := "absent"
|
|
if present {
|
|
status = "ok"
|
|
}
|
|
cold := strings.TrimSpace(getSectionValue(rec, "Cold Status"))
|
|
if strings.Contains(strings.ToLower(cold), "active") {
|
|
status = "ok"
|
|
}
|
|
|
|
psu := models.PSU{
|
|
Slot: slot,
|
|
Present: present,
|
|
Model: strings.TrimSpace(getSectionValue(rec, "Model")),
|
|
SerialNumber: strings.TrimSpace(getSectionValue(rec, "SN", "Serial Number")),
|
|
WattageW: parseMaybeIntLoose(getSectionValue(rec, "Max Power(W)", "Max Power")),
|
|
Status: status,
|
|
Description: cold,
|
|
}
|
|
if strings.TrimSpace(psu.Slot) == "" && strings.TrimSpace(psu.SerialNumber) == "" && strings.TrimSpace(psu.Model) == "" {
|
|
continue
|
|
}
|
|
out = append(out, psu)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func parseBoardCfgINI(content []byte, result *models.AnalysisResult) {
|
|
sections := parseLooseINI(string(content))
|
|
if len(sections) == 0 {
|
|
return
|
|
}
|
|
|
|
board := &result.Hardware.BoardInfo
|
|
boardType := getSectionValue(findINISection(sections, "Board Type"), "Board Type")
|
|
boardVersion := getSectionValue(findINISection(sections, "Board Version"), "Board Version")
|
|
customerID := getSectionValue(findINISection(sections, "Customer ID"), "CustomerID", "Customer ID")
|
|
oemFlag := getSectionValue(findINISection(sections, "OEM ID"), "OEM Flag", "OEMID")
|
|
|
|
setIfEmpty(&board.ProductName, boardType)
|
|
setIfEmpty(&board.Version, boardVersion)
|
|
|
|
extras := make([]string, 0, 2)
|
|
if customerID != "" {
|
|
extras = append(extras, "CustomerID: "+customerID)
|
|
}
|
|
if oemFlag != "" {
|
|
extras = append(extras, "OEM Flag: "+oemFlag)
|
|
}
|
|
if len(extras) > 0 {
|
|
if strings.TrimSpace(board.Description) == "" {
|
|
board.Description = strings.Join(extras, "; ")
|
|
} else if !strings.Contains(strings.ToLower(board.Description), strings.ToLower(extras[0])) {
|
|
board.Description = strings.TrimSpace(board.Description) + "; " + strings.Join(extras, "; ")
|
|
}
|
|
}
|
|
}
|
|
|
|
func findINISection(sections map[string]map[string]string, name string) map[string]string {
|
|
target := normalizeKey(name)
|
|
for sectionName, section := range sections {
|
|
if normalizeKey(sectionName) == target {
|
|
return section
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseRaidBPConfDevices(content []byte) []models.HardwareDevice {
|
|
lines := strings.Split(string(content), "\n")
|
|
section := ""
|
|
devices := make([]models.HardwareDevice, 0)
|
|
backplaneIndex := 0
|
|
|
|
for _, raw := range lines {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
|
section = strings.ToLower(strings.TrimSpace(line[1 : len(line)-1]))
|
|
continue
|
|
}
|
|
|
|
switch section {
|
|
case "bp information":
|
|
if strings.Contains(strings.ToLower(line), "description") && strings.Contains(line, "|") {
|
|
continue
|
|
}
|
|
if !strings.Contains(line, "|") {
|
|
continue
|
|
}
|
|
cols := parsePipeColumns(line)
|
|
if len(cols) < 3 {
|
|
continue
|
|
}
|
|
backplaneIndex++
|
|
desc := cols[0]
|
|
bpType := cols[1]
|
|
slot := cols[2]
|
|
if slot == "" || slot == "~" {
|
|
slot = fmt.Sprintf("BP-%d", backplaneIndex)
|
|
}
|
|
dev := models.HardwareDevice{
|
|
ID: fmt.Sprintf("h3c-bp-%d-%s", backplaneIndex, strings.ToLower(strings.ReplaceAll(slot, " ", "-"))),
|
|
Kind: models.DeviceKindStorage,
|
|
Slot: slot,
|
|
Model: bpType,
|
|
DeviceClass: "backplane",
|
|
Status: "ok",
|
|
Details: map[string]any{
|
|
"source": "Raid_BP_Conf_Info.ini",
|
|
"description": desc,
|
|
},
|
|
}
|
|
if len(cols) > 4 && cols[4] != "" && cols[4] != "~" {
|
|
dev.Location = cols[4]
|
|
}
|
|
devices = append(devices, dev)
|
|
|
|
case "raid information":
|
|
if strings.Contains(strings.ToLower(line), "pcie slot") && strings.Contains(line, "|") {
|
|
continue
|
|
}
|
|
if !strings.Contains(line, "|") {
|
|
continue
|
|
}
|
|
cols := parsePipeColumns(line)
|
|
if len(cols) < 2 {
|
|
continue
|
|
}
|
|
slot := strings.TrimSpace(cols[0])
|
|
if slot == "" || slot == "~" {
|
|
continue
|
|
}
|
|
sasNum := strings.TrimSpace(cols[1])
|
|
desc := "RAID Controller"
|
|
if sasNum != "" && sasNum != "~" {
|
|
desc = "SAS ports: " + sasNum
|
|
}
|
|
dev := models.HardwareDevice{
|
|
ID: "h3c-raid-slot-" + slot,
|
|
Kind: models.DeviceKindPCIe,
|
|
Slot: "PCIe " + slot,
|
|
Model: "RAID Controller",
|
|
DeviceClass: "raid_controller",
|
|
Status: "ok",
|
|
Details: map[string]any{
|
|
"source": "Raid_BP_Conf_Info.ini",
|
|
"description": desc,
|
|
"pcie_slot": slot,
|
|
"raid_sasnum": sasNum,
|
|
},
|
|
}
|
|
devices = append(devices, dev)
|
|
}
|
|
}
|
|
|
|
return dedupeHardwareDevices(devices)
|
|
}
|
|
|
|
func parsePipeColumns(line string) []string {
|
|
parts := strings.Split(line, "|")
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
v := strings.TrimSpace(part)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupeHardwareDevices(items []models.HardwareDevice) []models.HardwareDevice {
|
|
out := make([]models.HardwareDevice, 0, len(items))
|
|
seen := make(map[string]struct{}, len(items))
|
|
for _, item := range items {
|
|
key := strings.ToLower(strings.TrimSpace(item.ID))
|
|
if key == "" {
|
|
key = strings.ToLower(strings.TrimSpace(item.Kind + "|" + item.Slot + "|" + item.Model))
|
|
}
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseFirmwareJSON(content []byte, result *models.AnalysisResult, firmwareSeen map[string]struct{}) {
|
|
type fwEntry struct {
|
|
FirmwareName string `json:"Firmware Name"`
|
|
FirmwareVersion string `json:"Firmware Version"`
|
|
Location string `json:"Location"`
|
|
PartModel string `json:"Part Model"`
|
|
}
|
|
|
|
var payload map[string]fwEntry
|
|
if err := json.Unmarshal(content, &payload); err != nil {
|
|
return
|
|
}
|
|
|
|
keys := make([]string, 0, len(payload))
|
|
for key := range payload {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
for _, key := range keys {
|
|
entry := payload[key]
|
|
version := strings.TrimSpace(entry.FirmwareVersion)
|
|
if version == "" || strings.EqualFold(version, "N/A") {
|
|
continue
|
|
}
|
|
|
|
name := strings.TrimSpace(entry.FirmwareName)
|
|
if name == "" {
|
|
name = key
|
|
}
|
|
descParts := make([]string, 0, 2)
|
|
if loc := strings.TrimSpace(entry.Location); loc != "" && loc != "-" {
|
|
descParts = append(descParts, "location: "+loc)
|
|
}
|
|
if part := strings.TrimSpace(entry.PartModel); part != "" && part != "-" {
|
|
descParts = append(descParts, "model: "+part)
|
|
}
|
|
|
|
appendFirmwareUnique(result, firmwareSeen, models.FirmwareInfo{
|
|
DeviceName: name,
|
|
Version: version,
|
|
Description: strings.Join(descParts, "; "),
|
|
})
|
|
}
|
|
}
|
|
|
|
func parseCPUXML(content []byte, result *models.AnalysisResult) {
|
|
root, ok := parseXMLRoot(content)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
for _, node := range root.Nodes {
|
|
if !strings.HasPrefix(strings.ToLower(node.XMLName.Local), "cpu") {
|
|
continue
|
|
}
|
|
|
|
fields := xmlNodeFields(node)
|
|
model := firstNonEmpty(fields["Model"], fields["CPUName"])
|
|
if model == "" {
|
|
continue
|
|
}
|
|
|
|
socket := parseSocketID(node.XMLName.Local)
|
|
cpu := models.CPU{
|
|
Socket: socket,
|
|
Model: model,
|
|
Description: strings.TrimSpace(fields["Manufacturer"]),
|
|
Cores: parseMaybeInt(fields["TotalCores"]),
|
|
Threads: parseMaybeInt(fields["TotalThreads"]),
|
|
FrequencyMHz: parseMaybeInt(fields["ProcessorSpeed"]),
|
|
MaxFreqMHz: parseMaybeInt(fields["ProcessorMaxSpeed"]),
|
|
SerialNumber: strings.TrimSpace(fields["SerialNumber"]),
|
|
PPIN: strings.TrimSpace(fields["PPIN"]),
|
|
Status: normalizePresenceStatus(fields["Status"]),
|
|
}
|
|
|
|
result.Hardware.CPUs = append(result.Hardware.CPUs, cpu)
|
|
}
|
|
}
|
|
|
|
func parseMemoryXML(content []byte, result *models.AnalysisResult) {
|
|
root, ok := parseXMLRoot(content)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
for _, node := range root.Nodes {
|
|
name := strings.ToLower(node.XMLName.Local)
|
|
if !strings.HasPrefix(name, "dimm") {
|
|
continue
|
|
}
|
|
|
|
fields := xmlNodeFields(node)
|
|
slot := strings.TrimSpace(fields["Name"])
|
|
if slot == "" {
|
|
slot = node.XMLName.Local
|
|
}
|
|
|
|
statusRaw := strings.TrimSpace(fields["Status"])
|
|
present := strings.Contains(strings.ToLower(statusRaw), "presence")
|
|
status := "absent"
|
|
if present {
|
|
status = "ok"
|
|
}
|
|
|
|
dimm := models.MemoryDIMM{
|
|
Slot: slot,
|
|
Location: strings.TrimSpace(fields["DIMMSilk"]),
|
|
Present: present,
|
|
SizeMB: parseMaybeInt(fields["DIMMSize"]),
|
|
Type: strings.TrimSpace(fields["DIMMTech"]),
|
|
MaxSpeedMHz: parseMaybeInt(fields["MaxFreq"]),
|
|
CurrentSpeedMHz: parseMaybeInt(fields["CurFreq"]),
|
|
SerialNumber: strings.TrimSpace(fields["SerialNumber"]),
|
|
PartNumber: strings.TrimSpace(fields["PartNumber"]),
|
|
Status: status,
|
|
Ranks: parseMaybeInt(fields["DIMMRanks"]),
|
|
}
|
|
if dimm.Type == "" {
|
|
dimm.Type = "DIMM"
|
|
}
|
|
|
|
result.Hardware.Memory = append(result.Hardware.Memory, dimm)
|
|
}
|
|
}
|
|
|
|
var diskSectionRE = regexp.MustCompile(`(?i)^disk_\d+$`)
|
|
|
|
func parseStorageINI(content []byte) []models.Storage {
|
|
sections := parseLooseINI(string(content))
|
|
names := sortedSectionNames(sections)
|
|
out := make([]models.Storage, 0)
|
|
|
|
for _, sectionName := range names {
|
|
if !diskSectionRE.MatchString(strings.TrimSpace(sectionName)) {
|
|
continue
|
|
}
|
|
|
|
section := sections[sectionName]
|
|
slot := getSectionValue(section, "DiskSlotDesc")
|
|
if slot == "" {
|
|
slot = sectionName
|
|
}
|
|
|
|
present := isTruthy(getSectionValue(section, "Present"))
|
|
status := "absent"
|
|
if present {
|
|
status = "ok"
|
|
}
|
|
|
|
storage := models.Storage{
|
|
Slot: slot,
|
|
SerialNumber: getSectionValue(section, "SerialNumber"),
|
|
Present: present,
|
|
Status: status,
|
|
Type: inferStorageType(slot),
|
|
}
|
|
if storage.Type == "" {
|
|
storage.Type = "disk"
|
|
}
|
|
|
|
out = append(out, storage)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseRAIDJSONVolumes(content []byte) []models.StorageVolume {
|
|
var root map[string]any
|
|
if err := json.Unmarshal(content, &root); err != nil {
|
|
return nil
|
|
}
|
|
|
|
raidCfg := toAnyMap(lookupAnyCase(root, "RaidConfig"))
|
|
ctrlInfo := toAnySlice(lookupAnyCase(raidCfg, "CtrlInfo", "ControllerInfo"))
|
|
volumes := make([]models.StorageVolume, 0)
|
|
|
|
for ctrlIdx, ctrlAny := range ctrlInfo {
|
|
ctrl := toAnyMap(ctrlAny)
|
|
ctrlSlot := firstNonEmpty(
|
|
toStringAny(lookupAnyCase(ctrl, "CtrlSlot", "CtrlDevice Slot", "Slot")),
|
|
toStringAny(lookupAnyByPrefix(ctrl, "ctrldeviceslot")),
|
|
)
|
|
ctrlName := firstNonEmpty(
|
|
toStringAny(lookupAnyCase(ctrl, "CtrlName", "CtrlDevice Name", "Name", "ControllerName")),
|
|
toStringAny(lookupAnyByPrefix(ctrl, "ctrldevicename")),
|
|
fmt.Sprintf("RAID Controller %d", ctrlIdx+1),
|
|
)
|
|
controller := ctrlName
|
|
if ctrlSlot != "" {
|
|
controller = fmt.Sprintf("%s (slot %s)", ctrlName, ctrlSlot)
|
|
}
|
|
|
|
ldInfo := toAnySlice(lookupAnyCase(ctrl, "LDInfo", "LogicalDeviceInfo", "LogicalDiskInfo", "VirtualDriveInfo"))
|
|
for ldIdx, ldAny := range ldInfo {
|
|
ld := toAnyMap(ldAny)
|
|
vol := parseVolumeFromAnyMap(ld, controller, ldIdx+1)
|
|
if isVolumeEmpty(vol) {
|
|
continue
|
|
}
|
|
volumes = append(volumes, vol)
|
|
}
|
|
}
|
|
|
|
return dedupeVolumes(volumes)
|
|
}
|
|
|
|
func parseRAIDDetailTXT(content []byte) ([]models.Storage, []models.StorageVolume) {
|
|
lines := strings.Split(string(content), "\n")
|
|
|
|
controllerInfo := make(map[string]string)
|
|
logicalRecords := make([]map[string]string, 0)
|
|
physicalRecords := make([]map[string]string, 0)
|
|
|
|
section := ""
|
|
currentLogical := make(map[string]string)
|
|
currentPhysical := make(map[string]string)
|
|
|
|
flushLogical := func() {
|
|
if len(currentLogical) == 0 {
|
|
return
|
|
}
|
|
logicalRecords = append(logicalRecords, currentLogical)
|
|
currentLogical = make(map[string]string)
|
|
}
|
|
flushPhysical := func() {
|
|
if len(currentPhysical) == 0 {
|
|
return
|
|
}
|
|
physicalRecords = append(physicalRecords, currentPhysical)
|
|
currentPhysical = make(map[string]string)
|
|
}
|
|
|
|
for _, rawLine := range lines {
|
|
line := strings.TrimSpace(rawLine)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
lower := strings.ToLower(line)
|
|
switch {
|
|
case strings.Contains(lower, "controller information"):
|
|
flushLogical()
|
|
flushPhysical()
|
|
section = "controller"
|
|
continue
|
|
case strings.Contains(lower, "logical device information"):
|
|
flushLogical()
|
|
flushPhysical()
|
|
section = "logical"
|
|
continue
|
|
case strings.Contains(lower, "physical device information"):
|
|
flushLogical()
|
|
flushPhysical()
|
|
section = "physical"
|
|
continue
|
|
case strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]"):
|
|
continue
|
|
case strings.HasPrefix(line, "---"):
|
|
continue
|
|
}
|
|
|
|
key, value, ok := parseColonKVLine(line)
|
|
if !ok {
|
|
continue
|
|
}
|
|
normKey := normalizeKey(key)
|
|
|
|
switch section {
|
|
case "controller":
|
|
controllerInfo[key] = value
|
|
case "logical":
|
|
if len(currentLogical) > 0 && startsNewLogicalRecord(currentLogical, normKey) {
|
|
flushLogical()
|
|
}
|
|
currentLogical[key] = value
|
|
case "physical":
|
|
if len(currentPhysical) > 0 && normKey == "connectionid" {
|
|
flushPhysical()
|
|
}
|
|
currentPhysical[key] = value
|
|
}
|
|
}
|
|
|
|
flushLogical()
|
|
flushPhysical()
|
|
|
|
controller := firstNonEmpty(
|
|
getSectionValue(controllerInfo, "AssetTag", "CtrlName", "ControllerName", "ChipModel"),
|
|
"RAID Controller",
|
|
)
|
|
|
|
storages := make([]models.Storage, 0, len(physicalRecords))
|
|
for _, rec := range physicalRecords {
|
|
slot := firstNonEmpty(getSectionValue(rec, "Position", "Slot", "Location"))
|
|
if slot == "" {
|
|
slot = "Connection " + getSectionValue(rec, "ConnectionID")
|
|
}
|
|
mediaType := getSectionValue(rec, "MediaType", "Type")
|
|
protocol := getSectionValue(rec, "Protocol")
|
|
model := getSectionValue(rec, "Model")
|
|
statusRaw := firstNonEmpty(getSectionValue(rec, "StatusIndicator", "FirmwareStatus", "Status"), "ok")
|
|
|
|
capacityBytes, sizeGB := parseCapacityFields(getSectionValue(rec, "CapacityBytes"), getSectionValue(rec, "Capacity"), "")
|
|
if sizeGB == 0 && capacityBytes > 0 {
|
|
sizeGB = int(capacityBytes / 1_000_000_000)
|
|
}
|
|
|
|
present := true
|
|
statusLower := strings.ToLower(statusRaw)
|
|
if containsAny(statusLower, "missing", "absent", "removed", "not present") {
|
|
present = false
|
|
}
|
|
|
|
storage := models.Storage{
|
|
Slot: slot,
|
|
Type: inferStorageTypeFromValues(slot, mediaType, protocol, model),
|
|
Model: strings.TrimSpace(model),
|
|
SizeGB: sizeGB,
|
|
SerialNumber: getSectionValue(rec, "SerialNumber"),
|
|
Manufacturer: getSectionValue(rec, "Manufacturer", "Vendor"),
|
|
Firmware: getSectionValue(rec, "Revision", "FirmwareVersion"),
|
|
Interface: protocol,
|
|
Present: present,
|
|
Location: firstNonEmpty(getSectionValue(rec, "Position"), controller),
|
|
Status: normalizeStorageStatus(statusRaw, present),
|
|
}
|
|
if storage.Type == "" {
|
|
storage.Type = "disk"
|
|
}
|
|
storages = append(storages, storage)
|
|
}
|
|
|
|
volumes := make([]models.StorageVolume, 0, len(logicalRecords))
|
|
for idx, rec := range logicalRecords {
|
|
vol := parseVolumeFromStringMap(rec, controller, idx+1)
|
|
if isVolumeEmpty(vol) {
|
|
continue
|
|
}
|
|
volumes = append(volumes, vol)
|
|
}
|
|
|
|
return dedupeStorage(storages), dedupeVolumes(volumes)
|
|
}
|
|
|
|
func parseNVMeInfo(content []byte) []models.Storage {
|
|
text := strings.TrimSpace(string(content))
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
lower := strings.ToLower(text)
|
|
if strings.Contains(lower, "note: no nvme info") {
|
|
return nil
|
|
}
|
|
|
|
storages := make([]models.Storage, 0)
|
|
sections := parseLooseINI(text)
|
|
for _, sectionName := range sortedSectionNames(sections) {
|
|
section := sections[sectionName]
|
|
storage := parseStorageFromSection(section, sectionName, "nvme")
|
|
if isStorageEmpty(storage) {
|
|
continue
|
|
}
|
|
storages = append(storages, storage)
|
|
}
|
|
if len(storages) > 0 {
|
|
return dedupeStorage(storages)
|
|
}
|
|
|
|
for _, block := range parseKeyValueBlocks(text) {
|
|
storage := parseStorageFromSection(block, "NVMe", "nvme")
|
|
if isStorageEmpty(storage) {
|
|
continue
|
|
}
|
|
storages = append(storages, storage)
|
|
}
|
|
|
|
return dedupeStorage(storages)
|
|
}
|
|
|
|
func parseStorageFromSection(section map[string]string, fallbackSlot, defaultType string) models.Storage {
|
|
slot := firstNonEmpty(getSectionValue(section, "DiskSlotDesc", "Position", "Slot", "Name"), fallbackSlot)
|
|
model := firstNonEmpty(getSectionValue(section, "Model", "ProductName", "DeviceModel", "PartNumber"), "")
|
|
media := firstNonEmpty(getSectionValue(section, "MediaType", "Type"), defaultType)
|
|
protocol := firstNonEmpty(getSectionValue(section, "Protocol", "Interface"), "")
|
|
statusRaw := firstNonEmpty(getSectionValue(section, "Status", "State", "Health"), "")
|
|
presentRaw := firstNonEmpty(getSectionValue(section, "Present"), "")
|
|
|
|
present := true
|
|
if presentRaw != "" {
|
|
present = isTruthy(presentRaw)
|
|
if !present && !containsAny(strings.ToLower(presentRaw), "absent", "missing", "no") {
|
|
present = true
|
|
}
|
|
}
|
|
if statusRaw != "" && containsAny(strings.ToLower(statusRaw), "absent", "missing", "removed") {
|
|
present = false
|
|
}
|
|
|
|
capacityBytes, sizeGB := parseCapacityFields(
|
|
getSectionValue(section, "CapacityBytes", "SizeBytes"),
|
|
getSectionValue(section, "Capacity", "Size"),
|
|
getSectionValue(section, "SizeGB", "CapacityGB"),
|
|
)
|
|
if sizeGB == 0 && capacityBytes > 0 {
|
|
sizeGB = int(capacityBytes / 1_000_000_000)
|
|
}
|
|
|
|
storage := models.Storage{
|
|
Slot: slot,
|
|
Type: inferStorageTypeFromValues(slot, media, protocol, model),
|
|
Model: model,
|
|
SizeGB: sizeGB,
|
|
SerialNumber: getSectionValue(section, "SerialNumber"),
|
|
Manufacturer: getSectionValue(section, "Manufacturer", "Vendor"),
|
|
Firmware: getSectionValue(section, "Revision", "FirmwareVersion", "Firmware"),
|
|
Interface: protocol,
|
|
Present: present,
|
|
Location: firstNonEmpty(getSectionValue(section, "Position", "Location"), slot),
|
|
Status: normalizeStorageStatus(statusRaw, present),
|
|
}
|
|
if storage.Type == "" {
|
|
storage.Type = defaultType
|
|
}
|
|
|
|
return storage
|
|
}
|
|
|
|
func parseVolumeFromAnyMap(rec map[string]any, controller string, idx int) models.StorageVolume {
|
|
id := firstNonEmpty(
|
|
toStringAny(lookupAnyCase(rec, "LDID", "LD ID", "Id", "ID", "VDID", "LogicalDeviceID", "VirtualDriveID")),
|
|
fmt.Sprintf("ld-%d", idx),
|
|
)
|
|
name := firstNonEmpty(
|
|
toStringAny(lookupAnyCase(rec, "LDName", "LD_name", "Name", "VolumeName", "LogicalDeviceName")),
|
|
id,
|
|
)
|
|
|
|
capacityBytes := parseLDCapacity(rec)
|
|
sizeGB := parseMaybeInt(toStringAny(lookupAnyCase(rec, "SizeGB", "CapacityGB")))
|
|
if sizeGB == 0 && capacityBytes > 0 {
|
|
sizeGB = int(capacityBytes / 1_000_000_000)
|
|
}
|
|
|
|
bootable, _ := toBoolAny(lookupAnyCase(rec, "Bootable", "IsBootable"))
|
|
encrypted, _ := toBoolAny(lookupAnyCase(rec, "Encrypted", "IsEncrypted", "EncryptionEnabled"))
|
|
|
|
return models.StorageVolume{
|
|
ID: id,
|
|
Name: name,
|
|
Controller: controller,
|
|
RAIDLevel: normalizeRAIDLevel(firstNonEmpty(toStringAny(lookupAnyCase(rec, "RAIDLevel", "RaidLevel", "RAID", "Level")), toStringAny(lookupAnyByPrefix(rec, "raidlevel")))),
|
|
SizeGB: sizeGB,
|
|
CapacityBytes: capacityBytes,
|
|
Status: normalizeVolumeStatus(toStringAny(lookupAnyCase(rec, "Status", "State", "HealthStatus"))),
|
|
Bootable: bootable,
|
|
Encrypted: encrypted,
|
|
}
|
|
}
|
|
|
|
func parseVolumeFromStringMap(rec map[string]string, controller string, idx int) models.StorageVolume {
|
|
id := firstNonEmpty(
|
|
getSectionValue(rec, "LDID", "Id", "ID", "VDID", "LogicalDeviceID", "VirtualDriveID"),
|
|
fmt.Sprintf("ld-%d", idx),
|
|
)
|
|
name := firstNonEmpty(
|
|
getSectionValue(rec, "LDName", "Name", "VolumeName", "LogicalDeviceName"),
|
|
id,
|
|
)
|
|
|
|
capacityBytes, sizeGB := parseCapacityFields(
|
|
getSectionValue(rec, "CapacityBytes", "SizeBytes"),
|
|
getSectionValue(rec, "Capacity", "Size"),
|
|
getSectionValue(rec, "SizeGB", "CapacityGB"),
|
|
)
|
|
if sizeGB == 0 && capacityBytes > 0 {
|
|
sizeGB = int(capacityBytes / 1_000_000_000)
|
|
}
|
|
|
|
bootable, _ := parseBoolString(getSectionValue(rec, "Bootable", "IsBootable"))
|
|
encrypted, _ := parseBoolString(getSectionValue(rec, "Encrypted", "IsEncrypted", "EncryptionEnabled"))
|
|
|
|
return models.StorageVolume{
|
|
ID: id,
|
|
Name: name,
|
|
Controller: controller,
|
|
RAIDLevel: normalizeRAIDLevel(getSectionValue(rec, "RAIDLevel", "RaidLevel", "RAID", "Level")),
|
|
SizeGB: sizeGB,
|
|
CapacityBytes: capacityBytes,
|
|
Status: normalizeVolumeStatus(getSectionValue(rec, "Status", "State", "HealthStatus")),
|
|
Bootable: bootable,
|
|
Encrypted: encrypted,
|
|
}
|
|
}
|
|
|
|
func parseSensorInfoINI(content []byte) []models.SensorReading {
|
|
sensors := make([]models.SensorReading, 0)
|
|
scanner := bufio.NewScanner(strings.NewReader(string(content)))
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || !strings.Contains(line, "|") {
|
|
continue
|
|
}
|
|
if strings.Contains(strings.ToLower(line), "sensor name") && strings.Contains(strings.ToLower(line), "reading") {
|
|
continue
|
|
}
|
|
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 4 {
|
|
continue
|
|
}
|
|
|
|
name := strings.TrimSpace(parts[0])
|
|
reading := strings.TrimSpace(parts[1])
|
|
unit := strings.TrimSpace(parts[2])
|
|
status := strings.TrimSpace(parts[3])
|
|
if name == "" {
|
|
continue
|
|
}
|
|
|
|
sensor := models.SensorReading{
|
|
Name: name,
|
|
Type: inferSensorType(unit, reading),
|
|
Unit: unit,
|
|
RawValue: reading,
|
|
Status: normalizeSensorStatus(status),
|
|
}
|
|
if v, ok := parseMaybeFloat(reading); ok {
|
|
sensor.Value = v
|
|
}
|
|
|
|
sensors = append(sensors, sensor)
|
|
}
|
|
|
|
return sensors
|
|
}
|
|
|
|
type selEntry struct {
|
|
Created string `json:"Created"`
|
|
Description string `json:"Description"`
|
|
Severity string `json:"Severity"`
|
|
EntryCode string `json:"EntryCode"`
|
|
EntryType string `json:"EntryType"`
|
|
ID int `json:"Id"`
|
|
Level string `json:"Level"`
|
|
Message string `json:"Message"`
|
|
SensorName string `json:"SensorName"`
|
|
SensorType string `json:"SensorType"`
|
|
}
|
|
|
|
func parseSELJSON(content []byte) []models.Event {
|
|
text := strings.TrimSpace(string(content))
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
|
|
if !strings.HasPrefix(text, "[") {
|
|
text = strings.TrimRight(text, ", \n\r\t")
|
|
text = "[" + text + "]"
|
|
}
|
|
|
|
var entries []selEntry
|
|
if err := json.Unmarshal([]byte(text), &entries); err != nil {
|
|
return nil
|
|
}
|
|
|
|
events := make([]models.Event, 0, len(entries))
|
|
for _, entry := range entries {
|
|
timestamp := parseH3CTimestamp(entry.Created)
|
|
if timestamp.IsZero() {
|
|
timestamp = time.Now()
|
|
}
|
|
|
|
description := firstNonEmpty(entry.Message, entry.Description)
|
|
eventType := firstNonEmpty(entry.SensorType, entry.EntryType, "SEL")
|
|
|
|
events = append(events, models.Event{
|
|
ID: strconv.Itoa(entry.ID),
|
|
Timestamp: timestamp,
|
|
Source: "SEL",
|
|
SensorType: strings.TrimSpace(entry.SensorType),
|
|
SensorName: strings.TrimSpace(entry.SensorName),
|
|
EventType: strings.TrimSpace(eventType),
|
|
Severity: mapH3CSeverity(entry.Severity, entry.Level, entry.Message),
|
|
Description: strings.TrimSpace(description),
|
|
RawData: strings.TrimSpace(entry.EntryCode),
|
|
})
|
|
}
|
|
|
|
return events
|
|
}
|
|
|
|
func parseSELListTXT(content []byte) []models.Event {
|
|
lines := strings.Split(string(content), "\n")
|
|
events := make([]models.Event, 0, len(lines))
|
|
location := parser.DefaultArchiveLocation()
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || !strings.Contains(line, "|") {
|
|
continue
|
|
}
|
|
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 6 {
|
|
continue
|
|
}
|
|
|
|
id := strings.TrimSpace(parts[0])
|
|
date := strings.TrimSpace(parts[1])
|
|
tod := strings.TrimSpace(parts[2])
|
|
sensor := strings.TrimSpace(parts[3])
|
|
message := strings.TrimSpace(parts[4])
|
|
state := strings.TrimSpace(parts[5])
|
|
|
|
if strings.EqualFold(date, "Pre-Init") {
|
|
continue
|
|
}
|
|
|
|
ts, err := time.ParseInLocation("01/02/2006 15:04:05", date+" "+tod, location)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
sensorType := strings.TrimSpace(sensor)
|
|
sensorName := ""
|
|
if hashIdx := strings.Index(sensor, "#"); hashIdx != -1 {
|
|
sensorType = strings.TrimSpace(sensor[:hashIdx])
|
|
sensorName = strings.TrimSpace(sensor[hashIdx+1:])
|
|
}
|
|
|
|
events = append(events, models.Event{
|
|
ID: id,
|
|
Timestamp: ts,
|
|
Source: "SEL",
|
|
SensorType: sensorType,
|
|
SensorName: sensorName,
|
|
EventType: message,
|
|
Severity: mapH3CSeverity("", "", sensor+" "+message+" "+state),
|
|
Description: buildSELDescription(message, state),
|
|
RawData: line,
|
|
})
|
|
}
|
|
|
|
return events
|
|
}
|
|
|
|
func parseSELCSVFiles(files []parser.ExtractedFile) []models.Event {
|
|
events := make([]models.Event, 0)
|
|
for _, f := range files {
|
|
path := strings.ToLower(strings.TrimSpace(f.Path))
|
|
if !strings.HasSuffix(path, ".csv") {
|
|
continue
|
|
}
|
|
if !(strings.Contains(path, "/user/") || strings.HasPrefix(path, "user/")) {
|
|
continue
|
|
}
|
|
events = append(events, parseSELCSV(f.Content)...)
|
|
}
|
|
return events
|
|
}
|
|
|
|
func parseSELCSV(content []byte) []models.Event {
|
|
csvText := strings.ReplaceAll(string(content), "\x00", "")
|
|
reader := csv.NewReader(strings.NewReader(csvText))
|
|
reader.FieldsPerRecord = -1
|
|
reader.LazyQuotes = true
|
|
reader.TrimLeadingSpace = true
|
|
|
|
header, err := reader.Read()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
index := make(map[string]int, len(header))
|
|
for i, col := range header {
|
|
index[normalizeKey(col)] = i
|
|
}
|
|
|
|
required := []string{"descinfo"}
|
|
if !hasAllKeys(index, required...) {
|
|
return nil
|
|
}
|
|
if !hasAnyKey(index, "recordtimestamp", "eventoccurredtime") {
|
|
return nil
|
|
}
|
|
|
|
events := make([]models.Event, 0, 128)
|
|
|
|
for {
|
|
row, err := reader.Read()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
ts := parseSELCSVTimestamp(
|
|
readCSVField(row, index, "eventoccurredtime"),
|
|
readCSVField(row, index, "recordtimestamp"),
|
|
)
|
|
if ts.IsZero() {
|
|
continue
|
|
}
|
|
|
|
desc := strings.TrimSpace(readCSVField(row, index, "descinfo"))
|
|
explanation := strings.TrimSpace(readCSVField(row, index, "explanation"))
|
|
if desc == "" && explanation == "" {
|
|
continue
|
|
}
|
|
|
|
events = append(events, models.Event{
|
|
ID: strconv.Itoa(len(events) + 1),
|
|
Timestamp: ts,
|
|
Source: "SEL",
|
|
SensorType: strings.TrimSpace(readCSVField(row, index, "sensortypestr")),
|
|
SensorName: strings.TrimSpace(readCSVField(row, index, "sensorname")),
|
|
EventType: strings.TrimSpace(readCSVField(row, index, "eventdir")),
|
|
Severity: mapH3CSeverity(readCSVField(row, index, "severitylevel"), readCSVField(row, index, "severitylevelid"), desc+" "+explanation),
|
|
Description: composeSELCSVDescription(desc, explanation),
|
|
RawData: strings.TrimSpace(readCSVField(row, index, "severitylevelid")),
|
|
})
|
|
}
|
|
|
|
return events
|
|
}
|
|
|
|
func parseSELCSVTimestamp(values ...string) time.Time {
|
|
location := parser.DefaultArchiveLocation()
|
|
for _, raw := range values {
|
|
clean := strings.TrimSpace(strings.ReplaceAll(raw, "\x00", ""))
|
|
if clean == "" || strings.EqualFold(clean, "Pre-Init") {
|
|
continue
|
|
}
|
|
if ts := parseH3CTimestamp(clean); !ts.IsZero() {
|
|
return ts
|
|
}
|
|
if ts, err := time.ParseInLocation("2006-01-02 15:04:05", clean, location); err == nil {
|
|
return ts
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func composeSELCSVDescription(desc, explanation string) string {
|
|
desc = strings.TrimSpace(desc)
|
|
explanation = strings.TrimSpace(explanation)
|
|
if explanation == "" || explanation == desc {
|
|
return desc
|
|
}
|
|
if desc == "" {
|
|
return explanation
|
|
}
|
|
return desc + " | " + explanation
|
|
}
|
|
|
|
func readCSVField(row []string, index map[string]int, key string) string {
|
|
i, ok := index[normalizeKey(key)]
|
|
if !ok || i < 0 || i >= len(row) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(strings.ReplaceAll(row[i], "\x00", ""))
|
|
}
|
|
|
|
func hasAnyKey(index map[string]int, keys ...string) bool {
|
|
for _, key := range keys {
|
|
if _, ok := index[normalizeKey(key)]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasAllKeys(index map[string]int, keys ...string) bool {
|
|
for _, key := range keys {
|
|
if _, ok := index[normalizeKey(key)]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseH3CTimestamp(value string) time.Time {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return time.Time{}
|
|
}
|
|
|
|
layouts := []string{
|
|
"2006-01-02 15:04:05 MST-07:00",
|
|
"2006-01-02 15:04:05 -07:00",
|
|
time.RFC3339,
|
|
}
|
|
for _, layout := range layouts {
|
|
if ts, err := time.Parse(layout, value); err == nil {
|
|
return ts
|
|
}
|
|
}
|
|
|
|
cleaned := strings.ReplaceAll(value, "UTC", "")
|
|
cleaned = strings.Join(strings.Fields(cleaned), " ")
|
|
if ts, err := time.Parse("2006-01-02 15:04:05 -07:00", cleaned); err == nil {
|
|
return ts
|
|
}
|
|
|
|
return time.Time{}
|
|
}
|
|
|
|
func mapH3CSeverity(severity, level, text string) models.Severity {
|
|
all := strings.ToLower(strings.TrimSpace(severity + " " + level + " " + text))
|
|
if all == "" {
|
|
return models.SeverityInfo
|
|
}
|
|
|
|
if containsAny(all, "critical", "major", "fatal", "unrecoverable", "off-line", "offline", "redundancy lost", "failure", "fault", "error") {
|
|
return models.SeverityCritical
|
|
}
|
|
if containsAny(all, "warning", "warn", "minor", "non-critical", "lost", "degraded", "disabled", "abnormal") {
|
|
return models.SeverityWarning
|
|
}
|
|
return models.SeverityInfo
|
|
}
|
|
|
|
func buildSELDescription(message, state string) string {
|
|
message = strings.TrimSpace(message)
|
|
state = strings.TrimSpace(state)
|
|
if state == "" || strings.EqualFold(state, "Asserted") || strings.EqualFold(state, "Deasserted") {
|
|
return message
|
|
}
|
|
if message == "" {
|
|
return state
|
|
}
|
|
return message + " (" + state + ")"
|
|
}
|
|
|
|
func appendFirmwareUnique(result *models.AnalysisResult, seen map[string]struct{}, fw models.FirmwareInfo) {
|
|
fw.DeviceName = strings.TrimSpace(fw.DeviceName)
|
|
fw.Description = strings.TrimSpace(fw.Description)
|
|
fw.Version = strings.TrimSpace(fw.Version)
|
|
if fw.DeviceName == "" || fw.Version == "" {
|
|
return
|
|
}
|
|
|
|
key := strings.ToLower(fw.DeviceName + "|" + fw.Version + "|" + fw.Description)
|
|
if _, ok := seen[key]; ok {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
result.Hardware.Firmware = append(result.Hardware.Firmware, fw)
|
|
}
|
|
|
|
func parseLooseINI(content string) map[string]map[string]string {
|
|
sections := make(map[string]map[string]string)
|
|
current := ""
|
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
|
current = strings.TrimSpace(line[1 : len(line)-1])
|
|
if current != "" {
|
|
if _, ok := sections[current]; !ok {
|
|
sections[current] = make(map[string]string)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if current == "" {
|
|
continue
|
|
}
|
|
|
|
sep := strings.IndexAny(line, "=:")
|
|
if sep <= 0 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(line[:sep])
|
|
value := strings.TrimSpace(line[sep+1:])
|
|
if key == "" {
|
|
continue
|
|
}
|
|
sections[current][key] = value
|
|
}
|
|
|
|
return sections
|
|
}
|
|
|
|
func getSectionValue(section map[string]string, keys ...string) string {
|
|
for _, k := range keys {
|
|
normK := normalizeKey(k)
|
|
for rawKey, rawVal := range section {
|
|
if normalizeKey(rawKey) == normK {
|
|
val := strings.TrimSpace(rawVal)
|
|
if val != "" {
|
|
return val
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeKey(s string) string {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for _, r := range s {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func sortedSectionNames(sections map[string]map[string]string) []string {
|
|
names := make([]string, 0, len(sections))
|
|
for name := range sections {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func isEmptyFRU(f models.FRUInfo) bool {
|
|
return strings.TrimSpace(f.Manufacturer) == "" &&
|
|
strings.TrimSpace(f.ProductName) == "" &&
|
|
strings.TrimSpace(f.SerialNumber) == "" &&
|
|
strings.TrimSpace(f.PartNumber) == "" &&
|
|
strings.TrimSpace(f.Version) == ""
|
|
}
|
|
|
|
func applyBoardFromFRU(board *models.BoardInfo, fru models.FRUInfo) {
|
|
setIfEmpty(&board.Manufacturer, fru.Manufacturer)
|
|
setIfEmpty(&board.ProductName, fru.ProductName)
|
|
setIfEmpty(&board.SerialNumber, fru.SerialNumber)
|
|
setIfEmpty(&board.PartNumber, fru.PartNumber)
|
|
setIfEmpty(&board.Version, fru.Version)
|
|
}
|
|
|
|
func setIfEmpty(dst *string, value string) {
|
|
if strings.TrimSpace(*dst) != "" {
|
|
return
|
|
}
|
|
*dst = strings.TrimSpace(value)
|
|
}
|
|
|
|
type xmlNode struct {
|
|
XMLName xml.Name
|
|
Text string `xml:",chardata"`
|
|
Nodes []xmlNode `xml:",any"`
|
|
}
|
|
|
|
func parseXMLRoot(content []byte) (xmlNode, bool) {
|
|
var root xmlNode
|
|
if err := xml.Unmarshal(content, &root); err != nil {
|
|
return xmlNode{}, false
|
|
}
|
|
return root, true
|
|
}
|
|
|
|
func xmlNodeFields(node xmlNode) map[string]string {
|
|
fields := make(map[string]string, len(node.Nodes))
|
|
for _, child := range node.Nodes {
|
|
fields[child.XMLName.Local] = strings.TrimSpace(child.Text)
|
|
}
|
|
return fields
|
|
}
|
|
|
|
var socketSuffixRE = regexp.MustCompile(`(?i)cpu(\d+)$`)
|
|
|
|
func parseSocketID(name string) int {
|
|
m := socketSuffixRE.FindStringSubmatch(strings.TrimSpace(name))
|
|
if len(m) != 2 {
|
|
return 0
|
|
}
|
|
v, _ := strconv.Atoi(m[1])
|
|
if v <= 0 {
|
|
return 0
|
|
}
|
|
return v
|
|
}
|
|
|
|
func parseSectionIndex(sectionName, expr string) int {
|
|
re, err := regexp.Compile(expr)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
match := re.FindStringSubmatch(sectionName)
|
|
if len(match) < 2 {
|
|
return 0
|
|
}
|
|
v, _ := strconv.Atoi(match[1])
|
|
if v <= 0 {
|
|
return 0
|
|
}
|
|
return v
|
|
}
|
|
|
|
func formatSlot(frontRear, slotNum string) string {
|
|
frontRear = strings.TrimSpace(frontRear)
|
|
slotNum = strings.TrimSpace(slotNum)
|
|
switch {
|
|
case frontRear == "" && slotNum == "":
|
|
return ""
|
|
case frontRear == "":
|
|
return slotNum
|
|
case slotNum == "":
|
|
return frontRear
|
|
default:
|
|
return frontRear + " slot " + slotNum
|
|
}
|
|
}
|
|
|
|
func parseMaybeInt(raw string) int {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
|
|
if strings.HasPrefix(strings.ToLower(raw), "0x") {
|
|
if v, err := strconv.ParseInt(raw[2:], 16, 64); err == nil {
|
|
return int(v)
|
|
}
|
|
}
|
|
|
|
if v, err := strconv.Atoi(raw); err == nil {
|
|
return v
|
|
}
|
|
if fv, err := strconv.ParseFloat(raw, 64); err == nil {
|
|
return int(fv)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
var firstNumberRE = regexp.MustCompile(`[-+]?[0-9]+(?:\.[0-9]+)?`)
|
|
|
|
func parseMaybeIntLoose(raw string) int {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
if v := parseMaybeInt(raw); v > 0 {
|
|
return v
|
|
}
|
|
|
|
number := firstNumberRE.FindString(raw)
|
|
if number == "" {
|
|
return 0
|
|
}
|
|
if fv, err := strconv.ParseFloat(number, 64); err == nil {
|
|
return int(fv)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func parseMaybeFloat(raw string) (float64, bool) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || strings.EqualFold(raw, "na") {
|
|
return 0, false
|
|
}
|
|
if strings.HasPrefix(strings.ToLower(raw), "0x") {
|
|
return 0, false
|
|
}
|
|
v, err := strconv.ParseFloat(raw, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
func normalizePresenceStatus(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(strings.ToLower(raw), "presence") {
|
|
return "ok"
|
|
}
|
|
return strings.ToLower(raw)
|
|
}
|
|
|
|
func normalizeComponentStatus(raw string) string {
|
|
s := strings.ToLower(strings.TrimSpace(raw))
|
|
switch {
|
|
case s == "":
|
|
return ""
|
|
case containsAny(s, "ok", "normal", "presence", "present", "healthy", "running"):
|
|
return "ok"
|
|
case containsAny(s, "absent", "missing", "not present", "removed"):
|
|
return "absent"
|
|
case containsAny(s, "warn", "degrad", "minor"):
|
|
return "warning"
|
|
case containsAny(s, "critical", "major", "fault", "fail", "error"):
|
|
return "critical"
|
|
default:
|
|
return s
|
|
}
|
|
}
|
|
|
|
func inferStorageType(slot string) string {
|
|
return inferStorageTypeFromValues(slot, "", "", "")
|
|
}
|
|
|
|
func inferStorageTypeFromValues(slot, mediaType, protocol, model string) string {
|
|
lower := strings.ToLower(strings.TrimSpace(slot + " " + mediaType + " " + protocol + " " + model))
|
|
switch {
|
|
case strings.Contains(lower, "nvme"):
|
|
return "nvme"
|
|
case strings.Contains(lower, "ssd"):
|
|
return "ssd"
|
|
case strings.Contains(lower, "hdd"), strings.Contains(lower, "sas"), strings.Contains(lower, "sata"):
|
|
return "disk"
|
|
case strings.Contains(lower, "disk"), strings.Contains(lower, "front"), strings.Contains(lower, "rear"):
|
|
return "disk"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func inferSensorType(unit, reading string) string {
|
|
lowerUnit := strings.ToLower(strings.TrimSpace(unit))
|
|
switch {
|
|
case strings.Contains(lowerUnit, "degrees"):
|
|
return "temperature"
|
|
case strings.Contains(lowerUnit, "volts"):
|
|
return "voltage"
|
|
case strings.Contains(lowerUnit, "amps"):
|
|
return "current"
|
|
case strings.Contains(lowerUnit, "watts"):
|
|
return "power"
|
|
case strings.Contains(lowerUnit, "rpm"):
|
|
return "fan"
|
|
case strings.Contains(lowerUnit, "discrete"):
|
|
return "discrete"
|
|
}
|
|
|
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(reading)), "0x") {
|
|
return "discrete"
|
|
}
|
|
return "sensor"
|
|
}
|
|
|
|
func normalizeSensorStatus(status string) string {
|
|
status = strings.TrimSpace(status)
|
|
if status == "" {
|
|
return "unknown"
|
|
}
|
|
if strings.EqualFold(status, "na") {
|
|
return "unknown"
|
|
}
|
|
return strings.ToLower(status)
|
|
}
|
|
|
|
func isTruthy(raw string) bool {
|
|
raw = strings.ToLower(strings.TrimSpace(raw))
|
|
return raw == "yes" || raw == "y" || raw == "true" || raw == "1" || raw == "present"
|
|
}
|
|
|
|
func containsAny(s string, items ...string) bool {
|
|
for _, item := range items {
|
|
if strings.Contains(s, item) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizeStorageStatus(raw string, present bool) string {
|
|
s := strings.ToLower(strings.TrimSpace(raw))
|
|
switch {
|
|
case s == "" && present:
|
|
return "ok"
|
|
case s == "":
|
|
return "absent"
|
|
case containsAny(s, "ok", "healthy", "optimal", "normal"):
|
|
return "ok"
|
|
case containsAny(s, "missing", "absent", "removed", "not present"):
|
|
return "absent"
|
|
case containsAny(s, "predictive", "degrad", "warn"):
|
|
return "warning"
|
|
default:
|
|
return s
|
|
}
|
|
}
|
|
|
|
func normalizeVolumeStatus(raw string) string {
|
|
s := strings.ToLower(strings.TrimSpace(raw))
|
|
switch {
|
|
case s == "":
|
|
return ""
|
|
case containsAny(s, "ok", "healthy", "optimal", "normal"):
|
|
return "ok"
|
|
case containsAny(s, "degrad", "rebuild", "warn"):
|
|
return "warning"
|
|
case containsAny(s, "fail", "offline", "critical"):
|
|
return "critical"
|
|
default:
|
|
return s
|
|
}
|
|
}
|
|
|
|
func normalizeRAIDLevel(raw string) string {
|
|
s := strings.ToUpper(strings.TrimSpace(raw))
|
|
s = strings.ReplaceAll(s, " ", "")
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(s, "RAID") {
|
|
return s
|
|
}
|
|
if raidNumericRE.MatchString(s) {
|
|
return "RAID" + s
|
|
}
|
|
return s
|
|
}
|
|
|
|
var raidNumericRE = regexp.MustCompile(`^\d+(?:\+\d+)?$`)
|
|
var sizeValueRE = regexp.MustCompile(`(?i)^([0-9]+(?:\.[0-9]+)?)\s*([KMGTPE]?)(I?B)?$`)
|
|
|
|
func parseCapacityFields(bytesField, sizeField, sizeGBField string) (int64, int) {
|
|
if b := parseCapacityFromString(bytesField); b > 0 {
|
|
return b, int(b / 1_000_000_000)
|
|
}
|
|
if b := parseCapacityFromString(sizeField); b > 0 {
|
|
return b, int(b / 1_000_000_000)
|
|
}
|
|
sizeGB := parseMaybeInt(sizeGBField)
|
|
if sizeGB > 0 {
|
|
return int64(sizeGB) * 1_000_000_000, sizeGB
|
|
}
|
|
return 0, 0
|
|
}
|
|
|
|
func parseCapacityFromString(raw string) int64 {
|
|
clean := strings.ReplaceAll(strings.TrimSpace(raw), ",", "")
|
|
if clean == "" {
|
|
return 0
|
|
}
|
|
if strings.EqualFold(clean, "na") {
|
|
return 0
|
|
}
|
|
if v, err := strconv.ParseInt(clean, 10, 64); err == nil {
|
|
return v
|
|
}
|
|
m := sizeValueRE.FindStringSubmatch(clean)
|
|
if len(m) != 4 {
|
|
return 0
|
|
}
|
|
value, err := strconv.ParseFloat(m[1], 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
unit := strings.ToUpper(m[2])
|
|
suffix := strings.ToUpper(m[3])
|
|
base := float64(1000)
|
|
if strings.HasPrefix(suffix, "I") {
|
|
base = 1024
|
|
}
|
|
power := 0
|
|
switch unit {
|
|
case "K":
|
|
power = 1
|
|
case "M":
|
|
power = 2
|
|
case "G":
|
|
power = 3
|
|
case "T":
|
|
power = 4
|
|
case "P":
|
|
power = 5
|
|
case "E":
|
|
power = 6
|
|
}
|
|
mult := float64(1)
|
|
for i := 0; i < power; i++ {
|
|
mult *= base
|
|
}
|
|
return int64(value * mult)
|
|
}
|
|
|
|
func parseCapacityFromAny(v any) int64 {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return 0
|
|
case float64:
|
|
return int64(t)
|
|
case int:
|
|
return int64(t)
|
|
case int64:
|
|
return t
|
|
case json.Number:
|
|
if i, err := t.Int64(); err == nil {
|
|
return i
|
|
}
|
|
if f, err := t.Float64(); err == nil {
|
|
return int64(f)
|
|
}
|
|
return 0
|
|
case string:
|
|
return parseCapacityFromString(t)
|
|
case map[string]any:
|
|
return parseCapacityFromAny(lookupAnyCase(t, "value", "Value", "CapacityBytes", "SizeBytes"))
|
|
default:
|
|
return parseCapacityFromString(fmt.Sprintf("%v", t))
|
|
}
|
|
}
|
|
|
|
func parseLDCapacity(rec map[string]any) int64 {
|
|
base := parseCapacityFromAny(
|
|
lookupAnyCase(rec, "CapacityBytes", "SizeBytes", "Capacity", "Size", "LogicalCapacity", "LogicalCapacityBytes"),
|
|
)
|
|
if base > 0 {
|
|
return base
|
|
}
|
|
|
|
for rawKey, rawVal := range rec {
|
|
norm := normalizeKey(rawKey)
|
|
if strings.HasPrefix(norm, "logicalcapacity") || strings.HasPrefix(norm, "logicalcapicity") {
|
|
v := parseCapacityFromAny(rawVal)
|
|
if v <= 0 {
|
|
continue
|
|
}
|
|
if strings.Contains(norm, "512") {
|
|
return v * 512
|
|
}
|
|
return v
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func parseColonKVLine(line string) (string, string, bool) {
|
|
idx := strings.Index(line, ":")
|
|
if idx <= 0 {
|
|
return "", "", false
|
|
}
|
|
key := strings.TrimSpace(line[:idx])
|
|
value := strings.TrimSpace(line[idx+1:])
|
|
if key == "" {
|
|
return "", "", false
|
|
}
|
|
return key, value, true
|
|
}
|
|
|
|
func parseFlexibleKVLine(line string) (string, string, bool) {
|
|
if key, val, ok := parseColonKVLine(line); ok {
|
|
return key, val, true
|
|
}
|
|
idx := strings.Index(line, "=")
|
|
if idx <= 0 {
|
|
return "", "", false
|
|
}
|
|
key := strings.TrimSpace(line[:idx])
|
|
value := strings.TrimSpace(line[idx+1:])
|
|
if key == "" {
|
|
return "", "", false
|
|
}
|
|
return key, value, true
|
|
}
|
|
|
|
func startsNewLogicalRecord(current map[string]string, normalizedKey string) bool {
|
|
switch normalizedKey {
|
|
case "ldid", "logicaldeviceid", "virtualdriveid":
|
|
return true
|
|
case "id":
|
|
return hasAnyNormalizedKey(current, "ldid", "logicaldeviceid", "virtualdriveid", "id")
|
|
case "name":
|
|
return hasAnyNormalizedKey(current, "name", "ldname", "logicaldevicename")
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func hasAnyNormalizedKey(m map[string]string, keys ...string) bool {
|
|
for rawKey := range m {
|
|
n := normalizeKey(rawKey)
|
|
for _, key := range keys {
|
|
if n == key {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseKeyValueBlocks(content string) []map[string]string {
|
|
blocks := make([]map[string]string, 0)
|
|
current := make(map[string]string)
|
|
|
|
flush := func() {
|
|
if len(current) == 0 {
|
|
return
|
|
}
|
|
blocks = append(blocks, current)
|
|
current = make(map[string]string)
|
|
}
|
|
|
|
lines := strings.Split(content, "\n")
|
|
for _, rawLine := range lines {
|
|
line := strings.TrimSpace(rawLine)
|
|
if line == "" {
|
|
flush()
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
|
flush()
|
|
continue
|
|
}
|
|
key, value, ok := parseFlexibleKVLine(line)
|
|
if !ok {
|
|
continue
|
|
}
|
|
nk := normalizeKey(key)
|
|
if nk == "serialnumber" && hasAnyNormalizedKey(current, "serialnumber") {
|
|
flush()
|
|
}
|
|
current[key] = value
|
|
}
|
|
flush()
|
|
return blocks
|
|
}
|
|
|
|
func findCPUIndex(items []models.CPU, target models.CPU) int {
|
|
targetSocket := target.Socket
|
|
targetPPIN := strings.ToLower(strings.TrimSpace(target.PPIN))
|
|
targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber))
|
|
targetModel := strings.ToLower(strings.TrimSpace(target.Model))
|
|
|
|
for i := range items {
|
|
cpu := items[i]
|
|
if targetSocket > 0 && cpu.Socket > 0 && targetSocket == cpu.Socket {
|
|
return i
|
|
}
|
|
if targetSocket > 0 && cpu.Socket > 0 && targetSocket != cpu.Socket {
|
|
continue
|
|
}
|
|
|
|
ppin := strings.ToLower(strings.TrimSpace(cpu.PPIN))
|
|
if targetPPIN != "" && ppin != "" && targetPPIN == ppin {
|
|
return i
|
|
}
|
|
|
|
serial := strings.ToLower(strings.TrimSpace(cpu.SerialNumber))
|
|
if targetSerial != "" && serial != "" && targetSerial == serial {
|
|
return i
|
|
}
|
|
|
|
model := strings.ToLower(strings.TrimSpace(cpu.Model))
|
|
if targetSocket == 0 && cpu.Socket == 0 && targetModel != "" && model == targetModel {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func mergeCPU(dst *models.CPU, src models.CPU) {
|
|
if dst.Socket == 0 && src.Socket > 0 {
|
|
dst.Socket = src.Socket
|
|
}
|
|
setStorageString(&dst.Model, src.Model)
|
|
setStorageString(&dst.Description, src.Description)
|
|
if dst.Cores == 0 && src.Cores > 0 {
|
|
dst.Cores = src.Cores
|
|
}
|
|
if dst.Threads == 0 && src.Threads > 0 {
|
|
dst.Threads = src.Threads
|
|
}
|
|
if dst.FrequencyMHz == 0 && src.FrequencyMHz > 0 {
|
|
dst.FrequencyMHz = src.FrequencyMHz
|
|
}
|
|
if dst.MaxFreqMHz == 0 && src.MaxFreqMHz > 0 {
|
|
dst.MaxFreqMHz = src.MaxFreqMHz
|
|
}
|
|
if dst.L1CacheKB == 0 && src.L1CacheKB > 0 {
|
|
dst.L1CacheKB = src.L1CacheKB
|
|
}
|
|
if dst.L2CacheKB == 0 && src.L2CacheKB > 0 {
|
|
dst.L2CacheKB = src.L2CacheKB
|
|
}
|
|
if dst.L3CacheKB == 0 && src.L3CacheKB > 0 {
|
|
dst.L3CacheKB = src.L3CacheKB
|
|
}
|
|
setStorageString(&dst.PPIN, src.PPIN)
|
|
setStorageString(&dst.SerialNumber, src.SerialNumber)
|
|
setStorageString(&dst.Status, src.Status)
|
|
}
|
|
|
|
func findMemoryIndex(items []models.MemoryDIMM, target models.MemoryDIMM) int {
|
|
targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber))
|
|
targetSlot := strings.ToLower(strings.TrimSpace(target.Slot))
|
|
for i := range items {
|
|
serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber))
|
|
slot := strings.ToLower(strings.TrimSpace(items[i].Slot))
|
|
if targetSerial != "" && serial != "" && targetSerial == serial {
|
|
return i
|
|
}
|
|
if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func mergeMemoryDIMM(dst *models.MemoryDIMM, src models.MemoryDIMM) {
|
|
setStorageString(&dst.Slot, src.Slot)
|
|
setStorageString(&dst.Location, src.Location)
|
|
if src.Present {
|
|
dst.Present = true
|
|
}
|
|
if dst.SizeMB == 0 && src.SizeMB > 0 {
|
|
dst.SizeMB = src.SizeMB
|
|
}
|
|
setStorageString(&dst.Type, src.Type)
|
|
setStorageString(&dst.Technology, src.Technology)
|
|
if dst.MaxSpeedMHz == 0 && src.MaxSpeedMHz > 0 {
|
|
dst.MaxSpeedMHz = src.MaxSpeedMHz
|
|
}
|
|
if dst.CurrentSpeedMHz == 0 && src.CurrentSpeedMHz > 0 {
|
|
dst.CurrentSpeedMHz = src.CurrentSpeedMHz
|
|
}
|
|
setStorageString(&dst.Manufacturer, src.Manufacturer)
|
|
setStorageString(&dst.SerialNumber, src.SerialNumber)
|
|
setStorageString(&dst.PartNumber, src.PartNumber)
|
|
setStorageString(&dst.Status, src.Status)
|
|
if dst.Ranks == 0 && src.Ranks > 0 {
|
|
dst.Ranks = src.Ranks
|
|
}
|
|
}
|
|
|
|
func appendUniqueStorages(dst *[]models.Storage, additions []models.Storage) {
|
|
for _, add := range additions {
|
|
if isStorageEmpty(add) {
|
|
continue
|
|
}
|
|
idx := findStorageIndex(*dst, add)
|
|
if idx < 0 {
|
|
*dst = append(*dst, add)
|
|
continue
|
|
}
|
|
mergeStorage(&(*dst)[idx], add)
|
|
}
|
|
}
|
|
|
|
func dedupeStorage(items []models.Storage) []models.Storage {
|
|
out := make([]models.Storage, 0, len(items))
|
|
appendUniqueStorages(&out, items)
|
|
return out
|
|
}
|
|
|
|
func findStorageIndex(items []models.Storage, target models.Storage) int {
|
|
targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber))
|
|
targetSlot := strings.ToLower(strings.TrimSpace(target.Slot))
|
|
for i := range items {
|
|
serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber))
|
|
slot := strings.ToLower(strings.TrimSpace(items[i].Slot))
|
|
if targetSerial != "" && serial != "" && targetSerial == serial {
|
|
return i
|
|
}
|
|
if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func mergeStorage(dst *models.Storage, src models.Storage) {
|
|
setStorageString(&dst.Slot, src.Slot)
|
|
if mergedType := strings.TrimSpace(pickStorageType(dst.Type, src.Type)); mergedType != "" {
|
|
dst.Type = mergedType
|
|
}
|
|
setStorageString(&dst.Model, src.Model)
|
|
if dst.SizeGB == 0 && src.SizeGB > 0 {
|
|
dst.SizeGB = src.SizeGB
|
|
}
|
|
setStorageString(&dst.SerialNumber, src.SerialNumber)
|
|
setStorageString(&dst.Manufacturer, src.Manufacturer)
|
|
setStorageString(&dst.Firmware, src.Firmware)
|
|
setStorageString(&dst.Interface, src.Interface)
|
|
if src.Present {
|
|
dst.Present = true
|
|
}
|
|
setStorageString(&dst.Location, src.Location)
|
|
setStorageString(&dst.Status, normalizeStorageStatus(src.Status, src.Present || dst.Present))
|
|
}
|
|
|
|
func setStorageString(dst *string, value string) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return
|
|
}
|
|
if strings.TrimSpace(*dst) == "" {
|
|
*dst = value
|
|
return
|
|
}
|
|
if strings.EqualFold(*dst, "unknown") || strings.EqualFold(*dst, "n/a") || strings.EqualFold(*dst, "absent") {
|
|
*dst = value
|
|
}
|
|
}
|
|
|
|
func pickStorageType(current, next string) string {
|
|
if storageTypeRank(next) > storageTypeRank(current) {
|
|
return next
|
|
}
|
|
if strings.TrimSpace(current) == "" {
|
|
return next
|
|
}
|
|
return current
|
|
}
|
|
|
|
func storageTypeRank(t string) int {
|
|
switch strings.ToLower(strings.TrimSpace(t)) {
|
|
case "nvme":
|
|
return 4
|
|
case "ssd":
|
|
return 3
|
|
case "hdd", "disk":
|
|
return 2
|
|
case "unknown":
|
|
return 0
|
|
default:
|
|
if strings.TrimSpace(t) == "" {
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|
|
}
|
|
|
|
func isStorageEmpty(s models.Storage) bool {
|
|
return strings.TrimSpace(s.Slot) == "" &&
|
|
strings.TrimSpace(s.SerialNumber) == "" &&
|
|
strings.TrimSpace(s.Model) == ""
|
|
}
|
|
|
|
func appendUniqueNetworkAdapters(dst *[]models.NetworkAdapter, additions []models.NetworkAdapter) {
|
|
for _, add := range additions {
|
|
if isNetworkAdapterEmpty(add) {
|
|
continue
|
|
}
|
|
idx := findNetworkAdapterIndex(*dst, add)
|
|
if idx < 0 {
|
|
*dst = append(*dst, add)
|
|
continue
|
|
}
|
|
mergeNetworkAdapter(&(*dst)[idx], add)
|
|
}
|
|
}
|
|
|
|
func isNetworkAdapterEmpty(n models.NetworkAdapter) bool {
|
|
return strings.TrimSpace(n.Slot) == "" &&
|
|
len(n.MACAddresses) == 0 &&
|
|
strings.TrimSpace(n.Model) == ""
|
|
}
|
|
|
|
func findNetworkAdapterIndex(items []models.NetworkAdapter, target models.NetworkAdapter) int {
|
|
targetSlot := strings.ToLower(strings.TrimSpace(target.Slot))
|
|
for i := range items {
|
|
if hasSharedMAC(items[i].MACAddresses, target.MACAddresses) {
|
|
return i
|
|
}
|
|
slot := strings.ToLower(strings.TrimSpace(items[i].Slot))
|
|
if targetSlot != "" && slot != "" && targetSlot == slot {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func hasSharedMAC(a, b []string) bool {
|
|
if len(a) == 0 || len(b) == 0 {
|
|
return false
|
|
}
|
|
set := make(map[string]struct{}, len(a))
|
|
for _, item := range a {
|
|
norm := strings.ToLower(strings.TrimSpace(item))
|
|
if norm != "" {
|
|
set[norm] = struct{}{}
|
|
}
|
|
}
|
|
for _, item := range b {
|
|
norm := strings.ToLower(strings.TrimSpace(item))
|
|
if norm == "" {
|
|
continue
|
|
}
|
|
if _, ok := set[norm]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func mergeNetworkAdapter(dst *models.NetworkAdapter, src models.NetworkAdapter) {
|
|
setStorageString(&dst.Slot, src.Slot)
|
|
setStorageString(&dst.Location, src.Location)
|
|
if src.Present {
|
|
dst.Present = true
|
|
}
|
|
setStorageString(&dst.Model, src.Model)
|
|
dst.Description = mergeTextUnique(dst.Description, src.Description)
|
|
setStorageString(&dst.Vendor, src.Vendor)
|
|
if dst.VendorID == 0 && src.VendorID != 0 {
|
|
dst.VendorID = src.VendorID
|
|
}
|
|
if dst.DeviceID == 0 && src.DeviceID != 0 {
|
|
dst.DeviceID = src.DeviceID
|
|
}
|
|
setStorageString(&dst.SerialNumber, src.SerialNumber)
|
|
setStorageString(&dst.PartNumber, src.PartNumber)
|
|
setStorageString(&dst.Firmware, src.Firmware)
|
|
dst.PortCount = maxInt(dst.PortCount, src.PortCount)
|
|
setStorageString(&dst.PortType, src.PortType)
|
|
for _, mac := range src.MACAddresses {
|
|
appendStringUnique(&dst.MACAddresses, mac)
|
|
}
|
|
dst.PortCount = maxInt(dst.PortCount, len(dst.MACAddresses))
|
|
setStorageString(&dst.Status, src.Status)
|
|
}
|
|
|
|
func appendUniqueNICs(dst *[]models.NIC, additions []models.NIC) {
|
|
for _, add := range additions {
|
|
if isNICEmpty(add) {
|
|
continue
|
|
}
|
|
idx := findNICIndex(*dst, add)
|
|
if idx < 0 {
|
|
*dst = append(*dst, add)
|
|
continue
|
|
}
|
|
mergeNIC(&(*dst)[idx], add)
|
|
}
|
|
}
|
|
|
|
func isNICEmpty(n models.NIC) bool {
|
|
return strings.TrimSpace(n.Name) == "" &&
|
|
strings.TrimSpace(n.Model) == "" &&
|
|
strings.TrimSpace(n.MACAddress) == ""
|
|
}
|
|
|
|
func findNICIndex(items []models.NIC, target models.NIC) int {
|
|
targetMAC := strings.ToLower(strings.TrimSpace(target.MACAddress))
|
|
targetName := strings.ToLower(strings.TrimSpace(target.Name))
|
|
for i := range items {
|
|
mac := strings.ToLower(strings.TrimSpace(items[i].MACAddress))
|
|
if targetMAC != "" && mac != "" && targetMAC == mac {
|
|
return i
|
|
}
|
|
name := strings.ToLower(strings.TrimSpace(items[i].Name))
|
|
if targetMAC == "" && targetName != "" && name == targetName {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func mergeNIC(dst *models.NIC, src models.NIC) {
|
|
setStorageString(&dst.Name, src.Name)
|
|
setStorageString(&dst.Model, src.Model)
|
|
dst.Description = mergeTextUnique(dst.Description, src.Description)
|
|
setStorageString(&dst.MACAddress, src.MACAddress)
|
|
if dst.SpeedMbps == 0 && src.SpeedMbps > 0 {
|
|
dst.SpeedMbps = src.SpeedMbps
|
|
}
|
|
setStorageString(&dst.SerialNumber, src.SerialNumber)
|
|
}
|
|
|
|
func mergeTextUnique(dst, src string) string {
|
|
dst = strings.TrimSpace(dst)
|
|
src = strings.TrimSpace(src)
|
|
switch {
|
|
case dst == "":
|
|
return src
|
|
case src == "":
|
|
return dst
|
|
case strings.EqualFold(dst, src):
|
|
return dst
|
|
case strings.Contains(strings.ToLower(dst), strings.ToLower(src)):
|
|
return dst
|
|
case strings.Contains(strings.ToLower(src), strings.ToLower(dst)):
|
|
return src
|
|
default:
|
|
return dst + "; " + src
|
|
}
|
|
}
|
|
|
|
func appendUniquePSUs(dst *[]models.PSU, additions []models.PSU) {
|
|
for _, add := range additions {
|
|
if isPSUEmpty(add) {
|
|
continue
|
|
}
|
|
idx := findPSUIndex(*dst, add)
|
|
if idx < 0 {
|
|
*dst = append(*dst, add)
|
|
continue
|
|
}
|
|
mergePSU(&(*dst)[idx], add)
|
|
}
|
|
}
|
|
|
|
func isPSUEmpty(p models.PSU) bool {
|
|
return strings.TrimSpace(p.Slot) == "" &&
|
|
strings.TrimSpace(p.SerialNumber) == "" &&
|
|
strings.TrimSpace(p.Model) == ""
|
|
}
|
|
|
|
func findPSUIndex(items []models.PSU, target models.PSU) int {
|
|
targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber))
|
|
targetSlot := strings.ToLower(strings.TrimSpace(target.Slot))
|
|
for i := range items {
|
|
serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber))
|
|
slot := strings.ToLower(strings.TrimSpace(items[i].Slot))
|
|
if targetSerial != "" && serial != "" && targetSerial == serial {
|
|
return i
|
|
}
|
|
if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func mergePSU(dst *models.PSU, src models.PSU) {
|
|
setStorageString(&dst.Slot, src.Slot)
|
|
if src.Present {
|
|
dst.Present = true
|
|
}
|
|
setStorageString(&dst.Model, src.Model)
|
|
setStorageString(&dst.Description, src.Description)
|
|
setStorageString(&dst.Vendor, src.Vendor)
|
|
if dst.WattageW == 0 && src.WattageW > 0 {
|
|
dst.WattageW = src.WattageW
|
|
}
|
|
setStorageString(&dst.SerialNumber, src.SerialNumber)
|
|
setStorageString(&dst.PartNumber, src.PartNumber)
|
|
setStorageString(&dst.Firmware, src.Firmware)
|
|
setStorageString(&dst.Status, src.Status)
|
|
}
|
|
|
|
func dedupeVolumes(items []models.StorageVolume) []models.StorageVolume {
|
|
out := make([]models.StorageVolume, 0, len(items))
|
|
for _, item := range items {
|
|
if isVolumeEmpty(item) {
|
|
continue
|
|
}
|
|
idx := findVolumeIndex(out, item)
|
|
if idx < 0 {
|
|
out = append(out, item)
|
|
continue
|
|
}
|
|
mergeVolume(&out[idx], item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func findVolumeIndex(items []models.StorageVolume, target models.StorageVolume) int {
|
|
targetID := strings.ToLower(strings.TrimSpace(target.ID))
|
|
targetName := strings.ToLower(strings.TrimSpace(target.Name))
|
|
targetController := strings.ToLower(strings.TrimSpace(target.Controller))
|
|
for i := range items {
|
|
id := strings.ToLower(strings.TrimSpace(items[i].ID))
|
|
name := strings.ToLower(strings.TrimSpace(items[i].Name))
|
|
controller := strings.ToLower(strings.TrimSpace(items[i].Controller))
|
|
if targetID != "" && id != "" && targetID == id {
|
|
if !controllersCompatible(targetController, controller) {
|
|
continue
|
|
}
|
|
return i
|
|
}
|
|
if targetName != "" && name == targetName {
|
|
if controllersCompatible(targetController, controller) {
|
|
return i
|
|
}
|
|
}
|
|
if targetID == "" && targetName != "" && name == targetName && controller == targetController {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func controllersCompatible(a, b string) bool {
|
|
a = strings.ToLower(strings.TrimSpace(a))
|
|
b = strings.ToLower(strings.TrimSpace(b))
|
|
if a == "" || b == "" {
|
|
return true
|
|
}
|
|
if a == b {
|
|
return true
|
|
}
|
|
if strings.Contains(a, b) || strings.Contains(b, a) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func mergeVolume(dst *models.StorageVolume, src models.StorageVolume) {
|
|
setStorageString(&dst.ID, src.ID)
|
|
setStorageString(&dst.Name, src.Name)
|
|
setStorageString(&dst.Controller, src.Controller)
|
|
setStorageString(&dst.RAIDLevel, src.RAIDLevel)
|
|
if dst.SizeGB == 0 && src.SizeGB > 0 {
|
|
dst.SizeGB = src.SizeGB
|
|
}
|
|
if dst.CapacityBytes == 0 && src.CapacityBytes > 0 {
|
|
dst.CapacityBytes = src.CapacityBytes
|
|
}
|
|
setStorageString(&dst.Status, src.Status)
|
|
if src.Bootable {
|
|
dst.Bootable = true
|
|
}
|
|
if src.Encrypted {
|
|
dst.Encrypted = true
|
|
}
|
|
}
|
|
|
|
func isVolumeEmpty(v models.StorageVolume) bool {
|
|
return strings.TrimSpace(v.ID) == "" &&
|
|
strings.TrimSpace(v.Name) == "" &&
|
|
strings.TrimSpace(v.RAIDLevel) == "" &&
|
|
v.CapacityBytes == 0 &&
|
|
v.SizeGB == 0
|
|
}
|
|
|
|
func lookupAnyCase(m map[string]any, keys ...string) any {
|
|
if len(m) == 0 {
|
|
return nil
|
|
}
|
|
for _, key := range keys {
|
|
norm := normalizeKey(key)
|
|
for rawKey, value := range m {
|
|
if normalizeKey(rawKey) == norm {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func lookupAnyByPrefix(m map[string]any, prefixes ...string) any {
|
|
if len(m) == 0 {
|
|
return nil
|
|
}
|
|
normPrefixes := make([]string, 0, len(prefixes))
|
|
for _, prefix := range prefixes {
|
|
n := normalizeKey(prefix)
|
|
if n != "" {
|
|
normPrefixes = append(normPrefixes, n)
|
|
}
|
|
}
|
|
for rawKey, value := range m {
|
|
normKey := normalizeKey(rawKey)
|
|
for _, prefix := range normPrefixes {
|
|
if strings.HasPrefix(normKey, prefix) {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func toAnyMap(v any) map[string]any {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
if m, ok := v.(map[string]any); ok {
|
|
return m
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func toAnySlice(v any) []any {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
switch t := v.(type) {
|
|
case []any:
|
|
return t
|
|
case []map[string]any:
|
|
out := make([]any, 0, len(t))
|
|
for _, item := range t {
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func toStringAny(v any) string {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return strings.TrimSpace(t)
|
|
case fmt.Stringer:
|
|
return strings.TrimSpace(t.String())
|
|
case float64:
|
|
return strconv.FormatInt(int64(t), 10)
|
|
case int:
|
|
return strconv.Itoa(t)
|
|
case int64:
|
|
return strconv.FormatInt(t, 10)
|
|
case bool:
|
|
if t {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
case json.Number:
|
|
return t.String()
|
|
default:
|
|
return strings.TrimSpace(fmt.Sprintf("%v", t))
|
|
}
|
|
}
|
|
|
|
func toBoolAny(v any) (bool, bool) {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return false, false
|
|
case bool:
|
|
return t, true
|
|
case string:
|
|
return parseBoolString(t)
|
|
case float64:
|
|
return t != 0, true
|
|
case int:
|
|
return t != 0, true
|
|
case int64:
|
|
return t != 0, true
|
|
case json.Number:
|
|
if i, err := t.Int64(); err == nil {
|
|
return i != 0, true
|
|
}
|
|
if f, err := t.Float64(); err == nil {
|
|
return f != 0, true
|
|
}
|
|
return false, false
|
|
default:
|
|
return parseBoolString(fmt.Sprintf("%v", t))
|
|
}
|
|
}
|
|
|
|
func parseBoolString(raw string) (bool, bool) {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "1", "true", "yes", "y", "enabled", "on":
|
|
return true, true
|
|
case "0", "false", "no", "n", "disabled", "off":
|
|
return false, true
|
|
default:
|
|
return false, false
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
v = strings.TrimSpace(v)
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|