Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2c81758b5 | ||
|
|
6b52a1876f | ||
|
|
3e3c48bc08 | ||
|
|
cd864c3d6c | ||
|
|
5128ac5303 | ||
|
|
53cda82c79 | ||
|
|
a18d8fe648 |
Submodule internal/chart updated: fddae50d64...8c80591531
@@ -244,7 +244,7 @@ func isReplayStorageServiceEndpoint(doc map[string]interface{}, dev models.PCIeD
|
|||||||
if strings.Contains(name, "pcie switch management endpoint") {
|
if strings.Contains(name, "pcie switch management endpoint") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if strings.Contains(name, "volume management device nvme raid controller") {
|
if strings.Contains(name, "volume management device") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -174,3 +174,42 @@ func firstNonEmptyString(values ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportLogsCSV writes all recognized events as a semicolon-delimited UTF-8 CSV readable in Excel.
|
||||||
|
func ExportLogsCSV(w io.Writer, result *models.AnalysisResult) error {
|
||||||
|
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writer := csv.NewWriter(w)
|
||||||
|
writer.Comma = ';'
|
||||||
|
defer writer.Flush()
|
||||||
|
|
||||||
|
if err := writer.Write([]string{"timestamp", "source", "severity", "sensor_type", "sensor_name", "event_type", "id", "description", "raw_data"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range result.Events {
|
||||||
|
ts := ""
|
||||||
|
if !e.Timestamp.IsZero() {
|
||||||
|
ts = e.Timestamp.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
}
|
||||||
|
if err := writer.Write([]string{
|
||||||
|
ts,
|
||||||
|
e.Source,
|
||||||
|
string(e.Severity),
|
||||||
|
e.SensorType,
|
||||||
|
e.SensorName,
|
||||||
|
e.EventType,
|
||||||
|
e.ID,
|
||||||
|
e.Description,
|
||||||
|
e.RawData,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1235,7 +1235,7 @@ func normalizeEventLogSource(source string) string {
|
|||||||
switch strings.ToLower(strings.TrimSpace(source)) {
|
switch strings.ToLower(strings.TrimSpace(source)) {
|
||||||
case "redfish":
|
case "redfish":
|
||||||
return "redfish"
|
return "redfish"
|
||||||
case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller":
|
case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller", "hpe ilo", "ilo":
|
||||||
return "bmc"
|
return "bmc"
|
||||||
case "system", "syslog", "smart", "zfs", "file", "gpu", "dmi", "nvidia driver", "gpu field diagnostics", "fan", "memory", "host":
|
case "system", "syslog", "smart", "zfs", "file", "gpu", "dmi", "nvidia driver", "gpu field diagnostics", "fan", "memory", "host":
|
||||||
return "host"
|
return "host"
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const maxSingleFileSize = 10 * 1024 * 1024
|
const maxSingleFileSize = 10 * 1024 * 1024
|
||||||
|
const maxSingleFileSizeLarge = 1024 * 1024 * 1024
|
||||||
const maxZipArchiveSize = 50 * 1024 * 1024
|
const maxZipArchiveSize = 50 * 1024 * 1024
|
||||||
const maxGzipDecompressedSize = 50 * 1024 * 1024
|
const maxGzipDecompressedSize = 50 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
var supportedArchiveExt = map[string]struct{}{
|
var supportedArchiveExt = map[string]struct{}{
|
||||||
".ahs": {},
|
".ahs": {},
|
||||||
".gz": {},
|
".gz": {},
|
||||||
@@ -47,7 +49,7 @@ func ExtractArchive(archivePath string) ([]ExtractedFile, error) {
|
|||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".ahs":
|
case ".ahs":
|
||||||
return extractSingleFile(archivePath)
|
return extractSingleFileWithLimit(archivePath, maxSingleFileSizeLarge)
|
||||||
case ".gz", ".tgz":
|
case ".gz", ".tgz":
|
||||||
return extractTarGz(archivePath)
|
return extractTarGz(archivePath)
|
||||||
case ".tar", ".sds":
|
case ".tar", ".sds":
|
||||||
@@ -55,7 +57,7 @@ func ExtractArchive(archivePath string) ([]ExtractedFile, error) {
|
|||||||
case ".zip":
|
case ".zip":
|
||||||
return extractZip(archivePath)
|
return extractZip(archivePath)
|
||||||
case ".txt", ".log":
|
case ".txt", ".log":
|
||||||
return extractSingleFile(archivePath)
|
return extractSingleFileWithLimit(archivePath, maxSingleFileSize)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported archive format: %s", ext)
|
return nil, fmt.Errorf("unsupported archive format: %s", ext)
|
||||||
}
|
}
|
||||||
@@ -70,7 +72,7 @@ func ExtractArchiveFromReader(r io.Reader, filename string) ([]ExtractedFile, er
|
|||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".ahs":
|
case ".ahs":
|
||||||
return extractSingleFileFromReader(r, filename)
|
return extractSingleFileFromReaderWithLimit(r, filename, maxSingleFileSizeLarge)
|
||||||
case ".gz", ".tgz":
|
case ".gz", ".tgz":
|
||||||
return extractTarGzFromReader(r, filename)
|
return extractTarGzFromReader(r, filename)
|
||||||
case ".tar", ".sds":
|
case ".tar", ".sds":
|
||||||
@@ -78,7 +80,7 @@ func ExtractArchiveFromReader(r io.Reader, filename string) ([]ExtractedFile, er
|
|||||||
case ".zip":
|
case ".zip":
|
||||||
return extractZipFromReader(r)
|
return extractZipFromReader(r)
|
||||||
case ".txt", ".log":
|
case ".txt", ".log":
|
||||||
return extractSingleFileFromReader(r, filename)
|
return extractSingleFileFromReaderWithLimit(r, filename, maxSingleFileSize)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported archive format: %s", ext)
|
return nil, fmt.Errorf("unsupported archive format: %s", ext)
|
||||||
}
|
}
|
||||||
@@ -337,7 +339,7 @@ func extractZipFromReader(r io.Reader) ([]ExtractedFile, error) {
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractSingleFile(path string) ([]ExtractedFile, error) {
|
func extractSingleFileWithLimit(path string, limit int64) ([]ExtractedFile, error) {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("stat file: %w", err)
|
return nil, fmt.Errorf("stat file: %w", err)
|
||||||
@@ -348,7 +350,7 @@ func extractSingleFile(path string) ([]ExtractedFile, error) {
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
files, err := extractSingleFileFromReader(f, filepath.Base(path))
|
files, err := extractSingleFileFromReaderWithLimit(f, filepath.Base(path), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -358,14 +360,14 @@ func extractSingleFile(path string) ([]ExtractedFile, error) {
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractSingleFileFromReader(r io.Reader, filename string) ([]ExtractedFile, error) {
|
func extractSingleFileFromReaderWithLimit(r io.Reader, filename string, limit int64) ([]ExtractedFile, error) {
|
||||||
content, err := io.ReadAll(io.LimitReader(r, maxSingleFileSize+1))
|
content, err := io.ReadAll(io.LimitReader(r, limit+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read file content: %w", err)
|
return nil, fmt.Errorf("read file content: %w", err)
|
||||||
}
|
}
|
||||||
truncated := len(content) > maxSingleFileSize
|
truncated := int64(len(content)) > limit
|
||||||
if truncated {
|
if truncated {
|
||||||
content = content[:maxSingleFileSize]
|
content = content[:limit]
|
||||||
}
|
}
|
||||||
|
|
||||||
file := ExtractedFile{
|
file := ExtractedFile{
|
||||||
@@ -376,7 +378,7 @@ func extractSingleFileFromReader(r io.Reader, filename string) ([]ExtractedFile,
|
|||||||
file.Truncated = true
|
file.Truncated = true
|
||||||
file.TruncatedMessage = fmt.Sprintf(
|
file.TruncatedMessage = fmt.Sprintf(
|
||||||
"file exceeded %d bytes and was truncated",
|
"file exceeded %d bytes and was truncated",
|
||||||
maxSingleFileSize,
|
limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
81
internal/parser/vendors/hpe_ilo_ahs/parser.go
vendored
81
internal/parser/vendors/hpe_ilo_ahs/parser.go
vendored
@@ -214,8 +214,10 @@ func parseAHSContainer(data []byte) ([]ahsEntry, error) {
|
|||||||
name := strings.TrimRight(string(data[offset+20:offset+52]), "\x00")
|
name := strings.TrimRight(string(data[offset+20:offset+52]), "\x00")
|
||||||
start := offset + ahsHeaderSize
|
start := offset + ahsHeaderSize
|
||||||
end := start + size
|
end := start + size
|
||||||
|
truncated := false
|
||||||
if size < 0 || end > len(data) {
|
if size < 0 || end > len(data) {
|
||||||
return nil, fmt.Errorf("invalid payload size for %q", name)
|
end = len(data)
|
||||||
|
truncated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := append([]byte(nil), data[start:end]...)
|
payload := append([]byte(nil), data[start:end]...)
|
||||||
@@ -235,6 +237,9 @@ func parseAHSContainer(data []byte) ([]ahsEntry, error) {
|
|||||||
Content: content,
|
Content: content,
|
||||||
Compressed: compressed,
|
Compressed: compressed,
|
||||||
})
|
})
|
||||||
|
if truncated {
|
||||||
|
break
|
||||||
|
}
|
||||||
offset = end
|
offset = end
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -992,7 +997,7 @@ func parseEvents(tokens []string) []models.Event {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if looksLikeEventMessage(tokens[j]) {
|
if looksLikeEventMessage(tokens[j]) {
|
||||||
message = tokens[j]
|
message = trimEventJunk(tokens[j])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1173,7 +1178,7 @@ func looksLikeServerModel(v string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
lower := strings.ToLower(v)
|
lower := strings.ToLower(v)
|
||||||
return strings.Contains(lower, "proliant") || strings.Contains(lower, "apollo") || strings.Contains(lower, "synergy") || strings.Contains(lower, "edgeline")
|
return strings.Contains(lower, "proliant") || strings.Contains(lower, "apollo") || strings.Contains(lower, "synergy") || strings.Contains(lower, "edgeline") || strings.Contains(lower, "alletra")
|
||||||
}
|
}
|
||||||
|
|
||||||
func looksLikeCPUVendor(v string) bool {
|
func looksLikeCPUVendor(v string) bool {
|
||||||
@@ -1464,7 +1469,19 @@ func fabricIDFromPath(path string) string {
|
|||||||
func inferSeverity(message string) models.Severity {
|
func inferSeverity(message string) models.Severity {
|
||||||
lower := strings.ToLower(message)
|
lower := strings.ToLower(message)
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(lower, " down"), strings.Contains(lower, "warning"), strings.Contains(lower, "fail"), strings.Contains(lower, "error"):
|
case strings.Contains(lower, "critical"):
|
||||||
|
return models.SeverityCritical
|
||||||
|
case strings.Contains(lower, " down"),
|
||||||
|
strings.Contains(lower, "warning"),
|
||||||
|
strings.Contains(lower, "fail"),
|
||||||
|
strings.Contains(lower, "error"),
|
||||||
|
strings.Contains(lower, "server reset"),
|
||||||
|
strings.Contains(lower, "server power"),
|
||||||
|
strings.Contains(lower, "power restored"),
|
||||||
|
strings.Contains(lower, "ilo reset"),
|
||||||
|
strings.Contains(lower, "ilo restarted"),
|
||||||
|
strings.Contains(lower, "pcr measurements"),
|
||||||
|
strings.Contains(lower, "hardware data received from uefi"):
|
||||||
return models.SeverityWarning
|
return models.SeverityWarning
|
||||||
default:
|
default:
|
||||||
return models.SeverityInfo
|
return models.SeverityInfo
|
||||||
@@ -1478,21 +1495,73 @@ func inferEventType(message string) string {
|
|||||||
return "Login"
|
return "Login"
|
||||||
case strings.Contains(lower, "logout"):
|
case strings.Contains(lower, "logout"):
|
||||||
return "Logout"
|
return "Logout"
|
||||||
case strings.Contains(lower, "network"):
|
case strings.Contains(lower, "network"), strings.Contains(lower, "link"):
|
||||||
return "Network"
|
return "Network"
|
||||||
case strings.Contains(lower, "license"):
|
case strings.Contains(lower, "license"):
|
||||||
return "License"
|
return "License"
|
||||||
|
case strings.Contains(lower, "backup operation"), strings.Contains(lower, "remote console"):
|
||||||
|
return "Management"
|
||||||
|
case strings.Contains(lower, "server power"), strings.Contains(lower, "power restored"), strings.Contains(lower, "power off"), strings.Contains(lower, "server reset"), strings.Contains(lower, "ilo reset"), strings.Contains(lower, "ilo restarted"):
|
||||||
|
return "Power"
|
||||||
|
case strings.Contains(lower, "storage"), strings.Contains(lower, "volume"), strings.Contains(lower, "drive"), strings.Contains(lower, "firmware"):
|
||||||
|
return "Hardware"
|
||||||
|
case strings.Contains(lower, "certificate"), strings.Contains(lower, "pcr measurements"), strings.Contains(lower, "hardware data"), strings.Contains(lower, "security"):
|
||||||
|
return "Security"
|
||||||
default:
|
default:
|
||||||
return "Event"
|
return "Event"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trimEventJunk strips trailing single-byte frame markers written by iLO into
|
||||||
|
// binary .zbb log records. These markers are printable ASCII (letters, *, +, ')
|
||||||
|
// that appear immediately after the sentence-ending punctuation or a digit.
|
||||||
|
func trimEventJunk(s string) string {
|
||||||
|
if len(s) < 3 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
last := s[len(s)-1]
|
||||||
|
prev := s[len(s)-2]
|
||||||
|
isJunk := (last >= 'A' && last <= 'Z') || (last >= 'a' && last <= 'z') ||
|
||||||
|
last == '*' || last == '+' || last == '\''
|
||||||
|
prevIsBoundary := prev == '.' || prev == '!' || prev == '"' || prev == ')' ||
|
||||||
|
(prev >= '0' && prev <= '9')
|
||||||
|
if isJunk && prevIsBoundary {
|
||||||
|
return s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func looksLikeEventMessage(v string) bool {
|
func looksLikeEventMessage(v string) bool {
|
||||||
if len(v) < 8 || strings.HasPrefix(v, "src/") || strings.HasPrefix(v, "PciRoot(") {
|
if len(v) < 8 || strings.HasPrefix(v, "src/") || strings.HasPrefix(v, "PciRoot(") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// JSON document accidentally extracted — skip
|
||||||
|
if strings.HasPrefix(v, "{") || strings.HasPrefix(v, "[") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Numbered list items (e.g. "2.Perform the iLO reset.") are instructions, not events
|
||||||
|
if len(v) > 2 && v[0] >= '1' && v[0] <= '9' && v[1] == '.' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
lower := strings.ToLower(v)
|
lower := strings.ToLower(v)
|
||||||
return strings.Contains(lower, "login") || strings.Contains(lower, "logout") || strings.Contains(lower, "link") || strings.Contains(lower, "license") || strings.Contains(lower, "security state")
|
return strings.Contains(lower, "login") ||
|
||||||
|
strings.Contains(lower, "logout") ||
|
||||||
|
strings.Contains(lower, "link") ||
|
||||||
|
strings.Contains(lower, "license") ||
|
||||||
|
strings.Contains(lower, "security state") ||
|
||||||
|
strings.Contains(lower, "server power") ||
|
||||||
|
strings.Contains(lower, "server reset") ||
|
||||||
|
strings.Contains(lower, "power restored") ||
|
||||||
|
strings.Contains(lower, "power off") ||
|
||||||
|
strings.Contains(lower, "storage") ||
|
||||||
|
strings.Contains(lower, "firmware") ||
|
||||||
|
strings.Contains(lower, "certificate") ||
|
||||||
|
strings.Contains(lower, "backup operation") ||
|
||||||
|
strings.Contains(lower, "pcr measurements") ||
|
||||||
|
strings.Contains(lower, "hardware data") ||
|
||||||
|
strings.Contains(lower, "ilo reset") ||
|
||||||
|
strings.Contains(lower, "ilo restarted") ||
|
||||||
|
strings.Contains(lower, "remote console")
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeModel(v string) string {
|
func sanitizeModel(v string) string {
|
||||||
|
|||||||
@@ -153,6 +153,29 @@ func TestParseAHSInventory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseAHSTruncatedEntry(t *testing.T) {
|
||||||
|
p := &Parser{}
|
||||||
|
// Build archive where the last entry's declared size exceeds available data.
|
||||||
|
archive := makeAHSArchive(t, []ahsTestEntry{
|
||||||
|
{Name: "CUST_INFO.DAT", Payload: []byte("HPE\x00ProLiant DL380 Gen11\x00CZ2D1X0GS3\x00P52560-421")},
|
||||||
|
{Name: "0000150-2025-11-27.zbb", Payload: []byte("some content")},
|
||||||
|
})
|
||||||
|
// Corrupt the size field of the second entry to exceed len(archive).
|
||||||
|
secondHeaderOffset := ahsHeaderSize + len([]byte("HPE\x00ProLiant DL380 Gen11\x00CZ2D1X0GS3\x00P52560-421"))
|
||||||
|
binary.LittleEndian.PutUint32(archive[secondHeaderOffset+8:secondHeaderOffset+12], 0xFFFFFFFF)
|
||||||
|
|
||||||
|
result, err := p.Parse([]parser.ExtractedFile{{
|
||||||
|
Path: "HPE_CZ2D1X0GS3_20251127.ahs",
|
||||||
|
Content: archive,
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected graceful handling of truncated entry, got error: %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseExampleAHS(t *testing.T) {
|
func TestParseExampleAHS(t *testing.T) {
|
||||||
path := filepath.Join("..", "..", "..", "..", "example", "HPE_CZ2D1X0GS3_20260330.ahs")
|
path := filepath.Join("..", "..", "..", "..", "example", "HPE_CZ2D1X0GS3_20260330.ahs")
|
||||||
content, err := os.ReadFile(path)
|
content, err := os.ReadFile(path)
|
||||||
|
|||||||
5
internal/parser/vendors/inspur/parser.go
vendored
5
internal/parser/vendors/inspur/parser.go
vendored
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
// parserVersion - version of this parser module
|
// parserVersion - version of this parser module
|
||||||
// IMPORTANT: Increment this version when making changes to parser logic!
|
// IMPORTANT: Increment this version when making changes to parser logic!
|
||||||
const parserVersion = "2.0"
|
const parserVersion = "2.1"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
parser.Register(&Parser{})
|
parser.Register(&Parser{})
|
||||||
@@ -234,6 +234,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
if result.Hardware != nil {
|
if result.Hardware != nil {
|
||||||
applyGPUStatusFromEvents(result.Hardware, result.Events)
|
applyGPUStatusFromEvents(result.Hardware, result.Events)
|
||||||
enrichStorageFromSerialFallbackFiles(files, result.Hardware)
|
enrichStorageFromSerialFallbackFiles(files, result.Hardware)
|
||||||
|
// Enrich storage serial numbers from smartd output in SOLHostCapture.log.
|
||||||
|
// Fills in serial, model, firmware for backplane slots that the BMC HDD API left empty.
|
||||||
|
enrichStorageFromSOLSmartd(files, result.Hardware)
|
||||||
// Apply RAID disk serials from audit.log (authoritative: last non-NULL SN change).
|
// Apply RAID disk serials from audit.log (authoritative: last non-NULL SN change).
|
||||||
// These override redis/component.log serials which may be stale after disk replacement.
|
// These override redis/component.log serials which may be stale after disk replacement.
|
||||||
applyRAIDSlotSerials(result.Hardware, raidSlotSerials)
|
applyRAIDSlotSerials(result.Hardware, raidSlotSerials)
|
||||||
|
|||||||
247
internal/parser/vendors/inspur/sol_smartd.go
vendored
Normal file
247
internal/parser/vendors/inspur/sol_smartd.go
vendored
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// solSmartdDeviceRe matches smartd device info lines from SOLHostCapture.log.
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// Device: /dev/sda [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC7E3, WWN:..., FW:D4CM003, 480 GB
|
||||||
|
var solSmartdDeviceRe = regexp.MustCompile(
|
||||||
|
`Device: /dev/\S+ \[SAT\], (.+?), S/N:(\S+),.*?FW:(\S+), ([\d.]+) (GB|TB)`,
|
||||||
|
)
|
||||||
|
|
||||||
|
type solSmartdDevice struct {
|
||||||
|
Model string
|
||||||
|
Serial string
|
||||||
|
Firmware string
|
||||||
|
SizeGB int
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSOLSmartdDevices extracts unique disk entries from SOLHostCapture.log content.
|
||||||
|
// Deduplicates by serial number (case-insensitive); preserves first-seen order.
|
||||||
|
func parseSOLSmartdDevices(content []byte) []solSmartdDevice {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var out []solSmartdDevice
|
||||||
|
|
||||||
|
for _, line := range strings.Split(string(content), "\n") {
|
||||||
|
m := solSmartdDeviceRe.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
serial := strings.TrimSpace(m[2])
|
||||||
|
if serial == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(serial)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
|
||||||
|
sizeGB := parseSolSizeGB(m[4], m[5])
|
||||||
|
out = append(out, solSmartdDevice{
|
||||||
|
Model: strings.TrimSpace(m[1]),
|
||||||
|
Serial: serial,
|
||||||
|
Firmware: strings.TrimSpace(m[3]),
|
||||||
|
SizeGB: sizeGB,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSolSizeGB converts smartd size string ("480", "3.84") + unit ("GB", "TB") to integer GB.
|
||||||
|
// Uses decimal TB (1 TB = 1000 GB) matching disk manufacturer conventions.
|
||||||
|
func parseSolSizeGB(value, unit string) int {
|
||||||
|
f, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil || f <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if strings.EqualFold(unit, "TB") {
|
||||||
|
f *= 1000
|
||||||
|
}
|
||||||
|
return int(f + 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrichStorageFromSOLSmartd enriches the storage inventory using disk info from
|
||||||
|
// SOLHostCapture.log (smartd startup messages). Both the log/ and runningdata/ copies
|
||||||
|
// are processed; serials are deduplicated across both files.
|
||||||
|
//
|
||||||
|
// Enrichment priority:
|
||||||
|
// 1. Exact model match to existing entries that are missing a serial.
|
||||||
|
// 2. Positional assignment to present placeholder slots (no model, no serial).
|
||||||
|
// 3. New entries added for any remaining devices.
|
||||||
|
func enrichStorageFromSOLSmartd(files []parser.ExtractedFile, hw *models.HardwareConfig) {
|
||||||
|
if hw == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
solFiles := parser.FindFileByPattern(files, "SOLHostCapture.log")
|
||||||
|
if len(solFiles) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unique devices from all SOL log copies.
|
||||||
|
seenSerial := make(map[string]struct{})
|
||||||
|
var devices []solSmartdDevice
|
||||||
|
for _, f := range solFiles {
|
||||||
|
for _, d := range parseSOLSmartdDevices(f.Content) {
|
||||||
|
key := strings.ToLower(d.Serial)
|
||||||
|
if _, ok := seenSerial[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenSerial[key] = struct{}{}
|
||||||
|
devices = append(devices, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip devices whose serial already appears in the storage inventory.
|
||||||
|
existingSerials := make(map[string]struct{}, len(hw.Storage))
|
||||||
|
for _, dev := range hw.Storage {
|
||||||
|
sn := strings.ToLower(strings.TrimSpace(dev.SerialNumber))
|
||||||
|
if sn != "" {
|
||||||
|
existingSerials[sn] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var newDevices []solSmartdDevice
|
||||||
|
for _, d := range devices {
|
||||||
|
if _, ok := existingSerials[strings.ToLower(d.Serial)]; !ok {
|
||||||
|
newDevices = append(newDevices, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(newDevices) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1: enrich existing entries that match by model (first-match wins per device).
|
||||||
|
remaining := solEnrichByModel(hw, newDevices)
|
||||||
|
if len(remaining) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: assign to present placeholder slots (present=true, no model, no serial).
|
||||||
|
remaining = solEnrichByPlaceholder(hw, remaining)
|
||||||
|
if len(remaining) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 3: add as new storage entries without a slot assignment.
|
||||||
|
for _, d := range remaining {
|
||||||
|
hw.Storage = append(hw.Storage, solMakeStorage(d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// solEnrichByModel fills SerialNumber (and optionally Firmware/SizeGB) on existing storage
|
||||||
|
// entries whose model matches the smartd model exactly. Returns unmatched devices.
|
||||||
|
func solEnrichByModel(hw *models.HardwareConfig, devices []solSmartdDevice) []solSmartdDevice {
|
||||||
|
var unmatched []solSmartdDevice
|
||||||
|
for _, d := range devices {
|
||||||
|
matched := false
|
||||||
|
for i := range hw.Storage {
|
||||||
|
if strings.TrimSpace(hw.Storage[i].SerialNumber) != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(hw.Storage[i].Model), d.Model) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hw.Storage[i].SerialNumber = d.Serial
|
||||||
|
if strings.TrimSpace(hw.Storage[i].Firmware) == "" {
|
||||||
|
hw.Storage[i].Firmware = d.Firmware
|
||||||
|
}
|
||||||
|
if hw.Storage[i].SizeGB == 0 {
|
||||||
|
hw.Storage[i].SizeGB = d.SizeGB
|
||||||
|
}
|
||||||
|
matched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
unmatched = append(unmatched, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unmatched
|
||||||
|
}
|
||||||
|
|
||||||
|
// solEnrichByPlaceholder assigns smartd devices to present storage entries that have
|
||||||
|
// neither a model nor a serial number, sorted by slot name. Returns unmatched devices.
|
||||||
|
func solEnrichByPlaceholder(hw *models.HardwareConfig, devices []solSmartdDevice) []solSmartdDevice {
|
||||||
|
type slot struct {
|
||||||
|
index int
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
var placeholders []slot
|
||||||
|
for i := range hw.Storage {
|
||||||
|
if !hw.Storage[i].Present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(hw.Storage[i].SerialNumber) != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(hw.Storage[i].Model) != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
placeholders = append(placeholders, slot{index: i, name: hw.Storage[i].Slot})
|
||||||
|
}
|
||||||
|
sort.Slice(placeholders, func(i, j int) bool {
|
||||||
|
return placeholders[i].name < placeholders[j].name
|
||||||
|
})
|
||||||
|
|
||||||
|
pi := 0
|
||||||
|
var unmatched []solSmartdDevice
|
||||||
|
for _, d := range devices {
|
||||||
|
if pi >= len(placeholders) {
|
||||||
|
unmatched = append(unmatched, d)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := placeholders[pi].index
|
||||||
|
pi++
|
||||||
|
hw.Storage[idx].SerialNumber = d.Serial
|
||||||
|
hw.Storage[idx].Model = d.Model
|
||||||
|
hw.Storage[idx].Firmware = d.Firmware
|
||||||
|
if hw.Storage[idx].SizeGB == 0 {
|
||||||
|
hw.Storage[idx].SizeGB = d.SizeGB
|
||||||
|
}
|
||||||
|
hw.Storage[idx].Type = solStorageType(d.Model)
|
||||||
|
if hw.Storage[idx].Manufacturer == "" {
|
||||||
|
hw.Storage[idx].Manufacturer = extractStorageManufacturer(d.Model)
|
||||||
|
}
|
||||||
|
if hw.Storage[idx].Interface == "" {
|
||||||
|
hw.Storage[idx].Interface = "SATA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unmatched
|
||||||
|
}
|
||||||
|
|
||||||
|
func solMakeStorage(d solSmartdDevice) models.Storage {
|
||||||
|
return models.Storage{
|
||||||
|
Model: d.Model,
|
||||||
|
SerialNumber: d.Serial,
|
||||||
|
Firmware: d.Firmware,
|
||||||
|
SizeGB: d.SizeGB,
|
||||||
|
Type: solStorageType(d.Model),
|
||||||
|
Manufacturer: extractStorageManufacturer(d.Model),
|
||||||
|
Interface: "SATA",
|
||||||
|
Present: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// solStorageType infers SSD vs HDD from the model string.
|
||||||
|
// Micron SSD models start with "MTFDD"; Intel SSDs contain "SSD".
|
||||||
|
func solStorageType(model string) string {
|
||||||
|
upper := strings.ToUpper(model)
|
||||||
|
if strings.Contains(upper, "SSD") ||
|
||||||
|
strings.HasPrefix(upper, "MTFDD") ||
|
||||||
|
strings.HasPrefix(upper, "MICRON_5") {
|
||||||
|
return "SSD"
|
||||||
|
}
|
||||||
|
return "HDD"
|
||||||
|
}
|
||||||
191
internal/parser/vendors/inspur/sol_smartd_test.go
vendored
Normal file
191
internal/parser/vendors/inspur/sol_smartd_test.go
vendored
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
const solSmartdSample = `
|
||||||
|
[ 17.219818] smartd[3321]: Device: /dev/sda [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC7E3, WWN:5-00a075-1400dc7e3, FW:D4CM003, 480 GB
|
||||||
|
[ 17.553024] smartd[3321]: Device: /dev/sdc [SAT], MTFDDAK3T8TGA-1BC1ZABDA, S/N:25134F172DB3, WWN:5-00a075-14f172db3, FW:D4DK403, 3.84 TB
|
||||||
|
[ 17.553331] smartd[3321]: Device: /dev/sde [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC80F, WWN:5-00a075-1400dc80f, FW:D4CM003, 480 GB
|
||||||
|
[ 17.553709] smartd[3321]: Device: /dev/sdh [SAT], MTFDDAK3T8TGA-1BC1ZABDA, S/N:25134F57DAB8, WWN:5-00a075-14f57dab8, FW:D4DK403, 3.84 TB
|
||||||
|
[ 17.886180] smartd[3321]: Device: /dev/sda [SAT], state written to /var/lib/smartmontools/smartd.Micron-2310400DC7E3.ata.state
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestParseSOLSmartdDevices_Dedup(t *testing.T) {
|
||||||
|
devices := parseSOLSmartdDevices([]byte(solSmartdSample))
|
||||||
|
if len(devices) != 4 {
|
||||||
|
t.Fatalf("expected 4 unique devices, got %d: %v", len(devices), devices)
|
||||||
|
}
|
||||||
|
// order matches first-seen
|
||||||
|
if devices[0].Serial != "2310400DC7E3" {
|
||||||
|
t.Errorf("first device serial: got %q, want 2310400DC7E3", devices[0].Serial)
|
||||||
|
}
|
||||||
|
if devices[0].SizeGB != 480 {
|
||||||
|
t.Errorf("first device size: got %d, want 480", devices[0].SizeGB)
|
||||||
|
}
|
||||||
|
if devices[1].SizeGB != 3840 {
|
||||||
|
t.Errorf("TB device size: got %d, want 3840", devices[1].SizeGB)
|
||||||
|
}
|
||||||
|
if devices[1].Firmware != "D4DK403" {
|
||||||
|
t.Errorf("firmware: got %q, want D4DK403", devices[1].Firmware)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSOLSmartdDevices_SkipsNonInfoLines(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
[ 17.886177] smartd[3321]: Device: /dev/sda [SAT], state written to /var/lib/smartmontools/smartd.foo.ata.state
|
||||||
|
[ 17.040843] smartd[3321]: Device: /dev/sda [SAT], not found in smartd database 7.3/5319.
|
||||||
|
[ 17.040865] smartd[3321]: Device: /dev/sda [SAT], is SMART capable. Adding to "monitor" list.
|
||||||
|
`
|
||||||
|
devices := parseSOLSmartdDevices([]byte(content))
|
||||||
|
if len(devices) != 0 {
|
||||||
|
t.Errorf("expected 0 devices, got %d", len(devices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSolSizeGB(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
value, unit string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"480", "GB", 480},
|
||||||
|
{"1.92", "TB", 1920},
|
||||||
|
{"3.84", "TB", 3840},
|
||||||
|
{"1", "TB", 1000},
|
||||||
|
{"0", "GB", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := parseSolSizeGB(c.value, c.unit)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("parseSolSizeGB(%q, %q) = %d, want %d", c.value, c.unit, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolStorageType(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
model string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"MTFDDAK3T8TGA-1BC1ZABDA", "SSD"},
|
||||||
|
{"Micron_5400_MTFDDAK480TGA", "SSD"},
|
||||||
|
{"INTEL SSDSC2KB019TZ", "SSD"},
|
||||||
|
{"SEAGATE ST4000NM0115", "HDD"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := solStorageType(c.model)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("solStorageType(%q) = %q, want %q", c.model, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichStorageFromSOLSmartd_ModelMatch(t *testing.T) {
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "onekeylog/log/sollog/SOLHostCapture.log",
|
||||||
|
Content: []byte(solSmartdSample),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{Slot: "BP0:0", Model: "MTFDDAK3T8TGA-1BC1ZABDA", SizeGB: 3576, Present: true},
|
||||||
|
{Slot: "BP0:1", Model: "MTFDDAK3T8TGA-1BC1ZABDA", SizeGB: 3576, Present: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichStorageFromSOLSmartd(files, hw)
|
||||||
|
|
||||||
|
// The two existing slots must have received serials via model match.
|
||||||
|
for _, s := range hw.Storage[:2] {
|
||||||
|
if s.SerialNumber == "" {
|
||||||
|
t.Errorf("slot %q: expected serial to be assigned via model match", s.Slot)
|
||||||
|
}
|
||||||
|
if s.SizeGB != 3576 {
|
||||||
|
t.Errorf("slot %q: size should be preserved, got %d", s.Slot, s.SizeGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The two unmatched Micron entries should be added as new storage entries.
|
||||||
|
if len(hw.Storage) != 4 {
|
||||||
|
t.Errorf("expected 4 total storage entries (2 existing + 2 new Micron), got %d", len(hw.Storage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichStorageFromSOLSmartd_PlaceholderSlots(t *testing.T) {
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "onekeylog/log/sollog/SOLHostCapture.log",
|
||||||
|
Content: []byte(solSmartdSample),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{Slot: "BP0:0", Present: true},
|
||||||
|
{Slot: "BP0:1", Present: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichStorageFromSOLSmartd(files, hw)
|
||||||
|
|
||||||
|
for _, s := range hw.Storage {
|
||||||
|
if s.SerialNumber == "" {
|
||||||
|
t.Errorf("slot %q: expected serial to be assigned", s.Slot)
|
||||||
|
}
|
||||||
|
if s.Model == "" {
|
||||||
|
t.Errorf("slot %q: expected model to be assigned", s.Slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichStorageFromSOLSmartd_SkipsExistingSerial(t *testing.T) {
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "onekeylog/log/sollog/SOLHostCapture.log",
|
||||||
|
Content: []byte(solSmartdSample),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{Slot: "BP0:0", SerialNumber: "2310400DC7E3", Present: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
before := len(hw.Storage)
|
||||||
|
|
||||||
|
enrichStorageFromSOLSmartd(files, hw)
|
||||||
|
|
||||||
|
// BP0:0 should still have original serial unchanged
|
||||||
|
if hw.Storage[0].SerialNumber != "2310400DC7E3" {
|
||||||
|
t.Errorf("existing serial was changed: got %q", hw.Storage[0].SerialNumber)
|
||||||
|
}
|
||||||
|
// Remaining 3 devices should be added as new entries
|
||||||
|
if len(hw.Storage) <= before {
|
||||||
|
t.Errorf("expected new entries to be added, got %d (same as before)", len(hw.Storage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichStorageFromSOLSmartd_MergesTwoFiles(t *testing.T) {
|
||||||
|
// Two SOL files with partial overlap; combined unique serials = 3
|
||||||
|
file1 := `[ 17.0] smartd[1]: Device: /dev/sda [SAT], ModelA, S/N:SN001, WWN:w, FW:fw1, 480 GB`
|
||||||
|
file2 := strings.Join([]string{
|
||||||
|
`[ 17.0] smartd[2]: Device: /dev/sda [SAT], ModelA, S/N:SN001, WWN:w, FW:fw1, 480 GB`,
|
||||||
|
`[ 17.1] smartd[2]: Device: /dev/sdb [SAT], ModelB, S/N:SN002, WWN:w, FW:fw2, 480 GB`,
|
||||||
|
`[ 17.2] smartd[2]: Device: /dev/sdc [SAT], ModelC, S/N:SN003, WWN:w, FW:fw3, 480 GB`,
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{Path: "log/sollog/SOLHostCapture.log", Content: []byte(file1)},
|
||||||
|
{Path: "runningdata/var/sollog/SOLHostCapture.log", Content: []byte(file2)},
|
||||||
|
}
|
||||||
|
hw := &models.HardwareConfig{}
|
||||||
|
|
||||||
|
enrichStorageFromSOLSmartd(files, hw)
|
||||||
|
|
||||||
|
if len(hw.Storage) != 3 {
|
||||||
|
t.Fatalf("expected 3 unique storage entries, got %d", len(hw.Storage))
|
||||||
|
}
|
||||||
|
}
|
||||||
2787
internal/parser/vendors/pciids/pci.ids
vendored
2787
internal/parser/vendors/pciids/pci.ids
vendored
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,10 @@ func TestHandleChartCurrent_RendersCurrentReanimatorSnapshot(t *testing.T) {
|
|||||||
t.Fatalf("expected chart title in body, got %q", body)
|
t.Fatalf("expected chart title in body, got %q", body)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, `/chart/static/view.css`) {
|
if !strings.Contains(body, `/chart/static/view.css`) {
|
||||||
t.Fatalf("expected rewritten chart static path, got %q", body)
|
t.Fatalf("expected rewritten chart css path, got %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `/chart/static/view.js`) {
|
||||||
|
t.Fatalf("expected rewritten chart js path, got %q", body)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, "Snapshot Metadata") {
|
if !strings.Contains(body, "Snapshot Metadata") {
|
||||||
t.Fatalf("expected rendered chart output, got %q", body)
|
t.Fatalf("expected rendered chart output, got %q", body)
|
||||||
|
|||||||
@@ -84,10 +84,7 @@ func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
printMode := r.URL.Query().Get("print") == "true"
|
html, err := chartviewer.RenderHTMLWithOptions(snapshotBytes, title, chartviewer.RenderOptions{})
|
||||||
html, err := chartviewer.RenderHTMLWithOptions(snapshotBytes, title, chartviewer.RenderOptions{
|
|
||||||
PrintMode: printMode,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.htmlError(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError)
|
s.htmlError(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -140,7 +137,9 @@ func chartTitle(result *models.AnalysisResult) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func rewriteChartStaticPaths(html []byte) []byte {
|
func rewriteChartStaticPaths(html []byte) []byte {
|
||||||
return bytes.ReplaceAll(html, []byte(`href="/static/view.css"`), []byte(`href="/chart/static/view.css"`))
|
html = bytes.ReplaceAll(html, []byte(`href="/static/view.css"`), []byte(`href="/chart/static/view.css"`))
|
||||||
|
html = bytes.ReplaceAll(html, []byte(`src="/static/view.js"`), []byte(`src="/chart/static/view.js"`))
|
||||||
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1235,6 +1234,13 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
exp.ExportCSV(w)
|
exp.ExportCSV(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleExportLogsCSV(w http.ResponseWriter, r *http.Request) {
|
||||||
|
result := s.GetResult()
|
||||||
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "logs.csv")))
|
||||||
|
exporter.ExportLogsCSV(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleExportJSON(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleExportJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ func (s *Server) setupRoutes() {
|
|||||||
s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV)
|
s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV)
|
||||||
s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON)
|
s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON)
|
||||||
s.mux.HandleFunc("GET /api/export/reanimator", s.handleExportReanimator)
|
s.mux.HandleFunc("GET /api/export/reanimator", s.handleExportReanimator)
|
||||||
|
s.mux.HandleFunc("GET /api/export/logs-csv", s.handleExportLogsCSV)
|
||||||
s.mux.HandleFunc("POST /api/convert", s.handleConvertReanimatorBatch)
|
s.mux.HandleFunc("POST /api/convert", s.handleConvertReanimatorBatch)
|
||||||
s.mux.HandleFunc("GET /api/convert/{id}", s.handleConvertStatus)
|
s.mux.HandleFunc("GET /api/convert/{id}", s.handleConvertStatus)
|
||||||
s.mux.HandleFunc("GET /api/convert/{id}/download", s.handleConvertDownload)
|
s.mux.HandleFunc("GET /api/convert/{id}/download", s.handleConvertDownload)
|
||||||
|
|||||||
62
releases/v1.21/RELEASE_NOTES.md
Normal file
62
releases/v1.21/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# logpile v1.21
|
||||||
|
|
||||||
|
Дата релиза: 2026-06-15
|
||||||
|
Тег: `v1.21`
|
||||||
|
|
||||||
|
## Что нового
|
||||||
|
|
||||||
|
### Inspur/Kaytus (onekeylog) — серийные номера дисков из SOLHostCapture.log
|
||||||
|
|
||||||
|
Когда RAID-контроллер (например, Microchip PM8204-2GB) подключён напрямую через PCIe,
|
||||||
|
BMC возвращает пустой массив в секции `RESTful HDD info`. Серийники дисков теперь
|
||||||
|
восстанавливаются из вывода smartd в `SOLHostCapture.log`:
|
||||||
|
|
||||||
|
- Обрабатываются оба экземпляра файла (`log/sollog/` и `runningdata/var/sollog/`),
|
||||||
|
серийники дедуплицируются по обоим источникам.
|
||||||
|
- Три прохода обогащения: совпадение по модели → позиционное заполнение пустых
|
||||||
|
backplane-слотов → добавление новых записей.
|
||||||
|
- Определяется тип (SSD/HDD), производитель, прошивка и ёмкость.
|
||||||
|
|
||||||
|
### Inspur/Kaytus — корректное определение live-сбора на NF-серверах
|
||||||
|
|
||||||
|
NF-серверы хранения (например, NF5280M6) не имеют GPU-топологии, из-за чего
|
||||||
|
Redfish-коллектор раньше не мог идентифицировать их как Inspur и переходил в
|
||||||
|
режим fallback с AMI-профилем, пробуя несуществующие пути `/Oem/Ami`.
|
||||||
|
|
||||||
|
Добавлено определение по `SystemManufacturer` / `ChassisManufacturer`: значение
|
||||||
|
`"Inspur"` теперь даёт 60 очков — достаточно для входа в matched-режим без
|
||||||
|
GPU-сигналов.
|
||||||
|
|
||||||
|
### Inspur/Kaytus — исправление IDL-событий GPU (Assert/Deassert)
|
||||||
|
|
||||||
|
- Deassert-события больше не отбрасываются как дубликаты Assert — в ключ дедупликации
|
||||||
|
добавлен `EventType`.
|
||||||
|
- Deassert корректно снимает критический статус GPU: раньше GPUы оставались в Critical
|
||||||
|
даже после сброса аварии.
|
||||||
|
- В экспорт Reanimator добавлена секция `bmc_event_summary` — дедуплицированная таблица
|
||||||
|
критических и предупреждающих событий со статусом Active/Resolved на основе пар
|
||||||
|
Assert/Deassert.
|
||||||
|
|
||||||
|
### UI — кнопка PDF
|
||||||
|
|
||||||
|
Добавлена кнопка «PDF» в шапку отчёта. При нажатии отчёт открывается в новой
|
||||||
|
вкладке, откуда можно сохранить в PDF через системный диалог печати браузера.
|
||||||
|
|
||||||
|
### Внутренние изменения (bible-контракты)
|
||||||
|
|
||||||
|
- Идентификаторы нормализованы через `strings.EqualFold` (H3C-парсер).
|
||||||
|
- CSV-экспорт: UTF-8 BOM + разделитель `;`.
|
||||||
|
- Все русскоязычные строки в исходниках переведены на английский (ADL-007).
|
||||||
|
- `Job` расширен полями `Type`, `Message`, `Result`.
|
||||||
|
- List-эндпоинты обёрнуты в конверт `{items, total_count, page, per_page, total_pages}`.
|
||||||
|
- Страницы ошибок рендерят footer с версией.
|
||||||
|
- Логирование переведено на `log/slog` со структурированными атрибутами.
|
||||||
|
|
||||||
|
### pci.ids обновлён
|
||||||
|
|
||||||
|
База идентификаторов PCI-устройств обновлена до актуальной версии от 2026-06-15.
|
||||||
|
|
||||||
|
## Запуск на macOS
|
||||||
|
|
||||||
|
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/logpile-darwin-arm64`
|
||||||
|
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||||
60
releases/v1.22/RELEASE_NOTES.md
Normal file
60
releases/v1.22/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# logpile v1.22
|
||||||
|
|
||||||
|
Дата релиза: 2026-06-19
|
||||||
|
Тег: `v1.22`
|
||||||
|
|
||||||
|
## Что нового
|
||||||
|
|
||||||
|
### HPE iLO AHS — новый парсер
|
||||||
|
|
||||||
|
Добавлена поддержка файлов `*.ahs` (Active Health System), экспортируемых
|
||||||
|
из веб-интерфейса iLO. Парсер извлекает:
|
||||||
|
|
||||||
|
- **Инвентарь оборудования**: плата, процессоры, память, диски, сетевые
|
||||||
|
адаптеры, блоки питания, backplane, RAID-контроллеры.
|
||||||
|
- **Прошивки**: iLO, System ROM, SPS, TPM, SPLD, контроллеры, NIC, backplane —
|
||||||
|
из основного бинарного контейнера и XML-сертификата `bcert.pkg`.
|
||||||
|
- **События**: разбор `.zbb`-файлов с журналом iLO; определение типа и
|
||||||
|
серьёзности по тексту сообщения; очистка однобайтовых frame-сепараторов
|
||||||
|
из концов строк.
|
||||||
|
- **Устойчивость к битым файлам**: если последняя запись в AHS-контейнере
|
||||||
|
обрезана (объявленный размер выходит за границу файла), парсер обрабатывает
|
||||||
|
данные частично вместо возврата ошибки.
|
||||||
|
- Добавлено распознавание модельного ряда **Alletra Storage Server** (ранее
|
||||||
|
`ProductName` оставался пустым).
|
||||||
|
|
||||||
|
### Экспорт логов в CSV («Logs Export»)
|
||||||
|
|
||||||
|
Новая кнопка «**Logs Export**» в шапке интерфейса выгружает все
|
||||||
|
распознанные события (без какой-либо фильтрации) в CSV-файл:
|
||||||
|
|
||||||
|
- Разделитель — точка с запятой (`;`), кодировка — UTF-8 с BOM.
|
||||||
|
- Файл открывается в Excel без дополнительных настроек импорта.
|
||||||
|
- Колонки: `timestamp`, `source`, `severity`, `sensor_type`, `sensor_name`,
|
||||||
|
`event_type`, `id`, `description`, `raw_data`.
|
||||||
|
|
||||||
|
Кнопка «PDF» удалена.
|
||||||
|
|
||||||
|
### Исправления в Reanimator-экспорте
|
||||||
|
|
||||||
|
- `event_logs` в JSON-экспорте Reanimator больше не оказывается пустым для
|
||||||
|
HPE iLO AHS: источник `"HPE iLO"` теперь корректно нормализуется в `"bmc"`.
|
||||||
|
|
||||||
|
### Исправления chart viewer
|
||||||
|
|
||||||
|
- JavaScript `view.js` не загружался в LOGPile из-за отсутствия перезаписи
|
||||||
|
пути `/static/view.js` → `/chart/static/view.js`. Исправлено; фильтры
|
||||||
|
по колонкам в таблицах теперь работают.
|
||||||
|
- Субмодуль chart обновлён до **v2.7**: фильтры вынесены в отдельную строку
|
||||||
|
под заголовком, исправлена минимальная ширина колонок.
|
||||||
|
|
||||||
|
### Обновления зависимостей
|
||||||
|
|
||||||
|
- **pci.ids** (база PCI-устройств) обновлена. Коллектор скорректирован под
|
||||||
|
переименование `0x8086:0x28c0`: `"Volume Management Device NVMe RAID
|
||||||
|
Controller"` → `"Volume Management Device (VMD)"`.
|
||||||
|
|
||||||
|
## Запуск на macOS
|
||||||
|
|
||||||
|
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/logpile-darwin-arm64`
|
||||||
|
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||||
2
third_party/pciids
vendored
2
third_party/pciids
vendored
Submodule third_party/pciids updated: 82b1a68f47...a18f209e39
@@ -1410,7 +1410,7 @@ async function loadData(vendor, filename) {
|
|||||||
document.getElementById('clear-btn').classList.remove('hidden');
|
document.getElementById('clear-btn').classList.remove('hidden');
|
||||||
document.getElementById('header-raw-btn').classList.remove('hidden');
|
document.getElementById('header-raw-btn').classList.remove('hidden');
|
||||||
document.getElementById('header-reanimator-btn').classList.remove('hidden');
|
document.getElementById('header-reanimator-btn').classList.remove('hidden');
|
||||||
document.getElementById('header-pdf-btn').classList.remove('hidden');
|
document.getElementById('header-logs-csv-btn').classList.remove('hidden');
|
||||||
document.getElementById('header-log-meta').classList.remove('hidden');
|
document.getElementById('header-log-meta').classList.remove('hidden');
|
||||||
|
|
||||||
loadAuditViewer();
|
loadAuditViewer();
|
||||||
@@ -1510,10 +1510,6 @@ function exportData(format) {
|
|||||||
window.location.href = `/api/export/${format}`;
|
window.location.href = `/api/export/${format}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function printReport() {
|
|
||||||
window.open('/chart/current?print=true', '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear data
|
// Clear data
|
||||||
async function clearData() {
|
async function clearData() {
|
||||||
try {
|
try {
|
||||||
@@ -1523,7 +1519,7 @@ async function clearData() {
|
|||||||
document.getElementById('clear-btn').classList.add('hidden');
|
document.getElementById('clear-btn').classList.add('hidden');
|
||||||
document.getElementById('header-raw-btn').classList.add('hidden');
|
document.getElementById('header-raw-btn').classList.add('hidden');
|
||||||
document.getElementById('header-reanimator-btn').classList.add('hidden');
|
document.getElementById('header-reanimator-btn').classList.add('hidden');
|
||||||
document.getElementById('header-pdf-btn').classList.add('hidden');
|
document.getElementById('header-logs-csv-btn').classList.add('hidden');
|
||||||
document.getElementById('header-log-meta').classList.add('hidden');
|
document.getElementById('header-log-meta').classList.add('hidden');
|
||||||
document.getElementById('upload-status').textContent = '';
|
document.getElementById('upload-status').textContent = '';
|
||||||
const frame = document.getElementById('audit-viewer-frame');
|
const frame = document.getElementById('audit-viewer-frame');
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<button id="clear-btn" class="header-action hidden" onclick="clearData()">Clear Data</button>
|
<button id="clear-btn" class="header-action hidden" onclick="clearData()">Clear Data</button>
|
||||||
<button id="header-raw-btn" class="header-action hidden" onclick="exportData('json')">Raw Data</button>
|
<button id="header-raw-btn" class="header-action hidden" onclick="exportData('json')">Raw Data</button>
|
||||||
<button id="header-reanimator-btn" class="header-action hidden" onclick="exportData('reanimator')">Reanimator</button>
|
<button id="header-reanimator-btn" class="header-action hidden" onclick="exportData('reanimator')">Reanimator</button>
|
||||||
<button id="header-pdf-btn" class="header-action hidden" onclick="printReport()">PDF</button>
|
<button id="header-logs-csv-btn" class="header-action hidden" onclick="exportData('logs-csv')">Logs Export</button>
|
||||||
<button id="restart-btn" class="header-action" onclick="restartApp()">Restart</button>
|
<button id="restart-btn" class="header-action" onclick="restartApp()">Restart</button>
|
||||||
<button id="exit-btn" class="header-action" onclick="exitApp()">Exit</button>
|
<button id="exit-btn" class="header-action" onclick="exitApp()">Exit</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user