feat(parser): lenovo xcc vroc volume parsing - v1.2
Parse inventory_volume.log: Intel VROC (VMD) RAID volumes including RAID level, capacity (GiB/TiB support added), status and member drives. Add Drives []string to StorageVolume model. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1180,3 +1180,21 @@ collector architecture.
|
|||||||
- The codebase avoids introducing a second generic live-ingest/replay contract for IPMI data.
|
- The codebase avoids introducing a second generic live-ingest/replay contract for IPMI data.
|
||||||
- Future IPMI work must be justified by concrete Redfish gaps on real hardware, not by protocol
|
- Future IPMI work must be justified by concrete Redfish gaps on real hardware, not by protocol
|
||||||
symmetry alone.
|
symmetry alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADL-046 — The web shell delegates report rendering to `internal/chart`
|
||||||
|
|
||||||
|
**Date:** 2026-04-22
|
||||||
|
**Context:** The frontend had two competing report paths: the embedded `internal/chart` viewer and
|
||||||
|
an older client-side renderer in `web/static/js/app.js` for config, firmware, sensors, serials,
|
||||||
|
events, and parse errors. That duplication left dead controls in the shell and made the report
|
||||||
|
source of truth ambiguous.
|
||||||
|
**Decision:** The `web/` frontend shell is responsible only for data intake, job control, and
|
||||||
|
top-level actions. The report itself must be rendered exclusively through `internal/chart`.
|
||||||
|
Do not keep parallel report sections, filters, or table renderers in shell JavaScript.
|
||||||
|
**Consequences:**
|
||||||
|
- The browser UI has a single report rendering path: `/chart/current` inside the embedded viewer.
|
||||||
|
- Report-level filtering or extra report sections must be implemented in `internal/chart`, not in
|
||||||
|
`web/static/js/app.js`.
|
||||||
|
- Removing legacy DOM renderers from the shell is a correctness fix, not a behavior regression.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
@@ -38,10 +39,11 @@ func main() {
|
|||||||
server.WebFS = web.FS
|
server.WebFS = web.FS
|
||||||
|
|
||||||
cfg := server.Config{
|
cfg := server.Config{
|
||||||
Port: *port,
|
Port: *port,
|
||||||
PreloadFile: *file,
|
PreloadFile: *file,
|
||||||
AppVersion: version,
|
AppVersion: version,
|
||||||
AppCommit: commit,
|
AppCommit: commit,
|
||||||
|
ChartVersion: detectChartVersion(),
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := server.New(cfg)
|
srv := server.New(cfg)
|
||||||
@@ -92,6 +94,15 @@ func openBrowser(url string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detectChartVersion() string {
|
||||||
|
cmd := exec.Command("git", "-C", "internal/chart", "describe", "--tags", "--always", "--dirty", "--abbrev=7")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
func maybeWaitForCrashInput(enabled bool) {
|
func maybeWaitForCrashInput(enabled bool) {
|
||||||
if !enabled || !isInteractiveConsole() {
|
if !enabled || !isInteractiveConsole() {
|
||||||
return
|
return
|
||||||
|
|||||||
Submodule internal/chart updated: 2fb01d30a6...f6517987b3
@@ -257,15 +257,16 @@ type Storage struct {
|
|||||||
|
|
||||||
// StorageVolume represents a logical storage volume (RAID/VROC/etc.).
|
// StorageVolume represents a logical storage volume (RAID/VROC/etc.).
|
||||||
type StorageVolume struct {
|
type StorageVolume struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Controller string `json:"controller,omitempty"`
|
Controller string `json:"controller,omitempty"`
|
||||||
RAIDLevel string `json:"raid_level,omitempty"`
|
RAIDLevel string `json:"raid_level,omitempty"`
|
||||||
SizeGB int `json:"size_gb,omitempty"`
|
SizeGB int `json:"size_gb,omitempty"`
|
||||||
CapacityBytes int64 `json:"capacity_bytes,omitempty"`
|
CapacityBytes int64 `json:"capacity_bytes,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Bootable bool `json:"bootable,omitempty"`
|
Bootable bool `json:"bootable,omitempty"`
|
||||||
Encrypted bool `json:"encrypted,omitempty"`
|
Encrypted bool `json:"encrypted,omitempty"`
|
||||||
|
Drives []string `json:"drives,omitempty"` // member drive names/labels
|
||||||
}
|
}
|
||||||
|
|
||||||
// PCIeDevice represents a PCIe device
|
// PCIeDevice represents a PCIe device
|
||||||
|
|||||||
151
internal/parser/vendors/lenovo_xcc/parser.go
vendored
151
internal/parser/vendors/lenovo_xcc/parser.go
vendored
@@ -9,6 +9,7 @@ package lenovo_xcc
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,7 +18,7 @@ import (
|
|||||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
)
|
)
|
||||||
|
|
||||||
const parserVersion = "1.1"
|
const parserVersion = "1.2"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
parser.Register(&Parser{})
|
parser.Register(&Parser{})
|
||||||
@@ -88,6 +89,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
if f := findByPath(files, "tmp/inventory_disk.log"); f != nil {
|
if f := findByPath(files, "tmp/inventory_disk.log"); f != nil {
|
||||||
result.Hardware.Storage = parseDisks(f.Content)
|
result.Hardware.Storage = parseDisks(f.Content)
|
||||||
}
|
}
|
||||||
|
if f := findByPath(files, "tmp/inventory_volume.log"); f != nil {
|
||||||
|
result.Hardware.Volumes = parseVolumes(f.Content)
|
||||||
|
}
|
||||||
if f := findByPath(files, "tmp/inventory_card.log"); f != nil {
|
if f := findByPath(files, "tmp/inventory_card.log"); f != nil {
|
||||||
result.Hardware.PCIeDevices = parseCards(f.Content)
|
result.Hardware.PCIeDevices = parseCards(f.Content)
|
||||||
}
|
}
|
||||||
@@ -103,6 +107,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
for _, f := range findEventFiles(files) {
|
for _, f := range findEventFiles(files) {
|
||||||
result.Events = append(result.Events, parseEvents(f.Content)...)
|
result.Events = append(result.Events, parseEvents(f.Content)...)
|
||||||
}
|
}
|
||||||
|
applyDIMMWarningsFromEvents(result)
|
||||||
|
|
||||||
result.Protocol = "ipmi"
|
result.Protocol = "ipmi"
|
||||||
result.SourceType = models.SourceTypeArchive
|
result.SourceType = models.SourceTypeArchive
|
||||||
@@ -297,6 +302,25 @@ type xccEventDoc struct {
|
|||||||
Items []xccEvent `json:"items"`
|
Items []xccEvent `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type xccVolumeDoc struct {
|
||||||
|
Items []xccVolumeItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccVolumeItem struct {
|
||||||
|
Volumes []xccVolume `json:"volumes"`
|
||||||
|
TotalCapacityStr string `json:"totalCapacityStr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccVolume struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Drives string `json:"drives"` // e.g. "M.2 Drive 0, M.2 Drive 1"
|
||||||
|
RDLvlStr string `json:"rdlvlstr"` // e.g. "RAID 1"
|
||||||
|
CapacityStr string `json:"capacityStr"` // e.g. "893.750 GiB"
|
||||||
|
Status int `json:"status"`
|
||||||
|
StatusStr string `json:"statusStr"` // e.g. "Optimal"
|
||||||
|
}
|
||||||
|
|
||||||
type xccEvent struct {
|
type xccEvent struct {
|
||||||
Severity string `json:"severity"` // "I", "W", "E", "C"
|
Severity string `json:"severity"` // "I", "W", "E", "C"
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
@@ -462,6 +486,37 @@ func parseDisks(content []byte) []models.Storage {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseVolumes(content []byte) []models.StorageVolume {
|
||||||
|
var doc xccVolumeDoc
|
||||||
|
if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []models.StorageVolume
|
||||||
|
for _, item := range doc.Items {
|
||||||
|
for _, v := range item.Volumes {
|
||||||
|
vol := models.StorageVolume{
|
||||||
|
ID: fmt.Sprintf("%d", v.ID),
|
||||||
|
Name: strings.TrimSpace(v.Name),
|
||||||
|
RAIDLevel: strings.TrimSpace(v.RDLvlStr),
|
||||||
|
SizeGB: parseCapacityToGB(v.CapacityStr),
|
||||||
|
Status: strings.TrimSpace(v.StatusStr),
|
||||||
|
}
|
||||||
|
drives := strings.TrimSpace(v.Drives)
|
||||||
|
if drives != "" {
|
||||||
|
for _, d := range strings.Split(drives, ",") {
|
||||||
|
vol.Drives = append(vol.Drives, strings.TrimSpace(d))
|
||||||
|
}
|
||||||
|
// M.2 NVMe volumes are managed by Intel VROC (VMD)
|
||||||
|
if strings.Contains(strings.ToLower(drives), "m.2") {
|
||||||
|
vol.Controller = "Intel VROC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, vol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func parseCards(content []byte) []models.PCIeDevice {
|
func parseCards(content []byte) []models.PCIeDevice {
|
||||||
var doc xccCardDoc
|
var doc xccCardDoc
|
||||||
if err := json.Unmarshal(content, &doc); err != nil {
|
if err := json.Unmarshal(content, &doc); err != nil {
|
||||||
@@ -613,6 +668,96 @@ func isUnqualifiedDIMM(value string) bool {
|
|||||||
return strings.Contains(strings.ToLower(strings.TrimSpace(value)), "unqualified dimm")
|
return strings.Contains(strings.ToLower(strings.TrimSpace(value)), "unqualified dimm")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
unqualifiedDIMMSlotRE = regexp.MustCompile(`(?i)\bunqualified dimm\s+(\d+)\b`)
|
||||||
|
unqualifiedDIMMSerialRE = regexp.MustCompile(`(?i)\bserial number is\s+([A-Z0-9-]+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func applyDIMMWarningsFromEvents(result *models.AnalysisResult) {
|
||||||
|
if result == nil || result.Hardware == nil || len(result.Hardware.Memory) == 0 || len(result.Events) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ev := range result.Events {
|
||||||
|
if !isUnqualifiedDIMM(ev.Description) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := findDIMMIndexForUnqualifiedEvent(result.Hardware.Memory, ev.Description)
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dimm := &result.Hardware.Memory[idx]
|
||||||
|
dimm.Status = "Warning"
|
||||||
|
dimm.ErrorDescription = ev.Description
|
||||||
|
if !ev.Timestamp.IsZero() {
|
||||||
|
ts := ev.Timestamp.UTC()
|
||||||
|
dimm.StatusChangedAt = &ts
|
||||||
|
dimm.StatusCheckedAt = &ts
|
||||||
|
}
|
||||||
|
appendDIMMStatusHistory(dimm, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDIMMIndexForUnqualifiedEvent(memory []models.MemoryDIMM, description string) int {
|
||||||
|
slot := extractUnqualifiedDIMMSlot(description)
|
||||||
|
serial := normalizeUnqualifiedDIMMSerial(extractUnqualifiedDIMMSerial(description))
|
||||||
|
|
||||||
|
for i := range memory {
|
||||||
|
if slot != "" && strings.EqualFold(strings.TrimSpace(memory[i].Slot), slot) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range memory {
|
||||||
|
if serial != "" && normalizeUnqualifiedDIMMSerial(memory[i].SerialNumber) == serial {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractUnqualifiedDIMMSlot(description string) string {
|
||||||
|
m := unqualifiedDIMMSlotRE.FindStringSubmatch(description)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "DIMM " + strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractUnqualifiedDIMMSerial(description string) string {
|
||||||
|
m := unqualifiedDIMMSerialRE.FindStringSubmatch(description)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUnqualifiedDIMMSerial(serial string) string {
|
||||||
|
serial = strings.ToUpper(strings.TrimSpace(serial))
|
||||||
|
if idx := strings.Index(serial, "-"); idx >= 0 {
|
||||||
|
serial = serial[:idx]
|
||||||
|
}
|
||||||
|
return serial
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendDIMMStatusHistory(dimm *models.MemoryDIMM, ev models.Event) {
|
||||||
|
if dimm == nil || ev.Timestamp.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, item := range dimm.StatusHistory {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(item.Status), "Warning") &&
|
||||||
|
item.ChangedAt.Equal(ev.Timestamp.UTC()) &&
|
||||||
|
strings.TrimSpace(item.Details) == strings.TrimSpace(ev.Description) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dimm.StatusHistory = append(dimm.StatusHistory, models.StatusHistoryEntry{
|
||||||
|
Status: "Warning",
|
||||||
|
ChangedAt: ev.Timestamp.UTC(),
|
||||||
|
Details: ev.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func parseXCCTime(s string) (time.Time, error) {
|
func parseXCCTime(s string) (time.Time, error) {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
formats := []string{
|
formats := []string{
|
||||||
@@ -674,8 +819,12 @@ func parseCapacityToGB(s string) int {
|
|||||||
switch strings.ToUpper(parts[1]) {
|
switch strings.ToUpper(parts[1]) {
|
||||||
case "TB":
|
case "TB":
|
||||||
return int(v * 1000)
|
return int(v * 1000)
|
||||||
|
case "TIB":
|
||||||
|
return int(v * 1099.511627776) // 1 TiB = 1099.511... GB
|
||||||
case "GB":
|
case "GB":
|
||||||
return int(v)
|
return int(v)
|
||||||
|
case "GIB":
|
||||||
|
return int(v * 1.073741824) // 1 GiB = 1.073741824 GB
|
||||||
case "MB":
|
case "MB":
|
||||||
return int(v / 1024)
|
return int(v / 1024)
|
||||||
}
|
}
|
||||||
|
|||||||
108
internal/parser/vendors/lenovo_xcc/parser_test.go
vendored
108
internal/parser/vendors/lenovo_xcc/parser_test.go
vendored
@@ -2,6 +2,7 @@ package lenovo_xcc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"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"
|
||||||
@@ -224,6 +225,75 @@ func TestParse_LenovoXCCMiniLog_Firmware(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_VROCVolumes(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Hardware.Volumes) == 0 {
|
||||||
|
t.Error("expected at least one VROC volume, got none")
|
||||||
|
}
|
||||||
|
for i, v := range result.Hardware.Volumes {
|
||||||
|
t.Logf("Volume[%d]: id=%s controller=%q raid=%s size=%dGB status=%s drives=%v",
|
||||||
|
i, v.ID, v.Controller, v.RAIDLevel, v.SizeGB, v.Status, v.Drives)
|
||||||
|
if v.RAIDLevel == "" {
|
||||||
|
t.Errorf("Volume[%d]: RAIDLevel is empty", i)
|
||||||
|
}
|
||||||
|
if v.Status == "" {
|
||||||
|
t.Errorf("Volume[%d]: Status is empty", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVolumes_IntelVROC(t *testing.T) {
|
||||||
|
content := []byte(`{
|
||||||
|
"identifier": "storage.id",
|
||||||
|
"items": [{
|
||||||
|
"volumes": [{
|
||||||
|
"id": 1,
|
||||||
|
"name": "",
|
||||||
|
"drives": "M.2 Drive 0, M.2 Drive 1",
|
||||||
|
"rdlvlstr": "RAID 1",
|
||||||
|
"capacityStr": "893.750 GiB",
|
||||||
|
"status": 3,
|
||||||
|
"statusStr": "Optimal"
|
||||||
|
}],
|
||||||
|
"totalCapacityStr": "893.750 GiB"
|
||||||
|
}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
vols := parseVolumes(content)
|
||||||
|
if len(vols) != 1 {
|
||||||
|
t.Fatalf("expected 1 volume, got %d", len(vols))
|
||||||
|
}
|
||||||
|
v := vols[0]
|
||||||
|
if v.ID != "1" {
|
||||||
|
t.Errorf("expected ID=1, got %q", v.ID)
|
||||||
|
}
|
||||||
|
if v.RAIDLevel != "RAID 1" {
|
||||||
|
t.Errorf("expected RAIDLevel=RAID 1, got %q", v.RAIDLevel)
|
||||||
|
}
|
||||||
|
if v.Status != "Optimal" {
|
||||||
|
t.Errorf("expected Status=Optimal, got %q", v.Status)
|
||||||
|
}
|
||||||
|
if v.Controller != "Intel VROC" {
|
||||||
|
t.Errorf("expected Controller=Intel VROC, got %q", v.Controller)
|
||||||
|
}
|
||||||
|
if len(v.Drives) != 2 {
|
||||||
|
t.Errorf("expected 2 drives, got %d: %v", len(v.Drives), v.Drives)
|
||||||
|
}
|
||||||
|
if v.SizeGB < 900 || v.SizeGB > 1000 {
|
||||||
|
t.Errorf("expected SizeGB ~960, got %d", v.SizeGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseDIMMs_UnqualifiedDIMMAddsWarningEvent(t *testing.T) {
|
func TestParseDIMMs_UnqualifiedDIMMAddsWarningEvent(t *testing.T) {
|
||||||
content := []byte(`{
|
content := []byte(`{
|
||||||
"items": [{
|
"items": [{
|
||||||
@@ -256,3 +326,41 @@ func TestSeverity_UnqualifiedDIMMMessageBecomesWarning(t *testing.T) {
|
|||||||
t.Fatalf("expected warning severity, got %q", got)
|
t.Fatalf("expected warning severity, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyDIMMWarningsFromEvents_UpdatesDIMMStatusForExport(t *testing.T) {
|
||||||
|
result := &models.AnalysisResult{
|
||||||
|
Events: []models.Event{
|
||||||
|
{
|
||||||
|
Timestamp: time.Date(2026, 4, 13, 11, 37, 38, 0, time.UTC),
|
||||||
|
Severity: models.SeverityWarning,
|
||||||
|
Description: "Unqualified DIMM 3 has been detected, the DIMM serial number is 80CE042328460C5D88-V20.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
Memory: []models.MemoryDIMM{
|
||||||
|
{
|
||||||
|
Slot: "DIMM 3",
|
||||||
|
Present: true,
|
||||||
|
SerialNumber: "80CE042328460C5D88",
|
||||||
|
Status: "Normal",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDIMMWarningsFromEvents(result)
|
||||||
|
|
||||||
|
dimm := result.Hardware.Memory[0]
|
||||||
|
if dimm.Status != "Warning" {
|
||||||
|
t.Fatalf("expected DIMM status Warning, got %q", dimm.Status)
|
||||||
|
}
|
||||||
|
if dimm.ErrorDescription == "" || dimm.ErrorDescription != result.Events[0].Description {
|
||||||
|
t.Fatalf("expected DIMM error description to be populated, got %q", dimm.ErrorDescription)
|
||||||
|
}
|
||||||
|
if dimm.StatusChangedAt == nil || !dimm.StatusChangedAt.Equal(result.Events[0].Timestamp) {
|
||||||
|
t.Fatalf("expected status_changed_at from event timestamp, got %#v", dimm.StatusChangedAt)
|
||||||
|
}
|
||||||
|
if len(dimm.StatusHistory) != 1 || dimm.StatusHistory[0].Status != "Warning" {
|
||||||
|
t.Fatalf("expected warning status history entry, got %#v", dimm.StatusHistory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,11 +50,20 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
tmpl.Execute(w, map[string]string{
|
tmpl.Execute(w, map[string]string{
|
||||||
"AppVersion": s.config.AppVersion,
|
"AppVersion": normalizeDisplayVersion(s.config.AppVersion),
|
||||||
"AppCommit": s.config.AppCommit,
|
"AppCommit": s.config.AppCommit,
|
||||||
|
"ChartVersion": normalizeDisplayVersion(s.config.ChartVersion),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeDisplayVersion(v string) string {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(v, "v")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
|
||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
title := chartTitle(result)
|
title := chartTitle(result)
|
||||||
@@ -2045,14 +2054,14 @@ func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectReques
|
|||||||
|
|
||||||
func toCollectorRequest(req CollectRequest) collector.Request {
|
func toCollectorRequest(req CollectRequest) collector.Request {
|
||||||
return collector.Request{
|
return collector.Request{
|
||||||
Host: req.Host,
|
Host: req.Host,
|
||||||
Protocol: req.Protocol,
|
Protocol: req.Protocol,
|
||||||
Port: req.Port,
|
Port: req.Port,
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
AuthType: req.AuthType,
|
AuthType: req.AuthType,
|
||||||
Password: req.Password,
|
Password: req.Password,
|
||||||
Token: req.Token,
|
Token: req.Token,
|
||||||
TLSMode: req.TLSMode,
|
TLSMode: req.TLSMode,
|
||||||
DebugPayloads: req.DebugPayloads,
|
DebugPayloads: req.DebugPayloads,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ import (
|
|||||||
var WebFS embed.FS
|
var WebFS embed.FS
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int
|
Port int
|
||||||
PreloadFile string
|
PreloadFile string
|
||||||
AppVersion string
|
AppVersion string
|
||||||
AppCommit string
|
AppCommit string
|
||||||
|
ChartVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1108
web/static/js/app.js
1108
web/static/js/app.js
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -7,57 +7,63 @@
|
|||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header class="page-header">
|
||||||
<div class="app-header-row">
|
<div class="page-header-brand">
|
||||||
<div class="app-header-brand">
|
<p class="page-eyebrow">Diagnostic Workbench</p>
|
||||||
<h1>LOGPile <span class="header-domain">mchus.pro</span></h1>
|
<h1>LOGPile</h1>
|
||||||
<p>Анализатор диагностических данных BMC/IPMI</p>
|
<p class="page-subtitle">BMC diagnostic data analyzer</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="header-log-meta" class="header-log-meta hidden">
|
<div id="header-log-meta" class="header-log-meta hidden">
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="clear-btn" class="hidden" onclick="clearData()">Очистить данные</button>
|
<button id="clear-btn" class="header-action hidden" onclick="clearData()">Clear Data</button>
|
||||||
<button id="header-raw-btn" class="hidden" onclick="exportData('json')">Export Raw Data</button>
|
<button id="header-raw-btn" class="header-action hidden" onclick="exportData('json')">Raw Data</button>
|
||||||
<button id="header-reanimator-btn" class="hidden" onclick="exportData('reanimator')">Экспорт Reanimator</button>
|
<button id="header-reanimator-btn" class="header-action hidden" onclick="exportData('reanimator')">Reanimator</button>
|
||||||
<button id="restart-btn" onclick="restartApp()">Перезапуск</button>
|
<button id="restart-btn" class="header-action" onclick="restartApp()">Restart</button>
|
||||||
<button id="exit-btn" onclick="exitApp()">Выход</button>
|
<button id="exit-btn" class="header-action" onclick="exitApp()">Exit</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main class="page-main">
|
||||||
<section id="upload-section">
|
<section id="upload-section" class="control-deck">
|
||||||
<div class="source-switch" role="tablist" aria-label="Источник данных">
|
<div class="source-switch" role="tablist" aria-label="Data source">
|
||||||
<button type="button" class="source-switch-btn active" data-source-type="archive">Архив</button>
|
<button type="button" class="source-switch-btn active" data-source-type="archive">Archive</button>
|
||||||
<button type="button" class="source-switch-btn" data-source-type="api">API</button>
|
<button type="button" class="source-switch-btn" data-source-type="api">API</button>
|
||||||
<button type="button" class="source-switch-btn" data-source-type="convert">Convert</button>
|
<button type="button" class="source-switch-btn" data-source-type="convert">Convert</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="archive-source-content">
|
<div id="archive-source-content" class="surface-panel upload-panel">
|
||||||
<div class="upload-area" id="drop-zone">
|
<h2>Open Archive</h2>
|
||||||
<p>Перетащите архив, TXT/LOG или JSON snapshot сюда</p>
|
<p>Upload a support archive, plain log, or raw JSON snapshot to open the hardware report.</p>
|
||||||
|
<div class="upload-area upload-dropzone" id="drop-zone">
|
||||||
<input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip,application/json,text/plain,.ahs,.json,.tar,.tar.gz,.tgz,.sds,.zip,.txt,.log" hidden>
|
<input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip,application/json,text/plain,.ahs,.json,.tar,.tar.gz,.tgz,.sds,.zip,.txt,.log" hidden>
|
||||||
<button type="button" onclick="document.getElementById('file-input').click()">Выберите файл</button>
|
<span class="upload-kicker">Archive Import</span>
|
||||||
<p class="hint">Поддерживаемые форматы: ahs, tar.gz, tar, tgz, sds, zip, json, txt, log</p>
|
<strong>Drop a file here</strong>
|
||||||
|
<span class="upload-copy">LOGPile will parse it and open the report immediately.</span>
|
||||||
|
<div class="upload-actions">
|
||||||
|
<button type="button" onclick="document.getElementById('file-input').click()">Select File</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Supported formats: `.ahs`, `.tar.gz`, `.tar`, `.tgz`, `.sds`, `.zip`, `.json`, `.txt`, `.log`</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="upload-status"></div>
|
<div id="upload-status"></div>
|
||||||
<div id="parsers-info" class="parsers-info"></div>
|
<div id="parsers-info" class="parsers-info"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="api-source-content" class="api-placeholder hidden">
|
<div id="api-source-content" class="surface-panel upload-panel hidden">
|
||||||
|
<h2>BMC API</h2>
|
||||||
|
<p>Validate access and start live collection through the production Redfish pipeline.</p>
|
||||||
<form id="api-connect-form" novalidate>
|
<form id="api-connect-form" novalidate>
|
||||||
<h3>Подключение к BMC API</h3>
|
|
||||||
<div id="api-form-errors" class="form-errors hidden"></div>
|
<div id="api-form-errors" class="form-errors hidden"></div>
|
||||||
|
|
||||||
<div class="api-form-grid">
|
<div class="api-form-grid">
|
||||||
<label class="api-form-field" for="api-host">
|
<label class="api-form-field" for="api-host">
|
||||||
<span>Host</span>
|
<span>Host</span>
|
||||||
<input id="api-host" name="host" type="text" placeholder="10.0.0.10 или bmc.example.local">
|
<input id="api-host" name="host" type="text" placeholder="10.0.0.10 or bmc.example.local">
|
||||||
<span class="field-error" data-error-for="host"></span>
|
<span class="field-error" data-error-for="host"></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="api-form-field" for="api-port">
|
<label class="api-form-field" for="api-port">
|
||||||
<span>Порт</span>
|
<span>Port</span>
|
||||||
<input id="api-port" name="port" type="number" min="1" max="65535" value="443" placeholder="443">
|
<input id="api-port" name="port" type="number" min="1" max="65535" value="443" placeholder="443">
|
||||||
<span class="field-error" data-error-for="port"></span>
|
<span class="field-error" data-error-for="port"></span>
|
||||||
</label>
|
</label>
|
||||||
@@ -69,52 +75,52 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="api-form-field" id="api-password-field" for="api-password">
|
<label class="api-form-field" id="api-password-field" for="api-password">
|
||||||
<span>Пароль</span>
|
<span>Password</span>
|
||||||
<input id="api-password" name="password" type="password" autocomplete="current-password">
|
<input id="api-password" name="password" type="password" autocomplete="current-password">
|
||||||
<span class="field-error" data-error-for="password"></span>
|
<span class="field-error" data-error-for="password"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="api-form-actions">
|
<div class="api-form-actions">
|
||||||
<button id="api-connect-btn" type="button">Подключиться</button>
|
<button id="api-connect-btn" type="button">Connect</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="api-connect-status" class="api-connect-status"></div>
|
<div id="api-connect-status" class="api-connect-status"></div>
|
||||||
<div id="api-probe-options" class="api-probe-options hidden">
|
<div id="api-probe-options" class="api-probe-options hidden">
|
||||||
<div id="api-host-off-warning" class="api-host-off-warning hidden">
|
<div id="api-host-off-warning" class="api-host-off-warning hidden">
|
||||||
⚠ Host выключен — данные инвентаря могут быть неполными
|
⚠ Host is powered off. Inventory data may be incomplete.
|
||||||
</div>
|
</div>
|
||||||
<label class="api-form-checkbox" for="api-debug-payloads">
|
<label class="api-form-checkbox" for="api-debug-payloads">
|
||||||
<input id="api-debug-payloads" name="debug_payloads" type="checkbox">
|
<input id="api-debug-payloads" name="debug_payloads" type="checkbox">
|
||||||
<span>Сбор расширенных данных для диагностики</span>
|
<span>Collect extended diagnostics</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="api-form-actions">
|
<div class="api-form-actions">
|
||||||
<button id="api-collect-btn" type="submit">Собрать</button>
|
<button id="api-collect-btn" type="submit">Collect</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section id="api-job-status" class="job-status hidden" aria-live="polite">
|
<section id="api-job-status" class="job-status hidden" aria-live="polite">
|
||||||
<div class="job-status-header">
|
<div class="job-status-header">
|
||||||
<h4>Статус задачи сбора</h4>
|
<h4>Collection Job Status</h4>
|
||||||
<div class="job-status-actions">
|
<div class="job-status-actions">
|
||||||
<button id="skip-hung-btn" type="button" class="hidden" title="Прервать зависшие запросы и перейти к анализу собранных данных">Пропустить зависшие</button>
|
<button id="skip-hung-btn" type="button" class="hidden" title="Abort hung requests and continue with analysis of collected data">Skip Hung Requests</button>
|
||||||
<button id="cancel-job-btn" type="button">Отменить</button>
|
<button id="cancel-job-btn" type="button">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="job-status-meta">
|
<div class="job-status-meta">
|
||||||
<div><span class="meta-label">jobId:</span> <code id="job-id-value">-</code></div>
|
<div><span class="meta-label">jobId:</span> <code id="job-id-value">-</code></div>
|
||||||
<div>
|
<div>
|
||||||
<span class="meta-label">Статус:</span>
|
<span class="meta-label">Status:</span>
|
||||||
<span id="job-status-value" class="job-status-badge">Queued</span>
|
<span id="job-status-value" class="job-status-badge">Queued</span>
|
||||||
</div>
|
</div>
|
||||||
<div><span class="meta-label">Этап:</span> <span id="job-progress-value">Сбор данных...</span></div>
|
<div><span class="meta-label">Stage:</span> <span id="job-progress-value">Collecting data...</span></div>
|
||||||
<div><span class="meta-label">ETA:</span> <span id="job-eta-value">-</span></div>
|
<div><span class="meta-label">ETA:</span> <span id="job-eta-value">-</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="job-progress" aria-label="Прогресс задачи">
|
<div class="job-progress" aria-label="Job progress">
|
||||||
<div id="job-progress-bar" class="job-progress-bar" style="width: 0%">0%</div>
|
<div id="job-progress-bar" class="job-progress-bar" style="width: 0%">0%</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="job-active-modules" class="job-active-modules hidden">
|
<div id="job-active-modules" class="job-active-modules hidden">
|
||||||
<p class="meta-label">Активные модули:</p>
|
<p class="meta-label">Active modules:</p>
|
||||||
<div id="job-active-modules-list" class="job-module-chips"></div>
|
<div id="job-active-modules-list" class="job-module-chips"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="job-debug-info" class="job-debug-info hidden">
|
<div id="job-debug-info" class="job-debug-info hidden">
|
||||||
@@ -123,23 +129,23 @@
|
|||||||
<div id="job-phase-telemetry" class="job-phase-telemetry"></div>
|
<div id="job-phase-telemetry" class="job-phase-telemetry"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="job-status-logs">
|
<div class="job-status-logs">
|
||||||
<p class="meta-label">Журнал шагов:</p>
|
<p class="meta-label">Step log:</p>
|
||||||
<ul id="job-logs-list"></ul>
|
<ul id="job-logs-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="convert-source-content" class="api-placeholder hidden">
|
<div id="convert-source-content" class="surface-panel upload-panel hidden">
|
||||||
<h3>Пакетная выгрузка Reanimator</h3>
|
<h2>Batch Convert</h2>
|
||||||
<p>Выберите папку с файлами поддерживаемого типа. Для каждого файла будет создан отдельный экспорт Reanimator.</p>
|
<p>Select a folder with supported files. A separate Reanimator export will be produced for each file.</p>
|
||||||
<div class="api-form-actions">
|
<div class="api-form-actions">
|
||||||
<input type="file" id="convert-folder-input" webkitdirectory directory multiple hidden>
|
<input type="file" id="convert-folder-input" webkitdirectory directory multiple hidden>
|
||||||
<button id="convert-folder-btn" type="button" onclick="document.getElementById('convert-folder-input').click()">Выбрать папку</button>
|
<button id="convert-folder-btn" type="button" onclick="document.getElementById('convert-folder-input').click()">Choose Folder</button>
|
||||||
<button id="convert-run-btn" type="button">Конвертировать в Reanimator</button>
|
<button id="convert-run-btn" type="button">Convert to Reanimator</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="convert-progress" class="convert-progress hidden" aria-live="polite">
|
<div id="convert-progress" class="convert-progress hidden" aria-live="polite">
|
||||||
<div class="convert-progress-meta">
|
<div class="convert-progress-meta">
|
||||||
<span id="convert-progress-label">Подготовка...</span>
|
<span id="convert-progress-label">Preparing...</span>
|
||||||
<span id="convert-progress-value">0%</span>
|
<span id="convert-progress-value">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="convert-progress-track">
|
<div class="convert-progress-track">
|
||||||
@@ -152,12 +158,12 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="data-section" class="hidden">
|
<section id="data-section" class="hidden">
|
||||||
<section class="result-panel">
|
<section class="viewer-panel">
|
||||||
<div class="audit-viewer-shell">
|
<div class="audit-viewer-shell">
|
||||||
<iframe
|
<iframe
|
||||||
id="audit-viewer-frame"
|
id="audit-viewer-frame"
|
||||||
class="audit-viewer-frame"
|
class="audit-viewer-frame"
|
||||||
title="Reanimator chart viewer"
|
title="Hardware report"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
scrolling="no"
|
scrolling="no"
|
||||||
referrerpolicy="same-origin">
|
referrerpolicy="same-origin">
|
||||||
@@ -167,11 +173,9 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer class="page-footer">
|
||||||
<div class="footer-buttons">
|
|
||||||
</div>
|
|
||||||
<div class="footer-info">
|
<div class="footer-info">
|
||||||
<p>Автор: <a href="https://mchus.pro" target="_blank">mchus.pro</a> | <a href="https://git.mchus.pro/mchus/logpile" target="_blank">Git Repository</a>{{if .AppVersion}} | v{{.AppVersion}}{{end}}</p>
|
<p>{{if .AppVersion}}LOGPile {{.AppVersion}}{{end}}{{if and .AppVersion .ChartVersion}} · {{end}}{{if .ChartVersion}}Chart {{.ChartVersion}}{{end}}</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user