sync file-type support across upload/convert and fix collected_at timezone handling
This commit is contained in:
@@ -86,8 +86,11 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
|||||||
firmware = dedupeFirmwareInfo(append(firmware, r.collectFirmwareInventory()...))
|
firmware = dedupeFirmwareInfo(append(firmware, r.collectFirmwareInventory()...))
|
||||||
boardInfo := parseBoardInfoWithFallback(systemDoc, chassisDoc, fruDoc)
|
boardInfo := parseBoardInfoWithFallback(systemDoc, chassisDoc, fruDoc)
|
||||||
applyBoardInfoFallbackFromDocs(&boardInfo, boardFallbackDocs)
|
applyBoardInfoFallbackFromDocs(&boardInfo, boardFallbackDocs)
|
||||||
|
collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads)
|
||||||
|
|
||||||
result := &models.AnalysisResult{
|
result := &models.AnalysisResult{
|
||||||
|
CollectedAt: collectedAt,
|
||||||
|
SourceTimezone: sourceTimezone,
|
||||||
Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...),
|
Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...),
|
||||||
FRU: make([]models.FRUInfo, 0),
|
FRU: make([]models.FRUInfo, 0),
|
||||||
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
|
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
|
||||||
@@ -105,10 +108,41 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
|||||||
Firmware: firmware,
|
Firmware: firmware,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(sourceTimezone) != "" {
|
||||||
|
if result.RawPayloads == nil {
|
||||||
|
result.RawPayloads = map[string]any{}
|
||||||
|
}
|
||||||
|
result.RawPayloads["source_timezone"] = sourceTimezone
|
||||||
|
}
|
||||||
appendMissingServerModelWarning(result, systemDoc, joinPath(primarySystem, "/Oem/Public/FRU"), joinPath(primaryChassis, "/Oem/Public/FRU"))
|
appendMissingServerModelWarning(result, systemDoc, joinPath(primarySystem, "/Oem/Public/FRU"), joinPath(primaryChassis, "/Oem/Public/FRU"))
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inferRedfishCollectionTime(managerDoc map[string]interface{}, rawPayloads map[string]any) (time.Time, string) {
|
||||||
|
dateTime := strings.TrimSpace(asString(managerDoc["DateTime"]))
|
||||||
|
offset := strings.TrimSpace(asString(managerDoc["DateTimeLocalOffset"]))
|
||||||
|
if dateTime != "" {
|
||||||
|
if ts, err := time.Parse(time.RFC3339Nano, dateTime); err == nil {
|
||||||
|
if offset == "" {
|
||||||
|
offset = ts.Format("-07:00")
|
||||||
|
}
|
||||||
|
return ts.UTC(), offset
|
||||||
|
}
|
||||||
|
if ts, err := time.Parse(time.RFC3339, dateTime); err == nil {
|
||||||
|
if offset == "" {
|
||||||
|
offset = ts.Format("-07:00")
|
||||||
|
}
|
||||||
|
return ts.UTC(), offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if offset == "" && len(rawPayloads) > 0 {
|
||||||
|
if tz, ok := rawPayloads["source_timezone"].(string); ok {
|
||||||
|
offset = strings.TrimSpace(tz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, offset
|
||||||
|
}
|
||||||
|
|
||||||
func appendMissingServerModelWarning(result *models.AnalysisResult, systemDoc map[string]interface{}, systemFRUPath, chassisFRUPath string) {
|
func appendMissingServerModelWarning(result *models.AnalysisResult, systemDoc map[string]interface{}, systemFRUPath, chassisFRUPath string) {
|
||||||
if result == nil || result.Hardware == nil {
|
if result == nil || result.Hardware == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
)
|
)
|
||||||
@@ -305,6 +306,52 @@ func TestReplayRedfishFromRawPayloads_FallbackCollectionMembersByPrefix(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReplayRedfishFromRawPayloads_PreservesSourceTimezoneAndUTCCollectedAt(t *testing.T) {
|
||||||
|
raw := map[string]any{
|
||||||
|
"redfish_tree": map[string]interface{}{
|
||||||
|
"/redfish/v1": map[string]interface{}{
|
||||||
|
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
|
||||||
|
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
|
||||||
|
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
|
||||||
|
},
|
||||||
|
"/redfish/v1/Systems": map[string]interface{}{
|
||||||
|
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"}},
|
||||||
|
},
|
||||||
|
"/redfish/v1/Systems/1": map[string]interface{}{
|
||||||
|
"Manufacturer": "Inspur",
|
||||||
|
"Model": "NF5688M7",
|
||||||
|
"SerialNumber": "23E100051",
|
||||||
|
},
|
||||||
|
"/redfish/v1/Chassis": map[string]interface{}{
|
||||||
|
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"}},
|
||||||
|
},
|
||||||
|
"/redfish/v1/Chassis/1": map[string]interface{}{"Id": "1"},
|
||||||
|
"/redfish/v1/Managers": map[string]interface{}{
|
||||||
|
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"}},
|
||||||
|
},
|
||||||
|
"/redfish/v1/Managers/1": map[string]interface{}{
|
||||||
|
"DateTime": "2026-02-28T04:18:18+08:00",
|
||||||
|
"DateTimeLocalOffset": "+08:00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ReplayRedfishFromRawPayloads(raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replay failed: %v", err)
|
||||||
|
}
|
||||||
|
if got.SourceTimezone != "+08:00" {
|
||||||
|
t.Fatalf("expected source_timezone +08:00, got %q", got.SourceTimezone)
|
||||||
|
}
|
||||||
|
wantCollectedAt := time.Date(2026, 2, 27, 20, 18, 18, 0, time.UTC)
|
||||||
|
if !got.CollectedAt.Equal(wantCollectedAt) {
|
||||||
|
t.Fatalf("expected collected_at %s, got %s", wantCollectedAt, got.CollectedAt)
|
||||||
|
}
|
||||||
|
if got.RawPayloads["source_timezone"] != "+08:00" {
|
||||||
|
t.Fatalf("expected source_timezone in raw payloads, got %#v", got.RawPayloads["source_timezone"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReplayRedfishFromRawPayloads_ParsesInlineThresholdAndDiscreteSensors(t *testing.T) {
|
func TestReplayRedfishFromRawPayloads_ParsesInlineThresholdAndDiscreteSensors(t *testing.T) {
|
||||||
raw := map[string]any{
|
raw := map[string]any{
|
||||||
"redfish_tree": map[string]interface{}{
|
"redfish_tree": map[string]interface{}{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type AnalysisResult struct {
|
|||||||
SourceType string `json:"source_type,omitempty"` // archive | api
|
SourceType string `json:"source_type,omitempty"` // archive | api
|
||||||
Protocol string `json:"protocol,omitempty"` // redfish | ipmi
|
Protocol string `json:"protocol,omitempty"` // redfish | ipmi
|
||||||
TargetHost string `json:"target_host,omitempty"` // BMC host for live collect
|
TargetHost string `json:"target_host,omitempty"` // BMC host for live collect
|
||||||
|
SourceTimezone string `json:"source_timezone,omitempty"` // Source timezone/offset used during collection (e.g. +08:00)
|
||||||
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
|
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
|
||||||
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
|
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
|
||||||
Events []Event `json:"events"`
|
Events []Event `json:"events"`
|
||||||
|
|||||||
@@ -9,23 +9,38 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxSingleFileSize = 10 * 1024 * 1024
|
const maxSingleFileSize = 10 * 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{}{
|
||||||
|
".gz": {},
|
||||||
|
".tgz": {},
|
||||||
|
".tar": {},
|
||||||
|
".zip": {},
|
||||||
|
".txt": {},
|
||||||
|
".log": {},
|
||||||
|
}
|
||||||
|
|
||||||
// ExtractedFile represents a file extracted from archive
|
// ExtractedFile represents a file extracted from archive
|
||||||
type ExtractedFile struct {
|
type ExtractedFile struct {
|
||||||
Path string
|
Path string
|
||||||
Content []byte
|
Content []byte
|
||||||
|
ModTime time.Time
|
||||||
Truncated bool
|
Truncated bool
|
||||||
TruncatedMessage string
|
TruncatedMessage string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractArchive extracts tar.gz or zip archive and returns file contents
|
// ExtractArchive extracts tar.gz or zip archive and returns file contents
|
||||||
func ExtractArchive(archivePath string) ([]ExtractedFile, error) {
|
func ExtractArchive(archivePath string) ([]ExtractedFile, error) {
|
||||||
|
if !IsSupportedArchiveFilename(archivePath) {
|
||||||
|
return nil, fmt.Errorf("unsupported archive format: %s", strings.ToLower(filepath.Ext(archivePath)))
|
||||||
|
}
|
||||||
ext := strings.ToLower(filepath.Ext(archivePath))
|
ext := strings.ToLower(filepath.Ext(archivePath))
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
@@ -44,6 +59,9 @@ func ExtractArchive(archivePath string) ([]ExtractedFile, error) {
|
|||||||
|
|
||||||
// ExtractArchiveFromReader extracts archive from reader
|
// ExtractArchiveFromReader extracts archive from reader
|
||||||
func ExtractArchiveFromReader(r io.Reader, filename string) ([]ExtractedFile, error) {
|
func ExtractArchiveFromReader(r io.Reader, filename string) ([]ExtractedFile, error) {
|
||||||
|
if !IsSupportedArchiveFilename(filename) {
|
||||||
|
return nil, fmt.Errorf("unsupported archive format: %s", strings.ToLower(filepath.Ext(filename)))
|
||||||
|
}
|
||||||
ext := strings.ToLower(filepath.Ext(filename))
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
@@ -60,6 +78,27 @@ func ExtractArchiveFromReader(r io.Reader, filename string) ([]ExtractedFile, er
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsSupportedArchiveFilename reports whether filename extension is supported by archive extractor.
|
||||||
|
func IsSupportedArchiveFilename(filename string) bool {
|
||||||
|
ext := strings.ToLower(strings.TrimSpace(filepath.Ext(filename)))
|
||||||
|
if ext == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := supportedArchiveExt[ext]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportedArchiveExtensions returns sorted list of archive/file extensions
|
||||||
|
// accepted by archive extractor.
|
||||||
|
func SupportedArchiveExtensions() []string {
|
||||||
|
out := make([]string, 0, len(supportedArchiveExt))
|
||||||
|
for ext := range supportedArchiveExt {
|
||||||
|
out = append(out, ext)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func extractTarGz(archivePath string) ([]ExtractedFile, error) {
|
func extractTarGz(archivePath string) ([]ExtractedFile, error) {
|
||||||
f, err := os.Open(archivePath)
|
f, err := os.Open(archivePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,6 +150,7 @@ func extractTarFromReader(r io.Reader) ([]ExtractedFile, error) {
|
|||||||
files = append(files, ExtractedFile{
|
files = append(files, ExtractedFile{
|
||||||
Path: header.Name,
|
Path: header.Name,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
ModTime: header.ModTime,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +192,7 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro
|
|||||||
file := ExtractedFile{
|
file := ExtractedFile{
|
||||||
Path: baseName,
|
Path: baseName,
|
||||||
Content: decompressed,
|
Content: decompressed,
|
||||||
|
ModTime: gzr.ModTime,
|
||||||
}
|
}
|
||||||
if gzipTruncated {
|
if gzipTruncated {
|
||||||
file.Truncated = true
|
file.Truncated = true
|
||||||
@@ -180,6 +221,7 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro
|
|||||||
files = append(files, ExtractedFile{
|
files = append(files, ExtractedFile{
|
||||||
Path: header.Name,
|
Path: header.Name,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
ModTime: header.ModTime,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,6 +272,7 @@ func extractZip(archivePath string) ([]ExtractedFile, error) {
|
|||||||
files = append(files, ExtractedFile{
|
files = append(files, ExtractedFile{
|
||||||
Path: f.Name,
|
Path: f.Name,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
ModTime: f.Modified,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +324,7 @@ func extractZipFromReader(r io.Reader) ([]ExtractedFile, error) {
|
|||||||
files = append(files, ExtractedFile{
|
files = append(files, ExtractedFile{
|
||||||
Path: f.Name,
|
Path: f.Name,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
ModTime: f.Modified,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,13 +332,24 @@ func extractZipFromReader(r io.Reader) ([]ExtractedFile, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractSingleFile(path string) ([]ExtractedFile, error) {
|
func extractSingleFile(path string) ([]ExtractedFile, error) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stat file: %w", err)
|
||||||
|
}
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open file: %w", err)
|
return nil, fmt.Errorf("open file: %w", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
return extractSingleFileFromReader(f, filepath.Base(path))
|
files, err := extractSingleFileFromReader(f, filepath.Base(path))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(files) > 0 {
|
||||||
|
files[0].ModTime = info.ModTime()
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractSingleFileFromReader(r io.Reader, filename string) ([]ExtractedFile, error) {
|
func extractSingleFileFromReader(r io.Reader, filename string) ([]ExtractedFile, error) {
|
||||||
|
|||||||
@@ -69,3 +69,25 @@ func TestExtractArchiveFromReaderTXT_TruncatedWhenTooLarge(t *testing.T) {
|
|||||||
t.Fatalf("expected truncation message")
|
t.Fatalf("expected truncation message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsSupportedArchiveFilename(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "dump.tar.gz", want: true},
|
||||||
|
{name: "nvidia-bug-report-1651124000923.log.gz", want: true},
|
||||||
|
{name: "snapshot.zip", want: true},
|
||||||
|
{name: "report.log", want: true},
|
||||||
|
{name: "xigmanas.txt", want: true},
|
||||||
|
{name: "raw_export.json", want: false},
|
||||||
|
{name: "archive.bin", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := IsSupportedArchiveFilename(tc.name)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("IsSupportedArchiveFilename(%q)=%v, want %v", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,11 +66,41 @@ func (p *BMCParser) parseFiles() error {
|
|||||||
result.Filename = p.result.Filename
|
result.Filename = p.result.Filename
|
||||||
|
|
||||||
appendExtractionWarnings(result, p.files)
|
appendExtractionWarnings(result, p.files)
|
||||||
|
if result.CollectedAt.IsZero() {
|
||||||
|
if ts := inferCollectedAtFromExtractedFiles(p.files); !ts.IsZero() {
|
||||||
|
result.CollectedAt = ts.UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
p.result = result
|
p.result = result
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inferCollectedAtFromExtractedFiles(files []ExtractedFile) time.Time {
|
||||||
|
var latestReliable time.Time
|
||||||
|
var latestAny time.Time
|
||||||
|
for _, f := range files {
|
||||||
|
ts := f.ModTime
|
||||||
|
if ts.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if latestAny.IsZero() || ts.After(latestAny) {
|
||||||
|
latestAny = ts
|
||||||
|
}
|
||||||
|
// Ignore placeholder archive mtimes like 1980-01-01.
|
||||||
|
if ts.Year() < 2000 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if latestReliable.IsZero() || ts.After(latestReliable) {
|
||||||
|
latestReliable = ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !latestReliable.IsZero() {
|
||||||
|
return latestReliable
|
||||||
|
}
|
||||||
|
return latestAny
|
||||||
|
}
|
||||||
|
|
||||||
func appendExtractionWarnings(result *models.AnalysisResult, files []ExtractedFile) {
|
func appendExtractionWarnings(result *models.AnalysisResult, files []ExtractedFile) {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package parser
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
)
|
)
|
||||||
@@ -32,3 +33,30 @@ func TestAppendExtractionWarnings(t *testing.T) {
|
|||||||
t.Fatalf("expected warning details in RawData")
|
t.Fatalf("expected warning details in RawData")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInferCollectedAtFromExtractedFiles_PrefersReliableMTime(t *testing.T) {
|
||||||
|
files := []ExtractedFile{
|
||||||
|
{Path: "a.log", ModTime: time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||||
|
{Path: "b.log", ModTime: time.Date(2025, 12, 12, 10, 14, 49, 0, time.FixedZone("EST", -5*3600))},
|
||||||
|
{Path: "c.log", ModTime: time.Date(2026, 2, 28, 4, 18, 18, 0, time.FixedZone("UTC+8", 8*3600))},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := inferCollectedAtFromExtractedFiles(files)
|
||||||
|
want := files[2].ModTime
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Fatalf("expected %s, got %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInferCollectedAtFromExtractedFiles_FallsBackToAnyMTime(t *testing.T) {
|
||||||
|
files := []ExtractedFile{
|
||||||
|
{Path: "a.log", ModTime: time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||||
|
{Path: "b.log", ModTime: time.Date(1970, 1, 2, 0, 0, 0, 0, time.UTC)},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := inferCollectedAtFromExtractedFiles(files)
|
||||||
|
want := files[0].ModTime
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Fatalf("expected fallback %s, got %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
42
internal/parser/vendors/inspur/parser.go
vendored
42
internal/parser/vendors/inspur/parser.go
vendored
@@ -8,6 +8,7 @@ package inspur
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
@@ -86,6 +87,8 @@ func containsInspurMarkers(content []byte) bool {
|
|||||||
|
|
||||||
// Parse parses Inspur/Kaytus archive
|
// Parse parses Inspur/Kaytus archive
|
||||||
func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
|
func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
|
||||||
|
selLocation := inferInspurArchiveLocation(files)
|
||||||
|
|
||||||
result := &models.AnalysisResult{
|
result := &models.AnalysisResult{
|
||||||
Events: make([]models.Event, 0),
|
Events: make([]models.Event, 0),
|
||||||
FRU: make([]models.FRUInfo, 0),
|
FRU: make([]models.FRUInfo, 0),
|
||||||
@@ -145,7 +148,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
|
|
||||||
// Parse SEL list (selelist.csv)
|
// Parse SEL list (selelist.csv)
|
||||||
if f := parser.FindFileByName(files, "selelist.csv"); f != nil {
|
if f := parser.FindFileByName(files, "selelist.csv"); f != nil {
|
||||||
selEvents := ParseSELList(f.Content)
|
selEvents := ParseSELListWithLocation(f.Content, selLocation)
|
||||||
result.Events = append(result.Events, selEvents...)
|
result.Events = append(result.Events, selEvents...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +187,43 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inferInspurArchiveLocation(files []parser.ExtractedFile) *time.Location {
|
||||||
|
fallback := parser.DefaultArchiveLocation()
|
||||||
|
f := parser.FindFileByName(files, "timezone.conf")
|
||||||
|
if f == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
locName := parseTimezoneConfigLocation(f.Content)
|
||||||
|
if strings.TrimSpace(locName) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
loc, err := time.LoadLocation(locName)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTimezoneConfigLocation(content []byte) string {
|
||||||
|
lines := strings.Split(string(content), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "[") || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||||
|
val := strings.TrimSpace(parts[1])
|
||||||
|
if key == "timezone" && val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Parser) parseDeviceFruSDR(content []byte, result *models.AnalysisResult) {
|
func (p *Parser) parseDeviceFruSDR(content []byte, result *models.AnalysisResult) {
|
||||||
lines := string(content)
|
lines := string(content)
|
||||||
|
|
||||||
|
|||||||
16
internal/parser/vendors/inspur/sel.go
vendored
16
internal/parser/vendors/inspur/sel.go
vendored
@@ -13,6 +13,12 @@ import (
|
|||||||
// Format: ID, Date (MM/DD/YYYY), Time (HH:MM:SS), Sensor, Event, Status
|
// Format: ID, Date (MM/DD/YYYY), Time (HH:MM:SS), Sensor, Event, Status
|
||||||
// Example: 1,04/18/2025,09:31:18,Event Logging Disabled SEL_Status,Log area reset/cleared,Asserted
|
// Example: 1,04/18/2025,09:31:18,Event Logging Disabled SEL_Status,Log area reset/cleared,Asserted
|
||||||
func ParseSELList(content []byte) []models.Event {
|
func ParseSELList(content []byte) []models.Event {
|
||||||
|
return ParseSELListWithLocation(content, parser.DefaultArchiveLocation())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSELListWithLocation parses selelist.csv using provided source timezone
|
||||||
|
// for timestamps that don't contain an explicit offset.
|
||||||
|
func ParseSELListWithLocation(content []byte, location *time.Location) []models.Event {
|
||||||
var events []models.Event
|
var events []models.Event
|
||||||
|
|
||||||
text := string(content)
|
text := string(content)
|
||||||
@@ -49,7 +55,7 @@ func ParseSELList(content []byte) []models.Event {
|
|||||||
status := strings.TrimSpace(records[5])
|
status := strings.TrimSpace(records[5])
|
||||||
|
|
||||||
// Parse timestamp: MM/DD/YYYY HH:MM:SS
|
// Parse timestamp: MM/DD/YYYY HH:MM:SS
|
||||||
timestamp := parseSELTimestamp(dateStr, timeStr)
|
timestamp := parseSELTimestamp(dateStr, timeStr, location)
|
||||||
|
|
||||||
// Extract sensor type and name
|
// Extract sensor type and name
|
||||||
sensorType, sensorName := parseSensorInfo(sensorStr)
|
sensorType, sensorName := parseSensorInfo(sensorStr)
|
||||||
@@ -77,12 +83,16 @@ func ParseSELList(content []byte) []models.Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseSELTimestamp parses MM/DD/YYYY and HH:MM:SS into time.Time
|
// parseSELTimestamp parses MM/DD/YYYY and HH:MM:SS into time.Time
|
||||||
func parseSELTimestamp(dateStr, timeStr string) time.Time {
|
func parseSELTimestamp(dateStr, timeStr string, location *time.Location) time.Time {
|
||||||
// Combine date and time: MM/DD/YYYY HH:MM:SS
|
// Combine date and time: MM/DD/YYYY HH:MM:SS
|
||||||
timestampStr := dateStr + " " + timeStr
|
timestampStr := dateStr + " " + timeStr
|
||||||
|
|
||||||
|
if location == nil {
|
||||||
|
location = parser.DefaultArchiveLocation()
|
||||||
|
}
|
||||||
|
|
||||||
// Try parsing with MM/DD/YYYY format
|
// Try parsing with MM/DD/YYYY format
|
||||||
t, err := parser.ParseInDefaultArchiveLocation("01/02/2006 15:04:05", timestampStr)
|
t, err := time.ParseInLocation("01/02/2006 15:04:05", timestampStr, location)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback to current time
|
// Fallback to current time
|
||||||
return time.Now()
|
return time.Now()
|
||||||
|
|||||||
33
internal/parser/vendors/inspur/sel_test.go
vendored
Normal file
33
internal/parser/vendors/inspur/sel_test.go
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSELListWithLocation_UsesProvidedTimezone(t *testing.T) {
|
||||||
|
content := []byte("sel elist:\n1,02/28/2026,04:18:18,Sensor X,Event,Asserted\n")
|
||||||
|
shanghai, err := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load location: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := ParseSELListWithLocation(content, shanghai)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("expected 1 event, got %d", len(events))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 04:18:18 +08:00 == 20:18:18Z (previous day)
|
||||||
|
want := time.Date(2026, 2, 27, 20, 18, 18, 0, time.UTC)
|
||||||
|
if !events[0].Timestamp.UTC().Equal(want) {
|
||||||
|
t.Fatalf("unexpected timestamp: got %s want %s", events[0].Timestamp.UTC(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTimezoneConfigLocation(t *testing.T) {
|
||||||
|
content := []byte("[TimeZoneConfig]\ntimezone=Asia/Shanghai\n")
|
||||||
|
got := parseTimezoneConfigLocation(content)
|
||||||
|
if got != "Asia/Shanghai" {
|
||||||
|
t.Fatalf("unexpected timezone: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,33 @@
|
|||||||
package nvidia_bug_report
|
package nvidia_bug_report
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parserVersion - version of this parser module
|
// parserVersion - version of this parser module
|
||||||
const parserVersion = "1.0.0"
|
const parserVersion = "1.1.0"
|
||||||
|
|
||||||
|
var bugReportDateLineRegex = regexp.MustCompile(`(?m)^Date:\s+(.+?)\s*$`)
|
||||||
|
var dateWithTZAbbrevRegex = regexp.MustCompile(`^([A-Za-z]{3}\s+[A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+([A-Za-z]{2,5})\s+(\d{4})$`)
|
||||||
|
|
||||||
|
var timezoneAbbrevToOffset = map[string]string{
|
||||||
|
"UTC": "+00:00",
|
||||||
|
"GMT": "+00:00",
|
||||||
|
"EST": "-05:00",
|
||||||
|
"EDT": "-04:00",
|
||||||
|
"CST": "-06:00",
|
||||||
|
"CDT": "-05:00",
|
||||||
|
"MST": "-07:00",
|
||||||
|
"MDT": "-06:00",
|
||||||
|
"PST": "-08:00",
|
||||||
|
"PDT": "-07:00",
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
parser.Register(&Parser{})
|
parser.Register(&Parser{})
|
||||||
@@ -81,6 +100,10 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
content := string(files[0].Content)
|
content := string(files[0].Content)
|
||||||
|
if collectedAt, tzOffset, ok := parseBugReportCollectedAt(content); ok {
|
||||||
|
result.CollectedAt = collectedAt.UTC()
|
||||||
|
result.SourceTimezone = tzOffset
|
||||||
|
}
|
||||||
|
|
||||||
// Parse system information
|
// Parse system information
|
||||||
parseSystemInfo(content, result)
|
parseSystemInfo(content, result)
|
||||||
@@ -105,3 +128,49 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseBugReportCollectedAt(content string) (time.Time, string, bool) {
|
||||||
|
matches := bugReportDateLineRegex.FindStringSubmatch(content)
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return time.Time{}, "", false
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(matches[1])
|
||||||
|
if raw == "" {
|
||||||
|
return time.Time{}, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := dateWithTZAbbrevRegex.FindStringSubmatch(raw); len(m) == 4 {
|
||||||
|
if offset, ok := timezoneAbbrevToOffset[strings.ToUpper(strings.TrimSpace(m[2]))]; ok {
|
||||||
|
layout := "Mon Jan 2 15:04:05 -07:00 2006"
|
||||||
|
normalized := strings.TrimSpace(m[1]) + " " + offset + " " + strings.TrimSpace(m[3])
|
||||||
|
if ts, err := time.Parse(layout, normalized); err == nil {
|
||||||
|
return ts, offset, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layouts := []string{
|
||||||
|
"Mon Jan 2 15:04:05 MST 2006",
|
||||||
|
"Mon Jan 2 15:04:05 2006",
|
||||||
|
}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
ts, err := time.Parse(layout, raw)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ts, formatOffset(ts), true
|
||||||
|
}
|
||||||
|
return time.Time{}, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOffset(t time.Time) string {
|
||||||
|
_, sec := t.Zone()
|
||||||
|
sign := '+'
|
||||||
|
if sec < 0 {
|
||||||
|
sign = '-'
|
||||||
|
sec = -sec
|
||||||
|
}
|
||||||
|
h := sec / 3600
|
||||||
|
m := (sec % 3600) / 60
|
||||||
|
return fmt.Sprintf("%c%02d:%02d", sign, h, m)
|
||||||
|
}
|
||||||
|
|||||||
54
internal/parser/vendors/nvidia_bug_report/parser_test.go
vendored
Normal file
54
internal/parser/vendors/nvidia_bug_report/parser_test.go
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package nvidia_bug_report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseBugReportCollectedAt(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
Start of NVIDIA bug report log file
|
||||||
|
Date: Fri Dec 12 10:14:49 EST 2025
|
||||||
|
`
|
||||||
|
|
||||||
|
got, tz, ok := parseBugReportCollectedAt(content)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected collected_at to be parsed")
|
||||||
|
}
|
||||||
|
if tz != "-05:00" {
|
||||||
|
t.Fatalf("expected tz offset -05:00, got %q", tz)
|
||||||
|
}
|
||||||
|
wantUTC := time.Date(2025, 12, 12, 15, 14, 49, 0, time.UTC)
|
||||||
|
if !got.UTC().Equal(wantUTC) {
|
||||||
|
t.Fatalf("expected %s, got %s", wantUTC, got.UTC())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNvidiaBugReportParser_SetsCollectedAtAndTimezone(t *testing.T) {
|
||||||
|
p := &Parser{}
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "nvidia-bug-report-1653925023938.log",
|
||||||
|
Content: []byte(`
|
||||||
|
Start of NVIDIA bug report log file
|
||||||
|
nvidia-bug-report.sh Version: 34275561
|
||||||
|
Date: Fri Dec 12 10:14:49 EST 2025
|
||||||
|
`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := p.Parse(files)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.SourceTimezone != "-05:00" {
|
||||||
|
t.Fatalf("expected source timezone -05:00, got %q", result.SourceTimezone)
|
||||||
|
}
|
||||||
|
wantUTC := time.Date(2025, 12, 12, 15, 14, 49, 0, time.UTC)
|
||||||
|
if !result.CollectedAt.Equal(wantUTC) {
|
||||||
|
t.Fatalf("expected collected_at %s, got %s", wantUTC, result.CollectedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
internal/server/file_support_test.go
Normal file
17
internal/server/file_support_test.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsSupportedConvertFileName_AcceptsNvidiaBugReportGzip(t *testing.T) {
|
||||||
|
if !isSupportedConvertFileName("nvidia-bug-report-1651124000923.log.gz") {
|
||||||
|
t.Fatalf("expected .log.gz bug-report to be supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeUploadedFile_RejectsUnsupportedExtension(t *testing.T) {
|
||||||
|
s := &Server{}
|
||||||
|
_, _, _, err := s.analyzeUploadedFile("unsupported.bin", "application/octet-stream", []byte("abc"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected unsupported archive error")
|
||||||
|
}
|
||||||
|
}
|
||||||
46
internal/server/file_types_test.go
Normal file
46
internal/server/file_types_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleGetFileTypes(t *testing.T) {
|
||||||
|
s := &Server{}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/file-types", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
s.handleGetFileTypes(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
ArchiveExtensions []string `json:"archive_extensions"`
|
||||||
|
UploadExtensions []string `json:"upload_extensions"`
|
||||||
|
ConvertExtensions []string `json:"convert_extensions"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode payload: %v", err)
|
||||||
|
}
|
||||||
|
if len(payload.ArchiveExtensions) == 0 || len(payload.UploadExtensions) == 0 || len(payload.ConvertExtensions) == 0 {
|
||||||
|
t.Fatalf("expected non-empty extensions in payload")
|
||||||
|
}
|
||||||
|
if !containsString(payload.ArchiveExtensions, ".gz") {
|
||||||
|
t.Fatalf("expected .gz in archive extensions")
|
||||||
|
}
|
||||||
|
if !containsString(payload.UploadExtensions, ".json") || !containsString(payload.ConvertExtensions, ".json") {
|
||||||
|
t.Fatalf("expected .json in upload/convert extensions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(items []string, target string) bool {
|
||||||
|
for _, item := range items {
|
||||||
|
if item == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -138,6 +138,9 @@ func (s *Server) analyzeUploadedFile(filename, mimeType string, payload []byte)
|
|||||||
}
|
}
|
||||||
return snapshotResult, vendor, newRawExportFromUploadedFile(filename, mimeType, payload, snapshotResult), nil
|
return snapshotResult, vendor, newRawExportFromUploadedFile(filename, mimeType, payload, snapshotResult), nil
|
||||||
}
|
}
|
||||||
|
if !parser.IsSupportedArchiveFilename(filename) {
|
||||||
|
return nil, "", nil, fmt.Errorf("unsupported archive format: %s", strings.ToLower(filepath.Ext(filename)))
|
||||||
|
}
|
||||||
|
|
||||||
p := parser.NewBMCParser()
|
p := parser.NewBMCParser()
|
||||||
if err := p.ParseFromReader(bytes.NewReader(payload), filename); err != nil {
|
if err := p.ParseFromReader(bytes.NewReader(payload), filename); err != nil {
|
||||||
@@ -208,9 +211,10 @@ func (s *Server) reanalyzeRawExportPackage(pkg *RawExportPackage) (*models.Analy
|
|||||||
if strings.TrimSpace(result.TargetHost) == "" {
|
if strings.TrimSpace(result.TargetHost) == "" {
|
||||||
result.TargetHost = strings.TrimSpace(pkg.Source.TargetHost)
|
result.TargetHost = strings.TrimSpace(pkg.Source.TargetHost)
|
||||||
}
|
}
|
||||||
if result.CollectedAt.IsZero() {
|
if strings.TrimSpace(result.SourceTimezone) == "" {
|
||||||
result.CollectedAt = time.Now().UTC()
|
result.SourceTimezone = strings.TrimSpace(pkg.Source.SourceTimezone)
|
||||||
}
|
}
|
||||||
|
result.CollectedAt = inferRawExportCollectedAt(result, pkg)
|
||||||
if strings.TrimSpace(result.Filename) == "" {
|
if strings.TrimSpace(result.Filename) == "" {
|
||||||
target := result.TargetHost
|
target := result.TargetHost
|
||||||
if target == "" {
|
if target == "" {
|
||||||
@@ -253,6 +257,39 @@ func (s *Server) handleGetParsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetFileTypes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
archiveExt := parser.SupportedArchiveExtensions()
|
||||||
|
uploadExt := append([]string{}, archiveExt...)
|
||||||
|
uploadExt = append(uploadExt, ".json")
|
||||||
|
|
||||||
|
jsonResponse(w, map[string]any{
|
||||||
|
"archive_extensions": archiveExt,
|
||||||
|
"upload_extensions": uniqueSortedExtensions(uploadExt),
|
||||||
|
"convert_extensions": uniqueSortedExtensions(uploadExt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueSortedExtensions(exts []string) []string {
|
||||||
|
if len(exts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(exts))
|
||||||
|
out := make([]string, 0, len(exts))
|
||||||
|
for _, e := range exts {
|
||||||
|
e = strings.ToLower(strings.TrimSpace(e))
|
||||||
|
if e == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[e]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[e] = struct{}{}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
if result == nil {
|
if result == nil {
|
||||||
@@ -324,10 +361,11 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"source_type": result.SourceType,
|
"source_type": result.SourceType,
|
||||||
"protocol": result.Protocol,
|
"protocol": result.Protocol,
|
||||||
"target_host": result.TargetHost,
|
"target_host": result.TargetHost,
|
||||||
"collected_at": result.CollectedAt,
|
"source_timezone": result.SourceTimezone,
|
||||||
|
"collected_at": result.CollectedAt,
|
||||||
}
|
}
|
||||||
if result.RawPayloads != nil {
|
if result.RawPayloads != nil {
|
||||||
if fetchErrors, ok := result.RawPayloads["redfish_fetch_errors"]; ok {
|
if fetchErrors, ok := result.RawPayloads["redfish_fetch_errors"]; ok {
|
||||||
@@ -1012,13 +1050,14 @@ func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
jsonResponse(w, map[string]interface{}{
|
jsonResponse(w, map[string]interface{}{
|
||||||
"loaded": true,
|
"loaded": true,
|
||||||
"filename": result.Filename,
|
"filename": result.Filename,
|
||||||
"vendor": s.GetDetectedVendor(),
|
"vendor": s.GetDetectedVendor(),
|
||||||
"source_type": result.SourceType,
|
"source_type": result.SourceType,
|
||||||
"protocol": result.Protocol,
|
"protocol": result.Protocol,
|
||||||
"target_host": result.TargetHost,
|
"target_host": result.TargetHost,
|
||||||
"collected_at": result.CollectedAt,
|
"source_timezone": result.SourceTimezone,
|
||||||
|
"collected_at": result.CollectedAt,
|
||||||
"stats": map[string]int{
|
"stats": map[string]int{
|
||||||
"events": len(result.Events),
|
"events": len(result.Events),
|
||||||
"sensors": len(result.Sensors),
|
"sensors": len(result.Sensors),
|
||||||
@@ -1362,17 +1401,14 @@ func (s *Server) handleConvertDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isSupportedConvertFileName(filename string) bool {
|
func isSupportedConvertFileName(filename string) bool {
|
||||||
name := strings.ToLower(strings.TrimSpace(filename))
|
name := strings.TrimSpace(filename)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.HasSuffix(name, ".zip") ||
|
if strings.HasSuffix(strings.ToLower(name), ".json") {
|
||||||
strings.HasSuffix(name, ".tar") ||
|
return true
|
||||||
strings.HasSuffix(name, ".tar.gz") ||
|
}
|
||||||
strings.HasSuffix(name, ".tgz") ||
|
return parser.IsSupportedArchiveFilename(name)
|
||||||
strings.HasSuffix(name, ".json") ||
|
|
||||||
strings.HasSuffix(name, ".txt") ||
|
|
||||||
strings.HasSuffix(name, ".log")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeZipPath(filename string) string {
|
func sanitizeZipPath(filename string) string {
|
||||||
@@ -1599,17 +1635,101 @@ func inferArchiveCollectedAt(result *models.AnalysisResult) time.Time {
|
|||||||
return time.Now().UTC()
|
return time.Now().UTC()
|
||||||
}
|
}
|
||||||
|
|
||||||
var latest time.Time
|
var latestReliable time.Time
|
||||||
|
var latestAny time.Time
|
||||||
for _, event := range result.Events {
|
for _, event := range result.Events {
|
||||||
if event.Timestamp.IsZero() {
|
if event.Timestamp.IsZero() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if latest.IsZero() || event.Timestamp.After(latest) {
|
// Drop obviously bad epochs from broken RTC logs.
|
||||||
latest = event.Timestamp
|
if event.Timestamp.Year() < 2000 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if latestAny.IsZero() || event.Timestamp.After(latestAny) {
|
||||||
|
latestAny = event.Timestamp
|
||||||
|
}
|
||||||
|
if !isReliableCollectedAtEvent(event) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if latestReliable.IsZero() || event.Timestamp.After(latestReliable) {
|
||||||
|
latestReliable = event.Timestamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !latest.IsZero() {
|
if !latestReliable.IsZero() {
|
||||||
return latest.UTC()
|
return latestReliable.UTC()
|
||||||
|
}
|
||||||
|
if !latestAny.IsZero() {
|
||||||
|
return latestAny.UTC()
|
||||||
|
}
|
||||||
|
if fromFilename, ok := inferCollectedAtFromFilename(result.Filename); ok {
|
||||||
|
return fromFilename.UTC()
|
||||||
|
}
|
||||||
|
return time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isReliableCollectedAtEvent(event models.Event) bool {
|
||||||
|
// component.log-derived synthetic states are created "at parse time"
|
||||||
|
// and must not override real log timestamps.
|
||||||
|
src := strings.ToLower(strings.TrimSpace(event.Source))
|
||||||
|
etype := strings.ToLower(strings.TrimSpace(event.EventType))
|
||||||
|
stype := strings.ToLower(strings.TrimSpace(event.SensorType))
|
||||||
|
if etype == "fan status" && (src == "fan" || stype == "fan") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if etype == "memory status" && (src == "memory" || stype == "memory") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
filenameDateTimeRegex = regexp.MustCompile(`(?i)(\d{8})[-_](\d{4})(\d{2})?`)
|
||||||
|
filenameDateRegex = regexp.MustCompile(`(?i)(\d{4})-(\d{2})-(\d{2})`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func inferCollectedAtFromFilename(name string) (time.Time, bool) {
|
||||||
|
base := strings.TrimSpace(filepath.Base(name))
|
||||||
|
if base == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := filenameDateTimeRegex.FindStringSubmatch(base); len(m) == 4 {
|
||||||
|
datePart := m[1]
|
||||||
|
timePart := m[2]
|
||||||
|
if strings.TrimSpace(m[3]) != "" {
|
||||||
|
timePart += m[3]
|
||||||
|
} else {
|
||||||
|
timePart += "00"
|
||||||
|
}
|
||||||
|
if ts, err := parser.ParseInDefaultArchiveLocation("20060102 150405", datePart+" "+timePart); err == nil {
|
||||||
|
return ts, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := filenameDateRegex.FindStringSubmatch(base); len(m) == 4 {
|
||||||
|
datePart := m[1] + "-" + m[2] + "-" + m[3]
|
||||||
|
if ts, err := parser.ParseInDefaultArchiveLocation("2006-01-02 15:04:05", datePart+" 00:00:00"); err == nil {
|
||||||
|
return ts, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferRawExportCollectedAt(result *models.AnalysisResult, pkg *RawExportPackage) time.Time {
|
||||||
|
if result != nil && !result.CollectedAt.IsZero() {
|
||||||
|
return result.CollectedAt.UTC()
|
||||||
|
}
|
||||||
|
if pkg != nil {
|
||||||
|
if !pkg.CollectedAtHint.IsZero() {
|
||||||
|
return pkg.CollectedAtHint.UTC()
|
||||||
|
}
|
||||||
|
if _, finishedAt, ok := collectLogTimeBounds(pkg.Source.CollectLogs); ok {
|
||||||
|
return finishedAt.UTC()
|
||||||
|
}
|
||||||
|
if !pkg.ExportedAt.IsZero() {
|
||||||
|
return pkg.ExportedAt.UTC()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return time.Now().UTC()
|
return time.Now().UTC()
|
||||||
}
|
}
|
||||||
@@ -1621,7 +1741,14 @@ func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectReques
|
|||||||
result.SourceType = models.SourceTypeAPI
|
result.SourceType = models.SourceTypeAPI
|
||||||
result.Protocol = req.Protocol
|
result.Protocol = req.Protocol
|
||||||
result.TargetHost = req.Host
|
result.TargetHost = req.Host
|
||||||
result.CollectedAt = time.Now().UTC()
|
if strings.TrimSpace(result.SourceTimezone) == "" && result.RawPayloads != nil {
|
||||||
|
if tz, ok := result.RawPayloads["source_timezone"].(string); ok {
|
||||||
|
result.SourceTimezone = strings.TrimSpace(tz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.CollectedAt.IsZero() {
|
||||||
|
result.CollectedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
if strings.TrimSpace(result.Filename) == "" {
|
if strings.TrimSpace(result.Filename) == "" {
|
||||||
result.Filename = fmt.Sprintf("%s://%s", req.Protocol, req.Host)
|
result.Filename = fmt.Sprintf("%s://%s", req.Protocol, req.Host)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type RawExportPackage struct {
|
|||||||
ExportedAt time.Time `json:"exported_at"`
|
ExportedAt time.Time `json:"exported_at"`
|
||||||
Source RawExportSource `json:"source"`
|
Source RawExportSource `json:"source"`
|
||||||
Analysis *models.AnalysisResult `json:"analysis_result,omitempty"`
|
Analysis *models.AnalysisResult `json:"analysis_result,omitempty"`
|
||||||
|
// CollectedAtHint is extracted from parser_fields.json when importing
|
||||||
|
// a raw-export bundle and represents original collection time.
|
||||||
|
CollectedAtHint time.Time `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RawExportSource struct {
|
type RawExportSource struct {
|
||||||
@@ -36,6 +39,7 @@ type RawExportSource struct {
|
|||||||
Data string `json:"data,omitempty"`
|
Data string `json:"data,omitempty"`
|
||||||
Protocol string `json:"protocol,omitempty"`
|
Protocol string `json:"protocol,omitempty"`
|
||||||
TargetHost string `json:"target_host,omitempty"`
|
TargetHost string `json:"target_host,omitempty"`
|
||||||
|
SourceTimezone string `json:"source_timezone,omitempty"`
|
||||||
RawPayloads map[string]any `json:"raw_payloads,omitempty"`
|
RawPayloads map[string]any `json:"raw_payloads,omitempty"`
|
||||||
CollectLogs []string `json:"collect_logs,omitempty"`
|
CollectLogs []string `json:"collect_logs,omitempty"`
|
||||||
CollectMeta *CollectRequestMeta `json:"collect_meta,omitempty"`
|
CollectMeta *CollectRequestMeta `json:"collect_meta,omitempty"`
|
||||||
@@ -53,6 +57,7 @@ func newRawExportFromUploadedFile(filename, mimeType string, payload []byte, res
|
|||||||
Data: base64.StdEncoding.EncodeToString(payload),
|
Data: base64.StdEncoding.EncodeToString(payload),
|
||||||
Protocol: resultProtocol(result),
|
Protocol: resultProtocol(result),
|
||||||
TargetHost: resultTargetHost(result),
|
TargetHost: resultTargetHost(result),
|
||||||
|
SourceTimezone: resultSourceTimezone(result),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,6 +84,7 @@ func newRawExportFromLiveCollect(result *models.AnalysisResult, req CollectReque
|
|||||||
Kind: "live_redfish",
|
Kind: "live_redfish",
|
||||||
Protocol: req.Protocol,
|
Protocol: req.Protocol,
|
||||||
TargetHost: req.Host,
|
TargetHost: req.Host,
|
||||||
|
SourceTimezone: resultSourceTimezone(result),
|
||||||
RawPayloads: rawPayloads,
|
RawPayloads: rawPayloads,
|
||||||
CollectLogs: append([]string(nil), logs...),
|
CollectLogs: append([]string(nil), logs...),
|
||||||
CollectMeta: &meta,
|
CollectMeta: &meta,
|
||||||
@@ -158,23 +164,62 @@ func parseRawExportBundle(payload []byte) (*RawExportPackage, bool, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
var pkgBody []byte
|
||||||
|
var parserFieldsBody []byte
|
||||||
|
|
||||||
for _, f := range zr.File {
|
for _, f := range zr.File {
|
||||||
if f.Name != rawExportBundlePackageFile {
|
if f.Name != rawExportBundlePackageFile && f.Name != rawExportBundleFieldsFile {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rc, err := f.Open()
|
rc, err := f.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return nil, true, err
|
||||||
}
|
}
|
||||||
defer rc.Close()
|
|
||||||
body, err := io.ReadAll(rc)
|
body, err := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return nil, true, err
|
||||||
}
|
}
|
||||||
pkg, ok, err := parseRawExportPackage(body)
|
switch f.Name {
|
||||||
|
case rawExportBundlePackageFile:
|
||||||
|
pkgBody = body
|
||||||
|
case rawExportBundleFieldsFile:
|
||||||
|
parserFieldsBody = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pkgBody) == 0 {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
pkg, ok, err := parseRawExportPackage(pkgBody)
|
||||||
|
if err != nil || !ok {
|
||||||
return pkg, ok, err
|
return pkg, ok, err
|
||||||
}
|
}
|
||||||
return nil, false, nil
|
if ts, ok := parseCollectedAtHint(parserFieldsBody); ok {
|
||||||
|
pkg.CollectedAtHint = ts.UTC()
|
||||||
|
}
|
||||||
|
return pkg, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCollectedAtHint(parserFieldsBody []byte) (time.Time, bool) {
|
||||||
|
if len(parserFieldsBody) == 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
CollectedAt string `json:"collected_at"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(parserFieldsBody, &payload); err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
collectedAt := strings.TrimSpace(payload.CollectedAt)
|
||||||
|
if collectedAt == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
ts, err := time.Parse(time.RFC3339Nano, collectedAt)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return ts, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildHumanReadableCollectionLog(pkg *RawExportPackage, result *models.AnalysisResult, clientVersion string) string {
|
func buildHumanReadableCollectionLog(pkg *RawExportPackage, result *models.AnalysisResult, clientVersion string) string {
|
||||||
@@ -333,6 +378,7 @@ func buildParserFieldSummary(result *models.AnalysisResult) map[string]any {
|
|||||||
out["source_type"] = result.SourceType
|
out["source_type"] = result.SourceType
|
||||||
out["protocol"] = result.Protocol
|
out["protocol"] = result.Protocol
|
||||||
out["target_host"] = result.TargetHost
|
out["target_host"] = result.TargetHost
|
||||||
|
out["source_timezone"] = result.SourceTimezone
|
||||||
out["collected_at"] = result.CollectedAt
|
out["collected_at"] = result.CollectedAt
|
||||||
|
|
||||||
if result.Hardware == nil {
|
if result.Hardware == nil {
|
||||||
@@ -382,3 +428,10 @@ func resultTargetHost(result *models.AnalysisResult) string {
|
|||||||
}
|
}
|
||||||
return result.TargetHost
|
return result.TargetHost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resultSourceTimezone(result *models.AnalysisResult) string {
|
||||||
|
if result == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(result.SourceTimezone)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCollectLogTimeBounds(t *testing.T) {
|
func TestCollectLogTimeBounds(t *testing.T) {
|
||||||
@@ -44,3 +48,54 @@ func TestBuildHumanReadableCollectionLog_IncludesDurationHeader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseRawExportBundle_ExtractsCollectedAtHintFromParserFields(t *testing.T) {
|
||||||
|
pkg := &RawExportPackage{
|
||||||
|
Format: rawExportFormatV1,
|
||||||
|
ExportedAt: time.Date(2026, 2, 25, 9, 59, 41, 479023400, time.UTC),
|
||||||
|
Source: RawExportSource{
|
||||||
|
Kind: "live_redfish",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
pkgJSON, err := json.Marshal(pkg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal pkg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parserFields := []byte(`{"collected_at":"2026-02-25T09:58:05.9129753Z"}`)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&buf)
|
||||||
|
|
||||||
|
jf, err := zw.Create(rawExportBundlePackageFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create package file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := jf.Write(pkgJSON); err != nil {
|
||||||
|
t.Fatalf("write package file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ff, err := zw.Create(rawExportBundleFieldsFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create parser fields file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ff.Write(parserFields); err != nil {
|
||||||
|
t.Fatalf("write parser fields file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatalf("close zip writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotPkg, ok, err := parseRawExportBundle(buf.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse bundle: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || gotPkg == nil {
|
||||||
|
t.Fatalf("expected valid raw export bundle")
|
||||||
|
}
|
||||||
|
want := time.Date(2026, 2, 25, 9, 58, 5, 912975300, time.UTC)
|
||||||
|
if !gotPkg.CollectedAtHint.Equal(want) {
|
||||||
|
t.Fatalf("expected collected_at hint %s, got %s", want, gotPkg.CollectedAtHint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ func (s *Server) setupRoutes() {
|
|||||||
s.mux.HandleFunc("POST /api/upload", s.handleUpload)
|
s.mux.HandleFunc("POST /api/upload", s.handleUpload)
|
||||||
s.mux.HandleFunc("GET /api/status", s.handleGetStatus)
|
s.mux.HandleFunc("GET /api/status", s.handleGetStatus)
|
||||||
s.mux.HandleFunc("GET /api/parsers", s.handleGetParsers)
|
s.mux.HandleFunc("GET /api/parsers", s.handleGetParsers)
|
||||||
|
s.mux.HandleFunc("GET /api/file-types", s.handleGetFileTypes)
|
||||||
s.mux.HandleFunc("GET /api/events", s.handleGetEvents)
|
s.mux.HandleFunc("GET /api/events", s.handleGetEvents)
|
||||||
s.mux.HandleFunc("GET /api/sensors", s.handleGetSensors)
|
s.mux.HandleFunc("GET /api/sensors", s.handleGetSensors)
|
||||||
s.mux.HandleFunc("GET /api/config", s.handleGetConfig)
|
s.mux.HandleFunc("GET /api/config", s.handleGetConfig)
|
||||||
|
|||||||
@@ -60,6 +60,108 @@ func TestApplyArchiveSourceMetadata_InferCollectedAtFromEvents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyArchiveSourceMetadata_InferCollectedAtFromFilename(t *testing.T) {
|
||||||
|
result := &models.AnalysisResult{
|
||||||
|
Filename: "dump_23E100203_20260228-0428.tar.gz",
|
||||||
|
}
|
||||||
|
|
||||||
|
applyArchiveSourceMetadata(result)
|
||||||
|
|
||||||
|
// 2026-02-28 04:28 in Europe/Moscow => 2026-02-28 01:28 UTC
|
||||||
|
want := time.Date(2026, 2, 28, 1, 28, 0, 0, time.UTC)
|
||||||
|
if !result.CollectedAt.Equal(want) {
|
||||||
|
t.Fatalf("expected collected_at from filename: got %s want %s", result.CollectedAt, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyArchiveSourceMetadata_IgnoresSyntheticComponentNowEvents(t *testing.T) {
|
||||||
|
realTs := time.Date(2026, 2, 28, 4, 18, 18, 217225000, time.FixedZone("UTC+8", 8*3600))
|
||||||
|
syntheticNow := time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC)
|
||||||
|
result := &models.AnalysisResult{
|
||||||
|
Events: []models.Event{
|
||||||
|
{
|
||||||
|
Timestamp: realTs,
|
||||||
|
Source: "spx_restservice_ext",
|
||||||
|
SensorType:"syslog",
|
||||||
|
EventType: "System Log",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Timestamp: syntheticNow,
|
||||||
|
Source: "Fan",
|
||||||
|
SensorType: "fan",
|
||||||
|
EventType: "Fan Status",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyArchiveSourceMetadata(result)
|
||||||
|
|
||||||
|
if !result.CollectedAt.Equal(realTs.UTC()) {
|
||||||
|
t.Fatalf("expected collected_at from real log timestamp: got %s want %s", result.CollectedAt, realTs.UTC())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInferRawExportCollectedAt_PrefersResultCollectedAt(t *testing.T) {
|
||||||
|
expected := time.Date(2026, 2, 25, 8, 0, 0, 0, time.UTC)
|
||||||
|
result := &models.AnalysisResult{CollectedAt: expected}
|
||||||
|
pkg := &RawExportPackage{
|
||||||
|
ExportedAt: time.Date(2026, 2, 25, 9, 59, 41, 0, time.UTC),
|
||||||
|
Source: RawExportSource{
|
||||||
|
CollectLogs: []string{
|
||||||
|
"2026-02-25T09:00:00Z step1",
|
||||||
|
"2026-02-25T09:10:00Z step2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := inferRawExportCollectedAt(result, pkg)
|
||||||
|
if !got.Equal(expected) {
|
||||||
|
t.Fatalf("expected collected_at from result: got %s want %s", got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInferRawExportCollectedAt_UsesCollectLogsThenExportedAt(t *testing.T) {
|
||||||
|
hintTs := time.Date(2026, 2, 25, 9, 58, 5, 912975300, time.UTC)
|
||||||
|
pkgWithLogs := &RawExportPackage{
|
||||||
|
ExportedAt: time.Date(2026, 2, 25, 9, 59, 41, 0, time.UTC),
|
||||||
|
CollectedAtHint: hintTs,
|
||||||
|
Source: RawExportSource{
|
||||||
|
CollectLogs: []string{
|
||||||
|
"2026-02-25T09:10:13.7442032Z started",
|
||||||
|
"2026-02-25T09:31:00.5077486Z finished",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := inferRawExportCollectedAt(&models.AnalysisResult{}, pkgWithLogs)
|
||||||
|
if !got.Equal(hintTs) {
|
||||||
|
t.Fatalf("expected collected_at from parser_fields hint: got %s want %s", got, hintTs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgFromLogs := &RawExportPackage{
|
||||||
|
ExportedAt: time.Date(2026, 2, 25, 9, 59, 41, 0, time.UTC),
|
||||||
|
Source: RawExportSource{
|
||||||
|
CollectLogs: []string{
|
||||||
|
"2026-02-25T09:10:13.7442032Z started",
|
||||||
|
"2026-02-25T09:31:00.5077486Z finished",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got = inferRawExportCollectedAt(&models.AnalysisResult{}, pkgFromLogs)
|
||||||
|
wantFromLogs := time.Date(2026, 2, 25, 9, 31, 0, 507748600, time.UTC)
|
||||||
|
if !got.Equal(wantFromLogs) {
|
||||||
|
t.Fatalf("expected collected_at from collect logs: got %s want %s", got, wantFromLogs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgWithoutLogs := &RawExportPackage{
|
||||||
|
ExportedAt: time.Date(2026, 2, 25, 9, 59, 41, 479023400, time.UTC),
|
||||||
|
}
|
||||||
|
got = inferRawExportCollectedAt(&models.AnalysisResult{}, pkgWithoutLogs)
|
||||||
|
wantFromExportedAt := time.Date(2026, 2, 25, 9, 59, 41, 479023400, time.UTC)
|
||||||
|
if !got.Equal(wantFromExportedAt) {
|
||||||
|
t.Fatalf("expected collected_at from exported_at: got %s want %s", got, wantFromExportedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyCollectSourceMetadata(t *testing.T) {
|
func TestApplyCollectSourceMetadata(t *testing.T) {
|
||||||
req := CollectRequest{
|
req := CollectRequest{
|
||||||
Host: "bmc-api.local",
|
Host: "bmc-api.local",
|
||||||
@@ -106,6 +208,32 @@ func TestApplyCollectSourceMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyCollectSourceMetadata_PreservesCollectedAtAndTimezone(t *testing.T) {
|
||||||
|
req := CollectRequest{
|
||||||
|
Host: "bmc-api.local",
|
||||||
|
Protocol: "redfish",
|
||||||
|
Port: 443,
|
||||||
|
Username: "admin",
|
||||||
|
AuthType: "password",
|
||||||
|
Password: "super-secret",
|
||||||
|
TLSMode: "strict",
|
||||||
|
}
|
||||||
|
collectedAt := time.Date(2026, 2, 28, 4, 18, 18, 0, time.FixedZone("UTC+8", 8*3600))
|
||||||
|
result := &models.AnalysisResult{
|
||||||
|
CollectedAt: collectedAt,
|
||||||
|
SourceTimezone: "+08:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
applyCollectSourceMetadata(result, req)
|
||||||
|
|
||||||
|
if !result.CollectedAt.Equal(collectedAt) {
|
||||||
|
t.Fatalf("expected collected_at to be preserved: got %s want %s", result.CollectedAt, collectedAt)
|
||||||
|
}
|
||||||
|
if result.SourceTimezone != "+08:00" {
|
||||||
|
t.Fatalf("expected source_timezone to be preserved, got %q", result.SourceTimezone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStatusAndConfigExposeSourceMetadata(t *testing.T) {
|
func TestStatusAndConfigExposeSourceMetadata(t *testing.T) {
|
||||||
s := &Server{}
|
s := &Server{}
|
||||||
s.SetDetectedVendor("nvidia")
|
s.SetDetectedVendor("nvidia")
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
initTabs();
|
initTabs();
|
||||||
initFilters();
|
initFilters();
|
||||||
loadParsersInfo();
|
loadParsersInfo();
|
||||||
|
loadSupportedFileTypes();
|
||||||
});
|
});
|
||||||
|
|
||||||
let sourceType = 'archive';
|
let sourceType = 'archive';
|
||||||
let convertFiles = [];
|
let convertFiles = [];
|
||||||
let isConvertRunning = false;
|
let isConvertRunning = false;
|
||||||
|
let supportedUploadExtensions = null;
|
||||||
|
let supportedConvertExtensions = null;
|
||||||
let apiConnectPayload = null;
|
let apiConnectPayload = null;
|
||||||
let collectionJob = null;
|
let collectionJob = null;
|
||||||
let collectionJobPollTimer = null;
|
let collectionJobPollTimer = null;
|
||||||
@@ -642,8 +645,9 @@ function renderConvertSummary() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const supportedFiles = convertFiles.filter(file => isSupportedConvertFileName(file.webkitRelativePath || file.name));
|
const selectedFiles = convertFiles.filter(file => file && file.name);
|
||||||
const skippedCount = convertFiles.length - supportedFiles.length;
|
const supportedFiles = selectedFiles.filter(file => isSupportedConvertFileName(file.webkitRelativePath || file.name));
|
||||||
|
const skippedCount = selectedFiles.length - supportedFiles.length;
|
||||||
const previewCount = 5;
|
const previewCount = 5;
|
||||||
const previewFiles = supportedFiles.slice(0, previewCount).map(file => escapeHtml(file.webkitRelativePath || file.name));
|
const previewFiles = supportedFiles.slice(0, previewCount).map(file => escapeHtml(file.webkitRelativePath || file.name));
|
||||||
const remaining = supportedFiles.length - previewFiles.length;
|
const remaining = supportedFiles.length - previewFiles.length;
|
||||||
@@ -664,7 +668,8 @@ async function runConvertBatch() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const supportedFiles = convertFiles.filter(file => isSupportedConvertFileName(file.webkitRelativePath || file.name));
|
const selectedFiles = convertFiles.filter(file => file && file.name);
|
||||||
|
const supportedFiles = selectedFiles.filter(file => isSupportedConvertFileName(file.webkitRelativePath || file.name));
|
||||||
if (supportedFiles.length === 0) {
|
if (supportedFiles.length === 0) {
|
||||||
renderConvertStatus('В выбранной папке нет файлов поддерживаемого типа', 'error');
|
renderConvertStatus('В выбранной папке нет файлов поддерживаемого типа', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -835,15 +840,42 @@ function isSupportedConvertFileName(filename) {
|
|||||||
if (!name) {
|
if (!name) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (
|
if (Array.isArray(supportedConvertExtensions) && supportedConvertExtensions.length > 0) {
|
||||||
name.endsWith('.zip') ||
|
return supportedConvertExtensions.some(ext => name.endsWith(ext));
|
||||||
name.endsWith('.tar') ||
|
}
|
||||||
name.endsWith('.tar.gz') ||
|
return true;
|
||||||
name.endsWith('.tgz') ||
|
}
|
||||||
name.endsWith('.json') ||
|
|
||||||
name.endsWith('.txt') ||
|
async function loadSupportedFileTypes() {
|
||||||
name.endsWith('.log')
|
try {
|
||||||
);
|
const response = await fetch('/api/file-types');
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.upload_extensions)) {
|
||||||
|
supportedUploadExtensions = payload.upload_extensions
|
||||||
|
.map(ext => String(ext || '').trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.convert_extensions)) {
|
||||||
|
supportedConvertExtensions = payload.convert_extensions
|
||||||
|
.map(ext => String(ext || '').trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
applyUploadAcceptExtensions();
|
||||||
|
renderConvertSummary();
|
||||||
|
} catch (err) {
|
||||||
|
// Keep permissive fallback if endpoint is temporarily unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyUploadAcceptExtensions() {
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
if (!fileInput || !Array.isArray(supportedUploadExtensions) || supportedUploadExtensions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileInput.setAttribute('accept', supportedUploadExtensions.join(','));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderConvertStatus(message, status) {
|
function renderConvertStatus(message, status) {
|
||||||
|
|||||||
Reference in New Issue
Block a user