Redfish snapshot/export overhaul and portable release build
This commit is contained in:
12
Makefile
12
Makefile
@@ -6,7 +6,7 @@ COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "none")
|
|||||||
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)"
|
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build $(LDFLAGS) -o bin/$(BINARY_NAME) ./cmd/logpile
|
CGO_ENABLED=0 go build $(LDFLAGS) -o bin/$(BINARY_NAME) ./cmd/logpile
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./bin/$(BINARY_NAME)
|
./bin/$(BINARY_NAME)
|
||||||
@@ -19,11 +19,11 @@ test:
|
|||||||
|
|
||||||
# Cross-platform builds
|
# Cross-platform builds
|
||||||
build-all: clean
|
build-all: clean
|
||||||
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-amd64 ./cmd/logpile
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-amd64 ./cmd/logpile
|
||||||
GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-arm64 ./cmd/logpile
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-arm64 ./cmd/logpile
|
||||||
GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-amd64 ./cmd/logpile
|
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-amd64 ./cmd/logpile
|
||||||
GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-arm64 ./cmd/logpile
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-arm64 ./cmd/logpile
|
||||||
GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-windows-amd64.exe ./cmd/logpile
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-windows-amd64.exe ./cmd/logpile
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
go run ./cmd/logpile
|
go run ./cmd/logpile
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -21,6 +22,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
holdOnCrash := flag.Bool("hold-on-crash", runtime.GOOS == "windows", "Wait for Enter on crash to keep console open")
|
||||||
port := flag.Int("port", 8082, "HTTP server port")
|
port := flag.Int("port", 8082, "HTTP server port")
|
||||||
file := flag.String("file", "", "Pre-load archive file")
|
file := flag.String("file", "", "Pre-load archive file")
|
||||||
showVersion := flag.Bool("version", false, "Show version")
|
showVersion := flag.Bool("version", false, "Show version")
|
||||||
@@ -54,11 +56,22 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := srv.Run(); err != nil {
|
if err := runServer(srv); err != nil {
|
||||||
log.Fatalf("Server error: %v", err)
|
log.Printf("FATAL: %v", err)
|
||||||
|
maybeWaitForCrashInput(*holdOnCrash)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runServer(srv *server.Server) (runErr error) {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
runErr = fmt.Errorf("panic: %v", recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return srv.Run()
|
||||||
|
}
|
||||||
|
|
||||||
// openBrowser opens the default browser with the given URL
|
// openBrowser opens the default browser with the given URL
|
||||||
func openBrowser(url string) {
|
func openBrowser(url string) {
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
@@ -76,3 +89,23 @@ func openBrowser(url string) {
|
|||||||
log.Printf("Failed to open browser: %v", err)
|
log.Printf("Failed to open browser: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maybeWaitForCrashInput(enabled bool) {
|
||||||
|
if !enabled || !isInteractiveConsole() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "\nApplication crashed. Press Enter to close...")
|
||||||
|
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInteractiveConsole() bool {
|
||||||
|
stdinInfo, err := os.Stdin.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
stderrInfo, err := os.Stderr.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (stdinInfo.Mode()&os.ModeCharDevice) != 0 && (stderrInfo.Mode()&os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|||||||
24
docs/releases/v1.2.1.md
Normal file
24
docs/releases/v1.2.1.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# LOGPile v1.2.1
|
||||||
|
|
||||||
|
Release date: 2026-02-04
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
- Redfish collection significantly expanded: dynamic Systems/Chassis/Managers discovery, PSU/GPU/PCIe inventory mapping, improved NVMe and storage parsing (including SimpleStorage and chassis drive fallbacks).
|
||||||
|
- Added Redfish snapshot support with broad raw Redfish tree capture for future offline analysis.
|
||||||
|
- Upload flow now accepts JSON snapshots in addition to archives, enabling offline re-open of live Redfish collections.
|
||||||
|
- Export UX improved:
|
||||||
|
- Export filenames now follow `YYYY-MM-DD (SERVER MODEL) - SERVER SN`.
|
||||||
|
- TXT export now outputs tabular sections matching web UI views (no raw JSON dump).
|
||||||
|
- Live API UI improvements: parser/file badges for Redfish sessions and clearer upload format messaging.
|
||||||
|
- Redfish progress logs are more informative (snapshot stage and active top-level roots).
|
||||||
|
- Build/distribution hardening:
|
||||||
|
- Cross-platform builds via `make build-all`.
|
||||||
|
- `CGO_ENABLED=0` for more portable single-binary distribution.
|
||||||
|
- Crash hold option to keep console open for debugging (`-hold-on-crash`, enabled by default on Windows).
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
- `bin/logpile-linux-amd64`
|
||||||
|
- `bin/logpile-linux-arm64`
|
||||||
|
- `bin/logpile-darwin-amd64`
|
||||||
|
- `bin/logpile-darwin-arm64`
|
||||||
|
- `bin/logpile-windows-amd64.exe`
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -70,11 +70,59 @@ func TestRedfishConnectorCollect(t *testing.T) {
|
|||||||
"CapacityGB": 960,
|
"CapacityGB": 960,
|
||||||
"SerialNumber": "SN123",
|
"SerialNumber": "SN123",
|
||||||
})
|
})
|
||||||
|
register("/redfish/v1/Systems/1/PCIeDevices", map[string]interface{}{
|
||||||
|
"Members": []map[string]string{
|
||||||
|
{"@odata.id": "/redfish/v1/Systems/1/PCIeDevices/GPU1"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/PCIeDevices/GPU1", map[string]interface{}{
|
||||||
|
"Id": "GPU1",
|
||||||
|
"Name": "NVIDIA H100",
|
||||||
|
"Model": "NVIDIA H100 PCIe",
|
||||||
|
"Manufacturer": "NVIDIA",
|
||||||
|
"SerialNumber": "GPU-SN-001",
|
||||||
|
"PCIeFunctions": map[string]interface{}{
|
||||||
|
"@odata.id": "/redfish/v1/Systems/1/PCIeDevices/GPU1/PCIeFunctions",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/PCIeDevices/GPU1/PCIeFunctions", map[string]interface{}{
|
||||||
|
"Members": []map[string]string{
|
||||||
|
{"@odata.id": "/redfish/v1/Systems/1/PCIeFunctions/GPU1F0"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/PCIeFunctions/GPU1F0", map[string]interface{}{
|
||||||
|
"FunctionId": "0000:65:00.0",
|
||||||
|
"VendorId": "0x10DE",
|
||||||
|
"DeviceId": "0x2331",
|
||||||
|
"ClassCode": "0x030200",
|
||||||
|
"CurrentLinkWidth": 16,
|
||||||
|
"CurrentLinkSpeed": "16.0 GT/s",
|
||||||
|
"MaxLinkWidth": 16,
|
||||||
|
"MaxLinkSpeed": "16.0 GT/s",
|
||||||
|
})
|
||||||
register("/redfish/v1/Chassis/1/NetworkAdapters", map[string]interface{}{
|
register("/redfish/v1/Chassis/1/NetworkAdapters", map[string]interface{}{
|
||||||
"Members": []map[string]string{
|
"Members": []map[string]string{
|
||||||
{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/1"},
|
{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/1"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
register("/redfish/v1/Chassis/1/Power", map[string]interface{}{
|
||||||
|
"PowerSupplies": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"MemberId": "PSU1",
|
||||||
|
"Name": "PSU Slot 1",
|
||||||
|
"Model": "PWS-2K01A-1R",
|
||||||
|
"Manufacturer": "Delta",
|
||||||
|
"PowerCapacityWatts": 2000,
|
||||||
|
"PowerInputWatts": 1600,
|
||||||
|
"LastPowerOutputWatts": 1200,
|
||||||
|
"LineInputVoltage": 230,
|
||||||
|
"Status": map[string]interface{}{
|
||||||
|
"Health": "OK",
|
||||||
|
"State": "Enabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
register("/redfish/v1/Chassis/1/NetworkAdapters/1", map[string]interface{}{
|
register("/redfish/v1/Chassis/1/NetworkAdapters/1", map[string]interface{}{
|
||||||
"Name": "Mellanox",
|
"Name": "Mellanox",
|
||||||
"Model": "ConnectX-6",
|
"Model": "ConnectX-6",
|
||||||
@@ -122,7 +170,33 @@ func TestRedfishConnectorCollect(t *testing.T) {
|
|||||||
if len(result.Hardware.NetworkAdapters) != 1 {
|
if len(result.Hardware.NetworkAdapters) != 1 {
|
||||||
t.Fatalf("expected one nic, got %d", len(result.Hardware.NetworkAdapters))
|
t.Fatalf("expected one nic, got %d", len(result.Hardware.NetworkAdapters))
|
||||||
}
|
}
|
||||||
|
if len(result.Hardware.GPUs) != 1 {
|
||||||
|
t.Fatalf("expected one gpu, got %d", len(result.Hardware.GPUs))
|
||||||
|
}
|
||||||
|
if result.Hardware.GPUs[0].BDF != "0000:65:00.0" {
|
||||||
|
t.Fatalf("unexpected gpu BDF: %q", result.Hardware.GPUs[0].BDF)
|
||||||
|
}
|
||||||
|
if len(result.Hardware.PCIeDevices) != 1 {
|
||||||
|
t.Fatalf("expected one pcie device, got %d", len(result.Hardware.PCIeDevices))
|
||||||
|
}
|
||||||
|
if len(result.Hardware.PowerSupply) != 1 {
|
||||||
|
t.Fatalf("expected one psu, got %d", len(result.Hardware.PowerSupply))
|
||||||
|
}
|
||||||
|
if result.Hardware.PowerSupply[0].WattageW != 2000 {
|
||||||
|
t.Fatalf("unexpected psu wattage: %d", result.Hardware.PowerSupply[0].WattageW)
|
||||||
|
}
|
||||||
if len(result.Hardware.Firmware) == 0 {
|
if len(result.Hardware.Firmware) == 0 {
|
||||||
t.Fatalf("expected firmware entries")
|
t.Fatalf("expected firmware entries")
|
||||||
}
|
}
|
||||||
|
if result.RawPayloads == nil {
|
||||||
|
t.Fatalf("expected raw payloads")
|
||||||
|
}
|
||||||
|
treeAny, ok := result.RawPayloads["redfish_tree"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected redfish_tree in raw payloads")
|
||||||
|
}
|
||||||
|
tree, ok := treeAny.(map[string]interface{})
|
||||||
|
if !ok || len(tree) == 0 {
|
||||||
|
t.Fatalf("expected non-empty redfish_tree, got %#v", treeAny)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
)
|
)
|
||||||
@@ -125,13 +126,16 @@ func (e *Exporter) ExportTXT(w io.Writer) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(w, "File: %s\n", e.result.Filename)
|
fmt.Fprintf(w, "File:\t%s\n", e.result.Filename)
|
||||||
|
fmt.Fprintf(w, "Source:\t%s\n", e.result.SourceType)
|
||||||
|
fmt.Fprintf(w, "Protocol:\t%s\n", e.result.Protocol)
|
||||||
|
fmt.Fprintf(w, "Target:\t%s\n", e.result.TargetHost)
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
|
||||||
// Server model and serial number
|
// Server model and serial number
|
||||||
if e.result.Hardware != nil && e.result.Hardware.BoardInfo.ProductName != "" {
|
if e.result.Hardware != nil && e.result.Hardware.BoardInfo.ProductName != "" {
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintf(w, "Server Model:\t%s\n", e.result.Hardware.BoardInfo.ProductName)
|
||||||
fmt.Fprintf(w, "Server Model: %s\n", e.result.Hardware.BoardInfo.ProductName)
|
fmt.Fprintf(w, "Serial Number:\t%s\n", e.result.Hardware.BoardInfo.SerialNumber)
|
||||||
fmt.Fprintf(w, "Serial Number: %s\n", e.result.Hardware.BoardInfo.SerialNumber)
|
|
||||||
}
|
}
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
|
|
||||||
@@ -139,118 +143,172 @@ func (e *Exporter) ExportTXT(w io.Writer) error {
|
|||||||
if e.result.Hardware != nil {
|
if e.result.Hardware != nil {
|
||||||
hw := e.result.Hardware
|
hw := e.result.Hardware
|
||||||
|
|
||||||
// Firmware
|
// Firmware tab
|
||||||
if len(hw.Firmware) > 0 {
|
if len(hw.Firmware) > 0 {
|
||||||
fmt.Fprintln(w, "FIRMWARE VERSIONS")
|
fmt.Fprintln(w, "FIRMWARE VERSIONS")
|
||||||
fmt.Fprintln(w, "-----------------")
|
fmt.Fprintln(w, "-----------------")
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "Component\tVersion\tBuild Time")
|
||||||
for _, fw := range hw.Firmware {
|
for _, fw := range hw.Firmware {
|
||||||
fmt.Fprintf(w, " %s: %s\n", fw.DeviceName, fw.Version)
|
fmt.Fprintf(tw, "%s\t%s\t%s\n", fw.DeviceName, fw.Version, fw.BuildTime)
|
||||||
}
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CPUs
|
// CPU tab
|
||||||
if len(hw.CPUs) > 0 {
|
if len(hw.CPUs) > 0 {
|
||||||
fmt.Fprintln(w, "PROCESSORS")
|
fmt.Fprintln(w, "PROCESSORS")
|
||||||
fmt.Fprintln(w, "----------")
|
fmt.Fprintln(w, "----------")
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "Socket\tModel\tCores\tThreads\tFreq MHz\tTurbo MHz\tTDP W\tPPIN/SN")
|
||||||
for _, cpu := range hw.CPUs {
|
for _, cpu := range hw.CPUs {
|
||||||
fmt.Fprintf(w, " Socket %d: %s\n", cpu.Socket, cpu.Model)
|
id := cpu.SerialNumber
|
||||||
fmt.Fprintf(w, " Cores: %d, Threads: %d, Freq: %d MHz (Turbo: %d MHz)\n",
|
if id == "" {
|
||||||
cpu.Cores, cpu.Threads, cpu.FrequencyMHz, cpu.MaxFreqMHz)
|
id = cpu.PPIN
|
||||||
fmt.Fprintf(w, " TDP: %dW, L3 Cache: %d KB\n", cpu.TDP, cpu.L3CacheKB)
|
}
|
||||||
|
fmt.Fprintf(tw, "CPU%d\t%s\t%d\t%d\t%d\t%d\t%d\t%s\n",
|
||||||
|
cpu.Socket, cpu.Model, cpu.Cores, cpu.Threads, cpu.FrequencyMHz, cpu.MaxFreqMHz, cpu.TDP, id)
|
||||||
}
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memory
|
// Memory tab
|
||||||
if len(hw.Memory) > 0 {
|
if len(hw.Memory) > 0 {
|
||||||
fmt.Fprintln(w, "MEMORY")
|
fmt.Fprintln(w, "MEMORY")
|
||||||
fmt.Fprintln(w, "------")
|
fmt.Fprintln(w, "------")
|
||||||
totalMB := 0
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "Slot\tPresent\tSize MB\tType\tSpeed MHz\tVendor\tModel/PN\tSerial\tStatus")
|
||||||
for _, mem := range hw.Memory {
|
for _, mem := range hw.Memory {
|
||||||
totalMB += mem.SizeMB
|
location := mem.Location
|
||||||
|
if location == "" {
|
||||||
|
location = mem.Slot
|
||||||
|
}
|
||||||
|
fmt.Fprintf(tw, "%s\t%t\t%d\t%s\t%d\t%s\t%s\t%s\t%s\n",
|
||||||
|
location, mem.Present, mem.SizeMB, mem.Type, mem.CurrentSpeedMHz, mem.Manufacturer, mem.PartNumber, mem.SerialNumber, mem.Status)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, " Total: %d GB (%d DIMMs)\n", totalMB/1024, len(hw.Memory))
|
_ = tw.Flush()
|
||||||
fmt.Fprintf(w, " Type: %s @ %d MHz\n", hw.Memory[0].Type, hw.Memory[0].CurrentSpeedMHz)
|
|
||||||
fmt.Fprintf(w, " Manufacturer: %s\n", hw.Memory[0].Manufacturer)
|
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage
|
// Power tab
|
||||||
|
if len(hw.PowerSupply) > 0 {
|
||||||
|
fmt.Fprintln(w, "POWER SUPPLIES")
|
||||||
|
fmt.Fprintln(w, "--------------")
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "Slot\tPresent\tVendor\tModel\tWattage W\tInput W\tOutput W\tInput V\tTemp C\tStatus\tSerial")
|
||||||
|
for _, psu := range hw.PowerSupply {
|
||||||
|
fmt.Fprintf(tw, "%s\t%t\t%s\t%s\t%d\t%d\t%d\t%.0f\t%d\t%s\t%s\n",
|
||||||
|
psu.Slot, psu.Present, psu.Vendor, psu.Model, psu.WattageW, psu.InputPowerW, psu.OutputPowerW, psu.InputVoltage, psu.TemperatureC, psu.Status, psu.SerialNumber)
|
||||||
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage tab
|
||||||
if len(hw.Storage) > 0 {
|
if len(hw.Storage) > 0 {
|
||||||
fmt.Fprintln(w, "STORAGE")
|
fmt.Fprintln(w, "STORAGE")
|
||||||
fmt.Fprintln(w, "-------")
|
fmt.Fprintln(w, "-------")
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "Slot\tPresent\tType\tInterface\tModel\tSize GB\tVendor\tFirmware\tSerial")
|
||||||
for _, stor := range hw.Storage {
|
for _, stor := range hw.Storage {
|
||||||
fmt.Fprintf(w, " %s: %s (%d GB) - S/N: %s\n",
|
fmt.Fprintf(tw, "%s\t%t\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n",
|
||||||
stor.Slot, stor.Model, stor.SizeGB, stor.SerialNumber)
|
stor.Slot, stor.Present, stor.Type, stor.Interface, stor.Model, stor.SizeGB, stor.Manufacturer, stor.Firmware, stor.SerialNumber)
|
||||||
}
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PCIe
|
// GPU tab
|
||||||
|
if len(hw.GPUs) > 0 {
|
||||||
|
fmt.Fprintln(w, "GPUS")
|
||||||
|
fmt.Fprintln(w, "----")
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "Slot\tModel\tVendor\tBDF\tPCIe\tSerial\tStatus")
|
||||||
|
for _, gpu := range hw.GPUs {
|
||||||
|
link := fmt.Sprintf("x%d %s", gpu.CurrentLinkWidth, gpu.CurrentLinkSpeed)
|
||||||
|
if gpu.MaxLinkWidth > 0 || gpu.MaxLinkSpeed != "" {
|
||||||
|
link = fmt.Sprintf("%s / x%d %s", link, gpu.MaxLinkWidth, gpu.MaxLinkSpeed)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
gpu.Slot, gpu.Model, gpu.Manufacturer, gpu.BDF, link, gpu.SerialNumber, gpu.Status)
|
||||||
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network tab
|
||||||
|
if len(hw.NetworkAdapters) > 0 {
|
||||||
|
fmt.Fprintln(w, "NETWORK ADAPTERS")
|
||||||
|
fmt.Fprintln(w, "----------------")
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "Slot\tLocation\tModel\tVendor\tPorts\tType\tStatus\tSerial")
|
||||||
|
for _, nic := range hw.NetworkAdapters {
|
||||||
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n",
|
||||||
|
nic.Slot, nic.Location, nic.Model, nic.Vendor, nic.PortCount, nic.PortType, nic.Status, nic.SerialNumber)
|
||||||
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device inventory tab
|
||||||
if len(hw.PCIeDevices) > 0 {
|
if len(hw.PCIeDevices) > 0 {
|
||||||
fmt.Fprintln(w, "PCIE DEVICES")
|
fmt.Fprintln(w, "PCIE DEVICES")
|
||||||
fmt.Fprintln(w, "------------")
|
fmt.Fprintln(w, "------------")
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "Slot\tBDF\tClass\tVendor\tVID:DID\tLink\tSerial")
|
||||||
for _, pcie := range hw.PCIeDevices {
|
for _, pcie := range hw.PCIeDevices {
|
||||||
fmt.Fprintf(w, " %s: %s (x%d %s)\n",
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%04x:%04x\tx%d %s / x%d %s\t%s\n",
|
||||||
pcie.Slot, pcie.DeviceClass, pcie.LinkWidth, pcie.LinkSpeed)
|
pcie.Slot, pcie.BDF, pcie.DeviceClass, pcie.Manufacturer, pcie.VendorID, pcie.DeviceID,
|
||||||
if pcie.SerialNumber != "" {
|
pcie.LinkWidth, pcie.LinkSpeed, pcie.MaxLinkWidth, pcie.MaxLinkSpeed, pcie.SerialNumber)
|
||||||
fmt.Fprintf(w, " S/N: %s\n", pcie.SerialNumber)
|
|
||||||
}
|
|
||||||
if len(pcie.MACAddresses) > 0 {
|
|
||||||
fmt.Fprintf(w, " MACs: %v\n", pcie.MACAddresses)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sensors summary
|
// Sensors tab
|
||||||
if len(e.result.Sensors) > 0 {
|
if len(e.result.Sensors) > 0 {
|
||||||
fmt.Fprintln(w, "SENSOR READINGS")
|
fmt.Fprintln(w, "SENSOR READINGS")
|
||||||
fmt.Fprintln(w, "---------------")
|
fmt.Fprintln(w, "---------------")
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
// Group by type
|
fmt.Fprintln(tw, "Type\tName\tValue\tUnit\tRaw\tStatus")
|
||||||
byType := make(map[string][]models.SensorReading)
|
|
||||||
for _, s := range e.result.Sensors {
|
for _, s := range e.result.Sensors {
|
||||||
byType[s.Type] = append(byType[s.Type], s)
|
fmt.Fprintf(tw, "%s\t%s\t%.0f\t%s\t%s\t%s\n", s.Type, s.Name, s.Value, s.Unit, s.RawValue, s.Status)
|
||||||
}
|
|
||||||
|
|
||||||
for stype, sensors := range byType {
|
|
||||||
fmt.Fprintf(w, "\n %s:\n", stype)
|
|
||||||
for _, s := range sensors {
|
|
||||||
if s.Value != 0 {
|
|
||||||
fmt.Fprintf(w, " %s: %.0f %s [%s]\n", s.Name, s.Value, s.Unit, s.Status)
|
|
||||||
} else if s.RawValue != "" {
|
|
||||||
fmt.Fprintf(w, " %s: %s [%s]\n", s.Name, s.RawValue, s.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FRU summary
|
// Serials/FRU tab
|
||||||
if len(e.result.FRU) > 0 {
|
if len(e.result.FRU) > 0 {
|
||||||
fmt.Fprintln(w, "FRU COMPONENTS")
|
fmt.Fprintln(w, "FRU COMPONENTS")
|
||||||
fmt.Fprintln(w, "--------------")
|
fmt.Fprintln(w, "--------------")
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "Description\tManufacturer\tProduct\tSerial\tPart Number")
|
||||||
for _, fru := range e.result.FRU {
|
for _, fru := range e.result.FRU {
|
||||||
name := fru.ProductName
|
name := fru.ProductName
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = fru.Description
|
name = fru.Description
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, " %s\n", name)
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", fru.Description, fru.Manufacturer, name, fru.SerialNumber, fru.PartNumber)
|
||||||
if fru.SerialNumber != "" {
|
|
||||||
fmt.Fprintf(w, " Serial: %s\n", fru.SerialNumber)
|
|
||||||
}
|
|
||||||
if fru.Manufacturer != "" {
|
|
||||||
fmt.Fprintf(w, " Manufacturer: %s\n", fru.Manufacturer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Events summary
|
// Events tab
|
||||||
fmt.Fprintf(w, "EVENTS: %d total\n", len(e.result.Events))
|
fmt.Fprintf(w, "EVENTS: %d total\n", len(e.result.Events))
|
||||||
|
if len(e.result.Events) > 0 {
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "Time\tSeverity\tSource\tType\tName\tDescription")
|
||||||
|
for _, ev := range e.result.Events {
|
||||||
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
ev.Timestamp.Format("2006-01-02 15:04:05"), ev.Severity, ev.Source, ev.SensorType, ev.SensorName, ev.Description)
|
||||||
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
|
}
|
||||||
var critical, warning, info int
|
var critical, warning, info int
|
||||||
for _, ev := range e.result.Events {
|
for _, ev := range e.result.Events {
|
||||||
switch ev.Severity {
|
switch ev.Severity {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type AnalysisResult struct {
|
|||||||
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
|
||||||
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)
|
||||||
Events []Event `json:"events"`
|
Events []Event `json:"events"`
|
||||||
FRU []FRUInfo `json:"fru"`
|
FRU []FRUInfo `json:"fru"`
|
||||||
Sensors []SensorReading `json:"sensors"`
|
Sensors []SensorReading `json:"sensors"`
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -55,23 +58,48 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Parse archive
|
payload, err := io.ReadAll(file)
|
||||||
p := parser.NewBMCParser()
|
if err != nil {
|
||||||
if err := p.ParseFromReader(file, header.Filename); err != nil {
|
jsonError(w, "Failed to read file", http.StatusBadRequest)
|
||||||
jsonError(w, "Failed to parse archive: "+err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result := p.Result()
|
var (
|
||||||
applyArchiveSourceMetadata(result)
|
result *models.AnalysisResult
|
||||||
|
vendor string
|
||||||
|
)
|
||||||
|
|
||||||
|
if looksLikeJSONSnapshot(header.Filename, payload) {
|
||||||
|
snapshotResult, snapshotErr := parseUploadedSnapshot(payload)
|
||||||
|
if snapshotErr != nil {
|
||||||
|
jsonError(w, "Failed to parse snapshot: "+snapshotErr.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result = snapshotResult
|
||||||
|
vendor = strings.TrimSpace(snapshotResult.Protocol)
|
||||||
|
if vendor == "" {
|
||||||
|
vendor = "snapshot"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Parse archive
|
||||||
|
p := parser.NewBMCParser()
|
||||||
|
if err := p.ParseFromReader(bytes.NewReader(payload), header.Filename); err != nil {
|
||||||
|
jsonError(w, "Failed to parse archive: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result = p.Result()
|
||||||
|
applyArchiveSourceMetadata(result)
|
||||||
|
vendor = p.DetectedVendor()
|
||||||
|
}
|
||||||
|
|
||||||
s.SetResult(result)
|
s.SetResult(result)
|
||||||
s.SetDetectedVendor(p.DetectedVendor())
|
s.SetDetectedVendor(vendor)
|
||||||
|
|
||||||
jsonResponse(w, map[string]interface{}{
|
jsonResponse(w, map[string]interface{}{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"message": "File uploaded and parsed successfully",
|
"message": "File uploaded and parsed successfully",
|
||||||
"filename": header.Filename,
|
"filename": header.Filename,
|
||||||
"vendor": p.DetectedVendor(),
|
"vendor": vendor,
|
||||||
"stats": map[string]int{
|
"stats": map[string]int{
|
||||||
"events": len(result.Events),
|
"events": len(result.Events),
|
||||||
"sensors": len(result.Sensors),
|
"sensors": len(result.Sensors),
|
||||||
@@ -529,7 +557,7 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename=serials.csv")
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "csv")))
|
||||||
|
|
||||||
exp := exporter.New(result)
|
exp := exporter.New(result)
|
||||||
exp.ExportCSV(w)
|
exp.ExportCSV(w)
|
||||||
@@ -539,7 +567,7 @@ func (s *Server) handleExportJSON(w http.ResponseWriter, r *http.Request) {
|
|||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename=report.json")
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "json")))
|
||||||
|
|
||||||
exp := exporter.New(result)
|
exp := exporter.New(result)
|
||||||
exp.ExportJSON(w)
|
exp.ExportJSON(w)
|
||||||
@@ -549,7 +577,7 @@ func (s *Server) handleExportTXT(w http.ResponseWriter, r *http.Request) {
|
|||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.Header().Set("Content-Disposition", "attachment; filename=report.txt")
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "txt")))
|
||||||
|
|
||||||
exp := exporter.New(result)
|
exp := exporter.New(result)
|
||||||
exp.ExportTXT(w)
|
exp.ExportTXT(w)
|
||||||
@@ -682,7 +710,7 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
|
|||||||
s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "")
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "")
|
||||||
s.jobManager.AppendJobLog(jobID, "Сбор завершен")
|
s.jobManager.AppendJobLog(jobID, "Сбор завершен")
|
||||||
s.SetResult(result)
|
s.SetResult(result)
|
||||||
s.SetDetectedVendor("")
|
s.SetDetectedVendor(req.Protocol)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,6 +782,9 @@ func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectReques
|
|||||||
result.Protocol = req.Protocol
|
result.Protocol = req.Protocol
|
||||||
result.TargetHost = req.Host
|
result.TargetHost = req.Host
|
||||||
result.CollectedAt = time.Now().UTC()
|
result.CollectedAt = time.Now().UTC()
|
||||||
|
if strings.TrimSpace(result.Filename) == "" {
|
||||||
|
result.Filename = fmt.Sprintf("%s://%s", req.Protocol, req.Host)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func toCollectorRequest(req CollectRequest) collector.Request {
|
func toCollectorRequest(req CollectRequest) collector.Request {
|
||||||
@@ -769,6 +800,39 @@ func toCollectorRequest(req CollectRequest) collector.Request {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func looksLikeJSONSnapshot(filename string, payload []byte) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
if ext == ".json" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
trimmed := bytes.TrimSpace(payload)
|
||||||
|
return len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[')
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUploadedSnapshot(payload []byte) (*models.AnalysisResult, error) {
|
||||||
|
var result models.AnalysisResult
|
||||||
|
if err := json.Unmarshal(payload, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if result.Hardware == nil && len(result.Events) == 0 && len(result.Sensors) == 0 && len(result.FRU) == 0 {
|
||||||
|
return nil, fmt.Errorf("unsupported snapshot format")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(result.SourceType) == "" {
|
||||||
|
if result.Protocol != "" {
|
||||||
|
result.SourceType = models.SourceTypeAPI
|
||||||
|
} else {
|
||||||
|
result.SourceType = models.SourceTypeArchive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.CollectedAt.IsZero() {
|
||||||
|
result.CollectedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(result.Filename) == "" {
|
||||||
|
result.Filename = "uploaded_snapshot.json"
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) getCollector(protocol string) (collector.Connector, bool) {
|
func (s *Server) getCollector(protocol string) (collector.Connector, bool) {
|
||||||
if s.collectors == nil {
|
if s.collectors == nil {
|
||||||
s.collectors = collector.NewDefaultRegistry()
|
s.collectors = collector.NewDefaultRegistry()
|
||||||
@@ -808,3 +872,59 @@ func isGPUDevice(deviceClass string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exportFilename(result *models.AnalysisResult, ext string) string {
|
||||||
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
|
model := "SERVER MODEL"
|
||||||
|
sn := "SERVER SN"
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
if !result.CollectedAt.IsZero() {
|
||||||
|
date = result.CollectedAt.UTC().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
if result.Hardware != nil {
|
||||||
|
if m := strings.TrimSpace(result.Hardware.BoardInfo.ProductName); m != "" {
|
||||||
|
model = m
|
||||||
|
}
|
||||||
|
if serial := strings.TrimSpace(result.Hardware.BoardInfo.SerialNumber); serial != "" {
|
||||||
|
sn = serial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model = sanitizeFilenamePart(model)
|
||||||
|
sn = sanitizeFilenamePart(sn)
|
||||||
|
ext = strings.TrimPrefix(strings.TrimSpace(ext), ".")
|
||||||
|
if ext == "" {
|
||||||
|
ext = "txt"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (%s) - %s.%s", date, model, sn, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFilenamePart(v string) string {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"/", "_",
|
||||||
|
"\\", "_",
|
||||||
|
":", "_",
|
||||||
|
"*", "_",
|
||||||
|
"?", "_",
|
||||||
|
"\"", "_",
|
||||||
|
"<", "_",
|
||||||
|
">", "_",
|
||||||
|
"|", "_",
|
||||||
|
"\n", " ",
|
||||||
|
"\r", " ",
|
||||||
|
"\t", " ",
|
||||||
|
)
|
||||||
|
v = replacer.Replace(v)
|
||||||
|
v = strings.Join(strings.Fields(v), " ")
|
||||||
|
if v == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,6 +154,89 @@ func TestCollectStatusNotFoundSmoke(t *testing.T) {
|
|||||||
assertJSONError(t, resp, "Collect job not found")
|
assertJSONError(t, resp, "Collect job not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUploadRedfishSnapshotJSON(t *testing.T) {
|
||||||
|
_, ts := newFlowTestServer()
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
snapshot := `{
|
||||||
|
"filename": "redfish://bmc01.local",
|
||||||
|
"source_type": "api",
|
||||||
|
"protocol": "redfish",
|
||||||
|
"target_host": "bmc01.local",
|
||||||
|
"hardware": {
|
||||||
|
"storage": [
|
||||||
|
{
|
||||||
|
"slot": "Drive1",
|
||||||
|
"type": "NVMe",
|
||||||
|
"model": "KIOXIA CD8",
|
||||||
|
"size_gb": 3840,
|
||||||
|
"serial_number": "SN-NVME-1",
|
||||||
|
"present": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"raw_payloads": {
|
||||||
|
"redfish_tree": {
|
||||||
|
"/redfish/v1": {"Name": "ServiceRoot"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
reqBody := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(reqBody)
|
||||||
|
part, err := writer.CreateFormFile("archive", "snapshot.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create form file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write([]byte(snapshot)); err != nil {
|
||||||
|
t.Fatalf("write snapshot body: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadReq, err := http.NewRequest(http.MethodPost, ts.URL+"/api/upload", reqBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build upload request: %v", err)
|
||||||
|
}
|
||||||
|
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
uploadResp, err := http.DefaultClient.Do(uploadReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer uploadResp.Body.Close()
|
||||||
|
|
||||||
|
if uploadResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 from /api/upload, got %d", uploadResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploadPayload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(uploadResp.Body).Decode(&uploadPayload); err != nil {
|
||||||
|
t.Fatalf("decode upload response: %v", err)
|
||||||
|
}
|
||||||
|
if uploadPayload["vendor"] != "redfish" {
|
||||||
|
t.Fatalf("expected vendor redfish, got %v", uploadPayload["vendor"])
|
||||||
|
}
|
||||||
|
|
||||||
|
statusResp, err := http.Get(ts.URL + "/api/status")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("status request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer statusResp.Body.Close()
|
||||||
|
|
||||||
|
var statusPayload map[string]interface{}
|
||||||
|
if err := json.NewDecoder(statusResp.Body).Decode(&statusPayload); err != nil {
|
||||||
|
t.Fatalf("decode status response: %v", err)
|
||||||
|
}
|
||||||
|
if statusPayload["protocol"] != "redfish" {
|
||||||
|
t.Fatalf("expected protocol redfish, got %v", statusPayload["protocol"])
|
||||||
|
}
|
||||||
|
if statusPayload["filename"] != "redfish://bmc01.local" {
|
||||||
|
t.Fatalf("expected snapshot filename, got %v", statusPayload["filename"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func buildTarArchive(t *testing.T, name, content string) []byte {
|
func buildTarArchive(t *testing.T, name, content string) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -426,7 +426,11 @@ async function loadDataFromStatus() {
|
|||||||
if (!payload.loaded) {
|
if (!payload.loaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadData(payload.vendor || '', payload.filename || '');
|
const vendor = payload.vendor || payload.protocol || '';
|
||||||
|
const filename = payload.filename || (payload.protocol && payload.target_host
|
||||||
|
? `${payload.protocol}://${payload.target_host}`
|
||||||
|
: '');
|
||||||
|
await loadData(vendor, filename);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data after collect:', err);
|
console.error('Failed to load data after collect:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,10 @@
|
|||||||
|
|
||||||
<div id="archive-source-content">
|
<div id="archive-source-content">
|
||||||
<div class="upload-area" id="drop-zone">
|
<div class="upload-area" id="drop-zone">
|
||||||
<p>Перетащите архив сюда или</p>
|
<p>Перетащите архив или JSON snapshot сюда</p>
|
||||||
<input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip" hidden>
|
<input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip,application/json,.json,.tar,.tar.gz,.tgz,.zip" hidden>
|
||||||
<button type="button" onclick="document.getElementById('file-input').click()">Выберите файл</button>
|
<button type="button" onclick="document.getElementById('file-input').click()">Выберите файл</button>
|
||||||
<p class="hint">Поддерживаемые форматы: tar.gz, zip</p>
|
<p class="hint">Поддерживаемые форматы: tar.gz, zip, json</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user